引言
大家都知道函数调用是通过栈来实现的,而且知道在栈中存放着该函数的局部变量。但是对于栈的实现细节可能不一定清楚。本文将介绍一下在 x86 平台下函数栈是如何实现的。
1、x86-64 汇编
Intel 系列处理器通常称为x86,目前常用的笔记本或台式机都是 64 位的处理器,这些处理器使用的机器语言一般都是 x86_64,我记得以前学习微机原理课的时候,学习的还是 8086 处理器上的汇编。8086 是Intel的第一代16位的处理器,只有8个16位的寄存器,而现在的 64 位处理器对其进行了扩展,共有16个64位的寄存器。
需要注意的是,这里采用的汇编代码是 ATT 格式的,与 Intel 格式的汇编码有些不同:
- Intel汇编码省略了指示大小的后缀,如ATT格式中的pushq,在Intel中为push
- Intel代码访问寄存器时省略了%,如ATT格式是%rbx,在Intel中为rbx
- ATT代码使用小括号( )来访问内存中的位置,而Intel代码使用中括号[ ]
- 操作数的顺序不同,如 ATT 中 movq %rax, %rbx,第一个操作数为源操作数,第二个为目的操作数,Intel格式正好相反。
64位处理器中 16 个寄存器对理解 x86-64 汇编十分重要,见下图(图源 CSAPP):
- %rax 作为函数返回值使用。
- %rsp 栈指针寄存器,指向栈顶
- %rbp 栈桢指针,指向栈基
- %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数。。。
- %rbx,%r12,%r13,%14,%15 用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改
- %r10,%r11 用作数据存储,遵循调用者使用规则,简单说就是使用之前要先保存原值
- %rip: 相当于PC指针指向当前的指令地址,指向下一条要执行的指令
2、运行时栈
考虑C语言中的函数调用问题,当在一个程序中调用另一个程序的时候,如在函数 P 中调用函数Q,至少需要做以下几件事情:
- 首先需要将程序计数器指针指向 Q 的起始地址,在 Q 函数返回的时候需要将程序计数器指向 P 中函数 Q 后面的那条指令;
- 其次需要完成函数参数传递问题,以及函数返回值的传递问题;
- 最后是函数Q运行时可能需要保存额外的局部变量。
为了解决上述的问题,C语言调用时采用「运行时栈」来对函数的调用过程进行维护:
需要注意这里的栈是倒着画的,栈顶在下面,栈顶的内存地址是更小的,换句话说栈增长的方向是内存地址减小的方向。
从上面图中我们可以看出,在函数 P 调用 Q 时,首先在函数 P 的「栈帧」压入了返回地址(Return address),指示函数 Q 执行完成之后的断点位置。然后函数 Q 执行的时候会开辟它自己的栈帧(实际上就是将栈顶指针%rsp减小),并可能会做以下几件事情:
- 将一些必要的寄存器的值进行保存——(Saved register)(这些寄存器称为被调用者保存的寄存器(Callee saved),也就是函数 Q 有义务保证自己运行前后这些寄存器的值是保持不变的,因此函数 P 可以放心的将变量存储在这些寄存器中)
- 保存 Q 中的一些局部变量(Local variables)
- 若 Q 需要调用其它函数,且该函数的参数大于6个,则在 Q 的栈帧中存储这些参数的值。(后面会有示例)
2.1 栈帧
在 x86 系统的CPU中,rsp(stack pointer) 是栈指针寄存器,这个寄存器中存储着栈顶的地址。rbp(base pointer) 是基址指针寄存器,这个寄存器中存储着栈底的地址。函数栈空间主要是由这两个寄存器来确定的。
当程序运行时,栈指针 rsp 可以移动,栈指针和帧指针 rbp 一次只能存储一个地址,所以,任何时候,这一对指针指向的是同一个函数的栈帧结构。
而帧指针 rbp 是不移动的,访问栈中的元素可以用-4(%rbp)或者8(%rbp)访问 %rbp 指针下面或者上面的元素。
2.2 代码讲解
/* 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
......
2.2.1 func1
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 压栈的方式,保存 rbp - 通过执行
mov %rsp,%rbp
,原来的栈底(rbp 指向的位置)成为了新的栈顶(rsp 指向的位置)
到这里,函数栈情况如下图
- 通过执行
pop %rbp
,将 rbp 弹出,恢复调用函数的栈基址寄存器 rbp - 通过执行
ret
,根据 rsp 指向的位置从栈中弹出返回位置(也就是上面 call 指令中压栈的值),并通过修改 rip 的值为返回地址。到这里,函数 func1 执行结束,转而返回到 func2 中
2.2.2 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
- 通过以下片段,对 func2 入参进行压栈。这里需要注意的是,为了栈对齐,参数 b 放到了 rbp - 16 的位置,而不是 rbp - 12
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 语句:
*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)
- 通过以下片段,完成 C 语句:
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
- 执行 leave 指令相当于执行如下两条指令:
mov %rbp, %rsp
pop %rbp
- 通过
ret
指令,根据 rsp 指向的位置从栈中弹出返回位置(也就是上面 call 指令中压栈的值),并通过修改 rip 的值为返回地址。到这里,函数 func2 执行结束,转而返回到 main 中