在之前的文章中我们说过,BIOS除了开机自检和加载引导扇区之外,还提供了很多有用的中断程序,这些程序是我们在真正启动操作系统之前的阶段和硬件交互的利器。既然叫基本I/O软件,那么我们就来看看其中比较重要的几个I/O功能。
中断的大名大家都有所耳闻,中断发生时,会提供一个中断号,CPU立即停止当前指令的执行,并根据中断号去中断向量表中查找中断程序的入口地址,然后执行中断程序,执行完以后再回到之前的指令继续执行。
BIOS为每一类功能提供了一个中断号,比如0x10
号中断是屏幕显示相关的功能,0x13
号中断是磁盘相关的功能,0x16
号中断是键盘相关的功能,中断号是中断服务程序(ISR:Interrupt Service Routine)在中断向量表中的索引。在每一大类功能下有细分了不同的小功能,它们通过ah
寄存器的值来区分。
说到寄存器,让我们来认识一下寄存器。
这是x86架构下的寄存器,AX
,BX
,CX
,DX
这4个16位寄存器又可以分为8个8位寄存器,名称中的"X"表示Extern,表示从8位扩展到16位。CPU也是从8位,16位,32位,64位漫漫发展起来的,32位寄存器在16位寄存器名称前加了一个"E",还是Extern的意思,而到了64位CPU,则把"E"换成了"R",如RAX
。
我们不会在BIOS待太长时间,更准确的说是我们不会在16位绝对模式下待太久,下一章中我们会说明原因。所以这里我们只学习必要的BIOS功能,想要完全攻略BIOS的话可以参考附录的资料。
内存与寻址
很遗憾我们不得不在这个节骨眼上先讨论这个问题。出于它的重要性,我将它单独成章放到了开头的位置,在后面的例子中,我们还会不断加深对它的理解。
内存
内存,标签,地址,指针…这些都是什么啥?
内存其实就是一个数组!我们知道数组包含两个信息:下标和下标对应的值。标签,地址,指针说的都是下标,是同一个东西,没有区别,这一点一定要记住,很关键。在高级语言中使用数组我们习惯用十进制表示下标,而在汇编中,更多的是使用十六进制来表示,本质上其实是一样的。
对比下面的汇编代码和Go代码:
mov ax [0x0a]
arr := [1024]byte{}
ax := arr[10] // 或 ax := arr[0x0a]
如果我们把arr[10]
写成十六进制,则是arr[0x0a]
,如果再把arr
省略掉的话,可以说简直和汇编代码一摸一样,因为内存就那一个,所以我们访问内存这个大数组的时候不用特意指定它,于是就写成了[0x0a]
,虽然这只是一个语法上的巧合,但本质就是如此,与其说是巧合,我更愿意相信是一脉相承。
标签也只是一个下标而已,虽然我们写的是字符串,但是汇编器会帮助我么将它转换成下标的,因为要我们自己来算的话还是太麻烦了,前提是你要知道每条汇编代码对应的机器码有几个字节,想想就头大。除了机器码的长度,能影响标签值的还有org
指令,我相信你已经见过它了。
标签就是下标没错,但是机器码是放在磁盘上的,需要被加载到内存,而且可以被放在内存的不同位置。 比如下面这段汇编程序:
msg: db 'A'
mov al, [msg] ; 等价于 mov al, [0]
其中,mov al, [msg]
和mov al, [0]
没任何区别,汇编器会帮你把mag
替换成0的。它们汇编之后的机器码都是41 A0 00 00
,注意是十六进制。第一个0x41
是字符’A’的ASCII码,第二个0xA0
是mov ax
的机器码,最后两个0x0000
是msg
的值。注意此时的标签是16位的。
如果我们把这段程序加载到内存的开头,那么是没有问题的。如果我们把它加载到内存的0x10
处,那么al
寄存器的值还是A
吗?
显然,此时是找不到’A’的,根据程序被加载到内存的位置,我们需要修正标签的值,也就是所谓的绝对地址。永远记住,内存就是一个数组,我们只有下标和下标对应的值。org
指令就是用来帮助我们修正标签的值的,还是上面的程序,我们加上org 0x10
,如下:
org 0x10
msg: db 'A'
mov al, [msg]
此时汇编出来的机器码是41 A0 10 00
,还是十六进制,此时msg
的值变成了0x0010
,也就是在原值的基础上加上了0x10
的偏移。再次强调,x86是小端序,低字节在前。关于大端序和小端序的本质,还请参看附录。看到了吗?org
指令后面的数字本质也是下标,数组只有两个信息,不是值就是下标。
既然说到这里,我们再来说说jmp
。这里我们没有选择jmp
来说明org
的作用,因为jmp
其实不受org
的影响,因为jmp
并不是跳转到绝对地址,而相对当前地址的一个偏移量。比如最简单的一个死循环程序:
fin:
jmp fin
上面的代码经过汇编之后的机器码是EB FE
,EB
是jmp
的机器码,jmp
有3个机器码,分别是短跳转EB
,近跳转E9
和远跳转EA
。对于不同类型的跳转,偏移量的位宽是不一样的哦。
那么FE
又是什么呢?
jmp fin
经过汇编之后是2个字节,fin
的值是0,要跳到0,偏移量就是-2,FE
正是-2的补码。这么看确实不太容易一眼看出来,我们可以用下面的代码再试试。
jmp fin
db 0
fin:
经过汇编之后的机器码是EB 01 00
,现在的偏移量是0x0001
,fin
和jmp fin
之间可不隔着一个字节吗。
指针可能曾经是大部分C程序员的噩梦,现在大可不必了,你只需要记住内存是一个数组,指针是数组的下标,十进制还是十六进制根本不影响它的本质。char *p
,p
就是下标,*p
就是下标对应的值;char p[]
,p
还是下标,p[1]
是下标对应的值。关于指针的本质可以参考下图。
最后是关于mov
指令,告诉大家一个方便记忆的方法,就是把,
换成=
就可以了,比如mov ax, bx
就是ax = bx
,是不是一下子就记住了呢。不过要注意,mov
的复制方向在不同的汇编中是不同的,比如在gas汇编中,mov
的复制方向就是和nasm相反的,也就是gas中的mov
是符合move的字面语义的。
理解了标签和地址的本质之后,我们再来看看上一节中代码的倒数第二条语句times 510+$$-$ db 0
。其实它还有很多种写法,比如db 510+$$-$ dup 0
,其中最关键的是510+$$-$
,也就是决定我们要填充多少个0。$$
是代码的起始地址,是0吗?一般情况下是,但这里不是,因为有org 0x7c00
,所以$$=0x7c00
。$
表示当前指令的地址,也就是当前指令在数组中的下标。应该填充多少个0我们可以用下面的公式计算:
我们再次看到,不管是$$
还是$
都只是数组的下标而已。下标,下标,还是下标!
哦对了,还有变量名,它是什么呢?抱歉,还是下标,变量名也只不过是下标而已,变量的值就是下标对应的值。
段寻址
寻址听起来似乎很高深的样子,那如果我告诉你寻址即寻找地址,地址就是下标,寻址也就是寻找下标,数组的下标,这东西我们就很熟悉了吧。
在我们使用数组的过程中,似乎从来不存在寻址的问题,我想要第7个元素的内容,直接arr[7]
就完事了,啪的一下就很快呀。没错,这其实就是寻址,就是这么简单。但是回到硬件上来,问题会稍微有点不同,但是也不难的,信我。
首先我们需要简单了解下内存的工作原理,需要一点点的硬件知识。我们该如何驱动一块存储设备呢,或者说cpu该如何与存储设备交互呢?这一块的内容最好的方式是去看看简单点的存储芯片的芯片手册。存储芯片一般有地址总线和数据总线,地址总线选中存储单元,数据总线交换数据。还记得吗?数组包含两个信息,下标和下标对应的值,刚好能对应上。
简单来说,处理器首先把数据和地址分别给到数据总线和地址总线,然后给读/写控制引脚一个信号,数据交换就完成了。能够访问多少内存单元取决于地址总线的宽度,这就是CPU寻址和高级语言中使用数组的区别,在高级语言中使用数组,我们从来不担心数组下标会超过一个int
的上限,而在CPU这里,这种情况是实实在在会发生的。
如果使用一个16位的寄存器来存储下标,那么我们只能够访问64KB的内存范围,这显然太小了。解决办法就是段寻址,所谓段寻址,本质上就是二维数组下标。ds:bx
分别是二维数组的行和列,以64字节数组为例,直接寻址的最大下标是63,需要8位,而段寻址虽然需要两个寄存器,但每个下标的值都不超过7,只需要3位即可。
当然,数组并没有真的变成二维的,只是在抽象中,它是二维的。所谓的二维数组,也是一维数组模拟出来的。
但实际的情况理想情况是有出入的,假设二维数组大小为M×N
,按照二维数组的下标和一维数组下标转换关系,二维数组中r
行c
列的元素对应的一维数组下标为r×N+c
,因为内存实际是一维数组,所以转化是必要的。16位寄存器能存储的最大值是0xffff
,也就是说,一行最多可以有0xffff
列,即N=0xffff
,那么下标应该是r×0xffff+c
,这样算来,两个16位寄存器能覆盖4G的范围(这和一个32位寄存器能覆盖的范围是一样的),然而真实的情况是下标=r×16+c
,只能覆盖1M多一点的范围。这也导致相邻的段会重叠,比如第1段的第17字节,实际上也是第2段的第1字节。虽然按照计算公式,二维数组应该只有16列,但是CPU并不会限制你给一个大于16的列号。
尽管到后面32位模式后,段寻址又有所变化,但是万变不离其宗,你只需要记住一点,所谓段寻址,就是二维数组和一维数组的下标换算。
可能你会疑惑,我们平时写代码寻址的时候都是直接写的[xxx]
,并没有使用段呀?嗯,那只是汇编器帮助你偷了个懒而已,即使是只写[xxx]
,实际上背后也是[seg:xxx]
,CPU有4个段寄存器,具体使用哪个寄存器根据上下文而定。例如读数据的话默认会使用ds
寄存器,比如[0x7c00]
实际上是[ds:0x7c00]
,而涉及栈指针的寄存器就会默认使用ss
寄存器,如[bp]
实际上是[ss:bp]
。BIOS并不会去设置段寄存器,因此,BIOS加载完引导扇区后,4个段寄存器的值都是0。
屏幕显示
屏幕显示是计算机最重要的I/O功能之一,毕竟一个没有输出的系统对我们是没有意义的。BIOS提供了简单的屏幕显示中断服务程序,让我们可以在操作系统真正启动之前在屏幕上显示一些简单的信息,屏幕相关功能对应的中断号是0x10
。
显示模式
屏幕是有很多显示模式的,比如彩色还是单色,文本还是图形模式等,这里暂时不展开各个显示模式,多数计算机启动时,BIOS默认选择的显示模式是80×25彩色文本模式(80×25是分辨率,横向80个字符,纵向25个字符),至少我们的Qemu模拟器是这样的。目前我们还没有切换显示模式的打算,只是介绍下如何切换显示模式,以便以后我们想开发一个图形界面操作系统时知道如何开始。而且,BIOS提供的字符显示函数在各个屏幕模式下都是可以使用的。
切换显示模式的功能号是0x0
,通过ah
传递,具体的模式放在al
寄存器中。比如al=0x13
切换到320×200 256色图形模式,更多显示模式参考附录的资料。
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
mov ah, 0x00 ; 切换显示模式
mov al, 0x13 ; 320×200 256色彩色图形模式
int 0x10 ; 触发中断
jmp $ ; for {}
times 510-$+$$ db 0 ; 填充0直到510字节
dw 0xaa55 ; 启动扇区标识
不出意外的,运行上面的程序会看到下面这个黑乎乎的窗口,这说明我们的显示模式已经切换成功了。我们没有看到上一节中出现的启动提示文字了。
BIOS也提供了获取屏幕显示模式的终端服务程序,功能号是ah=0x0f
。返回值有两个:
-
ah
:字符列数 -
al
:显示模式
代码如下:
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
mov ah, 0x0f ; 获取屏幕显示模式
int 0x10 ; ah:字符列数 al:显示方式
jmp $ ; for {}
times 510-$+$$ db 0 ; 填充0直到510字节
dw 0xaa55 ; 启动扇区标识
虽然可以获取出来,但是目前我们还不知道如何把它打印出来。这里我的Qemu默认的显示模式是0x03
,也就是前面说的80×25彩色文本模式。接下来我们会学习如何在屏幕上打印字符,到时候你也可打印出你的屏幕显示模式看看。
显示字符
注意,我们回到了默认的80×25彩色文本模式下,显示字符的功能号是0x0e
,涉及以下3个寄存器。
-
ah=0x0e
:显示字符,自动移动光标 -
al
:要显示的字符的ASCII码 -
bl
:前景色
示例程序如下:
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
mov ah, 0x0e ; 打印字符
mov al, 'A' ; 打印A
int 0x10 ; 触发中断
jmp $ ; for {}
times 510-$+$$ db 0 ; 填充0直到510字节
dw 0xaa55 ; 启动扇区标识
0x0e
这个打印字符的功能会在光标处打印字符,并自动更新光标位置,使用起来比较简单,你可以动手改造,让它显示一个字符串。
虽然许多资料都表明bl
的参数可以设置字符属性,但是我的尝试结果是失败的。当然,这并不是屏幕显示模式的问题,事实上默认模式下是可以显示出彩色字符的,不过要通过另一种方式,所有大家不必过于纠结这个问题。
尽管不是很明显,但是我们在启动信息的下面还是能找到那个孤独的身影。
显示字符串
除了显示字符,BIOS也提供了显示字符串的中断服务程序,它的功能号是ah=0x13
。参数说明如下:
-
ah=0x13
:显示字符串 al
:写入模式
-
al=0x00
:字符属性由bl
寄存器提供,字符串格式为ascii,ascii,ascii...
,cx
是以字节为单位的长度,光标不动。 -
al=0x01
:同上,但光标会移动到字符串末尾。 -
al=0x02
:字符属性由紧跟在字符后的字节提供,字符串格式为ascii,attr,ascii,attr...
,cx
是以字(word)为单位的长度,光标不动。 -
al=0x03
:同上,但光标会移动到字符串末尾。
-
cx
:字符串长度 -
dh
:显示在哪一行 -
dl
:显示在哪一列 -
bp
:字符串地址,注意完整的地址是es:bp
-
bh
:页号 -
bl
:字符属性
下图是四种写入模式对应的字符串格式示意图。
这个函数对于字符属性的设置是有效的,你可以用它显示出花里胡哨的信息来。
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
mov ah, 0x13 ; 显示字符串
mov al, 0x1 ; 写入模式
mov cx, 0x0b ; 字符串长度
mov dh, 0x0a ; 显示位置的行号
mov dl, 0x0 ; 显示位置的列号
mov bp, msg1 ; 字符串地址
mov bh, 0 ; 页号
mov bl, 0x05 ; 字符显示属性
int 0x10 ; 触发中断
mov al, 0x03 ; 切换写入模式
mov bp, msg2 ; 字符串地址
mov dx, 0x0b00 ; 下一行第一列
int 0x10 ; 触发中断
jmp $ ; for {}
msg1: db "hello,anos!"
msg2: db 'h', 0x5, \
'e', 0x6, \
'l', 0x7, \
'l', 0x8, \
'o', 0x9, \
',', 0xa, \
'a', 0xb, \
'n', 0xc, \
'o', 0xd, \
's', 0xe, \
'!', 0xf
times 510-$+$$ db 0 ; 填充0直到510字节
dw 0xaa55 ; 启动扇区标识
不出意外的话,你能看到下面这张图的内容。
是彩色的呢,真不错,哈哈哈。如果你对为什么是这个颜色感到好奇,可以参加附录B。
屏幕上下卷
屏幕上卷的功能号是ah=0x06
,屏幕下卷的功能号是0x07
。所以我们就放到一起说了,参数如下:
-
ah=0x06/0x07
:屏幕上卷(0x6
)或屏幕下卷(0x7
)。 -
al
:卷入行数,0表示清空屏幕 -
bh
:卷入区域上/下行的写入属性 -
ch
:左上角行号 -
cl
:左上角行号 -
dh
:右下角行号 -
dl
:右下角列号
注意,该功能通过(ch,cl)
和(dh,dl)
划出了一个矩形区域,无论是上下卷还是清屏,都是针对这个区域来做的,区域外不受影响。不管是上卷还是下卷,相当于要将下图中白色矩形区域上下移动,而空出来的那些行要写入空白符,bh
实际上是指定在这些空出来的地方写入空白符的属性。所谓属性,就是字符的前景色,背景色等等。卷完以后,光标的位置并不会改变。
来搞一点好玩的,我们可以通过上卷清空屏幕并把屏幕刷成蓝色。
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
mov ah, 0x6 ; 屏幕初始化或上卷
mov al, 0x0 ; 卷如行数,0表示整个窗口空白
mov bh, 0x12 ; 卷入后空出的行的写入属性
mov ch, 0x0 ; 左上角行号
mov cl, 0x0 ; 左上角列号
mov dh, 0x18 ; 右下角行号
mov dl, 0x4f ; 右下角列号
int 0x10 ; 触发中断
jmp $ ; for {}
db 510+$$-$ dup 0 ; 填充0直到510字节,$$=0x7c00
dw 0xaa55 ; 启动扇区标识
不出意外的话那就是没有意外,你能看到下面的效果。
蓝哇哇的,哈哈哈。
设置显示位置
设置光标位置的功能号是ah=0x02
,有3个参数。
-
ah=0x02
:设置光标位置 -
bh
:页号 -
dh
:行号 -
dl
:列号
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
mov ah, 0x2 ; 设置光标位置
mov bh, 0 ; 页号
mov dh, 7 ; 行
mov dl, 25 ; 列
int 0x10 ; 触发中断
jmp $ ; for {}
db 510+$$-$ dup 0 ; 填充0直到510字节,$$=0x7c00
dw 0xaa55 ; 启动扇区标识
效果就大家自己看吧,光标会一闪一闪的提示你它在哪里。
读磁盘
认识磁盘
读磁盘是我们真正需要的功能,因为BIOS只会加载一个扇区到内存,如果我们的操作系统不止512字节,那么就必须通过引导扇区将真正的操作系统从磁盘加载到内存。
首先我们要认识磁盘,现实中的硬盘长下面这个样子,哇,还蛮精致的呢。
硬盘的基本结构包括磁盘,磁臂,磁头,马达等。磁盘是存储数据的地方,磁盘的两个盘面都可以存储数据;磁臂是移动磁头的装置,磁头装在磁臂上,磁头紧贴着磁盘盘面,数据就是靠磁头读写到磁盘上的,因此每个盘面都需要一个磁头。马达是用来旋转磁盘的,磁盘的旋转配合磁头的移动,就能无死角的读写磁盘的每一个地方。
磁盘的盘面上并不是每一个地方都能存储数据,实际上数据只存在于磁道上。磁道是分布在磁盘上一圈一圈的同心圆环,就像老唱片一样,磁道并不是连续的,它们被分割成一段一段的扇区。参考下图左边的部分,注意,扇区并不是整个绿色覆盖的区域,而是只有一条磁道上的一小段而已。以前内外圈磁道被分成同样多的扇区,每个扇区都是512字节,这就导致内外圈磁道的存储密度不同,内高外低。现代磁盘使用了新的技术,外圈磁道的扇区数要比内圈多,提高了外圈磁道的存储密度。注意,不管扇区有多长,一个扇区始终只有512字节。所以提高外圈磁道存储密度的方式只能是划分出更多的扇区。
柱面是一个抽象出来的概念,由所有盘面上相同半径的磁道组成。为什么要抽象出柱面的概念呢?因为磁臂不能单独移动某个磁头,所有磁头是一起移动的,其实构成柱面的,就是所有磁头选中的磁道。
磁盘读写的最小单位是扇区,也就是512字节。扇区是磁盘最基本的单元,那么我们该如何定位一个扇区呢?首先我们需要知道在哪个盘面,其次我们需要知道在哪个磁道,最后我们需要知道是第几个扇区。这就是磁盘的CHS定位机制,C指柱面(Cylinder),H指磁头(Heads),S指扇区(Sector)。柱面实际上就是在定位磁道,磁头是在定位盘面。柱面,磁头,扇区,都是有编号的,这就是我们读磁盘需要用到的全部参数。但是要注意,柱面和磁头的编号是从0开始的,而扇区的编号是从1开始的。
CHS的三个参数中,柱面共10比特,最大1023;磁头8比特,最大255;扇区6比特,最大63。因此CHS寻址的最大范围也才7.387GB。常见的3.5英寸软盘有80个柱面,2个磁头,每个磁道18个扇区,共1440KB。
CHS只适用于内外盘扇区数相同的老式磁盘,现代磁盘由于内外圈扇区数不同,采用的是线性的扇区编号寻址方式,也就是只通过扇区号来定位扇区,但是它依然可以通过地址翻译和CHS之间相互换算。
读盘
读盘的中断号是0x13
,功能号是ah=0x02
。参数说明如下:
-
ah=0x2
:读磁盘 -
al
:读几个扇区 -
ch
:柱面号 -
cl
:扇区号 -
dh
:磁头号 -
dl
:驱动器号,一个计算机可以装多个磁盘(包括硬盘,软盘,优盘,光盘等)。软驱编号从0开始,0表示软驱A,1表示软驱B;硬盘驱动器从0x80
开始编号,0x80
表示硬盘C,0x81
表示硬盘D。 -
bx
:数据写入内存的地址,完整寻址是es:bx
返回值说明如下:
-
flag.cf
:flag
寄存器的cf
位,也就是进位标志。cf=0
成功,cf=1
失败。 -
ah
:错误码,0表示读取成功,参见读盘错误码。 -
al
:成功读取的扇区数
耶,终于要读盘了吗?不急,还有一个问题没有搞清楚,目前我们是使用一个文件来模拟启动盘,我们需要知道每个扇区在文件中对应的位置,这样当我们生成镜像文件的时候才知道该把数据放到文件的什么位置,读盘的时候要怎么读。说白了,还是CHS坐标系和一维坐标系的转换关系,因为文件是个一维数组。
从文件头部开始首先是0柱面0磁头的扇区依次排列,然后是1磁头的全部扇区,直到最后一个磁头,然后是1柱面,又从0磁头开始,周而复始。如下图所示。
这里最好是使用软盘启动,因为对于软盘,我们明确知道它有几个柱面,几个磁头,几个扇区,而对于硬盘,我们就不那么清楚了,当然你也可以试出来。
作为例子,我们往磁盘的0柱面0磁头2扇区写入一个字符’A’,然后将第2扇区读到引导扇区后面的空闲区域,并将字符打印出来。这里再次回顾一下BIOS加载完引导扇区后的内存布局。
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
mov ah, 0x2 ; 读磁盘
mov al, 0x1 ; 扇区数
mov ch, 0 ; 柱面,从0开始
mov cl, 2 ; 扇区,从1开始
mov dh, 0 ; 磁头,从0开始
mov dl, 0x80 ; 驱动器号,0:软驱A,1:软驱B,0x80:磁盘C
mov bx, 0x7e00 ; 数据地址
int 0x13 ; 磁盘中断
jnc print ; 读盘成功则跳转到print
mov [error_code], ax ; 保存错误码
mov ah, 0x13 ; 显示字符串
mov al, 0x1 ; 写入模式
mov cx, 0x12 ; 字符串长度
mov dh, 0x08 ; 显示位置的行号
mov dl, 0x0 ; 显示位置的列号
mov bp, error_msg ; 字符串地址
mov bh, 0 ; 页号
mov bl, 0x04 ; 字符显示属性
int 0x10 ; 打印字符串中断
jmp fin
print:
mov ah, 0x0e ; 打印字符
mov al, [bx] ; 打印第2扇区的第一个字节
int 0x10 ; 打印字符中断
fin:
jmp $ ; for {}
error_msg: db 'Read disk failed.', 0
error_code: dw 0
db 510+$$-$ dup 0 ; 填充0直到510字节,$$=0x7c00
dw 0xaa55 ; 启动扇区标识
; 0柱面 0磁头 2扇区
db 'A', 511 dup 0
我这里是选择让Qemu模拟从硬盘启动,如果你使用的是软盘,记得要更改驱动器号。虽然我们手动指定了驱动器号,但实际上BIOS引导完成后会将引导盘的驱动器号存入dl
寄存器中,因此,这里其实可以不指定dl
寄存器的值,写出来只是为了把细节都扒开给你看。实际更好的做法是在一开始先将dl
寄存器的值存储到内存中,防止后面要用。
这里我们使用了jnc
跳转指令,它的含义是当cf=0
时跳转,n
表示not set;还有一个对应的jc
跳转指令,表示当cf=1
时跳转。
虽然还很简陋,但是到这里我们已经前进了一大步了,因为我们已经有能力将真正的操作系统加载到内存中了,毕竟512字节对操作系统来说还是太拮据了。
写磁盘的功能大家可以自己去尝试,对于加载操作系统,暂时用不到写磁盘的功能,这里就不演示了。
键盘
键盘有两个中断号,0x09
是键盘按下中断,用来向键盘缓冲区写入字符,0x16
是读键盘缓冲区中断,用来从键盘缓冲区读取数据,我们主要使用后者。
读键盘中断有两个功能号,ah=0x0
是阻塞读取,如果键盘缓冲区为空,会循环等待;而ah=0x1
是非阻塞的读取,它通过标志寄存器的ZF
标志位来告诉我们缓冲区是否有数据,ZF=0
表示有数据,ZF=1
表示缓冲区为空。这两种读取方式都是通过ah
返回扫描码(也就是ctrl
,shift
这些),al
返回字符码。最后还有一个ah=0x2
的功能是读取键盘的状态码,也是通过al
寄存器返回。
对于用来读入操作系统的引导扇区来说,键盘的功能暂时还用不上,我们写一个简单的示例以作说明,它会打印出你在键盘上按下的键。
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
keyboard:
mov ah, 0 ; 阻塞读键盘
int 0x16 ; 键盘中断
mov ah, 0x0e ; 打印字符
int 0x10 ; 打印字符中断
jmp keyboard ;
jmp $ ; for {}
db 510+$$-$ dup 0 ; 填充0直到510字节,$$=0x7c00
dw 0xaa55 ; 启动扇区标识
有一点需要注意的是,这里的Enter
键只能回车,没有换行的功能。
函数与栈
说到函数就绕不开栈,这大概也是许多高级语言程序员的噩梦了吧。
函数的本质只是内存数组种某个下标范围内的机器指令字节,函数调用的本质就是跳转,至于传参,只要调用双方遵守一致的规则即可,并无特殊。一次函数调用涉及两次跳转,一次是从调用者跳转到被调用者,另一次是从被调用者跳回调用者。从调用者跳到被调用者是非常简单的,因为跳转地址是固定的。但是从被调用者跳回调用者就麻烦了,因为不知道从哪里跳来的,也就不能确定应该跳回哪里。
如果让你解决整个问题你会怎么做呢?嗯,只要让调用者告诉被调用者应该跳回哪里就可以了,参数传递不就是调用者告诉被调用者信息的一种方式吗。例如,我们利用BIOS打印字符的功能写一个能打印字符串的函数。
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
mov si, msg ; 传递参数
mov bx, return_here ; 保存返回地址
jmp print ; 调用print函数
return_here: ; 返回地址
mov ah, 0x0e ; 打印字符
mov al, '!' ; 显示感叹号
int 0x10 ; 打印字符中断
jmp $ ; for {}
; 打印字符串
print:
mov ah, 0x0e
.print_loop:
mov al, [si]
add si, 1
cmp al, 0
je .fin
int 0x10
jmp .print_loop
.fin:
jmp bx
; 数据
msg: db 'Hello, anos', 0, 0
db 510+$$-$ dup 0 ; 填充0直到510字节,$$=0x7c00
dw 0xaa55 ; 启动扇区标识
运行上面的代码,如果能在屏幕上看到Hello, anos!
,那么就说明我们的函数返回到了正确的地方。
虽然我们成功调用了函数,但是你也能看到要自己摆弄返回地址还是比较麻烦的一件事,于是CPU提供了call
和ret
指令来帮助我们。call
指令和jmp
的区别是call
在跳转之前会将返回地址压入栈顶,而且它不需要定义标签,因为它可以读取ip
寄存器来计算返回地址。
为啥是栈呢?因为寄存器实在是太珍贵了,参数太多的时候也要借助栈来传递,还有就是用栈来保存寄存器的值,寄存器是指令执行的状态,如果被调用函数更改了寄存器的值,可能导致调用者无法正确运行,所以进入函数后要先保存重要寄存器的值,并在返回之前恢复寄存器,除了push
和pop
,CPU提供了pusha
和popa
来同时保存和恢复所有寄存器,pusha
即push all。
栈有栈底和栈顶,分别由bp
和sp
寄存器表示。x86的栈是从高地址向低地址增长的,也就是入栈之后,sp
的值会减小,bp
和sp
永远满足bp≥sp
。
改进一下我们的程序,使用call
指令,并用栈来保存寄存器的状态。
; 启动扇区
org 0x7c00 ; 也可以写作 [org 0x7c00]
mov si, msg ; 传递参数
call print ; 调用print函数
mov ah, 0x0e ; 打印字符
mov al, '!' ; 显示感叹号
int 0x10 ; 打印字符中断
jmp $ ; for {}
; 打印字符串
print:
pusha ; 保存寄存器
mov ah, 0x0e
.print_loop:
mov al, [si]
add si, 1
cmp al, 0
je .fin
int 0x10
jmp .print_loop
.fin:
popa ; 恢复寄存器
ret ; 返回调用者
; 数据
msg: db 'Hello, anos', 0, 0
db 510+$$-$ dup 0 ; 填充0直到510字节,$$=0x7c00
dw 0xaa55 ; 启动扇区标识
不错,我们在屏幕上成功看到了Hello, anos!
还有最后一个问题,我们的栈在哪儿呢?
栈的范围是ss:bp
到ss:sp
之间的内存字节。BIOS加载完引导扇区之后,ss
段寄存器和bp
寄存器的值是0,而sp
寄存器的值是0x6F00
,从内存分布图可以看出栈顶在引导扇区之上的一段空闲空间。我们也可以自己更改栈的位置,只要设置ss
,bp
和sp
寄存器的值即可,唯一需要注意的是让栈位于空闲空间内。
附录B
在前面显示彩色字符串的时候,我们并没有解释颜色参数的含义,它实际上是颜色的编号。Qemu使用的是Plex86/Bochs LGPL VGA BIOS,VGA使用的是调色模式,它会预先定义一些颜色,其RGB值放在显卡内存中,想要显示某种颜色,只需要指定该颜色在调色表中的索引即可,这样,我们使用一个字节就能指定256种颜色呢。
既然有颜色表,那么我们也可以自定义这个颜色表的内容,不过只在320×200图形模式下生效。BIOS设置调色板的功能号是ah=0xb
,具体信息可以参考附录A的相关资料,这里我们就不做演示了。
这里主要带大家看一下上面提到的两种显示模式的默认色彩表。
80×25彩色字符模式:
320×200彩色图形模式: