Hotdry.

Article

F# 函数式编程实现 Game Boy 模拟器:领域建模与性能优化实战

深度解析 Fame Boy 项目,展示 F# discriminated unions 在 CPU 指令建模中的应用,以及从领域驱动设计到性能调优的完整技术路径。

2026-05-01systems

当我们谈论游戏模拟器实现时,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 条指令。类型系统还提供了额外的安全保障 —— 使用 FromTo 两个独立类型,可以防止「将值存储到立即数」这类非法操作在编译期被捕获。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)

systems