昨天写完了 Wasmer PR #489 Su Engine 的实现。这个 PR 的核心功能是对 WebAssembly JIT 编译后代码运行状态的读取、解释和构造。以此为基础,我们可以实现一些有用的功能:
在此之前,除异常处理外,Wasmer 运行环境不会介入用户代码的执行。Su Engine 的设计目的是在几乎不引入性能损耗的前提下,为 WebAssembly 提供完整的托管(managed)运行环境。
「机器状态」的结构
刚才提到,Su Engine 的核心功能是读取、解释和构造 JIT 后代码的运行状态。这就涉及到目标架构机器状态和 WebAssembly 抽象机器状态之间的映射问题。
这里以 x86-64 架构、Singlepass 编译后端为例。
以上是 Singlepass 后端所生成的代码在执行过程中单个函数的机器状态结构,包含栈帧和寄存器内容的语义信息。Su Engine 的实现中,每个关键位置(循环头部、函数头部、call 指令处、可能导致异常的位置)都对应一个机器状态结构的描述。
当 Wasmer 的信号处理函数接收到异常信号时,它会尝试获取当前指令地址所对应的机器状态结构,以这一结构为模板读取和解释异常上下文,然后以返回地址为初始指令地址重复这一过程,直到不存在与其对应的机器状态结构。“解释”的结果就是 WebAssembly 抽象机器状态,包含每个栈帧对应的 Function index、Opcode offset、Locals & stack values。
到此为止,我们实现了运行状态的读取和解释。以此为基础,Backtrace、查看变量等基本的调试功能就可以实现了。
要实现本文开头提到的其他功能,我们还需要另一个方向的映射 - 从抽象机器到目标机器的状态映射,也就是运行状态的“构造”。这基本上是“解释”的逆向过程,没有太多额外的复杂度。
与操作系统交互
Su Engine 需要与操作系统“合作”来高效地管理托管代码的执行过程。需要解决的主要问题包括:
对于第 1 点的解决方案利用了内存保护机制。初始化 VM Context 时,Su Engine 会调用 mmap 分配一个 1 Page 大小的内存块作为“信号内存”。当收到外部中断信号(如 SIGINT)时,这个内存块将被设置为 PROT_NONE 而不可读写。编译后端生成代码时,会在上述关键位置处插入一个对这块信号内存的读访问。这样,外部中断信号最终将触发托管执行线程上的 SIGSEGV/SIGBUS 而被异常处理函数捕获。
第 2 点是利用信号处理函数 undocumented 的第三个 ucontext_t * 参数实现的。这个参数包含了异常的全部上下文信息。需要注意的是,ucontext_t 在 Linux 和 macOS 上的结构并不一致,这也是跨平台复杂性的来源之一。
第 3 点的实现基于类似协程的上下文切换机制,实现位于 lib/runtime-core/image-loading-{linux,macos}-x86-64.s 。
以上这些做法都是特定于 x86-64 Linux/macOS 的。当然,支持其他遵循 POSIX 系统接口和 System V ABI 的 x86-64 平台也应该比较容易。
Misc
Su Engine 中的 "Su" 对应中文“溯”,来源是 “溯洄从之”(《诗经·秦风·蒹葭》)。
演示视频: