baiyan

全部视频:https://segmentfault.com/a/11...

引入

  • 我们知道,在函数调用的过程中,需要先进行压栈,待函数运行结束后,再出栈,回到原始函数的调用位置处继续向下执行代码。那么我们举一个例子,来清晰地看一下C语言函数调用压栈的过程:
int bar(int c, int d){
    int e = c + d;
    return e;
}

int foo(int a, int b){
    return bar(a, b);
}

int main(void){
    foo(2, 5);
    return 0;
}
  • 在具体分析之前,我们首先需要了解一下寄存器的基本概念:

寄存器

  • 寄存器就是CPU上的一块存储区域,存取速度比普通存储器高好几个数量级。为了提升程序运行的效率,程序运行期间产生的数据往往会存到寄存器中。
  • 寄存器的分类有多种:数据寄存器、变址寄存器、指针寄存器、段寄存器、指令指针寄存器、标志寄存器等,用于存储不同类型的数据,下面我们逐个介绍:

数据寄存器

  • 数据寄存器主要用来保存操作数和运算结果等信息,从而节省读取操作数所需占用总线和访问存储器的时间。RAX、RBX、RCX、RDX和EAX、EBX、ECX、EDX以及AX、BX、CX、DX分别称为64位、32位、16位数据寄存器(通用寄存器)。

变址寄存器

  • 变址寄存器主要用于存放存储单元在段内的偏移量,用它们可实现多种存储器操作数的寻址方式,为以不同的地址形式访问存储单元提供方便。 寄存器RSI、RDI和ESI、EDI和SI、DI分别称为64位、32位、16位变址寄存器(Index Register)。

指针寄存器

  • 指针寄存器主要用于存放堆栈内存的地址,用它们可实现多种存储器操作数的寻址方式,为以不同的地址形式访问存储单元提供方便。 寄存器RBP、RSP和EBP、ESP和BP、SP称分别为64位、32位、16位指针寄存器(PointerRegister),它可分为两类:

(1)BP为基指针(BasePointer)寄存器,指向栈底,用它可直接存取堆栈中的数据;
(2)SP为堆栈指针(StackPointer)寄存器,用它只可访问栈顶

段寄存器

  • 段寄存器是根据内存分段的管理模式而设置的。内存单元的物理地址由段寄存器的值和一个偏移量值组合而成的,这样可用两个较少位数的值组合成一个可访问较大物理空间的内存地址,CS、DS、ES、SS、FS、GS。

指令指针寄存器

  • 指令指针寄存器是存放下一次将要执行的指令在代码段的偏移量。在具有预取指令功能的系统中,下次要执行的指令通常已被预取到指令队列中,除非发生转移情况。所以,在理解它们的功能时,不考虑存在指令队列的情况。 RIP、EIP、IP(Instruction Pointer)分别为64位、32位、16位指令指针寄存器。

    • 我们重点关注这两个个寄存器:RBP(指向栈底)/RSP(指向栈顶)。

使用gdb查看C函数调用的压栈过程

  • 下面我们使用gdb的反汇编(disassemble)命令,来查看函数执行的栈桢情况:

  • 我们观察红框中的部分,当前正在执行main函数,还没有进行函数foo的调用,在第10行代码下的两条指令还没有执行之前,当前main函数的执行栈桢的情况如下:

  • 寄存器RBP(%rbp)的值指向栈底,寄存器RSP(%rsp)的值指向栈顶,当前没有任何其它函数的入栈。
  • 执行push %rbp指令:将RBP寄存器的值入栈,是为了保存调用者(caller)的地址,这样在后面执行完调用函数之后,才能正确返回。执行push指令后,栈顶指针需要随之移动,执行后的栈桢结构如下:

  • 执行mov %rsp,%rbp指令:将寄存器RSP的值赋到寄存器RBP中,执行后的栈桢结构如下:

  • 接下来gdb图中的第11行代码,会首先使用变址寄存器ESI和EDI保存函数调用的参数值2和5。因为这里的参数要传递给foo函数供foo函数使用,所以才需要在这里进行暂存。然后,使用callq指令真正地进行函数调用。我们使用gdb的s命令进入foo函数的执行栈桢

  • 观察第6行代码的前两个汇编指令,和之前的入栈操作一摸一样,我们直接画图:

  • 接下来观察第3条汇编指令:sub $0x8, %rsp ,它表示用RSP寄存器的值减去0x8,然后把结果赋值给RSP寄存器。由于栈的生长方向是从高地址到低地址,所以需要做减法,从而空出一段内存空间,做完sub操作的栈桢如下:

  • 接下来观察第4、5条汇编指令,他们将EDI和ESI变址寄存器中的值2和5,拷贝到以rbp指针为起始位置,并偏移-0x4与-0x8地址的位置。那么,为什么要从寄存器拷贝到函数foo的执行栈桢上呢?因为只有这样,在函数内部才能更加方便地使用这两个变量:

  • 接下来我们看上图gdb的第7行代码,即return bar(a, b)的代码,它也是一个函数调用。同样,传入的参数也是2和5,这两个参数也需要暂存起来,待后续传递给bar函数的栈桢,供bar函数内部使用。在上述gdb图片中,首先是两个mov指令,将foo函数栈桢上的值2和5拷贝到EDX和EAX寄存器中,然后再拷贝到之前我们熟悉的ESI和EDI寄存器中得以暂存,以便后续再拷贝到bar函数的栈桢上得以使用。到这里foo函数就执行完毕了,接下来会执行callq指令执行下一个函数bar的调用,进入bar函数执行栈桢的部分:

  • 我们看第1行代码的前两行,我们非常熟悉,就是一个入栈的操作。然后后面两行将之前存储在EDI寄存器中存储的数值2和ESI寄存器中存储的数值5一起拷贝到bar函数的栈桢上,即rbp偏移量-0x14与-0x18的地址处(注意这里0x14为十进制的20,0x18为24),我们可以画出当前的栈桢结构:

  • 继续执行第2行代码,int e = c + d。前两行将栈桢上的数值2和5拷贝到数据寄存器上,准备进行运算。第三行真正进行加法运算,其结果会存储到EAX寄存器中,第四行将EAX寄存器中的数据存储到栈桢偏移rbp-0x4的地址处。在return之后,由于当前bar函数的调用栈已经被销毁,所以还会再将这个运算结果7拷贝回EAX寄存器,等待外层调用接收该返回值以及进行后续使用。此时注意,bar函数的局部变量已失去保存的必要,所以这里仅仅保存一个加法运算后的结果,是因为外层foo函数有可能还需要使用这个结果。然后,我们注意到它的末尾有一个pop指令。由于当前函数bar是最后一个被调用的函数,所以要将bar函数出栈。当前栈桢结构如下:

  • bar函数出栈完毕之后,我们就返回到了foo函数的调用栈中:

  • 注意左侧箭头的指向,当前执行到leaveq指令,这个leaveq指令也是一个出栈指令,继续将foo函数出栈,以此类推,直到最外层main函数也出栈,程序运行结束。这里出栈的栈桢就不画图赘述了,相信大家看到这里都能够理解。
  • 注意我们在自己gdb的时候,需要重点关注rbp和rsp这两个指针寄存器的值所指向的内存地址,以及函数的参数及返回值是如何在函数之间的调用过程中,顺利传递的。
  • 在视频中还提到了PHP递归压栈的过程,限于篇幅,在此不再一一列出,有兴趣的同学可以参照视频中的步骤gdb一下。

NoSay
449 声望544 粉丝