Contents

UE4 UBT 解析

一. 调试UBT

打开项⽬的默认的构建命令⾏ https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210524141505.png

可以获得如下的 BuildCommandLine

1
..\..\Build\BatchFiles\Build.bat -Target="ActionRPGEditor Win64 Debug" -Target="ShaderCompileWorker Win64 Development -Quiet" -WaitMutex - FromMsBuild

这⾥的 Build.bat,是可以直接换成 UnrealBuildTool.exe 的,如果你打开 Build.bat 查看内容,会发现⾥⾯的多数内容就是如何⽤ C# 构建⼀个 UnrealBuildTool.exe,然后调⽤这个 UnrealBuildTool.exe。

所以我们把 UnrealBuildTool 作为启动⼯程,然后启动命令⾏改成上⾯的 Build.bat 的命令⾏,就可以启动 UnrealBuildTool 的调试,如下图:

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210524141626.png

1.1启动调试

因为不知道 main 函数在哪⾥,这⾥有两种常⻅的调试⽅式 ⼀、直接按下 F11 启动,代码会⾃动停在 Main 函数的第⼀⾏,如下图

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210524142709.png

⼆、通过 Attach 的⽅式调试,这种⽅式⼀般⽤于⼆段甚⾄三段启动的程序(也就是⼀个 exe 启动另⼀个exe,我们⽆法控制 F11 的位置,典型的就是UBT会启动 UHT的exe) 我们找到Main函数,然后在写下如下代码和断点,这样任何⽅式启动程序(包括⼆段启动)都⼀定会进⼊死循环。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210524142701.png

Attach到⼀个进程上,此时断点⼀定会停在死循环⾥⾯。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210524142655.png 通过运⾏时修改内存的⽅式,把死循环解开,⽐如这⾥的把 i 改成 1。接下来就可以愉快的调试了。

二. 文件结构

Module 是构成 Unreal 的基本元素,每一个 Module 封装和实现了一组功能,并且可以供其他的 Module 使用,整个 Unreal Engine 就是靠各个 Module 组合驱动的。我们创建的游戏项目本身,就是一个单独的Module。

每一个独立模块,都会有一个XXX.Build.cs。这是所有moudle 都需要进行描述的描述文件。.Build.cs 控制的是 Module 编译过程,由它来控制所属 Module 的对其他 Module 的依赖、文件包含、链接、宏定义等等相关的操作,.Build.cs 告诉 UE 的构建系统,它是一个 Module,并且编译的时候要做哪些事情。

而我们的一个项目工程,则会有一个XXX.Target.cs文件,.Target.cs 控制的是生成的可执行程序的外部编译环境,就是所谓的 Target。比如,生成的是什么 Type(Game/Client/Server/Editor/Program),开不开启 RTTI (bForceEnableRTTI),CRT 使用什么方式链接 (bUseStaticCRT) 等等。

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210524155525.png UBT会递归查找"build.cs"/“target.cs"之类后缀的文件,这些是各个工程的编译配置文件,然后把它们放到一个列表。

如果文件还没编译,则编译之(因为C#不像python,不能直接import)。

三.代码结构

https://papalqiblog.oss-cn-beijing.aliyuncs.com/blog/picture20210524152412.png

UBT 经历过多年多次重构,是⼀段充满沧桑的代码。

  1. Configuration ⽬录:这⾥主要是⼀些配置⽂件,⽐如最重要的是BuildConfiguration.cs ⾥⾯直接和XML的配置挂钩;另⼀部分UEBuild打头的,主要是针对UE4构建的⼀些定制化配置,虽然很多不暴露给外部,但其实对UBT来说,也是配置的⼀部分,⽐如安卓的UEBuildAndroid / UEDeployAndroid等等,都是继承⾃这⾥。
  2. Executors ⽬录:所谓的 Executor 就是解决 UBT 在拼装了 cl 之类的字符串之后,如何进⾏分布式编译构建。从原理上来说,⽆论是XGE(incredibuild)还是其他系统,⽆⾮是把需要参与编译的编译器和代码⽂件分发到其他机器上并执⾏,最后把⽣成结果返回。
  3. Modes ⽬录:看起来应该是 UBT 根据使⽤模式提供了不同的状态机分⽀,⽤于管理代码复杂度。
  4. Platform ⽬录:针对每个平台相关的操作,都放在这个⽬录,典型的就是 UEToolChain 的那些⼦类,⽐如 VCToolChain,⽤于寻找 cl.exe 的位置,拼接cl命令⾏的字符串等等,是我们最常修改代码的位置之⼀ 。
  5. ProjectFiles ⽬录:⽤于⽣成 sln 之类的每个平台的⼯程⽂件,本质上就是字符串拼接⽣成⽂件的过程,这⾥就不细说了
  6. ToolChain ⽬录:这个⽬录本质上还是 ToolChain 相关的功能,重点封装了 RemoteMac 编译相关的⼀些逻辑。
  7. System ⽬录:这是 UBT 最复杂的⽬录,解决了⼤量的核⼼问题,下⾯开始细说。

3.1Unity Build

在 Unity.cs 的 GenerateUnityCPPs 函数中封装了把多个 CPP ⽂件合并成⼀个再进⾏编译的逻辑,这样做主要是为了减少 IO,提高编译效率,但这么做对写代码的⼈来说第⼀个缺点就是CPP的编译单元中,不能有命名冲突的变量,⽐如A.cpp⾥⾯有 int i;B.cpp⾥⾯也有⼀个,这样就冲突了。 注意,这⾥有个 bUseAdapativeUnityBuild,进⾏⾃适应 Unity Build 的合并⼤⼩。

3.2 UnrealPluginLanguage

UPL 的官⽅⽂档在这⾥,通俗的来说,是⽤来给 IOS 和 Android 构建做插件的,以安卓为例,除了编译常规的 Java ⽂件、C++ so ⽂件,还要把各种资源等打包成apk,这个流程和PC 的⽣成exe还是不⼀样的,⿇烦很多。

构建流程

我们现在来说下主构建的代码流程。

⽆论如何,UBT 都会尝试调⽤ UHT 进⾏代码⽣成,这个流程就不说了(感兴趣可以在 AutomationTool.CommandUtils.RunUBT 打断点,如果 UHT 源码发⽣过修改,这⾥会被调⽤⽤于 ⽣成 UHT的 exe),可以通过 -nobuilduht 启动命令⾏来进⾏关闭 UHT本⾝的构建(因为 UHT 本⾝ 也是通过 UBT 构建的)。

对第⼀次代码构建来说,UBT ⾸先是获取所有需要编译的⽂件,然后把这些⽂件进⾏ Unity Build 处理,最后组装成⼀个 ActionGraph,这个 Garph 配置了所有的构建命令(Action),最后把这个 Garph 分发到 Exector ⾥⾯,最后进⾏分布式编译,Graph 是为了解决 Action 之间的分布式依赖。

对第⼆次构建来说,会⽐较⿇烦,因为要引⼊修改检测和 cache(如 obj ⽂件)复⽤:从⽬标来说,⼀旦发⽣某个⽂件的修改,我们要把他所影响的 Action 找出来,并重新⽣成这些 Action。

这⾥多说⼀句,这个问题如果从VC的⻆度来看,问题本⾝都未必存在:因为⼤家会觉得修改了某个⽂件,VC 帮我找到最⼩化的修改进⾏增量编译是天经地义的事情。但事实并⾮如此,⽐如 GCC 的 CCache 就是专⻔⽤来解决这类问题的,GCC 只负责 Compile 和 Link 本⾝。

在 UBT 体系下,⼀个⽂件的修改,会触发最少以下⼏种 Action 的构建(具体代码在 UnrealBuildTool.ActionGraph.IsActionOutdated,⾥⾯的各种 bIsOutdated 就是⽤于判断这个 Action 到底应不应该被重新构建):

  1. UnityBuild 产⽣的 Action,主要是 Intermediate ⽬录下的 .gen.cpp ⽂件(⽤ Everything 搜索位置,就不细说了)
  2. cpp 内部相互 include 的⽂件,这些⽂件最终会表现在 Action 之间的依赖

其中第⼀种相对简单,UBT ⾃⼰就可以知道,⽐较难的是第⼆种:UBT 怎么会知道 cpp 内部之间的相 互 include 关系,进⽽⼀个⽂件修改触发连锁的脏标记进⾏编译?

为了解决这个问题,第⼀种思路可能是 parse .h 和 .cpp ⽂件,然后做成⼀个⽂件依赖 Graph,但这种⽅法稳定性⽐较差,我记得外部也有不少系统在⽤(主要是⽤来分析 cpp 是否包含了⽆⽤ .h 的系 统)

UBT 使⽤了另⼀种思路,在 Engine \ Extras \ Windows \ cl-filter ⽬录下有⼀个 cl-filter.exe 的⼯程, 这个项⽬其实是⽤于 Warp cl.exe 的,UBT 并不会直接调⽤ cl exe 进⾏ Compiler,⽽是使⽤ cl- filter,为什么有对 cl.exe 进⾏⼀层 Warp?

从代码可以看出,在 cl.exe 编译某个 cpp 的时候,是会把这个 cpp 的相关依赖 .h .cpp 通过 pipe 输出 的,这个输出⼀定是最准确的。

cl-filter.exe 再把这个输出写⼊到 .gen.cpp 同名的 .gen.cpp.txt ⾥⾯,⽤于表⽰这个 .gen.cpp 的依赖 项。

当我们第⼀次使⽤了 cl-filter.exe 进⾏编译,第⼆次通过查找 .gen.cpp.txt 就知道依赖关系,进⽽找到 变化的 Action 对象。

对代码来说,这个⾏为就在刚刚说的 UnrealBuildTool.ActionGraph.IsActionOutdated 函数的 RootAction.DependencyListFile 的这⼀段进⾏的判定,虽然 DependencyFiles 本⾝并不是 UBT 帮 忙找到的,⽽是 cl-filter。

⽽⾄于代码⾥⾯的时间⽐较,其实是在⽐较 .h .cpp ⽂件和 .obj ⽂件的时间戳,如果时间差不多说明 没改过,如果 .h .cpp ⽂件被修改了,⾃然不会和 .obj ⽂件时间戳对的上。

另外,代码中有⼀个 CppDependencyCache.cs,就是⽤于收集 .gen.cpp.txt 到⼀个集中单⽂件,⽽ 不是碎 IO,这是⼀种加速结构,最终这个结构会序列化在 DependencyCache.bin 下⾯;同时,这个 结构是⼀个层次化结构:引擎和项⽬各⾃有⼀个,每次查找的时候会根据层次选择⽤哪个。

最后,ActionGraph 本⾝会通过 TargetMakefile.cs 被序列化成 Makefile.bin,这⾥记录了最原始的 Action 数据,可以在启动的时候通过 -NoUBTMakefiles 命令⾏来关闭对 UBT Makefile 的读取,如果 要调试可以⽤到。

  1. WaitMutex 这个启动命令⾏是⽤于标识在启动的时候是否要在操作系统内核创建⼀个具名内核对 象,这样如果启动同样的进程,就可以通过观察这个对象是否存在了解系统是否已经有⼀个进程 了,⽽不会双开,以此来防⽌误操作同时启动多个 UBT 进⾏编译相互覆盖,这个在《Windows 核 ⼼编程》⼀书中有谈过相关的实现。
  2. Engine \ Build \ JunkManifest.txt:这个⽂件我忘了什么作⽤
  3. Engine \ Build \ SourceDistribution.txt:⽤于判断引擎是 Installed 构建还是源码构建,我这⾥没 有谈 Installed 构建的细节
  4. Engine \ Build \ Build.version:记录了引擎的版本号,可以在构建流程中尝试修改这个⽂件,来 获得引擎⼆进制⾥⾯对版本号的修改
  5. Engine \ Build \ PerforceBuild.txt:判断使⽤使⽤了 P4 的构建,来加强⼀些和 P4 的交互
  6. PCH(PrecompiledHeader)相关的⼀些细节这⾥没有说
  7. IWYU 的实现这⾥没有说
  8. HotReload 没说
  9. Definition.generated.h 没说