程序构建的沉默机械

每个程序员都写过编译命令,但很少有人真正理解ld在做什么。当编译器完成了词法分析、语法分析、中间代码生成、优化和目标代码生成后,它产出了一个或多个目标文件。这些文件包含了机器码,但还不能执行——它们之间存在引用关系,全局变量的地址未确定,外部函数的位置未知。链接器的任务就是将这些分散的碎片组装成一个完整的程序。

这个过程远比想象中复杂。现代链接器需要处理符号解析、重定位、库依赖、版本控制等众多问题。而加载器则负责将链接后的程序真正送入内存执行。两者共同构成了程序构建流水线的最后一公里。

flowchart LR
    A[源代码] --> B[编译器]
    B --> C[目标文件]
    C --> D[链接器]
    D --> E[可执行文件]
    E --> F[加载器]
    F --> G[内存中的进程]

符号:链接的基本单元

理解链接器的第一步是理解符号。在编译过程中,每个函数、全局变量都会被赋予一个符号名。编译器在目标文件中维护两张关键表:符号表(.symtab)和重定位表(.rel.text.rel.data)。

符号表记录了本模块定义的符号及其属性。每个符号表项包含:

  • st_name:符号名在字符串表中的索引
  • st_value:符号的值(通常是地址或偏移)
  • st_size:符号的大小
  • st_info:符号的类型和绑定属性
  • st_other:可见性等信息
  • st_shndx:符号所在的节索引

符号绑定属性决定了符号的可见性。STB_LOCAL表示本地符号,只能在定义它的目标文件中访问;STB_GLOBAL表示全局符号,可以被其他目标文件引用;STB_WEAK表示弱符号,提供了一种可选的符号定义机制。

当编译器遇到一个外部引用时,它无法确定被引用符号的实际地址。这时它会生成一个重定位条目,告诉链接器:“等你知道这个符号的地址后,把它填到这里”。

重定位条目的结构因体系结构而异,但核心字段包括:

  • r_offset:需要修改的位置在节中的偏移
  • r_info:包含符号表索引和重定位类型
  • r_addend(某些格式):用于计算的额外值

重定位类型决定了如何计算和修改目标地址。以x86-64为例,R_X86_64_PC32表示PC相对寻址,计算公式为S + A - P,其中S是符号地址,A是加数,P是修改位置。而R_X86_64_32表示绝对寻址,直接将符号地址写入目标位置。

flowchart TB
    subgraph 目标文件A
        A1[代码节]
        A2[数据节]
        A3[符号表]
        A4[重定位表]
    end
    
    subgraph 目标文件B
        B1[代码节]
        B2[数据节]
        B3[符号表]
        B4[重定位表]
    end
    
    subgraph 链接器
        L1[符号解析]
        L2[重定位]
        L3[节合并]
    end
    
    A4 --> L1
    B3 --> L1
    L1 --> L2
    L2 --> L3

符号解析:解谜的艺术

符号解析是链接器最核心的工作之一。它的任务是将每个符号引用与其唯一的定义关联起来。这个过程看似简单——找到名字匹配的定义不就行了——但实际上充满了复杂性。

首先考虑简单情况:如果符号引用只有一个定义,解析就很直接。但当存在多个同名定义时,问题就出现了。链接器需要处理强符号和弱符号的区别。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。链接器的规则是:

  1. 不允许有多个强符号同名
  2. 如果一个强符号和多个弱符号同名,选择强符号
  3. 如果多个弱符号同名,选择任意一个

这些规则可能导致微妙的问题。考虑以下代码:

// file1.c
int x = 1;  // 强符号

// file2.c  
int x;      // 弱符号,会被忽略

// file3.c
int x = 2;  // 编译错误:重复定义

符号解析还涉及库的处理。静态库是一组目标文件的归档,链接器按需从中提取目标文件。这里的"按需"非常重要:链接器从左到右处理命令行参数,遇到静态库时,只提取那些能解析当前未定义符号的目标文件。这导致了著名的"链接顺序问题":

# 错误:liba.a中的符号不会被提取,因为它在main.o之前处理
gcc -la main.o -lb

# 正确:main.o中的未定义符号会触发liba.a的提取
gcc main.o -la -lb

对于循环依赖的库,可以使用-Wl,--start-group -la -lb -Wl,--end-group让链接器多次扫描,但这会增加链接时间。更好的做法是重构代码,消除循环依赖。

重定位:填充最后的空白

符号解析完成后,链接器知道了每个符号的最终地址。现在需要进行重定位,将符号地址填入代码和数据中预留的位置。

重定位过程分为两步:节重定位和符号重定位。节重定位将各个目标文件的同名节合并,并为每个节分配运行时地址。符号重定位则处理具体的符号引用。

考虑一个简单的例子:

// main.c
extern int global_var;
extern void external_func(void);

int main() {
    int x = global_var;
    external_func();
    return 0;
}

编译后,main.o中访问global_var和调用external_func的代码会包含占位符。链接时,链接器会:

  1. 确定所有节的最终地址
  2. 解析global_varexternal_func的地址
  3. 根据重定位类型修改代码中的占位符

对于函数调用,重定位类型通常是PC相对寻址。链接器计算目标地址与调用指令地址的差值,将其填入调用指令的操作数字段。对于全局变量访问,可能是PC相对寻址(如x86-64的mov global_var(%rip), %eax)或绝对寻址(如x86-32的mov global_var, %eax)。

重定位不仅发生在静态链接时。动态链接的可执行文件在运行时也需要重定位——这就是加载时重定位和运行时重定位的由来。

ELF:链接与执行的双重视角

可执行与可链接格式(ELF,Executable and Linkable Format)是Unix类系统的标准二进制格式。ELF的精妙之处在于它提供了两种视角:链接视角和执行视角。

链接视角关注节:代码节、数据节、符号表、重定位表等。编译器和链接器从这个视角处理文件。执行视角关注段:代码段、数据段等。加载器从这个视角将文件映射到内存。

+------------------+
|    ELF Header    |
+------------------+
| Program Headers  | <--- 执行视角
+------------------+
|    .text节       |
|    .data节       | <--- 链接视角
|    .symtab节     |
|    .rel.text节   |
+------------------+
| Section Headers  | <--- 链接视角
+------------------+

节包含链接器需要的所有信息,而段是节到内存映射的描述。一个段可以包含多个节,例如,一个可加载段可能同时包含.text.rodata.eh_frame节。

这种设计带来一个重要后果:可执行文件可以剥离调试信息后仍能正常运行。strip命令会删除符号表和调试节,但程序头表保留,加载器仍然能正确加载程序。

ELF文件头包含了文件类型信息:

  • ET_REL:可重定位目标文件
  • ET_EXEC:可执行文件
  • ET_DYN:共享目标文件

每种类型都有特定的处理方式。可重定位文件的节从地址0开始,需要重定位;可执行文件有固定的加载地址;共享目标文件是位置无关的,可以加载到任意地址。

动态链接:延迟的艺术

静态链接简单直接,但存在明显问题:每个程序都包含一份库代码,浪费磁盘空间;库更新需要重新链接所有程序。动态链接解决了这些问题,但引入了新的复杂性。

动态链接的核心思想是将链接过程推迟到运行时。链接时,链接器只在可执行文件中记录依赖的共享库名称。运行时,动态链接器(ld-linux.so)加载这些库并完成符号绑定。

但这里有一个问题:编译时不知道库会被加载到什么地址,如何生成访问库中符号的代码?解决方案是位置无关代码(PIC)和全局偏移表(GOT)。

位置无关代码

PIC的核心思想是:不使用绝对地址,所有地址引用都相对于当前位置计算。对于函数调用,使用PC相对调用即可。对于全局变量访问,由于x86-64之前的架构不支持PC相对数据寻址,需要借助GOT。

GOT是一张地址表,每个表项存储一个全局符号的地址。代码通过固定偏移访问GOT中的表项,而GOT本身的位置可以通过PC相对寻址获得。运行时,动态链接器填充GOT表项。

+------------------+
|     代码段       |
|  mov foo@GOTPCREL(%rip), %rax  | -- 获取foo的地址
|  mov (%rax), %ebx              | -- 访问foo的值
+------------------+
|     数据段       |
|  GOT[0]: ...                   |
|  GOT[1]: ...                   |
|  GOT[n]: <foo的实际地址>       | <-- 动态链接器填充
+------------------+

过程链接表与延迟绑定

函数调用也面临类似问题。如果每个函数调用都要通过GOT间接跳转,会有性能损失。更重要的是,如果程序只使用了库中的少数函数,在程序启动时解析所有函数地址会延长启动时间。

解决方案是过程链接表(PLT)和延迟绑定。PLT是一小段代码,每个外部函数对应一个PLT条目。GOT被拆分为.got(存放全局变量地址)和.got.plt(存放函数地址)。

首次调用外部函数时:

  1. 代码跳转到PLT条目
  2. PLT条目跳转到GOT中存储的地址
  3. 首次调用时,该地址指向PLT条目的下一条指令(形成循环)
  4. 执行解析代码,调用_dl_runtime_resolve
  5. 解析器找到函数实际地址,填入GOT
  6. 跳转到实际函数

后续调用时,GOT中已有实际地址,直接跳转,无需解析。

sequenceDiagram
    participant 代码
    participant PLT
    participant GOT
    participant 动态链接器
    participant 目标函数

    Note over 代码,目标函数: 首次调用
    代码->>PLT: 调用foo@plt
    PLT->>GOT: 跳转到*foo@got
    Note right of GOT: 存储的是PLT下一条指令地址
    GOT-->>PLT: 返回PLT
    PLT->>动态链接器: 调用_dl_runtime_resolve
    动态链接器->>目标函数: 查找foo地址
    动态链接器->>GOT: 写入foo实际地址
    PLT->>目标函数: 跳转到foo

    Note over 代码,目标函数: 后续调用
    代码->>PLT: 调用foo@plt
    PLT->>GOT: 跳转到*foo@got
    Note right of GOT: 存储的是foo实际地址
    GOT->>目标函数: 直接跳转

PLT的巧妙之处在于它将公共的解析代码提取出来。PLT的第一个条目是解析器入口,后续每个条目的格式是:

foo@plt:
    jmp *foo@got(%rip)    ; 跳转到GOT中的地址
    push $index           ; 压入符号索引
    jmp .plt[0]           ; 跳转到解析器

.got.plt的前三个条目有特殊用途:

  • GOT[0]:.dynamic节的地址
  • GOT[1]:模块ID(用于解析器)
  • GOT[2]:_dl_runtime_resolve的地址

这样设计使得PLT条目紧凑而高效。

动态链接器:程序启动的幕后英雄

当执行一个动态链接的程序时,内核加载可执行文件后,控制权并不直接转给程序,而是先给动态链接器。动态链接器是一段特殊的代码,它在程序运行前完成最后的链接工作。

动态链接器的工作流程大致如下:

  1. 自举:动态链接器本身也是共享库,需要先完成自己的重定位
  2. 加载依赖:解析可执行文件的DT_NEEDED项,递归加载所有依赖库
  3. 重定位:对每个加载的模块执行重定位
  4. 初始化:执行各模块的初始化代码(.init.init_array
  5. 移交控制:跳转到程序的入口点

库的搜索顺序是:

  1. DT_RPATH(已废弃)
  2. LD_LIBRARY_PATH环境变量
  3. DT_RUNPATH
  4. /etc/ld.so.cache
  5. 默认路径(/lib、/usr/lib等)

DT_RPATHDT_RUNPATH的区别在于搜索顺序:DT_RPATH优先于LD_LIBRARY_PATH,而DT_RUNPATHLD_LIBRARY_PATH之后。现代实践推荐使用DT_RUNPATH(通过-Wl,--enable-new-dtags-Wl,-rpath选项)。

动态链接器还支持预加载(LD_PRELOAD),允许在程序启动前注入指定的库。这在调试、性能分析和某些安全场景中很有用,但也可能被滥用。

flowchart TB
    A[内核加载可执行文件] --> B[识别.interp段]
    B --> C[加载动态链接器]
    C --> D[动态链接器自举]
    D --> E[加载依赖库]
    E --> F[递归加载传递依赖]
    F --> G[执行重定位]
    G --> H[运行初始化代码]
    H --> I[跳转到程序入口]

复制重定位:效率与简洁的权衡

位置无关代码是共享库的理想选择,但有时库中的全局变量会被可执行文件直接访问。考虑以下场景:

// lib.c
int global_var = 42;

// main.c
extern int global_var;
int main() { return global_var; }

如果main.c编译时没有使用PIC,它会生成直接访问global_var的代码。但global_var在共享库中,运行时地址未知。如何处理?

链接器的解决方案是复制重定位。它在可执行文件的BSS节分配空间,并在动态段创建一个特殊重定位项,告诉动态链接器将库中的变量值复制到这个位置。这样,可执行文件中的代码就能用绝对地址访问变量了。

这带来一些限制:复制重定位的变量不能在库中修改(因为可执行文件有自己的副本),且必须知道变量的大小。因此,共享库中导出的全局变量最好有固定大小。

现代链接器:速度的革命

传统的GNU ld使用BFD库,设计目标是可移植性而非速度。对于大型项目,链接时间可能非常长。这催生了新一代链接器。

gold

Google开发的gold链接器首次将速度作为首要目标。它不使用BFD库,而是直接处理ELF格式,采用更高效的数据结构。gold的符号解析使用哈希表而非链表,内存分配更高效,整体设计更现代。

gold比传统ld快2-5倍,但也带来一些不兼容性。某些依赖于ld特定行为的链接脚本可能需要修改。

LLVM lld

lld是LLVM项目的链接器,采用模块化设计,支持ELF、COFF、Mach-O和WebAssembly格式。它的速度与gold相当或更快,且与LLVM工具链深度集成。

lld的一个独特功能是增量链接优化,可以在链接时进行更多优化,如消除重复代码、合并相似函数。

mold

mold是近年来最激进的链接器创新。由Rui Ueyama开发,它的速度比lld和gold快2-10倍。mold的秘诀包括:

  • 高度并行的算法设计
  • 内存映射文件的高效使用
  • 最小化内存拷贝
  • 缓存友好的数据结构
graph LR
    A[GNU ld<br/>传统] --> B[gold<br/>Google]
    A --> C[lld<br/>LLVM]
    B --> D[mold<br/>更快]
    C --> D

mold的设计哲学是:链接器不应该成为编译的瓶颈。对于包含数百万行代码的项目,mold可以将链接时间从分钟级降到秒级。

链接时优化:跨越编译单元

传统编译器一次处理一个源文件,优化范围受限。内联、常量传播等优化无法跨越编译单元边界。链接时优化(LTO)打破了这个限制。

LTO的基本思想是:编译时生成中间表示(IR)而非机器码,链接时将所有IR合并,进行全局优化,再生成最终代码。

LLVM的LTO流程:

  1. 编译器生成包含LLVM bitcode的目标文件
  2. 链接器识别bitcode,调用LTO插件
  3. LTO插件合并所有bitcode,执行全局优化
  4. 生成本地代码,继续常规链接

LTO可以实现跨编译单元的内联、死代码消除、全局常量传播等优化。代价是链接时间增加(因为优化工作转移到链接阶段)和内存使用增加。

flowchart LR
    subgraph 传统编译
        A1[源文件A] --> B1[目标文件A]
        A2[源文件B] --> B2[目标文件B]
        B1 --> C[链接]
        B2 --> C
        C --> D[可执行文件]
    end
    
    subgraph LTO
        E1[源文件A] --> F1[IR文件A]
        E2[源文件B] --> F2[IR文件B]
        F1 --> G[合并与优化]
        F2 --> G
        G --> H[代码生成]
        H --> I[可执行文件]
    end

LTO的使用很简单,只需添加编译选项:

# GCC/Clang
gcc -flto -O2 *.c

# 更激进的优化
gcc -flto -O3 -ffat-lto-objects *.c

-ffat-lto-objects选项生成同时包含IR和机器码的目标文件,便于不使用LTO时的兼容处理。

链接器脚本:精细控制内存布局

对于嵌入式开发或特殊需求,默认的内存布局可能不适用。链接器脚本提供了对内存布局的完全控制。

一个简单的链接器脚本:

ENTRY(_start)

MEMORY
{
    RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M
    FLASH (rx) : ORIGIN = 0x0, LENGTH = 1M
}

SECTIONS
{
    .text : {
        *(.text.entry)
        *(.text .text.*)
    } > FLASH

    .rodata : {
        *(.rodata .rodata.*)
    } > FLASH

    .data : {
        _data_start = .;
        *(.data .data.*)
        _data_end = .;
    } > RAM AT > FLASH

    .bss : {
        _bss_start = .;
        *(.bss .bss.*)
        _bss_end = .;
    } > RAM
}

这个脚本定义了两个内存区域(RAM和FLASH),并将不同节放入适当位置。.data节的AT > FLASH表示数据存储在FLASH中,但运行时位于RAM,启动代码需要负责复制。

链接器脚本还允许定义符号(如_data_start),供启动代码使用。这是嵌入式系统初始化的标准模式。

弱符号与符号版本:灵活性的代价

弱符号是一种特殊的符号定义,可以被强符号覆盖而不产生冲突。它常用于提供默认实现:

// 默认实现
__attribute__((weak)) void log_message(const char* msg) {
    printf("[LOG] %s\n", msg);
}

// 用户可以提供自己的实现覆盖
void log_message(const char* msg) {
    syslog(LOG_INFO, "%s", msg);
}

弱符号的另一个用途是检测特性是否可用:

// 检查函数是否存在
if (foo_ptr != NULL) {
    foo_ptr();  // 函数存在则调用
}

// 声明弱引用
extern void foo(void) __attribute__((weak));

符号版本是另一种高级特性,允许同一库中存在同一符号的多个版本。这对于保持向后兼容性至关重要。例如,glibc中memcpy有多个版本,旧程序使用旧版本语义,新程序使用新版本语义。

// 定义版本化符号
__asm__(".symver old_memcpy,memcpy@GLIBC_2.0");
__asm__(".symver new_memcpy,memcpy@@GLIBC_2.14");

void* old_memcpy(void* dest, const void* src, size_t n) {
    // 旧版本可能不处理重叠区域
}

void* new_memcpy(void* dest, const void* src, size_t n) {
    // 新版本正确处理重叠
}

版本脚本控制哪些符号被导出以及它们的版本:

VER_1.0 {
    global:
        foo;
        bar;
    local:
        *;  // 其他符号不导出
};

VER_2.0 {
    global:
        baz;  // 新增符号
} VER_1.0;    // 继承之前版本

异常处理:链接器的隐藏角色

C++异常处理依赖于链接器配合。当异常被抛出时,运行时需要找到匹配的catch块并正确展开栈。这需要精确知道每个函数的栈帧布局。

.eh_frame节包含调用帧信息(CFI,Call Frame Information),描述了如何展开每个函数的栈帧。编译器生成CFI指令,链接器负责合并它们,确保地址正确。

.eh_frame:
    CIE (Common Information Entry)
        # 通用信息:返回地址寄存器、栈指针等
    
    FDE (Frame Description Entry) for func1
        # func1的栈帧布局
        # 位置 -> CFI指令序列
    
    FDE for func2
        # func2的栈帧布局

链接器还处理.gcc_except_table,其中包含类型信息和异常处理表。这些表在链接时需要重定位,以便正确指向类型信息。

如果链接器配置错误(如使用--no-eh-frame-hdr),异常处理可能失败。这就是为什么某些嵌入式平台禁用异常——缺少链接器支持。

线程局部存储:每个线程的私有空间

线程局部存储(TLS)允许每个线程拥有全局变量的私有副本。链接器在实现TLS中扮演关键角色。

__thread关键字(或C++11的thread_local)声明TLS变量:

__thread int errno;  // 每个线程有自己的errno
thread_local int counter = 0;  // C++11

TLS的实现因平台而异。在x86-64 Linux上,TLS块位于线程特定的段寄存器(%fs)指向的区域。访问TLS变量需要计算相对于%fs的偏移。

链接器负责:

  1. 收集所有TLS变量到.tdata(已初始化)和.tbss(未初始化)节
  2. 分配每个变量的偏移
  3. 生成适当的重定位

动态链接时,动态链接器需要为每个线程分配TLS块,并初始化其内容。

调试信息:从源码到二进制的映射

调试器需要在机器码和源代码之间建立映射。这通过调试信息实现,主要格式是DWARF。

编译器生成多个调试节:

  • .debug_info:核心调试信息,类型、变量、函数描述
  • .debug_abbrev:缩写表,压缩.debug_info
  • .debug_line:行号信息,地址到源码行映射
  • .debug_str:字符串表
  • .debug_ranges:地址范围表

链接器处理这些节的方式与普通节类似,但需要特殊处理地址引用。当函数地址因重定位改变时,调试信息中的地址也需要更新。

分离调试信息(-gsplit-dwarf)是一种优化:将调试信息放在单独的.dwo文件中,减少链接时处理的数据量。链接器生成.dwo文件的索引,调试器据此找到对应的调试信息。

链接器优化:超越编译器

链接器拥有全局视角,可以看到整个程序。这使其能执行编译器无法完成的优化。

相同代码折叠(ICF)

ICF检测并合并完全相同的函数。这在C++模板代码中特别有效,不同模板实例化可能生成相同的机器码。

template<typename T>
T* get_instance() {
    static T instance;
    return &instance;
}

// get_instance<int>() 和 get_instance<float>()
// 可能生成相同代码(如果sizeof(int)==sizeof(float))

启用ICF:-Wl,--icf=all(gold、lld、mold支持)。

垃圾回收

--gc-sections选项让链接器删除未被引用的节。这需要编译时使用-ffunction-sections-fdata-sections,将每个函数和变量放入独立的节。

gcc -ffunction-sections -fdata-sections -Wl,--gc-sections

这对嵌入式系统特别有用,可以显著减少代码大小。

符号剥离

strip命令删除符号表和调试信息。-s--strip-all完全剥离,--strip-debug只删除调试信息。剥离后的二进制文件更小,但难以调试。

实践中的链接问题

常见错误

未定义符号:最常见错误,通常由缺少库或拼写错误引起。检查库的链接顺序和符号名(C++有名称修饰)。

重复定义:同名符号在多个目标文件中定义。检查是否在头文件中定义变量或函数(应使用extern声明,在一个源文件中定义)。

库版本冲突:程序链接的库版本与运行时找到的版本不同。使用ldd检查依赖,确保库路径正确。

调试技巧

# 查看符号表
nm -C program

# 查看依赖
ldd program

# 查看动态符号
readelf -Ws program

# 查看重定位
readelf -r program

# 查看节
readelf -S program

# 动态链接器调试
LD_DEBUG=bindings program
LD_DEBUG=libs program
LD_DEBUG=versions program

静态链接与动态链接的选择

静态链接优点:

  • 简单部署,无依赖问题
  • 可预测的性能,无运行时开销
  • 可进行更多优化

动态链接优点:

  • 节省磁盘和内存空间
  • 库更新无需重新链接
  • 支持插件架构

现代实践倾向于动态链接系统库,静态链接应用程序特定的依赖。

链接器的未来

链接器技术仍在演进。增量链接可以显著减少大型项目的链接时间,目前已有部分实现。LLVM的 ThinLTO 将LTO增量化,支持分布式编译。

WebAssembly带来了新的链接模型。Wasm模块是独立的,可以动态加载和链接,但需要工具链支持。

Rust和Go等新语言在链接器层面做了创新。Go使用自己的链接器,完全控制链接过程。Rust利用LLVM和LTO,提供跨语言的优化能力。

结语

链接器和加载器是程序构建流水线的沉默工作者。它们将编译器的输出转化为可运行的程序,处理符号解析、重定位、动态链接等复杂任务。理解它们的工作原理,不仅能帮助诊断构建问题,还能更好地理解程序的运行时行为。

从最初的简单重定位到现代的LTO和ICF,链接器技术不断演进。新一代链接器(mold等)证明了即使在这个看似成熟的领域,创新仍然可能。随着软件规模的增长和新平台的出现,链接器仍将是编译工具链中不可或缺的一环。


参考资料

  1. Beginner’s Guide to Linkers - https://www.lurklurk.org/linkers/linkers.html
  2. ELF Specification - https://refspecs.linuxbase.org/elf/gabi4+/ch4.reloc.html
  3. ld.so(8) - Linux manual page - https://www.man7.org/linux/man-pages/man8/ld.so.8.html
  4. PLT and GOT Workflow in Dynamic Linking - openEuler
  5. Linker and Libraries Guide - Oracle
  6. Computer Systems: A Programmer’s Perspective - CSAPP Chapter 7
  7. Position Independent Code (PIC) in shared libraries - Eli Bendersky
  8. All about Global Offset Table - MaskRay
  9. All about symbol versioning - MaskRay
  10. Symbol processing - MaskRay
  11. mold: Modern Linker - https://github.com/rui314/mold
  12. LLVM Link Time Optimization - https://llvm.org/docs/LinkTimeOptimization.html
  13. Static Library Linking Order - Eli Bendersky
  14. Linker Scripts Explained - dev.to
  15. Weak Symbols - Wikipedia
  16. Thread-Local Storage - Oracle Linker Guide
  17. Exception Handling - DWARF Debugging Standard
  18. Identical Code Folding - Google Research Paper
  19. Understanding ELF File Layout - Low-Level Lore
  20. Copy Relocations - Oracle Linker Guide
  21. Dynamic Linking Performance - LWN.net
  22. Load-time relocation of shared libraries - Eli Bendersky
  23. Symbol Resolution - Oracle Linker Guide
  24. Memory Layout of C Programs - GeeksforGeeks
  25. ELF sections vs segments - Stack Overflow
  26. Global Constructors and Destructors - OSDev Wiki
  27. Debugging Options - GCC Documentation
  28. Relocation Types - Oracle Linker Guide
  29. Symbol Table Section - Oracle Linker Guide
  30. C++ Name Mangling - Medium
  31. Dynamic linker search path - Stack Exchange
  32. TLS implementation - GCC Documentation
  33. Stack unwinding - MaskRay
  34. Linker performance optimization - Microsoft DevBlog
  35. Wild linker incremental linking - David Lattimore
  36. Understanding Weak Symbols - Beningo Embedded Group
  37. Shared library address space - Stack Overflow
  38. inline function linker handling - Stack Overflow
  39. COMDAT and ICF - MaskRay
  40. Embedded system linker scripts - Stack Overflow
  41. GOT for global variables - Medium
  42. Modern linker comparison - Phoronix