拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C++异常机制深度解析:32位SEH链与栈展开实战

这周排查一个线上工具的崩溃日志try/catch明明把整个逻辑包住了可异常就是没被捕获。WinDbg 打开 dump 顺着栈回溯的时候我意识到自己虽然写了快十年 C却从来没有认真想过异常在 32 位进程里到底是怎么被路由到catch块的。于是我把这套代码反汇编逐条看了一遍顺便把 MSVC 在 32 位下生成的异常结构彻底梳理了一遍很多以前只可意会的机制终于落到了一条条指令上。这篇文章就是这次梳理的成果。我会从 32 位 Windows 下的FS:[0]异常链讲起分析throw在汇编层的真实动作再深入到栈展开unwind和 funclet 的工作原理。内容适合两类读者一类是对 C 异常机制充满好奇、想知道背后到底发生了什么的人另一类是经常被崩溃了但 catch 不到Debug 能接住 Release 接不住困扰的排查党。看完之后你至少能明白异常处理器为什么放在栈上、__CxxFrameHandler到底在干什么、以及为什么栈一旦被破坏异常链会如此脆弱。1. 那次异常没接住之后我决定把32位异常结构彻底看一遍1.1 一个让我怀疑人生的崩溃现场先说当时让我抓狂的问题。程序主流程大致是int main() { try { process_all(); } catch (const std::exception e) { log_error(e.what()); return -1; } return 0; }逻辑上这已经是最外层兜底了process_all()内部所有子任务也都有各自的 try/catch。可线上 dump 显示进程崩溃了而且崩溃之后日志文件里连log_error的痕迹都没有。我第一反应是catch没匹配上于是检查了所有异常类型全是std::exception派生类理论上不可能漏。后来用 WinDbg 加载 dump执行!analyze -v发现崩溃异常是0xC0000005访问违例崩溃指令在一个看起来完全无关的地址上。再往下看栈回溯栈尾部的返回地址已经被一些看似随机数据覆盖了。那一刻我才反应过来不是catch没接住异常而是栈缓冲区在被异常机制处理之前就已经被写穿FS:[0]指向的 SEH 链被垃圾数据覆盖异常分发器沿着链找 handler 时直接踩到了非法地址进程都没能走到类型匹配那一步。这个问题的根源是第三方库的一个memcpy长度算错但它教会我一件事如果你不了解 32 位异常结构是挂在栈上的你永远无法理解为什么保护性的 try/catch 在某些崩溃面前形同虚设。1.2 为什么只讲32位和64位完全是两套机制这篇标题明确写了32位下的异常结构分析不是随便加的限定。x86 和 x64 的异常处理机制在设计上是两个物种32 位异常处理记录是动态链表每个节点存放在对应函数的栈帧上用FS:[0]指向链头。异常发生时系统沿着这个链表逐个询问 handler。64 位异常处理记录是静态表编译器把每个函数甚至每个代码区域的展开信息写进 PE 文件的.pdata段用RUNTIME_FUNCTION、UNWIND_INFO、UNWIND_CODE描述栈帧如何回退、哪些寄存器需要恢复。异常发生时系统通过查表而不是遍历链表来做栈展开。所以你在 x64 上很少遇到SEH 链被垃圾覆盖导致异常逃逸这类问题因为根本没有运行时链。但这不代表 x64 机制更简单只是把复杂性转移到了生成静态展开表上——这也是我打算在下一篇专门聊的内容。32 位这套动态机制虽然老但现在存量应用仍然不少而且它足够原始正好用来理解异常处理思想的底层雏形。1.3 文章会用到的工具与编译选项后续所有代码片段和分析我都基于以下环境你可以据此复现项目使用内容编译器MSVCVisual Studio 2019 / 2022 均可架构x8632 位编译选项/Od不优化便于阅读默认 /EHsc临时可加 /GS- 对照观察差异反汇编工具Visual Studio 反汇编窗口、WinDbg、dumpbin /disasm排错工具WinDbg 的 !exchain、kb、dt ntdll!_EXCEPTION_RECORD建议你编译 Debug 版看一遍再开 Release 看一遍。优化开不开函数的 SEH 注册逻辑和$state状态维护会差很多对照着看收获最大。2. FS:[0]异常链32位进程的SEH路由骨架2.1 从一条汇编指令认识ExceptionList在 32 位 Windows 用户态FS段寄存器指向当前线程的线程环境块TEB而 TEB 偏移 0 处就是异常链表头。想取出当前线程的异常链表头汇编只需要一句话mov eax, dword ptr fs:[0] ; EAX ExceptionList指向链表的第一个节点这里的链表节点就是结构化的异常处理记录它定义了最基本的 SEH 节点typedef struct _EXCEPTION_REGISTRATION_RECORD { struct _EXCEPTION_REGISTRATION_RECORD* Next; // 链表下一项 PEXCEPTION_ROUTINE Handler; // 异常处理器函数指针 } EXCEPTION_REGISTRATION_RECORD;很多入门资料会把SEH描述成一套抽象机制实际上它就是这样一个朴素的单链表。每个节点只有两个指针一个指向下一个节点一个指向处理函数。所谓注册异常处理器本质就是往这个链表头上挂一个新节点。我在反汇编里反复看到这种取链表的代码给人感觉是异常处理在 32 位下其实一点都不神秘就是一个数据结构和一段遍历逻辑。2.2 编译器在函数进出时做了什么不是每个 C 函数都需要往异常链上挂节点。只有函数里存在 try/catch、或存在带非平凡析构函数的局部对象因为异常展开时需要调用这些析构函数时编译器才会生成 SEH 注册代码。一个典型函数入口的注册序列长这样mov eax, dword ptr fs:[0] ; 保存当前链头作为新节点的 Next push offset __ehhandler$func ; 压入 handler 函数地址 push eax ; 压入原 ExceptionList mov dword ptr fs:[0], esp ; 令链头指向新节点 sub esp, 24h ; 为局部变量和异常状态预留空间注意看栈上的内存布局执行完mov fs:[0], esp之后新节点就是链头[esp]存的是原来的链表头也就是Next字段[esp4]存的是__ehhandler$func也就是Handler字段。函数返回前再反过来摘除节点mov ecx, dword ptr [ebp-18h] ; 或从某处还原 mov dword ptr fs:[0], ecx这种进函数挂链、出函数摘链的模式让我想起在停车场领一张临时卡进入时从入口拿卡新节点挂到链头出去时把卡还给入口从链头摘掉所有进出都发生在同一个入口位置非常符合栈的天然结构。2.3 RtlDispatchException怎么把异常送到handler手里CPU 触发异常或代码调用RaiseException之后内核的KiDispatchException会把异常分发回用户态最终落在 ntdll 的KiUserExceptionDispatcher再进入RtlDispatchException开始遍历链表。遍历逻辑的核心很简单从FS:[0]拿到链头循环访问每个节点调用节点的Handler回调。回调会收到异常记录、注册记录、上下文等参数处理器通过返回值告诉系统下一步动作。下面是 handler 返回值的常见常量返回值含义ExceptionContinueExecution异常已被处理/修复系统回到崩溃点继续执行ExceptionContinueSearch当前 handler 不处理沿链表找下一个ExceptionNestedExceptionhandler 内部又抛出了新异常ExceptionCollidedUnwind展开过程中发生冲突属于比较复杂的边界情况如果遍历完整条链表都没有 handler 愿意接系统就只能终止进程。所谓未处理异常在汇编层面对应的就是这段遍历走到了尽头。2.4 C异常如何借道SEH0xE06D7363到这里为止SEH 其实和 C 没有直接关系它是操作系统提供的通用机制。C 的throw编译后底层走的是 SEH但不是直接用try/catch那种__try/__except语法而是借助一个标志性的异常码0xE06D7363。这个十六进制数把 ASCII 字符msc嵌了进去6D是m73是s63是c可以看作 MSVC 的 C 异常签名。当程序执行throw时运行时库调用push 异常对象指针 push ThrowInfo 地址 push 0 ; 第 3 个附加参数通常为 0 push 1 ; ALWAYS_OPEN push 0E06D7363h ; MS C exception code call dword ptr [__imp_RaiseException]随后RaiseException会把异常送进系统的异常分发流程异常处理链上的__CxxFrameHandler发现异常码是0xE06D7363才按 C 异常的类型匹配规则来处理。这一下就把操作系统通用机制和C 语言语义接上了操作系统只负责传话C 语义靠编译器生成的 handler 自己解释。3. 反汇编解剖一次throw到catch的完整旅程3.1 极简示例一个抛异常对象的测试程序为了把路径拉通我写了一个最小用例只抛一个带 int 编码的异常对象主函数捕获并返回class MyException { public: MyException(int code) : code_(code) {} int code() const { return code_; } private: int code_; }; void inner() { throw MyException(42); } int main() { try { inner(); } catch (const MyException ex) { return ex.code(); } return 0; }编译成 32 位 Debug 版后我会分别看inner()和main()的反汇编。把整个流程拆成四个阶段throw如何构造异常对象、如何调用运行时、main如何注册异常处理器、以及catch如何被选中。3.2 throw编译结果CxxThrowException与RaiseExceptioninner()函数里throw MyException(42)编译后分为两部分。先构造临时对象push 2Ah ; 42 lea ecx, [ebp-4] ; 临时对象地址 call MyException::MyException然后调用运行时函数CxxThrowExceptionpush ebp ; 临时对象地址不是这里要看实际参数 ...实际看反汇编时CxxThrowException的调用大致是lea eax, [ebp-4] ; 临时对象地址 push eax push offset __TI??AVMyException ; ThrowInfo 结构地址 call CxxThrowExceptionCxxThrowException内部做了三件重要的事在堆上或运行时专用的分配区分配一块内存把临时对象拷贝进去。这个拷贝出来的对象才是后续真正伴随异常流转的对象原临时对象在栈展开时会被销毁。把ThrowInfo地址和异常对象地址组装成 SEH 附加参数。调用RaiseException(0xE06D7363, ...)把异常交还给系统。这里有个容易忽略的点throw的对象并不活在原来的栈帧里因为栈帧在展开时会被回收。所以异常对象必须有一个独立于所有栈帧的存储位置。我在看 dump 时经常发现异常对象地址落在堆区原理就在这里。3.3 进入try块前的SEH挂载__ehhandler$main再看main()的反汇编。函数开头除了建立标准栈帧push ebp; mov ebp, esp; sub esp, N还会多出一段 SEH 挂载代码mov eax, dword ptr fs:[0] push offset __ehhandler$main push eax mov dword ptr fs:[0], esp sub esp, 18h其中__ehhandler$main是一个编译器生成的跳板函数最终会转给__CxxFrameHandler。你不需要手动调用它系统在分发 0xE06D7363 异常时会沿着链找上门。进入try块之前编译器会在栈上记录当前状态变量这个$state值决定了异常发生时哪些对象需要析构、catch是否还有效。在汇编层面我看到类似这样的更新mov dword ptr [ebp-14h], 0 ; state 0表示还没开始执行 try 块 ... mov dword ptr [ebp-14h], 1 ; state 1已进入 try 块catch块的代码被编译成一个独立的 funclet并不内联在main的正常流程里。你可以理解为main的主路径只是注册好了异常处理器然后正常调用inner()真正接住异常的代码是备用入口只有异常发生时才会被触发。3.4 catch的类型匹配ThrowInfo、CatchableTypeArray与RTTI当RaiseException发出 0xE06D7363 后__CxxFrameHandler在main的帧上开始工作。第一步是识别异常码如果异常码不对直接返回ExceptionContinueSearch如果是对的就走进 C 异常类型匹配逻辑。匹配需要用到throw端传入的ThrowInfo结构。简化定义如下struct ThrowInfo { unsigned int attributes; PMD pmd; // 用于基类偏移修正 const CatchableTypeArray* pCatchableTypeArray; };CatchableTypeArray里存了一组CatchableType每个描述一种可以匹配的类型包括类型描述符_TypeDescriptor、拷贝构造地址、析构地址、类型在基类体系中的偏移等。_TypeDescriptor本质是一段 RTTI 信息里面有类型的 mangled name调试器里常见的.?AVMyException就是这个名字。匹配流程可以粗略写成对每个 CatchableType 比较 _TypeDescriptor 是否一致 如果不一致尝试做派生类到基类的合法性判定必要时修正 this 偏移 如果匹配成功跳转到对应的 catch funclet把异常对象地址传进去 否则继续检查下一个 CatchableTypecatch (const MyException ex)能接住MyException派生类对象靠的就是这个尝试基类匹配 this 偏移修正机制。多继承和虚继承场景下PMD 偏移的计算会让这个过程复杂不少但在汇编里最终都能追溯到几行指针运算。3.5 谁都不接的时候异常继续往父帧走如果main的catch (const MyException)不符合当前异常类型__CxxFrameHandler会返回ExceptionContinueSearch。RtlDispatchException拿到这个返回值后从链表中取出当前节点的Next字段继续找下一个节点。这个循环会一直走到栈底的节点最后一个Next通常指向0xFFFFFFFF直到某个 handler 愿意处理或者整个链表耗尽导致进程终止。这也是为什么catch (...)通常能兜底它对应一个不检查类型的 handler出现基本都会匹配成功。不过要特别提醒如果某帧的 SEH 节点被栈破坏覆盖了RtlDispatchException在取Next时可能得到一个野指针这时系统会再抛出一个访问违例异常导致原始异常被替换掉。我在 1.1 节遇到的崩溃就是这样用 WinDbg 看最终异常码就变成了0xC0000005。4. 栈展开与funclet析构函数是这么被保证执行的4.1 两阶段模型先找人后打扫很多资料会说 Windows SEH 分两阶段处理异常第一阶段查探dispatch第二阶段展开unwind。C 异常也是这样但展开有特殊的顺序要求。假设调用链是main - innerinner抛出异常main的catch最终接住。处理顺序是第一阶段从当前帧inner开始沿 SEH 链逐帧询问 handler看谁声称自己能处理这个类型。找到main的 handler 后不能立刻跳进catch因为从inner到main之间的所有栈帧都还留着一堆局部对象。第二阶段从抛出处inner开始往回走逐帧调用应该执行的析构函数清理中间帧。清理干净后才进入main的catchfunclet 执行用户代码。你可以把第一阶段理解成先看谁家有灭火器第二阶段是在到达灭火器之前先把每一层走廊上的杂物搬走。如果直接跳过去走廊上的易燃物会炸得更厉害。4.2 state状态机和scopetable编译器怎么知道该清理谁在 x86 上没有像 x64 那样的静态 unwind 表来告诉系统这个帧里有哪些对象需要析构、往哪跳。x86 的做法是为每个可能展开的函数生成一份状态机用整数state表示当前执行到的逻辑楼层再用一张 scopetable 描述每层有哪些清理动作。举个例子void sample() { Obj a; // 构造完成 - state 0 try { Obj b; // 构造完成 - state 1 call_may_throw(); } catch (...) { handle(); } }编译器维护的简化状态大致是state 值逻辑含义-1函数入口/尚未构造任何对象0对象 a 已构造1对象 b 已构造处于 try 块内2正在执行 catch 块异常展开时__CxxFrameHandler拿到当前state然后反向查 scopetable决定该依次调用哪些析构函数和哪个 funclet。比如state 1时异常发生编译器会先调Obj::~Objb再调Obj::~Obja最后进入 catch funclet。这个顺序和局部对象构造顺序严格相反。我在反汇编里看到mov dword ptr [ebp-14h], 1这类指令时现在第一反应就是编译器在更新异常状态机的楼层号。它和普通变量赋值混在一起但含义完全不同。4.3 funclet被异常机制空降执行的代码段funclet是 MSVC 编译器为每个 handler、析构动作生成的独立小函数符号名通常类似$catch$main、$unwind$sample。它们不会被普通call指令调用而是被异常机制按 scopetable 记录跳入。funclet 的设计有几个原因。最重要的一点handler 的作用域和大括号块严格对应把它做成独立函数编译器能精确控制跳转边界不用在main的主流程里塞入大量半死代码。另一个原因是每个 funclet 可以有自己的栈帧布局和参数约定展开机制调用它们时和调用普通函数一样干净。不过在 x86 下funclet 有一个特殊之处它执行时需要恢复原始帧的某些信息比如栈指针。因为 funclet 被异常机制调用时当前栈指针可能是已经处于展开途中的状态和正常函数调用的栈布局不一样。编译器通常会在函数帧的某个 slot 里保存原始ESP/EBP值funclet 入口处再把这些值恢复出来保证 handler 内部还能访问外层局部变量。4.4 从示例看局部对象的析构顺序把前面的sample扩展一下加上输出就能验证析构顺序struct Obj { const char* name; Obj(const char* n) : name(n) { printf(ctor %s\n, name); } ~Obj() { printf(dtor %s\n, name); } }; void sample() { Obj a(a); try { Obj b(b); throw 1; } catch (...) { printf(catch\n); } }实际输出必然是ctor a ctor b dtor b dtor a catch注意catch的打印发生在这两个析构之后而不是之前。这一点非常关键当处理器选中 handler 时中间帧的局部对象必须先清理完catch 块面对的才是干净的运行环境。如果你写过跨栈帧的引用计数或锁应该能体会这个顺序有多重要——GC 或 RAII 资源能正确释放靠的就是展开顺序被语言规范严格固定。5. 和异常结构有关的实战排查经验5.1 Debug能接住、Release接不住先检查SEH注册代码还在不在很多人遇到 Release 下try/catch失灵第一反应是编译器优化坏了。实际多数情况是函数被内联或者局部对象被优化掉导致原本应该生成的 SEH 注册代码彻底消失。举个例子Release 下如果inner()被内联进了main()异常抛出点和main的 try 块就处于同一个逻辑函数编译器可能不再为inner单独生成一个 SEH 节点但这本身不会导致接不住异常。真正容易出现的问题是$state变量被优化器折叠某些状态分支被跳过catch匹配时机变得和 Debug 不同。排查方法很简单打开 Release 反汇编搜索函数里是否还有mov fs:[0], esp和__ehhandler$。如果整个函数里已经找不到任何 SEH 注册代码再看是不是因为局部对象全被优化没了。如果你需要保证异常安全但函数体又很轻量可以用#pragma optimize(, off)临时关闭优化验证一下是不是优化导致的问题。5.2 栈被写穿导致SEH链失效的经典症状SEH 链节点放在栈上意味着任何越界写栈的 bug 都可能破坏异常机制本身。这是我见过最多、也最隐蔽的一类崩溃。经典症状包括但不限于异常码被替换为0xC0000005但崩溃地址看起来很奇怪。!exchain显示链表走向了垃圾地址或走不完。兜底catch (...)都没触发但程序又确实是在某个合法函数里抛的异常。栈回溯kb里返回地址全是乱码。遇到这种症状优先怀疑有没有栈缓冲区溢出、局部数组越界、memcpy长度错误、错误的memset覆盖了结构体。用/GS编译选项能有效缓解一部分栈覆盖风险因为编译器会在栈上插入安全 cookie越界时会触发快速失败而不是继续破坏链。5.3 _except_handler3到_except_handler4安全cookie改变了什么老 VC6 时代x86 异常处理器入口是_except_handler3后来 VS2005 随/GS引入了_except_handler4。二者核心区别在于_except_handler4会把 scopetable 指针加密用安全 cookie 参与异或防止攻击者通过篡改异常链来劫持控制流。在调试中看到__ehhandler$xxx内部调用的是_except_handler4时别奇怪为什么栈上的 scopetable 地址看起来是乱的。那实际上是被加密过的需要 cookie 才能还原。通常你不需要手工解密WinDbg 的!exchain和相关扩展会自动处理但理解这一点能避免你在手工分析时产生怎么数据全对不上的困惑。VS2015 之后x86 下 MSVC 又引入了__CxxFrameHandler4和配套的新版展开机制_except_handler4这类入口被进一步封装。虽然 SEH 链的注册方式没有变但 handler 内部实现更复杂了。排查时最好先确认编译器的具体版本再决定按哪套符号往下看。5.4 WinDbg看异常链!exchain与几个实用断点最后分享几个实际排查异常问题时我常用的 WinDbg 操作直接扔给你当工具包命令/操作作用!exchain打印当前线程的 SEH 链表快速判断链是否完整!analyze -v自动分析崩溃现场给出异常码和可能的根因kb/kn查看栈回溯配合符号判断异常发生时的调用链bp KERNELBASE!RaiseException在RaiseException入口下断点观察谁发起了异常sxe eh throw在 C 异常抛出时中断Windows 调试器支持按异常类型断点dt ntdll!_EXCEPTION_RECORD eax查看异常记录的字段尤其是附加参数遇到0xE06D7363异常时RaiseException的第二个附加参数ExceptionInformation[0]是ThrowInfo地址ExceptionInformation[1]是异常对象地址。你可以手动dt出来一步步追到_TypeDescriptor看到类型的 mangled name从而判断当前抛出的到底是什么类型。这在系统明明抛了异常但代码里看不出类型的场合特别好用。我在排查崩溃时最常用的一条断点就是bp KERNELBASE!RaiseException .if (poi(esp8) 0E06D7363h) { .echo CXX_EXCEPTION; k } .else { gc }它只在 C 异常码出现时停住并打印调用栈其他异常全部放行。实际项目中这个条件断点帮我定位过很多次谁在暗中抛异常的悬案比在代码里到处埋点高效得多。把这套 32 位异常结构摸清楚之后我最大的收获是排查崩溃时不再靠猜。遇到一个访问违例先看!exchain的链表是否健康再决定往内存破坏方向找还是往异常匹配方向找思路清晰了很多。如果你也在做 32 位应用的维护或逆向分析强烈建议亲手对一个只带 try/catch 的小程序反汇编一遍对照这篇文章把注册、分发、匹配、展开的链路走一遍效果比看十篇理论都管用。下一篇我会继续分析 64 位下的UNWIND_INFO和RUNTIME_FUNCTION那座静态表驱动的机器是另一种美。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门