SEH局部展开详解:异常处理中的栈展开机制与调试实战 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 的数据驱动展开放在一起看会明显感到设计代际的变化一版靠运行时状态机灵活处理一版把可计算信息全部前移到编译期。理解了两者的取舍再去看编译器生成的展开元数据就像看一张提前画好的拆解图每一步都清晰得很。