调试笔记:双误异常导致的蓝屏(一)
网球里有个术语叫双误,英文叫double fault。简单说,就是发球失误了一次之后,第二次发还失误。一次失误可以理解,第二次还失误,那么只好不客气了。类似的,在IA-32 CPU中,也有个双误,double fault,是一种异常,向量号是8,属于终止类(Abort)异常,一旦发生,问题就很严重。
老雷不会网球,因此下面讨论的都是计算机中的双误。
简单来说,双误就是发生了一个比较严重的异常后,还没处理完,就又发生了第二个比较严重的异常。用句成语,就是一波未平一波又起。为什么要在异常前面加上一个“比较严重”的限定呢?因为对于不严重的异常,那么CPU是可以分而治之,逐一把它们搞定的,只有当CPU无法逐一处理时,它才会抛出双误异常,意思是,“这他妈的也太糟糕了,按下葫芦起来瓢,老子不干了”。
那么到底哪些异常比较严重呢?这在IA-32手册中是有明确定义的。在老雷的《<软件调试>补编》中也有中文的解释,即表C-1和表C-2,摘录如下:
看了这两张表,清楚了吧?举例来说,如果两次都是良性异常,那么没有问题,不会产生双误。但是如果两次都是页错误,那么就双误了。
如果这样解释,还不够深入,那么给大家分析一个真实的例子——双误导致的系统崩溃,蓝屏。
先说明一下,这是一位朋友发过来的真实转储,为了不泄露朋友的“秘密”,以下分析中将故意隐藏掉或者替换一些具体的信息。
打开转储文件,除了司空见惯的目标信息外,WinDBG还给出了一个警告:
WARNING: Process directory table base 0FF2F000 doesn't match CR3 00030000
意思是当前进程的页目录基地址和CR3寄存器的内容不一致,前者是0FF2F000,后者是00030000。
执行一下!process命令,可以看到当前进程是CSRSS,它的页目录基地址是0FF2F000,即:
0: kd> !process
PROCESS 885ba400 SessionId: 0 Cid: 00e0 Peb: 7ffdf000 ParentCid: 00c4
DirBase: 0ff2f000 ObjectTable: 885ba988 TableSize: 25.
Image: csrss.exe
VadRoot 885ba948 Clone 0 Private 55. Modified 573. Locked 0.
...
执行一下r cr3,观察CR3寄存器的值,真的是00030000:
0: kd> r cr3
cr3=00030000
二者真的不一样。这正常么?不正常,CR3寄存器就是用来存放当前进程的页目录基地址的,因此,正常情况下,二者应该是一致的。现在不一致了,说明系统已经切换了CR3寄存器的内容,但是还没有切换当前进程。
事实上,这个不正常情况恰恰是双误异常的典型症状。直接了当的说,在发生双误异常后,CPU通过硬件实现的任务切换机制,切换到了一个新的专门用来处理双误异常的线程,这个线程开始执行后,便发起蓝屏了,操作系统没有机会来切换当前进程,所以当前进程还是旧的。
新的线程是在系统进程中执行的,因此,CR3中包含的应该是系统进程的页目录基地址,不信你看:
0: kd> !process 8 1
Searching for Process with Cid == 8
PROCESS 890598a0 SessionId: 0 Cid: 0008 Peb: 00000000 ParentCid: 0000
DirBase: 00030000 ObjectTable: 89095308 TableSize: 55.
Image: System
重复一遍,当双重发生后,CPU切换把CR3寄存器切换到00030000,也就是系统进程所使用的页目录基地址,然后在新的线程中开始执行处理双误的代码,让系统崩溃和保存转储信息。因为是硬件做的线程切换,所以操作系统没有来得及更新当前进程。
顺便说下,当前进程信息是如何保存的呢?首先,在CPU的控制区(即FS寄存器所指向的段)中保存着每个CPU的当前线程结构(KTHREAD);在线程结构中,有这个线程所属进程的进程结构,即EPROCESS。
举例来说,PsGetCurrentProcessId函数就是用类似的方法找到进程ID的:
0: kd> u nt!PsGetCurrentProcessId
nt!PsGetCurrentProcessId:
80455ab4 64a124010000 mov eax,dword ptr fs:[00000124h]
80455aba 8b80e0010000 mov eax,dword ptr [eax+1E0h]
80455ac0 c3 ret
0: kd> dd fs:[00000124h] l1
0030:00000124 885bbda0
0: kd> dd 885bbda0+44 l1
885bbde4 885ba400
其中885ba400,正是CSRSS的进程结构。
现在,大家应该明白了那个警告的含义。
接下来该如何办呢?看一下栈回溯吧。
0: kd> kvn
# ChildEBP RetAddr Args to Child
00 00000000 aebc6af2 f000e2c3 f000eef3 f000eef3 nt!KiTrap08+0x41 (FPO: TSS 28:0)
WARNING: Stack unwind information not available. Following frames may be wrong.
01 f655d7dc 00000000 f655d7ec f655e09c 4d4c4b48 HAIBAO+0x3af2
#号栈帧显示KiTrap08在执行,它就是负责处理双误异常的内核函数,也是CPU通过硬件机制切换到新线程时所用的线程入口函数。从它的当前执行点反向汇编:
0: kd> ub nt!KiTrap08+0x41
nt!KiTrap08+0x2a:
80468382 812424ffbfffff and dword ptr [esp],0FFFFBFFFh
80468389 9d popfd
8046838a 6a00 push 0
8046838c 6a00 push 0
8046838e 6a00 push 0
80468390 6a08 push 8
80468392 6a7f push 7Fh
80468394 e8d928fcff call nt!KeBugCheckEx (8042ac72)
可以看到,这一点其实就是调用蓝屏函数的call指令的下一条指令。
接下来需要注意#0号栈帧后面括号里的信息:
(FPO: TSS 28:0)
这就是所谓的任务门(Task Gate)信息。啥叫任务门,简单说是登记在IDT表中的一种特殊入口,CPU可以根据这个门所指向的任务状态段(TSS)来切换到指定的任务(线程)。比如,CPU在发生双误时,就是通过IDT表中8号表项处的任务门,来切换到处理双误异常的新线程的。
观察IDT表的8号表项,或者通过!pcr命令得到当前线程(即双误线程)的TSS,然后观察其内容:
0: kd> dt _KTSS 80474850
nt!_KTSS
+0x000 Backlink : 0x28
+0x002 Reserved0 : 0
+0x004 Esp0 : 0x80471850
+0x008 Ss0 : 0x10
+0x00a Reserved1 : 0
+0x00c NotUsed1 : [4] 0
+0x01c CR3 : 0x30000
+0x020 Eip : 0x80468358
+0x024 NotUsed2 : [9] 0
+0x048 Es : 0x23
+0x04a Reserved2 : 0
+0x04c Cs : 8
+0x04e Reserved3 : 0
+0x050 Ss : 0x10
+0x052 Reserved4 : 0
+0x054 Ds : 0x23
+0x056 Reserved5 : 0
+0x058 Fs : 0x30
+0x05a Reserved6 : 0
+0x05c Gs : 0
+0x05e Reserved7 : 0
+0x060 LDT : 0
+0x062 Reserved8 : 0
+0x064 Flags : 0
+0x066 IoMapBase : 0x20ac
+0x068 IoMaps : [1] _KiIoAccessMap
+0x208c IntDirectionMap : [32] ""
可以看到其CR3寄存器的内容就是0x30000。另外注意它的Backlink字段,这个字段代表着CPU所执行的前一个任务,或者说是从哪个线程切换到当前线程的。在IA-32手册里,这个地段被称为Previous task link field。其内容是前一个任务的TSS段的段选择子。
说到这,大家该知道,上面的(FPO: TSS 28:0)信息的含义和来历了。其含义是,前一个线程的TSS段选择子是0x28,数据的来源是当前线程的TSS段中的Previous task link field字段。
有了这个信息后,我们就可以使用.tss命令轻松的切换到前一个线程了。也就是出问题的那个线程,这对于找到这次崩溃的根源有着决定性的意义,因为那里有导致崩溃的第一现场。切换过去的情况怎么样呢?老雷要吃晚饭了,今天就写到这里,下一次接着写!