现代链接器工程深度实战:从符号解析、段合并到 mold/wild 的并行化革命

执行摘要:当编译被 ccache、distcc、分布式构建农场压到秒级之后,构建流水线的串行尾部只剩一件事——链接。链接器的本质工作其实只有三件:符号解析、段合并与布局、重定位与应用。所有现代链接器的性能战争,都围绕"这三件事能不能并行、能不能一遍过、能不能字节确定性"展开。本文从 ELF 的符号语义讲起,拆穿静态库顺序依赖、垃圾段回收(GC)、相同代码折叠(ICF)与重定位松弛(relaxation)的实现原理,给出一个可运行的符号解析器骨架,最后讨论 mold 与 wild 如何用数据并行重塑链接,并列出生产环境六个真实会咬人的坑。

一、先算笔账:链接为什么突然成了瓶颈

二十年前,大型 C++ 项目的构建时间几乎全部花在编译上:预处理、模板实例化、寄存器分配,每个编译单元几秒。链接只是把几十个 .o 拼起来,占比不到 5%。

今天这个比例被彻底扭转:

  • 编译端被彻底并行化——make -j$(nproc)、ccache、分布式构建(Bazel/Buildbarn)把墙钟时间压到接近单个编译单元的耗时;
  • 而链接端长期是单线程、串行、单趟不可回退的。

与此同时,链接器要干的活却暴涨:

  • C++ 模板与头文件内联让 .o 体积爆炸,一个中等项目动辄数万个 COMDAT 段;
  • LTO/ThinLTO 把本该在编译期做的过程间优化搬到了链接期,链接器要跑一遍完整的优化管线;
  • 静态链接的发行包要求 -ffunction-sections -fdata-sections --gc-sections,多了一整轮可达性分析。

于是出现了典型的构建时间分布:并行的编译部分 20 秒,串行的链接部分 40 秒。阿姆达尔定律在这里毫不留情——你把编译优化到极致,总时间的下限仍然是那 40 秒。

这就是为什么 2021 年之后 mold、lld 全面普及,2025 年又冒出用 Rust 写的 wild 链接器。它们不是"更快一点的 ld",而是把链接器当成一个数据并行问题重新设计。

二、链接器的三件本质工作

不管哪个链接器,抽象模型都一样:

阶段输入核心问题复杂度
符号解析各 .o 的符号表谁定义、谁引用、冲突怎么办O(符号数)
段合并与布局输入段哪些段留下、排在哪里O(段数)
重定位与应用重定位表 + 布局把占位符填成真实地址O(重定位数)

关键洞察是:这三件事里没有一件本质上需要串行。串行是历史包袱,不是算法必然。

三、符号解析:弱符号、Common 与静态库顺序

3.1 符号决议的真实规则

ELF 里符号不只是"有/没有",它有绑定属性与类型:

属性含义决议规则
GLOBAL(强)普通全局定义两个强定义同名 → 重复定义错误
WEAK弱定义强定义覆盖弱定义;多个弱定义取第一个
COMMON未初始化的 tentative 定义按最大 size 合并,属于"半个定义"
COMDATC++ 模板/内联函数生成的组按组去重,签名相同即丢弃副本

-fno-common 从 GCC 10 起成为默认,正是因为 COMMON 符号的"半个定义"语义会让同一变量在动态库与可执行文件里悄悄分裂成两个实体——这是经典的"改了配置不生效"类 bug 的源头。

3.2 静态库为什么有顺序要求

这是每个 C/C++ 工程师都踩过的坑:undefined reference to 'pthread_create',明明 -lpthread 写了。

原因不是路径,而是归档库(.a)的惰性提取语义:链接器按命令行从左到右扫描,只在一个 archive 中的成员能解析"当前尚未解析的符号"时才把它提取进来。扫过之后就不再回头。

# 最小符号解析器:演示强/弱符号决议与 archive 惰性提取
# 输入:一组目标文件与归档库;输出:解析结果或错误

class Symbol:
    __slots__ = ('name', 'binding', 'size', 'owner')
    def __init__(self, name, binding, size=0, owner=None):
        self.name, self.binding, self.size, self.owner = name, binding, size, owner

STRONG, WEAK, COMMON = 'GLOBAL', 'WEAK', 'COMMON'

def resolve(inputs):
    """inputs: [(kind, name, members)] kind in {'object','archive'}"""
    defined = {}       # name -> Symbol
    undefined = set()  # 尚未解析的引用
    extracted = set()  # 已从归档库提取的成员

    def define(sym):
        old = defined.get(sym.name)
        if old is None:
            defined[sym.name] = sym
            return
        # 强定义优先级最高;否则保留先出现者
        if old.binding != STRONG and sym.binding == STRONG:
            defined[sym.name] = sym
        elif old.binding == COMMON and sym.binding != COMMON:
            defined[sym.name] = sym
        elif old.binding == COMMON and sym.binding == COMMON:
            # COMMON 取最大尺寸合并
            old.size = max(old.size, sym.size)

    for kind, name, members in inputs:
        if kind == 'object':
            # 目标文件无条件参与链接
            for s in members:
                if s.binding is None:
                    undefined.add(s.name)
                else:
                    define(s); undefined.discard(s.name)
            continue

        # archive:反复扫描直到一轮内没有新成员被提取(模拟 --start-group)
        progress = True
        while progress:
            progress = False
            for m in members:
                if m['name'] in extracted:
                    continue
                # 惰性提取条件:能解析至少一个当前未定义符号
                provides = {s.name for s in m['defs']} & undefined
                if provides or (m['name'].startswith('force:')):
                    extracted.add(m['name'])
                    progress = True
                    for s in m['defs']:
                        define(s); undefined.discard(s.name)
                    for d in m['refs']:
                        if d not in defined:
                            undefined.add(d)

    if undefined - set(defined):
        return None, sorted(undefined - set(defined))
    return defined, []

这段代码里最关键的是那个 while progress 循环:它精确对应 --start-group ... --end-group 的语义。GNU ld 默认不回头,所以循环依赖要靠 group 或重复列出库;而 mold、lld 这类现代链接器把"反复扫描"作为默认行为,从根上消灭了这类链接错误。这是一个典型的"用一点额外计算换取开发者心智负担下降"的取舍。

四、段布局与 GC:可达性标记与相同代码折叠

4.1 --gc-sections 是一次图遍历

开启 -ffunction-sections -fdata-sections 后,每个函数/变量落在独立段里,链接器就能做一次 mark-sweep:

def gc_sections(sections, roots):
    """roots: .init_array / 导出符号 / 链接脚本 KEEP() 标记的段"""
    alive, stack = set(), list(roots)
    while stack:
        cur = stack.pop()
        if cur in alive:
            continue
        alive.add(cur)
        # 重定位引用的目标段也是活的——这是 mark 阶段的精髓
        for reloc in sections[cur].relocs:
            tgt = reloc.target_section
            if tgt and tgt not in alive:
                stack.append(tgt)
    return alive

注意这里重定位表就是依赖边。这解释了 --gc-sections 一个反直觉的行为:一个函数被函数指针数组引用时,如果那个数组本身不可达,函数照样会被删掉——即使运行时逻辑上"会用得到"。这就是为什么链接脚本里到处是 KEEP(*(.init_array))。

4.2 ICF:把相同的函数合并成一个

C++ 模板实例化会产生大量机器码完全相同的函数。--icf=all 的做法是:

  1. 按内容哈希分桶(先比 size、再比 section 内容哈希);
  2. 桶内逐字节比对(避免哈希碰撞导致错误合并);
  3. 安全检查(Safe ICF):地址有别的函数不参与合并——例如被 == 比较过函数指针、被 --icf=none 显式排除、或带有不同 .eh_frame / 调试信息的段。

mold 与 lld 都默认做 safe ICF,收益通常在二进制体积上(常见 5%~20%),而非链接时间。

五、重定位:地址计算的真相

重定位条目是一张"这里要填什么、怎么算"的指令表:

// 一个极简的重定位应用器(x86-64 常见类型子集)
static void apply_reloc(uint8_t *out, const Reloc *r, uint64_t S, uint64_t P, uint64_t G) {
    // S = 符号地址, P = 被重定位处地址, G = GOT 中该符号的槽位地址
    switch (r->type) {
    case R_X86_64_64:        write64(out + r->offset, S + r->addend); break;
    case R_X86_64_PC32:      write32(out + r->offset, S + r->addend - P); break;
    case R_X86_64_PLT32:     // 可能退化为直接调用(见下)
                             write32(out + r->offset, S + r->addend - P); break;
    case R_X86_64_GOTPCRELX: // GOT 加载
                             write32(out + r->offset, G + r->addend - P); break;
    case R_X86_64_TPOFF32:   // local-exec TLS:静态模块的固定偏移
                             write32(out + r->offset, S + r->addend - tls_base); break;
    default:                 die("unsupported relocation %u", r->type);
    }
}

真正有意思的是 linker relaxation。编译器生成 call foo@PLT 或 mov foo@GOTPCREL(%rip), %rax 时,并不知道 foo 最终是否在同一个模块里。链接器如果确定符号是本地的、且偏移在 32 位范围内,就可以把 GOT 间接加载改写成 lea foo(%rip), %rax,把 PLT 跳转改写成直接 call——省掉一次内存间接和一次 PLT 跳板。

这一步必须做,否则 R_X86_64_GOTPCRELX / R_X86_64_REX_GOTPCRELX 这些专门为松弛设计的重定位类型就白给了。它也解释了为什么"换个链接器,二进制大小就变了":不是布局变了,是重定位被重写成了更短的指令序列。

TLS 是另一条支线:local-exec(TPOFF32,最快,仅主程序)、initial-exec(GOTTPOFF)、general-dynamic(TLSGD,动态加载库必须)。链接器会在能静态决策时把后者降级为前者——用错模型的代价是每次变量访问多一次函数调用(__tls_get_addr)。

六、并行化:mold 与 wild 如何把链接拆开

mold 的设计可以概括成三句话:

  1. 输入先行并行解析:所有 .o / .a 并发读取、并发解析符号表,插入分片(sharded)并发哈希表;
  2. 中间结果全部并行计算:符号解析、GC 标记、ICF 分桶、重定位应用都可以并行,因为它们的输出位置是预先确定的;
  3. 输出用 mmap 直接映射:输出文件先 mmap 出最终大小,各线程把自己的段直接写到目标偏移,无需"先算后拼"的第二趟。

第三点是关键。传统链接器必须在写之前知道全部布局,于是"算布局"和"写数据"天然分成两趟;而 mmap 输出文件允许你先占位、后填充、并行写,因为写入位置不依赖其他线程的中间结果。

# 并行 + 确定性输出:先并行计算,再按确定性顺序落盘
from concurrent.futures import ThreadPoolExecutor

def link_parallel(objects):
    with ThreadPoolExecutor() as ex:
        parsed = list(ex.map(parse_object, objects))   # 1. 并行解析
        symtab = merge_symbol_tables(parsed)           # 2. 合并符号表(分片哈希)
        layout = compute_layout(parsed)                # 3. 布局:段顺序由确定性规则决定
        out = mmap_output(layout.total_size)           # 4. 映射输出
        # 5. 并行写段:位置已定,互不干扰
        list(ex.map(lambda s: emit_section(out, s, layout, symtab), all_sections(parsed)))
        # 6. 并行应用重定位:只写自己段内的字节
        list(ex.map(lambda s: apply_relocs(out, s, layout, symtab), all_sections(parsed)))
    out.flush()
    return out

这里有个必须讲清楚的误解:并行不等于不确定。并行计算的是"每一段的内容",而"每一段排在哪里"仍由确定性规则决定(段名排序、输入顺序、地址对齐)。只要写入顺序确定,输出就是字节可复现的。mold 的一项硬性工程目标就是:对绝大多数程序,输出与 GNU ld 逐字节一致——这使得切换链接器不会影响可复现构建(reproducible build)与 build-id 稳定性。

wild 走得更激进:用 Rust 实现、更细粒度的阶段切分、以及针对增量开发循环(edit-compile-link 反复迭代)的路径优化。它的赌注是"链接足够快之后,就不需要增量链接"——而 gold 当年提供的 incremental linking 正是因为它太慢才需要。

七、生产环境的六个坑

  1. --as-needed 把库"优化"没了

现代发行版默认开启 --as-needed,导致"链接时没直接引用符号"的库被丢弃——典型受害者是靠静态构造函数注册自己的插件库。解法是显式 --no-as-needed 包住该库,或 -Wl,--undefined=符号 强制保留。

  1. 库顺序与循环依赖

两个静态库互相引用时,GNU ld 会报 undefined reference。要么用 --start-group,要么干脆换 mold/lld,它们默认反复扫描。

  1. ODR 违规被静默吞掉

两个同名但实现不同的强符号,链接器只会取一个(或报错)。C++ 里这叫 ODR 违规,是未定义行为,但通常只在开启 -Wl,--detect-odr-violations(gold 曾有)或 LTO 时才暴露。

  1. LTO 与链接器插件的符号视图不一致

ThinLTO 依赖 summary index 做跨模块导入导出决策。如果链接器插件版本与编译器版本不匹配,会出现"符号在本地存在却仍走 PLT"或更糟的静默错误。锁死 toolchain 版本比事后调试便宜得多。

  1. 确定性构建被环境变量打穿

SOURCE_DATE_EPOCH、绝对路径、-frandom-seed、build-id 算法都会影响输出字节。CI 里要做 reproducible build,就把这些全部钉死,并用 diffoscope 做二进制比对。

  1. --gc-sections 删掉了你以为会用的东西

函数表、注册表、通过链接脚本放置的构造函数,如果没有 KEEP() 保护,会被 mark 阶段当成不可达。症状是"release 构建少了一个功能",且调试版一切正常——因为 -O0 默认不分段。

八、结论与判断标准

链接器的演进,本质上是把"符号图"这个数据结构从串行处理搬到了并行处理,同时用更聪明的语义(默认反复扫描、safe ICF、relaxation)替开发者承担心智负担。

要不要换链接器,问三个问题:

  1. 你的构建是否还能被并行化? 如果链接墙钟时间占总构建时间 30% 以上,换 mold/lld 通常是最便宜的收益(一行 -fuse-ld=mold,无需改代码)。
  2. 你的部署是否敏感于二进制体积? 是 → safe ICF + --gc-sections + LTO 的组合收益远大于换链接器本身。
  3. 你是否需要可复现构建? 是 → 切换前必须做字节级对比,别假设"快"等于"等价"。

最后一句实话:链接器不是魔法,它只是一张符号图上的三次遍历。理解这三次遍历,你就不会再被 undefined reference 和"release 少了功能"这类问题难住——你会直接去看符号表和重定位表,五分钟定位。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部