本节我们重点讨论栈指针esp和帧指针ebp,围绕这两个重要的寄存器,推导出函数栈帧结构。
一:压栈和出栈的操作本质(文中压栈等价于入栈)
上一节我们了解到push和pop是汇编中压栈和出栈的指令。栈这个东东,当某个程序运行时,会划分一块固定大小的区域(存储器映射),而栈就属于这个区域的一部分。要了解出入栈首先要了解栈的结构:
地址 栈中内容
最大地址 | 数据(栈底) |
…… | …… |
0x108 | 数据3 |
0x104 | 数据2 |
0x100 %esp |
数据1(栈顶) |
%FC 新%esp |
数据0 (新栈顶) |
从上图看出,栈的增长方向是向下的。栈有个最大地址,这个地址成为栈底,也是栈里面存储第一个元素的位置,随着入栈个数增加,栈顶的地址不断减小。感觉栈就像剩余停车位,随着进入的新车辆越多,剩余停车位就越少,%esp减到0表示停满。
esp寄存器就专门用来存储栈顶地址。关于栈是向下生长的理解:想象你被倒挂着,头就是你的栈顶,脚为栈底,地址就是海拔高度,此时你的脚肯定比头高。每压一次栈你身体就变长4厘米,头的高度就变矮,离地面越近,当长到你头撞地面时就压不动栈了。当然出栈就是你变短,头部的高度增加。%esp专门存储头部高度。
上述是本人原创的形象理解,不过有同事说我这种重口味比喻感觉深受虐待呼吸困难脑袋充血——本来已经很烧脑了何必呢?我只能说sorry也许我就是一个重口味的人:)试想当你苦苦研究地址空间原理时,有个比你还惨一个叫栈的倒霉蛋被这么倒挂着,自己还不能决定自己的身高,别提多解气了O(∩_∩)O~
在汇编中,%esp读出栈顶地址,(%esp)就能读出栈顶里的数值。如上图所示,如果再进行一次入栈push操作时,那么栈顶%esp就跳到地址0xFC(0x100-0x4)处,新压的数据也会存在这个地址上。如果上图不执行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里的值作为地址来看,再根据这个地址找到相应位置,并取出其中的值。至于为什么举例压栈%ebp,出栈%eax,这个完全处于需求。
还以上图为例。先来看push压栈,压栈是增加栈的元素,由于有新的数据(ebp里的值为数据0,具体什么值先不关心)要入栈,而栈又是向下生长的,因此需要将存有栈顶地址信息的esp进行调整,具体操作是将esp减4(头的高度降低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语言就是个函数嵌套语言,从一个主函数开始,内部生出几个子函数,每个子函数下面还有更细的子函数,有时父子函数之间还会出现递归嵌套调用,在加上循环和条件判断,如此复杂的操作,编译器是怎么翻译成汇编来实现的?这依赖于简单实用的栈帧结构,这里我们引用网上的一个火图(其实就是教材里的图):
说句老实话,本来这个图并不是那么难理解的,无论函数嵌套有多复杂,总有个先后吧?这个帧那个帧不就是根据调用的先后排列顺序的,先调用的函数,其栈帧结构就整体先入栈,后调用的函数就后入栈,那么栈顶所代表的函数帧(当前帧),就是当前正在调用的函数,所需要的数据映射,解释完毕。
哇塞,原来被倒挂的人的身体,还根据函数的区域,被分成了很多块帧……具体讲每个进程都有一个倒挂人,整个os运行起来……好壮观╮(╯▽╰)╭
如果栈帧结构真这么简单,那每个人都只需要花阅读上面文字所需要的时间,就能搞明白了。栈帧结构最难搞懂的,就是那句“被保存的%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来调用grandson孙子函数时用,因此参数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帧的帧首地址空间内,也就是son的所谓“保存的%ebp”,或者说旧的%ebp值,父函数调用时%ebp的值。调用son函数时,就形成了%ebp指向son的帧首地址,而该地址又刚好保存了father帧首的地址的格局。
这里感觉很绕的同学,一定是指针基础还不够扎实。来区分下一个概念,一个存储单元空间,有两个属性:1、CPU访问这个存储单元需要依赖的地址值;2、这个存储单元所存储的数值,包括空间地址值和空间内存储的数值,区分这两个值是理解指针概念的基础。现在讨论函数的帧,每个帧结构都有帧首,帧首作为存储单元空间,当然有标识自己的空间地址,同时空间里存了一个数值。栈帧结构恰恰巧妙的利用了这种概念,让%ebp始终保存当前调用函数的帧首地址,而当前帧首地址又指向(存储着)父函数的帧首地址,以此类推,每一个当前调用函数的帧首起始地址空间内都保留着父函数的帧首地址,而这个帧首起始地址值本身又被%ebp存储,当函数执行完成时帧指针%ebp又指回父函数的帧首地址,而该地址空间内又刚好存储着爷函数的帧首地址值……一直可以推到main函数的帧首,通过帧指针%ebp的修改和被保存,就能确保栈帧结构的访问顺利进行,是不是很奇妙?
%ebp存当前子函数帧首地址,而该帧首地址里又存了父函数帧首地址,你拿着这个地址回去找父亲时,他一定会通过%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函数的返回地址入栈,记录0x18这个地址,以便son返回后继续father代码的执行。到此为止,father函数的栈帧维护结束,函数调用的准备工作完成,可以通过跳转指令jmp跳转到son_add函数了。
我们发现,son_add第一句是push %ebp,理解这句很关键。想想,在这条语句之前,程序运行的是father函数,那么%ebp自然也保存的是father函数的帧首地址,直到执行到son的代码起始位置0,也没有谁修改过它,因此在push %ebp时,%ebp里仍然保存的是father帧首地址,现在对它进行压栈,于是push %ebp就使得该帧首地址就被顺利的放进了“返回地址”单元的下面(成为新栈顶,%esp就存储了其地址值),再由于这是运行son_add函数的第一条语句,因此该栈顶就作为son_add的帧首了,此时该帧首里面到是舒服的躺着father帧首的地址值,%ebp却并没有指向son_add函数的帧首,因此mov %esp,%ebp就是把当前这个帧首的地址值赋给%ebp,等于从%esp交到%ebp手上,因为%esp要处理随后的push操作。于是在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的值相加,搞定!
我们看到,虽程序已经执行到son函数,然而在帧空间访问时,汇编语句"入侵"了father函数的帧空间,原来传参是酱紫滴。
好了,这个时候%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结尾,层层包裹,稳定和完美。
我已经使出近乎手把手式的讲解和冗长的反复叙述,这绝对是迄今为止最傻瓜式的栈帧原理阐述了。