从汇编角度理解C/C++函数调用,能够加深我们对代码的理解,提升调试能力。本文主要讲函数调用栈的建立和销毁、call与ret指令的本质、栈变量的申请、函数参数-返回地址-ebp在栈上的相对位置。
1.前言
//main.c
int bar(int c, int d)
{
int e = c + d;
return e;
}
int foo(int a, int b)
{
return bar(a, b);
}
int main(void)
{
foo(2, 5);
return 0;
}
编译这段代码,进入gdb,然后对main函数进行反汇编(disassemble main
),得到如下汇编代码.
(gdb) disassemble main
Dump of assembler code for function main:
0x0000000000400506 <+0>: push %rbp
0x0000000000400507 <+1>: mov %rsp,%rbp //以上两句,建立了main函数的栈
=> 0x000000000040050a <+4>: mov $0x5,%esi
0x000000000040050f <+9>: mov $0x2,%edi // 将实参放入寄存器
0x0000000000400514 <+14>: callq 0x4004e7 <foo>
0x0000000000400519 <+19>: mov $0x0,%eax
0x000000000040051e <+24>: pop %rbp // 销毁main函数栈
0x000000000040051f <+25>: retq
此时,我们查看对应寄存器,得到如下结果. 我们可以看到,此时rbp和rsp指向同一个位置,这是在gdb中执行start
命令之后,指令所在的位置。
(gdb) info registers rbp rsp
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe380 0x7fffffffe380
接下来我们将用这个例子来详细探索函数调用过程中的参数传递、控制转移call
、新函数栈的建立、新函数栈的销毁、控制转移的恢复ret
.
2.进入被调用函数的准备
40050a
~40050f
两条指令为函数调用传递参数的过程,我们可以看到是函数参数是倒序传入的:先传入第N个参数,再传入第N-1个参数。(注意,目前64位的机器上,函数参数的传递很少是通过栈进行的。)
接下来的三行汇编,对应的是实参的传递和对foo函数的调用。下面,我们接着执行几条汇编(si 3
),进入foo函数内部,具体执行如下与解释如下.
(gdb) si 3
(gdb) disassemble foo
=> 0x00000000004004e7 <+0>: push %rbp //
0x00000000004004e8 <+1>: mov %rsp,%rbp //新的函数栈的建立
0x00000000004004eb <+4>: sub $0x8,%rsp //申请a和b对应的栈空间
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
call指令
注意当指令走到0x00000000004004e7
的时候(尚未执行),也就是callq
执行刚刚执行完毕,对应寄存器的值如下。
(gdb) info registers rbp rsp
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe378 0x7fffffffe378
和上一次我们查看rsp/rbp
相比,rsp
向低地址移动了8位(有数据入栈),结合上下文,这一寄存器的变化只会出现在callq 0x4004e7
之中。进一步,我们看一下入栈的数据是什么,也就是原来rsp被存放了什么数值。从下面的代码中我们可以看到,原来的rsp上方存放的数值是callq
之后的指令的地址。
(gdb) x/8 0x7fffffffe370
0x7fffffffe370: 0xffffe460 0x00007fff 0x00400519 0x00000000 //注意0x00400519这个值,它是main函数中对应指令的地址
0x7fffffffe380: 0x00000000 0x00000000 0xf7a30445 0x00007fff
总结一下: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
入栈,而此时,栈已经溢出了。
int func(void) {
int b;
b = 1;
return b;
}
int main(){
func();
int a[1024*1024*8];
a[0] = 1;
a[1024*1024*8 -1] = 1;
return 0;
}
函数栈的销毁
函数栈的销毁涉及两方面:栈上空间的释放与rsp和rbp的重置。我们来看看foo函数末尾对应的汇编指令
0x0000000000400504 <+29>: leaveq
继续用si
来逐条执行对应指令,同时查看这条指令前后rpb和rsp的值如下
(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 } //对应retq语句
(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执行完毕之后,寄存器前后的变化。
(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>
这里我们得到几点结论: 1. retq
指令的调用,导致出栈了一个8位的数,这个就是调用者的下一条指令。 2. retq
调用之前,已经处在调用者的栈帧。retq
的调用,仅仅是一个栈上保存的地址存放到rip
寄存器。 3. 对比main
和foo
函数的末尾,我们可以看到有细小的差别:main
函数栈的销毁仅仅有push %rbp
一句,但foo
函数的结尾是leaveq
。导致这一差别的原因是main函数建立调用栈之后,并没有移动rsp
.
总结
函数调用过程中栈的变化如图
我们可以看到子程序调用之前和之后(1&6),函数栈是没有任何变化的,有变化的在于rip
等相关寄存器的值;call
指令执行之后与ret
指令执行之前,函数栈也是相同的,而ret
指令之所以能转交控制权,是因为ip
的值被保存到栈上。 这里我们可以看到,栈不仅仅有保存的局部变量数据,也有对控制转移指令至关重要的寄存器临时存储。一旦栈被写坏,控制转移指令就无法正常执行。下一节,我们将讲解与栈相关的控制转移指令被写坏的场景。