本文是从零开始写个操作系统吧的系列文章之一。

我们已经知道,CPU的工作方式就是不断的取指执行,而x86系列的CPU的PC(program counter)是通过寄存器CS和IP所定位的,CPU就是这样不知疲倦的一条一条指令执行下去来完成我们交给它的任务。下面我简单介绍一下从按下开机按钮到操作系统的kernel被从磁盘加载到内存中这段时间内所发生的事情。

1. BIOS控制着一切

在按下开机按钮之后,此时CPU复位,PC被重置。当前CPU处于一个比较复杂的模式(具体可以看着这里,看不懂也没关系),此时CPU的PC所指向的内存地址位于BIOS内存空间,所以CPU就开始执行BIOS内已经固化好的一些指令。为什么CPU的PC最开始要指向BIOS的内存空间内呢?因为在刚开机的时候,内存中是没有指令的(内存为空),所以CPU没法从内存中读指令,那么我们必须弄一个在刚开机的时候就能够让CPU读取指令的区域,BIOS就是承担这一重任的部件。

说句题外话,计算机在上面的时间段内执行过程叫做boot,这个词源于一句谚语:

pull oneself up by one’s bootstraps

也就是“提着自己的靴子把自己提起来”,这显然是做不到的。计算机启动的时候也是一样,如果不使用一些在开机前就已经存在的指令,那又怎么能让CPU工作起来呢?

CPU会执行一段时间的BIOS的指令,BIOS中包含了一些开机器自检、读取硬件参数、初始化一个中断向量表等等的操作。除此之外,BIOS还会试图读取磁盘的第一个扇区(512byte)中的内容,如果这个512个字节的最后两个字节是0xaa55,那么BIOS会认为这是一个可以启动的设备,就会把这512个字节读到内存中起始地址为0x7c00的位置;如果最后两个字节不是0xaa55,那么BIOS就会认为这不是个可启动的设备,就会继续尝试读取下一个可能的启动设备。当BIOS中所需要执行的操作都执行完毕之后,BIOS的最后一条指令是让PC跳转到内存中0x7c00的地址处,也就是磁盘第一个扇区被读入到内存中所处的位置。接下来,因为PC指向了0x7c00处,所以从磁盘读到内存中的指令就开始被执行了,整个过程滴水不漏。这整个步骤大约如下所示:

我们现在所需要做的工作就是图上的第三步,也就是调用BIOS已经初始化好的中断向量表在屏幕上打印出计算机的启动信息。在实际写代码之前,我们需要了解一下Intel CPU的两种模式。

2. Intel CPU的模式

所谓的模式就是CPU定位内存中指定地址的操作方式。在前面,我只用PC或者CS、IP等等这样含糊不清的方式来表示了CPU操作内存的方式,现在我们更详细的了解一下x86 CPU是怎么定位内存中的指定地址的。首先需要知道,x86架构的CPU比较常用的模式有如下两种:

  • 16位实模式
  • 32位保护模式

32位保护模式我们会在后面的文章中接触到,目前不用管它,我们先了解一下16位实模式即可。所谓的16位保护模式,听起来感觉名字高大上,其实就是代表了 实际地址 = 段地址 * 16 + 偏移地址 的这种计算方式(如果看到这里你不理解段地址和偏移地址是什么意思,那么还是先看一下汇编语言(第2版)补充一些基础知识为好)。这就是16位实模式,我们也只需要了解一下它的地址的计算方式就足够了。

在跳转到0x7c00地址的时候,CPU就已经处于了16位实模式,所以我们存放在磁盘第一个扇区中的代码必须要使用16位实模式的方式来进行计算。事实上存放在磁盘第一个扇区中的代码并不是操作系统,而是我们一般称为bootsect的存在,bootsect的主要任务之一就是从磁盘上再把我们真正的操作系统(kernel)从磁盘读入到内存中。为什么要这么麻烦呢,为什么不直接把操作系统放在第一个扇区呢?原因就在于一般来说操作系统都是要比512个字节大的,所以第一个磁盘扇区放不下它,因此我们要兜一个圈子依靠bootsect才能把操作系统加载到内存中。整个流程如下所示:

3. 调用BIOS中断打印出启动信息

了解了以上的一些基础知识之后,我们就可以开始着手写我们的bootsect了,首先我们新建一个名为boot.asm的文件,用你喜欢的编辑器打开它,然后写入如下的代码:

1
2
3
4
5
mov ah, 0x0e            ; 调用 10 号中断时 ah 默认应为此值
mov al, 'X' ; al 等于 X 的 ASCII 码的值
int 0x10 ; 调用 10 号中断,把寄存器 al 所对应的字母在屏幕上打印出来
times 510-($-$$) db 0 ; $ 表示文件的当前位置,$$ 表示文件的起始位置,times 表示重复执行某个操作,db 表示放一个 0 在这个位置
dw 0xaa55 ; 再填入 aa 和 55 两个字节,保证该文件大小为 512 个字节,使得该文件可以合法的被 BIOS 加载到内存中

上面可能就是第4行代码稍微需要解释一下,假设 510 - ($ - $$)的值为n,那么我们在当前位置(也就是当前位置减去起始位置的值,即$ - $$)的基础再填充n个字节就能使得当前文件的大小为510个字节,即 510 - ($ - $$) + ($ - $$) = 510,最后再加上两个字节0xaa55,那么这个文件就妥妥的是512个字节了。

代码写好之后保存一下,之后执行以下命令对源码进行汇编操作:

1
nasm boot.asm -f bin -o boot.bin

其中 -f bin 表示输出完全原生的二进制代码,执行完命令后,就会有一个 boot.bin 文件被生成出来了。之后执行命令

1
qemu-system-i386 boot.bin

会让虚拟机执行我们生成的 bootsect 文件,如果没什么问题的话,会在虚拟机的屏幕上打印出一个字母X。

打印出X

OK,至此为止我们已经了解一些基础知识,创建了一个bootsect并且加载到内存中执行,通过10号中断成功的在屏幕上打印出我们想要的东西了。

4. 一点扩展

通过上面的学习我们已能够在屏幕上打印出我们想要的东西了,下面是一个稍微复杂一点的例子,我定义了一个名为 print_string 的函数,它的作用是打印出字符串。这个程序中的指令稍微多一点,而且还涉及到了 和我们之前的提到的16位保护模式的概念,如果有不理解的还是希望能看一看我前面提到的那本书,对于加强对代码理解是很有帮助的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[org 0x7c00]            ; means all the address in this file is begining in 0x7c00(equals to segment value is 0x7c0)

mov ah, 0x0e ; BIOS interupt 0x10 need ah is 0x0e, no reason

mov bx, STRING ; STRING is just a address
call print_string

jmp $ ; jmp to current address

STRING:
db 'test', 0 ; build a string in this address, and it end with a 0

print_string:
mov cl, [bx] ; the value of bx is STRING, so [bx] is the value which in address STRING
cmp cl, 0 ; cmp cl with 0
jne bx_add ; if cl not equals 0, jmp to bx_add(until [bx] is 0, this instruction is running)
ret

bx_add:
mov al, [bx]
int 0x10 ; when execute BISO interupt 0x10, it with print the value of register al into screen
add bx, 1 ; bx plus one, means bx is pointing the next letter noe
jmp print_string

times 510-($-$$) db 0 ; $ means current address, $$ means the start address, so 510-($-$$) means how many 0 we should write in this file after current address, then we can make this file is 510b. times x i means run i x times, so this instruction means we just run db 0(write a 0 here) 510-($-$$) times.
dw 0xaa55 ; write word(2 bytes) 0xaa55 at the end of this file, it will make this file bootable.

参考:

计算机是如何启动的? - 阮一峰的网络日志