第 15 章 · UHT 与宏的真相
第 4 章里我们给了一个"UHT 速写":UHT 扫描头文件、识别宏、生成反射注册代码。本章兑现那个承诺——完整追踪一个 UCLASS 从你写下宏的那一刻,到编译器看到最终代码的整条链路。
你会看到 .generated.h 和 .gen.cpp 里到底生成了什么,以及 GENERATED_BODY() 展开后的真实模样。
15.1 两阶段编译模型
标准 C++ 的编译管线是线性的:
.cpp / .h → 预处理器(展开 #include、宏) → 编译器(生成 .obj) → 链接器(生成 .exe/.dll)Unreal 在"预处理器"之前插入了额外的一步:
头文件(含 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.h",UHT 不会处理它,后续编译会报错(缺少 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,Category="Combat")floatMaxHealth;UHT 会得到:
- 属性名:
MaxHealth - 类型:
float(通过后面的声明解析) - 说明符:
EditAnywhere(flag)、BlueprintReadWrite(flag)、Category="Combat"(key-value)
这些信息会被编码进即将生成的 C++ 代码里——生成注册 FProperty 的调用、设置 CPF_Edit 等标志、写入元数据 Map。
15.3 代码生成阶段:.generated.h
.generated.h 的主要职责是:在你自己的类声明里插入一段"占位符",这段占位符由宏展开成 UHT 生成的声明。
典型结构(概念上)如下:
// 1) 包含必要的引擎头文件#include"UObject/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++ 特性
- 模板类:
template<typename 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 specifier | UPROPERTY 等宏后面的类型无法解析(拼写错误、前置声明缺失) |
| Missing ‘xxx.generated.h’ include | 该头文件有 UCLASS 等但未 include 对应 .generated.h |
| Redefinition 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 的报错信息——它会指出无法识别的说明符。