一直以来,我们都在使用汇编语言对MBR编程,但对于操作系统这样的复杂程序来说,使用汇编语言是比较困难的。本章将实现操作系统内核的加载与进入。
5.1 读硬盘的实现原理
操作系统存储于硬盘中,现在需要将其读出至内存。想要读硬盘,就需要依次进行以下操作:
- 设定读取的扇区数
- 设定起始扇区号
- 发送读硬盘命令
- 等待硬盘准备完毕
- 将硬盘中的数据读出
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位整数,只需要关注其中的两位:
- 如果第7位为1,表示硬盘忙,其他位都无效;如果第7位为0,表示硬盘不忙,其他位有效
- 如果第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段的存在。所以,加载一段程序时要分两步:
- 使用"这段程序的大小",将这段程序加载到目的地址中
- 计算
这段程序要求的内存大小 - 这段程序的大小
,记作N
,将这段程序需要加载到的地址 + 这段程序的大小
后面的N
字节清零,这样就完成了BSS段的加载
综上,加载并进入一个ELF文件需要以下步骤:
- 将ELF文件读取至缓冲区
- 取偏移量
0x1c
处的4字节整数,将其与ELF文件的起始地址相加,得到程序头表的起始地址 - 取偏移量
0x26
处的2字节整数,这是程序头表中每个表项的大小 - 取偏移量
0x28
处的2字节整数,这是程序头表中表项的数量 - 遍历程序头表,将其中每一段类型为
0x1
的程序加载到目的地址中,并将BSS段清零 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行,将硬盘中的数据读出。参数如下:
- 端口号:
0x1f0
- 目的地址:
0x80000
。这个地址离0xa0000
有128K,远远超过我们的操作系统的大小 - 读取次数:
99 * 512 / 2
。读取的扇区数是99,一个扇区是512字节,insw
指令一次读取2字节,所以可以使用这个公式计算读取次数
至此,ELF文件已经加载到0x80000
处,接下来需要解析这个文件。
第86\~92行,读取ELF文件头中的多项信息,列举如下:
- EBX中存放的是程序头表地址。请注意:
0x8001c
处的数值是一个偏移量,其需要与0x80000
相加才能得到内存地址 - EDX中存放的是程序头表中每个表项的大小
- ECX中存放的是程序头表中表项的数量
第98\~99行,判断表项的类型,只需要类型为0x1
的表项。
第101\~105行,将这段程序加载到目的地址中,参数如下:
- ESI中存放的是源地址。这里同样需要注意:
[ebx + 0x4]
处的数值只是一个偏移量,其需要与0x80000
相加才能得到内存地址 - EDI中存放的是目的地址
- ECX中存放的是这段程序的大小
第107\~110行,构造BSS段。这里的EDI沿用了上面rep movsb
指令的结果;ECX被设定为[ebx + 0x14] - [ebx + 0x10]
。
第114\~117行,通过循环解析程序头表中的每个表项。
第119行,跳转至内核的入口地址。
至此,我们已经正式进入操作系统内核。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。