1. 源程序的处理过程
(1) 预处理 根据“#”开头的预处理命令,修改原始代码文件得到一个中间文件。例如:将#include的头文件的内容直接插入到源文件中;
(2) 编译 对预处理之后的文件进行编译,得到包含汇编代码的文件;
(3) 汇编 对汇编文件进行处理,翻译成二进制的可重定位目标文件;
(4) 链接 链接器将多个可重定位目标文件以及静态库文件等组合起来得到一个可执行目标文件;
2. 可重定位目标文件
可重定位目标文件包含二进制代码和数据,一个目标文件对应程序的一个目标模块,我们以Unix系统的可重定位目标文件格式ELF文件来介绍可重定位目标文件的结构。
ELF头 |
.text |
.rodata |
.data |
.bss |
.symtab |
.rel.text |
.rel.data |
.debug |
.line |
.strtab |
节头部表 |
可重定位目标文件的头和尾分别是ELF头和节头部表,夹在ELF头和节头部表之间的各个部分称为节。ELF头描述了ELF文件的结构相关信息,节头部表描述了不同节的位置和大小。下面简单描述一下各个节:
.text节包含的是程序的机器代码;
.rodata节是只读数据;
.data节包含的是程序中已初始化的全局C变量(局部变量保存在运行时栈中,但static的已初始化局部变量也保存在.data节中);
.bss节包含未初始化的全局C变量,这个节实际上不占据空间,只是一个占位的作用;
.symtab节是符号表,保存程序中定义和引用的函数以及全局变量的信息;
.rel.text节保存的是.text节中一些需要重定位的条目,调用外部函数或者引用全局变量的指令中的位置在链接时需要修改;
.rel.data节保存的是.data节中一些需要重定位的条目,对于已初始化的全局变量,若它的初始值是全局变量地址或者外部函数的地址,则需要在链接时修改,例如:一个全局变量的初始值是全局数组的地址;
.debug节保存的是调试符号表;.line节保存的是源程序中的行号和.text节中机器代码的映射;.strtab节是字符串表,保存.symtab节和.debug节中的字符串;
3. 符号和符号表
目标文件中的.symtab节是符号表,保存程序中定义和引用的函数以及全局变量的符号信息,符号可以分为以下三种:
该目标模块定义且能被其他模块引用的全局符号,如该模块中定义的非static的函数以及非static的全局变量;
其他目标模块定义且能被该模块引用的外部符号,如外部函数以及外部变量;
该目标模块定义且只能自己引用的本地符号,如static的函数和static的全局变量;
每个符号都和目标文件中的某个节相关,已初始化的全局变量符号和.data节相关,未初始化的全局变量符号和.bss节相关,函数符号和.text节相关,该模块中引用而定义在其他模块中的符号和UNDEF这个伪节(即在节头部表中没有信息)相关。
4. 符号解析
链接器解析符号引用即将.text节中每个符号引用与输入链接器的所有可重定位目标文件的符号表中的一个定义关联起来。对于引用本地符号来说,符号解析较为简单,然而对于全局符号的引用,解析起来较为复杂,k可能会遇到未定义的符号引用,还有一个主要的问题是全局符号可能会出现多重定义的问题。
首先,对于全局符号按照强符号和弱符号来分类,函数符号和已经初始化的全局变量属于强符号,未初始化的全局变量是弱符号。Unix使用如下规则来处理全局符号的多重定义问题:
(1)不允许有多个强符号定义;
(2)如果有一个强符号定义和多个弱符号定义,则选择强符号定义;
(3)如果有多个弱符号定义,那么从这些弱符号定义中任选择一个;
对于规则(2)和规则(3),可能会产生一些难以发现的错误,例:
/*规则(2)的应用*/
/*test.c*/ #include <stdio.h> void f(); int x = 1; //强符号 int main() { f(); printf("%d ",x); return 0; } /*f.c*/ int x; //弱符号 void f() { x = 2; //f的本意是改变f.c中的x,却意外地改变了test.c中的x }
5. 静态链接库
在Unix系统中,静态链接库使用存档文件的格式打包多个目标文件,后缀名为.a,可以使用ar命令来创建静态链接库:
ar rcs libmy.a test1.o test2.o
链接器在链接静态库生成可执行文件时,只拷贝静态库中被引用的目标模块。链接器在链接目标文件以及静态库时,会按照命令行上从左到右的顺序来扫描它们包含的目标文件,进而扫描其中包含的符号,因此命令行上目标文件以及静态库出现的顺序十分重要。
例如:
g++ -o test main.o libx.a liby.a
main.o中引用了libx.a或者liby.a中的符号,libx.a和liby.a可以是相互独立的,如果不是,则libx.a中引用了liby.a中的符号。如果libx.a中引用了liby.a的符号,而liby.a中也引用了libx.a的符号,则可以写成:
g++ -o test main.o libx.a liby.a libx.a
6. 重定位
链接器合并多个输入模块,为每个符号分配运行时地址,之所以能够在链接时就确定运行时地址,是因为虚拟存储器的机制为进程提供了一致的地址空间。链接器重定位由两步构成:
(1)重定位节和符号定义
链接器将所有相同类型的节合并为同一类型的新聚合节,设置新聚合节的运行时地址,设置所有输入模块中的各个节以及各个符号定义的运行时地址。
(2)重定位节中的符号引用
链接器修改代码节和数据节中对每个符号的引用,使它们指向正确的运行时地址,重定位符号引用需要使用到输入模块中的.rel.text节以及.rel.data节。
7. 可执行目标文件
可执行目标文件的格式与可重定位目标文件类似:
段头部表描述了可执行目标文件中各个节与存储器中段的映射关系;
.init节定义了一个函数,在程序初始化时会被调用;
.text节、.rodata节和.data节即重定位之后的节,其中的符号引用已经指向运行时地址;
可执行文件中不再需要.rel.text节和.rel.data节,因为已经重定位了;
ELF头部到.rodata节构成代码段,.data节和.bss节构成数据段,剩下的节不加载存储器中。
可执行文件被加载内存后,在内存中的存储映像如下:
系统内核区 |
用户栈 |
共享库 |
用户堆 |
数据段 (.data节和.bss节) |
代码段 (.init节、.text节和.rodata节) |
加载可执行文件的过程并不是将可执行文件中的代码段和数据段拷贝到内存中,而是在内存中创建一个与之对应的存储映像,然后将存储映像中的各个段映射到可执行文件的页。当CPU执行需要引用一个页面时,才会将磁盘中的数据拷贝到内存。
8. 动态链接库
静态库有如下的缺点:
静态库更新之后,需要重新编译链接引用静态库的所有程序;
每个链接静态库的程序都会将引用到的目标文件合并到可执行文件中,占用很多内存;
动态链接库是一个可以在运行时加载到内存并和程序链接起来的目标模块,在Unix系统中,动态库以.so作为后缀名,在Windows系统中,以.dll作为后缀。动态库中的.text节被加载到内存后,可以被多个引用该库的程序共享,不需要多份拷贝。
在Unix系统中,可以使用如下命令生成动态库:
g++ -shared -fPIC -o libmy.so test1.c test2.c
参考资料 《深入理解计算机系统》