通过 BIOS 中断在屏幕上打印出一行启动信息
本文是从零开始写个操作系统吧的系列文章之一。
我们已经知道,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 | mov ah, 0x0e ; 调用 10 号中断时 ah 默认应为此值 |
上面可能就是第 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。
OK,至此为止我们已经了解一些基础知识,创建了一个 bootsect 并且加载到内存中执行,通过 10 号中断成功的在屏幕上打印出我们想要的东西了。
4. 一点扩展
通过上面的学习我们已能够在屏幕上打印出我们想要的东西了,下面是一个稍微复杂一点的例子,我定义了一个名为 print_string
的函数,它的作用是打印出字符串。这个程序中的指令稍微多一点,而且还涉及到了 栈
和我们之前的提到的 16 位保护模式的概念,如果有不理解的还是希望能看一看我前面提到的那本书,对于加强对代码理解是很有帮助的。
查看 plaintext 代码
1 | [org 0x7c00] ; means all the address in this file is begining in 0x7c00(equals to segment value is 0x7c0) |