一个数组越界的问题
写数组越界之后会发生什么事情呢?
先看一段代码,
|
|
这段代码看起来似乎是个笑话,一个完全没有编程常识的人才会写出这么搞笑的代码。但是深思一下,这段代码执行之后会有什么结果?Segment Fault?死循环?这都取决于越界的a[10] = 0究竟写到了哪里。如果&a[10]对应变量i的地址,这样就会出现死循环。如果&a[10]对应的是存放rbp寄存器的位置,main函数return的时候就会出问题。
Mac OSX平台
首先我们在Mac OSX系统上看这个问题。这段代码在OSX运行之后会异常退出。我们打开GDB,可以看到main函数在return时会检查堆栈,就是在这里发生的异常。
|
|
反汇编main函数,可以得到下面这块汇编代码。
|
|
<+0><+1>初始化栈帧,<+4>扩展堆栈。<+8><+15><+18>很有意思,它从代码段读了8字节,然后存到了堆栈上面。<+22>到<+67>对应着C代码中的循环体。<+72><+79><+82>尝试校验之前存放在堆栈上的8字节信息。由于代码段是只读的,因此堆栈上的这8字节正常情况下不会变化,如果被改变了,必定栈被写坏了。运行时的异常__stack_chk_fail似乎跟这块有关系。
我们用gdb检查a[10]的地址就能证实了。
|
|
果不其然,&a[10]对应的正是堆栈保护区域-0x8(%rbp)。OSX的编译器Clang提供了堆栈保护功能。正因为如此,OSX上这次数组越界会被发现,程序也会异常退出。
汇编代码中<+4>处扩展堆栈直接申请了64字节的空间(0x40)。这个是x86_64的ABI规定的,rsp寄存器必须保证16字节对齐。(x86_64 ABI P35)
Linux平台
我们用GCC外加编译选项”-g -O0”来编译这段代码,然后运行。居然一切正常!
这是怎么回事呢?先用GDB反汇编一把,
|
|
GCC没有像Clang那样搞堆栈保护。<+0><+1>初始化栈帧,<+4>相当于i=1,<+11>到<+34>对应循环,<+36>之后就是return 0了。顺便提一下<+16>的cltq指令,<+13>中相当于eax=i,但<+18>需要用rax计算偏移,因此需要将eax扩展到rax,相当于int64(i)。
我们看一下&a[10]的地址。
|
|
GCC把数组放到堆栈顶端,变量i则在栈帧的底部区域,紧邻rbp在堆栈上的存放值。为了保证堆栈8字节对齐,一共扩展了48字节堆栈(<+18>中0x30),数组a用了40字节,变量i占用4字节,多出来的4字节空档正好在&a[10]上。因此数组越界对程序的运行没有产生任何影响!
发散思考
如果我们编译的时候加优化会发生什么?如果数组大小为11,又会发生什么?下面我们在Linux上回答这些问题。
编译加优化选项
使用”-g -O1”编译,然后到gdb里面反汇编,
|
|
看到没有,优化之后只剩下一个简单的return 0。那段孤立的循环代码直接被编译器抛弃掉了。
数组大小设为11
将数组定义以及for循环中的10改为11,编译选项“-g -O0”,我们看看会有什么结果。
|
|
这时改&a[11]直接修改了变量,自然会死循环。
那么如果加优化选项编译呢,当然要做点事情保证GCC不会抛弃那段代码。改好的代码如下,
|
|
使用”-g -O1”编译,然后反汇编,
|
|
变量i在优化之后直接消失了。<+0>对应a[0]=1。<+8>中,-0x2c(%rsp)对应&a[1],相当于rax=&a[1]。<+13>相当于rdx=&a[12]。<+16>到<+29>对应for循环。由于扩展堆栈的时候需要保证16字节对齐,&a[11]落在空档中,所以优化之后的程序跑起来没有任何问题!
数组大小设为12 + 优化
将11都替换为12,使用”-g -O1”编译,然后反汇编,
|
|
这时&a[12]就跑出当前函数的栈帧了,从<+13>中的0x4(%rsp)就能看出问题。这个地方存放的是函数的返回地址。
在return a[0]处下断点,继续跑,
|
|
此时返回地址的低32位被清零,最终被Segmentation fault终结。
最后,优化后的栈帧
上面优化之后的汇编代码还有另外一个变化,使用rbp建立栈帧的操作通通消失了,于是一个可以还原调用栈的链表也就不存在了。如果没有rbp,怎么能得到函数的调用栈呢?
其实很简单,程序员不用工具几乎不可能在这种情况下拿到完整的调用栈,但是诸如GDB的调试工具却能够做到。GDB可以分析文件获取编译信息,因为编译结束后,每个扩展栈的操作已经定了,也就是说每个函数的工作栈大小是没有变数的。GDB根据这些信息自然可以还原调用栈。这篇文章有详细的解释。