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

先序文章请看
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)

编写MBR

上一章我们已经成功地在8086上运行了指令,同时也介绍了nasm汇编语言。那么接下来这一章,我们就来看看如何写BIOS自检后的第一道程序——MBR。

8086的14个寄存器

既然咱们已经决定要在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的寻址方式

前面我们说,8086是16位CPU,这个仅仅是指它的字长,但并不对应它的最大寻址空间。一个CPU的最大寻址空间并不取决于它的字长,而是取决于它对外的地址总线的个数。

如果你玩过数字逻辑器件的话,应该知道有一种器件叫做「译码器」,例如下图展示的是74138,三线-八线译码器:
从裸机启动开始运行一个C++程序(三)_第1张图片

它的输入端(A0、A1、A2)就是地址总线,我们可以想象,这三根线接到了CPU上。后面的输出端(Y0~Y7)就是数据线,我们可以想象,这8跟线接到了内存的存储单元上。

当A0A1A2输入为010时,表示需要控制第2号地址,那么Y2会输出1。同理,当A0A1A2输入为101时,表示需要控制52号地址,那么Y5会输出1。依次类推

在上面所述的这种结构中,我们认为CPU有3根地址总线,那么寻址空间就是23=8,地址从000111

而在计算机体系中,存储单元一般不会按二进制位(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,不过用二进制表示会比较冗长,所以我们通常用十六进制表示内存地址,也就是0x000000xfffff

前面我们提到过,类似于BIOS这样的部件,随不属于内存,但使用了统一编址的方式,因此,BIOS里的数据仍然会被包含在这1MB当中,因此实际可用的物理内存,是不足1MB的,但这件事对于CPU来说是无感知的,它会按照同样的方式,通过地址总线来操作外部硬件,无论它是内存还是BIOS。也正是由于这种编址方式,就是为了让CPU不去区分实质硬件,因此,对于统一编址的硬件来说,我们仍然称其地址为「内存地址」,虽然它压根不是内存。

那么另一个很严重的问题就出现了,8086是16位CPU,它的寄存器也都是16位的,但却有20根地址总线,那我们怎么表示一个内存地址呢?8086采用的方式是,用两个16位寄存器来拼成一个20位内存地址,示意图如下:

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

也就是说,把其中一个寄存器作为「段寄存器」,它的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 s0xf055 d d d0xa003那么地址怎么来算呢?首先给 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启动时发生的事情

前面我们已经体验过一次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会被映射到0xf00000xfffff的位置,这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

有了这些理论基础,我们就可以继续来编写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...没反应,然后认为程序没有生效的话,那请你往最开头来看:
从裸机启动开始运行一个C++程序(三)_第3张图片

可以看到,这里原本应该是「Bochs」,但是第一个字母被我们改成了「H」,所以输出是成功了。这主要是因为BIOS在屏幕上输出了一些东西,然后并没有清屏,导致我们自己的输出被「淹没」在里面了。不过要清屏需要额外解释一些其他东西,为了循序渐进,所以咱们暂时先忍忍,知道要在这些乱七八糟的信息里去寻找我们的输出就可以了。

接下来我们聚焦到这几行汇编语句上,解释一下我们都做了什么。

mov ax, 0xb800

这一句,是给ax寄存器中赋值0xb800mov指令其实更准确应该是「copy」,它会把右边的操作数赋值给左边,移动之后后面的操作数不会消失。后面一句

mov ds, ax

则是把ax的值赋值给ds寄存器,这样ds寄存器中也是0xb800了。

相信读者在这里一定会有疑惑,为什么我不能直接mov ds, 0xb800呢?何苦劳烦ax这样节外生枝?这就是我们编写汇编语言的时候必须要考虑的问题。汇编语言仅仅是把二进制的机器码,换了一种更加接近人类语言的方式展示而已,但它本质没有变,汇编器会把它转换成对应的机器码。所以,我们写的每一条汇编指令,都应该要有对应的机器指令才对,也就是机器能够支持的指令。而8086中的段寄存器并不可以直接通过立即数来赋值,因为8086体系根本没有这样的机器指令。

所以,在编写汇编语言的时候,我们要以CPU硬件的思维来思考,书写「指令」本身,而不是高层的抽象语义。用前面的例子来说,我们要达成「把0xb800这个数赋值给ds寄存器」的这个需求,要使用「mov ax, 0xb800mov ds, ax」这两条指令来完成。当然,你换成bxcx或者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

效果如下(注意,A是闪烁的,但截图显示不出来):
从裸机启动开始运行一个C++程序(三)_第4张图片

我们继续来解释代码,中括号表示取内存地址,所以这里的[0x0000]表示取地址是0x0000的内存地址,在mov指令下,表示给内存写入数据。我们知道,一个完整的内存地址应该有两部分,而对于立即数寻址的方式来说,默认段寄存器是ds,也就是说,[0x0000]其实等价于[ds:0x0000],这就是刚才我们之所以要先设置ds的原因。由于ds已经被设置为0xb800,因此[0x0000]就是[0xb800:0x0000],自然也就表示了0xb8000的地址,也就是显存的第一个字节。

那为什么要写那个byte呢?当我们操作寄存器的时候,会按照寄存器的大小来识别操作数,比如说mov ax, 0x5,由于ax是16位的,因此,后面的0x5会自动补全为0x0005。但是,当我们操作内存的时候,就需要手动指定操作数的长度了。长度描述符有byteworddwordqword,分别表示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只有在检测第一个扇区的后两个字节是0x550xaa的时候,才认为是合法MBR,并加载。所以,这行语句就是干这件事的,我们可以看到汇编之后的二进制中,最后2个字符被写入成功了:

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

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。然后最后两个字节留给0x550xaa

软中断

由于本系列文章并不是专业的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中断可以提供很多功能,详细的情况只能去查BIOS手册了,这里笔者只介绍咱们用得上的。首先,就来解决清屏的问题。

中断的调用需要配合固定的寄存器传入参数,之前我们说过,默认情况下显卡使用的是文字模式,那么只要重新再进入一次文字模式就可以自动清屏功能,需要al传入0x03ah传入0x0,然后使用0x10号中断即可实现清屏(如果是其他显示模式,则会切换至文字模式)。

等等,alah寄存器是哪冒出来的?其实是这样的,对于axbxcxdx这4个寄存器来说,可以拆成高8位和低8位两个8位寄存器来使用。al就是ax的低8位,bh就是bx的高8位,以此类推。

所以,al=0x03ah=0x0,效果跟ax=0x0003是一样的。

我们修改一下MBR的代码,首先清屏,然后再打印Hello,World!来看看效果:

mov al, 0x03
mov ah, 0x00
; 也可以写作 mov ax, 0x0003
int 0x10 ; 调用0x10BIOS中断,清屏

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

效果如下:

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

这样看上去是不是顺眼多了?

小结

本篇介绍了8086的寻址方式,以及如何通过写显存的方式来输出字符,最后介绍了通过BIOS中断实现清屏的方法。

下一篇我们将介绍跳转指令和利用BIOS中断加载外存其他部分代码的方法。

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

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