前言很多人一提到 SEH第一反应就是__try{}__except(...){}但如果只停留在这一层就很容易把 SEH 理解成“编译器提供的一种语法”。实际上在 Windows x86 下SEH 最原始的样子并不复杂在栈上构造一个异常注册节点然后把它挂到 FS:[0]。只要这一步完成了当异常发生时系统就会顺着这条链去找处理函数。所以我们先不急着分析__try / __except而是先亲手做一遍最底层的事情自己把异常处理函数挂到 FS:[0]一、先看最原始的异常注册节点在 x86 的经典 SEH 中最核心的节点结构可以先简化理解成这样typedefstruct_EXCEPTION_REGISTRATION_RECORD{struct_EXCEPTION_REGISTRATION_RECORD*Next;PEXCEPTION_ROUTINE Handler;}EXCEPTION_REGISTRATION_RECORD;它只有两个成员Next指向上一个异常注册节点Handler当前节点对应的异常处理函数也就是说SEH 链本质上就是一条单向链表。每当一个新的异常处理范围建立时系统并不是去改什么全局表而是先取出原来的 FS:[0]把它保存到当前节点的 Next再把当前节点地址写回 FS:[0]这样当前节点就成了新的链表头。二、完整示例手动挂接 FS:[0]#includeiostream#includewindows.husing namespace std;//右键项目 → 属性链接器 → 命令行,添加: /SAFESEH:NO//参数1异常信息//参数2当前异常注册记录节点的栈地址也就是挂的_EXCEPTION_REGISTRATION 结构在栈的地址//参数3发生异常时的上下文环境//参数4分发器附加信息EXCEPTION_DISPOSITION NTAPIseh(_Inout_struct_EXCEPTION_RECORD*ExceptionRecord,_In_ PVOID EstablisherFrame,_Inout_struct_CONTEXT*ContextRecord,_In_ PVOID DispatcherContext){MessageBoxA(0,捕获到异常,0,0);if(ExceptionRecord-ExceptionCode0xc0000094){//是除0异常MessageBoxA(0,捕获到除0异常,0,0);ContextRecord-Eip3;returnExceptionContinueExecution;}returnExceptionContinueSearch;}intmain(){EXCEPTION_REGISTRATION_RECORD reg{0};reg.Handlerseh;__asm{mov eax,fs:[0];//取原来的链表头lea ecx,reg;mov dword ptr[reg],eax;//reg.Next 原链表头mov fs:[0],ecx;// 把 reg 挂到 FS:[0]}printf(手动安装 SEH 完成\n);//触发除0异常intx0;inty0;intzx/y;__asm{lea ecx,reg mov eax,[ecx]//eax reg.Next 也就是原链表头mov fs:[0],eax// 卸载当前 SEH}cout666endl;system(pause);return0;}三、先认识 __try / __except 背后的异常注册结构上节我们手动挂接FS:[0]时用到的是最原始的 SEH 节点typedefstruct_EXCEPTION_REGISTRATION_RECORD{struct_EXCEPTION_REGISTRATION_RECORD*Next;PEXCEPTION_ROUTINE Handler;}EXCEPTION_REGISTRATION_RECORD;它只有两个成员NextHandler但当我们开始分析__try / __except的反汇编时就会发现编译器生成的结构已经不再这么简单了:struct_EXCEPTION_REGISTRATION{PEXCEPTION_REGISTRATION_RECORD next;// 上一个异常注册节点PVOID _except_handler4;// 统一异常处理入口scopetable_entry*scopetable;// 作用域表inttrylevel;// 当前 try 层级int_ebp;// 当前函数栈帧基址};这一节里我们先不展开 scopetable_entry 的具体内容只先理解这几个成员分别是干什么的。1️⃣next这个字段和我们手动挂链时完全一样它指向上一个异常注册节点也就是原来的 FS:[0]所以本质没变__try / __except 仍然是把一个新的异常节点挂到链表头。2️⃣ _except_handler4这个字段不是__except {}代码块本身。它是 MSVC 为这类 SEH 代码统一使用的一个异常分发入口也就是当前这个函数真正挂到异常链上的处理函数其实是 _except_handler4当异常发生时系统先调用它然后再由它去结合 scopetable、trylevel 等信息判断应该进入哪个过滤表达式、哪个 __except 块。所以这一点很重要__try / __except 并不是把你写的 __except {} 直接挂到 FS:[0]。真正挂上去的是编译器提供的_except_handler4。3️⃣ scopetable这个字段指向当前函数对应的一张“作用域表”。你可以先把它理解成这张表记录了当前函数里有哪些 __try、它们对应哪个过滤表达式、对应哪个异常处理块。这一节我们先不展开 scopetable_entry 的细节只先记住一个函数里有多个 __try / __except编-译器不会给每个 try 都单独挂一个新的 FS:[0] 节点它更常见的做法是挂一个异常注册结构再配一张作用域表然后通过 trylevel 来表示“当前代码执行到了哪一层 try”。4️⃣trylevel这个字段非常关键。它的作用可以先理解成用来表示“当前函数此时正处于哪个 try 层级”。比如-2通常表示当前不在任何 try 范围内0进入第 1 个 try1进入第 2 个 try2进入更内层的 try也就是说在同一个函数内部哪怕你写了多个__try编译器也不会反复重新挂链而是只挂一次然后不断修改 trylevel。这个现象后面看反汇编时会非常明显。四、__try / __except反汇编分析C语言代码#includeiostream#includewindows.husing namespace std;intmain(){__try{couttry1endl;}__except(1){}__try{couttry2endl;__try{couttry3endl;}__except(1){}}__except(1){}return0;}反汇编代码intmain(){00407AD055push ebp;保存旧栈帧基址00407AD18B EC mov ebp,esp;建立当前函数栈帧00407AD36A FE push0FFFFFFFEh;压入 TryLevel-2表示当前还不在任何 __try 作用域中00407AD568B0894F00push4F89B0h;压入 ScopeTable 地址00407ADA68B02A4400push offset _except_handler4;压入统一异常处理入口 _except_handler400407ADF64A100000000mov eax,dword ptr fs:[00000000h];eax原来的异常链表头 FS:[0]00407AE550push eax;压入原 FS:[0]作为注册记录中的 next00407AE681C438FF FF FF add esp,0FFFFFF38h;等价于 sub esp,0C8h为大量局部变量留栈空间00407AEC53push ebx;保存被调用者保存寄存器 EBX00407AED56push esi;保存 ESI00407AEE57push edi;保存 EDI00407AEF8D7D E8 lea edi,[ebp-18h];edi[ebp-18]准备从这里开始初始化局部变量区00407AF233C9 xor ecx,ecx;ecx000407AF4 B8 CC CC CC CC mov eax,0CCCCCCCCh;eax0xCCCCCCCCVS调试版常用填充值00407AF9 F3 AB rep stos dword ptr es:[edi];用0xCCCCCCCC批量填充局部变量区便于调试发现未初始化使用00407AFB A124E04F00mov eax,dword ptr[__security_cookie];取全局 GS 安全 cookie00407B003145F8 xor dword ptr[ebp-8],eax;[ebp-8]原本是 ScopeTable 地址这里与 cookie 异或EH4 用来保护 ScopeTable00407B0333C5 xor eax,ebp;eaxsecurity_cookie^ebp00407B0550push eax;压入 EH/GS 校验值供 _except_handler4 做完整性检查00407B068D45F0 lea eax,[ebp-10h];eax当前异常注册记录起始地址next 所在位置00407B0964A300000000mov dword ptr fs:[00000000h],eax;FS:[0]registration把当前函数的异常注册节点挂到异常链表头00407B0F8965E8 mov dword ptr[ebp-18h],esp;保存当前 esp异常进入 __except 时需要恢复这个栈位置 __try00407B12 C745FC00000000mov dword ptr[ebp-4],0;TryLevel0表示进入第1个 __try{couttry1endl;00407B1968D0254000push offset std::endlchar,std::char_traitschar;压入 endl 操纵符00407B1E6830F64C00push offset stringtry1;压入字符串try100407B2368A8 FA4F00push offset std::cout;压入 cout 对象地址00407B28 E8639C FF FF call std::operatorstd::char_traitschar;调用 operator输出字符串00407B2D83C408add esp,8;平衡前两个 push 的参数栈空间00407B308B C8 mov ecx,eax;ecx返回的 ostream 对象00407B32 E889CA FF FF call std::basic_ostreamchar,std::char_traitschar::operator;输出 endl}00407B37 C745FC FE FF FF FF mov dword ptr[ebp-4],0FFFFFFFEh;TryLevel-2表示第1个 __try 已结束00407B3E EB10jmp main80h(0407B50h);正常执行路径跳过后面的 filter/except 代码继续往下__except(1)00407B40 B801000000mov eax,1;filter 表达式返回1即 EXCEPTION_EXECUTE_HANDLER00407B45 C3 ret;返回给 _except_handler4表示“该异常我处理”00407B468B65E8 mov esp,dword ptr[ebp-18h];如果异常进入该 __except先把 esp 恢复到 try 之前保存的位置}00407B49 C745FC FE FF FF FF mov dword ptr[ebp-4],0FFFFFFFEh;except 执行完毕后TryLevel 重新设为-2{}__try00407B50 C745FC01000000mov dword ptr[ebp-4],1;TryLevel1表示进入第2个外层__try{couttry2endl;00407B5768D0254000push offset std::endlchar,std::char_traitschar;压入 endl00407B5C6838F64C00push offset stringtry2;压入字符串try200407B6168A8 FA4F00push offset std::cout;压入 cout00407B66 E8259C FF FF call std::operatorstd::char_traitschar;输出try200407B6B83C408add esp,8;平衡参数00407B6E8B C8 mov ecx,eax;ecxostream00407B70 E84B CA FF FF call std::basic_ostreamchar,std::char_traitschar::operator;输出 endl __try00407B75 C745FC02000000mov dword ptr[ebp-4],2;TryLevel2表示进入内层第3个 __try{couttry3endl;00407B7C68D0254000push offset std::endlchar,std::char_traitschar;压入 endl00407B816840F64C00push offset stringtry3;压入字符串try300407B8668A8 FA4F00push offset std::cout;压入 cout00407B8B E8009C FF FF call std::operatorstd::char_traitschar;输出try300407B9083C408add esp,8;平衡参数00407B938B C8 mov ecx,eax;ecxostream00407B95 E826CA FF FF call std::basic_ostreamchar,std::char_traitschar::operator;输出 endl}00407B9A C745FC01000000mov dword ptr[ebp-4],1;内层 __try 正常结束TryLevel 恢复为外层 __try 的100407BA1 EB10jmp main0E3h(0407BB3h);正常路径跳过内层 filter/except 代码__except(1)00407BA3 B801000000mov eax,1;内层 filter 返回1即 EXCEPTION_EXECUTE_HANDLER00407BA8 C3 ret;返回给 _except_handler4表示处理该异常00407BA98B65E8 mov esp,dword ptr[ebp-18h];若异常进入该内层 except先恢复 esp}00407BAC C745FC01000000mov dword ptr[ebp-4],1;内层 except 执行完后回到外层 __try对应 TryLevel1{}}00407BB3 C745FC FE FF FF FF mov dword ptr[ebp-4],0FFFFFFFEh;外层第2个 __try 正常结束TryLevel-200407BBA EB10jmp $LN120Ah(0407BCCh);正常路径跳过外层 filter/except 代码前往return__except(1)00407BBC B801000000mov eax,1;外层 filter 返回1即 EXCEPTION_EXECUTE_HANDLER00407BC1 C3 ret;返回给 _except_handler4表示由该 except 处理00407BC28B65E8 mov esp,dword ptr[ebp-18h];若异常进入该外层 except先恢复 esp{}}00407BC5 C745FC FE FF FF FF mov dword ptr[ebp-4],0FFFFFFFEh;外层 except 执行完毕TryLevel 恢复为-2{}return0;00407BCC33C0 xor eax,eax;eax0准备返回0}对于同一个使用__try / __except的函数无论其内部顺序出现多少个或者嵌套多少层__try / __except编译器通常都只在函数入口构造并挂接一次异常注册记录也就是只向当前线程的异常链表中插入一个节点。后续不同 try 作用域的切换主要依赖ScopeTable和TryLevel来描述。只有当该函数再次被调用时例如递归调用才会随着新的栈帧再创建一个新的异常注册记录并再次挂入线程异常链表。五、总结这里我们先不展开scopetable_entry的具体结构而是先借助简化结构体建立一个最重要的认识对于__try / __except来说编译器并不是给每一个 try 都单独往 FS:[0] 挂一个新的异常节点。更常见的做法是在函数入口只挂一次异常注册结构然后配合 scopetable 和 trylevel 来描述当前函数内部多个 try 的关系。从这段反汇编中我们已经能清楚看到next 对应原来的 fs:[0]_except_handler4 是真正挂到异常链上的统一入口scopetable 保存当前函数所有异常作用域的信息trylevel 随着进入、退出不同 try 不断变化所以到了这里__try / __except的底层轮廓其实已经出来了它并不是“出错后直接跳进except那么简单而是“先进入 _except_handler4再结合 ScopeTable TryLevel 去决定 filter 和 handler”。既然 _except_handler4 需要依靠 scopetable trylevel 才能知道当前应该执行哪个过滤表达式、进入哪个 __except 块那么接下来就要继续看这张 ScopeTable 里面到底存了什么。