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

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



最大

地址

数据(栈底)

……

……

0x108

数据3

0x104

数据2

0x100

%esp

数据1(栈顶)

%FC

新%esp

数据0

(新栈顶)



push %ebp:subl$4, %esp
      movl 
 
pop   %eax:movl   (%esp),  %eax
     addl


        还以上图为例。先来看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成功传送进去。




        这里有个细节问题,关于出栈,有没有发现,只有数据出和栈顶更新,并没有数据删除操作。也就是说,刚才连续执行了push %ebp和pop %eax后,栈指针指向的是0x100地址,栈顶的值是数据1。那么地址0xFC里存的什么呢?答案当然是数据0,因为没有任何语句删除它,所以才会出现有时候你调试C语言程序,指针越界访问后,会读出一些已经失效函数里面的临时变量值,就是这个原因。


二:函数调用的栈帧结构



esp01at指令手册 esp指针_结构


再往上呢?就进入grand函数的栈帧结构(较早的栈帧),往上第一个一定也是“返回地址”,其实就是grand函数执行完father后应该继续执行的代码的地址。



三:神秘的%ebp


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
    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 <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


        可见,两个参数入栈完成后,接着就是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

        这步在理解上稍显困难,主要是对出入栈的操作理解。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

        add  $4, %esp