深入理解 C++ 链接错误:vtable、名字修饰与 relocation
本文基于一次真实的编译报错,系统解释
_ZTV、_ZN、relocation 以及"为什么没有定义虚函数却报 vtable 未定义"等问题。
问题现象
编译时报错如下:
bash
g++ main.cpp
/usr/bin/ld: /tmp/ccWhQak0.o: warning: relocation against `_ZTV4Base' in read-only section `.text._ZN4BaseC2Ev[_ZN4BaseC5Ev]'
/usr/bin/ld: /tmp/ccWhQak0.o: in function `Base::Base()':
main.cpp:(.text._ZN4BaseC2Ev[_ZN4BaseC5Ev]+0xf): undefined reference to `vtable for Base'
/usr/bin/ld: /tmp/ccWhQak0.o: in function `Derived::Derived()':
main.cpp:(.text._ZN7DerivedC2Ev[_ZN7DerivedC5Ev]+0x1f): undefined reference to `vtable for Derived'
/usr/bin/ld: warning: creating DT_TEXTREL in a PIE
collect2: error: ld returned 1 exit status
根因非常直接:Base::f() 和 Derived::f() 都只有声明,没有定义。
下面结合实际目标文件中的符号与重定位记录,逐条解释这条链接消息,并纠正一些常见误解。
1. _ZTV4Base 是什么?
Linux 上 GCC 通常遵循 Itanium C++ ABI 的名字修饰规则,把 C++ 名称编码成链接器使用的符号名:
_Z TV 4 Base
│ │ │ └── 类名
│ │ └──── 后面的名称长度为 4
│ └─────── 虚函数表的特殊名称编码
└────────── C++ 修饰名称的前缀
因此:
| 修饰名 | 含义 |
|---|---|
_ZTV4Base |
vtable for Base |
_ZTV7Derived |
vtable for Derived |
_ZTV4Base 表示 Base 的虚函数表这个符号,而不是某个对象内部的虚表指针。
对象内部的虚表指针保存的是指向相应虚函数表中某个位置的地址。
2. _ZN 和 _ZTV 有什么区别?
它们属于同一套名字编码规则,但描述的对象不同:
| 编码 | 含义 | 示例 |
|---|---|---|
_ZTV... |
虚函数表的特殊符号名 | _ZTV4Base → Base 的虚函数表 |
_ZN...E... |
使用嵌套名称编码的符号(类成员、命名空间成员等) | _ZN4BaseC2Ev → Base::Base() 的一个构造函数版本 |
以 _ZN4BaseC2Ev 为例,拆解如下:
_Z N 4Base C2 E v
│ │ │ └── 无参数
│ │ └───── 嵌套名称结束
│ └──────── 基类子对象构造函数版本
└────────────────── 嵌套名称开始
这里的 C2 有具体含义。ABI 区分不同用途的构造函数:
- C1:构造完整对象的版本
- C2:构造基类子对象的版本
在这个简单例子中,两种版本可以共用同一份机器码。
报错中的:
.text._ZN4BaseC2Ev[_ZN4BaseC5Ev]
前半部分是构造函数的代码节名。方括号里的 _ZN4BaseC5Ev 是 GCC 使用的构造函数 COMDAT 节组标识 ,供链接器组织、去重相关代码使用;它不是 另一个报错函数,也不是某种调用顺序。
注意 :
_ZN中的N表示"嵌套名称"(Nested name),不是 "函数"的标记;末尾的v表示无参数,不表示 构造函数返回void。
3. relocation 到底是什么意思?
relocation 通常译为 重定位,可以理解为:
根据符号的最终位置,修正机器码或数据中与地址有关的字段。
比如构造函数需要取得 Base 虚函数表的地址,但生成 .o 文件时,编译器还不知道虚函数表最终会放在哪里,因此留下:
- 机器指令中的待填字段
- 重定位记录:这个字段需要根据
_ZTV4Base的位置计算
链接器安排好代码和数据的位置后,再根据记录修正这个字段。
修正的值不一定是完整地址,也可能是相对距离。你的程序实际用的就是后者。Base 构造函数中的指令类似:
asm
c: 48 8d 15 00 00 00 00 lea 0x0(%rip), %rdx
f: R_X86_64_PC32 _ZTV4Base+0xc
含义如下:
lea用于计算虚表相关地址00 00 00 00所在字段需要链接器修正R_X86_64_PC32表示 32 位 PC 相对重定位_ZTV4Base是计算时需要的目标符号f:表示待修正字段位于该代码节偏移0xf的位置
这恰好对应报错中的:
main.cpp:(...+0xf): undefined reference to `vtable for Base'
所以 +0xf 是代码节中的字节偏移,不是源代码行号,也不是虚表中的第几个条目。
4. 如何完整理解这句 warning?
/usr/bin/ld: /tmp/ccfsaz2x.o: warning:
relocation against `_ZTV4Base' in read-only section
`.text._ZN4BaseC2Ev[_ZN4BaseC5Ev]'
逐项解释:
| 部分 | 含义 |
|---|---|
/usr/bin/ld |
输出消息的程序是链接器 |
/tmp/ccfsaz2x.o |
g++ 编译源文件时生成的临时目标文件 |
warning |
这条消息本身是警告 |
relocation against '_ZTV4Base' |
存在一个引用 Base 虚函数表符号的重定位 |
in read-only section |
需要修正的位置位于只读节中 |
.text._ZN4BaseC2Ev[...] |
这个位置在 Base 构造函数的机器码里 |
这里特别容易混淆:
"只读节"说的是引用虚表的机器指令所在的节,而不是在描述虚表所在的节。
至于为什么警告,需要区分两种情况:
- 链接时重定位:链接器修改正在生成的文件里的指令字段。这很正常,不违反程序运行时的只读权限。
- 运行时动态重定位:程序加载时还需要修改那些字段。如果修改位置在只读代码区域,就涉及 text relocation,链接器会对此发出警告。
你的环境默认生成 PIE(位置无关可执行文件)。在这次链接失败过程中,未能解析的虚表引用触发了上述警告,并伴随:
warning: creating DT_TEXTREL in a PIE
DT_TEXTREL 是 ELF 动态信息中的一个标记,表示存在需要修改只读区域的动态重定位。
但这次链接最终已经失败,不能据此认为成功生成了一个带 DT_TEXTREL 的可执行文件。真正让链接失败的是随后两条 undefined reference。
补充纠正:目标文件的只读代码节里存在重定位,本身很正常。这里的警告涉及链接器在生成 PIE 时对动态重定位的处理,不能理解为"只读代码里有地址需要修正就一定报错"。
5. 为什么没有定义 f(),却报"没有虚函数表"?
这里需要引入 key function(关键虚函数) 规则。
在你这个例子里:
cpp
struct Base {
virtual void f();
};
struct Derived : Base {
void f() override;
};
Base::f() 和 Derived::f() 分别是各自类的关键虚函数 :它们是该类中第一个在类定义处非内联、非纯虚的虚函数。
按照这里使用的 ABI 规则,编译器通常在定义关键虚函数的翻译单元中生成该类的虚函数表。
但你没有定义这两个函数,因此:
- 没有
Base::f()的定义 → 没有相应的 Base 虚表定义 - 没有
Derived::f()的定义 → 没有相应的 Derived 虚表定义
同时,new Derived 又使构造函数需要引用这两个虚表。于是出现"有引用、没有定义"的链接错误。
实际检查目标文件也印证了这一点:
U vtable for Base
U vtable for Derived
U 表示该目标文件引用了符号,但没有定义它。
更准确地说:编译器按规则生成虚表,链接器负责解析对虚表的引用;不是链接器发现虚函数缺失之后,才尝试创建虚表。
6. 是不是先发现 Base,再发现 Derived?
从这次诊断的打印顺序看,先报告了 Base,随后报告了 Derived。
但这不能进一步推断为"链接器先检查基类,再检查派生类"。
链接器主要处理节、符号和重定位记录,并不是在按照 C++ 继承关系逐个检查类。
另外,需要把两个顺序分开:
| 顺序类型 | 说明 |
|---|---|
| 程序的构造顺序 | 构造 Derived 对象时,先构造 Base 子对象,再执行 Derived 自身的构造部分 |
| 链接器的报错顺序 | 取决于它遍历目标文件、节和重定位记录的顺序,不保证与构造顺序一致 |
这次两个构造函数分别引用各自的虚表,因此两个缺失的符号都被报告出来。首条 warning 只提到 Base,也不表示 Derived 没有同类引用。
如何修复
如果只解决当前链接错误,在类定义之后补上:
cpp
void Base::f() {}
void Derived::f() {}
即可提供缺失的定义。
另外还有一个独立问题:
cpp
delete p; // p 的类型是 Base*
通过 Base* 删除 Derived 对象时,需要给 Base 加上虚析构函数:
cpp
virtual ~Base() = default;
否则即使修好了链接错误,这个删除操作仍然是未定义行为。
总结
| 概念 | 核心含义 |
|---|---|
_ZTV4Base |
Base 类的虚函数表符号 |
_ZN... |
嵌套名称编码(类成员、命名空间成员等) |
C1 / C2 |
完整对象构造函数 / 基类子对象构造函数 |
| relocation | 根据符号最终位置修正机器码或数据中的地址相关字段 |
| key function | 决定虚表在哪个翻译单元生成的关键虚函数 |
| DT_TEXTREL | 表示存在需要修改只读区域的动态重定位 |