处理器访问内存时,采用的是“段地址:偏移地址”的模式。对于任何一个内存段来说,段地址可以开始于任何16 字节对齐的地方,偏移地址则总是从0x0000 开始递增。
为了支持这种内存访问模式,在源程序的编译阶段,编译器会把源程序5-1 整体上作为一个独立的段来处理,并从0 开始计算和跟踪每一条指令的地址。因为该地址是在编译期间计算的,故称为 汇编地址 。汇编地址是在源程序编译期间,编译器为每条指令确定的汇编位置(Assembly Position),指示该指令相对于程序或者段起始处的距离,以字节计。当编译后的程序装入物理内存后,它又是该指令在内存段内的偏移地址。
如表5-3 所示,在用我们的配书工具Nasmide 书写并编译代码清单5-1 后,除了生成一个以“.bin”为扩展名的二进制文件,还会生成一个以“.lst”为扩展名的列表文件。这张表列出的,就是本章代码清单5-1 编译后生成的列表文件内容。
表5-3 共分五栏,从左到右依次是行号、指令的汇编地址、指令编译后的机器代码、源程序代码和注释。可以看出,第一条指令mov ax,0xb800 的汇编地址是0x00000000,对应的机器代码为B8 00 B8;第二条指令mov es,ax 的汇编地址是0x00000003,机器代码为8E C0。
表5-3 代码清单5-1 编译后的列表文件内容
从表5-3 中可以看出,在编译阶段,每条指令都被计算并赋予了一个汇编地址,就像它们已经被加载到内存中的某个段里一样。实际上,如图5-5 所示,当编译好的程序加载到物理内存后,它在段内的偏移地址和它在编译阶段的汇编地址是相同的。
图5-5 汇编地址和偏移地址的关系
正如图5-5 所示,编译后的程序是整体加载到内存中某个段的,交叉箭头用于指示它们之间的映射关系。之所以箭头是交叉的,是因为源程序的编译是从上往下的,而内存地址的增长是从下往上的(从低地址往高地址方向增长)。
图5-5 中假定程序是从内存物理地址0x60000 开始加载的。因为该物理地址也对应着逻辑地址0x6000:0x0000,因此我们可以说,该程序位于段0x6000 内。
在编译阶段,源程序的第一条指令mov ax,0xb800 的汇编地址是0x00000000,而它在整个程序装入内存后,在段内的偏移地址是0x0000,即逻辑地址0x6000:0000,两者的偏移地址是一致的。
再看源程序的第二条指令,是mov es,ax,它在编译阶段的汇编地址是0x00000003。在整个程序装入内存后,它在段内的偏移地址是0x0003,也没有变化。
这就很好地说明了汇编地址和偏移地址之间的对应关系。理解这一点,对后面的编程很重要。
在NASM 汇编语言里,每条指令的前面都可以拥有一个标号,以代表和指示该指令的汇编地址。毕竟,由我们自己来计算和跟踪每条指令所在的汇编地址是极其困难的。这里有一个很好的例子,比如源程序第98 行:
在这里,行首带冒号的是标号是“infi”。请看表5-3,这条指令的汇编地址是0x0000012B,故infi 就代表数值0x0000012B,或者说是0x0000012B 的符号化表示。
标号之后的冒号是可选的。所以下面的写法也是正确的:
标号并不是必需的,只有在我们需要引用某条指令的汇编地址时,才使用标号。正是因为这样,本章源程序中的绝大多数指令都没有标号。
标号可以单独占用一行的位置,像这样:
这种写法和第98 行相比,效果并没有什么不同,因为infi 所在的那一行没有指令,它的地址就是下一行的地址,换句话说,和下一行的地址是相同的。
标号可以由字母、数字、“_”、“$”、“#”、“@”、“~”、“.”、“?”组成,但必须以字母、“.”、“_”和“?”中的任意一个打头。
我们已经知道,标号代表并指示它所在位置处的汇编地址。现在,我们要编写指令,在屏幕上把这个地址的数值显示出来。为此,源程序的第37 行用于获取标号所代表的汇编地址:
标号“number”位于源程序的第100 行,只不过后面没有跟着冒号“:”。你当然可以加上冒号,但这无关紧要。注意,传送到寄存器AX 的值是在源程序编译时确定的,在编译阶段,编译器会将标号number 转换成立即数。如表5-3 所示,标号number 处的汇编地址是0x012E,因此,这条语句其实就是(等效于)
问题在于,如果不是借助于别的工具和手段,你不可能知道此处的汇编地址是0x012E。所以,在汇编语言中使用标号的好处是不必关心这些。
因此,当这条指令编译后,得到的机器指令为B8[2E01],或者B8 2E 01。B8 是操作码,后面是字操作数0x012E,只不过采用的是低端字节序。
十六进制数0x012E 等于十进制数302,但是,通过前面对字符显示原理的介绍,我们应该清楚,直接把寄存器AX 中的内容传送到显示缓冲区,是不可能在屏幕上出现“302”的。
解决这个问题的办法是将它的每个数位单独拆分出来,这需要不停地除以10。
考虑到寄存器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。
观察表5-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。
和指令不同,对于在程序中声明的数值,在编译阶段,编译器会在它们被声明的汇编地址处原样保留。
按照标准的做法,程序中用到的数据应当声明在一个独立的段,即数据段中。但是在这里,为方便起见,数据和指令代码是放在同一个段中的。不过,方便是方便了,但也带来了一个隐患,如果安排不当,处理器就有可能执行到那些非指令的数据上。尽管有些数碰巧和某些指令的机器码相同,也可以顺利执行,但毕竟不是我们想要的结果,违背了我们的初衷。
好在我们很小心,在本程序中把数据声明在所有指令之后,在这个地方,处理器的执行流程无法到达。
检测点5.2
找出下面代码片断中的错误。用nasmide 程序实际编译一下,看看结果如何。
data1 db 0x55,0xf000,0x0f
data2 dw 0x38,0x20,0x55aa
源程序第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 的功能你是知道的。
说了这么多,其实是在强调标号和汇编地址的对应关系,以及如何在指令中使用符号化的偏移地址。
第二种类型是用32 位的二进制数除以16 位的二进制数。在这种情况下,因为16 位的处理器无法直接提供32 位的被除数,故要求 被除数的高16 位在DX 中,低16 位在AX 中 。
图5-6 用DX:AX 分解32 位二进制数示意图
这里有一个例子,如图5-6 所示,假如被除数是十进制数2218367590,那么,它对应着一个32 位的二进制数10000100001110011001101001100110。在做除法之前,先要分成两段进行“切割”,以分别装入寄存器DX 和AX 。为了方便, 我们通常用“DX:AX”来描述32 位的被除数。
同时,除数可以由16 位的通用寄存器或者内存单元提供,指令执行后,商在AX 中,余数在DX 中。比如下面的指令:
源程序第45 行把0 传送到DX 寄存器,这意味着,我们是想把DX:AX 作为被除数,即被除数的高16 位是全零。至于被除数的低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 开始计算的。请看一下表5-3 的第37 行,这个在编译阶段计算出来的值是0x012E。在运行的时候,如果该程序被加载到某个段内偏移地址为0 的地方,这不会有什么问题,因为它们是一致的。
但是,事实上,如图5-7 所示,这里显示的是整个0x0000 段,其中深色部分为主引导扇区所处的位置。主引导扇区代码是被加载到0x0000:0x7C00 处的,而非0x0000:0x0000。对于程序的执行来说,这不会有什么问题,因为主引导扇区的内容被加载到内存中并开始执行时,CS=0x0000, IP=0x7C00。
图5-7 主引导程序加载到内存后的地址变化
加载位置的改变不会对处理器执行指令造成任何困扰,但会给数据访问带来麻烦。要知道,当前数据段寄存器DS 的内容是0x0000,因此,number 的偏移地址实际上是0x012E+0x7C00=0x7D2E。当正在执行的指令仍然用0x012E 来访问数据,灾难就发生了。
所以,在编写主引导扇区程序时,我们就要考虑到这一点,必须把代码写成
指令中的目的操作数是在编译阶段确定的,因此,在编译阶段,编译器同样会首先将它转换成以下的形式,再进一步生成机器码:
这样,如表5-3 的第47 行所示,在编译后,编译器就会将这条指令编译成88 16 2E 7D,其中前两个字节是操作码,后两个字节是低端字节序的0x7D2E。当这条指令执行时,处理器将段寄存器DS 的内容(和CS 一样,是0x0000)左移4 位,再加上指令中提供的偏移地址0x7D2E,就得到了实际的物理地址(0x07D2E)。
关于这条指令的另外一个问题是,虽然目的操作数也是一个内存单元地址,但并没有用关键字“byte”来修饰。这是因为源操作数是寄存器DL,编译器可以据此推断这是一个字节操作,不存在歧义。
现在已经得到并保存了个位上的数字,下一步是计算十位上的数字,方法是用上一次得到的商作为被除数,继续除以10。恰好,AX 已经是被除数的低16 位,现在只需要把DX 的内容清零即可。
为此,代码清单5-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”,意思是所显示的数字是十进制的。
检测点5.3
1. INTEL x86 处理器访问内存时,是按低端字节序进行的。那么,以下程序片断执行后,寄存器AX 中的内容是多少?
mov word [data],0x2008
xor byte [data],0x05
add word [data],0x0101
mov ax,[data]
data db 0,0
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 0xf0
J.add ax,cl
4. 如果寄存器AX、BX 和DX 的内容分别为0xA000、0x9000 和0x0001,那么,执行div bh 后,这三个寄存器的内容各是多少?执行div bx 后呢?