SEH 局部展开这个词,我是被一个诡异的崩溃现场逼着去搞明白的。某个工具里有一段嵌套的__try,内层抛了异常,外层__except明明接住了,夹在中间的__finally却出现了一次“该跑没跑、不该跑时乱跑”的现象。翻了半天栈回溯才发现:SEH 的异常处理根本不存在“找到 handler 就马上执行”这回事,在“找到”和“执行”之间还隔着整套栈展开流程。如果这个展开只走到线程栈上的某一帧就停住,这就是局部展开;如果没有 handler 接管、系统把整条栈一路展到底,那就是全局展开。
这篇文章是 SEH 系列记录的第八篇,核心就聊局部展开:它什么时候触发、x86 和 x64 的运行时各是怎么实现的、编译器生成的展开数据长什么样,以及实际调试时应该在哪里下断点、看哪些关键字段。适合对异常分发已经有基本概念、想把它彻底吃透的逆向同学和系统开发同学。
1. 局部展开是什么:先说清概念和触发时机
1.1 两趟分派里,“找到处理程序”之后的故事
系统在分发 SEH 异常时,内部实际会走两趟,这是所有后续讨论的根基。
第一趟叫搜索趟。系统从当前栈帧对应的异常注册节点出发,沿着fs:[0]链表(x86)或者.pdata异常目录(x64)逐帧往上找。每遇到一个编译器生成的 handler,就检查该函数的作用域表,执行__except的过滤表达式。过滤表达式返回值只有三种:继续执行、继续找、执行 handler。一旦某个过滤器返回“执行 handler”,第一趟就结束,系统记录下这个 handler 所在的帧。
第二趟才是执行趟。系统拿到第一趟标记的“目标帧”之后,并不直接跳进__except块,而是先把从异常发生帧到目标帧之间的所有栈帧“拆掉”。拆的过程中,凡是经过的那些还没有正常退出的__try/__finally作用域,都要把对应的__finally函数体调一遍。这个“拆栈 + 顺路跑清理逻辑”的动作,就是展开。
第一趟:定位 handler(过滤器决定谁接) ↓ 找到后不立即执行 第二趟:从异常点展开到目标帧(局部展开) ↓ 沿途执行所有需要执行的 __finally 真正执行目标帧的 __except 处理块局部展开专指第二趟的前半段。它叫“局部”,是因为目标帧和异常发生帧在同一个线程栈上,展开到目标帧就停止,不需要惊动系统全局的顶层兜底逻辑。如果没有 handler 愿意接,系统才会从异常点一直展开到线程栈底,把途中所有__finally全部执行一遍,最后走到进程级默认处理,这就是全局展开。
1.2 触发局部展开的三种典型嵌套形态
第一种是同比函数的嵌套。同一个函数里,内层__try触发异常,外层__except接住,中间夹着的内层__finally必须执行。这是最直观的局部展开。
void Nested(void) { __try { __try { RaiseException(0xE0000002, 0, 0, NULL); Trace("永远到不了这里"); } __finally { Trace("内层 finally,局部展开时执行"); } } __except (EXCEPTION_EXECUTE_HANDLER) { Trace("外层 except 接住异常"); } }第二种是跨函数的嵌套。异常在某个被调函数里出现,调用方函数的外层__except接住,被调函数里未退出的__finally同样要执行。这种形态在工程里最常见,也最容易让人误以为“异常已经被别人接了,我这里的 finally 不用跑”。
第三种是夹杂在 C++ 异常处理之间的混用。C++ 的try/catch在展开栈的过程中也会触发局部展开逻辑,只是它走的是另一套语言相关处理器。本文先以原生 SEH 为主,最后再谈两者的关系。
这里有个容易混淆的点:__finally既会在局部展开里执行,也会在全局展开里执行,还会在函数正常 return 时执行。它不是“发生异常才执行”的机制,而是编译器安插在函数退出路径上的一个“必经节点”。展开过程只是撤销异常发生时“作用域尚未正常退出”的状态,逼迫这些节点按正常退出顺序走一遍。想清楚这一点,后面看作用域表就顺了。
2. x86 下局部展开的幕后实现:作用域表、try level 与编译器展开例程
2.1 编译器为每个 SEH 函数织好的三件套
x86 的 SEH 依赖fs:[0]异常注册链表,但编译器不是简单地把一个 handler 指针挂上去就完事。一个包含__try/__except/__finally的函数,编译后会在自己的栈帧里织入三样东西。
第一样是异常注册记录EXCEPTION_REGISTRATION_RECORD,包含前向指针 Next 和 handler 指针;第二样是作用域表SCOPETABLE,描述这个函数里所有受保护区间;第三样是一个整数槽位,通常叫 try level,记录“当前执行流正处在第几层 try 作用域”。
这三个东西配合起来,就构成一个完整的状态机。
作用域表的每一项对应一层__try,结构长这样:
typedef struct _SCOPETABLE_ENTRY { int dwPreviousTryLevel; // 上一层 try level,-1 表示没有外层 void (*lpfnFilter)(void); // __except 的过滤器,__finally 时为空 void (*lpfnHandler)(void); // handler 或 finally 函数入口 } SCOPETABLE_ENTRY;函数入口处,编译器把 try level 初始化为 -1。每进入一层__try,就把旧的 try level 压进作用域表的对应项,再把 try level 更新为当前层号。正常执行到__try末尾时,编译器安插的代码把 try level 恢复成旧值。也就是说,try level 这个槽位实时反映了“我现在已经正常退出了哪些层,还困在哪一层里面”。
这个槽位就是局部展开的“账本”。没有它,编译器根本不知道异常发生时哪些层还欠着__finally没跑。
2.2 handler 被调起来后怎么判断“是不是展开模式”
每个 SEH 函数挂到链表上的 handler,实际上是一个统一的入口例程,常见的编译形式是_except_handler3,开了安全校验后是_except_handler4。它不是写一次逻辑管所有异常,而是先看异常记录的ExceptionFlags,再决定走哪条分支。
第二趟展开时,系统会在异常记录的标志里置上EXCEPTION_UNWINDING (0x2),并在展开到目标帧时额外置上EXCEPTION_TARGET_UNWIND (0x20)。handler 入口的第一件事就是检查这个标志。
if (pExcept->ExceptionFlags & EXCEPTION_UNWINDING) { if (当前帧就是目标帧) { 调整 try level,准备进入 __except 块; 返回“执行 handler”; } else { 调用本函数的局部展开例程; 把 try level 置为 -1; 返回“继续展开”; } } else { // 第一趟:走过滤器匹配逻辑 }非目标帧的 handler 返回“继续展开”后,系统会自动退回该帧,继续沿注册链表向上处理。也就是说,x86 的局部展开本质上是一段“从异常帧向上,逐个节点问一遍要不要清理”的循环,每个节点靠 handler 入口例程里的分支决定自己是清理自己还是停下来。
2.3 编译器生成的局部展开例程到底干了什么
当_except_handler3判断自己不是目标帧时,它做的事情非常机械:拿到作用域表入口,从当前 try level 对应的那一项开始,反向往下扫。每扫到一项,看它的 previous level 是否大于等于某个边界,是的话就把对应的lpfnHandler(对__finally来说就是 finally 函数地址)调一遍。
这个扫表加调用 finally 的逻辑,编译器会生成一段独立的小例程,习惯上叫local_unwind或者__local_unwind。你可以在反汇编窗口里看到一个函数里多出来的这种内部符号,它接收一个帧指针参数,然后对作用域表做循环。
local_unwind 伪代码: scope = 当前帧注册记录关联的作用域表; level = 当前 try level; while (level >= 展开停止边界) { 调用 scope[level].lpfnHandler; // 执行 __finally level = scope[level].dwPreviousTryLevel; } 更新 try level 为展开停止边界;调用 finally 时,finally 函数体通过一个“展开专用返回地址”返回,编译器生成这个地址时故意让它不经过正常返回路径,这样 finally 内部即使逻辑复杂,也不会破坏展开过程维护的栈状态。这正是局部展开最微妙的地方:它是在“栈已经被判定要拆除”的前提下调用业务代码,任何栈操作都必须极其克制。
2.4 为什么说“局部”的本质是作用域表只描述本函数
作用域表是函数本地的静态数据,它只关心本函数内部那几层__try的嵌套关系,完全不关心其他函数。因此展开到一个函数时,系统只需要这个函数自己的信息就能完成本帧内部的清理。跨函数的局部展开,不过是这个单帧清理动作的串联。
这也是 x86 展开和后面 x64 展开在设计哲学上的分水岭:x86 靠“链表 + handler 主动跑业务例程”,每一步都灵活但难以静态验证;x64 则把所有展开信息固化成数据,让系统可以脱离了函数自身的配合代码,纯粹靠数据驱动完成整个拆除动作。
3. 实操:自己造一个局部展开并用调试器全程围观
3.1 一段能稳定复现局部展开的最小样例
为了看过程,先把样例写得越简单越好。我用两个函数分别演示“函数内局部展开”和“跨函数局部展开”。
#include <windows.h> #include <stdio.h> static void Trace(const char *s) { printf(" [%s]\n", s); fflush(stdout); } void Inner(void) { __try { Trace("Inner:try 开始"); RaiseException(0xE0000001, 0, 0, NULL); Trace("Inner:try 结束(异常后不执行)"); } __finally { Trace("Inner:finally 执行(跨函数局部展开)"); } } void SameFunc(void) { __try { __try { Trace("SameFunc:内层 try 开始"); RaiseException(0xE0000002, 0, 0, NULL); } __finally { Trace("SameFunc:内层 finally 执行(同函数局部展开)"); } } __except (EXCEPTION_EXECUTE_HANDLER) { Trace("SameFunc:外层 except 接住"); } } int main(void) { SetUnhandledExceptionFilter(NULL); __try { SameFunc(); Inner(); } __except (EXCEPTION_EXECUTE_HANDLER) { Trace("main:外层兜底"); } Trace("main:正常返回"); return 0; }编译时注意加/EHa,这是原生 SEH 和 C++ 异常处理共存时的标准打开方式。只写 C 不写 C++ 时,部分编译器也允许默认支持__try/__except,但混用 C++ 对象时一定要带/EHa,否则析构逻辑和展开逻辑会打架。
预期输出顺序是:
[SameFunc:内层 try 开始] [SameFunc:内层 finally 执行(同函数局部展开)] [SameFunc:外层 except 接住] [Inner:try 开始] [Inner:finally 执行(跨函数局部展开)] [main:外层兜底] [main:正常返回]这个输出现场就是局部展开最直白的证据:内层 finally 先于外层 except 执行,且紧接着的代码也能继续往下走。
3.2 断点应该打在哪些关键点上
在调试器里跟踪时,我习惯按“入口、搜索、展开、落地”四个阶段布点。
第一阶段的入口断点是 handler 公共入口,x86 下直接对_except_handler3下断点,启动后设置条件,只匹配我们需要看的进程。这里会命中两次,第一次第一趟搜索,第二次展开,靠ExceptionFlags区分。
第二阶段的搜索断点是看过滤器执行的,可以把断点下在过滤器函数体上。过滤器返回EXCEPTION_EXECUTE_HANDLER的那次是关键,它会直接决定后面的展开走向。
第三阶段的展开断点最有用,直接对ntdll!RtlUnwind和ntdll!RtlUnwindEx下断点。RtlUnwind在 x86 下是导出函数,展开动作从这里开始;x64 下实际工作是RtlUnwindEx配合RtlVirtualUnwind完成的。命中后执行kb查看调用栈,能明确看到当前展开停在哪个帧。
第四阶段是落地断点,直接下到__except处理块开头的函数上,确认展开完成后控制权是不是如预期转移。
对 x86 还可以用!exchain查看当前线程的异常注册链表,在展开前后各看一眼。展开前链表里有多个注册节点,展开完毕后当前活跃节点应该正好停在目标帧,链路上已经被“拆除”的帧的节点不再出现。
3.3 展开过程中值得盯住的三个现场指标
第一个指标是异常记录的ExceptionFlags。在第二次命中 handler 入口时,这个字段应该出现EXCEPTION_UNWINDING位;展开到目标帧时还会多出EXCEPTION_TARGET_UNWIND位。看到这两位的组合,就说明系统确实在走展开路径,而不是又触发了一次新的异常分派。
第二个指标是栈指针。展开过程中每个非目标帧处理完,系统都会把栈指针恢复到该帧的边界,继续向目标帧推进。观察esp/rsp的阶梯式变化,就能直观地看到“局部展开只走到目标帧为止”的边界感。如果在目标帧之上还有栈帧没有被回收,而异常却已经被处理了,那通常意味着有人绕过了标准展开路径。
第三个指标是 try level 槽位。展开例程每处理一层__finally,就把 try level 改回上一层值。单步跟入 local_unwind 例程,逐个对比作用域表项里的 previous level,基本就能画出这个函数内部的“清理进度图”。
看这几个现场的时候,dt ntdll!_EXCEPTION_RECORD这类结构展开命令很顺手,可以一次把异常代码、标志、发生地址都拉出来。重点记录异常发生地址所在的函数、目标帧在栈上的位置,以及两者之间隔了多少层__finally。
4. x64 的差别:数据驱动展开与语言相关处理器
4.1 没有 fs:[0] 链表之后,展开靠的是元数据
x64 相比 x86 最大的变化,是废除了fs:[0]异常注册链表。每个函数的展开信息被编译进镜像的.pdata段,对应一个RUNTIME_FUNCTION条目,里面记录函数起始 RVA、结束 RVA 以及UNWIND_INFO的位置。UNWIND_INFO里是一串展开指令码,比如“哪个寄存器被压栈”“保存在哪个栈偏移”,还有可选的“语言相关处理器”入口。
展开时系统从当前 RIP 出发,在.pdata里找到一个覆盖该 RIP 的函数条目,然后让RtlVirtualUnwind按UNWIND_INFO反向回放函数序言。回放的目的不是真的执行代码,而是“算”出栈指针应该恢复到哪个位置、哪些非易失寄存器应该从哪里恢复。每一帧的展开,都变成了一次纯数据计算。
这意味着 x64 的展开不再依赖函数内部配合执行的“展开例程”。系统可以只凭镜像里的元数据,在一帧一帧之间移动。真正需要与业务代码交互的,只有那些挂载了语言相关处理器的函数。
4.2 语言相关处理器和它的作用域表
SEH 在 x64 上的语言相关处理器是__C_specific_handler,它消费的数据块是镜像里的一块作用域表,每项四个字段:
typedef struct _SCOPE_TABLE_ENTRY64 { ULONG BeginAddress; // 受保护区间起始 RVA ULONG EndAddress; // 受保护区间结束 RVA ULONG HandlerAddress; // 过滤器或清理函数的 RVA ULONG JumpTarget; // 恢复执行的目标 RVA } SCOPE_TABLE_ENTRY64;四个字段描述的是:当前 RIP 落在[BeginAddress, EndAddress]区间内时,这个条目对应的HandlerAddress和JumpTarget才有效。区间内的受保护代码、区间外的普通代码,对展开来说完全是两回事。
局部展开触发时,系统在回放完一帧的展开信息后,把语言相关处理器也调一遍。处理器拿到“当前 RIP 所在位置”和“是否处于展开模式”,在自己的作用域表里做一次区间匹配。凡是匹配到清理类条目的,就调用对应的__finally函数体。这个动作和 x86 的 local_unwind 扫表逻辑殊途同归,只是x86 靠运行时的 try level 状态机,x64 靠编译期就固定下来的地址区间。
4.3 一个容易忽略的细节:JumpTarget 的语义
JumpTarget对__finally作用域通常是 0,标明“没有恢复目标”,因为 finally 只是清理,展开后控制权不会落回这里;而对__except过滤器匹配成功的条目,JumpTarget指向__except块实际要执行的那段代码。展开完成后,系统会直接跳转到这个目标 RVA 继续运行。
看清楚这一层就能理解 why x64 的展开描述是所有异常处理的“独木桥”:如果编译器因为优化错误把一个__finally条目的JumpTarget错填成非 0,展开结束后控制权会跑回一个已经被清理过的函数中间,栈已经回滚,寄存器已恢复,代码却以为上下文还在,几乎必然崩溃。这种崩溃很难调,因为现场早已被破坏,好在现代编译器对这块处理很成熟,平时见到的更多是误改镜像焊死数据导致的怪异行为。
5. 常见翻车现场与排查套路
5.1__finally跑了两次,或者一次都没跑
这是我在工程里被问到最多的问题。真相往往是:只要函数正常执行路径和展开路径都经过同一个__finally块,而编写者又在__finally里做了“非平凡且带副作用”的事情,两方的执行效果就会叠加。展开过程中的 finally 调用和正常退出时的 finally 调用,编译器会分别在作用域表项和普通退出代码里各安插一次,两次都必须保留。
反过来,一次都没跑基本都是 try level 状态被破坏。比较典型的是在__finally里调用了一个会改当前函数栈帧的汇编函数,或者极端优化的longjmp直接跳走了。此时局部展开扫表时发现 try level 已经不在预期的层级,会跳过对应的清理节点。
5.2 处理了异常却仍然崩溃,问题出在 cookie 校验
x86 下开了/GS编译后,函数会使用_except_handler4。这个入口例程比_except_handler3多一道全局安全 cookie 校验,其作用域表项也多了 cookie 偏移字段。常见崩溃场景是:把旧编译器编译的 SEH 函数库放到新进程里,handler 入口例程版本不匹配,cookie 校验失败,异常处理直接进入未定义分支。这时候能看到异常记录正常、展开也正常,但在 handler 入口处就崩了。
遇到这种问题,第一反应不是追业务逻辑,而是确认镜像的编译选项和链接顺序。SEH 相关的系统组件对编译选项非常敏感,多对象混编时尽量统一/GS、/EHa、/SAFESEH的设置。
5.3 在__finally里做危险操作
这里只做一次严肃提醒:不要在__finally里修改栈指针,不要在里面return一个非 void 值,不要在里面依赖展开时已经失效的局部变量副本。展开模式下,函数的一部分局部状态(尤其是那些被寄存器缓存的值)可能已经被系统“还原”到函数入口状态,或者被修改成非预期状态。要访问数据就在进入__try之前把值放到稳定的栈位置。
5.4 排查套路速查表
| 现象 | 优先怀疑 | 调试动作 |
|---|---|---|
__finally没执行 | try level 被破坏,或作用域表区间未覆盖异常点 | 在 local_unwind /__C_specific_handler下断点,检查匹配到的表项 |
__finally执行两次 | 正常退出与展开路径重复调用 | 断开正常退出路径,单独观察展开路径 |
| 异常已接住但进程仍崩 | cookie 校验 / 编译器选项混用 | 对比各模块/GS、/EHa、/SAFESEH编译选项 |
| 展开过程中二次异常 | finally 函数本身触发新异常 | 在错误码 0xE000000x 处手动二次断点,检查嵌套标志 |
| x64 下展开后跳错位置 | JumpTarget字段被改动 | dt展开表项,核对目标 RVA 是否落于__except块 |
排查时还有一个偷懒又高效的技巧:全程开着异常记录日志,把所有命中过的EXCEPTION_RECORD按时间排序。局部展开本质上是一次“受控的栈拆除”,它的现场很难事后还原,但异常记录的先后顺序、每个记录上携带的标志位,足够还原出那次删除动作是从哪个帧起步、停在哪一帧。把日志和.pdata交叉核对,大部分态度明确的异常问题都能在三分钟内找到方向。
6. 写在后面:与 C++ 异常处理的衔接观察
我个人在项目里最深的体会是:理解局部展开之后,C++ 异常处理的神秘感会消失一大半。C++ 的try/catch在底层同样要走第二趟展开,只是语言相关处理器换成了__CxxFrameHandler这一类专用于 C++ 语义的实现,它除了执行析构函数,还要维护“当前活跃 catch 块”这类更复杂的语言状态。
调试时如果同时混用两者,断点会频繁地进出 SEH 的展开路径。这时候不要被符号信息误导,先把异常记录标志位和栈帧走势看明白,再回头对应语言层是什么操作。展开这条主线永远是统一的,变化的只是每个帧里“清理函数”的具体内容。
还有一个值得专门一提的小技巧:如果你手工伪造异常记录触发展开,一定记得把EXCEPTION_NONCONTINUABLE这类标志处理好。非续执行异常一旦被错误地“继续执行”,系统会抛出人为拉起的二次异常,展开过程就会裂开。这个现象最开始差点让我以为是局部展开本身的 bug,后来才意识到是伪造异常记录时标志位没对齐。
局部展开在异常处理链路里属于“执行时被系统自动代劳、平时看不见”的部分,但恰恰是这部分决定了异常语义的轻重。把 x86 的试错机制式 handler 和 x64 的数据驱动展开放在一起看,会明显感到设计代际的变化:一版靠运行时状态机灵活处理,一版把可计算信息全部前移到编译期。理解了两者的取舍,再去看编译器生成的展开元数据,就像看一张提前画好的拆解图,每一步都清晰得很。