30、实战:手写一个极简链接器,理解核心流程
说实话,学编译原理的时候,很多人把注意力都放在前端——词法分析、语法分析、中间代码生成。但链接器这个环节,往往被当成「黑盒」跳过了。我以前也是这样,直到有一次在公司调试一个诡异的段错误,折腾了两天,最后发现是链接脚本里一个符号地址算错了。嗯,从那以后,我决定自己写一个极简链接器,把流程彻底吃透。
今天我们就来手写一个。目标不是造一个工业级的 ld,而是让你亲眼看到:目标文件怎么合并、符号怎么解析、重定位怎么完成。说白了,就是剥开链接器的外壳,看看里面到底在干什么。
30.1 链接器的核心任务
一个链接器,本质上就干三件事:
- 合并段:把多个目标文件的 .text、.data、.bss 等段合并到一起
- 符号解析:把每个目标文件里引用的外部符号,找到它的定义位置
- 重定位:修正代码里的地址引用,让它们指向合并后的正确位置
我习惯把链接器想象成一个「拼图工人」。你给他一堆碎片(目标文件),他按照图纸(链接脚本)把它们拼成完整的图案(可执行文件)。
核心理解:编译阶段生成的地址是「相对偏移」或「临时地址」,链接器负责把它们变成「最终绝对地址」。
30.2 我们需要的输入格式
为了简化,我们不处理 ELF 这种复杂格式。我们自己定义一种极简的目标文件格式,只保留最核心的信息。
每个目标文件包含:
- 段表:段名、起始地址(相对本文件)、大小
- 符号表:符号名、所在段、段内偏移
- 重定位表:需要修正的位置、引用的符号索引、修正类型
- 原始数据:段的二进制内容
举个例子,假设我们有两个源文件:
// a.c
extern int b;
int a = 10;
int main() {
return a + b;
}
// b.c
int b = 20;
编译后得到两个目标文件 a.o 和 b.o。我们的链接器要把它们合并成一个可执行文件。
30.3 数据结构设计
先定义核心数据结构。我个人习惯用 C 语言写这类工具,因为内存布局可控,调试方便。
// 段描述
typedef struct {
char name[16]; // 段名,如 ".text"
int size; // 段大小
int file_offset; // 在目标文件中的偏移
int addr; // 链接后的最终地址
} Section;
// 符号
typedef struct {
char name[32]; // 符号名
int section_idx; // 所在段索引(-1 表示未定义)
int offset; // 段内偏移
int value; // 最终地址(链接后计算)
} Symbol;
// 重定位条目
typedef struct {
int offset; // 在段内的偏移位置
int symbol_idx; // 引用的符号索引
int type; // 重定位类型:0=绝对地址,1=相对地址
} Relocation;
小提示:实际 ELF 的重定位类型有几十种,但我们只实现两种最常用的:绝对地址修正和相对地址修正。够用了。
30.4 核心流程:三步走
链接器的执行流程,我把它拆成三个清晰的步骤。你想想看,这其实和搬家很像:先清点物品(读取目标文件),再规划摆放位置(地址分配),最后把东西搬进去(重定位)。
第一步:读取并合并段
遍历所有输入的目标文件,把同名的段合并到一起。比如所有 .text 段按顺序拼接,所有 .data 段也按顺序拼接。
void merge_sections(ObjectFile *files, int nfiles) {
int text_offset = 0;
int data_offset = 0;
for (int i = 0; i < nfiles; i++) {
for (int j = 0; j < files[i].section_count; j++) {
Section *sec = &files[i].sections[j];
if (strcmp(sec->name, ".text") == 0) {
sec->addr = 0x1000 + text_offset; // 假设 .text 从 0x1000 开始
text_offset += sec->size;
} else if (strcmp(sec->name, ".data") == 0) {
sec->addr = 0x2000 + data_offset; // .data 从 0x2000 开始
data_offset += sec->size;
}
}
}
}
这里要注意:段地址的分配顺序很重要。我一般先放 .text,再放 .rodata,然后 .data,最后 .bss。这样布局符合大多数系统的内存映射习惯。
第二步:符号解析
把所有目标文件的符号表合并成一个全局符号表。如果同一个符号在多个文件中定义了,报错(多重定义)。如果引用了但没找到定义,也报错(未定义符号)。
int resolve_symbols(ObjectFile *files, int nfiles, Symbol *global_symtab) {
int sym_count = 0;
for (int i = 0; i < nfiles; i++) {
for (int j = 0; j < files[i].symbol_count; j++) {
Symbol *sym = &files[i].symbols[j];
// 查找是否已存在同名符号
int found = -1;
for (int k = 0; k < sym_count; k++) {
if (strcmp(global_symtab[k].name, sym->name) == 0) {
found = k;
break;
}
}
if (found == -1) {
// 新符号,加入全局表
global_symtab[sym_count++] = *sym;
// 计算最终地址:段基址 + 段内偏移
Section *sec = &files[i].sections[sym->section_idx];
global_symtab[sym_count-1].value = sec->addr + sym->offset;
} else {
// 已存在,检查是否重复定义
if (sym->section_idx != -1 && global_symtab[found].section_idx != -1) {
printf("Error: multiple definition of '%s'\n", sym->name);
return -1;
}
// 如果当前是定义,之前是声明,则更新
if (sym->section_idx != -1) {
global_symtab[found] = *sym;
Section *sec = &files[i].sections[sym->section_idx];
global_symtab[found].value = sec->addr + sym->offset;
}
}
}
}
return sym_count;
}
我曾经踩过的坑:符号解析时,一定要区分「声明」和「定义」。extern 声明不分配空间,它的 section_idx 是 -1。如果误把声明当成定义,会导致地址计算错误,运行时直接访问到错误的内存位置。
第三步:重定位
这是最核心的一步。遍历每个目标文件的重定位表,找到需要修正的位置,根据符号的最终地址,修改机器码。
void perform_relocation(ObjectFile *files, int nfiles, Symbol *global_symtab) {
for (int i = 0; i < nfiles; i++) {
for (int j = 0; j < files[i].reloc_count; j++) {
Relocation *rel = &files[i].relocs[j];
Symbol *sym = &global_symtab[rel->symbol_idx];
// 找到需要修正的段和位置
Section *sec = &files[i].sections[rel->section_idx];
int *patch_addr = (int *)(files[i].data + sec->file_offset + rel->offset);
if (rel->type == 0) {
// 绝对地址修正:直接写入符号的最终地址
*patch_addr = sym->value;
} else if (rel->type == 1) {
// 相对地址修正:计算相对偏移
// 公式:目标地址 - 当前指令地址 - 指令长度
int current_addr = sec->addr + rel->offset;
*patch_addr = sym->value - current_addr - 4; // 假设指令长度4字节
}
}
}
}
为什么相对地址要减 4?因为 x86 架构下,call/jmp 指令的偏移量是从下一条指令开始计算的。这个细节我当年调试了好久才明白。
30.5 完整流程的 SVG 示意图
下面这张图展示了整个链接过程的流水线。我建议你对照着代码看,会更清晰。
30.6 完整代码骨架
把上面三个步骤串起来,主函数大概长这样:
int main(int argc, char *argv[]) {
// 1. 读取所有目标文件
ObjectFile files[MAX_FILES];
int nfiles = read_object_files(argv + 1, files);
// 2. 合并段,分配地址
merge_sections(files, nfiles);
// 3. 符号解析
Symbol global_symtab[MAX_SYMBOLS];
int nsym = resolve_symbols(files, nfiles, global_symtab);
if (nsym < 0) {
fprintf(stderr, "Symbol resolution failed\n");
return 1;
}
// 4. 重定位
perform_relocation(files, nfiles, global_symtab);
// 5. 输出可执行文件
write_executable("a.out", files, nfiles);
printf("Linking completed successfully.\n");
return 0;
}
我的建议:如果你真想动手写一个,不要一开始就追求完整。先写一个只处理两个目标文件、只支持绝对地址修正的版本。跑通了,再逐步加功能。我当年就是这么干的,一周内就搞出了一个能用的玩具链接器。
30.7 常见问题与避坑
| 问题 | 原因 | 解决方法 |
|---|---|---|
| 符号多重定义 | 两个目标文件定义了同名全局变量 | 检查代码,使用 static 限制作用域 |
| 未定义符号 | 引用了外部函数/变量但没链接对应目标文件 | 检查链接命令行是否遗漏了 .o 文件 |
| 段错误 | 重定位时地址计算错误 | 打印每个符号的最终地址,逐条核对 |
| 函数调用跳错位置 | 相对地址修正时没考虑指令长度 | 记住:x86 相对偏移 = 目标 - 当前 - 指令长度 |
我曾经遇到过一个特别隐蔽的 bug:两个目标文件里都有 int global_var; 但没有初始化。我以为这是「弱符号」可以合并,结果链接器报了多重定义错误。后来查资料才知道,没有初始化的全局变量是强符号,多个强符号定义会冲突。正确的做法是用 extern 声明,或者加 -fcommon 编译选项。
30.8 小结
手写一个极简链接器,其实没那么神秘。核心就是三件事:合并段、解析符号、重定位。你只要把这三个步骤的代码写出来,就能亲眼看到目标文件是怎么变成可执行文件的。
我建议你找个周末,照着上面的思路自己写一遍。不用管 ELF 格式的细节,就用最简单的二进制文件格式。跑通的那一刻,你对链接器的理解会比看十篇文章都深刻。
记住:链接器不是黑盒,它只是一段按规则拼装数据的程序。你也能写出来。
公众号:蓝海资料掘金营,微信deep3321