在软件开发中,链接是将各个编译单元组合成最终的可执行文件或库的关键步骤。对于使用 GNU 工具链(例如 GCC、G++)的开发者来说,理解链接器 ld
的工作原理至关重要。本文将深入探讨 GNU 链接器的工作机制,特别是关于链接顺序的细节,并提供一些实用技巧。
链接的基础知识
GNU 链接器 ld
的基本工作单位是目标文件 (Object File) ,即 .o
文件,它们是源代码经过编译但尚未链接的中间产物。链接器主要关注目标文件中的两种符号:
- 导出符号 (Exported Symbols / Output Symbols) :定义在目标文件内部,可以被其他目标文件使用的符号,例如函数或全局变量。
- 未定义符号 (Undefined Symbols) :目标文件中引用了但没有定义的符号,需要在链接阶段找到其定义。
链接器的核心任务是解析所有目标文件的未定义符号,将它们与其它目标文件中的导出符号匹配起来,最终生成可执行文件或库。
重要原则:ld
要么链接整个目标文件,要么完全不链接。即使一个目标文件中只有少数符号被使用,整个目标文件的内容也会被链接到最终目标中。这也是为什么一些简单的 “Hello, World” 程序体积却很大的原因之一(因为它们链接了一些标准库,即使只用到了其中一小部分功能)。
查看符号
我们可以使用 nm
工具来查看目标文件和库中的符号信息。
查看目标文件中的未定义符号:
nm -u <object_file.o>
例如:
nm -u main.o
查看目标文件或库中的所有符号:
nm <object_file.o or library.a>
例如:
nm b.o
或者:nm libb.a
符号类型说明:
-
U
:表示未定义的符号。 -
T
:表示代码段中的定义的符号。 -
D
:表示已初始化的数据段中的定义的符号。 -
B
:表示未初始化的数据段中的定义的符号。 -
C
:表示COMMON 符号,通常是未初始化的全局变量。 -
W
:表示弱符号。 -
...
:更多符号类型请参考man nm
。
目标文件链接:简单的算法
当只涉及目标文件链接时,ld
使用一个简单的算法:
维护两个列表:
- 已知符号列表 (Defined Symbols List) :记录已解析的符号及其定义所在的目标文件。
- 未知符号列表 (Undefined Symbols List) :记录尚未找到定义的符号。
依次处理每个目标文件:
- 将目标文件中的所有导出符号加入已知符号列表。如果遇到符号冲突(多重定义),则报错。
- 用目标文件的导出符号解析未知符号列表中的符号,并将已解析的符号从未知符号列表中移除。
- 将目标文件中剩余的未定义符号加入未知符号列表。
处理完所有目标文件后,如果未知符号列表非空,则报告 “undefined reference” 错误。
在仅链接目标文件的情况下,链接顺序不影响最终结果。
静态库链接:顺序的重要性
静态库(.a
文件)本质上是一组目标文件的集合。链接静态库时,ld
的行为略有不同,链接顺序变得至关重要。
-
ld
依次检查静态库中的每个目标文件。 - 如果一个目标文件的导出符号可以解决未知符号列表中的任何符号,则将该目标文件加入链接,更新已知符号列表和未知符号列表(参考上一节的步骤)。
- 如果一个目标文件无法解决未知符号列表中的任何符号,则跳过该目标文件。在没有特定选项的情况下,
ld
后续不会重新考虑被跳过的目标文件。 - 如果步骤 2 中加入的目标文件引入了新的未定义符号,则
ld
会重新扫描 同一个 静态库,尝试解析新引入的未定义符号。此过程重复进行,直到没有新的未定义符号,或者本轮扫描无法解析出任何新的未定义符号。
**注意:**上述规则意味着同一个静态库内的目标文件链接顺序无关紧要。但是,不同静态库之间的链接顺序非常重要。
示例:
假设有三个源文件:
bar.cpp
:
#include <iostream>void bar() { std::cout << "bar()" << std::endl;}
foo.cpp
:
#include <iostream>void bar();void foo() { std::cout << "foo()" << std::endl; bar();}
main.cpp
:
#include <iostream>void foo();int main() { std::cout << "main()" << std::endl; foo(); return 0;}
如果将 foo.cpp
和 bar.cpp
分别编译成静态库 libfoo.a
和 libbar.a
,则以下链接命令:
-
g++ -o app.exe main.o libfoo.a libbar.a
:成功 -
g++ -o app.exe libfoo.a libbar.a main.o
:失败,提示undefined reference to 'foo'
-
g++ -o app.exe main.o libbar.a libfoo.a
:失败,提示undefined reference to 'bar'
解释:
- 第一个命令成功是因为
main.o
的未定义符号foo
可以由libfoo.a
解决,libfoo.a
的未定义符号bar
可以由libbar.a
解决。 - 第二个命令失败,因为
ld
先处理libfoo.a
和libbar.a
,当时main.o
未处理,foo
还不在未知符号列表中,所以libfoo.a
被跳过。然后处理main.o
时foo
成为未定义符号,而libfoo.a
已经被跳过,foo
无法解析。 - 第三个命令失败,因为
main.o
需要foo
,libbar.a
无法提供,所以被跳过。libfoo.a
提供了foo
,但是需要bar
,而libbar.a
已经被跳过。
经验法则: 如果库 A 依赖于库 B,则在链接命令中 A 应该放在 B 之前。
循环依赖
当静态库 A 依赖于静态库 B,同时静态库 B 也依赖于静态库 A 时,会出现循环依赖。
例如,将上例中的 bar.cpp
修改为:
#include <iostream>void foo();void bar() { std::cout << "bar()" << std::endl; foo();}
此时,libfoo.a
和 libbar.a
相互依赖。
-
g++ -o app.exe main.o libfoo.a libbar.a
:成功 -
g++ -o app.exe main.o libbar.a libfoo.a
:失败,提示undefined reference to 'foo'
在循环依赖的情况下,简单的调整静态库顺序可能无法解决问题。
动态库链接 (SO)
对于动态库(.so
文件在 Linux/Unix 上),链接行为有所不同。
- SO 文件: 链接时
ld
将.so
文件视为一个整体进行处理,类似于处理单个目标文件。多个.so
文件之间的链接顺序通常不影响结果。
重要区别: 生成 SO 时允许存在未解析的符号(在运行时解析)。
解决链接问题的进阶选项
如果默认的链接行为无法解决问题,ld
提供了一些选项来控制链接过程:
1. -start-group
和 -end-group
这两个选项可以将多个静态库视为一个组,使得 ld
在组内所有静态库中循环扫描,直到无法解析出新的未定义符号或者没有新的未定义符号。这可以解决循环依赖问题。
g++ -o app.exe main.o -Wl,-start-group libfoo.a libbar.a -Wl,-end-group
注意: 使用 -start-group
和 -end-group
会增加链接时间,尤其是在大型项目中。
2. --whole-archive
和 --no-whole-archive
--whole-archive
选项会强制 ld
将其后所有静态库中的所有目标文件都链接进来,无论这些目标文件是否被需要。--no-whole-archive
选项则恢复默认的链接行为。
g++ -o app.exe main.o -Wl,--whole-archive libbar.a -Wl,--no-whole-archive libfoo.a
注意: 使用 --whole-archive
会显著增大最终生成文件的体积,因为会链接进大量可能不需要的代码。
总结
本文深入探讨了 GNU 链接器 ld
的工作机制,特别是关于链接顺序的细节,内容要点包括:
- 导出符号和未定义符号的概念。
-
nm
工具查看符号信息的使用方法。 - 目标文件之间的链接顺序不重要。
- 静态库之间的链接顺序很重要:被依赖的库应该放在依赖它的库的后面。
- 循环依赖需要特殊处理,可以使用
-start-group
和-end-group
选项。 - 动态库的链接行为与静态库有所不同。
- 谨慎使用
--whole-archive
选项。