技术博客

C# 支持:从 Mono 嵌入宿主到 .NET 6+ 的迁移

第 87 / 149 篇 · 2026-01-18 · godot · C#.NET模块机制源码分析

Godot 的 C# 支持在 3.x 和 4.x 之间经历了一次很少有人注意的换底:用户看到的都是"写 C# 脚本",底下从"把 Mono 运行时嵌进引擎进程"换成了"用标准 .NET hosting API 启动 CoreCLR"。这个决定顺带解锁了 C# 热重载和 iOS AOT 两条路,这篇把两种方案的调用链逐层拆开看。

3.x:Mono 嵌入宿主

3.x 的实现在 modules/mono。引擎把 libmono 链进二进制,用 Mono 的 embedding API 直接操作运行时:创建 domain、加载程序集、用 mono_runtime_invoke() 调用托管方法。每个脚本类的入口(_Ready、_Process 这些虚函数)都注册成一个 MonoMethod 指针,引擎每帧按需 invoke。

这套方案有一个沉重的附属品:胶水代码生成。托管层不能直接碰引擎的 C++ 类,于是构建时跑一个 glue generator,给 core/object 里的每个类生成一份 C# 包装(godot_api 那一堆 Object.Types.cs)。生成的包装类持有指向原生对象的 IntPtr,每个方法展开成 mono_icall 调用。胶水层的问题不在正确性,在于它和引擎版本硬耦合——每次引擎 API 变动都要重新生成、重新编译,升级成本极高,也限制了社区给非 Mono 后端做绑定的可能。

4.0:标准 hosting,不再嵌入

4.0 起整个 mono 模块被移除,换成新的 modules/mono(名字没变,内容全换)。新方案用 .NET 官方的 hosting 三部曲:

// modules/mono/godotsharp_dirs.cpp 附近,简化
void *hostfxr = load_hostfxr();           // 通过 nethot 定位
hostfxr_initialize_for_runtime_config(config_path, params, &context);
hostfxr_get_runtime_delegate(context, hdt_load_assembly, &load_assembly);
load_assembly("Godot.Bridge.dll", ...);   // 加载托管入口

关键变化有三个:

不再持有运行时所有权。引擎只是"启动并寄宿" CoreCLR,进程里跑的是标准 .NET 6+ 运行时,行为和在系统上 dotnet run 一个程序一致。调试、profiler(dotnet-counters)、崩溃转储全部用标准工具链,3.x 时代那些 mono 专用的排障手段可以全扔了。

可卸载的 AssemblyLoadContext。托管程序集加载进一个 collectible 的 ALC,AssemblyLoadContext.Unload() 之后连同其中所有类型被 GC 回收。这是 4.2 C# 热重载的技术前提:编辑器里保存脚本 → 编译出新程序集 → 新 ALC 加载 → 旧 ALC 标脏卸载。3.x 的 Mono 嵌入做不到安全的程序集卸载,热重载在 3.x 只存在于 GDScript。

绑定层换成 GDExtension 风格。托管代码调引擎不再走 mono_icall,而是走 GDExtension 的 C 函数表(GDExtensionInterface)。这对 ABI 稳定性的要求和原生 GDExtension 完全一致——引擎升级不破坏已有托管程序集的二进制兼容,胶水代码从"构建时生成"变成了"随 .NET SDK 分发的 NuGet 包"。

marshaling 成本到底在哪

C# 调引擎 API 的边界成本和 GDScript 那篇的三层税是同一个坐标系,但 C# 明显便宜,原因是类型传输按内存布局分级:

C# 侧类型 传输方式 边界成本
Vector3、Transform2D 等 blittable 结构体 按字节布局直接拷贝,12 字节一个寄存器对 接近 C++ 函数调用
string UTF-16 ↔ UTF-32 转换,每次分配 中,含一次堆分配
GodotObject 及子类 只搬 IntPtr 句柄,包装类按句柄缓存 低
Variant 任意值 装箱为 24 字节结构 + 类型标签判断 高

写 C# 的性能原则因此很直白:热路径上传结构体,不传字符串和 GodotObject 包装。一次 Position 属性读写是两个 blittable 拷贝,和 C++ 没差别;一次 GetNode("Path/To/Node") 是字符串转换加路径解析,每次调用的成本能差出两个数量级。实测过一个每帧 300 次节点属性读写的场景,全部换成缓存的结构体后,C# 版本的帧时间和 GDExtension C++ 版本打平,差距全在字符串路径上。

还没解决的:iOS 与 AOT

CoreCLR 的 JIT 在 iOS 上被平台禁止,4.x 的 iOS 导出用 .NET AOT(Mono AOT 也是选项之一)。AOT 的限制是反射和泛型实例化空间在编译期封闭,部分依赖运行时代码生成的库(某些序列化器)会踩坑。桌面平台没有这个问题,.NET 6+ 的 JIT 质量足够好,纯 C# 数值代码和原生 C++ 的差距在 1.x 倍以内。

一个遗留的权衡值得记住:托管启动比 GDScript 贵。hostfxr 初始化加程序集加载在桌面冷启动要 100~200ms,启动敏感的轻量小游戏(工具类、game jam 作品)用 GDScript 反而整体更快——宿主方案没有对错,只有和被寄宿者是否匹配。

两种宿主方案的调用链对比 3.x:Mono 嵌入宿主 引擎 C++(modules/mono) mono_runtime_invoke 逐方法调托管 嵌入的 libmono 运行时 胶水代码构建期生成,与引擎版本硬耦合 程序集无法安全卸载,热重载无门 4.x:标准 .NET hosting 引擎 C++(modules/mono) nethost → hostfxr → 加载 CoreCLR collectible ALC 中的 Godot.Bridge 绑定走 GDExtension C 函数表,ABI 稳定 Unload() 可回收程序集,热重载成立 marshaling 分级:blittable 结构体按内存布局直传 ≈ 原生调用; string 每过一次边界一次 UTF-16↔UTF-32 转换;GodotObject 只搬 IntPtr 句柄 代价:冷启动 +100~200ms;iOS 走 AOT,反射受限