先序文章请看
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)
上一章我们已经成功地在8086上运行了指令,同时也介绍了nasm汇编语言。那么接下来这一章,我们就来看看如何写BIOS自检后的第一道程序——MBR。
既然咱们已经决定要在8086上运行程序了,那么自然,现在是逃不过要了解一下8086 CPU的一些详细情况了。
值得注意的是,8086并不是只有14个寄存器,只不过这14个寄存器是对于程序来说直接打交道的。CPU内部自然还有一些用于体系自身运行的,对外不透明的寄存器,不过这些我们就不需要了解了(其实很多更详细的那些也属于Intel的商业机密,咱也没法了解)。
我先把要关注的这14个寄存器的名称列出来,然后再来解释:
符号 | 名称 | 中文翻译 |
---|---|---|
AX | Accumulator | 累加器 |
BX | Base | 基地址 |
CX | Count | 计数器 |
DX | Data | 数据存储 |
BP | Base Pointer | 基址 |
SP | Stack Pointer | 栈地址 |
DI | Destination Index | 目的偏移地址 |
SI | Source Index | 源偏移地址 |
CS | Code Segment | 指令段 |
DS | Data Segment | 数据段 |
ES | Extra Segment | 附加段 |
SS | Stack Segment | 栈段 |
IP | Instruction Pointer | 指令地址 |
FLAG | Flag | 标志位 |
需要强调一点,除了IP和FLAG以外,上面寄存器的名称所描述的本意,只是这个寄存器「通常」或「默认」用做的事情,并不是说该寄存器只可以用做这一种情况。寄存器是很珍贵的资源,因此实际操作的使用用法是灵活多样的,所以笔者并不想拿这些寄存器名称本身的含义去大书特书。大家其实需要知道,我们要关注这14个寄存器,记住它们的符号(因为汇编语言里要用到)就好了,在一些必须指定寄存器的场景,我们再单独去记忆就好了。
另外,上面这些寄存器都是16位的,这也就意味着,8086每个节拍处理的数据都是16位的,在8086这块CPU里,数据处理和传递的基本单位就是16bit,我们也称「8086的字长为16位」,也称「8086是16位CPU」。
前面我们说,8086是16位CPU,这个仅仅是指它的字长,但并不对应它的最大寻址空间。一个CPU的最大寻址空间并不取决于它的字长,而是取决于它对外的地址总线的个数。
如果你玩过数字逻辑器件的话,应该知道有一种器件叫做「译码器」,例如下图展示的是74138,三线-八线译码器:
它的输入端(A0、A1、A2)就是地址总线,我们可以想象,这三根线接到了CPU上。后面的输出端(Y0~Y7)就是数据线,我们可以想象,这8跟线接到了内存的存储单元上。
当A0A1A2输入为010
时,表示需要控制第2号地址,那么Y2会输出1。同理,当A0A1A2输入为101
时,表示需要控制52号地址,那么Y5会输出1。依次类推
在上面所述的这种结构中,我们认为CPU有3根地址总线,那么寻址空间就是23=8,地址从000
到111
。
而在计算机体系中,存储单元一般不会按二进制位(bit)来编址,而是按照字节(Byte),也就是说,每8个bits为一组,编一个地址。那么地址总线是3的CPU,就可以访问8字节的内存空间。
而对于8086来说,它含有20根地址总线(注意,并不是16根!),那么,8086的寻址空间就是: 2 20 B = 1048576 B = 1024 K B = 1 M B 2^{20}B=1048576B=1024KB=1MB 220B=1048576B=1024KB=1MB
所以,8086最多支持1MB的内存空间,地址从20个0
到20个1
,不过用二进制表示会比较冗长,所以我们通常用十六进制表示内存地址,也就是0x00000
到0xfffff
。
前面我们提到过,类似于BIOS这样的部件,随不属于内存,但使用了统一编址的方式,因此,BIOS里的数据仍然会被包含在这1MB当中,因此实际可用的物理内存,是不足1MB的,但这件事对于CPU来说是无感知的,它会按照同样的方式,通过地址总线来操作外部硬件,无论它是内存还是BIOS。也正是由于这种编址方式,就是为了让CPU不去区分实质硬件,因此,对于统一编址的硬件来说,我们仍然称其地址为「内存地址」,虽然它压根不是内存。
那么另一个很严重的问题就出现了,8086是16位CPU,它的寄存器也都是16位的,但却有20根地址总线,那我们怎么表示一个内存地址呢?8086采用的方式是,用两个16位寄存器来拼成一个20位内存地址,示意图如下:
也就是说,把其中一个寄存器作为「段寄存器」,它的0~15地址线接给全加器的4~19位,作为第一个加数。再把另一个寄存器作为「地址寄存器」,它的0~15地址线接给全加器的0~15位,作为另一个加数。
上面的和作为输出地址。(当然,实际8086内部逻辑器件比这复杂的多,笔者仅仅是做一个示意)
那么用公式来表示就是:
a d d r = ( s < < 4 ) + d addr = (s << 4) + d addr=(s<<4)+d
其中 s s s表示段寄存器中的值, d d d表示地址寄存器中的值。左移4位是指二进制位,效果相当于十进制中的 × 16 \times16 ×16,相当于十六进制中的末尾补0。
举个简单的例子,如果 s s s是0xf055
, d d d是0xa003
那么地址怎么来算呢?首先给 s s s末尾补0(因为是十六进制的),然后跟 d d d相加即可,也就是0xf0550 + 0xa003
,等于0xfa553
。
在8086中,可以用做段寄存器的有cs、ds、es和ss,而可以用做地址寄存器的有bx、di、si、bp和sp。如果你要问,为什么其他寄存器不可以呢?那也很好解释,因为只有这几个寄存器,有连接到译码器之前那个全加器上的电路,其他寄存器没有这个电路,自然也就不能直接用做此目的。
由于一个二十位的内存地址需要两个十六位操作数来表示,在汇编语言中,会采用冒号隔开,也就是s:d
的方式。例如0xf0550:0xa003
表示了0xfa553
这个地址。当然,我们也发现了,这种方式下,地址表示是不唯一的,例如0xfa00:0x0553
也同样表示0xfa553
这个地址。所以由于这个特性也会导致一些有趣的问题,我们将会在后面的章节来详细解释。
前面我们已经体验过一次8086的启动了,不过那会笔者为了让大家能先快速有一个感性的认知,就没有介绍过多的内容。在继续编写MBR之前,我们还是有必要详细理解一下8086启动过程。
CPU在启动上电的瞬间之后,它只会机械性地做一件事,就是每个时钟周期,把指令读进来,执行,然后再读下一条指令,执行……如此循环往复。
那么,究竟要从哪个位置读指令呢?这是IP寄存器决定的,IP寄存器指向哪里,CPU就会读取哪里的指令。等指令结束后,IP会自动增加指令长度的数值,这样CPU就可以执行下一条指令了。
由于8086指令集属于CISC指令集(Complex Instruction Set Computer),它的指令长度是不同的,因此,每次执行指令后,IP的偏移数也不尽相同,这取决于刚才执行的那条指令的长度。不过我们不需要过多担心,指令长度这件事CPU会自己处理好。
这里还有一个问题,IP也是一个16位寄存器,它自己没法完整表示内存地址,还需要一个栈寄存器跟它组团。那么这个栈寄存器就是CS。
换句话说,CPU永远都会执行CS:IP
处的指令,只要设置好这两个寄存器,CPU就能正常执行指令。
在8086上电的时候,CS寄存器被初始化为0xf000
,而IP寄存器被初始化为0xfff0
,所以自然,CPU执行的第一条执行在0xffff0
这个位置。为了保证机器上电自检,以及MBR加载的事项能够顺利完成,那么这个位置已经会被映射到BIOS当中,这样保证机器上电后,可以自然而然地执行BIOS中的内容。
在8086中,BIOS会被映射到0xf0000
到0xfffff
的位置,这64KB的地址由BIOS来控制。
BIOS内部会具体执行哪些指令我们不得而知(虽然通过bochs确实能看到,但它用的BIOS也只是一个开源版本的固件罢了,真机上的BIOS内容并不开源,我们也没法知道),但BIOS一定会做一些约定好的事情,方便下一步的OS内核可以正常加载。比如说,BIOS会检测外存、I/O设备是否正常,并且如果发现了MBR(也就是外存中,第一个扇区的数据,以0xaa55
结尾的),就会把这一扇区(512字节)的内容,加载到0x7c00
的位置,然后把CS:IP
设置为0x0000:0x7c00
,保证下一条指令就是0x7c00
处的指令。
回想一下前面章节中,我们给软盘的第一个扇区的第一行写了一个B80600
,然后在0x7c00
出打了断点,就可以看到ax寄存器确实变成了6
,这就是因为,这一扇区的数据,被BIOS加载到了0x7c00
的地方,然后把CS:IP
设置为0x0000:0x7c00
,这样,B80600
就成了BIOS之后执行的首条指令了。
有了这些理论基础,我们就可以继续来编写MBR了。相信大家首先想做的,应该就是在屏幕上输出点东西吧!接下来我们就按照国际惯例,在屏幕上输出Hello World!
。
在已经安装好nasm
的前提下,我们在项目路径下新建一个文件,叫做mbr.nas
,然后输入下面内容:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
hlt
times 510-($-$$) db 0
dw 0xaa55
稍后我们再来解释代码,咱们现来看看效果。
首先,要把汇编代码转换为机器码,输入下面指令,通过nasm
来进行汇编:
nasm mbr.nas -o mbr.bin
得到mbr.bin
文件,然后将其重命名为a.img
(可以直接用图形界面操作,也可以执行命令cp mbr.bin a.img
),再启动bochs
。(注意,这里复用了前面章节的工程路径,因此需要前面bochrc
的配置文件,详情可以查看前面章节)
bochs -qf bochsrc
然后按c
命令,即可看到输出结果。如果你也跟我一开始一样,盯着下面的Booting from Floppy...
没反应,然后认为程序没有生效的话,那请你往最开头来看:
可以看到,这里原本应该是「Bochs」,但是第一个字母被我们改成了「H」,所以输出是成功了。这主要是因为BIOS在屏幕上输出了一些东西,然后并没有清屏,导致我们自己的输出被「淹没」在里面了。不过要清屏需要额外解释一些其他东西,为了循序渐进,所以咱们暂时先忍忍,知道要在这些乱七八糟的信息里去寻找我们的输出就可以了。
接下来我们聚焦到这几行汇编语句上,解释一下我们都做了什么。
mov ax, 0xb800
这一句,是给ax
寄存器中赋值0xb800
,mov
指令其实更准确应该是「copy」,它会把右边的操作数赋值给左边,移动之后后面的操作数不会消失。后面一句
mov ds, ax
则是把ax
的值赋值给ds
寄存器,这样ds
寄存器中也是0xb800
了。
相信读者在这里一定会有疑惑,为什么我不能直接mov ds, 0xb800
呢?何苦劳烦ax
这样节外生枝?这就是我们编写汇编语言的时候必须要考虑的问题。汇编语言仅仅是把二进制的机器码,换了一种更加接近人类语言的方式展示而已,但它本质没有变,汇编器会把它转换成对应的机器码。所以,我们写的每一条汇编指令,都应该要有对应的机器指令才对,也就是机器能够支持的指令。而8086中的段寄存器并不可以直接通过立即数来赋值,因为8086体系根本没有这样的机器指令。
所以,在编写汇编语言的时候,我们要以CPU硬件的思维来思考,书写「指令」本身,而不是高层的抽象语义。用前面的例子来说,我们要达成「把0xb800
这个数赋值给ds
寄存器」的这个需求,要使用「mov ax, 0xb800
和mov ds, ax
」这两条指令来完成。当然,你换成bx
、cx
或者dx
做中间量也是OK的,因为这几个寄存器都可以通过立即数来赋值。
这两行代码的含义已经清楚了,我们来解释一下目的。在前面的章节中笔者曾经介绍过「显存」的概念,显卡会按照每个刷新周期,读取某一片内存空间,然后按照一定的规则解析,并输出给显示器,这片内存空间就是「显存」。
在8086机器初始化时,会默认使用标准VGA协议,并且是80×25×16的文字模式。也就是说,在这种模式下,显示器可以显示25行,每行80个字符(ASCII字符),并且支持最多16种颜色。在这种模式下,对应的显存是0xb8000
~0xb8f9f
,一共4000字节的位置。每两字节对应一个字符显示位,低字节表示ASCII码,高字节表示颜色信息。
因此,0xb8000
这个内存地址,对应的就是屏幕上第一行第一个字符对应的ASCII码,0xb8001
对应的是它的颜色信息。同理,0xb8002
对应第一行第二个字符的ASCII,0xb8003
对应它的颜色……0xb80a0
对应第二行第一个字符的ASCII,0xb80a1
对应它的颜色……0xb8f9e
对应第25行(最后一行)第80个字符(最后一个字符)的ASCII,0xb8f9f
对应它的颜色。通过给显存中写入数据,就可以控制屏幕上的字符。
那么,颜色信息是怎样的呢?颜色信息的字节中,0~2位表示文字颜色的RGB,第3位表示是否高亮,4~6位表示背景色RGB,第7位表示是否闪烁。我们可以把颜色总结如下表:
位号 | 符号 | 意义 |
---|---|---|
0 | FB | 前景色蓝色元素 |
1 | FG | 前景色绿色元素 |
2 | FR | 前景色红色元素 |
3 | I | 高亮 |
4 | BB | 背景色蓝色元素 |
5 | BG | 背景色绿色元素 |
6 | BR | 背景色红色元素 |
7 | K | 闪烁 |
配合上I位,前景色可以有16种颜色,分别是:
R | G | B | I | 颜色 |
---|---|---|---|---|
0 | 0 | 0 | 0 | 黑 |
0 | 0 | 0 | 1 | 灰 |
0 | 0 | 1 | 0 | 蓝 |
0 | 0 | 1 | 1 | 浅蓝 |
0 | 1 | 0 | 0 | 绿 |
0 | 1 | 0 | 1 | 浅绿 |
0 | 1 | 1 | 0 | 青 |
0 | 1 | 1 | 1 | 浅青 |
1 | 0 | 0 | 0 | 红 |
1 | 0 | 0 | 1 | 浅红 |
1 | 0 | 1 | 0 | 品红 |
1 | 0 | 1 | 1 | 洋红 |
1 | 1 | 0 | 0 | 棕 |
1 | 1 | 0 | 1 | 浅棕 |
1 | 1 | 1 | 0 | 浅灰 |
1 | 1 | 1 | 1 | 白 |
而背景色没有高亮位,因此只支持8种:
R | G | B | 颜色 |
---|---|---|---|
0 | 0 | 0 | 黑 |
0 | 0 | 1 | 蓝 |
0 | 1 | 0 | 绿 |
0 | 1 | 1 | 青 |
1 | 0 | 0 | 红 |
1 | 0 | 1 | 品红 |
1 | 1 | 0 | 棕 |
1 | 1 | 1 | 浅灰 |
最后配合K位,表示是否闪烁。
这里建议大家想看那种颜色,可以做一些尝试,还可以配合一下位置来编写代码,比如说,我想在屏幕第一排第一个、第二排第二个、第三排第三个分别显示ABC
,然后随便用上点颜色看看效果,就可以写成:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'A'
mov [0x0001], byte 0xF0
mov [0x00A2], byte 'B'
mov [0x00A3], byte 0x46
mov [0x0144], byte 'C'
mov [0x0145], byte 0x32
hlt
times 510-($-$$) db 0
dw 0xaa55
我们继续来解释代码,中括号表示取内存地址,所以这里的[0x0000]
表示取地址是0x0000
的内存地址,在mov
指令下,表示给内存写入数据。我们知道,一个完整的内存地址应该有两部分,而对于立即数寻址的方式来说,默认段寄存器是ds
,也就是说,[0x0000]
其实等价于[ds:0x0000]
,这就是刚才我们之所以要先设置ds
的原因。由于ds
已经被设置为0xb800
,因此[0x0000]
就是[0xb800:0x0000]
,自然也就表示了0xb8000
的地址,也就是显存的第一个字节。
那为什么要写那个byte
呢?当我们操作寄存器的时候,会按照寄存器的大小来识别操作数,比如说mov ax, 0x5
,由于ax
是16位的,因此,后面的0x5
会自动补全为0x0005
。但是,当我们操作内存的时候,就需要手动指定操作数的长度了。长度描述符有byte
、word
、dword
和qword
,分别表示1字节、2字节、4字节和8字节。注意,如果使用word
或以上的形式,将会按照小端序来处理,例如mov [0], word 0xabcd
则会在ds:0
的位置写入0xcd
,然后在ds:1
的位置写入0xab
。再多啰嗦一句,如果不写0x
前缀或h
后缀的话,将会按照十进制类解读。
综合一下,前三行代码:
mov ax, 0xb800
mov ds, ax
mov [0x0000], byte 'H'
表示的就是,在屏幕的最左上角的位置显示一个字母’H’,由于之前BIOS已经写入部分显存数据了,所以它的颜色会保持不变,当然,我们可以通过类似于mov [0x0001], byte 0x0f
的语句把它的颜色变成白色。
大家可以尝试用这种方法在屏幕上输出各种各样的内容。
后面有一句
hlt
这是挂起指令,可以让CPU暂时先不要向下继续执行,直到响应中断(关于中断会在后续章节介绍)。这里写这行语句的目的在于,每次都给bochs打断点有点麻烦,而使用hlt
指令就可以让CPU悬停再此处,方便我们观察输出,所以就不用打断点了。
最后一行的dw 0xaa55
,这里的dw
是伪指令,也就是说,它并不会翻译成机器指令,而是用于指导编译器做预处理用的,有点类似与C/C++中以#
开头的语句。dw
的意思就是按字面写2个字节,内容是后面的数,也就是0xaa55
。前面我们说过,BIOS只有在检测第一个扇区的后两个字节是0x55
和0xaa
的时候,才认为是合法MBR,并加载。所以,这行语句就是干这件事的,我们可以看到汇编之后的二进制中,最后2个字符被写入成功了:
dw
表示写2个字节,对应的还有db
写1个字节,dd
写4个字节,dw
写8个字节,注意,都是小端序。所以上面的伪指令其实还可以改成db 0x55 0xaa
,效果是一样的。
最后一个问题就是,0xaa55
是这512字节的最后两个字节,但我们刚才也没写几句指令,这中间的部分咋整?可以补0,但得补多少0呢?这主要取决于,刚才我们写的所有指令占了多少字节。注意,汇编语言中的行号是没有执行层的含义的,因为对于CISC指令集来说,每条指令的长度都可能不一样,所以行数跟指令的字节数没有直接关系。
所以,计算指令长度的这件事也就交给汇编器了,times
也是伪指令,表示后面紧跟的指令执行几次,比如说times 5 db 0
就等价于db 0 0 0 0 0
。而$
和$$
符号则是指令的偏移数,$
表示当前位置的偏移数,$$
表示首行的偏移数。注意,之所以首行也会有偏移数,这是有一种情况,就是当前文件的第一条指令并不一定加载到内存0
的位置,虽然在本代码中$$
就是0
,但我们还是用$-$$
来计算一下偏移量,而不直接用$
。
所以,这一行的意义就很明确了,times 510-($-$$) db 0
,就是从当前位置,一直补到第510字节,都补0。然后最后两个字节留给0x55
和0xaa
。
由于本系列文章并不是专业的8086汇编教程 ,因此不会过分纠结汇编语言的指令和编程技巧。但距离我们的目标——运行一个C++程序还有挺远的距离,就比如,BIOS只负责加载512字节的MBR,多的部分怎么办?另外还有一个非常令人困扰的问题,就是如何清屏?
当然了,显存的位置都已经清楚了,把他们全搞成空格符,自然也就相当于清屏了。只不过这种功能还不需要我们自己来写,用软中断的方式就可以解决。
要解决这些问题,首先我们需要了解一下软中断,在此之前,需要先了解一下中断。
简单来说,中断机制解决的就是CPU和外部设备速度严重不匹配的问题。比如说,当你在键盘上按下一个按钮的时候,CPU是需要响应的,但是,CPU怎么知道你按没按下键盘呢?
一种方式就是主动监听,用大白话来解释就是,CPU要隔三差五去看一下,键盘有没有被按下,如果有,就响应,如果没有,就回来继续干活。
但这种主动监听的方式有一个非常严重的问题,就是速率不匹配。当代CPU的主频基本都是3GHz数量级,即便是最早的8086,主频也有4.77MHz。再想想你敲击键盘的速度,根据吉尼斯官方记录,世界冠军的打字速度也不过是每分钟807个字符,这个换算下来也就是13Hz左右。换句话说,你敲一下键盘,CPU已经干了50万次以上的工作了,由于这种速率不匹配,因此选用主动监听方式对资源是一种极大的浪费。
因此,人们就想了一个办法,设计了一个中断控制器,用来监听外部事项(例如键盘敲击信号),当需要CPU响应的时候,中断控制器再去「通知」CPU,“你把手上的活先停一下,有个事情要处理。”这种机制就叫中断机制。
对于中断信号,CPU要做出对应的处理,那么自然就要有一些用于处理中断的指令,当CPU收到对应的中断时,就去执行对应的指令即可。这种机制有点像Qt中的signal-slot机制,也有点类似于Vue中的@click
绑定触发事件。总之,都是将一个事件(或者信号)跟一个函数相绑定,当收到事件信号时,执行对应的函数。
不过既然中断的处理过程就相当于一个函数的话,它自然也可以当做一个普通的函数直接调用,这种方式就被称为「软中断」。换句话说,软中断其实跟原本的中断机制没什么关系,它只不过利用了中断号,直接去执行了对应的中断响应函数罢了。
所以,软中断本质上就是函数调用。
在BIOS内部,会实现存一些中断响应的流程指令,所以我们可以通过软中断调用方式,去执行BIOS所提供的一些功能。这些BIOS提供的功能也称为「BIOS中断」。
BIOS中断可以提供很多功能,详细的情况只能去查BIOS手册了,这里笔者只介绍咱们用得上的。首先,就来解决清屏的问题。
中断的调用需要配合固定的寄存器传入参数,之前我们说过,默认情况下显卡使用的是文字模式,那么只要重新再进入一次文字模式就可以自动清屏功能,需要al
传入0x03
,ah
传入0x0
,然后使用0x10
号中断即可实现清屏(如果是其他显示模式,则会切换至文字模式)。
等等,al
和ah
寄存器是哪冒出来的?其实是这样的,对于ax
、bx
、cx
和dx
这4个寄存器来说,可以拆成高8位和低8位两个8位寄存器来使用。al
就是ax
的低8位,bh
就是bx
的高8位,以此类推。
所以,al=0x03
,ah=0x0
,效果跟ax=0x0003
是一样的。
我们修改一下MBR的代码,首先清屏,然后再打印Hello,World!
来看看效果:
mov al, 0x03
mov ah, 0x00
; 也可以写作 mov ax, 0x0003
int 0x10 ; 调用0x10号BIOS中断,清屏
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
mov [0x000a], byte ','
mov [0x000b], byte 0x0f
mov [0x000c], byte 'W'
mov [0x000d], byte 0x70 ; 浅灰底黑字
mov [0x000e], byte 'o'
mov [0x000f], byte 0x70
mov [0x0010], byte 'r'
mov [0x0011], byte 0x70
mov [0x0012], byte 'd'
mov [0x0013], byte 0x70
mov [0x0014], byte '!'
mov [0x0015], byte 0x70
hlt
times 510-($-$$) db 0
dw 0xaa55
效果如下:
这样看上去是不是顺眼多了?
本篇介绍了8086的寻址方式,以及如何通过写显存的方式来输出字符,最后介绍了通过BIOS中断实现清屏的方法。
下一篇我们将介绍跳转指令和利用BIOS中断加载外存其他部分代码的方法。
从裸机启动开始运行一个C++程序(四)