我们先来研究 C 语言的调用惯例,使用 GCC1 和 Clang2 编译器将 C 语言编译成汇编代码是分析它调用惯例的最好方法,从汇编语言中可以一窥函数调用的具体过程。
GCC 和 Clang 编译相同 C 语言代码可能会生成不同的汇编指令,不过生成的代码在结构上不会有太大的区别,所以对只想理解调用惯例的人来说没有太多影响。作者在本节中选择使用 GCC 编译器来编译 C 语言:
1 | $ gcc --version |
假设我们有以下的 C 语言代码,代码中只包含两个函数,其中一个是主函数 main
,另一个是我们定义的函数 my_function
:
1 | // ch04/my_function.c |
我们可以使用 cc -S my_function.c
命令将上述文件编译成如下所示的汇编代码:
1 | main: |
我们按照 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 | main: |
main
函数调用 my_function
时,前六个参数是使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递的。寄存器的使用顺序也是调用惯例的一部分,函数的第一个参数一定会使用 edi 寄存器,第二个参数使用 esi 寄存器,以此类推。
最后的两个参数与前面的完全不同,调用方 main
函数通过传递这两个参数,图 4-1 展示了 main
函数在调用 my_function
前的栈信息:
上图中 rbp 寄存器的作用是存储函数调用栈的基址指针,即属于 main
函数的栈空间的起始位置,而另一个寄存器 rsp 存储的是 main
函数调用栈结束的位置,这两个寄存器共同表示了一个函数的栈空间。
在调用 my_function
之前,main
函数通过 subq $16, %rsp
指令分配了 16 个字节的栈地址,随后将第六个以上的参数按照从右到左的顺序存入栈中,即第八个和第七个,余下的六个参数会通过寄存器传递,接下来运行的 call my_function
指令会调用 my_function
函数:
1 | my_function: |
my_function
会先将寄存器中的全部数据转移到栈上,然后利用 eax 寄存器计算所有入参的和并返回结果。
我们可以将本节的发现和分析简单总结成 —— 当我们在 x86_64 的机器上使用 C 语言中调用函数时,参数都是通过寄存器和栈传递的,其中:
- 六个以及六个以下的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递;
- 六个以上的参数会使用栈传递,函数的参数会以从右到左的顺序依次存入栈中;
而函数的返回值是通过 eax 寄存器进行传递的,由于只使用一个寄存器存储返回值,所以 C 语言的函数不能同时返回多个值。
参考资料
- CSAPP