从汇编角度理解C/C++函数调用,能够加深我们对代码的理解,提升调试能力。本文主要讲函数调用栈的建立和销毁、call与ret指令的本质、栈变量的申请、函数参数-返回地址-ebp在栈上的相对位置。
1.前言
1 | //main.c |
编译这段代码,进入gdb,然后对main函数进行反汇编(disassemble main
),得到如下汇编代码.
1 | (gdb) disassemble main |
此时,我们查看对应寄存器,得到如下结果. 我们可以看到,此时rbp和rsp指向同一个位置,这是在gdb中执行start
命令之后,指令所在的位置。
1 | (gdb) info registers rbp rsp |
接下来我们将用这个例子来详细探索函数调用过程中的参数传递、控制转移call
、新函数栈的建立、新函数栈的销毁、控制转移的恢复ret
.
2.进入被调用函数的准备
40050a
~40050f
两条指令为函数调用传递参数的过程,我们可以看到是函数参数是倒序传入的:先传入第N个参数,再传入第N-1个参数。(注意,目前64位的机器上,函数参数的传递很少是通过栈进行的。)
接下来的三行汇编,对应的是实参的传递和对foo函数的调用。下面,我们接着执行几条汇编(si 3
),进入foo函数内部,具体执行如下与解释如下.
1 | (gdb) si 3 |
call指令
注意当指令走到0x00000000004004e7
的时候(尚未执行),也就是callq
执行刚刚执行完毕,对应寄存器的值如下。
1 | (gdb) info registers rbp rsp |
和上一次我们查看rsp/rbp
相比,rsp
向低地址移动了8位(有数据入栈),结合上下文,这一寄存器的变化只会出现在callq 0x4004e7
之中。进一步,我们看一下入栈的数据是什么,也就是原来rsp被存放了什么数值。从下面的代码中我们可以看到,原来的rsp上方存放的数值是callq
之后的指令的地址。
1 | (gdb) x/8 0x7fffffffe370 |
总结一下:call指令的实际用途:1.push IP 2.通过设置 IP实现指令之间的跳转
3.新函数栈的建立与销毁
函数栈的建立
对应4004e7
~4004e8
处的汇编指令:重新设置rsp
和rbp
.
函数中局部变量的申请与赋值
对应的代码位于4004eb
~4004f2
. 从这里我们可以看出1.同一个函数内部,栈变量的申请是同时发生的,但是赋值是逐条执行。2.栈变量的申请,仅仅涉及rsp指针的移动,不会导致segment fault,但是变量的读写,会具体访问到对应的内存,将会触发segment fault。这里我们来看一个例子,猜一猜,这段代码会crash到哪一行?答案是会crash在func()
这一行。因为数组a
对应的空间申请发生在func()
之前,而调用func
的时候会将IP
入栈,而此时,栈已经溢出了。
1 | int func(void) |
函数栈的销毁
函数栈的销毁涉及两方面:栈上空间的释放与rsp和rbp的重置。我们来看看foo函数末尾对应的汇编指令
0x0000000000400504 <+29>: leaveq
继续用si
来逐条执行对应指令,同时查看这条指令前后rpb和rsp的值如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37 (gdb) disassemble foo
Dump of assembler code for function foo:
0x00000000004004e7 <+0>: push %rbp
0x00000000004004e8 <+1>: mov %rsp,%rbp
0x00000000004004eb <+4>: sub $0x8,%rsp
0x00000000004004ef <+8>: mov %edi,-0x4(%rbp)
0x00000000004004f2 <+11>: mov %esi,-0x8(%rbp)
0x00000000004004f5 <+14>: mov -0x8(%rbp),%edx
0x00000000004004f8 <+17>: mov -0x4(%rbp),%eax
0x00000000004004fb <+20>: mov %edx,%esi
0x00000000004004fd <+22>: mov %eax,%edi
0x00000000004004ff <+24>: callq 0x4004cd <bar>
=> 0x0000000000400504 <+29>: leaveq
0x0000000000400505 <+30>: retq
End of assembler dump.
(gdb) info registers rbp rsp
rbp 0x7fffffffe370 0x7fffffffe370
rsp 0x7fffffffe368 0x7fffffffe368
(gdb) si
0x0000000000400505 9 }
(gdb) info registers rbp rsp
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe378 0x7fffffffe378
(gdb) disassemble foo
Dump of assembler code for function foo:
0x00000000004004e7 <+0>: push %rbp
0x00000000004004e8 <+1>: mov %rsp,%rbp
0x00000000004004eb <+4>: sub $0x8,%rsp
0x00000000004004ef <+8>: mov %edi,-0x4(%rbp)
0x00000000004004f2 <+11>: mov %esi,-0x8(%rbp)
0x00000000004004f5 <+14>: mov -0x8(%rbp),%edx
0x00000000004004f8 <+17>: mov -0x4(%rbp),%eax
0x00000000004004fb <+20>: mov %edx,%esi
0x00000000004004fd <+22>: mov %eax,%edi
0x00000000004004ff <+24>: callq 0x4004cd <bar>
0x0000000000400504 <+29>: leaveq
=> 0x0000000000400505 <+30>: retq
从理论上来说,leaveq
应该正好是入栈的逆向过程mov %rbp %rsp; pop %rbp
.
控制转移的恢复ret/retq
最后我们看看retq执行完毕之后,寄存器前后的变化。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 (gdb) info registers rbp rsp rip
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe378 0x7fffffffe378
rip 0x400505 0x400505 <foo+30>
(gdb) disassemble foo
Dump of assembler code for function foo:
0x00000000004004e7 <+0>: push %rbp
0x00000000004004e8 <+1>: mov %rsp,%rbp
0x00000000004004eb <+4>: sub $0x8,%rsp
0x00000000004004ef <+8>: mov %edi,-0x4(%rbp)
0x00000000004004f2 <+11>: mov %esi,-0x8(%rbp)
0x00000000004004f5 <+14>: mov -0x8(%rbp),%edx
0x00000000004004f8 <+17>: mov -0x4(%rbp),%eax
0x00000000004004fb <+20>: mov %edx,%esi
0x00000000004004fd <+22>: mov %eax,%edi
0x00000000004004ff <+24>: callq 0x4004cd <bar>
0x0000000000400504 <+29>: leaveq
=> 0x0000000000400505 <+30>: retq
End of assembler dump.
(gdb) si
main () at main.c:13
13 return 0;
(gdb) info registers rbp rsp rip
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe380 0x7fffffffe380
rip 0x400519 0x400519 <main+19>
这里我们得到几点结论:
retq
指令的调用,导致出栈了一个8位的数,这个就是调用者的下一条指令。retq
调用之前,已经处在调用者的栈帧。retq
的调用,仅仅是一个栈上保存的地址存放到rip
寄存器。- 对比
main
和foo
函数的末尾,我们可以看到有细小的差别:main
函数栈的销毁仅仅有push %rbp
一句,但foo
函数的结尾是leaveq
。导致这一差别的原因是main函数建立调用栈之后,并没有移动rsp
.
总结
函数调用过程中栈的变化如图
我们可以看到子程序调用之前和之后(1&6),函数栈是没有任何变化的,有变化的在于rip
等相关寄存器的值;call
指令执行之后与ret
指令执行之前,函数栈也是相同的,而ret
指令之所以能转交控制权,是因为ip
的值被保存到栈上。 这里我们可以看到,栈不仅仅有保存的局部变量数据,也有对控制转移指令至关重要的寄存器临时存储。一旦栈被写坏,控制转移指令就无法正常执行。下一节,我们将讲解与栈相关的控制转移指令被写坏的场景。