栈展开(Stack Unwinding)是理解 C++ 异常处理、RAII 资源管理、Linux 调用栈回溯、程序崩溃诊断以及编译器运行时机制的重要基础。
一、栈展开
1.1 栈展开定义
栈展开(Stack Unwinding)在 C++ 异常处理语境下定义:
当异常从抛出位置向匹配的异常处理器传播时,程序按照规定的顺序销毁已经完成构造的自动存储期对象,这个过程称为栈展开。
在系统编程领域,Stack Unwinding 有一个更广泛的含义:根据当前执行上下文和调用约定,逐层恢复或重建调用者栈帧信息的过程。
因此,需要区分两个概念:
| 类型 |
主要作用 |
是否执行析构函数 |
| 异常栈展开(Exception Unwinding) |
异常传播、清理对象、转移控制流 |
正常匹配并完成展开时,会执行应执行的析构函数 |
| 调用栈回溯(Stack Walking / Backtracing) |
重建函数调用链、定位问题 |
通常不会 |
例如,C++ 的 throw 属于第一类;GDB 的 bt、Linux 的调用栈采样主要属于第二类。两者能够共享部分底层栈帧恢复信息,但目的和运行行为完全不同。
1.2 从一个函数调用链开始理解
假设程序具有以下函数调用关系:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
void C() {
throw std::runtime_error("error");
}
void B() {
C();
}
void A() {
B();
}
int main() {
try {
A();
} catch (const std::exception& e) {
// 处理异常
}
}
|
当异常在 C() 中抛出时,异常处理机制需要从当前执行位置沿调用链查找处理器。
异常传播与栈展开方向:向下代表正常函数调用的深入方向;向上代表异常向调用者传播的方向。示意图不代表真实内存地址布局。
整体过程:
- C() 创建并抛出异常对象。
- 异常运行时查找能够处理该异常的处理器。
- 确定 main() 中的 catch 可以匹配异常。
- 在异常展开阶段,依次清理 C()、B()、A() 及相关作用域中需要销毁的对象。
- 转移控制权至 main() 对应的异常处理代码。
注意:异常不会让已经退出的函数再从调用点向下执行。例如,B() 中原本写在 C() 后面的普通语句不会继续执行。只有异常清理代码和相关析构函数会按照规则运行。
1.3 栈展开不等于简单移动栈指针
一个常见误解是:栈展开就是不断调整 SP(Stack Pointer),把函数栈帧弹出去。
这只描述了部分底层操作,并不完整。对于 C++ 异常处理,还需要:
- 判断有没有匹配的 catch。
- 确定当前执行路径中哪些对象已经完成构造。
- 按对象生命周期规则调用析构函数。
- 恢复必要的机器寄存器状态。
- 将控制流转移至编译器生成的异常处理代码。
所以,栈展开是编译器和运行时共同完成的控制流与对象生命周期管理过程,而不仅是释放一段栈内存。
二、函数调用栈
2.1 函数调用栈(Call Stack)
一个线程执行函数时,通常会为函数调用维护相关的执行状态,这些状态构成调用栈。典型信息包括:
- 函数返回地址
- 保存的寄存器
- 部分局部变量和临时数据
- 栈上传递的参数
- 为满足 ABI 要求分配的栈空间
这些信息构成一个逻辑上的栈帧(Stack Frame)。
考虑如下代码:
|
1
2
3
4
5
6
7
8
9
|
void foo(int x) {
int a = x + 10;
}
void bar() {
foo(5);
}
int main() {
bar();
}
|
执行到 foo() 时,在典型的 x86-64 Linux 实现中,可以将调用关系抽象为调用栈结构。
真实布局取决于体系结构、调用约定与编译器优化。某些函数可能没有独立的物理栈帧。
在典型的 x86-64 平台:
- RSP(Stack Pointer):表示当前栈顶位置。
- RBP(Frame Pointer):在保留帧指针时,可作为当前栈帧的稳定参考位置。
- RIP(Instruction Pointer):表示指令执行位置。
- call:执行函数调用并保存返回地址。
- ret:使用返回地址恢复执行位置。
2.2 一个典型的函数序言与尾声
在保留帧指针的情况下,常见的函数序言(Prologue)形式如下:
|
1
2
3
|
push rbp
mov rbp, rsp
sub rsp, 32
|
含义是:
- 保存调用者的 RBP。
- 建立当前函数的帧指针。
- 为当前函数保留栈空间。
函数尾声(Epilogue)可能是:
其中 leave 在典型情况下相当于:
正常函数返回时:
|
1
2
3
4
5
6
7
8
9
|
foo()
│
├─ 执行必要的局部对象析构
├─ 恢复保存的寄存器
├─ 恢复栈指针
└─ ret
│
▼
bar() 继续执行
|
注意,此处的对象析构是 C++ 正常退出作用域时的生命周期行为。正常函数返回虽然也会释放当前栈帧,但通常不称为 C++ 异常栈展开。
2.3 异常与正常返回
| 比较维度 |
正常函数返回 |
C++ 异常栈展开 |
| 控制流 |
返回直接调用者 |
寻找匹配异常处理器 |
| 经过的函数 |
通常一层 |
可能跨越多层 |
| 局部对象析构 |
正常作用域退出时执行 |
展开经过的作用域时执行 |
| 返回地址 |
按通常的返回路径使用 |
通过展开信息和异常处理代码恢复控制流 |
| 额外运行时操作 |
通常较少 |
异常搜索、类型匹配、清理与上下文恢复 |
一次异常传播可以跨越多个函数,但不意味着 CPU 必须逐个执行这些函数的普通返回指令。在常见的表驱动异常处理模型中,运行时能够利用展开信息恢复调用链,并通过专门的代码完成清理和控制流转移。
三、C++ 异常栈展开的执行过程
前面讨论了调用栈的基本结构,接下来通过实际代码观察异常如何触发跨函数的栈展开。
3.1 示例
|
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
|
#include <iostream>
#include <stdexcept>
struct Guard {
const char* name;
explicit Guard(const char* n) : name(n) {
std::cout << "+ " << name << '\n';
}
~Guard() noexcept {
std::cout << "- " << name << '\n';
}
};
void leaf() {
Guard g("leaf");
throw std::runtime_error("boom");
}
void middle() {
Guard g("middle");
leaf();
}
void outer() {
Guard g("outer");
middle();
}
int main() {
Guard g("main");
try {
Guard t("try");
outer();
}
catch (const std::exception& e) {
std::cout << "catch " << e.what() << '\n';
}
}
|
编译命令:
|
1
2
3
4
|
g++ -std=c++17 -O0 -g \
-fno-omit-frame-pointer \
unwind.cpp -o unwind
./unwind
|
实际运行输出:
|
1
2
3
4
5
6
7
8
9
10
11
|
+ main
+ try
+ outer
+ middle
+ leaf
- leaf
- middle
- outer
- try
catch boom
- main
|
3.2 执行顺序
对象构造与异常清理的时间线:
正常执行:依次构造
异常展开:反向析构
这反映了 C++ 一个非常重要的规则:异常传播过程中,需要销毁的自动存储期对象按照完成构造的相反顺序销毁。
不过,这句话也有边界。main() 中的 Guard g("main") 没有在进入 catch 前析构,因为它的生命周期仍然覆盖 catch 所在的位置。直到 main() 正常退出,其析构函数才执行。
这说明:栈展开不是无条件销毁整条调用链上的所有局部对象,而是根据异常处理器的位置和对象的实际生命周期,销毁已经离开其作用域的对象。
3.3 异常传播与控制流转移
当 leaf() 抛出异常时,程序不会执行后续的普通代码:
|
1
2
3
4
5
6
|
void middle() {
Guard g("middle");
leaf();
// 异常传播时不会执行这里的普通代码
std::cout << "after leaf\n";
}
|
但是 g 的析构函数会被执行。可以把该行为理解为编译器提供了两条逻辑路径:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
正常路径:
leaf() 返回
↓
执行后续代码
↓
离开作用域
↓
执行析构函数
异常路径:
leaf() 抛异常
↓
执行必要的析构函数
↓
继续向上层传播异常
|
两条路径可能执行相同的析构函数,但控制流完全不同。
四、栈展开与 RAII:C++ 资源安全的基础
4.1 栈展开与析构函数
假设一个函数申请了动态内存:
|
1
2
3
4
5
6
|
void process() {
int* data = new int[1024];
// 这里可能抛出异常
do_something();
delete[] data;
}
|
如果 do_something() 抛出异常:
|
1
2
3
4
5
6
7
8
9
|
new[] 成功
↓
do_something()
↓
throw
↓
process() 异常退出
↓
delete[] 未执行
|
这将导致动态内存泄漏。原因并不是栈展开没有执行,而是:栈展开并不知道一个普通指针所指向的动态内存需要由谁释放。普通指针是一个对象,但指针对象自身的销毁不会自动释放它指向的堆内存。
4.2 RAII 如何解决资源泄漏?
RAII(Resource Acquisition Is Initialization)将资源所有权绑定到对象生命周期上。
|
1
2
3
4
5
|
#include <memory>
void process() {
auto data = std::make_unique<int[]>(1024);
do_something();
}
|
栈上对象 std::unique_ptr 保存指针并拥有资源所有权,堆上动态内存由 unique_ptr 管理。栈展开 → unique_ptr 析构 → delete[] 释放资源,无需显式编写异常清理路径。
此时无论 do_something() 正常返回,还是抛出一个最终能被匹配处理的异常,只要 process() 的作用域按正常的 C++ 生命周期规则退出,unique_ptr 的析构函数就会释放它持有的内存。
这就是 RAII 能够实现异常安全的根本原因。它利用了 C++ 对象生命周期和栈展开的保证,让资源释放不再依赖业务代码是否执行到某条语句。
4.3 构造函数执行到一半抛出异常怎么办?
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
struct A {
A() { std::cout << "A constructed\n"; }
~A() { std::cout << "A destroyed\n"; }
};
struct B {
B() {
std::cout << "B constructing\n";
throw std::runtime_error("failed");
}
~B() {
std::cout << "B destroyed\n";
}
};
struct Object {
A a;
B b;
~Object() {
std::cout << "Object destroyed\n";
}
};
|
执行:
|
1
2
3
4
5
|
try {
Object obj;
} catch (...) {
std::cout << "caught\n";
}
|
结果为:
|
1
2
3
4
|
A constructed
B constructing
A destroyed
caught
|
注意两个现象:
第一,Object::~Object() 不会执行。因为 Object 没有完成构造。
第二,已经完成构造的成员 a 会被销毁。
C++ 会清理那些已经完成初始化、需要销毁的子对象,而不会调用尚未完成构造的对象的完整析构函数。这项规则直接来自 C++ 标准草案 [except.ctor]。
即便构造一个包含多个资源的对象时中途失败,已完成构造的 RAII 成员仍然可以负责自己的资源清理。
五、异常栈展开的底层实现
在 Linux GCC/Clang 常见的 Itanium C++ ABI 异常处理体系下,异常栈展开主要依赖两个阶段:
- Phase 1:Search Phase(搜索阶段):从抛出点沿调用链查找能够匹配该异常的处理器,确定目标栈帧。此阶段不实际销毁这些栈帧中的对象。
- Phase 2:Cleanup Phase(清理阶段):从抛出位置重新展开至目标处理器,执行需要的清理代码、析构对象并恢复执行上下文,最后进入处理器。
说明:C++ 标准规定的是异常传播和对象销毁的语义,而两阶段展开是 Itanium C++ ABI 对实现机制的规定,并非所有平台都必须采用完全相同的底层架构。
5.1 抛出异常:__cxa_throw
对于常见的 GCC/Clang + Itanium ABI 实现,抛出一个 C++ 异常通常涉及以下流程:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
throw exception
│
▼
__cxa_allocate_exception
│
▼
构造异常对象
│
▼
__cxa_throw
│
▼
_Unwind_RaiseException
│
▼
开始两阶段异常展开
|
异常对象通常不是简单地保存在抛出函数的普通局部栈帧里。它必须能够在原始抛出函数的栈帧失效之后继续存活。
因此,常见的 Itanium C++ ABI 实现会为异常对象及其运行时元数据分配独立的存储空间。GCC异常对象包含类似 __cxa_exception 的头部信息;运行时通常尝试通过 malloc 分配,并提供紧急缓冲机制。
|
1
2
3
4
5
|
异常对象
├── 异常类型相关信息
├── 异常对象的生命周期信息
├── 清理与处理所需的运行时信息
└── 实际异常数据
|
异常对象的具体布局属于 ABI 和运行时实现细节。
5.2 Phase 1:搜索异常处理器
考虑下面的函数调用链:
|
1
2
3
4
5
|
main()
└── A()
└── B()
└── C()
└── throw
|
如果 main() 里有匹配的 catch,第一阶段会逻辑上沿着当前调用链查找:
C:是否存在匹配的 catch? → 否
B:是否存在匹配的 catch? → 否
A:是否存在匹配的 catch? → 否
main:是否存在匹配的 catch? → 是
找到目标后,搜索阶段结束。在这个阶段:
- 不执行 C()、B()、A() 的析构清理。
- 不执行目标 catch。
- 不真正弹出并销毁这些栈帧。
- 通过逐帧恢复所需的逻辑上下文查找处理器。
ABI 使用 _UA_SEARCH_PHASE 向语言相关的 Personality Routine 表示当前处于搜索阶段。如果找到了处理器,它返回 _URC_HANDLER_FOUND。
5.3 Phase 2:执行清理并转移控制
确定最终处理器后,运行时进入第二阶段。
|
1
2
3
4
5
6
7
8
9
10
11
12
|
C()
├── 执行 C 的清理代码
└── 继续展开
B()
├── 执行 B 的清理代码
└── 继续展开
A()
├── 执行 A 的清理代码
└── 继续展开
main()
├── 执行 try 作用域所需的清理
└── 转移至匹配的 catch
|
对每个需要清理的栈帧,运行时可能通过 Landing Pad(异常着陆点)执行析构等清理代码。如果当前栈帧仅进行了清理,而异常仍然需要传播,代码可以通过 _Unwind_Resume 继续展开。
注意,_Unwind_Resume 与 C++ 的 throw; 重新抛出异常并不是一回事。前者用于继续尚未结束的展开过程;后者在异常处理器内重新抛出异常,通常需要重新进行处理器搜索。
5.4 Personality Routine:异常处理规则
在 Itanium C++ ABI 中,底层展开器负责识别和恢复调用栈结构,但不应把 C++ 的类型匹配和对象析构规则硬编码进去。因此引入 Personality Routine(语言相关处理例程)。
在 GNU C++ 异常处理环境中,常见的实现符号为:
它负责结合异常对象和当前函数的语言相关处理数据,决定:
- 当前帧是否具有匹配的异常处理器。
- 是否需要执行清理操作。
- 应该选择哪个 Landing Pad。
- 应该继续展开还是安装新的执行上下文。
| 组件 |
职责 |
| _Unwind_RaiseException |
启动异常展开 |
| Unwinder |
恢复调用帧、遍历展开信息 |
| __gxx_personality_v0 |
按 C++ 规则进行处理器匹配及清理决策 |
| Landing Pad |
执行编译器生成的异常清理或处理代码 |
| _Unwind_Resume |
清理完成后继续原来的异常展开 |
| __cxa_begin_catch / __cxa_end_catch |
管理进入、退出 C++ 异常处理器的运行时状态 |
六、编译器如何知道应该展开哪个栈帧?
这就涉及栈展开的底层数据结构:Unwind Information(展开信息)。
6.1 为什么不能单纯依靠 RBP?
我们前面提到,保留帧指针时,编译器可以生成类似下面的代码:
|
1
2
|
push rbp
mov rbp, rsp
|
在一种典型布局下,保存的 RBP 可以用来查找调用者栈帧。但现代编译器经常会优化掉帧指针。例如 GCC 的:
允许编译器在不需要帧指针的函数中省略帧指针维护操作。带来的收益包括减少指令和释放可供其他用途使用的寄存器。
因此,不能假设所有函数都存在稳定的 RBP 链。而且,即使指定:
|
1
|
-fno-omit-frame-pointer
|
GCC 也不保证每个函数都保留帧指针,部分架构的叶子函数仍可能省略。
6.2 DWARF CFI:描述如何恢复调用帧
DWARF(Debugging With Attributed Record Formats)是一套跨平台调试信息格式。
Call Frame Information(CFI)负责描述如何恢复调用帧相关的寄存器状态。CFI 使展开器不必假设每个函数都有固定的帧指针链,而可以根据当前位置对应的规则计算调用者的状态。
CFA(Canonical Frame Address)用于描述调用帧的一个规范化参考地址。在典型的 x86-64 调用约定中,可以通过 CFA 规则定位保存的返回地址及相关寄存器状态。
例如,一组简化的规则:
|
1
2
|
CFA = RSP + 8
Return Address = *(CFA - 8)
|
表示:
- 当前位置的 CFA 使用 RSP + 8 计算。
- 调用者的返回地址保存在 CFA - 8 对应的位置。
这只是特定执行位置的一组示例规则,不能将其理解为所有函数始终使用 RSP + 8。真实规则会随着函数序言、栈空间调整和执行位置而变化。
DWARF 标准由 DWARF Debugging Information Format Committee 维护,包含 CFI 指令以及 CFA 定义。
6.3 CIE 与 FDE
CFI 中有两个关键数据结构:
- CIE(Common Information Entry):描述一组栈展开规则所共享的信息,例如代码对齐、数据对齐、返回地址寄存器及初始规则。
- FDE(Frame Description Entry):对应特定代码地址范围,提供该范围对应的 CFA / 寄存器规则。
FDE 引用相关 CIE,为特定代码地址范围提供展开规则。当运行时需要恢复某个栈帧时,可以按照如下流程执行:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
当前 PC(指令位置)
│
▼
查找对应代码范围的 FDE
│
▼
获取 FDE 关联的 CIE
│
▼
解释当前 PC 对应的 CFI 规则
│
▼
计算 CFA / 恢复必要寄存器
│
▼
定位调用者执行上下文
|
这就是表驱动展开器能够恢复调用栈的重要基础。
6.4 ELF 中的重要节区
在 Linux ELF 程序中,常见的相关节区包括:
| 节区 |
主要用途 |
| .eh_frame |
保存运行时调用帧展开信息 |
| .eh_frame_hdr |
提供加速 .eh_frame 查找的索引等信息 |
| .gcc_except_table |
保存语言相关异常处理信息,常包含 LSDA |
| .debug_frame |
DWARF 调试用途的调用帧信息(如果生成) |
| .debug_info |
类型、变量、函数等调试信息 |
.eh_frame 主要回答“如何恢复调用者的栈帧”,而 .gcc_except_table 中的语言相关数据主要回答“异常在这个位置应该如何处理”。
6.5 LSDA 与 Landing Pad
LSDA(Language-Specific Data Area)是语言相关的异常处理数据。在常见的 C++ Itanium ABI 实现中,它能够帮助运行时根据抛出异常的位置、异常类型和关联处理动作,判断是否需要清理,以及对应的 Landing Pad。
编译器会生成处理异常所需的代码和元数据。运行时并不是在异常抛出后才去解析 C++ 源代码、查找变量和分析析构函数。编译器在编译阶段已经将必要的对象清理路径和异常处理规则编码到了生成的程序中。
七、LLVM IR 异常控制流
7.1 为什么普通call不够?
正常函数调用通常只有一条返回路径。
但一个可能抛出异常的函数,实际上存在两种可能:
|
1
2
3
4
5
6
7
|
调用 foo()
│
├── 正常执行完成
│ └── 继续原来的控制流
│
└── 异常传播
└── 进入异常清理或处理路径
|
因此 LLVM IR 提供了 invoke 指令。
7.2invoke、landingpad和resume
下列代码是展示概念的简化 LLVM IR 片段:
|
1
2
3
4
5
6
7
8
9
10
11
|
invoke void @foo()
to label %normal
unwind label %exception
normal:
; 正常执行路径
ret void
exception:
%eh = landingpad { ptr, i32 }
cleanup
; 执行必要的清理代码
resume { ptr, i32 } %eh
|
它与普通 call 的重要差异是:invoke 不仅包含正常执行的目标基本块,还包含异常传播时的目标基本块。
在采用 Itanium 风格异常处理的 LLVM IR 中:
- invoke 描述可能异常离开的调用。
- landingpad 接收异常相关信息。
- cleanup 表示这个异常入口需要进行清理。
- resume 表示清理完成后继续向上传播异常。
需要注意,以上是特定 LLVM 异常处理模型中的 IR 形式,而不是所有 LLVM 目标平台统一采用的机制。Windows 采用另一套异常处理 IR 结构,LLVM 也支持 catchpad、cleanuppad 等相关指令。
7.3 为什么这和优化器有关?
编译器不能把一个可能抛异常的函数调用随意当作普通单出口控制流。
假设:
|
1
2
3
4
|
void process() {
Resource resource;
foo();
}
|
如果 foo() 可能抛出异常,那么编译器必须保证:
正常退出时:
异常退出时:
|
1
|
foo 抛出异常 → Resource 析构 → 异常继续传播
|
优化器需要保留这些可观察的对象生命周期语义。这也是为什么异常处理会影响控制流图(CFG)、代码生成和编译器优化。
八、Zero-Cost Exception Handling:正常路径开销很低
8.1 Zero-Cost
LLVM 官方将 Itanium ABI 的异常处理方式描述为 Zero-Cost Exception Handling。这里的 Zero-Cost 主要针对没有异常发生时的正常执行路径。
表驱动异常处理的思路是:正常执行时,不需要为每一次可能抛出异常的调用都维护额外的动态异常检查点。如果真的抛出异常,才使用异常处理表进行搜索和清理。
8.2 与 SJLJ 异常处理比较
历史上还存在基于 Setjmp/Longjmp 的异常处理实现(SJLJ)。两者的主要区别:
| 特征 |
表驱动展开(常见 DWARF 模型) |
SJLJ 异常处理 |
| 处理器信息 |
主要使用静态异常处理表 |
维护运行时异常处理上下文 |
| 正常路径 |
通常附加开销较低 |
可能需要额外维护操作 |
| 抛异常时 |
查表、搜索栈帧并清理 |
利用维护的异常上下文 |
| 二进制大小 |
展开表可能增加体积 |
存在额外运行时代码与状态 |
| 常见用途 |
现代 Linux C++ 环境 |
部分特定架构或历史实现 |
SJLJ 并不等同于用户直接调用 C 语言的 longjmp(),这里讨论的是编译器使用该类机制实现异常处理的方案。
8.3 Zero-Cost 代价
第一,正常路径成本不是数学意义上的零
异常处理表会增加二进制大小;编译器还可能需要保留额外清理路径或受异常语义约束的控制流。这些因素可能间接影响代码布局和指令缓存。
第二,真正发生异常时可能很昂贵
异常路径涉及:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
|
异常对象构造
↓
运行时抛出操作
↓
查找调用帧
↓
处理器搜索
↓
对象析构与资源清理
↓
执行上下文恢复
↓
进入 catch
|
抛出异常的成本与展开深度、元数据、异常类型匹配以及实际析构操作等因素有关,不应假定为一个固定常数。
第三,异常抛出路径的延迟可能不够稳定
对于低延迟、硬实时或高频执行的程序,这一点尤其重要。例如机器人实时控制循环或高频图像采集处理链路,不适合把异常当作普通状态分支或高频控制流来使用。
更合理的架构通常是:
机器人项目应该有意识地划分实时执行区和异常处理边界。
九、特殊情况:不是所有异常都会完成栈展开
9.1noexcept会发生什么?
|
1
2
3
|
void foo() noexcept {
throw std::runtime_error("error");
}
|
该函数声明承诺不允许异常传播出函数。如果运行时有异常试图逃离这个 noexcept 函数,就会调用:std::terminate();
std::terminate 是 C++ 的紧急终止函数,一旦调用,程序立刻非正常退出,不会执行普通栈析构,也不会抛异常
但这里有一个细节:不要认为 std::terminate() 发生之前,当前函数和下层函数的所有局部对象一定都完成了析构。
C++ 标准规定,当异常试图逃离非抛出函数时,调用 terminate 前是否进行展开、进行部分展开还是完全不展开,是实现定义行为。没有匹配处理器时,是否在 terminate 之前展开栈,同样由实现决定。
因此,不能依赖异常穿越 noexcept 边界时仍然完成全部 RAII 清理。
9.2 析构函数在栈展开期间再次抛异常
下面是典型的危险代码:
|
1
2
3
4
5
6
7
8
9
|
struct Bad {
~Bad() noexcept(false) {
throw std::runtime_error("destructor error");
}
};
void process() {
Bad b;
throw std::runtime_error("original error");
}
|
展开 process() 时,需要销毁 b。但 b 的析构函数再次让一个异常向外传播。这会触发 std::terminate()。
C++ 标准明确规定,如果栈展开期间对象析构又因异常向外终止,则必须调用 std::terminate()。
因此通常应该做到:
|
1
2
3
|
~Resource() noexcept {
// 清理资源,不允许异常传播出去
}
|
如果资源释放操作可能失败,可以在析构函数内部处理或记录故障,但不应让新的异常从析构函数中逃逸。需要区分的是,析构函数内部抛出异常又在内部捕获,并不等同于异常逃离析构函数。
9.3throw;与throw e;的区别
|
1
2
3
4
5
|
try {
risky();
} catch (const std::exception& e) {
throw;
}
|
throw; 重新抛出当前处理的异常对象,并保持其原始异常类型。
而:
|
1
2
3
|
catch (const std::exception& e) {
throw e;
}
|
会从 e 创建新的异常对象。如果原始异常类型是派生类,这种写法还可能发生对象切片(Object Slicing),丢失派生类型信息。
因此,希望原样传播当前异常时,应使用 throw;。
9.4longjmp()
不能将 C 的 longjmp() 当成 C++ 异常处理使用。
C++ 标准对 setjmp / longjmp 有专门限制:如果将这一跳转替换成 catch / throw 会导致某些具有非平凡析构函数的自动对象需要析构,那么对应的 setjmp / longjmp 用法具有未定义行为。
这意味着不要使用 longjmp() 来跨越 C++ RAII 对象的生命周期边界。
9.5SIGSEGV、abort()和SIGKILL
| 场景 |
能否保证正常的 C++ 异常栈展开 |
| throw 且找到匹配处理器 |
是,按照 C++ 异常语义执行 |
| 异常逃离 noexcept |
否,可能直接终止 |
| 未捕获的异常 |
否,终止前展开与否取决于实现 |
| std::abort() |
否 |
| SIGKILL |
否 |
| 未处理的 SIGSEGV |
否 |
| C++ 中危险的 longjmp() 跳转 |
不能依赖,可能是未定义行为 |
一个程序在崩溃时生成了调用栈,不代表崩溃时曾经完成了 C++ 异常栈展开。
还要注意:C++ 异常不会自动跨越线程边界。如果一个线程需要将错误传播给另一个线程,通常需要通过 std::exception_ptr、std::future 或其他同步与通信机制明确传递。
十、Linux 实战:通过 GDB 和 ELF 验证栈展开
理解原理后,最有效的学习方式是直接观察编译产物。这里继续使用第三节的 unwind.cpp。
10.1 编译调试版本
|
1
2
3
4
5
6
|
g++ -std=c++17 \
-O0 \
-g \
-fno-omit-frame-pointer \
unwind.cpp \
-o unwind
|
参数含义:
| 参数 |
作用 |
| -O0 |
降低优化干扰,便于观察 |
| -g |
生成调试信息 |
| -fno-omit-frame-pointer |
尽量保留帧指针,便于分析 |
| -std=c++17 |
使用 C++17 |
10.2 GDB 捕获异常抛出点
运行:
在 GDB 中输入:
GDB 将尝试在 C++ 异常抛出事件发生时中断。此时执行:
可以查看当前调用栈。典型调用链中可以观察到:
|
1
2
3
4
5
|
__cxa_throw
leaf()
middle()
outer()
main()
|
真实回溯可能包含其他 C++ 运行时栈帧,显示内容也取决于调试符号和优化等级。
继续执行:
异常将继续传播,相关清理函数会运行。GDB支持通过 catch throw、catch catch 和 catch rethrow 设置 C++ 异常捕获点。
10.3 查看 ELF 的展开信息
通过:
可以查看 ELF 节区。示例编译生成的 ELF 中包含:
|
1
2
3
|
.eh_frame_hdr
.eh_frame
.gcc_except_table
|
继续执行:
|
1
|
readelf --debug-dump=frames ./unwind
|
可以看到类似以下内容:
|
1
2
3
4
5
6
|
CIE
Code alignment factor: 1
Data alignment factor: -8
Return address column: 16
DW_CFA_def_cfa: r7 (rsp) ofs 8
DW_CFA_offset: r16 (rip) at cfa-8
|
这里的 DW_CFA_def_cfa 用于定义 CFA 计算规则,而 DW_CFA_offset 可用于描述某个寄存器保存值相对于 CFA 的位置。这些并不是 C++ 变量,而是供调用帧恢复机制解释的底层规则。
如果环境安装了 LLVM 工具链,也可以使用:
|
1
|
llvm-dwarfdump --eh-frame ./unwind
|
10.4 观察异常处理相关符号
执行:
|
1
2
|
nm -DC ./unwind | grep -E \
'__cxa_throw|_Unwind_Resume|__gxx_personality'
|
在前面的 GCC 构建中,可以看到:
|
1
2
3
|
_Unwind_Resume
__cxa_throw
__gxx_personality_v0
|
它们分别对应异常展开继续、异常抛出以及 C++ 语言相关异常处理机制。实际符号是否出现在程序中,取决于链接方式、优化选项和目标平台,不能把它们作为所有 C++ 可执行文件必须存在的固定符号。
10.5 为什么 GDB 回溯有时不完整?
即使程序本身没有发生栈破坏,调用栈也可能与源代码层面的函数调用关系不同。原因包括:
函数内联(Inlining)
|
1
2
3
|
inline void foo() {
// ...
}
|
当函数被内联时,可能不存在独立的物理调用帧。
尾调用优化(Tail Call Optimization)
|
1
2
3
|
int foo() {
return bar();
}
|
编译器可能将该调用优化成尾调用,从而复用或替换当前调用帧。
省略帧指针
编译器可能不维护传统的 RBP 调用帧链。
缺少或损坏展开信息
展开器可能无法可靠恢复上层调用帧。
因此,做崩溃定位时需要区分:看不到某个函数 ≠ 该函数一定没有执行
有时候只是源代码调用关系被编译器优化了,或者现有调试数据不足以完整还原。
10.6 编译选项如何影响栈展开?
GCC 还提供了几个值得了解的选项。
| 编译参数 |
主要用途 |
| -fexceptions |
启用异常传播所需的代码生成 |
| -fno-exceptions |
禁用编译器的 C++ 异常处理支持 |
| -funwind-tables |
生成用于展开的静态信息 |
| -fasynchronous-unwind-tables |
生成可用于异步位置回溯的 DWARF 展开表(目标支持时) |
| -fno-omit-frame-pointer |
尽量保留帧指针 |
-funwind-tables 生成展开信息,并不等于启用了完整的 C++ 异常处理。反过来,-fno-exceptions 也不代表二进制文件绝对不会存在任何展开信息。
此外,混用启用和禁用 C++ 异常的目标文件需要特别谨慎。如果异常传播经过没有生成适当异常清理代码的函数,可能无法执行预期的 C++ 对象析构。
十一、工程实践
对于实际 C++ 系统开发,栈展开的价值不仅在于理解 throw 和 catch,更在于建立可靠的错误处理边界。
11.1 区分预期错误与异常故障
在设备控制、网络通信和图像处理链路中,会存在大量正常业务错误:
- 网络请求超时
- 相机当前没有新帧
- 机器人未进入允许运动状态
- 数据尚未准备完成
- 目标暂时丢失
这些不一定适合使用异常表达。尤其是在高频循环中,可以考虑使用显式返回结果:
|
1
2
3
4
5
6
7
|
enum class Status {
Ok,
Timeout,
DeviceDisconnected,
InvalidState
};
Status acquire_frame(Frame& frame);
|
调用层根据返回状态作出明确决策:
|
1
2
3
4
5
6
7
|
Status status = acquire_frame(frame);
if (status == Status::Timeout) {
// 超时重试、告警或状态转移
}
if (status == Status::DeviceDisconnected) {
// 进入设备故障处理流程
}
|
这种方案的控制流通常更明确,也更容易约束最坏执行时间。但对于配置加载失败、资源创建失败等低频错误,异常仍然可以是合理选择。
11.2 在系统边界捕获异常
在 ROS 2 多线程或异步任务架构中,建议区分:
例如:
|
1
2
3
4
5
6
7
8
9
10
11
|
void worker_entry() noexcept {
try {
run_worker();
}
catch (const std::exception& e) {
report_fault(e.what());
}
catch (...) {
report_unknown_fault();
}
}
|
这里假定 report_fault() 和 report_unknown_fault() 自身具有不会向外传播异常的实现,并且能够以线程安全的方式工作。
在实际机器人控制系统中,还需要确保异常处理路径不会阻塞关键线程,不会在设备状态不明时继续发送运动命令。
11.3 利用 RAII 管理设备资源
设备软件中可以借助 RAII 管理:
- Socket 连接。
- 文件描述符。
- 互斥锁。
- 动态内存。
- GPU 资源。
- 相机 SDK 句柄。
例如:
|
1
2
3
4
5
6
|
void process() {
std::unique_lock<std::mutex> lock(mutex_);
update_state();
// 若 update_state 抛异常,
// lock 离开作用域时会释放互斥锁
}
|
这可以避免由于异常跳过显式解锁语句而产生死锁。
不过,RAII 只能确保满足析构条件时释放所管理的资源。它并不能自动完成机器人停止、设备回零、通信状态恢复等业务操作。特别是涉及硬件运动时,应把安全动作、失败响应和异常清理策略分别设计并验证。
11.4 栈展开与异常安全保证
C++ 中常见的异常安全保证有三个层次:
| 保证级别 |
含义 |
| No-throw Guarantee |
操作不会让异常向调用者传播 |
| Strong Guarantee |
操作失败时,原有可观察状态保持不变 |
| Basic Guarantee |
操作失败时,对象仍满足不变量,资源不泄漏,但状态可能变化 |
例如:
|
1
2
3
4
5
|
void append_strong(std::vector<int>& data) {
auto next = data;
next.push_back(42);
data.swap(next);
}
|
这个操作先修改副本,最后通过对该 vector<int> 类型无异常的 swap 提交新状态。如果复制或 push_back 失败,原始 data 不发生修改。这是事务式提交思想在 C++ 异常安全中的一个简单应用。
需要注意,栈展开保证正确的对象清理,但不自动保证业务状态回滚。例如机器人已经移动到一个新位置,某个计算函数随后抛出异常;局部对象能够析构,并不意味着机器人会自动返回初始位置。
总结
可以将栈展开理解为三个层次:
- 语言层:C++ 对象生命周期:throw、catch、RAII、析构函数、noexcept、异常安全保证
- 编译器与 ABI 层:异常处理规则:异常对象、__cxa_throw、Personality Routine、LSDA、Landing Pad、两阶段展开
- 机器执行层:调用帧恢复:RSP / RBP / RIP、CFI、CFA、CIE / FDE、.eh_frame、寄存器上下文恢复
理解栈展开,最需要牢固掌握的是以下五个结论:
- C++ 异常栈展开不仅恢复调用栈,还负责按照语言规则销毁对象。
- 调用栈回溯与异常栈展开不是同一个操作,虽然可能共享展开信息。
- 两阶段展开机制是 Itanium ABI 的实现设计:先搜索异常处理器,再执行清理与控制转移。
- RAII 是异常安全的核心工具,但资源清理不等于业务状态回滚。
- DWARF CFI 解决调用帧恢复问题,LSDA 和 Personality Routine 解决语言相关异常处理问题,共同支撑表驱动异常展开。
其中,C++ 标准草案描述语言要求;Itanium ABI、LLVM 和 GCC 文档描述特定实现机制。实际开发时应避免把某个 ABI 的实现细节误认为所有 C++ 平台必须遵守的语言规则