在学习汇编语言程序设计时,如果结合具体的实例来学习,把汇编技术融入一些具体问题的解决过程当中,将能获得很好的学习效果。
初学者在写第一个程序时,都有一种在屏幕上显示点什么的想法,这是很正常的,可以理解,因为屏幕是最直观的,能够看出程序的运行是否正常,是否符合设计时的预期。为此,本章将带你了解如何控制显卡在屏幕上显示字符。当然,这并不是主要目的,真正的目的在于用这个具体的实例,让你学习到以下知识:
1.NASM汇编语言源程序的一般组成部分,如标号、指令、伪指令和注释等;
2.进一步学习mov指令和jmp指令的更多用法,以及加法指令add、除法指令div和异或指令xor的用法;
3.处理器的工作是取指令、执行指令,包括数据访问。而这一切,都是通过分段机制来完成的。在本章中,通过编写程序、分析程序的执行过程,观察程序的执行结果,进一步加深对内存分段访问机制的感性认识和对处理器工作过程的理解。
本章有配套的汇编语言源程序,并围绕这些源程序进行讲解,请对照阅读。
本章代码清单:6-1(主引导扇区程序)
源程序文件:c06_mbr.asm
在前面的预备知识里我们已经知道,处理器加电或者复位之后,如果硬盘是首选的启动设备,那么,ROM-BIOS将试图读取硬盘的0面0道1扇区。传统上,这就是主引导扇区(Main Boot Sector,MBR)。
读取的主引导扇区数据有512字节,ROM-BIOS程序将它加载到逻辑地址0x0000:0x7c00处,也就是物理地址0x07c00处,然后判断它是否有效。
一个有效的主引导扇区,其最后2字节应当是0x55和0xAA。ROM-BIOS程序首先检测这两个标志,如果主引导扇区有效,则以一个段间转移指令jmp 0x0000:0x7c00跳到那里继续执行。
一般来说,主引导扇区是由操作系统负责的。正常情况下,一段精心编写的主引导扇区代码将检测用来启动计算机的操作系统,并计算出它所在的硬盘位置。然后,它把操作系统的自举代码加载到内存,也用jmp指令跳转到那里继续执行,直到操作系统完全启动。
在本章中,我们将试图写一段程序,把它编译之后写入硬盘的主引导扇区,然后让处理器执行。当然,仅仅执行还不够,还必须在屏幕上显示点什么,要不然的话,谁知道我们的程序是不是成功运行了呢?
通过本章的学习,我们可以对处理器如何执行指令、如何访问内存及如何进行算术逻辑运算有一个基本的认知。
如本章代码清单6-1所展示的那样,在汇编语言源程序里,注释用于说明本程序的用途和编写时间等,可以单独成行,也可以放在每条指令的后面,解释本指令的目的和功能。注释不但有助于其他编程人员理解当前程序的编写思路和工作原理,而且也能帮助你自己在以后的某个时间重拾这些记忆。
注释必须以英文字母“;”开始。
在源程序编译阶段,编译器将忽略所有注释。因此,在编译之后,这些和生成机器代码无关的内容都统统消失了。
本程序首先要做的事是在屏幕上显示一行文字。当然,要想在屏幕上显示文字,就需要先了解文字是如何显示在屏幕上的。
为了显示文字,通常需要两种硬件,一是显示器,二是显卡。显卡的职责是为显示器提供内容,并控制显示器的显示模式和状态,显示器的职责是将那些内容以视觉可见的方式呈现在屏幕上。
一般来说,显卡都是独立生产、销售的部件,需要插在主板上才能工作。当然,像处理器、内存这样的东西,也位于主板上。每台计算机都有主板,它就在机箱内部,有时间你可以打开机箱来观察一下。
当然,显卡未必一定是独立的插卡。为了节省使用者的成本,有的显卡会直接做在主板上,这样的显卡也有个名字,叫集成显卡。
显卡控制显示器的最小单位是像素,一个像素对应着屏幕上的一个点。屏幕上通常有数十万乃至更多的像素,通过控制每个像素的明暗和颜色,我们就能让这大量的像素形成文字和美丽的图像。
不过,一个很容易想到的问题是,如何来控制这些像素呢?
答案是显卡都有自己的存储器,因为它位于显卡上,故称显示存储器(Video RAM,VRAM),简称显存,要显示的内容都预先写入显存。和其他半导体存储器一样,显存并没有什么特殊的地方,也是一个按字节访问的存储器件。
对显示器来说,显示黑白图像是最简单的,因为只需要控制每个像素是亮,还是不亮。如果把不亮当成比特“0”,亮看成比特“1”,那就好办了。因为,只要将显存里的每个比特和显示器上的每个像素对应起来,就能实现这个目标。
如图6-1所示,显存的第1字节对应着屏幕左上角连续的8个像素;第2字节对应着屏幕上后续的8个像素,后面的依次类推。
图6-1 显存内容和显示器内容之间的对应关系
显卡的工作是周期性地从显存中提取这些比特,并把它们按顺序显示在屏幕上。如果是比特“0”,则像素保持原来的状态不变,因为屏幕本来就是黑的;如果是比特“1”,则点亮对应的像素。
继续观察图6-1,假设在显存中,第1字节的内容是11110000,第2字节的内容是11111111,其他所有的字节都是00000000。在这种情况下,屏幕左上角先是显示4个亮点,再显示4个黑点,然后再显示8个亮点。因为像素是紧挨在一起的,所以我们看到的先是一条白短线,隔着一定距离(4个像素)又是一条白长线。
黑色和白色只需要1比特就能表示,但要显示更多的颜色,1比特就不够了。现在最流行的,是用24比特,即3字节,来对应一个像素。因为2 24 =16777216,所以在这种模式下,同屏可以显示16777216种颜色,这称为真彩色。有关颜色的显示和它们与字长的关系,在《穿越计算机的迷雾》一书中有详细的介绍,这里不再赘述。
上面所讨论的,是人们常说的图形模式。图形模式是最容易理解的,同时对显示器来说也是最自然的模式。
现在是图形的时代,就连手机的屏幕都是五彩缤纷的。时光倒退到几十年前,在那个时代,真彩色还没有出现,显示器只能提供有限的色彩,处理器也不够强劲(以今天的眼光来看)。在这种情况下,人们不太可能认为图形显示技术有多么重要,因为他们不看高清电影,也没有数码相机,用计算机制作动画片更是不能想象的事。那个时候,人们的愿望很简单,只要能显示文字就行。
不管是显示图片,还是文字,对显示器来说没有什么不同,因为所有的内容都是由像素组成的,区别仅仅在于这些像素组成的是什么。有时候,人们会说,哦,显示的是一棵树;有时候,人们会说,哦,显示的是一个字母“H”。
问题是,操作显存里的比特,使得屏幕上能显示出字符的形状,是非常麻烦、非常烦琐的工作,因为你必须计算该字符所对应的比特位于显存里的什么位置。
为了方便,工程师们想出了一个办法。就像一个二进制数既可以是一个普通的数,也可以代表一条处理器指令一样,他们认为每个字符也可以表示成一个数。比如,数字0x4C就代表字符“L”,这个数被称为是字符“L”的ASCII代码,后面会讲到。
如图6-2所示,可以将字符的代码存放到显存里,第1个代码对应着屏幕左上角第1个字符,第2个代码对应着屏幕左上角第2个字符,后面的依次类推。剩下的工作是如何用代码来控制屏幕上的像素,使它们或明或暗以构成字符的轮廓,这是字符发生器和控制电路的事情。
图6-2 字符在屏幕上的显示原理
传统上,这种专门用于显示字符的工作方式称为文本模式。文本模式和图形模式是显卡的两种基本工作模式,可以用指令访问显卡,设置它的显示模式。在不同的工作模式下,显卡对显存内容的解释是不同的。
为了给出要显示的字符,处理器需要访问显存,把字符的ASCII码写进去。但是,显存是位于显卡上的,访问显存需要和显卡这个外围设备打交道。同时,多一道手续自然是不好的,这当中最重要的考量是速度和效率。想想看,你让人传话给父母,和自己亲自往家里打电话,花费的时间是不一样的。为了实现一些快速的游戏动画效果,或者播放高码率的电影,不直接访问显存是办不到的。
为此,计算机系统的设计者们,这些敢想敢干的人,决定把显存映射到处理器可以直接访问的地址空间里,也就是内存空间里。
如图6-3所示,我们知道,8086可以访问1MB内存。其中,0x00000~9FFFF属于常规内存,由内存条提供;0xF0000~0xFFFFF由主板上的一个芯片提供,即ROM-BIOS。
这样一来,中间还有一个320KB的空洞,即0xA0000~0xEFFFF。传统上,这段地址空间由特定的外围设备来提供,其中就包括显卡。因为显示的功能对于现代计算机来说实在是太重要了。
由于历史的原因,所有在个人计算机上使用的显卡,在加电自检之后都会把自己初始化为80×25的文本模式。在这种模式下,屏幕上可以显示25行,每行80个字符,每屏总共2000个字符。
所以,如图6-3所示,一直以来,0xB8000~0xBFFFF这段物理地址空间,是留给显卡的,由显卡来提供,用来显示文本。除非显卡出了毛病,否则这段空间总是可以访问的。如果显卡出了毛病怎么办呢?很简单,计算机一定不会通过加电自检过程,这就是传说中的严重错误,计算机是无法启动的,更不要说加载并执行主引导扇区的内容了。
图6-3 文本模式下显存到内存的映射
和访问主内存一样,为了访问显存,也需要使用逻辑地址,也就是采用“段地址:偏移地址”的形式,这是处理器的要求。考虑到文本模式下显存的起始物理地址是0xB8000,这块内存可以看成段地址为0xB800,偏移地址从0x0000延伸到0xFFFF的区域,因此我们可以把段地址定为0xB800。
访问内存可以使用段寄存器DS,但这不是强制性的,也可以使用ES。因为DS还有别的用处,所以在这里我们使用ES来指向显存所在的段。
源程序第6、7行,首先把立即数0xB800传送到AX,然后再把AX的值传送到ES。这样一来,附加段寄存器ES就指向0xB800段(段基地址为0xB800)。
你可能会想,为什么不直接这样写:
而要用寄存器AX来中转呢?
原因是不存在这样的指令,INTEL处理器不允许将一个立即数传送到段寄存器,它只允许这样的指令:
没有人能够说清楚这里面的原因,INTEL公司似乎也从没有提到过这件事,尽管从理论上,这是可行的。我们只能想,也许INTEL是出于好心,避免我们无意中犯错,毕竟,段地址一旦改变,后面对内存的访问都会受到影响。理论上,麻烦一点的方法,可以保证你确实知道自己在做什么。
一旦将显存映射到处理器的地址空间,我们就可以使用普通的传送指令(mov)来读写它,这无疑是非常方便的。现在,我们已经把0xB800作为段地址传送到附加段寄存器ES了,以后就用ES来读写显存。这样,段内偏移为0的位置就对应着屏幕左上角的字符。
在计算机中,每个用来显示在屏幕上的字符,都有一个二进制代码。这些代码和普通的二进制数字没有什么不同,唯一的区别在于,发送这些数字的硬件和接收这些数字的硬件把它们解释为字符,而不是指令或者用于计算的数字。
这就是说,在计算机中,所有东西都是无差别的数字,它们的意义只取决于生成者和使用者之间的约定。为了在终端和大型主机,以及主机和打印机、显示器之间交换信息,1967年,美国国家标准学会制定了美国信息交换标准代码(American Standard Code for Information Interchange,ASCII),如表6-1所示。
表6-1 ASCII表
在不同设备之间,或者在同一设备的不同模块之间有一个信息传递标准是非常必要的。想想看,当你用手机向朋友发送短消息时,这些文字当然被编码成二进制数字。如果对方的手机使用了不同的编码,那么他将无法正确还原这些消息,而很可能显示为乱码。
值得注意的是,ASCII是7位代码,只用了一字节中的低7比特,最高位通常置0。这意味着,ASCII只包含128个字符的编码。所以,在表中,水平方向给出了代码的高3比特,而垂直方向给出了代码的低4比特。比如字符“*”,它的代码是二进制数的010 1010,即0x2A。
ASCII表中有相当一部分代码是不可打印和显示的,它们用于控制通信过程。比如,LF是换行;CR是回车;DEL和BS分别是删除和退格,在我们平时用的键盘上也是有的;BEL是振铃(使远方的终端响铃,以引起注意);SOH是文头;EOT是文尾;ACK是确认。
在计算机发展的早期,还没有显示器和独立的键盘,计算机应用的典型场景是将电传打字机通过通信线路连接到IBM大型主机上,来使用主机提供的计算能力。电传打字机集成了键盘、打印和通信功能,可以向远程的主机发送操作命令,并接受主机的远程控制。在这个时候,ASCII中的控制字符是非常重要的,但现在已经没有什么用处了。
注意,一定要遵从约定。比如,你在处理器上编写程序算了一道数学题2+3,你也希望把结果5显示在屏幕上。这个时候,算出的结果是0000 0101,即0x05。但是,数字5和字符5是不同的,显卡在任何时候都认为你发送的是ASCII码。所以,你不应该发送0x05,而应该发送0x35。
屏幕上的每个字符对应着显存中连续2字节,前一个是字符的ASCII代码,后面是字符的显示属性,包括字符颜色(前景色)和底色(背景色)。如图6-4所示,字符“H”的ASCII代码是0x48,其显示属性是0x07;字符“e”的ASCII代码是0x65,其显示属性是0x07。
图6-4 字符代码及字符属性
如图6-4所示,字符的显示属性(1字节)分为两部分,低4位定义的是前景色,高4位定义的是背景色。色彩主要由R、G、B这3位决定,毕竟我们知道,可以由红(R)、绿(G)、蓝(B)三原色来配出其他所有颜色。K是闪烁位,为0时不闪烁,为1时闪烁;I是亮度位,为0时正常亮度,为1时呈高亮。表6-2给出了背景色和前景色的所有可能值。
表6-2 80×25文本模式下的颜色表
从表6-2来看,图6-4中的字符属性0x07可以解释为黑底白字,无闪烁,无加亮。
你可能觉得奇怪,当屏幕上一片漆黑,什么内容都没有的时候,显存里会是什么内容呢?
实际上,这个时候屏幕上显示的全是黑底白字的空格字符(Space),它的ASCII代码是0x20,当你用大拇指按动键盘上最长的那个键时,就产生这个字符。空格只占用一个字符的位置,但没有图形轮廓,自然就无法在黑底上看到任何痕迹了。
从源程序的第10行开始,到第35行,目的是显示一串字符“Label offset:”。为此,需要把每个字符的ASCII码顺序写到显存中。
为了方便,多数汇编语言编译器允许在指令中直接使用字符的字面值来代替数值形式的ASCII码,比如源程序第10行:
这等效于
尽管通过查表可以知道字符“L”的ASCII代码是0x4C,但毕竟费事。不过,要在指令中使用字符的字面值,这个字符必须用引号围起来,就像上面一样。在源程序的编译阶段,汇编语言编译器会将它转换成ASCII码的形式。
当前的mov指令是将立即数传送到内存单元,目的操作数是内存单元,源操作数是立即数(ASCII代码)。为了访问内存单元,需要给出段地址和偏移地址。在这条指令中,偏移地址为0x00,段地址在哪里呢?一般情况下,如果没有附加任何指示,段地址默认在段寄存器DS中。比如:
当执行这条指令后,处理器把段寄存器DS的内容左移4位(相当于乘以十进制数16或者十六进制数0x10),加上这里的偏移地址0x00,就得到了物理地址。
但实际上,在我们的程序中,显存的段地址位于段寄存器ES中,我们希望使用段寄存器ES来访问内存。因此,这里使用了段超越前缀“es:”。这就是说,我们明确要求处理器在生成物理地址时,使用段寄存器ES,而不是默认情况下的段寄存器DS。
因为指令中给出的偏移地址是0x00,且段寄存器ES的值已经在前面被设为0xB800,故它指向段寄存器ES段中,偏移地址为0的内存单元,即0xB800:0x0000,也就是物理地址0xB8000,这个内存单元对应着屏幕左上角第一个字符的位置。
还需要注意的是,因为目的操作数给出的是一个内存地址,我们要用源操作数来修改这个地址里的内容,所以,目的操作数必须用方括号围起来,以表明它是一个地址,处理器应该用这个地址再次访问内存,将源操作数写进这个单元。实际上,这类似于高级语言里的指针。
最后,关键字“byte”用来修饰目的操作数,指出本次传送是以字节的方式进行的。在16位的处理器上,单次操作的数据宽度可以是8位,也可以是16位。到底是8位,还是16位,可以根据目的操作数或者源操作数来判断。遗憾的是,在这里,目的操作数是偏移地址0x00,它可以是字节单元,也可以是字单元,到底是哪一种,无法判断;而源操作数呢,是立即数0x4C,它既可以解释为8位的0x4C,也可以解释为16位的0x004C。在这种情况下,编译器将无法搞懂你的真实意图,只能报告错误,所以必须用“byte”或者“word”进行修饰(明确指示)。于是,一旦目的操作数被指明是“byte”的,那么,源操作数的宽度也就明确了。相反地,下面的指令就不需要任何修饰:
因为屏幕上的一个字符对应着内存中的2字节:ASCII代码和属性,所以,源程序第11行的功能是将属性值0x07传送到下一个内存单元,即偏移地址0x01处。这个属性可以解释为黑底白字,无闪烁,也无加亮,请参阅表6-2。
后面,从第12行开始,到第35行,用于向显存(或者叫显示缓冲区)填充剩余部分的字符。注意,在这个过程中,偏移地址一直是递增的。
到目前为止,我们已经多次接触了mov指令。在处理器的整个指令集中,mov指令是用得最多的一条。
mov指令用于数据传送。既然是数据传送,那么,目的操作数的作用应该相当于一个“容器”,故必须是通用寄存器或者内存单元;源操作数呢,也可以是和目的操作数具有相同数据宽度的通用寄存器和内存单元,还可以是立即数。传送指令只影响目的操作数的内容,不改变源操作数的内容。比如:
以上,第一条指令的目的操作数和源操作数都是8位寄存器,指令执行后,寄存器AH的内容和寄存器BH相同;第二条指令的目的操作数和源操作数都是16位寄存器,指令执行后,寄存器AX的内容和寄存器DX相同。但是,由于数据宽度不同,下面这条指令就是错误的:
再来看下面两条指令:
以上,第一条指令是把寄存器BH中的内容传送到偏移地址为0x02的8位内存单元;第二条指令是把偏移地址为0x06的16位内存单元里的内容传送到寄存器AX中。由于这两条指令中都有寄存器操作数,故不需要用“byte”或者“word”来修饰。
传送指令的源操作数也可以是立即数。比如:
以上,第一条指令是把立即数0x05传送到寄存器AH中,指令执行后,寄存器AH中的内容为0x05;第二条指令是把立即数0xf000传送到偏移地址为0x1c的16位内存单元中。因为上一节所说的原因,这里要用word来修饰。
mov指令的目的操作数不允许为立即数,而且,目的操作数和源操作数不允许同时为内存单元。因此,下面两条指令都是不正确的:
以上,说第一条指令是错误的,这很好理解。想想看,你把寄存器AL中的内容传送给一个立即数,这是什么意思呢?于理不通。至于第二条指令为什么不正确,那是因为处理器不允许在两个内存单元之间直接进行传送操作。事实上,这条指令的功能可以用两条指令实现(假设传送的是一个字):
就算处理器支持在两个内存单元之间直接传送数据,那么,它依然是在内部按上面的两个步骤进行操作的。而且,支持这种直接传送操作的指令还需要增加额外的电路。
不单是mov指令,其他指令都不支持在两个内存单元之间直接进行操作,包括加、减、乘、除和逻辑运算等指令。事情是明摆着的,既然增加了处理器的复杂性和用两条指令没什么区别,干脆就用两条指令好了。
◆ 检测点6.1
1.在我们日常使用的个人计算机上,在文本模式下的显示缓冲区被映射到物理内存地址空间,起始地址为( ),它对应的段地址为( )。在标准的80×25文本模式下,要想在屏幕右下角显示一个绿底白字的字符“H”,那么,应当在该段内偏移量为( )的地方开始,连续写入2字节( )和( )。
2.以下指令,哪些是不正确的,不正确的原因是什么?
A.mov al,0x55aa B.mov ds,0x6000 C.mov ds,al
D.mov [0x06],0x55aa E.mov ds,bx F.mov ax,0x02
G.mov word[0x0a],ax H.mov es,cx I.mov ax,bl
J.mov byte[0x00],'c' K.mov[0x02],[0xf 000] L.mov ds,[0x03]
处理器访问内存时,采用的是“段地址:偏移地址”的模式。对于任何一个内存段来说,段地址可以开始于任何16字节对齐的地方,偏移地址则总是从0x0000开始递增的。
为了支持这种内存访问模式,在源程序的编译阶段,编译器会把代码清单6-1整体上作为一个独立的段来处理,并从0开始计算和跟踪每条指令的地址。因为该地址是在编译期间计算的,故称为 汇编地址 。汇编地址是在源程序编译期间,编译器为每条指令确定的汇编位置(Assembly Position),指示该指令相对于程序或者段起始处的距离,以字节计。当编译后的程序装入物理内存后,它又是该指令在内存段内的偏移地址。
如表6-3所示,在用我们的配书工具Nasmide书写并编译代码清单6-1后,除了生成一个以“.bin”为扩展名的二进制文件,还会生成一个以“.lst”为扩展名的列表文件。这张表列出的,就是本章代码清单6-1编译后生成的列表文件内容。
表6-3 代码清单6-1编译后的列表文件内容
续表
续表
续表
表6-3共分五栏,从左到右依次是行号、指令的汇编地址、指令编译后的机器代码、源程序代码和注释。可以看出,第一条指令mov ax,0xb800的汇编地址是0x00000000,对应的机器代码为B8 00 B8;第二条指令mov es,ax的汇编地址是0x00000003,机器代码为8E C0。
从表6-3中可以看出,在编译阶段,每条指令都被计算并赋予了一个汇编地址,就像它们已经被加载到内存中的某个段里一样。实际上,如图6-5所示,当编译好的程序加载到物理内存后,它在段内的偏移地址和它在编译阶段的汇编地址是相同的。
图6-5 汇编地址和偏移地址的关系
正如图6-5所示,编译后的程序是整体加载到内存中某个段的,交叉箭头用于指示它们之间的映射关系。之所以箭头是交叉的,是因为源程序的编译是从上往下的,而内存地址的增长是从下往上的(从低地址往高地址方向增长)。
在图6-5中,假定程序是从内存物理地址0x60000开始加载的。该物理地址也对应着逻辑地址0x6000:0x0000,因此我们可以说,该程序位于段0x6000内。
在编译阶段,源程序的第一条指令mov ax,0xb800的汇编地址是0x00000000,而它在整个程序装入内存后,在段内的偏移地址是0x0000,即逻辑地址0x6000:0000,两者的偏移地址是一致的。
再看源程序的第二条指令,是mov es,ax,它在编译阶段的汇编地址是0x00000003。在整个程序装入内存后,它在段内的偏移地址是0x0003,也没有变化。
这就很好地说明了汇编地址和偏移地址之间的对应关系。理解这一点,对后面的编程很重要。
在NASM汇编语言里,每条指令的前面都可以拥有一个标号,以代表和指示该指令的汇编地址。毕竟,由我们自己来计算和跟踪每条指令所在的汇编地址是极其困难的。这里有一个很好的例子,比如源程序第98行:
在这里,行首带冒号的是标号是“infi”。请看表6-3,这条指令的汇编地址是0x0000012B,故infi就代表数值0x0000012B,或者说是0x0000012B的符号化表示。
标号之后的冒号是可选的。所以下面的写法也是正确的:
标号并不是必需的,只有在我们需要引用某条指令的汇编地址时,才使用标号。正是因为这样,本章源程序中的绝大多数指令都没有标号。
标号可以单独占用一行的位置,像这样:
这种写法和第98行相比,效果并没有什么不同,因为infi所在的那一行没有指令,它的地址就是下一行的地址,换句话说,和下一行的地址是相同的。
标号可以由字母、数字、“_”、“$”、“#”、“@”、“~”、“.”、“?”组成,但必须以字母、“.”“_”和“?”中的任意一个打头。
注意,汇编器输出的.lst列表文件通常只是一个初步的编译结果,还需要后续的处理,所以这个文件并不能反映编译后的结果,与编译后的结果可能会有出入,在分析程序的结果时,不要完全依赖于这个文件,而要以实际的编译结果为准。
我们已经知道,标号代表并指示它 所在位置处的汇编地址 。现在,我们要编写指令,在屏幕上把这个地址的数值显示出来。为此,源程序的第37行用于获取标号所代表的汇编地址:
标号“number”位于源程序的第100行,只不过后面没有跟着冒号“:”。你当然可以加上冒号,但这无关紧要。注意,传送到寄存器AX的值是在源程序编译时确定的,在编译阶段,编译器会将标号number转换成立即数。如表6-3所示,标号number处的汇编地址是0x012E,因此,这条语句其实就是(等效于)
问题在于,如果不是借助于别的工具和手段,你不可能知道此处的汇编地址是0x012E。所以,在汇编语言中使用标号的好处是不必关心这些。
因此,当这条指令编译后,得到的机器指令为B8[2E01],或者B8 2E 01。B8是操作码,后面是字操作数0x012E,只不过采用的是低端字节序。
十六进制数0x012E等于十进制数302,但是,通过前面对字符显示原理的介绍,我们应该清楚,直接把寄存器AX中的内容传送到显示缓冲区,是不可能在屏幕上出现“302”的。
解决这个问题的办法是将它的每个数位单独拆分出来,也就是分解它的每个数位。使用传统的数位分解方法,需要不停地除以10,每次的余数就是分解出来的数位。什么时候商为0,分解过程就结束了。
考虑到寄存器AX是16位的,可以表示的数从二进制的0000000000000000到1111111111111111,也就是十进制的0~65535,故它可以容纳最大5个数位的十进制数,从个位到万位,比如61238。那么,假如你并不知道它是多少,只知道它是一个5位数,如何通过分解得到它的每个数位呢?
首先,用61238除以10,商为6123,余8,本次相除的余数8就是个位数字;
然后,把上一次的商数6123作为被除数,再次除以10,商为612,余3,余数3就是十位数字;
接着,再用上一次的商数612除以10,商为61,余2,余数2就是百位数字;
同上,再用61除以10,商为6,余1,余数1就是千位数字;
最后,用6除以10,商为0,余6,余数6就是万位数字。
很显然,只要把AX的内容不停地除以10,只需要5次,把每次的余数反向组合到一起,就是原来的数字。同样,如果反向把每次的余数显示到屏幕上,应该就能看见这个十进制数是多少了。
不过,即使是得到了单个的数位,也还是不能在屏幕上显示,因为它们是数字,而非ASCII代码。比如,数字0x05和字符“5”是不同的,后者实际上是数字0x35。
观察表6-1,你会发现,字符“0”的ASCII代码是0x30,字符“1”的ASCII代码是0x31,字符“9”的ASCII代码是0x39。这就是说,把每次相除得到的余数加上0x30,在屏幕上显示就没问题了。
可以用处理器提供的除法指令来分解一个数的各个数位,但是每次除法操作后得到的数位需要临时保存起来以备后用。使用寄存器不太现实,因为它的数量很少,且还要在后续的指令中使用。因此,最好的办法是在内存中专门留出一些空间来保存这些数位。
尽管我们的目的仅仅是分配一些空间,但是,要达到这个目的必须初始化一些初始数据来“占位”。这就好比排队买火车票,你可以派任何无关的人去帮你占个位置,真正轮到你买的时候,你再出现。源程序的第100行用于声明并初始化这些数据,而标号number则代表了这些数据的起始汇编地址。
要放在程序中的数据是用DB指令来声明(Declare)的,DB的意思是声明字节(Declare Byte),所以,跟在它后面的操作数都占一字节的长度(位置)。注意,如果要声明超过一个以上的数据,各个操作数之间必须以逗号隔开。
除此之外,DW(Declare Word)用于声明字数据,DD(Declare Double Word)用于声明双字(两个字)数据,DQ(Declare Quad Word)用于声明四字数据。DB、DW、DD和DQ并不是处理器指令,它只是编译器提供的汇编指令,所以称作伪指令(Pseudo Instruction)。伪指令是汇编指令的一种,它没有对应的机器指令,所以它不是机器指令的助记符,仅仅在编译阶段由编译器执行,编译成功后,伪指令就消失了。所以在程序执行时,伪指令是得不到处理器光顾的。实际上,程序执行时,伪指令已不存在。
声明的数据可以是任何值,只要不超过伪指令所指示的大小。比如,用DB声明的数据,不能超过一字节所能表示的数的大小,即0xFF。我们在此声明了5字节,并将它们的值都初始化为0。
和指令不同,对于在程序中声明的数值,在编译阶段,编译器会在它们被声明的汇编地址处原样保留。有人会问,处理器不是可以访问任何内存位置吗,为啥还要用DB声明?处理器当然可以访问任何内存位置,但那个位置可能是其他程序的,伪指令DB用来保留只供自己访问的内存位置。
按照标准的做法,程序中用到的数据应当声明在一个独立的段,即数据段中。但是在这里,为方便起见,数据和指令代码是放在同一个段中的。不过,方便是方便了,但也带来了一个隐患:如果安排不当,处理器就有可能执行到那些非指令的数据上。尽管有些数碰巧和某些指令的机器码相同,也可以顺利执行,但毕竟不是我们想要的结果,违背了我们的初衷。
好在我们很小心,在本程序中把数据声明在所有指令之后,在这个地方,处理器的执行流程无法到达。
◆ 检测点6.2
找出下面代码片段中的错误。用Nasmide程序实际编译一下,看看结果如何。
源程序第41、42行,是把代码段寄存器CS的内容传送到通用寄存器CX,然后再从寄存器CX传送到数据段寄存器DS。在此之后,数据段和代码段都指向同一个段。之所以这么做,是因为我们刚才声明的数据是和指令代码混在一起的,可以认为是位于代码段中的。尽管在指令中访问这些数据可以使用段超越前缀“CS:”,但习惯上,通过数据段来访问它们更自然一些。
前面已经说过,要分解一个数的各个数位,需要做除法。8086处理器提供了除法指令div,它可以做两种类型的除法。
第一种类型是用16位的二进制数除以8位的二进制数。 在这种情况下,被除数 必须在寄存器AX中 ,必须事先传送到寄存器AX里。除数可以由8位的通用寄存器或者内存单元提供。指令执行后,商在寄存器AL中,余数在寄存器AH中。比如:
在前一条指令中,寄存器CL用来提供8位的除数。假如寄存器AX中的内容是0x0005,寄存器CL中的内容是0x02,指令执行后,寄存器CL中的内容不变,寄存器AL中的商是0x02,寄存器AH中的余数是0x01。
在后一条指令中,除数位于数据段内偏移地址为0x0023的内存单元里。这条指令执行时,处理器将数据段寄存器DS的内容左移4位,加上偏移地址0x0023以形成物理地址。然后,处理器再次访问内存,从那个物理地址处取得一字节,作为除数同寄存器AX做一次除法。
任何时候,只要是在指令中涉及内存地址的,都允许使用段超越前缀,比如:
话又说回来了,在一个源程序中,通常不可能知道汇编地址的具体数值,只能使用标号。所以,指令中的地址部分更常见的形式是使用标号,比如:
上面的程序很有意思,首先,声明了标号dividnd并初始化了一个字0x3f0作为被除数;然后,又声明了标号divisor并初始化一字节0x3f作为除数。
在后面的mov和div指令中,用标号dividnd和divisor来代替被除数和除数的汇编地址。在编译阶段,编译器用具体的数值取代括号中的标号dividnd和divisor。现在,假设dividnd和divisor所代表的汇编地址分别是0xf000和0xf002,那么,在编译阶段,编译器在生成这两条指令的机器码之前,会先将它们转换成以下的形式:
当第一条指令执行时,处理器用0xf000作为偏移地址,去访问数据段(段地址在段寄存器DS中),来取得内存中的一个字0x3F0,并把它传送到寄存器AX中。
当第二条指令执行时,处理器采用同样的方法取得内存中的一字节0x3F,用它来和寄存器AX中的内容做除法。当然,除法指令div的功能你是知道的。
说了这么多,其实是在强调标号和汇编地址的对应关系,以及如何在指令中使用符号化的偏移地址。
图6-6 用DX:AX分解32位二进制数
第二种类型是用32位的二进制数除以16位的二进制数。 在这种情况下,因为16位的处理器无法直接提供32位的被除数,故要求 被除数的高16位在寄存器DX中,低16位在寄存器AX中。
这里有一个例子,如图6-6所示,假如被除数是十进制数2218367590,那么,它对应着一个32位的二进制数10000100001110011001101001100110。在做除法之前,先要分成两段进行“切割”,以分别装入寄存器DX和寄存器AX。为了方便,我们通常用“DX:AX”来描述32位的被除数。
同时,除数可以由16位的通用寄存器或者内存单元提供,指令执行后,商在寄存器AX中,余数在寄存器DX中。比如下面的指令:
源程序第45行把0传送到寄存器DX,这意味着,我们是想把DX:AX作为被除数,即被除数的高16位全是0。至于被除数的低16位,已经在第37行的代码中被置为标号number的汇编地址。
回到前面的第38行,该指令把10作为除数传送到通用寄存器BX中。
一切都准备好了,源程序第46行,div指令用DX:AX作为被除数,除以寄存器BX的内容,执行后得到的商在寄存器AX中,余数在寄存器DX中。因为除数是10,余数自然比10小,我们可以从寄存器DL中取得。
第1次相除得到的余数是个位上的数字,我们要将它保存到声明好的数据区中。所以,源程序第47行,我们又一次用到了传送指令,把寄存器DL中的余数传送到数据段。
可以看到,指令中没有使用段超越前缀,所以处理器在执行时,默认地使用段寄存器DS来访问内存。偏移地址是由标号number提供的,它是数据区的首地址,也可以说是数据区中第一个数据的地址。因此,number和number+0x00是一样的,没有区别。
因为我们访问的是number所指向的内存单元,故要用中括号围起来,表明这是一个地址。
令人不解的是,在第47行中,偏移地址并非理论上的number+0x00,而是0x7c00+number+0x00。这个0x7c00是从哪里来的呢?
标号number所代表的汇编地址,其数值是在源程序编译阶段确定的,而且是相对于整个程序的开头,从0开始计算的。请看一下表6-3的第37行,这个在编译阶段计算出来的值是0x012E。在运行的时候,如果该程序被加载到某个段内偏移地址为0的地方,这不会有什么问题,因为它们是一致的。
但是,事实上,如图6-7所示,这里显示的是整个0x0000段,其中深色部分为主引导扇区所处的位置。主引导扇区代码是被加载到0x0000:0x7C00处的,而非0x0000:0x0000。对于程序的执行来说,这不会有什么问题,因为主引导扇区的内容被加载到内存中并开始执行时,CS=0x0000,IP=0x7C00。
图6-7 主引导程序加载到内存后的地址变化
加载位置的改变不会对处理器执行指令造成任何困扰,但会给数据访问带来麻烦。要知道,当前数据段寄存器DS的内容是0x0000,因此,number的偏移地址实际上是0x012E+0x7C00=0x7D2E。当正在执行的指令仍然用0x012E来访问数据时,灾难就发生了。
所以,在编写主引导扇区程序时,我们就要考虑到这一点,必须把代码写成
指令中的目的操作数是在编译阶段确定的,因此,在编译阶段,编译器同样会首先将它转换成以下的形式,再进一步生成机器码:
这样,如表6-3的第47行所示,在编译后,编译器就会将这条指令编译成88 16 2E 7D,其中前2字节是操作码,后2字节是低端字节序的0x7D2E。当这条指令执行时,处理器将段寄存器DS的内容(和寄存器CS一样,是0x0000)左移4位,再加上指令中提供的偏移地址0x7D2E,就得到了实际的物理地址(0x07D2E)。
关于这条指令的另外一个问题是,虽然目的操作数也是一个内存单元地址,但并没有用关键字“byte”来修饰。这是因为源操作数是寄存器DL,编译器可以据此推断这是一字节操作,不存在歧义。
现在已经得到并保存了个位上的数字,下一步是计算十位上的数字,方法是用上一次得到的商作为被除数,继续除以10。恰好,寄存器AX已经是被除数的低16位,现在只需要把寄存器DX的内容清零即可。
为此,代码清单6-1第50行,用了一个新的指令xor来将寄存器DX的内容清零。
xor,在数字逻辑里是异或(eXclusive OR)的意思,或者叫互斥或、互斥的或运算。在《穿越计算机的迷雾》一书中,已经花了大量的篇幅讲解数字逻辑。在数字逻辑里,如果0代表假,1代表真,那么
xor指令的目的操作数可以是通用寄存器和内存单元,源操作数可以是通用寄存器、内存单元和立即数(不允许两个操作数同时为内存单元)。而且,异或操作是在两个操作数相对应的比特之间单独进行的。
一般地,xor指令的两个操作数应当具有相同的数据宽度。因此,其指令格式可以总结为以下几种情况:
因为异或操作是在两个操作数相对应的比特之间单独进行的,故以下指令执行后,寄存器AX中的内容为0xF0F3。
注意,这两条指令的源操作数都采用了二进制数的写法,NASM编译器允许使用下划线来分开它们,好处是可以更清楚地观察到那些我们感兴趣的比特。
回到当前程序中,因为指令xor dx,dx中的目的操作数和源操作数相同,那么,不管寄存器DX中的内容是什么,两个相同的数字异或,其结果必定为0,故这相当于将寄存器DX清零。
值得一提的是,尽管都可以用于将寄存器清零,但是编译后,mov dx,0的机器码是BA 00 00;而xor dx,dx的机器码则是31 D2,不但较短,而且,因为xor dx,dx的两个操作数都是通用寄存器,所以执行速度最快。
第二次相除的结果可以求得十位上的数字,源程序第52行用来将十位上的数字保存到从number开始的第2个存储单元里,即number+0x01。
从源程序第55行开始,一直到第67行,做的都是和前面相同的事情,即分解各位上的数字,并予以保存,这里不再赘述。
经过5次除法操作,可以将寄存器AX中的数分解成单独的数位,下面的任务是将这些数位显示出来,方法是从寄存器DS指向的数据段依次取出这些数位,并写入寄存器ES指向的附加段(显示缓冲区)。
在分解并保存各个数位的时候,顺序是“个、十、百、千、万”位,当在屏幕上显示时,却要反过来,先显示万位,再显示千位,等等,因为屏幕显示是从左往右进行的。所以,源程序第70行,先从数据段中偏移地址为number+0x04处取得万位上的数字,传送到寄存器AL。当然,因为程序是加载到0x0000:0x7C00处的,所以正确的偏移地址是0x7C00+number+0x04。
然后,源程序第71行,将寄存器AL中的内容加上0x30,以得到与该数字对应的ASCII代码。在这里,add是加法指令,用于将一个数与另一个数相加。
add指令需要两个操作数,目的操作数可以是8位或者16位的通用寄存器,或者指向8位或者16位实际操作数的内存地址;源操作数可以是相同数据宽度的8位或者16位通用寄存器、指向8位或者16位实际操作数的内存地址,或者立即数,但不允许两个操作数同时为内存单元。相加后,结果保存在目的操作数中。比如:
源程序第72行,将要显示的ASCII代码传送到显示缓冲区偏移地址为0x1A的位置,该位置紧接着前面的字符串“Label offset:”。显示缓冲区是由段寄存器ES指向的,因此使用了段超越前缀。
源程序第73行,将该字符的显示属性写入下一个内存位置0x1B。属性值0x04的意思是黑底红字,无闪烁,无加亮。
从源程序的第75行开始,到第93行,用于显示其他4个数位。
源程序第95、96行,用于以黑底白字显示字符“D”,意思是所显示的数字是十进制的。
◆ 检测点6.3
1.INTEL x86处理器访问内存时,是按低端字节序进行的。那么,以下程序片段执行后,寄存器AX中的内容是多少?
2.对于以上程序片段,如果标号data在编译时的汇编地址是0x0030,那么,当该程序加载到内存后,该程序片段所在段的段地址为0x9020时,该标号处的段内偏移地址和物理内存地址各是多少?
3.对于以下指令的写法,说出哪些是正确的,哪些是错误的,错误的原因是什么。
A.mov ax,[data1] B.div[data1] C.xor ax,dx
D.div byte[data2] E.xor al,[data3] F.add[data4],0x05
G.xor 0xff,0x55 H.add 0x06,al I.div 0xf 0
J.add ax,cl
4.如果寄存器AX、寄存器BX和寄存器DX的内容分别为0x0090、0x9000和0x0001,那么,执行div bh后,这三个寄存器的内容各是多少?执行div bx后呢?
数字显示完成后,原则上整个程序就结束了,但对处理器来说,它并不知道。对它来说,取指令、执行是永无止境的。程序有大小,执行无停息,它这么做的结果,就是会执行到后面非指令的数据上,然后……
问题在于我们现在的确无事可做。为避免发生问题,源程序第98行,安排了一个无限循环:
jmp是转移指令,用于使处理器脱离当前的执行序列,转移到指定的地方执行,关键字near表示目标位置依然在当前代码段内。上面这条指令唯一特殊的地方在于它不是转移到别处,而是转移到自己。也就是说,它将会不停地重复执行自己。不要觉得奇怪,这是允许的。
处理器取指令、执行指令是依赖于段寄存器CS和指令指针寄存器IP的,8086处理器取指令时,把寄存器CS的内容左移4位,加上寄存器IP的内容,形成20位的物理地址,取得指令,然后执行,同时把IP的内容加上当前指令的长度,以指向下一条指令的偏移地址。
但是,一旦处理器取到的是转移指令,情况就完全变了。
很容易想到,指令jmp near infi的意图是转移到标号infi所在的位置执行。可是,正如我们前面所说的,程序在内存中的加载位置是0x0000:0x7C00,所以,这条指令应当写成
实际上,不加还好,加上了0x7C00,就完全错了。
jmp指令有多种格式。最典型的,它的操作数可以是直接给出的段地址和偏移地址,这称为绝对地址。比如:
此时,要转移到的目标位置是非常明确的,即段地址为0x5000,段内偏移地址为0xf 0c0。在这种情况下,指令的操作码为0xEA,故完整的机器指令是:
处理器执行时,发现操作码为0xEA,于是,将指令中给出的段地址传送到段寄存器CS;将偏移地址传送到指令指针寄存器IP,从而转移到目标位置处接着执行。
但是,在此处,jmp指令使用了关键字“near”,且操作数是以标号(infi)的形式给出的。这很容易让我们想到,这又是另一种形式的转移指令,转移的目标位置处在当前代码段内,指令中的操作数应当是目标位置的偏移地址。实际上,这是不正确的。
实际上,这是一个3字节指令,操作码是0xE9,后跟一个16位(2字节)的操作数。但是,该操作数并非目标位置的偏移地址,而是目标位置相对于当前指令处的偏移量(以字节为单位)。在编译阶段,编译器是这么做的:用标号(目标位置)处的汇编地址减去当前指令的下一条指令 的汇编地址,就得到了jmp near infi指令的实际操作数。也不是编译器愿意费这个事,这是处理器的要求。这样看来,jmp near infi的机器指令格式和它的汇编指令格式完全不同,颇具迷惑性,所以一定要认清它的本质。这种转移是相对的,操作数是一个相对量,如果你人为地加上0x7C00,那反而不对了。
那么,编译器是如何区分这两种不同的转移方式呢?很简单,当它看到jmp之后是一个绝对地址,如0xF000:0x2000时,它就知道应当编译成使用操作码0xEA的直接绝对转移指令。相反地,如果它发现jmp之后是一个标号,那么,它就会编译成使用操作码为0xE9的相对转移指令。关键字“near”不是最主要的,它仅仅用于指示相对量是16位的。
在这里,目标位置就是当前指令自己的位置,假定它的汇编地址是x,下一条指令(不管实际上有没有)的汇编地址是x+3。用x减去x+3,即x-x-3,即0-3。打开Windows计算器程序,实际减一下看看,你会发现,用二进制的0减去二进制的11,结果是
由于是在不断地向左边借位的,除了最右边是01,左边都是无休止的“1”。
再切换到十六进制计算一下0x0减去0x3,结果是
同样由于是在不断地向左边借位的,除了最右边是D,左边都是无休止的F。
由于在指令中使用了near关键字,因此,以上无休止的结果将被截断,只保留右边16位,即0xFFFD。又因为x86处理器使用低端字节序,所以,jmp near infi指令编译后的机器代码为E9 FD FF。
你可能觉得疑惑:0xFFFD等于十进制数65533,而这条指令需要的操作数实际是-3,我们这样做的原理是什么呢?计算机又是怎么表示负数的呢?不要着急,下一章我们就要介绍负数,并回过头来重新认识这个问题。
在指令执行阶段,处理器用指令指针寄存器IP的内容(它已经指向下一条指令)加上该指令的操作数,就得到了要转移的实际偏移地址,同时寄存器CS的内容不变。因为改变了指令指针寄存器IP的内容,这直接导致处理器的指令执行流程转向目标位置。
就jmp near infi指令来说,假定它的段内偏移量是0x7D2B,当它执行时,转移到的目标位置依然是0x7D2B,而指令指针寄存器IP的内容是下一条指令的地址0x7D2E。用来取代IP的新值是IP+操作数,也就是0x7D2E+0xFFFD。用Windows计算器程序实际做一下,0x7D2E+0xFFFD的结果是0x17D2B,但处理器只使用16位的偏移地址,故只保留16位的结果0x7D2B。因此,传送到指令指针寄存器IP的内容依然是这条jmp指令自己的地址,这导致处理器再次执行当前指令。
jmp指令具有多种格式,我们现在所用的,只是其中的一种,叫作相对近转移。有关其他格式,以及这些格式之间的差异,我们将在后面的章节里结合具体的实例进行讲解。
◆ 检测点6.4
写出以下程序片段中那两条jmp指令的机器指令码,并在Nasmide中编译,验证你的答案是否正确:
主引导扇区在系统启动过程中扮演着承上启下的角色,但并非唯一的选择。如果硬盘的主引导扇区不可用,系统还有其他选择,比如可以从光盘和U盘启动。
然而,如果不试试水的深浅就一个猛子扎下池塘,这并非明智之举。同样的,如果主引导扇区是无效的,上面并非一些处理器可以识别的指令,而处理器又不加鉴别地执行了它,其结果是陷入宕机状态,更不要提从其他设备启动了。
为此,计算机的设计者们决定,一个有效的主引导扇区,其最后2字节的数据必须是0x55和0xAA。否则,这个扇区里保存的就不是一些有意而为的数据。
定义这2字节很简单,伪指令db和dw就可以实现。源程序第103行就是db版本的实现,但没有标号。标号的作用是提供当前位置的汇编(偏移)地址,供其他指令引用,如果没有任何指令引用这个地址,标号可以省略。这是单独的2字节,所以0x55在前,0xAA在后,即使编译之后也是这个顺序。
但是,如果采用dw版本,应该这样写:
因为,在INTEL处理器上,将一个字写入内存时,是采用低端字节序的,低字节0x55置入低地址端(在前),高字节0xAA置入高地址端(在后)。
麻烦在于,如何使这2字节正好位于512字节的最后。前面的代码有多少字节我们不知道,那是由NASM编译器计算和跟踪的。
我们当然有非常好的办法,但还不宜在这里说明。但是,经过计算和尝试,我们知道,在前面的内容和结尾的0xAA55之间,有203字节的空洞。因此,源程序的第102行,用于声明203个为0的数值来填补。
为了方便,伪指令times可用于重复它后面的指令若干次。比如
将在编译时重复生成mov ax,bx指令20次,即重复该指令的机器码(89 D8)20次。
因此
将会在编译时保留203个数值为0的字节。
本章的代码是现成的,配书源代码解压缩之后,可以在文件夹“c06”里找到,文件名为c06_mbr.asm。打开该文件,将其编译成c06_mbr.bin。
该文件的大小为512字节,可以用配书工具HexView来查看其内容,如图6-8所示。
图6-8 用配书工具HexView查看c06_mbr.bin的内容
显而易见,在编译之后,源程序中的标号、注释、伪指令统统消失了,只剩下纯粹的机器指令和数据。那些需要在编译阶段决定的内容,也都有了确切的值。
在第5章,我们已经安装了VirtualBox虚拟机软件,并在它里面创建了一台名为LEARNASM的虚拟计算机。除此之外,还为它创建了一块虚拟硬盘。
虚拟硬盘其实是一个扩展名为“.vhd”的文件,具体的文件名和创建位置只有你自己知道。但是,无论如何,你现在都可以将我们刚刚编译好的代码写入这个虚拟硬盘的主引导扇区里。
首先,启动配书工具FixVhdWr,然后选择虚拟硬盘文件。如果以前已经选择过,则不必再次选择,FixVhdWr软件会自动打开它。注意,要写入的那个虚拟硬盘,必须是VirtualBox虚拟机使用的硬盘。否则的话,虚拟机怎么可能执行到你写入的程序呢!
接着,要在数据文件选择区域添加刚才编译好的二进制文件c06_mbr.bin,然后根据实际需求单击界面底部的功能按钮,包括“写入”“写入并执行VBox虚拟机”等。
最后要交代一句,千万不要在虚拟计算机LEARN-ASM运行的时候进行数据写入操作,因为虚拟硬盘文件正被VirtualBox以独占的方式使用。否则的话,会导致数据写入失败。
在Virtual Box软件的主界面上,选择“LEARN-ASM”计算机,然后单击“运行”按钮。
如果一切顺利的话,程序的运行效果如图6-9所示。
图6-9 本章程序在虚拟计算机中的运行效果
程序员的工作就像在历险,困难重重,途中不可避免地要遇上暗礁。有时候,少了一个字符,或者多了一个字符,或者拼错了字符,程序就无法成功编译;有时候,尽管能够编译,但程序中存在逻辑错误,要么少写了语句,要么算法不对,运行的时候也得不到正确结果。
有时候,错误的原因很简单,就是因为马虎和误操作,但很难知道问题出在哪里。等到你终于发现的时候,一天,甚至几天的时间已经花掉了。在这种情况下,没有调试工具来找到程序中隐藏的错误是不行的。有时候,即使有调试工具的帮助,也会令人筋疲力尽,不过有总比没有好。在现实的世界里,不管是经验老到的程序设计师,还是刚入门的新手,没有谁敢说自己的程序是不需要调试的。
调试工具并不是智能到可以自动发现程序中的错误,这是不可能的。但是,它可以单步执行你的程序(每执行一条指令后就停下来),或者允许你在程序中设置断点,当它执行到断点位置时就停下来。这时,它可以显示处理器各个寄存器的内容,或者内存单元里的内容。因此,你可以根据机器的状态来判断程序的执行结果是否达到了预期。通过这种方式,你可以逐步逼近出现问题的地方,直到最终发现问题的所在。市面上有多种流行的程序调试工具软件,但它们通常都像你用的其他软件一样工作在操作系统之上。
麻烦的是,本书中的程序全都只能运行在没有操作系统的祼机下。这意味着,所有流行的调试工具都不可用。不过,好消息是,一款叫作Bochs的软件可以帮助你。
Bochs是开源软件,是你唯一可选择的调试器,开源意味着你不用花钱购买就可以使用它。它用软件来模拟处理器取指令和执行指令的过程,以及整个计算机硬件。当它开始运行时,就直接模拟计算机的加电启动过程。正是因为如此,它才有可能做一些调试工作。
很重要的一点是,它本身就是一个虚拟机,类似于VirtualBox。因此,它也就很容易让你单步跟踪硬盘的启动过程,查看寄存器的内容和机器状态。在本书中,我们的程序都是直接从BIOS那里接管处理器的控制权,因此,Bochs的这个特点正好能够用来完成调试工作。不像本书中使用的其他工具,Bochs的使用方法在网上很容易搜索到。
要使用Bochs,首先要从它的官网下载安装程序。下载地址是:
在本书的配书文件包中,有一个关于如何下载、安装和配置Bochs的帮助文档,有WORD和PDF两种格式可以选用。请按照帮助文档的说明,安装和配置好Bochs。
一般来说,你会选择VirtualBox虚拟机来观察运行结果,而在调试程序时使用Bochs。因此,最好是它们共用同一个虚拟硬盘文件(VHD文件)。通过阅读帮助文档,你应该已经知道如何做到这一点,这里不再赘述。
Bochs虚拟机启动后,首先在当前的工作文件夹下寻找并读入配置文件bochsrc.bxrc,然后按它的参数调整当前虚拟机的各种“软硬件”配置和工作参数。
就像一台真正的计算机一样,Bochs的“处理器”在加电之后,要开始取指令并执行指令。但是,与真正的处理器不同,如图6-10所示,Bochs在执行它启动之后的第一条指令时,会停下来,等待你的调试命令。
图6-10 Bochs调试器的启动和断点命令的使用
如图6-10所示,命令窗口的底部显示了当前正在等待执行的那条指令,即“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)。如图6-11所示,输入“s”命令后回车,Bochs执行刚才那条指令,然后停下来,同时显示下一条即将执行的指令。
图6-11 在Bochs中单步执行指令
如图6-11所示,指令执行后,下一条等待执行的指令为xor ax,ax,对应的机器指令码为31 C0,所在的物理内存地址是0x00000000000FE05B。注意,物理地址变了。
现代的x86处理器在加电后,所有高端的地址线都被强制为高电平,直至遇到并执行了第一个段间转移指令。段间转移指令是在两个代码段之间实施控制转移,也就是同时改变代码段寄存器CS和指令指针寄存器IP的jmp指令,像jmp 0xf 000:0xe05b就是一个典型的例子。因此,当该指令执行后,处理器发出的物理地址就仅仅取决于代码段寄存器CS和指令指针寄存器IP了。
接下来,你可以继续单步执行。但是,老在BIOS中转悠也没什么意思。要知道,你调试的程序位于主引导扇区中,依靠单步执行得什么时候才能执行到主引导扇区代码!
不用担心,Bochs提供了断点指令“b”(break)。所谓断点,就是事先设置一个(物理)内存地址,当处理器执行到这个地址时,就自动停下来。因为计算机启动后,总是把主引导程序加载到物理内存地址0x7c00处,所以,可以将这个地址设为断点。
如图6-12所示,输入“b 0x7c00”。意思是,在处理器执行到地址0x7c00处的那条指令时,就停下来。然后,再输入命令“c”。
图6-12 在Bochs中设置断点
命令“c”(continue)是持续执行的意思,该命令要求处理器不间断地持续执行指令。但是,如果设置了断点,它就会在断点处停下来。因此,如图6-13所示,当“c”命令执行后,它会在执行到物理内存地址0x7c00时停下来。
图6-13 Bochs执行到主引导扇区代码时的状态
如图6-13所示,当前等待执行的指令是mov ax,0xb800,这就是本章源代码的第一条指令;该指令的物理地址是0x0000000000007C00,指令的机器代码为B8 00 B8。
如图6-14所示,此时,可以输入命令“r”(register)来显示通用寄存器的内容。
我知道,对于图中的内容,你一定会摇摇头表示看不懂,这其实很正常。我们此时正在介绍8086处理器,如图6-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位的。
图6-14 用“r”命令显示通用寄存器的内容
正如你已经知道的,8086已经成为历史,现在我们所使用的处理器,都是32位或者64位的。32位x86处理器对寄存器做了扩展,使之达到32位,以处理32位的数据。如图6-15所示,这8个32位寄存器分别是EAX、EBX、ECX、EDX、ESI、EDI、EBP和ESP,它们可以在程序中直接作为32位寄存器使用。同时,指令指针寄存器IP也做了扩展,达到32位,即EIP。为了保持同8086的兼容性,这些寄存器的低16位依然保持以前的用法,这使得以前的程序可以在32位处理器上正常运行。
图6-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。
屏幕的底部还显示了标志寄存器EFLAGS的状态。有关标志寄存器的内容将在后面的章节里具体阐述,这里先不用管它。有关32位处理器的内容,将在本书的后半部分讲解。
注意,尽管Bochs把所有寄存器都显示为64位的宽度,如RAX,但这并不表明你的处理器就一定是64位的。它的目的很简单,仅仅是希望用同一种最宽的格式来应付所有不同的处理器。
这样一说你就应该很清楚了,如图6-14所示,RAX的内容是0x000000000000aa55,这就意味着,RAX的高48位是全0,低16位(即AX)是0xAA55。
同样在这幅图中,RIP的内容是0x0000000000007c00,它表明寄存器RIP的高48位是全0,低16位(即IP)是0x7C00。
我们调试到哪一步了?
如图6-14所示,当前正在等待执行指令是mov ax,0xb800。现在,我们用“s”命令单步执行该指令。如图6-16所示,单步执行之后,下一条等待执行的指令是mov es,ax,该指令的物理内存地址是0x0000000000007C03。
因为刚才那条指令是将立即数0xB800传送到寄存器AX,那么,我们现在可以用“r”命令来看看寄存器AX的内容是否真的发生了改变。如图6-16所示,寄存器AX的内容是0xB800,确实符合我们的预期。
图6-16 观察指令执行后的效果(寄存器AX的变化)
接下来,继续用单步指令“s”来执行mov es,ax指令。如图6-17所示,该指令执行后,下一条即将执行的指令是
Bochs的汇编指令格式和NASM相比,在某些方面是不同的。实际上,这条指令就是本章程序中的
因为字面值’L’早在程序编译时就被转换成了立即数0x4c,所以,严格地说,这条指令在NASM中的格式是
图6-17 在Bochs中显示段寄存器的内容
无论如何,这条指令还没有执行,刚才执行的是mov es,ax指令。此时,段寄存器ES中的内容应当是0xB800。
为了验证这一点,应当要求Bochs显示段寄存器的内容。为此,需要使用“sreg”(segment register)命令。如图6-17所示,当输入“sreg”命令后,Bochs显示了一大堆东西。
在32位和64位处理器中,除了段寄存器CS、SS、DS和ES,还新增了两个段寄存器FS和GS,这一点首先要明白。
然后,在32位和64位处理器中,以上6个段寄存器都依然是16位的,但都额外增加了一个不可访问的部分,叫作段描述符高速缓存器。段描述符高速缓存器由处理器内部使用,不能在程序中访问,里面存放了段的起始地址、段的扩展范围,以及段的各种属性,比如它是代码段还是数据段,是否可以写入,是否被访问过,等等。这些知识,将在本书的后半部分详细讲解。
如图6-17所示,Bochs首先显示了段寄存器ES中的内容,是0xb800,这符合我们的预期。同时,它还显示了段寄存器ES描述符高速缓存器的内容,因为还没有讲到,所以暂时不用管它。
接下来,如图6-18所示,我们连续单步执行两次。对照本章的源程序,这实际上是执行了以下两条指令:
图6-18 显示内存区域中的内容
我们知道,这是在写文本模式下的显示缓冲区。因此,从物理内存地址0xB8000处开始的2字节必然是0x4C和0x07。
为了验证这一点,需要显示内存中的内容,这可以使用命令“xp”(eXamine memory at Physical address),即显示指定物理内存地址处的内容。命令xp每次只显示一个双字。要显示多个双字,需要用“/”附加一个数量。然后,还应当指定一个物理内存地址。
如图6-18所示,在这里,我们要求从物理内存地址0xB8000开始,显示2个双字。很快,Bochs做出了回应,显示了两个双字0x0b6c074c和0x0b780b65。
如图6-19所示,双字数据在内存中的存放是按低端字节序的。因此,0x0b6c074c这个双字数据,在内存中对应着从物理地址0xB8000开始的4字节0x4C、0x07、0x6C和0x0B。
图6-19 以低端字节序分析双字在内存中的位置
至此,基本的程序调试技术就讲完了,你可以使用“q”(quit)命令退出Bochs调试过程。
◆ 检测点6.5
1.在你自己的计算机上重现以上的编译、运行(使用VirtualBox)和调试(使用bochsdbg)过程。
2.单步执行本章程序,观察div指令执行后的寄存器内容变化。
1.试找出以下程序片段中隐藏的问题并进行修正:
2.本章的程序在内存中的加载地址是0x0000:0x7C00,此时,指令jmp near infi在段内的偏移地址是多少?试修改本章的源程序以显示该值。
3.汇编语言编译器采用助记符来方便指令的书写和阅读,而且在内存里,机器指令以数字的形式存在。比如,mov是传送指令,div是除法指令。假如INTEL公司新推出一款处理器,该处理器新增了一条指令,其机器码为2字节的0xCD 0x88。因为是新指令,所以你的NASM编译器肯定没有一个助记符与之相对应。在这种情况下,如何在你的程序中使用该指令?