一、编译器 (Compiler)
编译器将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个 ASCII 或其他编码的文本文件。
二、汇编器 (Assembler)
汇编器将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 目标文件 (Object File)。
汇编器输出的每个目标文件都有一个独立的程序内存布局,它描述了目标文件内各段所在的位置。
三、链接器 (Linker)
链接器将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。
链接器所做的事情主要有两件:
(1)将来自不同目标文件的段在目标内存布局中重新排布
如下图所示,在链接过程中,分别来自于目标文件 1.o 和 2.o 段被按照段的功能进行分类,相同功能的段被排在一起放在拼装后的目标文件 output.o 中。
注意到,目标文件 1.o 和 2.o 的内存布局是存在冲突的,同一个地址在不同的内存布局中存放不同的内容。而在合并后的内存布局中,这些冲突被消除。
来自不同目标文件的段的重新排布示意图
../_images/link-sections.png
(2)将符号替换为具体地址
这里的符号指什么呢?
我们知道,在我们进行模块化编程的时候,每个模块都会提供一些向其他模块公开的全局变量、函数等供其他模块访问,也会访问其他模块向它公开的内容。要访问一个变量或者调用一个函数,在源代码级别我们只需知道它们的名字即可,这些名字被我们称为符号。
取决于符号来自于模块内部还是其他模块,我们还可以进一步将符号分成内部符号和外部符号。
然而,在机器码级别(也即在目标文件或可执行文件中)我们并不是通过符号来找到索引我们想要访问的变量或函数,而是直接通过变量或函数的地址。
例如,如果想调用一个函数,那么在指令的机器码中我们可以找到函数入口的绝对地址或者相对于当前 PC 的相对地址。
符号何时被替换为具体地址?
因为符号对应的变量或函数都是放在某个段里面的固定位置(如全局变量往往放在 .bss 或者 .data 段中,而函数则放在 .text 段中),所以我们需要等待符号所在的段确定了它们在内存布局中的位置之后才能知道它们确切的地址。
当一个模块被转化为目标文件之后,它的内部符号就已经在目标文件中被转化为具体的地址了,因为目标文件给出了模块的内存布局,也就意味着模块内的各个段的位置已经被确定了。
然而,此时模块所用到的外部符号的地址无法确定。我们需要将这些外部符号记录下来,放在目标文件一个名为符号表(Symbol table)的区域内。由于后续可能还需要重定位,内部符号也同样需要被记录在符号表中。
外部符号需要等到链接的时候才能被转化为具体地址。
假设模块 1 用到了模块 2 提供的内容,当两个模块的目标文件链接到一起的时候,它们的内存布局会被合并,也就意味着两个模块的各个段的位置均被确定下来。此时,模块 1 用到的来自模块 2 的外部符号可以被转化为具体地址。
同时我们还需要注意:两个模块的段在合并后的内存布局中被重新排布,其最终的位置有可能和它们在模块自身的局部内存布局中的位置相比已经发生了变化。因此,每个模块的内部符号的地址也有可能会发生变化,我们也需要进行修正。
上面的过程被称为重定位(Relocation),这个过程形象一些来说很像拼图:
由于模块 1 用到了模块 2 的内容,因此二者分别相当于一块凹进和凸出一部分的拼图,正因如此我们可以将它们无缝地拼接到一起。
四、从可执行文件到内核镜像
上面我们简单介绍了程序内存布局和编译流程特别是链接过程的相关知识。
那么如何得到一个能够在 Qemu 上成功运行的内核镜像呢?
首先我们需要通过链接脚本调整内核可执行文件的内存布局,使得内核被执行的第一条指令位于地址 0x80200000 处,同时代码段所在的地址应低于其他段。
这是因为 Qemu 物理内存中低于 0x80200000 的区域并未分配给内核,而是主要由 RustSBI 使用。
其次,我们需要将内核可执行文件中的元数据丢掉得到内核镜像,此内核镜像仅包含实际会用到的代码和数据。
这是因为 Qemu 的加载功能过于简单直接,它直接将输入的文件逐字节拷贝到物理内存中,因此也可以说这一步是我们在帮助 Qemu 手动将可执行文件加载到物理内存中。
下一节我们将成功生成内核镜像并在 Qemu 上验证控制权被转移到内核。