技术博客

UHT 与 .generated.h:反射代码生成的输入输出

第 3 / 100 篇 · 2025-03-28 · ue5 · UHT反射代码生成源码分析

每个写了 UCLASS() 的头文件都必须 #include "MyClass.generated.h",这行 include 背后是一台完整的编译器前端在工作。UHT(UnrealHeaderTool,源码在 Engine/Source/Programs/UnrealHeaderTool)在编译之前先扫一遍所有头文件,把反射宏翻译成真正的 C++ 代码。这篇文章讲清楚它读什么、产什么,以及产物里那些看不懂的宏展开后长什么样。

UHT 在构建链路里的位置

UHT 不是一个独立运行的工具,它是 UBT 动作图里的一个动作(Action)。UBT 展开模块图后发现模块里有任何头文件包含 UCLASS() 等宏,就会为该模块插入一步 UHT 调用:

MyActor.h ──→ UHT ──→ MyActor.generated.h   (类内声明注入)
                └──→ MyActor.gen.cpp         (注册函数实现)
                └──→ MyModule.init.gen.cpp   (模块级注册表)

产物落在 Intermediate/Build/<Platform>/<Config>/Inc/<ModuleName>/,所以 .generated.h 永远不会出现在你的 Source/ 里。这带来一个直接结论:调反射问题(宏不生效、说明符报错)要去 Intermediate 看产物,源文件里看不到真相。IDE 报错指向 MyActor.generated.h(42) 时,那个文件就在 Intermediate 下,直接打开看。

UHT 的触发是增量的:UBT 把头文件哈希+解析结果缓存起来(UHT 自身的 .uhtmanifest 和缓存机制),只重扫改动的头文件。这解释了"改了 UPROPERTY 说明符但没重编"的另一个成因——如果你改了 .Build.cs 但没碰头文件,UHT 可能不会重跑。

UHT 读到的是什么

UHT 是一个真正的 C++ 头文件解析器(UnrealHeaderTool/Private/Parser),但它不解析完整的 C++——它只认识带反射宏的声明,其他内容一律跳过。它从 UCLASS()/UPROPERTY()/UFUNCTION() 里提取两类信息:

  1. 说明符(Specifier):Blueprintable、EditAnywhere、Server 这些关键字,UHT 把它们翻译成枚举位(EClassFlags、EPropertyFlags、EFunctionFlags)的置位操作。
  2. 元数据(meta):meta=(ClampMin="0") 这种键值对,原样存进反射对象的 MetaData 容器,运行时用 FProperty::GetMetaData 查。

一个容易误解的点:UCLASS() 宏本身展开后是空的(在 ObjectMacros.h 里定义为空宏),它纯粹是给 UHT 看的标记。C++ 编译器编译你的头文件时根本看不到 UCLASS() 的存在——它只编译 .generated.h 里注入的声明。这就是为什么宏拼写错了(比如 UCLSSS())UHT 不报错而编译器报"找不到 generated 声明"——UHT 跳过不认识的标记,编译器却在等它注入的代码。

GENERATED_BODY 展开后是什么

头文件里写的 GENERATED_BODY() 是整个机制的关键注入点。展开后(以 UE5.3 的一个普通 AActor 子类为例,简化掉样板代码)核心是三部分:

// MyActor.generated.h 展开形态(简化示意)
#define UCLASS_MyActor_..._GENERATED_BODY \
    /* 1. 类型标识与文件指纹 */ \
    static const UECodeGen_Private::FClassParams& GetClassParams(); \
    /* 2. 虚函数桥接:让基类的 StaticClass 链路指向本类 */ \
    virtual UClass* GetClass() const { return MyActor::StaticClass(); } \
    static UClass* StaticClass(); \
    /* 3. 友元与序列化钩子 */ \
    friend class Z_Construct_UClass_MyActor_Statics; \
    // ...

GENERATED_BODY 做的事本质上就一件:在类体内注入"本类的 UClass 怎么构造、怎么获取"的声明。实现不在头文件里,在同名的 .gen.cpp 里——IMPLEMENT_CLASS 宏(也在这个 gen.cpp 里)定义 StaticClass(),内部调用 UHT 生成的 Z_Construct_UClass_MyActor() 函数,那个函数逐个 new 出 FProperty、设置偏移量、挂到类的属性链上。

这里能看出设计取舍:反射数据不是运行时解析头文件得到的,而是编译期被翻译成了 C++ 构造代码。运行时装载一个类就是调一串构造函数,没有解析开销。代价是所有反射信息都要走"改头文件 → 重跑 UHT → 重编"这条慢链路,没法动态定义反射类型(蓝图类的 UClass 是另一条动态构造路径,走的是 UBlueprintGeneratedClass,和 C++ 类的静态生成完全两套)。

.gen.cpp 里的注册机制

每个类在 .gen.cpp 里会有一对注册函数和一个静态注册器:

// MyActor.gen.cpp 的结构(简化)
// 构造 UClass 本体:创建 FProperty、设置 flag、挂链
UClass* Z_Construct_UClass_MyActor() { /* ... new FObjectProperty ... */ }

// 模块启动时的自动注册
static FCompiledInDefer Z_CompiledInDefer_UClass_MyActor(
    Z_Construct_UClass_MyActor, TEXT("/Script/MyModule"), ...);

FCompiledInDefer 的构造函数在模块 DLL 加载时的静态初始化阶段执行,把这个类的构造函数登记进全局的延迟注册表(UObjectCompiledInDefer)。模块加载完成后,引擎统一调 ProcessNewlyLoadedUObjects 把所有登记的类真正构造出来——这就是"加载一个 DLL,里面的 UCLASS 就自动可用了"的原理。

模块级还有一份 MyModule.init.gen.cpp,里面是模块内所有类、所有包(Package)的注册表汇总。模块卸载时按这份表反注册。热重载出问题多半出在这一层:老 DLL 的静态注册器已经登记过一遍,新 DLL 再登记一遍,如果中途有 UObject 实例还持有老 UClass 的指针,新构造的类和旧实例就对不上——这就是为什么热重载后偶尔会出现"蓝图父类显示为 None"或属性面板空白。

版本号与指纹:generated.h 为什么敢 include

.generated.h 文件名里没有模块名前缀(就是 MyActor.generated.h),它能被正确 include 是因为 UBT 把每个模块的 Inc/<ModuleName>/ 目录加进了 include 路径,且文件名由 UHT 保证模块内唯一。如果你有两个同名类在不同命名空间,UHT 生成文件名会带上区分后缀——这是少数会撞车的情况。

每个 generated.h 头部还有一行指纹校验宏(#if ... #error "this file was generated by a different version of UHT" 形态),用于防止你用旧版 UHT 的产物配新版引擎编译——引擎源码升级后必须全量重跑 UHT,混用产物会在这里被拦下。

UHT 流水线:头文件 → 反射注册代码 → 模块加载时自动建类 MyActor.h UCLASS/UPROPERTY UHT 解析器 只认反射宏,跳过其余 Intermediate/.../Inc/MyModule/ .generated.h(声明注入)+ .gen.cpp(注册实现) GENERATED_BODY 注入的三部分 1. GetClassParams / StaticClass 声明 —— 本类 UClass 的获取入口 2. 虚函数桥接 —— GetClass() 让多态链路指向本类 3. 友元声明 —— 允许 Z_Construct_ 工厂访问私有成员做序列化挂钩 静态注册器 FCompiledInDefer 构造函数在 DLL 加载的静态初始化期执行,把类构造 函数登记进全局延迟注册表 ProcessNewlyLoadedUObjects 模块加载完成后统一调用登记的构造 函数,逐个 new FProperty 挂到属性链 热重载 = 老表反注册 + 新表重登记

实战排查清单

  • 宏说明符改了没效果:先看 Intermediate/.../Inc/ 下对应 .generated.h 的时间戳,确认 UHT 重跑过;再 grep 产物里有没有你要的 flag(比如 CLASS_/CPF_/FUNC_ 前缀的枚举置位)。
  • "unresolved external symbol Z_Construct_UClass_X":类的 .gen.cpp 没被编进模块,九成是头文件改了但 UHT 增量缓存没失效,删该模块的 Intermediate 子目录重编。
  • 热重载后蓝图父类丢失:老 DLL 的反注册没走完,检查是不是有 UObject 在模块卸载时还被 Root Set 或其他模块持有——IMPLEMENT_MODULE 那一层的问题,属于下一章模块加载的范畴。
  • 想自定义反射行为:UHT 支持 UDINT() 等扩展点和自定义 codegen,但 5.x 之后 Epic 明显在把扩展方向往 Verse 和插件化的 IUHTMakefile 接口引,改 UHT 本体前先看 UnrealHeaderTool/Public/IUHTExport.h 的插件接口够不够用。

UHT 输出的注册代码依赖另一套东西才能被引擎消费:说明符翻译成的那几百个 flag。下一篇把 UCLASS/UPROPERTY/UFUNCTION 的说明符全集和它们落到哪个 flag 位上理一遍。