程序员的工作就像是在历险,困难重重,途中不可避免地要遇上暗礁。有时候,少了一个字符,或者多了一个字符,或者拼错了字符,程序就无法成功编译;有时候,尽管能够编译,但程序中存在逻辑错误,要么少写了语句,要么算法不对,运行的时候也得不到正确结果。
有时候,错误的原因很简单,就是因为马虎和误操作,但很难知道问题出在哪里。等到你终于发现的时候,一天,甚至几天的时间已经花掉了。在这种情况下,没有调试工具来找到程序中隐藏的错误是不行的。有时候,即使有调试工具的帮助,也会令人筋疲力尽,不过有总比没有好。在现实的世界里,不管是经验老道的程序设计师,还是刚入门的新手,没有谁敢说自己的程序是不需要调试的。
调试工具并不是智能到可以自动发现程序中的错误,这是不可能的。但是,它可以单步执行你的程序(每执行一条指令后就停下来),或者允许你在程序中设置断点,当它执行到断点位置时就停下来。这时,它可以显示处理器各个寄存器的内容,或者内存单元里的内容。因此,你可以根据机器的状态来判断程序的执行结果是否达到了预期。通过这种方式,你可以逐步逼近出现问题的地方,直到最终发现问题的所在。市面上有多种流行的程序调试工具软件,但它们通常都像你用的其他软件一样工作在操作系统之上。
麻烦的是,本书中的程序全都只能运行在没有操作系统的祼机下。这意味着,所有流行的调试工具都不可用。不过,好消息是,一款叫做Bochs 的软件可以帮助你。
Bochs 是开源软件,是你唯一可选择的调试器。开源意味着,你不用花钱购买就可以使用它。它用软件来模拟处理器取指令和执行指令的过程,以及整个计算机硬件。当它开始运行时,就直接模拟计算机的加电启动过程。正是因为如此,它才有可能做一些调试工作。
很重要的一点是,它本身就是一个虚拟机,类似于VirtualBox。因此,它也就很容易让你单步跟踪硬盘的启动过程,查看寄存器的内容和机器状态。在本书中,我们的程序都是直接从BIOS 那里接管处理器的控制权,因此,Bochs 的这个特点正好能够用来完成调试工作。不像本书中使用的其他工具,bochs 的使用方法在网上很容易搜索到。
要使用Bochs,首先要从它的官网下载安装程序。下载地址是:
在本书的配书文件包中,有一个关于如何下载、安装和配置Bochs 的帮助文档,有WORD 和PDF 两种格式可以选用。请按照帮助文档的说明,安装和配置好Bochs。
一般来说,你会选择VirtualBox 虚拟机来观察运行结果,而在调试程序时使用Bochs。因此,最好是它们共用同一个虚拟硬盘文件(VHD 文件)。通过阅读帮助文档,你应该已经知道如何做到这一点,这里就不再赘述。
Bochs 虚拟机启动后,首先在当前的工作文件夹下寻找并读入配置文件bochsrc.bxrc,然后按它的参数调整当前虚拟机的各种“软硬件”配置和工作参数。
就像一台真正的计算机一样,Bochs 的“处理器”在加电之后,要开始取指令并执行指令。但是,与真正的处理器不同,如图5-10 所示,Bochs 在执行它启动之后的第一条指令时,会停下来,等待你的调试命令。
图5-10 Bochs 调试器的启动和断点命令的使用
如图中所示,命令窗口的底部显示了当前正在等待执行的那条指令,即“jmp far f000:e05b”。在这条指令中,关键字“far”是不必要的,而且在Bochs 中,数值默认是十六进制的。因此,该指令就是
很显然,转移的目标位置是ROM-BIOS。
在那一行的左侧,显示了该指令所在的物理内存地址,该地址是用方括号围起来的。你可能会想,它怎么会是0x00000000FFFFFFF0 呢?
8086 有20 根地址线,加电启动之后,代码段寄存器(CS)的内容为0xFFFF,指令指针寄存器IP 的内容为0x0000,因此,第一条指令的物理地址是20 位的0xFFFF0。但是,8086 处理器已经成为历史,它之后的处理器都能够兼容8086 的功能,但却拥有超过32 根的地址线。在当前的这个Bochs 虚拟机上,地址线的数量是32 根。因此,Bochs 在这里用64 位的宽度来显示物理地址。但是,它的值应该是0x00000000000FFFF0,不是吗?
事情是这样的,和8086 不同,现代处理器在加电启动时,段寄存器CS 的内容为0xF000,指令指针寄存器IP 的内容为0xFFF0,这就使得处理器地址线的低20 位同样是0xFFFF0。这还不算完,在刚刚启动时,处理器将其余(高位部分)的地址线强制为高电平。因为当前Bochs 虚拟机的地址线是32 根,所以,初始发出的物理内存地址就是0x00000000FFFFFFF0 了。
之所以这样做,是因为处理器的设计者希望把ROM-BIOS 放到4GB(32 根地址线可提供的寻址范围是2 32 =4GB)可寻址内存范围的最高端,这样,4GB 以下,连同传统的低端1MB 都是连续的RAM 区,连续的、不间断的RAM 能为操作系统管理内存带来方便。
问题在于,计算机制造商们会考虑很多现实问题。老的硬件和软件依赖于低端1MB 的ROMBIOS 来工作,这涉及到兼容性。最终,这两个地址区段都指向同一块ROM 芯片。
在物理地址的后边,是逻辑地址,即段寄存器CS 和指令指针寄存器IP 的内容,是以十六进制显示的,等效于0xf000:0xfff0。在这一行的右边,Bochs 还以注释的形式显示了指令的机器代码,即EA 5B E0 00 F0。
现在的情况是,Bochs 还没有执行该指令,它需要你的指示。此时,你可以单步执行指令。单步执行的意思是,每次只执行一条指令,执行完毕后再次停下来等待你的命令。
单步执行命令是“s”(step)。如图5-11 所示,输入“s”命令后回车,Bochs 执行刚才那条指令,然后停下来,同时显示下一条即将执行的指令。
图5-11 在Bochs 中单步执行指令
如图中所示,指令执行后,下一条等待执行的指令为xor ax,ax,对应的机器指令码为31 C0,所在的物理内存地址是0x00000000000FE05B。注意,物理地址变了。
现代的x86 处理器在加电后,所有高端的地址线都被强制为高电平,直至遇到并执行了第一个段间转移指令。段间转移指令是在两个代码段之间实施控制转移,也就是同时改变段寄存器CS和指令指针寄存器IP 的JMP 指令,像jmp 0xf000:0xe05b 就是一个典型的例子。因此,当该指令执行后,处理器发出的物理地址就仅仅取决于CS 和IP 了。
接下来,你可以继续单步执行。但是,老在BIOS 中转悠也没什么意思。要知道,你调试的程序位于主引导扇区中。依靠单步执行,得什么时候才能执行到主引导扇区代码!
不用担心,Bochs 提供了断点指令“b”(break)。所谓断点,就是事先设置一个(物理)内存地址,当处理器执行到这个地址时,就自动停下来。因为计算机启动后,总是把主引导程序加载到物理内存地址0x7c00 处,所以,可以将这个地址设为断点。
如图5-12 所示,输入“b 0x7c00”。意思是,在处理器执行到地址0x7c00 处的那条指令时,就停下来。然后,再输入命令“c”。
图5-12 在Bochs 中设置断点
命令“c”(continue)是持续执行的意思,该命令要求处理器不间断地持续执行指令。但是,如果设置了断点,它就会在断点处停下来。因此,如图5-13 所示,当“c”命令执行后,它会在执行到物理内存地址0x7c00 时停下来。
图5-13 Bochs 执行到主引导扇区代码时的状态
如图中所示,当前等待执行的指令是mov ax,0xb800,这就是本章源代码的第一条指令;该指令的物理地址是0x0000000000007C00,指令的机器代码为B8 00 B8。
如图5-14 所示,此时,可以输入命令“r”(register)来显示通用寄存器的内容。
图5-14 用“r”命令显示通用寄存器的内容
我知道,对于图中的内容,你一定会摇摇头表示看不懂,这其实很正常。我们此时正在介绍8086 处理器,如图5-15 所示,它有8 个16 位的通用寄存器AX、BX、CX、DX、SI、DI、BP 和SP。其中,前4 个寄存器还可以各自分成两个独立的8 位寄存器来用,即AH、AL、BH、BL、CH、CL、DH 和DL;后4 个寄存器只能作为16 位寄存器整体使用。除此之外,从图中可以看出,它的指令指针寄存器IP 也是16 位的。
正如你已经知道的,8086 已经成为历史,现在我们所使用的处理器,都是32 位或者64 位的。32 位x86 处理器对寄存器做了扩展,使之达到32 位,以处理32 位的数据。如图中所示,这8 个32 位寄存器分别是EAX、EBX、ECX、EDX、ESI、EDI、EBP 和ESP,它们可以在程序中直接做为32 位寄存器使用。同时,指令指针寄存器IP 也做了扩展,达到32 位,即EIP。为了保持同8086 的兼容性,这些寄存器的低16 位依然保持以前的用法,这使得以前的程序可以在32 位处理器上正常运行。
图5-15 通用寄存器的扩展示意图
在64 位处理器上,这些寄存器再次被扩展,达到了64 位,即RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP 和RIP。同时,它们的低32 位(包括低16 位)依然保持从前的用法。
除此之外,64 位的x86 处理器还新增了8 个64 位的寄存器R8、R9、R10、R11、R12、R13、R14 和R15,它们只能整体作为64 位的寄存器来用。
屏幕的底部还显示了标志寄存器EFLAGS 的状态。有关标志寄存器的内容将在后面的章节里具体阐述,这里先不用管它。有关32 位处理器的内容,将在本书的后半部分讲解;有关64 位处理器的内容,将在本套图书的其他分册讲解。
注意,尽管Bochs 把所有寄存器都显示为64 位的宽度,如RAX,但这并不表明你的处理器就一定是64 位的。它的目的很简单,仅仅是希望用同一种最宽的格式来应付所有不同的处理器。
这样一说你就应该很清楚了,如前图5-14 所示,RAX 的内容是0x000000000000aa55,这就意味着,RAX 的高48 位是全零,低16 位(即AX)是0xAA55。
再比如那幅图中,RIP 的内容是0x0000000000007c00,它表明RIP 寄存器的高48 位是全零,低16 位(即IP)是0x7C00。
我们调试到哪一步了?
如图5-14 所示,当前正在等待执行指令是mov ax,0xb800。现在,我们用“s”命令单步执行该指令。如图5-16 所示,单步执行之后,下一条等待执行的指令是mov es,ax,该指令的物理内存地址是0x0000000000007C03。
因为刚才那条指令是将立即数0xB800 传送到寄存器AX,那么,我们现在可以用“r”命令来看看寄存器AX 的内容是否真的发生了改变。如图中所示,寄存器AX 的内容是0xB800,确实符合我们的预期。
图5-16 观察指令执行后的效果(寄存器AX 的变化)
接下来,继续用单步指令“s”来执行mov es,ax 指令。如图5-17 所示,该指令执行后,下一条即将执行的指令是
Bochs 的汇编指令格式和NASM 相比,在某些方面是不同的。实际上,这条指令就是本章程序中的
因为字面值’L’早在程序编译时就被转换成了立即数0x4c,所以,严格地说,这条指令在NASM 中的格式是
无论如何,这条指令还没有执行,刚才执行的是mov es,ax 指令。此时,段寄存器ES 中的内容应当是0xB800。
为了验证这一点,应当要求Bochs 显示段寄存器的内容。为此,需要使用“sreg”(segment register)命令。如图中所示,当输入“sreg”命令后,Bochs 显示了一大堆东西。
在32 位和64 位处理器中,除了段寄存器CS、SS、DS 和ES 外,还新增了两个段寄存器FS 和GS,这一点首先要明白。
然后,在32 位和64 位处理器中,以上6 个段寄存器都依然是16 位的,但都额外增加了一个不可访问的部分,叫做段描述符高速缓存器。段描述符高速缓存器由处理器内部使用,不能在程序中访问,里面存放了段的起始地址、段的扩展范围,以及段和各种属性,比如它是代码段还是数据段,是否可以写入,是否被访问过,等等。这些知识,将在本书的后半部分详细讲解。
如图中所示,Bochs 首先显示了段寄存器ES 中的内容,是0xb800,这符合我们的预期。同时,它还显示了ES 描述符高速缓存器的内容,因为还没有讲到,所以暂时不用管它。
图5-17 在Bochs 中显示段寄存器的内容
接下来,如图5-18 所示,我们连续单步执行两次。对照本章的源程序,这实际上是执行了以下两条指令:
我们知道,这是在写文本模式下的显示缓冲区。因此,从物理内存地址0xB8000 处开始的两个字节必然是0x4C 和0x07。
图5-18 显示内存区域中的内容
为了验证这一点,需要显示内存中的内容,这可以使用命令“xp”(eXamine memory at Physical address),即,显示指定物理内存地址处的内容。xp 命令每次只显示一个双字。要显示多个双字,需要用“/”附加一个数量。然后,还应当指定一个物理内存地址。
如图5-18 所示,在这里,我们要求从物理内存地址0xB8000 开始,显示2 个双字。很快,Bochs 做出了回应,显示了两个双字0x0b6c074c 和0x0b780b65。
如图5-19 所示,双字数据在内存中的存放是按低端字节序的。因此,0x0b6c074c 这个双字数据,在内存中对应着从物理地址0xB8000 开始的4 个字节0x4C、0x07、0x6C 和0x0B。
至此,基本的程序调试技术就讲完了,你可以使用“q”(quit)命令退出Bochs 调试过程。
图5-19 以低端字节序分析双字在内存中的位置
检测点5.5
1. 在你自己的计算机上重现以上的编译、运行(使用VirtualBox)和调试(使用bochsdbg)过程。
2. 单步执行本章程序,观察div 指令执行后的寄存器内容变化。