现在的位置: 首页 > 综合 > 正文

漫谈兼容内核之二十四:Windows的结构化异常处理(一)

2012年10月09日 ⁄ 综合 ⁄ 共 7992字 ⁄ 字号 评论关闭

 

 

结构化异常处理(Structured Exception Handling),简称SEH,是Windows操作系统的一个重要组成部分。

ReactOS内核的源代码中,特别是在实现系统调用的代码中,读者已经看到很多类似于这样的代码:

 

   if(MaximumSize != NULL && PreviousMode != KernelMode)

   {

     _SEH_TRY

     {

       ProbeForRead(MaximumSize, sizeof(LARGE_INTEGER), sizeof(ULONG));

       /* make a copy on the stack */

       SafeMaximumSize = *MaximumSize;

       MaximumSize = &SafeMaximumSize;

     }

     _SEH_HANDLE

     {

       Status = _SEH_GetExceptionCode();

     }

     _SEH_END;

    

     if(!NT_SUCCESS(Status))

     {

       return Status;

     }

   }

  

 

这段代码取自NtCreateSection(),其参数之一是指针MaximumSize。系统调用一般都是从用户空间调用的,因此PreviousMode一般不是KernelMode。所以,只要指针MaximumSize不是NULL,就要从它所指的地方从用户空间把数值复制到内核空间。那为什么不直接把它的数值作为参数传递,而要这样绕一下呢?这是因为它的类型为LARGE_INTEGER,而作为参数传递的只能是32(或以下)的普通整数。

然而,从用户空间复制数据到内核空间(或反过来)恰恰是容易出事的。这是因为,用户程序的质量相对而言是没有保证的,这个指针所指向的地址(所在的页面)也许根本就没有映射,或者也许不允许读,那样就会发生与页面映射和访问有关的异常(Exception)

不过倒也并非只要发生异常就有问题,例如要是页面已经映射、也允许读,但是所在页面已经换出(Swap-Out),那就会发生缺页异常;而缺页异常其实不是“异常”而是“正常”,内核从磁盘上换入(Swap-In)目标页面,就可以从异常处理程序返回、并继续运行了,就像发生了一次中断一样。此时CPU将重新执行发生异常的指令,这一次一般就能正常完成了。所以,(物理上的)异常之是否真的(逻辑上)“异常”,还得看具体的原因。只要没有特别加以说明,本文中所讲的异常都是指真正意义上的异常。

对于异常的处理,内核一般会提供默认的方式,例如“杀掉”当前进程,让其一死百了,这样至少不会危害别的进程。但是如果具体的程序预期在某一段代码中有可能发生某几种特定的异常,并愿意为之提供解决、补救之道,那当然是更合理、更优雅的方式。举例言之,假如用户程序中有除法运算,CPU在碰到除数为0的时候就会发生异常,此时默认的处理方式一般是中止该用户程序的运行,因为不知该怎样让它继续下去了。然而这可能发生在已经连续计算了几十个小时以后,离成功也许只有一步之遥了,让它就这样退出运行未免损失太大。如果程序的设计人员事先估计到有这样的可能,也许会选择在这种情况下弹出一个对话框,提示使用者改变几个参数,然后以新的条件继续运算;或者至少问一下用户,是否把发生问题时的“现场”信息通过邮件发送给程序的设计者。显然,这是更好的解决方案。问题在于如何来实现,如何为程序的设计者提供这样做的手段。

简而言之,就是要为程序的设计者提供一种手段,使得倘若在执行某一段代码的过程中发生了特定种类的异常就执行另一些指定的代码。事实上,这正是微软的“结构化异常处理(Structured Exception Handling)”、即SEH机制要解决的问题之一。后面读者将会看到,SEH要解决两类问题,这是其中之一。

在上列的代码片断中,在_SEH_TRY{}里面是要加以“保护”的代码,即用户估计可能会在执行中发生异常的代码;而_SEH_HANDLE{}里面就是当发生异常时需要执行的代码;最后的_SEH_END则说明与SEH有关的代码到此为止,从此以后的代码恢复常态。这样,如果在执行_SEH_TRY{}里面受保护代码的过程中发生了某些异常,CPU就转入_SEH_HANDLE{};而若顺利执行完_SEH_TRY{}里面的代码,那就跳过_SEH_HANDLE{}直接到达_SEH_END

注意在_SEH_TRY{}里面可能会调用别的函数,被调用函数的代码虽然形式上不在_SEH_TRY{}里面,但是它的本次被调用执行却同样是在_SEH_TRY{}所指定的保护范围之内。在本文中,由_SEH_TRY{}所划定的范围称为一个“SEH保护域”,也称“SEH框架”,因为在执行这些代码时这表现为堆栈上的一个框架。所以在本文中“SEH域”和“SEH框架”是同义词。说是“保护域”,其实也可以说是“捕捉域”,就是说这是一个需要“捕捉”住异常的域(所以在C++语言中用“catch”表示捕捉到异常之后要执行的代码)。注意SEH域和函数是互相独立的两个概念。同一个函数,这一次是从_SEH_TRY{}里面调用,它的执行就在SEH域中;下一次不是从_SEH_TRY{}里面调用,就不在这个SEH域中了。所以一个函数(的执行)是否在SEH域里面是个动态的概念。不过,SEH域总是存在于某个函数的内部,而不可能游离在函数之外,就像C语句只能存在于函数之内一样。

在实际应用中,SEH域还可以嵌套,就是在一个SEH域的内部又通过_SEH_TRY{}开辟了第二个SEH域。例如,前者也许是针对页面异常的SEH域,而在这里面又有一部分代码可能会引起“除数为0”的异常,所以又得将其保护起来,形成一个嵌在外层保护域里面的内层保护域。

显然,多个SEH框架嵌套就形成了一个SEH框架栈。SEH框架栈既可以只是实质的,也可以既是实质的、又是形式的。比方说,一个SEH域的内部调用了一个函数,而在这个函数中又有一个SEH域,那么这两个SEH(框架)的嵌套是实质的,但却不是形式的,因为从代码上不能一目了然看出这样的嵌套关系,这种嵌套关系是运行起来才形成的。但是,如果在第一个SEH域的_SEH_TRY{}内部直接又有一个_SEH_TRY{},那么这两个SEH域的嵌套关系就既是实质的、又是形式的。在本文中,前者所形成的SEH框架栈称为“实质”SEH框架栈、或“全局”SEH框架栈,后者所形成的则称为“形式”SEH框架栈、或“局部”SEH框架栈。之所以如此,是因为0.3.0ReactOS的代码中对于SEH机制的实现有了一些变动。不过,在0.3.0ReactOS的代码中并未见到使用形式嵌套的SEH域。

回头看前面_SEH_TRY{}里面的代码。这里受保护的有三个语句,先看对ProbeForRead()的调用。ProbeForRead()是个内核函数,这里也是在内核中调用,所以对这个函数的调用本身并没有问题。

 

VOID STDCALL

ProbeForRead (IN CONST VOID *Address, IN ULONG Length, IN ULONG Alignment)

{

   ASSERT(Alignment == 1 || Alignment == 2 || Alignment == 4 || Alignment == 8);

 

   if (Length == 0)

      return;

 

   if (((ULONG_PTR)Address & (Alignment - 1)) != 0)

   {

      ExRaiseStatus (STATUS_DATATYPE_MISALIGNMENT);

   }

   else if ((ULONG_PTR)Address + Length - 1 < (ULONG_PTR)Address ||

            (ULONG_PTR)Address + Length - 1 > (ULONG_PTR)MmUserProbeAddress)

   {

      ExRaiseStatus (STATUS_ACCESS_VIOLATION);

   }

}

 

其目的只是检查参数的合理性,而并不真的去访问用户空间。如果用户空间数据所在的地址不与给定数据类型(在这里是ULONG)的边界对齐,或者所在的位置不对、长度不合理,那就要通过ExRaiseStatus()以软件方法模拟异常。这是为什么呢?因为在正常的情况下这是不可能发生的,既然发生了就一定是出了问题,按理说最好是CPU在碰到这种情况时能引起一次异常,但是386结构的CPU不会(486开始就会了,这就是17号异常“Alignment Check),所以就只好通过软件手段来模拟一次“软异常”。注意这里软异常的类型为STATUS_DATATYPE_MISALIGNMENTSTATUS_ACCESS_VIOLATION,前者表示与数据类型的边界不对齐,后者表示越界访问,这相当于硬异常的异常号,但是丰富得多。

就前述的SEH域而言,由此而引起的效果与硬件异常相同,CPU也会转入_SEH_HANDLE{}里面。

熟悉C++的读者可能会联想到throw语句,实际上也确实是同一回事。

如果ProbeForRead()没有检查出什么问题,前面的第二个语句是“SafeMaximumSize = *MaximumSize”,这是从用户空间读取数据写入系统空间。这里写入系统空间不会有问题,但是读用户空间可能会有问题,如果指针MaximumSize所指的页面无映射就会发生异常。所以要把它放在_SEH_TRY{}里面。

第三个语句是“MaximumSize = &SafeMaximumSize”,这是对指针MaximumSize进行赋值。作为调用参数,这个变量原先在用户空间堆栈上,CPU因系统调用进入内核以后把它复制到了系统空间堆栈上。因而这个赋值操作应该不会引起异常,本可以放在外面,但是放在_SEH_TRY{}里面也无不可。所以,并非凡是放在_SEH_TRY{}里面的都必须是可能引起异常的语句。对于不会引起异常的语句,放在_SEH_TRY{}里面或外面都是一样。

再看安排在发生异常时加以执行的代码、即_SEH_HANDLE{}里面的代码。在这里只有一个语句,就是对_SEH_GetExceptionCode()的调用。顾名思义,这就是获取具体异常的代码,例如STATUS_DATATYPE_MISALIGNMENTSTATUS_ACCESS_VIOLATION等等。然后将获取的代码赋值给变量Status,这就完事了。再往下就是_SEH_END及其后面的if语句了。当然,这里面也可以有不止一个、甚至很多的语句。

注意变量Status原本已经初始化成STATUS_SUCCESS,而_SEH_TRY{}里面的代码都不会改变它的值;所以只要“!NT_SUCCESS(Status)”为真就一定已经发生过异常,因此这个系统调用就出错返回了,而且所返回的就是所发生异常的代码。而根据所返回的值判断本次系统调用是否成功,以及采取什么措施,那就是用户软件的事了。

这里还要说明一下,并不是所有的异常都会落入这_SEH_HANDLE{}里面。发生异常时,首先是由内核底层的异常处理程序“认领”和处理,例如缺页异常就会被其认领并处理,处理完就返回了。即使是不归其认领处理的异常,也还得看当时是否正在通过调试工具(debugger)调试程序,如果是就交由debugger处理。只有不受这二者拦截的异常才会落入_SEH_HANDLE{}。后面读者将看到,每个SEH域都可以通过一个“过滤函数”检查本次异常的类型,已决定是否认领。如果存在嵌套的SEH域,则首先要由嵌套在最内层(最底层)SEH域先作过滤,决定不予认领才会交给上一层SEH域。所以,只有不被拦截、认领,并通过了层层过滤的异常才真正进入本SEH域的_SEH_HANDLE{}

上面所引的是内核中的代码,用户空间的代码同样也可以利用SEH所提供的功能和机制,其实C++语言中的try{..}catch{…}最终也是利用SEH实现的。

那么,以_SEH_TRY{}_SEH_HANDLE{}、以及_SEH_END为程序设计手段的这种SEH机制具体是怎么实现的呢?这正是本文要加以介绍的内容。从现在起,凡是ReacOS的代码均引自其0.3.0版。

 

先大致介绍一下基本的原理。

从形式上看,由_SEH_TRY{}_SEH_HANDLE{}、和_SEH_END在程序结构上有点像是条件语句if(){}else{}。可是,用条件语句是实现不了SEH的。这是因为:条件语句所判别的条件必须表现为一个布尔量,而对此布尔量的测试和相应的程序跳转只能发生在一个固定的点上,但是_SEH_TRY{}所要保护的却是一个“域”、一个范围。诚然,我们可以在程序中放上一个初值为0的全局量,比方说excepted,如果发生异常就让底层的异常响应程序将此变量设置成1。但是,总不能让_SEH_TRY{}里面的程序每执行完一条指令就来执行基于这个变量的条件语句(例如条件跳转)吧?怎么办呢,办法是预先设置好一个目标地址,只要发生了异常,就从底层的异常响应程序直接跳转到预设的目标地址。但是,这样的跳转必须发生在返回到因异常而被中断的程序中之前,因为一旦回到了被中断的程序,那里就没有实现此种跳转所需的代码了。从堆栈的角度看,这是要从内层的“异常框架”跳转到、而不是返回到外层的SEH框架中。此种垮框架的跳转称为“长程跳转(Long-Jump)”。C语言程序库中有一对函数setjmp()longjmp(),就是用来实现长程跳转的,前者用于设置长程跳转的目标地址,后者用于实际的跳转。

不过,这只是单个保护域的异常处理,还不能说是“结构化异常处理”。与单个保护域相连系的是单个目标地址、即单块_SEH_HANDLE{}代码;但是实际上可能发生的异常却是多样的,要在同一块_SEH_HANDLE{}代码中考虑应对所有不同原因的异常显然不现实。在编写一段需要受保护的代码时,程序员一般只能针对这段局部的代码作出估计,就其认为可能会发生的异常安排好应对措施。所以就有了让保护域嵌套的要求,保护域的嵌套使程序员得以将注意力集中在具体的局部,而又可以从全局上防止有些异常得不到合适的处理,这与“结构化程序设计”在精神上是一致的。

另一方面,异常既可能发生于系统空间,也可能发生于用户空间,因此两个空间都需要有实现SEH域的手段。但是,即使是发生于用户空间的异常,首先进入的也是内核底层的异常响应程序,从而需要有个将异常提交给用户空间进行处理的手段。这样,从内核底层的异常响应/处理程序开始,根据具体情况进入系统空间或用户空间嵌套在最内层的保护域,再根据具体情况逐层上升到外层保护域,直至穷尽所有预设的保护措施,这就形成了一套完整的异常处理机制而成为一个体系,那才可以说是“结构化异常处理”。可以想像,这么一套机制的实现并非易事。

为实现结构化异常处理,Windows在系统空间和用户空间都有一个后进先出的异常处理队列ExceptionList。为简化叙述,这里先从概念上作一说明,实际的实现则还要复杂一点:每当程序进入一个SEH框架时,就把一个带有长程跳转目标地址的数据结构挂入相应空间的异常处理队列,成为其一个节点;在内核中就挂入系统空间的队列,在用户空间就挂入用户空间的队列。而当离开当前SEH框架时,则从队列中摘除这数据结构。由于是后进先出队列,所摘除的一定是最近挂入队列的数据结构。显然,队列中的每一个节点都代表着一个保护域。只要队列非空,CPU就至少是在某个(最后进入的)保护域中运行。只要队列中的节点多于一个,后进节点所代表的保护域就一定是嵌套在先进入的保护域内部,而CPU则同时在多个保护域内部运行。所以异常处理队列本质上是一个堆栈,反映了保护域的层次关系。一般而言,当CPU运行于用户空间时,系统空间的异常处理队列应该是空的。

除长程跳转目标地址外,挂入ExceptionList的数据结构中还可以有两个函数指针。一个是“过滤(Filter)函数”的指针,这个函数判断所发生的异常是否就是本保护域所要保护、所要应对的那种异常,如果是才加以认领而执行本SEH域的长程跳转。另一个是“善后(final)函数”指针,善后函数的目的通常是释放动态获取的资源。

说到“善后函数”,这里有几个概念需要澄清一下。首先,前面讲到_SEH_TRY{}里面是要加以“保护”的代码,但是所谓保护并非让其不发生异常,而是说要为可能发生的异常准备好应对之道,就好像对于高空作业要在地面上铺设一张保护网、并准备好应急预案一样。根据具体的情况,应对之道可简可繁。前面代码中的应对之道就只是获取异常代码,然后使当前的系统调用夭折而返回,并把异常代码带回用户空间。而比较复杂的应对之道,则可能会试图消除发生异常的原因,例如对于因除数为0而引起的异常就有这样的可能。既然是应对之道,自然就带有“善后”的意思,可是这与“善后函数”不同。或许可以说,_SEH_HANDLE{}里面的代码所提供的应对之道是应用层面上的善后、是针对程序主流的善后,而“善后函数”所提供的是辅助性的、技术性的善后。在SEH域嵌套的情况下,这二者有很大的不同。例如,假定在针对页面异常的SEH域中嵌套了一个针对除数为0的异常,而实际发生的是页面异常,那么长程跳转的目标是上层SEH域的_SEH_HANDLE{},以执行针对页面异常的应对之道,而针对除数为0的应对之道则得不到执行,因为后者所在的函数框架被长程跳转跨越了。可是,与后者相联系的善后函数却仍须执行,因为后者所在的那个函数可能已经动态分配了某些资源,而函数中本来用于释放这些资源的代码却被跳过了。

这样,简而言之,当发生异常时,异常响应程序就(按后进先出的次序)依次考察相应ExceptionList中的各个节点并执行其过滤函数(如果有的话),如果过滤函数认为这就是本保护域所针对的异常、或默认为相符而无需过滤,就执行本保护域的长程跳转,进入本SEH域的_SEH_HANDLE{}里面的代码。而对于被跨越的各个内层SEH域,则执行其善后函数(如果有的话)

应该说,这是设计得很好的一种方案。明白了基本的原理以后,下面就可以看具体的代码了。

 

ReactOS的代码中,_SEH_TRY_SEH_HANDLE、以及_SEH_END都是宏定义。不过,在ReactOS0.3.0版中,这些宏操作的定义有两套。其中之一依赖于较新版本的C编译对__try__except__finally等较新语言成分的支持,由C编译在编译的时候自动生成相应的细节;另一种则不依赖于C编译对这些新语言成分的支持。这二者之间的关系有点像是高级语言与汇编语言之间的关系。对于深入理解SEH而言,后者反倒有助于读者更直观、更清晰地看到此项机制的原理和具体实现。反过来,搞明白了SEH机制在采用“朴素”C编译工具时的实现,也就明白了在较新版本的C编译中__try__except__finally这些新语言成分的原理。

先看_SEH_TRY的定义:

 

#define _SEH_TRY /

{                                                                            /

  _SEH2_INIT_CONST int _SEH2TopTryLevel = (_SEHScopeKind != 0);   

抱歉!评论已关闭.