Skip to content

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.hFFrame 栈帧定义与操作数读取
CoreUObject/Public/UObject/ScriptMacros.hP_GET_* / RESULT_PARAM / P_THIS 等宏(169 行)
CoreUObject/Public/UObject/Script.hEExprToken 操作码表
CoreUObject/Private/UObject/Class.cppUFunction::Invoke、UFunction::Bind(Func 指针绑定)

关于引用引擎源码

Unreal Engine 的源码受 Epic 的许可协议约束。本章只摘录理解机制所必需的最小片段并附注释说明, 不整段转载。需要完整上下文请查阅你自己有权限访问的引擎源码副本。

为什么必须用 5.8 而不是公开的 UE4 镜像

UE4 的公开镜像(EpicGames/UnrealTournament)操作码表和 5.8 已经不一样了,几个关键取值都变了:

取值UE4 公开镜像UE 5.8
EX_Max0x1000xFF
0x67EX_AssetConstEX_SoftObjectConst
0x38EX_PrimitiveCastEX_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 里的 ScriptBytecodeUFunction::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,节选)

三个细节:

  1. ParmsSize 为 0 时 Parms 可以是 null——无参函数不需要缓冲区;
  2. 返回值不是「返回」出来的,而是写在参数缓冲区的一个偏移上(ReturnValueOffset);
  3. 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 异常,置 bRanawayProcessLocalScriptFunction
循环上限每轮检查 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 章的静态描述,这里能确认三件事:

  1. Push 确实只是压栈,压完继续往下执行(注释原文就写了 "Execution continues on normally");
  2. Pop 从 FFrame::FlowStack 弹一个偏移,赋给 Code;
  3. 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,节选)

四个要点:

  1. 参数不是「push」进栈的,而是被写进被调函数的参数缓冲区——地址由 ContainerPtrToValuePtr 按属性的 Offset_Internal 算出来;
  2. 遍历顺序由 FProperty 链决定,遇到 CPF_ReturnParm 就停(循环条件里的 (It->PropertyFlags & (CPF_Parm|CPF_ReturnParm))==CPF_Parm)——这正好用上了 第 08 章里那套标志位;
  3. Stack.Step 每调一次消耗一个参数表达式,所以参数个数与字节码必须严格对应;
  4. 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 镜像逐项对照,能看出这个「格式」仍在演进:

取值UE4UE 5.8备注
EX_Max0x1000xFF分派表大小随之变化
0x0C—EX_NothingInt32「带 int32 参数的空操作,方便反汇编调试」
0x11—EX_BitFieldConst「按 FProperty 定义的单个 bit 赋值」
0x33—EX_PropertyConstFProperty 常量
0x37—EX_DoubleConst双精度常量
0x38EX_PrimitiveCastEX_Cast改名,语义注释相同
0x3D / 0x3F—EX_SetConst / EX_MapConstSet / Map 字面量
0x41—EX_Vector3fConstfloat 向量常量
0x45 / 0x46—EX_LocalVirtualFunction / EX_LocalFinalFunction「确定只在本地运行」的快速调用
0x5BEX_SkipOffsetConst同保持不变
0x67EX_AssetConstEX_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*,就能调它。

(详见运行时逆向 · ProcessEvent)

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.cppGNatives 声明、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.hFFrame 字段与构造函数、FlowStackType、Step / StepCompiledIn / StepExplicitProperty、ReadProperty / ReadPropertyUnchecked / ReadCodeSkipCount / ReadWord
Engine/Source/Runtime/CoreUObject/Public/UObject/ScriptMacros.hP_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.hEExprToken 取值(含 UE5 新增项)与注释原文
Engine/Source/Runtime/CoreUObject/Private/UObject/Class.cppUFunction::Invoke、UFunction::Bind 里的 Func = &UObject::ProcessInternal 分支
Engine/Source/Runtime/Launch/Resources/Version.hENGINE 5.8.0

相关章节:02 · Kismet 字节码 · 07 · 反射对象的字段级定义 · 09 · FKismetPropertyPointer · 11 / 12 · 常用字节码详解 · 运行时逆向 · ProcessEvent

相关 ​