- 1.调用惯例
- 2.相关寄存器
- 3.相关汇编指令
- 4.代码分析
- 5.函数返回值传递
调用惯例函数调用方和被调用函数对函数调用的统一理解,一般包含如下几个方面的内容:
- 函数参数的传递顺序和方式: 最常见的方式是通过栈传递,也可以使用寄存器传递参数,提高性能
- 栈的维护方式: 函数调用后,被压入栈中的参数需要被弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由调用函数来完成,也可以有被调用函数来完成
- 名字修饰策略: 不同的调用管理有着不同的修饰策略
| 调用惯例 | 出栈方 | 参数传递 | 名字修饰 |
|---|---|---|---|
| cdecl | 函数调用方 | 从右至左的顺序入栈 | 下划线+函数名 |
| stdcall | 函数本身 | 从右至左的顺序入栈 | 下划线+函数名+@+参数的字节数 |
| fastcall | 函数本身 | 头两个类型存放到寄存器,其他剩下参数从右到左的顺序入栈 | @+函数名+@+参数的字节数 |
x86_64中的常见寄存器和位数如下两幅图所示。
从图中可以看出64位是向前兼容32位,16位和8位寄存器的。
3.相关汇编指令分析函数调用过程中的栈帧变化主要涉及到mov,push,pop,call,ret,leave这几条汇编指令,这几条指令的作用如下表所示
| 汇编指令 | 作用 |
|---|---|
| mov | 移动指令 |
| push | 入栈指令 |
| pop | 出站指令 |
| call | 函数调用指令 |
| ret | 返回指令 |
| leave | 返回指令 |
有时候会在指令后面加上后缀,表明操作数的大小。常见的后缀有b,w,l,q,分别表示操作数大小位1字节,2字节,4字节,8字节。
call指令
call指令虽然是一条函数调用指令,但实际上它包含两步。第一步:将函数的返回地址入栈,即将函数调用后的第一条指令地址入栈;第二步:将被调用函数的地址装载入rip,从而跳转到被调函数。
ret指令
当执行ret指令时,rsp指向的栈顶被弹出值rip,完成被调函数返回
leave指令
leave指令是将栈顶指针指向帧指针,然后POP备份的原帧指针到%rbp,相当于如下两条指令:
mov %rbp %rsp pop %rbp4.代码分析
int sum(int num1, int num2, int num3, int num4, int num5, int num6, int num7, int num8) {
int ans;
ans = num1 + num2 + num3 + num4 + num5 + num6 + num7 + num8;
return ans;
}
int main() {
int ans = sum(1, 2, 3, 4, 5, 6, 7, 8);
return 0;
}
对其进行编译后,然后进行反汇编,得到对应的反汇编文件。
$ gcc func_call.c -o func_call $ objdump -d func_call > func_call.txt
涉及到的相关核心部分如下所示:
5.函数返回值传递从上述示例中可以发现,函数将返回值存储在eax寄存器中,然后函数的调用放再读取eax。eax本身只有4字节,那么大于4字节的返回值是如何传递的呢?
对于返回5-8字节的返回值,几乎所有的调用惯例都是采用eax和edx联合返回的方式进行的。eax存储返回值的低4个字节,edx存储放回置的高1-4字节。
对于字节数更大的返回值,是无法通过寄存器进行返回的。一种可能的实现方式是:先将返回值拷贝到栈上的临时区域内(临时对象),函数返回后再将该临时区域的值拷贝给函数调用方的接收变量。这也是为什么C++返回对象会调用两次拷贝构造函数的原因。



