函数调用是解耦思想的一个基本体现。为了实现调用,我们往往需要给子程序提供一些参数并得到子程序的执行结果。今天就来探究一下这个过程在计算机内部是如何实现的。更多内容欢迎来我的博客转转:
精神的壳qiuyueqy.com利用寄存器传参
来看例一:
int square(int num) {
return num * num;
}
int main() {
int k = 2;
square(k);
}
在 x86-64 平台上汇编得到的结果是这样的:
square:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
imul eax, eax
pop rbp
ret
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 2
mov eax, DWORD PTR [rbp-4]
mov edi, eax
call square
mov DWORD PTR [rbp-4], eax
mov eax, 0
leave
ret
变量 k 保存在内存 [rbp-4]
处,其值为 2。随后 [rbp-4]
中的值被存到了 eax
,最后保存在 edi
,这就完成了一次寄存器传参。square 函数更新 rbp
的值再从 edi
取值保存到 [rbp-4]
,完成形参到实参的复制。square 把结果保存在 eax
里,main 读取 eax
得到调用返回值。
用栈传参
当参数的数量过多无法全部保存到寄存器里或长度超过了机器字长,就要用到栈来传参。再来看例二:
typedef struct {
char name[20];
int age;
}Person;
void greet(Person people) {
printf("hello %s, you are %d years old.n", people.name, people.age);
}
int main() {
Person me = {name: "Rowan", age: 21};
greet(me);
}
翻译成汇编语言是这样:
.LC0:
.string "hello %s, you are %d years old.n"
greet:
push rbp
mov rbp, rsp
mov eax, DWORD PTR [rbp+36]
mov edx, eax
lea rsi, [rbp+16]
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
nop
pop rbp
ret
main:
push rbp
mov rbp, rsp
sub rsp, 32
movabs rax, 474081619794
mov edx, 0
mov QWORD PTR [rbp-32], rax
mov QWORD PTR [rbp-24], rdx
mov DWORD PTR [rbp-16], 0
mov DWORD PTR [rbp-12], 21
sub rsp, 8
push QWORD PTR [rbp-16]
push QWORD PTR [rbp-24]
push QWORD PTR [rbp-32]
call greet
add rsp, 32
mov eax, 0
leave
ret
一个 Person 实例占 24 字节,超过了 x86-64 平台下一个通用寄存器的最大长度(8 字节),编译器用栈来保存参数。
26 到 28 行的三个 push
操作把 me 入栈,随后用 call
指令跳到 square 函数。square 函数在栈的 [rbp+36]
取到 age 成员的值,取 [rbp+16]
(name 成员)的内存地址放入 rsi
,完成一次传参。
细探 sp 与 bp
sp(stack pointer),中文名“栈顶寄存器”。顾名思义,这个指针时刻指向栈的顶部,其值随 push 和 pop 操作隐式改变。假设操作对象为 x:
- push x:首先,sp -= len(x)(x 的字长,以字节为单位);然后,在 [sp-len(x), sp) 中存入 x 值。
- pop x:首先,从 [sp-len(x), sp) 中读取数据存放到 x;然后,sp += len(x)。
如果编译器能推断一个函数要占用多少空间,在进入这个函数时往往会为它预先分配好一段栈空间(如例二汇编代码第 18 行 sub rsp, 32
,为 main 函数分配了 32 字节栈空间)。在这个函数退出时,调整 sp 的值销毁这段空间(例二汇编代码第 30 行 add rsp, 32
)。
bp(base pointer),中文名“基指寄存器”。因为随着指令执行 sp 是不断变化的,所以在进入函数时常用 bp 保存 sp 的初始值以对要用到的数据进行寻址定位(例二汇编代码第 6 行 mov eax, DWORD PTR [rbp+36]
,第 21 行 mov QWORD PTR [rbp-32], rax
等)。
指令 call x
会设置 ip 寄存器的值为 x 的段偏移地址,达到“调用函数”的目的。这个过程分为两步:
- 把 ip 寄存器的原址入栈(此时 ip 指向 call 的下一条指令),相当于
push ip
- 更新 ip 寄存器的值,相当于
mov ip, x
指令 ret
从栈顶取值保存到 ip 寄存器,相当于 call
的逆操作。执行完 ret 指令后 ip 恢复到函数调用前的值,程序从而继续向下执行。
指令 leave
在函数执行结束后恢复 sp 和 bp 的值。上文说到进入函数时会把 sp 保存到 bp 再调整 sp 的值以为函数分配栈空间。那么在退出函数时自然要恢复原来的 sp 和 bp。这条指令相当于 mov rsp, rbp
和 pop rbp
的结合。