exception_access_violation

本章内容针对32位版本的Windows XP,但大部分内容也适用于其他32位版本的Windows (Windows NT、Windows 2000和Windows Vista),并且可以很容易地扩展到64位版本的Windows系统。

11.1 中断描述符表

在保护模式下,当出现中断或异常时,CPU通过中断描述符表(IDT)寻找处理函数。因此,可以说IDT是CPU(硬件)和操作系统(软件)交接中断和异常的大门。操作系统在启动初期的一个重要任务就是设置IDT,准备各种函数来处理异常和中断。

11.1.1 概况

简单地说,IDT是物理内存中的一个线性表,有256个条目。在IA-32e(64位)模式下,每个IDT项的长度为16字节,IDT的总长度为4096字节(4KB)。在32位模式下,每个IDT项的长度为8字节,IDT的总长度为2048字节(2KB)。32位和64位的主要区别在于地址长度的变化,所以下面只讨论32位的情况。

IDT的位置和长度是由CPU的IDTR描述的。IDTR有48位,高32位是IDT的基址,低16位是IDT的长度(限制)。LIDT(Load IDT)指令用于将操作数指定的基址和长度装入IDTR,即重写IDTR的内容。SIDT(存储IDT)指令用于将IDTR的内容写入内存变量,即读取IDTR的内容。LIDT和SIDT指令只能在实模式或保护模式下的高特权级别(环0)中执行。在调试内核时,可以使用rigtr和rigtl命令来观察IDTR的内容(第1卷2.6.2节)。

在Windows操作系统中,IDT的初始化过程大致是这样的。IDT的初始建立和初始化是由Windows系统的加载程序(NTLDR或WinLoad)在实模式下完成的。在准备好一个内存块后,加载程序首先执行CLI指令关闭中断,然后执行LIDT指令将IDT的位置和长度信息加载到CPU中。然后,加载程序将CPU从实模式切换到保护模式,并将执行权交给NT内核的入口函数KiSystemStartup。接下来,内核中的处理器初始化函数会通过SIDT指令获取IDT的信息,对其进行必要的调整,然后将其作为参数传递给KiInitializePcr函数,该函数会将其记录在描述处理器的基本数据区Pcr(处理器控制区)和Prcb(处理器控制块)中。

上述过程都发生在处理器0中,它被称为自举处理器,简称BSP。因为即使是多CPU系统,在把NTLDR或者WinLoad和执行权交给内核的阶段,也只有BSP在运行。在BSP完成内核的初始化和执行器的阶段0初始化之后,BSP将在阶段1初始化期间执行KeStartAllProcessors函数来初始化其他CPU。BSP以外的CPU一般称为AP(应用处理器)。对于每个AP,KeStartAllProcessors函数将建立一个单独的处理器状态区,包括其IDT,然后调用KiInitProcessor函数,该函数将根据启动CPU的IDT创建一个要初始化的AP的副本,并进行必要的修改。

在内核调试会话中,您可以使用!pcr命令观察CPU的PCR内容,清单11-1显示了Windows Vista系统中CPU 0的PCR内容。

清单11-1 Windows Vista系统中CPU 0的PCR内容

kd & gt!处理器0at81969a00的pcrkpcr://kpcr结构的线性内存地址Major 1 minor 1//kpcr结构的主版本号和次版本号Ntib。异常列表:9f1d 9644//异常处理注册列表[…]//省略几行关于NTTIB的信息:SelfPcr: 81969a00 //该结构的起始地址:Prcb: 81969b20 // KPRCB结构的KPRCB地址:IRQL:000001 f//CPU的中断请求级别(IRQL):000000//IDR:ffff 20 f 0//中断模式:000000//IDT: 834da400//IDT的基址 ETHREAD address next thread:0000000//IDLE thread:8196 CDC 0//IDLE ETHREAD address内核数据结构空闲线程的KPCR描述了PCR内存区的布局,所以也可以使用dt命令来观察PCR,例如KD >: dt nt! _KPCR 81969a00 .

11.1.2 门描述符

IDT的每个条目是所谓的门描述符结构。之所以这么叫,是因为IDT项的基本目的是带领CPU从一个空执行到另一个空,而每一个入口似乎都是从一个空到另一个空的闸门。过这道门的时候,CPU会做必要的安检和准备。

IDT可以包含以下三个门描述符。

(1)任务门描述符:用于任务切换,包含用于选择任务状态段(TSS)的段选择器。可以使用JMP或CALL指令切换到任务门指向的任务,当CPU因中断或异常而移动到任务门时,也会切换到指定的任务。

(2)中断门描述符:用于描述中断处理程序的入口。

(3)陷阱门描述符:用于描述异常处理例程的入口。

图11-1描述了上述三个门描述符的内容布局。

exception_access_violation

图11-1 IDT三种门描述符的内容布局

从图11-1可以看出,这三个门描述符的格式非常相似,并且有许多共同的字段。其中,DPL代表用于优先级控制的描述符优先级,P是段存在标志。段选择器用于选择段描述符(位于LDT或GDT,选择器的格式参见第1卷第2.6.3节),偏移量部分用于指定段中的偏移量,它们共同定义了准确的存储位置。对于中断门和陷阱门,它们指定中断或异常处理例程的地址;对于任务门,它们指定任务状态段的内存地址。

系统通过门描述符的类型字段,即高4字节的6 ~ 12位来区分描述符的类型。比如任务门的类型是0b00101(b代表二进制数),中断门的类型是0b0D110,其中d位用来表示描述的是16位门(0)还是32位门(1),陷阱门的类型是0b0D111。

11.1.3 执行中断和异常处理函数

让我们来看看当出现中断或异常时,CPU是如何通过IDT找到并执行处理功能的。首先,CPU将根据其向量号和IDTR的IDT基地址信息找到相应的门描述符。然后,判断门描述符的类型。如果是任务描述符,CPU会进行基于硬件的任务切换,切换到这个描述符定义的线程。如果是陷阱描述符或中断描述符,CPU将在当前任务上下文中调用描述符描述的处理例程。下面分别讨论。

先看任务门。简单地说,任务门描述了一个TSS。CPU需要做的就是切换到这个TSS代表的线程,然后开始执行这个线程。TSS是保存任务信息的内存区域,其格式由CPU定义。图11-2显示了IA-32 CPU的TSS格式。从中我们可以看到,TSS包含了一个任务的关键上下文信息,比如段寄存器、通用寄存器、控制寄存器,尤其是底部的字段SS0~SS2和ESP0~ESP2,记录了一个任务在不同优先级执行时应该使用的堆栈。SSx用于选择堆栈所在的段,ESPx是堆栈指针值。

CPU通过任务门的段选择器找到TSS描述符后,会执行一系列的检查动作,比如确保TSS描述符中的存在标志为1,边界值应该大于0x67,B(Busy)标志不为1。所有检查通过后,CPU会将当前任务的状态保存到当前任务的TSS中。然后将TSS描述符中的b标志设置为1。接下来,CPU会将新任务的段选择器(与门描述符中的段选择器等价)加载到TR寄存器中,然后将新任务的寄存器信息加载到物理寄存器中。最后,CPU开始执行新的任务。

图11-2 32位任务状态段(TSS)

让我们通过一个小实验来加深理解。首先,在调试Windows Vista的内核调试会话中,通过ridtr命令获取系统IDT的基址。

kd & gtR idtridtr=834da400因为Double Fault (#DF)通常是使用任务门来处理的,所以我们观察到了这个异常对应的IDT项。因为#DF异常的向量号是8,每个IDT项的长度是8个字节,所以我们可以使用下面的命令来显示IDT第8项的内容..

kd & gtdb 834 da 400+8 * 8l 8834 da 440 0000 50 00 00 85 00 00…p…第二个和第三个字节(从0开始,下同)组成的字是段选择器,即0x0050。第5个字节(0x85)是p标志(1)、DPL(0b00)和类型(0b00101)。

接下来,dg命令用于显示段选择器指向的段描述符。

kd & gtdg 50 P Si Gr Pr LoSel基址限制类型l ze an es ng标志——-0050 81967000 00000068 ts s32 l 0 Nb By P Nl 00000089换句话说,TSS的基址是0x81967000,长度是0x68字节(Gran位用By表示,即Byte)。类型字段显示该段的类型为32位TSS(TSS32),其状态为可用,不忙。

此时,我们知道由对应于#DF异常的门描述符指向的TSS是0x68字节,位于存储器地址0x81967000的开头。使用memory watch命令显示这个TSS的内容(清单11-2)。

清单11-2 TSS的内容

kd & gtDD 8196700081967000 000000000 81964000 00000000000081967010 000000000000000000000000000000000122000081967020 8193 f0a 0000000000000000 00000 00000000 0000000000 0000000000000000000000000000000接下来,跟随标志寄存器(EFLAGS)和通用寄存器的值。0x48字节偏移量处的0x23是es寄存器的值,相邻的00000008是CS寄存器的值,CS寄存器是该任务代码段的选择器。然后是SS寄存器的值,也就是栈段的选择器,然后是DS,FS,GS寄存器的值(0x23,0x30,0)。偏移量0x64字节的20ac0000是TSS的最后4个字节,它的最低位是T flag (0),也就是我们在第1卷4.3.3节介绍的TSS中的trap标志。高16字节是用来定位IO映射区基址的偏移地址,相对于TSS的基址。

使用ln命令观察EIP的值对应于内核函数KiTrap08。

kd & gtln 8193f0a0 (8193f0a0) nt!KiTrap08 | (8193f118) nt!Dr_kit9_aExact匹配:nt!KiTrap08 = & lt没有类型信息& gt也就是说,当发生#DF异常时,CPU会切换到上面TSS中描述的线程,然后在这个线程环境中执行KiTrap08函数。之所以要切换到新线程,而不是像其他异常一样在原线程中处理,是因为#DF exception在处理一个异常时引用了另一个异常,这可能意味着原来的线程环境不再可靠,需要切换到新线程来执行。

类似地,代表紧急任务的不可屏蔽中断(NMI)也通过使用任务门机制来处理。最后,因为x64架构不支持基于硬件的任务切换,所以在IDT中没有任务门。

大多数中断和异常由中断门或陷阱门处理。我们来看看这两种情况。

首先,CPU将根据门描述符中的段选择器定位段描述符,然后进行一系列检查。如果检查通过,CPU将判断是否需要切换堆栈。如果目标代码段的特权级别高于当前特权级别(特权级别的值较小),则CPU需要通过从当前任务的ts中读取新堆栈的段选择器(SS)和堆栈指针(ESP)并将其加载到SS和ESP寄存器中来切换堆栈。然后,CPU将被中断进程的堆栈段选择器(SS)和堆栈指针(ESP)推入新的堆栈。接下来,CPU将执行以下两个操作。

(1)将EFLAGS、CS和EIP的指针推入堆栈。CS和EIP的指针代表CPU在进入处理程序之前执行代码的位置。

(2)如果发生异常,并且异常有错误代码(见本书第1卷3.3.2节),那么错误代码也被推送到堆栈上。

如果处理例程所在代码段的特权级别与当前特权级别相同,则CPU不需要切换堆栈,但仍需要执行上述两步。

TR寄存器包含指向当前任务TSS的选择器,使用WinDBG可以观察到TSS的内容。

kd & gtr trtr = 00000028kd & gtDG 28 P Si Gr Pr LoSel Base Limit Type l ze an es ng Flags——-0028 8013 e000 000020 ab ts s32 Busy 0 Nb By P Nl 0000008 b经常做内核调试的读者可能会发现,TR寄存器的值大部分时间是固定的。换句话说,该值不会随着应用程序的线程切换而改变。事实上,Windows系统中TSS的数量与系统中线程的数量无关,而与CPU的数量有关。Windows系统在启动时会为每个CPU创建3 ~ 4个ts,一个处理NMI,一个处理#DF异常,一个处理机器检查异常(版本相关,XP SP1中已有),另一个供所有Windows线程共享。当Windows系统切换线程时,它将当前线程的状态复制到共享的TSS。也就是说,普通的线程切换是不切换TSS的,只是在NMI或者#DF异常发生时才切换,软件称之为切换线程(任务)。

11.1.4 IDT一览

用WinDBG!Idt extension命令可以列出Idt的所有项目,但是这个命令被翻译了很多,它没有显示门描述符的原始格式。

lkd & gt!倾销IDT:00: 804dbe13 nt!异常KiTrap00 // 0,即除以001: 804dbf6b nt!Kitrap0102:任务选择器的门描述符= 0x0058//nmi显示TSS 03: 804dc2bd nt的选择器!KiTrap03的表11-1列出了典型Windows系统的IDT设置。对于不同的Windows版本或具有不同硬件配置的系统,某些条目可能会有所不同,但大多数条目是相同的。

表11-1 IDT设置概述(略)

在Windows XP系统中,处理机器检查异常(#MC)的第18个条目是一个任务门描述符,它指向单个TSS,对应的处理函数是Hal模块中的halpmaexceptionHandlerwrapper。

11.2 异常的描述和登记

为了更好地管理异常,Windows系统定义了一种特殊的数据结构来描述异常,并定义了一系列代码来标识典型的异常。

在操作系统层面,有CPU产生的异常,也有软件模拟的异常,比如调用RaiseException API产生的异常,使用编程语言throw关键字抛出的异常。为了书写方便,我们称前一类为CPU异常(或硬件异常),后一类为软件异常。Windows使用统一的方式来描述和分配这两种类型的异常。本节介绍异常的描述,11.3节将介绍异常的分布过程。

11.2.1 EXCEPTION_RECORD结构

Windows系统使用EXCEPTION_RECORD结构来描述异常,清单11-3给出了该结构的定义。

清单11-3异常记录结构

typedef struct _ EXCEPTION _ RECORD { DWORD EXCEPTION code;//异常代码DWORD ExceptionFlags//异常标志struct _ exception _ record * exception record;//另一个相关异常PVOID ExceptionAddress//异常发生地址DWORD NumberParameters//参数数组中的元素个数ulong _ ptrexception信息[exception _ maximum _ parameters];//参数数组}异常_记录,* p异常_记录;ExceptionCode是一个异常代码,是一个32位的整数,其格式是Windows系统的状态码格式。NtStatus.h包含所有已定义的状态代码。在WinBase.h中,您可以看到异常代码只是状态代码的别名,例如:

# define exception _ breakpoint status _ breakpoint # define exception _ single _ step status _ single _ step表11-2列出了用于异常代码的常见状态代码。

字段用于记录异常标志,它的每一位都代表一个标志。到目前为止,已经定义的标志位如下。

(1)EH_NONCONTINUABLE(1),异常无法恢复,继续执行。

(2)EH_UNWINDING(2),由于执行堆栈扩展而调用异常处理函数时,将设置该标志。

(3)同样用于栈扩展的EH_EXIT_UNWIND(4)很少使用。

(4)EH_STACK_INVALID(8),当检测到堆栈错误时,设置该标志。

(5)EH_NESTED_CALL(0x10),用于识别嵌入式异常(第24章)。

EH_NONCONTINUABLE位用于指示异常是否可以恢复执行。如果试图恢复运行不可持续的异常,将导致exception _ non continuable _ exception。

ExceptionRecord指针指向与此异常相关的另一个异常记录。如果没有相关异常,那么这个指针就是空。

表11-2中使用的异常代码的状态代码(略)

ExceptionAddress字段用于记录异常地址。对于硬件异常,由于异常类型不同,其值可能是导致异常的指令的地址,也可能是导致异常的下一条指令的地址。比如非法访问异常(EXCEPTION_ACCESS_VIOLATION)属于故障异常,ExceptionAddress的值就是导致异常的指令的地址。数据断点触发的调试异常属于陷阱异常,ExceptionAddress的值是引起异常指令的下一条指令的地址。

NumberParameters是附加参数的数量,即ExceptionInformation数组中包含的有效参数的数量。这种结构允许存储多达15个附加参数。

非法访问异常的原因主要来自CPU的页面错误异常#PF(14),但也可能是由于系统检测到的其他违反系统规则的情况。

11.2.2 登记CPU异常

对于CPU异常,KiTrapXX例程完成该异常的特殊动作后,通常会调用CommonDispatchException函数,并通过寄存器将以下信息传递给该函数。

(1)将唯一识别异常的异常代码(表11-2)放入EAX登记簿。

(2)将导致异常的指令地址放入EBX寄存器。

(3)将其他信息作为附带参数放入EDX(参数1)、ESI(参数2)和EDI(参数3)寄存器,并将参数数量放入ECX寄存器。

调用CommonDispatchException后,会在堆栈中分配一个EXCEPTION_ RECORD结构,并将上述异常信息存储在该结构中。这个结构准备好之后,它会调用内核中的KiDispatchException函数来分发异常。

11.2.3 登记软件异常

我们来看看软件异常的产生和注册过程。简单来说,软件异常是通过直接或间接调用内核服务NtRaiseException产生的。

NTStatus NTRAISEEException(在p exception _ record异常记录中,在p context上下文记录中,在boolean first chance中)用户模式下的程序可以通过raiseeexception()API调用这个内核服务。RaiseException API是由KERNEL32.DLL导出的API,应用程序使用它来生成“自定义”异常。其原型如下。

void RaiseException( DWORD,DWORD,DWORD,const DWORD *);其中是异常代码,可以是表11-2中的代码,也可以是应用程序自己定义的代码。和常数等效于EXCEPTION_RECORD结构中的和。其实RaiseException的实现也很简单。它只是将参数放入EXCEPTION_RECORD,然后在NTDLL.DLL调用RtlRaiseException()。RtlRaiseException将把当前执行上下文(通用寄存器等。)到上下文结构中,然后通过NTDLL.DLL的系统服务调用机制调用内核中的NtRaiseException。

另一个内核函数KiRaiseException将在NtRaiseException内部调用。

NTSTATUS KiRaiseException(在PEXCEPTION _ RECORD Exception RECORD中,在PCONTEXT ContextRecord中,在PK Exception _ FRAME ExceptionFrame中,在PK trap _ frame trap frame中,在Boolean first chance中)异常记录是指向异常记录的指针,ContextRecord是指向线程上下文结构的指针,对于x86平台,Exception FRAME始终为NULL。TrapFrame是堆栈帧的基址,FirstChance表示这是异常的第一轮(TRUE)还是第二轮(FALSE)。

内核中的代码可以通过RtlRaiseException调用NtRaiseException和KiRaiseException(相当于NTDLL中的版本。DLL)。也就是说,无论是从用户态调用RaiseException API,还是从内核态调用相应的函数,最终都会走向KiRaiseException。

KiRaiseException会通过KeContextToKframes例程将ContextRecord结构中的信息复制到当前线程的内核堆栈中,然后清除ExceptionRecord中异常代码的最高位,以区分软件生成的异常和CPU异常。接下来,KiRaiseException将调用KiDispatchException开始分发异常。

对于Visual C++程序抛出的异常,比如MFC中从CException派生的各种异常类对应的异常,throw关键字直接对应CxxThrowException函数,该函数会调用RaiseException,并将ExceptionCode参数固定为0xe06d7363(对应的ASCII代码为。理学硕士)。下面的过程和上面直接调用RaiseException的过程是一样的。因为C++异常的实现与编译器有关,所以本书只讨论Visual C++编译器的使用。

引发的异常(CLR异常)。NET程序也是通过RaiseException API生成的,其异常代码固定为0xe0434f4d(对应的ASCII码为。COM)。

综上所述,无论是CPU异常还是软件异常,虽然原因不同,但最终都会调用内核中的KiDispatchException来分发异常,也就是说Windows系统使用统一的方法来分发CPU异常和软件异常。

11.3 异常分发过程

根据前面两节的介绍,当异常发生时,CPU会通过IDT找到异常处理函数,也就是内核中的KiTrapXX系列函数,然后转向执行。然而,KiTrapXX函数通常只是异常的简单表示和描述。为了支持软件本身定义的调试和异常处理功能,系统需要将异常分发给调试器或应用程序的处理功能。对于软件异常,Windows系统用CPU异常统一分配处理。本节将介绍分发异常的核心功能KiDispatchException及其工作流程。

11.3.1 KiDispatchException函数

Windows内核中的KiDispatchException函数是分发各种Windows异常的中枢。其功能原型如下。

VOID KiDispatchException(在PEX EXCEPTION_RECORD ExceptionRecord中,在PK EXCEPTION _ FRAME EXCEPTION FRAME中,在PKTRAP_FRAME TrapFrame中,在k processor _ mode previous模式中,在boolean first chance中),其中参数EXCEPTION RECORD指向上一节介绍的EXCEPTION _ RECORD结构,用于描述要分发的异常。对于x86系统,参数ExceptionFrame始终为NULL。参数TrapFrame是指KTRAP_FRAME结构,用于描述异常发生时的处理器状态,包括各种通用寄存器、调试寄存器、段寄存器等。参数Previouode是一个枚举类型的常量,在DDK的头文件中定义。

typedef enum _MODE { KernelMode,UserMode,MaximumMode}模式。也就是说,Previouode等于0,表示之前的模式(通常是触发异常代码的执行模式)是内核模式,1表示用户模式。FirstChance参数指示此异常是否是第一次分发。作为例外,Windows系统最多分发两轮。

图11-3显示了KiDispatchException分发异常的基本流程(示意图)。

从图11-3可以看出,KiDispatchException首先会调用KeContextFromKframes函数,目的是根据TrapFrame参数指向的KTRAP_FRAME结构生成一个上下文结构,在向调试器和异常处理函数报告异常时可以使用。

接下来,根据之前的模式(发生异常的模式)是内核模式还是用户模式,KiDispatchException会选择左右两个进程中的一个来分发异常,下面我们会进一步解释。

本文摘自《软件调试》第二版第二卷Windows平台调试第一卷和第二卷。

本书是目前国内专注于软件调试主题的权威著作。本书第二册共分5篇30章,主要是对Windows系统的介绍。第一章(第1-4章)介绍了Windows系统的简史、进程和线程、体系结构和系统组成,以及Windows系统的启动过程。既从空的角度描述了Windows的软件世界,又从时间的角度描述了Windows世界的构建过程。第二章(第5~8章)描述了特殊过程调用、垫片、托管世界和Linux子系统。第三章(第9-19章)讨论了用户态调试模型、用户态调试流程、中断和异常管理、未处理异常和JIT调试、硬错误和蓝屏、错误报告、日志、事件跟踪、WHEA、内核调试引擎和验证机制。第四章(第20-25章)从编译与编译时检查、运行时库与运行时检查、堆栈与函数调用、堆与堆检查、异常处理代码编译、调试符号等方面总结了编译器的调试支持。第五章(第26-30章)首先概述了调试器的发展历史、工作模式和经典架构,然后分别讨论了集成在Visual Studio和Visual Studio(VS)代码中的调试器,最后深入分析了WinDBG调试器的历史、结构和用法。

理论与实践相结合,本书不仅涵盖了相关的技术背景知识,还深入探讨了大量具有代表性的技术细节,是学习软件调试技术的珍贵资料。

本书适合所有从事软件开发的读者,尤其适合从事软件开发、测试和支持的技术人员。

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。

发表回复

登录后才能评论