摘要
连结器(Linker)是软体开发工具链中的重要组件,负责将目标档案(Object Files)、程式库及其他二进位组件结合,产生可执行档、共享程式库或新的可重定位目标档案。
在连结过程中,连结器必须处理符号解析(Symbol Resolution)、重定位(Relocation)、程式码与资料区段的配置、程式库相依性、程式进入点及作业系统载入程式所需的二进位结构。
连结器的发展与模组化程式设计密切相关。透过连结机制,开发者可以分别编译多个原始码档案,并在最后阶段将不同模组与程式库整合为完整程式,而不必将所有原始码视为单一编译单位。
本文首先说明连结器的定义、历史来源及其在编译流程中的地位,接着分析目标档案、符号表、符号解析与重定位机制,并讨论静态连结、动态连结、连结器脚本及连结阶段最佳化。此外,本文比较 Linux、macOS 与 Windows 的连结机制,说明连结器与应用程式二进位介面(ABI)、ELF、Mach-O 及 PE/COFF 格式之间的关系。
关键词: 连结器、编译器、目标档案、符号解析、重定位、静态连结、动态连结、ELF、Mach-O、PE/COFF、应用程式二进位介面、连结阶段最佳化。
一、什么是连结器?
1.1. 定义
连结器(Linker)是一种处理目标档案、程式库及相关二进位资料,并将其整合为最终连结产物的程式。
依据输出需求,连结产物可以是:
- 可执行档(Executable File)。
- 共享程式库(Shared Library)。
- 动态连结程式库(Dynamic-Link Library)。
- 可供后续连结的目标档案(Relocatable Object File)。
GNU Binutils 的《LD》文件对连结器的基本功能描述如下:
ld combines a number of object and archive files, relocates their data and ties up symbol references.
(中文译文:ld 将多个目标档案与封存档结合,对其中的资料进行重定位,并解析其符号参照关系。)
资料来源:GNU Binutils,LD,第一节〈Overview〉。[1]
从技术本质而言,连结器必须解决一个基本问题:
当程式的不同部分分别编译时,如何让它们正确地参照彼此,并形成可以共同运作的二进位程式?
1.2. 为什么需要连结器?
假设某个 C 语言程式由两个原始码档案构成。
**math.c:**
int add(int a, int b) {
return a + b;
}
**main.c:**
int add(int a, int b);
int mainvoid {
return add(20, 22) != 42;
}
可以分别编译:
gcc -c math.c -o math.o
gcc -c main.c -o main.o
得到:
math.o
main.o
其中,math.o 包含函式 add 的机器码。
main.o 则包含函式 main 的机器码,以及对 add 的呼叫。
然而,当 main.c 被独立编译时,编译器未必知道 add 在最终可执行档中的位置。
因此,目标档案必须保留足够的符号及重定位资讯,供后续连结使用。
执行:
gcc main.o math.o -o program
连结器便可确认 add 的定义位于 math.o,并依照目标格式的规则完成对应的符号参照与重定位。
这使程式能够由多个独立编译的模组组成。
值得注意的是,上述 gcc 指令使用的是编译器驱动程式(Compiler Driver)。它通常会自动加入必要的启动档案、程式库及连结参数,因此不完全等同于直接呼叫 ld。
1.3. 连结器在编译流程中的位置
传统编译流程可以简化表示如下:
原始码
main.c math.c
│ │
▼ ▼
编译与程式码产生
│ │
▼ ▼
main.o math.o
│ │
└─────┬──────┘
│
▼
连结器
Linker
│
▼
program
│
▼
作业系统载入
│
▼
程式执行
不同工具的职能如下:
| 组件 | 英文名称 | 主要功能 |
|---|---|---|
| 编译器 | Compiler | 将原始码转换为中介表示或机器码 |
| 组译器 | Assembler | 将组合语言转换为目标档案 |
| 连结器 | Linker | 整合目标档案并处理跨模组参照 |
| 载入器 | Loader | 将程式映像建立于记忆体并准备执行 |
| 动态连结器 | Dynamic Linker | 依平台规则处理动态程式库的载入与连结 |
实际工具链未必将这些阶段完全分开。
例如,编译器可以直接产生机器码,而不必先输出组合语言档案;某些编译器也可以内建部分或全部连结功能。
二、连结器的历史来源与发展
2.1. 从单一程式到模组化设计
在早期电脑系统中,程式的编写、记忆体配置与载入方式往往高度依赖特定硬体。
随着程式规模扩大,要求程式设计者手动计算每个函式与资料的最终记忆体位址,逐渐变得难以管理。
此外,不同程式经常需要使用相同的数学函式、输入输出功能及其他共用程式码。
如果每个程式都必须自行复制、调整及配置所有机器码,将造成开发与维护上的困难。
因此,目标程式的重定位、模组连结及程式库机制逐步形成。
历史上,连结载入器(Linking Loader)、连结器及载入器的功能并不一定彼此独立。有些系统会在载入程式时同时完成符号解析与重定位。
2.2. Unix 工具链与连结器
Unix 及 C 语言工具链的发展,使分离编译与连结的模式被广泛采用。
典型流程包括:
- 分别编译原始码。
- 产生目标档案。
- 将多个目标档案封装成程式库。
- 在最终阶段连结应用程式。
a.out、COFF 及 ELF 等目标档案格式,逐步提供机器码、资料、符号及重定位资讯的组织方式。
在 GNU 工具链中,ld 成为重要的连结器,并可透过相关后端机制支援多种硬体架构与档案格式。
2.3. 现代连结器的发展
现代大型软体可能包含成千上万个编译单位、大量程式库及庞大的除错资讯。
因此,连结器面临新的技术需求:
- 缩短连结时间。
- 支援不同处理器架构。
- 减少执行档大小。
- 移除未使用的程式码。
- 进行跨模组最佳化。
- 支援符号与除错资讯。
- 提供跨平台及交叉连结能力。
- 配合作业系统的安全及载入机制。
目前具有代表性的连结器包括:
| 连结器 | 主要用途 |
|---|---|
GNU ld |
GNU 工具链中的连结器 |
LLVM lld |
LLVM 专案的连结器 |
Apple ld64 |
Apple 平台的 Mach-O 连结 |
Microsoft link.exe |
Windows 平台的 PE/COFF 连结 |
LLVM LLD 透过不同的实作部分支援 ELF、PE/COFF、Mach-O 及 WebAssembly 等格式。[2]
三、目标档案与连结器需要处理的资料
3.1. 目标档案(Object File)
目标档案是编译过程中的重要二进位产物。
它可能包含:
- 机器指令。
- 已初始化资料。
- 未初始化资料的配置资讯。
- 符号表。
- 重定位纪录。
- 除错资讯。
- 其他与平台相关的中继资料。
例如:
gcc -c hello.c -o hello.o
hello.o 不一定可以直接执行,因为它可能仍然参照其他目标档案或程式库中的符号。
因此,连结器必须理解该档案使用的二进位格式。
常见格式包括:
| 格式 | 主要使用环境 | 技术特征 |
|---|---|---|
| ELF(Executable and Linkable Format) | Linux、部分 Unix 系统及嵌入式平台 | 节区、段、符号表与重定位 |
| Mach-O(Mach Object) | macOS、iOS 等 Apple 平台 | 标头、载入命令、段与节区 |
| COFF/PE(Common Object File Format/Portable Executable) | Windows | COFF 目标档案、PE 可执行映像及 DLL |
虽然三者具有部分相似的设计目的,但内部结构与规则并不相同。
3.2. 节区(Section)与段(Segment)
节区是用于组织目标档案内容的单位。
以 ELF 为例:
| 节区 | 典型内容 |
|---|---|
.text |
机器指令 |
.rodata |
唯读资料 |
.data |
已初始化且可写入的资料 |
.bss |
未初始化资料的记忆体配置资讯 |
.symtab |
符号表 |
.strtab |
字串表 |
.rela.text |
特定情况下与程式码有关的重定位纪录 |
段则主要用于描述执行映像中需要由载入器处理或映射至记忆体的区域。
在 ELF 中,节区与段可视为两种不同的档案组织视角:
- 连结视角(Linking View)。
- 执行视角(Execution View)。
一个段可以包含来自多个节区的内容。
ELF 规格对这两种视角及其资料结构均有明确说明。[3]
3.3. 符号表(Symbol Table)
符号表储存连结器或二进位分析工具需要识别的名称与相关资讯。
符号可能代表:
- 函式。
- 全域变数。
- 资料物件。
- 由连结器脚本定义的位置。
- 程式码或资料中的特定位址。
例如:
int shared_value = 10;
int get_valuevoid {
return shared_value;
}
目标档案可能包含 shared_value 与 get_value 的符号资讯。
这些资讯可以包括名称、值、大小、类型、系结属性及所在节区。
四、符号解析(Symbol Resolution)
4.1. 已定义符号与未定义符号
假设程式包含两个档案。
**provider.c:**
int calculate(int x) {
return x * 2;
}
**consumer.c:**
extern int calculate(int x);
int processvoid {
return calculate(21);
}
分别编译:
gcc -c provider.c -o provider.o
gcc -c consumer.c -o consumer.o
使用 nm:
nm provider.o
nm consumer.o
可能得到类似输出:
provider.o:
T calculate
consumer.o:
U calculate
T process
在常见的 nm 输出中:
T表示符号定义于对应的程式码区域。U表示目前目标档案中存在尚未定义的符号参照。
连结器的工作之一,就是将 consumer.o 中的 calculate 参照,与 provider.o 中相符的定义建立连结。
4.2. 符号的主要类型
以 ELF 为例,符号可依其系结性质分为不同类型。
| 类型 | 英文名称 | 说明 |
|---|---|---|
| 区域符号 | Local Symbol | 主要在所属目标档案内使用 |
| 全域符号 | Global Symbol | 可参与不同目标档案之间的符号解析 |
| 弱符号 | Weak Symbol | 具有特定优先顺序与解析规则的符号 |
| 未定义符号 | Undefined Symbol | 需要从其他单位取得定义的符号参照 |
符号解析规则与格式、ABI 及连结模式有关。
因此,不能假设 ELF、Mach-O 与 COFF 的弱符号机制完全一致。
4.3. 未定义符号错误
如果程式需要 calculate,但连结过程找不到合适的定义,可能出现:
undefined reference to `calculate`
常见原因包括:
- 缺少目标档案。
- 未加入必要的程式库。
- 符号名称不一致。
- 目标档案与架构不相容。
- 程式库搜寻顺序不符合预期。
- C++ 名称修饰或 ABI 不一致。
但某些共享程式库的连结模式允许保留部分未解析符号,待载入或执行时再处理。
是否允许此种行为,取决于平台与连结选项。
4.4. 重复定义符号
假设:
**a.c:**
int counter = 10;
**b.c:**
int counter = 20;
如果两者均产生相同名称的强全域定义,连结时通常会发生:
multiple definition of `counter`
实际处理方式仍取决于语言规则、符号性质及编译器选项。
五、重定位(Relocation)
5.1. 为什么需要重定位?
当原始码被分别编译时,编译器通常无法得知整个程式的最终记忆体配置。
某个函式可能呼叫另一个档案中的函式;某个指令也可能需要取得外部资料的位址。
因此,编译器或组译器会产生必要的重定位资讯。
连结器在确定最终配置后,依据这些资讯计算并修改相应的位址栏位或指令内容。
重定位的本质,是在确定符号与配置资讯后,完成原本尚无法确定的位址计算。
5.2. 相对位址的例子
假设连结器将两个函式配置于:
caller: 0x00001000
target: 0x00001800
如果呼叫指令使用相对位址,连结器必须依照架构规格计算所需位移。
但不能直接认为所有情况都是:
target - caller
因为某些指令以目前指令位址为基准,另一些则可能以指令结束位置或重定位栏位位置为基准。
此外,部分架构还可能要求位移缩放、位元栏位编码或范围检查。
5.3. 重定位运算的基本元素
在若干 ABI 规格中,常使用以下符号描述重定位公式:
| 符号 | 意义 |
|---|---|
| 被参照符号的值或位址 | |
| 加数(Addend) | |
| 重定位发生的位置 | |
| 载入基底位址,适用于特定重定位类型 |
相对位址重定位可能使用:
绝对位址重定位可能使用:
但这些仅是常见公式。
真正采用的运算方式、位元宽度、符号延伸与溢位检查,必须依据处理器及 ABI 的具体重定位类型判定。
5.4. 重定位纪录
ELF 的重定位资讯通常采用两种形式:
REL
加数可能保存在需要修改的原始位置。
RELA
加数明确记录于重定位项目中。
重定位纪录通常包含:
- 需要修正的位置。
- 相关符号。
- 重定位类型。
- 必要时使用的加数。
不同处理器具有不同的重定位类型。
例如,x86-64 与 AArch64 的指令编码方式不同,因此其重定位种类及计算规则也不相同。
5.5. 连结时重定位与载入时重定位
重定位不一定全部在建立执行档时完成。
连结时重定位(Link-time Relocation)
由连结器在产生二进位输出时完成。
载入时重定位(Load-time Relocation)
由载入器或动态连结器在程式载入记忆体时完成。
在部分系统中,符号解析或相关连结工作亦可能延后至第一次使用特定函式时。
这种工作分配是理解动态连结的重要基础。
六、静态连结(Static Linking)
6.1. 基本概念
静态连结是指在建立程式时,从静态程式库中选取必要的机器码及资料,并将其整合到连结产物中。
Unix 系统上的静态程式库通常使用 .a 副档名。
Windows 上的 .lib 则可能是静态程式库,也可能是用于 DLL 的汇入程式库(Import Library)。
因此,副档名相同不代表其内容或用途完全相同。
6.2. 建立静态程式库
例如:
gcc -c math.c -o math.o
ar rcs libmath.a math.o
再进行连结:
gcc main.o ./libmath.a -o program
连结器可以从封存档中选取符合未解析符号需求的目标档案成员。
因此,静态连结并不必然将整个程式库的所有内容复制至执行档。
实际选取与移除规则取决于连结器和相关最佳化选项。
6.3. 静态连结的特征
可能的优点包括:
- 减少执行时对部分外部程式库的相依性。
- 便于在特定环境部署单一二进位程式。
- 适用于部分嵌入式或精简系统。
可能的限制包括:
- 执行档体积增加。
- 多个程式可能各自包含相同程式库的程式码。
- 更新程式库可能需要重新连结与部署。
- 并不保证完全不依赖作业系统或执行阶段环境。
6.4. 程式库搜寻顺序
在 GNU ld 的传统封存档搜寻模式中,目标档案与程式库在命令列上的顺序可能影响结果。
例如:
gcc main.o -L. -lmath -o program
main.o 的未解析符号可以促使连结器从后方的 libmath.a 中选取适当成员。
但:
gcc -L. -lmath main.o -o program
在采用传统单次封存档搜寻规则的情况下,可能无法得到预期结果。
GNU ld 文件明确说明,封存档通常在命令列出现的位置被搜寻,而不是在后续每次出现未解析符号时都自动重新搜寻。[1]
不同连结器或选项可能采用其他处理策略。
七、动态连结(Dynamic Linking)
7.1. 定义
动态连结允许程式在载入或执行阶段,依照平台规定使用外部动态程式库中的程式码与资料。
常见形式:
| 作业系统 | 动态程式库 |
|---|---|
| Linux | .so |
| macOS | .dylib |
| Windows | .dll |
在这种模式中,建置阶段的连结器通常会记录程式库相依性,以及执行阶段完成连结所需的资讯。
7.2. Linux 范例
建立共享程式库:
gcc -fPIC -shared math.c -o libmath.so
连结主程式:
gcc main.o -L. -lmath \
-Wl,-rpath,'$ORIGIN' \
-o program
在支援该机制的 ELF 载入环境中,$ORIGIN 可用于表示与程式所在位置相关的路径。
实际路径搜寻规则仍取决于系统与载入环境。
7.3. 建置阶段连结器与动态连结器
两者虽然都可能被称为 Linker,但其运作时间与工作内容不同。
| 项目 | 建置阶段连结器 | 动态连结器 |
|---|---|---|
| 运作时间 | 建立二进位程式时 | 载入或执行程式时 |
| 主要输入 | 目标档案、程式库与连结参数 | 执行映像、动态程式库与载入资讯 |
| 主要工作 | 组合、符号解析、配置及重定位 | 载入相依程式库并完成必要的动态解析 |
| 代表工具 | ld、ld.lld、ld64、link.exe |
ld.so、dyld 等平台机制 |
Windows 的 DLL 载入及汇入表解析主要由作业系统的载入机制处理,并不完全对应 Linux 的 ld.so 模型。
7.4. 动态连结的重要结构
在 ELF 系统中,动态连结可能涉及:
- 动态符号表(Dynamic Symbol Table)。
- 程式库相依资讯。
- 动态重定位纪录。
- 全域偏移表(Global Offset Table,GOT)。
- 程序连结表(Procedure Linkage Table,PLT)。
- 立即或延迟符号解析。
GOT 与 PLT 是许多 ELF 实作中的重要机制。
然而,并非所有动态函式呼叫都必须使用完全相同的 GOT/PLT 流程。
Mach-O 与 PE 亦具有自己的动态连结结构,不应直接套用 ELF 的全部术语。
7.5. 优点与限制
动态连结可能带来:
- 多个行程共用同一程式库的可共享记忆体页面。
- 减少主程式执行档的大小。
- 在 ABI 相容的条件下更新程式库。
- 支援模组化与扩充式程式架构。
但也会引入:
- 程式库遗失。
- 版本不相容。
- 符号冲突。
- ABI 不相容。
- 程式库搜寻路径问题。
- 不可信程式库载入所造成的安全风险。
八、连结器脚本(Linker Script)与记忆体配置
8.1. 什么是连结器脚本?
连结器脚本是描述或控制连结器如何配置节区、位址、记忆体区域及相关符号的设定档案。
GNU ld 文件指出:
Every link is controlled by a linker script.
(中文译文:每一次连结作业都由连结器脚本控制。)
在 GNU ld 中,如果没有提供自订脚本,连结器可以使用符合目前连结模式的预设脚本。[1]
8.2. 基本范例
ENTRYstart
SECTIONS
{
. = 0x10000;
.text : {
*(.text)
*(.text.*)
}
.rodata : {
*(.rodata)
*(.rodata.*)
}
.data : {
*(.data)
*(.data.*)
}
.bss : {
*(.bss)
*(.bss.*)
*COMMON
}
}
这是用于说明 GNU ld 语法的简化范例,不代表所有平台都能直接使用的完整连结器脚本。
| 语法 | 功能 |
|---|---|
ENTRY |
指定程式进入点符号 |
SECTIONS |
定义输出节区的配置 |
. |
位置计数器(Location Counter) |
.text |
配置机器指令 |
.rodata |
配置唯读资料 |
.data |
配置已初始化资料 |
.bss |
配置未初始化资料所需空间 |
实际的可执行映像仍须符合目标系统对段、对齐、记忆体权限及 ABI 的要求。
8.3. 执行位址与载入位址
在部分嵌入式系统中,必须区分:
虚拟记忆体位址(Virtual Memory Address,VMA)
指程式执行时使用的位址。
载入记忆体位址(Load Memory Address,LMA)
指资料原先存放或被载入的位置。
例如,韧体可能将已初始化资料保存在 Flash 中,并于启动时复制至 RAM。
连结器脚本可以定义相应的位址与符号,供启动程式使用。
但资料复制的实际工作并非由连结器在执行阶段自动完成,而是由启动程式或载入机制负责。
8.4. 在作业系统与嵌入式系统中的用途
连结器脚本特别常见于:
- 作业系统核心。
- 开机载入程式(Bootloader)。
- 韧体(Firmware)。
- 裸机程式(Bare-metal Program)。
- 记忆体配置受到严格限制的系统。
- 自订可执行映像的工具链。
在这些环境中,连结器所决定的记忆体配置往往直接影响程式能否正确启动。
九、Linux、macOS 与 Windows 的连结机制
9.1. Linux:ELF
Linux 常使用 ELF 作为目标档案与可执行档格式。
连结器必须依照 ELF 及目标架构的 ABI,处理:
- 节区与段。
- 符号表。
- 重定位纪录。
- 动态连结资料。
- 程式进入点。
- 程式标头表。
GNU ld 与 LLVM ld.lld 是常见的 ELF 连结器。
9.2. macOS:Mach-O
macOS 与其他 Apple 平台广泛使用 Mach-O 格式。
Mach-O 连结器需要建立或处理:
- Mach 标头(Mach Header)。
- 载入命令(Load Commands)。
- 段与节区。
- 符号及重定位资讯。
- 动态连结相关资料。
- 平台要求的其他中继资料。
Apple 使用 ld64 作为重要连结器,LLVM LLD 也具有 Mach-O 连结实作。[4]
此外,Mach-O 结构正确并不必然代表程式可以执行。
Apple 平台还可能要求程式码签署、符合平台安全政策,以及使用正确的目标平台资讯。
9.3. Windows:PE/COFF
Windows 通常使用 COFF 作为目标档案格式,使用 PE 作为 EXE 与 DLL 等执行映像的格式。
连结器需要处理:
- COFF 符号。
- COFF 重定位。
- 节区配置。
- 汇入表(Import Table)。
- 汇出表(Export Table)。
- 基底重定位(Base Relocation)。
- PE 标头及资料目录。
Microsoft 的 PE/COFF 规格对上述结构有正式说明。[5]
常见连结器包括 Microsoft link.exe 与 LLVM lld-link。
9.4. 三种格式比较
| 比较项目 | ELF | Mach-O | PE/COFF |
|---|---|---|---|
| 代表平台 | Linux | macOS、Apple 平台 | Windows |
| 主要结构 | 节区、段、各种标头 | 段、节区、载入命令 | 标头、节区、资料目录 |
| 符号机制 | ELF 符号表 | Mach-O 符号及动态连结资讯 | COFF 符号、汇入与汇出 |
| 重定位 | ELF 与架构 ABI | Mach-O 与目标架构规则 | COFF 重定位与 PE 基底重定位 |
| 常见连结器 | GNU ld、LLD | ld64、LLD Mach-O | link.exe、lld-link |
| 动态载入 | ELF 载入机制 | dyld | Windows Loader |
此表仅供概念比较。三种格式均具有更复杂的版本、架构及平台差异。
十、连结器的最佳化技术
10.1. 移除未使用的节区
连结器可以在符合规则的情况下,移除最终程式不需要的节区。
在 GNU ELF 工具链中,可以使用:
gcc -O2 -ffunction-sections -fdata-sections \
-Wl,--gc-sections \
main.c math.c -o program
其中:
-ffunction-sections:将适当的函式放入独立节区。-fdata-sections:将适当的资料物件放入独立节区。--gc-sections:要求连结器执行节区垃圾回收。
这些机制可以减少未使用程式码及资料占用的空间。
但部分节区可能受到进入点、保留规则或执行阶段中继资料要求的保护,不能任意删除。
10.2. 相同程式码折叠(Identical Code Folding)
某些连结器具有相同程式码折叠(Identical Code Folding,ICF)功能。
此技术可将符合条件的等效程式码节区合并,以减少最终二进位档案的大小。
然而,即使两个函式具有相同的机器码,其位址是否可被合并,仍可能涉及程式可观察行为或 ABI 的限制。
因此,ICF 必须遵守相应的安全与正确性要求。
10.3. 连结阶段最佳化(Link-Time Optimization)
连结阶段最佳化(LTO)允许编译器在跨越多个编译单位的范围内分析与改善程式。
这类技术通常需要在目标档案或其他中间产物中保留可供后续最佳化的程式表示。
例如:
gcc -O2 -flto -c math.c -o math.o
gcc -O2 -flto -c main.c -o main.o
gcc -O2 -flto main.o math.o -o program
可能进行的最佳化包括:
- 跨模组函式内联。
- 常数传播。
- 未使用函式消除。
- 程式码特化。
- 控制流程简化。
值得注意的是,LTO 通常涉及编译器与连结器之间的协作,并不表示所有分析及最佳化工作都由连结器本身独立完成。
10.4. 增量连结(Incremental Linking)
增量连结旨在重用先前连结的部分结果,以避免在输入只发生少量变化时重新执行全部工作。
这对于大型软体专案可能具有重要的建置效能价值。
但增量连结的支援程度,以及它与其他最佳化技术的相容性,依连结器而异。
十一、常见连结错误
11.1. 未定义符号
例如:
undefined reference to `foo`
可能原因包括缺少目标档案、程式库或符号名称不一致。
可使用:
nm file.o
nm library.a
检查符号定义及参照。
11.2. 重复定义
例如:
multiple definition of `counter`
需要检查符号是否在多个编译单位中被重复定义,以及其系结方式是否正确。
11.3. 架构不相容
ARM64 程式不能直接将 x86-64 机器码当作相同架构的目标档案进行一般连结。
连结器必须检查目标架构、档案格式及相关 ABI。
11.4. 重定位超出范围
部分指令所能表示的跳转位移具有固定范围。
如果目标位址超出该范围,连结器可能报告重定位溢位或超出范围。
在部分架构与连结器中,可以透过跳板程式码(Thunk)或转接段(Veneer)解决特定情况。
但并非所有超出范围的重定位都能自动处理。
11.5. 动态程式库无法载入
程式可能已成功连结,却在执行时找不到必要的共享程式库。
这种情况未必是建置阶段连结器的错误,也可能与动态载入器的搜寻路径或程式库版本有关。
分析时应先确认错误发生的阶段。
十二、实际操作与验证
12.1. 建立目标档案
建立 math.c:
int add(int a, int b) {
return a + b;
}
建立 main.c:
int add(int a, int b);
int mainvoid {
return add(20, 22) != 42;
}
分别编译:
gcc -O0 -c math.c -o math.o
gcc -O0 -c main.c -o main.o
12.2. 检查符号
nm math.o
nm main.o
观察 math.o 是否定义 add,以及 main.o 是否具有对 add 的未定义参照。
12.3. 检查重定位
在 ELF 平台:
readelf -r main.o
输出可能包含与 add 呼叫相关的重定位纪录。
具体类型依处理器架构与编译器而异。
12.4. 完成连结
gcc main.o math.o -o program
接着:
nm program
objdump -d program
可以比较目标档案与最终执行档中的符号及机器码。
如果使用 Mach-O 或 PE/COFF,应改用支援该格式的检查工具,而非直接假设 readelf 适用。
12.5. 产生连结映射档
在支援相关 GNU 连结器选项的环境中:
gcc main.o math.o \
-Wl,-Map=program.map \
-o program
产生的 program.map 可以协助观察:
- 连结后的节区配置。
- 目标档案与程式库的配置情况。
- 符号位置。
- 记忆体区域与大小。
这类资讯对于韧体、核心及自订执行环境的开发尤其重要。
十三、每个编译器都必须使用独立连结器吗?
答案是否定的。
从系统架构的角度来看,编译器可以自行完成传统上属于连结器的工作。
例如:
- 分析所有程式单位。
- 建立全程式符号资讯。
- 产生机器码。
- 配置程式码与资料。
- 计算及修正位址参照。
- 建立执行档标头。
- 输出符合目标平台规范的二进位档案。
在这种设计下,编译器不一定需要呼叫外部 ld、lld 或其他连结器程式。
但是,系统仍然需要完成符号解析、位址配置及重定位等连结功能。
另一种设计是编译器先完成部分连结工作,再将特定任务交由外部连结器处理。
因此,必须区分两个概念:
- 连结器作为独立工具。
- 连结作为程式建置过程中的一组必要功能。
独立工具不是所有编译器架构的强制要求,而相关连结功能是否必要,则取决于程式输出形式与编译系统设计。
十四、结论
连结器是现代软体工具链中的重要组件,负责将独立编译的目标档案及程式库结合为完整的二进位产物。
符号解析与重定位是连结器的两项基础工作。除此之外,连结器还必须处理程式码及资料配置、程式库相依性、进入点、二进位档案结构,以及部分最佳化任务。
从早期程式载入机制到现代 GNU ld、LLVM LLD、Apple ld64 及 Microsoft link.exe,连结技术随着软体规模、处理器架构与作业系统的发展而日益复杂。
理解连结器,亦必须理解目标档案、符号表、重定位、ABI 及可执行档格式之间的关系。
对编译器设计而言,连结机制的重要性不仅在于产生最终执行档,更在于提供跨模组组合与二进位互通的能力。
因此,连结器不应只被理解为编译流程中的最后一个命令,而应被视为连接程式码产生、二进位格式与程式执行环境的重要技术环节。
附录一:技术术语
| 中文术语 | 英文术语 | 说明 |
|---|---|---|
| 连结器 | Linker | 整合目标档案并产生连结产物的工具 |
| 目标档案 | Object File | 含有机器码、资料及连结资讯的二进位档案 |
| 符号解析 | Symbol Resolution | 确定符号参照所对应的定义 |
| 重定位 | Relocation | 计算并修正程式码或资料中的位址参照 |
| 符号表 | Symbol Table | 储存符号名称及相关资讯的结构 |
| 静态连结 | Static Linking | 在建置时将所需程式码整合至连结产物 |
| 动态连结 | Dynamic Linking | 在载入或执行阶段完成相应的程式库连结 |
| 载入器 | Loader | 在记忆体中建立程式执行映像的组件 |
| 动态连结器 | Dynamic Linker | 执行阶段处理动态程式库连结的组件 |
| 连结器脚本 | Linker Script | 控制节区及位址配置的设定档案 |
| 进入点 | Entry Point | 程式开始执行的位置 |
| 增量连结 | Incremental Linking | 重用部分先前结果以缩短连结时间 |
| 连结阶段最佳化 | Link-Time Optimization | 于连结阶段进行跨编译单位最佳化 |
| 应用程式二进位介面 | Application Binary Interface | 定义二进位组件互通规则的介面 |
| 静态程式库 | Static Library | 用于建置阶段连结的目标档案集合 |
| 共享程式库 | Shared Library | 可由动态连结机制使用的程式库 |
| 位置无关程式码 | Position-Independent Code | 可依相应机制配置于不同载入位址的程式码 |
附录二:参考文献
A. 连结器官方文件
[1] GNU Binutils, LD: The GNU Linker.
GNU ld 的官方文件,涵盖符号解析、封存档搜寻、重定位、连结器脚本及连结参数。
https://sourceware.org/binutils/docs/ld.html
[2] LLVM Project, LLD – The LLVM Linker.
LLVM LLD 的官方文件,介绍连结器的架构与 ELF、COFF、Mach-O 等格式支援。
[3] LLVM Project, The ELF, COFF and Wasm Linkers.
介绍 LLD 不同连结器实作的设计与技术背景。
https://releases.llvm.org/9.0.0/tools/lld/docs/NewLLD.html
[4] LLVM Project, Linker Script Implementation Notes and Policy.
说明 LLD 对 GNU 连结器脚本的支援与部分实作差异。
https://lld.llvm.org/ELF/linker_script.html
B. 二进位格式与 ABI 规格
[5] System V Application Binary Interface, Executable and Linking Format.
ELF 格式的重要规格文件,说明节区、段、符号表、重定位及程式载入相关结构。
https://refspecs.linuxfoundation.org/elf/elf.pdf
[6] Microsoft, Portable Executable and Common Object File Format Specification.
微软的 PE/COFF 官方规格,包含标头、符号、重定位、汇入表及汇出表等资料结构。
https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
[7] Apple, Mach-O Documentation.
Apple 平台 Mach-O 二进位格式的相关技术文件。
https://developer.apple.com/documentation/kernel/mach-o
[8] LLVM Project, Mach-O LLD Port.
LLVM LLD 中 Mach-O 连结器实作的技术文件。
https://lld.llvm.org/MachO/index.html
C. 专书与延伸研究
[9] John R. Levine, Linkers and Loaders, Morgan Kaufmann, 1999.
专门研究连结器、载入器、符号解析、重定位及程式库机制的经典著作。
[10] David A. Wheeler, Program Library HOWTO.
介绍 Unix 系统中静态程式库、共享程式库及相关使用方式。
[11] Alfred V. Aho, Monica S. Lam, Ravi Sethi, Jeffrey D. Ullman, Compilers: Principles, Techniques, and Tools, Second Edition, Pearson, 2006.
探讨编译器架构、中介表示法、最佳化、程式码产生及相关建置技术的基础著作。
注释
- GNU Binutils, LD: The GNU Linker,〈Overview〉、〈Linker Scripts〉及程式库搜寻相关章节。https://sourceware.org/binutils/docs/ld.html ↩ ↩² ↩³
- LLVM Project, LLD – The LLVM Linker. https://lld.llvm.org/ ↩
- System V Application Binary Interface, Executable and Linking Format,关于连结视角、执行视角、节区及程式标头的说明。https://refspecs.linuxfoundation.org/elf/elf.pdf ↩
- LLVM Project, Mach-O LLD Port. https://lld.llvm.org/MachO/index.html ↩
- Microsoft, PE Format,〈COFF Relocations〉、〈Import Table〉、〈Export Table〉及〈Base Relocations〉。https://learn.microsoft.com/en-us/windows/win32/debug/pe-format ↩