中EN
概念页

C++ 内存布局与对齐

C++ Memory Layout & Alignment

更新于 2026-10-13 约 8 分钟内存布局对齐链接栈堆虚函数表GEAPBRT

理解 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 给每种具体类型分配一个整数标签,在运行时据此分派。两条理由:

  1. 内存:每个带虚函数的对象多一个 vtable 指针(现代系统上 8 字节,与上文 GEA 所说的 vpointer 一致),渲染复杂场景时,仅形状和图元的 vtable 指针就占了相当多内存;标签指针没有额外的类型存储开销。
  2. CPU/GPU 共用:vtable 里存的是函数地址,而 CPU 与 GPU 的可执行代码在不同地址,同一张表不可能同时对两边有效。 调用方写法不变,只是用 . 而不是 ->。实现见 PBRT 附录 B.4.4,第一个使用它的类是 Spectrum(见 光谱渲染)。
  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 3 章 3.1.1、3.3.4–3.3.7 节
  3. PBRT 4e source excerpt: chapter 01 (Introduction)
  4. 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

相关页面

游戏数学数值表示与浮点数Numeric Representation & Floating Point游戏引擎里到处是数值,所以要清楚它们在机器中如何存储:二进制和十六进制、补码整数、定点数、IEEE-754 浮点数(量级与精度此消彼长、次正规数、机器 epsilon、ULP),以及多字节值的大小端问题和安全的类型双关 (type punning) 方法。书中一个直观的例子是:用 32 位浮点数记录以秒为单位的游戏时钟,大约 12.14 天后,加 1/30 秒就不再改变它的值。游戏开发计算机硬件与内存层次Computer Hardware & Memory Hierarchy从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。游戏开发构建配置与编译优化Build Configurations & Compiler OptimizationC++ 游戏代码从源文件到可执行文件要经过预处理、编译、链接。游戏项目通常不止 Debug/Release 两种构建配置 (build configuration),而是 Debug、Development、Ship,有时再加混合构建;配置越多测试越难。编译器优化分为局部优化和全局优化(链接时优化 LTO、剖析引导优化 PGO),它们让程序更快,但也让调试器“说谎”。所以要学会调试优化过的构建,并用统计式或插桩式性能分析器 (profiler) 找出最耗时的代码。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏开发组件模式Component Pattern组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。游戏开发进程、线程与纤程Processes, Threads & Fibers并发编程依赖操作系统提供的服务:内核态与用户态、中断、系统调用、抢占式多任务。进程只是为线程提供运行环境(虚拟地址空间与资源),线程才是操作系统的基本执行单位(调用栈 + 寄存器)。线程由内核抢占调度;纤程 (Fiber) 由程序协作调度,适合实现引擎任务系统;用户级线程与协程完全在用户空间切换上下文,更轻量。游戏开发无锁编程与内存序Lock-Free Programming & Memory Ordering「无锁 (lock-free)」的真正含义不是不用互斥锁,而是从不让线程阻塞(作者说叫 block-free 更贴切)。数据竞争有三个来源:关键操作被打断、编译器与 CPU 的指令重排、缓存一致性协议优化造成的内存序问题。对应的工具是原子指令(TAS、XCHG、CAS、LL/SC)和内存屏障/栅栏(acquire、release、full fence),C++11 用 std::atomic 与 std::memoryorder 把它们封装成可移植接口。在此基础上可以写出自旋锁、可重入锁、读写锁,以及基于「本地准备 + 一次原子提交」事务的无锁数据结构。游戏开发SIMD 向量化SIMD VectorizationSIMD(单指令多数据)用一条机器指令并行处理多个数据。x86 上从 MMX 发展到 SSE(128 位,4 个 float)、AVX(256 位,8 个)和 AVX-512(512 位,16 个)。GEA 用 SSE 内建函数演示:数组相加、点积、向量×矩阵、矩阵×矩阵,强调两点——避免寄存器内的水平相加(先转置数据),以及把 SIMD 寄存器看成 N 条并行的「通道 (lane)」而不只是一个 4D 向量;条件分支则用向量谓词化 (vector predication / select) 代替。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发资源交叉引用与加载后初始化Resource Cross-References & Post-Load Initialization资源之间以任意方式相互引用(网格引用材质,材质引用纹理),整个资源数据库是一张有向依赖图。内存中的指针离开运行中的程序就失去意义,所以存盘时要把引用改写为 GUID 或文件内偏移,加载后再修正 (fix up) 回指针;含非 POD 的 C++ 对象还要用 placement new 调用构造函数;跨文件的外部引用要先加载全部相关文件。加载后通常还需要按资源类型做加载后初始化(以及释放前的拆除)。游戏开发运行时对象模型架构Runtime Object Model Architectures玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。图形学光谱渲染Spectral Rendering光谱渲染 (Spectral Rendering) 在光传输中直接处理波长,而不是 RGB 三元组。pbrt 第 4 版在每条相机光线上随机采样少数几个波长(默认 4 个),把光谱积分并入渲染本来就在做的蒙特卡洛积分;最后用颜色匹配函数估计像素的 XYZ/RGB。输入端的 RGB 颜色则通过"sigmoid 二次多项式"转换成平滑光谱。本页按 PBRT 4e 4.5、4.6.4–4.6.6 节整理。图形学三角形带、扇与索引网格Triangle Strips, Fans & Indexed Meshes让共享顶点的三角形成组送入管线,可以减少顶点着色器调用。三角形扇和带把每三角形发送的顶点数降到接近 1;现代 GPU 的标准做法是带单一索引缓冲的三角形网格,理论上每三角形只需处理约 0.5 个顶点,实际效果取决于变换后顶点缓存,由三角形顺序优化(Forsyth、Tipsify 等)决定。图形学空间数据结构Spatial Data Structures空间数据结构把几何体按 n 维空间组织起来,通常是层次化的树,使"几何体是否重叠"一类查询从 O(n) 降到大约 O(\log n)。常见类型有包围体层次 (BVH)、BSP 树(轴对齐的 k-d 树与多边形对齐两种)、四叉树/八叉树(含松散八叉树),以及更偏向组织模型关系的场景图。它们是剔除、光线求交、碰撞检测等加速算法的基础。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原