从汇编角度看C语言函数调用

从汇编角度理解C/C++函数调用,能够加深我们对代码的理解,提升调试能力。本文主要讲函数调用栈的建立和销毁、call与ret指令的本质、栈变量的申请、函数参数-返回地址-ebp在栈上的相对位置。

1.前言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//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),得到如下汇编代码.

1
2
3
4
5
6
7
8
9
10
(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命令之后,指令所在的位置。

1
2
3
(gdb) info registers rbp rsp
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe380 0x7fffffffe380

接下来我们将用这个例子来详细探索函数调用过程中的参数传递、控制转移call、新函数栈的建立、新函数栈的销毁、控制转移的恢复ret.

2.进入被调用函数的准备

40050a~40050f两条指令为函数调用传递参数的过程,我们可以看到是函数参数是倒序传入的:先传入第N个参数,再传入第N-1个参数。(注意,目前64位的机器上,函数参数的传递很少是通过栈进行的。)

接下来的三行汇编,对应的是实参的传递和对foo函数的调用。下面,我们接着执行几条汇编(si 3),进入foo函数内部,具体执行如下与解释如下.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(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执行刚刚执行完毕,对应寄存器的值如下。

1
2
3
4
(gdb) info registers rbp rsp
rbp 0x7fffffffe380 0x7fffffffe380
rsp 0x7fffffffe378 0x7fffffffe378
`

和上一次我们查看rsp/rbp相比,rsp向低地址移动了8位(有数据入栈),结合上下文,这一寄存器的变化只会出现在callq 0x4004e7之中。进一步,我们看一下入栈的数据是什么,也就是原来rsp被存放了什么数值。从下面的代码中我们可以看到,原来的rsp上方存放的数值是callq之后的指令的地址。

1
2
3
(gdb) x/8 0x7fffffffe370
0x7fffffffe370: 0xffffe460 0x00007fff 0x00400519(注意) 0x00000000
0x7fffffffe380: 0x00000000 0x00000000 0xf7a30445 0x00007fff

总结一下:call指令的实际用途:1.push IP 2.通过设置 IP实现指令之间的跳转

3.新函数栈的建立与销毁

函数栈的建立

对应4004e7~4004e8处的汇编指令:重新设置rsprbp.

函数中局部变量的申请与赋值

对应的代码位于4004eb~4004f2. 从这里我们可以看出1.同一个函数内部,栈变量的申请是同时发生的,但是赋值是逐条执行。2.栈变量的申请,仅仅涉及rsp指针的移动,不会导致segment fault,但是变量的读写,会具体访问到对应的内存,将会触发segment fault。这里我们来看一个例子,猜一猜,这段代码会crash到哪一行?答案是会crash在func()这一行。因为数组a对应的空间申请发生在func()之前,而调用func的时候会将IP入栈,而此时,栈已经溢出了。

1
2
3
4
5
6
7
8
9
10
11
12
13
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的值如下

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>

这里我们得到几点结论:

  1. retq指令的调用,导致出栈了一个8位的数,这个就是调用者的下一条指令。
  2. retq调用之前,已经处在调用者的栈帧。retq的调用,仅仅是一个栈上保存的地址存放到rip寄存器。
  3. 对比mainfoo函数的末尾,我们可以看到有细小的差别:main函数栈的销毁仅仅有push %rbp一句,但foo函数的结尾是leaveq。导致这一差别的原因是main函数建立调用栈之后,并没有移动rsp.

总结

函数调用过程中栈的变化如图 栈变化图

我们可以看到子程序调用之前和之后(1&6),函数栈是没有任何变化的,有变化的在于rip等相关寄存器的值;call指令执行之后与ret指令执行之前,函数栈也是相同的,而ret指令之所以能转交控制权,是因为ip的值被保存到栈上。 这里我们可以看到,栈不仅仅有保存的局部变量数据,也有对控制转移指令至关重要的寄存器临时存储。一旦栈被写坏,控制转移指令就无法正常执行。下一节,我们将讲解与栈相关的控制转移指令被写坏的场景。