程序语言的底层描述(2)——栈指针esp和帧指针ebp

程序语言的底层描述(2)——栈指针esp和帧指针ebp

2014年10月11日 17:43:57 coreyspomu 阅读数:3062更多

个人分类: 信息标识和处理

本节我们重点讨论栈指针esp和帧指针ebp,围绕这两个重要的寄存器,推导出函数栈帧结构。

一:压栈和出栈的操作本质

        上一节我们了解到push和pop是汇编中压栈和出栈的指令。栈这个东东,当某个程序运行时,会划分一个块固定大小的区域(存储器映射),而栈就属于这个区域的一部分。要了解出入栈首先要了解栈的结构:

      地址     栈中内容

最大
地址
 数据(栈底)  
…… ……
0x108 数据3
0x104 数据2
0x100
%esp
数据1(栈顶)
%FC
新%esp
数据0
(新栈顶)
扫描二维码关注公众号,回复: 3883148 查看本文章

        从上图看出,栈的增长方向是向下的。栈有个最大地址,这个地址成为栈底,也是存储栈里面存储第一个元素的位置,随着入栈个数增加,栈顶的地址不断减小。

        esp寄存器就是专门用来存储栈顶地址的。在汇编中,%esp读出栈顶地址,(%esp)就能读出栈顶里的数值。如上图所示,如果再进行一次入栈push操作时,那么栈顶%esp就跳到地址0xFC(0x100-4)处,新压的数据也会存在这个地址上。如果上图不执行push,而是直接执行pop出栈时,(也就是pop出栈后,esp的地址由原来的0x100挑战到0X104处)esp将存储地址0x104。

        push和pop这两个汇编操作指令,是可以用基本的汇编操作代替的,事实上,push和pop在汇编中对应的操作是:

push %ebp:subl$4, %esp  (push %ebp这一句意思是告诉编译器有已经一存放在寄存器中的数据要入栈,你要给我找地                                                        //方存放,我先开辟一个空间,你要给我安排地址(此处的意思是调整栈指针esp)和放置数据到                                                    //我开辟的空间中去,并不是执 行完这一指令后就立即把数据放到位了,于是接下来就会调整                                                    //栈顶指针和把要存放的数据放 进开辟的空间中去)

                                              //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(此句的意思是告诉编译器我有这种想法,你看咋办?并不是执行完这句后栈顶地址:%esp:0xFC中的数据就会立即到寄存器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帧首的同时(也就是说%ebp保存的地址是son的帧首地址,也就是说%ebp指向了son的帧首地址,这个帧首地址自然是一个地址空间,也就是一个存储单元空间,这个空间放什么内容呢?编译器规定要存放当前函数即:被调用函数son将来要返回上一个函数father的帧首地址,这个地址在上图叫“保存的%ebp”,或者说是旧的%ebp,或者说是本函数上一级函数:father的%ebp,概况一句话就是father调用某个函数son时,当前son函数的%ebp保存着当前函数son的帧首地址,这个son帧首地址作为一个地址空间又保存着一个值,这个值就是要返回的函数father的帧首地址。),更改帧首空间内所保存的值为father帧首地址,也就是son的所谓“保存的%ebp”,或者说旧的%ebp值,父函数调用时%ebp的值。

        这里感觉很绕的同学,一定是指针基础还不够扎实。来区分下一个概念,一个存储单元空间,有两个属性:1、CPU访问这个存储单元需要依赖的地址值;2、这个存储单元所存储的数值,空间地址值空间内存储的数值,区分这两个值是理解指针概念的基础。现在讨论函数的帧,每个帧都帧首(也叫帧指针ebp),帧首作为存储单元空间,当然有标识自己的空间地址,同时空间里存了一个数值。栈帧结构恰恰巧妙的利用了这种概念,让%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 <son_add>:
   0:   55                      push   %ebp  //(把%ebp中的值压栈,此时的%ebp帧地址是father的帧地址,因为此时还没有形成新                                                                  //的栈,帧地址是对应某个形成的栈而言的,所以这个帧地址只能是已经形成的栈:                                                                   //father栈的帧地址,)

                                                             
   1:   89 e5                 mov    %esp,%ebp  //因为上面指令的执行,栈压入了新的数据,栈指针%esp自动下移4个字节后变成了

                                                                    //新的栈顶,此时的栈顶就是当前帧的帧首地址了,所以使用mov指令把栈顶%esp                                                                     //中的值赋值给存放帧首地址的寄存器%ebp,此时寄存器%ebp中存放的是son函数的

                                                                     //帧首指针。
   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 <father>:
   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 <father+0x8>
  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(意思是把当前%ebp中的值(上一个函数的帧地址)压入栈),理解这句很关键。想想,在这条语句之前,程序运行的是father函数,那么%ebp自然也保存的是father函数的帧首地址,直到执行到0,也没有谁修改过它,因此在还行push %ebp时,%ebp里仍然保存的是father帧首地址,现在对他进行压栈,于是push   %ebp就使得该帧首地址就被顺利的放进了“返回地址”单元的下面(成为新栈顶,%esp就存储了其地址值),再由于这是运行son_add函数的第一条语句,因此该栈顶就作为son_add的帧首了,此时该帧首里面到是舒服的躺着father帧首的地址值,%ebp却并没有指向son_add函数的帧首(也就说此时的%ebp中还是father的帧首地址,但是栈地址%esp因为有了新的值自动更新了地址值),因此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(将son栈的帧首指针赋值给son的栈指针,也就是修改了son的栈指针,将son的栈指针%esp指向                                             //了将原来存放的father的帧首指针的son的帧首地址处,因为son的帧首地址处存放的是father的帧                                                 //首指针,等待下一步指令使用)

    pop   %ebp    //因此此时栈指针指向了son的帧首地址处,所以此指令指令后,做了两件事:一是:会将son帧首指针处存放的father的帧首指针弹出来放到寄存器%ebp中,也就是说%ebp指向了father栈帧的帧首指针处;二是:同时此时的栈指针由指向原来的son的帧首处变成了指向上一个存储单元的地址,这个地址处存放的是“返回地址”

        这步在理解上稍显困难,主要是对出入栈的操作理解。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)(跳到“返回地址”处,也就是跳到call指令的下一条指令处,地址为18处,继续往下执行)

        add  $4, %esp(因为上面的%esp中的值已经使用了,所以“返回地址”处的空间就没有用来,所以就将栈指针加4指向上一个单元了)

        因为此时%esp指向father帧的末尾,而该末尾里面又存储了father调用son_add函数后应该返回的地址(这里应该是18),因此就应该是将该地址取出,直接跳转到18,也就是call语句之后的语句,而子函数son_add的调用既然已经完成,根据个人的猜测,“返回地址”已失去意义,因此栈指针会加4返回。

        至此,关于函数调用的栈帧原理就全部讲完了,如果你看懂了上面的论述,就能自然而然的推到出father其余部分的汇编含义,也能显而易见的弄明白大型C程序中每个函数都已push   %ebp、mov    %esp,%ebp开头以及leave和ret结尾,层层包裹,稳定和完美。

        我已经使出近乎手把手式的讲解和冗长的反复叙述,这绝对是迄今为止最傻瓜式的栈帧原理阐述了。

猜你喜欢

转载自blog.csdn.net/weixin_41632560/article/details/82986019