上一篇博文我们讲了如何看到实验结果,这篇博文我们着重分析源代码。
书中作者为了说明原理,约定了一种比较简单地用户程序头部格式,示意图如下(我参考原书图8-15绘制的,左边的数字表示偏移地址):
所以,如果用户程序要利用本章的源码c08_mbr.asm生成的加载器来加载的话,就应该遵循这种头部格式。
下面我们讲解源码c08_mbr.asm(粘贴的源代码不一定和配书的代码完全一样,因为有些地方我加了注释)
;代码清单8-1
;文件名:c08_mbr.asm
;文件说明:硬盘主引导扇区代码(加载程序)
;创建日期:2011-5-5 18:17
app_lba_start equ 100 ;声明常数(用户程序起始逻辑扇区号)
;常数的声明不会占用汇编地址
SECTION mbr align=16 vstart=0x7c00
;设置堆栈段和栈指针
mov ax,0
mov ss,ax
mov sp,ax
mov ax,[cs:phy_base] ;计算用于加载用户程序的逻辑段地址
mov dx,[cs:phy_base+0x02]
mov bx,16
div bx
mov ds,ax ;令DS和ES指向该段以进行操作
mov es,ax
;以下读取程序的起始部分
xor di,di
mov si,app_lba_start ;程序在硬盘上的起始逻辑扇区号
xor bx,bx ;加载到DS:0x0000处
call read_hard_disk_0
;以下判断整个程序有多大
mov dx,[2] ;曾经把dx写成了ds,花了二十分钟排错
mov ax,[0]
mov bx,512 ;512字节每扇区
div bx
cmp dx,0
jnz @1 ;未除尽,因此结果比实际扇区数少1
dec ax ;已经读了一个扇区,扇区总数减1
@1:
cmp ax,0 ;考虑实际长度小于等于512个字节的情况
jz direct
;读取剩余的扇区
push ds ;以下要用到并改变DS寄存器
mov cx,ax ;循环次数(剩余扇区数)
@2:
mov ax,ds
add ax,0x20 ;得到下一个以512字节为边界的段地址
mov ds,ax
xor bx,bx ;每次读时,偏移地址始终为0x0000
inc si ;下一个逻辑扇区
call read_hard_disk_0
loop @2 ;循环读,直到读完整个功能程序
pop ds ;恢复数据段基址到用户程序头部段
;计算入口点代码段基址
direct:
mov dx,[0x08]
mov ax,[0x06]
call calc_segment_base
mov [0x06],ax ;回填修正后的入口点代码段基址
;开始处理段重定位表
mov cx,[0x0a] ;需要重定位的项目数量
mov bx,0x0c ;重定位表首地址
realloc:
mov dx,[bx+0x02] ;32位地址的高16位
mov ax,[bx]
call calc_segment_base
mov [bx],ax ;回填段的基址
add bx,4 ;下一个重定位项(每项占4个字节)
loop realloc
jmp far [0x04] ;转移到用户程序
;-------------------------------------------------------------------------------
read_hard_disk_0: ;从硬盘读取一个逻辑扇区
;输入:DI:SI=起始逻辑扇区号
; DS:BX=目标缓冲区地址
push ax
push bx
push cx
push dx
mov dx,0x1f2
mov al,1
out dx,al ;读取的扇区数
inc dx ;0x1f3
mov ax,si
out dx,al ;LBA地址7~0
inc dx ;0x1f4
mov al,ah
out dx,al ;LBA地址15~8
inc dx ;0x1f5
mov ax,di
out dx,al ;LBA地址23~16
inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盘
or al,ah ;LBA地址27~24
out dx,al
inc dx ;0x1f7
mov al,0x20 ;读命令
out dx,al
.waits:
in al,dx
and al,0x88
cmp al,0x08
jnz .waits ;不忙,且硬盘已准备好数据传输
mov cx,256 ;总共要读取的字数
mov dx,0x1f0
.readw:
in ax,dx
mov [bx],ax
add bx,2
loop .readw
pop dx
pop cx
pop bx
pop ax
ret
;-------------------------------------------------------------------------------
calc_segment_base: ;计算16位段地址
;输入:DX:AX=32位物理地址
;返回:AX=16位段基地址
push dx
add ax,[cs:phy_base]
adc dx,[cs:phy_base+0x02]
shr ax,4
ror dx,4
and dx,0xf000
or ax,dx
pop dx
ret
;-------------------------------------------------------------------------------
phy_base dd 0x10000 ;用户程序被加载的物理起始地址
times 510-($-$$) db 0
db 0x55,0xaa
app_lba_start equ 100 ;声明常数(用户程序起始逻辑扇区号)
这句话作者假定用户程序从硬盘第100扇区开始。所以在我们把这个源文件对应的.bin文件写入虚拟硬盘的时候,要从逻辑扇区100开始写。
equ 类似于C语言中的#define,用来定义一个常量。
一般使用格式:
符号名 EQU 表达式
作用是左边的符号名代表右边的表达式。
注意:不会给符号名分配存储空间,符号名不能与其它符号同名,也不能被重新定义
SECTION mbr align=16 vstart=0x7c00
解释:
NASM编译器用SECTION或者SEGMENT来定义段。mbr是段名称(可以随便起);
注意:如果整个程序都没有段定义语句,那么整个程序自成一个段(这点好像和MASM不同哦!);
align=16 表示16字节对齐;
vstart=0x7c00,关于这个,我们就不得不多说几句了。
==================插叙部分================
汇编地址以及标号的本质:
1. 所谓汇编地址,就是编译器给源程序中每条指令定义的地址,由于编译后的程序可以在内存中浮动(即可以装载在内存中的任意位置),因此直接用绝对地址(20位的实模式下的物理内存地址)来给源程序中的指令定位的话将不利于程序在内存中的浮动;
2. 汇编地址定位规则:
(1)一般规则:
i. 如果在没有使用特殊指令的一般情况下(特别是vstart指令),整个源程序中第一条指令的汇编地址为0,之后所有指令的汇编地址都是相对于整个源程序第一条指令的偏移地址,即使程序中分了很多段也是如此。在这种情况下,如果将整个源程序看做一个段的话则汇编地址就是段内偏移地址;
ii. 在NASM中,所有的标号实质上就是其所在处指令的汇编地址,在编译后会将所有标号都替换成该汇编地址值(即立即数);
(2)特殊规则:
i. 如果在定义段的时候使用了vstart伪指令,比如
“section my_segment vstart=15”,
则会提醒汇编器,该段起始指令的汇编地址是15,段内的其它指令的汇编地址都是距该段起始指令地址的偏移量加上15;因此,vstart伪指令就是指定段的起始汇编地址;如果vstart=0,则段内的汇编地址就是段内的偏移地址!(这种手法经常使用!)
ii. 使用NASM规则的标准段,是指section .data、section .text、section .bss,这三种标准段都默认包含有vstart=0,因此段内的指令以及标号的汇编地址都是段内偏移地址,并且在加载程序的时候会自动使cs指向.text,ds指向.bss,es指向.data,而无需人手工执行对段寄存器赋值的步骤,而对于i.中的定义段的方式则没有这种自动的步骤,需要亲手对段寄存器进行赋值(是这样吗?从网上搜来的,我不能肯定。)
(3) 引用标号:
i. 和MASM不一样的是NASM大大简化了对标号的引用,不需要再用seg和offset对标号取段地址和偏移地址了;
ii. 在NASM中,标号就是一个立即数,而这个立即数就是汇编地址;
iii. 在NASM中不再有MASM中数据标号的概念,也就不存在什么arr[5]之类的内存寻址形式了!
iv. 在NASM中所有出现标号的地方都会用标号的汇编地址替换,因此诸如mov ax, tag之类的指令,仅仅就是将一个立即数(tag的汇编地址)传送至ax而已,而不是取tag地址内的数据了!如果要取标号处内存中的数据就必须使用[ ](类似C语言中的指针运算符*);
==================插叙结束================
处理器加电或者复位后,BIOS会执行硬件检测和初始化程序,如果没有错误,接下来就会进行操作系统引导。
BIOS会根据CMOS(一块可读写的RAM芯片,保存系统当前的硬件配置和用户的设定参数)里记录的启动顺序逐个地来尝试加载启动代码。
具体的过程是BIOS将磁盘的第一扇区(磁盘最开始的512字节,也就是主引导扇区)载入内存,放在0X0000:0X7C00处,然后检查这个扇区的最后两个字节是不是“0x55AA”,如果是则认为这是一个有效的启动扇区,如果不是就会尝试下一个启动介质;
如果主引导扇区有效,则以一个段间转移指令
jmp 0x0000:0x7c00
跳过去继续执行;
如果所有的启动介质都判断过后仍然没有找到可启动的程序,那么BIOS会给出错误提示。
所以,代码中的vstart=0x7c00不是空穴来风,而是根据代码被加载的实际位置决定的。
当这段程序刚被加载到内存后,
CS=0x0000, IP=0x7c00
如上图所示,假设不写vstart=0x7c00,那么标号“number”的偏移地址就从程序头(认为是0)开始算起,为0x012e;
但是实际上“number”的段内偏移地址是0x7d2e(0x012e+0x7c00=0x7d2e)!
为了修正这个偏移地址的差值,于是有vstart=0x7c00,也就是说段内所有指令的汇编地址都在原来的基础上加上0x7c00.
这里还要再补充一点,如果看这个源文件对应的列表文件,是看不出来偏移地址被加了0x7c00的。
列表文件的一个截图如下:
看到了吗?第一条指令的汇编地址,还是从0开始的!
而且
SECTION mbr align=16 vstart=0x7c00
这句话还是出现在了列表文件里。
我的理解是,列表文件仅仅是对源码的第一遍扫描吧。在后面的扫描中,0x7c00就起作用了。
举个例子吧,
上图有一行
16 00000007 2EA1[CA00] mov ax,[cs:phy_base]
列表文件的末尾有
151 000000CA 00000100 phy_base dd 0x10000
也就是说 phy_base 这个标号的汇编地址就是00CA(这时候7C00还没有起作用)
我们再看一下编译后的二进制文件
在偏移为0x07的地方,对应的指令码是
2EA1CA7C
注意到其中的CA7C(低字节在前面)了吗? 这个就是00CA+7C00=7CCA的结果啊!
我们继续看代码,
;设置堆栈段和栈指针
mov ax,0
mov ss,ax
mov sp,ax
定义栈需要两个连续的步骤,即初始化SS和SP.
*——————-小贴士—————-
原书P158上方:处理器在设计的时候就规定,当遇到修改段寄存器SS的指令时,在这条指令和下一条指令执行完毕期间,禁止中断,以此来保护栈。也就是说,我们应该在修改SS的指令之后,紧接着一条修改SP的指令。
——————————————–*
因为已经设置了SP=SS=0,所以第一次执行PUSH指令时,先把SP减2,即0x0000-0x000=0xFFFE(借位被忽略);然后把内容送入SS:SP指向的内存单元处。如下图所示(文章中画的只是示意图,不是按照比例画的,凑合看)
mov ax,[cs:phy_base] ;计算用于加载用户程序的逻辑段地址
mov dx,[cs:phy_base+0x02]
mov bx,16
div bx
mov ds,ax ;令DS和ES指向该段以进行操作
mov es,ax
代码的末尾部分有
phy_base dd 0x10000 ;用户程序被加载的物理起始地址
也就是说作者安排把用户程序加载到物理内存0x10000处,(我们完全可以修改成别的16字节对齐的地址,只要把用户程序加载到一个空闲的地方就可以。)
上面这几行的意思是根据物理地址计算出逻辑段地址,[DX:AX]是被除数,BX的内容是除数(16),计算结果在AX(对于本程序,结果就是0x1000)中。然后令DS和ES都指向这个段。
;以下读取程序的起始部分
xor di,di
mov si,app_lba_start ;程序在硬盘上的起始逻辑扇区号
xor bx,bx ;加载到DS:0x0000处
call read_hard_disk_0
这段代码的最后调用了过程 read_hard_disk_0,我们看一下过程调用的代码,我在代码中加了一些注释:
read_hard_disk_0: ;从硬盘读取一个逻辑扇区
;输入:DI:SI=起始逻辑扇区号
; DS:BX=目标缓冲区地址
;使用LBA28寻址方式
push ax
push bx
push cx
push dx; 用到的寄存器压栈保存
mov dx,0x1f2
mov al,1
out dx,al ;读取的扇区数为1
inc dx ;0x1f3
mov ax,si
out dx,al ;LBA地址7~0
inc dx ;0x1f4
mov al,ah
out dx,al ;LBA地址15~8
inc dx ;0x1f5
mov ax,di
out dx,al ;LBA地址23~16
inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盘
or al,ah ;LBA地址27~24
out dx,al
inc dx ;0x1f7
mov al,0x20 ;读命令
out dx,al
.waits:
in al,dx
and al,0x88
cmp al,0x08
jnz .waits ;不忙,且硬盘已准备好数据传输
mov cx,256 ;总共要读取的字数
mov dx,0x1f0
.readw:
in ax,dx
mov [bx],ax
add bx,2
loop .readw
pop dx
pop cx
pop bx
pop ax
ret
要理解这段,先看下面的示意图(参照原书图8-11画的)
inc dx ;0x1f6
mov al,0xe0 ;LBA28模式,主盘
or al,ah ;LBA地址27~24
out dx,al
mov al,0xe0 表示选择LBA模式,选择主硬盘
注意,在调用这个过程的时候,DI:SI=起始逻辑扇区号,DI的低四位是有效的,高四位应该为0,其实这里我觉得应该加一句,
mov al,0xe0 这句后面加一句 and ah,0x0f
目的是把DI的高四位清零,万一调用者忘记清零了,这样做可以防止意外发生。
inc dx ;0x1f7
mov al,0x20 ;读命令
out dx,al
当把起始LBA扇区号设置好后,就可以发出读命令了。上面的代码表示向端口0x1F7写入0x20,请求读硬盘。
接下来等待读请求完成。端口0x1F7既是命令端口,也是状态端口。部分状态位的含义如图:
.waits:
in al,dx ;读端口的值
and al,0x88 ;提取出bit3和bit7
cmp al,0x08 ;bit3==1且bit7==0说明准备好了
jnz .waits ;否则继续检查
一旦硬盘准备好了,就可以读取数据了。0x1F0是硬盘接口的数据端口,是16位的。可以连续从这个端口读取数据。
mov cx,256
in ax,dx
这两句话就表示读取了一个字的数据(16位)到AX中
mov cx,256 ;总共要读取的字数
mov dx,0x1f0
.readw:
in ax,dx
mov [bx],ax ;读取的数据放在数据段,偏移地址由BX指定
add bx,2
loop .readw
现在我们再回到那部分代码,就很容易理解了。
xor di,di ;di清零 (因为我们传入逻辑扇区号是100,不超过16 bits)
mov si,app_lba_start ;程序在硬盘上的起始逻辑扇区号
xor bx,bx ;加载到DS:0x0000处
call read_hard_disk_0
执行到这里,内存大概如下图所示:
;以下判断整个程序有多大
mov dx,[2] ;曾经把dx写成了ds,花了二十分钟排错
mov ax,[0]
mov bx,512 ;512字节每扇区
div bx
cmp dx,0
jnz @1 ;未除尽,因此结果比实际扇区数少1
dec ax ;已经读了一个扇区,扇区总数减1
@1:
cmp ax,0 ;考虑实际长度小于等于512个字节的情况
jz direct
;读取剩余的扇区
push ds ;以下要用到并改变DS寄存器
mov cx,ax ;循环次数(剩余扇区数)
@2:
mov ax,ds
add ax,0x20 ;得到下一个以512字节为边界的段地址
mov ds,ax
xor bx,bx ;每次读时,偏移地址始终为0x0000
inc si ;下一个逻辑扇区
call read_hard_disk_0
loop @2 ;循环读,直到读完整个功能程序
pop ds ;恢复数据段基址到用户程序头部段
上面这段代码是为了把剩余的用户程序读到内存里(以扇区为单位)
我们分别讲解。
;以下判断整个程序有多大
mov dx,[2]
mov ax,[0]
mov bx,512 ;512字节每扇区
div bx
cmp dx,0
jnz @1 ;未除尽,因此结果比实际扇区数少1
dec ax ;已经读了一个扇区,扇区总数减1
@1:
cmp ax,0 ;考虑实际长度小于等于512个字节的情况
jz direct
因为已经约定了用户程序的头部4个字节是用户程序的总长度,所以这里取总长度到[dx:ax]中,把[dx:ax]除以512,就能得到有几个扇区。dx存放余数,ax存放商。
如果dx==0,那么就把ax减一(因为前面已经读了一个扇区),继续执行@1;如果dx!=0,那么剩余的扇区数就是ax,然后跳到@1;
开始执行@1处的代码时,ax已经保存了还要读取的扇区数,但是这个值也有可能为0,如果为0,就不用再读取了, jz direct就可以;如果不为0,就执行下面的代码。
好了,如果你觉得上面说得不够清楚,那么看这个简单的流程图吧:
;读取剩余的扇区
push ds ;以下要用到并改变DS寄存器
mov cx,ax ;循环次数(剩余扇区数)
@2:
mov ax,ds
add ax,0x20 ;得到下一个以512字节为边界的段地址
mov ds,ax
xor bx,bx ;每次读时,偏移地址始终为0x0000
inc si ;下一个逻辑扇区
call read_hard_disk_0
loop @2 ;循环读,直到读完整个功能程序
pop ds ;恢复数据段基址到用户程序头部段
mov ax,ds
add ax,0x20
mov ds,ax ;这三行表示调整ds的位置,让ds指向最后读入的块的末尾,也就是将要读入的块的开始。其他语句都好理解,这里就不解释了。
接下来是处理段的重定位表。我们要修正每个表项的值。
为什么要修正呢?看图就明白了。
用户程序在编译的时候,每个段的段地址都是相对于程序开头(0)计算的。但是用户程序被加载器加到到物理地址[phy_base]的时候,相当于每个段的物理地址都向后偏移了[phy_base],所以我们要修正这个差值。
我们看看代码:
calc_segment_base: ;计算16位段地址
;输入:DX:AX=32位物理地址
;返回:AX=16位段基地址
push dx
add ax,[cs:phy_base]
adc dx,[cs:phy_base+0x02]
shr ax,4
ror dx,4
and dx,0xf000
or ax,dx
pop dx
ret
add ax,[cs:phy_base]
adc dx,[cs:phy_base+0x02];
这两句其实是做了一个20位数的加法,修正后的物理地址是[dx:ax];
shr ax,4
ror dx,4
and dx,0xf000
or ax,dx;
这四句是求出段基地址(16位),也就是逻辑段地址,结果在AX中。然后回填到原处(仅覆盖低16位,高16位不用管)。
为什么要求出段基地址呢?因为在用户程序中,对段寄存器赋值,都是从这里引用的。
;计算入口点代码段基址
direct:
mov dx,[0x08]
mov ax,[0x06]
call calc_segment_base
mov [0x06],ax ;回填修正后的入口点代码段基址
;开始处理段重定位表
mov cx,[0x0a] ;需要重定位的项目数量
mov bx,0x0c ;重定位表首地址
realloc:
mov dx,[bx+0x02] ;32位地址的高16位
mov ax,[bx]
call calc_segment_base
mov [bx],ax ;回填段的基址
add bx,4 ;下一个重定位项(每项占4个字节)
loop realloc
jmp far [0x04] ;转移到用户程序
只要参考本文开头的用户程序头部示意图,上面这段代码不难理解。
需要说明的是 jmp far [0x04] ;这个是16位间接绝对远转移指令。一定要使用关键字far。处理器执行这条指令的时候,会访问DS所指向的数据段,从偏移地址0x04处取出两个字(低字是偏移地址,高字是段基址),用低字代替IP的内容,用高字代替CS的内容,于是就可以转移了。