当我们谈论游戏模拟器实现时,Rust 和 C++ 往往是首选语言。然而,Nick Kossolapov 用 F# 构建了一个完整的 Game Boy 模拟器 Fame Boy,在 Hacker News 获得高分关注。这个项目不仅实现了可玩的模拟器(桌面端 1400-2500 FPS,Web 端 600-1000 FPS),更展示了函数式编程在系统级开发中的独特工程价值。
核心架构:组件分离与时钟同步
Game Boy 的硬件由多个并行运行的子系统构成:CPU、内存、PPU(图形处理单元)、APU(音频处理单元)、定时器和手柄。在真实硬件中,这些组件都围绕一个主时钟振荡器同步运行。Fame Boy 通过一个集中的 stepper 函数模拟这种行为:
let stepper () =
// 执行单条 CPU 指令,返回消耗的周期数
let mCycles = stepCpu cpu io
// 每个 M-cycle 内同步其他组件
for _ in 1..mCycles do
stepTimers timer io
stepSerial serial io
stepApu apu // APU 实际以 4 倍 CPU 频率运行,可批量处理
let tCycles = mCycles * 4
// PPU 以 4 倍 CPU 周期运行
for _ in 1..tCycles do
stepPpu ppu
mCycles
这种设计确保了所有组件严格同步。Game Boy 运行于约 4.2 MHz(1048576 Hz),每帧(60 FPS)需要约 17500 个 CPU 周期。前端通过音频采样率驱动模拟器(有声音时)或帧率驱动(静音时),实现流畅的游戏体验。
F# 领域建模:指令集的类型化抽象
Fame Boy 最具特色的部分是对 CPU 指令的函数式建模。Game Boy 使用 Sharp LR35902 处理器(类似 Z80),共有 512 个操作码。Nick 遵循 Gekkio 的技术参考文档,将指令分组并用 F# 的 discriminated unions(DU)建模:
type LoadInstr =
| Load8Immediate of uint8
| Load8Direct of Register
| Load8Indirect
// ... 其他加载指令
type ArithmeticInstr =
| IncrementDirect of uint8
| IncrementIndirect of Register
// ... 其他算术指令
进一步抽象后,他提取了操作数的「位置」概念:
type To =
| Direct of Register
| Indirect
type From =
| Immediate of uint8
| Direct of Register
| Indirect
type LoadInstr =
| Load of From * To
通过这种建模,512 个操作码被精简为 58 条指令。类型系统还提供了额外的安全保障 —— 使用 From 和 To 两个独立类型,可以防止「将值存储到立即数」这类非法操作在编译期被捕获。Nick 坦言唯一的小瑕疵是 opcode 0x76(被映射为 HALT),技术上允许 Load(From.Indirect, To.Indirect),但这在硬件上等价于 NOP,不会造成实际问题。
标志位处理:组合函数的力量
在实现 CPU 标志位时,Nick 经历了一次重构。最初他使用数组和 DU 类型传递标志:
cpu.setFlags [ Half, false; Zero, a = 0uy ]
最终他找到了更优雅的解决方案 —— 组合纯函数:
module Flags =
let inline setZ (v: bool) (f: uint8) =
if v then f ||| ZMask else f &&& ~~~ZMask
let inline setH (v: bool) (f: uint8) =
// ... 其他标志函数
// 使用时
cpu.Flags <-
cpu.Flags
|> setH false
|> setZ (a = 0uy)
这些函数是内联的,无需堆分配,可测试性极强。这次重构将模拟器性能提升了约 10%。Nick 表示:「这可能是我写过的最满意的 F# 代码。」
性能优化:从领域模型到实际性能
Fame Boy 的性能调优过程揭示了函数式代码与性能之间的权衡。
内存访问的 DU 陷阱:最初使用 DU 表示内存区域,每次读写都创建新对象。分析器显示 mapAddress 占用了异常高的 CPU 时间。解决方案是直接访问数组,这次改动将帧率翻倍。后续测试表明,85% 的性能提升来自移除 DU 和分支,只有 15% 来自使用结构体 DU(栈分配)。
调试模式的代价:从调试模式切换到发布模式后,性能从约 120 FPS 跃升至 1000 FPS 以上 —— 这是最简单却最有效的优化。
AI 辅助优化:项目后期,Nick 使用 AI 扫描代码寻找优化机会,发现了 STAT 寄存器更新策略的改进(仅在模式 / LY 转换时更新),使部分基准测试性能提升超过一倍。不过这也引入了回归 bug,需要后续修复。
基准测试结果:
| 平台 | Flag ROM | Roboto ROM | Merken ROM |
|---|---|---|---|
| Ryzen 9 7900 (桌面) | 1785 FPS | 1943 FPS | 1422 FPS |
| Apple M4 (桌面) | 1907 FPS | 2508 FPS | 1700 FPS |
| Ryzen 9 7900 (Web) | 646 FPS | 883 FPS | 892 FPS |
| Apple M4 (Web) | 779 FPS | 976 FPS | 972 FPS |
有趣的是,APU(声音)对性能的影响超过 PPU。禁用 PPU 提升约 250 FPS,而禁用 APU 提升约 500 FPS。
跨平台部署:Fable 的力量
通过 Fable(F# 到 JavaScript 的编译器),Nick 实现了代码零修改的 Web 部署。Web 包仅约 100 KB,不依赖 .NET 运行时。
他遇到了一个有趣的 JavaScript 互操作问题:Fable 将 F# 的 uint8 编译为 JavaScript 数值时,未做截断处理,导致 8 位寄存器值出现溢出(如显示 -15565461 而非 95)。查阅 Fable 文档后,他在所有 8 位值操作处添加了显式截断,问题得以解决。
技术启示
Fame Boy 展示了函数式编程在系统级开发中的可行性。F# 的类型系统非常适合建模硬件指令集,DU 和模式匹配提供了比传统 switch 语句更安全的抽象。然而,为了性能,模拟器大量使用可变状态 ——Nick 坦言「对函数式编程纯粹主义者道歉」。
最终,这个项目证明了学习动机(理解计算机如何工作)加上合适的工具,可以产出高质量的工程作品。Nick 在「计时器冬季」中花费超过 20 小时调试一个 bug(定时器应按周期而非按指令调用),最终被 Claude Opus 几分钟内修复 —— 这是 AI 辅助学习的典型案例。
资料来源:Fame Boy 项目博客(nickkossolapov.github.io/fame-boy)