技术博客

UBT 构建流程解析:.Target.cs/.Build.cs 如何生成编译命令

第 2 / 100 篇 · 2026-07-17 · ue5 · UBT构建系统源码分析

UE 项目里没有 CMakeLists,没有 Makefile,只有 .Target.cs 和 .Build.cs 两类 C# 文件。点下 Build 之后发生的一切都由 UBT(UnrealBuildTool,源码在 Engine/Source/Programs/UnrealBuildTool)推导出来。这篇文章把这条推导链走完:目标描述 → 模块图 → 编译动作图 → 命令行。理解了它,"为什么我加的库没被链接"、"为什么这个宏在 Shipping 里没生效"这类问题就都有答案了。

两层声明:Target 管"编成什么",Module 管"怎么编"

UBT 的输入是两级 C# 类,职责分得很清楚:

// MyGame.Target.cs —— 描述一个"构建目标"
public class MyGameTarget : TargetRules
{
    public MyGameTarget(TargetInfo Target) : base(Target)
    {
        Type = TargetType.Game;                 // Game / Editor / Server / Client / Program
        DefaultBuildSettings = BuildSettingsVersion.V5;
        ExtraModuleNames.Add("MyGame");          // 这个目标要编哪些模块
    }
}

// Source/MyGame/MyGame.Build.cs —— 描述一个"模块"
public class MyGame : ModuleRules
{
    public MyGame(ReadOnlyTargetRules Target) : base(Target)
    {
        PublicDependencyModuleNames.AddRange(new string[] {
            "Core", "CoreUObject", "Engine", "InputCore"
        });
        PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
    }
}

Target 回答的是产品层面的问题:这个目标是什么类型(Game 要 cook 资产,Editor 要带编辑器模块)、用哪套默认设置、包含哪些模块。Module 回答的是工程层面的问题:我这个模块依赖谁、include 路径加什么、链什么库、定义什么宏。

关键的认知是:依赖不是传递闭包式的随意引用。PublicDependencyModuleNames 和 PrivateDependencyModuleNames 的区别不是"能不能 include",而是你的 Public 头文件里暴露了谁。如果你的模块 A 的 Public 头文件里出现了模块 B 的类型,那 B 必须在 Public 依赖里——否则下游模块 C 依赖 A 时,UBT 不会把 B 的 include 路径传给 C,C 编译 A 的头文件就会找不到 B 的类型。UBT 在 ModuleRules 的校验阶段(UnrealBuildTool/Rules/ 下)会检查这个,报错信息是 "expects private module X to be public"。

推导链第一步:从 Target 展开模块图

UBT 入口在 UnrealBuildTool/Modes/BuildMode.cs,核心流程是:

  1. 收集 Target:扫描 Source/ 下所有 *.Target.cs,用 C# 反射实例化 TargetRules 子类(UnrealBuildTool/System/RulesCompiler.cs 负责把这些 .cs 文件编译成一个临时程序集再执行——所以 .Build.cs 语法报错时你会先看到 C# 编译错误)。
  2. 实例化模块规则:对 Target 声明的每个模块名,找到对应的 .Build.cs,实例化 ModuleRules。同一个模块被多个 Target 引用时,每个 Target 一份独立实例——这就是为什么 ModuleRules 的构造函数拿到的是 ReadOnlyTargetRules,模块规则可以读取目标信息(比如 Target.Configuration)来条件化自己的行为,但不能修改目标。
  3. 递归展开依赖图:从 Target 的模块出发,按 PublicDependencyModuleNames/PrivateDependencyModuleNames 递归,得到完整的有向无环图。这一步在 UEBuildModule 里完成,产出是 UEBuildTarget 对象——构建的全部决策数据都在它身上。

这里有个容易踩的坑:依赖图是模块粒度的,不是文件粒度。你在 PrivateDependencyModuleNames 里加了 Slate,整个模块的所有 .cpp 都能 include Slate 的 Public 头文件,没有办法做"只有这几个文件需要 Slate"的细粒度声明。想隔离依赖,唯一的办法是拆模块。

推导链第二步:模块图变成编译动作

有了 UEBuildTarget,UBT 要为每个模块算出具体的编译参数。这一步的核心在 UnrealBuildTool/Platform/ 下的各平台 ToolChain(如 VCToolChain.cs、AppleToolChain.cs)。每个 ToolChain 负责把模块规则翻译成 CompileEnvironment 和 LinkEnvironment:

  • include 路径:模块自身 Public/Classes + 所有依赖模块的 Public/Classes。Private 目录只加自己模块的。
  • 预处理器定义:来自三处叠加——模块规则里写的 PublicDefinitions/PrivateDefinitions、UBT 自动注入的(如 WITH_EDITOR=1、UE_BUILD_SHIPPING=1、每个模块的 MODULENAME_API 导出宏)、全局 BuildConfiguration.xml 里的设置。
  • PCH 与 Unity Build:默认开启。PCH(预编译头)由 PCHUsage 字段控制(ModuleRules.PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs 是现在的推荐值);Unity Build 把多个 .cpp 合成一个大文件编译(bUseUnity),默认每 32 个文件合一,可以通过 Engine/Programs/UnrealBuildTool/Configuration/BuildConfiguration.cs 里的 MaxIncludedFilesPerUnityFile 或模块级的 bFasterWithoutUnity 调整。

Unity Build 值得展开,因为它是新手最常撞上的"灵异现象"源头。把 32 个 .cpp 合成一个编译单元意味着:文件 A 里 using namespace Foo; 会影响同单元的文件 B;A 里定义的匿名命名空间符号、宏泄漏都可能污染 B。症状是"单独编译没问题,合编就挂"或反之。排查方法是在模块的 .Build.cs 里加 bFasterWithoutUnity = true 关掉 Unity 重编——如果好了,就是 Unity 污染,去查宏泄漏和 using 声明。UE 5.x 引入了 Adaptive Unity(bAdaptiveUnityDisablesPCH 等开关):UBT 记录每个文件最近的编译时间,自动把"经常改动的文件"排除出 unity blob,只对稳定的文件做合并。这是对增量编译速度和全量编译速度的动态权衡,代价是第一次全量编译后行为才收敛。

// 模块级构建参数的实际出处(简化自 ModuleRules.cs)
public class ModuleRules
{
    public PCHUsageMode PCHUsage;                    // PCH 策略
    public bool bFasterWithoutUnity;                 // 关掉 unity build
    public List<string> PublicIncludePaths;          // 额外 include(相对引擎根)
    public List<string> PublicDefinitions;           // 对所有下游生效的宏
    public string PrivatePCHHeaderFile;              // 独占 PCH(大模块省编译时间的手段)
    public List<string> PublicAdditionalLibraries;   // 额外链接库
    // ...
}

推导链第三步:动作图与增量判断

UBT 最后把"每个模块编哪些文件、用什么参数"展开成一个动作图(Action Graph):节点是编译/链接动作(Action),边是依赖(编译依赖 PCH 产物,链接依赖所有编译产物)。这个图的执行器在 UnrealBuildTool/Executors/ 下,本地执行时是 ParallelExecutor,接 FASTBuild/ Horde 时是远程执行器。

增量编译的判断不靠文件时间戳的朴素比较,而是 UBT 把每个动作的完整输入指纹(源文件哈希 + 编译参数 + 依赖产物哈希)序列化存进 Intermediate/Build/ 下的缓存。下次构建时重算指纹比对,一致就跳过。这解释了三个常见现象:

  • 改 .Build.cs 里一个宏定义,整个模块重编——因为编译参数变了,指纹全变。
  • 删了 Intermediate/ 等于全量重编——指纹缓存没了。
  • 有时"改了头文件但下游没重编"——头文件指纹没变(比如只改了注释)或者下游动作指纹的依赖列表里根本没纳入这个头文件(依赖扫描靠编译器生成的 .d//showIncludes 输出,某些 include 方式会漏)。

实战:怎么看 UBT 到底生成了什么

三个我自己常用的排查手段:

  1. 导出 JSON 描述:UnrealBuildTool -Mode=JsonExport -Target=MyGameEditor ... 会把整个动作图(每个文件的编译命令行、include 路径、宏定义)导出到 Intermediate/Build/.../*.json。想确认"这个宏到底有没有定义到这个文件上",查 JSON 比猜快得多。
  2. 打开详细日志:BuildConfiguration.xml 里加 <PrintDebugInfo>true</PrintDebugInfo> 或命令行 -Verbose,UBT 会把模块图展开过程和指纹比对结果打出来。
  3. 看 Response File:实际传给编译器的参数在 Intermediate/Build/.../UnrealEditor/<Module>/ 下的 .rsp 文件里,这是"编译器真正看到的"最终参数,比 IDE 显示的更准确(IDE 的工程文件也是 UBT 生成的,是二手信息)。

UBT 推导链:两级 C# 声明 → 模块图 → 动作图 → 编译命令 .Target.cs TargetRules(编成什么) .Build.cs × N ModuleRules(怎么编) UEBuildTarget 模块依赖图(DAG) RulesCompiler 反射加载 ToolChain VC / Clang / Apple CompileEnv + LinkEnv cl/link .rsp 参数 动作图与增量判断 每个 Action 的指纹 = 源哈希 + 编译参数 + 依赖产物哈希 指纹一致 → 跳过;存在 Intermediate/Build/ 缓存里 删 Intermediate = 指纹清空 = 全量重编 改 .Build.cs 的宏 → 参数变化 → 整个模块指纹失效 Unity Build 的污染面 32 个 .cpp 合成一个编译单元(bUseUnity) using / 宏 / 匿名命名空间会跨文件泄漏 排查:bFasterWithoutUnity = true 重编对比 5.x Adaptive Unity:常改的文件自动排除出 blob

几个结论

  • 依赖写 Public 还是 Private,判断标准是"我的 Public 头文件暴没暴露它",不是"我用不用它"。写错位置的错误要等到下游模块编译时才暴露,很绕。
  • 编译参数的最终权威是 .rsp 文件和 JsonExport 产物,IDE 显示的参数是 UBT 生成的二手工程文件,二者不一致时信前者。
  • 增量编译出问题先怀疑指纹缓存(Intermediate/Build),再怀疑 Unity 污染(bFasterWithoutUnity 对比),最后才怀疑 UBT 本身的 bug——前两个占了九成以上。
  • .Build.cs 是代码不是配置:它能拿到 Target.Platform、Target.Configuration 做条件分支,也能读环境变量、查文件系统。把"按平台换库版本"这类逻辑写在这里,比在外面包脚本干净。

下一篇讲这条链路上游的另一个生成器:UHT 怎么把 UCLASS() 变成 .generated.h 里的注册代码。