关于栈
如果你常常编写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前压入栈的参数.
现在栈帧恢复原来的状态, 返回值在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寄存器进行)