Windows Debugging之三
全部组件概览
图片来自《Windows Internal》
横线以上的部分是用户态的进程,下面的组件是内核态的服务。
用户态的线程在一个保护的进程空间之中运行,尽管在内核执行模式之下他们还是有权利访问系统空间的。因此,系统支持的进程,服务进程,用户应用程序和环境子系统都用自己的私有进程地址空间。
注意图中的Subsystem dll的部分。在Windows 2000中,用户应用程序不会直接调用系统原生服务,他们会通过环境子系统动态链接库来完成调用。子系统dll扮演的角色就是把一个Windows对外公开函数调用转换成合适的内部的(非公开的)操作系统服务调用。
用户态进程包括以下几个。
*系统支持的进程- 比如登陆进程,或者是session manager等等不是由Service control manager启动的进程。
*服务进程- Win32服务的宿主,比如计划任务和背景打印程序。许多服务器端应用程序拥有像服务一样运行的组件。比如SharePoint的Timer Service,Search Service等等。
*用户应用程序- Win32,MS-Dos之类的
*环境子系统- 通过一系列可调用的函数向user application暴露操作系统原生服务的组件。Win32就是一个环境子系统。
内核态组件包括以下几个。
*可执行组件- 包括一些基本的操作系统服务,比如说内存管理,进程线程管理,安全,IO,进程间通信。
*内核- 由底层操作系统函数组成,比如线程计划,中断和异常分配,多处理器同步。还提供一系列常规对象和基础对象,其他的可执行组件用这些对象来实现其他高级架构。
*设备驱动- 包括硬件驱动程序,比方说把用户的IO函数调用转换为某硬件设备的IO请求,和文件系统,网络系统一样。
*硬件抽象层(HAL)- 相当于为内核、设备驱动程序还有其他的可执行部分建立起来的一层隔离层,为他们屏蔽掉真实硬件的区别。也就是说,内核,设备驱动程序什么的看到的不是硬件,而是硬件的抽象。HAL去跟真正的硬件打交道。
*窗口系统和图形系统- 实现GUI函数,也就是Win32 User functions, GDI functions。处理窗口,用户接口控件和绘图。
该表列出了一些windows操作系统重要的组件。
Filename | Components | |
Ntoskrnl.exe | Executive and kernel | 内核和执行者 |
Ntkrnlpa.exe | Executive and kernel with support for Physical Address Extension (PAE), which allows addressing of up to 64 GB of physical memory | 支持物理地址扩展的内核和执行者,允许64GB的物理内存寻址 |
Hal.dll | Hardware abstraction layer | 硬件抽象层 |
Win32k.sys | Kernel-mode part of the Win32 subsystem | Win32子系统的内核态部分 |
Ntdll.dll | Internal support functions and system service dispatch stubs to executive functions | 内部支持函数和系统服务 |
Kernel32.dll, | Core Win32 subsystem DLLs | 核心Win32子系统dll |
中断和异常
图片来自《Windows Internal》
中断和异常都是操作系统用来把处理器从正常控制流中转移出来的功能。术语“陷阱”指的是处理器的一种机制,这种机制可以在异常或者中断发生时捕获正在执行的线程,并且将控制流转换到一个操作系统指定好的地方去。这个指定的地方就是trap handler,该函数专门处理中断和异常。
中断和异常有什么区别呢?中断是异步事件,跟当前处理器正在执行什么没关系。中断主要是由IO设备,处理器时钟引起的,他们可以被屏蔽和开启。对比之下,异常是一个同步事件,由某一个特定的指令引起。比如说在同样的条件下,用同样的数据执行同样的代码两次,就会引发异常。举例呢,异常包括非法访问内存,特定的debugger指令,除零操作等。
注意,内核对待系统服务调用的方式,是把它看做异常来处理的。
硬件和软件都可以产生异常和中断。举例说明,总线错误造成的异常由硬件产生的,而除0异常是一个软件的bug。同样,IO设备可以产生中断,内核自身可以产生一个软件中断(APC,DPC)。
当一个硬件异常或中断产生之后,处理器记录下足够的机器状态,以便于它可以回到这个控制流的点上,好像什么都没有发生过一样的继续执行下去。要达到这种效果,处理器在被中断线程的内核栈中建立起一个陷阱框架(trap frame),用来存储该线程的运行状态。这个陷阱框架通常是一个线程完整上下文的一部分。内核处理软件中断有两种方式,要不就像对待部分硬件中断一样,要不就是在线程发起跟软件中断相关的内核函数调用时,同步处理。
中断处理
硬件中断的典型是通过IO设备产生的,这些IO设备在他们需要服务的时候必须通知处理器。中断驱动的设备允许操作系统最大化的将IO相关的操作与主要操作重叠起来。也就是说,启动一个线程,在线程中完成与设备相关的IO转换的任务,主操作在另外的线程中仍并行的运行着。设备完成之后,该设备中断处理器,请求服务。定点设备,打印机,键盘,磁盘驱动和网卡基本上都是中断驱动的。
软件也可以产生中断。比如说内核启动一个软件中断来初始化线程分配,并且异步的进入一个线程的执行之中。内核还可以禁用中断,从而处理器可以不受影响的执行下去,这种做法并不常见,一般用在关键的时刻,比如说当前正在处理一个中断或者正在处理一个异常。
内核安装中断陷阱处理器来响应设备的中断请求。中断陷阱处理器将控制, 要不转移给外部处理中断的部分(routine)ISR,要不转移给内部的内核响应中断的函数。设备驱动为ISR们提供设备中断服务,内核为其他种类的中断提供中断处理部分。
接下来的部分,你会发现硬件是如何通知处理器它要进行设备中断的,以及内核支持的中断种类,还有设备驱动与内核的交互方式,恩,还有内核识别的软件中断(还会介绍一些用于实现这些的内核对象)。
异常处理
相对于可以在任何时间发生的中断,异常是由于运行中的程序的执行直接导致的。Win32引入了一个名为structured exception handling的设施,它允许应用程序在异常产生的时候获得控制权。应用程序可以修复错误状态然后回去继续执行;展开栈(unwind stack)终止报错的子程序;或者像系统声明该异常没有被识别,请系统继续寻找合适的异常处理部分。
在X86上,所有的异常都有预定义好的中断号码,这些中断号码直接和IDT中用于处理异常的,指向陷阱处理器的入口相关。
除去那些足够简单以至于trap handler可以解决掉的异常之外,所有的异常都接受来自一个叫做exception dispatcher的系统模块的服务。exception dispatcher的任务是寻找到一个能够解决(dispose of)这个异常的异常处理器(exception handler)。系统定义好的这些与架构无关(architecture independent)的异常包括非法访问内存,整数除零,整数溢出,浮点异常和debugger断点等。这种异常的完整列表可以在Win32 API手册中找到。
内核捕捉这些异常对于用户程序来说是透明的。比如说,正在debug程序,遇到了一个断点。这时,内核会产生一个异常,通过调用debugger来处理这个异常,这样程序就暂停在了我们指定断点的地方,并且VS2005这样的debugger处于激活状态。内核处理某些其他异常的时候,仅仅是对调用者返回不成功的状态码。
无论是显式的由软件引发的异常还是隐式的由硬件产生的异常,当它发生时,内核中会有一连串的事件发生。
1. CPU硬件转移控制流到trap handler中,trap handler创建trap frame(如同中断发生时一样)。trap frame的作用是能够在异常处理结束之后让系统成功的恢复执行。
2. Trap handler还创建一个异常记录,包括异常的原因和一些相关的信息。
debugger的断点是非常常见的异常来源。所以说,异常分配器(exception dispatcher)的第一个行动就是看遭受异常的进程是否有一个debugger进程跟它相关联。如果是,那么它就把包含异常的进程的第一手的debug信息(通过LPC端口)传递给debugger端口。(信息发送给session manager进程,由它来指派合适的debugger进程)。
如果没有debugger相关联,或者说debugger不处理这个异常,那么异常处理器就会切换开关到用户态模式,并调用一个routine来寻找一个基于Frame(frame-based)的异常处理器。如果没找到,或者没有任何部分能处理该异常,异常分配器切换回内核态,然后再次调用debugger来允许用户做进一步的debugging。(这叫做second-chance notification).
所有的win32线程,在未处理异常的栈的顶部,都被声明了一个异常处理器(exception handler)。这个异常处理器在Win32内部函数start-of-process或者start-of-thread中被声明。start-of-process函数在进程中的第一线程运行的时候开始运行,它调用主入口。start-of-thread函数在用户创建另一个额外线程的时候被调用。它调用CreadThread中特定的,用户提供的线程开启routine(thread Start routine)。
这些内部启动函数的一般代码显示如下:
void Win32StartOfProcess(LPTHREAD_START_ROUTINE lpStartAddr, LPVOID lpvThreadParm) { __try { DWORD dwThreadExitCode = lpStartAddr(lpvThreadParm); ExitThread(dwThreadExitCode); } __except(UnhandledExceptionFilter(GetExceptionInformation())) { ExitProcess(GetExceptionCode()); } }
注意,当线程中有异常发生时,Win32 unhandled exception filter被调用。它在注册表中查找键值HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AeDebug,来决定是直接运行debugger还是先问一下用户。
windows2000上默认的debugger是Dr.Watson. 它并不是一个真正的debugger, 而更像是一个验尸工具,采集应用程序崩溃时的各种状态并记录到一个日志文件之中(Drwtsn32.log), 并且产生一个dump文件(user.dmp), 默认情况下这两个都可以在目录\Documents And Settings\All Users\Documents\DrWatson 中找到。
日志文件包含基本的信息,例如exception code, name of the image that failed, 已经加载了的dll的列表, 调用栈和引起异常的指令信息。
crash dump文件中包含异常发生时进程的私有分页(private page)。dump文件不包含code pages中的exe和dll文件。这个文件可以被WinDbg打开。 因为user.dmp文件可以在任意一次进程崩溃时被覆盖,你只能拥有系统最近的一次crash的dump文件,除非你重命名了这个文件或者在每一次崩溃之后都将该文件拷贝走。
如果debugger没有在运行,并且没有frame-based的handler被找到,内核就向跟线程的进程相关联的异常端口发送信息。这个异常端口(exception port)如果存在,就是由控制这个线程的环境子系统注册的。这个异常端口给环境子系统一个机会来转化这个异常为一个特定环境的信号或者异常。最后,如果内核走到这一步了,子系统还没处理异常的话,内核就会执行一个默认的exception handler,该handler就简简单单的终止引发异常的线程。
系统服务分配(System Service Dispatching)
内核的trap handler分配中断,异常和系统服务调用。前面,我们已经看到了中断和异常的处理工作。这里我们来看看系统服务吧。
系统服务分配是由一个在x86处理器上执行int 0x2e指令所触发的。因为执行int指令的结果是触发trap,Windows在IDT的46entry处填写信息来指向系统服务分配器(system service dispatcher). trap引起执行的线程转移到内核模式下,然后进入service dispatcher的系统中。一些参数被传入EAX寄存器中,用来指示多少系统服务被请求。还有一些被传入EBX寄存器中来指出传递给系统服务的参数列表的地址。
下面的代码展现了系统服务调用的代码
NtWriteFile:
mov eax, 0x0E ; system service number for NtWriteFile
mov ebx, esp ; point to parameters
int 0x2E ; execute system service trap
ret 0x2C ; pop parameters off stack and return to caller
系统服务分配器- KiSystemService,确认正确的最小参数数目,从用户态栈下拷贝调用者参数到内核态栈中(这样用户才不能在内核态访问这些参数的时候修改他们了)。然后执行系统服务。
软终端请求的等级 Software Interrupt Request Levels (IRQLs)
尽管中断控制器会进行某种程度上的中断优先级排序,Windows还是弄了一个自己的中断优先级表,也就是interrupt request levels(IRQL). 内核在内部使用数字0到31来在内部表示IRQLs,数字越大,表示的优先级就越高。相对于内核定义了软件的IRQLs的标准集合,HAL(硬件抽象层)将硬件中断映射到IRQL上。
中断是按照优先级来顺序来被响应的,高优先级的中断优先占有服务。当高等级的中断发生时,处理器保存中断线程的状态,激活相关的trap dispatchers。Trap Dispatcher加载IRQL,然后调用终端服务routine。在service routine执行结束之后,interrupt dispatcher降低处理器的IRQL到未曾中断时的样子,然后加载保存的机器状态。中断的线程回到原来的地方继续执行。当内核降低IRQL,低等级的中断被实现。如果这个发生了的话,内核重复刚才的过程来处理新的中断。
IRQL优先级 VS thread-scheduling优先级
thread-scheduling优先级是线程的一个属性,而IRQL是一个中断源(鼠标,键盘)的属性。另外,任意一个处理器都有一个选项,该选项可以修改操作系统代码执行的功能。
任意一个处理器的IRQL选项都可以决定处理器可以接受到那些中断。IRQL还可以被用来同步访问内核态的数据结构。当内核态的线程运行时,他通过直接方式,或者调用KeRaiseIrql 和 KeLowerIrql,或者更常见的,间接的通过一些需要内核同步对象的函数调用,来提高或者降低处理器的IRQL。
内核态的线程依靠它想做什么来提高或者降低处理器的IRQL。比如说,当一个中断发生的时候,trap handler(或者是处理器)提高处理器的IRQL为与中断源相同的IRQL。这样做会屏蔽掉所有低于那个IRQL的中断(仅对那个处理器而言),保证了服务中断的处理器不会被相同等级的或者更低等级的中断所抢走。屏蔽掉的中断要不被另一个处理器处理,或者被挂起等待IRQL降下来。所以,系统中所有的组件,包括内核和设备驱动,都尽可能的保持一个比较低水平的IRQL。他们这样做因为当IRQL没有长时间被保持在一个不必要的高水平上的时候,驱动程序可以及时的响应硬件中断。
因为修改处理器的IRQL对于系统的操作有如此重大的影响,这种修改只能在内核态中完成。用户态模式下的线程不能修改处理器的IRQL。这意味着当用户态程序执行的时候处理器的IRQL总是保持在一个低水平上。只有处理器执行内核态代码的时候,IRQL才可能高一些。
每一个中断等级都有自己的目的。比如说,内核用inter-processor interrupt (IPI) 来请求另一个处理器来做点什么(比如分配某个线程来执行或者更新它的旁侧模式缓冲存储区)。系统时钟在某个时间间隔之后产生一个终端,内核通过更新时钟,衡量线程执行时间来响应中断。如果硬件平台支持双时钟,内核会增添另一个时钟中断等级来衡量性能。HAL提供一些列的中断等级供靠中断方式驱动的设备使用;准确的IRQL数值随处理器和系统设置的不同而不同。内核使用软件中断来初始化thread scheduling还有来异步的打断线程的执行。
预定义的IRQLs
让我们从高往低看吧。
- HIGH LEVEL---仅在KeBugCheckEx当住了系统时,内核使用HIGH LEVEL,从而屏蔽掉所有的中断。
- Power fail---指示了系统电源崩溃时的code,不过这个IRQL从来没被使用过。
- Inter-processor interrupt---在请求另一个处理器执行什么动作的时候才使用。
- Clock--- 该等级被系统时钟使用,系统用它来跟踪一天中的时间,还有衡量CPU和线程时间。
- Profile--- 在内核收集数据(profiling)时,或者开启了性能衡量机制时,系统实时时钟使用该等级。当内核profiling时,内核的profiling trap handler记录下被中断的代码地址。随着时间的过去,一张地址表被建立起来,有工具可以分析它。Win2k resources kit包含一个叫做KernelProfiler(Kernprof.exe)的工具,你可以使用它来查看profiling-generated数据。
- device--- 等级被用来区分优先级次序的设备使用
- DPC/dispatch 和APC----是内核和设备驱动产生出来的软件中断使用的。
- Passive---是最低的等级,根本就不是中断等级,它是通畅线程执行发生时的一种配置,任何终端都允许产生的。
有一个重要的限制。在DPC/dispatch等级或更高等级的代码运行时,它不能等待一个对象,如果它等了,会迫使scheduler选择另一个线程来运行。
另一个限制就是,IRQL DPC/dispatch或更高等级的代码只能访问未分页内存。这个限制实际上是上一个限制的副作用。当一个分页错误发生的时候,内存管理器会初始化磁盘IO然后等待文件系统驱动程序来从磁盘上读取内存分页。这样的等待会轮流一群殴球scheduler来perform上下文的切换(可能切换到空闲进程,如果没有用户线程在等待运行)。所以违反这个规则,scheduler不会被激活(因为读盘的时候IRQL还是DPC/dispatch水平或更高水平上)。如果任意一个规则被违反了,系统会崩溃并产生一个IRQL_NOT_LESS_OR_EQUAL的崩溃代码。违反这些规则是设备驱动程序的一个常见bug。