Skip to content

编译链接

编译、汇编、链接分别做什么?

cpp-compile-assemble-link

完整构建流程通常是:预处理、编译、汇编、链接。你问的三个重点是:编译生成汇编,汇编生成目标文件,链接生成最终可执行程序。

编译做什么

编译 compile 是把 C++ 源代码翻译成汇编代码,或者先翻译成编译器内部的中间表示,再生成汇编。

它主要做:

  • 语法检查
  • 类型检查
  • 模板实例化
  • 函数重载解析
  • 语义分析
  • 优化
  • 生成汇编代码

比如:

c
main.cpp -> main.s

编译阶段常见错误:

语法错误
类型不匹配
函数参数不对
类成员不存在
头文件找不到
模板实例化失败

汇编做什么

汇编 assemble 是把汇编代码翻译成机器码,生成目标文件。

比如:

c
main.s -> main.o

Windows 上常见是:

c
main.asm -> main.obj

目标文件里通常有:

  • 机器码
  • 符号表
  • 重定位信息
  • 代码段
  • 数据段

但目标文件还不是最终程序。 它里面可能还有一些没解决的符号,比如“我调用了 Foo(),但 Foo() 的实现可能在别的 .o 文件里”。

链接做什么

链接 link 是把多个目标文件和库合成最终程序。

比如:

c
main.o + player.o + libmath.a -> game.exe

链接器主要做:

  • 合并多个目标文件
  • 查找外部函数和全局变量
  • 解析未定义符号
  • 处理重复定义
  • 合并静态库
  • 安排代码段和数据段
  • 修正地址,也就是重定位
  • 生成最终可执行文件或动态库

链接阶段常见错误:

c
undefined reference
unresolved external symbol
multiple definition
找不到库文件
库版本或架构不匹配

编译错误和链接错误怎么区分

编译错误通常是“单个源文件自己就过不去”。

比如:

c
int main() // 定义 main 函数
{ // 函数体开始
    int x = "hello"; // 类型错误,字符串不能直接赋给 int
    return 0; // 返回 0
} // 函数体结束

链接错误通常是“每个文件单独编译都没问题,但合起来找不到实现”。

比如:

c
void Foo(); // 声明 Foo 函数,但没有提供实现

int main() // 定义 main 函数
{ // 函数体开始
    Foo(); // 调用了 Foo,编译能过,但链接时可能找不到 Foo 的实现
    return 0; // 返回 0
} // 函数体结束

这类会在链接阶段报类似:

c
undefined reference to Foo

面试高分回答

NOTE

C++ 构建通常分为预处理、编译、汇编和链接。编译阶段以单个翻译单元为单位,把预处理后的 C++ 代码做语法和类型检查、模板实例化、优化,然后生成汇编代码。汇编阶段把汇编代码翻译成机器码,生成 .o.obj 目标文件,里面有机器码、符号表和重定位信息。链接阶段把多个目标文件和库合并,解析跨文件的函数和全局变量引用,处理重定位,最终生成可执行文件或动态库。简单记:编译看单个源文件是否合法,汇编生成目标文件,链接解决多个文件之间“谁调用谁、符号在哪里”的问题。

头文件和源文件分别是什么?

cpp-header-source-files

在 C++ 里,头文件通常放 声明和接口,源文件通常放 定义和实现

头文件是什么

头文件常见后缀是 .h.hpp

它主要放:

  • 类声明
  • 函数声明
  • 结构体声明
  • 枚举
  • 常量
  • 模板定义
  • inline 函数
  • 必要的类型声明

比如 Player.h

c
#pragma once // 防止头文件被重复包含

class Player // 声明 Player 类
{ // 类体开始
public: // public 访问区开始
    void Move(); // 声明 Move 函数,告诉别人有这个函数
private: // private 访问区开始
    int hp; // 声明成员变量 hp
}; // Player 类声明结束

头文件的作用是告诉其他 .cpp: “这个类有什么成员,这个函数叫什么、参数是什么、返回值是什么。”

源文件是什么

源文件常见后缀是 .cpp.cc.cxx

它主要放函数实现。

比如 Player.cpp

c
#include "Player.h" // 引入 Player 的声明,让编译器知道 Player 长什么样

void Player::Move() // 定义 Player 类的 Move 成员函数
{ // 函数体开始
    hp -= 1; // 示例逻辑:移动消耗一点 hp
} // 函数体结束

源文件会被编译器真正编译成目标文件,比如:

c
Player.cpp -> Player.o

Windows 上可能是:

c
Player.cpp -> Player.obj

最后链接器把多个 .o/.obj 合成最终程序。

为什么要分成头文件和源文件

主要是为了 接口和实现分离

别人想用 Player,只需要 include Player.h,知道 Player 有哪些函数。 至于 Move() 里面到底怎么写,可以放在 Player.cpp 里。

这样有几个好处:

  • 结构清晰
  • 模块之间依赖更明确
  • 不需要把所有实现暴露出来
  • 多个 .cpp 可以复用同一份声明
  • 改实现时,接口不变,调用方代码更稳定

容易踩的坑

不要把普通函数定义随便放头文件里。

比如:

c
void Foo() // 在头文件里定义普通函数,多个 cpp include 后可能重复定义
{ // 函数体开始
} // 函数体结束

如果多个 .cpp 都 include 这个头文件,链接时可能出现重复定义。

如果一定要放头文件里,通常要用 inline

c
inline void Foo() // inline 函数可以放在头文件中,避免普通重复定义问题
{ // 函数体开始
} // 函数体结束

为什么模板常写在头文件里

模板比较特殊。 模板实例化时,编译器需要看到完整定义,所以模板通常写在 .h/.hpp 里。

c
template <typename T> // 定义模板参数 T
T Add(T a, T b) // 定义模板函数 Add
{ // 函数体开始
    return a + b; // 返回两个参数相加的结果
} // 函数体结束

如果模板只有声明,编译器实例化时看不到函数体,就可能链接失败或无法生成代码。

面试高分回答

IMPORTANT

头文件一般放声明和接口,比如类声明、函数声明、结构体、枚举、模板和 inline 函数;源文件一般放实现,比如函数体和类成员函数定义。.cpp 会被单独编译成目标文件,头文件本身通常不是单独编译,而是通过 #include 被插入到源文件中。这样做可以实现接口和实现分离,方便模块化和复用。需要注意普通函数定义不要随便放头文件里,否则多个源文件包含后可能链接时报重复定义;模板因为实例化需要看到完整定义,所以通常放在头文件里。

#include 本质是什么?

cpp-include-essence

#include预处理指令。 它的本质不是“导入模块”,也不是“链接库”,而是:把被包含文件的文本内容复制到当前位置

简单理解

你写:

c
#include "Player.h" // 让预处理器把 Player.h 的内容插入到这里

int main() // 程序入口函数
{ // main 函数体开始
    return 0; // 返回 0,表示程序正常结束
} // main 函数体结束

假设 Player.h 里是:

c
class Player // 声明 Player 类
{ // Player 类体开始
public: // public 访问区开始
    void Move(); // 声明 Move 成员函数
}; // Player 类体结束

预处理后,编译器看到的大概是:

c
class Player // Player.h 的内容被插入进来
{ // Player 类体开始
public: // public 访问区开始
    void Move(); // 声明 Move 成员函数
}; // Player 类体结束

int main() // 原 main.cpp 里的代码
{ // main 函数体开始
    return 0; // 返回 0,表示程序正常结束
} // main 函数体结束

所以 #include 的本质就是 文本展开

它发生在哪个阶段

发生在预处理阶段。

C++ 大概流程是:

c
源文件 -> 预处理 -> 编译 -> 汇编 -> 链接

#include、宏展开、条件编译这些都属于预处理阶段。

#include <...>#include "..." 区别

c
#include <iostream> // 通常用于包含系统库或标准库头文件
#include "Player.h" // 通常用于包含项目自己的头文件

大致区别是搜索路径不同:

  • <...>:优先去系统头文件目录、编译器配置的 include 路径找
  • "...":通常先从当前文件所在目录找,再去 include 路径找

不同编译器细节可能略有差异,但面试这么答够用了。

它不负责链接实现

#include 只是让编译器“看到声明”。

比如:

c
void Foo(); // 声明 Foo 函数

int main() // 程序入口函数
{ // main 函数体开始
    Foo(); // 调用 Foo,编译阶段知道 Foo 存在
    return 0; // 返回 0,表示程序正常结束
} // main 函数体结束

如果你没有在某个 .cpp 或库里提供 Foo 的实现,编译可能能过,但链接会失败。

也就是说:

  • #include 解决“看不看得到声明”
  • 链接器解决“找不找得到定义”

为什么要防止重复包含

因为 #include 是文本复制。 如果同一个头文件被包含多次,里面的类、结构体、普通函数定义可能被重复展开,导致重复定义。

所以头文件常写:

c
#pragma once // 防止这个头文件在同一个翻译单元里被重复包含

或者传统写法:

c
#ifndef PLAYER_H // 如果 PLAYER_H 没有被定义过
#define PLAYER_H // 定义 PLAYER_H,表示这个头文件已经包含过

class Player // 声明 Player 类
{ // Player 类体开始
}; // Player 类体结束

#endif // 结束条件编译

面试高分回答

NOTE

#include 是 C++ 预处理阶段的文本包含指令,本质是把指定头文件的内容插入到当前源文件对应位置,形成一个完整的翻译单元,然后再交给编译器编译。它不是运行时加载,也不是链接库。#include <...> 通常用于系统库或标准库头文件,#include "..." 通常用于项目头文件,主要区别是搜索路径。因为 include 是文本展开,所以头文件需要 #pragma once 或 include guard 防止重复包含。需要注意,include 只能让编译器看到声明,真正的函数定义还要在链接阶段由链接器找到。

头文件重复包含怎么解决?

cpp-header-duplicate-include-solution

核心原因:C/C++ 的 #include 本质是文本复制粘贴。如果同一个头文件被多个路径间接包含进同一个 .cpp,里面的类、结构体、函数声明就可能被展开多次,导致重复定义或编译变慢。

最常用方案 1:#pragma once

c
#pragma once // 保证这个头文件在同一个编译单元中只被包含一次

class Player // 声明 Player 类
{ // Player 类体开始
}; // Player 类体结束

优点是简单、清晰,现代主流编译器基本都支持。实际项目里很常见。

最标准方案 2:头文件保护宏

c
#ifndef PLAYER_H // 如果 PLAYER_H 这个宏还没有定义过
#define PLAYER_H // 定义 PLAYER_H,表示这个头文件已经被包含过

class Player // 声明 Player 类
{ // Player 类体开始
}; // Player 类体结束

#endif // 结束头文件保护

第一次包含时,PLAYER_H 没定义,所以内容会进入。第二次再包含时,PLAYER_H 已经定义过了,编译器就会跳过中间内容。

面试里要补一句

#pragma once 和头文件保护宏解决的是:同一个头文件在同一个 .cpp 编译过程中被重复展开的问题

但它不能解决这种问题:

c
int Add(int a, int b) // 在头文件里定义了普通函数
{ // 函数体开始
    return a + b; // 返回两个整数的和
} // 函数体结束

如果这个头文件被多个 .cpp 包含,每个 .cpp 都会生成一份 Add 的定义,链接时可能报重复定义。

所以普通函数定义通常放 .cpp,头文件只放声明:

c
int Add(int a, int b); // 在头文件里声明函数

进一步优化:少 include,多前置声明

c
class Player; // 前置声明 Player,告诉编译器有这个类型

class Team // 声明 Team 类
{ // Team 类体开始
private: // private 访问区域开始
    Player* player; // 只保存指针时,前置声明通常够用
}; // Team 类体结束

如果只是用 Player*Player&,很多时候不用 #include "Player.h",前置声明就够了。这样可以减少编译依赖,提高编译速度。

什么时候必须 include?

当你需要知道类型完整大小或具体成员时,就必须包含头文件。比如:对象成员、继承、调用成员函数、sizeof(Player)

面试高分说法

IMPORTANT

头文件重复包含一般用 #pragma once 或 include guard 解决;本质是防止 #include 文本展开导致同一声明/定义在同一编译单元里出现多次。工程上还要减少头文件之间的互相包含,能前置声明就前置声明,普通函数和全局变量定义不要随便放头文件,否则可能出现链接期重复定义。

#pragma once 和 include guard 区别是什么?

一句话区别

#pragma once 和 include guard 都是为了解决头文件重复包含问题。区别是:#pragma once 更简单,依赖编译器;include guard 更标准,依赖宏判断。

cpp-pragma-once-vs-include-guard

#pragma once

c
#pragma once // 告诉编译器这个头文件在同一个编译单元中只包含一次

class Player // 声明一个 Player 类
{ // Player 类体开始
}; // Player 类体结束

优点:写法简单,不容易写错。 缺点:它不是 C++ 标准的一部分,但主流编译器基本都支持,所以工程里很常见。

include guard

c
#ifndef PLAYER_H // 如果 PLAYER_H 这个宏还没有定义过
#define PLAYER_H // 定义 PLAYER_H,表示这个头文件已经被包含过

class Player // 声明一个 Player 类
{ // Player 类体开始
}; // Player 类体结束

#endif // 结束头文件保护

优点:标准写法,兼容性最好。 缺点:宏名要保证唯一,如果两个头文件用了同一个宏名,可能会导致其中一个头文件被错误跳过。

底层理解

#include 的本质不是“导入模块”,而是把头文件内容复制到当前 .cpp

所以如果出现这种情况:

c
#include "Player.h" // 第一次包含 Player.h
#include "Player.h" // 第二次包含 Player.h

如果没有保护机制,Player 类就可能被声明或定义两次,编译器就会报错。

面试高分回答

NOTE

#pragma once 和 include guard 作用相同,都是防止头文件在同一个编译单元中被重复包含。#pragma once 更简洁,现代项目常用;include guard 是标准写法,跨平台兼容性最好。但它们只解决重复包含问题,不能解决把普通函数定义、全局变量定义放进头文件导致的链接期重复定义。工程上还要减少不必要的 include,能用前置声明就用前置声明。

静态库和动态库区别是什么?

一句话区别

静态库是在链接期把用到的库代码合进最终程序;动态库是在运行期由系统加载,程序运行时再去调用库里的代码。

cpp-static-vs-dynamic-library

静态库是什么?

静态库常见后缀:

Windows:.lib Linux/macOS:.a

它本质上是一堆 .o.obj 目标文件的集合。链接时,链接器会把程序真正用到的代码合进最终的可执行文件里。

所以静态库的特点是:

程序发布时通常不需要带着这个库文件;

可执行文件体积可能更大;

库更新后,程序通常需要重新链接、重新发布;

运行时依赖少,部署简单。

动态库是什么?

动态库常见后缀:

Windows:.dll Linux:.so macOS:.dylib

动态库不会完整合进 exe。可执行文件里通常记录的是:我需要哪个动态库,以及需要调用哪些函数。

程序启动时,或者第一次调用时,系统加载器会把动态库加载进内存。

所以动态库的特点是:

exe 本身体积通常更小;

运行时必须能找到对应的动态库;

动态库可以单独替换,方便更新;

多个程序可以共享同一份动态库代码页;

但容易出现版本不兼容、路径找不到、符号找不到等问题。

底层理解

静态库更像:

c
main.cpp + libmath.a  -->  链接器  -->  game.exe

最终 game.exe 自己就带着需要的那部分库代码。

动态库更像:

c
game.exe  -->  运行时加载  -->  math.dll / libmath.so

game.exe 不直接带完整代码,而是在运行时找库、加载库、解析函数入口。

容易踩坑的点

Windows 里的 .lib 不一定都是静态库。它可能是:

静态库:真正包含代码;

导入库:只是帮助链接 .dll 的符号信息,真正代码还在 .dll 里。

这个点面试说出来很加分。

什么时候用静态库?

适合依赖稳定、体积可接受、希望部署简单的场景。比如一些基础算法库、工具库、小型第三方库。

什么时候用动态库?

适合插件系统、模块化架构、需要单独更新、多个程序共享库的场景。游戏引擎、编辑器插件、平台 SDK、热更新相关模块里经常会见到。

面试高分说法

IMPORTANT

静态库和动态库的核心区别在链接时机。静态库在链接期把代码合进可执行文件,运行时依赖少,但更新要重新链接;动态库在运行期加载,方便模块化和单独更新,也能被多个进程共享,但会带来版本兼容、加载路径和 ABI 稳定性问题。工程上选哪个,不是单看性能,而是看部署、更新、复用和稳定性要求。

符号表是什么?

一句话理解

符号表就是编译产物里的“名字索引表”。它记录函数、全局变量、静态变量等符号的名字、类型、位置、可见性,链接器靠它把不同 .o 文件和库文件拼起来。

cpp-symbol-table-explained

为什么需要符号表?

因为 C++ 程序通常不是一个文件编译完就结束,而是很多 .cpp 分别编译成 .o.obj,最后再链接成可执行文件。

比如:

c
int Add(int a, int b); // 声明 Add 函数,告诉编译器有这个函数

int main() // 程序入口函数
{ // main 函数体开始
    return Add(1, 2); // 调用 Add,但当前文件里没有 Add 的函数体
} // main 函数体结束

编译 main.cpp 时,编译器可以先通过,因为它知道有个 Add。 但 Add 的真正地址在哪里,编译器这一步还不知道。

所以目标文件里会记录:

c
我需要一个叫 Add 的符号,但我这里没有定义它。

这条记录就会放到符号表里。

符号表里有什么?

常见信息包括:

符号名:比如 AddmaingScore

符号类型:函数、全局变量、静态变量、未定义引用。

所在段:比如 .text 代码段、.data 数据段、.bss 未初始化数据段。

地址或偏移:这个符号在目标文件或最终程序里的位置。

可见性:局部符号、全局符号、弱符号。

链接器怎么用符号表?

假设有两个文件:

c
int Add(int a, int b); // 声明 Add 函数

int main() // 程序入口函数
{ // main 函数体开始
    return Add(1, 2); // 调用 Add,链接器后面要找到它
} // main 函数体结束
int Add(int a, int b) // 定义 Add 函数
{ // Add 函数体开始
    return a + b; // 返回两个参数的和
} // Add 函数体结束

main.o 的符号表里会说:我需要 Addmath.o 的符号表里会说:我定义了 Add。 链接器就把这两个符号匹配起来,然后把 main 里调用 Add 的地方修正成真实地址。

常见链接错误其实都和符号表有关

undefined reference: 说明有地方使用了某个符号,但链接器找不到它的定义。

multiple definition: 说明同一个强符号被定义了多次,比如普通函数定义放进头文件,被多个 .cpp 包含。

面试高分说法

TIP

符号表是目标文件、库文件或可执行文件中的一张表,用来描述函数和变量这些符号。编译器生成目标文件时会记录“我定义了哪些符号”和“我还需要哪些外部符号”。链接器读取各个目标文件的符号表,完成符号解析和地址重定位。如果符号找不到,就会出现未定义引用;如果符号重复强定义,就会出现重复定义错误。调试器和崩溃栈还会依赖调试符号把地址还原成函数名和源码行号。

链接错误 unresolved external symbol 可能是什么原因?

一句话理解

unresolved external symbol链接错误,不是编译语法错误。意思是:编译器看到了声明,所以编译能过;但链接器找不到对应的函数或变量定义,所以最终程序生成失败。

cpp-unresolved-external-symbol

1. 只有声明,没有定义

比如头文件里写了声明:

c
int Add(int a, int b); // 声明 Add 函数,告诉编译器有这个函数

但是没有任何 .cpp 写函数体:

c
int Add(int a, int b) // 定义 Add 函数,链接器需要找到这个实现
{ // Add 函数体开始
    return a + b; // 返回两个整数相加的结果
} // Add 函数体结束

如果缺少下面这个定义,链接时就可能报 unresolved external symbol Add

2. .cpp 文件没有加入工程

有时候你确实写了定义,但这个 .cpp 没被加入 Visual Studio 工程,或者 CMake 没把它放进目标里。

c
add_executable(Game main.cpp) # 这里只编译 main.cpp,没有编译 Math.cpp

如果 Add 的定义在 Math.cpp 里,但 Math.cpp 没参与构建,链接器当然找不到。

3. 库文件没有链接

调用了第三方库的函数,但没有链接对应 .lib.a.so.dll 的导入库。

例如 Windows 下常见问题:

c
#pragma comment(lib, "SomeLib.lib") // 告诉 MSVC 链接 SomeLib.lib

如果少了库,编译器只知道函数声明,链接器找不到函数实现。

4. 声明和定义签名不一致

看起来函数名一样,但参数不同、const 不同、命名空间不同,链接器会认为它们是不同符号。

c
int Add(int a, int b); // 声明的是两个 int 参数
float Add(float a, float b) // 定义的是两个 float 参数,不是同一个函数
{ // Add 函数体开始
    return a + b; // 返回两个浮点数相加的结果
} // Add 函数体结束

C++ 有函数重载,符号名会带上参数信息,所以签名不一致会导致链接失败。

5. 类的 static 成员只声明没定义

老式写法里,类中 static 数据成员在类里只是声明,还需要在 .cpp 里定义一次。

c
class Player // 声明 Player 类
{ // Player 类体开始
public: // public 区域开始
    static int Count; // 声明静态成员 Count
}; // Player 类体结束
int Player::Count = 0; // 在 .cpp 中定义静态成员 Count

如果少了这行定义,使用 Player::Count 时可能链接失败。

6. 模板实现放在 .cpp

模板通常要把声明和实现都放在头文件里。因为模板只有在使用时才实例化,编译别的 .cpp 时如果看不到模板实现,就可能链接失败。

c
template <typename T> // 声明模板参数 T
T Max(T a, T b) // 定义模板函数 Max
{ // Max 函数体开始
    return a > b ? a : b; // 返回两个值中较大的那个
} // Max 函数体结束

模板函数如果只在头文件声明,实现藏在 .cpp,很容易出问题。

7. C 和 C++ 混用时没有 extern "C"

C++ 会对函数名做 name mangling,也就是把函数名、命名空间、参数类型编码进符号名。 C 不会这样做。

如果 C++ 调 C 库,一般要这样:

c
extern "C" int Add(int a, int b); // 用 C 语言方式查找 Add 符号

否则 C++ 链接器可能找的是加工后的名字,而 C 库里只有原始的 Add

面试高分说法

NOTE

unresolved external symbol 的本质是链接器拿着符号表找不到定义。常见原因有:函数只声明没定义、定义所在 .cpp 没加入工程、第三方库没链接、声明和定义签名不一致、类静态成员没在类外定义、模板实现没有放在头文件、C/C++ 混编时缺少 extern "C"。排查时先看缺失符号名,再确认定义是否存在、是否参与编译、库是否参与链接,最后检查命名空间、参数签名和编译配置。

ODR 是什么?

一句话理解

ODR 是 One Definition Rule,中文叫单一定义规则。它规定:C++ 里的函数、变量、类、模板等实体,定义的数量和一致性必须符合规则,否则就可能链接报错,或者更危险地出现未定义行为。

cpp-odr-one-definition-rule

声明和定义先分清

声明只是告诉编译器:这个东西存在。

c
int Add(int a, int b); // 声明 Add 函数,告诉编译器有这个函数

定义是真的提供实现或分配空间。

c
int Add(int a, int b) // 定义 Add 函数,提供真正的函数体
{ // Add 函数体开始
    return a + b; // 返回两个参数的和
} // Add 函数体结束

ODR 主要管的是定义,不是普通声明。声明可以出现很多次,定义不能随便重复。

最常见的 ODR 问题

把普通函数定义放进头文件:

c
int Add(int a, int b) // 在头文件里定义普通函数
{ // Add 函数体开始
    return a + b; // 返回两个整数的和
} // Add 函数体结束

如果 A.cppB.cpp 都包含这个头文件,那么两个 .obj 里都会有一份 Add 的定义。链接时就可能报:

c
multiple definition

或者 MSVC 里类似:

c
already defined

全局变量也一样

错误写法:

c
int gScore = 0; // 在头文件里定义全局变量,多个 cpp 包含会产生多份定义

正确写法:

c
extern int gScore; // 在头文件里声明全局变量,不分配存储空间

然后在一个 .cpp 里定义一次:

c
int gScore = 0; // 在一个源文件里定义全局变量,只保留一份真正存储

include guard 能不能解决 ODR?

不能完全解决。

#pragma once 和 include guard 只能防止同一个 .cpp 中重复展开同一个头文件

但如果 A.cppB.cpp 都包含了同一个头文件,头文件里的普通函数定义依然会分别进入两个编译单元。

所以它们解决的是重复包含,不是所有 ODR 问题。

哪些东西可以放头文件?

类定义通常可以放头文件。

c
class Player // 在头文件里定义类类型
{ // Player 类体开始
public: // public 区域开始
    int hp; // 声明成员变量 hp
}; // Player 类体结束

模板通常也要放头文件。

c
template <typename T> // 声明模板参数 T
T Max(T a, T b) // 定义模板函数 Max
{ // Max 函数体开始
    return a > b ? a : b; // 返回两个值中较大的那个
} // Max 函数体结束

inline 函数也可以放头文件,因为 inline 允许多个编译单元里出现相同定义。

c
inline int Add(int a, int b) // 定义 inline 函数,允许多个编译单元包含同一份定义
{ // Add 函数体开始
    return a + b; // 返回两个整数的和
} // Add 函数体结束

注意:允许多份,不代表可以不一致。多个编译单元看到的定义必须一样。

面试高分说法

IMPORTANT

ODR 是 C++ 的单一定义规则。简单说,声明可以重复出现,但普通函数、全局变量这类实体在整个程序中通常只能有一个定义。类、模板、inline 函数可以在多个编译单元中出现定义,但这些定义必须完全一致。违反 ODR 可能导致链接期的重复定义错误,也可能更隐蔽地导致未定义行为。工程上要避免把普通函数定义和全局变量定义随便放进头文件,头文件多放声明、类定义、模板和 inline,真正的普通定义放到 .cpp 里。

C++ ABI 是什么?

cpp-abi-explained

一句话理解

ABI 是 Application Binary Interface,中文叫应用程序二进制接口。它规定的是:编译后的目标文件、动态库、可执行文件之间,如何在二进制层面互相调用、传参数、找函数、理解对象内存布局。

API 和 ABI 的区别

API 是给程序员看的源码接口。

c
int Add(int a, int b); // 这是源码层面的 API,表示有一个 Add 函数

ABI 是给编译器、链接器、操作系统加载器看的二进制约定。

比如:

函数名在符号表里叫什么;

参数放寄存器还是栈上;

返回值怎么返回;

类对象内存怎么布局;

虚函数表怎么排列;

异常怎么跨函数传播;

动态库之间怎么链接和调用。

C++ ABI 具体包括什么?

第一,调用约定。 比如函数参数怎么传、返回值放哪里、栈由谁清理。调用方和被调用方必须遵守同一套规则,否则程序会崩。

第二,符号名规则。 C++ 支持函数重载,所以编译器会把函数名加工成更复杂的符号名,这叫 name mangling

c
int Add(int a, int b); // 一个 Add 函数,参数是两个 int
float Add(float a, float b); // 另一个 Add 函数,参数是两个 float

源码里都叫 Add,但编译后的符号名通常不同。

第三,对象内存布局。 比如成员变量顺序、对齐填充、基类子对象位置、虚指针位置。

c
class Player // 声明 Player 类
{ // Player 类体开始
public: // public 区域开始
    int hp; // 成员变量 hp,占用对象内存
    float speed; // 成员变量 speed,占用对象内存
}; // Player 类体结束

不同编译器、不同编译选项下,对象布局可能有差异。

第四,虚函数和虚表布局。 如果类有虚函数,对象里通常会多一个虚指针,指向虚表。 ABI 会影响虚表里函数指针的排列方式。

第五,异常、RTTI、标准库布局。 比如 std::stringstd::vector 的内部结构,不同标准库实现可能不同。 所以跨 DLL 或 .so 边界直接传 STL 容器,是很容易出 ABI 问题的。

为什么 C 接口更稳定?

C 没有函数重载、类、虚函数、模板这些复杂机制,符号名和调用规则更简单。

所以动态库导出接口经常会写成:

c
extern "C" int Add(int a, int b); // 使用 C 的符号规则导出 Add,避免 C++ name mangling

这样别的语言、别的编译器、别的模块更容易调用。

什么时候 ABI 很重要?

动态库接口设计;

插件系统;

游戏引擎 SDK;

C++ 调用第三方库;

不同编译器或不同版本混用;

热更新、模块化加载;

跨语言调用 native 库。

面试高分说法

CAUTION

C++ ABI 是二进制层面的接口约定,它决定了编译后的模块之间能不能正确链接和调用。它包含调用约定、符号名改编、对象内存布局、虚表布局、异常处理、RTTI 和标准库对象布局等内容。API 兼容不代表 ABI 一定兼容,比如函数声明没变,但类成员顺序、编译器版本、标准库实现变了,动态库边界就可能出问题。工程上跨库接口要尽量稳定,常见做法是暴露 C 风格接口、避免跨 DLL 传 STL 容器和复杂 C++ 对象。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0