DotNetIsolator源码剖析:ShadowStack与C原生层如何实现跨边界方法调用
【免费下载链接】DotNetIsolatorA library for running isolated .NET runtimes inside .NET项目地址: https://gitcode.com/gh_mirrors/do/DotNetIsolator
DotNetIsolator 是一款用于在 .NET 进程内运行“隔离 .NET 运行时”的开源库,其核心价值在于:宿主程序借助 Wasmtime 加载一个携带 Mono 运行时内核的 Wasm 模块,从而在隔离沙箱中执行任意 .NET 代码。本文的 DotNetIsolator 源码剖析将聚焦两个最关键的技术点——ShadowStack 参数栈与C 原生互操作层,为你彻底讲清宿主与隔离运行时之间的跨边界方法调用究竟是如何打通、传递数据、再返回结果的。
一条调用链的全景:宿主、Wasm 与 Mono 的三角关系
在深入代码之前,先建立整体地图。整个项目由三个工程组成:
src/DotNetIsolator/:宿主侧 API,负责创建运行时、实例化对象、查找并调用方法;src/DotNetIsolator.WasmApp/:被编译为 Wasm 的“运行时容器”,内部运行 Mono 运行时内核;src/DotNetIsolator.Guest/:供隔离代码引用的“客人库”,用于反向回调宿主。
跨边界方法调用有两条方向完全相反的通道:
- 宿主 → 隔离运行时:宿主序列化参数,写入 Wasm 线性内存,再调用 Wasm 导出函数(如
dotnetisolator_invoke_method)驱动 C 层执行; - 隔离运行时 → 宿主:客人代码通过 internal call 触发
call_host导入函数,把请求交还给宿主进程。
两条通道中间,都少不了 ShadowStack 这块"共用内存缓冲区"。
ShadowStack 源码解析:一块 8KB 参数缓冲区的妙用
先看宿主侧最容易被忽视却非常巧妙的设计——src/DotNetIsolator/ShadowStack.cs。
const int Length = 8 * 1024; // 只用于传递少量参数,所以 8KB 绰绰有余它在 Wasm 线性内存里一次性malloc了 8KB 空间,此后所有需要"传给客端的小型标量参数"都从这块空间里按需压栈、出栈:
Push<T>()按类型大小前进栈指针,返回一个带地址的ShadowStackEntry<T>;Pop<T>(expectedAddress)回退指针,并校验 push/pop 是否配对,防止内存错乱;Dispose()时一次性释放整块内存。
它最典型的用途是传递错误信息输出参数。以IsolatedRuntime.CreateObject为例(见src/DotNetIsolator/IsolatedRuntime.cs):
var errorMessageParam = _shadowStack.Push<int>(); var gcHandle = _instantiateDotNetClass( CopyValue(assemblyName), CopyValue(@namespace), CopyValue(monoClassName), errorMessageParam.Address); if (errorMessageParam.Value != 0) { /* 读取并抛出 IsolatedException */ } finally { errorMessageParam.Pop(); }宿主把errorMessageParam.Address传给 C 函数,C 层出错时用asprintf把错误字符串指针写进这个槽位;宿主再通过ReadNullTerminatedString读回并抛出IsolatedException。整个过程零额外分配,这正是 ShadowStack 存在的意义——高频的小参数传递不值得反复 malloc/free。
为什么不用宿主侧托管堆?
因为宿主与客端运行在不同的地址空间里,唯一共享的就是 Wasm 线性内存。ShadowStack 相当于在共享内存里划出的"临时通信区",配合ShadowStackEntry<T>(见src/DotNetIsolator/ShadowStackEntry.cs)用ref直接读写,性能极佳。
宿主到隔离运行时:Invocation 结构体如何驱动 C 原生层
调用一个隔离方法的主路径位于IsolatedMethod.Invoke(src/DotNetIsolator/IsolatedMethod.cs),其流程分四步:
- 参数序列化:用
MessagePackSerializer.Typeless.Serialize把每个参数编码为字节数组; - 写入共享内存:
CopyValueLengthPrefixed在 Wasm 内存中 malloc 一块"长度前缀 + 数据"的缓冲区,返回地址; - 构造 Invocation 结构体:
IsolatedRuntime.InvokeDotNetMethod把目标 GCHandle、方法指针、参数地址数组组装成一个Invocation结构体,写入共享内存; - 调用导出函数:执行
_invokeDotNetMethod(wasmPtr),即 Wasm 导出函数dotnetisolator_invoke_method。
C 原生层如何执行这次调用
进入src/DotNetIsolator.WasmApp/native/dotnetisolate.c,你会看到RunnerInvocation结构体与宿主侧的Invocation逐字段对应。C 层做了三件关键事:
- 反序列化参数:
deserialize_param调用mono_wasm_invoke_method去执行客人代码里的Serialization.Deserialize(见src/DotNetIsolator.WasmApp/Serialization.cs),把字节还原成 Mono 对象; - 固定与拆箱:对还原出的对象创建 pinned GCHandle,若是值类型则
mono_object_unbox拆箱,保证调用期间对象不被 GC 移动; - 真正的方法调用:
mono_wasm_invoke_method(invocation->method_ptr, target, method_params, &exc)完成执行,随后serialize_return_value把返回值序列化回字节数组,连同长度、GCHandle 一起写回RunnerInvocation。
宿主侧拿到结果后,用MessagePackSerializer.Deserialize<TRes>还原返回值,并调用ReleaseGCHandle释放客端句柄,一次跨边界调用就此闭环。
值得注意的安全设计:宿主反序列化返回值时故意不用 Typeless 模式,只允许实例化
TRes类型图中静态定义的类型,防止不可信的客人代码诱导宿主创建任意类型。
隔离运行时到宿主:internal call 与 call_host 的逆向通道
跨边界调用不是单向的。客人代码经常需要回调宿主,例如输出日志、读取配置。这条逆向通道的起点在src/DotNetIsolator.Guest/DotNetIsolatorHost.cs:
- 客人调用
DotNetIsolatorHost.Invoke,把回调名与参数序列化成GuestToHostCall(见src/DotNetIsolator.Guest/GuestToHostCall.cs); - 通过
fixed拿到字节数组指针,调用Interop.CallHost; Interop.CallHost是一个InternalCall声明(src/DotNetIsolator.Guest/Interop.cs),它没有托管实现,由 C 层接管。
C 层把 InternalCall 接到 Wasm 导入函数
src/DotNetIsolator.WasmApp/native/host_callback.c只有寥寥几行,却承担了承上启下的作用:
mono_add_internal_call("DotNetIsolator.Guest.Interop::CallHost", dotnetisolator_call_host);它把托管的CallHost映射到dotnetisolator_call_host——这个函数带import_module("dotnetisolator")与import_name("call_host")属性,意味着它由宿主进程实现并注入。对应地,IsolatedRuntimeHost(src/DotNetIsolator/IsolatedRuntimeHost.cs)在启动时用 Wasmtime 的Linker.DefineFunction("dotnetisolator", "call_host", ...)注册了实现,最终路由到IsolatedRuntime.AcceptCallFromGuest:
- 反序列化
GuestToHostCall,按回调名在_registeredCallbacks字典中查找委托; - 逐个参数反序列化后
DynamicInvoke执行宿主回调; - 把返回值序列化写入共享内存,返回成功/失败标志。
有趣的是,宿主回调出现异常时,出于安全考虑并不会把原始异常信息暴露给不可信的客人代码,而是统一返回 "The call failed. See host console logs for details.",只在宿主控制台打印完整堆栈。
程序集加载同样走这条导入通道
隔离运行时要执行任意程序集,还依赖另一个导入函数request_assembly。src/DotNetIsolator.WasmApp/native/assembly_search_hook.c安装了 Mono 的装配搜索钩子,当 Mono 需要加载程序集时,通过request_assembly向宿主索要 DLL 字节,宿主用WithAssemblyLoader注册的加载器返回字节流,再由mono_image_open_from_data加载进 Mono 运行时。注意钩子内部用assembly_search_hook_in_progress标志防止递归死循环。
参数序列化与 GCHandle:跨边界的数据生命线
贯穿两条通道的还有两条"生命线":
- MessagePack 序列化:所有非平凡参数、返回值都以字节流形式穿越边界。宿主侧使用受限的 Contractless 解析器,客人侧则用 Typeless 模式,灵活性与安全性各取所需;
- GCHandle 引用:隔离对象在客端用
MonoGCHandle表示,宿主持有的IsolatedObject本质上只是一个句柄整数。dotnetisolator_release_object负责回收,IsolatedRuntime.Dispose时统一释放 ShadowStack 与 Store,确保不留泄漏。
值得一提的还有src/DotNetIsolator.WasmApp/Program.cs:它故意做成空入口,只在预初始化阶段"预热"序列化代码路径,真正的业务执行全部由宿主在运行时注入程序集驱动——这正体现了"隔离运行时"的设计哲学。
总结:一次调用的完整旅程
把整条链路串起来,一次宿主 → 隔离运行时的跨边界方法调用是这样的:
- 宿主用 MessagePack 序列化参数,写入 Wasm 共享内存;
- 组装
Invocation结构体,调用 Wasm 导出dotnetisolator_invoke_method; - C 原生层反序列化参数、固定 GCHandle、执行 Mono 方法、序列化返回值;
- 宿主读取结果、反序列化、释放句柄——完成闭环。
而 ShadowStack 这块 8KB 的"通信缓冲区"则在每一环的出错处理、小型参数传递中默默发挥作用,避免了海量的小内存分配。理解了这两块设计,你就掌握了 DotNetIsolator 跨边界方法调用的完整心智模型,无论是二次开发还是学习 WebAssembly 与 .NET 互操作,都能事半功倍。
【免费下载链接】DotNetIsolatorA library for running isolated .NET runtimes inside .NET项目地址: https://gitcode.com/gh_mirrors/do/DotNetIsolator
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考