1. 这个系列要解决什么问题:异常机制背后的盲区
先聊点实在的。我们平时写 C++ 代码,try-catch 用得很顺手,只要抛出去就能接住,但有没有想过,程序运行时背后到底发生了什么?这个问题我以前也没太当回事,直到有一次线上排查崩溃问题,dump 文件里清清楚楚地看到异常发生在某个 throw 点,却没有对应的 catch 能够处理,程序直接 terminate。那时候我才意识到,异常机制并不是"编译器帮我们搞定了",它是一整条完整的执行链路,涉及异常对象的内存布局、栈帧遍历、catch 块的匹配和跳转规则。
所以这个系列第一篇文章,我把目标锁定在 32 位下的异常结构分析。为什么先写 32 位而不是 64 位?两个原因。第一个原因是 32 位下的异常处理结构更简单、更典型,非常适合作为切入点,搞懂它之后再看 64 位会轻松很多。第二个原因是,在实际逆向分析工作中,很多老代码模块和遗留系统仍然是 32 位,了解它的内部结构是必做的功课。
这篇文章面向的是对 C++ 有一定基础、但又没深入过底层的开发者,以及想提升二进制分析能力的逆向爱好者。我会从反汇编视角出发,把异常处理的底层数据结构、执行流程和关键函数一层层剥开。看完之后,你至少能看懂 32 位 C++ 可执行文件中跟异常相关的汇编代码和结构体布局,也能自己动手分析一段简单的异常代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先建立整体认知:异常处理在 CPU 眼里是什么样
在展开具体结构之前,我们先做一件事:把"异常"这个概念翻译成 CPU 能理解的语言。很多朋友在学习异常机制时卡住,根源在于一直停留在 C++ 语法层面思考,没有切换到机器视角。
2.1 从 try-catch 语法到底层指令流的转变
我们写一句 try { foo(); } catch (int e) { ... },编译器拿到这行代码后,并不会生成什么"魔法指令"来标记 try 块的开始和结束。它实际上做的是:在函数执行过程中预先注册一个"处理者"的地址,当异常发生时,运行时系统沿着栈向上找,看谁能处理这个异常对象。
这个过程在 CPU 视角下就是一系列普通指令和数据结构操作。try 对应的是往栈上某个位置写入一条记录,throw 对应的是调用一个运行时库函数(在 MSVC 下通常是 _CxxThrowException),catch 对应的是根据异常类型去匹配一条跳转目标。整个机制没有用到任何 CPU 特殊指令,完全是系统层面的软件约定。
这就是为什么我们要分析"异常结构"——它在内存里有实实在在的字节分布。弄明白了这些字节,你就弄明白了异常的本质。
2.2 为什么 32 位环境下选择 SEH 作为底座
Windows 平台上,32 位 C++ 异常处理建立在 SEH(结构化异常处理)之上。SEH 是操作系统提供的一套异常机制,核心思路是每条线程维护一个异常处理器链表,链表的头指针存放在 TEB(线程环境块)的偏移 0 处,也就是我们常说的 FS:[0]。
每个链表节点是一个注册记录,典型结构如下:
code复制typedef struct _EXCEPTION_REGISTRATION_RECORD {
struct _EXCEPTION_REGISTRATION_RECORD* Next; // 下一个记录
PEXCEPTION_ROUTINE Handler; // 异常处理函数指针
} EXCEPTION_REGISTRATION_RECORD;
在 x86 汇编里,访问这个链表头的常见指令就是:
code复制mov eax, fs:[0]
这行指令出现在反汇编代码中的频率,比你想象的高得多。只要函数里有异常处理相关的代码(try-catch 或 _except),你大概率能在函数序言部分看到它的影子。
C++ 之所以选择 SEH 作为 32 位的基础,是因为它能处理系统级的异常分发,同时允许编译器把 throw 映射到 SEH 的异常派发流程中。简单说,操作系统负责"找到能处理异常的代码",编译器负责"告诉系统怎么找"。
2.3 编译器和运行时各管哪一段
搞清楚分工很重要。在 C++ 异常机制的整个动作链里,编译器和运行时库各负责一段。
编译器负责的,是在编译阶段生成异常相关的表数据。比如某个函数需要被展开栈、某个 catch 块能捕获什么类型、函数中出现异常的指令范围对应哪个处理块,这些信息都会被编译进二进制文件的某些节区。在 MSVC 下,这些数据存放在 .pdata、.xdata 或专门的异常目录段;在 32 位下,你会在 PE 文件中找到这些辅助数据的痕迹。
运行时库负责的,是异常的派发和栈展开。当 throw 语句执行时,运行时会先构造异常对象,然后调用 _CxxThrowException,这个函数拿到异常信息后,会沿着线程的 SEH 链逐级查找合适的处理者。找到之后,开始栈展开工作:依次调用各局部对象的析构函数,释放栈帧,最终把控制流转移到 catch 块。
这样说框架可能有点虚,下面我们直接进入二进制世界,把真实的结构体拉出来看。
3. 反汇编视角下的 32 位异常结构核心
这一节是整个系列的开胃主菜。为了让分析过程可复现,我用 Visual Studio 2022 的 x86 编译器编译了一段最简单的异常代码,关闭优化,生成 Release 版,然后放进 IDA Pro 里逐行分析。
3.1 示例代码与编译参数
测试代码如下:
cpp复制#include <windows.h>
#include <iostream>
void InnerFunction() {
throw 42;
}
void OuterFunction() {
try {
InnerFunction();
} catch (int e) {
std::cout << "Caught: " << e << std::endl;
}
}
int main() {
OuterFunction();
return 0;
}
编译参数:
code复制cl.exe /O1 /EHsc /GS- /analyze- test.cpp
简单说明一下为什么选这些参数。/O1 是尽量紧凑优化,方便看逻辑结构;/EHsc 是启用 C++ 异常(同步异常);/GS- 关闭缓冲区安全检查,减少干扰项;/analyze- 关闭静态分析。如果你是新手,第一次练手可以干脆 -Od 关闭优化,但后面你会发现,真实分析时遇到的绝大多数代码都带着优化,所以 -O1 更贴近实战。
3.2 找到异常注册代码:函数序言里的 FS:[0]
先看 OuterFunction 的反汇编:
assembly复制; 函数序言
push ebp
mov ebp, esp
push -1
push offset eh_func_ccotr
push offset __except_handler4
mov eax, dword ptr fs:[0]
push eax
mov dword ptr fs:[0], esp
add esp, -38h
...
这里有一个经典模式,看到这套代码就说明当前函数注册了异常处理器。
关键是这段:
code复制push -1
push offset eh_func_ccotr
push offset __except_handler4
mov eax, dword ptr fs:[0]
push eax
mov dword ptr fs:[0], esp
它做的事情,是把一个 _EXCEPTION_REGISTRATION_RECORD 塞进当前线程的 SEH 链表头部。push -1 表示该异常帧在当前函数内部的某处记录信息编号从 -1 开始,后续具体意义我们等会展开;push offset eh_func_ccotr 压入的是编译器生成的描述函数;push offset __except_handler4 压入统一的异常分发入口函数。
接着把旧的 FS:[0] 值压栈,再把当前栈顶指针写入 FS:[0]。这样一来,栈上就形成了一个新的异常注册节点,它的布局正好和 EXCEPTION_REGISTRATION_RECORD 吻合:[esp] 是旧链表指针(Next),[esp+4] 是处理函数地址(Handler)。
这里有个细节值得留意:__except_handler4 是 MSVC 在引入了 /GS 和安全异常处理后的标准入口。如果你在反汇编里看到 __except_handler3,说明这是老版本编译器产物或未启用安全异常。两者的差异后面会专门提一句。
3.3 三个压栈值到底是什么:ID 与描述表
从汇编看,压入栈里的 -1 和 eh_func_ccotr 地址看起来毫不相关。实际上,这两个值合在一起,指向了编译器为当前函数生成的"异常描述表"。
这个描述表大致是这样的一个结构(MSVC 内部叫它 FuncInfo 或 ScopeTable):
code复制struct FuncInfo {
unsigned long magicNumber; // 描述表的标识,通常是 0x19930520(C++ 异常的标识值)
unsigned long maxState; // 状态总数,表示函数被划分为多少段
void* pUnwindMap; // 指向栈展开映射表
unsigned long nTryBlocks; // try 块数量
void* pTryBlockMap; // 指向 try 块映射表
unsigned long nIPMapEntries; // IP 映射条目数量
void* pIPToStateMap; // 指令地址到状态的映射表
};
magicNumber = 0x19930520 这个值很有辨识度,在反汇编里见到它就不用怀疑,下面就是一个 C++ 异常描述表。1993 年 5 月 20 日,这是个有纪念意义的日期,说明 C++ 异常实现定下的时间点。
那 -1 和 eh_func_ccotr 怎么关联起来?实际上,-1 是一个相对偏移量,它以 eh_func_ccotr 的地址为基准,向前偏移得到真正的描述表地址。由于在 32 位下这个描述表本身就在 eh_func_ccotr 之前,所以压入 -1(4 字节)正好定位到它。可以去 IDA 里验证:eh_func_ccotr - 1 就是上述结构体的起始位置。
这个算是单眼识别法了:拿到一个 eh_func_ccotr,把它减 4,往下读结构就能看到完整的异常描述。
3.4 异常分发入口:ExceptionHandler 的签名和实际工作
在 SEH 中,处理函数(Handler)的签名是:
code复制EXCEPTION_DISPOSITION __cdecl _except_handler(
EXCEPTION_RECORD* ExceptionRecord,
EXCEPTION_REGISTRATION_RECORD* EstablisherFrame,
CONTEXT* ContextRecord,
void* DispatcherContext
);
这个函数并不是用来真正"捕获"C++ 异常的,而是负责把系统异常事件转交给 C++ 异常运行时。它拿到 ExceptionRecord 后,会检查异常代码是否等于 0xE06D7363(这是 C++ 异常特有的异常代码,其 ASCII 为 "msc" 的变形)。如果不是,则返回 ExceptionContinueSearch,表示自己不处理,让系统继续沿 SEH 链查找。
如果是 0xE06D7363,说明这是 C++ 运行时抛出的异常,它就会调用后续的 CxxFrameHandler 来执行匹配逻辑。在反汇编里,你看到的流程大致是这样:
assembly复制mov eax, [ebp+8] ; ExceptionRecord
cmp dword ptr [eax], 0E06D7363h
jne continue_search
; 是 C++ 异常,进入 CxxFrameHandler 的处理流程
0xE06D7363 这个魔数在反汇编中四处可见,它是识别 C++ 异常与原生 SEH 异常的关键分水岭。
3.5 状态机的核心:IP 到 State 的映射表
C++ 异常处理最核心的机制之一,是"状态机"。编译器在编译一个包含 try-catch 的函数时,会把函数内部按指令地址划分为多个区间,每个区间对应一个"状态号"。程序执行到哪个状态,运行时就以此决定当前处于哪个 try 块的作用域内、是否需要调用析构函数、哪一个 catch 块在捕获射程内。
这个映射的关键结构是 IPToStateMap 和 UnwindMap:
IPToStateMap 是一组条目,每个条目包含指令地址(RVA)和对应的状态号。编译器保证,在任意一条指令执行之前,都可以通过查找这个映射表来确定当前状态。
UnwindMap 是一组条目,每个条目描述进入某个状态时需要执行哪些清理操作(通常是析构局部对象),对应反汇编里你会看到:
code复制UnwindMap 条目:
[0] state: 0, action: NULL ; 无人员需清理
[1] state: 1, action: obj0_dtor ; 需要调用局部对象 obj0 的析构函数
当异常发生时,CxxFrameHandler 通过查询当前指令地址在 IPToStateMap 中的映射,得到当前状态号,然后沿着状态号往"状态 0"方向的路径上逐条执行 Unwind 操作。这就是"栈展开"表中的逻辑基础。
4. 动手实践:从反汇编中还原异常结构
纸上谈兵没有意义。这里我用一个完整的实操流程,带你从二进制里手工提取并还原异常结构。跟我一步步来。
4.1 第一步:定位 eh_func_ccotr 和描述表
用 IDA 加载测试程序的 x86 Release 版本。定位到 OuterFunction,在函数序言部分你会看到上面提到过的 push offset eh_func_ccotr。
选中它,按 x 查看交叉引用跳转过去,你会落到某个地址。这个地址减去 4,就是描述表的起始。
手动提取结构:
assembly复制.data:00411000 FuncInfo:
.data:00411000 dd 19930520h ; magicNumber
.data:00411004 dd 3 ; maxState = 3
.data:00411008 dd offset UnwindMap ; pUnwindMap
.data:0041100C dd 1 ; nTryBlocks = 1
.data:00411010 dd offset TryBlockMap
.data:00411014 dd 4 ; nIPMapEntries = 4
.data:00411018 dd offset IPToStateMap
分析到这里,信息已经非常清晰:maxState=3 说明函数被划分为 4 种状态(0 到 3);nTryBlocks=1 说明只有一个 try 块;后面跟着三张映射表。
4.2 第二步:解读 UnwindMap 与 TryBlockMap
画出 UnwindMap:
code复制UnwindMap[0]: state=0, action=NULL ; 状态 0 不需要清理
UnwindMap[1]: state=1, action=NULL ; 状态 1 也没有对象需要析构
UnwindMap[2]: state=2, action=@ObjDtor_InnerFunctionScope
UnwindMap[3]: state=3, action=NULL
看到 state=2 时调用了某个析构函数,而这个析构对应的就是 OuterFunction 里 try 块内创建的临时对象(此例中为异常对象复制等内部结构,实际开发中对应栈上的局部对象)。
TryBlockMap 的每个条目通常长这样:
code复制struct TryBlockEntry {
unsigned long tryLow; // 起始状态
unsigned long tryHigh; // 结束状态
unsigned long catchHigh; // catch 最高状态
unsigned long nCatches; // 捕获块数量
void* pHandlerArray; // 捕获描述数组
};
在示例程序里,tryLow=1, tryHigh=2, catchHigh=3, nCatches=1,捕获描述数组中有一个条目,类型信息是 int,处理地址对应 catch 块的入口指令。
这里有个高频考点:一个函数里 try 嵌套 try,TryBlockMap 会以数组形式依次排列,且存在父子嵌套关系。分析时要注意状态的包含关系,不要漏掉内层 try 的 catchHigh 边界。
4.3 第三步:看 catch 块类型匹配的反汇编
真正分发时,运行时需要判断异常对象的类型是否匹配 catch 声明的类型。在反汇编里,这个判断逻辑藏在 CxxFrameHandler 调用链中,最后会递归到类型比对函数 _FindHandler / _TypeMatch。
关键点是 type_info 结构和其 RTTI 信息:
- 异常对象的头部有一个指向 type_info 的指针;
- catch 块描述里也有一个 type_info 指针;
- 运行时通过比较两个 type_info 的名字字符串,或者检查类派生关系,决定是否匹配。
反汇编里你能看到对一个 vftable 偏移位置的访问,那往往就是 type_info 的地址。对于 int 这种内置类型,你会在二进制里找到 ".H" 或类似的名字字符串指针。
这种"名字比对"方式的优点是简单,缺点是跨模块异常匹配完全靠名字,两个模块里定义了同名的类型,异常会"互相接住",这在某些场景下会引发隐蔽的 bug。这是后话。
4.4 第四步:说出完整流程,把每个环节串一遍
结合上面提取的所有信息,我们来完整推演一下异常从 throw 到 catch 的全流程:
InnerFunction内执行throw 42,编译器将其翻译为调用_CxxThrowException,参数分别是异常对象的地址和类型信息指针;_CxxThrowException为异常对象分配内存、填充 RTTI 信息,然后调用操作系统的RaiseException,异常代码为0xE06D7363;- 系统沿当前线程的 SEH 链分发异常,进入
OuterFunction注册的__except_handler4; __except_handler4识别异常代码为 C++ 异常后,调用CxxFrameHandler;CxxFrameHandler根据当前发生异常的指令地址,查询IPToStateMap得到状态号,确定处于 try 块的射程内;- 查找
TryBlockMap中与之匹配的 catch 描述,用 RTTI 类型信息做匹配校验(int vs int,匹配成功); - 执行栈展开:根据
UnwindMap从当前状态逐级回溯,调用所有需要清理的析构函数; - 把控制流转移到 catch 块入口,异常对象在栈上(或寄存器中)被传递给
e; - catch 块执行完毕,程序继续正常的控制流;
这几步对应的汇编指令总时长可能只有几微秒,但每一步都有实实在在的内存读写和结构体查询。这就是异常机制的真实成本——如果你在性能敏感代码里频繁 throw-catch,查询映射表和栈展开的开销是不可忽视的。
5. 常见问题与排查技巧实录
分析异常结构时,我踩过不少坑,整理出几个频率极高的问题供大家参考。
5.1 为什么我的函数里看不到 FS:[0] 的写入
很多朋友拿着自己的代码反汇编,发现函数里没有 push fs:[0] 相关的指令。原因多半有几种:
- 函数没有 try-catch,但有析构对象。注意,
FS:[0]写入的注册动作并不只出现在 try 函数中。只要函数中有需要栈展开时清理的局部对象(即非平凡析构函数),编译器也可能会注册异常帧。但在/O2高优化下,MSVC 有"忽略展开"的优化(C++ 异常关闭时,或函数声明了noexcept),如果函数本身不抛异常,编译器就可能把这个注册优化掉。 - 你打开了
-EHsc-(未启用异常)。有些项目为了体积关闭了异常支持,编译器会把所有异常处理表生成逻辑省略掉。 - 你分析的函数本身不在异常影响路径上。一个完全没有本地对象、没有 try-catch、没有被
throw点可能调用的函数,自然不会生成异常帧。
排查思路:查看编译命令行中是否包含 /EHsc 或 /EHs;确认函数签名不是 noexcept;确认至少在测试代码里成功执行一次 throw。
5.2 __except_handler4 和 __except_handler3 的区别
分析老程序时,你看到的是 __except_handler3。这两个入口函数的核心差异在于对"安全异常"(SEHOP)的支持。
__except_handler3 是早期版本,异常记录中没有 cookie 校验字段,任何模块只要伪造了 SEH 链节点,就能劫持控制流——这正是栈缓冲区溢出漏洞利用的经典路径。
__except_handler4 引入了安全 cookie 机制,它在异常注册记录中保存一个额外的 cookie,异常分发时会先校验 cookie 合法性,如果不匹配则中止处理。使用 /GS 编译时默认启用,这也是为什么我开头建议关闭 /GS 来分析基础结构,少一个干扰项。
实操中看到 __except_handler4 时,要记得多一个 4 字节的 cookie 字段,不要把它当成普通 Next 或 Handler 的成员。
5.3 异常结构表在 IDA 中识别为数据还是代码
经验不足时,IDA 经常会把包含异常处理代码的段标成 "LOAD" 或 "DATA",导致你分析不到函数体。这是因为异常描述表的 pUnwindMap 和 pTryBlockMap 指针在代码段中形成了一种类似数据跳转表的结构。
遇到这种情况,可以尝试按 C 把指定地址强制定义为数据并手动重命名;也可以使用 F5 反编译后跟着伪代码跳转。更直接的办法:分析前把 IDA 的 "Stack variables" 和 "Frame pointer omission" 设置都打开,有时它能自动识别出异常结构。
5.4 有一类异常不在 try 字面范围内,也能被 catch 捕获
这是因为 C++ 异常匹配不严格限定在 throw 所在行的字面范围内。比如你调用了一个内联函数,内联后在调用点展开,其内部的 throw 在指令流上已经处于调用函数体的 try 范围内,异常自然能被 catch。反过来,有些看似在 try 块内执行的函数实际不在指令范围内(比如因为纯虚拟函数表跳转),也可能最终不被捕获,走入 terminate。
这种"看起来能捕获但实际不能"的问题,在反汇编分析中最容易浪费生命。解决办法:直接在反汇编里确认 throw 点的实际指令地址,把它与 TryBlockMap 中 tryLow/tryHigh 的状态区间映射对比,而不是靠源代码行号猜测。
5.5 多模块异常匹配:同一类型为什么抓不到
另一种常见的坑出现在 DLL 开发中。A 模块抛出一个自定义异常类型 MyException,B 模块 catch MyException,结果死活匹配不上。
原因还是 RTTI 类型匹配靠名字和 vftable 对齐。如果两个模块的编译选项不一致(例如一个开了 /GR-,另一个开了 /GR),或者头文件版本不一致导致类布局不同,类型匹配必然失败。你在反汇编里会看到两个 type_info 结构中的类型名字符串完全相同,但对象大小或 vftable 指针不同。
我的建议是:跨模块异常尽量用内置类型(int、std::exception 等)或异常基类,不要自己定义跨 DLL 的自定义异常类型。这条建议来自线上生产环境的血泪教训。
5.6 手动标注异常结构的快速方法
最后分享一个实战技巧。每次我拿到一个 32 位反汇编对象,想快速找异常结构,会在 IDA 的脚本窗口执行一段搜索逻辑:
- 在段中搜索魔数
0x19930520; - 对每个命中位置,向后解析
FuncInfo; - 打印
maxState、pTryBlockMap等字段的偏移地址; - 用注释标注到 IDA 里。
这个操作可以写成一个简单的 IDAPython 脚本,一次性把多个函数的异常表全部整理出来。以后你接手一个全新模块,打开 IDA 就先把异常表铺满注释,分析效率会高出不少。
6. 扩展一点:32 位异常结构与 64 位的主要差异
虽然本系列第一篇聚焦 32 位,但提前了解 64 位差异有助于构建整体认知框架。
在 x64 下,异常处理不再使用 FS:[0] 链表,而是完全依赖编译期生成的 .pdata 和 .xdata 数据表。每个函数的异常信息都集中存放,运行时通过 RIP(指令指针)回溯函数地址,再查 .pdata 获取对应异常处理数据。这使得 x64 的栈展开是"表驱动"的,更加规整,也更适合现代操作系统的异常分发模型。
C++ 层面对 FuncInfo 的总体设计思路一脉相承,但字段更丰富,加入了更多展开描述信息、catch 块描述和类型信息。从反汇编学习的角度,我强烈建议先把 32 位的结构吃透——你看懂了 x86 版本最朴素的"状态机 + 映射表"模型,再去看 x64 会感觉它只是在模型上加了更多参数。
7. 随手记几点心得
这部分是我个人实践留下的经验,不算教程,但去掉它又觉得缺了点味。
第一点,分析异常结构时,不要执着于一次性看懂所有字段。先抓主干:FuncInfo 的魔数字段、maxState、TryBlockMap 的起止状态、catch 块的类型信息。主干认识清楚后,再回来看细节字段,比如 nIPMapEntries 和 IP 映射条目的完整排列方式,慢慢就熟了。
第二点,异常结构在代码段、数据段、只读数据段都有可能出现,分析时关注 RVA 而不是文件偏移,因为 PE 加载到内存后,文件对齐和内存对齐可能不一致。用 IDA / WinDbg 统一以 VA/RVA 视角工作,能避免很多困惑。
第三点,也是我反复强调的:异常机制对性能的影响不能轻视。反汇编看清了流程你就会明白,一次异常从 throw 到 catch 需要经历 SEH 链遍历、状态映射查询、RTTI 匹配、栈展开等多个环节。设计高实时系统时,"不用异常做业务控制流"不只是编码规范,是底层机制决定的性能取舍。
第四点,安全问题不能忽视。异常结构中的 SEH 链是栈上重要的控制流数据,历史上大量针对 SEH 覆盖(SEH overwrite)的攻击都在这里做文章。深入研究异常结构后,你会发现缓冲区溢出防护、SEHOP 校验、CFG(Control Flow Guard)这些现代安全机制为什么要这样设计——一切都有迹可循。
后面这个系列我会接着写异常对象的真正构造与析构时机、catch 类型匹配的完整 RTTI 规则、以及栈展开的逐指令拆解。感兴趣的话可以持续关注。
