本节我们重点讨论栈指针esp和帧指针ebp,围绕这两个重要的寄存器,推导出函数栈帧结构。
一:压栈和出栈的操作本质
上一节我们了解到push和pop是汇编中压栈和出栈的指令。栈这个东东,当某个程序运行时,会划分一个块固定大小的区域(存储器映射),而栈就属于这个区域的一部分。要了解出入栈首先要了解栈的结构:
地址 栈中内容
最大 地址 |
数据(栈底) |
…… | …… |
0x108 | 数据3 |
0x104 | 数据2 |
0x100 %esp |
数据1(栈顶) |
%FC 新%esp |
数据0 (新栈顶) |
从上图看出,栈的增长方向是向下的。栈有个最大地址,这个地址成为栈底,也是存储栈里面存储第一个元素的位置,随着入栈个数增加,栈顶的地址不断减小。
esp寄存器就是专门用来存储栈顶地址的。在汇编中,%esp读出栈顶地址,(%esp)就能读出栈顶里的数值。如上图所示,如果再进行一次入栈push操作时,那么栈顶%esp就跳到地址0xFC(0x100-4)处,新压的数据也会存在这个地址上。如果上图不执行push,而是直接执行pop出栈时,esp将存储地址0x104。
push和pop这两个汇编操作指令,是可以用基本的汇编操作代替的,事实上,push和pop在汇编中对应的操作是:
push %ebp:subl$4, %esp
movl %ebp, (%esp)
pop %eax:movl (%esp), %eax
addl $4, %esp
在分析上面汇编代码之前再复习一下,%eax直接获取里面的值,(%eax)类似C指针‘*’间接寻址操作,是取出%eax里的值作为地址来看,再根据这个地址找到相应位置,并取出其中的值。
还以上图为例。先来看push压栈,压栈是增加栈的元素,由于有新的数据(ebp里的值为数据0,具体什么值先不关心)要入栈,而栈又是向下生长的,因此需要将存有栈顶地址信息的esp进行调整,具体操作是将esp减4,得到增长后的下一个栈顶地址,subl$4, %esp操作使得esp的值从0x100跳变到0xFC,实现了栈顶的生长;接着是赋值,我们需要把ebp里的值传送到新的栈顶指向的空间中去(地址0xFC代表的空间),完成入栈。语句movl %ebp, (%esp)比较好理解,就是把ebp里的值,通过“()”对栈指针进行间接引用,传送到地址0xFC的空间里面去,esp是栈指针(叫栈顶指针更好理解)。
为啥%esp要加括号?如果不加括号,栈指针所存的地址数据将被破坏,本来跳变好了新栈顶地址0xFC,会因为你的一个不加括号的语句而使栈指针%esp被覆盖成%ebp的值(数据0)。而加了括号,则会做间接寻址操作,通过%esp,找到地址为0xFC的空间(也就是新的栈顶空间),并把数据0成功传送进去。
一旦你理解了上面冗长的废话,再理解pop就很简单了,出栈无非就是把操作反过来。比如刚才push完了,我们再执行pop %eax,就是要把栈顶元素的值弹出来,传送到%eax中去,然后栈顶更新状态。那么movl (%esp), %eax语句就是将当前栈顶里的值(数据0),传送到eax中去;而addl $4, %esp就是更新栈指针,把地址值加回去(从0xFC变回0x100)。
这里有个细节问题,关于出栈,有没有发现,只有数据出和栈顶更新,并没有数据删除操作。也就是说,刚才连续执行了push %ebp和pop %eax后,栈指针指向的是0x100地址,栈顶的值是数据1。那么地址0xFC里存的什么呢?答案当然是数据0,因为没有任何语句删除它,所以才会出现有时候你调试C语言程序,指针越界访问后,会读出一些已经失效函数里面的临时变量值,就是这个原因。
用汇编语句理解出栈入栈,对于接下来的函数栈空间的理解是至关重要的。
二:函数调用的栈帧结构
在我看来,从某种意义上说,C语言就是个函数嵌套语言,从一个主函数开始,内部生出几个子函数,每个子函数下面还有更细的子函数,有时父子函数之间还会出现递归嵌套调用,在加上循环和条件判断,如此复杂的操作,编译器是怎么翻译成汇编来实现的?这依赖于简单实用的栈帧结构,这里我们引用网上的一个火图:
说句老实话,本来这个图并不是那么难理解的,无论函数嵌套有多复杂,总有个先后吧?这个帧那个帧不就是根据调用的先后排列顺序的,先调用的函数,其栈帧结构就整体先入栈,后调用的函数就后入栈,那么栈顶所代表的函数帧(当前帧),就是当前正在调用的函数,所需要的数据映射,解释完毕。
如果栈帧结构真这么简单,那每个人都只需要花阅读上面文字所需要的时间,就能搞明白了。栈帧结构最难搞懂的,就是那句“被保存的%ebp”,这句话难的背后,是对ebp在栈帧中作用的理解,可以这样的说,只有你理解的ebp,才能真正理解栈帧结构,你甚至可以当黑客,往栈帧里嵌入恶意代码,构造自己的栈帧,这种小游戏前段时间我自己也尝试过,把生成好的a.out可执行文件,用vi直接修改二进制,加入恶意代码。程序原本要执行打印“I am Superman:)”的函数,经过对可执行文件的直接修改,a.out乖乖的跳转到另一个函数,打印出“I am Hacker!^_^”,而Superman已不知去向,这个就是典型的利用缓冲区溢出进行代码攻击,虽然显得太小儿科,但原理类似。
言归正传。要理解%ebp,首先还是要复习一下上面讲的间接引用,搞清楚寄存器所存值的概念。寄存器里存的值本质上就是数值,关键是我们如何看待它的意义,就比如栈指针%esp,叫它栈指针是因为它一般来说存的都是某个空间的地址,这是编译器的习惯分配。如果你是做编译器的,完全可以用%esp当成%eax或者其他什么寄存器来临时存放一下其他数值,再把地址赋回值给它,如果不嫌麻烦的话。因此类似栈帧结构的这些知识,其实是编译器事先定义好的对寄存器的使用规则,记住,寄存器里的值我们要怎么理解,那是由编译器说了算的。
为了简单好理解,我们讨论最简单的函数嵌套,假如函数grand调用函数father,而father调用函数son,father的栈帧就是上图所说的“调用者的帧”,而son就是“当前帧”,grand自然就包含在“较早的帧”之中。father有1~n,n个变量要作为参数传给son。从上图能明显看出,n个参数是倒着排列的,这是由栈结构决定。在参数传递中,son(1,2,3,…,n)代码顺序,在栈帧结构上是地址由小增大排列的。参数下面是返回地址,这个返回地址,其实就是father函数自己的地址,同时也是father函数栈帧的末尾(注意和栈顶或者栈底概念完全无关)。
好,回过头来看,那么参数n以上的省略号是什么呢?其实,son函数栈帧的“参数构造区域”,和father的参数1~n是一回事,也许里面放着的是参数1~m,用于son来调用孙子函数时用,因此参数n上面的省略号,就是father函数被保存的寄存器、本地变量和临时变量,再往上就是father函数自己的“被保存的%ebp”。
再往上呢?就进入grand函数的栈帧结构(较早的栈帧),往上第一个一定也是“返回地址”,其实就是grand函数执行完father后应该继续执行的代码的地址。
说到这里可能你觉得还好,按照调用顺序,函数的栈帧结构维护得很清楚。可以想象,当某个函数要调用其他函数调用时,先通过一系列压栈操作,在栈里面备份函数自身的本地临时变量,还有传递给子函数的参数变量信息,最后压上函数自个儿的地址,完事,下面的空间就留给子函数玩了。
这里问题就来了,CPU如何区分不同的栈帧?如何搞清楚栈里面哪部分是子函数哪部分是父函数?栈指针%esp只知道自己现在在哪玩,对于具体玩的是哪个函数的内容,那是一头雾水啊。于是我们有必要解开%ebp面纱了。
三:神秘的%ebp
%ebp叫帧指针,相信熟悉C指针的朋友看到名字时,对%ebp的工作原理就基本明白个七八分了。没错,既然叫帧指针,那就是用来存放各帧首地址的指针。
设想,当father函数要调用son函数时,需要对栈帧信息进行修改和维护,如何在son函数执行完后让CPU顺利的找到father的栈帧地址并成功返回呢?这就要在调用son之前做好充分的准备工作。比如,father栈帧有自己的帧首,在father函数执行时,%ebp就保存了这个帧首的地址值,或者说%ebp正指向帧首。当调用子函数son时,%ebp就会保存son的帧首地址,为了让son在返回时能够顺利更新%ebp,使得帧指针顺利指回到father的帧来,有必要在%ebp指向son帧首的同时,更改帧首空间内所保存的值为father帧首地址,也就是son的所谓“保存的%ebp”,或者说旧的%ebp值,父函数调用时%ebp的值。
这里感觉很绕的同学,一定是指针基础还不够扎实。来区分下一个概念,一个存储单元空间,有两个属性:1、CPU访问这个存储单元需要依赖的地址值;2、这个存储单元所存储的数值,空间地址值和空间内存储的数值,区分这两个值是理解指针概念的基础。现在讨论函数的帧,每个帧都帧首,帧首作为存储单元空间,当然有标识自己的空间地址,同时空间里存了一个数值。栈帧结构恰恰巧妙的利用了这种概念,让%ebp始终保存当前调用函数的帧首地址,而当前帧首内又存储着父函数的帧首地址,以此类推,每一个当前调用函数的帧首内都保留着父函数的帧首地址,函数执行完成时都能顺利更新栈指针%ebp的值,一直可以推到main函数的帧首,通过栈指针%ebp的修改和被保存,就能确保栈帧结构的访问顺利进行,是不是很奇妙?
以上是纯理论推导,一旦你真正看明白,那具体的汇编代码实现就会很容易弄懂了。函数调用在汇编中还会涉及到call、leave、ret等指令,其实都可以用更基本的指令进行描述。
为了便于讲解,我写了一段简单得不能再简单的函数调用事例ebpesp.c:
int son_add(int a, int b)
{
return a+b;
}
int father()
{
int a = 8;
int b = 9;
int sum = 0;
sum = son_add(a, b);
return sum;
}
利用gcc 的-O2优化选项进行编译生成ebpesp.o的二进制文件(没有main函数所有不能编译成可执行文件,但汇编原理完全一样)
然后再反汇编代码,其中函数体部分如下:
00000000
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 8b 45 0c mov 0xc(%ebp),%eax
6: 03 45 08 add 0x8(%ebp),%eax
9: c9 leave
a: c3 ret
b: 90 nop
0000000c
c: 55 push %ebp
d: 89 e5 mov %esp,%ebp
f: 6a 02 push $0x9
11: 6a 01 push $0x8
13: e8 fc ff ff ff call 14
18: 5a pop %edx
19: 59 pop %ecx
1a: c9 leave
1b: c3 ret
father函数从第三行push开始看起。两条push语句明显就是对参数进行压栈,先压9后压8,与c语言中的自右向左的原理一致,两个参数的值被成功压入栈。注意此时还是father执行阶段,因此参数所压的位置仍属于father的栈帧空间。接下来就是子函数调用call语句,call可以近似看成做如下操作:
call:push 返回地址,%esp
jmp 子函数地址
因此father中的call就可以翻译成更直观的汇编语句就是:(注意,18和0都是逻辑地址,这里只是为举例而写的伪汇编代码,在后面章节将详细描述。)
push $18,%esp
jmp $0
可见,两个参数入栈完成后,接着就是father函数的返回地址,返回到18这个地址,以便继续father代码的执行。到此为止,father函数的栈帧维护结束,函数调用的准备工作完成,可以通过跳转指令jmp跳转到son_add函数了。我们发现,son_add第一句是push %ebp,理解这句很关键。想想,在这条语句之前,程序运行的是father函数,那么%ebp自然也保存的是father函数的帧首地址,直到执行到0,也没有谁修改过它,因此在还行push %ebp时,%ebp里仍然保存的是father帧首地址,现在对他进行压栈,于是push %ebp就使得该帧首地址就被顺利的放进了“返回地址”单元的下面(成为新栈顶,%esp就存储了其地址值),再由于这是运行son_add函数的第一条语句,因此该栈顶就作为son_add的帧首了,此时该帧首里面到是舒服的躺着father帧首的地址值,%ebp却并没有指向son_add函数的帧首,因此mov %esp,%ebp就是把当前这个帧首的地址值赋给%ebp,于是在son_add函数返回前,%ebp都作为当前帧指针不会变动了。
接下来的两句很有意思,mov 0xc(%ebp),%eax是对帧指针里的地址先增加0xC再取里面的值,增加12是啥意思?12刚好是4的倍数,也就是向上移动三个栈存储单元。根据栈结构图发现,%ebp作为帧首,向上移动一个单元是“返回地址”;向上移动两个单元是参数1,向上移动三个单元当然是参数2!也就是我们传给son_add的第二个参数9。因此这条汇编的意思是把9赋值给%eax寄存器。依次类推是不是还应该把参数1的这个8赋给另一个寄存器呢?编译器可没这么傻,你son_add不就是想做个加法么?直接add 0x8(%ebp),%eax,让%ebp寻找到参数1的地址位置,读取出8,然后直接和%eax的值相加,搞定!
好了,这个时候%eax寄存器就是存有加法结果的寄存器了,计算完成子函数需要返回了,于是先后执行leave和ret,先看leave的等价汇编代码:
leave:movl %ebp,%esp
pop %ebp
这步在理解上稍显困难,主要是对出入栈的操作理解。movl %ebp,%esp这条语句,其实目的就是破坏子函数son_add栈帧结构。想想看,直接修改栈指针%esp,让他指向son_add的帧首,然后执行pop %ebp,将帧首里的值赋给%ebp!回忆下帧首里存的是啥值啊?那当然是father帧首的地址值啊,这句目的就是让%ebp重新指回father栈帧的帧首!OK,son_add的帧首被弹出栈后,栈指针也不会再指向son_add帧首了,而是指向他的上面一个栈存储单元,那就是father帧的末尾:返回地址,leave的使命便完成。接下来就是ret,考虑到ret要完成函数调用的返回,还要维护栈帧的返回,我们可以猜测ret的等价汇编代码应该是:
ret:jmp (%esp)
add $4, %esp
因为此时%esp指向father帧的末尾,而该末尾里面又存储了father调用son_add函数后应该返回的地址(这里应该是18),因此就应该是将该地址取出,直接跳转到18,也就是call语句之后的语句,而子函数son_add的调用既然已经完成,根据个人的猜测,“返回地址”已失去意义,因此栈指针会加4返回。
至此,关于函数调用的栈帧原理就全部讲完了,如果你看懂了上面的论述,就能自然而然的推到出father其余部分的汇编含义,也能显而易见的弄明白大型C程序中每个函数都已push %ebp、mov %esp,%ebp开头以及leave和ret结尾,层层包裹,稳定和完美。