您现在的位置是:首页 >技术杂谈 >从裸机启动开始运行一个C++程序(三)网站首页技术杂谈

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

borehole打洞哥 2024-06-18 18:01:02
简介从裸机启动开始运行一个C++程序(三)

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

编写MBR

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

8086的14个寄存器

既然咱们已经决定要在8086上运行程序了,那么自然,现在是逃不过要了解一下8086 CPU的一些详细情况了。

值得注意的是,8086并不是只有14个寄存器,只不过这14个寄存器是对于程序来说直接打交道的。CPU内部自然还有一些用于体系自身运行的,对外不透明的寄存器,不过这些我们就不需要了解了(其实很多更详细的那些也属于Intel的商业机密,咱也没法了解)。

我先把要关注的这14个寄存器的名称列出来,然后再来解释:

符号名称中文翻译
AXAccumulator累加器
BXBase基地址
CXCount计数器
DXData数据存储
BPBase Pointer基址
SPStack Pointer栈地址
DIDestination Index目的偏移地址
SISource Index源偏移地址
CSCode Segment指令段
DSData Segment数据段
ESExtra Segment附加段
SSStack Segment栈段
IPInstruction Pointer指令地址
FLAGFlag标志位

需要强调一点,除了IP和FLAG以外,上面寄存器的名称所描述的本意,只是这个寄存器「通常」或「默认」用做的事情,并不是说该寄存器只可以用做这一种情况。寄存器是很珍贵的资源,因此实际操作的使用用法是灵活多样的,所以笔者并不想拿这些寄存器名称本身的含义去大书特书。大家其实需要知道,我们要关注这14个寄存器,记住它们的符号(因为汇编语言里要用到)就好了,在一些必须指定寄存器的场景,我们再单独去记忆就好了。

另外,上面这些寄存器都是16位的,这也就意味着,8086每个节拍处理的数据都是16位的,在8086这块CPU里,数据处理和传递的基本单位就是16bit,我们也称「8086的字长为16位」,也称「8086是16位CPU」。

8086的寻址方式

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

如果你玩过数字逻辑器件的话,应该知道有一种器件叫做「译码器」,例如下图展示的是74138,三线-八线译码器:
74138

它的输入端(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位内存地址,示意图如下:

8086地址拼接

也就是说,把其中一个寄存器作为「段寄存器」,它的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 imes16 ×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...没反应,然后认为程序没有生效的话,那请你往最开头来看:
bochs输出

可以看到,这里原本应该是「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位表示是否闪烁。我们可以把颜色总结如下表:

位号符号意义
0FB前景色蓝色元素
1FG前景色绿色元素
2FR前景色红色元素
3I高亮
4BB背景色蓝色元素
5BG背景色绿色元素
6BR背景色红色元素
7K闪烁

配合上I位,前景色可以有16种颜色,分别是:

RGBI颜色
0000
0001
0010
0011浅蓝
0100绿
0101浅绿
0110
0111浅青
1000
1001浅红
1010品红
1011洋红
1100
1101浅棕
1110浅灰
1111

而背景色没有高亮位,因此只支持8种:

RGB颜色
000
001
010绿
011
100
101品红
110
111浅灰

最后配合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是闪烁的,但截图显示不出来):
文字颜色

我们继续来解释代码,中括号表示取内存地址,所以这里的[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个字符被写入成功了:

mbr.bin

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

效果如下:

清屏后显示

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

小结

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

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

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。