问题引入
当我在学习汇编语言,c语言代码转化成汇编代码时,了解到汇编代码需要将变量储存到寄存器,而x86-64寄存器只有稀缺的16个,这16个寄存器需要分给无限层级的函数调用,不可避免遇到深层的函数使用相同的寄存器覆盖上层函数的寄存器值,导致函数回调时原先的值已经被破坏的情况。那么是怎么处理这个问题的呢?
具体代码如下图,
是关于计算两个数的相乘结果同时用传指针的方法来获取值的一个功能。
c语言代码
long mult2(long x, long y) {
return x * y;
}
// 把乘法结果存到指定地址
void multstore(long x, long y, long *dest) {
long t = mult2(x, y); // 调用mult2计算
*dest = t; // 结果存入dest指向的内存
}
int main() {
long result = 0; // 初始化为0
multstore(3, 4, &result); // 调用:3*4=12,存入result
// result现在是12
return 0;
}
对应汇编代码
# mult2函数:计算x*y
mult2:
movq %rdi, %rax # x从%rdi移到%rax
imulq %rsi, %rax # %rax = x * y
ret # 返回,结果在%rax
# multstore函数:调用mult2,结果存到*dest
multstore:
pushq %rbx # 保存%rbx的旧值到栈(callee-saved)
movq %rdx, %rbx # 把dest指针存到%rbx(安全转移)
call mult2 # 调用mult2(x, y)
movq %rax, (%rbx) # 把mult2的返回值存到*dest
popq %rbx # 恢复%rbx的旧值
ret # 返回main
# main函数
main:
subq $16, %rsp # 给result分配栈空间
movq $0, 8(%rsp) # result = 0
leaq 8(%rsp), %rdx # &result → %rdx(第3个参数)
movl $4, %esi # 4 → %esi(第2个参数y)
movl $3, %edi # 3 → %edi(第1个参数x)
call multstore # 调用multstore
movq 8(%rsp), %rax # result的值(可选)
addq $16, %rsp # 恢复栈
xorl %eax, %eax # return 0
ret
ok,首先这里multstore中 pushq %rbx 和 popq %rbx 就体现了对应的寄存器值保存处理
发现问题
解决问题
首先我们要知道,解决这个问题就在于在需要调用函数时保存原值。但是保存方法有两种,第一是调用者在调用函数前保存,第二种是被调用的函数在它的内部执行代码之前先保存原值。相当于将寄存器分为 caller-saved(调用者保存) / callee-saved(被调用者保存) 两种,来避免这个问题
下面我们就来探讨不同的变量用哪种方式保存
caller-saved / callee-saved
caller-saved寄存器,调用者保存,被调用者不负责保存,适合临时短期变量
callee-saved寄存器,被调用者保存,调用者不负责保存,适合长期变量
具体例子:(说明什么是长期什么是短期。以及为什么选择对应寄存器)
图一:
首先我们(在具体例子中再次)解释为什么要保存! 在图一场景中,(转换成汇编代码后)如果我将i赋给一个寄存器假如是&rbx,那么在while循环中会调用另一个函数do_something,那么这个do_something函数中可能也会将一个变量储存在&rbx中,那么i原本的值可能就被这个新变量的值覆盖了,当这个do_something函数结束后,i++的时候i的值已经被覆盖不是执行do_something前的数字,那么程序就出问题了。所以就需要保存调用函数之前那个i值。(挺像并发的,记住原来的模样后再切出去)
然后我们再来说这里是什么类型变量,选择什么寄存器
这里你看do_something函数在while循环中执行的次数很多,也就是每次执行do_something时我们都需要考虑保存的问题,因为后面i++时,i需要再次被用到,而且while循环多少次就会被用到多少次,这就是个长期变量。我们就会选择callee寄存器,道理也很简单,如果是调用者保存的话每次在执行do_something之前都必须保存一次, 代码会显得重复冗余而且复杂类似这样
// 伪代码示意
while (...) {
push i; // 第 1 次保存
do_something();
i = pop(); // 第 1 次恢复
do_something(); // 中间可能还有多次调用
push i; // 第 N 次保存
do_something();
i = pop(); // 第 N 次恢复
}
如果交给do_something也就是被调用者,那么他在函数内部封装保存逻辑后,再循环就可以了。(如下图)
void main() {
long i = 0;
while (i < 100) {
// main 函数完全不需要操心保存 i
do_something();//在内部封装保存逻辑
i++;
}
}
代码无论是数量还是规范逻辑都更合适,其实这俩方法都是要考虑如何在100函数调用的情况下每次都能正确保存返回i值。
图二:
这里avg只使用一次,不需要长期使用,使用caller寄存器,movq &rax,%rsi调用者自己保存。把callee寄存器留给更需要的长期变量。
总的寄存器调用流程图
我们形象的把caller和callee对应比作上级和下级的关系
这里其实发现mult2作为multstore的下级,为什么他不用保存?
这里是因为下级有两种选择
1.选择使用caller寄存器,他自己要使用的变量不是长期变量用完就丢,他根本不会碰上级可能使用过的callee寄存器,保证了不会覆盖值,所以就没有需要保存这一说
2.因为他自己处于某种需求(自己有长期变量要使用),必须使用callee寄存器,那么必须要先保存再用callee寄存器。
注意这里涉及一条规则:下级没有办法知道上级有没有用callee寄存器,用的哪一个。这些信息他是不知情的!所以如果他需要用callee寄存器他必须假设上级有用到这个寄存器的可能,所以它必须要先保存!
所以一共两个方案,要么你用caller寄存器根本不会碰callee寄存器,根本不用考虑保存的问题,因为绝对不会覆盖原值;要么你用callee寄存器前必须先保存!