GCC中ret指令后跳转至cold段的异常相关代码疑问
我想验证GCC是否会将异常抛出时执行析构函数的额外代码放入二进制的cold段,以此远离正常执行路径,降低指令缓存压力。为此编写了示例代码,其中GCC无法判断bar是否会抛出异常。
查看汇编输出时,确实看到了独立的buzz(int) [clone .cold.0]:标签,但在它上方发现了一段奇怪的代码片段:
.L9: mov edi, OFFSET FLAT:.LC0 xor eax, eax call printf mov eax, ebx add rsp, 16 imul eax, ebx add eax, ebx pop rbx ret mov rbx, rax jmp .L3 buzz(int) [clone .cold.0]: .L3: mov eax, DWORD PTR [rsp+12] ...
注意到ret指令后跟着一条跳转到cold段的jmp指令,但这段在ret之后的指令理论上永远不会被执行——毕竟ret已经跳回到调用方了。我原本以为是指令对齐的需求,但紧跟跳转到cold段的情况不像是巧合。
想请教:
- 这些指令为何存在?
- 是否与unwind协议要求有关?比如异常unwind表将其标记为跳转目标但未添加标签?
- 如果是这样,为何不直接跳转到cold段?
对应的C++代码如下:
#include <stdio.h> extern int global; struct Foo { Foo(int x) : x(x) {} ~Foo() { if(x % global == 0) { printf("Wow."); } } int x; }; void bar(Foo& f); // 编译器无法看到实现,必须假设可能抛出异常 int buzz(int num) { { Foo foo(3); bar(foo); } return num * num + num; }
解答
这段ret后的指令是GCC为满足DWARF栈展开(unwind)协议要求生成的,本质是为异常处理的栈回溯提供正确的元数据支撑。
1. 指令存在的原因
当bar抛出异常时,栈展开机制需要调用Foo的析构函数。GCC会为正常执行路径和异常路径生成分离的代码:
- 正常路径:执行到
.L9的ret直接返回,对应buzz中Foo对象正常销毁后的逻辑。 - 异常路径:异常发生时,unwind运行时需要找到正确的代码路径执行析构函数,而这段
ret后的mov rbx, rax; jmp .L3就是异常路径的“入口桩”。
DWARF unwind表会标记这段代码的地址作为异常展开时的跳转目标,但不会给它添加汇编标签——因为正常执行永远不会走到这里,只有unwind机制会通过元数据定位到该地址。
2. 为何不直接跳转到cold段
这和GCC的代码生成策略相关:
- 异常路径属于冷路径,GCC会将其放到
.cold段以减少对指令缓存的影响,但unwind协议要求展开时的代码必须能被正确定位。 - 虽然直接在unwind表中记录跳转到
.L3的地址技术上可行,但GCC选择生成这段“冗余”指令,是为了统一异常展开的代码流程——让unwind运行时通过执行这段桩代码进入cold段的析构逻辑,而非直接修改栈帧跳转,这样能简化unwind元数据的生成逻辑。
另外,这段代码不会影响正常执行:ret之后的指令永远不会被CPU执行,仅作为unwind元数据的“锚点”存在,也不会增加指令缓存压力——CPU的指令预取在遇到ret后会停止预取后续指令,且这段代码属于冷段之外的“死代码”,不会被加载到指令缓存中。
3. 与指令对齐无关
虽然指令对齐可能会在函数末尾填充nop,但这段代码是有实际逻辑的mov和jmp,并非填充的空指令,所以和对齐需求无关。
内容的提问来源于stack exchange,提问作者Joseph Garvin

