编译过程
在Linux下使用GCC将源码编译成可执行文件的过程可以分解为4个步骤,分别是预处理(Prepressing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。一个简单的hello word程序编译过程如下:
1. 预处理
首先源代码文件(.c/.cpp)和相关头文件(.h/.hpp)被预处理器cpp预编译成.i文件(C++为.ii)。预处理命令为:
gcc –E hello.c –o hello.i
预编译过程主要处理那些源代码中以#开始的预编译指令,主要处理规则如下:
u 将所有的#define删除,并且展开所有的宏定义;
u 处理所有条件编译指令,如#if,#ifdef等;
u 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。该过程递归进行,及被包含的文件可能还包含其他文件。
u 删除所有的注释//和 /**/;
u 添加行号和文件标识,如#2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号信息;
u 保留所有的#pragma编译器指令,因为编译器须要使用它们。
2. 编译
编译过程就是把预处理完的文件进行一系列词法分析,语法分析,语义分析及优化后生成相应的汇编代码文件(.s)。编译的命令为:
gcc –S hello.i –o hello.s
或者从源文件直接输出汇编代码文件:
gcc –S hello.c –o hello.s
现在版本的GCC把预编译和编译两个步骤合并成一个步骤,由程序cc1来完成(C++为cc1plus)。
3. 汇编
汇编就是将汇编代码转变成机器可以执行的命令,生成目标文件(.o),汇编器as根据汇编指令和机器指令的对照表一一翻译即可完成。汇编的命令为:
gcc –c hello.s –o hello.o
或者从源文件直接输出目标文件:
gcc –c hello.c –o hello.o
4. 链接
链接就是链接器ld将各个目标文件组装在一起,解决符号依赖,库依赖关系,并生成可执行文件。链接的命令为:
ld –static crt1.o crti.o crtbeginT.o hello.o –start-group –lgcc –lgcc_eh –lc-end-group crtend.o crtn.o
一般我们使用一条命令就可以完成上述4个步骤:
gcc hello.c
实际上gcc只是一些其它程序的包装,它会根据不同参数去调用预编译编译程序cc1、汇编器as、链接器ld。
目标文件
Linux下的可执行文件格式为ELF(Executalbe Linkable Format),包括可执行文件、可重定位文件(目标文件.o、静态库.a)、共享目标文件(动态库.so)、核心转储文件(core dump)。ELF目标文件的结构如下:
其中ELF文件中与段有关的重要结构就是段表(Section Header Table),该表描述了ELF文件包含的所有段的信息,比如每个段的名称、长度、在文件中的偏移、读写权限及段的其他属性。我们可以通过readelf工具来查看ELF文件的段:
几个比较重要的段如下:
段名 |
说明 |
.text |
存放编译后的机器指令 |
.data |
存放已初始化的全局静态变量和局部静态变量 |
.rodata |
存放只读数据,如全局const变量、字符串常量 |
.bss |
存放未初始化的全局静态变量和局部静态变量 |
.symtab |
符号表,记录符号信息 |
.rel.xxx |
重定位表,记录.xxx段中需要重定位定位符号 |
链接过程的本质就是要把多个不同目标文件粘合成一个整体,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。在链接中,我们将函数和变量统称为符号(Symbol),函数名和变量名就是符号名(Symbol Name),我们可以将符号看做是链接中的粘合剂,整个链接过程正是基于符号才能够正确完成。每个目标文件都会有一个符号表(Symbol Table),即上图的.symtab段,这个表里记录了目标文件所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。我们可以通过readelf工具来查看符号表中所有的符号信息:
静态链接
链接器链接的过程,就是将几个输入的目标文件加工后合并成一个输出文件。合并的方法简单来说,就是将相同性质的段合并到一起,比如将输入文件的.text段合并到.text段,接着是.data段、.bss段等,如下图所示:
链接器一般采用一种叫做两步链接的方法:
- 空间与地址分配。链接器扫描所有的输入目标文件,将它们的段进行合并,计算出输出文件中各个段合并后的长度和位置,建立映射关系;并且将输入目标文件的符号表中的所有符号定义和符号引用收集起来,统一放到一个全局的符号表。
- 符号解析和重定位。使用上一步收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的位置等,这一步是链接过程的核心。
在链接之前,目标文件中所有段的虚拟地址都是0,因为虚拟空间还没有被分配。链接之后,输出文件的各个段都被分配到了相应的虚拟地址。同样的,链接器将输入文件中段进行合并后,就能计算出符号表中的符号在所在段的新的偏移量,通过符号所在段的虚拟地址和符号在段中的偏移量,就可以计算出符号最终的虚拟的地址。
重定位是连接符号引用和符号定义的过程。在目标文件中,有一个叫重定位表(Relocation Table)的结构专门用来保存与重定位相关的信息,对于每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段。比如.text段和.data段都有被重定位的地方,那么就会有相应的重定位表.rel.text段和.rel.data段。每个要被重定位的地方叫做一个重定位入口(Relocation Entry),重定位入口的偏移表示该入口在要被重定位的段中的位置。重定位的过程中,每个重定位入口都是对一个符号的引用,那么当链接器需要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址,这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号进行重定位。我们看下面的符号表:
类型为GLOBAL的符号shared和swap都是UND,这种未定义的符号是因为该目标文件中有关于它们的重定位项。所以在链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能在全局符号表中找到,否则链接器就报符号未定义错误。
在静态链接中,除了链接源代码生成的目标文件,还需要链接其它静态库,如C语言静态库libc。其实静态库可以简单地看成是一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。我们可以使用ar工具来查看静态库中包含了那些目标文件:
链接器在链接静态库的时候是以目标文件为单位的,只有引用了静态库中某个目标文件中定义的符号,才会把改目标文件链进来。
装载
可执行文件只有被装载到内存以后才能被CPU执行。操作系统创建一个进程,然后装载相应的可执行文件并且执行,这个过程最开始只需要做3件事情:
-
创建一个独立的虚拟地址空间。创建虚拟空间实际上只是分配一个页目录,虚拟空间到物理内存的映射关系等到后面程序发生页错误的时候再进行设置。
-
读取可执行文件头(Program Header Table),并且建立虚拟空间与可执行文件的映射关系。当操作系统捕获到缺页错误时,通过该映射关系就知道当前所需要的页在可执行文件中的位置。这种映射关系是按照段(Segment)进行映射的,进程虚拟空间中的一个段叫做虚拟内存区域(VMA,Virtual Memory Area)。
-
将CPU的执行寄存器设置成可执行文件的入口地址,启动运行。ELF文件头中保存了入口地址,操作系统通过设置CPU指令寄存器将控制权转交给进程,由此进程开始执行。
进程虚拟空间中的一个段叫做虚拟内存区域(VMA,Virtual Memory Area),一个VMA按照一个Segment来映射可执行文件,在ELF文件中把权限相同的Section合并成一个Segment,系统正式按照Segment而非Section来映射可执行文件的。从Section的角度来看ELF文件就是连接视图(Linking View),从Segment的角度来看就是执行视图(Executiong View)。当我们在谈到ELF装载时,段专门指Segment;而在其他情况下,段指的是Section。
ELF文件的Segment信息保存在可执行文件头(Program Header Table),它描述了ELF文件如何被操作系统映射到进程的虚拟空间,可以通过readelf工具查看Segment:
在上图中,类型为LOAD的两个Segment是需要被映射的,我们还可以看到哪些Section被合并到了这两个Segment中。
VMA除了被用来映射可执行文件中的各个Segment,进程在执行时用到的堆、栈等空间也是以VMA的形式存在的。我们可以查看进程虚拟空间的分布:
操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA。一个进程基本上可以分为如下几种VMA区域:
-
代码VMA,权限只读、可执行,有映像文件。
-
数据VMA,权限可读写、可执行,有映像文件。
-
堆VMA,权限可读写、可执行,无映像文件,匿名,可向上扩展。
-
栈VMA,权限可读写、不可执行,无映像文件,匿名,可向下扩展。
一个常见进程的虚拟空间如下图所示:
动态链接
动态链接的基本思想是把程序按照模块拆分成相对独立的部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都连接成一个单独的可执行文件。ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Object),简称共享对象,它们一般都是.so为扩展名的文件。相比静态链接,动态链接有两个优势,一是共享对象在磁盘和内存只有一份,节省了空间;二是升级某个共享模块时,只需要将目标文件替换,而无须将所有的程序重新链接。
共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。为了能够使共享对象在任意地址装载,在连接时对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成,即装载时重定位。同时为了实现共享模块的指令部分在多个进程间共享,共享的指令部分就不能因为装载地址的改变而改变,解决方法就是把指令中那些需要被修改的部分分离出来,和数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本,这种方案就是地址无关代码(PIC,Position-independent Code)的技术,我们在GCC中使用-fPIC参数来生成地址无关代码。对于模块内部的符号引用使用的是相对地址,所以这种指令是不需要重定位的;而对于模块外部的符号引用,做法是在数据段建立一个全局偏移表(GOT,Global Offset Table),代码通过GOT中相对应的项进行间接引用,对GOT的引用同样使用相对地址,基本机制如下:
在动态链接情况下,操作系统在映射完可执行文件之后,会启动一个动态链接器(Dynamic Linker),动态链接器ld.so实际上也是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中,并将控制权交给动态链接器的入口地址,动态链接器开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作,当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
动态链接ELF中最重要的结构是.dynamic段,这个段里面保存了动态链接器所需要的基本信息,如依赖于哪些共享对象、动态链接符号的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。使用readelf工具可以查看.dynamic段的内容:
另外还可以通过ldd工具来查看一个程序或共享库依赖哪些共享库:
为了表示动态链接模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表(Dynamic Symbol Table)的段来保存这些信息,这个段通常叫做.dynsym,它只保存了与动态链接相关的符号,静态链接符号表.syntab保存了所有的符号,一般动态链接模块同时拥有两个符号表。可以使用readelf工具来查看ELF文件的动态符号表:
动态链接基本上分为3步:
- 动态链接器自举。当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码开始执行。自举代码获得动态链接器本身的重定位表和符号表,将它们重定位后,才可以使用自己的全局变量和静态变量。
- 装载共享对象。完成自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个全局符号表。然后链接器开始寻找可执行文件所依赖的共享对象,并将这些共享对象的名字放入到一个装载集合中。链接器开始从集合里取一个所需要的共享对象的名字,打开相应的文件并读取ELF文件头和.dynamic段,然后将它对应的代码段和数据段映射到进程空间。如果这个ELF共享对象还依赖于其他的共享对象,那么将所依赖的共享对象放入到装载集合中。如此循环知道所有依赖的共享对象都被装载进来为止。
- 重定位和初始化。链接器开始重新遍历可执行文件和每个共享对象的重定位表,将他们的GOT中每个需要重定位的位置进行修正。重定位完成之后如果某个共享对象有.init段,那么动态链接器就会执行.init段中的代码,用以实现共享对象特有的初始化过程,比如共享对象中的C++全局/静态对象的构造。
动态链接还有一种更加灵活的模块加载方式,叫做显式运行时链接,也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载,这种共享对象往往被称为动态装载库,可以用来实现诸如插件、驱动等功能。动态库和一般的共享对象没有区别,不同的是共享对象是有动态链接器在程序启动之前负责装载和链接的,这个过程对程序本身是透明的;而动态库的装载则是通过一系列由动态链接器提供的API,具体地讲共有4个函数:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)和关闭动态库(dlclose),程序可以通过这几个API对动态库进行操作。
动态链接和静态链接相比,性能上大约要慢有1%-5%。有两个原因影响了动态链接的性能,一是程序开始执行时,动态链接器都要进行一次链接工作,会减慢程序的启动速度;二是对模块外部的符号引用需要通过GOT进行间接访问。