举步维艰——如何调试显示器点亮前的故障

举步维艰——如何调试显示器点亮前的故障举步维艰 如何调试显示器点亮前的故障显示器是个人计算机 PC 系统中必不可少的输出设备 它是计算机向用户传递信息的首要媒介

大家好,欢迎来到IT知识分享网。

转载: 举步维艰——如何调试显示器点亮前的故障

举步维艰——如何调试显示器点亮前的故障

显示器是个人计算机(PC)系统中必不可少的输出设备,它是计算机向用户传递信息的首要媒介。用户也正是通过显示器来观察计算机所作的“工作”,与其交流。离开了显示器,我们便很难知道计算机在干什么。因为这个原因,在计算机系统启动的早期,要做的一个重要任务就是初始化显示系统以便可以通过显示器输出信息,俗称点亮显示器。

对于今天的大多数个人计算机,从用户按下电源按钮到显示器被点亮通常在一秒钟左右。对人类而言,这是一个稍纵即逝的时间。但对计算机系统和CPU而言,这一秒钟要完成很多任务。如果中间遇到障碍,那么便可能停滞不前,出现显示器迟迟没有被点亮的现象。今天我们就由浅入深的谈一谈遇到这种情况时该如何处理。考虑到笔记本系统的差异性较大,我们将以典型的台式机系统(即所谓的IBM兼容PC)为例。为了辅助记忆,我们不妨套用一下我国中医使用的“望闻问切”方法。

望——不要闹笑话

首先,应该“望一望”主机和显示器的电源是否都插上了,它们的指示灯是否正常,它们之间的连线是不是连接妥当。这样做的目的是在“大动干戈”之前做好基本的检查,防止费了很多力气最终才发现是插头松了这样的低级问题,闹出笑话。不过这些检查靠常识就足够,没有什么技术含量,我们不去赘述。

闻——听声识原委

中医中的“闻”既包含用耳朵听,也包含用鼻子闻——嗅。这两种途径对我们也都适用。

我们先来谈如何靠听来了解计算机系统的病在哪里。尽管今天的个人计算机主要是靠声卡(或者集成在芯片组中集成音频设备)来播放声音的,但是在个人计算机诞生之初并没有声卡,甚至到了上世纪九十年代初笔者购买电脑时,典型的PC系统仍没有声卡,原因是价格很贵。在声卡出现之前,图1所示的扬声器是PC系统上的主要发声设备。

图1 位于机箱上的PC喇叭

图1中的照片是从一台大约购于2000年的旧电脑中拍摄的。那时声卡设备便比较普及,PC喇叭的用途变得越来越少,为了节约成本,今天的PC系统通常用一个位于主板上的小蜂鸣器(Beeper)来代替PC喇叭(图2),二者虽然外观有很大的不同,但是工作原理是完全一样的。因此我们仍使用统一的名字来称呼它们。

图2 位于主板上的蜂鸣器

对程序员来说,使用PC喇叭的方法非常简单,只要将I/O端口0x61的最低两位都写为1便可以让PC喇叭开始鸣叫;将最低两位中的某一位置为0便可以让它停止鸣叫。通过一个小实验可以很方便的感受一下。使用WinDBG启动一个本地内核调试会话,然后使用端口输出命令来读写0x61端口,这样便可以开关PC喇叭。具体来讲,首先使用ib命令读出端口0x61的当前内容:

lkd> ib 0x61

00000061: 30

然后,把读到值的低三位置为1,使用ob命令输出(执行前做好心理准备,叫声可能很刺耳):

lkd> ob 0x61 30|3

此时读取这个端口的内容,可以看到端口值的低两位都为1。听得不耐烦了吧,那么赶紧执行下面的命令将其停止:

lkd> ob 0x61 30

事实上,端口0x61的位0的含义是启用PC系统中的可编程时钟计数器(Programmable Interval-Timer/Counter,通常称为8253/8254)的2号通道(共有三个,分别为0、1和2)使其输出一定频率的方波脉冲,刚才听到的鸣叫声正是这个方波输出给PC喇叭而发出的。端口0x61的位1相当于给时钟控制器的输出加一个开关,或者说加了个与门(图3)。

图3 PC喇叭的工作原理示意图

时钟控制器的输出频率是可以改变的,以变换的频率来驱动PC喇叭可以使其发出一些简单的“旋律”。

因为大多数PC系统都有PC喇叭,而且对软件来说,使用PC喇叭输出声音非常简单,所以计算机系统的设计者们很自然地想到了在PC启动早期使用蜂鸣器来报告系统遇到的错误情况。虽然播放复杂的声音很困难,但是可以使用蜂鸣的次数或者每次蜂鸣的长短不同来代表不同的含义。因为这种错误信息是通过PC喇叭以鸣叫的方式报告的,所以通常称为蜂鸣代码(Beep Code)。

当按下PC机的电源按钮后,首先运行的是固化在系统主板上的固件程序(firmware),通常称为POST程序,POST是Power On Self Testing的缩写,含义是上电自检。不同的POST程序(固件),定义蜂鸣代码的方式也有所不同。表1中列出的是英特尔主板通常使用的蜂鸣代码。

表1 英特尔主板所使用的蜂鸣代码

蜂鸣代码

含义

鸣叫1声

DRAM刷新失败

鸣叫2声

校验电路失败

鸣叫3声

基础64K内存失败,可能是没有插内存条或者内存条松动

鸣叫4声

系统时钟失败

鸣叫5声

处理器(CPU)失败

鸣叫6声

键盘控制器失败

鸣叫7声

CPU产生异常

鸣叫8声

显卡不存在,或者显卡上的显存失败

鸣叫9声

固件存储器中内容的校验和与固件中记录的不一样

鸣叫10声

读写CMOS中的数据失败或者其内容有误

鸣叫11声

高速缓存失败

通常,在固件厂商的网站或者产品手册中可以查找到蜂鸣代码的含义。例如通过以下链接可以访问到英特尔主板/固件的蜂鸣代码含义:http://www.intel.com/support/motherboards/desktop/sb/cs-010249.htm

在以下网页中列出了其它几种常见固件的蜂鸣代码定义:http://www.computerhope.com/beep.htm

关于PC喇叭,还有两点需要说明。第一点是,有些固件在正常完成基本的启动动作后会鸣叫一声,这并不是报告错误,而是报告好消息。第二点是台式机的PC喇叭通常是不受静音控制的,而笔记本电脑的PC喇叭是受静音控制的,因此在诊断笔记本电脑时应该调整音量按钮取消静音,这样才可能听到蜂鸣代码。

下面再谈一下闻的另一种含义——“嗅”,也就是闻味道。当出现显示器无法点亮这样的故障时,确实可能是某些硬件损坏了,比如电容被击穿和短路等。因此在每次开机和调试时,不妨用鼻子闻一闻,如果闻到烧焦味道,那么应该立刻切断电源;如果有其它事情需要离开,那么也该先给系统断电。

问——黑暗中交谈

在显示器被点亮前,系统通常还不能接收键盘和鼠标输入,这时该如何询问它呢?一种简单的方法是改变系统的配置或者调换系统的部件,然后通过聆听蜂鸣代码或者观察它的其它反应来感知计算机的“回答”,以便收集更多的信息。举例来说,有一个故障系统,按下电源后很久,显示器仍不亮,也听不到任何蜂鸣声音。这时,可以先切断电源,拔下所有内存条,然后再上电开机,如果听到三声鸣叫,那么便说明系统已经执行到内存检查部分,这可以初步证明CPU是正常的,系统的主板也是可以工作的。

切——接收自举码

中医中的切是指把脉,也就是通过感受患者的脉搏来了解健康状况。那么如何能感受计算机系统的脉搏并从中提取出它的生命信息呢?PC系统的开拓者们真的设计出了一种方法。简单来说,就是将一种名为“上电自检卡(POST Card)”的标准PC卡插到系统的扩展槽中,让这块卡“切”入到目标系统中来监听系统总线上的活动,接收上面的数据。上电自检卡通常是PCI接口的,也有ISA接口的。图4中的照片便是一个PCI接口的上电自检卡。

图4 PCI接口的上电自检卡

为了支持调试,POST程序在执行的过程中,会将代表一定含义的POST代码发送到0x80端口。系统硬件会将发送到这个端口的数据发送到PCI总线上,于是上电自检卡便可以从总线上读取到POST代码,然后显示出来。POST程序会使用不同的POST代码代表不同的含义,有些代表错误号,有些代表进展到了哪个阶段。通常可以在产品的技术文档中查找到POST代码的含义,然后根据这个含义来了解故障的原因。

透视和跟踪

使用上面介绍的四类方法,通常可以定位出导致故障的部件或者粗略的原因,对于普通的测试或者维修目的,做到这一步也就可以满足要求了。那么对于需要修正故障或者想深入研究的开发人员该如何进一步分析出精确的故障位置呢?如果是软件错误,那么能不能分析出是哪段程序或者哪条指令出错了呢?

要做到这一点,比较有效的方法是使用调试器。因为这个时候系统还在初始化阶段,纯软件的调试器还不能工作,所以这时需要硬件调试器,也就是《软件调试》第7章介绍的基于JTAG技术的ITP/XDP调试器或者同类的硬件工具。

使用硬件调试器可以单步跟踪执行POST程序;可以设置断点,包括代码断点(执行到指定地址的代码时中断)、内存访问断点(访问指定的内存地址时中断)和IO访问断点(访问指定的IO地址时中断);也可以在发生重要事件时中断下来,比如进入或者退出系统管理模式(SMM)时中断、进入或者退出低功耗状态时中断、或者系统复位后立刻中断等。举例来说,在将系统复位事件的处理方式设置为中断(break)并重启系统后,CPU复位后一开始执行便会中断到调试器中。观察此时的寄存器值(图5),代码段寄存器cs=f000,程序指针寄存器eip=0000fff0。因为这时CPU工作在实模式下,所以目前要执行代码的物理地址是0xf000 x 16 + 0xfff0 = 0xffff0,这正是PC标准中定义的CPU复位后开始执行的程序地址,PC系统的硬件保证这个地址会指向位于主板上的POST程序。因此可以毫不夸张的说,以这种方式中断到调试器中可以得到“最早的”调试机会,从CPU复位后执行的第一条指令开始跟踪调试。

图5 CPU复位后的寄存器值

接下来,使用断点功能对端口0x80设置一个IO断点,然后恢复CPU执行:

[P0]>go

结果,这个断点很快便命中了,调试器显示:

[ Debug Register break at 0x0010:00000000fffffeca in task 00000 ]

[[P1] BreakAll break at 0xf000:0000000000000000 ]

因为系统中的CPU是双核的,所以第1行显示的是0号CPU(P0)的中断位置,第2行显示的是1号CPU的中断位置,其程序指针寄存器的值为0,还没有开始工作。使用反汇编指令可以观察断点附近的指令:

[P0]>asm $-2 length 10

0x0010:00000000fffffec8 e680 out 0x80, al

0x0010:00000000fffffeca e971f9ffff jmp $-0x0000068a ;a=fffff840

0x0010:00000000fffffecf e4ff push 0xffe40000

0x0010:00000000fffffed4 68c4faffff push 0xfffffac4

可见,中断前执行的正是向0x80号端口输出的指令。观察一下al寄存器,它的值为1,看一下上电自检卡上显示的数字也是1,正好吻合。

归纳

今天,我们介绍了一种比较特殊的调试任务。之所以介绍这个内容,除了让大家了解上面介绍的调试方法外,还有两个目的。一是学习PC系统的设计者们以不同方式支持调试的聪明才智和重视调试的职业精神,他们在打印信息或者显示文字等方法不可行的情况下,设计出了蜂鸣代码和POST代码这样的调试机制,这些机制看似简陋,但是却可以传递出来自系统第一线的直接信息,实践证明这些信息可以大大提高调试的效率。二是希望能提高大家对计算机硬件和整个系统的兴趣,程序员的主要目标是编写软件,但是对硬件和系统的深刻理解对于程序员的长远发展是有非常有意义的。

下一期的问题:

一台安装Windows的计算机系统开机后显示因为系统文件丢失而无法进入系统,对于这样的问题有哪些方法来调试和解决?

权利移交——如何调试引导过程中的故障

上一期我们讨论了如何调试显示器点亮前的故障,在文章中我们提到,CPU复位(Reset)后,首先执行的是固化在主板上的POST程序(图1)。POST程序的核心任务是检测系统中的硬件设备,并对它们做基本的检查和初始化,并根据需要给它们分配系统资源(中断、内存和IO空间等)。POST程序成功执行后,系统接下来要做的一个重要任务便是寻找和加载操作系统(OS)。对于不同的计算机系统和不同的使用需求,需要加载的操作系统可能位于不同的地点。最常见的情况是操作系统位于硬盘(Hard Disk)上,但是也可能位于光盘、优盘、软盘或者网络上。

图1 计算机的启动过程

通常把寻找和加载操作系统的过程叫做引导(Boot或者Bootstrap),也就是图一中的黄色方框。本期我们就谈谈引导有关的问题,介绍如何分析和调试的这个过程中可能发生的故障。

BBS——BIOS引导规约

考虑到引导过程涉及到来自不同厂商生产的不同部件之间的协作,因此需要一个标准来定义每个部件的职责和各个部件之间交互的的方法,在这种背景下,英特尔、Phoenix和康柏公司在1996年联合发布了BIOS引导规约(BIOS Boot Specification),简称BBS(图2)。尽管十几年已经过去了,但是这个规约中的大多数内容至今仍被使用着。本文中使用的很多术语和数据结构都来自这个规约。在互联网上搜索BIOS Boot Specification,可以下载到BSS的电子版本。

图2 BIOS引导规约

IPL表格

BBS把系统中可以引导和加载OS的设备成为初始程序加载设备(Initial Program Load Device),简称IPL设备。BIOS会在内存中维护一个IPL表格,每一行(表项)描述一个IPL设备。表1列出了IPL表的各个列(表项的字段)的用途和详细情况。

表1 IPL表项的各个字段

名称

偏移

长度(字节)

描述

deviceType

00h

2

设备标号,参见下文

statusFlags

02h

2

状态标志

bootHandler

04h

4

发起引导的代码的地址

descString

08h

4

指向一个以零结束的ASC字符串

expansion

0ch

4

保留,等于0

其中的deviceType字段用来记录代表引导设备编号的数字,01h代表软盘,02h代表硬盘,03h代表光盘,04h代表PCMCIA设备,05h代表USB设备,06h代表网络,07h..7fh和81..feh保留,80h代表以BEV方式启动的设备(我们稍后详细讨论)。接下来的statusFlags字段用来记录它所描述的引导设备的状态信息,使用不同的二进制位代表不同的状态,图2画出了各个位域,Old Position位域(bit 3..0)代表上次引导时这个表项在IPL表中的索引,Enabled位域(位8)用来启用(1)或禁止(0)这个表项,Failed位域(位9)为1代表已经尝试过使用该表项而且得到了失败的结果,Media Present位域(位11..10)的典型用途是描述驱动器是否有可引导的媒介(光盘、磁盘),0代表没有,1代表未知,2代表有媒介,而且看起来可以引导。

图3 IPL表的状态标志字段(statusFlag)的位定义

从编程的角度来看,可以使用下面的数据结构来描述IPL表的表项:

struct ipl_entry {

Bit16u deviceType;

Bit16u statusFlags;

Bit32u bootHandler;

Bit32u descString;

Bit32u expansion;

};

在EFI(Extensible Firmware Interface)中,使用BBS_TABLE结构来描述IPL设备,希望了解其具体定义的读者可以到http://www.openefi.org/下载详细文档。

引导设备分类

BBS将引导设备划分为以下三种类型:

n BAID – 即BIOS知道的IPL设备(BIOS Aware IPL Device),也就是说BIOS中已经为这样的设备准备了支持引导的代码。第一个软驱、第一个硬盘、ATAPI接口的光驱等都属于这一类型。

n 传统设备 – 是指带有Option ROM(见下文)但没有PnP扩展头的标准ISA设备。例如已经过时的通过ISA卡连接到系统中的SCSI硬盘控制器。

n PnP设备 – 是指符合PnP BIOS规约(Plug and Play BIOS Specification)的即插即用设备。

因为第二类设备已经很少见,所以我们重点介绍一下从另两类设备引导的方法。

从即插即用(PnP)设备引导

这需要先了解什么是Option ROM。所谓Option ROM就是在位于PCI或者ISA设备上的只读存储器,因为这个存储器不是总线标准规定一定要实现的,所以叫Option ROM(可选实现的ROM)。Option ROM里面通常存放着用于初始化该设备的数据和代码。显卡和网卡等设备上通常带有Option ROM。

PnP BIOS规约详细定义了Option ROM的格式。简单来说,在它的开始处,总是一个固定结构的头结构,称为PnP Option ROM Header,为了行文方便,我们将其简称为PORH。在PORH的偏移18h和1Ah处可以指向另外两个结构,分别称为PCI数据结构和PnP扩展头结构(PnP Expansion Header),我们将其简称为PEH。PEH中有一个起到链表作用的Next字段(偏移06h,长度为WORD)用来描述下一个扩展结构的偏移。

图4 PnP设备Option ROM中的头结构

首先,所有的Option ROM的头两个字节都是0xAA55,因此在调试时可以通过这个签名来搜索或者辨别Option ROM的头结构。另外,PC标准规定,0xC0000到0xEFFFF这段物理内存地址空间是供Option ROM使用的。

在图4中,以黄颜色标出的向量字段与引导有着比较密切的关系,下面分别作简单介绍:

n 初始化向量 – 系统固件在引导前会通过远调用执行这个地址所指向的代码,这就是通常所说的执行Option ROM。Option ROM得到执行后,除了做初始化工作外,如果该设备希望支持引导,那么可以通过改写(Hook)系统的INT 13h(用于读写磁盘的软中断)和输入设备来实现,上面提到过的传统SCSI硬盘就是这样做的。对于PnP设备,应该使用下面的BCV或者BEV方法。

n 引导连接向量(Boot Connect Vector) – 这个向量可以指向Option ROM中的一段代码(通过相对于Option ROM起始处的偏移),当这段代码被BIOS调用后,它可以根据需要改写(Hook)INT 13h。

n 引导入口向量(Boot Entry Vector) – 用来指向可以加载操作系统的代码的入口,当系统准备从这个设备引导时,那么会执行这个向量所指向的代码。下面介绍的从网卡通过PXE方式启动就是使用的这种方法。

图5所示的是网卡设备的Option ROM内容,第1列是内存物理地址,后面四列是这第一地址起始的16字节数据(以DWORD格式显示,每4字节一组)。图中第一个黄颜色方框包围起来的32字节是PORH结构,它的0x1A偏移处的值0x60代表的是PEH结构的偏移,因此下面的方框包围起来的便是这个扩展结构。

图5 观察PnP设备的引导入口向量

根据图4,在PEH结构的0x1A处的两个字节便是BEV向量,也就是0x0c04。因此,当在BIOS中选择从这个网卡引导时,BIOS在做好引导准备工作后,便会通过远调用来执行0xcb00:0c04处的代码。在调试时,如果对这个地址设置断点,那么便会命中。

INT 19h和INT 18h

BBS还定义了两个软中断来支持引导,它们分别是发起引导的INT 19h和使用某一设备引导失败后恢复重新引导的INT 18h。

下面列出的是BBS中给出的INT 19h的伪代码。

IPLcount = current number of BAIDs and BEV devices at this boot.

FOR (i = 0; i < IPLcount; ++i)

currentIPL = IPL Priority[i].

Use currentIPL to index the IPL Table entry.

Do a far call to the entry’s boot handler or BEV.

IF (control returns via RETF, or an INT 18h)

Clean up the stack if necessary.

ENDIF

Execute an INT 18h instruction.

其中,第5行的远调用便是把执行权交给了用于引导当前IPL设备的过程,如果这个调用成功,那么便永远不会返回。

下面是INT 18h的伪代码。

Reset stack.

IF (all IPL devices have been attempted)

Print an error message that no O/S was found.

Wait for a key stroke.

Execute the INT 19h instruction.

ELSE

Determine which IPL device failed to boot.

Jump to a label in the INT 19h handler to try the next IPL device.

ENDIF

需要说明的是,上面的伪代码完全是示意性的,实际的BIOS实现会更复杂而且可能有所不同。

使用Bochs调试引导过程

除了可以使用ITP这样的硬件调试器来调试引导过程外,某些情况下,也可以使用虚拟机来调试。具体来说就是把要调试的固件(BIOS或者EFI)文件配置到虚拟机中,然后利用虚拟机管理软件的调试功能来调试。例如,Bochs虚拟机便具有这样的功能。Bochs目前是一个开源的项目,可以从它的网站http://bochs.sourceforge.net/上下载安装文件和源代码。

图6中的屏幕截图便是使用Bochs调试的场景,大的窗口是虚拟机,重叠在大窗口上的小窗口是Bochs的控制台,在里面可以输入各种调试命令。图中显示的是设置在INT 19h入口处(0xf000:e6f2)的断点命中时的状态。

图6 使用Bochs调试引导过程

使用xp 0x19*4可以显示中断向量表中INT 19h所对应的内容,即0xf000e6f2,其中高16位是段地址,低16位是偏移。值得说明的是,大多数BIOS中的INT 19h的入口地址都与此相同。知道了地址后,就可以使用pb 0xfe6f2来设置断点,其中0xfe6f2是0xf000:e6f2这个实模式地址对应的物理地址,其换算方法是把0xf000左移4个二进制位(相当于在十六进制数的末尾加一个0),然后加上偏移。

顺便说一下,在Bochs项目中,实现了一个简单的BIOS,其主要代码都位于rombios.c文件,通过下面的链接可以访问到这个文件:http://bochs.sourceforge.net/cgi-bin/lxr/source/bios/rombios.c 想学习BIOS的读者,可以仔细读一下这个文件,这是深刻理解BIOS的很有效方法。

0x7c00——新的起点

对于大多数时候使用从BAID设备引导,BIOS中的支持函数会从设备(磁盘)的约定位置读取引导扇区,存放到内存中0x0000:7c00这个位置,然后把控制权转交过去。转交时会通过DL寄存器传递一个参数,这个参数用来指定磁盘号码,00代表A盘,0x80代表C盘。接下来的引导代码在通过INT 13h来访问磁盘时,应该使用这个参数来指定要访问的磁盘。

因为从磁盘引导时,BIOS一定会把控制权移交到0x7c00这个地址,所以在调试时可以在这个位置设置断点,开始分析和跟踪。表2列出了其它一些固定的BIOS入口地址。

表2 BIOS兼容入口点

地址

用途

0xf000:e05b

POST入口点

0xf000:e2c3

不可屏蔽中断(NMI)处理函数入口点

0xf000:e3fe

INT 13h硬盘服务入口点

0xf000:e401

硬盘参数表

0xf000:e6f2

INT 19h(引导加载服务)入口点

0xf000:e6f5

配置数据表

0xf000:e739

INT 14h入口点

0xf000:e82e

INT 16h入口点

0xf000:e987

INT 09h入口点

0xf000:ec59

INT 13h软盘服务入口点

0xf000:ef57

INT 0Eh(Diskette Hardware ISR)入口点

0xf000:efc7

软盘控制器参数表

0xf000:efd2

INT 17h(打印机服务)入口点

0xf000:f065

INT 10h(显示服务)入口点

0xf000:f0a4

MDA/CGA显示参数表 (INT 1Dh)

0xf000:f841

INT 12h(内存大小服务)入口点

0xf000:f84d

INT 11h入口点

0xf000:f859

INT 15h(系统服务)入口点

0xf000:fa6e

低128个字符的图形模式字体

0xf000:fe6e

INT 1Ah(时间服务)入口点

0xf000:fea5

INT 08h(System Timer ISR)入口点

0xf000:fef3

POST用这个值来初始化中断向量表

0xf000:ff53

只包含IRET指令的dummy中断处理过程

0xf000:ff54

INT 05h(屏幕打印服务)的入口点

0xf000:fff0

CPU复位后的执行起点

0xf000:fff5

构建日期,按MM/DD/YY格式,共8个字符

0xf000:fffe

系统型号

另外,地址0x0040:0000开始的257个字节是所谓的BIOS数据区(BIOS Data Area),简称BDA,里面按固定格式存放了BIOS向后面的引导程序和操作系统移交的信息。

下一期的问题:

一台PC系统开机后显示Windows could not start because of a general computer hardware configuration problem.,对于这样的问题有哪些方法来调试和解决?(注:上期的问题留到下一期给出答案)

步步为营——如何调试操作系统加载阶段的故障

上一期我们介绍了系统固件(BIOS)寻找不同类型的引导设备的方法,描述了固件向引导设备移交执行权的过程。对于从硬盘引导,首先接受控制权的是位于硬盘的0面0道0扇区中的主引导记录(Main Boot Record),简称MBR。MBR一共有512个字节,起始处为长度不超过446字节的代码,然后是64个字节长的分区表,最后两个字节固定是0x55和oxAA。MBR中的代码会在分区表中寻找活动的分区,找到后,它会使用INT 13h将活动分区的引导扇区(Boot Sector)加载到内存中,加载成功后,将执行权移交过去。按照惯例,引导扇区也应该被加载到0x7C00这个内存位置,所以MBR代码通常会先把自己复制到0x600开始的512个字节,以便给引导扇区腾出位置。也正是因为这个原因,当使用虚拟机或者ITP调试时,如果在0x7C00处设置断点,那么这个断点通常会命中两次。引导扇区的内容是和操作系统相关的,在安装操作系统时,操作系统的安装程序会设置好引导扇区的内容。引导的职责通常是加载操作系统的加载程序(OS Loader)。OS Loader得到控制权后,再进一步加载操作系统的内核和其它程序。本期我们就以Windows Vista操作系统为例谈一谈OS Loader的工作过程以及如何调试这一阶段的问题。

切换工作模式

我们知道,对于x86 CPU来说,不管它是否支持32位或64位,在它复位后都是处于16位的实地址模式。在BIOS阶段,CPU可能被切换到保护模式,但是在BIOS把控制权移交给主引导记录前,它必须将CPU恢复回实模式,这是一直保持下来的传统。对于使用EFI固件的系统,固件可以在保护模式下把控制权移交给操作系统的加载程序。但本文仍旧讨论传统的方式。

因为实模式下的每个段最大只有64K,而且只能直接访问1MB的内存,这个空间是无法容纳今天的主流操作系统的核心文件的,所以OS Loader首先要做的一件事就是把CPU切换到可以访问更大空间的保护模式。

在切换到保护模式前,应该先建立好全局描述符表(GDT)和中断描述符表(IDT)。通常在OS Loader阶段不会开启CPU的分页机制(Paging),而且描述符表中的每个段的基地址通常都设置为0,界限设置为0xFFFFFFFF,这样便可以在程序中自由访问4GB的地址空间,而且线性地址的值就等于物理地址的值,这里使用内存空间的方法就是所谓的平坦模式(Flat Model)。以Windows Vista操作系统为例,它的引导管理器程序BootMgr.EXE内部既有16位代码又有32位代码,16位代码先执行,在验证文件的完好后,会切换到保护模式,并把内嵌的32位程序映射到0x开始的内存区,然后把控制权移交给32位代码的起始函数BmMain。此时观察CR0寄存器,可以看到代表保护模式的位0已经为1。

kd> r cr0

cr0=00000013

但是代表分页机制的位31为0,说明没有启用分页。观察代码段和数据段的段描述符:

kd> dg cs

P Si Gr Pr Lo

Sel Base Limit Type l ze an es ng Flags

—- ——– ——– ———- – — — — — ——–

0020 00000000 ffffffff Code RE Ac 0 Bg Pg P Nl 00000c9b

kd> dg ds

P Si Gr Pr Lo

Sel Base Limit Type l ze an es ng Flags

—- ——– ——– ———- – — — — — ——–

0030 00000000 ffffffff Data RW Ac 0 Bg Pg P Nl 00000c93

可见,它们的基地址都是0,边界都是0xFFFFFFFF,这正是平坦模式的典型特征。分别使用dd命令和!dd(观察物理地址)观察同一个地址值:

kd> dd idtr l4

0001f080 00 00008f00 002073b0 00448e00

kd> !dd idtr l4

# 1f080 00 00008f00 002073b0 00448e00

显示的内容是一样的,这说明线性地址与它所对应的物理地址的值是相等的。

休眠(Hibernation)支持

在执行BlImgQueryCodeIntegrityBootOptions函数和BmFwVerifySelfIntegrity函数对自身的完整性做进一步检查后,BootMgr会调用BmResumeFromHibernate检查是否需要从休眠(Hibernation)中恢复,如果需要,那么它会加载WinResume.exe,并把控制权移交给它。

显示启动菜单

BootMgr会从系统的引导配置数据(Boot Configuration Data,简称BCD)中读取启动设置信息,如果有多个启动选项,那么它会显示出启动菜单。清单1中的栈回溯显示的便是BootMgr在显示启动菜单后等待用户选择时的状态。

清单1 等待用户选择启动项

kd> kn

# ChildEBP RetAddr

00 00061e34 00 bootmgr!DbgBreakPoint

01 00061e44 00431c24 bootmgr!BlXmlConsole::getInput+0xe

02 00061e90 00402e8f bootmgr!OsxmlBrowser::browse+0xe0

03 00061e98 00402b5e bootmgr!BmDisplayGetBootMenuStatus+0x13

04 00061f10 004017ce bootmgr!BmDisplayBootMenu+0x174

05 00061f6c 00 bootmgr!BmpGetSelectedBootEntry+0xf8

06 00061ff0 00020a9a bootmgr!BmMain+0x278

WARNING: Frame IP not in any known module. Following frames may be wrong.

07 00000000 f000ff53 0x20a9a

08 00000000 00000000 0xf000ff53

栈帧6中的BmMain便是BootMgr的32位代码的入口函数,栈帧4中的BmDisplayBootMenu是显示启动菜单的函数,栈帧7和8是在实模式中执行时的痕迹。

执行用户选择的启动项

当用户选择一个启动选项后,BootMgr会调用 函数来准备引导对应的操作系统。如果系统上有Windows XP或者更老的Windows,而且用户选择了这些项,那么BootMgr会加载NTLDR来启动它们。如果用户选择的是Windows Vista的启动项,那么BootMgr会寻找和加载WinLoad.exe,如果没有找到或者在检查文件的完整性时发现问题,那么BootMgr会显示出图1所示的错误界面。

在成功加载WinLoad.exe后,BootMgr会为其做一系列其它准备,包括启用新的GDT和IDT,然后调用平台相关的控制权移交函数把执行权移交给WinLoad。在x86平台中,完成这一任务的是Archx86TransferTo32BitApplicationAsm函数。至此,BootMgr完成使命,WinLoad开始工作。

加载系统核心文件

WinLoad的主要任务是把操作系统内核加载到内存,并为它做好“登基”的准备。它首先要做的一件事就是进一步改善运行环境,启用CPU的分页机制。然后初始化自己的支持库,如果启用了引导调试支持(稍后介绍),那么它会初始化调试引擎。

图1 加载WinLoad.exe失败时的错误提示

接下来WinLoad会读取启动参数,决定是否显示高级启动菜单,高级菜单中含有以安全模式启动等选项,也叫Windows Error Recovery菜单。如果用户按了F8或者上次没有正常关机,那么WinLoad便会显示高级启动菜单。

接下来要做的一个重要工作是读取和加载注册表的System Hive,因为其中包含了更多的系统运行参数,负责这项工作的是OslpLoadSystemHive函数。

做好以上工作后,WinLoad开始它的核心任务,那就是加载操作系统的内核文件和引导类型的设备驱动程序。它首先加载的是NTOSKRNL.EXE,这个文件包含了Windows操作系统的内核和执行体。此时真正的磁盘和文件系统驱动程序还没有加载进来,所以WinLoad是使用它自己的文件访问函数来读取文件的。例如,FileIoOpen函数便是用来打开一个文件的,

如果FileIoOpen 打开文件失败,那么调用它的BlpFileOpen 函数会返回错误码0C000000Dh,否则返回0代表成功。

其中,PSHED.DLL用于支持WHEA(Windows Hardware Error Architecture)(《软件调试》第17章有详细介绍),HAL.DLL是硬件抽象层模块,BOOTVID.DLL用于引导期间和发生蓝屏时的显示,KDCOM.DLL用于支持内核调试,CLFS.SYS是支持日志的内核模块,CI.DLL是用于检查模块的完整性的(Code Integrity)。

加载好系统模块后,WinLoad还需要加载引导类型(Boot Type)的设备驱动程序,在安装驱动程序时,每个驱动程序都会指定启动类型(Start Type),这个设置决定了驱动程序的加载时机,指定为引导类型的驱动程序是最先被加载的。

接下来加载的是硬件抽象层模块HAL.DLL,支持调试的KDCOM.DLL和它们的依赖模块。使用Depends工具可以观察一个PE模块所依赖的其它模块,例如,图2显示出了内核文件NTOSKRNL.EXE所依赖的其它模块。

图2 使用DEPENDS工具观察NTOSKRNL.EXE所依赖的其它模块

如果在加载以上程序模块或者注册表的过程中找不到需要的文件或者在检查文件的完整性时发现异常,那么WinLoad便会提示错误而停止继续加载,我们在08年第11期中提到的问题便是与此有关的。当遇到这样的问题时,可以使用安装光盘引导,然后恢复丢失或者被破坏的文件。

完成模块加载后,WinLoad开始准备把执行权移交给内核,包括为内核准备新的GDT和IDT(OslArchpKernelSetupPhase0)和建立内存映射(OslBuildKernelMemoryMap)等。所有准备工作做完后,WinLoad调用OslArchTransferToKernel函数把供内核使用的GDT和IDT地址加载到CPU中,然后调用内核的入口函数,正式把控制权移交个内核。

启用调试选项

Windows Vista的BootMgr和WinLoad程序内部都集成了调试引擎,不管是Checked版本还是Free版本,对于Free版本,默认是禁止的,使用时需要开启,具体做法如下:

如果要启用BootMgr中的调试引擎,那么应该在一个具有管理员权限的控制台窗口中执行如下命令:

bcdedit /set {bootmgr} bootdebug on

bcdedit /set {bootmgr} debugtype serial

bcdedit /set {bootmgr} debugport 1

bcdedit /set {bootmgr} baudrate

以上命令是使用串行口作为主机和目标机之间的通信方式,如果使用其它方式,那么应该设置对应的参数。

如果要启用WinLoad程序中的调试引擎,那么应该先找到它所对应的引导项的GUID值,然后执行如下命令:

bcdedit /set {GUID} bootdebug on

启用调试引擎并连接通信电缆后,在主机端运行WinDBG工具,便可以进行调试了,栈回溯、访问内存、访问寄存器等内核调试命令都可以像普通内核调试一样使用。

Windows Vista之前的情况

在Vista之前,NTLDR是Windows操作系统的加载程序。因为只有Checked版本的NTLDR才支持调试,所以如果要调试加载阶段的问题,应该先将NTLDR替换为Checked版本。DDK中通常包含有Checked版本的NTLDR程序。记住,在替换前,应该先去除NTLDR文件的系统、隐藏和只读属性,在更换后,要加上这些属性,否则的话引导扇区中的代码会报告NTLDR is missing错误,无法继续启动。

除了加载内核和引导类型的驱动程序外,NTLDR会调用NTDETECT.COM来做基本的硬件检查并搜集硬件信息。NTDETECT会把搜集到的信息存放到注册表中。如果找不到NTDETECT.COM,那么通常会直接重启,如果NTDETECT发现系统缺少必须的硬件或固件支持,比如ACPI支持,那么会显示因为硬件配置问题而无法启动,也就是我们上一期所提问的问题。对于这样的问题,可以尝试更改BIOS选项来解决,或者通过调试NTLDR来进一步定位错误原因。

恢复缺失文件

可以使用如下方法之一来尝试恢复丢失或者损坏的系统文件:

1. 启动时按F8,调出高级启动菜单,尝试选择Last Known Good Configuration(LKG)。

2. 启动时按F8,在高级启动菜单中选择安全模式(Safe Mode),如果成功启动后,那么可以尝试执行CHKDSK命令检查和修复磁盘,或者从安装光盘中恢复缺失的文件。

3. 使用Windows安装光盘引导,并记入到恢复控制台(Recovery Console)界面。对于Windows XP,在安装程序的主界面中按R键进入文本界面的恢复控制台,进入时输入管理员密码。对于Windows Vista,从安装光盘启动后,可以进入图形界面的系统恢复向导(图3)。如果是MBR或者引导分区损坏,那么Windows XP的恢复控制台中提供了FIXMBR和FIXBOOT命令。而Vista的恢复向导中包含了自动修复功能。

图3 Windows Vista安装光盘上的系统恢复程序

4. 如果系统硬盘的个数或者有所变化,那么可能是因为分区编号变化而导致系统无法找到文件,这时可以考虑恢复旧的磁盘和分区配置,或者启动到恢复控制台来修改系统的启动配置文件,对于Vista,需要修改BCD,对于Vista之前的系统,也就是修改BOOT.INI文件。

对于第11期的问题,天津的黄小非读者给出了非常好的答案,他的来信中给出了多种方法,包括使用控制台,使用Windows Preinstallation Environment(WinPE)以及修改BOOT.INI。其实Vista的恢复界面就是运行在WinPE中的。从黄小非的来信中,我们可以看出他的实践经验很丰富。

下一期的问题:

系统启动后很快出现蓝屏,其中含有STOP 0x0000007B INACCESSABLE_BOOT_DEVICE,哪些原因会导致这样的问题,该如何来解决?

百废待兴——如何调试内核初始化阶段的故障

上一期我们介绍了加载操作系统的过程。简单来说,负责加载操作系统的加载程序(OS Loader)会把系统内核模块、内核模块的依赖模块、以及引导类型的驱动程序加载到内存中,并为内核开始执行准备好基本的执行环境。这些工作做好后,加载程序会把执行权移交给内核模块的入口函数,于是操作系统的内核模块就开始执行了。在今天的软件架构中,操作系统承担着统一管理系统软硬件资源的任务,可以说是整个系统的统帅。内核模块是操作系统的核心部分,像任务调度、中断处理、输入输出等核心功能就是实现在内核模块中的。因此,内核模块开始执行,标志着“漫长的”启动过程进入到了一个新的阶段,系统的统帅走马上任了。虽然前面已经做了很多准备工作,但是对于一个典型的多任务操作系统来说,要建设出一个可以运行各种应用程序的多任务环境来,还有很多事情要做,可谓是百废待兴。本期我们仍以Windows操作系统为例谈一谈系统内核和执行体初始化(简称内核初始化)的过程以及如何调试这一阶段的问题。

入口函数

Windows程序的入口函数地址是登记在可执行文件的头结构中的,也就是IMAGE_OPTIONAL_HEADER结构的AddressOfEntryPoint 字段。内核文件的入口函数也是如此。通过下面几个步骤就可以使用WinDBG观察到内核文件的入口函数。先启动WinDBG,并开始一个本地内核调试对话,使用lm nt命令列出内核文件的基本信息:

lkd> lm a nt

start end module name

804d7000 806cdc80 nt (pdb symbols) d:/symbols/ntkrnlpa.pdb/C…/ntkrnlpa.pdb

其中804d7000就是内核模块在内存中的起始地址。起始处是一个所谓的DOS头:

dt nt!_IMAGE_DOS_HEADER 804d7000

+0x000 e_magic : 0x5a4d

+0x03c e_lfanew : 232

其中e_lfanew字段的值代表的是新的NT类型可执行文件的头结构的起始偏移地址。

lkd> dt nt!_IMAGE_NT_HEADERS 804d7000+0n232

+0x000 Signature : 0x4550

+0x004 FileHeader : _IMAGE_FILE_HEADER

+0x018 OptionalHeader : _IMAGE_OPTIONAL_HEADER

现在可以知道804d7000+0n232+18处便是_IMAGE_OPTIONAL_HEADER结构,于是可以使用下面的命令来显示出AddressOfEntryPoint字段的值:

lkd> dt _IMAGE_OPTIONAL_HEADER -y Add* 804d7000+0n232+18

nt!_IMAGE_OPTIONAL_HEADER

+0x010 AddressOfEntryPoint : 0x1b6f5c

上面显示的AddressOfEntryPoint字段的值0x1b6f5c便代表着内核文件的入口函数在模块中的偏移,加上模块的基地址便可以得到入口函数的线性地址,使用ln命令查找这个地址对应的符号:

lkd> ln 0x1b6f5c+804d7000

(8068df5c) nt!KiSystemStartup | (8068e244) nt!KiSetCR0Bits

Exact matches:

nt!KiSystemStartup = <no type information>

这表明入口地址处的函数名为KiSystemStartup,实际上,它就是NT系统Windows操作系统的内核文件一直使用的入口函数。

上面我们介绍的是使用类型显示命令一步步观察,当然也可以使用扩展命令!dh一下子显示出以上信息:

lkd> !dh 804d7000 -f

File Type: EXECUTABLE IMAGE

1B6F5C address of entry point

当OS Loader(NTLDR或WinLoad)调用KiSystemStartup时,它会将启动选项以一个名为LOADER_PARAMETER_BLOCK的数据结构传递给KiSystemStartup函数。Windows Vista的内核符号文件包含了这个结构的符号,因此在对Windows Vista做内核调试时可以观察到这个结构的详细定义。

内核初始化

KiSystemStartup函数开始执行后,它首先会进一步完善基本的执行环境,包括建立和初始化处理器控制结构(PCR)、建立任务状态段(TSS)、设置用户调用内核服务的MSR寄存器等。在这些基本的准备工作完成后,接下来的过程可以分为图1所示的左右两个部分。左侧为发生在初始的启动进程中的过程,这个初始的进程就是启动后的Idle进程。右侧为发生在系统进程(System)中的所谓的执行体阶段1初始化过程。

图1 Windows启动过程概览

首先我们来看KiSystemStartup函数的执行过程,它所做的主要工作有:

一、调用HalInitializeProcessor()初始化CPU。

二、调用KdInitSystem初始化内核调试引擎,我们稍后将详细介绍这个函数。

三、调用KiInitializeKernel开始内核初始化,这个函数会调用KiInitSystem来初始化系统的全局数据结构,调用KeInitializeProcess创建并初始化Idle进程,调用KeInitializeThread初始化Idle线程。

对于多CPU的系统,每个CPU都会执行KiInitializeKernel函数,但只有第一个CPU会执行其中的所有初始化工作,包括全局性的初始化,其它CPU会只执行CPU相关的部分。比如只有0号CPU会调用和执行KiInitSystem,初始化Idle进程的工作也只有0号CPU执行,因为只需要一个Idle进程,但是因为每个CPU都需要一个Idle线程,所以每个CPU都会执行初始化Idle线程的代码。KiInitializeKernel函数使用参数来了解当前的CPU号。全局变量KeNumberProcessors标志着系统中的CPU个数,其初始值为0,因此当0号CPU执行KiSystemStartup函数时,KeNumberProcessors的值刚好是当前的CPU号。当第二个CPU开始运行时,这个全局变量会被递增1,因此KiSystemStartup函数仍然可以从这个全局变量了解到CPU号,依此类推,直到所有CPU都开始运行。ExpInitializeExecutive函数的第一个参数也是CPU号,在这个函数中也有很多代码是根据CPU号来决定是否执行的。

执行体的阶段0初始化

在KiInitializeKernel函数结束基本的内核初始化后,它会调用ExpInitializeExecutive()开始初始化执行体。如果把操作系统看作是一个国家机器,那么执行体便是这个国家的各个行政机构。典型的执行体部件有进程管理器、对象管理器、内存管理器、IO管理器等等。考虑到各个执行体之间可能有相互依赖关系,所以每个执行体会有两次初始化的机会,第一次通常是做基本的初始化,第二次做可能依赖其它执行体的动作。通常前者叫阶段0初始化,后者叫阶段1初始化。

ExpInitializeExecutive的主要任务是依次调用各个执行体的阶段0初始化函数,包括调用MmInitSystem构建页表和内存管理器的基本数据结构,调用ObInitSystem建立名称空间,调用SeInitSystem初始化token对象,调用PsInitSystem对进程管理器做阶段0初始化(稍后详细说明),调用PpInitSystem让即插即用管理器初始化设备链表。

下面我们仔细看一下进程管理器的阶段0初始化,它所做的主要动作有:

n 定义进程和线程对象类型。

n 建立记录系统中所有进程的链表结构,并使用PsActiveProcessHead全局变量指向这个链表。此后WinDBG的!process命令才能工作。

n 为初始的进程创建一个进程对象(PsIdleProcess),并命名为Idle。

n 创建系统进程和线程,并将Phase1Initialization函数作为线程的起始地址。

注意上面的最后一步,因为它衔接着系统启动的下一个阶段,即执行体的阶段1初始化。但是这里并没有直接调用阶段1的初始化函数,而是将它作为新创建系统线程的入口函数。此时由于当前的IRQL很高,所以这个线程还得不到执行。在KiInitializeKernel函数返回后,KiSystemStartup函数将当前CPU的中断请求级别(IRQL)降低到DISPATCH_LEVEL,然后跳转到KiIdleLoop(),退化为Idle进程中的第一个Idle线程。当再有时钟中断发生时,内核调度线程时,便会调度执行刚刚创建的系统线程,于是阶段1初始化便可以继续了。

执行体的阶段1初始化

阶段1初始化占据了系统启动的大多数时间,其主要任务就是调用执行体各机构的阶段1初始化函数。有些执行体部件使用同一个函数作为阶段0和阶段1初始化函数,使用参数来区分。图1列出了这一阶段所调用的主要函数,简要说明其中几个:

n 调用KeStartAllProcessors()初始化所有CPU。这个函数会构建并初始化好一个处理器状态结构,然后调用硬件抽象层的HalStartNextProcessor函数将这个结构赋给一个新的CPU。新的CPU仍然是从KiSystemStartup开始执行。

n 再次调用KdInitSystem函数,并且调用KdDebuggerInitialize1来初始化内核调试通信扩展DLL(KDCOM.DLL等)。

n 调用IO管理器的阶段1初始化函数IoInitSystem做设备枚举和驱动加载工作,需要花很长的时间。

在这一阶段结束前,会创建第一个使用映像文件创建的进程,即会话管理器进程(SMSS.EXE)。会话管理器进程会初始化Windows子系统,创建Windows子系统进程和登录进程(WinLogon.EXE),我们以后再介绍。

0x7B蓝屏

上面介绍的过程不总是一帆风顺的。如果遇到意外,那么系统通常会以蓝屏形式报告错误。比如图2所示的0x7B蓝屏就是发生在内核和执行体初始化期间的(我们上一期的问题)。

图2 0x7B蓝屏

注意这个蓝屏的下方没有转储有关的信息(稍后你就会明白原因了)。

那么应该如何寻找这个蓝屏的故障原因呢?

首先可以根据蓝屏的停止代码0x7B查阅WinDBG的帮助文件或者MSDN了解它的含义。于是我们知道,0x7B是INACCESSIBLE_BOOT_DEVICE错误的代码,其含义是不可访问的引导设备。意思是系统在读或者写引导设备时出错了,进一步来说,也就是在访问包含有系统文件的磁盘分区时出问题了。

访问系统分区怎么会出问题呢?操作系统加载程序刚刚不是还读过系统分区来加载系统文件了的,现在怎么不能访问了呢?磁盘设备在这两个时间点之间损坏的概率很低,因此,主要的原因还是因为访问的方式不同了。操作系统加载程序是使用简单的方式来访问磁盘的,而操作系统内核开始运行后,开始改用更为强大的驱动程序来访问磁盘,而这里恰恰是常出问题的地方。对于典型的IDE硬盘,需要使用ATAPI.SYS这个驱动程序来进行访问。那么ATAPI这个驱动是谁来加载的呢?让内核自己来加载,肯定不行,因为内核是依赖它来访问磁盘的,正所谓“自己的刀刃削不了自己的刀把”。那么应该由谁来加载呢?OS Loader,也就是NTLDR或者WinLoad。它们怎么知道要加载这个驱动呢?是根据注册表。图2显示了注册表中ATAPI驱动程序的各个键值。其中的Start键值等于0代表是引导类型,Group键值标志着这个驱动属于SCSI miniport这个组。OS Loader看到Start键值为0后,就会将这个驱动程序加载到内存中。我们不妨把以这种方式加载的驱动程序称为第一批加载的驱动程序。

图3 ATAPI驱动程序的注册表键值

如果按F8通过高级选项菜单中的某一项启动,那么NTLDR会显示出它加载的第一批驱动程序的清单(图4)。

在上面的清单中,没有ATAPI.SYS,这正是问题所在。事实上笔者就是将Start值改为3来“制造”出这个蓝屏的(读者一定不要草率模仿,以免丢失数据)。

图4 第一批加载的驱动程序清单

除了观察访问磁盘的关键驱动程序是否加载,还可以使用内核调试来做进一步的分析。如果目标系统事先没有启用内核调试,那么可以在引导初期按F8调出高级引导菜单,然后选择Debug。这时系统通常会使用串行口2(COM2)以波特率19200来启用内核调试引擎(参见《软件调试》18.3.3 P478)。然后使用一根串口通信电缆将目标机器与调试主机相连接(主机不一定要使用COM2)。

成功建立调试会话后,在出现蓝屏前,调试器便会收到通知:

* Fatal System Error: 0x0000007b

(0xFC8D3528,0xC0000034,0x00000000,0x00000000)

此时观察栈回溯,便可以看到发生蓝屏的过程:

kd> kn

# ChildEBP RetAddr

00 fc8d3090 e7 nt!RtlpBreakWithStatusInstruction

01 fc8d30dc be nt!KiBugCheckDebugBreak+0x19

02 fc8d34bc ae nt!KeBugCheck2+0x574

03 fc8d34dc 806bdc94 nt!KeBugCheckEx+0x1b

04 fc8d3644 806ae093 nt!IopMarkBootPartition+0x113

05 fc8d3694 806a4714 nt!IopInitializeBootDrivers+0x4ba

06 fc8d383c 806a5ab0 nt!IoInitSystem+0x712

07 fc8d3dac 80582fed nt!Phase1Initialization+0x9b5

08 fc8d3ddc 804ff477 nt!PspSystemThreadStartup+0x34

09 00000000 00000000 nt!KiThreadStartup+0x16

这个栈回溯表明这个系统线程正在做执行体的阶段1初始化。目前在执行的是IO管理器的IoInitSystem函数。后者又调用IopInitializeBootDrivers来初始化第一批加载的驱动程序。IopInitializeBootDrivers又调用IopMarkBootPartition来把引导设备标识上引导标记。在做标记前,IopMarkBootPartition需要打开引导设备,获得它的对象指针。但是打开这个设备时失败了,于是IopMarkBootPartition调用KeBugCheckEx发起蓝屏,报告错误。

蓝屏停止码的第一个参数是引导设备的路径,使用dS命令可以显示其内容:

kd> dS fc8d3528

e13fa810 “/ArcName/multi(0)disk(0)rdisk(0)”

e13fa850 “partition(1)”

蓝屏停止码的第二个参数是IopMarkBootPartition调用ZwOpenFile打开引导设备失败的返回值。使用!error命令可以显示其含义:

kd> !error 0xC0000034

Error code: (NTSTATUS) 0xc0000034 () – Object Name not found.

也就是没有这样的设备对象存在,无法打开,这是因为没有加载ATAPI驱动。

观察系统中的进程列表,可以看到系统中目前只有System进程和IDLE进程。

kd> !process 0 0

NT ACTIVE PROCESS DUMP

PROCESS f8 SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000

DirBase: 00039000 ObjectTable: e1000b98 HandleCount: 34.

Image: System

使用lm观察模块列表,可以看到与图4中一致的结果。也就是说,目前系统中还没有加载普通的驱动程序,必须等到引导类型的驱动程序初始化结束后,也就是访问磁盘和文件系统的第一批驱动程序准备好了后,才可能加载其它驱动程序。

对于上面分析的例子,原因是由于注册表异常而没有加载必要的ATAPI.SYS。知道了原因后,对于Windows Vista可以使用我们上一期介绍的用安装光盘引导到恢复控制台,然后将注册表中的Start键值改回到0系统便恢复正常了。对于Windows XP,可以借助ERD Commander等工具来引导和修复。

在上一期的读者来信中,天津的黄小非先生给出了很全面的分析,把导致问题的可能原因归纳为病毒破坏、驱动程序故障和硬件故障三种情况,归纳的很好。关于如何定位原因,他提到了使用转储文件(DUMP),也是有帮助的。但因为默认的小型转储文件包含的信息有限,所以我们在上文中重点介绍了利用双机内核调试来跟踪和分析活动的目标。因为建立内核调试会话的详细步骤很容易找到,所以我们没有详细描述,感觉有困难的朋友可以参考WinDBG帮助文件中Kernel-Mode Setup一节,有《软件调试》一书的朋友可以看第18章的前三节。黄小非在来信中还对我们以后要讨论的内容提出了很好的建议,我们会认真考虑这些建议,在此深表感谢。

下一期的问题:

一台装有Windows的系统输入用户名和密码后桌面一闪便自动Log Off了,再尝试登录,现象一样,始终无法进入到正常的桌面状态,哪些原因会导致这样的问题,该如何来解决? 

举步维艰——如何调试显示器点亮前的故障

显示器是个人计算机(PC)系统中必不可少的输出设备,它是计算机向用户传递信息的首要媒介。用户也正是通过显示器来观察计算机所作的“工作”,与其交流。离开了显示器,我们便很难知道计算机在干什么。因为这个原因,在计算机系统启动的早期,要做的一个重要任务就是初始化显示系统以便可以通过显示器输出信息,俗称点亮显示器。

对于今天的大多数个人计算机,从用户按下电源按钮到显示器被点亮通常在一秒钟左右。对人类而言,这是一个稍纵即逝的时间。但对计算机系统和CPU而言,这一秒钟要完成很多任务。如果中间遇到障碍,那么便可能停滞不前,出现显示器迟迟没有被点亮的现象。今天我们就由浅入深的谈一谈遇到这种情况时该如何处理。考虑到笔记本系统的差异性较大,我们将以典型的台式机系统(即所谓的IBM兼容PC)为例。为了辅助记忆,我们不妨套用一下我国中医使用的“望闻问切”方法。

望——不要闹笑话

首先,应该“望一望”主机和显示器的电源是否都插上了,它们的指示灯是否正常,它们之间的连线是不是连接妥当。这样做的目的是在“大动干戈”之前做好基本的检查,防止费了很多力气最终才发现是插头松了这样的低级问题,闹出笑话。不过这些检查靠常识就足够,没有什么技术含量,我们不去赘述。

闻——听声识原委

中医中的“闻”既包含用耳朵听,也包含用鼻子闻——嗅。这两种途径对我们也都适用。

我们先来谈如何靠听来了解计算机系统的病在哪里。尽管今天的个人计算机主要是靠声卡(或者集成在芯片组中集成音频设备)来播放声音的,但是在个人计算机诞生之初并没有声卡,甚至到了上世纪九十年代初笔者购买电脑时,典型的PC系统仍没有声卡,原因是价格很贵。在声卡出现之前,图1所示的扬声器是PC系统上的主要发声设备。

图1 位于机箱上的PC喇叭

图1中的照片是从一台大约购于2000年的旧电脑中拍摄的。那时声卡设备便比较普及,PC喇叭的用途变得越来越少,为了节约成本,今天的PC系统通常用一个位于主板上的小蜂鸣器(Beeper)来代替PC喇叭(图2),二者虽然外观有很大的不同,但是工作原理是完全一样的。因此我们仍使用统一的名字来称呼它们。

图2 位于主板上的蜂鸣器

对程序员来说,使用PC喇叭的方法非常简单,只要将I/O端口0x61的最低两位都写为1便可以让PC喇叭开始鸣叫;将最低两位中的某一位置为0便可以让它停止鸣叫。通过一个小实验可以很方便的感受一下。使用WinDBG启动一个本地内核调试会话,然后使用端口输出命令来读写0x61端口,这样便可以开关PC喇叭。具体来讲,首先使用ib命令读出端口0x61的当前内容:

lkd> ib 0x61

00000061: 30

然后,把读到值的低三位置为1,使用ob命令输出(执行前做好心理准备,叫声可能很刺耳):

lkd> ob 0x61 30|3

此时读取这个端口的内容,可以看到端口值的低两位都为1。听得不耐烦了吧,那么赶紧执行下面的命令将其停止:

lkd> ob 0x61 30

事实上,端口0x61的位0的含义是启用PC系统中的可编程时钟计数器(Programmable Interval-Timer/Counter,通常称为8253/8254)的2号通道(共有三个,分别为0、1和2)使其输出一定频率的方波脉冲,刚才听到的鸣叫声正是这个方波输出给PC喇叭而发出的。端口0x61的位1相当于给时钟控制器的输出加一个开关,或者说加了个与门(图3)。

图3 PC喇叭的工作原理示意图

时钟控制器的输出频率是可以改变的,以变换的频率来驱动PC喇叭可以使其发出一些简单的“旋律”。

因为大多数PC系统都有PC喇叭,而且对软件来说,使用PC喇叭输出声音非常简单,所以计算机系统的设计者们很自然地想到了在PC启动早期使用蜂鸣器来报告系统遇到的错误情况。虽然播放复杂的声音很困难,但是可以使用蜂鸣的次数或者每次蜂鸣的长短不同来代表不同的含义。因为这种错误信息是通过PC喇叭以鸣叫的方式报告的,所以通常称为蜂鸣代码(Beep Code)。

当按下PC机的电源按钮后,首先运行的是固化在系统主板上的固件程序(firmware),通常称为POST程序,POST是Power On Self Testing的缩写,含义是上电自检。不同的POST程序(固件),定义蜂鸣代码的方式也有所不同。表1中列出的是英特尔主板通常使用的蜂鸣代码。

表1 英特尔主板所使用的蜂鸣代码

蜂鸣代码

含义

鸣叫1声

DRAM刷新失败

鸣叫2声

校验电路失败

鸣叫3声

基础64K内存失败,可能是没有插内存条或者内存条松动

鸣叫4声

系统时钟失败

鸣叫5声

处理器(CPU)失败

鸣叫6声

键盘控制器失败

鸣叫7声

CPU产生异常

鸣叫8声

显卡不存在,或者显卡上的显存失败

鸣叫9声

固件存储器中内容的校验和与固件中记录的不一样

鸣叫10声

读写CMOS中的数据失败或者其内容有误

鸣叫11声

高速缓存失败

通常,在固件厂商的网站或者产品手册中可以查找到蜂鸣代码的含义。例如通过以下链接可以访问到英特尔主板/固件的蜂鸣代码含义:http://www.intel.com/support/motherboards/desktop/sb/cs-010249.htm

在以下网页中列出了其它几种常见固件的蜂鸣代码定义:http://www.computerhope.com/beep.htm

关于PC喇叭,还有两点需要说明。第一点是,有些固件在正常完成基本的启动动作后会鸣叫一声,这并不是报告错误,而是报告好消息。第二点是台式机的PC喇叭通常是不受静音控制的,而笔记本电脑的PC喇叭是受静音控制的,因此在诊断笔记本电脑时应该调整音量按钮取消静音,这样才可能听到蜂鸣代码。

下面再谈一下闻的另一种含义——“嗅”,也就是闻味道。当出现显示器无法点亮这样的故障时,确实可能是某些硬件损坏了,比如电容被击穿和短路等。因此在每次开机和调试时,不妨用鼻子闻一闻,如果闻到烧焦味道,那么应该立刻切断电源;如果有其它事情需要离开,那么也该先给系统断电。

问——黑暗中交谈

在显示器被点亮前,系统通常还不能接收键盘和鼠标输入,这时该如何询问它呢?一种简单的方法是改变系统的配置或者调换系统的部件,然后通过聆听蜂鸣代码或者观察它的其它反应来感知计算机的“回答”,以便收集更多的信息。举例来说,有一个故障系统,按下电源后很久,显示器仍不亮,也听不到任何蜂鸣声音。这时,可以先切断电源,拔下所有内存条,然后再上电开机,如果听到三声鸣叫,那么便说明系统已经执行到内存检查部分,这可以初步证明CPU是正常的,系统的主板也是可以工作的。

切——接收自举码

中医中的切是指把脉,也就是通过感受患者的脉搏来了解健康状况。那么如何能感受计算机系统的脉搏并从中提取出它的生命信息呢?PC系统的开拓者们真的设计出了一种方法。简单来说,就是将一种名为“上电自检卡(POST Card)”的标准PC卡插到系统的扩展槽中,让这块卡“切”入到目标系统中来监听系统总线上的活动,接收上面的数据。上电自检卡通常是PCI接口的,也有ISA接口的。图4中的照片便是一个PCI接口的上电自检卡。

图4 PCI接口的上电自检卡

为了支持调试,POST程序在执行的过程中,会将代表一定含义的POST代码发送到0x80端口。系统硬件会将发送到这个端口的数据发送到PCI总线上,于是上电自检卡便可以从总线上读取到POST代码,然后显示出来。POST程序会使用不同的POST代码代表不同的含义,有些代表错误号,有些代表进展到了哪个阶段。通常可以在产品的技术文档中查找到POST代码的含义,然后根据这个含义来了解故障的原因。

透视和跟踪

使用上面介绍的四类方法,通常可以定位出导致故障的部件或者粗略的原因,对于普通的测试或者维修目的,做到这一步也就可以满足要求了。那么对于需要修正故障或者想深入研究的开发人员该如何进一步分析出精确的故障位置呢?如果是软件错误,那么能不能分析出是哪段程序或者哪条指令出错了呢?

要做到这一点,比较有效的方法是使用调试器。因为这个时候系统还在初始化阶段,纯软件的调试器还不能工作,所以这时需要硬件调试器,也就是《软件调试》第7章介绍的基于JTAG技术的ITP/XDP调试器或者同类的硬件工具。

使用硬件调试器可以单步跟踪执行POST程序;可以设置断点,包括代码断点(执行到指定地址的代码时中断)、内存访问断点(访问指定的内存地址时中断)和IO访问断点(访问指定的IO地址时中断);也可以在发生重要事件时中断下来,比如进入或者退出系统管理模式(SMM)时中断、进入或者退出低功耗状态时中断、或者系统复位后立刻中断等。举例来说,在将系统复位事件的处理方式设置为中断(break)并重启系统后,CPU复位后一开始执行便会中断到调试器中。观察此时的寄存器值(图5),代码段寄存器cs=f000,程序指针寄存器eip=0000fff0。因为这时CPU工作在实模式下,所以目前要执行代码的物理地址是0xf000 x 16 + 0xfff0 = 0xffff0,这正是PC标准中定义的CPU复位后开始执行的程序地址,PC系统的硬件保证这个地址会指向位于主板上的POST程序。因此可以毫不夸张的说,以这种方式中断到调试器中可以得到“最早的”调试机会,从CPU复位后执行的第一条指令开始跟踪调试。

图5 CPU复位后的寄存器值

接下来,使用断点功能对端口0x80设置一个IO断点,然后恢复CPU执行:

[P0]>go

结果,这个断点很快便命中了,调试器显示:

[ Debug Register break at 0x0010:00000000fffffeca in task 00000 ]

[[P1] BreakAll break at 0xf000:0000000000000000 ]

因为系统中的CPU是双核的,所以第1行显示的是0号CPU(P0)的中断位置,第2行显示的是1号CPU的中断位置,其程序指针寄存器的值为0,还没有开始工作。使用反汇编指令可以观察断点附近的指令:

[P0]>asm $-2 length 10

0x0010:00000000fffffec8 e680 out 0x80, al

0x0010:00000000fffffeca e971f9ffff jmp $-0x0000068a ;a=fffff840

0x0010:00000000fffffecf e4ff push 0xffe40000

0x0010:00000000fffffed4 68c4faffff push 0xfffffac4

可见,中断前执行的正是向0x80号端口输出的指令。观察一下al寄存器,它的值为1,看一下上电自检卡上显示的数字也是1,正好吻合。

归纳

今天,我们介绍了一种比较特殊的调试任务。之所以介绍这个内容,除了让大家了解上面介绍的调试方法外,还有两个目的。一是学习PC系统的设计者们以不同方式支持调试的聪明才智和重视调试的职业精神,他们在打印信息或者显示文字等方法不可行的情况下,设计出了蜂鸣代码和POST代码这样的调试机制,这些机制看似简陋,但是却可以传递出来自系统第一线的直接信息,实践证明这些信息可以大大提高调试的效率。二是希望能提高大家对计算机硬件和整个系统的兴趣,程序员的主要目标是编写软件,但是对硬件和系统的深刻理解对于程序员的长远发展是有非常有意义的。

下一期的问题:

一台安装Windows的计算机系统开机后显示因为系统文件丢失而无法进入系统,对于这样的问题有哪些方法来调试和解决?

权利移交——如何调试引导过程中的故障

上一期我们讨论了如何调试显示器点亮前的故障,在文章中我们提到,CPU复位(Reset)后,首先执行的是固化在主板上的POST程序(图1)。POST程序的核心任务是检测系统中的硬件设备,并对它们做基本的检查和初始化,并根据需要给它们分配系统资源(中断、内存和IO空间等)。POST程序成功执行后,系统接下来要做的一个重要任务便是寻找和加载操作系统(OS)。对于不同的计算机系统和不同的使用需求,需要加载的操作系统可能位于不同的地点。最常见的情况是操作系统位于硬盘(Hard Disk)上,但是也可能位于光盘、优盘、软盘或者网络上。

图1 计算机的启动过程

通常把寻找和加载操作系统的过程叫做引导(Boot或者Bootstrap),也就是图一中的黄色方框。本期我们就谈谈引导有关的问题,介绍如何分析和调试的这个过程中可能发生的故障。

BBS——BIOS引导规约

考虑到引导过程涉及到来自不同厂商生产的不同部件之间的协作,因此需要一个标准来定义每个部件的职责和各个部件之间交互的的方法,在这种背景下,英特尔、Phoenix和康柏公司在1996年联合发布了BIOS引导规约(BIOS Boot Specification),简称BBS(图2)。尽管十几年已经过去了,但是这个规约中的大多数内容至今仍被使用着。本文中使用的很多术语和数据结构都来自这个规约。在互联网上搜索BIOS Boot Specification,可以下载到BSS的电子版本。

图2 BIOS引导规约

IPL表格

BBS把系统中可以引导和加载OS的设备成为初始程序加载设备(Initial Program Load Device),简称IPL设备。BIOS会在内存中维护一个IPL表格,每一行(表项)描述一个IPL设备。表1列出了IPL表的各个列(表项的字段)的用途和详细情况。

表1 IPL表项的各个字段

名称

偏移

长度(字节)

描述

deviceType

00h

2

设备标号,参见下文

statusFlags

02h

2

状态标志

bootHandler

04h

4

发起引导的代码的地址

descString

08h

4

指向一个以零结束的ASC字符串

expansion

0ch

4

保留,等于0

其中的deviceType字段用来记录代表引导设备编号的数字,01h代表软盘,02h代表硬盘,03h代表光盘,04h代表PCMCIA设备,05h代表USB设备,06h代表网络,07h..7fh和81..feh保留,80h代表以BEV方式启动的设备(我们稍后详细讨论)。接下来的statusFlags字段用来记录它所描述的引导设备的状态信息,使用不同的二进制位代表不同的状态,图2画出了各个位域,Old Position位域(bit 3..0)代表上次引导时这个表项在IPL表中的索引,Enabled位域(位8)用来启用(1)或禁止(0)这个表项,Failed位域(位9)为1代表已经尝试过使用该表项而且得到了失败的结果,Media Present位域(位11..10)的典型用途是描述驱动器是否有可引导的媒介(光盘、磁盘),0代表没有,1代表未知,2代表有媒介,而且看起来可以引导。

图3 IPL表的状态标志字段(statusFlag)的位定义

从编程的角度来看,可以使用下面的数据结构来描述IPL表的表项:

struct ipl_entry {

Bit16u deviceType;

Bit16u statusFlags;

Bit32u bootHandler;

Bit32u descString;

Bit32u expansion;

};

在EFI(Extensible Firmware Interface)中,使用BBS_TABLE结构来描述IPL设备,希望了解其具体定义的读者可以到http://www.openefi.org/下载详细文档。

引导设备分类

BBS将引导设备划分为以下三种类型:

n BAID – 即BIOS知道的IPL设备(BIOS Aware IPL Device),也就是说BIOS中已经为这样的设备准备了支持引导的代码。第一个软驱、第一个硬盘、ATAPI接口的光驱等都属于这一类型。

n 传统设备 – 是指带有Option ROM(见下文)但没有PnP扩展头的标准ISA设备。例如已经过时的通过ISA卡连接到系统中的SCSI硬盘控制器。

n PnP设备 – 是指符合PnP BIOS规约(Plug and Play BIOS Specification)的即插即用设备。

因为第二类设备已经很少见,所以我们重点介绍一下从另两类设备引导的方法。

从即插即用(PnP)设备引导

这需要先了解什么是Option ROM。所谓Option ROM就是在位于PCI或者ISA设备上的只读存储器,因为这个存储器不是总线标准规定一定要实现的,所以叫Option ROM(可选实现的ROM)。Option ROM里面通常存放着用于初始化该设备的数据和代码。显卡和网卡等设备上通常带有Option ROM。

PnP BIOS规约详细定义了Option ROM的格式。简单来说,在它的开始处,总是一个固定结构的头结构,称为PnP Option ROM Header,为了行文方便,我们将其简称为PORH。在PORH的偏移18h和1Ah处可以指向另外两个结构,分别称为PCI数据结构和PnP扩展头结构(PnP Expansion Header),我们将其简称为PEH。PEH中有一个起到链表作用的Next字段(偏移06h,长度为WORD)用来描述下一个扩展结构的偏移。

图4 PnP设备Option ROM中的头结构

首先,所有的Option ROM的头两个字节都是0xAA55,因此在调试时可以通过这个签名来搜索或者辨别Option ROM的头结构。另外,PC标准规定,0xC0000到0xEFFFF这段物理内存地址空间是供Option ROM使用的。

在图4中,以黄颜色标出的向量字段与引导有着比较密切的关系,下面分别作简单介绍:

n 初始化向量 – 系统固件在引导前会通过远调用执行这个地址所指向的代码,这就是通常所说的执行Option ROM。Option ROM得到执行后,除了做初始化工作外,如果该设备希望支持引导,那么可以通过改写(Hook)系统的INT 13h(用于读写磁盘的软中断)和输入设备来实现,上面提到过的传统SCSI硬盘就是这样做的。对于PnP设备,应该使用下面的BCV或者BEV方法。

n 引导连接向量(Boot Connect Vector) – 这个向量可以指向Option ROM中的一段代码(通过相对于Option ROM起始处的偏移),当这段代码被BIOS调用后,它可以根据需要改写(Hook)INT 13h。

n 引导入口向量(Boot Entry Vector) – 用来指向可以加载操作系统的代码的入口,当系统准备从这个设备引导时,那么会执行这个向量所指向的代码。下面介绍的从网卡通过PXE方式启动就是使用的这种方法。

图5所示的是网卡设备的Option ROM内容,第1列是内存物理地址,后面四列是这第一地址起始的16字节数据(以DWORD格式显示,每4字节一组)。图中第一个黄颜色方框包围起来的32字节是PORH结构,它的0x1A偏移处的值0x60代表的是PEH结构的偏移,因此下面的方框包围起来的便是这个扩展结构。

图5 观察PnP设备的引导入口向量

根据图4,在PEH结构的0x1A处的两个字节便是BEV向量,也就是0x0c04。因此,当在BIOS中选择从这个网卡引导时,BIOS在做好引导准备工作后,便会通过远调用来执行0xcb00:0c04处的代码。在调试时,如果对这个地址设置断点,那么便会命中。

INT 19h和INT 18h

BBS还定义了两个软中断来支持引导,它们分别是发起引导的INT 19h和使用某一设备引导失败后恢复重新引导的INT 18h。

下面列出的是BBS中给出的INT 19h的伪代码。

IPLcount = current number of BAIDs and BEV devices at this boot.

FOR (i = 0; i < IPLcount; ++i)

currentIPL = IPL Priority[i].

Use currentIPL to index the IPL Table entry.

Do a far call to the entry’s boot handler or BEV.

IF (control returns via RETF, or an INT 18h)

Clean up the stack if necessary.

ENDIF

Execute an INT 18h instruction.

其中,第5行的远调用便是把执行权交给了用于引导当前IPL设备的过程,如果这个调用成功,那么便永远不会返回。

下面是INT 18h的伪代码。

Reset stack.

IF (all IPL devices have been attempted)

Print an error message that no O/S was found.

Wait for a key stroke.

Execute the INT 19h instruction.

ELSE

Determine which IPL device failed to boot.

Jump to a label in the INT 19h handler to try the next IPL device.

ENDIF

需要说明的是,上面的伪代码完全是示意性的,实际的BIOS实现会更复杂而且可能有所不同。

使用Bochs调试引导过程

除了可以使用ITP这样的硬件调试器来调试引导过程外,某些情况下,也可以使用虚拟机来调试。具体来说就是把要调试的固件(BIOS或者EFI)文件配置到虚拟机中,然后利用虚拟机管理软件的调试功能来调试。例如,Bochs虚拟机便具有这样的功能。Bochs目前是一个开源的项目,可以从它的网站http://bochs.sourceforge.net/上下载安装文件和源代码。

图6中的屏幕截图便是使用Bochs调试的场景,大的窗口是虚拟机,重叠在大窗口上的小窗口是Bochs的控制台,在里面可以输入各种调试命令。图中显示的是设置在INT 19h入口处(0xf000:e6f2)的断点命中时的状态。

图6 使用Bochs调试引导过程

使用xp 0x19*4可以显示中断向量表中INT 19h所对应的内容,即0xf000e6f2,其中高16位是段地址,低16位是偏移。值得说明的是,大多数BIOS中的INT 19h的入口地址都与此相同。知道了地址后,就可以使用pb 0xfe6f2来设置断点,其中0xfe6f2是0xf000:e6f2这个实模式地址对应的物理地址,其换算方法是把0xf000左移4个二进制位(相当于在十六进制数的末尾加一个0),然后加上偏移。

顺便说一下,在Bochs项目中,实现了一个简单的BIOS,其主要代码都位于rombios.c文件,通过下面的链接可以访问到这个文件:http://bochs.sourceforge.net/cgi-bin/lxr/source/bios/rombios.c 想学习BIOS的读者,可以仔细读一下这个文件,这是深刻理解BIOS的很有效方法。

0x7c00——新的起点

对于大多数时候使用从BAID设备引导,BIOS中的支持函数会从设备(磁盘)的约定位置读取引导扇区,存放到内存中0x0000:7c00这个位置,然后把控制权转交过去。转交时会通过DL寄存器传递一个参数,这个参数用来指定磁盘号码,00代表A盘,0x80代表C盘。接下来的引导代码在通过INT 13h来访问磁盘时,应该使用这个参数来指定要访问的磁盘。

因为从磁盘引导时,BIOS一定会把控制权移交到0x7c00这个地址,所以在调试时可以在这个位置设置断点,开始分析和跟踪。表2列出了其它一些固定的BIOS入口地址。

表2 BIOS兼容入口点

地址

用途

0xf000:e05b

POST入口点

0xf000:e2c3

不可屏蔽中断(NMI)处理函数入口点

0xf000:e3fe

INT 13h硬盘服务入口点

0xf000:e401

硬盘参数表

0xf000:e6f2

INT 19h(引导加载服务)入口点

0xf000:e6f5

配置数据表

0xf000:e739

INT 14h入口点

0xf000:e82e

INT 16h入口点

0xf000:e987

INT 09h入口点

0xf000:ec59

INT 13h软盘服务入口点

0xf000:ef57

INT 0Eh(Diskette Hardware ISR)入口点

0xf000:efc7

软盘控制器参数表

0xf000:efd2

INT 17h(打印机服务)入口点

0xf000:f065

INT 10h(显示服务)入口点

0xf000:f0a4

MDA/CGA显示参数表 (INT 1Dh)

0xf000:f841

INT 12h(内存大小服务)入口点

0xf000:f84d

INT 11h入口点

0xf000:f859

INT 15h(系统服务)入口点

0xf000:fa6e

低128个字符的图形模式字体

0xf000:fe6e

INT 1Ah(时间服务)入口点

0xf000:fea5

INT 08h(System Timer ISR)入口点

0xf000:fef3

POST用这个值来初始化中断向量表

0xf000:ff53

只包含IRET指令的dummy中断处理过程

0xf000:ff54

INT 05h(屏幕打印服务)的入口点

0xf000:fff0

CPU复位后的执行起点

0xf000:fff5

构建日期,按MM/DD/YY格式,共8个字符

0xf000:fffe

系统型号

另外,地址0x0040:0000开始的257个字节是所谓的BIOS数据区(BIOS Data Area),简称BDA,里面按固定格式存放了BIOS向后面的引导程序和操作系统移交的信息。

下一期的问题:

一台PC系统开机后显示Windows could not start because of a general computer hardware configuration problem.,对于这样的问题有哪些方法来调试和解决?(注:上期的问题留到下一期给出答案)

步步为营——如何调试操作系统加载阶段的故障

上一期我们介绍了系统固件(BIOS)寻找不同类型的引导设备的方法,描述了固件向引导设备移交执行权的过程。对于从硬盘引导,首先接受控制权的是位于硬盘的0面0道0扇区中的主引导记录(Main Boot Record),简称MBR。MBR一共有512个字节,起始处为长度不超过446字节的代码,然后是64个字节长的分区表,最后两个字节固定是0x55和oxAA。MBR中的代码会在分区表中寻找活动的分区,找到后,它会使用INT 13h将活动分区的引导扇区(Boot Sector)加载到内存中,加载成功后,将执行权移交过去。按照惯例,引导扇区也应该被加载到0x7C00这个内存位置,所以MBR代码通常会先把自己复制到0x600开始的512个字节,以便给引导扇区腾出位置。也正是因为这个原因,当使用虚拟机或者ITP调试时,如果在0x7C00处设置断点,那么这个断点通常会命中两次。引导扇区的内容是和操作系统相关的,在安装操作系统时,操作系统的安装程序会设置好引导扇区的内容。引导的职责通常是加载操作系统的加载程序(OS Loader)。OS Loader得到控制权后,再进一步加载操作系统的内核和其它程序。本期我们就以Windows Vista操作系统为例谈一谈OS Loader的工作过程以及如何调试这一阶段的问题。

切换工作模式

我们知道,对于x86 CPU来说,不管它是否支持32位或64位,在它复位后都是处于16位的实地址模式。在BIOS阶段,CPU可能被切换到保护模式,但是在BIOS把控制权移交给主引导记录前,它必须将CPU恢复回实模式,这是一直保持下来的传统。对于使用EFI固件的系统,固件可以在保护模式下把控制权移交给操作系统的加载程序。但本文仍旧讨论传统的方式。

因为实模式下的每个段最大只有64K,而且只能直接访问1MB的内存,这个空间是无法容纳今天的主流操作系统的核心文件的,所以OS Loader首先要做的一件事就是把CPU切换到可以访问更大空间的保护模式。

在切换到保护模式前,应该先建立好全局描述符表(GDT)和中断描述符表(IDT)。通常在OS Loader阶段不会开启CPU的分页机制(Paging),而且描述符表中的每个段的基地址通常都设置为0,界限设置为0xFFFFFFFF,这样便可以在程序中自由访问4GB的地址空间,而且线性地址的值就等于物理地址的值,这里使用内存空间的方法就是所谓的平坦模式(Flat Model)。以Windows Vista操作系统为例,它的引导管理器程序BootMgr.EXE内部既有16位代码又有32位代码,16位代码先执行,在验证文件的完好后,会切换到保护模式,并把内嵌的32位程序映射到0x开始的内存区,然后把控制权移交给32位代码的起始函数BmMain。此时观察CR0寄存器,可以看到代表保护模式的位0已经为1。

kd> r cr0

cr0=00000013

但是代表分页机制的位31为0,说明没有启用分页。观察代码段和数据段的段描述符:

kd> dg cs

P Si Gr Pr Lo

Sel Base Limit Type l ze an es ng Flags

—- ——– ——– ———- – — — — — ——–

0020 00000000 ffffffff Code RE Ac 0 Bg Pg P Nl 00000c9b

kd> dg ds

P Si Gr Pr Lo

Sel Base Limit Type l ze an es ng Flags

—- ——– ——– ———- – — — — — ——–

0030 00000000 ffffffff Data RW Ac 0 Bg Pg P Nl 00000c93

可见,它们的基地址都是0,边界都是0xFFFFFFFF,这正是平坦模式的典型特征。分别使用dd命令和!dd(观察物理地址)观察同一个地址值:

kd> dd idtr l4

0001f080 00 00008f00 002073b0 00448e00

kd> !dd idtr l4

# 1f080 00 00008f00 002073b0 00448e00

显示的内容是一样的,这说明线性地址与它所对应的物理地址的值是相等的。

休眠(Hibernation)支持

在执行BlImgQueryCodeIntegrityBootOptions函数和BmFwVerifySelfIntegrity函数对自身的完整性做进一步检查后,BootMgr会调用BmResumeFromHibernate检查是否需要从休眠(Hibernation)中恢复,如果需要,那么它会加载WinResume.exe,并把控制权移交给它。

显示启动菜单

BootMgr会从系统的引导配置数据(Boot Configuration Data,简称BCD)中读取启动设置信息,如果有多个启动选项,那么它会显示出启动菜单。清单1中的栈回溯显示的便是BootMgr在显示启动菜单后等待用户选择时的状态。

清单1 等待用户选择启动项

kd> kn

# ChildEBP RetAddr

00 00061e34 00 bootmgr!DbgBreakPoint

01 00061e44 00431c24 bootmgr!BlXmlConsole::getInput+0xe

02 00061e90 00402e8f bootmgr!OsxmlBrowser::browse+0xe0

03 00061e98 00402b5e bootmgr!BmDisplayGetBootMenuStatus+0x13

04 00061f10 004017ce bootmgr!BmDisplayBootMenu+0x174

05 00061f6c 00 bootmgr!BmpGetSelectedBootEntry+0xf8

06 00061ff0 00020a9a bootmgr!BmMain+0x278

WARNING: Frame IP not in any known module. Following frames may be wrong.

07 00000000 f000ff53 0x20a9a

08 00000000 00000000 0xf000ff53

栈帧6中的BmMain便是BootMgr的32位代码的入口函数,栈帧4中的BmDisplayBootMenu是显示启动菜单的函数,栈帧7和8是在实模式中执行时的痕迹。

执行用户选择的启动项

当用户选择一个启动选项后,BootMgr会调用 函数来准备引导对应的操作系统。如果系统上有Windows XP或者更老的Windows,而且用户选择了这些项,那么BootMgr会加载NTLDR来启动它们。如果用户选择的是Windows Vista的启动项,那么BootMgr会寻找和加载WinLoad.exe,如果没有找到或者在检查文件的完整性时发现问题,那么BootMgr会显示出图1所示的错误界面。

在成功加载WinLoad.exe后,BootMgr会为其做一系列其它准备,包括启用新的GDT和IDT,然后调用平台相关的控制权移交函数把执行权移交给WinLoad。在x86平台中,完成这一任务的是Archx86TransferTo32BitApplicationAsm函数。至此,BootMgr完成使命,WinLoad开始工作。

加载系统核心文件

WinLoad的主要任务是把操作系统内核加载到内存,并为它做好“登基”的准备。它首先要做的一件事就是进一步改善运行环境,启用CPU的分页机制。然后初始化自己的支持库,如果启用了引导调试支持(稍后介绍),那么它会初始化调试引擎。

图1 加载WinLoad.exe失败时的错误提示

接下来WinLoad会读取启动参数,决定是否显示高级启动菜单,高级菜单中含有以安全模式启动等选项,也叫Windows Error Recovery菜单。如果用户按了F8或者上次没有正常关机,那么WinLoad便会显示高级启动菜单。

接下来要做的一个重要工作是读取和加载注册表的System Hive,因为其中包含了更多的系统运行参数,负责这项工作的是OslpLoadSystemHive函数。

做好以上工作后,WinLoad开始它的核心任务,那就是加载操作系统的内核文件和引导类型的设备驱动程序。它首先加载的是NTOSKRNL.EXE,这个文件包含了Windows操作系统的内核和执行体。此时真正的磁盘和文件系统驱动程序还没有加载进来,所以WinLoad是使用它自己的文件访问函数来读取文件的。例如,FileIoOpen函数便是用来打开一个文件的,

如果FileIoOpen 打开文件失败,那么调用它的BlpFileOpen 函数会返回错误码0C000000Dh,否则返回0代表成功。

其中,PSHED.DLL用于支持WHEA(Windows Hardware Error Architecture)(《软件调试》第17章有详细介绍),HAL.DLL是硬件抽象层模块,BOOTVID.DLL用于引导期间和发生蓝屏时的显示,KDCOM.DLL用于支持内核调试,CLFS.SYS是支持日志的内核模块,CI.DLL是用于检查模块的完整性的(Code Integrity)。

加载好系统模块后,WinLoad还需要加载引导类型(Boot Type)的设备驱动程序,在安装驱动程序时,每个驱动程序都会指定启动类型(Start Type),这个设置决定了驱动程序的加载时机,指定为引导类型的驱动程序是最先被加载的。

接下来加载的是硬件抽象层模块HAL.DLL,支持调试的KDCOM.DLL和它们的依赖模块。使用Depends工具可以观察一个PE模块所依赖的其它模块,例如,图2显示出了内核文件NTOSKRNL.EXE所依赖的其它模块。

图2 使用DEPENDS工具观察NTOSKRNL.EXE所依赖的其它模块

如果在加载以上程序模块或者注册表的过程中找不到需要的文件或者在检查文件的完整性时发现异常,那么WinLoad便会提示错误而停止继续加载,我们在08年第11期中提到的问题便是与此有关的。当遇到这样的问题时,可以使用安装光盘引导,然后恢复丢失或者被破坏的文件。

完成模块加载后,WinLoad开始准备把执行权移交给内核,包括为内核准备新的GDT和IDT(OslArchpKernelSetupPhase0)和建立内存映射(OslBuildKernelMemoryMap)等。所有准备工作做完后,WinLoad调用OslArchTransferToKernel函数把供内核使用的GDT和IDT地址加载到CPU中,然后调用内核的入口函数,正式把控制权移交个内核。

启用调试选项

Windows Vista的BootMgr和WinLoad程序内部都集成了调试引擎,不管是Checked版本还是Free版本,对于Free版本,默认是禁止的,使用时需要开启,具体做法如下:

如果要启用BootMgr中的调试引擎,那么应该在一个具有管理员权限的控制台窗口中执行如下命令:

bcdedit /set {bootmgr} bootdebug on

bcdedit /set {bootmgr} debugtype serial

bcdedit /set {bootmgr} debugport 1

bcdedit /set {bootmgr} baudrate

以上命令是使用串行口作为主机和目标机之间的通信方式,如果使用其它方式,那么应该设置对应的参数。

如果要启用WinLoad程序中的调试引擎,那么应该先找到它所对应的引导项的GUID值,然后执行如下命令:

bcdedit /set {GUID} bootdebug on

启用调试引擎并连接通信电缆后,在主机端运行WinDBG工具,便可以进行调试了,栈回溯、访问内存、访问寄存器等内核调试命令都可以像普通内核调试一样使用。

Windows Vista之前的情况

在Vista之前,NTLDR是Windows操作系统的加载程序。因为只有Checked版本的NTLDR才支持调试,所以如果要调试加载阶段的问题,应该先将NTLDR替换为Checked版本。DDK中通常包含有Checked版本的NTLDR程序。记住,在替换前,应该先去除NTLDR文件的系统、隐藏和只读属性,在更换后,要加上这些属性,否则的话引导扇区中的代码会报告NTLDR is missing错误,无法继续启动。

除了加载内核和引导类型的驱动程序外,NTLDR会调用NTDETECT.COM来做基本的硬件检查并搜集硬件信息。NTDETECT会把搜集到的信息存放到注册表中。如果找不到NTDETECT.COM,那么通常会直接重启,如果NTDETECT发现系统缺少必须的硬件或固件支持,比如ACPI支持,那么会显示因为硬件配置问题而无法启动,也就是我们上一期所提问的问题。对于这样的问题,可以尝试更改BIOS选项来解决,或者通过调试NTLDR来进一步定位错误原因。

恢复缺失文件

可以使用如下方法之一来尝试恢复丢失或者损坏的系统文件:

1. 启动时按F8,调出高级启动菜单,尝试选择Last Known Good Configuration(LKG)。

2. 启动时按F8,在高级启动菜单中选择安全模式(Safe Mode),如果成功启动后,那么可以尝试执行CHKDSK命令检查和修复磁盘,或者从安装光盘中恢复缺失的文件。

3. 使用Windows安装光盘引导,并记入到恢复控制台(Recovery Console)界面。对于Windows XP,在安装程序的主界面中按R键进入文本界面的恢复控制台,进入时输入管理员密码。对于Windows Vista,从安装光盘启动后,可以进入图形界面的系统恢复向导(图3)。如果是MBR或者引导分区损坏,那么Windows XP的恢复控制台中提供了FIXMBR和FIXBOOT命令。而Vista的恢复向导中包含了自动修复功能。

图3 Windows Vista安装光盘上的系统恢复程序

4. 如果系统硬盘的个数或者有所变化,那么可能是因为分区编号变化而导致系统无法找到文件,这时可以考虑恢复旧的磁盘和分区配置,或者启动到恢复控制台来修改系统的启动配置文件,对于Vista,需要修改BCD,对于Vista之前的系统,也就是修改BOOT.INI文件。

对于第11期的问题,天津的黄小非读者给出了非常好的答案,他的来信中给出了多种方法,包括使用控制台,使用Windows Preinstallation Environment(WinPE)以及修改BOOT.INI。其实Vista的恢复界面就是运行在WinPE中的。从黄小非的来信中,我们可以看出他的实践经验很丰富。

下一期的问题:

系统启动后很快出现蓝屏,其中含有STOP 0x0000007B INACCESSABLE_BOOT_DEVICE,哪些原因会导致这样的问题,该如何来解决?

百废待兴——如何调试内核初始化阶段的故障

上一期我们介绍了加载操作系统的过程。简单来说,负责加载操作系统的加载程序(OS Loader)会把系统内核模块、内核模块的依赖模块、以及引导类型的驱动程序加载到内存中,并为内核开始执行准备好基本的执行环境。这些工作做好后,加载程序会把执行权移交给内核模块的入口函数,于是操作系统的内核模块就开始执行了。在今天的软件架构中,操作系统承担着统一管理系统软硬件资源的任务,可以说是整个系统的统帅。内核模块是操作系统的核心部分,像任务调度、中断处理、输入输出等核心功能就是实现在内核模块中的。因此,内核模块开始执行,标志着“漫长的”启动过程进入到了一个新的阶段,系统的统帅走马上任了。虽然前面已经做了很多准备工作,但是对于一个典型的多任务操作系统来说,要建设出一个可以运行各种应用程序的多任务环境来,还有很多事情要做,可谓是百废待兴。本期我们仍以Windows操作系统为例谈一谈系统内核和执行体初始化(简称内核初始化)的过程以及如何调试这一阶段的问题。

入口函数

Windows程序的入口函数地址是登记在可执行文件的头结构中的,也就是IMAGE_OPTIONAL_HEADER结构的AddressOfEntryPoint 字段。内核文件的入口函数也是如此。通过下面几个步骤就可以使用WinDBG观察到内核文件的入口函数。先启动WinDBG,并开始一个本地内核调试对话,使用lm nt命令列出内核文件的基本信息:

lkd> lm a nt

start end module name

804d7000 806cdc80 nt (pdb symbols) d:/symbols/ntkrnlpa.pdb/C…/ntkrnlpa.pdb

其中804d7000就是内核模块在内存中的起始地址。起始处是一个所谓的DOS头:

dt nt!_IMAGE_DOS_HEADER 804d7000

+0x000 e_magic : 0x5a4d

+0x03c e_lfanew : 232

其中e_lfanew字段的值代表的是新的NT类型可执行文件的头结构的起始偏移地址。

lkd> dt nt!_IMAGE_NT_HEADERS 804d7000+0n232

+0x000 Signature : 0x4550

+0x004 FileHeader : _IMAGE_FILE_HEADER

+0x018 OptionalHeader : _IMAGE_OPTIONAL_HEADER

现在可以知道804d7000+0n232+18处便是_IMAGE_OPTIONAL_HEADER结构,于是可以使用下面的命令来显示出AddressOfEntryPoint字段的值:

lkd> dt _IMAGE_OPTIONAL_HEADER -y Add* 804d7000+0n232+18

nt!_IMAGE_OPTIONAL_HEADER

+0x010 AddressOfEntryPoint : 0x1b6f5c

上面显示的AddressOfEntryPoint字段的值0x1b6f5c便代表着内核文件的入口函数在模块中的偏移,加上模块的基地址便可以得到入口函数的线性地址,使用ln命令查找这个地址对应的符号:

lkd> ln 0x1b6f5c+804d7000

(8068df5c) nt!KiSystemStartup | (8068e244) nt!KiSetCR0Bits

Exact matches:

nt!KiSystemStartup = <no type information>

这表明入口地址处的函数名为KiSystemStartup,实际上,它就是NT系统Windows操作系统的内核文件一直使用的入口函数。

上面我们介绍的是使用类型显示命令一步步观察,当然也可以使用扩展命令!dh一下子显示出以上信息:

lkd> !dh 804d7000 -f

File Type: EXECUTABLE IMAGE

1B6F5C address of entry point

当OS Loader(NTLDR或WinLoad)调用KiSystemStartup时,它会将启动选项以一个名为LOADER_PARAMETER_BLOCK的数据结构传递给KiSystemStartup函数。Windows Vista的内核符号文件包含了这个结构的符号,因此在对Windows Vista做内核调试时可以观察到这个结构的详细定义。

内核初始化

KiSystemStartup函数开始执行后,它首先会进一步完善基本的执行环境,包括建立和初始化处理器控制结构(PCR)、建立任务状态段(TSS)、设置用户调用内核服务的MSR寄存器等。在这些基本的准备工作完成后,接下来的过程可以分为图1所示的左右两个部分。左侧为发生在初始的启动进程中的过程,这个初始的进程就是启动后的Idle进程。右侧为发生在系统进程(System)中的所谓的执行体阶段1初始化过程。

图1 Windows启动过程概览

首先我们来看KiSystemStartup函数的执行过程,它所做的主要工作有:

一、调用HalInitializeProcessor()初始化CPU。

二、调用KdInitSystem初始化内核调试引擎,我们稍后将详细介绍这个函数。

三、调用KiInitializeKernel开始内核初始化,这个函数会调用KiInitSystem来初始化系统的全局数据结构,调用KeInitializeProcess创建并初始化Idle进程,调用KeInitializeThread初始化Idle线程。

对于多CPU的系统,每个CPU都会执行KiInitializeKernel函数,但只有第一个CPU会执行其中的所有初始化工作,包括全局性的初始化,其它CPU会只执行CPU相关的部分。比如只有0号CPU会调用和执行KiInitSystem,初始化Idle进程的工作也只有0号CPU执行,因为只需要一个Idle进程,但是因为每个CPU都需要一个Idle线程,所以每个CPU都会执行初始化Idle线程的代码。KiInitializeKernel函数使用参数来了解当前的CPU号。全局变量KeNumberProcessors标志着系统中的CPU个数,其初始值为0,因此当0号CPU执行KiSystemStartup函数时,KeNumberProcessors的值刚好是当前的CPU号。当第二个CPU开始运行时,这个全局变量会被递增1,因此KiSystemStartup函数仍然可以从这个全局变量了解到CPU号,依此类推,直到所有CPU都开始运行。ExpInitializeExecutive函数的第一个参数也是CPU号,在这个函数中也有很多代码是根据CPU号来决定是否执行的。

执行体的阶段0初始化

在KiInitializeKernel函数结束基本的内核初始化后,它会调用ExpInitializeExecutive()开始初始化执行体。如果把操作系统看作是一个国家机器,那么执行体便是这个国家的各个行政机构。典型的执行体部件有进程管理器、对象管理器、内存管理器、IO管理器等等。考虑到各个执行体之间可能有相互依赖关系,所以每个执行体会有两次初始化的机会,第一次通常是做基本的初始化,第二次做可能依赖其它执行体的动作。通常前者叫阶段0初始化,后者叫阶段1初始化。

ExpInitializeExecutive的主要任务是依次调用各个执行体的阶段0初始化函数,包括调用MmInitSystem构建页表和内存管理器的基本数据结构,调用ObInitSystem建立名称空间,调用SeInitSystem初始化token对象,调用PsInitSystem对进程管理器做阶段0初始化(稍后详细说明),调用PpInitSystem让即插即用管理器初始化设备链表。

下面我们仔细看一下进程管理器的阶段0初始化,它所做的主要动作有:

n 定义进程和线程对象类型。

n 建立记录系统中所有进程的链表结构,并使用PsActiveProcessHead全局变量指向这个链表。此后WinDBG的!process命令才能工作。

n 为初始的进程创建一个进程对象(PsIdleProcess),并命名为Idle。

n 创建系统进程和线程,并将Phase1Initialization函数作为线程的起始地址。

注意上面的最后一步,因为它衔接着系统启动的下一个阶段,即执行体的阶段1初始化。但是这里并没有直接调用阶段1的初始化函数,而是将它作为新创建系统线程的入口函数。此时由于当前的IRQL很高,所以这个线程还得不到执行。在KiInitializeKernel函数返回后,KiSystemStartup函数将当前CPU的中断请求级别(IRQL)降低到DISPATCH_LEVEL,然后跳转到KiIdleLoop(),退化为Idle进程中的第一个Idle线程。当再有时钟中断发生时,内核调度线程时,便会调度执行刚刚创建的系统线程,于是阶段1初始化便可以继续了。

执行体的阶段1初始化

阶段1初始化占据了系统启动的大多数时间,其主要任务就是调用执行体各机构的阶段1初始化函数。有些执行体部件使用同一个函数作为阶段0和阶段1初始化函数,使用参数来区分。图1列出了这一阶段所调用的主要函数,简要说明其中几个:

n 调用KeStartAllProcessors()初始化所有CPU。这个函数会构建并初始化好一个处理器状态结构,然后调用硬件抽象层的HalStartNextProcessor函数将这个结构赋给一个新的CPU。新的CPU仍然是从KiSystemStartup开始执行。

n 再次调用KdInitSystem函数,并且调用KdDebuggerInitialize1来初始化内核调试通信扩展DLL(KDCOM.DLL等)。

n 调用IO管理器的阶段1初始化函数IoInitSystem做设备枚举和驱动加载工作,需要花很长的时间。

在这一阶段结束前,会创建第一个使用映像文件创建的进程,即会话管理器进程(SMSS.EXE)。会话管理器进程会初始化Windows子系统,创建Windows子系统进程和登录进程(WinLogon.EXE),我们以后再介绍。

0x7B蓝屏

上面介绍的过程不总是一帆风顺的。如果遇到意外,那么系统通常会以蓝屏形式报告错误。比如图2所示的0x7B蓝屏就是发生在内核和执行体初始化期间的(我们上一期的问题)。

图2 0x7B蓝屏

注意这个蓝屏的下方没有转储有关的信息(稍后你就会明白原因了)。

那么应该如何寻找这个蓝屏的故障原因呢?

首先可以根据蓝屏的停止代码0x7B查阅WinDBG的帮助文件或者MSDN了解它的含义。于是我们知道,0x7B是INACCESSIBLE_BOOT_DEVICE错误的代码,其含义是不可访问的引导设备。意思是系统在读或者写引导设备时出错了,进一步来说,也就是在访问包含有系统文件的磁盘分区时出问题了。

访问系统分区怎么会出问题呢?操作系统加载程序刚刚不是还读过系统分区来加载系统文件了的,现在怎么不能访问了呢?磁盘设备在这两个时间点之间损坏的概率很低,因此,主要的原因还是因为访问的方式不同了。操作系统加载程序是使用简单的方式来访问磁盘的,而操作系统内核开始运行后,开始改用更为强大的驱动程序来访问磁盘,而这里恰恰是常出问题的地方。对于典型的IDE硬盘,需要使用ATAPI.SYS这个驱动程序来进行访问。那么ATAPI这个驱动是谁来加载的呢?让内核自己来加载,肯定不行,因为内核是依赖它来访问磁盘的,正所谓“自己的刀刃削不了自己的刀把”。那么应该由谁来加载呢?OS Loader,也就是NTLDR或者WinLoad。它们怎么知道要加载这个驱动呢?是根据注册表。图2显示了注册表中ATAPI驱动程序的各个键值。其中的Start键值等于0代表是引导类型,Group键值标志着这个驱动属于SCSI miniport这个组。OS Loader看到Start键值为0后,就会将这个驱动程序加载到内存中。我们不妨把以这种方式加载的驱动程序称为第一批加载的驱动程序。

图3 ATAPI驱动程序的注册表键值

如果按F8通过高级选项菜单中的某一项启动,那么NTLDR会显示出它加载的第一批驱动程序的清单(图4)。

在上面的清单中,没有ATAPI.SYS,这正是问题所在。事实上笔者就是将Start值改为3来“制造”出这个蓝屏的(读者一定不要草率模仿,以免丢失数据)。

图4 第一批加载的驱动程序清单

除了观察访问磁盘的关键驱动程序是否加载,还可以使用内核调试来做进一步的分析。如果目标系统事先没有启用内核调试,那么可以在引导初期按F8调出高级引导菜单,然后选择Debug。这时系统通常会使用串行口2(COM2)以波特率19200来启用内核调试引擎(参见《软件调试》18.3.3 P478)。然后使用一根串口通信电缆将目标机器与调试主机相连接(主机不一定要使用COM2)。

成功建立调试会话后,在出现蓝屏前,调试器便会收到通知:

* Fatal System Error: 0x0000007b

(0xFC8D3528,0xC0000034,0x00000000,0x00000000)

此时观察栈回溯,便可以看到发生蓝屏的过程:

kd> kn

# ChildEBP RetAddr

00 fc8d3090 e7 nt!RtlpBreakWithStatusInstruction

01 fc8d30dc be nt!KiBugCheckDebugBreak+0x19

02 fc8d34bc ae nt!KeBugCheck2+0x574

03 fc8d34dc 806bdc94 nt!KeBugCheckEx+0x1b

04 fc8d3644 806ae093 nt!IopMarkBootPartition+0x113

05 fc8d3694 806a4714 nt!IopInitializeBootDrivers+0x4ba

06 fc8d383c 806a5ab0 nt!IoInitSystem+0x712

07 fc8d3dac 80582fed nt!Phase1Initialization+0x9b5

08 fc8d3ddc 804ff477 nt!PspSystemThreadStartup+0x34

09 00000000 00000000 nt!KiThreadStartup+0x16

这个栈回溯表明这个系统线程正在做执行体的阶段1初始化。目前在执行的是IO管理器的IoInitSystem函数。后者又调用IopInitializeBootDrivers来初始化第一批加载的驱动程序。IopInitializeBootDrivers又调用IopMarkBootPartition来把引导设备标识上引导标记。在做标记前,IopMarkBootPartition需要打开引导设备,获得它的对象指针。但是打开这个设备时失败了,于是IopMarkBootPartition调用KeBugCheckEx发起蓝屏,报告错误。

蓝屏停止码的第一个参数是引导设备的路径,使用dS命令可以显示其内容:

kd> dS fc8d3528

e13fa810 “/ArcName/multi(0)disk(0)rdisk(0)”

e13fa850 “partition(1)”

蓝屏停止码的第二个参数是IopMarkBootPartition调用ZwOpenFile打开引导设备失败的返回值。使用!error命令可以显示其含义:

kd> !error 0xC0000034

Error code: (NTSTATUS) 0xc0000034 () – Object Name not found.

也就是没有这样的设备对象存在,无法打开,这是因为没有加载ATAPI驱动。

观察系统中的进程列表,可以看到系统中目前只有System进程和IDLE进程。

kd> !process 0 0

NT ACTIVE PROCESS DUMP

PROCESS f8 SessionId: none Cid: 0004 Peb: 00000000 ParentCid: 0000

DirBase: 00039000 ObjectTable: e1000b98 HandleCount: 34.

Image: System

使用lm观察模块列表,可以看到与图4中一致的结果。也就是说,目前系统中还没有加载普通的驱动程序,必须等到引导类型的驱动程序初始化结束后,也就是访问磁盘和文件系统的第一批驱动程序准备好了后,才可能加载其它驱动程序。

对于上面分析的例子,原因是由于注册表异常而没有加载必要的ATAPI.SYS。知道了原因后,对于Windows Vista可以使用我们上一期介绍的用安装光盘引导到恢复控制台,然后将注册表中的Start键值改回到0系统便恢复正常了。对于Windows XP,可以借助ERD Commander等工具来引导和修复。

在上一期的读者来信中,天津的黄小非先生给出了很全面的分析,把导致问题的可能原因归纳为病毒破坏、驱动程序故障和硬件故障三种情况,归纳的很好。关于如何定位原因,他提到了使用转储文件(DUMP),也是有帮助的。但因为默认的小型转储文件包含的信息有限,所以我们在上文中重点介绍了利用双机内核调试来跟踪和分析活动的目标。因为建立内核调试会话的详细步骤很容易找到,所以我们没有详细描述,感觉有困难的朋友可以参考WinDBG帮助文件中Kernel-Mode Setup一节,有《软件调试》一书的朋友可以看第18章的前三节。黄小非在来信中还对我们以后要讨论的内容提出了很好的建议,我们会认真考虑这些建议,在此深表感谢。

下一期的问题:

一台装有Windows的系统输入用户名和密码后桌面一闪便自动Log Off了,再尝试登录,现象一样,始终无法进入到正常的桌面状态,哪些原因会导致这样的问题,该如何来解决?

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

(0)
上一篇 2024-11-27 13:26
下一篇 2024-11-27 13:33

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信