购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

3.3 初始化内核调试

内核调试引擎的初始化和模拟调试引擎的初始化配置相比复杂一些,需要深入理解中断和异常。中断是实现系统多任务多线程的理论基础,是系统的中枢神经。中断的提出最初主要是解决系统和外围设备通信的问题,Windows API的主要设计可能也是从中断得到了启发。内核调试引擎需要从中断处理程序进入,但中断处理程序的处理过程可以不需要调试引擎支持,所以中断处理程序首先需要设计。Windows NT的中断程序处理能力的最初目标定位如下:提供单一机制,中断处理程序能在所有语言中使用;能处理硬件和软件产生的异常;支持有特权和非特权的软件使用;支持复杂的调试器使用(WinDbg);支持结构化异常处理扩展(详见第5章)。

3.3.1 中断和异常向量表

硬件发出信号后,在程序执行的过程中,中断会随时发生。系统硬件用中断处理外部事件,如外围设备服务的请求。软件也可以通过执行INT n指令产生中断。在执行指令过程中,处理器检测到一个错误条件,如除零操作,就会发生异常。处理器检测错误的各种条件,包括保护违法行为、页面故障和内部机器故障等。当收到一个中断或检测到异常时,当前运行的程序或任务会被暂停,处理器转而去执行一个中断或异常处理程序。执行处理程序完成后,处理器恢复执行被中断的程序或任务。被中断的程序或任务不会损失程序的连续性,除非恢复异常不可能或中断引起当前正在运行的程序被终止。

为了帮助处理异常和中断,体系结构对每个异常和中断分配了唯一的识别号,称为一个向量号(vector No.)。处理器使用向量号作为进入中断描述符表(interrupt descriptor table,IDT)的索引。中断描述符表提供了异常或中断处理程序的入口点。向量号所允许的范围是0—255。其中0—31保留给Intel 64和IA-32架构所定义的中断或异常使用,32—255留给用户定义。更多信息参见表3.1。

表3.1

续表

注意事项:

1.向量号6,UD2指令在Pentium Pro处理器引入。

2.向量号9,Intel 386处理器后不产生此异常。

3.向量号17,此异常在Intel 486处理器引入。

4.向量号18,此异常在Pentium处理器和增强P6系列处理器引入。

5.向量号19,此异常在Pentium III处理器引入。

中断源

处理器接收来自两个信号源的中断:

· 外部(硬件产生)中断。通过处理器引脚或本地APIC接收。

· 软件产生中断。如INT n指令。

异常源

处理器接收异常有三个来源:

· 处理器检测程序错误异常。类型有Fault、Trap和Abort。

· 软件产生异常。如INT n指令。

· 机检异常。Pentium和P6系列处理器提供。

IDT描述符

IDT可以包含任何门描述符:

· 任务门描述符。

· 中断门描述符。

· 陷阱门描述符。

图3.3显示了任务门、中断门和陷阱门的描述符格式。中断门和陷阱门是非常相似的调用门。它们包含一个远指针(段选择符和偏移量),处理器使用它转移程序到一个异常或中断处理程序中。

图3.3

根据图3.3观察任务门(00101)、中断门(0D110)和陷阱门(0D111)之间的区别。控制权一旦进入中断门例程,在栈中保存eflags,清除IF位标志,避免嵌套中断发生,退出时从栈中还原eflags值,原来是什么值就还原什么值,在中断门例程内允许置IF位。控制权进入陷阱门例程后不会改变IF位,eflags原来是什么值就是什么值。本章也大量提及“异常”,注意没有异常门。异常是从中断门和陷阱门按事件来源分出来的特殊概念,以Windows Server 2003 SP1 WRK内核为例:除2号和8号使用任务门外,其余全是中断门,所以当看到TRAP字样时,不要认为是陷阱门,本书主要讲解中断门。

中断和异常在概念上有一些细小差别。比如,时钟相隔一定的滴答数会定时产生一个中断,我们说产生中断比较合理;而除零操作产生的错误叫作异常比较合理。异常一般会导致中断,中断并不意味着异常。异常可恢复回到原地址继续执行,如页面错误被处理后。无论产生的是中断还是异常,控制权都会转到IDT向量表相应的门描述符项处理函数中。下面讲述描述符项结构:

Offset表示中断例程地址的高16位,ExtendedOffset表示中断例程地址的低16位,(高16位+低16位=32位)4字节地址就构成了一个中断向量例程地址。任务门、中断门和陷阱门使用相同的结构KIDTENTRY,每个描述符占8B,Selector占2B(16 bit),2 16 =65 536,可见最多允许设置描述符数为65 536÷8=8 192,参见第2章。

3.3.2 注册异常处理例程

异常处理程序是书面明确规定,什么样的异常就用什么样的代码处理。异常处理程序都用特定语言的语法声明,并与特定的代码范围相关联,可能是一个块代码、一组嵌套块代码,或者整个过程是一个函数。中断的响应是由硬件来实施的,但其处理过程交给操作系统去操作,所以要在中断向量表中设置正确的处理例程。中断处理过程一般分为4个阶段:

1.响应中断请求(由硬件完成)。

2.保存寄存器上下文(由硬件完成)。

3.转入相应的处理例程(由硬件完成)。

4.还原寄存器并退出(由软件完成)。

知道了原理,我们的首要任务是在中断向量表中注册异常处理例程(或叫中断处理例程),对照图3.3来设置异常处理例程:除零错误_KiTrap00(#DE),单步断点_KiTrap01(#DB),INT3断点_KiTrap03(#BP),一般保护性错误_KiTrap0D(#GP),页面错误_KiTrap0E(#PF)和调试器调用_KiTrap2D(debugger call)。这些例程框架大多数是用内联汇编编写的,其实C语言也可以编写例程框架,只是在精确控制寄存器方面没有优势。

为了方便安装中断描述符,设置了idt宏,Selector选择子全部设为08,它是内核代码段选择子,从Loader模块开始我们使用的代码段选择子都是08。

我们已经在SU中设置了中断描述符表,但是这个表是空的,没有安装任何有效的描述项。当系统触发异常时,会寻找相应的中断描述项,如果描述项无效,计算机可能进入不可控的状态,如不断重启,所以在SU执行时就得确保不会发生异常,因为就算发生也没法处理,这一点在设计上是需要注意的。SU主要用汇编语言编写,设置完整的中断描述符项比较麻烦,留到Loader再设置是比较理想的,再者lidt指令允许重复安装中断描述表。KdSetTrap函数用来安装中断描述符项,在调试系统初始化时被调用。

KdSetTrap函数直接以idt宏的形式设置,每个中断例程最后2字节是所在的中断号。Windows允许使用256个中断,有些中断我们未使用,如果发生未预料中断,通过INT 3断点,我们很快可以知道发生在哪个中断号上。中断例程内部也可以触发INT 3/INT 1中断,但不能触发INT 3/INT 1自身中断,否则会进入死循环状态。可以跟踪除这两个中断之外的其他中断例程执行过程,这是需要特殊设置的,后面将要说到。

256个中断中未使用的都需要设置Selector和Access值。如果不这样做,会有未知的错误发生,比如HAL硬件抽象层设置的时钟中断,就不会设置Selector和Access值,它只会设置Offset和ExtendedOffset。我们来看一下KeRegisterInterruptHandler函数的实现:

hal.dll使用KeRegisterInterruptHandler内联函数注册中断处理程序,这些中断处理程序大多数是在HAL模块中实现的。IDT可以重复安装,进入ntoskrnl内核后,重新设置中断描述符表,如WRK。我们的ReactOS使用相同的kd模块,只不过Loader阶段使用的kd和内核使用的是不同的环境,在内核下kd会重新安装。

3.3.3 基于栈框架的异常处理程序

通俗一点讲,异常处理程序栈框架的主要作用就是在控制权进入时保存寄存器,退出时还原寄存器。一般函数的栈框架是依赖于编译器帮助实现的,而这里的栈框架必须自已实现,因为异常处理程序对寄存器需求远大于一般函数,而且必须保证寄存器的安全性和完整性。异常处理程序框架部分采用汇编编写有着得天独厚的优势,在WRK中我们可以看到大量的MASM汇编编码和C语言链接。笔者不太喜欢MASM汇编烦琐的形式,所以使用了精简的函数内联汇编。

从函数的角度描述,CPU寄存器可分为两类:易失性寄存器(volatile register)和非易失性寄存器(nonvolatile registers)。易失性寄存器内容可以改变,可在跨越子函数期间改变其值。非易失性寄存器在跨越子函数时必须保存其值,如果某个程序改变了非易失性寄存器的值,它必须在栈中保存旧值,并在返回之前将其恢复。下列结构用来保存寄存器:

所有的门例程中都使用陷阱框架KTRAP_FRAME,而不是KINTERRUP_FRAME(未定义),KTRAP_FRAME的主要作用是在进入中断例程时保存寄存器,退出时还原寄存器。它能保存大部分CPU寄存器,如通用寄存器、段寄存器、调试寄存器等,对于中断处理例程来说够用了。KTRAP_FRAME结构中寄存器的值在门例程内部是可以根据需求改变的。最常见的是KTRAP_FRAME.Eip的值被修改,如果异常未被处理,返回的是原来出错的位置,进一步执行又产生中断,再次循环进入门例程。如果内核在调试状态,就会中断下来。如果内核不在调试状态,就会显示蓝屏出错信息。

注意,KTRAP_FRAME.TempSegCs通常保存的是SegSs值,KTRAP_FRAME.TempEsp通常保存的是Esp值。保存这些寄存器时注意观察它们对应结构中的那个成员。有时侯编码确实会混淆,笔者就发生过几次这样的问题,弄乱了寄存器,以致保存的寄存器和退出时还原的寄存器不是同一个值。

通过ENTER_TRAP2宏来保存寄存器,只保存常见的寄存器,有一些特定的寄存器在后面代码中用不同的形式保存。保存寄存器的值,可以直接从寄存器中获取,然后保存到对应的KTRAP_FRAME结构成员中。KTRAP_FRAME开辟了函数栈空间,基本上开辟函数的栈空间形式都是sub esp,n。DS和ES被设为KGDT_R3_DATA|DPL_3,不改变这两个段寄存器对我们来说是没有什么影响的,通常INT 1和INT 3的DPL设为3。

ENTER_TRAP和ENTER_TRAP2的作用是一样的,都是保存寄存器。使用的方式是寄存器直接入栈然后出栈保存。push方式比mov更高效,只是入栈出栈次数太多容易出错,可读性也没那么高。特别注意,保存过程是从ErrCode开始,然后倒序依次保存,要学会观察ENTER_TRAP宏。

像这样同一功能的函数,我们设计了两种版本,一种是专用于INT 1、INT 3断点使用的处理框架,另一种是其他异常处理程序使用的框架。如果全部异常框架都使用一种版本,比如WRK原版的调试系统就只有一种版本,就无法跟踪部分异常处理内部的流程。现在有了两种版本,除了INT 1、INT 3不能跟踪外,其他中断程序内部流程都能跟踪到,比如,我们可以跟踪页面错误处理流程。我们只需bp_KiTrap0E下个断点,当有页面错误产生时,就会中断到这个入口,然后就可以单步跟踪页面错误是如何处理的,特别是查看寄存器是如何保存的,或进入到我们感兴趣的页面错误处理函数MmAccessFault,或者直接bp MmAccessFault下断点,绕过_KiTrap0E异常处理程序入口,这取决于你的调试需要。编码和调试需要灵活地结合。

3.3.4 除零错误(#DE)

整数除以零会产生异常。异常代码:STATUS_INTEGER_DIVIDE_BY_ZERO。什么是异常代码?异常代码由系统定义,用于识别产生的异常。函数可以根据异常代码来执行特定的操作。

在数学中,除0操作的结果是无定义的。所谓无定义的意思是没有好的答案。假如现在要求1/0的结果,我们让除数从正数和负数两个方向无限接近0。先从正数方向,我们知道1/0.1=10,1/0.01=100,…,1/0.000001=1 000 000,…,当除数从正数方向无限逼近于0时,可以得到1/0=+∞(无穷大)。再从负数方向,1/(-0.1)=-10,1/(-0.01)=-100,…,1/(-0.000001)=-1 000 000,…,当除数从负数方向无限逼近于0时,可以得到1/0=-∞(无穷小)。+∞和-∞具体是指什么数,事实上我们并不知道,这就是除0操作结果无定义的原因。

下面展示了在内核下除0出错,但是没有处理的情况,系统直接崩溃。我们用调试器查看:

UNEXPECTED_KERNEL_MODE_TRAP(未预知内核模式陷阱)意味着一个陷阱发生在内核模式下,就算有try/except语句也不允许捕捉。如果发生在内核态Ki386CheckDivideByZeroTrap函数期间,直接调用KeBugCheck2函数崩溃;如果发生在用户态,有try/except语句则允许捕捉,如果没有,终止程序运行。尽管内核有能力处理除以零的错误发生,即把控制权交给try/except处理,但是Windows设计者并没有这样做。他们使用了最极端的方式——崩溃,在内核下,要么好好运行,要么直接崩溃,半死不活的状态需要消耗相当多的系统资源,而且极难恢复。我们将在第5章专门讲述try/except结构化异常处理,允许在内核模式下捕捉产生的异常并进行特殊处理。

除法错误,一般由div或idiv指令引起。错误代码并不是每个异常都会产生的。如果没有产生错误代码,为了向后兼容,我们默认设置一个伪错误码0,就是说这个错误代码是我们故意设置,方便识别。0xC0000094是除法错误的代码,主要对除零错误处理程序进行特定的标识,二进制为11000000 00000000 00000000 10010100,从SDK开发包的ntstatus.h文件中可以找到一些有关出错状态码的解释:

最高2位表示安全级别码:00为成功,01为信息,10为警告,11为错误。以0xC开头的表明出错特别严重,其次是以0x8开头表示的警告,最后是以0x0开头表示的成功。比如,STATUS_BREAKPOINT(0x80000003L)断点信息,STATUS_ACCESS_VIOLATION(0xC0000005)访问违规错误,STATUS_IN_PAGE_ERROR(0xC0000006)页面错误。Windows API大多数执行结果都会返回异常代码,查看高2位可以判断是哪一类型,更详细的解释可对照ntstatus.h文件或查阅MSDN,这些在编程和调试中都是必须要掌握的技能。

在Win32环境下,运行osloader.exe进程,用户态是无法访问dr0—dr7寄存器的,所以使用一个定义SIMULATE_DEBUG在编译时去掉。FirstChance指定异常首次发生,Previ-ousMode通过(KTRAP_FRAME.SegCs&MODE_MASK)得出是KernelMode还是UserMode,此外还向下传递两个结构,第一个是进入例程时建立的TRAP_FRAME,第二个便是EX-CEPTION_RECORD(异常记录)。异常记录描述了一个软件/硬件产生的异常及其相关参数。

ExceptionCode(异常代码)用于指定异常的原因(如,0xC0000094是除零异常产生的)。ExceptionFlags(异常标志)描述异常的属性。ExceptionAddress指向硬件异常或软件异常发生的地址。对于所有的异常记录信息,这个值是最重要的,它能准确指出产生异常的地址来源。如果调试器加载了正确的符号文件,异常的来源很容易被标示出来,调试器中使用!analyze-v命令就会显示出错的源码位置。中断发生也可能有一些参数传入,比如_KiTrap2D通过寄存器传递参数(不是通过__fastcall调用),第1个参数值存放在eax,第2个参数值存放在ecx,第3个参数值存放在edx,第4个参数值存放在ebx,第5个参数值存放在edi。

把原来保存在KTRAP_FRAME结构中的寄存器值还原给进入此例程前CPU的寄存器,iretd指令返回相应的位置,iretd指令也可以返回用户模式,进入用户代码段。iretd返回的可能是原eip的下一条指令地址或是继续原出错的地址,取决于异常是怎样被处理的。这里还提供了另一个版本的异常退出函数KiExceptionExit。

这个函数的作用和_KiExceptionExit2一样,用于还原寄存器,只不过它是通过出栈的方式直接一个一个地还原寄存器,我们说过,这种方式比较高效。

3.3.5 单步异常(#DB)

单步异常即单步指令引起的异常,比如WinDbg中按F11键单步执行一条指令产生的异常。eflags寄存器的TF位表示单步执行。当TF为1时,CPU执行完一条指令后会产生单步异常,进入异常处理程序后TF自动置0。每按一次F11键,TF执行一次置1,程序就可以每执行一条指令中断一次。异常代码:STATUS_SINGLE_STEP(0x80000004)。

由于没有错误代码提供,因此也需要压入特别设置的伪错误码。为预防单步中断再次触发,TF标志必须去掉。

注意,远程调试器不能直接设置被调试的系统寄存器eflags,远程调试器是和调试引擎核心交互通信的。试想一下,如果远程调试器发送设置单步包,调试引擎核心收到后,马上对标志寄存器eflags设置TF位。设置完成后,调试引擎核心内部总是要执行的,再执行会立刻产生单步中断。执行控制再次来到_KiTrap01,之后会再次进入调试引擎核心,这样会出现什么状况呢?

无法估计,所以这种设计是不可行的。远程调试器和调试引擎核心的设计原则肯定会避免这种情况的出现,所以当我们在调试器手动设置TF位时,没有任何反应,系统的标志寄存器eflags原来是什么值就是什么值。但我们要知道当WinDbg中按F11键单步执行时调试引擎核心是如何处理的。

在WinDbg中按F11键单步执行,意味着WinDbg暂时交出控制权,调试引擎核心必然要退出来,在退出之前调用KdpGetStateChange函数设置“Context->EFlags|=EFLAGS_TF”,这个Context随后会设置栈框架的esp。我们看_KiExceptionExit框架的最后一条指令iretd,标志寄存器eflags的值就是在这里重新设置的,那么TF位就会置1,所以iretd返回后,再执行就会因为TF置位产生一个单步中断,又进入调试内核引擎核心,这样又将与远程调试器进行连接。我们知道这个过程是执行了一条指令的,eip位置移动了一条指令的长度,指向下一条指令。

3.3.6 断点异常(#BP)

当执行断点指令时,将出现断点异常,硬件定义的断点被触发(例如,在一个断点寄存器的地址,这个异常通常由调试器使用)。异常代码:STATUS_BREAKPOINT。

断点异常是由一个单字节0xCC(INT 3)指令造成的。在WinDbg中按F10键,内核调试组件在相应的内存位置写入一字节码0xCC,CPU执行到0xCC时便会产生中断,转而进入以下例程。

断点错误可发生在用户态或内核态,没有错误代码提供,需要压入特别设置的伪错误代码。断点异常和单步异常都不能在异常处理程序下断点观察,否则将触发无限重入而使计算机崩溃。如果是模拟调试,ebx保存的IP地址需要减5(call_KiTrap03占5B十六进制的机器码),内核态调试减1(0xCC占1B),这样计算后,调试器加载的符号才能正常显示出错的位置。当例程返回时,会自动加上减去的大小(5或1)。具体参见函数KdpTrap,后面会讲到。

当使用如bp 0x401000设置断点时,并不会马上在0x401000位置写入CC指令,而是在远程调试器交出控制权后,才进行设置。按F10键执行也是如此。一旦执行了这样的断点,调试内核引擎核心会还原断点之前的一个代码。断点信息保存在全局数组KdpBreakpointTable。

3.3.7 一般保护性错误(#GP)

一般保护性异常产生的条件比较多,比如,选择子不存在会触发#GP,大于或小于段限值Limit的偏移值将触发#GP,当处理器检测到违反特权级的操作时也会触发#GP。异常代码:STATUS_ACCESS_VIOLATION。

每个异常例程的框架有一些是相同的,这里特别指出不同的地方。在_KiTrap0D例程中注释掉了push 0,为什么?因为一旦产生这个#GP异常,CPU将自动在栈中压入一个错误码。我们先在_KiTrap0D入口下断点(WinDbg中按F9键),执行以下代码:

00000090是ErrCode;0040102a是Eip,00000008是SegCs,00010006是EFlags,所以_KiTrap0D例程不需要压入错误码。观察表3.1中向量号13对应的错误代码,标注了“有”,说明发生异常时错误码自动压入栈。这个代码不是伪错误代码,它标示了出错的选择子号。

3.3.8 页面错误(#PF)

最常见的情形是,页面不存在会触发#PF异常。出错的程序没有足够的特权访问指定的页面也会触发#PF异常。它帮助内存管理器提供了copy on write(写时拷贝)机制。物理内存页面是有限的,如果系统预先分配过多内存可能造成浪费,少了也不行。页面异常能很好地解决这个问题,当有需求时才分配物理页。分页池内存(PagedPool)和原型PTE(Prototype)基本上都是通过这个异常建立虚拟页面和物理页面的映射。异常代码:STATUS_IN_PAGE_ERROR。

页面出错的地址保存在cr2寄存器中。调用MmAccessFault函数处理不存在的页面,如果引用的页面被加载到内存中,则成功返回继续执行。如果MmAccessFault函数处理失败,则返回错误状态码。在Loader模块中若发生#PF错误,MmAccessFault函数只是简单地返回STATUS_ACCESS_VIOLATION,随后交给调试器处理。在调试器中我们可以处理这些页面或者不处理直接返回。如果内核态有2次不处理,会使系统直接崩溃,在用户态,应用程度会终止。我们将在第6章和第7章中介绍MmAccessFault函数,这是一个比较重要的函数。

3.3.9 调试器服务(debugger service)

_KiDebugService中断向量号是0x2d,通过这个调用,内核可以主动向远程调试器发起请求。它带有三个参数。第一个参数指定服务类型,这些服务类型包括BREAK-POINT_PRINT(打印字符串)、BREAKPOINT_LOAD_SYMBOLS(加载符号)和BREAK-POINT_UNLOAD_SYMBOLS(卸载符号)。这个中断向远程调试器发出请求,但是不会使系统中断。请求发出以后,它会原路返回继续执行。异常代码:STATUS_BREAKPOINT。_declspec(naked)_KiDebugService(){_asm{

需要push 0的情况,调用DbgPrint输出任意字符串,最后也是调用_KiDebugService,我们在此例程下断点。运行后在命令窗观察栈:

004022c4是Eip,00000008是SegCs,00000006是EFlags,没有ErrCode。所以在_KiDe-bugService例程设计中需要加上push 0,然后在ENTER_TRAP宏框架保存寄存器。像INT 0x2d这样的异常处理程序,更像普通函数,只不过在中断描述符表安装了描述符项,使用时用INT中断号就可以调用。这种设计思路为Windows NT的API设计从NTDLL进入内核提供了理论基础。 KT0VW+5BGh6QZeIuGlKRVOcqFEXYKnYs6unTHzw7/4Ia5H3FV2WwnjoA37KJIU3A

点击中间区域
呼出菜单
上一章
目录
下一章
×