程序崩溃时 gdb 打出来的调用栈,“栈溢出”,“不要返回局部变量的地址”,“RVO 省掉了一次拷贝”:这些说法背后是同一件事,每调一次函数,栈上就多出一块,用来放返回地址、保存的寄存器和局部变量,这一块叫栈帧(stack frame)。这篇用 GCC 实际生成的汇编,把一次调用在栈上留下了什么一格一格画出来,再看哪些错误会落在这些格子上。栈本身在进程地址空间的哪里、默认多大,见进程地图里的“栈”那一格(速查页有文字版)。
下面的汇编都是 GCC 16.2 按 x86-64 System V 调用约定(Linux 用的就是这一套)编出来的,用的是 AT&T 写法(源操作数在前),去掉了和讲解无关的伪指令。
1. 一次调用在栈上留下什么
1.1 call 和 ret:返回地址压在栈上
call f 做两件事:把下一条指令的地址压到栈上,再跳到 f。ret 做相反的事:从栈顶弹出一个地址,跳回去。被调用的函数并不知道自己是被谁调的,它只认栈顶那 8 个字节,所以返回地址只存在栈上这一处,这一格被写坏,ret 就会跳到别处去,第 5 部分的缓冲区溢出就是冲着这一格来的。
栈从高地址往低地址长。rsp 永远指向栈顶,也就是当前最低的那个已用地址。压栈是先把 rsp 减 8 再写,弹栈是先读再把 rsp 加 8。
1.2 序言和尾声(prologue / epilogue):保存 rbp,挪出局部变量的位置
拿两个函数来看,mid 调 leaf:
int leaf(int a, int b) {
int t = a * b;
return t + 1;
}
int mid(int x) {
int buf[4] = {x, x + 1, x + 2, x + 3};
return leaf(buf[0], buf[3]) + buf[1];
}
不开优化(-O0)时,mid 编出来是这样:
mid(int):
pushq %rbp # 序言:保存调用者的 rbp
movq %rsp, %rbp # rbp 指向这一格,从此按 rbp 找局部变量
subq $32, %rsp # 挪出 32 字节放局部变量
movl %edi, -20(%rbp) # 参数 x 从寄存器存进栈里
movl -20(%rbp), %eax
movl %eax, -16(%rbp) # buf[0] = x
movl -20(%rbp), %eax
addl $1, %eax
movl %eax, -12(%rbp) # buf[1] = x + 1
movl -20(%rbp), %eax
addl $2, %eax
movl %eax, -8(%rbp) # buf[2] = x + 2
movl -20(%rbp), %eax
addl $3, %eax
movl %eax, -4(%rbp) # buf[3] = x + 3
movl -4(%rbp), %edx
movl -16(%rbp), %eax
movl %edx, %esi # 第 2 个参数:buf[3]
movl %eax, %edi # 第 1 个参数:buf[0]
call leaf(int, int)
movl -12(%rbp), %edx
addl %edx, %eax # 返回值在 eax,加上 buf[1]
leave # 尾声:rsp = rbp,再弹出旧的 rbp
ret
开头三条叫序言。push %rbp 把调用者的 rbp 存起来,因为接下来要拿 rbp 当自己的基准;mov %rsp, %rbp 让 rbp 指向刚存下的那一格;sub $32, %rsp 把栈顶往下挪 32 字节,中间这段就是局部变量的地方。之后局部变量都按 rbp 减一个固定偏移来找,buf[0] 在 rbp-16,x 在 rbp-20。结尾的 leave 等于 mov %rbp, %rsp 加 pop %rbp,把栈顶挪回来、把调用者的 rbp 还回去,然后 ret。
在栈上分配局部变量就是一条 sub,释放就是一条 leave,和局部变量有多少个、多大都无关,这是栈比堆分配便宜的原因(堆要在空闲块里找一块合适的,见 new 一次走到哪)。两者访问的都是同一种内存,栈的另一个好处是同一段地址被一层层调用反复使用,几乎总在 L1 缓存里(硬件地图)。
mid 调 leaf、leaf 刚执行完自己的序言时,栈上是这样:
每一格 8 字节,偏移都是相对各自那一帧的 rbp。两个红色的格子是两次 call 压进去的返回地址,蓝色的是两次序言保存的 rbp。每个保存的 rbp 都指向上一帧保存 rbp 的那一格,连成一条链,顺着这条链往上走,每一格上面紧挨着的就是返回地址,调试器就是这样一层层列出“谁调了谁”的(第 4 部分讲没有这条链时怎么办)。
1.3 红区(red zone):叶子函数不用挪 rsp
再看 leaf 的 -O0 汇编:
leaf(int, int):
pushq %rbp
movq %rsp, %rbp
movl %edi, -20(%rbp) # 没有 sub rsp,直接往 rsp 下面写
movl %esi, -24(%rbp)
movl -20(%rbp), %eax
imull -24(%rbp), %eax
movl %eax, -4(%rbp) # t = a * b
movl -4(%rbp), %eax
addl $1, %eax
popq %rbp
ret
它少了 sub $N, %rsp,局部变量直接写在 rsp 下面。平时这样做是危险的:栈顶以下的地方被认为是空的,信号处理函数一来,内核会把现场压在当前 rsp 下面,正好盖掉这些变量。System V 调用约定为此留了一条规矩:rsp 下面 128 字节是红区,内核压信号现场时先跳过这 128 字节,谁也不会动它。
leaf 是叶子函数,它不再调别的函数,没有人会往它的栈顶下面压东西,所以它可以省掉挪 rsp 的那两条指令,直接用红区。红区只对叶子函数有用:一旦要 call,返回地址就会压进红区,盖掉里面的东西。 Linux 内核自己编译时加了 -mno-red-zone,因为中断会直接压在被打断的内核栈上,内核不能跳过红区。
2. 调用约定:参数和返回值放在哪
2.1 寄存器传参:前 6 个整数参数走寄存器
调用者和被调用者必须对“第 1 个参数放在哪”有同样的约定,否则两边各编各的就对不上。x86-64 Linux 用的是 System V ABI 里规定的那一套:
| 放什么 | 放在哪 |
|---|---|
| 第 1–6 个整数或指针参数 | rdi、rsi、rdx、rcx、r8、r9 |
| 第 1–8 个浮点参数 | xmm0–xmm7 |
| 更多的参数 | 从右往左压栈,第 7 个整数参数在 call 前的 rsp 处 |
| 整数返回值 | rax(128 位的再加 rdx) |
| 浮点返回值 | xmm0 |
8 个整数参数时,多出的两个就要走栈:
int many(int a, int b, int c, int d, int e, int f, int g, int h) {
return a + b + c + d + e + f + g + h;
}
-O2 下:
many(int, int, int, int, int, int, int, int):
addl %esi, %edi # a 在 edi,b 在 esi
addl %edx, %edi
addl %ecx, %edi
addl %r8d, %edi
leal (%rdi,%r9), %eax # 前 6 个都在寄存器里
addl 8(%rsp), %eax # g:在返回地址上面那格
addl 16(%rsp), %eax # h
ret
进到 many 时 rsp 指着返回地址,所以第 7 个参数在 8(%rsp),第 8 个在 16(%rsp)。参数先走寄存器,是因为大多数函数参数不超过 6 个,这样一次调用通常完全不碰内存。 寄存器只有 6 个能拿来传整数,传一个大结构体时,按大小和类型要么拆进寄存器,要么整块放到栈上;成员函数的 this 就是第 1 个参数,占 rdi。
2.2 调用者保存和被调用者保存:call 前后哪些寄存器还在
mid 在调 leaf 之后还要用 x + 1,这个值放在哪个寄存器里,才能保证 leaf 回来时它还在?约定把寄存器分成两类:
| 类别 | 寄存器 | 规矩 |
|---|---|---|
| 被调用者保存(callee-saved) | rbx、rbp、r12–r15(还有 rsp) | 被调用的函数要用,就得先存起来、返回前还原 |
| 调用者保存(caller-saved) | rax、rcx、rdx、rsi、rdi、r8–r11,全部 xmm | 被调用的函数随便改;调用者想留住,就自己在 call 前存 |
leaf 放在另一个 .cpp 里、mid 开 -O2 编译:
mid(int):
pushq %rbx # 要用 rbx,先替调用者存起来
leal 3(%rdi), %esi # 第 2 个参数 x + 3
movl %edi, %ebx # x 放进 rbx:leaf 保证不改它
call leaf(int, int) # 第 1 个参数 x 本来就在 edi 里
leal 1(%rbx,%rax), %eax # leaf 的结果 + x + 1
popq %rbx # 还原调用者的 rbx
ret
buf 数组整个被优化掉了,mid 只需要在调用前后留住 x。它把 x 放进 rbx,因为 rbx 是被调用者保存的,leaf 若要用它必须自己存、自己还原,所以 call 回来以后 rbx 里还是 x;代价是 mid 自己也是“被调用者”,它动了 rbx,就得在开头 push、结尾 pop,替它的调用者守住这个规矩。两类寄存器各占一半,是在“调用者每次都存”和“被调用者每次都存”之间折中:短命的临时值放调用者保存的寄存器,跨调用要活下来的值放被调用者保存的寄存器。
2.3 16 字节对齐:call 之前 rsp 必须是 16 的倍数
约定还要求:执行 call 的那一刻,rsp 必须是 16 的倍数。call 再压 8 字节返回地址,所以每个函数刚进来时,rsp 除以 16 余 8。这条规矩是给 SSE 指令留的:movaps 这类指令要求内存地址 16 字节对齐,编译器敢用它们读写栈上的局部变量,前提就是栈是对齐的。
回头看上面两段汇编,凑对齐的痕迹都在:-O0 的 mid 进来时余 8,push %rbp 后变成 0,于是 sub 的是 32 而不是刚好够用的 20;-O2 的 mid 里那条 push %rbx 一举两得,既保存了 rbx,也把 rsp 从余 8 推到了余 0,call leaf 时正好对齐。手写汇编或者自己切栈时最容易忘这一条,忘了不会马上出事,直到某个用了 movaps 的库函数(printf 就常见)在栈上读写时崩掉。
3. 返回一个大对象
3.1 隐藏指针:调用者留位置,被调用者往里写
返回值超过两个寄存器(16 字节),或者类型不能按位拷贝(有自定义的拷贝构造或析构),就没法放在 rax 里带回来。约定的办法是让调用者先在自己的帧里留好一块地方,把这块地方的地址当作一个看不见的第 1 个参数,放在 rdi 里传进去:
struct Big { long long a[8]; }; // 64 字节
Big make(int x) {
Big b{};
b.a[0] = x;
return b;
}
long long use() {
Big b = make(3);
return b.a[0] + b.a[7];
}
-O2 下:
make(int):
pxor %xmm0, %xmm0
movslq %esi, %rsi # x 在 esi:rdi 已经被隐藏指针占了
movq $0, 56(%rdi)
movq %rdi, %rax # 返回时把这个地址放进 rax
movq %rsi, (%rdi) # b.a[0] = x,直接写进调用者的那块地方
movups %xmm0, 8(%rdi)
movups %xmm0, 24(%rdi)
movups %xmm0, 40(%rdi)
ret
use():
subq $72, %rsp # 64 字节放 b,再加 8 字节凑对齐
movl $3, %esi # x 成了第 2 个参数
movq %rsp, %rdi # 隐藏的第 1 个参数:b 的地址
call make(int)
movq 56(%rsp), %rax # b.a[7]
addq (%rsp), %rax # + b.a[0]
addq $72, %rsp
ret
use 的 x 本来该是第 1 个参数,现在挪到了 esi,因为 rdi 被隐藏指针占了。make 里那个 b 根本没有自己的位置,它的每次写入都直接落在 use 帧里的 b 上。返回大对象并不意味着“先在被调用者里造一个、再拷给调用者”:调用约定本来就是让被调用者直接写调用者留好的那块内存。
3.2 RVO 和 NRVO:直接在调用者的位置上构造
3.1 的 make 返回的是一个具名的局部变量 b,编译器把 b 直接放在调用者给的地址上,于是 return b 时什么都不用做。这叫具名返回值优化(NRVO)。返回一个临时对象,比如 return Big{};,直接在那块地址上构造它叫返回值优化(RVO)。两者在 C++ 标准里的地位不一样:
#include <cstdio>
#include <utility>
struct Noisy {
Noisy() {}
Noisy(const Noisy&) { std::puts(" 拷贝"); }
Noisy(Noisy&&) noexcept { std::puts(" 移动"); }
};
Noisy f1() { return Noisy{}; } // 返回纯右值:C++17 起保证不拷不移
Noisy f2() { Noisy n; return n; } // 返回具名局部变量:NRVO,允许省、不保证
Noisy f3() { Noisy n; return std::move(n); } // 自己写 std::move:NRVO 做不成了
Noisy f4(bool c) { // 两个分支返回不同的对象:NRVO 做不成
Noisy a, b;
if (c) return a;
return b; // 但不会拷贝:返回局部变量时自动当右值,走移动
}
int main() {
std::puts("f1:"); Noisy x1 = f1();
std::puts("f2:"); Noisy x2 = f2();
std::puts("f3:"); Noisy x3 = f3();
std::puts("f4:"); Noisy x4 = f4(true);
}
GCC -std=c++17,-O0 和 -O2 输出一样:
f1:
f2:
f3:
移动
f4:
移动
f1 是 C++17 规定的:用纯右值初始化同类型的对象,根本不存在中间那个临时对象,所以就算类型不能拷贝也不能移动,f1 照样能编译。f2 的 NRVO 标准允许做、不要求做,GCC 在所有 return 都返回同一个局部变量时会做,连 -O0 也做。f3 多写的 std::move(n) 让返回表达式不再是“一个局部变量的名字”,NRVO 的条件就不满足了,只能老老实实移动一次,GCC 会用 -Wpessimizing-move 警告这一行。f4 的两个分支返回不同的对象,编译器没法让 a 和 b 同时住在调用者给的那一块地址上,NRVO 做不成;但返回一个局部变量时,语言规定先把它当右值试一遍,所以走的是移动,不是拷贝。
所以写法上的结论很简单:直接 return 局部变量,不要加 std::move,更不要为了“省拷贝”改成返回引用:返回局部变量的引用,引用的是一块函数返回后就作废的栈内存(5.2)。
4. 没有帧指针时,怎么找回调用栈
4.1 -O2 默认省掉帧指针
对比 1.2 的 -O0 和 2.2 的 -O2,-O2 的 mid 没有 push %rbp; mov %rsp, %rbp。GCC 在 x86-64 上从 -O1 起默认打开 -fomit-frame-pointer:局部变量直接按 rsp 加偏移找,rbp 省下来当普通寄存器用,每次调用也少两条指令。代价是 1.2 那条 rbp 链断了,调试器没法再顺着它一层层往上走。想留着它,编译时加 -fno-omit-frame-pointer;有些发行版(比如 Fedora 38 起、Ubuntu 24.04 起)为了让性能分析工具好用,整个系统的软件包都是这样编的。
4.2 .eh_frame 和 CFI:按指令地址查“上一帧在哪”
没有帧指针也能打出调用栈,靠的是编译器另外生成的一张表。把 2.2 的 mid 带上这张表的伪指令再编一次:
mid(int):
.cfi_startproc
pushq %rbx
.cfi_def_cfa_offset 16 # 从这里起:CFA = rsp + 16
.cfi_offset 3, -16 # rbx(3 号寄存器)存在 CFA − 16
leal 3(%rdi), %esi
movl %edi, %ebx
call leaf(int, int)
leal 1(%rbx,%rax), %eax
popq %rbx
.cfi_def_cfa_offset 8 # 从这里起:CFA = rsp + 8
ret
.cfi_endproc
CFA(canonical frame address)是这一帧的基准点,定义成"调用这个函数的那条 call 执行之前的 rsp",所以返回地址永远在 CFA − 8。.cfi_* 这些伪指令告诉汇编器:在函数里的每一段指令范围内,CFA 等于 rsp 加多少,哪些被调用者保存的寄存器存在 CFA 减多少的地方。汇编器把它们整理成 .eh_frame 段里的一张表,跟着程序一起加载。
回溯时,调试器拿当前的 rip 去表里查到这一段的规则,算出 CFA,从 CFA − 8 读出返回地址,就知道上一层停在哪条指令;再把 rsp 设成 CFA、按规则还原 rbx 这些寄存器,拿上一层的 rip 继续查。有了这张表,回溯就不需要 rbp 链:每一帧的大小和保存位置,编译时就已经写进了表里。 C++ 抛异常时一层层退栈、调析构函数,用的也是这张表,这就是它的名字里有 eh(exception handling)的原因;所以就算是 C 程序,x86-64 Linux 上默认也会生成它。
自己想看这张表,readelf --debug-dump=frames 程序 会把每个函数的规则打出来。perf record --call-graph=dwarf 用的就是它,而默认的 --call-graph=fp 只会顺着 rbp 链走,程序没有帧指针时,后者打出的调用栈是断的。
5. 栈上会出的错
5.1 栈溢出:递归太深、局部数组太大
主线程的栈有上限,默认是 ulimit -s 给的 8 MiB。这 8 MiB 并不是一开始就分好的:内核只先映射一小段,栈往下长、碰到还没映射的页时触发缺页,内核把栈的映射往下扩,直到碰上限。再往下,内核还在栈和下面的映射之间留了一段永远不映射的空隙(保护间隔,默认 256 页,也就是 1 MiB),写到那里就是 SIGSEGV。
int depth(int n) {
volatile char pad[1024]; // volatile:不让编译器把数组优化掉
pad[0] = static_cast<char>(n);
if (n == 0) return pad[0];
return depth(n - 1) + pad[0]; // 不是尾调用:每一层的帧都留着
}
每层帧一千多字节,8 MiB 大约够几千层,depth(100000) 必然越过上限。局部数组太大是同一回事:一个函数里写 char buf[16 << 20];,这一帧就要 16 MiB,第一次碰到它就越界。栈的大小是 MiB 级的,适合放小而短命的东西;大块数据、深度不可控的递归,应该放到堆上或者改成循环。
线程的栈不在这里,它是 glibc 用 mmap 在 mmap 区分出来的一块,大小在创建时就定死、不会长,最低处留一页不可访问的保护页。glibc 默认取的也是 ulimit -s 的值,所以通常同样是 8 MiB;musl 这类 C 库默认只有 128 KiB,从 glibc 换过去时深递归最容易出事。std::thread 没法指定栈大小,要改得用 pthread:
#include <pthread.h>
void* work(void*) { return nullptr; }
int main() {
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 64 << 20); // 64 MiB
pthread_t t;
pthread_create(&t, &attr, work, nullptr);
pthread_join(t, nullptr);
pthread_attr_destroy(&attr);
}
保护页只有一页时还有个漏洞:一帧比保护页还大,第一次写入就可能直接跳过保护页,落进下面别的映射里,不崩溃但默默写坏了别人的数据。GCC 的 -fstack-clash-protection 会在分配大帧时每隔一页先探一下,保证一定先碰到保护页;不少发行版的 GCC 默认就开着它。
5.2 返回局部变量的地址:指向一块马上会被覆盖的格子
int* bad() {
int x = 42;
return &x;
}
x 住在 bad 的帧里。bad 一返回,rsp 挪回去,那一格就成了栈顶以下的空地,下一次调用任何函数,新的帧就会盖在同一个位置上。所以这个指针“有时候还能读到 42”,完全取决于中间有没有别的调用。GCC 对这种写法会警告 -Wreturn-local-addr,而且干脆把返回值编成空指针:上面的函数 -O2 下编出来是 xorl %eax, %eax 加 ret,-O0 下是 movl $0, %eax,解引用时马上崩,比读到一个看起来正常的值好查。返回局部变量的引用、lambda 按引用捕获了局部变量再被带出函数,都是同一类问题,只是编译器不一定看得出来,要靠 ASan 的 detect_stack_use_after_return 查。
5.3 缓冲区溢出和栈金丝雀(stack canary):写过头会盖到返回地址
局部数组和返回地址在同一个帧里,而且数组在下面、返回地址在上面。往数组里写是从低地址往高地址写的,写过了头就会依次盖掉上面的东西:
#include <cstring>
#include <cstdio>
void greet(const char* name) {
char buf[16];
std::strcpy(buf, name); // name 超过 15 个字符,就写出 buf 的范围
std::printf("hello %s\n", buf);
}
name 超过 15 个字符,strcpy 就会接着往上写,先盖掉 buf 上面的东西,再盖掉保存的 rbp 和返回地址。函数照常运行到 ret,从被盖掉的那一格取出地址跳过去:如果这串字符是精心构造的,程序就跳到了攻击者想让它去的地方,这就是经典的栈溢出攻击。
栈金丝雀(stack canary)是编译器加的一道检查:在局部数组和保存的寄存器之间放一个随机值,进函数时写进去,ret 之前读出来和原值比,变了就说明有人从下面写穿了,直接调 __stack_chk_fail 打印 *** stack smashing detected *** 并终止程序。x86-64 Linux 上这个随机值每个进程一个,放在线程局部存储里,代码读的是 %fs:0x28;它的最低字节固定是 0,strcpy 这类以 0 结尾的字符串拷贝没法把它原样写回去。金丝雀不能防止写坏,它只是保证写坏了返回地址的函数走不到 ret 那一步。 GCC 的 -fstack-protector-strong 给带局部数组、或者取过局部变量地址的函数加这道检查,Ubuntu 等发行版的 GCC 默认就打开它。
金丝雀之外还有几层:栈所在的页不可执行(NX),攻击者就没法把代码放在栈上直接跳过去;地址空间布局随机化(ASLR)让栈、堆、共享库每次运行的地址都不一样,攻击者很难事先知道该往返回地址里填什么。根本的办法还是别让写入越界:用 std::string、snprintf 这类带长度的接口,开发时开 ASan,越界的第一个字节就会报出来。
6. 自己看栈帧
6.1 读汇编:-S 和 objdump
g++ -O0 -S -fno-asynchronous-unwind-tables -o - frame.cpp | c++filt # 不优化:能看到完整的序言、尾声
g++ -O2 -S -fno-asynchronous-unwind-tables -o - frame.cpp | c++filt # 优化后:帧指针省掉,局部变量多半在寄存器里
objdump -d --no-show-raw-insn -C ./a.out | less # 看已经编好的程序
-fno-asynchronous-unwind-tables 去掉 4.2 那些 .cfi_* 伪指令,汇编干净一些;想看 CFI 时把它去掉。c++filt 把 _Z4leafii 这样的修饰名还原成 leaf(int, int)。读的时候先找三样东西:开头动了哪些寄存器和 rsp(帧有多大、存了谁),call 前参数放进了哪些寄存器,ret 前 rax 里是什么。
6.2 gdb:bt、frame、info frame、x
带 -g 编译,在 gdb 里停下来以后:
| 命令 | 看什么 |
|---|---|
bt | 调用栈,每层一行:第几帧、函数名、参数、源码行;bt full 连局部变量一起打 |
frame 1、up、down | 切到某一层,之后 print 看的是那一层的局部变量 |
info frame | 这一层的帧在哪(frame at 就是 4.2 的 CFA)、保存的 rip(返回地址)、被调用者保存的寄存器存在哪 |
info registers rsp rbp rip | 三个寄存器现在的值 |
x/8gx $rsp | 从栈顶起按 8 字节一格打 8 格,对照第 1 部分的图能认出返回地址和保存的 rbp |
disassemble | 当前函数的汇编,=> 标着停在哪一条 |
x/8gx $rsp 打出来的那些数里,落在程序代码段地址范围内的通常就是返回地址,info symbol 地址 能告诉你它在哪个函数里。栈被写坏时 bt 往往打出一串 ??,这时候手动看 x/32gx $rsp 找残存的返回地址,是定位从哪一层开始坏的办法。
6.3 core dump:崩溃以后再看调用栈
程序在线上崩溃时没有 gdb 挂着,要靠 core dump:内核在进程因为 SIGSEGV、SIGABRT 这类信号死掉时,把它的内存和寄存器写成一个文件。
ulimit -c unlimited # 允许生成 core(默认常是 0)
cat /proc/sys/kernel/core_pattern # core 写到哪:一个路径模板,或者交给 systemd-coredump
coredumpctl list # 交给 systemd-coredump 时:列出最近的 core
coredumpctl gdb # 直接用 gdb 打开最近那个
gdb ./prog core.12345 # core 是普通文件时
打开以后和 6.1 一样用 bt、frame、info frame,看的是进程死掉那一刻的栈。栈溢出死掉的进程,bt 会打出成千上万层同一个函数,用 bt -10 看最外面 10 层,就能找到递归是从哪里开始的。core 里只有内存,没有符号;拿来分析的那份程序必须和崩溃的是同一次编译出来的,带着 -g 的符号(或者单独保存的调试信息文件)才能对上源码行。