链接器与加载器
目录
前言
一、链接加载
二、体系结构
三、目标文件
四、存储空间分配
五、符号管理
六、库
七、重定位
八、加载和重叠
九、共享库
前言
读了一遍链接器与加载器译文,梳理内容,做做笔记
一、链接加载
1.1 链接器与加载器是什么?
程序最终编译出的是二进制文件,代码运行时涉及到函数的跳转,或者数据取值等操作,而链接器与加载器就是将运行时调用的函数名、调用变量等抽象化的事物,关联到具体二进制文件地址的工具。
链接与加载主要的三个动作:程序加载、重定位、符号解析
1.2 两遍链接
当链接器运行时,会首先对输入文件进行扫描,得到各个段的大小,并收集对所有符号的定义和引用。它会创建一个列出输入文件中定义的所有段的段表,和包含所有导出、导入符号的符号表。编译器驱动首先会将每个源文件编译为汇编语言,然后转换为目标代码,接着链接器会将目标代码链接器一起,输出目标文件。
整个过程一般为两遍链接:
第一遍扫描得到的数据,链接器为符号分配数字地址,决定各个段在输出地
址空间中的大小和位置,并确定每一部分在输出文件中的布局。第二遍扫描会利用第一遍扫描中收集的信息来控制实际的链接过程。一次链接器运行的输出文件可以作为下次链接器运行的输入。
1.3 链接目标代码库
链接器将部分工作从链接时推迟到了加载时,链接器会在输出文件中标明用来解析这些符号的库名称,不需要链接进程序,在运行中绑定相关库进行调用。
二、体系结构
本章主要了解一些常用的体系结构概念
2.1 内存地址
计算机系统都有主存储器。主存总是表现为一块连续的存储空间,每一个存储位置都有一个数字地址。这个地址从 0 开始,并逐渐增长为某个较大的数字
2.2 字节顺序和对齐
几乎所有产品化的计算机都使用 8 位,通过将相邻的字节合为一组,计算机同样可以很好的处理 16 位、32 位、64 位或 128 位的数据。
数据的存储存在大端和小端概念,可以见我以前的分析:
内存大小端_Top嵌入式的博客-CSDN博客
2.3 指令格式
操作数可以被编码到指令本身(立即操作数),或者放置在内存中,放在内存中的地址需要通过一些寻址方式查找,常见的寻址方式看完以前写的文章:
ARM裸机开发:ARM汇编基础
2.4 过程调用
代码运行中有着多次调用过程,每个过程调用分配的一块栈内存称为"栈框架(stack frame)",参数和本地变量通常在栈中分配空间,芯片架构一般会使用一个寄存器作为栈指针,它可以基址寄存器来使用,通过寄存器获取栈指针,压栈和出栈获取栈中的数据。在一个过程的内部,数据寻址可分为 4 类:
- 调用者可以向过程传递参数。
- 本地变量在过程中分配,并在过程返回前释放。
- 本地静态数据保存在内存的固定位置中,并为该过程私有。
- 全局静态数据保存在内存的固定位置中,并可被很多不同过程引用。
对于局部变量使用栈进行数据的传递,但本地调用过程中的全局变量和局部变量调用则是由编译器创建一个指针表。如果某个寄存器存有指向这个表的指针,那么调用过程可以通过使用表指针寄存器将对象在表中的指针读取出来,加载到另一个使用表指针寄存器作为基址的寄存器中,并将第二个寄存器做为基址寄存器来寻址任何想要访问的静态目标。
注意很多情况下,在一个模块中的所有过程调用会共享一个指针表,这时模块内的调用不需要改变表指针。 |
表指针的链最初的地址加载无法通过代码完成,无论使用的是什么技术,都是需要链接器帮助加载。
三、目标文件
目标文件:含由源程序生成的二进制代码和数据。
目标文件的五类信息:
- 头信息:关于文件的整体信息,诸如代码大小,翻译成该目标文件的源文件名称,和创建日期等。
- 目标代码:由编译器或汇编器产生的二进制指令和数据。
- 重定位信息:目标代码中的一个位置列表,链接器在修改目标代码的地址时会对它进行调整。
- 符号表:该模块中定义的全局符号,以及从其它模块导入的或者由链接器定义的符号。
- 调试信息:目标代码中与链接无关但会被调试器使用到的其它信息。包括源代码文件和行号信息。
常见的目标文件
3.1 .out文件
简化格式如下:

.out 格式是简单高效,但不能很容易的支持动态链接和c++,逐渐被淘汰。
3.2 .elf文件
ELF 格式有三个略有不同的类型:
可重定位的,可执行的,和共享目标elf。其中可重定位文件由编译器和汇编器创建,运行前需要被链接器处理。可执行elf完成了所有的重定位工作和符号解析(除了那些可能需要在运行时被解析的共享库符号),共享目标就是共享库,即包括链接器所需的符号信息,也包括运行时可以直接执行的代码。
elf格式采用了段分布方式,头部存放各段的描述符,后面则跟着各段的数据,一个简单的段分布示意图如下:

实际链接时写链接脚本也是按照段进行分配,如下图定义了.text .data .rodata等段,链接脚本中将对应的代码链接进去

elf段清晰明了,目前是嵌入式领域常用的链接加载目标文件。
3.3 IBM 360 目标格式
60 年代设计的,但一直沿用至今,每个目标文件包含一系列的控制区段(csect),即单独的可重定位代码或数据块的另一种可选命名。一个源代码例程通常会被编译到一个 csect 中,或将代码编入一个 csect,数据编入另一个 csect。如果一个控制区段有名字的话,它可以用来作为寻址该控制区段起始地址的符号。
四、存储空间分配
4.1 简单存储布局
从位置 0 开始的多个段按照一个跟着另一个的方式重定位,链接器或加载器依次检查各个模块,按顺序分配存储空间。
大部分架构的数据必须对齐于字边界,顺序分配到的空间的起始地址会严格对齐。

4.2 多类型段空间分配
目标格式具有多种段的类型,链接器需要将所有输入模块中相应的段组合在一起。比如下图按类型将text、data和 BSS 段分别归并,理解起来很容易,不同库文件包含的text、data、bss分类合并存放。

4.3 内容段与页对齐
链接过程中某些段的大小必须扩充为一个整页,相应的数据和 BSS 段的位置也要进行调整。
复杂的架构设计需要此对齐方式,对于部分简单芯片的应用,data和bss可以连续放置
4.4 公共块存储
在 ELF之前的 UNIX 目标文件只有文本、数据和 BSS 段,没有办法直接声明一个公共块。作为一个特殊技巧,链接器将未定义但具有非零初值的符号当作是公共块,而该值就是公共块的尺寸。
公共块的存储很重要,在链接脚本或者代码内常常需要使用一些符号存储地址信息,例如下面代码,使用服用保存了部分段信息
对于每一个公共块,它在输出文件的 BSS 段中定义了相应的符号,在每一个符号的后面分配所需要的空间

4.5 重复代码消除
某些编译系统中,C++编译器会由于虚函数表、模板和外部 inline 函数而产生大量的重复代码。链接器的方法是让编译器在每个目标文件中生成所有可能的重复代码,然后让链接器来识别和消除重复的代码。
以GCC为例,GNU 链接器是通过定义一个“link once”类型的区段(与公共块很相似)来解决这个模板的问题的。如果链接器看到诸如.gnu.linkonce.name 之类的区段名称,它会将第一个明确命名的此类区段保留下来并忽略其它冗余区段。
4.6 代码初始化和结束
代码执行前需要初始化和执行结束后需要去初始化代码,有很多办法可以在不需要链接器支持的情况下做到这一点,通常的方法是将每个目标文件中的初始化代码都放入一个匿名的例程中,然后将指向该例程的指针放置在名为.init(或其它相近名字)的段中。链接器将所有的.init 段串联在一起,因此就创建了一个指向所有这些初始化例程的指针列表。程序的初始化部分只需要遍历该列表依次调用所有例程即可。
简单的说,就是将构造、解构的初始化,去除初始化代码放到一个段上,串成指针列表,统一执行
但是应用程序级的构造函数运行顺序是不确定的,一个更简单的近似方法是设置多个用于初始化的段,按照依赖关系,顺序执行各个段
五、符号管理
链接器要处理各种类型的符号来进行模块之间的引用,符号包括:
- 当前模块中被定义(和可能被引用)全局符号。
- 在被模块中被引用但未被定义的全局符号(通常成为外部符号)。
- 段名称,通常被当作定义在段起始位置的全局符号。
- 非全局符号。
符号表通常以表项组成的数组形式来保存,并通过一个 hash 函数 来定位表项,或者是由指针组成的数组,并通过 hash 函数来索引,相同 hash 的表项以链表 的形式来组织。当需要在表中定位一个符号时,链接器根据符号名计算 hash 值,将该值用 桶的个数来取模,以定位某一个 hash 桶,然后遍历其中的符号链表来查找符号。
编译器为每一个程序变量生成名称、类型和位置,符号信息可以是一个隐式或显式的树结构。每个文件的最顶层是在最顶层定义的类型、变量和函数的列表,每一个内部是数据结构的子域,或函数内部定义的变量。
六、库
库文件由多个目标文件聚合而成,为了支持程序员查看并选择例程加入到他们自己的程序中,加载器和链接器在开始解析符号引用后,通过从库中选择例程来解析未定义符号,自动处理这个过程,。
UNIX 链接器库使用一种称为“archive”的格式,库的组成,首先是一个 archive 头部,然后交替着是文件头部和目标文件。
在每一个 archive 成员之前是一个 60 字节的头部,包含有:该成员名称,补齐到 16 个字符), 修改时间,由从 1970 年到当时的十进制秒数表示。 十进制数字表示的用户和组 ID。 一个八进制数表示的 UNIX 文件模式。 以字节为单位的十进制数表示的文件尺寸。
一个库文件在创建后,链接器还要能够对它进行搜索。库的搜索通常发生在链接器的。如果一个或多个库具有符号目录,那么链接器就将目录读入,然后根据链接器的符号表依次检查每个符号。
如果库之间存在循环依赖的时候经常需要将库列出多次。例如库A中的例程依赖一个库B中的例程,但是库B中的例程又依赖了库A中的另一个例程,那么从A扫描到B或从B扫描到A都无法找到所有需要的例程。 当这种循环依赖发生在三个或更多的库之间时情况会更加糟糕。告诉链接器去搜索 A B A 或 者 B A B,甚至有时为 A B C D A B C D,这种方法会严重降低效率,故在开发时也要尽量避免循环依赖。
大多数 C 程序会调用 printf 函数族中的例程来格式化输出数据。printf可以格式化各种类型的数据,包括浮点类型。这就意味着任何使用 printf 的程序都会将浮 点库链接进来,即便它根本不使用浮点数。
解决这个困境的方法就是弱外部符号,就是不会导致加载库成员的外部符号。如果该符号存在一个有效的定义,无论是从一个显式链接的文件还是普通的外部引用而被链接进来的库成员中,一个弱外部符号会被解析为一个普通的外部引用。
ELF 还添加了另一种弱符号,和弱引用(weak reference)等价的弱定义(weak defini tion)。“弱定义”定义了一个没有有效的普通定义的全局符号。
七、重定位
为了决定段的大小、符号定义、符号引用,并指出包含那些库模块、将这些段放置在输出地址空间的什么地方,链接器会将所有的输入文件进行扫描。扫描完成后的下一步就是链接过程的核心,重定位。链接器的第一次扫描会列出各个段的位置,并收集程序中全局符号与段相关的值。一旦链接器确定了每一个段的位置,它需要修改所有的相关存储地址以反映这个段的新位置。
第一遍扫描也会建立第五章中所讲的全局符号表。链接器还会将符号表中的地址解析为引用全局符号时所存储的地址。
几乎所有的现代计算机都具有硬件重定位,硬件重定位允许操作系统为每个进程从一个固定共知的位置开始分配独立的地址空间, 这就使程序容易加载,并且可以避免在一个地址空间中的程序错误破坏其它地址空间中的程序。但在嵌入式MCU领域,MCU通常不具备MMU(内存管理单元),无法实现硬件层面的地址转换。软件重定位通过链接脚本(linker script)在编译阶段直接完成地址映射,这种静态地址分配方式与硬件特性完美契合。
由于安全性,程序文件最好绑定在一起并且在链接时确定地址,这样它们在调试时静止不变而量产后仍能保持一致性。
链接器将一系列的输入文件合并成一个准备加载到特定地址的单一输出文件。当这个程序被加载后,所存储的那个地址是无效的,加载器必须重新定位被加载得程序以反应实际的加载地址。
加载时重定位相对链接时重定位更简单。在链接时,不同的地址需要根据段的大小和位置重定位为不同的位置。很多链接器将段重定位和符号重定位统一对待,这是因为它们将段当作是一种值为段基址的“伪符号”。这使得和段相关的重定位就成了和符号相关的重定位的特例。每个输入文件保留一个指针数组,指向全局符号表中的表项。每一个可重定位的目标文件都含有一个重定位表,其中是在文件中各个段里需要被重定位的一系列地址。链接器读入段的内容,处理重定位项,然后再解决整个段,通常就是将它写入到输出文件中。
每个目标文件都有两个重定位项集合,一个是文本段的,一个是数据段的(bss 段被定义为全 0,有其他方式可以默认reset,比如芯片电自动清0,因此没有什么需要重定位的)。
八、加载和重叠
8.1 基本加载
加载意味着所有的程序都被放到了一个已知的固定地址,并可以从这个地址被链接。简单的流程如下:
- 从目标文件中读取足够的头部信息,找出需要多少地址空间。
- 分配地址空间,如果目标代码的格式具有独立的段,那么就将地址空间按独立的段划分。
- 将程序读入地址空间的段中。
- 将程序末尾的 bss 段空间填充为 0,如果虚拟内存系统不自动这么做得话。
- 如果体系结构需要的话,创建一个堆栈段(stack segment)。
- 设置诸如程序参数和环境变量的其他运行时信息。
- 开始运行程序。
加载的过程,会伴随着实际数据的变迁,一个好方法就是尽量将代码位置无关化。许多 UNIX 系统是一个过程的数据地址当作这个过程的地址,并在这个地址上放置一个指向该过程代码的指针,如要调用一个过程,调用者就将该例程的数据地址加载到约定好的数据指针寄存器,然后从数据指针指向的位置中加载代码地址到一个寄存器,然后调用这个函数。例如ELF 可执行程序中的代码页组跟在数据页组后面,不论程序被加载到地址空间的什么位置,代码到数据的偏移量是不变的。链接器将可执行文件中寻址的所有全局变量的指针保存在它创建的全局偏移量表(Glob al Offset Table, GOT)中(每一个共享库拥有自己的 GOT,_GLOBAL_OFFSET_TABLE 是链接器可以理解的一个数值,在链接的时候链接器会将它替换为当前指令地址到 GOT 基地址之间的距离差值。
下图展示了即使程序被加载到普通地址空间的不同地址:
程序数据段中的静态数据与 GOT 直接的距离在链接时被固定了,代码就可以将 GOT 寄存器作为一个基址寄存器来引用局部静态数据。
九、共享库
随着各种开发语言编译器的发展,程序库成为编程的一部分。当程序调用一个标准过程时,如 sqrt()、printf()时,编译器都会主动的使用系统调用的共享库。
所有共享库基本上以相同的方式工作。在链接时,链接器搜索整个库以找到用于解决那些未定义的外部符号的模块。共享库是一个包含所有准备被映射的库代码和数据的可执行格式文件,典型格式如下:
- 文件头,a.out, COFF 或 ELF 头 (初始化例程,不总存在) 跳转表
- 代码
- 全局数据
- 私有数据
创建一个共享库需要以下几步:
- 确定库本身的代码和数据将加载位置。
- 彻底扫描输入的库识别出所有需要输出的代码符号。
- 创建一个跳转表,建立输出的符号的跳转关系。
- 如果在库的开头有一个初始化或加载例程,那么就编译或者汇编它。
- 创建共享库。运行链接器把所有内容都链接为一个大的可执行格式文件。
- 创建空占位库:空占位库模块既不包含代码也不包含数据,只包含符号定义。从刚刚建立的共享库中提取出需要的符号,针对输入库的符号调整 这些符号。为每一个库例程创建一个空占位例程。以跳转表中每一项的地址的形式定义每个文本全局变量,以共享库中实际地址的形式定义每个数据或 bss 全局变量。
任何共享库系统都需要有一种办法处理库的多个版本。当一个库被更新后,新版本相 对于之前版本而言在地址和调用上都有可能兼容或不兼容。这里提到了一个UNIX经典的版本命名规范:
版本号使用三位数字,例如x.x.x格式,第一个数字是大版本号,在每次发布一个不兼容的全新的库的时候才被改变。例如指定4.x.x版本库链接的程序不能使用 3.x.x 或 5.x.x 的库。
第二个数是通常是小版本号。在 Sun 系统上,每一个可执行程序所链接的库都至少需要比其指定的小版本号要大。例如,如果它链接的是 4.2.x,那么它就可以和 4.3.x 一起运行而 4.1.x 则不行。另一些系统将第二个数字当作第一个数字的扩展,这样的话使用 一个 4.2.x 的库链接的程序就只能和 4.2.x 的库一起运行。
第三个数字通常都被当作补丁级别。虽然任何的补丁级别都是可用的,可执行程序最好还是使用最高的有效补丁级别。