跳到主要内容

互操作协议

更新日期:2025-02-24

为了同时支持多种 .NET 运行时,Z# 放弃了针对特定实现的互操作方案,选择只依赖 ECMA-335 中明确定义的特性来定义自己的互操作协议。

Z# 的互操作协议只需要运行时提供两个接口:

int load_assembly(const void* assembly_bytes, size_t assembly_bytes_len, const void* symbol_bytes, size_t symbol_bytes_len);
int get_function_pointer(const char_t* type_name, const char_t* method_name, const char_t* delegate_type_name, void** delegate);

两个接口分别用于从字节流中加载一个程序集到 Default ALC 和从中获取一个 [UnmanagedCallersOnly] 的托管方法。

任何完整实现了 ECMA-335 且有上述两个能力的 .NET 9 版本及以上的运行时,都是一个有效的 Z# 脚本后端。

ZCall

对于那些不可穷举的接口,Z# 定制了一套统一的互操作协议来进行调用,称为 ZCall

之所以存在不可穷举的接口,是因为虚幻引擎采用多语言混合开发的模式。 即使 Z# 能穷举所有引擎接口,也无法知道开发者会在项目的 C++ 层定义哪些接口。

实际上,引擎的所有代码都在 C++ 层,想手动导出所有引擎代码本身就已经是不可能的了。 因此,除了真正无法穷举的接口(项目代码),引擎中的接口也是优先使用 ZCall。 只有像最底层的字符串、智能指针、容器、委托这种使用高频调用的接口才会选择手动导出。

ZCall 是进程内的、二进制格式的通信协议。 一次 ZCall 由 协议名 (ZCallName)协议体 (ZCallBuffer) 构成。

ZCallName

协议名是一个 URL 格式的字符串,由类型和地址两部分组构成。 类型决定了如何定位函数,定位哪种函数,如何调用函数;地址决定了具体定位到哪个函数。 当对端解析一次 ZCall 时,会根据协议名确定一个唯一要调用的函数。

例如:协议名 uf://Script/Engine.Actor:ReceiveTick 的类型是 uf,表示定位一个 UFunction,并通过 ProcessEvent 调用。 地址是 Script/Engine.Actor.ReceiveTick,表示定位到 AActor::ReceiveTick 函数。 uf 协议使用 virtual 调用,如果 this 参数重写了 ReceiveTick,则会调用重写版本。 如果改为 uf! 协议,将使用 final 调用,意味着最终调用到的函数一定是 AActor::ReceiveTick

负责将协议名映射到唯一函数的组件是 ZCallResolverZCallDispatcher

ZCallResolver

负责接收 ZCall 的组件会持有一个由 ZCallResolver 构成的责任链,当组件收到一个未知的协议名时,将会沿责任链依次查询。 每个 ZCallResolver 负责解析一到多种特定类型的协议,如果协议类型和地址均符合规范,ZCallResolver 会为该协议名分配一个 ZCallDispatcher。 这意味着 ZCall 是发现式的,开发者无需手动注册接口。

ZCallDispatcher

当成功解析一个未知的协议名时,会为其分配一个 ZCallDispatcher。 每个 ZCallDispatcher 有一个唯一的 Handle,解析 Handle 比解析协议名快得多,客户端后续可以通过 Handle 而不是协议名来进行通信,提高互操作的性能。 ZCallDispatcher 会根据与之绑定的路径定位到要调用的函数,并将参数解析成函数可以理解的形式进行调用。

协议类型表

协议类型地址格式逻辑
ex一个自定义的唯一名字定位并调用一个显式注册的全局函数
uf一个 UFunction 对象的路径名定位并调用一个 UFunction,有多态
uf!一个 UFunction 对象的路径名定位并调用一个 UFunction,无多态
up一个 FProperty 对象的路径名定位并读/写一个 UProperty,具体是读还是写取决于第二个参数的值
nm一个托管方法所在的程序集名:类路径名:方法名定位并调用一个托管方法
nd调用第一个参数对应的托管委托对象

ZCallBuffer

ZCallBuffer 是一块由 0~N 个固定大小的 Slot 构成的缓冲区。 每个 Slot 表示一个参数,有类型(1字节)和值(8字节)两个字段。 类型字段仅用于校验,可以在发布版本中去掉,在8字节对齐的情况下可以让 Buffer 的内存占用减半。

由于 Buffer 本身不带任何元信息,ZCall 的发起方必须保证 ZCallBuffer 的结构与协议名的要求相匹配,否则会产生未定义行为。 在开发环境下,解析 ZCall 时会校验类型字段,但这并不足以发现所有类型错误。

Slot 支持13种类型,这些类型都是 blittable 且不超过8字节的:

类型托管类型非托管类型大小(字节)
UInt8System.Byte (byte)uint81
UInt16System.UInt16 (ushort)uint162
UInt32System.UInt32 (uint)uint324
UInt64System.UInt64 (ulong)uint648
Int8System.SByte (sbyte)int81
Int16System.Int16 (short)int162
Int32System.Int32 (int)int324
Int64System.Int64 (long)int648
FloatSystem.Single (float)float4
DoubleSystem.Double (double)double8
PointerSystem.IntPtr (nint)void*8
GCHandleSystem.Runtime.InteropServices.GCHandleZSharp::FZGCHandle8
ConjugateZeroGames.ZSharp.Core.ConjugateHandleZSharp::FZConjugateHandle8

其中, Pointer 类型用于传递拥有复杂结构的参数; GCHandle 类型用于传递一个托管对象的引用; Conjugate 类型用于传递一个共轭对象的引用。

信息

布尔类型在非托管内存中的形式与平台相关,因此不是 blittable 的,在使用 ZCall 时,一般用 UInt8 充当布尔类型。

在托管栈上分配 Buffer

绝大多数支持反射的引擎接口都会以 ZCall 的形式导出,每个接口都对应一个托管方法或属性。 因此,每个方法或属性在构造 Buffer 时,都可以在编译期直接确定大小,进而可以直接在栈上分配内存,显著提升互操作的性能。

函数指针

对于高频调用的接口,Z# 直接使用函数指针进行调用,这是 .NET 原生提供的互操作能力。

Z# 使用入口方法来交换托管和非托管函数指针: 托管程序集的入口方法会接受一个包含非托管函数指针(输入)和托管函数指针(输出)的参数。 托管侧将非托管函数指针绑定到静态字段上供托管代码调用,同时给托管函数指针赋值,返回给非托管侧。

Z# 核心层的组件和引擎层的内置反射类型(UObject、FString、SoftObjectPtr、TArray 等)大量使用函数指针进行互操作。 开发者也可以使用函数指针,但需要手写绑定代码。

函数指针的速度非常快,接近于原生调用。 如果参数类型全部都是 blittable 的,还可以使用 [SuppressGCTransition] 调用约定来进一步提升互操作的性能。