先序文章请看
从裸机启动开始运行一个C++程序(三)
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)
跳转
前面我们介绍过,8086CPU总是在执行CS:IP
所对应的内存位置的指令,一般情况下,会按照顺序一条一条执行。除非一种特殊情况——跳转指令。
所谓「跳转」,顾名思义,就是不要再继续向下执行,而是跳到某一个位置开始执行。因此,跳转指令就是要改变CS:IP
的指向。
跳转指令主要分为两种,分别是「近跳」和「远跳」。不过笔者认为,这两个名字也起得不是特别恰当,其实他们跟远近并没有直接关系。
近跳
所谓「近跳」,我们可以理解为CS
不变,IP
做一个偏移,它的操作数是一个偏移量,比如说-3
就表示向前跳转3
字节、5
就表示向后偏移5
字节。
然而在汇编语言里,我们也不好手动去计算偏移量,因此这种时候就需要用到强大的汇编器预处理功能——标签。我们来看一个例子:
L1:
mov ax, 1
jmp L2
mov bx, 2
L2:
mov cx, 8
其中的L1:
和L2:
就是标签,它也是伪指令,并不会生成对应的机器码,而是会影响汇编器的预处理。标签名可以随便起,只要不跟汇编关键字冲突即可,后面的冒号也可以省略。
上面例程中的近跳指令是:
jmp L2
预处理时,汇编器会根据L2
标签到当前位置(跳转指令的位置)之前的偏移量来给近跳指令添加操作数。以上面例程来说,实际的操作数正好是mov bx, 2
这条指令的长度,也就是3,那么jmp L2
就相当于jmp +3
。
当CPU执行到近跳指令时,则会将IP
寄存器与近跳指令的操作数相加,然后去执行对应位置的指令,进而达到跳转的目的。
远跳
所谓「远跳」,其实是给CS
和IP
都给一个绝对值,它的操作数是一个绝对的内存地址,而不是偏移量。例如:
jmp 0x0820:0x0000
这条指令执行完后,CS
会赋值为0x0820
,IP
会赋值为0x0000
,接着就会执行0x08200
位置的指令。
这里需要强调的是,汇编语言指导的是机器指令,它不具备高等语义,因此,汇编器不会去检查0x08200
这个地址在不在你当前操作的源文件里,也不会去管那个位置到底会不会加载合法的指令,这一切都应该由程序员自行负责。
当然,使用远跳指令时也可以使用标签,只不过此时的标签会使用「相对于文件头」的偏移量。比如说:
mov ax, 0
mov bx, 1
L1:
mov cx, 2
jmp 0x0000:L1
上面例程中jmp 0x0000:L1
就是远跳指令,这时的L1
就会解析为这个标签相对于文件头的偏移量,实际上也就是mov ax, 0
和mov bx, 1
的指令长度和,也就是6。那么这条指令其实应该是jmp 0x0000:0x0006
。
这里再次强调重点:近跳指令不改变CS
,操作数是偏移量;远跳指令会改变CS
,操作数是绝对数。这一点在8086模式下可能看上去没那么重要,但当后面我们切换到286模式时,这一点会非常重要,所以请读者一定要记住。
多加载几个扇区
到目前为止,我们的程序都挤在软盘的第一个扇区里,指望BIOS自动加载。不过显然这区区512字节的空间很容易捉襟见肘,那么如何把软盘中的其他扇区内容也加载到内存中呢?在8086模式下,BIOS中断可以替我们搞定。
; 加载一个扇区到0x08000的位置
mov ax, 0x0800
mov es, ax
mov bx, 0 ; 软盘中的内容会加载到es:bx的位置
mov ah, 2 ; ah=2, 使用读盘功能
mov al, 2 ; ah表示需要读取连续的几个扇区(读2个就是1KB的大小)
mov ch, 0 ; ch表示第几柱面
mov dh, 0 ; dh表示第几磁头
mov cl, 2 ; cl表示第几扇区
mov dl, 0 ; dl表示驱动器号,软盘会在0x00~0x7F,硬盘会在0x80~0xFF
int 0x13 ; 执行0x13号中断的2号功能(读盘功能)
对于老式机械硬盘、软盘来说,它们都属于「磁盘」的一种。根据其机械结构分为柱面(Cylinder)、磁头(Head)、扇区(Sector),一般表示为CHS
,柱面和磁头从0
开始,扇区从1
开始标号。
BIOS如果设置为软盘启动,就会加载0
号驱动器的C0-H0-S1
到内存的0x07c00
的位置。如果设置为硬盘启动,就会加载0x80
号驱动器的C0-H0-S1
到内存的0x07c00
的位置。
那么现在,我们承担MBR角色的程序,就需要再把其他数据也加载到内存中。不过这时的内存选址就由我们随意了,并不一定要紧接着MBR加载的位置,上面例程中选择了0x08000
的位置,你也可以选择其他位置,但要主要,不能占用BIOS预留的位置,也不能占用显存位置。通常8086的内存布局如下:
起始地址 | 结束地址 | 长度 | 作用 |
---|---|---|---|
0x00000 | 0x003ff | 1KB | 中断向量表 |
0x00400 | 0x004ff | 256B | BIOS数据区 |
0x00500 | 0x07bff | 29.75KB | - |
0x07c00 | 0x07dff | 512B | MBR |
0x07e00 | 0x9fbff | 607.5KB | - |
0xa0000 | 0xbffff | 128KB | 显存 |
0xc0000 | 0xc7fff | 32KB | 显卡BIOS |
0xc8000 | 0xeffff | 160KB | 统一编址的I/O |
0xf0000 | 0xfffff | 64KB | BIOS |
从上表可知,0x00500
到0x9fbff
这638.75KB的空间都是可用的,但是由于MBR占用了其中的512B,剩下的部分我们可以自由支配。
下面我们就编写一个程序,前512B作为MBR,加载两个扇区(1KB)的数据到0x08000
的位置,然后再跳转至该位置,执行指令:
; C0H0S1
; 调用0x10号BIOS中断,清屏
mov al, 0x03
mov ah, 0x00
int 0x10
; 加载一个扇区到0x08000的位置
mov ax, 0x0800
mov es, ax
mov bx, 0 ; 软盘中的内容会加载到es:bx的位置
mov ah, 2 ; ah=2, 使用读盘功能
mov al, 2 ; ah表示需要读取连续的几个扇区(读2个就是1KB的大小)
mov ch, 0 ; ch表示第几柱面
mov dh, 0 ; dh表示第几磁头
mov cl, 2 ; cl表示第几扇区
mov dl, 0 ; dl表示驱动器号,软盘会在0x00~0x7F,硬盘会在0x80~0xFF
int 0x13 ; 执行0x13号中断的2号功能(读盘功能)
jmp 0x0800:0x0000 ; 这里写成0x0000:0x8000也OK,只是CS和IP的值会不同,但CS:IP是相同的
times 510-($-$$) db 0 ; MBR剩余部分用0填充
dw 0xaa55
; 现在已经是C0H0S2的内容了
begin:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
mov [0x0001], byte 0x0f
mov [0x0002], byte 'e'
mov [0x0003], byte 0x0f
mov [0x0004], byte 'l'
mov [0x0005], byte 0x0f
mov [0x0006], byte 'l'
mov [0x0007], byte 0x0f
mov [0x0008], byte 'o'
mov [0x0009], byte 0x0f
hlt
times 1024-($-begin) db 0 ; 补满2个扇区
可以确认一下,此时的mbr.bin
变成了1536B,当然,它现在叫「MBR」已经不太合适了,它应当是包含了MBR和内核程序的一个总包。暂时我们先忽略这个叫法的问题,稍后再来看如何将MBR和内核程序分离。
同样,将其重命名为a.img
,然后打开bochs看运行效果:
这证明,后面扇区的内容也加载成功了,跳转指令也完成了正确的跳转。
另外,当我们程序有稍微的规模了的时候,大家可以考虑用单步执行命令来做调试。例如启动后,我们先在0x7c00
处打断点,然后c
执行BIOS的指令,然后按n
开始跳过调用流程的单步调试(s
是单纯的单步调试,但是会把BIOS中断中的指令也显示出来,按n
则不会)。大概效果如下:
而在经历一些加载数据功能后,我们还可以用x
命令来查看对应内存位置,例如当执行完0x13
中断后,我可以看一下0x08000
位置的内存,到底有没有写入数据:
也可通过r
和sreg
指令查看寄存器的值,比如在跳转指令前后,查看CS
和IP
的值。跳转前:
然后执行n
,完成跳转指令后,再看一下CS
和IP
的值:
大家可以根据需要进行调试观察自己的程序。
改为硬盘启动
BIOS中断的局限性
照理说,按照前面一节的方法,利用BIOS中断加载软盘中的数据到内存中再去执行,在8086下貌似是没什么问题的。但这不是长久之际,8086下只有640KB不到的内存空间供我们支配,自然用当前的这种方式没什么问题,但毕竟8086模式只是过渡,后续我们要切换到32位模式以支持4GB内存,还要切换到64位模式支持更大的内存。
虽然BIOS中断是很方便的工具,相当于基础系统提供了一些库函数供我们使用,但它毕竟依赖BIOS,BIOS中提供的指令都是16位实模式(8086模式)的指令,一旦后续我们切换为i286模式、i386模式后,这些BIOS中断就无法使用了(因为指令集不匹配)。
其实,向显存写入数据的这种需求,也是可以通过BIOS中断来完成的,但笔者并没有介绍这种方法,而是使用直接操作显存的方式,目的也就在此,因为我们不可能一直停留在8086模式。同理,加载外存中的数据这种需求,也应当有它原始方法。
I/O设备的操作
前面我们介绍过I/O,有一些是统一编址的(比如显存),也有一些是独立编址的,CPU会通过专用的指令,控制I/O控制器(或者也可以叫南桥芯片)来管理这些I/O设备。
I/O设备会映射成一个端口号,CPU向对应的端口号发送或读取数据,间接通过I/O控制器来控制外围的I/O设备。软驱也是其中的一员,我们可以控制几个软驱控制器(例如DOR、FDC)来读取和写入软盘中的内容。不过软驱的控制方法比较麻烦(只支持CHS模式,不支持LBA模式。LBA模式在后面章节详细介绍),又因为3.5英寸软盘只有1440KB的限制,迟早不够使,因此,我们姑且就不去详细研究软驱的控制方法了。接下来,我们要将我们的模拟器环境,改为用硬盘启动。
配置硬盘启动
配置硬盘,需要修改bochsrc
的内容,我们将软盘启动相关配置注释掉或删除掉,改为以下内容:
# boot: floppy # 设置软盘启动
# floppy_bootsig_check: disabled=0 # 打开自检
# floppya: type=1_44, 1_44="a.img", status=inserted, write_protected=0 # 使用1.44MB的3.5英寸软盘,取镜像为a.img,开机默认已插入软驱,不开启写保护
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14 # 主盘端口映射为1f0,从盘映射为3f0,中断号设置为14(虽然这几个参数都可以定制化,但这个参数是业界标准的,不建议更改)
ata0-master: type=disk, mode=flat, path=a.img, cylinders=1, heads=1, spt=1 # 主盘位置加载一块规格为C1H1S1的硬盘,镜像使用a.img
boot: disk # 设置为硬盘启动
这里需要注意一下,硬盘的规格我们暂时设置的是1柱面1磁头1扇区,也就是只有512字节的硬盘,那么对于a.img
来说,超过512B的部分是不会加载进去的。(暂时这样设置一下,后面肯定会改的。)
首先先来测试一下MBR能否正常加载,所有我们把之前MBR中写的那些跳转语句、还有512B后面的部分都先删除,打印几个文字来验一验效果:
; 调用0x10号BIOS中断,清屏
mov al, 0x03
mov ah, 0x00
int 0x10
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
mov [0x0001], byte 0x0f
mov [0x0002], byte 'e'
mov [0x0003], byte 0x0f
mov [0x0004], byte 'l'
mov [0x0005], byte 0x0f
mov [0x0006], byte 'l'
mov [0x0007], byte 0x0f
mov [0x0008], byte 'o'
mov [0x0009], byte 0x0f
hlt
times 510-($-$$) db 0 ; MBR剩余部分用0填充
dw 0xaa55
将其编译为mbr.bin
,确认一下它的大小是512字节:
然后把它复制为a.img
,再启动一下看看效果:
能看到输出,说明我们已经成功切换成硬盘启动了。那么接下来就是如何加载后面扇区的数据的问题了。
通过操作I/O加载硬盘数据
前面我们用了CHS方式来编号硬盘,但除了CHS以外,还有另外一种方式,叫做LBA,也就是Logical Block Address。这种方式下,硬盘会直接按照连续的扇区进行编号,对磁头和柱面不再感知。
LBA28是一种比较原始的方式,28表示用28位编号,也就是0x0000000
~0xFFFFFFF
的扇区号,注意,0
号是预留位,真正的扇区是从1
号开始的。
用于控制硬盘的设备会有对应的端口号,在前面我们bochsrc
中也有对应的配置,比如当前使用了默认值,也就是0x01f0
,从这个端口向后的若干端口都是用来操作硬盘的。因此,我们要按照一定的顺序,向对应的端口中写入数据,来指导硬盘控制器读取硬盘数据。
首先要配置的是需要读取的端口数,这个数据要写入0x01f2
端口中:
; 设置读取扇区的数量
mov dx, 0x01f2
mov al, 2 ; 读取连续的几个扇区,每读取一个al就会减1
out dx, al
然后我们来配置起始扇区号。1号扇区就是MBR,已经加载进来了,所以我们从第2号扇区开始加载。虽然只是一个简单的2号,但其实LBA28模式下扇区号是有28位的,因此我们要拆分成4次,分别写入不同的端口中。0x01f3
需要传入扇区号的0~7
位,0x01f4
需要传入扇区号的8~15
位,0x01f5
需要传入扇区号的16~23
位,0x01f5
则拆分为3部分,低4位是扇区号的24~27
位,第4位表示主从盘,高3位表示扇区编号的模式。
这部分的代码如下,笔者已经加入了详细的注释,请读者仔细阅读:
; 设置起始扇区号,28位需要拆开
mov dx, 0x01f3
mov al, 0x02 ; 从第2个扇区开始读(1起始,0留空),扇区号0~7位
out dx, al
mov dx, 0x01f4 ; 扇区号8~15位
mov al, 0
out dx, al
mov dx, 0x01f5 ; 扇区号16~23位
mov al, 0
out dx, al
mov dx, 0x01f6
mov al, 111_0_0000b ; 低4位是扇区号24~27位,第4位是主从盘(0主1从),高3位表示磁盘模式(111表示LBA模式)
接下来要配置操作命令,我们要做「读盘」操作,对应的命令号是0x20
,它要写入0x01f7
端口:
; 配置命令
mov dx, 0x01f7
mov al, 0x20 ; 0x20命令表示读盘
out dx, al
一切就绪之后,控制器就会开始读盘了,但这需要一定的时间,所以此时程序要等待驱动器工作完成。0x01f7
端口如果使用in
命令,读取到的是硬盘控制器的状态数据,其中第7
位表示是否忙碌,第3
位表示是否就绪。那么也就是说,当第7
位是0
且第3
位是1
的话,说明驱动器已经完成,否则就要持续等待:
wait_finish:
; 检测状态,是否读取完毕
mov dx, 0x01f7
in al, dx ; 通过该端口读取状态数据
and al, 1000_1000b ; 保留第7位和第3位
cmp al, 0000_1000b ; 要检测第7位为0(表示不在忙碌状态)和第3位是否是1(表示已经读取完毕)
jne wait_finish ; 如果不满足则循环等待
当驱动器就绪后,我们就可以通过0x01f0
端口来加载数据到内存了。这个端口是个16位端口,因此每次可以读2字节。这里我们用一个循环语句来完成,循环语句的循环次数要写在cx
中,每次循环时cx
会自动减1,直到cx
为0则跳出循环。
所以,如果我们需要加载2个扇区的数据,那么就是1024
字节的内容,而循环次数就是512
,所以把这个数配到cx
中:
mov cx, 512 ; 一共要读的字节除以2(表示次数,因为每次会读2字节所以要除以2)
还是按照一开始的规划,我们把屏幕打印的部分放到第二扇区,然后把它加载到0x08000
的内存位置:
mov dx, 0x01f0
mov ax, 0x0800
mov ds, ax
xor bx, bx ; [ds:bx] = 0x08000
read:
in ax, dx ; 16位端口,所以要用16位寄存器
mov [bx], ax
add bx, 2 ; 因为ax是16位,所以一次会写2字节
loop read
最后通过跳转指令跳转过去,查看是否加载成功。下面给出完整代码:
; C0H0S1
; 调用0x10号BIOS中断,清屏
mov al, 0x03
mov ah, 0x00
int 0x10
; LBA28模式,逻辑扇区号28位,从0x0000000到0xFFFFFFF
; 设置读取扇区的数量
mov dx, 0x01f2
mov al, 2 ; 读取连续的几个扇区,每读取一个al就会减1
out dx, al
; 设置起始扇区号,28位需要拆开
mov dx, 0x01f3
mov al, 0x02 ; 从第2个扇区开始读(1起始,0留空),扇区号0~7位
out dx, al
mov dx, 0x01f4 ; 扇区号8~15位
mov al, 0
out dx, al
mov dx, 0x01f5 ; 扇区号16~23位
mov al, 0
out dx, al
mov dx, 0x01f6
mov al, 111_0_0000b ; 低4位是扇区号24~27位,第4位是主从盘(0主1从),高3位表示磁盘模式(111表示LBA)
; 配置命令
mov dx, 0x01f7
mov al, 0x20 ; 0x20命令表示读盘
out dx, al
wait_finish:
; 检测状态,是否读取完毕
mov dx, 0x01f7
in al, dx ; 通过该端口读取状态数据
and al, 1000_1000b ; 保留第7位和第3位
cmp al, 0000_1000b ; 要检测第7位为0(表示不在忙碌状态)和第3位是否是1(表示已经读取完毕)
jne wait_finish ; 如果不满足则循环等待
; 从端口加载数据到内存
mov cx, 512 ; 一共要读的字节除以2(表示次数,因为每次会读2字节所以要除以2)
mov dx, 0x01f0
mov ax, 0x0800
mov ds, ax
xor bx, bx ; [ds:bx] = 0x08000
read:
in ax, dx ; 16位端口,所以要用16位寄存器
mov [bx], ax
add bx, 2 ; 因为ax是16位,所以一次会写2字节
loop read
jmp 0x0800:0x0000 ; 这里写成0x0000:0x8000也OK,只是CS和IP的值会不同,但CS:IP是相同的
times 510-($-$$) db 0 ; MBR剩余部分用0填充
dw 0xaa55
; 现在已经是C0H0S2的内容了
begin:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
mov [0x0001], byte 0x0f
mov [0x0002], byte 'e'
mov [0x0003], byte 0x0f
mov [0x0004], byte 'l'
mov [0x0005], byte 0x0f
mov [0x0006], byte 'l'
mov [0x0007], byte 0x0f
mov [0x0008], byte 'o'
mov [0x0009], byte 0x0f
hlt
times 1024-($-begin) db 0 ; 补满2个扇区
注意,由于我们已经把扇区扩展到了3个,因此bochsrc
里面 也需要修改一下硬盘的规模:
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14 # 主盘端口映射为1f0,从盘映射为3f0,中断号设置为14(虽然这几个参数都可以定制化,但这个参数是业界标准的,不建议更改)
ata0-master: type=disk, mode=flat, path=a.img, cylinders=1, heads=1, spt=3 # 主盘位置加载一块规格为C1H1S3的硬盘,镜像使用a.img
boot: disk # 设置为硬盘启动
最后通过汇编生成mbr.bin
,复制为a.img
,再启动bochs
就可以看到执行效果:
此时也可以通过调试指令来验证0x8000
的内存中确实加载了对应的指令:
由此殊途同归,我们没有使用BIOS中断,也同样完成了硬盘加载的工作。
小结
本篇介绍了跳转指令和硬盘加载方法,为后面继续编写内核做了铺垫。
下一篇会介绍如何分写MBR代码,使得MBR和内核代码分开管理。