linker
No related notes
Outlinks (0)
No outlinks found
Backlinks (0)
No backlinks found
1 · linker#
S 多个 .o / .a / .so / .dll 一起组成可执行文件时,编译能过,但链接阶段报 undefined reference、duplicate symbol,或者 C/C++ 混编、模板、静态初始化、动态加载行为让人困惑。
C 编译器只负责把单个源文件变成目标文件;跨文件名字最终指向哪里、哪些定义该保留、哪些库要被拉进来、运行时还要不要继续解析符号,这些都不是编译器单独能决定的。
Q linker 到底做了什么?为什么有些错误发生在链接期而不是编译期?C++ 又给链接增加了哪些复杂度?
A 链接器本质上是在“把所有目标文件里的名字对上号”:
- 为未定义符号找到唯一且可见的定义
- 把代码/数据放进最终可执行文件或共享库
- 处理重定位,让代码里“留白”的地址在最终产物中变成真实地址
- 决定静态库是否抽取目标文件、共享库如何在装载时或运行时解析符号
- 在 C++ 中额外处理名字改编、全局对象构造、模板实例化等问题
1.1 · 核心脉络
这篇文章把链接问题拆成一条很清楚的链路:
- 源文件里有“定义”和“声明”
- 编译器把每个源文件单独编译成目标文件
- 目标文件里既有本文件提供的符号,也有对外部符号的“未解析引用”
- 链接器把这些引用和定义配对,生成最终可执行文件或库
- 操作系统再把可执行文件和共享库装入内存,完成最后一部分地址绑定
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 · 读完后的实用结论
- 编译通过只能说明“单文件语法和语义大体成立”,不代表程序能链接成功。
- 遇到链接错误,优先问自己三件事:
- 这个符号有没有定义?
- 这个定义对当前文件是否可见?
- 链接器扫描库的时机和顺序对不对?
- C/C++ 混编先检查
extern "C"。 - 模板/inline/头文件实现相关问题,本质常常是“实例是否生成”和“重复定义如何合并”。
- 全局对象问题常常不是语法问题,而是链接和启动阶段的初始化顺序问题。
1.9 · 一句话总结
linker 的核心职责不是“编译代码”,而是把多个编译结果里的符号、地址和初始化责任整合成一个真正可运行的程序;C++ 的很多“玄学问题”,本质上都发生在这个整合阶段。