Flycast WASM JIT:浏览器全速运行Dreamcast模拟器的技术突破

从2 FPS到满帧:Flycast WASM JIT如何实现不可能
六个月前,开发者 nasomers 将上游 Dreamcast 模拟器 Flycast 编译成 WebAssembly,作为 libretro core 运行在浏览器中。结果只能跑到约 2 FPS——一个有趣的概念验证,但完全无法游玩。
而如今,《Jet Set Radio》《莎木》这类重度 3D 大作,能够在普通硬件的浏览器标签页里以原生分辨率稳定保持目标帧率,音频清晰无卡顿。开发者近日在 Reddit 的 r/emulation 社区发布了 Flycast WASM JIT v1,并附带了一篇极具技术含量的实现说明。

WebAssembly架构为何让动态重编译"不可能"
要理解这个项目的难度,需要先明白 Dreamcast 模拟器全速运行的核心依赖:动态重编译器(dynarec)。dynarec 在运行时生成原生机器码并直接跳转执行,是所有高性能模拟器的命脉。
Dreamcast硬件与dynarec的必要性
Dreamcast 搭载的 SH4 处理器是日立(现 Renesas)设计的 SuperH 系列 RISC 处理器,运行频率 200MHz,具有独特的 16 位压缩指令集和浮点向量单元。由于 SH4 的指令密度高、流水线行为复杂,纯解释执行的开销极大——每条客户机指令需要数十甚至上百条宿主指令来模拟。动态重编译器通过将 SH4 指令块一次性翻译为宿主机的原生代码(如 x86-64 或 ARM64),实现接近 1:1 的指令比率,这是 Dreamcast 模拟从学术原型走向可游玩状态的关键转折点。Flycast 的上游实现支持 x86、ARM 和 RISC-V 等多个后端,但所有后端都依赖同一个前提:能够在运行时向可执行内存页写入机器码。
然而,WebAssembly 的沙盒架构从结构上封堵了这条路径:
- 代码与数据处于独立地址空间,不存在既可写又可执行的内存区域;
- 写入线性内存(linear memory)的任何内容,永远无法被当作代码执行。
WebAssembly的哈佛架构本质
WebAssembly 的内存模型本质上是一种修改过的哈佛架构:代码段(存放已验证的 WASM 函数字节码)和数据段(线性内存,即一块连续的可寻址字节数组)被严格隔离。线性内存的增长通过 memory.grow 指令进行,且只能作为数据读写,永远不会进入执行管线。WASM 模块中的所有可执行函数必须在模块实例化前经过验证(type-checking、stack validation),浏览器引擎随后才会对其进行基线编译或优化编译(如 V8 的 Liftoff 和 TurboFan)。这种设计是 Web 安全模型的基石——它阻止了任意代码执行漏洞,但同时也让传统 JIT 的 mmap + mprotect 模式完全失效。
传统 dynarec 依赖的"写入内存 → 跳转执行"路径在 WASM 里被彻底堵死。上游 Flycast 项目此前已明确拒绝支持 WASM,社区的共识也是:浏览器里的 Dreamcast 注定只能是一张幻灯片。
核心突破:运行时生成完整WASM模块替代内存修补
这个 JIT 的巧妙之处在于不修补内存,而是在运行时生成完整的 WebAssembly 模块。整个执行流程如下:
- SH4 处理器代码被解码为 Flycast 的中间表示 SHIL IR;
- IR 在浏览器中被降级(lower)为 WASM 字节码;
- 通过 WASM API 完成编译与实例化;
- 由 C 语言的调度循环通过
call_indirect进行分发。
SHIL IR:解耦前端与后端的关键抽象层
SHIL(SuperH Intermediate Language)是 Flycast 内部定义的中间表示层,它将 SH4 的原始指令解码为与宿主无关的操作序列。SHIL IR 类似于 LLVM IR 或 GCC 的 GIMPLE,但更为轻量——它保留了 SH4 的寄存器语义和内存模型,同时抽象掉了编码细节。在传统 Flycast 中,SHIL IR 被后端直接降级为 x86 或 ARM 机器码;而在 WASM JIT 中,这一层成为关键的解耦点——相同的 IR 现在被降级为 WASM 二进制格式的函数体,包括局部变量声明、操作码序列和控制流结构。这意味着开发者不需要重写前端的指令解码逻辑,只需实现一个新的后端发射器。
call_indirect与WASM表:零JS开销的调度机制
call_indirect 是 WebAssembly 的间接函数调用指令,它通过查询一个类型化的函数表(table)来动态分发调用目标。在 Flycast WASM JIT 中,每个编译好的 SH4 代码块被注册到 WASM 表的一个槽位中,调度循环根据客户机 PC 值计算表索引后执行 call_indirect。由于调用方和被调用方都是同一 WASM 实例中的函数,这种调用不会触发 JavaScript 引擎的跨语言边界开销。V8 和 SpiderMonkey 等引擎对 call_indirect 有专门的内联缓存优化,使其性能接近直接调用。
关键设计点:跨越宿主边界(host boundary)只在编译时发生一次。稳态执行阶段完全是 WASM 调用 WASM,调度路径上没有任何 JavaScript 参与。这一架构避开了 JS/WASM 边界频繁往返带来的性能灾难,是整个方案能全速运行的根基。
性能优化:从能跑到全速的关键技术
让模拟器跑起来只是起点,真正的挑战是达到可游玩的帧率。开发者记录了每一次优化的具体数据。
自修改代码检测优化
通过**基于内存页的代次计数器(generation counter)**检测自修改代码,直接消除了运行时 98% 的代码块哈希计算开销。
内存访问内联快路径
这是最关键的性能瓶颈。Dreamcast 的内存映射极为复杂:4GB 虚拟地址空间中包含主内存、显存、SH4 片上寄存器、AICA 音频寄存器、PowerVR 图形寄存器等多个区域,不同区域有不同的访问副作用。
优化前,客户机(guest)内存操作需要跨越 WASM → JS 的 import 边界来进行地址解码和分发,每帧高达约 50 万次。这种 import 调用在 V8 中的开销约为数十纳秒——看似微小,但当每帧发生 50 万次时,仅边界穿越就消耗了大量 CPU 周期。
经过内联快路径改造后降至每帧约 700 次——三个数量级的削减,是帧率飞跃的核心来源。内联快路径的做法是:在 WASM 字节码生成阶段直接将最常见的地址范围(如主内存 0x0C000000-0x0CFFFFFF)编码为直接的线性内存 load/store 指令,仅在地址落入 IO 寄存器等需要副作用处理的范围时才回退到慢路径。
其他关键优化策略
- 热点块融合:将频繁执行的代码块合并为多块模块,减少调度开销;
- 帧节拍器(frame pacer):以"偿还客户机时间"而非单纯计数渲染帧的方式控制节奏;
- 编译风暴管理:运行时生成并实例化 WASM 模块涉及非平凡的开销——浏览器需要对字节码进行验证、基线编译(生成未优化的机器码)、以及后续的分层优化编译。当场景切换导致大量新代码块同时需要编译时,累积延迟可能达到数百毫秒。管理策略包括限制单帧内的最大编译数量、对尚未编译完成的块回退到解释执行、以及利用异步编译特性将编译分散到多个帧中,确保场景切换时不会因大量即时编译而卡死;
- AudioWorklet 环形缓冲:配合基于节奏的速率控制,实现干净的音频输出。AudioWorklet 运行在独立的音频渲染线程上,与主线程解耦,通过 SharedArrayBuffer 构建的环形缓冲区实现无锁数据传递,避免了传统 ScriptProcessorNode 的主线程阻塞问题。
差分测试方法论:模拟器开发者最值得借鉴的经验
整个 JIT 通过差分测试框架(differential test harness)对照未修改的参考解释器进行验证。数十万次块级状态比对的结果是零偏差(zero divergence)。
差分测试在编译器验证中的地位
差分测试(differential testing/fuzzing)是编译器和代码生成领域最强大的正确性验证技术之一。其核心思想是:对于同一输入,两个语义等价的实现应产生完全相同的输出。在 Flycast 的场景中,参考解释器逐条执行 SH4 指令并记录每个基本块结束时的完整 CPU 状态(32 个通用寄存器、FPU 寄存器、状态寄存器、内存校验和等),JIT 执行相同的代码块后也导出同样格式的状态快照,任何位级差异都意味着 JIT 后端存在 bug。这种方法的优势在于它将调试从"游戏画面看起来不对"的模糊问题,转化为"第 N 个块执行后寄存器 R7 的值偏差了 1 位"的精确定位,配合二分法可以快速缩小到具体的 IR 指令降级错误。
作者提出了一个关键观点:
"独自构建一个正确的 dynarec,本质上是一个测试问题,而非代码生成问题。"
一旦测试框架建立,每一个 bug 都从"神秘难题"变成可以二分定位(bisect)的确定性问题。这套方法论具有普遍价值——只要有可用的参考解释器可供比对,就适用于任何目标平台的移植与动态重编译工作。
项目现状与未来路线图
该项目已在 GitHub 开源(GPLv2),仓库包含:
- 已构建好的 core,可直接加载进基于浏览器的 libretro 前端;
- 完整源码,以针对上游 Flycast 的补丁形式提供;
- 详尽的技术说明文档,按架构突破顺序讲解;
- 针对唯一大型缺失功能的完整实现路线图。
libretro架构简介
libretro 是一个跨平台的模拟器/游戏引擎抽象层 API,它将"核心"(core,即模拟器引擎)与"前端"(负责输入、音视频输出、UI)解耦。RetroArch 是最知名的 libretro 前端,而基于浏览器的前端(如 RetroArch Web Player)则通过 Emscripten 将 C/C++ 前端编译为 WASM 运行。在这种架构下,Flycast 作为一个 .so/.dll 变成了一个 .wasm 模块,通过标准化的 retro_run() 等回调与前端交互。这种插件化设计使得 JIT 优化可以独立于前端进行,用户只需替换 core 文件即可获得性能提升。
项目不包含也不托管任何 BIOS 或游戏,用户需自备合法转储的固件与游戏文件。
当前局限与后续计划
Windows CE 游戏尚不支持,因为它们需要 JIT 中完整的 SH4 MMU 支持。Dreamcast 的部分游戏(如《Resident Evil 2》《Half-Life》等 PC 移植作品)运行在微软提供的 Windows CE 运行时之上,这些游戏启用了 SH4 的虚拟内存管理单元(MMU),涉及页表遍历、TLB 缓存和地址翻译异常处理等复杂机制。在 JIT 中支持 MMU 意味着每次内存访问都需要额外的地址翻译步骤,这与前述的内联快路径优化存在冲突,需要精心设计才能兼顾性能与正确性。这是开发者的下一个目标,分阶段计划与根因分析已经发布,欢迎社区贡献者参与。
总结:受限环境中动态重编译的新范式
Flycast WASM JIT 的意义不仅在于"浏览器里跑 Dreamcast"这个成果,更在于它展示了一条在受限执行环境(如 WASM 沙盒)中实现高性能动态重编译的可行路径,以及一套以差分测试驱动正确性的工程方法论。这个被上游拒绝、被社区认定为死路的方向,最终被一个开发者用六个月证明是可行的。
从更广泛的视角看,这种"运行时生成模块而非修补内存"的策略,对其他同样面临 W^X(Write XOR Execute)约束的平台具有启发意义——例如 iOS 上的 JIT(在非越狱设备上同样无法分配可执行内存)、以及未来可能出现的更严格的硬件安全架构。Flycast WASM JIT 证明了:只要找到正确的抽象层级,沙盒的安全约束与运行时代码生成的性能需求并非不可调和。
对于模拟器乃至更广泛的运行时代码生成领域,这都是一个值得深入研究的案例。
致敬:Flycast 是 flyinghead 的作品,本项目建立在其代码库与参考解释器之上;WASM 移植与 JIT 部分为 nasomers 的独立工作。
相关推荐

Hugging Face工程师用AI Agent自动化团队工作全流程实战
Hugging Face机器学习工程师Niels分享如何用AI Agent自动化Community Science Team的核心工作,从确定性Workflow到自主Agent的架构演进,涵盖技术栈选择、部署方案与真实成效。

微调Qwen3-4B实录:100条数据解决角色混乱问题
分享Qwen3-4B模型微调实践全过程,通过补充100-200条身份稳定数据,成功解决角色混乱问题。详解小模型微调中的数据策略、效果验证方法及MoE架构进阶规划。

Memorex Code开源:给Coding Agent装上长期记忆
Memorex Code是一套开源的Coding Agent长期记忆系统,解决AI编程助手跨会话记忆丢失问题。支持自动记忆召回、去重裁剪、本地代码库扫描,让项目知识和开发经验持久沉淀,兼容Cursor、Claude Code等主流工具。