从GCC编译源码到得到可执行二进制文件的过程主要分为4步。
1)预编译:将源码中的预处理指令进行展开,如#include以及#define等指令。
2)编译:编译是将源码经过一系列的分析和优化,生成对应架构的汇编代码。其中包括编译器前端的词法分析、语法分析、语义分析,编译器中端的IR(Intermediate Representation,中间表示)、IR之间的分析/变换/优化,以及编译器后端的指令调度、指令选择、寄存器分配以及代码生成等。
3)汇编:第2步生成的.s汇编文件此时还是人类可读的ASCII格式的文件,但是CPU执行的机器码需要的是二进制指令。因此第3步需要将.s中人类可读的指令与数据一一翻译成CPU可读的二进制文件。这个过程比较简单,只需要查表翻译即可。
4)链接:前边3步的预处理、编译、汇编的过程都是对单一的编译单元来进行的,也就是只有一个源文件。因此,编译器在执行完前面3个步骤后,会得到多个编译单元后缀为.o的目标文件,此时就需要链接器来将这些目标文件链接到一起生成最终的可执行文件。
由此可以看到,链接器做的事情主要是对编译器生成的多个.o文件进行合并,一般采取的策略是把各个目标文件中相同的段进行合并,例如多个.text段合并成可执行文件中的一个.text段。在这个阶段中,链接器对输入的各个目标文件进行扫描,获取各个段的大小,同时会收集所有的符号定义以及引用信息,构建一个全局的符号表。此时,链接器已经构造好了最终的文件布局以及虚拟内存布局,再根据符号表就能确定每个符号的虚拟地址。然后链接器会对整个文件进行第二遍扫描,这一阶段会利用第一遍扫描得到的符号表信息,依次对文件中每个符号引用的地方进行地址替换。这个阶段也就是对符号的解析以及重定位的过程。
以上4个过程是GCC编译链接的全过程。其中,预处理、编译以及汇编的过程,不管在哪个平台(Windows、Linux、macOS)都是通用的。因为虽然操作系统平台不一样,但是CPU的指令集是一样的,有差异的地方主要在于链接的过程。
不同的操作系统平台有着自己的二进制文件格式,例如Windows下的PE格式、Linux下的ELF格式,以及macOS上的MachO格式。二进制文件格式中定义了文件类型的魔数(Magic Number),代码段、数据段的存储位置以及一些其他程序相关的元数据等,因此当你运行对应系统的可执行文件时,需要对应系统的加载器(Loader)识别并加载对应格式的可执行文件,否则应用程序就无法运行,比如,ELF格式的可执行文件就无法运行在Windows系统中。
链接的过程就是生成对应系统加载器可识别格式的文件,组织不同段的位置,设置魔数,设置程序运行起始地址等。由此可见,链接器与加载器的工作关系类似镜像,链接器负责根据二进制文件格式标准生成对应格式的磁盘文件,而加载器则根据二进制文件格式标准将对应的磁盘文件读取到内存当中并执行。
运行在操作系统上的应用程序,是由系统的加载器进行加载并运行的。而操作系统内核在开机上电的时候并没有加载器来负责加载系统内核,因此操作系统的引导程序就需要由自己负责加载。例如,Linux 0.11代码中的bootsetct.S和setup.S这两个汇编文件做的事情就是加载系统内核。
同样,对系统内核的链接器而言,也不能生成ELF格式或者PE格式等,这里需要按照bootsetct.S和setup.S对初始引导过程中的内存布局进行设置,将对应的代码段生成在0x0位置,我们可以在内核的构建系统中看到ld的构建选项中有“Ttext 0x0”这个选项,表示将对应的代码段的虚拟内存地址设置到0x0位置。
如果想查看经过ld链接后的镜像文件中的符号与虚拟内存地址的对应关系,可以通过“-M”选项将对应关系输出到文件中。在调试内核代码的时候可以方便查看内存地址与符号的映射关系。