调用一个函数之后发生了什么?

假设:

  • AMD64 Linux

  • C/C++

首先,我们不需要讲太多的概念。只需要回顾几个基本的寄存器:

  • %rsp:保存栈顶指针

  • %rbp:保存栈底指针

  • %rbp~%rsp 这一段向下伸展的区域,就是栈帧。

  • %rip:保存下条指令的地址

  • %rdi:保存函数的第一个参数

  • %rsi:保存函数的第二个参数

  • %rax:保存返回值

然后,直接看代码吧!

样例程序

假设有程序如下:

 1int sum(int x, int y)
 2{
 3    return a + b;
 4}
 5int main(int argc, char const *argv[])
 6{
 7    int a = 1, b = 2;
 8    int c = sum(a, b);
 9    return 0;
10}

使用 gcc -g prog.c -o prog 进行编译。

其汇编代码如下:

int sum(int x, int y)
{
    1125:	55                   	push   %rbp
    1126:	48 89 e5             	mov    %rsp,%rbp
    1129:	89 7d fc             	mov    %edi,-0x4(%rbp)
    112c:	89 75 f8             	mov    %esi,-0x8(%rbp)
    return a + b;
    112f:	8b 55 fc             	mov    -0x4(%rbp),%edx
    1132:	8b 45 f8             	mov    -0x8(%rbp),%eax
    1135:	01 d0                	add    %edx,%eax
}
    1137:	5d                   	pop    %rbp
    1138:	c3                   	retq

0000000000001139 <main>:
int main(int argc, char const *argv[])
{
    1139:	55                   	push   %rbp
    113a:	48 89 e5             	mov    %rsp,%rbp
    113d:	48 83 ec 20          	sub    $0x20,%rsp
    1141:	89 7d ec             	mov    %edi,-0x14(%rbp)
    1144:	48 89 75 e0          	mov    %rsi,-0x20(%rbp)
    int a = 1;
    1148:	c7 45 fc 01 00 00 00 	movl   $0x1,-0x4(%rbp)
    int b = 2;
    114f:	c7 45 f8 02 00 00 00 	movl   $0x2,-0x8(%rbp)
    int c = sum(a, b);
    1156:	8b 55 f8             	mov    -0x8(%rbp),%edx
    1159:	8b 45 fc             	mov    -0x4(%rbp),%eax
    115c:	89 d6                	mov    %edx,%esi
    115e:	89 c7                	mov    %eax,%edi
    1160:	e8 c0 ff ff ff       	callq  1125 <sum>
    1165:	89 45 f4             	mov    %eax,-0xc(%rbp)
    return 0;
    1168:	b8 00 00 00 00       	mov    $0x0,%eax
}

执行流程

我们直接从 main 读起。请务必仔细关注调用栈的变化

0000000000001139 <main>:
int main(int argc, char const *argv[])
{
    1139:	55                  push   %rbp             #
    113a:	48 89 e5            mov    %rsp,%rbp        #
    113d:	48 83 ec 20         sub    $0x20,%rsp       #   这代码是将局部变量 argc 和 argv 保存到内存
    1141:	89 7d ec            mov    %edi,-0x14(%rbp) #   我们只需注意,rbp 往下开辟了 0x20,即 32 个
                                                        # 字节来存放一些局部变量
    1144:	48 89 75 e0         mov    %rsi,-0x20(%rbp) #

    int a = 1;                                          #
    1148:	c7 45 fc 01 00 00 00 movl  $0x1,-0x4(%rbp)  #
    int b = 2;                                          #
    114f:	c7 45 f8 02 00 00 00 movl  $0x2,-0x8(%rbp)  #

上面这些代码,实际上是将 main 函数的上下文保存到内存。

为什么要保存呢?因为现在参数 argc,argv 都保存在寄存器里,局部变量还是一个立即数。如果不保存下来,等我们调用完 sum 再回来,寄存器等现场数据的值就变了。

                                                        #
    int c = sum(a, b);                                  #   我们从这里开始看
    1156:	8b 55 f8            mov    -0x8(%rbp),%edx  #   \
    1159:	8b 45 fc            mov    -0x4(%rbp),%eax  #    \ 这部分代码将参数 a, b 存放在 rdi, rsi
    115c:	89 d6               mov    %edx,%esi        #    / 为调用 sum 函数做准备
    115e:	89 c7               mov    %eax,%edi        #   /
                                                        #
    1160:	e8 c0 ff ff ff      callq  1125 <sum>       #   callq 相当于:
                                                        #     pushq %rip
                                                        #     jmpq <sum>
                                                        #   而 pushq %rip 相当于
                                                        #                 sub $0x8, %rsp
                                                        #                 movq %rip, (%rsp)
                                                        #
                                                        #   callq 后的栈帧是:
                                                        #    +-------+
                                                        #    |main_val| <--- rbp
                                                        #    |  ...  |
                                                        #    |  ...  |
                                                        #    |  ...  |
                                                        #    |main_val|
                                                        #    |  1165 | <--- rsp
                                                        #    |       |
                                                        #   其中 1165 是下条指令的地址
                                                        #

上面这些代码,就是为调用 sum 函数做准备。首先是准备参数,然后是保存下条指令(%rip)的数据。这样我们调用完之后,就可以从内存中读出 %rip 的值,从而继续执行程序。

由于 callq 指令的作用,我们跳转到了 1125 的地方:

0000000000001125 <sum>:

int sum(int x, int y)
{
                                                        #   在正式执行函数前,栈的内容是:
                                                        #   +-------+
                                                        #   |main_val| <--- rbp (main_rbp)
                                                        #   |  ...  |
                                                        #   |  ...  |
                                                        #   |  ...  |
                                                        #   |main_val|
                                                        #   |  1165 | <--- rsp
                                                        #   |       |
                                                        #
    1125:	55                  push   %rbp             #   这一步时,rbp 还和在 main 中时一样,因为没有
                                                        # 修改过。当 push 其入栈之后,记它值为 main_rsp.
                                                        #   %rbp 入栈,栈内容变成:
                                                        #   +--------+
                                                        #   |main_val|
                                                        #   |  ...   |
                                                        #   |  ...   |
                                                        #   |  ...   |
                                                        #   |main_val|
                                                        #   |  1165  |
                                                        #   |main_rbp| <--- rsp
                                                        #
    1126:	48 89 e5            mov    %rsp,%rbp        #  在这里,rbp 的值变成 rsp,rbp 已经属于新函数
                                                        # 栈内容变成:
                                                        #   +--------+
                                                        #   |main_val|
                                                        #   |  ...   |
                                                        #   |  ...   |
                                                        #   |  ...   |
                                                        #   |main_val|
                                                        #   |  1165  |
                                                        #   |main_rbp| <--- rsp, rbp

这两个指令都是常规操作:

  1. 保存上一个函数的栈底到内存。

  2. 创建自己的栈底。

                                                        #   这里,rbp 往下分配了 8 个字节,
                                                        # 来保存局部变量(x, y)

    1129:	89 7d fc            mov    %edi,-0x4(%rbp)  # 栈内容变成:
    112c:	89 75 f8            mov    %esi,-0x8(%rbp)  #   +--------+
                                                        #   |main_val|
                                                        #   |  ...   |
                                                        #   |  ...   |
                                                        #   |  ...   |
                                                        #   |main_val|
                                                        #   |  1165  |
                                                        #   |main_rbp| <--- rsp, rbp
                                                        #   |   x    |
                                                        #   |   y    |

这里同样是常规操作,保存两个参数的值到栈上。

    return a + b;                                       #
    112f:	8b 55 fc            mov    -0x4(%rbp),%edx  # \
    1132:	8b 45 f8            mov    -0x8(%rbp),%eax  #  > 在这里,用 edx, eax 分别暂存参数 a, b。
    1135:	01 d0               add    %edx,%eax        # /  调用机器指令 add 将其相加,结果保存到 eax
}

上面这几句,准备了 add 机器指令的参数,然后调用 add 指令完成运算。

                                                        #    eax 是 Linux 规定的返回值寄存器
                                                        #
    1137:	5d                  pop    %rbp             #   在这里,将栈中的值弹出,放到 rbp 寄存器。由于
                                                        # 栈指针 rsp 指向的值就是 main 的 rbp,所以 rbp
                                                        # 重新还原为原来的值,rbp 也就重新指向回原来的位置
                                                        #   +--------+
                                                        #   |main_val| <--- rbp (main_rbp)
                                                        #   |  ...   |
                                                        #   |  ...   |
                                                        #   |  ...   |
                                                        #   |main_val|
                                                        #   |  1165  | <--- rsp
                                                        #   |        |
                                                        #   |        |      这里 x, y 的值已经废弃了。
                                                        #   |        |
                                                        #
    1138:	c3                  retq                    #   retq 指令相当于 popq %rip
                                                        # 所以,1165 将被弹出,赋予 rip
                                                        #   而 rip 指向的是 main 中 callq <sum> 的下一
                                                        # 条指令,因此 main 函数恢复了其执行流程
                                                        #

下面我们又返回到了 main 函数继续执行:

    1165:	89 45 f4            mov    %eax,-0xc(%rbp)  #   我们从 <sum> 函数返回到此处继续执行
    return 0;
    1168:	b8 00 00 00 00      mov    $0x0,%eax
}
    116d:	c9                   	leaveq
    116e:	c3                   	retq
    116f:	90                   	nop

总结

程序运行之后,所有的函数调用会反映在一个栈上,这个栈被称为 程序栈(Program stack),简称栈。栈保存在内存上,从高地址向低地址增长(也即栈顶是“朝下”的)。

栈帧(Stack frame)是栈的组成单位,构成如下:

typora\\20201211220708_0395f22fa2c42f5709ea0e74b081b7cf.png

可以认为,栈帧上保存着的全部是都是被调用的函数的信息。只不过 A 调 B,B 调 C,使得对于 C 而言,B 是 Caller,对于 B 而言,A 是 Caller。栈帧上信息包括:

  • BP 指针(%ebp)。这个指针是被调函数一开始的时候保存的。

  • 保存的寄存器和局部变量。这个也是被调函数负责保存的。

  • 输入的参数。这个也是被调函数负责保存的。

  • 返回地址。这个是主调函数保存的,通过 retq 指令读取后设置给 rip

函数调用的流程如下:

1. 主调:保存上下文

将自己的实际参数、局部变量、调用者保存寄存器等保存到栈上。

2. 主调:执行 callq 指令,跳转执行

将返回地址 %rip 保存到栈上,然后跳转到被调函数执行

3. 被调:替换栈底

将主调的栈底 %rbp 保存到栈上,然后将主调的栈顶 %rsp 值作为自己的栈底值。

4. 被调:保存上下文

将自己的实际参数、局部变量、调用者保存寄存器等保存到栈上。

5. 被调:执行自己的指令

执行机器指令等

6. 被调:还原栈底

将栈顶保存的主调的栈底还原

7. 被调:还原返回地址

通过 retq %rip 从栈上取出

8. 主调:继续执行

CPU 从 %rip 处继续执行主调的指令。