浅谈函数调用
函数是编程里非常基础的知识,也是日常非常常用的编程封装方式,于是浅浅探索一下函数调用的底层实现。
寄存器
深入探究函数调用原理,避免不了需要了解汇编知识以及部分寄存器的作用。本文所讲是基于X86-64 架构。
通用寄存器
64-bit | 32-bit | 16-bit | 8 high bits of lower 16 bits | 8-bit | Description |
---|---|---|---|---|---|
RAX | EAX | AX | AH | AL | Accumulator |
RBX | EBX | BX | BH | BL | Base |
RCX | ECX | CX | CH | CL | Counter |
RDX | EDX | DX | DH | DL | Data (commonly extends the A register) |
RSI | ESI | SI | N/A | SIL | Source index for string operations |
RDI | EDI | DI | N/A | DIL | Destination index for string operations |
RSP | ESP | SP | N/A | SPL | Stack Pointer |
RBP | EBP | BP | N/A | BPL | Base Pointer (meant for stack frames) |
R8 | R8D | R8W | N/A | R8B | General purpose |
R9 | R9D | R9W | N/A | R9B | General purpose |
R10 | R10D | R10W | N/A | R10B | General purpose |
R11 | R11D | R11W | N/A | R11B | General purpose |
R12 | R12D | R12W | N/A | R12B | General purpose |
R13 | R13D | R13W | N/A | R13B | General purpose |
R14 | R14D | R14W | N/A | R14B | General purpose |
R15 | R15D | R15W | N/A | R15B | General purpose |
RAX
通常还被用来存放函数返回值RCX
通常用作循环计数RSP
指向栈的顶部RBP
指向栈的底部,通常使用基址指针+偏移量的方式获取入参以及本地变量
除了通用寄存器还有非常多寄存器,比如RIP指令寄存器,段寄存器等等,但这里并不继续深入。
函数调用
函数调用主要有以下步骤
- 上下文保存
- 数据传递
- 控制转移
- 上下文恢复
下面会通过具体的汇编代码来看每一步到底是怎么实现的。所有的汇编代码都是AT&T
格式。
上下文保存
在进行函数调用前,肯定是需要对一些数据进行保存的,比如当前函数保存在寄存器内的值,如果不提前保存好,就可能会被调用的函数使用并且覆盖里面的数据,当调用结束后,寄存器里面的值将是脏数据,会导致程序运行出错。
每个寄存器的值由调用方(caller)保存还是由被调用方(callee)保存是有约定的,叫做Calling Convention
。Calling Convention
是一些规则的集合,是通俗的约定,编译器对高级语言编译后生成的汇编代码都是符合这些约定的,在写汇编程序的时候遵守这些规定可以认为是必须的。
更多关于Calling Convention
的细节可以参考The 64 bit x86 C Calling Convention 这篇文章,这里不继续展开。
下面看一个简单的例子
#include <stdio.h>
int add(int a, int b) {
int c = a + b;
return c;
}
int main() {
int a = 1, b = 2;
int ret = add(a, b);
ret += a;
ret += b;
printf("%d\n", ret);
return 0;
}
0000000000400596 <add>:
400596: 55 push %rbp
400597: 48 89 e5 mov %rsp,%rbp
40059a: 89 7d ec mov %edi,-0x14(%rbp)
40059d: 89 75 e8 mov %esi,-0x18(%rbp)
4005a0: 8b 55 ec mov -0x14(%rbp),%edx
4005a3: 8b 45 e8 mov -0x18(%rbp),%eax
4005a6: 01 d0 add %edx,%eax
4005a8: 89 45 fc mov %eax,-0x4(%rbp)
4005ab: 8b 45 fc mov -0x4(%rbp),%eax
4005ae: 5d pop %rbp
4005af: c3 retq
00000000004005b0 <main>:
4005b0: 55 push %rbp
4005b1: 48 89 e5 mov %rsp,%rbp
4005b4: 48 83 ec 10 sub $0x10,%rsp
4005b8: c7 45 fc 01 00 00 00 movl $0x1,-0x4(%rbp)
4005bf: c7 45 f8 02 00 00 00 movl $0x2,-0x8(%rbp)
4005c6: 8b 55 f8 mov -0x8(%rbp),%edx
4005c9: 8b 45 fc mov -0x4(%rbp),%eax
4005cc: 89 d6 mov %edx,%esi
4005ce: 89 c7 mov %eax,%edi
4005d0: e8 c1 ff ff ff callq 400596 <add>
4005d5: 89 45 f4 mov %eax,-0xc(%rbp)
4005d8: 8b 45 fc mov -0x4(%rbp),%eax
4005db: 01 45 f4 add %eax,-0xc(%rbp)
4005de: 8b 45 f8 mov -0x8(%rbp),%eax
4005e1: 01 45 f4 add %eax,-0xc(%rbp)
4005e4: 8b 45 f4 mov -0xc(%rbp),%eax
4005e7: 89 c6 mov %eax,%esi
4005e9: bf 98 06 40 00 mov $0x400698,%edi
4005ee: b8 00 00 00 00 mov $0x0,%eax
4005f3: e8 a8 fe ff ff callq 4004a0 <printf@plt>
4005f8: b8 00 00 00 00 mov $0x0,%eax
4005fd: c9 leaveq
4005fe: c3 retq
4005ff: 90 nop
我们定义了一个加法函数add()
并且在main
函数中进行调用。在main
函数中定义了两个局部变量a
和b
,查看汇编代码看到首先将栈指针rsp
减小16,申请了16字节的栈空间,并且前4个字节存放值1,接下来的4个字节存放值2,分别对应局部变量a
和b
。在调用完add()
函数之后,分别将保存在栈内的局部变量分别存储到eax
并且相加到-0xc(%rbp)
中,对应main
函数中ret += a; ret += b;
可以发现虽然申请了16字节的栈空间,但实际上并没有使用到这么多,为什么不用多少就申请多少?这样不是更节约内存吗?这里主要是为了内存对齐
。
数据传递
在调用add()
函数时需要传递两个参数,看汇编代码可以发现是将%edi
和%esi
中的两个变量相加并且将结果放在了%eax
,查看main
函数的汇编代码,可以发现传递的参数恰好是放在%edi
和%esi
中,并且调用结束后也是从%eax
中取出结果并且加上局部变量的值。
下面看一个传递6个参数的例子。
#include <stdio.h>
int add(int a, int b, int c, int d, int e, int f) {
int sum = a + b + c + d + e + f;
return sum;
}
int main() {
int ret = add(1, 2, 3, 4, 5, 6);
printf("%d\n", ret);
return 0;
}
0000000000400596 <add>:
400596: 55 push %rbp
400597: 48 89 e5 mov %rsp,%rbp
40059a: 89 7d ec mov %edi,-0x14(%rbp)
40059d: 89 75 e8 mov %esi,-0x18(%rbp)
4005a0: 89 55 e4 mov %edx,-0x1c(%rbp)
4005a3: 89 4d e0 mov %ecx,-0x20(%rbp)
4005a6: 44 89 45 dc mov %r8d,-0x24(%rbp)
4005aa: 44 89 4d d8 mov %r9d,-0x28(%rbp)
4005ae: 8b 55 ec mov -0x14(%rbp),%edx
4005b1: 8b 45 e8 mov -0x18(%rbp),%eax
4005b4: 01 c2 add %eax,%edx
4005b6: 8b 45 e4 mov -0x1c(%rbp),%eax
4005b9: 01 c2 add %eax,%edx
4005bb: 8b 45 e0 mov -0x20(%rbp),%eax
4005be: 01 c2 add %eax,%edx
4005c0: 8b 45 dc mov -0x24(%rbp),%eax
4005c3: 01 c2 add %eax,%edx
4005c5: 8b 45 d8 mov -0x28(%rbp),%eax
4005c8: 01 d0 add %edx,%eax
4005ca: 89 45 fc mov %eax,-0x4(%rbp)
4005cd: 8b 45 fc mov -0x4(%rbp),%eax
4005d0: 5d pop %rbp
4005d1: c3 retq
00000000004005d2 <main>:
4005d2: 55 push %rbp
4005d3: 48 89 e5 mov %rsp,%rbp
4005d6: 48 83 ec 10 sub $0x10,%rsp
4005da: 41 b9 06 00 00 00 mov $0x6,%r9d
4005e0: 41 b8 05 00 00 00 mov $0x5,%r8d
4005e6: b9 04 00 00 00 mov $0x4,%ecx
4005eb: ba 03 00 00 00 mov $0x3,%edx
4005f0: be 02 00 00 00 mov $0x2,%esi
4005f5: bf 01 00 00 00 mov $0x1,%edi
4005fa: e8 97 ff ff ff callq 400596 <add>
4005ff: 89 45 fc mov %eax,-0x4(%rbp)
400602: 8b 45 fc mov -0x4(%rbp),%eax
400605: 89 c6 mov %eax,%esi
400607: bf b8 06 40 00 mov $0x4006b8,%edi
40060c: b8 00 00 00 00 mov $0x0,%eax
400611: e8 8a fe ff ff callq 4004a0 <printf@plt>
400616: b8 00 00 00 00 mov $0x0,%eax
40061b: c9 leaveq
40061c: c3 retq
40061d: 0f 1f 00 nopl (%rax)
可以发现传递六个参数的时候借助了%edi %esi %edx %ecx %r8d %r9d
这六个寄存器,毕竟寄存器的数量是有限的,如果传递更多的参数呢?
下面是一个传递9个参数的例子。
#include <stdio.h>
int add(int a, int b, int c, int d, int e, int f, int g, int h, int i) {
int sum = a + b + c + d + e + f + g + h + i;
return sum;
}
int main() {
int ret = add(1, 2, 3, 4, 5, 6, 7, 8, 9);
printf("%d\n", ret);
return 0;
}
0000000000400596 <add>:
400596: 55 push %rbp
400597: 48 89 e5 mov %rsp,%rbp
40059a: 89 7d ec mov %edi,-0x14(%rbp)
40059d: 89 75 e8 mov %esi,-0x18(%rbp)
4005a0: 89 55 e4 mov %edx,-0x1c(%rbp)
4005a3: 89 4d e0 mov %ecx,-0x20(%rbp)
4005a6: 44 89 45 dc mov %r8d,-0x24(%rbp)
4005aa: 44 89 4d d8 mov %r9d,-0x28(%rbp)
4005ae: 8b 55 ec mov -0x14(%rbp),%edx
4005b1: 8b 45 e8 mov -0x18(%rbp),%eax
4005b4: 01 c2 add %eax,%edx
4005b6: 8b 45 e4 mov -0x1c(%rbp),%eax
4005b9: 01 c2 add %eax,%edx
4005bb: 8b 45 e0 mov -0x20(%rbp),%eax
4005be: 01 c2 add %eax,%edx
4005c0: 8b 45 dc mov -0x24(%rbp),%eax
4005c3: 01 c2 add %eax,%edx
4005c5: 8b 45 d8 mov -0x28(%rbp),%eax
4005c8: 01 c2 add %eax,%edx
4005ca: 8b 45 10 mov 0x10(%rbp),%eax
4005cd: 01 c2 add %eax,%edx
4005cf: 8b 45 18 mov 0x18(%rbp),%eax
4005d2: 01 c2 add %eax,%edx
4005d4: 8b 45 20 mov 0x20(%rbp),%eax
4005d7: 01 d0 add %edx,%eax
4005d9: 89 45 fc mov %eax,-0x4(%rbp)
4005dc: 8b 45 fc mov -0x4(%rbp),%eax
4005df: 5d pop %rbp
4005e0: c3 retq
00000000004005e1 <main>:
4005e1: 55 push %rbp
4005e2: 48 89 e5 mov %rsp,%rbp
4005e5: 48 83 ec 10 sub $0x10,%rsp
4005e9: 6a 09 pushq $0x9
4005eb: 6a 08 pushq $0x8
4005ed: 6a 07 pushq $0x7
4005ef: 41 b9 06 00 00 00 mov $0x6,%r9d
4005f5: 41 b8 05 00 00 00 mov $0x5,%r8d
4005fb: b9 04 00 00 00 mov $0x4,%ecx
400600: ba 03 00 00 00 mov $0x3,%edx
400605: be 02 00 00 00 mov $0x2,%esi
40060a: bf 01 00 00 00 mov $0x1,%edi
40060f: e8 82 ff ff ff callq 400596 <add>
400614: 48 83 c4 18 add $0x18,%rsp
400618: 89 45 fc mov %eax,-0x4(%rbp)
40061b: 8b 45 fc mov -0x4(%rbp),%eax
40061e: 89 c6 mov %eax,%esi
400620: bf d8 06 40 00 mov $0x4006d8,%edi
400625: b8 00 00 00 00 mov $0x0,%eax
40062a: e8 71 fe ff ff callq 4004a0 <printf@plt>
40062f: b8 00 00 00 00 mov $0x0,%eax
400634: c9 leaveq
400635: c3 retq
400636: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
40063d: 00 00 00
可以看到最后三个参数不再是通过寄存器传递给add()
函数了,而是通过pushq
指令压入栈中,而且压栈顺序是请求参数的逆序。在add()
函数中是如何取栈中的参数呢?查看汇编代码发现分别取了0x10(%rbp) 0x18(%rbp) 0x20(%rbp
,三个地址的差值都是8字节,这个倒是没疑问,但为什么第一个参数的地址是0x10(%rbp)
呢?跳过的16个字节保存的是什么内容呢?在使用callq
指令调用add()
函数的时候,会将返回地址0x400614
压入栈中,占用8字节,在add()
函数开头将%rbp
压入栈中,同样占用8个字节,所以取参数时需要跳过16个字节,通过gdb查看栈中保存的内容发现确实如此。
虽然可以通过栈传递更多函数参数,但是访问内存的速度不如访问寄存器,所以在设计函数时要尽可能减少函数参数的个数。
控制转移
rip
寄存器保存的是指令的地址,cpu每次执行指令都从指令寄存器内获取要执行的指令地址,所以控制转移其实就是修改rip
寄存器的值,指向add()
函数的代码段地址即可。通过gdb
单步汇编代码跟踪就可以发现在调用之前%rip
内保存的地址是0x40060f
,之后修改为0x400596
跳转到add()
函数。当add()
函数执行完之后,retq
指令会将栈中保存的返回地址0x400614
赋给%rip
,完成函数调用,并且继续执行main
中的指令。
上下文恢复
在完成函数调用之后,需要将之前保存的寄存器等恢复成和函数调用前一模一样,上面的汇编代码在完成函数调用后,执行了add $0x18,%rsp
指令,通过修改rsp
寄存器的值清空调用函数前压入栈中的三个参数,可以发现0x18
刚好就是24个字节,对应参数栈中7 8 9
的大小。
参考资料
X86-84 wiki
CPU Registers x86-64
The 64 bit x86 C Calling Convention