从零开始的 OS 轮子——保护模式

果然在我做清华实验之前还是要简单了解一下操作系统是怎么跑起来的比较稳。架构还是我熟悉的 x86 ,有机会可以去撸一遍 ARM 的(日常立 flag ),其它的……暂时没兴趣。

推荐去 OSDev.org 上多看看,它还有个论坛,有问题可以去提问。

我使用 QEmu (2.11.1) 进行模拟,使用 GDB (8.1) 调试,汇编使用 GAS (2.29.1) AT&T 语法,编译器使用 GNU C Compiler (7.3.1) 。

引导

计算机启动之后, BIOS (Basic Input Output System) 会对设备进行简单的自检。确认一切正常后,就开始引导启动操作系统软件了。

BIOS 会在硬盘(或软盘)的首个柱面、首个磁头、首个扇区的 512 个字节去寻找引导代码。 BIOS 并不能辨别这 512 个字节究竟是数据还是可执行机器码,它只会去检查 512 个字节的最后两个字节是不是 0xaa55 ,并以此辨别当前设备是否可引导。因此,留给我们可编程的一共只有 510 个字节,我们需要确保编译出来的机器码小于等于 510 个字节。

万事俱备,着手写一个最简单的程序,为之后的引导程序打下基础:

.code16

jmp .

.org 510
.word 0xaa55

第一行 .code16 表示生成 16 位的机器码,这条指令在寻址的时候有用,此处可以略去。

GAS 中 . 指代当前的位置计数器 (location counter) ,指代汇编中的当前位置。

之后的 .org 是 GAS 的一个伪指令,这一句会将位置计数器设置为 510 。

最后的 .word 是 GAS 的一个伪指令,指的是位置寄存器之后的一个 word 值为 0xaa55 。

使用命令

as -o boot.o boot.s
ld -e 0 -Ttext=0x7c00 -o boot.img --oformat=binary boot.o

生成引导块。

其中 as 命令中 -o 指输出文件名,剩下的都是输入的汇编文件名。

ld 命令中 -e (--entry) 指明程序入口,这里是偏移 0 。 -o (--output) 指明输出文件名, --oformat 指明输出格式,默认为 elf64-x86-64 ,此处生成纯二进制机器码文件 。 -Ttext 是指定 text 段的位置,如果有需要重定位的则需要这一条,这里其实可以略去,此处其值为 BIOS 加载我们引导程序的位置,也就是 0x7c00

然后就可以使用 qemu 欢快地运行啦!

使用命令

qemu-system-i386 boot.img

就可以直接模拟运行我们的引导, boot.img 会被作为硬盘载入,默认为 raw 二进制格式。

如果嫌警告烦人的话,可以显式指定为 raw boot sector : qemu-system-i386 -drive format=raw,file=boot.img

如果成功引导的话,在尝试从 Hard Disk 读取引导之后就什么都不会输出,因为我们写了一段死循环在里面。如果失败的话, QEmu 模拟的 BIOS 会继续尝试从 Floppy 、 DVD/CD 、 ROM 、 Network 引导,不过当然,都会失败。

至此,我们的第一个什么都不干的引导就完成了。

调试

乘现在我们的引导程序还简单,我们来试试看用 GDB 调试我们的引导。

第一步,我们需要使 QEmu 可以和 GDB 交互,我们让 QEmu 在运行的时候监听一个 TCP 端口即可, QEmu 提供了一个简易的运行选项 -s 来帮我们监听端口 1234 。当然我们也可以自己指定协议和端口,甚至支持本地 unix socket 和管道。关于自定义端口的方法详见手册,本文简单起见,直接使用 -s 选项运行。

使用如下命令启动 QEmu :

qemu-system-i386 -s -drive format=raw,file=boot.img

启动 GDB 并连接上 1234 端口:

target remote :1234

如果看到 QEmu 窗口出现了 QEMU [PAUSED] 字样,则说明连接成功, GDB 中应该出现了我们写在 boot.s 中的汇编指令(虽说有点区别),这里开始就可以在 GDB 里和调试普通程序一般调试引导了。

按理来说 GDB 的输出是:

0x00007c00 in ?? ()=> 0x00007c00:
eb fe jmp 0x7c00

可以看到,这时候我们的引导程序被加载到了 0x7c00 的位置,并且不停在 jmp 回当前位置。

但是实际上,我们是在程序运行期间暴力 attach 上去的。在引导程序变得复杂之后,想要调试引导程序不可能在 QEmu 一通乱跑之后才 attach,为此我们需要在启动 QEmu 之后立刻停止运行,等待指令。为此, QEmu 提供了运行选项 -S

使用 -S 启动 QEmu 的命令:

qemu-system-i386 -S -s -drive format=raw,file=boot.img

运行 GDB ,在 0x7c00 地址下断点(当然你也可以不下断点,跟着流程一步一步走下去看看 BIOS 自检的过程):

target remote :1234
break *0x7c00
continue

这样就可以从头开始调试了, GDB 的其它调试指令请自行查阅相关资料。

Hello, World!

准备完毕!既然我们让第一个 boot 跑起来了,第一件事情当然是:

Hello, World!

于是问题来了,在 POSIX 系统下我们用内核提供的系统调用 write 可以向 STDOUT 输出一串字符,可是在这个什么都没有的机器上,我们该怎么告诉屏幕:”我要打印一串字符啦“?

这时候就需要 BIOS 来帮忙了。 试想, BIOS 在启动的时候帮我们初始化检查了所有的设备,它应当知道各个设备都是些什么东西,如果知道怎么使用这些设备自然就更好了。

中断

CPU 使用硬件异常来和外部设备进行交互,这里的异常和高级语言程序设计中的异常大不相同。硬件异常分为中断、陷阱等。中断可以由软件使用 int 指令主动产生,称其为软中断。在(无论什么原因)产生中断之后, CPU 会从中断向量表 (Interrupt vector) 中取出中段号对应的值,也就是中断处理函数 (Interrupt handler 或 Interrupt service routine, 即 ISR) 的地址,并通过中断处理函数处理中断。事实上,x86 中进行系统调用就是由软件产生软中断,把控制权交给由内核指定的 ISR 处理。

利用 BIOS 写屏

BIOS 控制了中断向量表中的第 0x10 号位置来提供写屏的操作,我们想在实模式下写屏,可以手动唤起一个 0x10 中断利用 BIOS 提供的服务。不过在此之前,我们要准备好需要交给 BIOS 的参数,将其放置于寄存器 ax 中。

当寄存器 ax 的高位 (ah) 为 0xe 的时候, BIOS 会帮我们通知显示屏进行一次打印,打印的字符为寄存器 ax 的低位 (al) 。

汇编如下(考虑到篇幅,此处只输出一部分):

.code16

movb $0xe, %ah
movb $'H', %al
int $0x10
movb $'e', %al
int $0x10
movb $'l', %al
int $0x10
movb $'l', %al
int $0x10
movb $'o', %al
int $0x10
movb $'!', %al
int $0x10

jmp .
.org 510
.word 0xaa55

这样,屏幕上就应该有一行 Hello! 了。

进入保护模式

学会了简单的输出,我们可以利用引导程序做一些简单的工作了。但是我们现在写的毕竟只是引导程序,不仅大小不能超过 510 字节,而且寻址范围也有限,最高只有 20 个位的寻址,因此有必要进入保护模式使用 32 位的寻址。

实模式

早期 Intel 的 80×86 (80386 前) 系列 CPU 中, CPU 的字长只有 16 位,寻址能力低下。为了可以向更高内存地址寻址(此时的地址总线有 20 位) CPU 采用的寻址方式是段 : 偏移寻址,提供了 cs 代码段寄存器 (Code Segment)ds 数据段寄存器 (Data Segment)ss 堆栈段寄存器 (Stack Segment)es 附加段寄存器 (Extra Segment) 四个段寄存器。

此时 CPU 不区分系统应用程序和普通应用程序,任何应用可以直接使用段寄存器 : 通用寄存器使用 20 位的地址总线直接向物理内存寻址,任何内存都是可读可写可执行的。这造成的后果是任意应用程序都可以有意无意地改写系统、其它应用的内容而影响到整个系统的运行。

因此从 80386 开始, Intel 提供了 32 位的寄存器,并在段偏移寻址之后加入了页表寻址、使用 runlevel 区分开了系统应用和普通应用的权限,由操作系统控制页表以将虚拟内存映射为物理内存,这就可以有效地控制应用程序访问的物理内存地址区间了。这种模式被称为保护模式(不过保护模式是 80286 引入的, 80386 有 32 位寄存器)。

但是为了保持兼容性,使得旧的、只支持实模式的系统内核即使在新的 80386 (及之后的 CPU )上都可以正常运行, CPU 在复位或加电之后都以实模式启动。如果系统内核需要运行在保护模式下,需要手动切换至保护模式。另外,在 x86-64 架构中,如果需要切换至长模式,也需要手动切换,本文暂不考虑 x86-64 。

所以接下来要从实模式切换至保护模式。

进入保护模式之后,我们也无法简单的通过中断来访问 BIOS 提供的服务了: BIOS 提供的服务均基于实模式,如果在保护模式下访问这个服务,会导致 CPU 乱飙。因此就需要在切换之后重新设置中断向量表。

段描述符和全局描述符表

在保护模式下,内存的寻址方式和实模式下完全不同。

实模式下,内存寻址是通过段 : 偏移寻址的,也就是段寄存器值 * 16 + 通用寄存器值,获得的最终值就是物理地址。

但是在保护模式下,段寄存器并不直接用来寻址物理内存,它实际上储存的是一个偏移,是段描述符 (SD, Segment Descriptor)全局描述符表 (GDT, Global Descriptor Table) 中的偏移。

段描述符是这样的:

总计有 64 个位。其中,除了身首异处的 BaseLimit 块,另外两块 Access ByteFlags 块的内容是这样的:

以上图片取自 OSDev.org

解释一下 Access Byte 中 8 个位的含义:

  • Ac 是访问位,只要设置为 0 即可。 CPU 会在第一次访问这个段之后设置为 1 。
  • RW 是读写位:
    • 对于数据段: 0 表示不可写(只读), 1 表示可写。数据段永远可读。
    • 对于代码段: 0 表示不可读(无访问权限), 1 表示可读。代码段永远不可写。
  • DC 位, Direction/Conforming 位:
    • 对于数据段: 0 表示访问该段的偏移必须要比 Limit 要大(向下增长), 1 则是要小(向上增长)。
    • 对于代码段: 1 表示可以被更低运行权限(更大 Privl )执行, 0 表示只能被同权或更高权限执行。
  • Ex 是可执行位: 0 表示该段为数据段, 1 表示该段为代码段。
  • Privl 是权限位,占据两个比特。权限位从 0~3 ,越小表示权限越高,最高为 0
  • Pr 为 Present 位,对于所有可用段必须为 1

解释一下 Flags 中的 4 个比特含义:

  • Sz 是大小位: 0 表示该段运行于 16-bit 保护模式下, 1 表示运行于 32-bit 保护模式下。用于向后兼容。
  • Gr 是粒度位: 0 表示 Limit 的单位是 1 Byte , 1 表示 Limit 的单位是 4 KB (一个页)。

Flags 中的第 2 个 bit 在 x86-64 中指明为 64 位描述符,此时 Sz 位必须为 0

好,可以设计我们的数据段和代码段了,简单起见,我就让这两者指向同一片物理内存,因此 GDT 中这两段的 BaseLimit 相同,基址 Base 为 0 ,长度 Limit0xfffff ( 20 位能表示的最大值,因为下面设置 Gr 位为 1 ,因此可以寻址大小应为 0xfffff + 1 个页,也就是 4 GB )。

首先权限位必然为 0 ,最高权限。对于数据段设置 Ex0 ,对于代码段设置 Ex1 。读写位均设置为 1 。数据段的 DC 位设置为 1 ,代码段的 DC 位设置为 0 。其它都用缺省值即可。

所以最后,数据段的段描述符在内存中应该为 ff ff 00 00 00 92 cf 00 ,代码段的段描述符在内存中应该是 ff ff 00 00 00 9a cf 00

除了这两者之外, GDT 要求第一项必须为全空(全 0 ),因此最终的 GDT 如下定义:

GDT:

null:
.quad 0b0000000000000000000000000000000000000000000000000000000000000000

data:
.word 0b1111111111111111  # Limit:[0, 16)
.word 0b0000000000000000  # Base:[0, 16)
.byte 0b00000000          # Base:[16, 24)
.byte 0b10010010          # (Pr)1 (Privl)00 1 (Ex)0 (DC)0 (RW)1 (Ac)0
.byte 0b11001111          # (Gr)1 (Sz)1 (L)0 0 Limit:[16,20)
.byte 0b00000000          # Base:[24, 32)

code:
.word 0b1111111111111111  # Limit:[0, 16)
.word 0b0000000000000000  # Base:[0, 16)
.byte 0b00000000          # Base:[16, 24)
.byte 0b10011010          # (Pr)1 (Privl)00 1 (Ex)1 (DC)0 (RW)1 (Ac)0
.byte 0b11001111          # (Gr)1 (Sz)1 (L)0 0 Limit:[16,20)
.byte 0b00000000          # Base:[24, 32)

在内存中构建好 GDT 之后,我们需要告知 CPU 其位置。需要再在内存中构建一个特殊的(简单许多的)结构,并使用一个特殊的指令 lgdt 来将其从内存中加载出来。

该结构体很简单,总共 3 个字长,第一个字是 GDT 的大小 – 1 ,后两个字长为 GDT 在物理内存中的地址。

GDTdesc:
.word . - GDT - 1
.long GDT

好了,准备完成了,接下来就真的开始切换了。

Switch!

x86 引入了几个新的控制寄存器 (Control Registers) cr0 cr1cr7 ,每个长 32 位。这其中的某些寄存器的某些位被用来控制 CPU 的工作模式,其中 cr0 的最低位,就是用来控制 CPU 是否处于保护模式的。

因为控制寄存器不能直接拿来运算,所以需要通过通用寄存器来进行一次存取。

movl %cr0, %eax
orl  $1, %eax
movl %eax, %cr0

最后,由于一些现代 CPU 特性 (乱序执行和分支预测等),在转到保护模式之后 CPU 可能仍然在跑着实模式下的代码,这显然会造成一些问题。因此我们必须强迫 CPU 清空一次缓冲。对此,最有效的方法就是进行一次 long jump 。

在 long jump 时,由于已经载入了 GDT ,因此指定的 %cs 也需要和 GDT 中对应的 code 段的偏移相同,在我的表中 code 段在 null 和 data 之后,每段长 8 字节,因此偏移为 0x10 字节。另外,这里也不能直接修改 %cs ,否则当前的代码会直接挂掉,所以 ljmp 的第一个参数应当直接指明为 $0x10

最终代码为:

.code16
  cli
  lgdt GDTdesc
  movl %cr0, %eax
  orl  $1, %eax
  movl %eax, %cr0
  ljmp $0x10, $init_prot

.include "gdt.s"

.code32
init_prot:
  movw $0x8, %ax
  movw %ax, %ds
  movw %ax, %es
  movw %ax, %fs
  movw %ax, %gs
  movw %ax, %ss

  movl $start, %ebp
  movl %ebp, %esp

  jmp .

.org 510
.word 0xaa55

至此,我们成功切换到了保护模式并且初始化了所有的段寄存器和栈帧,使用 GDB 调试应该会发现在不停 jmp 回 0x7c48 这个地址。

这之后,就可以将控制交给内核代码了,不管怎样,我先睡一觉。

附:保护模式下打印字符

进入保护模式之后,如果不急于载入内核,可以通过在内存地址 0xb8000 之后的 4000 个字节写数据来展示字符。其中 0xb8000 地址对应的是 80×25 的 VGA 屏幕左上角第 0 行第 0 列的字符, 0xb8001 是左上角第 0 行第 0 列的字符的显示属性, 0xb8002 是第 0 行第 1 列的字符,以此类推。具体属性可以参照 Wikipedia 上的表格。

以下是清屏并打印一行 Hello, World! 的代码示例:

main:
  movl $hw, %edi
  call puts
  jmp .

# Print a line of string to screen.
# Parameter:
#   %edi: address of NUL-terminated string that is to be printed.
puts:
  call clear
  xorl %ecx, %ecx
puts.0:
  movb (%edi,%ecx), %dl
  test %dl, %dl
  jz   puts.1
  movb $0xf, %dh
  movw %dx, (%ebx,%ecx,2)
  incl %ecx
  jmp  puts.0
puts.1:
  ret

# Clear screen.
clear:
  xorl %ecx, %ecx
  movl $0xb8000, %ebx
clear.0:
  movl $0x0f200f20, (%ebx,%ecx,4)
  incl %ecx
  cmpl $1000, %ecx
  jl   flush.0
  ret

hw:
  .string "Hello, World!"

上面代码中出现的一堆 $0x20 实际上是黑底白字,如果有兴趣可以改动这个值来看看打印到屏幕上的字符都有什么效果。

附件:本文特色图片代码

注意最后的图片略瞎眼。

.code16

  # initialize
  movw  $0x03, %ax
  int   $0x10
  movw  $0x13, %ax
  int   $0x10

  movw  $0xc00, %ax  # color

loop:

  xorw  %cx, %cx     # col
  xorw  %dx, %dx     # row
  xorw  %bx, %bx     # circle

l2r:
  int   $0x10
  incw  %cx
  movw  %cx, %di
  addw  %bx, %di
  cmpw  $MAX_COL, %di
  jne   l2r

u2d:
  int   $0x10
  incw  %dx
  movw  %dx, %di
  addw  %bx, %di
  cmpw  $MAX_ROW, %di
  jne   u2d

r2l:
  int   $0x10
  decw  %cx
  movw  %cx, %di
  subw  %bx, %di
  cmpw  $0, %di
  jne   r2l

  incb  %al
  addw  $2, %bx
  cmpw  $100, %bx
  je    loop

d2u:
  int   $0x10
  decw  %dx
  movw  %dx, %di
  subw  %bx, %di
  cmpw  $0, %di
  jne   d2u

  jmp   l2r

.set MAX_COL, 319
.set MAX_ROW, 199

.org 510, 0x90
.word 0xaa55

发表评论

电子邮件地址不会被公开。 必填项已用*标注