程序从源代码到可执行文件的编译步骤大致分为:预处理、编译、汇编、链接。以下示例继续使用hello.c,4步分别对应的指令如下。
# 预处理
gcc -E hello.c -o hello.i
# 编译
gcc -S hello.i -o hello.s
# 汇编
gcc -c hello.s -o hello.o
# 链接,-static为静态链接
gcc hello.o -o hello -static
汇编后形成的.o格式的文件已经是ELF格式文件了。程序编译后生成的目标文件至少含有3个节区(Section),分别为.text、.data和.bss。在此为兼顾传统的名称,后面也称其为段。但要注意在ELF格式中Section与Segment是不同的。
链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或被复制)到内存中并执行。在hello world例子中就是将编译输出的.o文件与libc库文件进行链接,生成最终的可执行文件。
通俗地说,链接就是把多个文件拼接到一起,本质上是节的拼接。
下图为链接前目标文件的节区信息表:
下图为可执行文件的节区信息表:
比较链接前目标文件的节区信息表,可执行文件的节区信息表中节多了,由14个变成32个。一个重要变化是,.text的Addr有了值。多出来的节是从外部库中添加过来的,编译器进行了整合,并安排了地址布局。另一个变化是,链接后多了段头表(Program header table),前文已有介绍其结构Linux- 浅谈ELF目标文件格式_青衫客36的博客-CSDN博客,可以自行查看。可执行文件的加载执行,其实是操作系统按照段头的指示,将可执行文件按照安排好的布局加载到内存,再跳转到其中的代码段。以上内容用于帮助我们从ELF格式层面理解可执行文件加载器的工作流程建立初步的印象,从编译原理的角度看可能并不严谨。
在可执行文件的生成过程中,最后的部分就是链接,链接对于我们理解可执行程序的加载和执行非常关键。链接从过程上讲分为符号解析和重定位两部分;根据链接时机的不同,又分为静态链接和动态链接两种。
先以hello.c为例简要说明符号、符号解析与重定位。其实应该是以hello.i为例,因为真正编译的C源文件是hello.i。
简单来说,hello.c中只有两个符号——main和printf。main的实现就在hello.c中,而printf的实现显然没有在hello.c中。相应的hello.c编译为hello.o后,main这个符号是“有定义”的,printf这个符号则是“无定义”的。[ ps:“有定义”的意思就是函数对应的机器指令地址在当前文件中(有明确的地址)]
编译器需要到其他的共享库中找到printf的“定义(机器指令片段)”,找到后把该片段机器指令与hello.o拼接到一起(静态链接),生成可执行文件hello。hello中printf就存在了(有定义即有了明确的地址),这就是符号解析。
在拼接所有目标文件的同时,编译器会确定各个函数加载到内存中的地址,然后反过来修改所有调用该函数的机器指令,使得该指令能跳转到正确的内存地址。这个过程就是重定位。
符号包含全局变量和全局函数。例如printf就是一个符号,hello程序需要在函数库中找到这个符号。
符号表(symbol table)是一种供编译器用于保存有关源程序构造的各种信息的数据结构。这些信息在编译器的分析阶段被逐步收集并放入符号表,它们在综合阶段用于生成目标代码。符号表的每个条目包含与一个标识符相关的信息,比如它的字符串、类型、存储位置和其他相关信息。符号表通常需要支持同一标识符在一个程序中的多重声明。
符号表的功能是找未知函数在其他库文件中的代码段的具体位置。还是以hello.c为例,其调用的printf是外部库提供的函数。在链接前,编译器需要把类似于printf这种符号都记录下来,存储于符号表中。
符号表的查看方法为objdump -t hello.o或readelf -s hello.o
如下是输出的链接前hello.o与链接后hello两个ELF文件符号表的内容。我们对内容进行删减,只留下了需要关注的部分。我们只关注main函数和puts(对应前文中的printf)。
链接前的ELF文件符号表:
链接后的ELF文件符号表:
如上图main函数前后对比,变化在Value和Ndx列。Value在链接前是0,在链接后是0401775。对符号来说,Value就是内存地址。在链接前可执行文件各部分未分配内存地址,所以其值为0。Ndx是该符号对应的节区编号,之前是1,之后是7,这是因为链接后加入了很多外部库的节区。其他属性未变,因为main函数本身就在hello.o文件中,所以其类型是函数(FUNC),大小30都是已知的。
puts(printf)是调用外部的函数,也就是引用外部符号。之前Type为NOTYPE(未知),Ndx为UND(未定义),Value为0,因为其对应的机器指令不在hello.o中,而在libc中。链接后Value为040c140,Ndx与main一样同为7,也就是编译器把puts所在的节与main所在的节全部作为新的.text节。
链接前符号表只有6个项,链接后变成了2092个(contains 2092 entries)。链接的符号解析是个递归的过程,所以即使是这样一个小小的程序也是用了大量的库函数。
宏定义名 | 值 | 说明 |
---|---|---|
STB_LOCAL | 0 | 局部符号,对目标文件的外部不可见 |
STB_GLOBAL | 1 | 全局符号,外部可见 |
STB_WEAK | 2 | 弱引用 |
全局符号的强、弱
全局符号有强、弱的特性。
宏定义名 | 值 | 说明 |
STT_NOTYPE | 0 | 未知类型符号 |
STT_OBJECT | 1 | 该符号是一个数据对象,比如变量、数组等 |
STT_FUNC | 2 | 该符号是一个函数或其他可执行代码 |
STT_SECTION | 3 | 该符号表示一个段,这种符号必须是STB_LOCAL类型的 |
STT_FILE | 4 | 该符号表示文件名,一般是该目标文件所对应的源文件名,它一定是STB_LOCAL类型的 |
从符号表定义的“符号所在段(st_shndx)”字段可以看出,如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标;如果符号不是定义在本目标文件中,或者对于某些特殊符号,sh_shndx的值有些特殊,如下表所示。
宏定义名 | 值 | 说明 |
SHN_ABS | 0xfff1 | 表示该符号包含了一个绝对的值。比如表示文件名的符号就属于这种类型的。 |
SHN_COMMON | 0xfff2 | 表示该符号是一个“COMMON”块类型的符号,一般来说,未初始化的全局符号定义就是这种类型的。 |
SHN_UNDEF | 0 | 表示该符号未定义。这个符号表示该符号在本目标文件被引用到,但是定义在其他目标文件中。 |
由此可见,符号表中定义过的函数,其Ndx字段会显示这个函数表示符号所在段在段表中的下标;如果未经定义的函数,则会显示UND;未初始化的全局符号则显示COMMON。
重定位是把程序的逻辑地址空间变换成进程线性地址空间的过程,也就是链接时对目标程序中指令和数据的地址修改的过程。
重定位表中的每一条记录都对应一个需要重定位的符号。汇编器将为可重定位文件中每个包含需要重定位符号的段都建立一个重定位表。
重定位表的查看方法是readelf -r hello.o
符号表记录了目标文件中所有的全局函数及其地址;重定位表中记录了所有调用这些函数的代码位置。在链接时,这两大类数据都需要逐一修改为正确的值。
1、静态链接。在编译链接时直接将需要的执行代码复制到最终可执行文件中,优点是代码的装载速度快,执行速度也比较快,对外部环境依赖度低。编译时它会把需要的所有代码都链接进去,应用程序相对比较大。缺点是如果多个应用程序使用同一库函数,会被装载多次,浪费内存。
2、动态链接。在编译时不直接复制可执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统。操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定的代码时,去共享执行内存中已经加载的动态库去执行代码,最终达到运行时链接的目的。优点是多个程序可以共享同一段代码,而不需要在内存/磁盘上存储多个副本。缺点是在运行时加载,可能会影响程序的前期执行性能,而且对使用的库依赖性较高,在升级时特别容易出现版本不兼容的问题。
如前文中的hello就是静态链接的可执行文件。如果在编译时不加“-static”选项,则编译器会默认使用动态链接。如下动态链接的可执行文件hello.dynamic只有15960字节,而静态链接版本hello大小约是其56倍。
以上内容为中科大软件学院《Linux操作系统分析》课后总结,感谢孟宁老师的倾心教授,老师讲的太好啦(^_^)
参考资料:《庖丁解牛Linux内核分析》 孟宁 编著