系列目录

从 mbr 到 loader

接上一篇 BIOS 启动到实模式,这篇开始 loader 的编写。首先回顾一下那张磁盘镜像和内存分布图:

目前只需要关注 1MB 一下的内存分布,主要是黄色 mbr 和蓝色 loader 部分。上一篇中已经将 mbr 加载到内存,并且程序流通过 mbr 最后一条指令 jmp LOADER_BASE_ADDR (0x8000) 已经执行到了 loader 的入口处,接下来就需要将 loader 实现。

loader 的工作

总的来说, loader 的工作主要有以下几项:

  • 建立 GDT(Global Descriptor Table),初始化内核代码和数据段寄存器(segment registers),带领 CPU 进入保护模式(protection mode);
  • 建立 kernel 页目录(page directory)和页表(page tables),打开虚拟内存(virtual memory),进入 paging 模式;
  • 加载 kernel 镜像到内存,然后进入到 kernel 代码执行,至此系统的控制权转交到了 kernel ;

可以看到 loader 的工作是比较多的,并且已经涉及到了x86 体系架构中的一些核心部分,因此为了读懂并实现 loader,你必须做好以下的知识准备:

  • GDT,段内存寻址,段寄存器,保护模式;
  • 虚拟内存,页目录,页表;
  • elf 文件格式,因为 kernel 会被编译链接成该格式的文件;

loader 实现

仍然和之前一样,先给出我的项目代码链接 src/boot/loader.S,供你参考。

这个源代码已经比较多了,尤其是它还是汇编写成的,而且代码里还包含了很多工具函数和打印相关的函数。为了避免陷入混乱,这里抽取出几个最重要的关键节点(函数),分别代表了上面所述的 loader 需要做的几项工作:

# 入口
loader_start

# 初始化 GDT 并进入保护模式
setup_protection_mode
protection_mode_entry

# 初始化 kernel 页目录和页表
setup_page

# 加载并进入 kernel
init_kernel

接下来我们一个一个实现这些功能。本篇我们首先初始化 GDT,进入 32-bit 保护模式

进入 loader

在开始之前,我们首先看 loader 的开始部分的代码,和 mbr 一样,这里仍然首先定义了 loader 编码的起始内存地址,为 0x8000,这是因为我们预先设计好了,mbr 会将 loader 从磁盘上加载到内存 0x8000 位置处并跳转过去,所以 loader 的编址必须从该地址开始。

; LOADER_BASE_ADDR = 0x8000
SECTION loader vstart=LOADER_BASE_ADDR

接下来正式进入 loader 的第一条代码 jmp loader_start,它是一个简单的跳转,我们跳到了 loader_start开始真正执行 loader 的工作:

loader_entry:
  jmp loader_start

; 全局数据
; ...

loader_start:
  call clear_screen
  call setup_protection_mode

如果你对这种汇编编码的方式不熟悉,可能会觉得奇怪,为什么要 jmp 一下,中间跳过的部分是什么?答案是,中间是我们要定义的数据部分,类似于 .c 文件里定义的全局变量。那里定义了一堆用来打印的字符串,以及至关重要的 GDT

你可能已经意识到了,汇编源代码里的指令和数据部分是可以自由混杂排布的,而且最终编译出来的二进制里它们排布顺序完全遵循源代码的排布。所以你可以任意安排你的指令和数据所处的位置,只要指令流能顺利地流转和执行下去,不至于跑飞就行。当然,整个 loader 的起始位置,即 0x8000 处必须是入口代码,因为这是和 mbr 约定好的跳转地址。至于后面全部可以自由发挥和排布。

初始化 GDT 表

来到上面说的全局数据的定义部分,你可以跳过我加入的一些打印字符串信息,直接来到 GDT 的定义处。这里定义了 4 个 GDT entry,每个 entry 占了 8 个字节即 64 bits。关于 GDT 的含义和字段格式,可以参考这里,也可以参考我之前推荐的 JamesM's kernel development tutorials 。这些都是 x86 体系架构的历史包袱,我不想浪费笔墨再解释一遍,但是我们的代码必须实现并遵从它的法则。

GDT 第一个 entry 是保留项不做使用;第四个为显示器 video 内存段描述符,这个其实并不是必须的,你可以无视它;所以我们只需要关注第二和第三项即可,它们是:

  • 内核代码段( kernel code )描述符;
  • 内核数据段 (kernel data )描述符;

我们用 dd 伪指令定义这两个段描述符(segment descriptor):

CODE_DESC:
  dd DESC_CODE_LOW_32
  dd DESC_CODE_HIGH_32

DATA_DESC:
  dd DESC_DATA_LOW_32
  dd DESC_DATA_HIGH_32

DESC_CODE_LOW_32DESC_CODE_HIGH_32DESC_DATA_LOW_32DESC_DATA_HIGH_32 都定义在了 src/boot/boot.inc 中,你可以对照上面给出的手册文档验证每一个 bit。还是那句话,这是一个枯燥、麻烦、细致但是绕不开的工作,没有什么难点,需要的是读文档手册的耐心。


为了照顾对汇编还不是很熟悉的同学,有必要将 dd 伪指令的作用解释一遍。dd 的意思是 define double (4-bytes),与之类似的还有 db (byte)dw (word, 2-bytes),它们出现在汇编源代码里,就是指在编译后的二进制里,在该位置上写入后面所定义的数据内容。由此你可以再次体会一下汇编与编译后的二进制的关系,这几乎就是一种刻板的翻译而已。

进入保护模式

设置完 GDT 后,我们就可以进入保护模式:

; enable A20
in al, 0x92
or al, 0000_0010b
out 0x92, al

; load GDT
lgdt [gdt_ptr]

; open protection mode - set cr0 bit 0
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

; refresh pipeline
jmp dword SELECTOR_CODE:protection_mode_entry

注意这里使用了 lgdt 指令加载 GDT,并且打开了 cr0 寄存器的保护模式的 bit 位,正式进入保护模式。后面通过一个 far jump,将 cs 段寄存器初始化为 kernel code 段。注意 cs 寄存器的值不能直接通过 mov 指令设置,而是必须通过跳转语句隐式地被设置。

跳转后,接下来程序来到 protection_mode_entry 的执行,这里初始化了几个 kernel data 段寄存器:

protection_mode_entry:
  ; set data segments
  mov ax, SELECTOR_DATA
  mov ds, ax
  mov es, ax
  mov ss, ax

  ; set video segment
  mov ax, SELECTOR_VIDEO
  mov gs, ax

到此保护模式的初始化工作算是完成,然后就来到了 loader 的重点部分 setup_page 函数,开始建立 kernel 的虚拟内存,留待下一篇。

03-05 23:58