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 示意图

下面这张图展示了整个链接过程的流水线。我建议你对照着代码看,会更清晰。

极简链接器核心流程 a.o (目标文件) b.o (目标文件) c.o (目标文件) 第一步:合并段 将所有 .text 合并,所有 .data 合并,按顺序排列 第二步:符号解析 建立全局符号表,计算每个符号的最终地址 第三步:重定位 修正所有地址引用,生成最终可执行文件 a.out 可执行文件 输入:多个目标文件 输出:一个可执行文件

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