搜索
❏ 站外平台:

Caffeinated 6.828:实验 1:PC 的引导过程

作者: Mit 译者: LCTT qhwdw

| 2018-06-12 18:08   收藏: 1    

第二部分:引导加载器

在 PC 的软盘和硬盘中,将它们分割成 512 字节大小的区域,每个区域称为一个扇区。一个扇区就是磁盘的最小转存单元:每个读或写操作都必须是一个或多个扇区大小,并且按扇区边界进行对齐。如果磁盘是可引导盘,第一个扇区则为引导扇区,因为,第一个扇区中驻留有引导加载器的代码。当 BIOS 找到一个可引导软盘或者硬盘时,它将 512 字节的引导扇区加载进物理地址为 0x7c000x7dff 的内存中,然后使用一个 jmp 指令设置 CS:IP0000:7c00,并传递控制权到引导加载器。与 BIOS 加载地址一样,这些地址是任意的 —— 但是它们对于 PC 来说是固定的,并且是标准化的。

后来,随着 PC 的技术进步,它们可以从 CD-ROM 中引导,因此,PC 架构师趁机对引导过程进行轻微的调整。最后的结果使现代的 BIOS 从 CD-ROM 中引导的过程更复杂(并且功能更强大)。CD-ROM 使用 2048 字节大小的扇区,而不是 512 字节的扇区,并且,BIOS 在传递控制权之前,可以从磁盘上加载更大的(不止是一个扇区)引导镜像到内存中。更多内容,请查看 “El Torito” 可引导 CD-ROM 格式规范

不过对于 6.828,我们将使用传统的硬盘引导机制,意味着我们的引导加载器必须小于 512 字节。引导加载器是由一个汇编源文件 boot/boot.S 和一个 C 源文件 boot/main.c 构成,仔细研究这些源文件可以让你彻底理解引导加载器都做了些什么。引导加载器必须要做两件主要的事情:

  1. 第一、引导加载器将处理器从实模式切换到 32 位保护模式,因为只有在 32 位保护模式中,软件才能够访问处理器中 1 MB 以上的物理地址空间。关于保护模式将在 PC 汇编语言 的 1.2.7 和 1.2.8 节中详细描述,更详细的内容请参阅 Intel 架构手册。在这里,你只要理解在保护模式中段地址(段基地址:偏移量)与物理地址转换的差别就可以了,并且转换后的偏移是 32 位而不是 16 位。
  2. 第二、引导加载器通过 x86 的专用 I/O 指令直接访问 IDE 磁盘设备寄存器,从硬盘中读取内核。如果你想去更好地了解在这里说的专用 I/O 指令,请查看 6.828 参考页面 上的 “IDE 硬盘控制器” 章节。你不用学习太多的专用设备编程方面的内容:在实践中,写设备驱动程序是操作系统开发中的非常重要的部分,但是,从概念或者架构的角度看,它也是最让人乏味的部分。

理解了引导加载器源代码之后,我们来看一下 obj/boot/boot.asm 文件。这个文件是在引导加载器编译过程中,由我们的 GNUmakefile 创建的引导加载器的反汇编文件。这个反汇编文件让我们可以更容易地看到引导加载器代码所处的物理内存位置,并且也可以更容易地跟踪在 GDB 中步进的引导加载器发生了什么事情。同样的,obj/kern/kernel.asm 文件中包含了 JOS 内核的一个反汇编,它也经常被用于内核调试。

你可以使用 b 命令在 GDB 中设置中断点地址。比如,b *0x7c00 命令在地址 0x7C00 处设置了一个断点。当处于一个断点中时,你可以使用 csi 命令去继续运行:c 命令让 QEMU 继续运行,直到下一个断点为止(或者是你在 GDB 中按下了 Ctrl - C),而 si N 命令是每次步进 N 个指令。

要检查内存中的指令(除了要立即运行的下一个指令之外,因为它是由 GDB 自动输出的),你可以使用 x/i 命令。这个命令的语法是 x/Ni ADDR,其中 N 是连接的指令个数,ADDR 是开始反汇编的内存地址。

练习 3

查看 实验工具指南,特别是 GDB 命令的相关章节。即便你熟悉使用 GDB 也要好好看一看,GDB 的一些命令比较难理解,但是它对操作系统的工作很有帮助。

在地址 0x7c00 处设置断点,它是加载后的引导扇区的位置。继续运行,直到那个断点。在 boot/boot.S 中跟踪代码,使用源代码和反汇编文件 obj/boot/boot.asm 去保持跟踪。你也可以使用 GDB 中的 x/i 命令去反汇编引导加载器接下来的指令,比较引导加载器源代码与在 obj/boot/boot.asm 和 GDB 中的反汇编文件。

boot/main.c 文件中跟踪进入 bootmain() ,然后进入 readsect()。识别 readsect() 中相关的每一个语句的准确汇编指令。跟踪 readsect() 中剩余的指令,然后返回到 bootmain() 中,识别 for 循环的开始和结束位置,这个循环从磁盘上读取内核的剩余扇区。找出循环结束后运行了什么代码,在这里设置一个断点,然后继续。接下来再走完引导加载器的剩余工作。

完成之后,就能够回答下列的问题了:

  • 处理器开始运行 32 代码时指向到什么地方?从 16 位模式切换到 32 位模式的真实原因是什么?
  • 引导加载器执行的最后一个指令是什么,内核加载之后的第一个指令是什么?
  • 内核的第一个指令在哪里?
  • 为从硬盘上获取完整的内核,引导加载器如何决定有多少扇区必须被读入?在哪里能找到这些信息?

加载内核

我们现在来进一步查看引导加载器在 boot/main.c 中的 C 语言部分的详细细节。在继续之前,我们先停下来回顾一下 C 语言编程的基础知识。

练习 4

下载 pointers.c 的源代码,运行它,然后确保你理解了输出值的来源的所有内容。尤其是,确保你理解了第 1 行和第 6 行的指针地址的来源、第 2 行到第 4 行的值是如何得到的、以及为什么第 5 行指向的值表面上看像是错误的。

如果你对指针的使用不熟悉,Brian Kernighan 和 Dennis Ritchie(就是大家知道的 “K&R”)写的《C Programming Language》是一个非常好的参考书。同学们可以去买这本书(这里是 Amazon 购买链接),或者在 MIT 的图书馆的 7 个副本 中找到其中一个。在 SIPB Office 也有三个副本可以细读。

在课程阅读中,Ted Jensen 写的教程 可以使用,它大量引用了 K&R 的内容。

警告:除非你特别精通 C 语言,否则不要跳过这个阅读练习。如果你没有真正理解了 C 语言中的指针,在接下来的实验中你将非常痛苦,最终你将很难理解它们。相信我们;你将不会遇到什么是 ”最困难的方式“。

要了解 boot/main.c,你需要了解一个 ELF 二进制格式的内容。当你编译和链接一个 C 程序时,比如,JOS 内核,编译器将每个 C 源文件('.c')转换为一个包含预期硬件平台的汇编指令编码的二进制格式的对象文件('.o'),然后链接器将所有编译过的对象文件组合成一个单个的二进制镜像,比如,obj/kern/kernel,在本案例中,它就是 ELF 格式的二进制文件,它表示是一个 ”可运行和可链接格式“。

关于这个格式的全部信息可以在 我们的参考页面 上的 ELF 规范 中找到,但是,你并不需要深入地研究这个格式 的细节。虽然完整的格式是非常强大和复杂的,但是,大多数复杂的部分是为了支持共享库的动态加载,在我们的课程中,并不需要做这些。

鉴于 6.828 的目的,你可以认为一个 ELF 可运行文件是一个用于加载信息的头文件,接下来的几个程序节,根据加载到内存中的特定地址的不同,每个都是连续的代码块或数据块。引导加载器并不修改代码或者数据;它加载它们到内存,然后开始运行它。

一个 ELF 二进制文件使用一个固定长度的 ELF 头开始,紧接着是一个可变长度的程序头,列出了每个加载的程序节。C 语言在 inc/elf.h 中定义了这些 ELF 头。在程序节中我们感兴趣的部分有:

  • .text:程序的可运行指令。
  • .rodata:只读数据,比如,由 C 编译器生成的 ASCII 字符串常量。(然而我们并不需要操心设置硬件去禁止写入它)
  • .data:保持在程序的初始化数据中的数据节,比如,初始化声明所需要的全局变量,比如,像 int x = 5;

当链接器计算程序的内存布局的时候,它为未初始化的全局变量保留一些空间,比如,int x;,在内存中的被称为 .bss 的节后面会马上跟着一个 .data。C 规定 "未初始化的" 全局变量以一个 0 值开始。因此,在 ELF 二进制中 .bss 中并不存储内容;而是,链接器只记录地址和.bss 节的大小。加载器或者程序自身必须在 .bss 节中写入 0。

通过输入如下的命令来检查在内核中可运行的所有节的名字、大小、以及链接地址的列表:

athena% i386-jos-elf-objdump -h obj/kern/kernel

如果在你的计算机上默认使用的是一个 ELF 工具链,比如像大多数现代的 Linux 和 BSD,你可以使用 objdump 来代替 i386-jos-elf-objdump

你将看到更多的节,而不仅是上面列出的那几个,但是,其它的那些节对于我们的实验目标来说并不重要。其它的那些节中大多数都是为了保留调试信息,它们一般包含在程序的可执行文件中,但是,这些节并不会被程序加载器加载到内存中。

我们需要特别注意 .text 节中的 VMA(或者链接地址)和 LMA(或者加载地址)。一个节的加载地址是那个节加载到内存中的地址。在 ELF 对象中,它保存在 ph->p_pa 域(在本案例中,它实际上是物理地址,不过 ELF 规范在这个域的意义方面规定的很模糊)。

一个节的链接地址是这个节打算在内存中运行时的地址。链接器在二进制代码中以变量的方式去编码这个链接地址,比如,当代码需要全局变量的地址时,如果二进制代码从一个未链接的地址去运行,结果将是无法运行。(它一般是去生成一个不包含任何一个绝对地址的、与位置无关的代码。现在的共享库大量使用的就是这种方法,但这是以性能和复杂性为代价的,所以,我们在 6.828 中不使用这种方法。)

一般情况下,链接和加载地址是一样的。比如,通过如下的命令去查看引导加载器的 .text 节:

athena% i386-jos-elf-objdump -h obj/boot/boot.out

BIOS 加载引导扇区到内存中的 0x7c00 地址,因此,这就是引导扇区的加载地址。这也是引导扇区的运行地址,因此,它也是链接地址。我们在boot/Makefrag 中通过传递 -Ttext 0x7C00 给链接器来设置链接地址,因此,链接器将在生成的代码中产生正确的内存地址。

练习 5

如果你得到一个错误的引导加载器链接地址,通过再次跟踪引导加载器的前几个指令,你将会发现第一个指令会 “中断” 或者出错。然后在 boot/Makefrag 修改链接地址来修复错误,运行 make clean,使用 make 重新编译,然后再次跟踪引导加载器去查看会发生什么事情。不要忘了改回正确的链接地址,然后再次 make clean

我们继续来看内核的加载和链接地址。与引导加载器不同,这里有两个不同的地址:内核告诉引导加载器加载它到内存的低位地址(小于 1 MB 的地址),但是它期望在一个高位地址来运行。我们将在下一节中深入研究它是如何实现的。

除了节的信息之外,在 ELF 头中还有一个对我们很重要的域,它叫做 e_entry。这个域保留着程序入口的链接地址:程序的 .text 节中的内存地址就是将要被执行的程序的地址。你可以用如下的命令来查看程序入口链接地址:

athena% i386-jos-elf-objdump -f obj/kern/kernel

你现在应该能够理解在 boot/main.c 中的最小的 ELF 加载器了。它从硬盘中读取内核的每个节,并将它们节的加载地址读入到内存中,然后跳转到内核的入口点。

练习 6

我们可以使用 GDB 的 x 命令去检查内存。GDB 手册 上讲的非常详细,但是现在,我们知道命令 x/Nx ADDR 是输出地址 ADDRNword就够了。(注意在命令中所有的 x 都是小写。)警告:word的多少并没有一个普遍的标准。在 GNU 汇编中,一个word是两个字节(在 xorw 中的 'w',它在这个词中就是 2 个字节)。

重置机器(退出 QEMU/GDB 然后再次启动它们)。检查内存中在 0x00100000 地址上的 8 个词,输出 BIOS 上的引导加载器入口,然后再次找出引导载器上的内核的入口。为什么它们不一样?在第二个断点上有什么内容?(你并不用真的在 QEMU 上去回答这个问题,只需要思考就可以。)

LCTT 译者
qhwdw 💎
共计翻译: 152.5 篇 | 共计贡献: 353
贡献时间:2017-10-31 -> 2018-10-19
访问我的 LCTT 主页 | 在 GitHub 上关注我


返回顶部

分享到微信

打开微信,点击顶部的“╋”,
使用“扫一扫”将网页分享至微信。