PHP源码学习20190412-C语言函数调用的压栈

15次阅读

共计 2889 个字符,预计需要花费 8 分钟才能阅读完成。

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 一下。

正文完
 0