0x110-从头开始写操作系统-CPU模拟器

目录

回顾

系列开篇讲了计算机启动时的情况。计算机启动时,BIOS 做硬件检查,然后按顺序读取存储介质上 512 字节长的 boot sector。如果读到某个存储介质的 boot sector 最后 2 个字节是 0xaa55,就加载该介质上的操作系统,将控制权交给该操作系统。

CPU 模拟器

上篇中我们有了一个 512 字节的 boot sector:

e9 fd ff 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
*
00 00 00 00 00 00 00 00 00 00 00 00 00 00 55 aa

现在,我们要讲如何测试这段程序。

有三种选择:

  • 将这 512 个字节写入到 USB 等存储介质,然后重启电脑,设置 BIOS 到从 USB 启动
  • 使用 Vmware 或者 Virtualbox 等虚拟机软件,选择包含 boot sector code 的文件作为启动介质
  • 使用 CPU 模拟器,如 Bochs、QEMU

第一种方式不推荐,光是写入 raw bytes 到 USB 就是一大堆麻烦事。看这里这里还有这里,另外,还要重启电脑。

推荐使用第三种方式,同时,推荐使用 QEMU,它比其他产品更高效。

这里不讨论 CPU 模拟器的工作原理,有兴趣的小伙伴可以可以 看这里, 还有这篇 How to Write a Computer Emulator

接下来,我们会使用 QEMU 对所有代码进行测试。

引导扇区编程(16-bit Real Mode)

在开始之前,我们先看一下什么是 16-bit Real Mode 以及它的内存管理。

这个系列文章讨论的是在 8086 的架构上构建一个操作系统。因为为了向前兼容,所有现代 CPU 启动时都处于16 bit real mode,为的是模拟 x86 架构中最古老的一代 CPU,也就是 8086。

所以,后面提到的 16 bit 系统和 8086 架构会有交叉使用的地方,统一代指 16 bit real mode 系统。

什么是 16-bit Real Mode?

16-bit real mode, 也称为 16-bit real address mode。是所有 x86 CPU 一种运行模式(另一种常见的是 32-bit Protected Mode)。为了向前兼容,所有 x86 CPU 启动时,都处在 16-bit real mode。

16 bit real mode 下,内存管理采用内存分段的方式。

16 bit real mode 下,内存没有保护机制,所有程序可以随意访问任意段内存的内容。

我发现有些东西不能精简,这是实践过程中必须弄懂的一些东西。所以我们先展开这个话题,讨论一些基础的东西,为今后能走的更快更远。

16-bit 系统中的 16 是什么意思?

16 位系统,是早期的一种计算机系统架构。那时的 CPU 工艺没有现在那么发达,CPU 的能力有限,一次性处理的数据量相比现在也小很多。

CPU 上有两条总线(BUS),一条地址总线(Address Bus),一条数据总线(Data Bus)。

地址总线负责传递内存地址给 CPU,让 CPU 知道哪里去储存或者读取数据。数据总线负责传递地址总线内存地址上的具体数据。

地址总线的宽度决定了系统能够寻址的总量。

通常情况下,16-bit 系统上,这两条总线的宽度也是 16 bit。意味着,可以寻址的总量是 64 KB (216 bits),一次传输的数据量,是 64 KB (216 bits)。

16-bit 系统架构上,CPU 支持 16 种不同的状态。每一个状态可以是开,或者关。意味着,CPU 一个时钟周期能处理 16 bits 的数据量。再多的数据,就要等到下一个时钟周期去处理。

同样的,16-bit 系统的寄存器,也是 16-bit。意味着每个寄存器能存放 65536 (216)个不同的数字。

相应的 32-bit,64-bit 系统,是相同的道理。

8086 架构的内存寻址总量

上面说到了 16-bit 系统的地址总线是也是 16-bit,该系统的寻址总量为:

216 Bits = 65535 Bits ~= 64 KB

所以,16-bit 系统的寻址总量,大约 64 KB,换句话说,就是 16 位系统只识别 64 KB 内存。

具体看 8086 CPU,寄存器是 16-bit,数据总线也是 16-bit。但是,地址总线被设计成有 20 bit。

因此,8086 CPU 的寻址总量,根据前文所说,就不止 64 KB,而达到了 1 MB(220 bits)。

这里先提出一个问题,带着问题读下去,20 bit 的地址,怎么放入 16 bit 的寄存器中存储呢?

8086 架构的内存管理

一切数据都存在与内存当中,所以掌握内存的动向是深入理解底层运作的关键。我们一起看一下什么是内存分段,以及 16-bit real mode 下物理内存地址的计算。

什么是内存分段?

为了更加高效利用内存,减少内存碎片,有很多的内存管理技术被发明出来。内存分段是其中一种。

内存分段的过程,是将一个程序加载到不同的不连续的内存段中。例如,一些简单的程序,数据将被加载到数据段,代码被加载到代码段,其他变量等,加载到堆栈段。复杂的程序,会被加载到更多的段中。

8086 CPU 有 4 个段寄存器,分别是:

  • Code Segment Register (CS) - 用来指向代码段在内存中的基地址
  • Data Segment Register (DS) - 用来指向数据段在内存中的基地址
  • Extra Segment Register (ES) - 额外的数据寄存器
  • Stack Segment Register (SS) - 用来指向堆栈段在内存中的基地址

内存分段在内存中的示意图:

图片来自 geekforgeeks

因为 8086 是 16 位的,每一个段的长度,最小 16 KB,最大只能是 64 KB (216 bits)。

16-bit Real Mode 物理内存地址的计算

现在我们来回答前文的问题,20-bit 的地址,怎么放入 16-bit 的寄存器中存储呢?

回想一下,大家多少都听说过 实际内存地址 = 段地址 * 10H (16 进制,相当于二进制左移 4 位) + 偏移地址 的公式。现在我们来讲解一下这是怎么来的。

8086 CPU 是 16 位的,要寻址到 1 MB,就要弥补这 4 bits 的差距。

CPU 内部处理内存地址,实际上是逻辑地址,或者虚拟地址,也是 16 位的。前文问题的答案是,20-bit 的地址,不能放入 16-bit 的寄存器,20-bit 物理地址的寻址,要使用两个寄存器来完成。

步骤如下:

  • 将段地址存放到某一个段寄存器,比如 CS
  • 将偏移地址存放到 IP (Instruction Pointer)
  • 8086 内部将 CS 的值左移 4 位,最低有效位 (LSB) 补 0
  • 将 CS 左移后的值加上 IP 的值
  • 得到真实物理地址

这就是 20 bit 的地址总线,和 16 bit 的寄存器配合工作的原理。也说明了上述真实物理地址计算公式的来历。

左移 4 位,是因为要满足这 20 bit 地址总线可以访问 1 MB 内存的设计。

这里说一下,设计上,几个寄存器是搭配使用的:

  • CS:IP
  • DS:SI
  • ES:DI
  • SS:SP

更多寄存器配对使用的信息看这里

再重申一下物理内存地址的计算公式:

*8086 架构物理地址 = 段地址左移 10H + 偏移地址**

更多关于 16-bit Real Mode 的信息,可以看这两篇文章,16-bit Real Mode Wiki,以及 OSDev 16-bit Real Mode

第一个引导扇区程序

讲了这么多,终于到了编写第一个引导扇区程序的时候。有了上面的铺垫,后面的一些概念理解起来会更简单。

开始代码之前,还有一个点需要了解一下。

中断

计算机有许多最底层的内在机制,如操控屏幕像素,显示字符。在计算机启动的最初始的阶段,我们没有 C 语言这样的高级库,去和底层交互。目前,如果我们想调用计算机的这些底层能力,只有一种方式,那就是中断

中断的作用

顾名思义,中断发生的时候,CPU 将暂时停止正在进行的任务,转而去执行一些指定的优先级更高的操作,然后再返回去继续原来的任务。

中断可以由软件触发(如下面要讲的 0x10 中断,由汇编指令 int 0x10 触发),也可以由某些硬件触发,比如某个硬件要读取一些网络数据,这个操作的优先级比 CPU 正在执行的任务更高,那么这个硬件就触发一个中断,让 CPU 去读取数据。

中断,Interrupt Vector 和 ISR

每一个中断,都由一个对应的唯一的数值表示。这个数值,代表该中断在 interrupt vector 中的索引。

这张 interrupt vector table,包含了指向 interrupt servie routines (ISRs) 的内存地址。

而 ISR 当中,包含的是每个中断所要执行的的机器指令,例如从磁盘读取数据等。

举个例子:

0x10 中断,调用 ISR 中与屏幕相关的机器指令。0x13 中断,调用 ISR 中与磁盘读写相关的机器指令。

中断的触发不完全靠中断数字的值,还可以依靠通用寄存器中(如 ax 寄存器)的存储的值。就像是另一个 if 语句。

if interrupt_number == 0x10:
    if ax == 1:
        do_something();
    else if ax == 2:
        do_other_things();

常见中断

这里有一些常见中断,关于中断的更多信息,可以 参考这篇文章

图片来自 wiki

Hello World

编程的开始离不开 Hello World 😄

我们将用汇编编写一个 Hello World 程序,当 QEMU 运行这个程序的时候,BIOS 会在屏幕上显示 Hello World 字符串。

8086 CPU 有 4 个通用寄存器,ax,bx,cx,dx,他们的长度都是 16 bits (位),2 bytes(字节),1 word (字)。

boot_sect.asm 代码:

mov ah , 0x0e  ; ax 中写入 0x0e,代表 scrolling teletype BIOS routine,可以在屏幕输出字符
mov al,'H'
int 0x10
mov al,'e'
int 0x10
mov al,'l'
int 0x10
mov al,'l'
int 0x10
mov al,'o'
int 0x10
mov al,'W'
int 0x10
mov al,'o'
int 0x10
mov al,'r'
int 0x10
mov al,'l'
int 0x10
mov al,'d'
int 0x10
jmp $  ; 跳转到当前地址(无限循环)
times 510 - ($ - $$) db 0 ; 512 个字节中剩余字节全部填充 0,$ 表示当前指令的地址,$$ 表示当前代码 section 开始的地址,也就是 mov ah , 0x0e 指令的地址
dw 0xaa55 ; 最后一个字节,是 0xaa55,让 BIOS 知道这是 boot sector

使用 nasm 编译:

nasm boot_sect.asm -f bin -o boot_sect.bin

使用 QEMU 测试:

qemu boot_sect.bin

使用 od 查看 16 进制内容:

od -t x1 -A n boot_sect.bin

运行如下:

在这里插入图片描述

od 查看原始数据:

可以看到 cd 即汇编中断指令 inteb 即汇编跳转指令 jmp,最后两个字节,0xaa55 告诉 BIOS,这是一个正规的 boot sector。

在这里插入图片描述

第一个程序测试结束。

总结

  • QEMU 是我们测试代码的工具
  • 16-bit Real Mode 是 x86 系列 CPU 的一种工作模式,所有 x86 系列 CPU 启动时,都处于 16-bit Real Mode
  • 16 bit 系统中,只有 64 KB 内存可以被识别,CPU 一次只能处理 16 bit 的数据
  • 8086 架构,因为有 20 bit 的地址总线,所以寻址总量有 1 MB
  • 8086 架构的内存管理采用内存分段机制,内存分段将程序不同的部分加载到不同的不连续的内存中
  • 16-bit Real Mode 真实物理地址的计算公式为:物理地址 = 段地址左移 4 位 + 偏移地址
  • 中断可以让 CPU 暂时停止当前任务,执行我们指定的任务,然后回去执行原先的任务
  • 中断在 interrupt vector 中由一个数值做索引,interrupt vector 包含 interrupt service routines 的内存地址
  • 第一个 Hello World 程序使用中断,在屏幕上显示 HelloWorld 字符串

下一篇,我们会看到物理地址计算在代码中的应用,以及一些基础汇编,如字符串定义,条件控制,堆栈的使用等。


推荐阅读(参考链接):