C++ 内存布局与对齐
C++ Memory Layout & Alignment
理解 C/C++ 程序在内存中的样子,是写高性能引擎代码的前提。主要内容包括:声明与定义、内部与外部链接;可执行映像的几个段(text、data、BSS、rodata)以及栈和堆;结构体成员的自然对齐和填充;继承和虚函数如何改变类的布局(vpointer 和 vtable)。GEA 也简要回顾了面向对象:多重继承容易产生致命菱形,所以通常只允许 mix-in;组合往往比继承更好。
声明、定义与链接(卷 I 3.3.4)
- 编译器一次只处理一个翻译单元,遇到外部引用时只能“相信它存在”。链接器负责解析这些引用,只会报两类错:符号未解析 (unresolved symbol) 和符号重复定义 (multiply defined symbol)。
- 声明只描述名字和类型,定义则对应一块唯一的内存(变量、实例或函数机器码)。一个实体可以有多次相同的声明,但只能有一个定义。
- 头文件中一般不放定义,否则被多个 .cpp 包含时会重复定义。例外是内联函数和模板:编译器必须看到函数体才能内联或实例化,所以它们的定义必须放在头文件里。
inline只是给编译器的提示,最终由编译器决定;__forceinline之类的关键字可以绕过它的判断。 - 链接性 (linkage):默认是外部链接;
static把定义改为内部链接,不同 .cpp 中同名的 static 实体互不冲突。GEA 进一步说“内联函数默认为内部链接(如同 static),这才是它们可以放在头文件里的真正原因”(卷 I 3.3.4.3)。原书问题:按 C++ 标准,非 static 的
inline函数具有外部链接;它能放进头文件被多个翻译单元包含,是因为单一定义规则 (ODR) 允许 inline 函数在每个翻译单元各有一份完全相同的定义,链接器会把它们合并为一个实体(因此各处取到的函数地址相同)。只有加了static或放在匿名命名空间里的函数才是内部链接。两种说法在“不会报重复定义”这一结论上一致,机制不同。书中另说声明“可以粗略看作”内部链接,严格讲声明本身没有链接属性。
可执行映像与运行时内存(卷 I 3.3.5)
| 区域 | 内容 |
|---|---|
| text(代码段) | 所有函数的机器码 |
| data | 已初始化的全局和静态变量,按运行时布局预先填好初值 |
| BSS | 未初始化的全局和静态变量。文件里只记录需要多少字节的零,载入时由操作系统分配并清零 |
| rodata | 只读常量,如 const float kPi、const 全局对象。整型常量常作为立即数直接嵌入指令,因此在代码段而非 rodata 中 |
| 栈 | 每次调用压入一个栈帧,包含返回地址、保存的寄存器和局部(自动)变量,通过移动栈指针来压入和弹出 |
| 堆(自由存储区) | malloc/free、new/delete(可以重载,不一定来自全局堆)。自定义分配器见卷 I 第 6 章 |
函数内的 static 变量在第一次调用时初始化,但在映像中和文件级静态变量一样,放在 data 或 BSS 段。返回局部变量的地址是常见错误:栈帧被抛弃后,那块内存很快会被后续调用覆盖。static 有三种含义:文件作用域下表示内部链接;函数作用域下表示只在函数内可见的全局变量;类中表示按类而非按实例存储的成员,其存储空间必须在某个 .cpp 中单独定义(卷 I 3.3.6.1)。
对齐与填充(卷 I 3.3.7.1)
每种类型都有自然对齐,一般等于它的字节宽度:32 位值按 4 字节对齐,16 位按 2 字节对齐,8 位不限,四元 float 的 SIMD 向量按 16 字节对齐。读取非对齐数据时,内存控制器要读两个块,再掩码、移位、合并;有些处理器则直接返回垃圾或崩溃(例如 PS2)。
因此编译器会在成员之间插入填充。书中的例子是:把 U32, F32, U8, I32, bool, char* 重新排成先大后小,可以消除中间的空洞。但结构体仍会被补到 20 字节而不是 18 字节,这样它在数组中每个元素都能对齐。结构体整体的对齐等于其成员中最大的对齐要求。 作者习惯在末尾写出显式填充 U8 _pad[2],让浪费的空间一目了然。#pragma pack 可以要求编译器不填充,但默认会填充。
下图示意重新排序前后(32 位指针)的布局差异:
flowchart TB
subgraph A["InefficientPacking:成员交错,产生多处空洞"]
a1["mU1 (4)"] --> a2["mF2 (4)"] --> a3["mB3 (1) + 填充 3"] --> a4["mI4 (4)"] --> a5["mB5 (1) + 填充 3"] --> a6["mP6 (4)"]
end
subgraph B["MoreEfficientPacking:小成员集中放在末尾"]
b1["mU1 (4)"] --> b2["mF2 (4)"] --> b3["mI4 (4)"] --> b4["mP6 (4)"] --> b5["mB3 (1) + mB5 (1) + 末尾填充 2 = 20 字节"]
end后一种排法只在末尾留下 2 字节填充,用来保证数组中每个元素都对齐。
C++ 类的布局(卷 I 3.3.7.2)
- 继承:派生类的成员紧跟在基类成员后面(中间可能有对齐填充)。多重继承可能让派生类包含同一个基类的多份副本(致命菱形,可用虚继承避免),指针转换到不同基类时地址可能改变,因为对象里有多个 vtable 指针。
- 虚函数:只要类声明或继承了虚函数,就会多出一个 4 或 8 字节的 vpointer,通常放在开头,指向该具体类的 vtable,即全部虚函数的指针表。调用
pShape->Draw()时,先解引用 vpointer,再查表调用:Circle 的表指向Circle::Draw,Triangle 的表指向Triangle::Draw。纯虚函数在基类中没有实现,派生类必须覆盖才能实例化。
面向对象回顾(卷 I 3.1.1)
封装、继承(is-a)、多态(用虚函数代替遍布代码的 switch)、组合与聚合(has-a / uses-a)。书中的告诫是:新手往往过度使用继承、过少使用组合,例如让 Window 继承 Rectangle 是错的,应该让它包含一个 Rectangle。多重继承在实践中容易造成混乱,常见规则是只允许没有父类的简单 mix-in 类被多重继承。
与其他页面的关系
GPP 的 数据局部性 和 组件模式 也主张组合优于继承、按访问方式安排数据,与 GEA 在这里的建议一致。对齐和缓存行的关系见 计算机硬件与内存层次。
PBRT 的视角:用标签指针代替虚函数(PBRT 4e 1.3、1.5.7)
pbrt 前三版用抽象基类加纯虚函数;第 4 版为了支持 GPU,除积分器外的多态类型都改为基于标签的分派 (tag-based dispatch):TaggedPointer 给每种具体类型分配一个整数标签,在运行时据此分派。两条理由:
- 内存:每个带虚函数的对象多一个 vtable 指针(现代系统上 8 字节,与上文 GEA 所说的 vpointer 一致),渲染复杂场景时,仅形状和图元的 vtable 指针就占了相当多内存;标签指针没有额外的类型存储开销。
- CPU/GPU 共用:vtable 里存的是函数地址,而 CPU 与 GPU 的可执行代码在不同地址,同一张表不可能同时对两边有效。
调用方写法不变,只是用
.而不是->。实现见 PBRT 附录 B.4.4,第一个使用它的类是Spectrum(见 光谱渲染)。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 3 章 3.1.1、3.3.4–3.3.7 节
- PBRT 4e source excerpt: chapter 01 (Introduction)
- Pharr, Jakob, Humphreys《Physically Based Rendering: From Theory to Implementation》第 4 版(MIT Press, 2023)第 1 章 1.3、1.5.7 节,pbr-book.org/4ed/Introduction