链接器抛出的multiple definition和undefined reference看着像两类错误,其实是同一条规则的两面。这条规则叫 ODR(One Definition Rule,单一定义规则):只有每个翻译单元对同一个实体有完全一致的理解,「分开编译、最后链接」才可能成立。它也是最容易被跳过的一课,因为违反它的第二种方式,编译器不会给任何提示。
1. 引子:一行放错位置的定义
头文件里写了一行看起来人畜无害的变量,单文件编译一切正常,文件一多就在链接阶段炸了。
// header.h —— 反例,不要这么写(违反 ODR 第一层)intg_config=0;// 这是「定义」:分配存储、可被修改;不是「声明」原因不复杂:头文件会被每个.cpp各自展开一份,于是每个翻译单元(translation unit,简称 TU)都提供了一份g_config的定义;链接器把目标文件拼起来时,看到同一个名字出现了两份「强定义」。
a.cpp #include "header.h" ─┐ │ 预处理展开:每个 TU 各拿到一份 g_config 的定义 b.cpp #include "header.h" ─┘ │ ▼ a.o: 符号 g_config(定义) b.o: 符号 g_config(定义) └──────────────┬───────────────┘ ▼ 链接器:同名强符号出现两份,无解 ▼ error: multiple definition of `g_config'下面用两个独立的翻译单元真跑一遍这个冲突:a.cpp和b.cpp各自定义一次g_config,再交给 CMake 链接成一个可执行文件。上一版这里受限于校验服务只能编译单个 TU,没能复现跨 TU 的链接报错;这次改用 Compiler Explorer 的多文件工程端点,链接器原文就原样拿到了:
# CMakeLists.txt cmake_minimum_required(VERSION 3.20) project(dup CXX) set(CMAKE_CXX_STANDARD 17) add_executable(dup a.cpp b.cpp) // a.cpp int g_config = 42; int fa(); int fa() { return g_config; } // b.cpp int g_config = 7; // 第二份定义:违反 ODR int fa(); int main() { return fa() == g_config ? 0 : 1; }两个.cpp各自都能编译通过,冲突只发生在链接阶段:
[ 33%] Building CXX object CMakeFiles/dup.dir/a.cpp.o [ 66%] Building CXX object CMakeFiles/dup.dir/b.cpp.o [100%] Linking CXX executable dup ld: CMakeFiles/dup.dir/b.cpp.o:(.data+0x0): multiple definition of `g_config'; CMakeFiles/dup.dir/a.cpp.o:(.data+0x0): first defined here collect2: error: ld returned 1 exit status说明:
ld前面的完整工具链路径已省略,其余逐字未改。注意看前三行——两个.o都是Building成功、没有任何编译错误;炸的是第三行的Linking。这正是「编译期能过、
链接期才炸」的典型形态,也是 ODR 第一层约束由链接器兜底的直接证据。
把两个.cpp里的那两份定义合到一个 TU 里看,编译器会立刻拦下(链接器看到两份定义时,本质就是这个冲突):
// 反例,不要这么写:同一个 TU 里出现两份定义(片段,编译不过)intg_config=1;intg_config=2;prog.cc:2:5: error: redefinition of 'int g_config' 2 | int g_config = 2; | ^~~~~~~~ prog.cc:1:5: note: 'int g_config' previously defined here 1 | int g_config = 1; | ^~~~~~~~反过来,只要「有声明、没定义」,编译器照样放行,问题留给链接器:
// 只声明不定义:编译通过,链接期报错(片段)#include<cstdio>externintg_config;// 声明:不分配存储intreadConfig(){returng_config;}// 用它,但全程序没有那份定义/usr/bin/ld: /tmp/ccUD5U7t.o: in function `main': prog.cc:(.text.startup+0x6): undefined reference to `g_config' collect2: error: ld returned 1 exit status(/tmp/ccXXXXXX.o里的临时文件名每次编译都不同,报错格式才是重点。)
官方文档:Definitions and ODR — cppreference:ODR 的正式定义与例外清单就在这一页。
2. ODR 的两层含义
ODR 由两条独立约束组成,很多「ODR 问题」讲不清,就是把两层混在一起谈。
| 层次 | 约束内容 | 违反后谁报错 | 症状 |
|---|---|---|---|
| 第一层:一份定义 | 整个程序里,每个非inline的变量 / 函数只能有一份定义;类、模板、枚举等类型在每个 TU 内最多一份定义 | 链接器(能看到multiple definition) | 构建直接失败,反而安全 |
| 第二层:逐 token 相同 | 同一个实体出现在多个 TU 时,各处的定义必须逐 token 相同,且名字查找结果一致 | 没有人报错→ 行为未定义(undefined behavior) | 链接成功、运行结果错乱,最难查 |
第一层的后果是可见的,第二层才阴险:所有 TU 都编译过了、链接也过了,只有一个 TU 认为Config是 4 字节、另一个认为是 16 字节,各自按自己的理解读写同一块内存。
「定义」和「声明」的区别是理解第一层的前提:声明只是告诉编译器「有这么个东西」,定义才把实体造出来。下面这段程序故意把两者分开写:
#include<cstdio>// 声明:告诉编译器「有这个东西,定义在别的 TU」externintg_config;externintreadConfig();// 定义:本 TU 提供实体(真实项目里这两行在 config.cpp)intg_config=7;intreadConfig(){returng_config*2;}intmain(){printf("g_config = %d\n",g_config);printf("readConfig() = %d\n",readConfig());}g_config = 7 readConfig() = 14官方文档:Storage duration and linkage — cppreference:区分
extern/static/ 内部链接 / 外部链接的权威表格。
3. 为什么非得有 ODR:编译模型决定的
每个 TU 是独立编译的:编译器处理a.cpp时对b.cpp一无所知,而很多决策必须在编译期定死:sizeof(Config)、成员偏移、inline函数体展开、模板实例生成。它们都建立在一个假设上:别的 TU 对同一个实体的理解跟我完全一样。
a.cpp b.cpp (各自独立编译) ┌──────────┐ ┌──────────┐ │ 预处理 │ │ 预处理 │ 头文件被各自展开一份,互不知情 ├──────────┤ ├──────────┤ │ 编译 │ │ 编译 │ 这里定死:sizeof、成员偏移、 ├──────────┤ ├──────────┤ inline 函数体、模板实例地址 │ a.o │ │ b.o │ .symtab:我提供哪些符号 / 我需要哪些 └────┬─────┘ └─────┬─────┘ └───────────┬──────────────────┘ ▼ ┌─────────┐ │ 链接器 │ 只做两件事:给同名符号配一个地址、填重定位 └────┬────┘ 不做任何布局一致性检查 ▼ 可执行文件链接器只「按名字匹配」,不知道a.o和b.o里的Config布局是否一致,它连这是个类都不知道。所以 ODR 是作者与编译器之间的契约:你保证各 TU 定义一样,编译器才敢把布局和内联结果提前定死;这份信任换来独立编译与增量构建的效率,代价是第二层违约时没人替你报警。
官方文档:Translation phases — cppreference:TU 是怎么从源文件一步步变成待链接单元的。
4. ODR 的例外:哪些定义可以出现在多个 TU 里
ODR 不是「一律禁止重复定义」,有一批实体天然要写在头文件里、被多个 TU 各包含一份。它们的共同要求是:各处的定义必须逐 token 相同。
| 实体 | 能否在多个 TU 里出现定义 | 条件 / 说明 |
|---|---|---|
| 普通函数、普通全局变量 | 不能 | 只能在一个.cpp里定义一份 |
inline函数 | 能 | 各 TU 定义必须逐 token 相同,链接器留一份 |
inline变量(C++17) | 能 | 同上,变量也享受这条豁免 |
类 /struct/union定义 | 能 | 用到的每个 TU 里都得有完整且相同的定义;类内定义的成员函数隐式inline |
| 模板(函数模板、类模板及其成员) | 能 | 实例化结果在各 TU 必须相同 |
constexpr函数 | 能 | C++11 起constexpr函数隐含inline |
枚举、typedef、using别名 | 能 | 属于类型定义,同上 |
匿名命名空间 /static实体 | 能,但各 TU 是不同实体 | 内部链接,不参与同名合并,因此不受 ODR 约束 |
inline那一行的实际含义用一个例子最直观,头文件里放inline变量,多个「TU」(这里用两个函数模拟)拿到的是同一块内存:
#include<cstdio>// 头文件里写 inline 变量:全程序只有一份定义(ODR 例外)inlineintg_shared=0;int&unitA(){returng_shared;}// 假装这个函数定义在 a.cppint&unitB(){returng_shared;}// 假装这个函数定义在 b.cppintmain(){unitA()=5;printf("unitA 写入后,unitB 读到 = %d\n",unitB());printf("两个函数拿到同一块内存 = %d\n",(int)(&unitA()==&unitB()));}unitA 写入后,unitB 读到 = 5 两个函数拿到同一块内存 = 1inline变量与inline函数解决的是同一个问题:让「有必要写在头文件里的定义」不再触发multiple definition,变量这条豁免到 C++17 才有。
官方文档:inline specifier — cppreference:
inline的语义就是「允许在多个 TU 定义,链接器留一份」。
5. 违规场景二:宏让不同 TU 看到不同版本(最阴险)
第二层约束被违反,最典型的成因不是手滑写错,而是同一个头文件在不同 TU 里被宏展开成了不同内容。下面用两个结构体模拟「A 侧没开附加宏、B 侧开了」的两种展开结果:
#include<cstdio>#include<cstddef>// 同一个「头文件」因宏不同被展开成两个版本:// TU A 看到 CfgA,TU B 看到 CfgB —— 而 ODR 要求各 TU 的类定义逐 token 相同structCfgA{intid;};structCfgB{intid;doubleratio;};intmain(){printf("TU A 眼里的 sizeof(Cfg) = %zu\n",sizeof(CfgA));printf("TU B 眼里的 sizeof(Cfg) = %zu\n",sizeof(CfgB));printf("A 按自己布局推算的下一字段偏移 = %zu\n",sizeof(int));printf("B 里 ratio 的真实偏移 = %zu\n",offsetof(CfgB,ratio));}TU A 眼里的 sizeof(Cfg) = 4 TU B 眼里的 sizeof(Cfg) = 16 A 按自己布局推算的下一字段偏移 = 4 B 里 ratio 的真实偏移 = 8A 侧认为对象只有 4 字节、下一字段从偏移 4 开始,B 侧实际从偏移 8 开始。于是:
- A 侧按自己的布局去读 B 侧传过来的对象,会把 B 的填充字节当成数据——脏读;
- A 侧按 4 字节分配 / 释放或
memcpy这个对象,直接踩坏堆。
这类代码编译器不报错、链接器不报错,只在特定调用路径上表现异常。常见成因:头文件里的#ifdef开关(NDEBUG、平台宏);同一路径下装着两个版本的头文件(#include搜索顺序不同);一个宏同时改了类定义和inline函数的实现体。修法只有一个方向:让同一个实体的定义在所有 TU 里逐 token 相同,去掉影响布局的宏开关,或保证构建系统给所有 TU 传一致的宏(统一加-D)。
官方文档:C++ Core Guidelines:SF.2「头文件必须自洽」与 SF.22「内部实体放匿名命名空间」是本节修法的规范出处。
6. 违规场景三:头文件里的全局const(反直觉)
直觉上const更安全,所以很多人会在头文件里写const int kLimit = 512;。它不报错,但行为跟你想的不一样:命名空间作用域的const变量默认是内部链接(internal linkage),相当于自动加了static:头文件被 10 个 TU 包含,内存里就有 10 份kLimit,地址各不相同。
#include<cstdio>// 想在头文件里放「全程序共享一份」的常量:C++17 用 inline(constexpr 隐含 inline)inlineconstexprintkMaxConn=1024;// 文件作用域的 const 默认是内部链接:每个包含它的 TU 各拿一份自己的副本constintkFileLocal=512;constint*readMax(){return&kMaxConn;}intmain(){printf("kMaxConn = %d\n",kMaxConn);printf("kFileLocal = %d\n",kFileLocal);printf("两次取址拿到同一份 = %d\n",(int)(readMax()==&kMaxConn));}kMaxConn = 1024 kFileLocal = 512 两次取址拿到同一份 = 1「每个 TU 各有一份」在单个 TU 里观测不到,但它在真实项目里有两种典型表现:跨 TU 用extern const int kLimit;引用它会报undefined reference(内部链接不导出符号);每个 TU 各自初始化,「这边改了那边没生效」,很多玄学 bug 的来源。
只读常量各占一份问题不大,真正会出事的是带动态初始化的const对象:const std::string kPrefix = makePrefix();写在头文件里,每个 TU 各跑一遍初始化,跨 TU 顺序还不确定(静态初始化顺序问题,SIOF)。修法:常量用inline constexpr,运行期共享状态用extern声明 + 单个.cpp定义。
官方文档:const — cppreference:明确写出命名空间作用域
const默认内部链接这条反直觉规则。
7. 常见误解:匿名命名空间里的东西不是「同一份」
匿名命名空间(unnamed namespace)和static一样给出内部链接,所以两个 TU 里各自的helper()是两个互不相干的实体,这不是违反 ODR,而是压根不参与 ODR 的合并,常被误解成「匿名命名空间里的函数全局唯一」。
#include<cstdio>namespace{// 匿名命名空间:本 TU 私有(内部链接),类型、常量、函数都能放structImpl{intv;};constexprintkSeed=7;intstep(intx){returnx+kSeed;}}// namespacestaticintlegacyStep(intx){returnx*3;}// static:同样内部链接,但只能修饰函数/变量intmain(){constImpl impl{5};printf("匿名命名空间里的类型可实例化 sizeof(Impl) = %zu\n",sizeof(Impl));printf("匿名命名空间里的函数 step(5) = %d\n",step(impl.v));printf("static 函数 legacyStep(5) = %d\n",legacyStep(5));}匿名命名空间里的类型可实例化 sizeof(Impl) = 4 匿名命名空间里的函数 step(5) = 12 static 函数 legacyStep(5) = 15匿名命名空间 vsstatic:现代 C++ 该选哪个
| 维度 | 文件作用域static | 匿名命名空间 |
|---|---|---|
| 能修饰什么 | 只有函数、变量 | 函数、变量、类型(类 / 枚举 / 模板)、常量 |
| 链接属性 | 内部链接 | 内部链接 |
| 跨 TU 是否同一实体 | 否,各 TU 一份 | 否,各 TU 一份 |
| 类型能否也私有化 | 不能(static修饰类无意义) | 能,类定义不导出符号,天然不与别的 TU 撞名 |
| 推荐度 | 历史写法 | 现代 C++ 首选 |
为什么优先匿名命名空间:它把「内部」表达得更完整。static只管名字可见性,管不了类型,两个 TU 各定义一个同名但结构不同的struct Helper就是实打实的 ODR 违规(第二层),放进各自的匿名命名空间就没这个问题。这正是 Core Guidelines SF.22 的建议。
8. 头文件里想放一个变量,到底怎么写
判断标准只有两个:要全程序一份还是每 TU 一份;要不要可变。
| 写法 | 链接属性 | 全程序几份 | 适用场景 | 代价 / 坑 |
|---|---|---|---|---|
头文件里int g_config = 0; | 外部链接 | 每 TU 一份定义 | 无(写法错误) | 直接multiple definition |
头文件里extern int g_config;+ 某个.cpp里定义 | 外部链接 | 一份 | 默认选择:共享的可变状态 | 多写一行定义,容易忘 |
头文件里inline int g_config = 0;(C++17) | 外部链接 | 一份 | 想让头文件自洽、配置表 / 常量表 | 需 C++17;初始化仍是动态初始化 |
头文件里static int g_config = 0; | 内部链接 | 每 TU 一份副本 | 只在本 TU 用 | 跨 TU 不共享,改了别处看不到 |
头文件里inline constexpr int kMax = 1024; | 外部链接 | 一份 | 编译期常量(最优) | 不可改,且不能是运行期算出来的值 |
结论:共享可变状态用extern声明 + 单点定义,共享常量用inline constexpr,本 TU 私有实现放匿名命名空间。头文件里的裸const和static都不是「共享」的正确表达。
9. 完整示例:一个「头文件友好」的写法
把这篇要点串成一个可编译的程序:inline constexpr常量、类内隐式inline的成员函数、只在本 TU 使用的匿名命名空间实现细节。
#include<cstdio>#include<string>#include<vector>// —— 一个「头文件友好」的写法示范 ——inlineconstexprconstchar*kAppName="odr-demo";// C++17 inline 变量:多 TU 包含不冲突structConfig{std::string name{kAppName};intmax_conn{1024};// 类内定义的成员函数隐式 inline,可以安全写在头文件里std::stringdescribe()const{returnname+" (max_conn="+std::to_string(max_conn)+")";}};namespace{// 只在本 TU 使用的实现细节:匿名命名空间std::stringnormalize(std::string s){for(char&ch:s){if(ch==' ')ch='-';}returns;}}// namespaceintmain(){constConfig cfg{"odr demo",2048};conststd::vector<std::string>tags{normalize(cfg.name),normalize("max conn")};printf("Config::describe() = %s\n",cfg.describe().c_str());printf("归一化后的标签 = %s / %s\n",tags[0].c_str(),tags[1].c_str());}Config::describe() = odr demo (max_conn=2048) 归一化后的标签 = odr-demo / max-conn这几行写进头文件也不会出问题:kAppName是inline变量、Config是类定义、describe()是类内定义的成员函数(隐式inline)、normalize()在匿名命名空间里对外不可见。
10. 延伸阅读
- Definitions and ODR — cppreference:ODR 原文定义与例外清单,这篇第 2、4 节的依据。
- Storage duration and linkage — cppreference:
extern/static/ 内部链接 / 外部链接对照表,理清第 6 节那条反直觉规则。 - C++ Core Guidelines:SF.2(头文件自洽)、SF.22(内部实体放匿名命名空间)的实操建议。
本知识库内的相关篇目:
- 《命名空间与代码组织:匿名命名空间、ADL 与 using 的边界》 —— 命名空间不只是「给名字加前缀」,它同时管三件事
- 《头文件组织与前置声明:编译依赖管理》 —— #include 的本质是文本替换,一个被 N 个 .cpp 包含的头文件改一次就要重编 N 个文件。
- 《CMake 入门:从单文件到多目标工程》 —— 现代 CMake 的核心是以 target 为中心
11. 一句话总结
两层要分开记:第一层「整个程序一份定义」违反时链接器会拦下(multiple definition),第二层「各 TU 定义逐 token 相同」违反时没人报错、是静默 UB。修法记四条:头文件里只放声明、共享常量用inline constexpr、共享状态用extern+ 单点定义、本 TU 内部实现放匿名命名空间。