X86汇编实模式下裸机编程

662 阅读25分钟

对于每个程序员来讲,如果不是做单片机开发,大部分情况下我们写的程序都是跑在操作系统上的,而且也必须依赖操作系统才能运行。但假设我们只有一堆硬件(CPU、内存、硬盘),我们该如何让程序跑起来呢?这就是这篇文章试图解决的问题。

这篇文章不探讨一些基础的问题,比如为什么要有汇编语言?这类问题网上有非常多的文章可以参考。

Intel的8086 CPU将计算机发展的时间轴一分为二,在这之后的CPU几乎都兼容8086指令集。直到现在,64位机器操作系统的启动还是从8086的实模式一步步的切换到64位的长模式,最终才能具备64位的寻址能力。

下面是8086CPU通用寄存器

图片

图1-1 8086通用寄存器

8086CPU有8个16位通用寄存器,其中AX、BX、CX、DX又可以各自拆分成2个8位的寄存器,比如AX就可以拆分成AH和AL,其中AL表示低8位,AH表示高8位。

CPU的实模式主要是指对内存的访问都是访问真实物理地址,这在运行多进程的现代操作系统里基本是行不通的。所以,现在的操作系统都需要将CPU切换到保护模式才能正常工作。

内存的最小单元是字节,8086CPU通用寄存器是16位。所以,CPU访问内存一次操作两个内存单元。如下图:

图片

图1-2 内存和寄存器的关系

上图中,AX寄存器通过16根数据线连接到内存中,由于内存每个单元是1个字节,所以需要两个内存单元才能将AX寄存器填满。

程序如何存放

程序代码会被编译器翻译成机器码加载到内存中,程序运行时,CPU从内存中取出指令依次执行,为了方便指令的读取,一般都将代码和数据分开存放,如下图:

图片

图1-3 代码在内存中如何存放

上图中,0000~0C00所在的内存位置保存了翻译成机器指令的程序代码,0C00开始保存的是程序相关的数据。下面我们来看一个具体的例子,如下:

图片

图1-4 指令运行

图中,0000~0002的位置对应的指令是mov ax, [0C00],表示将0C00位置的数据保存到寄存器ax中。0003~0006的位置是指令add ax, [0C02],表示将0C02位置的值加上ax中的值然后将结果保存在ax中。

程序运行的过程是这样的。首先,指令寄存器IPR指向0000,取出第一条指令,也就是mov  ax, [0C00]。指令执行完后,寄存器的值如下所示:

图片

图1-5

第一条指令执行完之后,IPR指向下一条指令的起始位置,也就是add  ax, [0C002]这条指令。此时寄存AX中的值为3C05。

注意,上面的IPR寄存器里保存的是CPU下一次要执行的指令。

接着,执行第二条指令,如下图:

图片

图1-6

第二条指令执行完,IPR寄存器指向了下一条指令的起始位置。此时寄存器AX中的值就是3C05+8B0F = C714。

上面的程序运行过程太过理想化,回想一下我们平时使用计算机的时候可能同时打开很多个应用程序,这些程序不可能都从起始为0的位置开始,我们将上面的程序稍微做一下改变,如下所示:

图片

图1-7

可以看到,上面程序的指令是从内存位置1000开始的。原来的0C00和0C02现在是在1C00和1C02的位置上了。此时,第一条指令将0C00地址里的数据保存到寄存器AX中,但是0C00所在的位置并不是我们期望的数据,可能是一个非法的数据。

你可能会说了,我们把0C00和0C02改成1C00和1C02不就解决了吗?其实这个也是可以实现的,早期的程序就是这么运行的。但是,仔细想想就会发现这样是有问题的。假如,又有一个程序也要运行,这个时候我们就要将这两个程序的内存给分开,并且要把地址都算好,更糟糕的是每次要安装一个新的程序都需要重新计算。

如果是一两个程序还好,但如果是成百上千个程序要运行,这几乎是一个不可能完成的工作。

所以,编译器都是使用相对地址来寻址,如下所示:

图片

图1-8 相对地址

上面的例子中,一开始IPR从1000开始,表示代码指令开始的位置。DSR从1C00开始,也就是数据的起始位置,仔细观察可以发现,1C00正是0C00+1000的结果。

当执行到下一条指令的时候,DSR就是1C00+2=1C02,这其实就是CPU的相对位置寻址,在现代操作系统的内存管理中也基本上沿用了这种思想。

8086CPU有16根数据线,一次性可以传输2字节数据。而这正好是8086CPU通用寄存器的大小。但是,8086为了能寻址更大的内存,使用了20根地址总线,所以,8086CPU最大可以寻址2^20=1MB。

这就产生问题了,寄存器最大只能保存4字节的数据,远远不能保存1MB的地址数据,如下图所示:

图片

图1-9 20位地址线

由于没有20位的寄存器,所以没办法保存一个完整的地址,于是CPU的设计者们又设计了段寄存器DS和CS,分别表示数据段寄存器和代码段寄存器。如下图:

图片

图1-20 

上图中,每个内存单元的地址都是20位,下面我们来看一下8086CPU是如何通过段寄存器来实现20位的寻址能力的。

想像一下,假如你去上海出差,办完事回家之前要做核酸,但你对上海又不熟悉,你就问酒店的保安大哥哪里有做核酸的。他告诉你,出门往右直走500米,到五角场万达A座,向前100米有一个核酸点,向前200米可以拿核酸结果。

我们将上面的场景套到CPU寻址的场景中,万达A座就相当于基地址,后面核酸点都和取结果的地方都是在此基础上分别加100米和200米。这里的基地址就可以理解为段寄存器。

我们观察到上图中有些内存地址是以0结尾的,而抹掉这个0刚好是16位,可以保存到8086的寄存器中。通过这个特点,我们可以把这个0给抹掉保存到CPU的寄存器,而抹掉右边的0也很简单,只要将地址右移4位就可以了,当要使用的时候拿出来再左移4位就可以还原了。

下面我们来看一个例子,我们以16进制的方式来表示内存地址,程序的开始地址是12560,我们将地址向右移4位,得到1256(因为是16进制形式,数字的每一位表示4个二进制位)。如果这样理解得不够透彻的话,我们可以将它转成二进制的形式,12560转成二进制如下:

0001 0010 0101 0110 0000

然后右称4位,得到:

0000 0001 0010 0101 0110

上面的二进制数对应的16进制形式就是1256,通过这种方式我们就得到了一个段起始地址。下面我们通过段地址的形式来回顾一下文章开头的例子,如下:

图片

图1-21

一般约定DS表示数据段地址,CS表示代码段地址。上图中,数据段地址和代码段地址都已经知道了。接下来要解决的问题是如何通过这两个段地址来计算真正的内存地址。

由于程序指令的长度并不是固定的,所以,一般使用IP寄存器来保存下一条要执行的指令地址,如下图:

图片

图1-22

上图中,代码段地址30CE0可以理解为30CE0+0000 = 30CE0,代码段地址30CE3可以理解为30CE0+0003 = 30CE3。所以,一开始IP寄存的值为0000,当第一条指令运行完,IP的值变为0003,CS寄存器保持不变。当要取第二条指令时,地址总线先将CS的值左移4位得到30CE0,然后加上0003也就是30CE0+0003=30CE3,而这正是第二条指令的起始地址。这样通过段地址加上一个偏移地址就实现了20位的寻址能力。

对于数据段其实也是一样的,执行第一条指令的时候,将数据段地址左移4位得到33CE0,然后加上0000,也就是33CE0+0000=33CE0。执行第二条指令的时候,数据起始位置就是33CE0+0002=33CE2。

如果细心观察,上面的例子中有一个特点,就是段地址都是以0结尾的。这是由于要将段地址保存到16位的寄存器中,需要将段地址右移4位把右边的0抹掉。如果段地址不是以0结尾右移4位之后就无法还原原来的地址了,这里你可以思考一下。

下面图中,65C70可以是一个段地址,65C71、65C72、65C73、65C74、65C75都可以基于65C70通过一个偏移量到达。

图片

图1-23

而我们知道,8086通用寄存器最大是16位,可以表示的数据大小为0000FFFF,也就是064KB,这也是8086CPU一个内存段的最大范围。

上面我们搞明白了8086内存访问机制,这部分内容至关重要,如果搞不明白对于8086CPU下的裸机编程将无从下手。所以,一定要搞明白了之后再往下看。

上面的例子都是假定数据已经在内存中了,但当我们按下计算机电源按钮的那一刻,内存里面是没有任何数据的,这是因为断电之后内存里面所有的数据都将丢失。

所以,我们写好的代码都需要可以永久保存,比如U盘、光盘或者硬盘。当我们打开一个程序的时候,先要将程序加载内存中,这其中就包括代码指令和程序依赖的各种数据。

那计算机启动的时候又如何能将硬盘中的程序加载到内存中呢?

上面我们说8086CPU的寻址能力是1MB,实际上,这1MB并不全是内存,而是将其划分了几个区域,每个区域的用途都不一样。比如,有些区域负责和显示器交互这部分就是显存,有些区域和BIOS交互,用于计算机启动时的初始化。具体的划分如下图:

8086CPU上电之后各个寄存器的状态(注意,这里指的是8086CPU),如下图:

图片

图1-24

可以看到,8086上电之后CS寄存器的值被设置成了FFFF,其它寄存器都设置成了0000,CS正是代码段寄存器,我们通过之前讲的通过CS+IP可以找到下一条要执行的指令。

根据代码段地址计算规则CS+IP=FFFF<<4 + 0000 = FFFF0,问题来了,我们知道内存在刚上电的时候里面什么也没有,那这个FFFF0所在的位置到底是什么呢?

为了弄清楚这个问题,首先要搞明白,8086CPU的1MB的寻址能力,并不全是内存,而是被分成了几个部分,具体的划分规则如下:

图片

图1-25

可以看到,000009FFFF这段才是真正内存,A0000EFFFF这一段是各种设备,比如显卡。F0000~FFFFF这一段是BIOS芯片。前面我们说8086上电之后首先会从FFFF0的位置取指令开始执行,这部分正是BIOS的ROW所在的区域,那这部分指令具体是什么呢?看下图:

图片

图1-26

上图中是BIOS中FFFF0处的第一条指令(注意,不同的机器可能会有所差异),这条指令翻译成汇编语言就是:JMP 0xF000:0xE05B,根据我们上面讲的寻址规则可以知道,这条指令执行完之后,会跳转到FE05B的位置继续执行。

我们回忆一下内存划分规则,F0000~FFFFF是BIOS的ROW所在区域,所以最终还是跳到了BIOS中ROW所保存的代码指令,而这里保存的指令的作用是从持久化存储(U盘、光盘、硬盘)读取512字节的数据到内存07C00的位置。

真是一环套一环,没有操作系统的情况下运行一个程序还真是麻烦啊,这里我们先来简单看一下持久化存储又是怎么回事。为了方便说明,这里以机械硬盘为例,机械硬盘的结构如下:

图片

图1-27

一块机械硬盘有多个铝合金的盘片,上面有磁性涂层,每个盘片的正反面都有一个读写的磁头,通过转轴可以转动盘片,我们通常说硬盘有3600转、7200转就是说的这个转轴转动的速度。速度越快读写就越快。

每个盘片的正反面都有磁道,如下:

图片

图1-28

不同盘片的磁道组成一个个的虚拟的柱面,如下:

图片

图1-29

每一条磁道又被划分成多个扇区,一般是63个扇区,每个扇区以空白分隔,如下:

图片

图1-30

每个扇区的内容包含一个扇区头,和512字节的数据,扇区头在现代硬盘中还保存了当前扇区是否损坏等信息,扇区和盘片、磁道不是一样,是从1开始的,比如第一个盘片是0号盘片,而第一个扇区是1号扇区。

好了,搞明白硬盘的结构之后,我们接着前面的讲,BIOS从硬盘读512字节到内存的07C00位置,那应该从哪里读呢?实际上,硬盘的盘片、磁道、扇区也是有编号的,从上到下第一个盘片为0号盘片,从外向内第一个磁道为0号磁道,而BIOS就是从第0号盘片、0号磁道的1号扇区开区开始读,而一个扇区刚好是512字节。所以,最终其实是将0号盘片、0号磁道的1号扇区里的数据全部加载到内存07C00的位置了。

现在的硬盘都支持一种叫LBA(Logical Block Address)的逻辑块地址,其在硬件层面将物理扇区进行了统一编号,规则如下:

假如一块硬盘有2个磁头,100个柱面,每个磁道有17个扇区

0盘面0磁道1扇区的逻辑编号=0扇区

0盘面0磁道2扇区的逻辑编号=1扇区

...

0盘面0磁道17扇区的逻辑编号=16扇区

1盘面0磁道1扇区的逻辑编号=17扇区

1盘面0磁道2扇区的逻辑编号=18扇区

《X86汇编语言从实模式到保护模式》

通过上面的规则,我们很容易可以看出,逻辑扇区是以柱面的维度进行编号的,这是因为同一个柱面上的读取磁头不需要移动,效率是最高的。

使用LBA编号之后,我们只要把逻辑0号扇区的512字节读入到内存07C00位置,就可以运行了,这是可以的。但是在开始之前,我们还要解决一个问题。

我们回想一下,平时开发程序的时候都需要借助显示器来观察程序的运行情况,否则我们都不知道我们的程序是否在正常的运行。所以,我们还要解决程序显示的问题。

CPU通过向显卡的显存里写入要显示的内容,显卡将显示的内容转换成信号发送到显示器,显示器根据收到的信号来显示内容,如下图:

图片

图1-31

显示器最小的显示单位是像素,通过控制显存里的内容就可以控制这些像素点的行为。我们假如显示器只能显示黑白两种颜色,我们可以使用二进制位来对应每个像素点,二进制位等于1的时候为白色,等于0的时候为黑色,如下图:

图片

图1-32

对于黑白显示器来讲,只有绝对的白和黑显示内容过于生硬,所以还需要有一些过渡,比如中间的灰度,我们可以使用不同的二进制数来表示白~黑的过渡,如下图:

图片

图1-33

当然,现在的显示器几乎都是彩色的,而其原理就是通过三原色来调配出各种各样的颜色,每一个像素点都有红、绿、蓝三种颜色,每种颜色都可以通过一个二进制数来控制明暗度,显示器会将三原色叠加起来从而实现各种各样的颜色。如下图:

图片

图1-34

在8086机器上电之后,显卡自动进入文本显示模式,显存通过一个字符发生器将字符显示需要的信号发送到显示器,然后显示器就可以显示字符了,如下图:

图片

图1-35

我们前面讲过8086CPU的1MB的寻址空间其实是分成了几个部分,其中A0000EFFFF之间是设备存储用的,而显存就是B8000BFFFF这部分,也就是说,在字符模式下,通过向这部分内存写入内容就可以在显示器显示出字符了,如下图所示:

图片

图1-36

8086CPU字符模式下显示器每行可以显示80个字符,一共可以显示25行。

下面的表是字符模式下的色彩控制,可以对照这个表来设置字符的背景和前景色。

图片

好了,到此为止我们需要了解的所有前置知识都准备好了,下面我们就可以正式开始了。

在开始之前,我们需要有一台运行在X86机器上的Linux操作系统,我使用的是Ubuntu20。

除了操作系统之外,还需要一个汇编器,我使用的是nasm,一个虚拟机,我使用的是qemu,当然虚拟机你也可以使用VirtualBox,但为了环境统一最好还是使用和我一样的环境。最后我们还需要一个支持调试功能的虚拟机Bochs,这些工具的安装都不是很复杂,这里不再详细介绍。

首先,我们要创建一块硬盘,Bochs的bximage和qemu的qemu-img工具都可以实现,但为了兼容bochs这里我们使用bximage工具,直接在命令行中输入bximage进入交互模式如下:

图片

选择第1项

图片

可以看到默认就是hd,直接回车

图片

提示我们选择type,直接使用默认的flat就可以了

图片

这里是每个扇区大小,我们使用默认的512字节

图片

接着设置硬盘大小,这里我们设置为64M,这个你也可以随意设置,只要不小于512字节就可以了。

图片

最后一步是设置硬盘的名字,这里我们叫boot.img,这个名字你也可以随意设置,具体怎么用后面会讲。这样我们就创建好了一块虚拟硬盘,大小64M,名字是boot.img。

图片

我们可以验证一下是不是如我们所愿,如下:

图片

硬盘创建好之后,我们就可以开始写代码了,这里我们还是在显示器上输出一个"Hello World"为例,代码如下:

mov ax, 0xb800
mov ds, ax
mov byte [0x00], 'H'
mov byte [0x01], 0x04
mov byte [0x02], 'e'
mov byte [0x03], 0x04
mov byte [0x04], 'l'
mov byte [0x05], 0x04
mov byte [0x06], 'l'
mov byte [0x07], 0x04
mov byte [0x08], 'o'
mov byte [0x09], 0x04
mov byte [0x0a], ' '
mov byte [0x0b], 0x04
mov byte [0x0c], 'W'
mov byte [0x0d], 0x04
mov byte [0x0e], 'o'
mov byte [0x0f], 0x04
mov byte [0x10], 'r'
mov byte [0x11], 0x04
mov byte [0x12], 'l'
mov byte [0x13], 0x04
mov byte [0x14], 'd'
mov byte [0x15]0x04

上面的代码我们使用0x04设备字符显示的颜色,十六进制0x04转成二进制就是0100,对照字符模式色彩表就是110,最终会显示成红色。

8086CPU规定,引导扇区的512字节数据必须以55、和AA结尾,所以,我们还需要修改一下我们的代码,如下:

mov ax, 0xb800
mov ds, ax

mov byte [0x00], 'H'
mov byte [0x01], 0x04

...

db 0x55, 0xa

接着我们来尝试编译一下这个小程序,如下:

nasm hello.asm -f bin -o hello.bin

接着我们使用一个16进制的分析工具来分析一下这个文件,如下:

图片

图1-40

可以看到,文件的最后是以55 和 AA结尾。但这里还有一个问题,55和AA必须是512字节的最后两个字节,所以我们还需要想办法将55和AA放到第511和512字节的位置,具体怎么操作呢?

我们可以将中间的空洞都填充成0,填充的大小等于512字节减去程序指令大小再减去2个字节的魔数,也就是55和AA,通过上面的图我们可以算出来程序指令所占的空间是115字节,所以最终我们需要填充的大小就是512-115-2=395,在汇编语言中我们可以使用times指令来重复执行,如下:

mov ax, 0xb800
mov ds, ax

mov byte [0x00], 'H'
mov byte [0x01], 0x04

...
times 395 db 0

db 0x55, 0xa

修改之后重新编译一下,如下:

图片

图1-41

可以看到,现在正好是512字节。

接下来,将编译好的机器码写到前面创建的虚拟硬盘中,如下:

dd if=hello.bin of=boot.img bs=512 count=1 conv=notrunc

图片

这样我们就将编译好的机器指令写入到虚拟硬盘,然后我们通过qemu运行起来,如下:

qemu-system-x86_64 -fda boot.img

此时会出现下面的情况:

图片

图1-46

这是因为我们前面将中间的空洞都填充成了0,前面的Hello World跑完之后,计算机无法识别指令,我们可以使用跳转指令将程序不断跳转到开始的地方执行,如下:

mov ax, 0xb800
mov ds, ax

mov byte [0x00], 'H'
mov byte [0x01], 0x04

...

mov byte [0x14], 'd'
mov byte [0x15], 0x04

jmp 0x0000:0x7c00

times 395 db 0

db 0x55, 0xaa

上面我们加了一条jmp指令,表示执行完上面的指令又从07c00这个位置开始执行(回忆一下07c00的含义),由于我们又加了一条指令所以中间填充0的数量又要重新计算了,这样每次都人工计算一次效率太低了,在汇编语言中可以通过标号来定位指令的绝对位置,下面我们就来改造一下上面的代码,最终如下:

hello_start:  
    mov ax, 0xb800  
    mov ds, ax
    mov byte [0x00], 'H'  
    mov byte [0x01], 0x04  
    mov byte [0x02], 'e'  
    mov byte [0x03], 0x04  
    mov byte [0x04], 'l'  
    mov byte [0x05], 0x04  
    mov byte [0x06], 'l'  
    mov byte [0x07], 0x04  
    mov byte [0x08], 'o'  
    mov byte [0x09], 0x04  
    mov byte [0x0a], ' '  
    mov byte [0x0b], 0x04  
    mov byte [0x0c], 'W'  
    mov byte [0x0d], 0x04  
    mov byte [0x0e], 'o'  
    mov byte [0x0f], 0x04  
    mov byte [0x10], 'r'  
    mov byte [0x11], 0x04  
    mov byte [0x12], 'l'  
    mov byte [0x13], 0x04  
    mov byte [0x14], 'd'  
    mov byte [0x15], 0x04  
    jmp 0x0000:0x7c00
hello_end:  
    times 510-(hello_end-hello_start) db 0  
    db 0x55, 0xaa

然后我们重新启动虚拟机,可以看到Hello World已经显示出来了。

图片

图1-48

好了,到这里为止我们就在x86机器上实现了一个最简单的裸机程序。

平时在开发的过程中,我们遇到问题的时候可能会通过IDE来对程序进行debug,从而找出问题。而在裸机开发中连操作系统都没有更不可能有IDE了。但程序的debug还是非常有必要的,下面介绍一个支持debug功能的虚拟机Bochs。

Bochs可以通过配置文件来启动,这里我们直接给出配置文件,配置文件如下:

# 文件名:bochsrc
#设置内存
megs: 32
# 设置bios,注意这需要安装bochs-x,例如ubuntu上就apt install bochs-x
romimage: file=/usr/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/share/vgabios/vgabios.bin
# 从硬盘启动
boot: disk
floppy_bootsig_check: disabled=0
# 设置虚拟硬盘,这里的信息是在创建虚拟硬盘的时候产生的
ata0-master: type=disk, path="boot.img", mode=flat
log: bochsout.txt

# 设置分舵鼠标
mouse:enabled=0
keyboard: keymap=/usr/share/bochs/keymaps/x11-pc-us.map

接着我们就可以启动Bochs了,如下:

bochs -f bochsrc

图片

会有两个窗口,一个是调试用的交互命令行窗口,另一个可以理解为显示器。我们可以使用b命令来设置断点,比如:

图片

然后可以使用s命令单步调试,比如:

图片

也可以使用c命令直接跳到断点处,你可以自己尝试一下。

可以使用r命令查看各个寄存器的值,如下:

图片

好了,到这里为止,我们抛开操作系统自己写的一个程序不断跑起来了,而且还可以debug是不是很有成就感呢?但不要高兴的得太早。

在实模式下,CPU最大寻址只有区区1MB,这在如今软件包动不动好几个G的情况下实在是太小了。而现的计算机几乎都是32位、64位的了,对于32位机器来讲最大寻址能力可以有4GB,而对于64位机器来讲更是可以达到256TB。

要想解锁CPU的超能力,就一定绕不开CPU的保护模式以及64位CPU的长模式,而这部分的知识内容又非常庞杂。在后续的文章中会在本文的基础上一步一步的切换到保存模式和长模式,并且实现一个简单的操作系统。

而关于更多的汇编知识,比如中断本文由于篇幅原因并没有介绍,关于汇编相关的内容后面看时间可能也会出几篇文章来专门讲X86下的汇编编程。

总结

如今,计算机已经发展得相对比较成熟了,对于程序员来讲,就算不明白计算机、操作系统原理也能写出可以运行的程序。

可是我相信,很多程序员都有一个操作系统的执念,但是由于操作系统所覆盖的知识又深又广,不像应用开发,花一周时间就可以实现一个demo那样简单。所以,很多人都在学习的过程中总是一次次拿起来又一次次放弃了,这其中就包括我自己。

最近这几年,我慢慢尝试通过写文章的方式倒逼学习,一开始极度不适应,我发现学习效率变得非常低,常常为了讲清楚一个知识点要查好多资料,但慢慢的我发现,这些知识通过文章输出之后都比较牢固。相比快餐式的阅读更加能找到感觉,走出似懂非懂的怪圈。

最后推荐一本李忠的《x86汇编语言从实模式到保护模式》,这本书是学习汇编语言不可多得的资料