C++函数调用栈过程

函数压栈过程

以如下代码为例来分析C++中函数调用过程中,栈是如何变化的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

int foo(int sp1, int sp2) {
int res = sp1 + sp2;
return res;
}

int main() {
int res = foo(1, 2);

cout << res << endl;

return 0;
}

在Linux进程的地址空间中,栈是位于进程的高地址位置,且栈顶是向下增长的,每次的函数调用都有它自己独立的一个栈帧,用以记录这个函数调用过程中所需的信息,包括函数返回地址、参数、临时变量、保存的上下文等。其中有两个寄存器非常重要,分别espebp,分别记录当前栈帧的栈顶位置和栈底位置,(对于X86-64平台上来说,对应的寄存器则为rsprbp),压栈使esp变小。一个典型的栈帧如下所示:

从参数开始的数据即是当前函数的栈帧,ebp的位置在运行过程中是固定的,而esp始终指向栈顶,在运行过程中是会发生变化的,而参数及返回地址之所以在ebp之上是因为这个两项是由该函数的调用者进行压栈的。ebp指向的old ebp是函数调用者的ebp值,这样该函数退出后通过old ebp即可恢复调用者的栈帧。

将示例代码编译后,通过gdb进行反汇编,首先来看main函数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(gdb) disassemble main
Dump of assembler code for function main():
0x0000000000400830 <+0>: push %rbp
0x0000000000400831 <+1>: mov %rsp,%rbp
0x0000000000400834 <+4>: sub $0x10,%rsp
0x0000000000400838 <+8>: mov $0x2,%esi #将foo第二个参数存入寄存器中
0x000000000040083d <+13>: mov $0x1,%edi #将foo的第一个参数存入寄存器中
0x0000000000400842 <+18>: callq 0x400816 <foo(int, int)> # 调用foo
0x0000000000400847 <+23>: mov %eax,-0x4(%rbp)
0x000000000040084a <+26>: mov -0x4(%rbp),%eax
0x000000000040084d <+29>: mov %eax,%esi
0x000000000040084f <+31>: mov $0x601060,%edi
0x0000000000400854 <+36>: callq 0x4006a0 <_ZNSolsEi@plt>
0x0000000000400859 <+41>: mov $0x400700,%esi
0x000000000040085e <+46>: mov %rax,%rdi
0x0000000000400861 <+49>: callq 0x4006f0 <_ZNSolsEPFRSoS_E@plt>
0x0000000000400866 <+54>: mov $0x0,%eax
0x000000000040086b <+59>: leaveq
0x000000000040086c <+60>: retq

可以看到,参数是在调用foo之前传入的,参数可能是通过压栈的方式传入,也可能是通过寄存器传递,其中call指令用以调用foo函数,该指令也会将函数的返回地址进行压栈。下面来对foo进行反汇编,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
(gdb) disassemble foo
Dump of assembler code for function foo(int, int):
0x0000000000400816 <+0>: push %rbp #将老的rbp压栈
0x0000000000400817 <+1>: mov %rsp,%rbp # 将rbp指向rsp
0x000000000040081a <+4>: mov %edi,-0x14(%rbp) #参数1入栈
0x000000000040081d <+7>: mov %esi,-0x18(%rbp) #参数2入栈
0x0000000000400820 <+10>: mov -0x14(%rbp),%edx #参数1存入edx
0x0000000000400823 <+13>: mov -0x18(%rbp),%eax #参数存2入eax
0x0000000000400826 <+16>: add %edx,%eax #执行加法运算,结果存储在eax中
0x0000000000400828 <+18>: mov %eax,-0x4(%rbp) #将运算结果赋值给result,它的位置为rbp-4
0x000000000040082b <+21>: mov -0x4(%rbp),%eax #将result赋值给eax,eax为函数返回值
0x000000000040082e <+24>: pop %rbp #从栈中恢复保存的rbp,即恢复函数调用者的栈帧
0x000000000040082f <+25>: retq #从栈中取到返回地址,并跳转到该位置

从上述代码也可总结出,c++中一个函数调用(foo)过程是这样的:

  1. 把该函数的所有或者部分参数入栈,或者将参数存入寄存器中
  2. 把当前指令的下一条指令压如栈中
  3. 跳转到函数foo内部执行
  4. 将调用者的ebp值压栈保存(push ebp)
  5. 重置foo函数的栈帧(mov ebp esp)
  6. 执行相关操作
  7. 执行完函数foo之后,恢复调用者的栈帧(pop ebp)
  8. 跳转到下一指令继续执行

函数返回值传递

通过前文的发汇编代码也可看到,函数的返回值是通过eax来返回的,但是eax只能存储4个字节,那么对于大于4个字节的返回值是如何传递的呢,以如下代码为例来看:

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
#include <iostream>
using namespace std;

int test4Bytes() {
return 1;
}

long test8Bytes() {
return 12345678901l;
}

struct node {
long l1;
long l2;
};

node testMoreBytes() {
node n1;
n1.l1 = 1234567889l;
n1.l2 = 1234567889l;
return n1;
}

int main() {
int s1 = test4Bytes();

long s2 = test8Bytes();

node s3 = testMoreBytes();

return 0;
}

main函数的反汇编结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(gdb) disassemble main
Dump of assembler code for function main():
0x000000000040077e <+0>: push %rbp
0x000000000040077f <+1>: mov %rsp,%rbp
0x0000000000400782 <+4>: sub $0xa0,%rsp
0x0000000000400789 <+11>: mov %fs:0x28,%rax
0x0000000000400792 <+20>: mov %rax,-0x8(%rbp)
0x0000000000400796 <+24>: xor %eax,%eax
0x0000000000400798 <+26>: callq 0x400726 <test4Bytes()>
0x000000000040079d <+31>: mov %eax,-0x9c(%rbp)
0x00000000004007a3 <+37>: callq 0x400731 <test8Bytes()>
0x00000000004007a8 <+42>: mov %rax,-0x98(%rbp)
0x00000000004007af <+49>: lea -0x90(%rbp),%rax
0x00000000004007b6 <+56>: mov %rax,%rdi
0x00000000004007b9 <+59>: callq 0x400741 <testMoreBytes()>
0x00000000004007be <+64>: mov $0x0,%eax
0x00000000004007c3 <+69>: mov -0x8(%rbp),%rdx
0x00000000004007c7 <+73>: xor %fs:0x28,%rdx
0x00000000004007d0 <+82>: je 0x4007d7 <main()+89>
0x00000000004007d2 <+84>: callq 0x400610 <__stack_chk_fail@plt>
0x00000000004007d7 <+89>: leaveq
0x00000000004007d8 <+90>: retq

main函数的反汇编结果也可看出,前两个函数是分别是通过eaxrax来返回的。首先来看test4Bytes,如下所示:

1
2
3
4
5
6
7
(gdb) disassemble test4Bytes
Dump of assembler code for function test4Bytes():
0x0000000000400726 <+0>: push %rbp
0x0000000000400727 <+1>: mov %rsp,%rbp
0x000000000040072a <+4>: mov $0x1,%eax
0x000000000040072f <+9>: pop %rbp
0x0000000000400730 <+10>: retq

如前文所述,4个字节的函数返回值是通过eax来传递的。而test8Bytes的反汇编结果如下:

1
2
3
4
5
6
7
(gdb) disassemble test8Bytes
Dump of assembler code for function test8Bytes():
0x0000000000400731 <+0>: push %rbp
0x0000000000400732 <+1>: mov %rsp,%rbp
0x0000000000400735 <+4>: movabs $0x2dfdc1c35,%rax
0x000000000040073f <+14>: pop %rbp
0x0000000000400740 <+15>: retq

可以看出,此时8个字节的返回值是通过rax来返回的,这个对于不同平台的实现来说可能是不一样的,例如在32位的机器上,一般是通过eaxedx组合来返回8个字节的返回值。上述main函数的反汇编代码中有段保护机制,为了简化无关代码的干绕,关闭端保护机制编译如下代码(g++ -fno-stack-protector ):

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
38
39
40
41
42
43
44
45
46
47
#include <iostream>
using namespace std;

struct node {
char buf[64];
};

node testMoreBytes() {
node n1;
n1.buf[0] = 'c';
return n1;
}

int main() {
node s3 = testMoreBytes();

s3.buf[0] = 'a';

return 0;
}

# 相应的反汇编结果分别为:
(gdb) disassemble main
Dump of assembler code for function main:
0x00000000004006e7 <+0>: push %rbp
0x00000000004006e8 <+1>: mov %rsp,%rbp
0x00000000004006eb <+4>: sub $0x40,%rsp
0x00000000004006ef <+8>: lea -0x40(%rbp),%rax
0x00000000004006f3 <+12>: mov %rax,%rdi
0x00000000004006f6 <+15>: callq 0x4006d1 <_Z13testMoreBytesv>
0x00000000004006fb <+20>: movb $0x61,-0x40(%rbp)
0x00000000004006ff <+24>: mov $0x0,%eax
0x0000000000400704 <+29>: leaveq
0x0000000000400705 <+30>: retq
End of assembler dump.
(gdb) disassemble testMoreBytes
Dump of assembler code for function _Z13testMoreBytesv:
0x00000000004006d1 <+0>: push %rbp
0x00000000004006d2 <+1>: mov %rsp,%rbp
0x00000000004006d5 <+4>: mov %rdi,-0x8(%rbp)
0x00000000004006d9 <+8>: mov -0x8(%rbp),%rax
0x00000000004006dd <+12>: movb $0x63,(%rax)
0x00000000004006e0 <+15>: nop
0x00000000004006e1 <+16>: mov -0x8(%rbp),%rax
0x00000000004006e5 <+20>: pop %rbp
0x00000000004006e6 <+21>: retq
End of assembler dump.

main的反汇编结果可以看出,它先是分配了64字节的一个堆栈空间,然后将该地址空间保存在rax中,然后将rax的值传入rdi中,紧接着便调用testMoreBytes函数,显而易见的是它是要将分配的这一段空间传递给testMoreBytes函数。而通过testMoreBytes的反汇编结果可以看出,它显示从rdi中取出这段地址,并将地址赋值给rax,最后再将值拷贝到这段地址空间处,所以此时函数返回值的传递过程是:

  1. main函数会在堆栈中额外开辟一段空间,用以存储返回值
  2. 将上述空间的地址传递给testMoreBytes函数,函数内部将值拷贝到该空间处
  3. main函数通过该端空间来读取返回值