Unreal对C++做了什么 · Part4幕后 · 第 15 章 · UHT 与宏的真相
第 15 章 · UHT 与宏的真相第 4 章里我们给了一个UHT 速写UHT 扫描头文件、识别宏、生成反射注册代码。本章兑现那个承诺——完整追踪一个 UCLASS 从你写下宏的那一刻到编译器看到最终代码的整条链路。你会看到 .generated.h 和 .gen.cpp 里到底生成了什么以及 GENERATED_BODY() 展开后的真实模样。15.1 两阶段编译模型标准 C 的编译管线是线性的.cpp / .h → 预处理器展开 #include、宏 → 编译器生成 .obj → 链接器生成 .exe/.dllUnreal 在预处理器之前插入了额外的一步头文件含 UCLASS/UPROPERTY/UFUNCTION │ ▼ ┌─────────┐ │ UHT │ 扫描宏、解析说明符、生成 C 代码 └────┬────┘ │ 输出 ▼ .generated.h .gen.cpp │ ▼ 预处理器 → 编译器 → 链接器UHT 是一个独立的可执行程序或由 UBT 调用的工具不是编译器插件。它不理解 C 的完整语法——它只做有限的解析找到UCLASS、UPROPERTY、UFUNCTION等宏提取类名、成员名、说明符参数然后根据模板生成代码。所以 Unreal 的编译实际上是两阶段UHT 阶段生成代码和C 阶段和你手写的代码一起编译。15.2 UHT 扫描阶段扫描哪些文件UBT 在编译每个模块前会先列出该模块中所有#include “xxx.generated.h”出现的头文件。只有这些文件会被交给 UHT。所以如果你在一个 .h 里写了 UCLASS 但忘了写#include MyClass.generated.hUHT 不会处理它后续编译会报错缺少 GENERATED_BODY 等符号。包含 .generated.h 的必须是最后一个 #include。这是约定方便 UHT 和生成代码的宏正确展开。UHT 能识别什么UHT 会查找并解析以下宏以及它们的变体如带参数、带返回值UCLASS(...)USTRUCT(...)UENUM(...)UPROPERTY(...)UFUNCTION(...)UDELEGATE(...)/DECLARE_DYNAMIC_DELEGATE_...GENERATED_BODY()/GENERATED_USTRUCT_BODY()等它不会解析你类里的普通 C 代码——不解析模板、不解析复杂的继承、不解析预处理器条件。它主要依赖宏出现在类/成员上方这种固定模式用正则或简单语法去提取类名、成员名、说明符键值对。说明符如何被提取例如UPROPERTY(EditAnywhere,BlueprintReadWrite,CategoryCombat)floatMaxHealth;UHT 会得到属性名MaxHealth类型float通过后面的声明解析说明符EditAnywhereflag、BlueprintReadWriteflag、CategoryCombatkey-value这些信息会被编码进即将生成的 C 代码里——生成注册 FProperty 的调用、设置 CPF_Edit 等标志、写入元数据 Map。15.3 代码生成阶段.generated.h.generated.h 的主要职责是在你自己的类声明里插入一段占位符这段占位符由宏展开成 UHT 生成的声明。典型结构概念上如下// 1) 包含必要的引擎头文件#includeUObject/NoExportTypes.h#include...// 本模块需要的其他头文件// 2) 前置声明和模块 API 宏#defineMYGAME_API...// 3) 为 GENERATED_BODY() 准备的宏定义// 这里会根据类的父类、是否抽象等定义不同的宏体#defineMYGAME_APIAMyCharacter_NoPureDeclarations(...)\/* 一堆声明拷贝构造删除、GetNativeClass()、StaticClass()、序列化辅助等 */\...// 4) 你类里的 GENERATED_BODY() 会展开成对上面宏的调用// 例如// #define GENERATED_BODY() \ // MYGAME_API AMyCharacter_NoPureDeclarations(AMyCharacter, ACharacter, ...)也就是说GENERATED_BODY() 在预处理器阶段会展开成一大段成员声明。这些声明包括根据引擎版本和类类型略有差异删除的拷贝构造函数/赋值运算符static UClass* GetStaticClass()或等价物UClass* GetNativeClass() const override与序列化、CDO 相关的内部接口有时还有反射用的小型结构体你不需要手写这些——UHT 根据你的类名、父类名、是否 USTRUCT 等选择对应的模板生成。15.4 代码生成阶段.gen.cpp.gen.cpp 里是反射数据的注册代码在程序启动时执行把 UClass、FProperty、UFunction 等填进引擎的全局表。典型内容结构1) 静态结构体属性/函数元数据UHT 会为每个 UPROPERTY 生成一个小的静态结构体描述偏移、类型、标志位、元数据例如简化structZ_Construct_UClass_AMyCharacter_Statics{staticconstFProperty*constPropPointers[];staticconstFCppClassTypeInfoStatic StaticCppClassTypeInfo;staticconstUECodeGen_Private::FFloatPropertyParams MaxHealthParam;staticconstUECodeGen_Private::FFloatPropertyParams CurrentHealthParam;// ...};2) 属性注册表每个 UPROPERTY 对应一个FProperty的构造参数如 FFloatPropertyParams里面包含属性名FName偏移量通过offsetof(AMyCharacter, MaxHealth)或类似方式得到标志位CPF_Edit、CPF_BlueprintVisible 等元数据Category 等3) UClass 注册函数一个大的函数通常叫Z_Construct_UClass_AMyCharacter()里面会调用UClass::StaticClass()或等价机制获取/创建 UClass注册父类关系逐个注册 FProperty通过UClass::AddPropertyToClass或类似 API为每个 UFUNCTION 注册 UFunction 和 thunk设置类的 CDO 等。4) 静态初始化通过一个在程序启动时执行的静态初始化如IMPLEMENT_CLASS宏展开后的代码调用上面的Z_Construct_UClass_AMyCharacter()从而在 main 之前就把反射树建好。5) UFunction 的 thunk每个 UFUNCTION 会生成一个小的thunk函数把反射调用ProcessEvent 传来的参数块转成对实际 C 成员函数的调用包括从参数块里按偏移取出参数、调用你的函数、把返回值写回。15.5 GENERATED_BODY() 的展开在 UE4 的早期版本中有的类用的是GENERATED_UCLASS_BODY()它会在类里插入一个构造函数声明或定义用于初始化 UObject 的默认属性等。后来引擎统一推荐使用GENERATED_BODY()不再在宏里注入构造函数构造函数完全由你手写。GENERATED_BODY()必须放在类体的最前面在第一个访问说明符之后、第一个成员之前因为展开后的声明需要作为类的第一部分以满足引擎对 UObject 布局的假设例如虚表、反射信息指针等。如果你忘记写GENERATED_BODY()会出现两类错误UHT 报错UHT 发现 UCLASS 但没有对应的 body 宏会直接报错。链接错误即使 UHT 生成了 .gen.cpp你的类里缺少 StaticClass() 等声明链接时会找不到符号。15.6 宏标记的规则与限制UHT 的解析能力有限因此有一系列不能做的事不支持的 C 特性模板类templatetypename T class UMyClass : public UObject不能是 UCLASS。UHT 无法为模板实例化生成多份反射数据。多继承 UObject一个类只能继承一个 UCLASS 标记的基类可以是多个 UInterface。匿名结构体/联合体中的 UPROPERTY需要给结构体命名并用 USTRUCT if 需要反射。某些复杂类型在 UPROPERTY 里用到的类型必须能被 UHT 识别基本类型、USTRUCT、UObject*、TArray 等不能是任意 C 类型。常见 UHT 错误与含义错误信息示例常见原因Unrecognized type ‘XXX’XXX 未用 USTRUCT/UCLASS/UENUM 声明或头文件未包含Expected a type specifierUPROPERTY 等宏后面的类型无法解析拼写错误、前置声明缺失Missing ‘xxx.generated.h’ include该头文件有 UCLASS 等但未 include 对应 .generated.hRedefinition of ‘GENERATED_BODY’同一个类里写了多个 body 宏或 .generated.h 被重复包含方式不对The type ‘YYY’ must be a USTRUCT or UCLASS在 UPROPERTY 里用了 YYY 类型但 YYY 不是引擎可识别的类型遇到 UHT 报错时先看它指向的头文件和行号再对照上述规则检查类型是否暴露给 UHT、include 顺序是否正确、是否用了不支持的语法。UHT 的演进从 C 到 C#早期 UHT 是用 C 实现的和引擎一起编译。UE5 中 UHT 逐步迁移到 C#和 UBT 一样作为独立工具运行。这样做的目的是不依赖引擎的编译结果启动更快与 UBT 共享 C# 代码解析、文件 IO、诊断信息更容易扩展和修复 UHT 的解析逻辑。对你使用 UCLASS/UPROPERTY 的方式影响不大只是错误信息可能更清晰、未来新特性会先在 C# 版 UHT 里出现。15.7 完整旅程回顾把第 4 章的从一个 UPROPERTY 出发的追踪和本章串起来你写下UPROPERTY(EditAnywhere) float MaxHealth;UHT 扫描到这一行提取属性名、类型、说明符。UHT 生成.gen.cpp 里的一段 FFloatPropertyParams 和注册调用把 MaxHealth 的偏移、CPF_Edit 等写入 UClass。预处理器把你的 .h 和 .generated.h 合并GENERATED_BODY() 展开成引擎需要的声明。编译器编译你的 .cpp 和 .gen.cpp得到 .obj。链接器产出模块 dll。运行时引擎启动时执行 .gen.cpp 里的静态初始化AMyCharacter 的 UClass 被注册MaxHealth 的 FProperty 挂在 UClass 上。编辑器/蓝图/序列化/网络通过 UClass 和 FProperty 发现并操作 MaxHealth。这就是一个 UCLASS 从标记到编译完成的完整链条。UHT 是这条链的起点——没有它就没有 .generated.h 和 .gen.cpp也就没有反射没有 GC 对 UPROPERTY 的识别没有蓝图和编辑器集成。一句话总结UHT 在 C 编译之前扫描含 .generated.h 的头文件识别 UCLASS/UPROPERTY/UFUNCTION 等宏生成 .generated.h供 GENERATED_BODY 展开和 .gen.cpp反射注册代码两阶段编译是 Unreal 反射和工具链的根基。实验在项目中找一个简单的 UCLASS只有少量 UPROPERTY/UFUNCTION打开其对应的Intermediate/Build下生成的 .generated.h 和 .gen.cpp具体路径因版本和平台而异可在编译后搜索文件名。在 .generated.h 里搜索GENERATED_BODY或你的类名看宏展开后的声明长什么样。在 .gen.cpp 里搜索你的属性名或Z_Construct_UClass_看属性注册和 UClass 构造的代码。故意在 UCLASS() 里写一个不存在的说明符如UCLASS(NotARealSpecifier)保存并编译阅读 UHT 的报错信息——它会指出无法识别的说明符。