外观
13 · UE5 蓝图虚拟机
前面十二章讲的都是静态:字节码长什么样、怎么解析、怎么反编译。这一章讲动态—— 当你(或游戏自己)调用一个蓝图函数时,引擎里到底发生了什么。
0. 依据与范围
本章所有代码引自 CCB-TEAM 私有镜像的 Epic 官方 UE5 源码(CCB-Team/UnrealEngine,fork 自 EpicGames/UnrealEngine,release 分支):
ENGINE_MAJOR_VERSION 5
ENGINE_MINOR_VERSION 8
ENGINE_PATCH_VERSION 0涉及的文件:
| 文件 | 作用 |
|---|---|
CoreUObject/Private/UObject/ScriptCore.cpp | 虚拟机本体(4256 行):GNatives 分派表、92 个 opcode 处理函数、ProcessEvent、ProcessInternal、主循环 |
CoreUObject/Public/UObject/Stack.h | FFrame 栈帧定义与操作数读取 |
CoreUObject/Public/UObject/ScriptMacros.h | P_GET_* / RESULT_PARAM / P_THIS 等宏(169 行) |
CoreUObject/Public/UObject/Script.h | EExprToken 操作码表 |
CoreUObject/Private/UObject/Class.cpp | UFunction::Invoke、UFunction::Bind(Func 指针绑定) |
关于引用引擎源码
Unreal Engine 的源码受 Epic 的许可协议约束。本章只摘录理解机制所必需的最小片段并附注释说明, 不整段转载。需要完整上下文请查阅你自己有权限访问的引擎源码副本。
为什么必须用 5.8 而不是公开的 UE4 镜像
UE4 的公开镜像(EpicGames/UnrealTournament)操作码表和 5.8 已经不一样了,几个关键取值都变了:
| 取值 | UE4 公开镜像 | UE 5.8 |
|---|---|---|
EX_Max | 0x100 | 0xFF |
0x67 | EX_AssetConst | EX_SoftObjectConst |
0x38 | EX_PrimitiveCast | EX_Cast |
拿 UE4 的表去读 UE5.8 的字节码,在这几个点上会直接读错。
1. 全景:蓝图不是「解释节点图」
先纠正一个常见误解:运行时没有节点图。节点图是编辑器里的东西,编译完就没了。运行的是字节码, 由一个 C++ 写的栈式解释器执行。
一次蓝图调用的完整链路:
外部调用者(C++ / 另一个蓝图 / 引擎)
│
▼
UObject::ProcessEvent(Function, Parms) ← ScriptCore.cpp
│ 安全检查(GC / PostLoad / 断点)
│ FUNC_Native?→ 走远程复制分支
▼
UFunction::Invoke(Obj, NewStack, ReturnAddr) ← Class.cpp
│ 接口偏移修正;把 CurrentNativeFunction 压栈
▼
(*Func)(Obj, Stack, RESULT_PARAM) ← 函数指针调用
│
│ ★ Func 是什么?见第 4 节:
│ 蓝图函数 → &UObject::ProcessInternal
│ 原生函数 → 各自的 C++ 实现
▼
UObject::ProcessInternal ← ScriptCore.cpp
│ Callspace 分流:Remote 走网络,Local 走本地
▼
ProcessLocalScriptFunction ← 主循环
│ while (*Stack.Code != EX_Return)
▼
FFrame::Step → (GNatives[opcode])(Context, Stack, RESULT_PARAM)
│
▼
UObject::execXxx (92 个之一)这张图是本章的骨架。 下面逐段展开。
2. 字节码挂在哪:UStruct::Script
FFrame 的构造函数给出了最直接的证据——Code 指针初始化自 Node->Script:
cpp
inline FFrame::FFrame( UObject* InObject, UFunction* InNode, void* InLocals, ... )
: Node(InNode)
, Object(InObject)
, Code(InNode->Script.GetData()) // ← 字节码就是 UStruct::Script
, Locals((uint8*)InLocals)(Stack.h)
Node 是 UFunction*,而 UFunction 继承 UStruct,所以 Script 就是第 07 章 里那个 TArray<uint8> Script——资产文件里的 ScriptBytecode,运行时就是这块内存。
反编译器和 VM 看的是同一串字节,区别只是:
| 反编译器(离线) | VM(在线) | |
|---|---|---|
| 起点 | .uasset 里的 ScriptBytecode | UFunction::Script |
| 方式 | 递归解析成 AST | 线性扫描 + 分派执行 |
| 目标 | 人能读的伪代码 | 真的把事做了 |
3. 栈帧:FFrame
FFrame 是整个虚拟机的状态载体。完整字段(Stack.h,注释为原文):
cpp
struct FFrame : public FOutputDevice
{
UFunction* Node; // 当前执行的函数
UObject* Object; // this 对象
uint8* Code; // ★ 字节码游标
uint8* Locals; // ★ 局部变量 + 参数的内存块
FProperty* MostRecentProperty; // ★ 最近解析的属性
uint8* MostRecentPropertyAddress; // ★ 它的地址
uint8* MostRecentPropertyContainer; // ★ 它所属的容器
/** The execution flow stack for compiled Kismet code */
FlowStackType FlowStack; // ★ 执行流栈
FFrame* PreviousFrame; // 调用链
FField* PropertyChainForCompiledIn; // 编译期内联函数用的属性链
UFunction* CurrentNativeFunction;
// ...
};其中 FlowStackType 是:
cpp
typedef TArray< CodeSkipSizeType, TInlineAllocator<8> > FlowStackType;四个字段是理解 VM 的关键:
| 字段 | 作用 |
|---|---|
Code | 游标。每个 opcode 处理函数几乎都在改它——这就是「跳转」的实现方式 |
Locals | 参数与局部变量共用的一块内存,大小由 UFunction::PropertiesSize 决定 |
MostRecentProperty* | 属性访问缓存(见第 9 节) |
FlowStack | 蓝图顺序执行 / 延迟逻辑的实现(见第 7 节) |
注意 Locals 是「参数 + 局部变量」
引擎用 Function->PropertiesSize 一次性分配,Memzero 清零。参数在这块内存的什么位置, 由每个属性的 Offset_Internal 决定——而那个偏移不在资产文件里,是加载时 Link() 算出来的 (见第 08 章)。
4. 进入 VM 的四步
4.1 ProcessEvent:入口与安全检查
cpp
void UObject::ProcessEvent( UFunction* Function, void* Parms )
{
// 不可达对象(即将被 GC)不允许调用
checkf(!IsUnreachable(), ...);
// PostLoad 期间不允许执行脚本
checkf(!FUObjectThreadContext::Get().IsRoutingPostLoad, ...);
if (!IsValidChecked(this)) return;
#if WITH_EDITORONLY_DATA
// 断点暂停期间不允许执行脚本
if(GIntraFrameDebuggingGameThread) { ...; return; }
#endif
if ((Function->FunctionFlags & FUNC_Native) != 0)
{
int32 FunctionCallspace = GetFunctionCallspace(Function, NULL);
if (FunctionCallspace & FunctionCallspace::Remote)
CallRemoteFunction(Function, Parms, NULL, NULL);
if ((FunctionCallspace & FunctionCallspace::Local) == 0) return;
}
// ...(ScriptCore.cpp,节选)
三处 checkf/return 值得注意——这就是为什么「随便找个地址调 ProcessEvent」很容易崩: 调用前有一堆前置条件(对象可达、非 PostLoad、非断点暂停、线程正确)。
4.2 分配参数缓冲区与返回值
cpp
checkSlow((Function->ParmsSize == 0) || (Parms != NULL));
// ...
const bool bHasReturnParam = Function->ReturnValueOffset != MAX_uint16;
uint8* ReturnValueAddress = bHasReturnParam
? ((uint8*)Parms + Function->ReturnValueOffset)
: nullptr;
Function->Invoke(this, NewStack, ReturnValueAddress);
if (!bUsePersistentFrame)
{
// Destroy local variables except function parameters.
// !! see also UObject::CallFunctionByNameWithArguments
}(ScriptCore.cpp,节选)
三个细节:
ParmsSize为 0 时Parms可以是 null——无参函数不需要缓冲区;- 返回值不是「返回」出来的,而是写在参数缓冲区的一个偏移上(
ReturnValueOffset); MAX_uint16是「没有返回值」的哨兵值——这正好呼应第 10 章 里ReturnValueOffset是「内存变量」这件事。
4.3 Invoke:真的跳过去
cpp
void UFunction::Invoke(UObject* Obj, FFrame& Stack, RESULT_DECL)
{
checkSlow(Func);
UClass* OuterClass = (UClass*)GetOuter();
if (OuterClass->IsChildOf(UInterface::StaticClass()))
{
Obj = (UObject*)Obj->GetInterfaceAddress(OuterClass); // 接口偏移修正
}
TGuardValue<UFunction*> NativeFuncGuard(Stack.CurrentNativeFunction, this);
return (*Func)(Obj, Stack, RESULT_PARAM);
}(Class.cpp)
它就是一次函数指针调用。 所有 UE 函数(原生的、蓝图的)签名统一成 (UObject*, FFrame&, void*),这是整套机制能成立的前提。
4.4 Func 指向哪里?——VM 的关键接线
答案在 UFunction::Bind() 里(Class.cpp,注释为原文):
cpp
// if this isn't a native function, or this function belongs to a native interface
// class (which has no C++ version), use ProcessInternal (call into script VM only)
// as the function pointer for this function
if (!HasAnyFunctionFlags(FUNC_Native))
{
// Use processing function.
Func = &UObject::ProcessInternal;
}
else
{
// Find the function in the class's native function lookup table.
Func = Found->Pointer;
}这一行是整章的题眼
蓝图函数的 Func 指针就是 &UObject::ProcessInternal。
所以「调用蓝图函数」和「调用原生函数」在 Invoke 那一层看不出区别——都是 (*Func)(...)。 区别在于蓝图函数进去以后,落到 VM 里去了。
4.5 ProcessInternal:本地还是远程
cpp
DEFINE_FUNCTION(UObject::ProcessInternal)
{
UFunction* Function = (UFunction*)Stack.Node;
int32 FunctionCallspace = P_THIS->GetFunctionCallspace(Function, NULL);
if (FunctionCallspace & FunctionCallspace::Remote)
{
P_THIS->CallRemoteFunction(Function, Stack.Locals, Stack.OutParms, NULL);
}
if (FunctionCallspace & FunctionCallspace::Local)
{
ProcessLocalScriptFunction(Context, Stack, RESULT_PARAM);
}
else
{
FProperty* ReturnProp = (Function)->GetReturnProperty();
ClearReturnValue(ReturnProp, RESULT_PARAM);
}
}(ScriptCore.cpp)
网络分流在这里发生:同一个蓝图函数,在客户端是本地执行,在服务端可能走 RPC 复制。 Callspace 同时含 Remote 与 Local 位时,两边都会做。
5. 分派表:GNatives
虚拟机的心脏是一张按操作码索引的函数指针表:
cpp
COREUOBJECT_API constinit TStaticArray<FNativeFuncPtr, EX_Max> GNatives(InPlace, &UObject::execUndefined);(ScriptCore.cpp)
- 大小是
EX_Max——UE 5.8 里等于0xFF,所以表有 255 个槽; - 默认值全填
&UObject::execUndefined,也就是说未注册的 opcode 会落到一个「未定义」处理函数; constinit保证它在静态初始化期就完成,不需要运行时构造。
每个 opcode 是怎么注册进去的
注册靠一个宏,ScriptCore.cpp 里用了 92 次:
cpp
#define IMPLEMENT_VM_FUNCTION(BytecodeIndex, func) \
STORE_INSTRUCTION_NAME(BytecodeIndex) \
IMPLEMENT_FUNCTION(func) \
static uint8 UObject##func##BytecodeTemp = GRegisterNative( BytecodeIndex, &UObject::func );(ScriptCore.cpp)
写法是这样的——一个 opcode 一个 exec 函数,紧跟着一行注册:
cpp
DEFINE_FUNCTION(UObject::execJump)
{
CheckRunaway();
// Jump immediate.
CodeSkipSizeType Offset = Stack.ReadCodeSkipCount();
Stack.Code = &Stack.Node->Script[Offset];
}
IMPLEMENT_VM_FUNCTION( EX_Jump, execJump );(ScriptCore.cpp)
注意那个 static uint8 ... = GRegisterNative(...) 的技巧:利用静态变量的初始化副作用完成注册, 不需要任何显式的「初始化 VM」调用。STORE_INSTRUCTION_NAME 则是把 opcode 名字存进 GNativeFuncNames[EX_Max],供反汇编/审计使用。
完整的 opcode → 处理函数映射(UE 5.8,92 条)
这张表本身就是一份「VM 支持什么」的清单,也是逆向时的速查表:
| 分组 | 映射 |
|---|---|
| 变量 | EX_LocalVariable→execLocalVariable、EX_InstanceVariable→execInstanceVariable、EX_DefaultVariable→execDefaultVariable、EX_LocalOutVariable→execLocalOutVariable、EX_ClassSparseDataVariable→execClassSparseDataVariable、EX_Self→execSelf |
| 上下文/调用 | EX_Context→execContext、EX_Context_FailSilent→execContext_FailSilent、EX_ClassContext→execClassContext、EX_InterfaceContext→execInterfaceContext、EX_VirtualFunction→execVirtualFunction、EX_FinalFunction→execFinalFunction、EX_LocalVirtualFunction→execLocalVirtualFunction、EX_LocalFinalFunction→execLocalFinalFunction、EX_CallMath→execCallMathFunction、EX_EndFunctionParms→execEndFunctionParms |
| 控制流 | EX_Jump→execJump、EX_JumpIfNot→execJumpIfNot、EX_ComputedJump→execComputedJump、EX_SwitchValue→execSwitchValue、EX_Assert→execAssert、EX_SkipOffsetConst→execSkipOffsetConst、EX_EndOfScript→execEndOfScript |
| 执行流栈 | EX_PushExecutionFlow→execPushExecutionFlow、EX_PopExecutionFlow→execPopExecutionFlow、EX_PopExecutionFlowIfNot→execPopExecutionFlowIfNot |
| 赋值 | EX_Let→execLet、EX_LetObj→execLetObj、EX_LetWeakObjPtr→execLetWeakObjPtr、EX_LetBool→execLetBool、EX_LetDelegate→execLetDelegate、EX_LetMulticastDelegate→execLetMulticastDelegate、EX_LetValueOnPersistentFrame→execLetValueOnPersistentFrame、EX_BitFieldConst→execBitFieldConst |
| 常量 | EX_IntConst→execIntConst、EX_Int64Const→execInt64Const、EX_UInt64Const→execUInt64Const、EX_FloatConst→execFloatConst、EX_DoubleConst→execDoubleConst、EX_StringConst→execStringConst、EX_UnicodeStringConst→execUnicodeStringConst、EX_TextConst→execTextConst、EX_NameConst→execNameConst、EX_ByteConst→execByteConst、EX_ObjectConst→execObjectConst、EX_SoftObjectConst→execSoftObjectConst、EX_PropertyConst→execPropertyConst、EX_FieldPathConst→execFieldPathConst、EX_IntConstByte→execIntConstByte、EX_RotationConst→execRotationConst、EX_VectorConst→execVectorConst、EX_Vector3fConst→execVector3fConst、EX_TransformConst→execTransformConst、EX_IntZero→execIntZero、EX_IntOne→execIntOne、EX_True→execTrue、EX_False→execFalse、EX_NoObject→execNoObject、EX_NoInterface→execNullInterface |
| 结构/容器 | EX_StructConst→execStructConst、EX_SetArray→execSetArray、EX_SetSet→execSetSet、EX_SetMap→execSetMap、EX_SetConst→execSetConst、EX_MapConst→execMapConst、EX_ArrayConst→execArrayConst、EX_StructMemberContext→execStructMemberContext、EX_ArrayGetByRef→execArrayGetByRef |
| 类型转换 | EX_DynamicCast→execDynamicCast、EX_MetaCast→execMetaCast、EX_Cast→execCast、EX_ObjToInterfaceCast→execInterfaceCast、EX_CrossInterfaceCast→execInterfaceToInterface、EX_InterfaceToObjCast→execInterfaceToObject |
| 委托 | EX_InstanceDelegate→execInstanceDelegate、EX_BindDelegate→execBindDelegate、EX_AddMulticastDelegate→execAddMulticastDelegate、EX_RemoveMulticastDelegate→execRemoveMulticastDelegate、EX_ClearMulticastDelegate→execClearMulticastDelegate、EX_CallMulticastDelegate→execCallMulticastDelegate |
| 事务内存(UE5 新增) | EX_AutoRtfmTransact→execAutoRtfmTransact、EX_AutoRtfmStopTransact→execAutoRtfmStopTransact、EX_AutoRtfmAbortIfNot→execAutoRtfmAbortIfNot、EX_AutoRtfmAbort→execAutoRtfmAbort |
| 空操作/调试 | EX_Nothing→execNothing、EX_NothingInt32→execNothingInt32、EX_DeprecatedOp4A→execNothingOp4a、EX_Breakpoint→execBreakpoint、EX_Tracepoint→execTracepoint、EX_WireTracepoint→execWireTracepoint、EX_InstrumentationEvent→execInstrumentation |
这张表和静态反编译的关系
第 11 / 12 章讲的是「这个 opcode 序列化成什么」, 这张表讲的是「它执行时调用哪个函数」。两边合起来,你就有了从字节到语义的完整映射—— 遇到不认识的 opcode,先去引擎源码里找对应的 exec* 函数,语义就清楚了。
6. 主循环
6.1 三行分派
整个虚拟机的核心只有三行(ScriptCore.cpp):
cpp
void FFrame::Step(UObject* Context, RESULT_DECL)
{
int32 B = *Code++;
(GNatives[B])(Context, *this, RESULT_PARAM);
}读一个字节 → 游标 +1 → 查表 → 调用处理函数。 没有解码循环、没有指令缓存、没有 JIT。
6.2 循环条件
cpp
void ProcessLocalScriptFunction(UObject* Context, FFrame& Stack, RESULT_DECL)
{
UFunction* Function = (UFunction*)Stack.Node;
// No POD struct can ever be stored in this buffer.
MS_ALIGN(16) uint8 Buffer[MAX_SIMPLE_RETURN_VALUE_SIZE] GCC_ALIGN(16);
// ... 递归保护 ...
// Execute the bytecode
while (*Stack.Code != EX_Return && !Stack.bAbortingExecution)
{
// ... 每轮做一次失控检查 ...(ScriptCore.cpp,节选)
循环的终止条件是「下一个字节是 EX_Return」,不是「执行了 N 条指令」。 这意味着 EX_Return 不只是「返回」,它还是循环哨兵——这也是为什么 第 11 章里说 EX_EndOfScript 和 EX_Return 是两种不同的结束。
6.3 失控保护:三道闸
蓝图 VM 是「图灵完备但会挂死」的东西,所以引擎装了三道保护:
| 机制 | 触发条件 | 出处 |
|---|---|---|
| 递归上限 | ++BpET.Recurse == GScriptRecurseLimit → 抛 InfiniteLoop 异常,置 bRanaway | ProcessLocalScriptFunction |
| 循环上限 | 每轮检查 BpET.Runaway > GMaximumScriptLoopIterations → Runaway loop detected | 主循环体内 |
| 时间上限 | CheckRunaway() 里每 256 次自增做一次超时检查 | CheckRunaway() |
cpp
static FORCEINLINE void CheckRunaway()
{
FBlueprintContextTracker& ContextTracker = FBlueprintContextTracker::Get();
int32 RunawayCount = ContextTracker.AddRunaway();
// We need to periodically check that we're still within the script time limit.
// For every 1 out of 256 runaway counter increments, we do a timeout check here.
if (UNLIKELY((RunawayCount & 0xFF) == 0))
{
ContextTracker.EnforceScriptTimeLimit();
}
}(ScriptCore.cpp)
注意 CheckRunaway() 只被会改变控制流的 opcode 调用(execJump、execJumpIfNot、 execComputedJump、execPopExecutionFlow…),而不是每条指令都调——这是个很实际的性能取舍。
另外在非 DO_BLUEPRINT_GUARD 构建(即 Shipping)里,这个函数是空的:
cpp
static FORCEINLINE void CheckRunaway() {}所以打包后的游戏里没有这些保护——失控的蓝图会把游戏直接卡死。
7. 三个操作码的执行实例
7.1 EX_Jump:最简单的跳转
cpp
DEFINE_FUNCTION(UObject::execJump)
{
CheckRunaway();
// Jump immediate.
CodeSkipSizeType Offset = Stack.ReadCodeSkipCount();
Stack.Code = &Stack.Node->Script[Offset];
}「跳转」就是给 Code 指针赋个新值。 偏移的读取方式(Stack.h):
cpp
inline CodeSkipSizeType FFrame::ReadCodeSkipCount()
{
CodeSkipSizeType Result = FPlatformMemory::ReadUnaligned<CodeSkipSizeType>(Code);
Code += sizeof(CodeSkipSizeType);
return Result;
}CodeSkipSizeType 就是字节码里的「跳转偏移类型」。这正是静态反编译时 GetSize 要累计的东西—— 两边算的必须是同一个偏移。
7.2 EX_JumpIfNot:先求值再决定
cpp
DEFINE_FUNCTION(UObject::execJumpIfNot)
{
CheckRunaway();
// Get code offset.
CodeSkipSizeType Offset = Stack.ReadCodeSkipCount();
uint8 TempValue = 0;
Stack.Step(Stack.Object, &TempValue); // ★ 递归求值条件表达式
const bool NewBoolValue = (TempValue != 0);
// Jump if false.
if (!NewBoolValue) { ... }
}(ScriptCore.cpp,节选)
Stack.Step(...) 是 VM 的递归点:求值一个子表达式 = 在当前帧上再跑一次分派。 条件表达式求值完,Code 已经指向跳转目标之后的位置了。
7.3 执行流栈三兄弟 + ubergraph 分发
cpp
DEFINE_FUNCTION(UObject::execPushExecutionFlow)
{
// Read a code offset and push it onto the flow stack
CodeSkipSizeType Offset = Stack.ReadCodeSkipCount();
Stack.FlowStack.Push(Offset);
}
DEFINE_FUNCTION(UObject::execPopExecutionFlow)
{
CheckRunaway();
// Try to pop an entry off the stack and go there
if (Stack.FlowStack.Num())
{
CodeSkipSizeType Offset = Stack.FlowStack.Pop(EAllowShrinking::No);
Stack.Code = &Stack.Node->Script[ Offset ];
}
else { ... }
}
DEFINE_FUNCTION(UObject::execComputedJump)
{
CheckRunaway();
// Get the jump offset expression
int32 ComputedOffset = 0;
Stack.Step( Stack.Object, &ComputedOffset );
check((ComputedOffset < Stack.Node->Script.Num()) && (ComputedOffset >= 0));
// Jump to the new offset
Stack.Code = &Stack.Node->Script[ComputedOffset];
}(ScriptCore.cpp,节选)
对照第 12 章的静态描述,这里能确认三件事:
Push确实只是压栈,压完继续往下执行(注释原文就写了 "Execution continues on normally");Pop从FFrame::FlowStack弹一个偏移,赋给Code;execComputedJump里那句check(...)是范围校验——偏移必须落在Script数组内。 这解释了为什么 ubergraph 分发(ComputedJump(EntryPoint))能工作:EntryPoint就是Script数组里的字节下标。
而事件函数调用 ubergraph 用的是另一条路(ScriptCore.cpp):
cpp
DEFINE_FUNCTION(UObject::execLocalFinalFunction)
{
// Call the final function.
ProcessLocalFunction(Context, (UFunction*)Stack.ReadObject(), Stack, RESULT_PARAM);
}EX_LocalFinalFunction 读一个对象引用直接调用——这就是 KismetDecompiler 里说的「ExecuteUbergraph 调用在事件函数中是 EX_LocalFinalFunction,通过 StackNode.Index 匹配 export」。
8. 参数是怎么传的
这是「蓝图 VM 行为」里最实用的一节。
8.1 调用方:把参数「步进」进对方的缓冲区
引擎在 UObject::CallFunction 的远程分支里给出了最清晰的写法(本地分支同理):
cpp
// Form the RPC parameters.
for (TFieldIterator<FProperty> It(Function); It && (It->PropertyFlags & (CPF_Parm|CPF_ReturnParm))==CPF_Parm; ++It)
{
uint8* CurrentPropAddr = It->ContainerPtrToValuePtr<uint8>(Buffer);
if (CastField<FBoolProperty>(*It) && It->ArrayDim == 1)
{
uint8 TempValue = 0;
Stack.Step(Stack.Object, &TempValue);
// ... bool 特判 ...
}
else
{
Stack.Step(Stack.Object, CurrentPropAddr); // ★ 把下一个参数求值到目标地址
}
}
checkSlow(*Stack.Code==EX_EndFunctionParms);(ScriptCore.cpp,节选)
四个要点:
- 参数不是「push」进栈的,而是被写进被调函数的参数缓冲区——地址由
ContainerPtrToValuePtr按属性的Offset_Internal算出来; - 遍历顺序由
FProperty链决定,遇到CPF_ReturnParm就停(循环条件里的(It->PropertyFlags & (CPF_Parm|CPF_ReturnParm))==CPF_Parm)——这正好用上了 第 08 章里那套标志位; Stack.Step每调一次消耗一个参数表达式,所以参数个数与字节码必须严格对应;checkSlow(*Stack.Code==EX_EndFunctionParms)——参数读完,游标必须正好停在EX_EndFunctionParms上。这是 VM 自己的自检,也说明这个终止符是硬约定。
8.2 被调方:原生函数怎么读参数
原生函数的参数靠宏读取(ScriptMacros.h):
cpp
#define P_GET_PROPERTY(PropertyType, ParamName) \
PropertyType::TCppType ParamName = PropertyType::GetDefaultPropertyValue(); \
Stack.StepCompiledIn<PropertyType>(&ParamName);
#define P_GET_PROPERTY_REF(PropertyType, ParamName) \
PropertyType::TCppType ParamName##Temp = PropertyType::GetDefaultPropertyValue(); \
PropertyType::TCppType& ParamName = Stack.StepCompiledInRef<PropertyType, PropertyType::TCppType>(&ParamName##Temp);
#define P_THIS_OBJECT (Context)
#define P_THIS P_THIS_CAST(ThisClass)
#define P_FINISH Stack.Code += !!Stack.Code; /* increment the code ptr unless it is null */(ScriptMacros.h)
StepCompiledIn 的名字里有个关键信息:它先看有没有字节码——
cpp
/** Replacement for Step that checks the for byte code, and if none exists,
then PropertyChainForCompiledIn is used. **/(Stack.h)
也就是说,同一个原生函数既能被蓝图调用(走字节码),也能被C++ 直接调用(走 PropertyChainForCompiledIn 属性链)。这就是 FFrame::PropertyChainForCompiledIn 字段存在的理由。
8.3 out 参数:走 OutParms 链
cpp
void FFrame::StepExplicitProperty(void*const Result, FProperty* Property)
{
checkSlow(Result != NULL);
if (Property->PropertyFlags & CPF_OutParm)
{
// look through the out parameter infos and find the one that has the address of this property
FOutParmRec* Out = OutParms;
checkSlow(Out);
while (Out->Property != Property) { Out = Out->NextOutParm; checkSlow(Out); }
MostRecentPropertyAddress = Out->PropAddr;
MostRecentPropertyContainer = nullptr;
// no need to copy property value, since the caller is just looking for MostRecentPropertyAddress
}
else
{
MostRecentPropertyAddress = Property->ContainerPtrToValuePtr<uint8>(Locals);
// ...(ScriptCore.cpp,节选)
out 参数的传递不靠返回值,靠一张 FOutParmRec 链表:调用方把自己的变量地址登记进去, 被调方按属性指针在链上找到对应地址,直接写过去。
这也解释了本站 KismetDecompiler README 里那句 「UE 函数调用的 out 形参(如 getSkirmishBP(Temp))直接把结果写进引用参数,而非返回赋值」—— 在 VM 层面,它就是改了一块别人的内存。
9. 属性访问:MostRecentProperty 三兄弟
FFrame 里有三个看起来冗余的字段:
cpp
FProperty* MostRecentProperty;
uint8* MostRecentPropertyAddress;
uint8* MostRecentPropertyContainer;它们是属性访问的缓存。看变量读取的实现(ScriptCore.cpp):
cpp
DEFINE_FUNCTION(UObject::execLocalVariable)
{
checkSlow(Stack.Object == P_THIS);
checkSlow(Stack.Locals != NULL);
FProperty* VarProperty = Stack.ReadProperty();
if (VarProperty == nullptr)
{
FBlueprintExceptionInfo ExceptionInfo(EBlueprintExceptionType::AccessViolation,
LOCTEXT("MissingLocalVariable",
"Attempted to access missing local variable. If this is a packaged/cooked build, "
"are you attempting to use an editor-only property?"));
FBlueprintCoreDelegates::ThrowScriptException(P_THIS, Stack, ExceptionInfo);
Stack.MostRecentPropertyAddress = nullptr;
Stack.MostRecentPropertyContainer = nullptr;
// ...(节选)
而 ReadProperty 本身极简(Stack.h):
cpp
inline FProperty* FFrame::ReadProperty()
{
FProperty* Result = ReadPropertyUnchecked();
// Callers don't check for NULL; this method is expected to succeed.
// ...
}
inline FProperty* FFrame::ReadPropertyUnchecked()
{
FProperty* Result = (FProperty*)ReadPointer();
// ...
}属性指针在字节码里就是一个「指针大小的值」——这与第 09 章讲的 KismetPropertyPointer 完全对应:文件里它是 FPackageIndex 或 FFieldPath, 运行时它已经被解析成内存里的 FProperty*。
那条错误信息值得抄下来
「Attempted to access missing local variable. If this is a packaged/cooked build, are you attempting to use an editor-only property?」
这是打包后蓝图报 Accessed None / 变量访问失败的经典原因之一: 引用了 editor-only 属性,编辑器里能跑,cooked 包里那个属性根本不存在。
为什么需要三个字段
因为「拿到属性」和「拿到值」是两件事:
| 字段 | 含义 |
|---|---|
MostRecentProperty | 最近解析到的 FProperty(类型信息) |
MostRecentPropertyAddress | 它的值在哪 |
MostRecentPropertyContainer | 值所在的容器(out 参数场景下为 null) |
下一个操作码(比如 EX_Let 的右值、或 execContext 的取值)往往紧接着要用这个结果, 所以缓存在帧上而不是靠返回值传递。StepAndCheckMostRecentProperty 就是给这种「刚取完属性就要用」的模式准备的。
10. UE 5.8 相对 UE4 的变化
拿 UE5.8 的 Script.h 与公开的 UE4 镜像逐项对照,能看出这个「格式」仍在演进:
| 取值 | UE4 | UE 5.8 | 备注 |
|---|---|---|---|
EX_Max | 0x100 | 0xFF | 分派表大小随之变化 |
0x0C | — | EX_NothingInt32 | 「带 int32 参数的空操作,方便反汇编调试」 |
0x11 | — | EX_BitFieldConst | 「按 FProperty 定义的单个 bit 赋值」 |
0x33 | — | EX_PropertyConst | FProperty 常量 |
0x37 | — | EX_DoubleConst | 双精度常量 |
0x38 | EX_PrimitiveCast | EX_Cast | 改名,语义注释相同 |
0x3D / 0x3F | — | EX_SetConst / EX_MapConst | Set / Map 字面量 |
0x41 | — | EX_Vector3fConst | float 向量常量 |
0x45 / 0x46 | — | EX_LocalVirtualFunction / EX_LocalFinalFunction | 「确定只在本地运行」的快速调用 |
0x5B | EX_SkipOffsetConst | 同 | 保持不变 |
0x67 | EX_AssetConst | EX_SoftObjectConst | 含义被替换 |
0x6C / 0x6D | — | EX_ClassSparseDataVariable / EX_FieldPathConst | 引擎自己用掉了 |
外加 UE5 新增的 AutoRTFM 事务内存四个操作码(EX_AutoRtfmTransact / StopTransact / AbortIfNot / Abort)。
修正上一章的一处说法
第 12 章按 UE4 的表写过「0x6C–0xFF 是空档」。在 UE5.8 里这不准确: 引擎自己用到了 0x6D,厂商可用的空间是 0x6E–0xFE—— 正好对上鸣潮的 EX_6E/EX_6F 与 Borderlands 4 的 EX_FD/EX_FE。
11. 这套知识对逆向有什么用
四条能直接落地的:
11.1 遇到不认识的 opcode,去查它的 exec 函数
第 5 节那张表给了 opcode → execXxx 的映射。 拿到映射,就能在引擎源码里读到它的确切语义,而不是靠猜。
11.2 理解 ProcessEvent 为什么是万能入口
因为所有 UE 函数的签名被统一成 (UObject*, FFrame&, void*), 且蓝图函数的 Func 就是 &UObject::ProcessInternal。只要拿到 UFunction*,就能调它。
11.3 更好的 hook 点
既然分派是「查 GNatives[opcode] 表」,那么:
| hook 方式 | 特点 |
|---|---|
换 GNatives 表项 | 只影响某一个操作码的全局行为,不需要改指令字节,不碰页保护 |
inline hook 某个 execXxx | 粒度更细(可只针对某函数),但要处理指令长度与 trampoline |
hook ProcessEvent | 最粗,能看到所有跨对象调用,但拿不到单条指令 |
比起在 ProcessEvent 上硬 hook,改 GNatives 表项是这套 VM 独有的、更干净的位置。
11.4 理解 ubergraph 与事件分发
事件函数 → EX_LocalFinalFunction 调 ExecuteUbergraph → 内部 EX_ComputedJump(EntryPoint) 跳到对应 case → case 末尾 EX_PopExecutionFlow 回到调度器。
这条链解释了为什么「反编译时把 EX_ComputedJump 当成普通语句」会导致分发体整体消失 (第 03 章那次事故)。
12. 出处
本章代码全部取自 CCB-Team/UnrealEngine(私有,Epic 官方 UE5 源码镜像,release 分支, ENGINE 5.8.0):
| 文件 | 引用内容 |
|---|---|
Engine/Source/Runtime/CoreUObject/Private/UObject/ScriptCore.cpp | GNatives 声明、IMPLEMENT_VM_FUNCTION 宏、FFrame::Step、ProcessInternal、ProcessLocalScriptFunction、ProcessScriptFunction、ProcessEvent、CallFunction、StepExplicitProperty、CheckRunaway、execJump / execJumpIfNot / execLocalVariable / execPushExecutionFlow / execPopExecutionFlow / execComputedJump / execFinalFunction / execLocalFinalFunction、全部 92 条 IMPLEMENT_VM_FUNCTION |
Engine/Source/Runtime/CoreUObject/Public/UObject/Stack.h | FFrame 字段与构造函数、FlowStackType、Step / StepCompiledIn / StepExplicitProperty、ReadProperty / ReadPropertyUnchecked / ReadCodeSkipCount / ReadWord |
Engine/Source/Runtime/CoreUObject/Public/UObject/ScriptMacros.h | P_GET_PROPERTY / P_GET_PROPERTY_REF / P_GET_UBOOL* / P_GET_STRUCT / P_GET_OBJECT / P_FINISH / P_THIS / P_THIS_OBJECT / P_NATIVE_BEGIN / P_NATIVE_END |
Engine/Source/Runtime/CoreUObject/Public/UObject/Script.h | EExprToken 取值(含 UE5 新增项)与注释原文 |
Engine/Source/Runtime/CoreUObject/Private/UObject/Class.cpp | UFunction::Invoke、UFunction::Bind 里的 Func = &UObject::ProcessInternal 分支 |
Engine/Source/Runtime/Launch/Resources/Version.h | ENGINE 5.8.0 |
相关章节:02 · Kismet 字节码 · 07 · 反射对象的字段级定义 · 09 · FKismetPropertyPointer · 11 / 12 · 常用字节码详解 · 运行时逆向 · ProcessEvent