大家都知道函数调用是通过栈来实现的,而且知道在栈中存放着该函数的局部变量。但是对于栈的实现细节可能不一定清楚。本文将介绍一下在 x86 平台下函数栈是如何实现的。
Intel 系列处理器通常称为x86,目前常用的笔记本或台式机都是 64 位的处理器,这些处理器使用的机器语言一般都是 x86_64,我记得以前学习微机原理课的时候,学习的还是 8086 处理器上的汇编。8086 是Intel的第一代16位的处理器,只有8个16位的寄存器,而现在的 64 位处理器对其进行了扩展,共有16个64位的寄存器。
需要注意的是,这里采用的汇编代码是 ATT 格式的,与 Intel 格式的汇编码有些不同:
64位处理器中 16 个寄存器对理解 x86-64 汇编十分重要,见下图(图源 CSAPP):
考虑C语言中的函数调用问题,当在一个程序中调用另一个程序的时候,如在函数 P 中调用函数Q,至少需要做以下几件事情:
为了解决上述的问题,C语言调用时采用「运行时栈」来对函数的调用过程进行维护:
需要注意这里的栈是倒着画的,栈顶在下面,栈顶的内存地址是更小的,换句话说栈增长的方向是内存地址减小的方向。
从上面图中我们可以看出,在函数 P 调用 Q 时,首先在函数 P 的「栈帧」压入了返回地址(Return address),指示函数 Q 执行完成之后的断点位置。然后函数 Q 执行的时候会开辟它自己的栈帧(实际上就是将栈顶指针%rsp减小),并可能会做以下几件事情:
在 x86 系统的CPU中,rsp(stack pointer) 是栈指针寄存器,这个寄存器中存储着栈顶的地址。rbp(base pointer) 是基址指针寄存器,这个寄存器中存储着栈底的地址。函数栈空间主要是由这两个寄存器来确定的。
当程序运行时,栈指针 rsp 可以移动,栈指针和帧指针 rbp 一次只能存储一个地址,所以,任何时候,这一对指针指向的是同一个函数的栈帧结构。
而帧指针 rbp 是不移动的,访问栈中的元素可以用-4(%rbp)或者8(%rbp)访问 %rbp 指针下面或者上面的元素。
/* proc.c */
void func1()
{
}
int func2(int a, long b, char *c)
{
*c = a * b;
func1();
return a * b;
}
int main()
{
char value;
int rc = func2(1, 2, &value);
}
研究一个简单的函数示例对理解该过程有帮助.
liangjie@liangjie-virtual-machine:~/Desktop/cfp$ gcc proc.c -o proc
liangjie@liangjie-virtual-machine:~/Desktop/cfp$ objdump -d proc
......
0000000000001149 <func1>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 90 nop
1152: 5d pop %rbp
1153: c3 ret
0000000000001154 <func2>:
1154: f3 0f 1e fa endbr64
1158: 55 push %rbp
1159: 48 89 e5 mov %rsp,%rbp
115c: 48 83 ec 18 sub $0x18,%rsp
1160: 89 7d fc mov %edi,-0x4(%rbp)
1163: 48 89 75 f0 mov %rsi,-0x10(%rbp)
1167: 48 89 55 e8 mov %rdx,-0x18(%rbp)
116b: 8b 45 fc mov -0x4(%rbp),%eax
116e: 89 c1 mov %eax,%ecx
1170: 48 8b 45 f0 mov -0x10(%rbp),%rax
1174: 89 c2 mov %eax,%edx
1176: 89 c8 mov %ecx,%eax
1178: 0f af c2 imul %edx,%eax
117b: 89 c2 mov %eax,%edx
117d: 48 8b 45 e8 mov -0x18(%rbp),%rax
1181: 88 10 mov %dl,(%rax)
1183: b8 00 00 00 00 mov $0x0,%eax
1188: e8 bc ff ff ff call 1149 <func1>
118d: 48 8b 45 f0 mov -0x10(%rbp),%rax
1191: 89 c2 mov %eax,%edx
1193: 8b 45 fc mov -0x4(%rbp),%eax
1196: 0f af c2 imul %edx,%eax
1199: c9 leave
119a: c3 ret
000000000000119b <main>:
119b: f3 0f 1e fa endbr64
119f: 55 push %rbp
11a0: 48 89 e5 mov %rsp,%rbp
11a3: 48 83 ec 10 sub $0x10,%rsp
11a7: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
11ae: 00 00
11b0: 48 89 45 f8 mov %rax,-0x8(%rbp)
11b4: 31 c0 xor %eax,%eax
11b6: 48 8d 45 f3 lea -0xd(%rbp),%rax
11ba: 48 89 c2 mov %rax,%rdx
11bd: be 02 00 00 00 mov $0x2,%esi
11c2: bf 01 00 00 00 mov $0x1,%edi
11c7: e8 88 ff ff ff call 1154 <func2>
11cc: 89 45 f4 mov %eax,-0xc(%rbp)
11cf: b8 00 00 00 00 mov $0x0,%eax
11d4: 48 8b 55 f8 mov -0x8(%rbp),%rdx
11d8: 64 48 2b 14 25 28 00 sub %fs:0x28,%rdx
11df: 00 00
11e1: 74 05 je 11e8 <main+0x4d>
11e3: e8 68 fe ff ff call 1050 <__stack_chk_fail@plt>
11e8: c9 leave
11e9: c3 ret
......
func1 函数比较简单,由 func1 先讲起。 func1 主要关注函数调用关系流程。
func2 中的 call 指令意为:
将 func2 () 中下一条语句的地址(也就是「mov -0x10(%rbp),%rax」这句的地址)压入栈中并修改 rip 的值为 func1() 的地址
1188: e8 bc ff ff ff call 1149 <func1>
再看函数 func1,
0000000000001149 <func1>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp # 将 rbp 压栈
114e: 48 89 e5 mov %rsp,%rbp # 将 rsp -> rbp
1151: 90 nop
1152: 5d pop %rbp # 将 rbp 弹出
1153: c3 ret
push %rbp
,使用将 rbp 压栈的方式,保存 rbpmov %rsp,%rbp
,原来的栈底(rbp 指向的位置)成为了新的栈顶(rsp 指向的位置)到这里,函数栈情况如下图
pop %rbp
,将 rbp 弹出,恢复调用函数的栈基址寄存器 rbpret
,根据 rsp 指向的位置从栈中弹出返回位置(也就是上面 call 指令中压栈的值),并通过修改 rip 的值为返回地址。到这里,函数 func1 执行结束,转而返回到 func2 中我们再看看 func2,func2 则主要关注函数传参以及局部变量的存储。
0000000000001154 <func2>:
1154: f3 0f 1e fa endbr64
1158: 55 push %rbp # rbp 入栈保存值
1159: 48 89 e5 mov %rsp,%rbp # 修改 rbp 指向地址为 rsp
115c: 48 83 ec 18 sub $0x18,%rsp # 为 func2 函数的入参分配空间
1160: 89 7d fc mov %edi,-0x4(%rbp) # edi -> (rbp - 4)
1163: 48 89 75 f0 mov %rsi,-0x10(%rbp) # rsi -> (rbp - 16)
1167: 48 89 55 e8 mov %rdx,-0x18(%rbp) # rdx -> (rbp - 24)
116b: 8b 45 fc mov -0x4(%rbp),%eax # (rbp - 4) -> eax
116e: 89 c1 mov %eax,%ecx # eax -> ecx
1170: 48 8b 45 f0 mov -0x10(%rbp),%rax # (rbp - 16)-> rax
1174: 89 c2 mov %eax,%edx # eax -> edx
1176: 89 c8 mov %ecx,%eax # ecx -> eax
1178: 0f af c2 imul %edx,%eax # edx * eax -> eax
117b: 89 c2 mov %eax,%edx # eax -> edx
117d: 48 8b 45 e8 mov -0x18(%rbp),%rax # (rbp - 18)-> rax
1181: 88 10 mov %dl,(%rax) # dl -> (rax)
1183: b8 00 00 00 00 mov $0x0,%eax
1188: e8 bc ff ff ff call 1149 <func1>
118d: 48 8b 45 f0 mov -0x10(%rbp),%rax # (rbp - 16)-> rax
1191: 89 c2 mov %eax,%edx # eax -> edx
1193: 8b 45 fc mov -0x4(%rbp),%eax # (rbp - 4)-> eax
1196: 0f af c2 imul %edx,%eax # edx * eax -> eax
1199: c9 leave
119a: c3 ret
115c: 48 83 ec 18 sub $0x18,%rsp # 为 func2 函数的入参分配空间
1160: 89 7d fc mov %edi,-0x4(%rbp) # edi -> (rbp - 4)
1163: 48 89 75 f0 mov %rsi,-0x10(%rbp) # rsi -> (rbp - 16)
1167: 48 89 55 e8 mov %rdx,-0x18(%rbp) # rdx -> (rbp - 24)
*c = a * b;
的功能 116b: 8b 45 fc mov -0x4(%rbp),%eax # (rbp - 4) -> eax
116e: 89 c1 mov %eax,%ecx # eax -> ecx
1170: 48 8b 45 f0 mov -0x10(%rbp),%rax # (rbp - 16)-> rax
1174: 89 c2 mov %eax,%edx # eax -> edx
1176: 89 c8 mov %ecx,%eax # ecx -> eax
1178: 0f af c2 imul %edx,%eax # edx * eax -> eax
117b: 89 c2 mov %eax,%edx # eax -> edx
117d: 48 8b 45 e8 mov -0x18(%rbp),%rax # (rbp - 18)-> rax
1181: 88 10 mov %dl,(%rax) # dl -> (rax)
a * b;
的功能,结果存储在 eax 寄存中,作为 func2 函数的返回值 118d: 48 8b 45 f0 mov -0x10(%rbp),%rax # (rbp - 16)-> rax
1191: 89 c2 mov %eax,%edx # eax -> edx
1193: 8b 45 fc mov -0x4(%rbp),%eax # (rbp - 4)-> eax
1196: 0f af c2 imul %edx,%eax # edx * eax -> eax
mov %rbp, %rsp
pop %rbp
ret
指令,根据 rsp 指向的位置从栈中弹出返回位置(也就是上面 call 指令中压栈的值),并通过修改 rip 的值为返回地址。到这里,函数 func2 执行结束,转而返回到 main 中