Appearance
符号解析与重定位
概念
上一篇把 .o 拆开看清了"半成品"的样子——它带着符号表(谁定义了什么、还缺什么)和重定位表(哪些位置要填)。
链接器(ld)的全部工作就是消化这两张表:
| 步骤 | 输入 | 输出 |
|---|---|---|
| ① 符号解析 | 每个 .o 的 .symtab | "每个未定义引用(UND)都配上了唯一一个定义" |
| ② 重定位 | 每个 .o 的 .rela.text 等 | 所有占位位置都填上了最终数值 |
两句话概括链接器的处境:
| 认知 | 说明 |
|---|---|
| ① 它只知道"名字",不知道"语义" | 两个 .o 里都叫 f 的函数,链接器没法判断哪个是你想要的——所以必须有一套"谁赢"的规则(强/弱符号) |
| ② 它必须"一次性定死所有地址" | 因为一条 call 指令里的偏移量,在链接完成后就必须是确定的数——这就是重定位表的使命 |
本篇在主线上的位置:
lang/20讲了四步流水线、lang/21讲了.o的结构,本篇讲第 ④ 步"链接"内部到底怎么算。这条链的下游(lang/23)会问:"能不能把这件事推迟到程序启动时再做?"——那就是动态链接。
原理
一、三类符号,与"强/弱"这套仲裁规则
从 .o 的 .symtab 视角看,每个全局名字落在三种情况里:
| 情况 | st_shndx | 例子 | 链接器要做什么 |
|---|---|---|---|
| ① 既定义又引用 | 有节索引 | main.c 里定义并调用 helper() | 用本文件的定义 |
| ② 只定义 | 有节索引 | 库里 foo() 的实现 | 被别人的 UND 认领 |
| ③ 只引用 | SHN_UNDEF | main.c 里调用 printf | 必须找到唯一一个定义 |
问题来了:如果两个 .o 都定义了 g,怎么办? ——C 用"强/弱符号"这套规则来仲裁:
| 类别 | 哪些定义算 | 说明 |
|---|---|---|
| 强符号(strong) | 函数定义、已初始化的全局变量 | int g = 10;、void f(void){} |
| 弱符号(weak) | 未初始化的全局变量(传统意义上);显式 __attribute__((weak)) 标记的定义 | int g; |
三条仲裁规则(背下来):
| 规则 | 结果 |
|---|---|
| ① 多个强符号同名 | 报错 multiple definition of 'g' |
| ② 一个强符号 + 若干弱符号 | 选强符号(弱符号被"覆盖",不报错) |
| ③ 全是弱符号 | 任选一个(不同链接器策略不同:有的选最大的、有的选先出现的) |
为什么要有"弱符号"这个设计? 两个真实用途:
| 用途 | 机制 |
|---|---|
| 库函数可被用户覆盖 | 库把某个函数声明成弱符号——用户自己定义了同名函数就"胜出"(__gmon_start__ 这类钩子就是这样) |
| "未初始化的全局变量"天然是弱符号 | 因为 int g; 与 int g; "意思相同",多个文件里各写一遍不该报错(见下面的坑) |
⚠️ 一个必须知道的新变化:GCC 10 起默认开启
-fno-common——过去"未初始化的全局变量是 COMMON/弱符号、可以重复定义"的做法,现在会直接报multiple definition。所以"把int g;写进头文件"从"能编过但很脏"变成了"直接链接失败"——这也是"头文件里只能放extern声明"这条纪律越来越硬的原因。
二、符号解析:按顺序单向扫描 + 库的"按需抽取"
链接器处理命令行参数是从左到右单向扫描的——它维护两个集合:
| 集合 | 含义 |
|---|---|
E(export/pulled) | 已经确定要链进来的 .o / 库成员 |
U(undefined) | 当前还没找到定义的符号 |
对每个输入的处理规则:
| 输入类型 | 处理方式 |
|---|---|
.o 文件 | 无条件拉进来(把它定义的加进 E,未定义的加进 U) |
.a 静态库 | 只抽取"能解决当前 U 里某个符号"的成员——不用的成员一个都不进 |
这个"按需抽取"直接推出两条实践纪律:
示例(lang/20 提过,这里说清原因):
bash
gcc main.o -lmylib # ✔ 扫描 main.o 时 U={square,...};轮到 mylib 时按需抽取
gcc -lmylib main.o # ✘ 扫描 mylib 时 U 还是空的 → 一个成员都不抽 → 之后 U 没人解决同类问题:静态库 A 依赖静态库 B 时,-lA -lB 能过、-lB -lA 可能不过;互相依赖时用 -Wl,--start-group -lA -lB -Wl,--end-group 让它在组内反复扫描。
三、重定位:三步走
重定位不是"填一个数"这么简单,它包含三件事(顺序固定):
| 步骤 | 名字 | 干什么 |
|---|---|---|
| ① | 合并节 | 所有 .o 的 .text 并成一个 .text,.data 并成一个 .data……同类的合并、各自保持内部顺序 |
| ② | 分配地址 | 按链接脚本决定每个节、每个符号的最终虚拟地址(默认 ET_EXEC 从 0x400000 起) |
| ③ | 执行重定位 | 遍历重定位表,按 r_info 指定的类型算出值,写进 r_offset 处 |
第 ③ 步的"算",就是几个量之间的加减:
| 记号 | 含义 | 谁来提供 |
|---|---|---|
S | 符号(Symbol)的最终地址 | 第 ② 步算出 |
A | 加数(Addend) | 重定位表里的 r_addend |
P | 被填位置本身的地址(Place) | 第 ② 步算出(注意:是"字段"的地址,不是"指令开头"的地址!) |
两类最常见的重定位:
| 类型 | 计算式 | 写几个字节 | 用途 |
|---|---|---|---|
R_X86_64_64 | S + A | 8 | 绝对地址(指针数组、long 常量表) |
R_X86_64_PC32 | S + A - P | 4 | PC 相对(同文件内引用、相邻代码) |
R_X86_64_PLT32 | S + A - P | 4 | 调用外部函数(经 PLT,见 lang/23) |
为什么 PC 相对要减 P? 因为 x86 的 call/jmp 用的是"相对下一条指令"的偏移:
为了让等式成立,A 必须等于 -4(4 就是那个偏移字段的长度)——这就是"addend = -4 不是魔法数"的由来。
四、把整件事画成一条流水线
① 读所有 .o 的 .symtab
│
├─ 建 U(未定义)与 E(已确定)
├─ 归档(.a)只抽"能解决 U"的成员 ── 库顺序敏感的原因
└─ 用强/弱规则仲裁同名符号 ── 冲突报 multiple definition
│
▼
② 合并同类节 + 分配地址(链接脚本)
.o#1 .text ─┐
.o#2 .text ─┼─→ 一个连续的 .text(每块起点地址已定)
.o#3 .text ─┘
│
▼
③ 遍历 .rela.text / .rela.data,按公式算值并写入
R_X86_64_PC32 → 写 S + A - P (4 字节)
R_X86_64_64 → 写 S + A (8 字节)
│
▼
可执行文件(所有地址已定,符号表/重定位表已被"消耗")一个常被忽略的事实:"链接期能算出地址"的前提是"程序被放在固定位置"。如果希望库能被任意程序共享、且加载到任意地址,就必须把"填地址"推迟到加载时——这正是 lang/23-dynamic.md 要讲的。
示例
例 1:强/弱符号的三种结局
任务:把三条仲裁规则跑一遍,看清"哪种情况报错、哪种情况静默选一个"。
c
/* ── 符号强弱的一手证据 ── */
int g_init = 10; /* 强:已初始化 */
int g_zero; /* 传统上是弱符号(COMMON) */
__attribute__((weak)) int g_weak = 20; /* 显式弱符号(即使已初始化) */
__attribute__((weak)) int choose(void) { return 1; } /* 弱函数 */
/* GCC 10 起 -fno-common 是默认值:
此时 int g_zero; 也被当成定义 → 两个文件各写一遍会报 multiple definition */python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
print("=== 什么算强、什么算弱 ===")
print(" " + pad("写法", 34) + pad("强弱", 8) + "说明")
KINDS = [
("void f(void) { }", "强", "函数定义"),
("int g = 10;", "强", "已初始化的全局变量"),
("__attribute__((weak)) int g;", "弱", "显式标弱"),
("int g;", "弱*", "传统 COMMON;GCC10+ 默认当定义处理"),
]
for a, b, c in KINDS:
print(" " + pad(a, 34) + pad(b, 8) + c)
print()
print("=== 三个场景的结局 ===")
SCENES = [
("A:一个强 + 一个弱", ["强", "弱"], "选强符号(g 用 a.o 的),静默通过"),
("B:两个强", ["强", "强"], "报 multiple definition of 'g'"),
("C:全是弱", ["弱", "弱"], "任选一个(策略依赖链接器),不报错"),
]
for title, defs, out in SCENES:
strong = defs.count("强")
if strong >= 2:
verdict = "✘ 报错"
elif strong == 1:
verdict = "✔ 通过(取强)"
else:
verdict = "✔ 通过(任取)"
print(" " + pad(title, 22) + pad("a.o=%s b.o=%s" % (defs[0], defs[1]), 20)
+ pad(verdict, 16) + out)
print()
print("=== 为什么“同名变量不该报错”这个期待会落空 ===")
print(" 传统 C 里 int g; 叫“暂定定义(tentative definition)”,")
print(" 编译器把它放进 COMMON(弱符号),链接器发现多个就直接合并成一个大块。")
print(" 但 GCC 10 起默认 -fno-common,暂定定义变成普通定义:")
print(" 两个 .c 各写 int g; → multiple definition of 'g'")
print(" 唯一正确的做法:头文件里写 extern int g;(声明),某一个 .c 里写 int g;(定义)")
print()
print("=== 强符号覆盖弱符号的实用价值 ===")
print(" 库把 foo 声明为 weak:用户自己写了 foo,用户版胜出(无需修改库)")
print(" 这就是 malloc 钩子、日志钩子、__gmon_start__ 这类机制的基础")预期输出:
=== 什么算强、什么算弱 ===
写法 强弱 说明
void f(void) { } 强 函数定义
int g = 10; 强 已初始化的全局变量
__attribute__((weak)) int g; 弱 显式标弱
int g; 弱* 传统 COMMON;GCC10+ 默认当定义处理
=== 三个场景的结局 ===
A:一个强 + 一个弱 a.o=强 b.o=弱 ✔ 通过(取强) 选强符号(g 用 a.o 的),静默通过
B:两个强 a.o=强 b.o=强 ✘ 报错 报 multiple definition of 'g'
C:全是弱 a.o=弱 b.o=弱 ✔ 通过(任取) 任选一个(策略依赖链接器),不报错
=== 为什么“同名变量不该报错”这个期待会落空 ===
传统 C 里 int g; 叫“暂定定义(tentative definition)”,
编译器把它放进 COMMON(弱符号),链接器发现多个就直接合并成一个大块。
但 GCC 10 起默认 -fno-common,暂定定义变成普通定义:
两个 .c 各写 int g; → multiple definition of 'g'
唯一正确的做法:头文件里写 extern int g;(声明),某一个 .c 里写 int g;(定义)
=== 强符号覆盖弱符号的实用价值 ===
库把 foo 声明为 weak:用户自己写了 foo,用户版胜出(无需修改库)
这就是 malloc 钩子、日志钩子、__gmon_start__ 这类机制的基础三条结论:
| 结论 | 说明 |
|---|---|
| "强强冲突"才报错 | 一强多弱选强、全弱任选——所以"能编过"不代表"两个定义都生效了" |
| 未初始化全局变量的身份变了 | 传统是 COMMON/弱符号,GCC 10 起 -fno-common 默认开启 → 变成普通定义 |
| 弱符号是"可被覆盖"的机制 | 它是库允许用户接管某个符号的标准手段 |
例 2:算出一条 call 指令的最终机器码
任务:把 S + A - P 一步步算出来,并验证"运行时能否跳到正确地址"。
python
import struct, unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
TEXT_BASE = 0x401000 # 链接脚本给 .text 的起始地址
MAIN_SIZE = 0x20 # main.o 的 .text 大小
CALL_OFF = 0x12 # main 里 call 指令的“操作码”在 .text 内的偏移
FOO_OFF = MAIN_SIZE # foo 的 .text 紧跟在 main 之后合并
FOO_ADDR = TEXT_BASE + FOO_OFF # S:符号 foo 的最终地址
P = TEXT_BASE + CALL_OFF + 1 # P:被填的 4 字节“字段”地址(跳过 1 字节 e8)
A = -4 # A:addend
print("=== ① 合并后的 .text(两块 .o 的内容首尾相接) ===")
print(" " + pad("地址", 12) + pad("内容", 42) + "来自")
ROWS = [
(TEXT_BASE, "main 的机器码(偏移 0x00~0x11)", "main.o"),
(TEXT_BASE + CALL_OFF, "e8 __ __ __ __ ← call foo(待填)", "main.o"),
(TEXT_BASE + CALL_OFF + 5,"main 剩余机器码", "main.o"),
(FOO_ADDR, "foo 的机器码(foo 函数入口 = 目标)", "foo.o"),
]
for addr, what, src in ROWS:
print(" " + pad("0x%06x" % addr, 12) + pad(what, 42) + src)
print()
print("=== ② 定位三个量 ===")
print(" S(符号地址) = .text 起始 + foo 的偏移 = 0x%06x + 0x%02x = 0x%06x = %d"
% (TEXT_BASE, FOO_OFF, FOO_ADDR, FOO_ADDR))
print(" P(字段地址) = 指令起点 + 1(跳过 opcode e8) = 0x%06x + 1 = 0x%06x = %d"
% (TEXT_BASE + CALL_OFF, P, P))
print(" A(addend) = %d" % A)
print(" ★ P 是“字段地址”,不是“指令起点”——这是最容易算错的地方")
print(" 指令起点 0x%06x 与 P 0x%06x 差 1 字节(e8 的长度)"
% (TEXT_BASE + CALL_OFF, P))
print()
print("=== ③ 按 R_X86_64_PC32 计算:值 = S + A - P ===")
val = S_A_P = FOO_ADDR + A - P
val_u = val & 0xFFFFFFFF
print(" S + A - P = 0x%06x + (%d) - 0x%06x = %d" % (FOO_ADDR, A, P, val))
print(" 写成 4 字节小端:%s" % " ".join("%02x" % b for b in struct.pack("<I", val_u)))
print(" 整条指令 = e8 %s" % " ".join("%02x" % b for b in struct.pack("<I", val_u)))
print()
print("=== ④ 反汇编核对:CPU 会跳到哪? ===")
next_pc = P + 4
print(" 反汇编: 0x%06x: e8 %s call 0x%06x"
% (TEXT_BASE + CALL_OFF, " ".join("%02x" % b for b in struct.pack("<I", val_u)), FOO_ADDR))
print(" CPU 的算法:目标 = 下一条指令地址 + 偏移")
print(" = 0x%06x + %d = 0x%06x" % (next_pc, val, next_pc + val))
print(" 期望目标 = 0x%06x → %s" % (FOO_ADDR, "一致" if next_pc + val == FOO_ADDR else "不一致"))
print()
print("=== ⑤ 如果 addend 忘了 -4 会怎样(经典 bug) ===")
bad = FOO_ADDR + 0 - P
print(" 值 = S + 0 - P = 0x%06x - 0x%06x = %d" % (FOO_ADDR, P, bad))
print(" CPU 目标 = 0x%06x + %d = 0x%06x" % (next_pc, bad, next_pc + bad))
print(" 偏离 %d 字节(正好是那个偏移字段的长度)→ 跳到指令中间,程序崩" % (bad - val))
print()
print("=== ⑥ 对照:绝对地址重定位 R_X86_64_64(值 = S + A) ===")
ptr_val = FOO_ADDR + 0
print(" 放在 .data 里的一个函数指针:值 = S + A = 0x%06x" % ptr_val)
print(" 写成 8 字节小端:%s" % " ".join("%02x" % b for b in struct.pack("<Q", ptr_val)))
print(" → 绝对重定位不需要减 P,因为存的就是“完整地址”而不是“相对偏移”")预期输出:
=== ① 合并后的 .text(两块 .o 的内容首尾相接) ===
地址 内容 来自
0x401000 main 的机器码(偏移 0x00~0x11) main.o
0x401012 e8 __ __ __ __ ← call foo(待填) main.o
0x401017 main 剩余机器码 main.o
0x401020 foo 的机器码(foo 函数入口 = 目标) foo.o
=== ② 定位三个量 ===
S(符号地址) = .text 起始 + foo 的偏移 = 0x401000 + 0x20 = 0x401020 = 4198432
P(字段地址) = 指令起点 + 1(跳过 opcode e8) = 0x401012 + 1 = 0x401013 = 4198419
A(addend) = -4
★ P 是“字段地址”,不是“指令起点”——这是最容易算错的地方
指令起点 0x401012 与 P 0x401013 差 1 字节(e8 的长度)
=== ③ 按 R_X86_64_PC32 计算:值 = S + A - P ===
S + A - P = 0x401020 + (-4) - 0x401013 = 9
写成 4 字节小端:09 00 00 00
整条指令 = e8 09 00 00 00
=== ④ 反汇编核对:CPU 会跳到哪? ===
反汇编: 0x401012: e8 09 00 00 00 call 0x401020
CPU 的算法:目标 = 下一条指令地址 + 偏移
= 0x401017 + 9 = 0x401020
期望目标 = 0x401020 → 一致
=== ⑤ 如果 addend 忘了 -4 会怎样(经典 bug) ===
值 = S + 0 - P = 0x401020 - 0x401013 = 13
CPU 目标 = 0x401017 + 13 = 0x401024
偏离 4 字节(正好是那个偏移字段的长度)→ 跳到指令中间,程序崩
=== ⑥ 对照:绝对地址重定位 R_X86_64_64(值 = S + A) ===
放在 .data 里的一个函数指针:值 = S + A = 0x401020
写成 8 字节小端:20 10 40 00 00 00 00 00
→ 绝对重定位不需要减 P,因为存的就是“完整地址”而不是“相对偏移”三条结论:
| 结论 | 说明 |
|---|---|
P 是"字段地址" | 不是"指令起点"——e8 占 1 字节,所以 P = 指令起点 + 1;算错这里,整条链接都错 |
A = -4 是"长度补偿" | 因为硬件用的是"相对下一条指令",而公式里减的是 P,差的就是字段长度 4 |
| PC 相对与绝对的区别是"存什么" | PC32 存"相对偏移"(省空间、可重定位);64 存"完整地址"(不需要减 P) |
例 3:静态库的"按需抽取"与顺序敏感性
任务:模拟链接器扫库的过程,看清"库放前面就一个成员都不抽"。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
# 静态库成员:名字、定义的符号、自己还缺的符号
ARCHIVE = [
("math.o", ["square"], ["log_msg"]),
("util.o", ["log_msg", "helper"], ["strlen"]),
("str.o", ["strlen"], []),
]
MAIN_UNDEF = ["square", "helper"] # main.o 引入的未定义符号
def scan_archive(U, taken, trace):
"""对同一个归档反复扫描,直到不再有新的成员被抽取(ld 的实际行为)"""
changed = True
while changed:
changed = False
for name, defs, undef in ARCHIVE:
if name in taken:
continue
if set(defs) & U:
taken.append(name)
U -= set(defs)
U |= set(undef)
trace.append((name, sorted(U)))
changed = True
return U, taken
def link(order):
U, taken, trace, log = set(), [], [], [] # 一开始什么都没拉进来,U 是空的
for item in order:
if item == "main.o":
U |= set(MAIN_UNDEF)
log.append("拉入 main.o(无条件) → U = {%s}" % ", ".join(sorted(U)))
else:
if not U:
log.append("扫描 lib.a:U 为空 → 一个成员都不抽")
continue
U, taken = scan_archive(U, taken, trace)
for name, u in trace:
log.append(" 抽取 %s → U = %s"
% (name, "{" + ", ".join(u) + "}" if u else "空"))
log.append("扫描 lib.a 结束,已抽 %d 个成员" % len(taken))
return U, taken, log
for order in (["main.o", "lib.a"], ["lib.a", "main.o"]):
U, taken, log = link(order)
print("=== 命令:gcc %s -o app ===" % " ".join(order))
for line in log:
print(" " + line)
print(" 剩余未定义 = %s" % ("{" + ", ".join(sorted(U)) + "}" if U else "空"))
print(" 结果:%s" % ("✔ 链接成功" if not U else "✘ undefined reference to " +
"、".join(sorted(U))))
print()
print("=== 一条经验规则 ===")
print(" " + pad("情形", 26) + "做法")
RULES = [
("库总是放在最后", "引用它的 .o / .a 写在前面"),
("多个静态库互相依赖", "-lA -lB -lA 或 --start-group -lA -lB --end-group"),
("只想链一次", "--start-group 让组内反复扫描到不再变化"),
]
for a, b in RULES:
print(" " + pad(a, 26) + b)预期输出:
=== 命令:gcc main.o lib.a -o app ===
拉入 main.o(无条件) → U = {helper, square}
抽取 math.o → U = {helper, log_msg}
抽取 util.o → U = {strlen}
抽取 str.o → U = 空
扫描 lib.a 结束,已抽 3 个成员
剩余未定义 = 空
结果:✔ 链接成功
=== 命令:gcc lib.a main.o -o app ===
扫描 lib.a:U 为空 → 一个成员都不抽
拉入 main.o(无条件) → U = {helper, square}
剩余未定义 = {helper, square}
结果:✘ undefined reference to helper、square
=== 一条经验规则 ===
情形 做法
库总是放在最后 引用它的 .o / .a 写在前面
多个静态库互相依赖 -lA -lB -lA 或 --start-group -lA -lB --end-group
只想链一次 --start-group 让组内反复扫描到不再变化三条结论:
| 结论 | 说明 |
|---|---|
| 静态库是"按需抽取"的 | 只有"能解决当前 U"的成员才会被拉进来——这就是它比"全链进来"省空间的全部理由 |
| 顺序敏感是机制必然 | U 为空时扫库等于白扫——所以库必须放在引用它的目标文件之后 |
| 组内反复扫描解决互相依赖 | --start-group/--end-group 让组内多轮扫描直到收敛 |
考点
考点
1. 链接器的两件事(必背)
- ① 符号解析:把每个"未定义引用(
UND)"配上唯一一个"定义"; - ② 重定位:合并同类节 → 分配地址 → 按重定位表填值;
- 判据:"编译过、链接不过"永远是"跨文件"问题——名字对不上,或没参与链接。
2. 强/弱符号三规则
| 情形 | 结局 |
|---|---|
| 多个强符号同名 | 报 multiple definition of 'x' |
| 一强 + 若干弱 | 取强符号,弱符号被覆盖(不报错) |
| 全弱 | 任选一个(策略依赖链接器),不报错 |
- 强符号:函数定义、已初始化的全局变量;
- 弱符号:未初始化的全局变量(传统 COMMON)、显式
__attribute__((weak)); - ⚠️ GCC 10 起默认
-fno-common:未初始化全局变量不再是 COMMON/弱符号,"两个文件各写一遍int g;"会直接报重定义——所以头文件里必须写extern int g;。
3. 静态库的按需抽取(易考)
.a是.o的归档;链接器只抽取"能解决当前未定义符号"的成员;- 因为是从左到右单向扫描,所以"库必须放后面"(
main.o -lmylib✔ /-lmylib main.o✘); - 静态库之间互相依赖时:用
--start-group ... --end-group让它反复扫描; - 反面:如果只是想"把所有目标文件都链进来",应该直接列
.o(无条件拉入),而不是打包成.a。
4. 重定位的三个量(核心计算)
| 记号 | 名字 | 来源 |
|---|---|---|
S | 符号的最终地址 | 合并与分配地址阶段算出 |
A | addend | 重定位表里的 r_addend |
P | 被填字段本身的地址 | 分配地址阶段算出 |
R_X86_64_64:值 =S + A(写 8 字节,绝对地址);R_X86_64_PC32:值 =S + A - P(写 4 字节,PC 相对);R_X86_64_PLT32:S + A - P,只是S取的是 PLT 表项地址(见lang/23)。
5. 两个最容易算错的地方
P是"字段地址",不是"指令起始地址":x86 的call是e8+ 4 字节偏移,所以P = 指令起点 + 1(若指令有其他前缀,还要加上前缀长度);addend常为-4:因为 CPU 的相对寻址基准是"下一条指令"(P + 4),而公式里减的是P,差的正好是 4;- 验证方法:算完后用
目标 = (P + 4) + 值反推,看是否等于S + A。
6. 两类链接错误(诊断题常考)
| 报错 | 原因 | 修法 |
|---|---|---|
undefined reference to 'f' | ① 没写实现 ② 实现所在 .o/.a 没加进命令 ③ 库顺序不对 ④ 名字拼写不一致 | 补实现 / 加输入 / 调顺序 / 查拼写 |
multiple definition of 'g' | ① 变量定义写在头文件里 ② 同一个 .o 被列两次 ③ 同名函数在两个文件里都定义(且都是强符号) | 头文件改 extern,定义收到一个 .c |
7. 与上下文的关系(易错)
- 重定位在"链接期"完成(静态场景)——运行期不再改;要"推迟到加载期"就得用动态链接或 PIC;
.o的地址是"节内偏移",只有链接后才变成虚拟地址;- 链接器不做"语法/类型检查"——它只看名字(这也是 C 没有名字修饰、拼错就直接找不到的根本原因,与 C++ 的重载机制对照);
- 链接器不认识"你想要的语义":同名不同义(比如两个不同用途的
init)它无法分辨——所以项目里要避免全局名字撞车(用static限制作用域)。
8. 高频易错点
static函数/变量不会进全局符号表(STB_LOCAL)——所以两份都叫helper的static函数不会冲突;__attribute__((weak))也能标函数——这是"库函数可被用户覆盖"的实现基础;-Wl,是"把后面的选项传给链接器"(gcc -Wl,--start-group);.a里面每个成员是一个.o——所以.a是"打包",不是"合并";- 链接顺序也可能影响"弱符号任选"的结果——"全弱"时选哪一个,与你把谁写在前面有关(不要把"能编过"当成"行为符合预期")。
小结
- 链接 = 符号解析 + 重定位:先把名字接上,再把地址填上。
- 符号分强弱:函数与已初始化全局变量是强符号,未初始化全局变量传统上是弱符号;强强冲突报错、一强多弱选强、全弱任选。
- GCC 10 起
-fno-common默认开启:int g;不再"多个文件各写一遍都合法"——头文件只用extern声明。 - 符号解析是单向扫描:
.o无条件拉入、.a按需抽取——这就是"库要放后面"以及--start-group的存在理由。 - 重定位三步:合并同类节 → 分配地址 → 按重定位表填值。
- 两个公式:
R_X86_64_64写S + A;R_X86_64_PC32写S + A - P。 - 两个易错点:
P是"字段地址"(call要 +1 跳过e8);A = -4来自"CPU 相对下一条指令"。 - 两类错误:
undefined reference(缺定义/顺序不对)、multiple definition(重复定义)。 - 链接器的局限:它只认名字、不认语义——同名不同义它分辨不了,所以要把不该外露的东西
static掉。
回到主线:L2 讲"指令怎么执行",lang/20 讲"指令怎么生成",lang/21 讲"半成品的形状"。本篇回答的是"半成品怎么拼成品"——也就是:C 里的一个函数名,是怎么变成一条 call 指令里那个确定的偏移量的。
下一篇要问的是一件很有野心的事:既然链接器能"填地址",那能不能把这件事推迟到程序启动时做?——这样一来:一个库可以被所有程序共用(省内存)、升级库不用重编程序、库能被加载到任意地址。代价是什么?
GOT和PLT是干什么的?——23-dynamic.md。
下一篇:静态链接与动态链接
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。