在Windows系统中,每个进程都被赋予它自己的虚拟地址空间,对于32位进程来说,这个地址空间为4GB(即0x00000000~0xFFFFFFFF)。当进程的一个线程正在运行时,该线程可以访问只属于它的进程的内存;属于所有其他进程的内存则隐藏着,并且不能被正在运行的线程访问。对于Window2000以上的操作系统,属于操作系统本身的内存也是隐藏的,不能被正在运行的用户线程访问(Windows98则可以,所以98很容易被搞死机,^-^)。
进程的4GB虚拟地址空间被划分为若干分区,不同的操作系统,其分区略有不同,以下内容适用于Windows2000及以上系统:
n NULL指针分配的分区
0x00000000~0x0000FFFF (64K)
这个分区的设置是为了帮助程序员掌握NULL指针的分配情况,如果你的进程中的线程试图读取该分区的地址空间的数据,或者将数据写入该分区的地址空间,那么CPU就会引发一个访问违规(0xC0000005)。保护这个分区是及其有用的,它可以帮助你发现NULL指针的分配情况。
n 用户方式分区
0x00010000~0x7FFEFFFF (3GB方式:0x00010000~0xBFFEFFF)
这个分区是进程的私有(非共享)地址空间所在的地方,一个进程不能读取、写入、或者以任何方式访问驻留在该分区的另一个进程的数据。对于所有应用程序来说,该分区是维护进程的大部分数据的地方。由于每个进程可以得到它自己的私有的、非共享分区,以便存放它的数据,因此,应用程序不太可能被其他的应用程序所破坏,这使得系统更加健壮。
在Windows2000中,所有的EXE和DLL模块均加载在该分区,每个进程可以将这些DLL加载到该分区的不同地址中,系统还可以在这个分区中映射该进程可以访问的所有内存映射文件。
n 64KB禁止进入的分区
0x7FFF000~0x7FFFFFFF (3GB方式:0x-xBFFF0000~0xBFFFFFFF)
这个位于用户方式分区上面的64KB分区是禁止进入的,访问该分区中的内存的任何企图均将导致访问违规。微软之所以保留该分区,是为了便于操作系统检测用户进程对内核方式分区(0x80000000以上区域)的任何访问企图。
n 内核方式分区
0x8000000~0xFFFFFFFF (3GB方式:0xC0000000~0xFFFFFFFF)
这个分区是存放操作系统代码的地方,用于线程调度、内存管理、文件系统支持、网络支持和所有设备驱动程序的代码全部都在这个分区加载。驻留在这个分区中的一切均可被所有进程共享。在Windows2000中,这些组件是完全受到保护的。如果你试图访问该分区中的内存地址,你的线程将会产生访问违规。
当进程被创建并被赋予它的地址空间时,该可用地址空间的主体是空闲的(即未分配的);若要使用该地址空间的各个部分,必须通过调用VirtualAlloc函数来分配它里边的各个区域。对一个地址空间的区域进行分配的操作称为保留(reserving)。
每当你保留地址空间的一个区域时,系统要确保该区域从一个分配粒度的边界开始,对于x86平台来说,这个分配粒度是64K(可用GetSystemInfo函数查询该值)。当你保留地址空间的一个区域时,系统还要确保该区域的大小是系统的页面大小的倍数。页面是操作系统在管理内存时使用的一个内存单位,对于目前的x86平台,页面大小为4K(可用GetSystemInfo函数查询该值)。
在程序不再需要访问已经保留的地址空间区域时,该区域应该被释放,这个过程称为释放地址空间的区域,通过调用VirtualFree函数来完成的。
若有使用已保留的地址空间区域,必须分配物理存储器,然后将该物理存储器映射到已保留的地址空间区域。这个过程称为提交物理存储器。物理存储器总是以页面的形式来提交的,若要将物理存储器提交给一个已保留的地址空间区域,也要调用VirtualAlloc函数。当将物理存储器提交给地址空间区域时,不必将物理存储器提交给整个区域(可以只提交某些页)。
当不再需要访问保留的地址空间区域中已提交的物理存储器时,该物理存储器应该被释放,这个过程称为回收物理存储器,它也是通过VirtualFree来完成的。
在这里我们所指的物理存储器,通常是指的分页文件,因为在调用VirtualAlloc函数提交物理存储器的时候,操作系统只是在分页文件中为请求分配一块区域。然后操作系统会根据后续的访问请求,动态的将要访问的页数据交换到内存中。所以,通常的物理存储器是由分页文件来维护的。
但是也有另外一种情况,物理存储器并是分页文件,这是模块加载的时候所发生的事情。当启动一个应用程序的时候,系统将打开应用程序的EXE文件,确定该应用程序的代码和数据的大小,然后系统要保留一个地址空间的区域,并指明与该区域相关联的物理存储器是在EXE文件本身中。即系统并不是从页文件中分配地址空间,而是将EXE文件的实际内容(即程序映象)用作程序的保留地址空间区域。这使得应用程序的加载非常迅速,并使页文件能够保持得非常小。
当硬盘上的一个程序的文件映象(EXE或DLL文件)用作地址空间区域的物理存储器时,它称为内存映射文件。当一个EXE或DLL文件被加载时,系统将自动保留一个地址空间的区域,并将该文件映象映射到该区域中。
查询地址空间中内存地址的某些信息(如大小、存储器类型、保护属性等),可以使用VirtualQuery函数;此外Windows还提供了另外一个函数,它使一个进程能够查询另外一个进程的内存信息,这个函数是VirtualQueryEx(调试程序经常使用该函数)。
VirtualQuery的函数原型是:
SIZE_T VirtualQuery(
LPCVOID lpAddress, 待查询的内存地址
PMEMORY_BASIC_INFORMATION lpBuffer, 用于保存返回信息的缓冲区
SIZE_T dwLength 指明缓冲区的长度
);
其中,第二个参数为内存信息结构,其定义如下:
typedef struct _MEMORY_BASIC_INFORMATION {
PVOID BaseAddress; 内存区域页的基址,该值是lpAddress圆整为页边界的值,
PVOID AllocationBase; 调用VirtualAlloc函数分配区域地址空间时的起始地址。
DWORD AllocationProtect; 分配时设置的地址包含属性。
SIZE_T RegionSize; 区域大小(字节数)。
DWORD State; 内存状态,MEM_COMMIT/MEM_FREE/MEM_RESERVE
DWORD Protect; 区域的访问属性。
DWORD Type; 区域的类型,MEM_IMAGE/MEM_MAPPED/MEM_PRIVATE
} MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;
进程通常被定义为一个正在运行的程序的实例组成,它由两部分组成:
n 操作系统用来管理进程的内核对象:该内核对象也是操作系统用来存放关于进程的统计信息的地方;
n 地址空间:它包含所有可执行模块或动态库模块的代码和数据,它还包含动态内存分配的空间,如线程堆栈和堆分配空间。
进程是不活泼的,若要使进程完成某项操作,它必须拥有一个在它的环境中运行的线程,该线程负责执行包含在进程的地址空间中的代码。当创建一个进程时,系统会自动创建它的第一个线程,称为主线程,然后该线程可以创建其他的线程。每个线程都有它自己的一组CPU寄存器(称为线程的上下文,Context)和它自己的堆栈。
当调用CreateProcess创建进程时,系统将执行下列操作:
n 系统找出调用CreateProcess时设定的EXE文件,如果无法找到,则返回FALSE;
n 系统创建一个新进程内核对象;
n 系统为这个新进程创建一个私有地址空间;
n 系统保留一个足够大的地址空间区域,用于存放该EXE文件。该区域需要的位置在EXE文件本身中设定,按照默认设置,EXE文件的基地址为0x00400000。可以在创建应用程序EXE文件时覆盖这个地址,方式是使用链接程序的/BASE选项;
n 系统注意到支持已保留区域的物理存储器是在磁盘上的EXE文件中,而不是系统的分页文件中。
当EXE文件被映射到进程的地址空间中之后,系统将访问EXE文件的一个某个,该部分列出了包含EXE文件中的代码要调用的函数的DLL文件。然后,系统为每个DLL文件调用LoadLibrary函数,如果任何一个DLL需要更多的DLL,那么系统也将调用LoadLibrary函数,以便加载这些DLL。
每当系统调用LoadLibrary函数来加载一个DLL时,系统将执行下列操作步骤:
n 系统保留一个足够大的地址空间区域,用于存放该DLL文件。该区域需要的位置在DLL文件本身中设定。按照默认设置,VC建立的DLL文件的基址是0x10000000,可以使用/BASE选项覆盖该基址。Windows提供的所有标准系统DLL都拥有不同的基地址,这样如果这些标志的系统DLL被加载到单个地址空间,它们就不会重叠。
n 如果系统无法在该DLL的首选基地址上保留一个区域,其原因可能是该区域已经被另一个DLL或EXE占用,也可能因为该区域不够大,此时系统将设法寻找另一个地址空间的区域来为该DLL保留地址空间。如果一个DLL不能加载到其基址,将非常不利:如果系统没有重定位信息,将无法加载该DLL;系统必须在DLL中执行某些重定位操作。
n 系统会注意到支持已保留区域的物理存储器位于磁盘上的DLL文件,而不是在系统的分页文件中。
与进程类似,线程也由两部分组成:
n 线程的内核对象:操作系统用它来对线程实施管理,内核对象也是系统用来存放线程统计信息的地方;
n 线程堆栈:它用于维护线程在执行代码时需要的所有函数和局部变量。
进程从来不执行任何东西,它只是线程的容器。线程总是在某个进程环境中创建的,而且它的整个生命周期都在该进程中。这意味着线程在它的进程地址空间中执行代码,并且在进程的地址空间中对数据进行操作。因此,如果在单个进程环境中,有两个或多个线程正在运行,那么这两个(或多个)线程将共享单个地址空间,它们能够执行相同的代码,以及对相同的数据进行操作。此外,这些线程还能共享内核对象句柄,因为句柄表也是依赖于每个进程而存在的,而不是依赖于每个线程。
调用CreateThread可使系统创建一个线程内核对象,该对象的初始引用计数为2(在线程停止运行和从CreateThread返回的句柄关闭之前,线程内核对象不会被撤消)。线程的内核对象的其他属性也被初始化,暂停计数被设置为1,退出代码设置为STILL_ACTIVE(0x103),该内核对象设置为未通知状态。
一旦内核对象创建完成,系统就分配用于线程的堆栈的内存,该内存是从进程的地址空间分配而来的,因为线程并不拥有它自己的地址空间。然后系统将两个值写入新线程的堆栈的上端(栈底:线程堆栈总是从内存的高地址向低地址建立)。写入堆栈的第一个值是传递给CreateThread的pvParam参数的值,紧靠它下面的是pfnStartAddr参数的值。
每个线程都有它自己的一组CPU寄存器,称为线程的上下文(Context)。该上下文反映了线程上次运行时该线程的CPU寄存器的状态。线程的这组CPU寄存器保存在一个CONTEXT结构(定义于WinNT.h头文件)中,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;
// 扩展寄存器
BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
指令指针(EIP)和堆栈指针(ESP)寄存器是线程上下文中两个最重要的寄存器,当线程的内核对象被初始化时,CONTEXT结构的堆栈指针寄存器(ESP)被设置未线程堆栈上用来存放pfnStartAddr的地址,指令指针(EIP)寄存器置为称为BaseThreadStart的未公开(和未导出)的函数的地址中,该函数在Kernel32.DLL中(这也是实现CreateThread的地方)。
VOID BaseThreadStart( PTHREAD_START_ROUTINE pfnStartAddr, PVOID pvParam )
{
try
{
if ( NtCurrentTeb()->NtTib.Version == OS2_VERSION )
{
if ( !BaseRunningInServerProcess )
CsrNewThread();
}
ExitThread( (pfnStartAddr)(pvParam) );
}
except( UnhandledExceptionFilter( GetExceptionInformation() ))
{
if ( !BaseRunningInServerProcess )
ExitProcess(GetExceptionCode());
else
ExitThread(GetExceptionCode());
}
}
当线程完全初始化后,系统就要查看CREATE_SUSPENDED标志是否被传递给CreateThread函数,如果该标志没有传递,系统便将线程的暂停计数递减为0,该线程然后可以调度到一个进程中;然后系统用上次保存在线程上下文中的值加载到实际的CPU寄存器中,这时线程就可以执行代码,并对它的进程的地址空间中的数据进行操作。
由于新线程的指令指针(EIP)被置为BaseThreadStart,因此该函数实际上是线程开始执行的地方。BaseThreadStart的原型会使你认为该函数接收了两个参数,但是这表示该函数是由另一个函数来调用的,而实际情况并非如此。新线程只是在此处产生并且开始执行。BaseThreadStart认为它是由另一个函数调用的,因为它可以访问两个函数。但是,之所以可以访问这些参数,是因为操作系统将这两个参数的值显式的写入了线程的堆栈(这就是通常的函数传递参数的方法)。注意:有些CPU结构使用CPU寄存器而不是堆栈来传递参数,对于这样的CPU,系统将在允许线程执行BaseThreadStart函数之前对相应的寄存器正确的进行初始化。
当新线程执行BaseThreadStart函数时,将会出现下列情况:
n 在线程函数中建立一个结构化异常(SHE)处理帧,这样在线程执行时产生的任何异常情况都会得到系统的某种默认处理;
n 系统调用线程函数,并将你传递给CreateThread函数的pvParam参数传递给它;
n 当线程函数返回时,BaseThreadStart调用ExitThread,并将线程的返回值传递给它。该线程的内核对象的引用计数被递减,线程停止执行。
n 如果线程产生一个没有处理的异常条件,由BaseThreadStart函数建立的SHE帧将处理该异常条件。通常情况下,这意味着向用户显示一个消息框,并且在用户撤消该消息框时,BaseThreadStart调用ExitProcess,以终止整个进程的执行,而不只是终止线程的运行。
注意:在BaseThreadStart函数中,线程要么调用ExitThread,要么调用ExitProcess。这意味着线程不能退出该函数,它总在该函数中被撤消,这就是BaseThreadStart的原型规定返回VOID,而它从来不返回的原因(这也应该就是在创建线程对象时,没有所谓的返回地址入栈过程的原因)。
另外,由于使用BaseThreadStart,线程函数可以在它完成处理后返回。当BaseThreadStart调用线程函数时,它会把返回地址入栈,这样线程函数就能知道在何处返回。但是,BaseThreadStart不允许返回,如果它不强制撤消线程,而是试图返回,那么几乎可以肯定会引发访问违例,因为线程堆栈上不存在返回地址,而BaseThreadStart将试图返回到某个随机的内存位置。
当进程的主线程被初始化时,它的指令指针(EIP)被设置为另一个未公开的函数,称为BaseProcessStart,该函数几乎与BaseThreadStart相同:
VOID BaseProcessStart(PPROCESS_START_ROUTINE lpStartAddress )
{
Try
{
NtSetInformationThread( NtCurrentThread(),
ThreadQuerySetWin32StartAddress,
&lpStartAddress,
sizeof( lpStartAddress ));
ExitThread( (lpStartAddress)() );
}
except( UnhandledExceptionFilter( GetExceptionInformation() ))
{
if ( !BaseRunningInServerProcess )
ExitProcess(GetExceptionCode());
else
ExitThread(GetExceptionCode());
}
}
这两个函数的最主要差别在于BaseProcessThread没有pvParam参数,当BaseProcessStart开始执行时,它调用C/C++运行期库的启动代码,该启动代码先要初始化main(或wmain、WinMain、wWinMain)函数,然后再调用这些函数。当EntryPoint函数返回时,C/C++运行期库的启动代码就调用ExitProcess函数。因此,对于C/C++应用程序来说,主线程从不返回BaseProcessStart函数。(UnhandledExceptionFilter函数的实现代码由于比较长一些,不在此列出,有兴趣的同志可以自行研究Win2K的代码。)
当线程终止运行时将发生以下的操作:
n 线程拥有的所有用户对象均被释放。在Windows中,大多数对象是由包含创建这些对象的线程的进程所拥有的,但是一个线程拥有两个用户对象,即窗口和钩子。当线程终止运行时,系统会自动撤消任何窗口,并且卸载线程创建的或安装的任何钩子。其他对象只有在拥有线程的进程终止运行时才被撤消。
n 线程的退出代码从STILL_ACTIVE该为传递给ExitThread或TerminateThread的代码。
n 线程的内核对象的状态变为已通知;
n 如果线程是进程中的最后一个活动线程,系统也将进程视为已经终止运行;
n 线程内核对象的引用计数递减1;当一个线程终止运行时,在与它相关联的线程内核对象的所有未结束的引用关闭之前(即引用计数变为0之前),线程内核对象不会自动释放。
当线程终止运行后,别的线程可以调用GetExitcodeThread来检查线程是否已经终止,如果它已经终止,则可以得到它的退出代码。
每当创建一个线程时,系统就会为线程的堆栈保留一个堆栈空间区域,并将一些物理存储器提交给这个已保留区域。按照默认设置,系统保留1MB的地址空间并提交两个页面的内存。但是,这些默认值是可以修改的,方法是在链接应用程序时设置/STACK选项:
/STACK:reserved[,commit]
当创建一个线程的堆栈时,系统将会保留一个链接程序的/STACK开关指明的地址空间区域;但是当调用CreateThread或_beginthreadex函数时,可以覆盖原先提交的内存数量,这两个函数都有一个参数,可以用来覆盖原先提交给堆栈的地址空间的内存数量。如果设定的这个值为0,那么系统将使用/STACK开关指明的已提交的堆栈大小值。(如下表所示,页面大小为4K)
内存地址 |
页面状态 |
0x080FF000 |
栈的高端:已提交的页面 |
0x080FD000 |
带有保护属性标志的已提交页面 |
0x080FC000 |
保留页面 |
…… |
……(保留页面) |
…… |
……(保留页面) |
0x08000000 |
栈的低端:保留页面 |
当保留了这个区域(假设使用1M的默认设置,堆栈的起始地址为0x08000000)后,系统将物理存储器提交给区域顶部的两个页面,在允许线程启动运行之前,系统将线程的堆栈指针寄存器(ESP)设置为指向堆栈区域的最高页面,这个页面就是线程开始使用它的堆栈的位置。从顶部向下的第二个页面称为保护页面,当线程调用更多的函数来扩展它的调用树状结构时,线程将需要更多的堆栈空间。
每当线程试图访问保护页面中的存储器时,系统就会得到关于这个情况的通知,作为响应,系统将提交紧靠保护页面的下的另一个存储器页面,然后系统从当前保护页面中删除保护标志,并将它(指保护标志)赋予新提交的存储器页面。这种方法使得堆栈存储器只有在线程需要时才会增加,最终如果线程的调用树继续扩展,堆栈区域就会逐渐往低地址方向扩展。(注:这应该是通过结构化异常机制来实现的。)
内存地址 |
页面状态 |
0x080FF000 |
栈的高端:已提交的页面 |
0x080FD000 |
已提交页面 |
0x080FC000 |
已提交页面 |
…… |
……(已提交页面 |
…… |
……(已提交页面 |
0x08003000 |
已提交页面 |
0x08002000 |
带有保护属性的已提交页面 |
0x08001000 |
保留页面 |
0x08000000 |
栈的低端:保留页面 |
如上表所示(假设这时候堆栈指针寄存器ESP指向内存地址0x08003004),整个1M大小的堆栈几乎已经被用尽,这时如果线程需要提交更多的页面,情况将会变得有些不同。如果这时候线程调用另外一个函数时,系统必须提交更多的物理存储器,但是当系统将物理存储器提交给0x08001000对应的页面上,系统将清除0x08002000地址页面上的保护标志,并且0x08001000页面也被提交,但是系统并不将保护属性应用于新的物理存储器页面(即0x08001000)。这意味着该堆栈已保留的地址空间区域包含了它能够包含的全部物理存储器。堆栈底部的最后一页(0x08000000)总是被保留的,从来不会被提交。
内存地址 |
页面状态 |
0x080FF000 |
栈的高端:已提交的页面 |
0x080FD000 |
已提交页面 |
0x080FC000 |
已提交页面 |
…… |
……(已提交页面 |
…… |
……(已提交页面 |
0x08003000 |
已提交页面 |
0x08002000 |
已提交页面 |
0x08001000 |
已提交页面 |
0x08000000 |
栈的低端:保留页面 |
当系统将物理存储器提交给0x08001000的虚拟地址时,它就会引发一个EXCEPTION_STACK_OVERFLOW异常(0xC00000FD)。通过使用结构化异常处理,应用程序能够捕获该异常,并且能够实现适度的恢复。如果在出现堆栈溢出异常之后,线程继续使用该堆栈,那么在0x08001000地址上的页面中的内存将被使用,同时,该线程试图访问0x08000000页面中的内存。当线程试图访问这个保留的(未提交的)内存时,系统就会引发一个访问违规异常(0xC0000005)。这时,系统就会接管控制权,并终止进程的运行(而不仅终止线程的运行)。堆栈区域的最后一个页面之所以始终保留着,就是为了防止不小心改写进程使用的其他数据。
PE(Portable Executable)文件格式是Windows系统的可执行文件格式,可执行文件(EXE)和动态链接库(DLL)都遵循这种格式。从本质上而言,PE格式是COFF(Common Object File Format)文件格式的一种变体,关于PE文件格式的规范,可从微软以下网址获得:
http://www.microsoft.com/hwdev/hardware/PECOFF.asp
下图是一个典型的PE文件内部布局图:
图3.1 PE文件格式布局
(这个图取自Matt Pietrek的《Windows95系统程式设计大奥秘(侯捷译)》第八章)
(1)DOS MZ Header
MS-DOS 2.0 Compatible .EXE Header |
MS-DOS 2.0 Compatible Stub Program & Relocation Table |
UNUSED BYTES |
每个PE文件(EXE或DLL)的第一部分都是跟DOS相关的内容,其又可分为三个部分,其中第一部分为MS-DOS兼容的EXE文件头(64字节),其内容如下:
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
上述的结构当中,只有第一个字段(e_magic)和最后一个字段(e_lfanew)对于Win32程序员是有意义的,e_magic应始终为0x5A4D(即MZ的ASCII码),而e_lfanew字段则指明了真正的PE文件格式头相对于文件开头的偏移。
紧跟在上述结构之后的内容是一个DOS存根程序,其实就是一个有效的DOS可执行程序,在不支持PE文件格式的操作系统(例如在DOS下运行Windows程序)中,它将简单显示一个错误提示,类似于"This program cannot run in DOS mode "。这段存根程序是由链接程序自动生成的,可以把它想象为这样一个程序:
int main( int argc,char** argv )
{
printf(“This program cannot run in DOS mode./n”);
}
(2)PE文件头
在DOS MZ Header之后是真正的PE格式头,它位于文件中的偏移由上述IMAGE_DOS_HEADER结构中的e_lfanew字段指明。PE文件头是一个IMAGE_NT_HEADERS结构,其定义如下(Winnt.h):
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
在上述结构中,首先是四个字节的PE文件签名(其值为:0x00004550,即PE00),然后是映象文件头,最后是可选文件头(虽然命名为可选的,但事实上对于PE文件这个头部是必须的,^-^)。其中映象文件头结构如下:
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
在上述结构中,最重要的两个字段是:NumberOfSections和SizeOfOptionHeader。其中NumberOfSections字段指明了文件包含的节数(如代码节、数据节、输出节等),每个节的头部和实体是连续存放在一起的。而SizeOfOptionHeader则指明了可选文件头的字节数。
可选文件头的结构如下:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
在上述结构中,包含了一些非常重要的信息:
n SizeOfCode:可执行代码尺寸;
n SizeOfInitializedData:已初始化的数据尺寸;
n SizeOfUninitializedData:未初始化的数据尺寸;
n AddressOfEntryPoint:应用程序入口点的位置;
n BaseOfCode:已载入映像的代码(“.text”段)的相对偏移量;
n BaseOfData:已载入映像的未初始化数据(“.bss”段)的相对偏移量;
n ImageBase:进程映像地址空间中的首选基地址,VC将这个值默认设为0x00400000;
n SectionAlignment:从ImageBase开始,每个节都被相继的装入进程的地址空间中。SectionAlignment则规定了装载时节能够占据的最小空间数量——就是说,节是关于SectionAlignment对齐的。Windows虚拟内存管理器规定,节对齐不能少于页尺寸(当前的x86平台是4096字节),并且必须是成倍的页尺寸。4096字节是x86链接器的默认值。
n SizeOfImage:表示载入的可执行映像的地址空间中要保留的地址空间大小,这个数字很大程度上受SectionAlignment的影响。
n SizeOfHeaders:这个域表示文件中有多少空间用来保存所有的文件头部,包括MS-DOS头部、PE文件头部、PE可选头部以及PE段头部。文件中所有的段实体就开始于这个位置;
n SizeOfStackReserve、 SizeOfStackCommit、SizeOfHeapReserve、SizeOfHeapCommit:这些域控制要保留的地址空间数量,并且负责栈和默认堆的申请。这些值可以使用链接器开关来设置。
(3)Section Table
在PE文件头之后就是节头部表,它是IMAGE_SECTION_HEADER结构(40个字节大小)的数组(数组大小由IMAGE_FILE_HEADER结构中的NumberOfSections字段指出):
#define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
上述结构中各个字段的含义如下:
n Name:每个节都有一个8字符长的名称域,并且第一个字符必须是一个句点(.);
n PhysicalAddress/VirtualSize:现在已不使用了;
n VirtualAddress:这个域标识了进程地址空间中要装载这个节的虚拟地址。实际的地址由将这个域的值加上可选头部结构中的 ImageBase虚拟地址得到。切记,如果这个映像文件是一个DLL,那么这个DLL就不一定会装载到ImageBase要求的位置。所以一旦这个文件被装载进入了一个进程,实际的ImageBase值应该通过使用GetModuleHandle来检验。
n SizeOfRawData:这个域表示了相对FileAlignment的节实体尺寸。文件中实际的节实体尺寸将少于或等于 FileAlignment的整倍数。一旦映像被装载进入了一个进程的地址空间,节实体的尺寸将会变得少于或等于FileAlignment的整倍数。
n PointerToRawData:这是一个文件中节实体位置的偏移量。
n PointerToRelocations、PointerToLinenumbers、NumberOfRelocations、NumberOfLinenumbers:这些域在PE格式中不使用。
n Characteristics:定义了段的特征,这些值可以在WINNT.H中找到。
(4)预定义节
一个Windows应用程序典型地拥有如下的9个预定义段,它们分别是.text、.bss、.rdata、.data、
.rsrc、.edata、.idata、.pdata 和.debug。一些应用程序不需要所有的这些段,同样还有一些应用程序为了自己特殊的需要而定义了更多的段。事实上,应用程序定义一个独特的段的方法是使用标准编译器来指示对代码段和数据段的命名。
n 可执行代码节(.text):Windows默认的做法是将所有的代码段组成了一个单独的节,名为“.text”。
n 数据节,.bss、.rdata、.data:.bss节表示应用程序的未初始化数据,包括所有函数或源模块中声明为static的变量。.rdata节表示只读的数据,比如字符串文字量、常量和调试目录信息。所有其它变量(除了出现在栈上的自动变量)存储在.data节之中。基本上,这些是应用程序或模块的全局变量。
n 资源节,.rsrc:.rsrc节包含了模块的资源信息。它起始于一个资源目录结构,这个结构就像其它大多数结构一样,但是它的数据被更进一步地组织在了一棵资源树之中。IMAGE_RESOURCE_DIRECTORY结构(参见winnt.h)形成了这棵树的根和各个结点。
n 导出数据节,.edata:.edata节包含了应用程序或DLL的导出数据。在这个节出现的时候,它会包含一个到达导出信息的导出目录(参见Winnt.h的结构定义:IMAGE_EXPORT_DIRECTORY)。
n 导入数据节,.idata:.idata节是导入数据,包括导入库和导入地址名称表。
n 调试信息段,.debug:调试信息位于.debug节之中,同时PE文件格式也支持单独的调试文件(.DBG或.PDB文件)作为一种将调试信息集中的方法。调试节包含了调试信息。
关于PE文件最重要的是,磁盘上的可执行文件和它被WINDOWS调入内存之后是非常相像的。WINDOWS载入器不必为从磁盘上载入一个文件而辛辛苦苦创建一个进程,载入器使用内存映射文件机制来把文件中相似的块映射到虚拟空间中。装载模块到内存(例如使用LoadLibrary)只是将PE文件中的某些范围映射到对应的地址空间而已。于是像IMAGE_NT_HEADERS这样的数据结构在内存和磁盘上是完全一致的。
对Win32来讲,模块所使用的所有代码,数据,资源,导入表,和其它需要的模块数据结构都在一个连续的内存块中。在这种情况下,你只需要知道载入器把可执行文件映射到了什么地方。通过作为映像的一部分的指针,你可以很容易的找到这个模块所有不同的块。
另一个你需要知道的概念是相对虚拟地址(RVA)。PE文件中的许多域都用术语RVA来指定。一个RVA只是一些项目相对于文件映射到内存的偏移。比如说,载入器把一个文件映射到虚拟地址0x10000开始的内存块。如果一个映像中的实际的表的首址是0x10464,那么它的RVA就是0x464。即:(虚拟地址 0x10464)-(基地址 0x10000)=RVA 0x00464
为 了把一个RVA转化成一个有用的指针,只需要把RVA值加到模块的基地址上即可。基地址是内存映射EXE和DLL文件的首址,在Win32中这是一个很重要的概念。当PE文件被装载进内存时,内存中的版本被称为模块(MODULE)。文件映射的起始地址被称为HMODULE,这是个值得记住的事情:给定HMODULE,你就可以知道在该地址的数据结构,然后你就可以据此得到内存中的其他数据结构。(注意:在WinCE下,HMODULE与模块的装载地址并不相同)。
当载入器把一个Win32程序映射到内存,这个映射文件的第一个字节对应于DOS残留部分的第一个字节。那是无疑的。和你启动的任一个基于Win32 的程序一起,都有一个基于DOS的程序连带被载入。正如前面所介绍的,e_lfanew 域是PE真实首部的偏移。为了得到PE首部在内存中的指针,只需要把这个值加到映像的基址上即可。
pNTHeader = dosHeader + dosHeader->e_lfanew; // 忽略类型转换
图3.2 模块的装载
结构化异常(SEH:Structured Exception Handling)是一个非常易于使用的框架,它允许程序捕获可能会引起系统崩溃的异常。一个异常是指一个非正常的状态,它会强制CPU停止工作,而不管CPU正在做什么。CPU产生的异常,是硬件异常(hardware exception);由操作系统和应用程序产生的异常,称为软件异常(software exception)。
产生异常的典型操作是:从一个无效线性地址读取或写入数据(这里的无效线性地址指的是没有映射到物理内存或页面文件的线性地址)、向代码段中写入数据、试图在数据段中执行指令或者除数为零。
有些个别异常是良性的,例如,如果要访问的内存已经被置换到页面文件中,则也会产生一个异常,操作系统通过将目标页再次调入内存来解决这一异常。不过,大多数异常都是致命的,因为操作系统不知道如何从异常中恢复过来,因此系统就简单的将自己挂起来表示自己的不满。这种反应似乎有些过于激进,不过在事情变得更严重之前,将系统挂起还是一个比较好的选择。
通过使用结构化异常,产生异常的程序将获得一个机会来处理此异常。使用微软专用的__try/__except之类的语法,可以监控一段任意代码中可能产生的异常,如果一个异常将系统引入了临界状态,那么一个自定义的处理例程(位于用户自己的程序中)将被调用,这样就允许程序员提供一个比蓝屏更好的处理方法。
实际上,结构化异常包含三个部分的内容:
n 结束处理(Termination Handling);
n 基于帧的异常处理(Frame-based Exception Handling);
n 向量化异常处理(Vectored Exception Handling);(从WindowsXP开始新增的特性)
Matt Pietrek 在“A Crash Course on the Depths of Win32 Structured Exception Handling”对结构化异常是这样描述的:
“在所有 Win32 操作系统提供的机制中,使用最广泛的未公开的机制恐怕就要数结构化异常处理(structured exception handling,SEH)了。一提到结构化异常处理,可能就会令人想起_try、_finally和_except之类的词儿。在任何一本不错的Win32书中都会有对 SEH 详细的介绍。甚至连Win32 SDK里都对使用_try、_finally和_except进行结构化异常处理作了完整的介绍。既然有这么多地放都提到了SEH,那我为什么还要说它是未公开的呢?本质上讲,Win32结构化异常处理是操作系统提供的一种服务。编译器的运行时库对这种服务操作系统实现进行了封装,而所有能找到的介绍SEH的文档讲的都是针对某一特定编译器的运行时库。关键字_try、_finally和_except并没有什么神秘的。微软的OS和编译器定义了这些关键字以及它们的行为。其它的C++ 编译器厂商也只需要遵从它们定好的语义就行了。在编译器的SEH层减少了直接使用纯操作系统的SEH所带来的危害的同时,也将纯操作系统的SEH从大家的面前隐藏了起来。”
结构化异常处理流程如图4.2所示,该图取自《Windows核心编程》一书。需要注意的是:该图只是具有参考意义,并不完全准确,至少它没有包含向量化异常的相关内容(因为《Windows核心编程》针对的是Win2000,而Win2000中还没有向量化异常。),而且异常发生之后,总是首先会发送到调试器(如果存在调试程序的话)。
图4.2 结构化异常处理流程(《Windows核心编程》)
一个结束处理程序能够确保去调用和执行下一个代码块(结束处理程序,termination handler),而不管另外一段代码(保护体,guarded body)是如何退出的。结束处理程序的文法结构为:
__try
{
// guarded body
……
}
__finally
{
// termination handler
……
}
不论在保护体中使用return、还是goto,或者longjump,结束处理程序(finally块)都将被调用。结束处理程序常用于资源释放的场景,例如:
__try
{
EnterCriticalSection(&cs);
……
}
__finally
{
LeaveCriticalSection(&cs);
}
尽管结束处理程序可以捕捉到__try块过早退出的大多数情况,但当线程或进程被结束时,它不能引起__finally块中的代码执行。当调用ExitThread或ExitProcess时,将立即结束线程或进程,而不会执行__finally块中的任何代码。当然,TerminateThread和TerminateProcess函数也会导致同样的问题。某些C运行期函数,例如abort,也会有相同的问题。
另外一个需要注意的问题是性能问题,当控制流自然的离开__try块并进入__finally块的时候,进入__finally块的系统开销是最小的。当在__try块使用return/goto这样的语句退出时(称为过早退出),编译器需要生成额外的代码来保证__finally块中代码被执行,因而系统会有额外的开销。所以在编写代码时,应该避免引起__try块中的过早退出,因为程序的性能会受到影响。为了帮助避免在__try块中使用return语句,微软在编译器中增加了另一个关键字__leave。在__try块中使用__leave关键字会引起跳转到__try块的结尾,由于控制流自然的从__try块退出并进入__finally块,所以不产生系统开销。
基于帧结构化异常处理的语法:
__try
{
// guarded body
……
}
__except( exception filter )
{
// exception handler
……
}
注意:一个__try块后面不能同时跟着__finally和__except块,但必须跟着其中之一。
异常过滤器是一个表达式,该表达式的结果只能是以下三个值之一:
n EXCEPTION_EXECUTE_HANDLE(1):这个值的含义是告诉操作系统,这个异常要由自己处理。在这个时候,系统执行一个全局展开,然后执行向__except块中代码(即异常处理代码)的跳转。在__except块中的代码执行完之后,系统允许应用程序继续执行(从__except块之后的第一条指令开始)。这种机制使得应用程序能够抓住并处理错误,再使程序继续运行,不需要用户知道错误的发生。
n EXCEPTION_CONTINUE_SEARCH(0):这个值的含义是告诉操作系统,我不想处理这个异常,由操作系统继续查找异常处理程序。在存在__try块嵌套的情况下,这意味着操作系统将把异常交由更外层的__except块处理;如果找不到这样的__except块,系统将该异常交由一个特殊的过滤器函数:UnhandledExceptionFilter,其细节将在下面的内容中详细介绍(该函数在前面的创建线程时已经出现过)。
n EXCEPTION_CONTINUE_EXECUTION(-1):这个值的含义告诉系统,重新执行引发异常的指令。该值用于内存管理程序及堆栈管理成员,当一个线程试图去存取并不存在的堆栈区域时,就产生一个异常。系统的异常过滤器可以确定这个异常是源于试图存储堆栈的保留地址空间,然后调用VirtualAlloc向线程的堆栈提交更多的存储区,然后过滤器返回EXCEPTION_CONTINUE_EXECUTION,这时试图存取栈的CPU指令可以成功执行,线程可以继续运行。
与C++的异常机制一样,结构化异常也可以嵌套使用,因此在处理发生的异常时,就可能需要全局展开(Unwind)。每当一个线程要从一个try-finally块离开时,必须保证执行finally块中的代码,在发生异常的时候,全局展开就是保证这条规则的机制。为了暂停全局展开,可以在finally块中放一个return语句。原则上,应该小心避免在finally块中安排return语句。
图 4.4 全局展开逻辑(来自《Windows核心编程》)
当一个异常发生时,操作系统要向引起异常的线程的堆栈里压入三个结构,这三个结构是:EXCEPTION_RECORD结构、CONTEXT结构(前面已经介绍过,参见2.2节)和EXCEPTION_POINTERS结构。其中,EXCEPTION_RECORD结构包含有关已发生异常的独立于CPU的信息。CONTEXT结构包含已发生异常依赖于CPU的信息。
EXCEPTION_POINTERS结构只包含两个数据成员,二者都是指针,分别指向被压入栈的EXCEPTION_RECORD和CONTEXT结构:
typedef struct _EXCEPTION_POINTERS {
PEXCEPTION_RECORD ExceptionRecord;
PCONTEXT ContextRecord;
} EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;
使用GetExceptionInformation函数可以获得指向EXCEPTION_POINTERS结构的指针:
PEXCEPTION_POINTERS GetExceptionInformation(void);
关于该函数,需要注意的是:它只能在异常过滤器中调用,因为仅仅在处理异常过滤器时,上述三个结构才是有效的。一旦控制被转移到异常处理程序,栈中的数据就被删除了。
EXCEPTION_RECORD结构如下:
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD* ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;
EXCEPTION_RECORD结构包含有关最近发生的异常的详细信息,这些信息独立于CPU:
n ExceptionCode:包含异常的代码,这同内部函数GetExceptionCode返回的信息是一样的。
n ExceptionFlags:包含有关异常的标志,当前只有两个值:0(指出一个可以继续的异常),EXCEPTION_NONCONTINUABLE(指出一个不可继续的异常)。在一个不可继续的异常之后,若要继续执行,会引发一个EXCEPTION_NONCONTINUABLE_EXCEPTION异常。
n ExceptionRecord:指向另一个未处理异常的EXCEPTION_RECORD结构。在处理一个异常的时候,有可能引发另外一个异常(例如,在异常处理代码中发生除0错误)。当嵌套异常发生时,可将异常记录链接起来,以提供另外的信息。如果没有发生嵌套异常,该成员为NULL。
n ExceptionAddress:指出产生异常的CPU指令的异常。
n NumberParameters:规定了与异常相联系的参数数量(0~15)。这是在ExceptionInformation数组中定义的元素数量。对于几乎所有的异常来说,这个值都是0,除了访问违例异常EXCEPTION_ACCESS_VIOLATION之外。
n ExceptionInformation:规定了一个附加参数的数组,用来描述异常。对于大多数异常来说,数组元素是未定义的。对于访问违例异常EXCEPTION_ACCESS_VIOLATION,定义了两个数组元素,第一个数组元素是操作类型(0:读 1:写),第二个数组元素指明了发生异常时试图访问的虚拟地址。
另外,可以使用RaiseException函数来产生自己的软件异常。
Windows提供一个函数:SetUnhandledExceptionFilter,利用该函数可以设置自定义的未处理异常过滤函数。其函数原型为:
LPTOP_LEVEL_EXCEPTION_FILTER SetUnhandledExceptionFilter(
LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter );
未处理异常过滤函数的原型为(即LPTOP_LEVEL_EXCEPTION_FILTER):
LONG WINAPI UnhandledExceptionFilter(
STRUCT _EXCEPTION_POINTERS* ExceptionInfo );
程序员可以在自己的异常过滤函数中执行任何想做的处理,但要返回三个可能的值之一:
n EXCEPTION_EXECUTE_HANDLER:执行相应的异常处理,并从过滤函数中返回,通常这意味着结束进程。
n EXCEPTION_CONTINUE_EXECUTION:从引起异常的指令处继续执行;
n EXCEPTION_CONTINUE_SEARCH:执行通常的系统UnhandledExceptionFilter函数(该函数我们已经在前面的2.2节中看过了)。
注意:与基于帧的结构化异常处理不同(本质上是线程相关的,参见下面章节的相关内容),SetUnhandledExceptionFilter函数是进程相关的,即整个进程只需设置一次,不需针对每个线程单独进行调用。而且未处理异常过滤器属于顶级的(即不允许嵌套),对SetUnhandledExceptionFilter函数的第二次调用将覆盖前一次调用设置的过滤器函数;若以NULL为参数调用该函数,则意味着恢复系统默认的UnhandledExceptionFilter函数(即前面BaseThreadStart函数中用到的)。
向量化异常(VEH:Vectored Exception Handling)是WindowsXP操作系统中引入的新特性,对此Matt Pietrek是这样说的:“ 也许,KERNEL32中最令人兴奋的新特性就是向量化异常处理(VEH)。利用这种特性,可以更灵活地处理异常。”
具有__try/__except机制的传统结构化异常处理(SEH) 在本质上是特定于线程的。异常只能由建立处理程序的线程来处理。(编译器和操作系统处理此问题的所有棘手细节,并提供相对简单的__try/__except 语法。)更严重的是,使用SEH,你可能建立了一个处理程序来处理异常,没想到此异常竟首先被另一个不知道如何正确处理该异常的处理程序捕获了。
向量化异常处理的工作方式更像一个传统的通知回调方案(因此不需要任何像__try/__except这样的关键字)。要处理异常,只需调用如下的API函数AddVectoredExceptionHandler,向它传递你的异常回调函数的地址。
PVOID AddVectoredExceptionHandler(
ULONG FirstHandler,
PVECTORED_EXCEPTION_HANDLER VectoredHandler
);
其中,第一个参数指明注册的向量化异常处理函数是否首先被调用(也许参数类型为BOOL会更容易理解),0表示注册的异常处理函数被最后调用,非0表示注册的异常处理函数被首先调用。第二个参数为向量化异常处理函数指针,其函数原型为:
LONG WINAPI VectoredHandler(
PEXCEPTION_POINTERS ExceptionInfo );
不难看出,向量化异常处理函数的原型与未处理异常过滤器函数的原型完全一致,但是与SetUnhandledExceptionFilter不同的是,AddVectoredExceptionHandler函数可以多次调用,而且后续的调用不会覆盖前面的调用,相反它们(指异常处理函数)会以一种链表的方式串起来,这正是向量化异常处理名称的来由。
当异常发生时,回调函数收到指向 EXCEPTION_POINTERS 结构的指针。这是结构化异常处理中可通过GetExceptionInformation函数获得的同一个结构。 可以从EXCEPTION_POINTERS结构中的字段获知异常代码(例如,0xC0000005)和寄存器值(通过包括在内的 CONTEXT 结构)。
VEH 回调选择处理异常或将它链接到列表中的下一个处理程序。它通过从回调返回适当的值来确定即将发生的操作。每个进程(注意:不是线程)都有一个链接的VEH回调列表。作为处理异常的一部分,操作系统会在VEH列表中依次选择,调用相应的处理程序。要从该列表中删除一个处理程序,可使用RemoveVectoredExceptionHandler函数。
ULONG RemoveVectoredExceptionHandler(
PVOID VectoredHandlerHandle );
注意:由于向量化异常是在WinXP中才引入的新概念,因此为了使用上述两个新增的API函数,需要在包含Windows系统头文件之前定义_WIN32_WINNT宏的值为大于等于0x0500的值。
向量化异常处理可以与结构化异常共存,在将异常发送到结构化异常处理函数之前,系统会首先遍历向量化异常处理程序列表,这也就是说,向量化异常处理程序的优先级要高于结构化异常处理程序。
由于向量化异常只能在WinXP以上的系统才能使用,故我们下面“崩溃捕获”相关内容仍然是采用结构化异常机制来实现,不过其中的重点并不在于如何捕获异常,而在于得到了异常记录结构之后,如何从中分析出对程序员有用的信息(例如,程序调用堆栈),而这部分内容与采用结构化异常还是向量化异常并没有关系,对两者都是适用的。
结构化异常机制在用于C++编程时,有时候可能会遇到问题,主要是结构化异常无法处理对象的构造函数与析构函数。C++运行时库提供了一个函数:_set_se_translator可以将结构化异常转换为标准的C++异常,这样就可以用统一的机制来处理结构化异常和C++异常。为了能够使这种机制能够正确工作,必须在每个线程的入口函数中调用该函数,该函数告诉C++运行时系统,当结构化异常发生时,就调用设置的转换函数。
_se_translator_function _set_se_translator(
_se_translator_function se_trans_func )
自定义转换函数的原型为(其中的参数为GetExceptionCode和GetExceptionInformation函数的返回值,分别代表异常代码和异常信息):
typedef void (*_se_translator_function) ( unsigned int, struct _EXCEPTION_POINTERS* );
此外,使用_set_se_translator需要修改编译器的默认设置,VC7默认的异常处理选项是/Ehsc,而要使用_set_se_translator,需要将该选项修改为/EHa。
在下例中使用XDBWin32Exception类来完成转换函数的设置和结构化异常的包装:
/// WIN32结构化异常封装类。
class XDBWin32Exception
{
public:
/// 将结构化异常映射为C++异常。
/// 需要为每个线程(线程进入点函数)调用该函数。
static void MapWin32ExceptionToCPP()
{ _set_se_translator( XDBWin32Exception::TranslateWin32ExceptionToCPP ); }
/// 获得结构化异常信息。
PEXCEPTION_POINTERS ExceptionPointers()
{ return m_pException; }
/// 获得结构化异常代码。
DWORD ExceptionCode()
{
if( NULL != m_pException && NULL != m_pException->ExceptionRecord )
return m_pException->ExceptionRecord->ExceptionCode;
else
return 0;
}
/// 获得结构化异常地址。
PVOID ExceptionAddress()
{
if( NULL != m_pException && NULL != m_pException->ExceptionRecord )
return m_pException->ExceptionRecord->ExceptionAddress;
else
return NULL;
}
private:
/// 构造函数。
XDBWin32Exception( PEXCEPTION_POINTERS pException )
{
m_pException = pException;
}
/// 将Win32结构化异常翻译为C++异常的转换函数。
static void TranslateWin32ExceptionToCPP(
UINT nExceptioCode , PEXCEPTION_POINTERS pException )
{ throw XDBWin32Exception( pException );}
/// 结构化异常信息
PEXCEPTION_POINTERS m_pException;
};
结构化异常机制的使用示例:
/// 运行测试的方法。
void run()
{
// 设置异常处理转换函数
XDBWin32Exception::MapWin32ExceptionToCPP();
// 运行测试方法
try
{
(_parent->*_testCaseMethod)();
}
catch( std::exception& error )
{
// ……
}
catch( XDBWin32Exception& Win32Ex )
{
// ……
// 输出错误堆栈信息到文件(文件名:可执行程序名.RPT)
XDBExceptionReport::XDBHandledExceptionFileter(
XDBExceptionReport::GetReportFileName( ),
Win32Ex.ExceptionPointers(),
true );
}
catch(...)
{
// ……
}
// ……
}
本节的思想和方法主要来自MSDN的文章《Under the hood:Improved Error Reporting with DBGHELP 5.1 APIS》(作者:Matt Pietrek)。前面的4.4节已经详细的讲述了EXCEPTION_POINTERS结构所包含的内容,我们的目的就是根据GetExceptionInformation函数返回的这个结构指针,分析出发生异常或者崩溃的模块及地址信息,理想的结果是能够得到发生异常时的调用堆栈及相应的参数信息。
(1) 获取发生异常或崩溃的模块信息
首先要做的是根据发生异常的地址信息,得到发生异常的模块信息,对异常地点进行定位。我们先来看看Matt Pietrek老大是如何做的吧:
BOOL GetLogicalAddress(
PVOID addr, PTSTR szModule, DWORD len,
DWORD& section, DWORD& offset )
{
MEMORY_BASIC_INFORMATION mbi;
if ( !VirtualQuery( addr, &mbi, sizeof(mbi) ) )
return FALSE;
DWORD hMod = (DWORD)mbi.AllocationBase;
if ( !GetModuleFileName( (HMODULE)hMod, szModule, len ) )
return FALSE;
在上述代码中,参数addr指的是发生异常的指令地址,这可从EXCEPTION_RECORD的ExceptionAddress字段或者Context结构的EIP寄存器得到(根据我的经验,这两者的值应该是相同的)。关于VirtualQuery函数的使用及参数含义,在前面已经讨论过;结合PE文件格式及模块装载的相关知识,上面的代码就不难理解了。
接下来就是获得发生异常的指令所位于的代码节及相对偏移(有了这个偏移,再根据MAP文件的信息,实际上就可以得到了发生异常的代码大概所处的位置),而下面的代码都是基于前面介绍的有关PE文件的格式的知识:
// 指向内存的DOS MZ Header
PIMAGE_DOS_HEADER pDosHdr = (PIMAGE_DOS_HEADER)hMod;
// 获得PE文件头的地址
PIMAGE_NT_HEADERS pNtHdr = (PIMAGE_NT_HEADERS)(hMod + pDosHdr->e_lfanew );
// 获得节表中的第一个节头
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION( pNtHdr );
// 计算相对虚拟地址RVA
DWORD rva = (DWORD)addr - hMod;
// 遍历每个节,查找所属于的节
for ( unsigned i = 0;
i < pNtHdr->FileHeader.NumberOfSections;
i++, pSection++ )
{
DWORD sectionStart = pSection->VirtualAddress;
DWORD sectionEnd = sectionStart
+ max(pSection->SizeOfRawData, pSection->Misc.VirtualSize);
// 检查相对偏移是否位于节的地址范围
if ( (rva >= sectionStart) && (rva <= sectionEnd) )
{
// 如果位于节的地址范围,则任务完成
section = i+1;
offset = rva - sectionStart;
return TRUE;
}
}
return FALSE; // Should never get here!
}
上面的代码是Matt Pietrek给出的,当然这不是唯一的方法,另外一种方法就是利用DBGHELP.DLL中提供的SymGetModuleBase64、ImageNtHeader、ImageRvaToSection函数来达到同样的目的。因为Matt Pietrek的方法更直接,所包含的内容更丰富,所以在此列出。
(2)获取发生异常的调用堆栈信息
获取程序崩溃时的调用堆栈信息,这一直是程序员的一个梦想,借助于PDB文件和DBGHELP.DLL调试库,我们可以实现这个梦想。需要强调的是,要得到程序崩溃时的调用堆栈信息,必须有相应崩溃模块的包含调试信息的PDB文件,否则,我们只能使用(1)中的代码,得到相应的模块及相对虚拟地址信息。
首先,初始化DBGHELP库的符号引擎:
// 设置符号引擎属性
DWORD dwOptions = SymGetOptions() ;
// 必须装载行号信息,这样才能跟踪到发生异常的源代码的行号
SymSetOptions(dwOptions | SYMOPT_DEFERRED_LOADS | SYMOPT_LOAD_LINES);
// 使用当前进程句柄初始化DBGHELP库的符号引擎
if ( !SymInitialize( m_hProcess, NULL , TRUE ) )
return;
与此相对应的,在使用完符号引擎之后,必须关闭符号引擎:
// 释放符号引擎资源
SymCleanup( m_hProcess );
要获得程序崩溃时的调用堆栈信息,需要借助于DBGHELP库的StackWalk64函数:
BOOL StackWalk64(
DWORD MachineType,
HANDLE hProcess,
HANDLE hThread,
LPSTACKFRAME64 StackFrame,
PVOID ContextRecord,
PREAD_PROCESS_MEMORY_ROUTINE64 ReadMemoryRoutine,
PFUNCTION_TABLE_ACCESS_ROUTINE64 FunctionTableAccessRoutine,
PGET_MODULE_BASE_ROUTINE64 GetModuleBaseRoutine,
PTRANSLATE_ADDRESS_ROUTINE64 TranslateAddress );
其中的参数及含义如下:
n MachineType:指明CPU类型,对于我们而言,就是IMAGE_FILE_MACHINE_I386。
n hProcess:进程句柄,使用当前的进程句柄就可以了(GetCurrentProcess)。
n hThread:线程句柄,使用当前的线程句柄就可以了(GetCurrentThread)。
n StackFrame[in,out]:指向一个STACKFRAME64结构,如果函数成功,该参数将保存下一个调用堆栈帧的信息;
n ConextRecord[in,out]:对于I386可以忽略该值,不过按照MSDN的意思,最好传入一个有效的值(使用异常结构的上下文就可以了);
n 后面四个参数是回调函数的指针,DBGHELP库提供了相应的默认的回调函数实现,使用默认实现就可以了。
STACKFRAME64结构如下:
typedef struct _tagSTACKFRAME64 {
ADDRESS64 AddrPC; 程序计数器,即EIP
ADDRESS64 AddrReturn; 返回地址
ADDRESS64 AddrFrame; 帧寄存器 EBP
ADDRESS64 AddrStack; 栈寄存器 ESP
ADDRESS64 AddrBStore; 未使用(用于IA64架构)
PVOID FuncTableEntry; 用于定位调用帧的结构,一般用不着
DWORD64 Params[4]; 函数可能的参数
BOOL Far; 指明WOW的远调用
BOOL Virtual; 指明是虚拟帧
DWORD64 Reserved[3]; 用于StackWalk64内部的保留字段
KDHELP64 KdHelp; 用于内核调试器的结构
} STACKFRAME64, *LPSTACKFRAME64;
需要注意的是,在第一次调用StackWalk64函数之前,必须初始化StackFrame参数的AddrPC和AddrFrame字段,否则StackWalk64函数调用会失败,这使用异常结构中的上下文字段(下面代码中的pContext参数)中的寄存器信息可以实现:
STACKFRAME64 sf = { 0 } ;
sf.AddrPC.Offset = pContext->Eip; // 程序计数器
sf.AddrPC.Mode = AddrModeFlat; // 线性地址模式
sf.AddrStack.Offset = pContext->Esp; // 栈寄存器
sf.AddrStack.Mode = AddrModeFlat;
sf.AddrFrame.Offset = pContext->Ebp; // 帧寄存器
sf.AddrFrame.Mode = AddrModeFlat;
以下就是调用堆栈遍历代码:
// 遍历调用堆栈
while ( true )
{
// 获得下一个调用栈帧
if ( !StackWalk64(dwMachineType,
m_hProcess,
GetCurrentThread(),
&sf,
pContext,
NULL,
SymFunctionTableAccess64,
SymGetModuleBase64,
NULL ) )
break;
// 检查帧是否为有效帧
if ( 0 == sf.AddrFrame.Offset )
break;
得到了调用栈帧之后,下一步就是获得代码所在位置的符号信息,这是通过调用SysFromAddr函数实现的:
BOOL SymFromAddr(
HANDLE hProcess, 进程句柄(当前进程句柄即可)
DWORD64 Address, 输入参数,指明符号的地址,应该传入调用指令的地址
PDWORD64 Displacement, 输出参数,返回距离符号开始地方的偏移
PSYMBOL_INFO Symbol 输入/输出参数,返回所指定位置的符号信息
);
其中,第四个参数是代表符号信息的结构,其定义如下:
typedef struct _SYMBOL_INFO {
ULONG SizeOfStruct; 该结构的大小
ULONG TypeIndex; 符号的类型索引(一个神秘的值!)
ULONG64 Reserved[2]; 保留字段
ULONG Index; 符号对应的唯一值(不知道有什么用?^-^)
ULONG Size; 符号以字节为单位的尺寸(MSDN:通常为0,应该忽略,奇怪!)
ULONG64 ModBase; 包含该符号的模块基址(即HMODULE)
ULONG Flags; 符号的属性,指明符号是参数、局部变量以及其他信息
ULONG64 Value; 常量符号的值
ULONG64 Address; 符号的起始地址
ULONG Register; 指明寄存器的编号
ULONG Scope; 用于DIA SDK的字段,指明符号范围
ULONG Tag; 符号的类型
ULONG NameLen; 符号名称的长度(包含NULL尾字节的长度)
ULONG MaxNameLen; 名字缓冲区长度
TCHAR Name[1]; 名字缓冲区
} SYMBOL_INFO, *PSYMBOL_INFO;
获得符号信息的代码,如果无法正确获得符号信息,则使用(1)中的代码打印出模块信息,对于系统动态库(例如Kernel32.dll)中的符号,这可能是唯一的选择:
// 以下代码获得符号信息
BYTE symbolBuffer[ sizeof(SYMBOL_INFO) + 1024 ] = { 0 };
PSYMBOL_INFO pSymbol = (PSYMBOL_INFO)symbolBuffer;
pSymbol->SizeOfStruct = sizeof(SYMBOL_INFO);
pSymbol->MaxNameLen = 1024;
DWORD64 symDisplacement = 0;
if ( SymFromAddr(m_hProcess,sf.AddrPC.Offset,&symDisplacement,pSymbol))
{
// 输出符号名称信息(就是函数名啦!)
// ……
}
else // 如果没有找到相应的符号信息(PDB文件不存在时),则输出
{ // 符号的逻辑地址信息
GetLogicalAddress( (PVOID)sf.AddrPC.Offset,
szModule, sizeof(szModule), section, offset );
// 输出地址信息 ……
// 继续执行下一个堆栈帧
continue;
}
除了可以获得符号名(通常是函数名)之外,我们还可进一步得到发生错误的源代码行号,能够这样做的前提是相应的PDB文件中含有行号信息时才行,否则,将无法得到相应的行号信息。得到行号信息需要借助于DBGHELP库的SymGetLineFromAddr64函数:
BOOL SymGetLineFromAddr64(
HANDLE hProcess, 进程句柄
DWORD64 dwAddr, 需要查询的地址
PDWORD pdwDisplacement, 输出参数,相对于行开始处的偏移
PIMAGEHLP_LINE64 Line 行号信息结构
);
其中,行号信息结构的定义如下:
typedef struct _IMAGEHLP_LINE64 {
DWORD SizeOfStruct; 该结构的大小
PVOID Key; 保留给操作系统使用的字段(天知道微软想干什么?)
DWORD LineNumber; 行号
PTSTR FileName; 源文件名(包含全路径)
DWORD64 Address; 该行代码对应的第一条指令的地址
} IMAGEHLP_LINE64, *PIMAGEHLP_LINE64;
获得行号的代码如下,在实际上我们还发现,对于ANSI版本的DBGHELP库,SymGetLineFromAddr64函数有一个小BUG,就是没有正确的计算含宽字节字符的源文件名的长度,因此返回的IMAGEHLP_LINE64结构中的FileName字段被不正确的截断;不过这个BUG在DBGHELP 6.3以上版本的UNICODE版中没有出现。
// 获得该调用帧的源文件及行号信息
IMAGEHLP_LINE64 lineInfo = { sizeof(IMAGEHLP_LINE64) };
DWORD dwLineDisplacement = 0;
if ( SymGetLineFromAddr64( m_hProcess, sf.AddrPC.Offset,
&dwLineDisplacement, &lineInfo ) )
{
// 输出行号信息
// ……
}
else
{
// 只能获得模块及地址信息
GetLogicalAddress( (PVOID)sf.AddrPC.Offset,
szModule, sizeof(szModule), section, offset );
// 输出模块信息
// ……
}
(3)获得调用堆栈的详细信息
获得了程序崩溃时的调用堆栈信息(尤其若是能够获得源文件和行号信息的话),对于程序员来说已经是极大的帮助了,但若是能够更进一步获得调用堆栈的详细信息(函数参数、局部变量等),那无疑将会对分析发生异常或崩溃的原因提供更大的帮助。不过不幸的是,MSDN对于这方面相关的文档实在是太少了,所以即便是Matt Pietrek这样的牛人也无法很完美的解决这个问题。在Matt Pietrek的代码当中,虽然有部分的示例代码,但是问题比较多,所以实际中并没有采用Matt Pietrek的代码,而是结合DIA SDK与DBGHELP的相关文档,做了一些尝试,目前尚不甚完善。
要查询局部变量或参数的信息,首先要设置变量的上下文,因为局部变量都有自己的生存环境(作用域),通过SymSetContext函数来实现:
BOOL SymSetContext(
HANDLE hProcess, 进程句柄
PIMAGEHLP_STACK_FRAME StackFrame, 局部变量的上下文(帧信息)
PIMAGEHLP_CONTEXT Context 忽略的参数
);
其中,第二个参数是一个类似于STACKFRAM64结构的结构:
typedef struct _IMAGEHLP_STACK_FRAME {
ULONG64 InstructionOffset; 程序计数器EIP
ULONG64 ReturnOffset; 返回地址
ULONG64 FrameOffset; 帧寄存器EBP
ULONG64 StackOffset; 栈寄存器ESP
ULONG64 BackingStoreOffset; 用于IA64架构
ULONG64 FuncTableEntry; 可以忽略
ULONG64 Params[4]; 函数的参数
ULONG64 Reserved[5]; 保留给系统使用
BOOL Virtual; 指明是否为虚拟帧(不明白)
ULONG Reserved2; 保留给系统使用
} IMAGEHLP_STACK_FRAME,*PIMAGEHLP_STACK_FRAME;
要调用SetSymContext函数,只需设置InstructionOffset字段为调用栈的地址即可:
// 设置局部变量上下文
IMAGEHLP_STACK_FRAME imagehlpStackFrame = { 0 };
imagehlpStackFrame.InstructionOffset = sf.AddrPC.Offset;
if( SymSetContext( m_hProcess, &imagehlpStackFrame, NULL ) )
{
// 枚举局部变量和函数参数
// ……
}
然后,就可以调用SymEnumSymbols函数来枚举所有的局部变量和函数参数(调用该函数同样可以枚举模块内的全局变量):
BOOL SymEnumSymbols(
HANDLE hProcess, 进程句柄
ULONG64 BaseOfDll, 模块的基址(即HMODULE)
PCTSTR Mask, 指定要枚举符号名的正则表达式条件
PSYM_ENUMERATESYMBOLS_CALLBACK EnumSymbolsCallback, 回调函数
PVOID UserContext 用户提供的回调函数参数
);
其中,枚举回调函数的原型如下:
BOOL CALLBACK SymEnumSymbolsProc(
PSYMBOL_INFO pSymInfo, 符号信息(前面已经介绍过了)
ULONG SymbolSize, 符号的尺寸(MSDN:符号大小是通过计算和并且实际上是猜测得到的,有时候可能为0,FAINT!)
PVOID UserContext 用户提供的回调函数参数
);
很显然,我们所能做的文章主要就是考回调函数的第一个参数pSymInfo,这是一个表示符号信息的结构,要想据此得到有用的信息,主要依靠DBGHELP库中一个“具有神秘魔力”的函数SymGetTypeInfo,不幸的是,MSDN对这个函数的说明是惜字如金、语焉不详,因此目前对于这个函数的使用基本上建立在猜测和试验的基础上。
BOOL SymGetTypeInfo(
HANDLE hProcess, 进程句柄
DWORD64 ModBase, 模块基址
ULONG TypeId, 类型索引(SYMBOL_INFO结构中的神秘字段TypeIndex)
IMAGEHLP_SYMBOL_TYPE_INFO GetType, 一个枚举值
PVOID pInfo 用于保存结果的缓冲区
);
之所以说SymGetTypeInfo函数是一个具有魔力的函数,是因此从直观上看,这个函数更像一个分派函数。根据GetType参数的不同,这个函数可以得到一个符号多达近30种不同的信息,而这些信息是如何互相关联的,在MSDN中并没有太多的描述,还需要进一步的研究和试验。