一个操作系统的设计与实现——第5章 加载内核

一直以来,我们都在使用汇编语言对MBR编程,但对于操作系统这样的复杂程序来说,使用汇编语言是比较困难的。本章将实现操作系统内核的加载与进入。

5.1 读硬盘的实现原理

操作系统存储于硬盘中,现在需要将其读出至内存。想要读硬盘,就需要依次进行以下操作:

  1. 设定读取的扇区数
  2. 设定起始扇区号
  3. 发送读硬盘命令
  4. 等待硬盘准备完毕
  5. 将硬盘中的数据读出

5.1.1 设定读取的扇区数

读取的扇区数需要写入到0x1f2端口。这是一个8位端口,如果向此端口写入0,则读取的扇区数为256个;否则,读取的扇区数就是写入的值。

5.1.2 设定起始扇区号

我们的操作系统使用的是具有28位逻辑扇区号的硬盘。然而,硬盘中用于存放逻辑扇区号的端口全都是8位的。也就是说,一共需要"3.5个端口"来存放逻辑扇区号。"3.5个端口"显然是不存在的,实际使用的是3个8位端口,加上1个8位端口的低4位,共同凑成28位的逻辑扇区号。这些端口如下表所示:

端口号 作用
0x1f3 存放逻辑扇区号的0~7位
0x1f4 存放逻辑扇区号的8~15位
0x1f5 存放逻辑扇区号的16~23位
0x1f6 低4位存放逻辑扇区号的24~27位,高4位固定为0xe

5.1.3 发送读硬盘命令

0x1f7端口是一个既可读又可写的8位端口。如果向此端口写入数据,其用于接收命令。读硬盘的命令是0x20

5.1.4 等待硬盘准备完毕

硬盘接收到0x20命令后就会开始准备。在此期间,需要不断的读0x1f7端口以查询硬盘状态。对于读取到的这个8位整数,只需要关注其中的两位:

  1. 如果第7位为1,表示硬盘忙,其他位都无效;如果第7位为0,表示硬盘不忙,其他位有效
  2. 如果第3位为1,表示硬盘已经准备就绪;如果第3位为0,表示硬盘尚未准备就绪

综上,等待的目标是第7位为0且第3位为1。

5.1.5 将硬盘中的数据读出

0x1f0端口用于读取数据。这是一个16位的端口。当硬盘准备完毕后,可以通过(大量的)in指令或insw指令将数据读出。

5.2 编译内核

本章代码5/Kernel.c是用于测试的内核。

内核在编译时不能依赖任何已有的库,且需要一些特殊设定。

请看本章代码5/Makefile

第3行,将内核编译成库文件。

在我们的操作系统中,会使用一些与C语言标准库重名的函数,命令中的-fno-builtin用于关闭GCC对这些函数名的警告。

第4行,将库文件链接,得到可执行文件。

-Ttext-segment 0x0用于设定内核在内存中的起始加载地址。事实上,最低1K内存中存储的是中断向量表,但我们并不使用这个表,所以可以直接覆盖这段内存。

-e main用于设定入口点,如果没有这个设置,链接器会提示"找不到_start"。读者可能会疑惑:C语言的入口点难道不是main函数吗?这个问题将在后续章节中讨论。此外,读者也可以将代码中的main改成_start,并去除-e main,也能通过链接。

第6行,将内核写入虚拟硬盘。在我们的操作系统中,内核使用99个扇区,与MBR使用的1个扇区共同凑整到100个扇区。

5.3 ELF文件

在Linux中,不管是上文得到的Kernel.o文件还是Kernel文件,其格式都是ELF,即可执行与可链接格式(Executable and Linkable Format,ELF)。ELF格式适用于多种文件,包括静态链接库,动态链接库,可执行程序等,在我们的操作系统中,只需要关注可执行程序。

ELF文件的加载分为两个阶段,首先需要将整个文件读入内存的一个缓冲区中,在这里解析该文件,并按文件中提供的信息将程序加载到目的地址。

ELF文件的开头记录了一些最重要的信息,以下只列出和可执行程序有关的部分:

相对于文件开头的偏移量 字节数 含义
0x0 1 魔数0x7f
0x1 3 字符串ELF
... ... ...
0x18 4 入口地址
0x1c 4 程序头表相对于文件开头的偏移量
... ... ...
0x26 2 程序头表中每个表项的大小
0x28 2 程序头表中表项的数量
... ... ...

程序头表中存放的是可执行程序的加载信息。其由一组表项构成,每个表项的结构如下:

相对于表项开头的偏移量 字节数 含义
0x0 4 类型
0x4 4 这段程序相对于ELF文件开头的偏移量
0x8 4 这段程序需要加载到的地址
... ... ...
0x10 4 这段程序的大小
0x14 4 这段程序要求的内存大小
... ... ...

在我们的操作系统中,只需要关注类型为0x1的表项,这个类型表示可加载的程序段。

之所以要区分"这段程序的大小"和"这段程序要求的内存大小",是因为BSS段的存在。所以,加载一段程序时要分两步:

  1. 使用"这段程序的大小",将这段程序加载到目的地址中
  2. 计算这段程序要求的内存大小 - 这段程序的大小,记作N,将这段程序需要加载到的地址 + 这段程序的大小后面的N字节清零,这样就完成了BSS段的加载

综上,加载并进入一个ELF文件需要以下步骤:

  1. 将ELF文件读取至缓冲区
  2. 取偏移量0x1c处的4字节整数,将其与ELF文件的起始地址相加,得到程序头表的起始地址
  3. 取偏移量0x26处的2字节整数,这是程序头表中每个表项的大小
  4. 取偏移量0x28处的2字节整数,这是程序头表中表项的数量
  5. 遍历程序头表,将其中每一段类型为0x1的程序加载到目的地址中,并将BSS段清零
  6. jmp到偏移量0x18处的入口地址

5.4 加载内核的实现

请看本章代码5/Mbr.s

第1~49行与上一章一致,用于进入保护模式和分页模式。

第51~53行,设定读取的扇区数为99。

第55~68行,设定起始扇区号为1。

第70~72行,向硬盘发送读命令。此时,硬盘开始准备。

第74~79行,不断读取0x1f7端口,以等待硬盘准备完毕。等待的目标已于上文中讨论过:第7位为0且第3位为1。

第81~84行,将硬盘中的数据读出。参数如下:

  1. 端口号:0x1f0
  2. 目的地址:0x80000。这个地址离0xa0000有128K,远远超过我们的操作系统的大小
  3. 读取次数:99 * 512 / 2。读取的扇区数是99,一个扇区是512字节,insw指令一次读取2字节,所以可以使用这个公式计算读取次数

至此,ELF文件已经加载到0x80000处,接下来需要解析这个文件。

第86~92行,读取ELF文件头中的多项信息,列举如下:

  1. EBX中存放的是程序头表地址。请注意:0x8001c处的数值是一个偏移量,其需要与0x80000相加才能得到内存地址
  2. EDX中存放的是程序头表中每个表项的大小
  3. ECX中存放的是程序头表中表项的数量

第98~99行,判断表项的类型,只需要类型为0x1的表项。

第101~105行,将这段程序加载到目的地址中,参数如下:

  1. ESI中存放的是源地址。这里同样需要注意:[ebx + 0x4]处的数值只是一个偏移量,其需要与0x80000相加才能得到内存地址
  2. EDI中存放的是目的地址
  3. ECX中存放的是这段程序的大小

第107~110行,构造BSS段。这里的EDI沿用了上面rep movsb指令的结果;ECX被设定为[ebx + 0x14] - [ebx + 0x10]

第114~117行,通过循环解析程序头表中的每个表项。

第119行,跳转至内核的入口地址。

至此,我们已经正式进入操作系统内核。

posted @ 2023-11-12 09:47  樱雨楼  阅读(27)  评论(0编辑  收藏  举报