静态链接_基础知识
Wednesday, March 1, 2023
本文共3399字
7分钟阅读时长
⚠️本文是作者P3troL1er原创,首发于https://peterliuzhi.top/principle/%E9%9D%99%E6%80%81%E9%93%BE%E6%8E%A5_%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/。商业转载请联系作者获得授权,非商业转载请注明出处!
There are basically two types of people. People who accomplish things, and people who claim to have accomplished things. The first group is less crowded.
— Mark Twain
本文是笔者阅读《俞甲子, 石凡, 潘爱民 - 程序员的自我修养_ 链接、装载与库-电子工业出版社 (2009)》的学习笔记~
链接的原因与原理
由于工程组织与后期维护的需要,我们将源文件编译成一个个目标文件(Linux下是.o,Windows下是.obj),所以我们需要一种方式将他们整合起来。
那么,我们应该如何将他们整合起来呢?是像预处理器处理include那样直接拼接在一起吗?显然不行,原因如下:
- 在我们编译其中一个代码文件的时候,编译器是不知道某个未在本源文件的函数的地址的,比如如下代码,编译器虽然因为include了头文件得到了printf的声明,但是编译器并不知道它的定义的地址,那在主函数call的时候也就无从转向这个函数:
#include<stdio.h>
int main(void){
printf("Hello World!\n");
return 0;
}
- 因为在不同的源文件中可能多次声明了一个同名弱符号,需要链接器进行判断,最后在统一的可执行文件中使用那个弱符号(有强符号使用强符号)
- 不同的可执行文件都有各自的文件头、段和节,如果粗暴地拼接在一起,那对这些信息的索引将非常麻烦,因此需要链接器将他们整合在一起
- 因为空间对齐和分页机制的影响,如果只是粗暴地拼接在一起,会造成空间的极大浪费
因着这些缺点,所以现在大部分链接器都采用两步链接的方法。
第一步:相似段(节)合并(空间与地址分配)
分配实际地址空间和虚拟地址地址
因为bss段中是未初始化的变量,在目标文件和可执行文件中并不占用空间,只在装载入内存时占用地址空间。但是为了程序中使用bss段中的变量时能够寻址,链接器会为它分配虚拟地址空间,意味着它只在装载的时候才占用空间(依赖文件头的定义)。
因此,像.text,.data这样的段(节),链接器不仅会为他们分配虚拟地址空间,还会为他们在可执行文件(目标文件)中分配空间;而像.bss这样的段(节),链接器只会为他们分配虚拟地址空间。
VMA和LMA
VMA(Virtual Memory Address,虚拟内存地址)意为程序运行时的虚拟空间地址;LMA(Load Memory Address,加载空间地址)意为程序在执行前的加载过程使用的地址。两者一般情况下相同,在一些特殊的嵌入式系统上会有差别。
下面我们用objdump查看一下VMA和LMA。我写了两个小的源文件,编译得到了他们两个的目标文件和合并后的可执行文件:
用objdump分别查看header:
可以看到目标文件在合并前的VMA和LMA都是0,这是因为此时虚拟空间还没有分配。而等到链接以后,虚拟空间分配了以后,VMA和LMA才发生了变化:
两者在普通情况下都是相等的。
COMMON块机制判断同名弱符号声明
对于之前提到的多个同名弱符号的问题,因为链接器不知道符号的类型,只知道一个符号的名字,所以编译器使用了一种和fortran中类似的办法:以占用空间最大的那个为准。
因此如果可执行文件中同名的符号全都是弱符号,那么链接器在链接过程中是可以判断符号的占用空间大小的,因此,这些未初始化全局变量最终会变成.bss节中的一份子。因此,common块机制让这些未初始化全局变量能够像未初始化局部静态变量一样被放进.bss节
当然,我们也可以在gcc中指定"-fno-common"选项或者使用__attribute__((nocommon))
扩展防止一个未初始化全局变量被放进common块。不过一旦这么做了,那它就会被判定为强符号;一旦可执行文件中出现了重复的强符号,链接器就会报错。
符号地址的确定
在前面分配空间的时候,段基址就已经确定了,现在需要确定各个符号的地址。符号地址=(所属)段基址+偏移
第二步:符号解析与重定向
重定位基本介绍
在我们确定了符号地址之后,由于我们代码中call、jmp还有使用到全局变量等的地方的地址还是"假地址",需要改成已经确定后的"真地址",因此需要重定位
而因为不同处理器的指令都不太一样,所以这些重定位的符号修正方式都不太一样,要具体问题具体分析
如何找到需要重定位的指令
在ELF文件中,有一个重定位表储存相关信息,它往往是一个或者多个段。
比如若.text节有需要重定位的地方,段名就是.rel.text;.data同理,为.rel.data
重定位表被描述为重定位入口和其类型与符号的结构体:
其中r_offset也就是偏移在动态链接中有不同的含义,但总的来说,都是用来定址的。
符号解析
因为每一个重定位入口都是一个对符号的引用,因此如果要进行重定位,必须首先确定该符号的地址,这时链接器就会去查找全局符号表,找到相应的符号后进行重定位。
因此,如果程序缺少某一个库,而又依赖于这个库,那么进行重定位的时候链接器就找不到使用这个库的符号的实际地址(分配的虚拟地址)
C++对链接的影响
重复代码消除
C++很容易在编译阶段产生重复代码,比如模板、外部内联函数、虚函数表等。
比如模板,对单文件编译的时候,编译器并不知道是否在别的文件里实例化了,因此在不同文件中可能有重复的函数,这样会产生以下问题:
我们可以设计一个类似登记表的结构,只要出现了这个函数,那么就把他登记在登记表上。这样每一个目标文件都会有这样一个"登记表",在链接时可以通过查表来丢弃重复段。
根据这种思想,不同编译器有不同做法,比如:
- gcc使用.gnu.linkonce.name段,其中登记的数据是函数经过修饰后的名字
- MSVC使用COMDAT段,它的成员都有IMAGE_SCN_LNK_COMDAT标记,一旦编译器发现了这个标记,它就会把该段看作COMDAT类型,在链接时将重复的段丢弃
全局构造与析构
在main函数之前总是需要进行一些初始化操作,在其之后也需要一些清理工作。
在Linux下一般程序的入口是_start
函数,在main函数之前执行,并调用main函数,在main函数执行完之后返回_start
函数进行清理工作。针对这两者不同的特殊需求,ELF文件还定义了两种不同的段:
- .init,存放初始化代码指令
- .fini,存放终止代码指令
ABI
如果两个目标文件能够相互链接,那么他们必须ABI(Application Binary Interface,程序二进制接口)兼容(采用相同的目标文件格式、拥有相同的符号修饰规则、内存分布方式相同、函数调用方式相同等)。
由于C++的语言规定等,它的二进制兼容更为不易,因此大家都盼望着有统一的C++ ABI,但是目前来看问题将长久存在
静态链接库
静态库可以被看作是一组目标文件的集合,比如我们的HelloWorld程序里面调用了printf函数,这就需要链接标准库。
在C语言的运行库中存放了很多和系统功能相关的代码,这些代码非常繁杂,为了良好的组织,并且各目标文件中往往存在复杂的依赖关系,人们用ar程序将这些目标程序压缩在一起并为他们进行编号和索引方便查找与检索
控制链接过程
使用ld script
我们可以通过向编译器传递命令行参数来控制链接,也可以将链接控制信息写在目标文件的段中(比如MSVC会将控制信息放在PE文件的.drectve段中),但有一种更为强大的方式,即使用ld script(MSVC使用.def模块定义文件)
它可以控制程序入口、段名、是否丢弃段等
具体写法见ld script 学习笔记_ioscoder的博客-CSDN博客
Make、CMake控制生成与链接库
我们还可以通过一些更现代化的构建器脚本来控制生成与链接库,比如Make、CMake、XMake等,他们可以通过指定命令行参数来控制编译与链接过程(比如CMake中的add_compile_options命令),因而也可以单独写一个ld script,然后用这些构建工具传参给编译器进行更加底层的链接控制
BFD库——文件抽象
因为现代硬件和平台种类繁多,因着架构等的不同,ELF在不同平台也有不同变种,这样对于分析程序来说极为不便,最好有一种统一的接口来处理这种差异,而BFD库就担任着这个大任。
BFD库将文件抽象成一个统一的模型,里面包含了文件头、符号表、重定位表等概念,使用者只需要通过这些抽象的概念就能描述文件,而不用去管那些复杂细微的差异。
BFD库是gnu binutils的子项目,可以在Binutils - GNU Project - Free Software Foundation找到,可以在Index of /gnu/binutils
扫码阅读此文章
点击按钮复制分享信息
点击订阅