0%

函数调用堆栈

我们先来研究 C 语言的调用惯例,使用 GCC1Clang2 编译器将 C 语言编译成汇编代码是分析它调用惯例的最好方法,从汇编语言中可以一窥函数调用的具体过程。

GCC 和 Clang 编译相同 C 语言代码可能会生成不同的汇编指令,不过生成的代码在结构上不会有太大的区别,所以对只想理解调用惯例的人来说没有太多影响。作者在本节中选择使用 GCC 编译器来编译 C 语言:

1
2
3
4
5
$ gcc --version
gcc (Ubuntu 4.8.2-19ubuntu1) 4.8.2
Copyright (C) 2013 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

假设我们有以下的 C 语言代码,代码中只包含两个函数,其中一个是主函数 main,另一个是我们定义的函数 my_function

1
2
3
4
5
6
7
8
// ch04/my_function.c
int my_function(int arg1, int arg2) {
return arg1 + arg2;
}

int main() {
int i = my_function(1, 2);
}

我们可以使用 cc -S my_function.c 命令将上述文件编译成如下所示的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $2, %esi // 设置第二个参数
movl $1, %edi // 设置第一个参数
call my_function
movl %eax, -4(%rbp)
my_function:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp) // 取出第一个参数,放到栈上
movl %esi, -8(%rbp) // 取出第二个参数,放到栈上
movl -8(%rbp), %eax // eax = esi = 1
movl -4(%rbp), %edx // edx = edi = 2
addl %edx, %eax // eax = eax + edx = 1 + 2 = 3
popq %rbp

我们按照 my_function 函数调用前、调用时以及调用后三个部分分析上述调用过程:

  • my_function 调用之前,调用方 main 函数将 my_function 的两个参数分别存到 edi 和 esi 寄存器中;
  • my_function 执行时,它会将寄存器 edi 和 esi 中的数据存储到 eax 和 edx 两个寄存器中,随后通过汇编指令 addl 计算两个入参之和;
  • my_function 调用之后,使用寄存器 eax 传递返回值,main 函数将 my_function 的返回值存储到栈上的 i 变量中;

my_function 函数的入参增加至八个,这时重新编译当前的程序可以会得到不同的汇编语言:

1
2
3
4
5
6
7
8
9
10
11
12
13
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp // 为参数传递申请 16 字节的栈空间
movl $8, 8(%rsp) // 传递第 8 个参数
movl $7, (%rsp) // 传递第 7 个参数
movl $6, %r9d
movl $5, %r8d
movl $4, %ecx
movl $3, %edx
movl $2, %esi
movl $1, %edi
call my_function

main 函数调用 my_function 时,前六个参数是使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递的。寄存器的使用顺序也是调用惯例的一部分,函数的第一个参数一定会使用 edi 寄存器,第二个参数使用 esi 寄存器,以此类推。

最后的两个参数与前面的完全不同,调用方 main 函数通过传递这两个参数,图 4-1 展示了 main 函数在调用 my_function 前的栈信息:

C 语言 main 函数的调用栈

上图中 rbp 寄存器的作用是存储函数调用栈的基址指针,即属于 main 函数的栈空间的起始位置,而另一个寄存器 rsp 存储的是 main 函数调用栈结束的位置,这两个寄存器共同表示了一个函数的栈空间。

在调用 my_function 之前,main 函数通过 subq $16, %rsp 指令分配了 16 个字节的栈地址,随后将第六个以上的参数按照从右到左的顺序存入栈中,即第八个和第七个,余下的六个参数会通过寄存器传递,接下来运行的 call my_function 指令会调用 my_function 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
my_function:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp) // rbp-4 = edi = 1
movl %esi, -8(%rbp) // rbp-8 = esi = 2
...
movl -8(%rbp), %eax // eax = 2
movl -4(%rbp), %edx // edx = 1
addl %eax, %edx // edx = eax + edx = 3
...
movl 16(%rbp), %eax // eax = 7
addl %eax, %edx // edx = eax + edx = 28
movl 24(%rbp), %eax // eax = 8
addl %edx, %eax // edx = eax + edx = 36
popq %rbp

my_function 会先将寄存器中的全部数据转移到栈上,然后利用 eax 寄存器计算所有入参的和并返回结果。

我们可以将本节的发现和分析简单总结成 —— 当我们在 x86_64 的机器上使用 C 语言中调用函数时,参数都是通过寄存器和栈传递的,其中:

  • 六个以及六个以下的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递;
  • 六个以上的参数会使用栈传递,函数的参数会以从右到左的顺序依次存入栈中;

而函数的返回值是通过 eax 寄存器进行传递的,由于只使用一个寄存器存储返回值,所以 C 语言的函数不能同时返回多个值。

参考资料

  • CSAPP