跳转到内容

02 · 编译链接与内存模型

定位:面试第一类必考基础题,也是理解所有内存话题(智能指针、内存池、ECS)的地基。 学完标准:能画出编译四阶段流程图;能手算 sizeof;能说清静态/动态库区别;能写代码判断大小端。


1. 从 .cpp 到可执行文件:编译四阶段

Section titled “1. 从 .cpp 到可执行文件:编译四阶段”
源代码 a.cpp b.cpp
│ ① 预处理(Preprocessing) gcc -E
翻译单元(头文件展开、宏替换、条件编译、去注释)
│ ② 编译(Compilation) gcc -S
汇编代码 a.s b.s
│ ③ 汇编(Assembly) gcc -c
目标文件 a.o b.o(机器码 + 符号表 + 重定位表)
│ ④ 链接(Linking) gcc 链接器
可执行文件(a.out / a.exe)

各阶段做什么:

阶段 输入→输出 核心工作
预处理 .cpp → .i 展开 #include、替换 #define 宏、处理 #if/#ifdef 条件编译、删除注释
编译 .i → .s 词法分析(拆单词)→ 语法分析(建语法树)→ 语义分析(类型检查)→ 优化 → 生成汇编
汇编 .s → .o 把汇编翻译成机器码,生成符号表(函数/变量名→地址)和重定位表(哪些地址待定)
链接 .o + 库 → 可执行 符号解析(把每个符号引用找到定义) + 重定位(把符号填成真实地址)

面试常问的衍生题:

  • 编译错误 vs 链接错误怎么区分?
    • 编译错误:语法/类型问题,报错里带 .cpp:行号,比如 未定义标识符int i = "str";
    • 链接错误:每个文件单独编译都过了,但连起来时符号找不到,典型报错 undefined reference to 'xxx'(漏实现、漏链接库、名字不一致)。
  • 同一个变量在多文件引用:定义只能有一份(ODR 一个定义规则),声明(extern)可以有很多份。
  • 头文件只放声明、不放定义,否则多个 .cpp include 同一个头文件就会“重复定义”链接错误。两个例外见第 2 节(inline / 模板)。

// 方式一:#ifndef(老式、跨平台,靠宏名避免冲突)
#ifndef MY_HEADER_H
#define MY_HEADER_H
// 内容
#endif
// 方式二:#pragma once(现代主流,编译器保证只展开一次)
#pragma once

区别:#ifndef 依赖宏名唯一——两个不同头文件若 guard 宏同名,后一个会被跳过;#pragma once 由编译器按文件去重,更快,但同一文件经硬链接/软链接/不同路径包含时可能被当成两个文件。前者是标准写法,后者现在所有主流编译器都支持,现代工程常用。

💡 什么是 guard? guard = “头文件守卫”,就是 #ifndef XXX_H / #define XXX_H / #endif 这三行宏,作用是防止同一个头文件被重复包含:一个 .cpp 里 #include "a.h" 两次(或 a.h 又 include b.h、b.h 又 include a.h),没有 guard 时头文件内容被展开两遍,里面若含变量/函数定义就报“重复定义”。guard 宏名必须全局唯一、全大写、下划线分隔,如 MYMATH_HMY_PROJECT_UTIL_H,否则两个头文件撞名会互相跳过。#pragma once 本质是让编译器帮你做同一件事(按文件路径去重)。

2.2 声明 vs 定义(ODR,One Definition Rule)

Section titled “2.2 声明 vs 定义(ODR,One Definition Rule)”
  • 声明:告诉编译器“存在这个东西”,不分配内存:extern int x;void foo();
  • 定义:实际创建实体、分配内存:int x;void foo(){}
  • ODR:程序里每个实体只能有一个定义
  • 两个例外(一个头文件里可以放定义、多个 .cpp 包含也不冲突):
    • inline 函数 / inline 变量(C++17):允许多个相同定义,链接器会合并。
    • 类模板/函数模板:实例化时才生成代码,天然满足“多份也没事”。

💡 为什么 inline / 模板能放头文件?例子:

// math.h —— 头文件里允许放这些"定义"
inline int square(int x) { return x * x; } // ① inline 函数:多份相同定义,链接器自动合并
inline int version = 1; // ② C++17 inline 变量:同理合并成一份
template <typename T> T my_max(T a, T b) { // ③ 模板:只是"图纸",用到才实例化
return a > b ? a : b;
}
int add(int a, int b); // 普通函数:只能放声明

普通函数 add 若把定义写进 math.h,a.cpp、b.cpp 都 include → 链接时出现两份定义 → 报“重复定义”。

  • inline 函数:每个 .cpp 各编译一份,链接器发现多份相同定义时自动合并取一份,不报错。
  • inline 变量(C++17):同理多份合并成一份,解决“头文件里定义全局变量”的老大难。
  • 模板:模板本身不生成符号;哪个 .cpp 用到 my_max<int> 就在哪个翻译单元实例化出代码,多份实例化同样由链接器合并(COMDAT 机制)。

⚠️ 易错点(面试常考):inline 函数在头文件里、10 个 .cpp 都调用 → 最终链接成 1 份副本,不是 10 份! 两阶段理解:编译期每个 .cpp 各生成一份(确实有 10 份)→ 链接期链接器发现这 10 份定义完全相同(同签名 + 同函数体),按 COMDAT 规则合并成 1 份。这就是 inline 能放头文件的底层原因。 反过来:普通函数定义放头文件 → 链接器视为多份不同定义(违反 ODR)→ 报 multiple definition 重定义错误。

mymath.h
// 正确做法:声明放 .h,定义放 .cpp
int add(int a, int b); // 声明
// mymath.cpp
#include "mymath.h"
int add(int a, int b) { return a + b; } // 定义

2.3 extern “C” 与名字修饰(name mangling)

Section titled “2.3 extern “C” 与名字修饰(name mangling)”
  • C++ 支持函数重载,靠编译器给函数名“改名”来区分:void foo(int)void foo(double) 编译后符号名不同,这个过程叫名字修饰。C 语言没有重载,不改名。
  • 所以 C 写的库(符号名是原始名),在 C++ 里链接时会找不到——用 extern "C" 声明,告诉编译器“这段按 C 规则链接”:
extern "C" {
#include "some_c_library.h"
}
// 或者声明单个函数
extern "C" int c_func(int x);

💡 extern “C” 展开讲:核心是名字修饰(name mangling)。C++ 为支持重载会改函数名:void foo(int)void foo(double) 编译后符号分别是 _Z3fooi_Z3food(名字不同才能共存)。而 C 语言没有重载,符号名就是原名 foo。 链接阶段靠符号名找函数。你在 C++ 里 extern int my_add(int,int); 去链 C 写的库 libmylib.a,C++ 编译器找的是 _Z6my_addii,库里却只有 my_addundefined referenceextern "C" 就是告诉 C++ 编译器“这段按 C 规则处理、不要改名”,于是链接时找的就是 my_add,能对上。 头文件常见写法 #ifdef __cplusplus extern "C" { ... }:让同一个头文件既能被 C 也能被 C++ 包含(C 没有 extern "C" 语法,必须条件编译包住)。 面试补充:extern “C” 块里不能重载(C 规则无重载);它只影响链接,不影响函数本身的行为。

追问static 全局变量/函数的作用?→ 把符号的链接属性改为内部链接(internal linkage),只在本 .cpp 可见,不参与跨文件链接,避免和其他文件符号冲突。


静态库(.a / .lib) 动态库(.so / .dll)
链接时机 编译链接时,把用到的库代码拷贝进可执行文件 链接时只记录依赖,运行时(或加载时)才加载
生成文件大小 大(代码重复包含)
部署 单文件即可跑,独立 必须带上 .dll/.so,缺了就崩(找不到XXX.dll
内存占用 每个进程各一份 多个进程可共享同一份物理内存
更新 改库要重新编译整个可执行文件 只替换 .dll/.so 即可(热更新方便)
启动速度 快(代码已在) 稍慢(要加载+重定位)
常见报错 链接期 undefined reference 运行期 无法定位程序输入点 / not found

Windows 注意:.lib 既可能是静态库,也可能是 DLL 的导入库(import library,只含导入符号,不含真正实现)。

制作静态库(Linux 视角):gcc -c foo.c -o foo.o && ar rcs libfoo.a foo.o,链接 -lfoo制作动态库gcc -shared -fPIC foo.c -o libfoo.so

面试金句:静态库“静态链接、以空间换独立性”;动态库“动态加载、以启动开销换体积与共享、更新灵活”。


4. 进程内存布局(重点背这张图)

Section titled “4. 进程内存布局(重点背这张图)”
高地址
┌─────────────────────┐
│ 栈 Stack │ ← 向下增长,局部变量、函数调用帧,自动分配释放
│ (大小默认约1~8MB)│
├─────────────────────┤
│ ↓ │
│ 空 闲 区 │
│ ↑ │
├─────────────────────┤
│ 堆 Heap │ ← 向上增长,new/malloc,手动管理,碎片化
├─────────────────────┤
│ 未初始化数据区 BSS │ ← 未初始化的全局变量/static 变量(运行时清零)
├─────────────────────┤
│ 已初始化数据区 .data│ ← 已初始化的全局变量/static 变量
├─────────────────────┤
│ 只读常量区 .rodata │ ← 字符串常量、const 全局常量
├─────────────────────┤
│ 代码段 .text │ ← 程序指令(只读)
└─────────────────────┘
低地址
变量 存放位置 生命周期 是否默认初始化
全局变量 .data / .bss 程序启动→结束 是(0)
static 全局变量 .data / .bss 程序启动→结束
static 局部变量 .data / .bss 首次执行到声明处初始化,程序结束析构(作用域仅函数内)
局部变量 函数调用期间 否(垃圾值)
new/malloc 出的对象 直到 delete/free 堆内存不自动清零:new int 未初始化、new int() 为 0、new T 调构造
字符串常量 "hello" .rodata(只读) 程序周期
const 局部变量 栈(编译期可优化) 同局部
虚函数表 vtable .rodata(只读) 程序周期
函数指令 .text 程序周期

必考题示例int a;(文件作用域)→ .bss;int b = 5; → .data;static int c; → .bss;void f(){ int d; } → 栈;int* p = new int; → 堆上分配、p 本身在栈。

💡 一样。“程序周期”就是“程序从启动到结束”的简称:变量在程序启动时创建(全局/static 在 main 之前完成初始化)、程序结束时销毁。注意区分两个概念:

  • 生命周期/存储期:全局/static 是“静态存储期”,从启动到结束(与“程序周期”同义)。
  • 初始化时机:static 局部变量的初始化发生在“第一次执行到它的声明处”(之后不再重新初始化),但它初始化后存活到程序结束——这两个说法不矛盾。

💡 意思是:栈上局部变量如果不写初始化,拿到的是垃圾值(不确定值)。栈是别人用过的内存,编译器不会帮你清零。

void f() {
int x; // 未初始化 → 值不确定(可能 0,也可能任意残留)
std::cout << x; // 读未初始化变量 = 未定义行为(UB),-Wall 会警告
int y{}; // 值初始化 → 确定是 0
int z = 0; // 显式初始化 → 0
}

对比记忆:全局/static 默认 0(.bss 由系统清零);栈和堆(new int)默认不初始化;new int() 才是 0。规则:栈/堆默认不初始化,全局/static 默认是 0。

对比项
分配方式 编译器自动分配/释放 程序员手动 new/delete
速度 快(只移动栈指针) 慢(要查找空闲块、可能系统调用)
容量 小,默认约 1~8MB,递归太深会栈溢出 大(取决于系统,可达 GB 级)
碎片 无(严格 LIFO) 有,会产生内存碎片
方向 向下增长(高地址→低地址) 向上增长
越界后果 段错误/程序崩溃 破坏堆管理数据结构,可能延迟崩溃

追问点:栈溢出的原因?→ 无限递归、超大局部数组。怎么避免?→ 检查递归基、大数组用堆(new/vector)。


CPU 访问内存不是按字节的,而是按“字”(如 8 字节)一次读取。如果数据跨了两个字(不对齐),需要两次访问甚至出错;对齐后一次就能取到。所以编译器默认会给结构体成员排位置,让每个成员都落在自身对齐数(通常等于大小)整数倍的地址上。

  1. 每个成员的起始偏移必须是 alignof(成员) 的整数倍;若用了 #pragma pack(n),则取 min(n, alignof(成员))
  2. 结构体自身的对齐值 = 成员中最大的 alignof总大小必须是这个对齐值的整数倍(最后可能补 padding)。
  3. 空类/空结构体大小为 1(见 04 章)。

💡 alignof(T) 和 sizeof(T) 是两件事

  • sizeof(T):这个类型/对象占多少字节(含尾部 padding)。
  • alignof(T):这个类型要求起始地址必须是几的倍数(对齐数)。 例:sizeof(int)=4, alignof(int)=4sizeof(double)=8, alignof(double)=8;但
struct S { char a; int b; }; // sizeof(S)=8,alignof(S)=4

S 里 b 要 4 字节对齐,a 后面垫 3 字节 → 总大小 8;但整个结构体对齐数是最大成员对齐 4(不是 8)。 记忆:sizeof 回答“占地多大”,alignof 回答“住址要求”。内存布局时:每个成员的偏移 = 上一个成员结束位置向上取整到 alignof(该成员) 的倍数;结构体总大小再向上取整到 alignof(结构体) 的倍数。

struct S1 { // 分析偏移:
char a; // 0,对齐1,占 [0]
int b; // 对齐4 → 偏移4,占 [4,7],中间 [1,3] 是 padding
double c; // 对齐8 → 偏移8,占 [8,15]
}; // 最大对齐8,总大小16(已是8的倍数)
// sizeof(S1) = 16
struct S2 { // 调换顺序:
double c; // 偏移0,占 [0,7]
int b; // 偏移8,占 [8,11]
char a; // 偏移12,占 [12]
}; // 最大对齐8,总大小必须8的倍数 → 补到16
// sizeof(S2) = 16(顺序改变不影响这里,但下面这个顺序有影响)
struct S3 {
char a; // [0]
char b; // [1]
int c; // 对齐4 → 偏移4,占 [4,7]
}; // 最大对齐4 → 8
// sizeof(S3) = 8
struct S4 {
char a; // [0]
double c; // 对齐8 → 偏移8!占 [8,15]
int b; // 对齐4 → 偏移16,占 [16,19]
}; // 最大对齐8,总大小 → 24
// sizeof(S4) = 24 —— 看出"把大成员放前面"可以省内存

总结一句话:按成员大小从大到小排,padding 最少。

#pragma pack(push, 1) // 强制 1 字节对齐(省内存、但访问变慢)
struct Packed { char a; int b; }; // sizeof = 5
#pragma pack(pop)
struct Aligned { alignas(16) char buf[64]; }; // C++11 alignas:强制16字节对齐(如SIMD数据)
> 💡 **alignas(16) 的作用**:`char` 自然对齐是 1(放哪都行),`alignas(16)` 强制这个结构体的**对齐数变成 16**——即 `alignof(Aligned) == 16`,编译器保证起始地址是 16 的倍数(`sizeof` 也必须是 16 的倍数)。
> **sizeof(Aligned) = 64**buf64 字节,64 本来就是 16 的倍数,无需补 padding
>buf 是 `char[65]`:65 不是 16 的倍数 → sizeof 补到 **80**
> 用途:SIMDSSE 要求 16 字节对齐)、原子操作、缓存行对齐(`alignas(64)`)提升性能。

追问:pragma pack 用在什么场景?→ 网络协议报文、文件格式、硬件寄存器映射,需要和外部结构严格对应时。 追问:offsetof 能拿成员偏移;alignof(T) 拿类型对齐数。

💡 offsetof(struct, member):标准库宏(#include <cstddef>),返回成员在结构体内的字节偏移

struct S { char a; int b; double c; };
offsetof(S, a) == 0
offsetof(S, b) == 4 // int 对齐 4
offsetof(S, c) == 8 // double 对齐 8

原理(编译器魔法):((size_t)&((S*)0)->b) —— 假装结构体放在地址 0,取成员的地址就是它的偏移。 用途:序列化/反序列化、反射系统、游戏引擎属性系统。注意:对非标准布局的类(含虚函数、多重继承等)用 offsetof 是未定义行为,标准只保证标准布局类型。


  • 大端(Big-Endian):高位字节存低地址。网络字节序是大端。
  • 小端(Little-Endian):低位字节存低地址。x86/ARM 主流是小端

判断方法(背代码):

#include <iostream>
bool isLittleEndian() {
int x = 1; // 内存里低字节是 0x01
return *(char*)&x == 1; // 取首字节:小端 → 1,大端 → 0
}
// 或用 union(更常见于面试)
union { int i; char c[4]; } u;
u.i = 1;
std::cout << (u.c[0] == 1 ? "小端" : "大端");

⚠️ 易错点(面试高频坑):小端机器上 int x = 0x12345678;,内存从低地址到高地址依次是 78 56 34 12,不是 12 34 56 78! 小端 = 低位字节存低地址,0x78 是最低位字节 → 先落地。12 34 56 78大端/网络字节序的排列。 记忆:小端 = “小尾端”,尾巴(低位)先存。判断:*(char*)&x == 0x78 → 小端;== 0x12 → 大端。

为什么考:网络协议、跨平台存档/二进制格式、序列化时字节序不一致会读错数据。游戏联机或存二进制存档时要用 htonl/ntohl 或统一转成小端。

💡 htonl / ntohl:字节序转换函数(Linux 在 arpa/inet.h,Windows 在 winsock2.h)。

  • htonl = host to network long:把“主机字节序”(x86 是小端)转成“网络字节序”(规定是大端)。发送数据前调用。
  • ntohl = network to host long:接收后转回主机字节序。
  • 还有 16 位版本 htons / ntohs(s = short)。这里的 “long” 指 32 位、“short” 指 16 位(与 C 的 long 类型大小无关)。
uint32_t v = 0x12345678;
uint32_t net = htonl(v); // 发送前:小端 → 大端
uint32_t back = ntohl(net); // 接收后:大端 → 小端

本质:网络协议/跨平台文件格式必须约定一种字节序(大端),各平台收发时转换。自己设计的存档格式也可直接统一用某一种,但用 htonl/ntohl 更通用。

标准提示:union 类型双关在 C++ 中严格是未定义行为(GCC/MSVC 作为扩展允许);更标准的写法是 memcpy、C++20 std::bit_cast,或读 unsigned char 对象表示。

💡 展开讲(union 类型双关):上面判断大小端的 union 写法:

union { int i; char c[4]; } u;
u.i = 1;
std::cout << u.c[0]; // 写 i、读 c —— 类型双关

C 语言允许“写一个成员读另一个成员”(类型双关);C++ 标准认为这是未定义行为(UB),但 GCC/MSVC 把它当扩展支持,实践中能跑,所以面试/笔试可以这么答。更标准的做法:

// 方式一:memcpy(C++03 起合法)
int x = 1; char bytes[4];
std::memcpy(bytes, &x, sizeof x); // 把字节拷出来看
// 方式二:C++20 std::bit_cast(编译期、最现代)
auto bytes = std::bit_cast<std::array<char, 4>>(x);
// 方式三:用 unsigned char* 读对象表示(标准明确允许)
const unsigned char* p = reinterpret_cast<const unsigned char*>(&x);
p[0] // 小端 == 1

面试话术:先答 union 法(考官预期答案),再补一句“严格 C++ 里这是 UB,规范做法是 memcpy / bit_cast”,加分。


7. 高频面试题 Q&A(合上书能讲)

Section titled “7. 高频面试题 Q&A(合上书能讲)”

Q1:从 .cpp 到可执行文件经历哪几步? 预处理(展开头文件、宏替换)→ 编译(词法/语法/语义分析,生成汇编)→ 汇编(生成目标文件 .o)→ 链接(符号解析+重定位,和库拼成可执行文件)。

Q2:静态库和动态库区别? 静态库链接期拷入代码,体积大、独立部署、启动快;动态库运行期加载,体积小、可共享、可热更新,但依赖缺失会运行时报错。

Q3:全局变量、static、局部变量存哪?生命周期? 全局/static → 数据段(.data/.bss),程序启动到结束;局部 → 栈,函数返回即销毁;new → 堆,直到 delete。

Q4:栈和堆的区别? 栈:自动管理、快、容量小、无碎片;堆:手动管理、慢、容量大、有碎片。

Q5:sizeof 一个结构体怎么算? 成员按自身大小对齐 + 总大小是最大对齐数整数倍 + 顺序影响 padding。例:{char,int,double} → 16;{char,char,int} → 8。

Q6:为什么要内存对齐? CPU 按字访问,对齐保证一次读取、访问更快,也避免跨页/跨字访问。

Q7:怎么判断大小端? union 或 char* 强转取首字节,看低字节是不是低地址。

Q8:#pragma once 和 #ifndef 区别? #ifndef 靠宏名防重、跨平台;#pragma once 编译器按文件去重、更快。现代工程用 #pragma once 居多。

Q9:extern “C” 干嘛的? 关闭 C++ 名字修饰,让 C++ 里能链接 C 编译出的符号;反过来 C 里也常用。

Q10:链接报 undefined reference 可能原因? 函数只声明没实现;源文件没参与编译/链接;忘了链接对应的库(-lxxx);符号名不一致(extern “C” 或 const/static 链接属性问题)。

Q11:const 全局变量和字符串常量放哪? 只读数据段 .rodata,改写会崩溃(段错误)。

Q12:一个进程的虚拟地址空间为什么 32 位只有 4G?栈为什么不能无限大? 虚拟地址由地址线位数决定(32 位 → 2^32 = 4G);栈大小由系统限制(可 ulimit 调整),防止无限递归把整个空间耗尽。


8. 本章自测(10 题,限时 15 分钟,先自己答再看答案)

Section titled “8. 本章自测(10 题,限时 15 分钟,先自己答再看答案)”

1. 预处理和编译阶段的分界产物是什么?.i .s

2. `.text`、`.data`、`.bss`、`.rodata`、栈、堆分别装什么?

我的原答:代码、初始化的全局变量和static、未初始化的全局变量和static、const以及常量字符串

3. `int x = 1;`(文件级)和 `void f(){ int x = 1; }` 的 x 存哪?

我的原答:.data、栈

4. `struct { int a; char b; char c; }` 的 sizeof?

我的原答:写理由。4+1+1=6 需要时4的倍数 8

5. `struct { char a; int b; char c; }` 的 sizeof?

我的原答:4(1)+4+1 = 9 12

6. 静态库和动态库各举一个"为什么选它"的场景。

我的原答:需要保持程序独立性,启动需要快;经常更新库,共享

7. 用代码判断大小端(手写)。

我的原答:`int x = 1; *(char*)&x == 1`

8. `#pragma once` 能防住所有重复包含问题吗?

我的原答:有什么局限?不能,按照文件识别重复,但是有时候因为软硬链接等问题会把同一个文件识别成两个文件

9. 链接时报 `undefined reference to main` 说明什么?

我的原答:未实现定义;未链接库;extern "C";未把当前文件编译/链接

10. 为什么 const 成员函数里修改成员会编译报错?

我的原答:(提示:先想 const 指针,这个在 03 章有呼应)

📖 答案(先自己答完再展开)
  1. 预处理产出 .i 文件(展开后的翻译单元);编译从它开始生成汇编 .s。
  2. 代码段=指令;.data=已初始化全局/static;.bss=未初始化全局/static;.rodata=字符串常量等只读数据;栈=局部变量/调用帧;堆=动态内存。
  3. 前者 .data;后者栈(局部)。
  4. int(4)+char(1)+char(1) → 对齐 4 → 8。(char 连续放,4+1+1=6 补到 8)
  5. char[0] + pad[1..3] + int[4..7] + char[8] → 最大对齐 4 → 12。
  6. 静态库:需要单文件发布/离线环境;动态库:插件化、频繁更新、多进程共享。
  7. 见第 6 节代码。
  8. 不能:只防“同一路径被多次包含”;同一文件经硬链接/软链接/不同路径包含时可能被当成两个文件(工程上 #pragma once 足够用)。
  9. 入口函数 main 没被链接进来(缺 main 定义/没编 main.cpp/编译链接命令漏了文件)。
  10. const 成员函数里 this 是 const T*,所有成员都只读——呼应下一章“const 指针”。

## 9. 进阶追问(答不上来就回来复习)
  • 动态库加载时“重定位”做了什么?(PIC 地址无关代码)
  • 栈为什么向下增长、堆为什么向上?(历史约定,避免相撞)
  • alignas 对齐超出类型默认对齐的场景?(SIMD、原子操作 16 字节对齐)
  • 什么是 TLB、页表?(性能章 11 会讲)