从裸机启动开始运行一个C++程序(四)

先序文章请看
从裸机启动开始运行一个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寄存器与近跳指令的操作数相加,然后去执行对应位置的指令,进而达到跳转的目的。

远跳

所谓「远跳」,其实是给CSIP都给一个绝对值,它的操作数是一个绝对的内存地址,而不是偏移量。例如:

jmp 0x0820:0x0000

这条指令执行完后,CS会赋值为0x0820IP会赋值为0x0000,接着就会执行0x08200位置的指令。

这里需要强调的是,汇编语言指导的是机器指令,它不具备高等语义,因此,汇编器不会去检查0x08200这个地址在不在你当前操作的源文件里,也不会去管那个位置到底会不会加载合法的指令,这一切都应该由程序员自行负责。

当然,使用远跳指令时也可以使用标签,只不过此时的标签会使用「相对于文件头」的偏移量。比如说:

mov ax, 0
mov bx, 1
L1:
mov cx, 2
jmp 0x0000:L1

上面例程中jmp 0x0000:L1就是远跳指令,这时的L1就会解析为这个标签相对于文件头的偏移量,实际上也就是mov ax, 0mov 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

从上表可知,0x005000x9fbff这638.75KB的空间都是可用的,但是由于MBR占用了其中的512B,剩下的部分我们可以自由支配。

下面我们就编写一个程序,前512B作为MBR,加载两个扇区(1KB)的数据到0x08000的位置,然后再跳转至该位置,执行指令:

; C0H0S1
; 调用0x10BIOS中断,清屏
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:0x8000OK,只是CSIP的值会不同,但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看运行效果:
从裸机启动开始运行一个C++程序(四)_第1张图片

这证明,后面扇区的内容也加载成功了,跳转指令也完成了正确的跳转。

另外,当我们程序有稍微的规模了的时候,大家可以考虑用单步执行命令来做调试。例如启动后,我们先在0x7c00处打断点,然后c执行BIOS的指令,然后按n开始跳过调用流程的单步调试(s是单纯的单步调试,但是会把BIOS中断中的指令也显示出来,按n则不会)。大概效果如下:

从裸机启动开始运行一个C++程序(四)_第2张图片

而在经历一些加载数据功能后,我们还可以用x命令来查看对应内存位置,例如当执行完0x13中断后,我可以看一下0x08000位置的内存,到底有没有写入数据:

也可通过rsreg指令查看寄存器的值,比如在跳转指令前后,查看CSIP的值。跳转前:

从裸机启动开始运行一个C++程序(四)_第3张图片

然后执行n,完成跳转指令后,再看一下CSIP的值:

从裸机启动开始运行一个C++程序(四)_第4张图片

大家可以根据需要进行调试观察自己的程序。

改为硬盘启动

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后面的部分都先删除,打印几个文字来验一验效果:

; 调用0x10BIOS中断,清屏
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字节:
从裸机启动开始运行一个C++程序(四)_第5张图片

然后把它复制为a.img,再启动一下看看效果:

从裸机启动开始运行一个C++程序(四)_第6张图片

能看到输出,说明我们已经成功切换成硬盘启动了。那么接下来就是如何加载后面扇区的数据的问题了。

通过操作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位是主从盘(01从),高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
; 调用0x10BIOS中断,清屏
mov al, 0x03
mov ah, 0x00
int 0x10 

; LBA28模式,逻辑扇区号28位,从0x00000000xFFFFFFF
; 设置读取扇区的数量
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位是主从盘(01从),高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:0x8000OK,只是CSIP的值会不同,但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就可以看到执行效果:
从裸机启动开始运行一个C++程序(四)_第7张图片

此时也可以通过调试指令来验证0x8000的内存中确实加载了对应的指令:

由此殊途同归,我们没有使用BIOS中断,也同样完成了硬盘加载的工作。

小结

本篇介绍了跳转指令和硬盘加载方法,为后面继续编写内核做了铺垫。
下一篇会介绍如何分写MBR代码,使得MBR和内核代码分开管理。

从裸机启动开始运行一个C++程序(五)

你可能感兴趣的:(底层软件,嵌入式,操作系统)