UE5 系统浏览之 UHT 与反射元数据生成系统

简介: 这里就会牵扯到 UE 哪些形形色色的代码生成宏定义了,得力于前面已经梳理过 UObject 和 FField 相关的类,这里再次遇到就方便很多了。
语法提示:UBT 和 UHT 已经都是 C# 编写的了,所以在阅读记录里的语法点都是 C# 的,本页基本没有 C++ 相关的
心得: 一直以为 UBT 和 UHT 会完全生成全新的 C++ 代码,经过抽丝剥茧的逻辑梳理,最终发现它只是根据我们标记的宏或标签,自动生成一些增量的代码,例如需要实现 C++ 反射用到的代码,宏的展开代码等。这也算是一个巨大收获,毕竟现在连 ue 的功能菜单都还认不全

相关模块状态和入口

  • UnrealHeaderTool:在UE5中已经用 C# 重新,是一个独立的命令行程序,位于 Engine/Source/Programs/Shared/EpicGames.UHT/EpicGames.UHT.csproj,是UHT的主模块。
  • CoreUObject:提供反射运行时的支持,包含反射元数据的基础类(如UClass,UProperty等),位于 Engine/Source/Runtime/CoreUObject
  • UnrealHeaderTool 现在是被编译成一个 dll 文件,由 UBT 进行调用,UBT 则会在不同的地方大概率被以命令行的形式调用例如
    • FProcHandle ProcHandle = FPlatformProcess::CreateProc(*ExecutableFileName, *CmdLineParams, bLaunchDetached, bLaunchHidden, bLaunchReallyHidden, NULL, 0, NULL, OutWritePipe, OutReadPipe); 其中 ExecutableFileName 就是已经获取好的执行命令的 UBT dll 的名称
  • UnrealBuildTool 模块 也被编译成了一个 dllUnrealBuildTool.cs 内的入口 main 函数主要负责参数的提取与整理校对,获取正确的 ToolMode具体子类,然后执行 ExecuteAsync 命令
  • UnrealBuildTool 模块内的 UnrealHeaderToolMode 主要通过 UnrealHeaderTool 模块内的 UhtSession 来进行 UHT 相关功能的调用

UBT 和 UHT 的关系

  • UHT 的执行逻辑基本都是通过 UBT 来调用的
  • UHT 会被 UBT 调用的几种情况
    1. 编译目标时
    2. Hot Reload 时
    3. GenerateProjectFiles 时
    4. 执行 -Clean 命令式
    5. 蓝图原生基类更新时
  • UBT 调用 UHT 的具体步骤大致如下
    1. UBT解析目标描述文件(.target.cs)和模块文件(.Build.cs)来确定需要编译的模块。我们前面预览的 Target 和 Build 文件主要用途就在这里了
    2. UBT 扫描每个模块的公共头文件,检查是否存在反射宏,例如(GENERATED_BODY)
    3. 对于需要生成反射代码的模块,UBT 就会通过 UhtSession 类来执行 UHT 的相关功能
    4. UHT 运行后,解析指定的头文件,生成相应的反射代码文件(.generated.h和.generated.cpp)
    5. UBT 继续执行编译步骤,将生成的代码文件包含在模块的编译过程中
  • 常见的反射宏,这些宏在前面阅读代码的时候经常可以遇到
    // 反射标记触发 UHT
    - UCLASS()
    - USTRUCT()
    - UENUM()
    - UFUNCTION()
    - UPROPERTY()

UHT 模块目录分类

截止到目前 ,UHT 最新的代码结构被归纳在了以下几个目录内

1. Tokenizer 分词器,关键功能是识别 反射宏、关键字、标识符、字面量、运算符等。将源代码文本 -> Tokens 流
2. Parsers 接受来自 Tokenizer 的 token 流,根据 C++ 和反射宏 的语法规则构建抽象语法树。消费 Tokens 流 -> 理解代码结构 (类、函数、属性、宏) -> 输出结构信息 + 属性字符串
3. Specifiers 限定符、属性处理器,用来处理 反射宏 括号内的(...) 属性字符串 解析属性字符串 ((…)) -> 结构化的属性键值对/标志 -> 应用到对应的结构信息上
4. Types 类型表示,将解析器 Parsers 提取的结构化的信息存储在 C++ 对象表示的内存模型中 解析器 (Parsers) 生成的结构化信息(附加了属性) -> 被存储在具体类型 (FClass, FStruct, FEnum, FProperty, FFunction) 的对象实例中
5. Tables收集、存储、查找并管理所有从源代码中提取出来的反射元数据对象 收集、索引、管理来自 Types 的所有对象 -> 构建全局元数据数据库 -> 解决依赖和进行最终验证
6. Exporters 代码生成器,获取存储在 Tables 数据库中的完整元数据模型,并将其转换为目标输出。最主要的输出是每个包含反射标记的 .h 文件对应的 .generated.cpp 文件 消费 Tables 数据库 -> 生成目标输出代码 (.generated.cpp 等)
7. Utils 辅助工具类

运作流程再梳理

  1. UBT 在其 Main 函数内,处理到对应的 ToolMode 调用 ExecuteAsync,若当前对象的类型是 UnrealHeaderToolMode 则自然会调用 UnrealHeaderToolModeExecuteAsync 函数
  2. UnrealHeaderToolMode 内的 ExecuteAsync 函数 会初始化 UhtTables 、UhtConfigImpl 、UhtGlobalOptions ,然后创建 UhtSession 对象,并将初始化的几个对象设置给 Session 对象持有
  3. 执行 UhtSession 对象的 Run 函数,传入 manifestFile 文件路径,和命令行参数
  4. UhtSession 对象的 Run 函数内执行一些了操作,基本就是 UHT 的主要流程了,如下:
//一系列的检查
StepReadManifestFile -  读取 manifest 文件,并通过 JsonSerializer 将内容解析到 UhtManifestFile 内的 Manifest 对象
StepPrepareModules() - 初始化引擎模块系统并加载所有参与处理的模块信息。
StepPrepareHeaders() - 扫描并收集所有需要处理的头文件及其依赖关系。
StepParseHeaders() - 解析头文件内容生成抽象语法树(AST)并记录元数据,这个是一个主要的 Parser 入口,其它的parser 也会在执行过程中通过委托的形式调用
StepPopulateTypeTable() - 创建类型系统表并填充基本类型定义关系。
StepResolveInvalidCheck() - 检测并标记无效符号引用与语法错误。
StepBindSuperAndBases() - 建立类继承树并绑定父类基础关系。
RecursiveStructCheck() - 验证结构体嵌套定义是否符合内存布局约束。
StepResolveBases() - 解析并确认所有基类的合法性和可访问性。
StepResolveProperties() - 完善属性系统元数据并建立反射关系链。
StepResolveFinal() - 执行跨符号的最终绑定和类型校验检查。
StepResolveValidate() - 执行完整性检查确保所有符号完全解析。
StepCollectReferences() - 收集类型引用关系为代码生成做准备。
TopologicalSortHeaderFiles() - 按依赖关系拓扑排序头文件确保正确生成顺序。
StepExport();将解析完成的类型系统元数据转换为目标代码格式(如 C++ 或 蓝图),生成运行时反射所需的 .generated.h/.cpp 文件及其他辅助脚本。

Parse的调用过程

其它几个目录内的类基本都是可以搜到调用位置的,而 Parse 内的类只有
UhtHeaderFileParserUhtPropertyParser 可以找到调用位置。其它的 parser 貌似没有被调用。

  • UhtHeaderFileParserstatic 类型的 Parse 函数是在 UhtSessionStepParseHeaders 函数内调用的
  • UhtPropertyParserstatic 类型的 Parse 函数是在 UhtFunctionParser 内的 UPROPERTYKeyword 函数内调用的,这个函数就是被反射调用的一个函数
  • UhtSpecifierParser 内的函数在被其他的 Parser 需要的时候调用

其实 Parser 内的大部分类使用了反射进行调用,他们大部分都能和前面浏览过的类有关联,然后他们有个特点就是有几个头部函数是有 UhtKeyword 属性标记的,类也有 UnrealHeaderTool 的属性标记。

  • UhtClassParser
  • UhtEnumParser
  • UhtFunctionParser
  • UhtAccessSpecifierKeywords
  • UhtInterfaceClassParser
  • UhtNativeInterfaceClassParser
  • UhtScriptStructParser

那么UHT 是如何使用反射调用这些 parser 的呢,根据之前分析的目录功能,Tables 里的类都是负责收集数据的,其中里面就有一个 UhtKeyword 类,它包含一个 Delegate 委托函数,对应的就是那些被 UhtKeyword 属性标记过的函数

  • 委托函数的设置路径

UnrealBuildTool 内的 Main 函数调用 ToolMode 实例(具体类为 UnrealHeaderToolMode)的 ExecuteAsync, 在其开头的 UhtTables 的构造函数内 , CheckForAttributes(Assembly? assembly) -> CheckForAttributes(Type type) -> HandleUnrealHeaderToolAttribute(Type type, UnrealHeaderToolAttribute parserAttribute) -> KeywordTables.OnKeywordAttribute(type, methodInfo, keywordAttribute); -> 创建 UhtKeyword 的时候在构造函数内完成了 委托函数的设置,所以 委托函数被 UhtKeyword 持有
UhtKeywordTables 继承自 UhtLookupTables<UhtKeywordTable> 父类里有一个 字典用来根据table的名字保存的 table 对象,在 OnKeywordAttribute 函数内根据名字获取到 UhtKeywordTable 对象,然后给他添加委托函数

  • 委托函数的执行调用路径

UhtSession 这个对象用一个 Tables 实例持有了所有的 table,在其它方法调用的时候有时候会通过 UhtSession 来获取需要的 table
也是从 UhtSession 对象的 Run(string manifestFilePath, CommandLineArguments? arguments = null) 函数开始 -> StepParseHeaders() -> UhtHeaderFileParser.Parse(headerFile);进入 UhtHeaderFileParser 内部的 Parse 函数,headerParser.ParseStatements(); -> ParseStatements((char)0, (char)0, true); -> ParseStatement(_topScope, ref token, logUnhandledKeywords); -> DispatchKeyword(topScope, ref token);DispatchKeyword 函数内 通过指定的 Table 获取 对应的 UhtKeyword 对象,然后调用其 委托函数,完成了 parser 的调用

这里有一个需要注意的点,在 UhtHeaderFileParserParse 函数的开头执行 headerParser.ParseStatements(); 之前 ,创建了一个 topScope 对象,UhtParsingScope 的构造函数参数需要一个 UhtHeaderFileParser 对象,构造函数将刚构建的 UhtParsingScope 对象使用传入的 UhtHeaderFileParser 对象的 PushScope 函数推入,并设置为 _topScope 对象

BindSuperAndBases 的过程

  • UhtType 作为基类定义了一个虚拟函数 BindSuperAndBase(),具体的绑定有继承它的子类来实现,找到了两个进行了 Override 的子类

    1. UthClass
    2. UthScriptStruct
  • 调用过程

    1. UhtSessionRun 函数内调用 StepBindSuperAndBases ()
    2. ForEachHeader 循环遍历 _headerFiles 文件并调用 UhtHeaderFile 对象的 BindSuperAndBases() 函数

StepResolve 过程

整体就是在填充对应的 UhtType 的内部属性,用来为最后做代码生成做准备

Exporter 导出、代码的生成

导出相关的主要逻辑文件都存放在了 Exporters 目录下面
Exporter 的使用方法类似 Parser, 它也是使用属性标签,然后在 UhtSession 里给 Parser 设置代理的函数里,进行代理的设置。
在函数上使用了 UhtExporter 属性的类有

  • ScriptGenerator
  • UhtJsonExporter
  • UhtCodeGenerator
  • UhtStatsExporter

IUhtExportFactory 持有了 Exporter 需要的信息,所以在委托函数的参数里会被传入

UHT 相关的核心模块和类

模块 关键类/文件 重要性 核心职责 关键接口/方法
EpicGames.UHT (C#) Utils/UhtSession.cs ★★★★★ 全局会话状态管理 Run() UBT 内主要调用到的,StepParseHeaders(), StepExport()
UhtTokenReader.cs->IUhtTokenReader ★★★★★ 头文件词法/语法解析 Token 的读取接口,UhtTokenBufferReaderUhtTokenReplayReader 继承了这个接口。UhtTokenBufferReader接口内声明的方法很少,但是在同目录下使用 8 个Extensions 文件以扩展的形式给他添加了更多的函数,来应对更多的数据类型 ,且很多都是static 函数
IUhtTokenPreprocessor ★★★★★ token 预处理 UhtHeaderFileParser 继承了它 ParsePreprocessorDirective()
UhtType.cs ★★★★★ 反射类型基类 ,Types 目录下有一批和 UObject 继承结构类似的 Uht 开头的类,他们继承自 UhtObjectUhtObject 继承自 UhtTypes BindSuperAndBases() , Resolve(UhtResolvePhase phase)
UhtHeaderFile.cs ★★★★★ 头文件类型,它在Types目录内,但是没有继承 UhtTypes AddReferencedHeader() , AddChild , Resolve , BindSuperAndBases , CollectReferences
UhtHeaderFileParser.cs ★★★★★ Parser 的主要触发类 Parse(UhtHeaderFile headerFile) 输入一个HeadFile 进行解析
UhtTables.cs ★★★★★ 保存反射元数据的类 里面声明了很多 table 类,其中 UhtTables 是外部使用的最大的类 UhtTableNames 定义了一些列标准的table名,是个静态类
UHTManifest ★★★★★ 保存ManifestFile文件解析后的结果,被 UhtManifestFile 对象持有 UhtManifestFile 调用 Read 函数后,将结果保存在内部的 UHTManifest 对象上,里面有解析好的一些属性 。这个UHTManifest 在 Shared EpicGames.Core 模块内的 UHTTypes.cs 文件内
UhtMetaData ★★★☆☆ 保存宏定义内的 meta 元数据 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Player", meta=(DisplayName="生命值", ClampMin=0.0))
UhtExportFactory ★★★☆☆ 构造时传入 Exporter 对象,执行 Run 函数进行代码输出 Run()
UnrealBuildTool UnrealBuildTool.cs ★★★★★ 构建系统入口 Main(string[] ArgumentsArray)
ToolMode.cs ★★★★★ 工具构建模式基类,有很多子类实现 ExecuteAsync 的命令行执行 ExecuteAsync(CommandLineArguments Arguments, ILogger Logger)
UnrealHeaderToolMode -> ToolMode ★★★★★ UHT 模块工具 ExecuteAsync(CommandLineArguments Arguments, ILogger Logger)

阅读记录

UBA (Unreal Build Accelerator) unreal 的分布式编译系统
FileReference? RunFile = null; 这个C#的 FileReference 类后面的问号表示 RunFile可以为空, dotnet8 的新语法规则, 不加? RunFile 不可以赋予空值
PrintUsage UnrealBuildTool 内的函数,它通过获取 GlobalOptions 内的 Fields 然后再根据 Field的attribute 的 信息就可以输出 helper 信息,这个方法很好,在写代码的时候就自动带了 helper 信息~~~
Tokenizer UHT 模块的 Tokenizer 目录下,保存的是 token相关类型,基本就是一个接口IUhtTokenReader,两个实现类UhtTokenBufferReader,UhtTokenReplayReader,剩下的 Extensions 文件都是为 IUhtTokenReader 添加对应类型数据的函数扩展
Token 这里的 token 类似将代码拆成数组,例如 public void test(float a), 就变成了 [public,void,test,(,float,a,)] 这样子,然后它使用类似堆盏的方式,可以取出,这样就可以一步一步的判断下一个是什么。IUhtTokenReader 的 require 函数返回的还是 IUhtTokenReader,这样就可以函数传承一条链。
_ = PropertyTypeTable.Default; 这是一种丢失语法,他会执行一下 Default 的getter,然后将返回值丢弃,就是空跑一下,但是可以触发 getter 内的逻辑。还可以消除 Default never used 的编辑器警告

UhtLookupTableBase Table 数据的基类

  • UhtLookupTable 类继承自 UhtLookupTableBase 用于实现查找 Table 内的具体数据
  • UhtLookupTables 用于保存和查找 UhtLookupTable,它并继承 UhtLookupTableBase

public UhtExporterTable ExporterTable => Tables!.ExporterTable; !的作用是禁用 编辑器的 null 报警,意思就是可以保证 ExporterTalbe 肯定不会为空,不用报警

Parallel.ForEach() C# 的并行计算功能
Task C# 的并行计算功能

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容