关于栈

如果你常常编写C/C++代码, 那么想必三天两头和Segment Fault: Core dumped打照面, 在使用gdb调试的时候使用bt查看调用路径, 其dump出来的就是一层层的调用栈. 现在几乎所有常见的编程语言写出的程序进行编译, 产物反汇编出来都能看出是基于栈进行函数调用的. 如果你足够了解汇编, 那么是可以看着汇编想象出C语言代码的.

经典的进程内存地址空间分布和栈帧

一个经典的进程地址空间会被划分成以下几个区域, 他们会被映射到不同的页上, 赋予不同的权限.

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
全部内存空间:
|------------------|
| Kernel Space | <- 内核空间(通常是1G)
|------------------| <- 0xC0000000
| |
| User Space | <- 用户地址空间(通常有3G)
| |
|------------------| <- 0x00000000

用户空间:
高地址
|------------------|
| Env, Args | <- 环境变量等
|------------------|
| Stack | <- 栈(rw-)
|------------------|
| |
| |
| |
|------------------|
| Shared Libs |
|------------------|
| |
| |
| |
|------------------|
| Heap | <- 堆(rw-)
|------------------|
| BSS | <- BSS段(未初始化数据)(rw-)
|------------------|
| Data | <- 数据段(rw-)
|------------------|
| Text | <- 代码段(r-x)
|------------------|
低地址

本文仅讨论栈. 上图中可知栈在用户区域内存地址最高的区域中, 随函数调用向下增长. 栈帧是用来管理函数调用的内存区域, 一次函数调用会产生一个栈帧, 并在函数返回的时候栈帧被销毁. 栈帧中会储存局部变量以及函数的参数和返回地址, 便于在返回的时候恢复上一级调用者的上下文.

函数调用中栈的行为

下面以全部使用栈传参的方式, 讲解函数调用中栈的行为, 以及参数和局部变量的寻址方式, 假设没有任何的编译优化和栈帧填充.

一般来说, 栈和rsp/esp以及rbp/ebp寄存器有关. 这两个寄存器分别指向当前栈帧的顶部地址和基址, 下面的示意图为了美观, 是倒过来画的.

C语言代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
int add(int a, int b) {
int c = a + b;
return c;
}

int main(int argc, char** argv) {
int x = 114;
int y = 514;
int z = add(x, y);
printf("%d + %d = %d\n", x, y, z);
return 0;
}

在准备调用add函数之前, 栈帧是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+------------------+ <- rsp
| local var z(0) |
+------------------+ <- rbp-12
| local var y(514) |
+------------------+ <- rbp-8
| local var x(114) |
+------------------+ <- rbp-4
| last frame's rbp |
+------------------+ <- rbp
| return address |
+------------------+ <- rbp + 4 (saved eip)
| param argc |
+------------------+
| param argv |
+------------------+

现在将x, y通过栈传递给add函数, 假定从左向右依次进栈传参:

1
2
3
push   DWORD PTR [rbp-4]
push DWORD PTR [rbp-8]
call 0x5655617d <add>

现在栈帧变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+------------------+ <- rsp
| param b = 514 |
+------------------+
| param a = 114 |
+------------------+
| local var z(0) |
+------------------+ <- rbp-12
| local var y(514) |
+------------------+ <- rbp-8
| local var x(114) |
+------------------+ <- rbp-4
| last frame's rbp |
+------------------+ <- rbp
| return address |
+------------------+ <- rbp + 4 (saved eip, return to __libc_start_call_main)
| param argc |
+------------------+
| param argv |
+------------------+

call指令将rip压栈, 并跳转到add函数地址, 开始执行代码.

1
2
3
push rbp
mov rbp, rsp
sub rsp, 0x4

add函数开头先将旧的rbp压栈, 并生成新的栈帧, 此时栈帧上的布局可以画出.

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
+------------------+ <- rsp
| local var c(0) |
+------------------+ <- rbp-4
| last frame's ebp |
+------------------+ <- rbp
| return address |
+------------------+ <- rbp+4
| param b = 514 |
+------------------+ <- rbp+8
| param a = 114 |
+------------------+ <- rbp+12
| local var z(0) |
+------------------+
| local var y(514) |
+------------------+
| local var x(114) |
+------------------+
| last frame's ebp |
+------------------+
| return address |
+------------------+
| param argc |
+------------------+
| param argv |
+------------------+

然后执行add函数, 可以看见, 局部变量全部是rbp加上一个负偏移完成寻址, 而参数则是rbp加上正偏移寻址.

1
2
3
4
5
6
7
mov rcx, [rbp+4]
add rcx, [rcx+8]
mov [rbp-4], rcx
mov [rbp-4], rax ; 将返回值给rax
mov rsp, rbp ; 清理栈帧
pop rbp ; 恢复rbp
ret

执行结束后, 将栈帧清除.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+------------------+ <- rsp
| param b = 514 |
+------------------+
| param a = 114 |
+------------------+
| local var z(0) |
+------------------+
| local var y(514) |
+------------------+
| local var x(114) |
+------------------+
| last frame's ebp |
+------------------+ <- rbp
| return address |
+------------------+
| param argc |
+------------------+
| param argv |
+------------------+

完成调用后, 指令流返回main函数, main函数清理调用add前压入栈的参数.

1
sub rsp, 0x8

现在栈帧恢复原来的状态, 返回值在rax中.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+------------------+ <- rsp
| param b = 514 |
+------------------+
| param a = 114 |
+------------------+
| local var z(0) |
+------------------+
| local var y(514) |
+------------------+
| local var x(114) |
+------------------+
| last frame's ebp |
+------------------+ <- rbp
| return address |
+------------------+
| param argc |
+------------------+
| param argv |
+------------------+

这是最基本的使用栈进行传参的示例, 并未考虑返回大型数据类型(如结构体), 以及编译器生成的栈填充物和栈溢出检测, 而事实上为了优化传参速度, 函数调用约定不只采用栈, 有时也会使用寄存器传参.

调用约定

调用约定指的是函数调用过程中如何储存参数和返回值, 以及如何操作栈的约定, 这是ABI的一部分, 决定了程序接口的兼容性. 事实上, 为了适应各种需求, 并不只有一种调用约定. 调用规范一般包含:

  • 当参数多于一定数量, 参数以什么顺序入栈

  • 函数调用后谁来恢复堆栈

  • 函数的返回值存在于哪里

stdcall

stdcall同时也是pascal的调用约定, 特点是:

  • 全部使用堆栈传参, 参数从右向左入栈

  • 函数自己修改栈帧

  • 函数的修饰名称是函数名前加下划线, 后面用@符号分割, 后面是参数的尺寸, 例如上文add函数, 会变成_add@8

  • 返回值在rax中

由于函数自己清理堆栈, 所以stdcall不允许声明不确定参数个数的函数. stdcall还是wendous api常采用的调用约定.

cdecl

cdecl是C语言的默认调用约定, 特点是:

  • 全部使用堆栈传参, 参数从右向左入栈

  • 由上一层调用者清理堆栈, 而不是函数自身.

  • 返回值在rax中

由于是调用者负责清理堆栈, C语言允许实现函数参数不固定的函数, 如int printf(const char* format, ...);.

Notes

可变参数函数支持条件

若要支持可变参数的函数,则参数应自右向左进栈,并且由主调函数负责清除栈中的参数(参数出栈)

首先,参数按照从右向左的顺序压栈,则参数列表最左边(第一个)的参数最接近栈顶位置。所有参数距离帧基指针的偏移量都是常数,而不必关心已入栈的参数数目。只要不定的参数的数目能根据第一个已明确的参数确定,就可使用不定参数。例如printf()函数,第一个参数即格式化字符串可作为后继参数指示符。通过它们就可得到后续参数的类型和个数,进而知道所有参数的尺寸。当传递的参数过多时,以帧基指针为基准,获取适当数目的参数,其他忽略即可。若函数参数自左向右进栈,则第一个参数距离栈帧指针的偏移量与已入栈的参数数目有关,需要计算所有参数占用的空间后才能精确定位。当实际传入的参数数目与函数期望接受的参数数目不同时,偏移量计算会出错。

其次,调用函数将参数压栈,只有它才知道栈中的参数数目和尺寸,因此调用函数可安全地清栈。而被调函数永远也不能事先知道将要传入函数的参数信息,难以对栈顶指针进行调整。

C++为兼容C,仍然支持函数带有可变的参数。但在C++中更好的选择常常是函数多态。

fastcall

和stdcall差不多, 但是使用rdi, rsi传递左起两个参数, 速度更快.

x64 call(?)

现代编译器编译64位代码使用的默认调用规则

  • 采用rdi, rsi, r8, r9四个寄存器传递前四个参数, 剩下的从右向左入栈

  • 调用者清理堆栈, 因而支持可变参数

  • 返回值位于rax

thiscall

C++为了传入this指针, 需要使用寄存器rcx传递this指针. 其他和x64调用规则一致.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class base {
public:
base(int a, int b): x(a), y(b) {};
int get_sum() {
return x + y;
}
private:
int x;
int y;
};

int main() {
base b(114, 514);
int x = b.get_sum();
return 0;
}

这段c++代码中get_sum函数的反汇编为:

1
2
3
4
5
6
7
8
9
10
Dump of assembler code for function _ZN4base7get_sumEv:
0x00005555555551c0 <+0>: push rbp
0x00005555555551c1 <+1>: mov rbp,rsp
=> 0x00005555555551c4 <+4>: mov QWORD PTR [rbp-0x8],rdi
0x00005555555551c8 <+8>: mov rcx,QWORD PTR [rbp-0x8]
0x00005555555551cc <+12>: mov eax,DWORD PTR [rcx]
0x00005555555551ce <+14>: add eax,DWORD PTR [rcx+0x4]
0x00005555555551d1 <+17>: pop rbp
0x00005555555551d2 <+18>: ret
End of assembler dump.

(b似乎通过栈传递了, 但是对b.x, b.y寻址还是通过rcx寄存器进行)