构建配置与编译优化
Build Configurations & Compiler Optimization
C++ 游戏代码从源文件到可执行文件要经过预处理、编译、链接。游戏项目通常不止 Debug/Release 两种构建配置 (build configuration),而是 Debug、Development、Ship,有时再加混合构建;配置越多测试越难。编译器优化分为局部优化和全局优化(链接时优化 LTO、剖析引导优化 PGO),它们让程序更快,但也让调试器“说谎”。所以要学会调试优化过的构建,并用统计式或插桩式性能分析器 (profiler) 找出最耗时的代码。
翻译单元、目标文件与链接(卷 I 2.2.1–2.2.2)
- 翻译单元 (translation unit):编译器一次翻译一个源文件(.cpp 等)。头文件并不直接交给编译器,而是由预处理器把
#include替换成头文件的内容,所以编译器看到的只有翻译单元。 - 目标文件(.obj / .o)里的机器码有两个特点:可重定位(还没有确定地址)和未链接(引用的外部函数和全局数据还没解析)。库只是若干目标文件的归档,类似 ZIP。
- 链接器有两项工作:算出所有机器码的最终相对地址,并解析各翻译单元之间的外部引用。可执行文件仍然是可重定位的,绝对基址要到载入内存时才确定。
- 动态链接库 (DLL) 介于库和可执行文件之间:像库一样可以被多个程序调用,又像可执行文件一样能被操作系统独立载入,带有启动和关闭代码。使用 DLL 的程序中,指向 DLL 的引用保持未链接,运行时由操作系统载入 DLL 并修补地址,因此 DLL 可以单独更新。
- Visual Studio 中,项目 (project)(.vcxproj,XML 格式)产出一个库、可执行文件或 DLL;解决方案 (solution)(.sln)管理多个项目。
构建配置(卷 I 2.2.4)
构建配置就是绑定在项目上的一组预处理器、编译器、链接器选项,免得每次都手写一长串命令行(例如 cl /c foo.cpp /Fo foo.obj /Wall /Od /Zi,或 Clang 的 -O0 -g)。主要选项有三类:
| 类别 | 典型选项 | 说明 |
|---|---|---|
| 预处理器 | -D / /D 定义宏 | Debug 构建定义 _DEBUG,非 Debug 构建定义 NDEBUG,代码据此进行条件编译;编译器还会自动定义 __cplusplus、_MSC_VER、__GNUC__、_WIN32 等宏,方便写跨平台代码 |
| 编译器 | 调试信息、内联展开、优化级别 | 调试信息会让可执行文件变大,也方便别人逆向,所以发行版总会剥离;关闭内联后每个内联函数在内存中只有一份,便于跟踪;优化会重排语句、删除或移动变量、复用寄存器,使调试器难以如实显示 |
| 链接器 | 输出类型、外部库与搜索路径、栈大小、基址、目标机器、是否开启全局优化 | 常见做法是 Debug 构建链接调试版库,非 Debug 构建链接优化版库;VS 还能用源码中的特殊 #pragma 让链接器自动链接某个库,所以“附加依赖项”里未必列出全部实际链接的库(DirectX 程序就是例子) |
作者建议不要给单个翻译单元单独覆盖项目设置,因为之后很难分清哪些 .cpp 有特殊设置。在 VS 属性页中尽量选“所有配置”统一编辑,只把确实需要不同的设置(如内联、优化)分开;包含目录尽量写成相对路径或用 $(OutDir)、$(IntDir)、$(ConfigurationName) 等宏表示,并把中间文件和最终产物放在不同目录(卷 I 2.2.4.4)。
局部优化与全局优化(卷 I 2.2.4.2)
除局部和全局两大类之外还有别的优化,例如窥孔优化 (peep-hole optimization),让优化器做平台或 CPU 相关的优化。
局部优化只作用于基本块 (basic block),粗略地说就是一段不含分支的指令。包括:代数化简、运算强度削减(如把 x / 2 换成 x >> 1)、内联、常量折叠、常量传播、循环展开、死代码消除、指令重排(减少流水线停顿)。
全局优化考虑整个控制流图,例如公共子表达式消除。理想情况下它跨越翻译单元,所以由链接器完成,称为链接时优化 (LTO)。LLVM/Clang 等还支持剖析引导优化 (PGO):用之前运行时采集的剖析数据,反复优化最关键的路径。代价是 LTO 会大大延长链接时间,PGO 则需要反复运行程序(由 QA 或自动化测试驱动)来生成剖析数据。gcc、Clang、Visual C++ 的优化级别都从 -O0(不优化)到 -O3(全部开启)。
游戏常用的构建配置(卷 I 2.2.4.3)
作者主张少用含义模糊的“release build”,只区分 debug build(局部和全局优化都关闭)与 non-debug build(开启局部和/或全局优化)。下图把常见配置排成一条从易调试到高性能的光谱:
flowchart LR D["Debug:关闭全部优化与内联,带完整调试信息"] --> H["Hybrid:大部分代码按 Development 编译,少数翻译单元按 Debug 编译"] H --> V["Development:开启大部分局部优化,保留调试信息和断言"] V --> S["Ship:剥离调试信息,去掉大部分断言,开启 LTO/PGO 等全部优化"]
越往右越快、越难调试。
- 混合构建 (Hybrid):只把正在排查的那部分代码按 Debug 编译,其余部分以接近全速运行。用 make 很容易实现:定义一个
$HYBRID_SOURCES变量列出要按 Debug 编译的文件,两套目标文件放进不同目录,链接时按列表挑选。VS 的配置以项目为单位,较难做到;可以用脚本生成 .vcxproj,或者在解决方案级按项目(即按库)混合。 - 可测试性:配置越多越难测,因为某个严重 bug 可能只出现在其中一种配置里。一般不正式测试 Debug 构建;但如果平时只测 Development,就不能在 Gold Master 前一晚才做 Ship 构建,指望它的 bug 情况和 Development 一样。整个 alpha 和 beta 阶段,Development 和 Ship 都要同样充分地测试。所以有些工作室干脆不设单独的 Ship 配置,测好 Development 后剥掉调试信息就直接发行。
调试技巧(卷 I 2.2.5)
- 基础操作:启动项目、断点(F9)、单步(F10 跳过函数,F11 进入函数)、调用栈窗口、监视窗口。监视窗口能求任意 C/C++ 表达式、做类型转换,甚至调用程序里的函数(如
quatToAngleDeg();注意监视表达式在每次命中断点或单步时都会自动重新求值,所以这个函数每次都会被调用);后缀,d显示十进制、,x显示十六进制、,!绕过 Natvis 显示原始格式、,n把指针当作 n 个元素的数组,也可写成my_array,[my_array_count]。展开特别大的数据结构可能让调试器慢到无法使用。 - 数据断点 (data breakpoint),也叫硬件断点:利用 CPU 在指定地址被写入时触发中断的能力。例如可以监视
&object.m_angle,看是谁写进了 0。 - 条件断点:例如用
(uintptr_t)this == 0x12345678只在某个实例上停下;也可以设置命中次数(比如只看第 376 次循环)。代价是每次命中都要计算条件表达式,会拖慢调试器和游戏。 - 只在非 Debug 构建出现的 bug,常见原因有:未初始化变量(Debug 模式下常被清零)、重要代码被误写进断言、数据结构大小或成员打包随配置变化、内联或优化触发的问题,少数情况是优化器本身的 bug。调试优化后的代码可以:在反汇编模式下单步;从寄存器推断变量的值或地址;把已知地址强制转换成类型来查看,如
(Foo*)0x1378A0C0;借助静态或全局变量(如全局PhysicsWorld)找到内部对象;修改代码,加打印、加全局变量、加检测条件。
性能分析器与内存工具(卷 I 2.3–2.4)
帕累托原则(80/20 法则) 用在优化上的意思是:大约 80% 的运行时间花在约 20% 的代码上,所以先用性能分析器找到这 20%。函数耗时多有两种原因:单次执行慢,或者调用次数多,因此调用次数和调用图(父函数/子函数及各自的耗时占比)都很重要。
| 类别 | 原理 | 特点 | 书中示例 |
|---|---|---|---|
| 统计式 (statistical) | 周期性采样程序计数器,按样本数估算各函数的时间占比 | 几乎不影响运行速度 | Intel VTune |
| 插桩式 (instrumenting) | 在每个函数插入序言和尾声代码,调用剖析库记录调用栈、调用者和次数,甚至可以逐行计时 | 极为精确全面,但会让游戏慢到几乎无法游玩 | IBM Rational Quantify |
| 混合式 | 低开销采样,同时在每个样本上分析调用栈 | 能得到调用者分布 | Microsoft LOP;PS5 上的 Razor CPU 两种方式都支持,Razor GPU 用于着色器和计算任务 |
内存泄漏是分配了却从不释放;内存破坏是写到了错误的地址,覆盖了原有数据,同时该写的地方却没写。两者都源于指针。书中列出的检测工具有 IBM Rational Purify(插桩所有解引用和分配/释放操作,实时报告问题,退出时输出泄漏报告)、Parasoft Insure++、Valgrind。其他常用工具还有 diff 工具、三方合并工具(合并两组 diff,涉及原始版本和两人各自的版本)、十六进制编辑器(排查二进制文件格式)。
版本控制部分(2.1:集中式与分布式、独占签出与多重签出、三方合并、删除文件仍保留历史)在 GEA 中只是通用入门,本页不展开。书中的个人评价是:Subversion 每次都要扫描整棵目录树才能找出改动,大项目上会变慢;大型商业项目作者更倾向 Perforce 或 git。
与 UE 的对应
UE 的构建目标 DebugGame / Development / Shipping 与书中的 Debug / Development / Ship 基本对应,由 UnrealBuildTool 按模块组织,见 模块、插件与构建系统;Live Coding 用增量编译和补丁缩短迭代周期,见 Live Coding 实时编译;Unreal Insights 等工具属于书中所说的插桩式剖析(作用域计时),见 UE5 性能分析。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 2 章 2.2–2.4 节