深入解析结构化异常处理(SEH)

本文深入解析了Windows操作系统中的结构化异常处理(SEH),介绍了当线程发生错误时,操作系统如何调用用户定义的回调函数 `_except_handler`,以及如何通过EXCEPTION_REGISTRATION结构链表来管理异常处理程序。文章通过示例程序展示了SEH的工作原理,揭示了操作系统层面SEH的机制和回调函数的调用流程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

尽管以前写过一篇SEH相关的文章《关于SEH的简单总结》, 但那真的只是皮毛,一直对Windows异常处理的原理似懂非懂, 看了下面的文章 ,一切都豁然开朗. 
1997年文章,Windows技术的根一直没变: http://www.microsoft.com/msj/0197/exception/exception.aspx

Matt Pietrek 著  
董岩 译
 

在Win32操作系统提供的所有功能中,使用最广泛而又没有公开的恐怕要数结构化异常处理(Structured Exception Handling,SEH) 了。当你考虑Win32结构化异常处理时,也许会想到__try、__finally和__except等术语。可能你在任何一本讲解Win32的好书上 都能找到关于SEH较为详细的描述,甚至Win32 SDK文档也对使用__try、__finally和__except进行结构化异常处理进行了相当完整的描述。

既 然已经有了这些文档,那为什么我还说SEH并未公开呢?本质上来说,Win32结构化异常处理是操作系统提供的服务。你可能找到的所有关于SEH方面的文 档都只是描述了某个特别的编译器的运行时库对操作系统实现的封装。关键字__try、__finally或者__except并没有什么神奇的。 Microsoft的操作系统和编译器开发小组定义了这些关键字和它们的作用。其它C++编译器厂商完全按照它们的语义来就可以了。当编译器的SEH支持 层把原始的操作系统SEH的复杂性封装起来的时候,它同时也把原始的操作系统SEH的细节隐藏了起来。

我 曾经接到大量来自想自己实现编译器层面SEH的人发来的电子邮件,他们苦于找不到关于操作系统SEH实现方面的任何文档。按说,我应该能够告诉他们 Visual C++或Borland C++的运行时库源代码就是他们想要的。但是不知出于什么原因,编译器层面的SEH看起来好像是个大秘密。无论是Microsoft还是Borland都 没有提供他们的SEH支持层最底层的源代码。(现在Microsoft仍然没有提供这些源代码,它提供的是编译过的目标文件,而Borland则提供了相 应的源代码。)

在 本文中,我会剥掉结构化异常处理外面的包装直至其最基本的概念。在此过程中,我会把操作系统提供的支持与编译器通过代码生成和运行时库提供的支持分开来 说。当我挖掘到关键的操作系统例程时,我使用的是运行于Intel处理器上的Windows NT 4.0。但是我这里讲的大部分内容同样也适用于其它处理器。

我会避免涉及到真实的C++异常处理,它使用的是catch()而不是__except。从内部来讲,真实的C++异常处理的实现与我这里要讲的非常相似。但是真实的C++异常处理有一些其它的复杂问题,它会混淆我这里要讲的一些概念。

在 挖掘组成Win32 SEH的晦涩的.H和.INC文件的过程中,我发现最好的信息来源之一是IBM OS/2头文件(特别是BSEXCPT.H)。如果你涉足这方面已经有一段时间了,就不会感到太奇怪。这里描述的SEH机制是早在Microsoft还工 作在OS/2上时就已经定义好的。由于这个原因,你会发现Win32下的SEH和OS/2下的SEH极其相似。(现在我们可能已经没有机会体验这一点 了,OS/2已经永远成为历史了。)

浅析SEH

如 果我把SEH的所有细节一股脑儿全倒给你,你可能无法接受。因此我先从一小部分开始,然后层层深入。如果你以前从未接触过结构化异常处理,那正好,因为你 头脑中没有一些自己设想的概念。如果你以前接触过SEH,最好把头脑中有关__try、GetExceptionCode和 EXCEPTION_EXECUTE_HANDLER之类的词统统忘掉,假设它对你来说是全新的。深呼吸。准备好了吗?让我们开始吧!

设想我告诉过你,当一个线程出现错误时,操作系统给你一个机会被告知这个错误。说得更明白一些就是,当一个线程出现错误时,操作系统调用用户定义的一个回调函数。这个回调函数可以做它想做的一切。例如它可以修复错误,或者它也可以播放一段音乐。无论回调函数做什么,它最后都要返回一个值来告诉系统下一步做什么。(这不是十分准确,但就此刻来说非常接近。)

当你的某一部分代码出错时,系统再回调你的其它代码,那么这个回调函数看起来是什么样子呢?换句话说,你想知道关于异常什么类型的信息呢?实际上这并不重要,因为Win32已经替你做了决定。异常的回调函数的样子如下:

EXCEPTION_DISPOSITION
__cdecl _except_handler( struct _EXCEPTION_RECORD *ExceptionRecord,
                        void * EstablisherFrame,
                        struct _CONTEXT *ContextRecord,
                        void * DispatcherContext);

这个原型来自标准的Win32头文件EXCPT.H,乍看起来有些费解。但如果你仔细看,它并不是很难理解。首先,忽略掉返回值的类型(EXCEPTION_DISPOSITION)。你得到的基本信息就是它是一个叫作_except_handler并且带有四个参数的函数。

这个函数的第一个参数是一个指向EXCEPTION_RECORD结构的指针。这个结构在WINNT.H中定义,如下所示:

typedef struct _EXCEPTION_RECORD {
   DWORD ExceptionCode;
   DWORD ExceptionFlags;
   struct _EXCEPTION_RECORD *ExceptionRecord;
   PVOID ExceptionAddress;
   DWORD NumberParameters;
   DWORD ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;

这个结构中的 ExcepitonCode成员是赋予异常的代码。通过在WINNT.H中搜索以“STATUS_”开头的#define定义,你可以得到一个异常代码列 表。例如所有人都非常熟悉的STATUS_ACCESS_VIOLATION的代码是0xC0000005。一个更全面的异常代码列表可以在 Windows NT DDK的NTSTATUS.H中找到。此结构的第四个成员是异常发生的地址。其它成员暂时可以忽略。

_except_handler函数的第二个参数是一个指向establisher帧结构的指针。它是SEH中一个至关重要的参数,但是现在你可以忽略它。

_except_handler回调函数的第三个参数是一个指向CONTEXT结 构的指针。此结构在WINNT.H中定义,它代表某个特定线程的寄存器值。图1显示了CONTEXT结构的成员。当用于SEH时,CONTEXT结构表示 异常发生时寄存器的值。顺便说一下,这个CONTEXT结构就是GetThreadContext和SetThreadContext这两个API中使用 的那个CONTEXT结构。

图1 CONTEXT结构

typedef struct _CONTEXT
{
    DWORD ContextFlags;
    DWORD Dr0;
    DWORD Dr1;
    DWORD Dr2;
    DWORD Dr3;
    DWORD Dr6;
    DWORD Dr7;
    FLOATING_SAVE_AREA FloatSave;
    DWORD SegGs;
    DWORD SegFs;
    DWORD SegEs;
    DWORD SegDs;
    DWORD Edi;
    DWORD Esi;
    DWORD Ebx;
    DWORD Edx;
    DWORD Ecx;
    DWORD Eax;
    DWORD Ebp;
    DWORD Eip;
    DWORD SegCs;
    DWORD EFlags;
    DWORD Esp;
    DWORD SegSs;
} CONTEXT;

_except_handler回调函数的第四个参数被称为DispatcherContext。它暂时也可以被忽略。

到现在为止,你头脑中已经有了一个当异常发生时会被操作系统调用的回调函数的模型了。这个回调函数带四个参数,其中三个指向其它结构。在这些结构中,一些域比较重要,其它的就不那么重要。这里的关键是_exept_handler回调函数接收到操作系统传递过来的许多有价值的信息,例如异常的类型和发生的地址。使用这些信息,异常回调函数就能决定下一步做什么。

对 我来说,现在就写一个能够显示_except_handler作用的样例程序是再诱人不过的了。但是我们还缺少一些关键信息。特别是,当错误发生时操作系 统是怎么知道到哪里去调用这个回调函数的呢?答案是还有一个称为EXCEPTION_REGISTRATION的结构。通篇你都会看到这个结构,所以不要 跳过这一部分。我唯一能找到的EXCEPTION_REGISTRATION结构的正式定义是在Visual C++运行时库源代码中的EXSUP.INC文件中:

_EXCEPTION_REGISTRATION struc
    prev                dd       ?
    handler             dd       ?
_EXCEPTION_REGISTRATION ends

这 个结构在WINNT.H的NT_TIB结构的定义中被称为_EXCEPITON_REGISTARTION_RECORD。唉,没有一个地方能够找到 _EXCEPTION_REGISTRATION_RECORD的定义,所以我不得不使用EXSUP.INC中这个汇编语言的结构定义。这是我前面所说 SEH未公开的一个证据。(读者可以使用内核调试器,如KD或SoftICE并加载调试符号来查看这个结构的定义。

下图是在KD中的结果:

下图是在SoftICE中的结果: 

译者注)

无 论正在干什么,现在让我们回到手头的问题上来。当异常发生时,操作系统是如何知道到哪里去调用回调函数的呢?实际 上,EXCEPTION_REGISTARTION结构由两个域组成,第一个你现在可以忽略。第二个域handler,包含一个指向 _except_handler回调函数的指针。这让你离答案更近一点,但现在的问题是,操作系统到哪里去找 EXCEPTION_REGISTATRION结构呢?

要回答这个问题,记住结构化异常处理是基于线程的这一点是非常有用的。也就是说,每个线程有它自己的异常处理回调函数。在1996年五月的Under The Hood专栏中,我介绍了一个关键的Win32数据结构——线程信息块(Thread Information/Environment Block,TIB或TEB)。这个结构的某些域在Windows NT、Windows 95、Win32s和OS/2上是相同的。TIB的 第一个DWORD是一个指向线程的EXCEPTION_REGISTARTION结构的指针。在基于Intel处理器的Win32平台上,FS寄存器总是 指向当前的TIB。因此在FS:[0]处你可以找到一个指向EXCEPTION_REGISTARTION结构的指针。

到 现在为止,我们已经有了足够的认识。当异常发生时,系统查找出错线程的TIB,获取一个指向EXCEPTION_REGISTRATION结构的指针。在 这个结构中有一个指向_except_handler回调函数的指针。现在操作系统已经知道了足够的信息去调用_except_handler函数,如图 2所示。


图2 _except_handler函数

  把 这些小块知识拼凑起来,我写了一个小程序来演示上面这个对操作系统层面的结构化异常处理的简化描述,如图3的MYSEH.CPP所示。它只有两个函数。 main函数使用了三个内联汇编块。第一个内联汇编块通过两个PUSH指令(“PUSH handler”和“PUSH FS:[0]”)在堆栈上创建了一个EXCEPTION_REGISTRATION结构。PUSH FS:[0]这条指令保存了先前的FS:[0]中的值作为这个结构的一部分,但这在此刻并不重要。重要的是现在堆栈上有一个8字节的 EXCEPTION_REGISTRATION结构。紧接着的下一条指令(MOV FS:[0],ESP)使线程信息块中的第一个DWORD指向了新的EXCEPTION_REGISTRATION结构。(注意堆栈操作)

图3 MYSEH.CPP

//==================================================
// MYSEH - Matt Pietrek 1997
// Microsoft Systems Journal, January 1997
// FILE: MYSEH.CPP
// 用命令行CL MYSEH.CPP编译
//==================================================

#define WIN32_LEAN_AND_MEAN

#include <windows.h>
#include <stdio.h>

DWORD scratch;

EXCEPTION_DISPOSITION
__cdecl
_except_handler( struct _EXCEPTION_RECORD *ExceptionRecord,
                void * EstablisherFrame,
                struct _CONTEXT *ContextRecord,
                void * DispatcherContext )
{
    unsigned i;

    // 指明是我们让流程转到我们的异常处理程序的
    printf( "Hello from an exception handler\n" );

    // 改变CONTEXT结构中EAX的值,以便它指向可以成功进写操作的位置
    ContextRecord->Eax = (DWORD)&scratch;

    // 告诉操作系统重新执行出错的指令
return ExceptionContinueExecution;

}

int main()
{
    DWORD handler = (DWORD)_except_handler;

    __asm
    { 
        // 创建EXCEPTION_REGISTRATION结构:
        push handler // handler函数的地址
        push FS:[0] // 前一个handler函数的地址
        mov FS:[0],ESP // 安装新的EXECEPTION_REGISTRATION结构
    }

    __asm
    {
        mov eax,0     // 将EAX清零
        mov [eax], 1 // 写EAX指向的内存从而故意引发一个错误
    }

    printf( "After writing!\n" );

    __asm
    { 
        // 移去我们的EXECEPTION_REGISTRATION结构
        mov eax,[ESP]    // 获取前一个结构
        mov FS:[0], EAX // 安装前一个结构
        add esp, 8       // 将我们的EXECEPTION_REGISTRATION弹出堆栈
    }

    return 0;

}

如 果你想知道我为什么把EXCEPTION_REGISTRATION结构创建在堆栈上而不是使用全局变量,我有一个很好的理由可以解释它。实际上,当你使 用编译器的__try/__except语法结构时,编译器自己也把EXCEPTION_REGISTRATION结构创建在堆栈上。我只是简单地向你展 示了如果使用__try/__except时编译器做法的简化版。

回 到main函数,第二个__asm块通过先把EAX寄存器清零(MOV EAX,0)然后把此寄存器的值作为内存地址让下一条指令(MOV [EAX],1)向此地址写入数据而故意引发一个错误。最后的__asm块移除这个简单的异常处理程序:它首先恢复了FS:[0]中先前的内容,然后把 EXCEPTION_REGISTRATION结构弹出堆栈(ADD ESP,8)。

现 在假若你运行MYSEH.EXE,就会看到整个过程。当MOV [EAX],1这条指令执行时,它引发一个访问违规。系统在FS:[0]处的TIB中查找,然后发现了一个指向 EXCEPTION_REGISTRATION结构的指针。在MYSEH.CPP中,在这个结构中有一个指向_except_handler函数的指针。 系统然后把所需的四个参数(我在前面已经说过)压入堆栈,接着调用_except_handler函数。

一 旦进入_except_handler,这段代码首先通过一个printf语句表明“哈!是我让它转到这里的!”。接着,_except_handler 修复了引发错误的问题——即EAX寄存器指向了一个不能写的内存地址(地址0)。修复方法就是改变CONTEXT结构中的EAX的值使它指向一个允许写的 位置。在这个简单的程序中,我专门为此设置了一个DWORD变量(scratch)。_except_handler函数最后的动作是返回 ExceptionContinueExecution这个值,它在EXCPT.H文件中定义。

当 操作系统看到返回值为ExceptionContinueExecution时,它将其理解为你已经修复了问题,而引起错误的那条指令应该被重新执行。由 于我的_except_handler函数已经让EAX寄存器指向一个合法的内存,MOV [EAX],1指令再次执行,这次main函数一切正常。看,这也并不复杂,不是吗?

移向更深处

有 了这个最简单的情景之后,让我们回去填补那些空白。虽然这个异常回调机制很好,但它并不是一个完美的解决方案。对于稍微复杂一些的应用程序来说,仅用一个 函数就能处理程序中任何地方都可能发生的异常是相当困难的。一个更实用的方案应该是有多个异常处理例程,每个例程针对程序中的一部分。实际上,操作系统提 供的正是这个功能。

还 记得系统用来查找异常回调函数的EXCEPTION_REGISTRATION结构吗?这个结构的第一个成员,称为prev,前面我们暂时把它忽略了。它 实际上是一个指向另外一个EXCEPTION_REGISTRATION结构的指针。这第二个EXCEPTION_REGISTRATION结构可以有一 个完全不同的处理函数。它的prev域可以指向第三个EXCEPTION_REGISTRATION结构,依次类推。简单地说,就是有一个EXCEPTION_REGISTRATION结构链表。线程信息块的第一个DWORD(在基于Intel CPU的机器上是FS:[0])指向这个链表的头部。

操作系统要这个EXCEPTION_REGISTRATION结构链表做 什么呢?原来,当异常发生时,系统遍历这个链表以查找一个(其异常处理程序)同意处理这个异常的EXCEPTION_REGISTRATION结构。在 MYSEH.CPP中,异常处理程序通过返回ExceptionContinueExecution表示它同意处理这个异常。异常回调函数也可以拒绝处理 这个异常。在这种情况下,系统移向链表的下一个EXCEPTION_REGISTRATION结构并询问它的异常回调函数,看它是否同意处理这个异常。图 4显示了这个过程。一旦系统找到一个处理这个异常的回调函数,它就停止遍历链表。


图4 查找一个处理异常的EXCEPTION_REGISTRATION结构

图 5的MYSEH2.CPP就是一个异常处理函数不处理某个异常的例子。为了使代码尽量简单,我使用了编译器层面的异常处理。main函数只设置了一个 __try/__except块。在__try块内部调用了HomeGrownFrame函数。这个函数与前面的MYSEH程序非常相似。它也是在堆栈上 创建一个EXCEPTION_REGISTRATION结构,并且让FS:[0]指向此结构。在建立了新的异常处理程序之后,这个函数通过向一个NULL 指针所指向的内存处写入数据而故意引发一个错误:

*(PDWORD)0 = 0;

这个异常处理回调函 数,同样被称为_except_handler,却与前面的那个截然不同。它首先打印出ExceptionRecord结构中的异常代码和标志,这个结构 的地址是作为一个指针参数被这个函数接收的。打印出异常标志的原因一会儿就清楚了。因为_except_handler函数并没有打算修复出错的代码,因 此它返回ExceptionContinueSearch。这导致操作系统继续在EXCEPTION_REGISTRATION结构链表中搜索下一个 EXCEPTION_REGISTRATION结构。接下来安装的异常回调函数是针对main函数中的__try/__except块的。 __except块简单地打印出“Caught the exception in main()”。此时我们只是简单地忽略这个异常来表明我们已经处理了它。

图5 MYSEH2.CPP

//=================================================
// MYSEH2 - Matt Pietrek 1997
// Microsoft Systems Journal, January 1997
// FILE: MYSEH2.CPP
// 使用命令行CL MYSEH2.CPP编译
//=========================================

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值