第4章:链接的职责:符号解析与重定位

编译四阶段跑完,最后一棒交给链接器:把散落各处的目标文件拼成能运行的程序。这一章拆开链接器的两大任务——符号解析与重定位,再用三个真实报错,练一遍福尔摩斯式的排错

🎩

本章导师:福尔摩斯

核心方法论:排除不可能

「当你排除了所有不可能的情况,剩下的,不管多么难以置信,那就是真相。链接错误从来不是随机的:每一个 undefined reference 背后,都藏着一个没被满足的约定。这一章我们不猜、不试、不撞运气——先看清链接器到底在做什么,再沿着'排除不可能'的清单,把报错一步一步逼到死角。」

4.1 链接器到底做什么:符号解析与重定位

上一章我们解剖了目标文件(.o,编译与汇编的产物,里面是机器指令和数据的"半成品")。但一个 .o 是跑不起来的——真实程序由几十上百个源文件编译而来,每个源文件只生成了自己的那一小块。把它们拼成一个整体、让互相的调用都对上号的,就是链接(linking,把不同代码和数据收集、组合成一个可执行文件的过程)和链接器(linker,干这件事的程序,GCC 体系里是 ld)。说人话:编译是各自为战,链接是战后收编。链接器有两大任务——符号解析(symbol resolution)和重定位(relocation)。

先造一个最小案例。两个文件:math.cpp 定义计算平方的函数,use_math.cpp 调用它。注意 use_math.cpp 里只有一行声明(declaration,只说明"存在这么个函数")而没有定义(definition,函数体本身)——这正是链接器要解决的第一个问题:谁来兑现这张"欠条"?

// math.cpp —— 定义(definition):函数体在这里
int square(int x) {
    return x * x;
}

// use_math.cpp —— 只有声明(declaration),没有定义
#include <cstdio>
int square(int x);       // 声明:告诉编译器"有这函数",但没给函数体

int main() {
    std::printf("%d\n", square(6));
    return 0;
}
# 先把两个源文件分别编译成目标文件,再链接(实测通过,输出 36)
g++ -c math.cpp -o math.o
g++ -c use_math.cpp -o use_math.o
g++ use_math.o math.o -o app
./app
36

任务一:符号解析。符号(symbol)就是函数名、变量名在二进制世界里的"身份证"——编译时,源文件里的每个函数和全局变量都会被登记成一个符号,记进目标文件的符号表(symbol table,一张"名字 → 类型/地址"的登记簿)。链接时,use_math.o 引用了 square 却没定义它,math.o 定义了 square 却没被引用——符号解析做的,就是把每个"引用"和它的"定义"对上号。用 nm(列出目标文件符号表的工具)看这两个文件,一目了然:

# nm 列出符号表:U = undefined(未定义引用),T = 已定义的代码符号
$ nm use_math.o
                 U __Z6squarei        # square(int) 的"修饰名"——有人引用,没人定义
0000000000000000 T _main              # main 已定义,住在 .text 节
                 U _printf            # 连 printf 也是未定义——它住在 C 标准库里

$ nm math.o
0000000000000000 T __Z6squarei        # 定义在这里!符号解析就是把它俩对上号
nm 符号类型含义说人话
Uundefined欠条:有人引用,还没人兑现
Ttext(代码节)已定义函数定义,链接时可以被"兑付"
D已初始化的全局数据带初值的全局变量
Bbss(未初始化数据)不带初值的全局变量,运行时清零
小写(d/t/b)局部符号static 修饰,只在本文件可见(4.3 节见它)

任务二:重定位。编译器和汇编器生成代码时有个偷懒的前提:每个 .o 都假设自己从地址 0 开始。可两个 .o 拼在一起,不可能都住在地址 0。所以链接器把每个符号定义安排到最终的内存位置,再回去修改所有引用它的地方,让它们指向这个新位置——这个"改地址"的过程就是重定位。名字里的"位"是位置的位:符号解析回答"你是谁",重定位回答"你在哪"

# 链接前:use_math.o 里对 square 的调用是个占位地址
use_math.o(从地址 0 开始)          math.o(也从地址 0 开始)
  0x0000: call 0x00000000          0x0000: square 的机器指令...

# 链接后:合并进同一个地址空间,引用被改写为真实地址
可执行文件 app
  0x1000: call 0x1040  ->  square 的代码被安排在 0x1040,引用已改指
  0x1040: square 的机器指令...
# 链接器的完整工作流(把"素材块"按规则拼成成品)
# ① 收集:把命令行上的 .o 和库里的 .o 全部收进来
# ② 符号解析:把每个引用和定义对上号(对不上的 = undefined reference)
# ③ 重定位:合并各节,给符号分配最终地址,改写所有引用点
# ④ 产出:可执行目标文件(或共享库)
输入(.o, .a, .so) --> 链接器 --> 输出(可执行文件/共享库)
福尔摩斯提示

记住这一节的"对子":符号解析 = 对号入座(谁引用了谁),重定位 = 改地址(都搬去最终位置)。本章后面所有报错,本质都是这两个任务中某一步出了岔子——解析失败叫 undefined reference,解析撞车叫 multiple definition,全都能归到这张图上。

注意:链接器对 C++ 语义一窍不通——它只看修饰后的符号名。C++ 编译器会把函数名"改名"(name mangling,把参数类型编码进名字里),所以 square(int) 在符号表里是 _Z6squarei。这也是重载函数能共存的原因:foo(int)foo(double) 修饰后是两个不同符号,链接器把它们当两个毫不相干的名字。反过来,C 函数没有修饰名——这就是 C++ 调用 C 库时要用 extern "C" 的原因,否则两边对不上号(第 4.4 节会见到它的报错形态)。

4.2 链接发生的三个时机:编译时 / 加载时 / 运行时

链接不一定非得发生在"编译那一下"。素材里说得明白:链接可以发生于编译时(源代码被翻译成机器代码时),也可以发生于加载时(程序被加载器装入内存执行时),甚至发生于运行时(由应用程序自己来执行)。三个时机对应三种链接形态,也对应三种不同的"打包程度":编译时链接把一切钉死,加载时链接留一半到启动时再解,运行时链接则把决定权交给程序本身。

# ① 编译时链接:链接器在生成可执行文件时,把静态库(.a)里的成员并入
g++ use_math.o libmath.a -o app

# ② 加载时链接:可执行文件里只记一个"待解析"标记,
#    程序启动时由动态链接器把共享库(.so)装进来并解析符号
g++ use_math.o -lmath -o app
ldd ./app        # Linux:查看这个程序加载时需要哪些共享库
otool -L app     # macOS:等价的查看命令

# ③ 运行时链接:程序自己调 dlopen 现装现用(第 5 章的主角)
void* h = dlopen("./libmath.so", RTLD_LAZY);
# 加载时链接的幕后流程(.so 的符号在第 6 章虚拟地址空间里继续展开):
./app --启动--> 加载器(loader) --> 动态链接器(ld-linux.so)
                 # 按可执行文件里的 NEEDED 列表逐个加载 .so
                 # 把 app 里未定义的符号与 .so 导出的符号对上号
                 # 全部就绪,才跳进 main()

眼见为实。上面链接出的 good 程序,用 otool -L 能看到它启动时要加载的动态库清单;在 Linux 上,等价的 ldd 会显示类似的结构——程序自己写的代码之外,libc 这类"基础设施"都是靠加载时链接进来的,这也解释了 4.1 节里 printf 为什么是 U 符号:它到加载那一刻才被兑现。

# macOS 实测:otool -L 显示 good 启动时要加载的动态库
$ otool -L good
good:
        /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 2100.43.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)

# Linux 上等价的 ldd(canonical 输出示例)
$ ldd ./app
        linux-vdso.so.1 (0x00007ffe2c3f9000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8e4a200000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f8e4a430000)
时机谁执行什么时候典型形态说人话
编译时链接器(ld)生成可执行文件那一刻静态库 .a 并入快递当场签收,全部钉死
加载时动态链接器程序启动、跳进 main 之前共享库 .so到货了才签收,边用边解
运行时应用程序自己程序运行中任意时刻dlopen / dlsym现用现买,还能退(卸载)
福尔摩斯提示

三选一的直觉:要部署简单、跑起来不依赖环境 → 编译时静态链接;要省内存、多程序共享代码、能升级库不重编 → 加载时共享库;要插件化、热插拔、程序自己决定装谁 → 运行时 dlopen。这三条线的具体手艺(ar 打包、-fPIC、dlopen 全家桶)都是第 5 章的主场,本章先记住"时机"这张地图。

注意:链接发生得越晚,错误暴露得越晚,代价越大。编译时链接的错误在你敲编译命令时就现形;加载时链接的错误拖到程序启动时才炸,典型的像 ./app: symbol lookup error: undefined symbol: square;运行时链接的错误则在运行中途才出现。福尔摩斯的建议:能早发现的问题不要拖到运行时——这也是为什么测试环境要尽量贴近生产环境(库版本、加载路径一致),否则"我这儿好好的"会成为排查时最头疼的干扰项。

4.3 为什么要懂链接:五个好处

对入门课堂里构造小程序的学生来说,链接器"安静地"处理一切,从来不是个议题——一个小程序就一两个文件,链接几乎不可能出错。可一旦进入真实项目,不懂链接的代价会成倍放大。素材给了五个好处:① 帮你构造大型程序——大型项目最常见的链接错误是"缺模块、缺库、库版本不兼容",不懂"链接器如何解析引用",这些报错永远像天书;② 帮你避免危险的编程错误——链接器在默认情况下会不动声色地放行某些错误的定义,让程序带着雷运行;③ 帮你理解作用域规则——全局/局部、static 到底意味着什么,答案不在语法书里,而在符号表里;④ 帮你理解系统概念——加载器、动态链接器、虚拟内存都绕着链接转;⑤ 帮你开发共享库——现代软件靠共享库在运行时升级、打补丁。②和③,我们用两个真实实验证明。

/* tent_a.c —— C 语言:两个文件都写"不带初始化"的全局变量 */
int g_counter;

/* tent_b.c */
#include <stdio.h>
int g_counter;
int main(void) {
    printf("%d\n", g_counter);
    return 0;
}
# 实测(本机):C 语言竟然链接成功,还打印了 0!
$ gcc tent_a.c tent_b.c -o tent && ./tent
0

# 同样的两个文件,用 g++ 编译:立刻报错(macOS 实测)
$ g++ tent_a.c tent_b.c -o tentcpp
duplicate symbol '_g_counter' in:
    .../tent_a-xxx.o
    .../tent_b-xxx.o
ld: 1 duplicate symbols

第二个实验:static 的作用域。两个文件都声明 static int counter——名字一模一样,但它们是两个完全独立的符号,谁也不知道谁的存在。用 nm 看,两个 .o 里各有一个小写 d(local,局部数据符号)的 counterstatic 把符号的"可见范围"锁死在本文件,这就是"内部链接"(internal linkage)在符号表里的样子。

// loc_a.cpp                     // loc_b.cpp
static int counter = 100;    static int counter = 1;   // 同名,互不相干

int bump_a() { return ++counter; }   int bump_b() { return counter * 10; }
# 实测:两个 .o 里各有各的 counter,地址不同,互不干扰
$ nm loc_a.o | grep counter
000000000000006c d __ZL7counter
$ nm loc_b.o | grep counter
0000000000000014 d __ZL7counter

# 把两个文件一起链接、运行(本机实测):
$ g++ loc_a.cpp loc_b.cpp -o loc && ./loc
203   # bump_a 的 counter 从 100 加到 102,bump_b 的 counter 一直是 1
好处说人话对应技能
① 构造大型程序模块拆开、库来复用,出错知道去哪查看懂链接报错、-l / -L 参数
② 避免危险编程错误链接器的"静默放行"可能埋雷C 的弱符号陷阱(本章 4.4/4.5)
③ 理解作用域规则全局/局部/static 的真相在符号表里nm 读符号、内部链接与外部链接
④ 理解系统概念加载器、动态链接器、虚拟内存互相咬合第 5、6 章的整条链路
⑤ 开发共享库运行时升级、插件化、热加载第 5 章:.a / .so / dlopen
福尔摩斯提示

把五个好处串成一句话:懂链接 = 大型程序不迷路 + 躲开静默埋雷 + 看懂作用域真相 + 打通系统概念 + 玩转共享库。你不需要成为链接器专家,但"链接器是怎么想问题的"这个视角,会让后面每一章的排错都少走弯路。

注意:C 语言那个"静默通过"不是福利,是雷区。两个文件都写不带初始化的 int g_counter;,C 编译器会把它们合并成一个 COMMON 符号(弱符号,多个定义取其一),链接器不警告——如果两个文件对它的初值各有期待,运行时就会悄悄出错,且极难调试。这正是素材里说的"错误的定义多个全局变量的程序将通过链接器而不产生任何警告"。而 C++ 选择了更严格的路:int g_counter; 在 C++ 里就是定义,两个定义直接报错(4.5 节的主角)。

4.4 实战排错一:undefined reference

现在把方法论用起来。福尔摩斯的原则:排除所有不可能,剩下的就是真相——排错第一步永远是"确认这是什么错误",而不是盯着报错乱猜。链接错误和编译错误长得像,性质完全不同:编译错误是"代码写错了"(语法/类型),链接错误是"找不着"(有引用,没定义)。下面这个例子,编译阶段安然无恙,链接阶段当场爆炸:

// undef.cpp —— 声明了 square,却从未定义它
#include <cstdio>

int square(int x);   // 只有声明,没有定义

int main() {
    std::printf("%d\n", square(5));
    return 0;
}
# 编译:没有任何问题(-c 只做到汇编,链接是后话)
$ g++ -c undef.cpp -o undef.o

# 链接:报错了。Linux(g++)的经典报错长这样:
$ g++ undef.o -o undef
/usr/bin/ld: undef.o: in function `main':
undef.cpp:(.text+0x14): undefined reference to `square(int)'
collect2: error: ld returned 1 exit status
# 本机 macOS(Apple clang)实测,同一个错误长这样:
$ g++ undef.cpp -o undef
Undefined symbols for architecture arm64:
  "square(int)", referenced from:
      _main in undef-309db3.o
ld: symbol(s) not found for architecture arm64
clang++: error: linker command failed with exit code 1 (use -v to see invocation)

两句话就能看懂报错:Linux 版直说 "undefined reference to `square(int)'",macOS 版说 "Undefined symbols: square(int), referenced from _main"——意思完全一样:main 里引用了一个符号,但链接器在所有输入里都找不到它的定义。排查思路按"排除不可能"逐条过:① 符号真的存在吗?nm 看目标文件,确认 U 符号是谁;② 定义在哪个文件/库里?全工程搜一遍定义;③ 定义所在的文件/库,链接命令带上没有?顺序对不对?(顺序问题 4.5 节专治);④ 名字对吗?注意 C++ 修饰名和 extern "C"⑤ 被宏/条件编译裁掉了?比如定义被 #ifdef 包住而宏没开。

# 排查第一刀:nm 看符号到底存不存在(本机实测)
$ nm undef.o
                 U __Z6squarei        # 果然:U 符号,引用存在,定义缺失
0000000000000000 T _main

# 排查第二刀:全工程搜定义(.cpp 里有没有 square 的函数体)
$ grep -rn "square" . --include=*.cpp

# 修复:补上定义(或把 libmath.a 加进链接命令),重编重链(实测输出 25)
$ g++ undef_fixed.cpp -o undef && ./undef
25
福尔摩斯提示

看到 _Z6squarei 这种"乱码"名字别慌,它是 square(int) 的修饰名(mangled name)。用 c++filt 一秒还原人话:c++filt _Z6squarei 输出 square(int)。注意 macOS 的符号表在修饰名前还会多加一个下划线(__Z6squarei),用 c++filt 时去掉一个前缀下划线再喂给它——这是平台差异,不是 bug。

注意:undefined reference 的报错信息里藏着"嫌疑清单",但别只盯着最后一行。常见的五种病因:声明了没定义库忘连(调用了函数却没用 -l)、库顺序错C/C++ 名字对不上(缺 extern "C")、条件编译把定义裁掉了。福尔摩斯式做法:把报错里的符号名记下来 → nm 确认引用 → 搜定义 → 核对链接命令,一次只改一处,改完立刻重链。永远不要靠"加一个 -l 试试"撞运气。

4.5 实战排错二:multiple definition 与链接顺序

第二个高频链接错误:multiple definition(多重定义,同一个符号被定义了不止一次)。它的报错逻辑和 undefined reference 正好相反——那是"要的没有",这是"给的太多"。最经典的场景:两个源文件都定义了同名全局变量(还记得 4.3 节 C++ 的严格规则吗?):

// dup_a.cpp —— 全局变量 g_counter 的定义之一
int g_counter = 0;

// dup_b.cpp —— g_counter 的另一个定义!
#include <cstdio>
int g_counter = 0;
int main() {
    std::printf("%d\n", g_counter);
    return 0;
}
# Linux(g++)经典报错(canonical 格式):
$ g++ dup_a.cpp dup_b.cpp -o dup
/usr/bin/ld: dup_b.o: in function `main':
dup_b.cpp:4: multiple definition of `g_counter'; dup_a.o:dup_a.cpp:1: first defined here
collect2: error: ld returned 1 exit status

# 本机 macOS 实测,同一个错误长这样:
$ g++ dup_a.cpp dup_b.cpp -o dup
duplicate symbol '_g_counter' in:
    .../dup_b-627e5f.o
    .../dup_a-de48df.o
ld: 1 duplicate symbols
clang++: error: linker command failed with exit code 1

修复的思路顺着"为什么会有两个定义"展开:① 本意是共享?留一个定义,其他文件用 extern 声明引用它;② 本意是各自独立?static 变成内部链接(见 4.3 节),或包进 namespace 用名字隔离;③ 是头文件里的定义被多个 .cpp 包含?把定义挪进 .cpp,头文件只留声明——这也是"头文件里不要放定义"这条纪律的底层原因。注意:多个 .cpp 包含同一头文件不会触发这个错误(预处理后每个编译单元各有一份拷贝);报错只发生在"不同编译单元各定义了一份"时。

第二个坑:链接顺序。Linux 的 GNU ld 对命令行上的文件做单遍、从左到右扫描:遇到目标文件(.o)全部并入;遇到静态库(.a)只抽取"当前尚未解析的引用"所需的成员,抽完即过、不回头。所以库必须放在引用它的目标文件之后——把 libmath.a 放到 use_math.o 前面,扫描到库时 square 还没被引用,库成员一个都不抽,等轮到 use_math.o 时引用才出现——结果就是 4.4 节的报错换了个马甲重新登场:

# 库放对位置:成功(实测输出 36)
$ g++ use_math.o libmath.a -o app && ./app
36

# 库放错位置(Linux):报 undefined reference!
$ g++ libmath.a use_math.o -o app
/usr/bin/ld: use_math.o: in function `main':
use_math.cpp:(.text+0x14): undefined reference to `square(int)'
collect2: error: ld returned 1 exit status
# 本机 macOS 实测:两种顺序居然都能过!(ld64 用全局符号表,不按顺序扫)
$ g++ libmath.a use_math.o -o bad && ./bad
36
福尔摩斯提示

两条实战纪律:① 源文件在前,库在后——写链接命令时把 .o/源文件放前面,-l 库统一放最后,这是 Linux 下的保命姿势;② 别拿 macOS 的宽容当通行证——同一个项目在 Linux 的 CI 上可能瞬间挂掉。库里成员互相依赖、两个库循环依赖(A 需要 B 的符号、B 又需要 A 的)时,还有 --start-group/--end-group 这类解法,第 5 章讲静态库时会系统展开。

注意:multiple definition 还有个隐蔽变体——头文件里定义了全局变量。假如 globals.h 里写了 int g_counter = 0;,两个 .cpp#include 它,预处理后两份定义进到两个编译单元,链接照样报 duplicate symbol。同理还有"两个库都定义了同名符号"(比如误装了同名库的两个版本)。记住口诀:定义进 .cpp,声明进头文件;全局变量少用,用了就加 static 或进 namespace

4.6 小结:链接器的世界地图

把这一章的碎片拼成一张图:编译四阶段走完,每个 .o 都是"从地址 0 开始、带着一堆欠条"的半成品;链接器收编它们,符号解析把欠条兑现(UT),重定位给每个人安排最终地址;成品可执行文件在加载时还要靠动态链接器再解一轮 .so 的账。整条链路里,链接器是承上启下的总装配车间——这也是为什么第 1 章那张流程图里,链接是最后一棒。

# 链接器世界地图:从源码到运行的完整链路
源码 --编译--> .o --符号解析+重定位--> 可执行文件 --加载--> 进程
      (第2/3章)    (本章 4.1)        (第5章:库)  (第6章:内存)

# 报错对照表(遇到就翻这里):
undefined reference / Undefined symbols  = 有引用,没定义        --> 4.4
multiple definition / duplicate symbol   = 有引用,定义多了      --> 4.5
库顺序错(Linux)                         = 扫描时机不对          --> 4.5
symbol lookup error(加载时)             = 运行时库对不上        --> 第5章
# 福尔摩斯式排错四步(链接错误专用):
1. 确认阶段:是 ld 的错,不是 cc1 的错(编译错误 vs 链接错误)
2. 确认符号:nm 看 U/T,这个符号到底有没有定义
3. 确认来源:定义在哪个文件/库?链接命令带上没有?顺序对不对?
4. 动手修复:补定义 / 加库 / 调顺序,一次改一处,改完立刻重链

下一站,第 5 章把"库"补完:静态库(.a)怎么打包、共享库(.so)怎么用 -fPIC-shared 生成、dlopen 怎么在运行时现装现用、binutils 工具箱怎么解剖一切目标文件——4.2 节那张"三个时机"的表,会在那里逐一落地成命令。到那时你会发现,本章学到的符号与重定位,正是理解库的全部地基。

福尔摩斯提示

收束全章:链接错误就三类——找不到、给多了、来晚了。找不到(undefined reference)查定义在不在;给多了(multiple definition)查为什么定义了两份;来晚了(链接顺序)查库放没放对位置。配合"排除不可能"四步法,任何链接报错都能在几分钟内定位。把这章的四步流程抄在便签上,第 5 章排错时你会感谢自己。

章末练习

练习 1:概念配对 入门

把术语与描述配对:① 符号解析;② 重定位;③ 符号表;④ 修饰名(mangled name)。候选描述:A. 把符号引用与符号定义对上号;B. 给符号分配最终地址并改写引用;C. "名字 → 类型/地址"的登记簿;D. C++ 把函数名编码进参数类型的产物。

提示

回顾 4.1 节的两个任务和 nm 输出;修饰名在 warning 里出现过(_Z6squarei)。

参考答案

① → A(对号入座);② → B(改地址);③ → C(登记簿);④ → D(_Z6squarei 就是 square(int) 的修饰名)。口诀:解析回答"你是谁",重定位回答"你在哪"。

练习 2:判读 nm 输出 进阶

下面是一份真实的 nm 输出(macOS 实测节选)。请回答:哪些符号是"有引用没定义"的?哪些已定义?_printf 为什么是未定义?

$ nm undef.o
                 U __Z6squarei
0000000000000000 T _main
                 U _printf
提示

U 和 T 分别代表什么?《printf 住在哪个库里》——4.2 节有线索。

参考答案

__Z6squarei(即 square(int))和 _printfU(未定义引用);_mainT(已定义代码符号)。_printf 之所以未定义,是因为它住在 C 标准库(libc/libSystem)里,要等链接(静态库并入或加载时动态链接)才被兑现——这正是 4.1 节 nm 里"欠条"的含义。

练习 3:链接错误分诊 进阶

三个报错分别是什么问题?怎么修?(a) undefined reference to `square(int)';(b) multiple definition of `g_counter';(c) Linux 下 g++ libmath.a use_math.o -o appundefined reference to `square(int)',但把顺序反过来就正常。

提示

(a)(b) 的区别是"找不到"与"给多了";(c) 的报错文本和 (a) 一样,但病因不同——想想 GNU ld 的单遍扫描。

参考答案

(a) 有引用没定义:补定义、加库,或核对 extern "C" / 拼写;(b) 定义了两份:留一份,其余用 extern 引用,或加 static / 进 namespace;(c) 是链接顺序问题:GNU ld 从左到右单遍扫描,遇到 libmath.asquare 的引用还没出现,成员没被抽取——把库放到引用它的目标文件之后即可。同样的命令在 macOS 上可能不报错(ld64 用全局符号表),这正是 4.5 节 tip 里"别拿 macOS 的宽容当通行证"。

练习 4:亲手复现三连错 挑战

在 Linux(或 macOS)上亲手制造并修复三个错误:① 写 undef.cpp(声明 square 不定义),记录编译与链接两个阶段的报错原文;② 写两个都定义 int g_counter = 0; 的文件,记录 multiple definition 报错;③ 用 ar rcs libmath.a math.o 打包 square,分别用 g++ use_math.o libmath.ag++ libmath.a use_math.o 链接,对比结果。最后用 nmc++filt 验证符号与修饰名。

提示

所有命令和报错样本都在 4.4/4.5 节;macOS 上若报 cstdio not found,是 SDK 路径问题(加 -I 指向 SDK 的 include 目录),与链接主题无关。

参考答案

① 编译(-c)无错,链接报 undefined reference to 'square(int)'(Linux)或 Undefined symbols: "square(int)"(macOS);② 报 multiple definition of 'g_counter'(Linux)或 duplicate symbol '_g_counter'(macOS);③ Linux 下顺序错误必挂、macOS 下两种顺序都过——把实测输出存下来,这就是你亲手验证的平台差异证据。nm 看到 U/T 符号,c++filt _Z6squarei 还原成 square(int),全流程闭环。