Visual C++ 里的异常处理
微软Visual C++是Win32最广泛使用的编译器,因此Win32反向器对其内部工作非常熟悉。能够识别编译器生成的粘合代码有助于快速集中于程序员编写的实际代码。它还有助于恢复程序的高级结构。我将集中讨论MSVC编译程序中的堆栈布局、异常处理和相关结构。假设对汇编程序、寄存器、调用约定等有一定的了解。
名词解释:
- Stack frame---堆栈帧,函数使用的堆栈段的片段。通常包含函数参数、返回调用方地址、保存的寄存器、局部变量和其他特定于此函数的数据。在x86(和大多数其他架构)上,调用者和被调用者堆栈帧是连续的。
- Frame pointer---帧指针,指向堆栈帧内固定位置的寄存器或其他变量。通常堆栈帧中的所有数据都是相对于帧指针寻址的。在x86上,它通常是ebp,通常指向返回地址的正下方。
- Object---对象,(C++)类的一个实例。
- Unwindable Object---不可逆转的对象,具有自动存储类说明符的本地对象,在堆栈上分配,超出范围时需要销毁。
- Stack UInwinding---堆栈展开:当控制由于异常而离开作用域时,自动销毁此类对象。
win32里C/C++异常分类
在C或C++程序中可以使用两种类型的异常。
- SEH异常(来自“结构化异常处理”)。也称为Win32或系统异常。著名的Matt Pietrek文章[1]详尽地介绍了这些内容。它们是C程序唯一可用的例外。编译器级别的支持包括关键字try、except、finally和其他一些关键字。
- C++异常(有时称为“EH”)。在SEH之上实现,C++异常允许任意类型的抛出和捕获。C++的一个非常重要的特点是在异常处理过程中自动堆栈展开,而MSVC使用一个非常复杂的底层框架来保证它在所有情况下都能正常工作。
栈基本布局
调试System.UnauthorizedAccessException(通常后跟:拒绝访问路径)
今天,我要向您介绍System.UnauthorizedAccessException。异常通常是由IO错误引起的,但其他问题(如安全限制)也可以利用此错误。我们一起挖进去!
制造错误
捕捉异常非常简单。让我们创建一个小程序来触发和捕获此错误。在编写代码之前,我将创建一个名为c:\temp\readonly.txt的文本文件。创建后,右键单击文件,选择属性并启用只读复选框。这会引起意外!,文件为只读。对于代码:
classProgram
{static void Main(string[] args)
{var path = "c:\\temp\\readonly.txt";try{
File.Delete(path);
}catch(UnauthorizedAccessException e)
{
Console.WriteLine(e);
}
}
}
Windows加载器与模块初始化
本文是Matt Pietrek在1999年9月的MSJ杂志上发表的关于Windows加载器与模块初始化方面的文章。作者深入分析了LdrpRunInitialize璕outines例程的作用,用C语言写出了此函数的伪代码,并剖析了模块初始化的整个过程,对于想了解Windows加载器的读者来说是不可多得的资料。
在最近的MSJ专栏(1999年六月)中,我讨论了COM类型库和数据库访问层,例如ActiveX荻韵螅ˋDO)和OLE DB。MSJ专栏的长期读者可能认为我已经不行了(写不出技术层次比较高的文章了)。为了重振雄风,这个月我要讲解一部分Windows NT釉仄鞔耄遣僮飨低澈湍愕拇虢雍系牡胤健M保乙不嵯蚰阊菔疽恍┗袢〖釉仄髯刺畔⒌母叱记桑约翱梢杂迷贒eveloper Studio魇云髦械南喙丶记伞?br />
考虑一下你对EXE、DLL以及它们是如何被加载的和初始化的到底知道多少。你可能知道当一个用C++写成的DLL被加载时,它的DllMain函数会被调用。想一想当你的EXE隐含链接到一些DLL(例如,KERNEL32.DLL和USER32.DLL)时到底发生了什么。这些DLL是以什么顺序被初始化的?某个DLL将要被初始化,而它所依赖的其它DLL还未被初始化,这可能吗?Platform SDK在“Dynamic Link Library Entry Point Function(动态链接库入口点函数)”一节中对此描述如下:
“你的函数应该仅进行一些简单的初始化任务,例如设置线程局部存储(TLS),创建同步对象和打开文件等。它绝对不能调用LoadLibrary函数,因为这可能在DLL加载顺序上造成循环依赖。这可能导致即将使用一个DLL但是系统还未对它进行初始化。同样,你也不能在入口点函数中调用FreeLibrary函数,因为这可能导致即将使用一个DLL但是系统已经执行完了它的终止代码。”
“调用除TLS函数、同步函数和文件函数之外的Win32部赡芤鸷苣颜锒系奈侍狻@纾饔肬ser函数、Shell函数和COM函数可能引起访问违规,因为这些DLL中一些函数调用LoadLibrary加载其它系统组件。”
看了上述文档后我的第一反应是它太含糊了。例如你想在自己的DllMain函数中读取注册表是再正常不过的事了,它当然可以作为初始化的一部分。但不幸的是,在你的DllMain代码开始执行时ADVAPI32.DLL还没有初始化。这样,对注册表API的调用将会失败。
在上述文档中对使用LoadLibrary给出了严厉的警告。但非常有趣的是,Windows NT的 USER32.DLL却明确地忽略前面的忠告。你可能知道Widnows NT上的一个注册表键AppInit_Dlls,它用来加载一系列DLL到每个进程。事实表明,是USER32在初始化时加载这些DLL的。USER32在它的DllMain代码中查看这个注册表键并调用LoadLibrary加载这些DLL。稍微思考一下就会知道,如果你的应用程序不使用USER32.DLL的话,AppInit_Dlls这个技巧就不能发挥作用。不过,这有点跑题了。
我之所以要讲解这方面的内容是因为DLL的加载与初始化还是一片盲区。在大多数情况下,对操作系统加载器是如何工作的有一个简单的印象就足够了。然而,在极少数情况下,除非你对操作系统加载器的行为方式有比较详细的了解,否则就会陷入困境之中。
加载器醒来!
大多数程序员所认为的模块加载过程实际上分为两个截然不同的步骤。第一步是把EXE或DLL映射进内存。此时加载器查看模块的导入地址表(IAT)来判断这个模块是否依赖于其它DLL。如果它依赖的DLL还未被加载进那个进程,加载器也将它们映射进内存。这个过程递归进行,直到所有依赖的模块都被映射进内存。要查看一个可执行文件隐含依赖的所有DLL,最好的方法是使用Platform SDK附带的DEPENDS程序。
第二步是初始化所有DLL。在第一步中,当操作系统把EXE和DLL映射进内存时,它并不调用相应的初始化例程。初始化例程是在所有模块都被映射进内存之后才被调用的。关键是:DLL被映射进内存的顺序并不需要与它们被初始化的顺序一样。我曾经见到有人看到Developer Studio调试器中对DLL映射时的通知而误认为DLL是以相同的顺序被初始化的。
在Windows NT中,调用EXE和DLL入口点代码的例程被称为LdrpRunInitializeRoutines。在平常的工作中,我已经多次跟踪到LdrpRunIntializeRoutines的汇编代码中。但是,看着大堆的汇编代码并不是理解它的好方法。因此,我用类似C++的伪代码重写了Windows NT 4.0 SP3 的LdrpRunInitializeRoutines函数,如图1所示。实际上,在NTDLL.DBG中这个例程的名字按__stdcall调用约定被粉碎成了_LdrpRunInitializeRoutines@4。在伪代码中,除了那些名字前面加了下划线的,其余的都是我起的名字。
在Windows NT加载器代码中,LdrpRunInitializeRoutines是调用EXE或DLL的指定入口点代码之前的最后一站。(在下面的讨论中,我将把 “入口点”和“初始化例程”互换着使用。)这段加载器代码在被加载的DLL所在的那个进程环境中执行。也就是说,它并不是什么特别的加载器进程的一部分。在进程启动过程中处理隐含加载的DLL时,LdrpRunInitializeRoutines至少被调用一次。同时,每当动态加载一个或多个DLL(一般是通常调用LoadLibrary实现的)时,都要调用它,
每当LdrpRunInitializeRoutines执行时,它就查找并调用已经被映射进内存但还未被初始化的所有DLL的入口点代码。在看上面的伪代码时,注意所有提供跟踪输出的额外代码(也就是上面的伪代码中使用_ShowSnaps变量和_DbgPrint函数的代码),它们甚至存在于非调试版的Windows NT中。稍候我会接着说这一点。
这个函数大体上分为四个不同的部分。第一部分调用_LdrpClearLoadInProgress函数。这个NTDLL函数返回刚才映射进内存的DLL的数目。例如,如果你在FOO.DLL中调用LoadLibrary函数,而FOO隐含链接到了BAR.DLL和BAZ.DLL,那么_LdrpClearLoadInProgress将返回3,因为有三个DLL被映射进内存中。
在知道了相关的DLL数目之后,LdrpRunInitializeRoutines调用_RtlAllocateHeap(也被称为HeapAlloc)来为一个指针数组分配内存。在伪代码中我把这个数组称为pInitNodeArray。这个数组中的每个元素(指针)最终分别指向一个包含有关最近加载(但尚未初始化)的DLL的信息的结构。
在LdrpRunInitializeRoutines的第二部分中,它使用内部进程数据结构来获取一个包含最近加载的DLL的链表。然后它遍历这个链表来确定加载器是否曾经加载过这个DLL。接下来确定DLL是否有入口点函数。如果这两个测试都通过了,它就将指向相应模块信息的指针添加到pInitNodeArray数组中。在伪代码中我称这个模块信息为pModuleLoaderInfo。一定要注意:一个DLL完全有可能不包含入口点函数——例如纯资源DLL。因此,pInitNodeArray中的元素数可能比前面由_LdrpClearLoadInProgress函数返回的值小。
LdrpRunInitializeRoutines例程的第三部分(也是最大的一部分)才是真正的重头戏。它的任务就是枚举pInitNodeArray数组中的每个元素并调用相应的入口点函数。由于DLL的初始化代码可能会出错,因此这部分代码整个用一个__try块包装着。这就是动态加载DLL时虽然DllMain中出现错误但并不会导致整个进程终止的原因。
遍历一个数组并调用其中的每个入口点函数应该是小菜一碟。然而由于Windows NT中一些灰暗不明的特性使它变得复杂起来。首先需要考虑进程是否正在被像MSDEV.EXE之类的Win32调试器调试。Windows NT有一个选项允许你在DLL初始化之前将一个进程挂起并把控制权发送到调试器。这个功能是基于DLL的,可以通过向注册表中一个以DLL名称命名的键中添加一个字符串值(BreakOnDllLoad)来实现。详细信息可以参考图1的伪代码中函数_LdrQueryImageFileExecutionOptions的调用代码上面的注释。
在调用DLL的入口点函数之前可能需要执行的另一块代码是TLS的初始化代码。当你使用__declspec(thread)定义TLS变量时,链接器会包含触发这个条件的数据。在DLL的入口点函数被调用之前,LdrpRunInitializeRoutines要确定是否需要初始化TLS。如果需要,它就调用_LdrpCallTlsInitializers。后面会详细讨论。
LdrpRunInitializeRoutines中真正调用DLL入口点函数的代码终于到来了。我有意用汇编语言来表示这部分代码。原因一会儿就清楚了。这里面最关键的一条指令是CALL EDI。在这里,EDI指向DLL的入口点函数,而入口点函数是由DLL的PE文件头指定的。当CALL EDI返回时,DLL已经完成了它的初始化。对于用C++写的DLL来说,这意味着它的DllMain函数已经执行完了与DLL_PROCESS_ATTACH相应的那部分代码。同时要注意传递给入口点函数的第三个参数,它通常被称为lpvReserved。事实上,对于可执行文件隐含链接(直接链接或通过其它DLL间接链接)到的DLL来说,这个参数非0。对于其它DLL(即通过调用LoadLibrary动态加载的DLL)来说,这个参数为0。
DLL的入口点函数被调用之后,LdrpRunInitializeRoutines开始进行安全性检查以确保DLL的入口点代码中没有错误。它比较调用入口点代码前后堆栈指针(ESP)的值。如果它们不同,那就表明DLL的初始化函数出现了错误。由于大多数程序员从未定义过真正的DLL入口点函数,这种情况很少发生。但是它一旦发生,Windows就会用一个对话框通知你这个问题(如图2所示)。我不得不使用调试器并在恰当的地方修改寄存器的值才产生了这个对话框。
图2 非法DLL入口点
堆栈检查完毕之后,LdrpRunInitializeRoutines检查入口点函数的返回值。对于用C++写的DLL来说,它就是DllMain的返回值。如果DLL返回0,它通常表示出现了错误,不能继续加载这个DLL了。如果发生这种情况,你就会得到一个令人害怕的“DLL初始化失败”对话框。
在所有的DLL初始化完毕之后开始执行LdrpRunInitializeRoutines函数的第三部分中的最后一些代码。如果进程本身的EXE文件包含TLS数据,并且如果隐含链接到的DLL已经被初始化,那它就调用_LdrpCallTlsInitializers。
LdrpRunInitializeRoutines函数的第四部分(也是最后一部分)是清理代码。还记得前面_RtlAllocateHeap创建的pInitNodeArray数组吗?这部分内存需要被释放,释放它的代码在__finally块中。这样,即使这些DLL中可能有的初始化时会失败,__try/__finally代码也能保证会调用_RtlFreeHeap来释放pInitNodeArray。
我们的LdrpRunInitializeRoutines之旅就此结束了,现在让我们来看一下与此相关的一些问题。
调试初始化例程
我也曾经遇到过DLL在初始化时失败的情况。不幸的是,错误可能是好几个DLL中的一个,而操作系统并没有告诉我到底哪一个才是罪魁祸首。在这种情况下,你就可以使用调试器断点来解决问题。
大多数调试器都直接跳过静态链接的DLL的初始化例程。它们把注意力放在EXE文件的第一条指令或第一行上。但是知道了LdrpRunInitializeRoutines的内部情况之后,你就可以在CALL EDI这条指令上设置一个断点,此时正要执行的是DLL的入口点代码。一旦设置了这个断点,每次DLL将要接到DLL_PROCESS_ATTACH通知时,就会中断在NTDLL的CALL指令上。图3是在Visual C++?nbsp;6.0 IDE(MSDEV.EXE)中的情况。
图3 在CALL EDI指令上设置断点
如果单步跟踪CALL指令,你会遇到DLL入口点代码的第一条指令。意识到这段代码绝大多数情况下都不是你自己写的这一点很重要。因为它通常是运行时库中的代码,这段代码先做一些准备工作,然后再调用你的初始化代码。例如,在用Visual C++写的DLL中,它的入口点函数是_DllMainCRTStratup,这个函数在CRTDLL.C中。在没有调试符号和源代码的情况下,你在MSDEV的汇编窗口中看到内容类似下面这个样子(图4):
图4 单步跟踪CALL指令
通常我在调试时会按照下面这个过程进行。第一步就是找出哪个DLL出现了错误。通常设置前面讲的断点然后单步跟踪到每个DLL的初始化例程就可以了。使用调试器找出你当前正处于哪个DLL中,并把它记下来。一种方法就是使用调试器的内存窗口来观察堆栈(ESP),获取你进入的DLL的HMODULE。
当你知道进入到了哪个DLL之后,让进程继续运行(一般是Go命令)。很快就会在下一个DLL中再次触发断点。重复这个过程直到你找到有问题的DLL为止。你很容易就能找到出错的DLL,因为它的初始化代码被调用了,但在这个初始化代码返回之前,进程却意外终止了(因为出错了)。
第二步就是仔细检查出错的DLL。如果你有那个DLL的源代码,你最好在DllMain上设置一个断点,然后让进程运行等待断点被触发。如果你没有源代码,只管让进程运行,等待你在CALL EDI指令上设置的断点在那个位置触发。继续运行直到你碰到出错的指令。单步跟踪进入这个入口点代码并一直单步跟踪下去直到你确定问题所在。这通常需要跟踪大量汇编代码!我从没有说过这很容易,但有时候这是解决问题的惟一方法。
找出CALL EDI指令需要一些技巧(至少是在当前的Microsoft魇云魃希D阆衷诰湍芾斫馕椅裁丛谏厦娴奈贝胫杏没惚嘤镅员硎菊獠糠执肓恕J紫龋苊飨阅阈枰袾DDLL.DLL配套的NTDLL.DBG文件(现在当然是NTDLL.PDB文件)放在你的SYSTEM32目录中。当你开始单步跟踪你的程序时,调试器应该会自动加载调试符号。
在Visual C++的汇编窗口,原理上你可以使用符号名作为地址。在这里,你当然是想转到_LdrpRunInitializeRoutines@4,然后滚动窗口直到你看到CALL EDI这条指令。不幸的是,除非你中断在NTDLL.DLL中,否则Visual C++调试器并不能识别NTDLL中的符号名。
如果你碰巧知道_LdrpRunInitializeRoutines@4的地址(在Intel平台的Windows NT 4.0 SP3上这个地址为0x77F63242),你可以键入那个地址,汇编窗口很容易就会显示它。IDE甚至会显示这个函数的名称为_LdrpRunInitializeRoutines@4。如果你不是调试器老手,符号名识别失败让人很困惑。如果你和我一样是个调试器爱好者,这是非常讨厌的,因为你不知道到底问题出在哪里。
Platform SDK中的WinDBG在识别符号名方面稍好一些。一旦你启动了目标进程,你就可以用_LdrpRunInitializeRoutines@4的名称在这个函数上设置一个断点。不幸的是,当你首次执行这个进程时,你还没来得及在_LdrpRunInitializeRoutines@4上设置断点,执行流程已经过了这个函数了。为了解决这个问题,启动WinDBG后,先单步跟踪一步,然后设置断点并停止调试, 仍然保留调试器。然后你可以重启被调试程序,这次断点就会在每一次调用_LdrpRunInitializeRoutines@4时被触发。这个技巧也可以用在Visual C++调试器中。
ShowSnaps是什么?
_ShowSnaps这个全局变量是我在查看LdrpRunInitializeRoutines的代码时首先注意到的内容之一。趁这个好机会简要地解释一下有关GlobalFlag和GFlags.EXE方面的内容。
Windows NT注册表中包含了影响系统代码某些行为的DWORD值。它们大部分与堆和调试有关。注册表中HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager子键下的GlobalFlag值是一组位域。知识库文章Q147314描述了这些域中的大部分,因此我在这里就不详细讲了。除了系统范围内的GlobalFlag值外,各个可执行文件也可以有它们自己的GlobalFlag值。与单个进程相关的GlobalFlag值被保存在注册表中HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\imagename这个子键下,这里的imagename是可执行文件的名字(例如WinWord.exe)。所有这些对于编制文档来说都是极大挑战的位域以及嵌套极深的注册表键急需有一个程序来简化。实际上,Microsoft就提供了一个这样的程序(GFlags.EXE)。(关于GFlags工具以及各个标志位的详细含义,可以参考最新的Microsoft?nbsp;Debugging Tools for Windows镏牡怠#?br
/>
图5 GFlags.EXE
图5显示的是GFlags.EXE,它来自于Windows NT 4.0资源工具包。GFlags.EXE左上角是三个单选按钮。选择最上面的两个(System Registry或Kernel Mode)中任意一个就可以改变Session Manager中的GlobalFlags的值。如果你选择第三个单选按钮(Image File Options)的话,那么许多选项就会消失。这是因为一些GlobalFlag选项只影响内核模式代码,对每个进程来说并无多大意义。需要注意的一点是,大多数只用于内核模式的选项都假定你使用的是诸如i386kd之类的系统级调试器。如果不使用这样的调试器深入内部或接收输出信息,那使用这些选项也就没有什么意义了。(最新版本的GFlags.EXE除了使用了三个选项卡而不是三个单选按钮外,基本与此类似。)
这些标志中与_ShowSnaps相关的就是Show loader snaps选项。如果它被选中,那么NTDLL.DLL中的_ShowSnaps变量就会被设置成一个非0值。在注册表中,这个位是0x00000002,它被定义为FLG_SHOW_LDR_SNAPS。幸运的是,这个标志是GlobalFlag中可以被设置为针对于每个线程的一些标志中的一个。要不然你要是在系统范围使用这个标志的话,那输出内容会相当多。
检查ShowSnaps输出
现在让我们看一下选中Show loader snaps标志后会输出什么类型的信息。我发现没有讨论到的Windows NT加载器的其它部分也会检查这个标志并输出一些信息。图6就是运行CALC.EXE时输出内容的一部分。要获得这个文本,我首先运行GFlags打开CALC.EXE的Show loader snaps标志。然后我在MSDEV.EXE的控制下运行CALC.EXE,这样就从输出窗口中获得了那些内容。
在图6中,注意所有从NTDLL中输出的内容前面都加了LDR:前缀。其它行(例如“Loaded symbols for XXX”)是由MSDEV进程插入的。在查看带有LDR:的行时会发现一些有价值的信息。例如在进程启动时给出了EXE文件的完整路径以及当前目录和搜索路径。
由于NTDLL加载各个DLL并修正导入函数的地址,因此你会看到类似下面的信息:
LDR: ntdll.dll used by SHELL32.dll
LDR: Snapping imports for SHELL32.dll from ntdll.dll
第一行表明SHELL32.DLL链接到了NTDLL中的API上。第二行表明了从NTDLL导入的API正常被“snapped(快照)”。当可执行模块从其它DLL导入函数时,在它里面就有一个函数指针数组。这个函数指针数组就是IAT。加载器的工作之一就是定位导入函数的地址并把它们填入IAT中。因此,术语“snapping”就出现在了LDR:输出中。
输出内容中另一个引起我注意的是正在被处理的DLL的绑定信息。
LDR: COMCTL32.dll bound to KERNEL32.dll
LDR: COMCTL32.dll has correct binding to KERNEL32.dll
在以前的专栏中,我曾经讲过使用BIND.EXE程序或IMAGEHLP.DLL导出的BindImageEx这个API来绑定程序。将一个可执行文件绑定到某个DLL上实际就是查找导入函数的地址并把它们写入到磁盘上的可执行文件中。这可以加速加载过程,因为加载时不再需要查找导入函数的地址了。
上面的第一行表明COMCTL32绑定到了KERNEL32.DLL上。第二行表明绑定的地址是正确的。加载器通过比较时间戳来确定这一点。如果时间戳不匹配,那么绑定就是无效的。在这种情况下,加载器就重新查找导入函数的地址,就好像这个可执行文件并没有绑定一样。
TLS初始化
最后我以另一个例程的伪代码来结束本期专栏。在LdrpRunInitializeRoutines函数中,在调用模块的入口点代码前的最后一刻,NTDLL检查这个模块是否需要初始化TLS。如果需要,它就调用LdrpCallTlsInitializers函数来进行初始化。图7是我为这个例程写的伪代码。
这个函数相当简单。PE文件头中保存了IMAGE_TLS_DIRECTORY结构(在WINNT.H中定义)的偏移(RVA)。这个函数调用RtlImageDirecotoryEntryToData来获取指向这个结构的指针。IMAGE_TLS_DIRECTORY结构中保存了一个指针,它指向一个由回调函数的地址组成的数组。这些回调函数被声明为PIMAGE_TLS_CALLBACK类型的函数,这个类型在WINNT.H中定义。TLS初始化回调函数与DllMain函数非常相似。实际上在使用__declspec(thread)定义变量时,Visual C++生成了一些导致这些函数会被调用的数据。但是当前运行时库并未定义实际的回调函数,因此这个函数指针数组只有一个值为NULL的元素。
总结
我对Windows NT模块初始化方面的讨论已经结束了。很明显我跳过了许多相关内容。例如确定模块初始化顺序的算法是什么?Windows NT上的这个算法至少已经改变过一次,如果有Microsoft technical note就好了,至少它可以给我们一些指导。同样,我也没有讨论与模块加载对应的话题:模块卸载。然而,我希望我对Windows NT加载器内部工作过程的“一瞥”能够为你更深层次的探索提供一些材料。
附录
图1 RunInit.cpp
//=============================================================================
// Matt Pietrek, September 1999 Microsoft Systems Journal
//
// NTDLL.DLL中LdrpRunInitializeRoutines例程的伪代码(NT 4,SP3)
//
// 在一个进程中首次调用LdrpRunInitializeRoutines(也就是在初始化
// 隐含链接的模块)时,bImplicitLoad参数不为0;在后续的调用
// (通过调用LoadLibrary而间接调用此例程)中,bImplicitLoad为0。
//
//=============================================================================
#include <ntexapi.h> // 用于函数末尾的HardError定义
// 以下是全局符号(这些名字是准确的,它们来自NTDLL.DBG文件)
// _NtdllBaseTag
// _ShowSnaps
// _SaveSp
// _CurSp
// _LdrpInLdrInit
// _LdrpFatalHardErrorCount
// _LdrpImageHasTls
NTSTATUS
LdrpRunInitializeRoutines( DWORD bImplicitLoad )
{
// 获取可能需要被初始化的模块数。其中的一些可能已经被初始化了
unsigned nRoutinesToRun = _LdrpClearLoadInProgress();
if ( nRoutinesToRun )
{
// 如果存在需要初始化的模块,就为保存模块相关信息的数组分配内存
pInitNodeArray = _RtlAllocateHeap(GetProcessHeap(),
_NtdllBaseTag + 0x60000,
nRoutinesToRun * 4 );
if ( 0 == pInitNodeArray ) // 确保内存分配成功
return STATUS_NO_MEMORY;
}
else
pInitNodeArray = 0;
//
// 进程环境块(Process Environment Block,Peb)中保存了一个指向已加载
// 模块链表的指针。现在获取这个指针。
//
pCurrNode = *(pCurrentPeb->ModuleLoaderInfoHead);
ModuleLoaderInfoHead = pCurrentPeb->ModuleLoaderInfoHead;
if ( _ShowSnaps )
{
_DbgPrint( "LDR: Real INIT LIST\n" );
}
nModulesInitedSoFar = 0;
if ( pCurrNode != ModuleLoaderInfoHead )
{
//
// 遍历链表
//
while ( pCurrNode != ModuleLoaderInfoHead )
{
ModuleLoaderInfo pModuleLoaderInfo;
//
// 显然指向下一个结点的指针在ModuleLoaderInfo结构中的0x10字节处
//
pModuleLoaderInfo = &NextNode - 0x10;
// 这条语句看起来好像没有什么作用
localVar3C = pModuleLoaderInfo;
//
// 确定模块是否已经被初始化。如果是,就跳过它
//
// X_LOADER_SAW_MODULE = 0x40
if ( !(pModuleLoaderInfo->Flags35 & X_LOADER_SAW_MODULE) )
{
//
// 此模块还未被初始化。检查它是否有入口点函数
//
if ( pModuleLoaderInfo->EntryPoint )
{
//
// 这个未初始化的模块有入口点函数。将它添加到
// pInitNodeArray数组中。此函数会在后面初始化
// 这个数组中的模块。
//
pInitNodeArray[nModulesInitedSoFar] =pModuleLoaderInfo;
// 若ShowSnaps不为0,输出模块的路径及其入口点地址。例如:
//
// C:\WINNT\system32\KERNEL32.dll init routine 77f01000
if ( _ShowSnaps )
{
_DbgPrint( "%wZ init routine %x\n",
&pModuleLoaderInfo->24,
pModuleLoaderInfo->EntryPoint );
}
nModulesInitedSoFar++;
}
}
// 设置此模块的X_LOADER_SAW_MODULE标志。注意:此时模块实际
// 并未被初始化。要等到这个函数快结束时才初始化
pModuleLoaderInfo->Flags35 &= X_LOADER_SAW_MODULE;
// 移向模块列表中的下一个结点
pCurrNode = pCurrNode->pNext
}
}
else
{
pModuleLoaderInfo = localVar3C; // 可能未被初始化吗???
}
if ( 0 == pInitNodeArray )
return STATUS_SUCCESS;
// ************************* MSJ建议代码布局! *****************
// 如果由于页面限制需要将代码分开,这里是最好的分隔点。
// 不过要记住移去这个注释
// ************************* MSJ建议代码布局! *****************
//
// 现在pInitNodeArray数组中包含的是未初始化的模块的信息的指针。
// 是调用它们的初始化例程的时候了。
//
try // 用try块将整个代码包装起来,以防初始化例程失败。
{
nModulesInitedSoFar = 0; // 从索引为0的数组元素开始
//
// 开始遍历整个模块数组
//
while ( nModulesInitedSoFar < nRoutinesToRun )
{
// 获取有关模块信息的指针
pModuleLoaderInfo = pInitNodeArray[ nModulesInitedSoFar ];
// 这条语句好像没什么作用
localVar3C = pModuleLoaderInfo;
nModulesInitedSoFar++;
// 将初始化例程的地址保存在一个局部变量中
pfnInitRoutine = pModuleLoaderInfo->EntryPoint;
fBreakOnDllLoad = 0; // 默认加载时不中断
//
// 如果进程正处于被调试状态,确认一下是否应该在调用
// 初始化例程之前中断在调试器中
//
// DebuggerPresent(在PEB结构中的偏移2处)是IsDebuggerPresent()
// 返回的内容。这个API仅存在于Windows NT上
//
if ( pCurrentPeb->DebuggerPresent || pCurrentPeb->1 )
{
LONG retCode;
//
// 查询注册表中的“HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\
// Windows NT\CurrentVersion\Image File Execution Options”
// 这个键。如果它下面存在一个以这个可执行文件名命名的子键,
// 就检查这个子键下的BreakOnDllLoad值
//
retCode =
_LdrQueryImageFileExecutionOptions(
pModuleLoaderInfo->pwszDllName,
"BreakOnDllLoad",
REG_DWORD,
&fBreakOnDllLoad,
sizeof(DWORD),
0 );
// 如果未找到这个值(通常是这样),在初始化DLL时就不中断
if ( retCode <= STATUS_SUCCESS )
fBreakOnDllLoad = 0;
}
if ( fBreakOnDllLoad )
{
if ( _ShowSnaps )
{
// 在实际中断进调试器之前,输出模块名称和初始化例程的地址
_DbgPrint( "LDR: %wZ loaded.",
&pModuleLoaderInfo->pModuleLoaderInfo );
_DbgPrint( "- About to call init routine at %lx\n",
pfnInitRoutine )
}
// 中断进调试器
_DbgBreakPoint(); // 它实际是一条INT 3指令,后面跟着RET指令
}
else if ( _ShowSnaps && pfnInitRoutine )
{
// 在调用初始化例程之前输出模块名称和初始化例程的地址
_DbgPrint( "LDR: %wZ loaded.",
pModuleLoaderInfo->pModuleLoaderInfo );
_DbgPrint("- Calling init routine at %lx\n", pfnInitRoutine);
}
if ( pfnInitRoutine )
{
// 设置标志来表明已将DLL_PROCESS_ATTACH通知发送给了DLL
//
// (难道这不应该是在实际调用初始化例程之后才设置吗?)
//
// X_LOADER_CALLED_PROCESS_ATTACH = 0x8
pModuleLoaderInfo->Flags36 |= X_LOADER_CALLED_PROCESS_ATTACH;
//
// 如果此模块使用了线程局部存储(TLS),现在调用TLS初始化函数
// *** 注意 ***
// 这仅发生在一个进程首次调用此函数时(也就是在初始化隐含
// 链接的DLL时)。动态加载的DLL不应该使用TLS变量,正如
// SDK文档所说的那样
//
if ( pModuleLoaderInfo->bHasTLS && bImplicitLoad )
{
_LdrpCallTlsInitializers( pModuleLoaderInfo->hModDLL,
DLL_PROCESS_ATTACH );
}
hModDLL = pModuleLoaderInfo->hModDLL
MOV ESI,ESP // 将ESP寄存器的值保存到ESI中
MOV EDI,DWORD PTR [pfnInitRoutine] // 将模块的入口点
//地址加载到EDI中
//以下的汇编语言代码用C++代码表示就是:
//
// initRetValue =
// pfnInitRoutine(hInstDLL,DLL_PROCESS_ATTACH,bImplicitLoad);
//
PUSH DWORD PTR [bImplicitLoad]
PUSH DLL_PROCESS_ATTACH
PUSH DWORD PTR [hModDLL]
CALL EDI // 调用初始化例程。这是设置断点的最佳位置。
// 单步跟踪这个调用就进入到了DLL的入口点中
MOV BYTE PTR [initRetValue],AL // 保存入口点函数的返回值
MOV DWORD PTR [_SaveSp],ESI // 保存入口点函数返回后的
MOV DWORD PTR [_CurSp],ESP // 堆栈指针的值
MOV ESP,ESI // 恢复调用入口点函数之前ESP中的值
//
// 校验调用前后堆栈指针(ESP)的值。如果它们不同,这
// 表明DLL的初始化例程并没有正确地清理堆栈。例如,它的
// 入口点函数可能定义的不正确。尽管这极少发生,但是如果
// 它确实发生了,我们要通知用户并让他们决定是否继续执行
//
if ( _CurSP != _SavSP )
{
hardErrorParam = pModuleLoaderInfo->FullDllPath;
hardErrorRetCode =
_NtRaiseHardError(
STATUS_BAD_DLL_ENTRYPOINT | 0x10000000,
1, // 参数个数
1, // UnicodeStringParametersMask,
&hardErrorParam,
OptionYesNo, // 让用户决定
&hardErrorResponse );
if ( _LdrpInLdrInit )
_LdrpFatalHardErrorCount++;
if ( (hardErrorRetCode >= STATUS_SUCCESS)
&& (ResponseYes == hardErrorResponse) )
{
return STATUS_DLL_INIT_FAILED;
}
}
//
// 如果DLL的入口点函数返回0(表示失败),通知用户
//
if ( 0 == initRetValue )
{
DWORD hardErrorParam2;
DWORD hardErrorResponse2;
hardErrorParam2 = pModuleLoaderInfo->FullDllPath;
_NtRaiseHardError( STATUS_DLL_INIT_FAILED,
1, // 参数个数
1, // UnicodeStringParametersMask
&hardErrorParam2,
OptionOk, // 只能以“确定”作为响应
&hardErrorResponse2 );
if ( _LdrpInLdrInit )
_LdrpFatalHardErrorCount++;
return STATUS_DLL_INIT_FAILED;
}
}
}
//
// 如果这个进程自身的EXE文件定义了TLS变量,现在调用TLS初始化例程。
// 要获取更详细的信息,参考前面调用_LdrpCallTlsInitializers时的注释
//
if ( _LdrpImageHasTls && bImplicitLoad )
{
_LdrpCallTlsInitializers( pCurrentPeb->ProcessImageBase,
DLL_PROCESS_ATTACH );
}
}
__finally
{
//
// 在这个函数退出之前,确保它在前面分配的内存被释放
//
_RtlFreeHeap( GetProcessHeap(), 0, pInitNodeArray );
}
return STATUS_SUCCESS;
}
返回
图6 CALC.EXE的ShowSnaps输出信息
## 以##开头的是我的注释
Loaded 'C:\WINNT\system32\CALC.EXE', no matching symbolic information found.
Loaded symbols for 'C:\WINNT\system32\ntdll.dll'
LDR: PID: 0x3a started - '"C:\WINNT\system32\CALC.EXE"'
LDR: NEW PROCESS
Image Path: C:\WINNT\system32\CALC.EXE (CALC.EXE)
Current Directory: C:\WINNT\system32
Search Path: C:\WINNT\system32;.;C:\WINNT\System32;C:\WINNT\system;...
LDR: SHELL32.dll used by CALC.EXE
Loaded 'C:\WINNT\system32\SHELL32.DLL', no matching symbolic information found.
LDR: ntdll.dll used by SHELL32.dll
LDR: Snapping imports for SHELL32.dll from ntdll.dll
LDR: KERNEL32.dll used by SHELL32.dll
Loaded symbols for 'C:\WINNT\system32\KERNEL32.DLL'
LDR: ntdll.dll used by KERNEL32.dll
LDR: Snapping imports for KERNEL32.dll from ntdll.dll
LDR: Snapping imports for SHELL32.dll from KERNEL32.dll
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlEnterCriticalSection
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlDeleteCriticalSection//其余部分省略....
LDR: GDI32.dll used by SHELL32.dll
Loaded symbols for 'C:\WINNT\system32\GDI32.DLL'
LDR: ntdll.dll used by GDI32.dll
LDR: Snapping imports for GDI32.dll from ntdll.dll
LDR: KERNEL32.dll used by GDI32.dll
LDR: Snapping imports for GDI32.dll from KERNEL32.dll
LDR: USER32.dll used by GDI32.dll
Loaded symbols for 'C:\WINNT\system32\USER32.DLL'
LDR: ntdll.dll used by USER32.dll
LDR: Snapping imports for USER32.dll from ntdll.dll
LDR: KERNEL32.dll used by USER32.dll
LDR: Snapping imports for USER32.dll from KERNEL32.dll
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlSizeHeap
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlReAllocateHeap
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlFreeHeap
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlAllocateHeap//其余部分省略....
## 注意加载器开始查找并校验COMCTL32导入并绑定的DLL
Loaded 'C:\WINNT\system32\COMCTL32.DLL', no matching symbolic information found.
LDR: COMCTL32.dll bound to ntdll.dll
LDR: COMCTL32.dll has correct binding to ntdll.dll
LDR: COMCTL32.dll bound to GDI32.dll
LDR: COMCTL32.dll has correct binding to GDI32.dll
LDR: COMCTL32.dll bound to KERNEL32.dll
LDR: COMCTL32.dll has correct binding to KERNEL32.dll
LDR: COMCTL32.dll bound to ntdll.dll via forwarder(s) from KERNEL32.dll
LDR: COMCTL32.dll has correct binding to ntdll.dll
LDR: COMCTL32.dll bound to USER32.dll
LDR: COMCTL32.dll has correct binding to USER32.dll
LDR: COMCTL32.dll bound to ADVAPI32.dll
LDR: COMCTL32.dll has correct binding to ADVAPI32.dll//其余部分省略....
LDR: Refcount COMCTL32.dll (1)
LDR: Refcount GDI32.dll (3)
LDR: Refcount KERNEL32.dll (6)
LDR: Refcount USER32.dll (4)
LDR: Refcount ADVAPI32.dll (5)
LDR: Refcount KERNEL32.dll (7)
LDR: Refcount GDI32.dll (4)
LDR: Refcount USER32.dll (5)## List of implicit link DLLs to be init'ed.
LDR: Real INIT LIST
C:\WINNT\system32\KERNEL32.dll init routine 77f01000
C:\WINNT\system32\RPCRT4.dll init routine 77e1b6d5
C:\WINNT\system32\ADVAPI32.dll init routine 77dc1000
C:\WINNT\system32\USER32.dll init routine 77e78037
C:\WINNT\system32\COMCTL32.dll init routine 71031a18
C:\WINNT\system32\SHELL32.dll init routine 77c41094
## 开始实际调用隐含链接的DLL的初始化例程
LDR: KERNEL32.dll loaded. - Calling init routine at 77f01000
LDR: RPCRT4.dll loaded. - Calling init routine at 77e1b6d5
LDR: ADVAPI32.dll loaded. - Calling init routine at 77dc1000
LDR: USER32.dll loaded. - Calling init routine at 77e78037
## USER32开始做与AppInit_DLLs有关的工作,因此静态初始化被暂时中断
## 这个例子中,“globaldll.dll”是在USER32的初始化代码中由LoadLibrary加载的
LDR: LdrLoadDll, loading c:\temp\globaldll.dll from C:\WINNT\system32;.;
LDR: Loading (DYNAMIC) c:\temp\globaldll.dll
Loaded 'C:\TEMP\GlobalDLL.dll', no matching symbolic information found.
LDR: KERNEL32.dll used by globaldll.dll//其余部分省略....
LDR: Real INIT LIST
c:\temp\globaldll.dll init routine 10001310
LDR: globaldll.dll loaded. - Calling init routine at 10001310
## 现在接着初始化隐含链接的DLL
LDR: COMCTL32.dll loaded. - Calling init routine at 71031a18
LDR: LdrGetDllHandle, searching for USER32.dll from
LDR: LdrGetProcedureAddress by NAME - GetSystemMetrics
LDR: LdrGetProcedureAddress by NAME - MonitorFromWindow
LDR: SHELL32.dll loaded. - Calling init routine at 77c41094
//其余部分省略....
返回
图7 TLSInit.cpp
void _LdrpCallTlsInitializers( HMODULE hModule, DWORD fdwReason )
{
PIMAGE_TLS_DIRECTORY pTlsDir;
DWORD size
// 从IMAGE_OPTIONAL_HEADER.DataDirectory中查找TLS目录
pTlsDir = _RtlImageDirectoryEntryToData(hModule,
1,
IMAGE_DIRECTORY_ENTRY_TLS,
&size );
__try // 用try/catch块保护所有代码
{
if ( pTlsDir->AddressOfCallbacks )
{
if ( _ShowSnaps ) // 输出诊断信息
{
_DbgPrint( "LDR: Tls Callbacks Found. "
"Imagebase %lx Tls %lx CallBacks %lx\n",
hModule, TlsDir, pTlsDir->AddressOfCallbacks );
}
// 获取指向包含TLS回调函数地址的数组的起始位置的指针
PVOID * pCallbacks = pTlsDir->AddressOfCallbacks;
while ( *pCallbacks ) // 遍历数组中的每一个元素
{
PIMAGE_TLS_CALLBACK pTlsCallback = *pCallbacks;
pCallbacks++;
if ( _ShowSnaps ) // 输出更多诊断信息
{
_DbgPrint( "LDR: Calling Tls Callback "
"Imagebase %lx Function %lx\n",
hModule, pTlsCallback );
}
// 实际调用回调函数
pTlsCallback( hModule, fdwReason, 0 );
}
}
}
__except( EXCEPTION_EXECUTE_HANDLER )
{
}
}
返回
调试System.AccessViolationException
System.AccessViolationException异常通常发生在非托管代码尝试从尚未分配的内存读取或写入内存时。
制造错误
Win32下的中断和异常
本文是Matt Pietrek在1997年月10月的MSJ杂志Under The Hood专栏上发表的文章。中断和异常在DOS时代是整个系统的灵魂,但Windows已将其隐藏到了系统深处。Matt Pietrek详细剖析了Windows下的中断和异常及其处理机制以及内核模式与用户模式代码之间调用的问题。作者还提供了一个比较有意思的实验程序。
你可能感觉一切都好。但当你写了一些新代码并运行它时才知道你被感觉欺骗了!又出现了令人烦恼的访问违规(Access Violation)。你可能也看到了那个令人害怕的代码0xC0000005,也就是STATUS_ACCESS_VIOLATION。0xC0000005是如何表示“刚刚出错了”的,以及Win32®是如何支持不同类型的异常的,这些并不为很多人所知。在本月的专栏中,我要挖掘Win32下的异常以及它们是如何与硬件异常相关联的。在讨论硬件时主要针对的是Intel x86平台。
如果你曾经为Windows® 3.x编写过程序或者编写过MS-DOS®
extender,你一定遇到过0xD这个异常(一般保护性错误,简称为GPF)。你也可能看到过其它错误,例如非法指令错误(异常6)。这些代码并不是人工赋予的。任何Intel手册上都说过,这些异常代码是CPU用来通知各种问题或事件用的。在Win32中你看不到这些代码,因为Windows®
NT,这个Win32操作系统家族的旗舰产品,被设计运行于多种平台上。它没有简单地让Alpha或MIPS版本的Windows NT使用Intel
CPU的异常代码。
相反,Win32使用它自己的一套代码系统来表示各种异常。在任何给定的Win32平台上,系统把相应的CPU的异常代码映射到一个或多个通用的Win32异常代码上。例如,Intel
CPU上的异常代码0xD可能变成STATUS_ACCESS_VIOLATION(0xC0000005)。同样,异常代码0xD也可能变成Win32的STATUS_PRIVILEGED_INSTRUCTION(0xC0000096)异常。底层的硬件异常决定了它应该被映射到哪个Win32异常上。
让我们从CPU异常和中断出发,开始我们的Win32异常之旅。异常(Exception)和中断(Interrupt)是一种手段,
当正在执行代码时CPU通过它切换到一个完全不同的代码路径上以处理一些外部的刺激或条件。中断通常是由外部的刺激引起的,例如按下了一个键。而异常则是代码或数据中的条件导致处理器生成的。CPU试图读取一个没有物理内存映射到的地址时会产生异常,这是最经典的一个异常的例子。
Intel
CPU保留了32个中断/异常号以处理各种情形。图1是一些常用的代码。它们中很多意义很清楚,但是还有很多你没有遇到过(至少是在运行本专栏的样例程序之前)。MS-DOS上的老手可能奇怪竟然列出的INT
5H不是打印屏幕,INT
8H也不是计时器中断。这是为什么?图1的描述是Intel对异常和中断的定义。但不幸的是,在Intel迅猛发展之前,MS-DOS的作者已经把其中的一些中断号用作其它用途。结果导致当程序员使用BOUND指令时竟然意外到得到了屏幕的输出内容!
图1:Intel定义的异常和中断
代码 定义
00 除法错
01 调试异常(单步和硬件调试)
02 不可屏敝中断(NMI)
03 断点中断
04 溢出中断(INTO)
05 越界中断
06 非法指令
07 协处理器不可用
08 异常嵌套
0A 非法任务状态段(TSS)
0B 段不存在
0C 堆栈错误
0D 一般保护性错
0E 页错误
为了简单起见,本专栏以下的部分中我就用异常来代表异常或中断。就像我前面说的,中断和异常在技术上是不同的。另外,异常可以被进一步分成故障(Fault)、自陷(Trap)和终止(Abort)。我不想在这里对它们做详细描述,你可以简单地认为它们是一样的。
当异常发生时,CPU挂起当前的执行路径,把控制权交给异常处理程序。CPU把标志寄存器(EFLAGS)、代码段寄存器(CS)、指令指针寄存器(EIP)压入堆栈以保护当前的执行状态。接着,根据异常代码查找事先设计好的处理这个异常的程序的地址,并把控制权转交给它。实际上,异常代码就是中断描述符表(Interrupt
Descriptor Talbe,IDT)的索引,而中断描述符表指出异常应该交由谁处理。
IDT是Intel CPU使用的基本数据结构,它由多达256个中断描述符组成,每个长为8字节。中断描述符表由操作系统创建和维护,因此虽然被理解为是CPU的数据结构,但它也受到操作系统的控制。如果操作系统把IDT搞错了,那整个系统立马崩溃。
在大多数操作系统上,包括基于Win32的系统,IDT被放在高特权级内存上,低特权级的应用程序根本不能访问它。这与实模式的MS-DOS程序有很大不同,在那里,应用程序通常替换中断向量表(IDT在实模式下的一种版本)。由于多个基于MS-DOS的程序、驱动程序、TSR(终止并驻留程序)缺乏协调,导致MS-DOS系统和16位的Windows系统特别不稳定。在最新的32位操作系统上,CPU严格限制对IDT的访问,相应地增加了稳定性。然而Win32设备驱动程序(高特权级)可以访问IDT,并且可以修改它在IDT中的相应项。
现在让我们回到异常发生时的情形。CPU把异常号作为索引获取8字节的描述符。在描述符中包括各种域。图2显示的是中断描述符的一种简化形式。注意,对于每个异常来说,都有一个相应的异常处理程序地址(CS:EIP),控制权就是要转到这个地址。图3显示了GPF(异常0xD)发生时的事件顺序。
图2:中断描述符
图3:异常发生时的事件顺序
要是在平时,到这里我一定会写一个能显示IDT内容的试验程序。但不幸的是(至少对于我来说),应用程序不能访问IDT。这是因为在Win32下,应用程序运行在Ring
3,这是最低的特权级。Win32操作系统内核运行在Ring
0(内核或管理模式),这是最高的特权级。同时,关键的操作系统数据结构,例如IDT,只能通过Ring 0的代码进行访问。(Ring
1和2在Win32中没有使用。从80286开始起它们就存在,但据我所知,还没有人使用这些特权级。)
既然我不能写一个可以读取IDT的程序,那就拿一些其它资料吧。图4是用SoftICE/NT的IDT命令得到的前30个中断描述符表项。SoftICE作为Ring 0下的驱动程序运行,所以它对IDT有读/写权。
图4:SoftICE的IDT命令输出结果
Int Type Sel:Offset Attributes Symbol/Owner
IDTbase=80036400 Limit=07FF
0000 IntG32 0008:8013C354 DPL=0 P _KiTrap00
0001 IntG32 0008:8013C49C DPL=3 P _KiTrap01
0002 IntG32 0008:0000137E DPL=0 P
0003 IntG32 0008:8013C764 DPL=3 P _KiTrap03
0004 IntG32 0008:8013C8B8 DPL=3 P _KiTrap04
0005 IntG32 0008:8013C9F4 DPL=0 P _KiTrap05
0006 IntG32 0008:8013CB4C DPL=0 P _KiTrap06
0007 IntG32 0008:8013D068 DPL=0 P _KiTrap07
0008 TaskG 0050:000013D8 DPL=0 P
0009 IntG32 0008:8013D3A8 DPL=0 P _KiTrap09
000A IntG32 0008:8013D4A8 DPL=0 P _KiTrap0A
000B IntG32 0008:8013D5CC DPL=0 P _KiTrap0B
000C IntG32 0008:8013D8BC DPL=0 P _KiTrap0C
000D IntG32 0008:8013DABC DPL=0 P _KiTrap0D
000E IntG32 0008:8013E468 DPL=0 P _KiTrap0E
000F IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
0010 IntG32 0008:8013E8D4 DPL=0 P _KiTrap10
0011 IntG32 0008:8013E9E8 DPL=0 P _KiTrap11
0012 TaskG 00A0:8013E7D4 DPL=0 P
0013 IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
0014 IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
0015 IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
0016 IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
0017 IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
0018 IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
0019 IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
001A IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
001B IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
001C IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
001D IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
001E IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
001F IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
0020 Reserved 0008:00000000 DPL=0 NP
0021 TrapG16 00C7:00000696 DPL=3 P
0022 Reserved 0008:00000000 DPL=0 NP
0023 Reserved 0008:00000000 DPL=0 NP
0024 Reserved 0008:00000000 DPL=0 NP
0025 Reserved 0008:00000000 DPL=0 NP
0026 Reserved 0008:00000000 DPL=0 NP
0027 Reserved 0008:00000000 DPL=0 NP
0028 Reserved 0008:00000000 DPL=0 NP
0029 Reserved 0008:00000000 DPL=0 NP
002A IntG32 0008:8013B8A6 DPL=3 P _KiGetTickCount
002B IntG32 0008:8013B990 DPL=3 P _KiCallbackReturn
002C IntG32 0008:8013BAA0 DPL=3 P _KiSetLowWaitHighThread
002D IntG32 0008:8013C65C DPL=3 P _KiDebugService
002E IntG32 0008:8013B440 DPL=3 P _KiSystemService
002F IntG32 0008:8013E7D4 DPL=0 P _KiTrap0F
首先看到的是,Windows NT 的IDT中所有的异常处理程序地址都在0x80000000之上。0x80000000之上的地址被Windows
NT保留用于特权级(Ring
0)访问。尽管从图上看可能不明显,但是确实几乎所有的异常处理程序地址都在NTOSKRNL.EXE中,它是Windows NT中运行于Ring
0的核心组件。由于我事先已经从NTOSKRNL的DBG文件中加载了调试符号,所以SoftICE查找异常处理程序地址并且找到了大部分异常处理程序的名称。前0x20个异常被一系列名字为_KiTrap00,_KiTrap01等的例程处理。“Ki”代表内核中断(Kernel
Interrupt)。
还有一个应该注意的是IDT中的描述符特权级(Descriptor Privilege
Level,DPL)域。它指定了允许调用特定软件中断的最低特权级。例如,INT 2EH可以被从Ring 3(最低特权级)到Ring
0(最高特权级)中任何一级调用。同样,用于断点的INT 3H,也可以被Ring 3及更高特权级的代码调用。
从0x2A到0x2E的异常被NTOSKRNL.EXE中的其它例程处理。例如,在我1996年八月的文章“Poking Around Under
the Hood: A Programmer’s View of Windows NT 4.0”,我讲到了Ring
3级的应用程序代码传递控制权到Ring 0级的系统代码以完成诸如创建一个新进程之类的特殊操作的机制,那就是调用INT 2E。INT
2E被系统DLL,例如NTDLL.DLL、USER32.DLL和GDI32.DLL从Ring
3调用。看一下IDT的0x2E这一项,你会看到它的地址指向NTOSKRNL中的_KiSystemService函数。正是这个函数把控制权转到了相应的代码。
INT 2EH之后,在前面的表中接下来最经常使用使用的中断当属INT
2BH。这个中断在IDT中的项的名称叫_KiCallbackReturn,这个名字提示了它的作用。当Ring 3的回调函数被Ring
0的代码调用后,需要一种回到Ring 0的调用者中的方法。INT
2BH正用于此目的。这方面的一个典型例子是调用SetWindowsHookEx来安装的Windows钩子回调函数。用户功能中的真正实现部分在Ring
0的WIN32K.SYS驱动程序中,正是它调用了在Ring 3中的钩子回调函数。当回调函数执行完毕,系统执行一个INT 2BH返回到Ring
0。
关于中断讲的已经够多了。那异常怎么样呢,特别是像访问违规之类令人讨厌的异常?处理器级别最经常出现的两个异常是异常0xD(GPF)和0xE(页错误)。从CPU产生这些异常到你的应用程序得到机会处理它们这段时间内,操作系统把异常代码改成它喜欢的更一般的代码。
假设你想运行下面这个有错误的程序,它试图把2写到内存偏移0处:
int main()
{
*(int *) 0 = 2;
}
正如你所料,偏移0不是一个可用的程序地址。例如,在Windows
NT中,内存的第一个4KB页面被标记为“不存在”,用以阻止使用NULL指针的程序问题。试图写这个地址将引发一个页错误(异常0xE)。看一下上面的IDT图,你会看到这个异常是由NTOSKRNL.EXE中的_KiTrap0E处理的。
我已经多次在调试器中跟踪到_KiTrap0E的代码中,但这个代码相当复杂,想全面描述得另用一篇文章才行。眼下,只要知道Ring
0的_KiTrap0E代码检查各种各样的特殊条件就足够了。因此,KiTrap0E调用了IRETD指令把控制权传到了Ring
3的NTDLL开头的KiUserExceptionDispatcher函数中。我在这里不讲KiUserExceptionDispatcher,因为我已经在我的文章“A
Crash Course on the Depths of Win32 Structured Exception
Handling”(MSJ,1997年一月)中详细讲了这个函数。重点是要知道KiUserExceptionDispatcher被告知异常代码是0xC0000005(STATUS_ACCESS_VIOLATION),并不是由CPU产生的那个异常代码0xE。
像0xC0000005之类的Win32异常代码是哪里来的?答案可以在Win32 SDK或你的C++编译器中的WINERROR.H头文件中找到。几乎在最上面,你会看到一个注释:
// Values are 32 bit values layed out as follows:
继续读这个注释,你就会知道,最高的两位(位31和30)代表严重程度。接下来的位(29)表示定义者。位28是保留的。高位字中剩下的12位是设备代码。低位字(位0到15)是异常代码。
比较有趣的一点是,Win32的Last
Error代码也是通过用位域来分类信息的。因此,你会知道像0x80010002(RPC_E_CALL_CANCELED)之类的错误代码来自哪里。顺便说一下,使用严重程度,定义者和设备位域并不是起源于Windows
NT。IBM的OS/2使用了相同的机制,它是在20世纪80年代后期合并分别由Microsoft和IBM完成的操作系统的工作的一个副产品。
回到异常中,看一下严重程度位,位31和30。值0代表成功,1代表信息,2代表警告,3(两个位均置位)代表错误。一个致命的异常相当于一个错误,因此任何32位的致命异常代码最高的两位都是置位的。接下来的两个位,定义者和保留位,通常都被设置为0,因为很少使用它们。
仅仅知道上面那些有限的异常代码构造方面的知识,你就能推断出致命异常代码都是以0xC开头的。因此,遇到像0xC0000005(STATUS_ACCESS_VIOLATION)和0xC000001D(STATUS_ILLEGAL_INSTRUCTION)之类的异常代码,你知道它们就属于这一类。比这严重程序低一些的异常,也就是警告,它的严重系数是2,因此你看到类似0x80000003(STATUS_BREAKPOINT)和0x80000004(STATUS_SINGLE_STEP)之类的代码,你知道它们就属于这一类。在WINNT.H中搜索STATUS_可以找到一份相当完整的可能的异常代码列表。当你看这个列表时要记住,并不是支持Win32的每一个处理器都可以生成所有Win32异常代码。
在写这个专栏时,我到底能导致多少个Win32异常引起了我的兴趣。我对操作系统到底能赋予我有意导致的许多错误什么样的异常代码也充满好奇。为了帮助解决这些问题,我写了一个能以各种方式产生处理器错误并且报告它们被映射到的Win32异常代码的程序框架。这就是我的GenException程序(见图5)。
图5 GenException.CPP
//==========================================
// Matt Pietrek
// Microsoft Systems Journal, October 1997
// FILE: GenException.CPP
// 使用命令行CL GenException.CPP编译
//==========================================
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
#include <float.h>
#include <assert.h>
typedef void (* PFNGENERATEEXCEPTION)(void);
void GenerateSTATUS_BREAKPOINT( void )
{
__asm int 3 // 普通的断点指令
}
void GenerateSTATUS_SINGLE_STEP( void )
{
// 这比使用硬件断点寄存器生成int 1更容易
__asm int 1
}
void GenerateSTATUS_ACCESS_VIOLATION( void )
{
// 通过读取地址在2GB以上的内存来产生
// 一个页错误(异常代码0xE)
int i = *(int *)0xFFFFFFF0;
}
void GenerateSTATUS_ILLEGAL_INSTRUCTION( void )
{
__asm _emit 0x0F // 无效指令导致产生异常0xD
__asm _emit 0xFF
}
void GenerateSTATUS_ARRAY_BOUNDS_EXCEEDED( void )
{
DWORD arrayBounds[2] = { 10, 48 };
__asm mov eax, 12
__asm bound eax, arrayBounds // 这条BOUND指令运行正常
__asm mov eax, 7
__asm bound eax, arrayBounds // 这条BOUND指令会产生异常0x5
}
void UnmaskFPExceptionBits( void )
{
unsigned short cw;
__asm fninit // 初始化数值协处理器
__asm fstcw [cw]
cw &= 0xFFE0; // 关闭大部分异常位(除了精度异常)
__asm fldcw [cw]
}
void GenerateSTATUS_FLOAT_DIVIDE_BY_ZERO( void )
{
double a = 0;
a = 1 / a;
__asm fwait;
}
void GenerateSTATUS_FLOAT_OVERFLOW( void )
{
double a = DBL_MAX;
a *= a;
__asm fwait;
}
void GenerateSTATUS_FLOAT_STACK_CHECK( void )
{
unsigned a;
__asm fistp [a]
__asm fwait;
}
void GenerateSTATUS_FLOAT_UNDERFLOW( void )
{
double a = DBL_MIN;
a /= 10;
__asm fwait;
}
void GenerateSTATUS_INTEGER_DIVIDE_BY_ZERO( void )
{
// 除以0导致异常0x0
int i = 0;
i = 2 / i;
}
void GenerateSTATUS_INTEGER_OVERFLOW( void )
{
__asm mov eax, 07FFFFFFFh // 带符号数的最大值
__asm add eax, 2 // 结果 = 0x80000001 -> 溢出!
__asm into // 产生异常0x4
}
void GenerateSTATUS_PRIVILEGED_INSTRUCTION( void )
{
// HLT指令只能在ring 0下执行
__asm hlt
}
void GenerateSTATUS_STACK_OVERFLOW( void )
{
DWORD myArray[512];
// “无穷”递归导致堆栈溢出
GenerateSTATUS_STACK_OVERFLOW();
}
DWORD GetExceptionNumber( PFNGENERATEEXCEPTION pfn )
{
DWORD exceptionCode = 0;
__try
{
pfn();
}
__except( exceptionCode = GetExceptionCode(), EXCEPTION_EXECUTE_HANDLER )
{
}
return exceptionCode;
}
#define SHOW_EXCEPTION( x ) \
dwExceptionNumber = GetExceptionNumber( Generate##x ); \
printf( "%X %s\n", dwExceptionNumber, #x ); \
assert( dwExceptionNumber == x );
int main(int argc, char *argv[])
{
DWORD dwExceptionNumber;
SHOW_EXCEPTION( STATUS_BREAKPOINT )
SHOW_EXCEPTION( STATUS_SINGLE_STEP )
SHOW_EXCEPTION( STATUS_ACCESS_VIOLATION )
SHOW_EXCEPTION( STATUS_ILLEGAL_INSTRUCTION )
SHOW_EXCEPTION( STATUS_ARRAY_BOUNDS_EXCEEDED )
UnmaskFPExceptionBits();
SHOW_EXCEPTION( STATUS_FLOAT_DIVIDE_BY_ZERO )
UnmaskFPExceptionBits();
SHOW_EXCEPTION( STATUS_FLOAT_OVERFLOW )
UnmaskFPExceptionBits();
SHOW_EXCEPTION( STATUS_FLOAT_STACK_CHECK )
UnmaskFPExceptionBits();
SHOW_EXCEPTION( STATUS_FLOAT_UNDERFLOW )
SHOW_EXCEPTION( STATUS_INTEGER_DIVIDE_BY_ZERO )
SHOW_EXCEPTION( STATUS_INTEGER_OVERFLOW )
SHOW_EXCEPTION( STATUS_PRIVILEGED_INSTRUCTION )
SHOW_EXCEPTION( STATUS_STACK_OVERFLOW );
return 0;
}
GetException程序的代码被分成三部分。第一部分是一系列函数,它们的名字以Generate开头,后面是它们要产生的Win32异常的名字。例如,GenerateSTATUS_ILLEGAL_INSTRUCTION引起一个非法指令异常。第二部分是GetExceptionNumber函数。它使用Win32结构化异常处理(SEH)来确定各个GenerateXXX函数引起的Win32异常代码,并且将这个异常代码返回它的调用者。GetExceptionNumber函数带有一个参数,这个参数是指向它要调用的GenerateXXX函数的指针。
GenException.CPP的最后一部分是main函数。它是一系列C++预处理器宏的调用,这个宏被我命名为SHOW_EXCEPTION。对SHOW_EXCEPTION的每一次调用就会产生一个Win32异常。SHOW_EXCEPTION带一个预定义的异常名称(例如STATUS_ACCESS_VIOLATION),然后将它合成一个与其相应的GenerateXXX函数的调用。我使用SHOW_EXCEPTION宏来省略大量模板代码,这些模块代码只有实际调用的异常代码不同。通过使用预处理器符号粘贴(preprocessor
token pasting)和字符串化(stringizing)宏,这一行
SHOW_EXCEPTION( STATUS_BREAKPOINT )
被扩展成:
dwExceptionNumber = GetExceptionNumber( GenerateSTATUS_BREAKPOINT );
printf( "%X %s\n", dwExceptionNumber, "STATUS_BREAKPOINT" );
assert( dwExceptionNumber == STATUS_BREAKPOINT );
在写GetException时,一些异常非常容易产生,例如STATUS_ACCESS_VIOLATION。创建那些不常见的异常也很重要,例如STATUS_ILLEGAL_INSTRUCTION。许多情况下,我不得不借助于内联汇编。两个比较好的例子是CPU异常4和5,它们分别由INTO指令和BOUND指令产生。我不详细讲述各种异常是如何产生的,GenException.CPP代码中包含了许多相关注释。
生成浮点异常需要一些技巧,因为Win32初始化浮点单元时不会产生异常。我不得不明确关闭协处理器控制字中的某些位来产生浮点异常,像STATUS_FLOAT_DIVIDE_BY_ZERO。如果你对此好奇,可以看UnmaskFPExceptionBits函数,它包含了处理那些位的代码。因为在执行浮点指令时,只有执行到实际出错指令的下一条指令时才引发异常,因此我使用__asm
fwait指令强制在一个有意出错的指令后引发一个异常。
可能GetException不是你曾经运行过的程序中最令人兴奋或最有用的程序,但是我相信你一定能从如何产生各种Win32异常中受到启发。在大多数情况下,CPU生成一个异常0xD,然后Win32异常处理程序分析这个代码并构造一个更有意义,更加明确的异常代码。我的目的是描述这些机制,解释硬件级别和操作系统级别的异常,并且向你展示它们之间的联系。