Skip to main content

linker

📅 2026-03-25 ✏️ 2026-03-25 CS
No related notes

1 · linker#

S 多个 .o / .a / .so / .dll 一起组成可执行文件时,编译能过,但链接阶段报 undefined referenceduplicate symbol,或者 C/C++ 混编、模板、静态初始化、动态加载行为让人困惑。

C 编译器只负责把单个源文件变成目标文件;跨文件名字最终指向哪里、哪些定义该保留、哪些库要被拉进来、运行时还要不要继续解析符号,这些都不是编译器单独能决定的。

Q linker 到底做了什么?为什么有些错误发生在链接期而不是编译期?C++ 又给链接增加了哪些复杂度?

A 链接器本质上是在“把所有目标文件里的名字对上号”:

  • 为未定义符号找到唯一且可见的定义
  • 把代码/数据放进最终可执行文件或共享库
  • 处理重定位,让代码里“留白”的地址在最终产物中变成真实地址
  • 决定静态库是否抽取目标文件、共享库如何在装载时或运行时解析符号
  • 在 C++ 中额外处理名字改编、全局对象构造、模板实例化等问题

https://www.lurklurk.org/linkers/linkers.html

1.1 · 核心脉络

这篇文章把链接问题拆成一条很清楚的链路:

  1. 源文件里有“定义”和“声明”
  2. 编译器把每个源文件单独编译成目标文件
  3. 目标文件里既有本文件提供的符号,也有对外部符号的“未解析引用”
  4. 链接器把这些引用和定义配对,生成最终可执行文件或库
  5. 操作系统再把可执行文件和共享库装入内存,完成最后一部分地址绑定

1.2 · 编译器做什么

  • 编译器处理单个源文件,输出目标文件(Unix 常见 .o,Windows 常见 .obj)。
  • 目标文件里主要有两类东西:
    • 代码:函数定义生成的机器码
    • 数据:全局变量定义及其初始值
  • 如果当前文件只看到了声明、没看到定义,编译器不会报错;它会先在目标文件里留一个“待回填”的引用,交给链接器处理。
  • nm 可以看到这些符号:
    • U:未定义符号
    • T/t:函数定义
    • D/d:已初始化全局变量
    • B/b/C:未初始化全局变量
  • static 的本质是缩小符号可见范围:只在当前编译单元内部可见。

1.3 · 链接器做什么

  • 最核心的工作就是“fill in the blanks”:把一个目标文件中的未定义引用,接到另一个目标文件或库中的定义上。
  • 如果找不到定义,就会出现 undefined reference
  • 如果出现多个同名且都应对外可见的定义,就会出现重复定义错误。
  • static 符号因为只在本文件可见,所以不同文件里可以各自有同名 static 变量/函数而不冲突。

1.4 · 操作系统做什么

  • 链接结束后,程序还没真正“跑起来”。
  • 操作系统装载程序时,会把代码段、数据段映射到内存,把未初始化数据段(如 .bss)清零,并把程序入口跳转到启动代码,再进入 main
  • 所以“能链接”不等于“已经完全执行”;装载阶段仍然参与了地址安排和共享库绑定。

1.5 · 静态库与共享库

1.5.1 · 静态库

  • 静态库本质上是多个目标文件的打包。
  • 链接器不会把整个库无脑塞进最终程序,而是只抽取“当前缺失符号所需的目标文件”。
  • 因此库的顺序可能影响链接结果:某些 Unix 链接器只按从左到右的一遍扫描处理库,后面的需求可能无法回头触发前面库的抽取。

1.5.2 · 共享库

  • 共享库把一部分链接工作延后到装载期甚至运行期。
  • 最终可执行文件里不一定直接包含实现代码,而是保留“这个符号运行时去某个共享库找”的信息。
  • 优点是节省磁盘/内存、便于升级;代价是部署和兼容性更复杂。

1.5.3 · Windows DLL#

  • Windows 下 DLL 通常伴随 import library(.lib)使用。
  • 导出符号需要显式约定;导入方往往不是直接链接 DLL,而是链接对应的 import library。
  • 文章特别提醒:Windows 在导入/导出、循环依赖和相关文件组织上,比 Unix 共享库更“显式”。

1.6 · C++ 让链接更复杂的地方#

1.6.1 · 1. 名字改编(Name Mangling)#

  • C++ 需要支持函数重载,所以符号名不能只看源码里的函数名,还要编码参数类型等信息。
  • 于是 findmax(int, int) 在符号表里不再只是 findmax,而是某种 mangled name。
  • 这也是 C/C++ 混编最常见的坑:C 编译器导出的名字没被改编,C++ 编译器查找的却是改编后的名字。
  • 解决方法通常是 extern "C":告诉 C++ 编译器按 C 链接名导出/导入。

1.6.2 · 2. 全局/静态对象初始化#

  • C 的全局初始化很多时候只是“把初始值拷贝到内存”。
  • C++ 的全局对象需要先跑构造函数,因此链接器/启动代码要把各个编译单元登记的构造函数列表拼起来,在 main 前依次调用。
  • 这也引出了经典问题:跨编译单元的静态初始化顺序没有良好保证。

1.6.3 · 3. 模板实例化#

  • 模板不是一份代码直接复用到底,而是“每个具体类型实例化成一份机器码”。
  • 文章提到两种主要做法:
    • 每个用到模板的目标文件都生成实例,最后由链接器折叠重复定义
    • 目标文件先不生成实例,到链接期再统一生成
  • 常见实现会把模板实例做成 weak symbol,这样多个相同实例可以在链接时去重。

1.7 · 动态加载

  • 比共享库再往后一步的是运行时显式加载:dlopen / dlsym(Windows 对应 LoadLibrary / GetProcAddress)。
  • dlopen 可以选择:
    • RTLD_NOW:加载时立即解析全部引用
    • RTLD_LAZY:等真正用到时再解析
  • dlsym 是通过字符串查符号地址,所以对 C++ 并不友好:
    • 你拿到的是 mangled name
    • 不同编译器/平台 mangling 规则未必一致
  • 更稳妥的实践是:动态库暴露一个稳定的 extern "C" 入口,再由它返回 C++ 对象或函数表。

1.8 · 读完后的实用结论

  • 编译通过只能说明“单文件语法和语义大体成立”,不代表程序能链接成功。
  • 遇到链接错误,优先问自己三件事:
    1. 这个符号有没有定义?
    2. 这个定义对当前文件是否可见?
    3. 链接器扫描库的时机和顺序对不对?
  • C/C++ 混编先检查 extern "C"
  • 模板/inline/头文件实现相关问题,本质常常是“实例是否生成”和“重复定义如何合并”。
  • 全局对象问题常常不是语法问题,而是链接和启动阶段的初始化顺序问题。

1.9 · 一句话总结

linker 的核心职责不是“编译代码”,而是把多个编译结果里的符号、地址和初始化责任整合成一个真正可运行的程序;C++ 的很多“玄学问题”,本质上都发生在这个整合阶段。