Hotdry.

Article

用 F# 构建 Game Boy 模拟器:函数式思维下的 CPU/PPU 与内存架构实现

基于 F# 函数式特性构建 Game Boy 模拟器,探讨 CPU/PPU 指令映射、内存管理与渲染管线实现

2026-04-30systems

.Game Boy 模拟器一直是计算机硬件爱好者的经典练手项目,但大多数实现都采用 C、C++ 或 Rust 等命令式语言。F# 作为一门以函数式编程见长的 .NET 语言,其不可变性、高阶函数和模式匹配特性为模拟器的模块化设计提供了独特的工程化视角。FunGBC 是一个完全使用 F# 编写的 Game Boy 模拟器项目,它在保证代码可读性与函数式风格的同时,也不得不在性能关键路径上引入必要的有状态 mutation,以达到可玩的运行速度。本文将深入探讨如何在 F# 中构建 CPU 指令映射、PPU 渲染管线与内存管理系统,并给出关键的工程化参数与实现要点。

一、整体架构设计:模块化与总线通信

模拟器的核心架构通常由 CPU、内存总线(Bus)、PPU(图形处理单元)、定时器和输入模块组成。F# 的优势在于可以将每个模块封装为独立的 namespace 或 module,通过显式的类型定义和纯函数来描述硬件行为。以 FunGBC 为例,其项目结构大致分为 CPU、Memory、PPU 和 Cartridge 等子模块,每个模块对外提供清晰的接口。

总线是连接各模块的核心枢纽。在 F# 中,总线通常实现为一个记录类型(Record Type),包含对 CPU 寄存器组、RAM、VRAM、OAM(对象属性内存)以及 I/O 寄存器的可变引用。由于 Game Boy 的内存空间高度碎片化(0x0000-0xFFFF),总线需要根据地址范围将读写请求分发到对应的硬件组件。F# 的模式匹配(match 表达式)在此场景中尤为适合,可以简洁地实现地址解码逻辑。

type Bus = {
    cpuRegisters: CPURegisters
    workRam: byte[]
    videoRam: byte[]
    oam: byte[]
    // ... 其他硬件组件
}

let readByte (bus: Bus) (addr: uint16) =
    match addr with
    | addr when addr < 0x8000u -> bus.cartridge.Read(addr)
    | addr when addr < 0xA000u -> bus.videoRam.[int addr - 0x8000]
    // ... 更多地址范围匹配

这种基于模式匹配的分发机制不仅代码可读性高,而且易于扩展新的内存区域。需要注意的是,F# 的数组访问默认会进行边界检查,在高频的内存读写场景下可能带来性能开销,实际项目中往往需要使用不安全的指针操作或 inline 优化来提升吞吐量。

二、CPU 指令映射:Sharp LR35902 的函数式实现

Game Boy 使用的 Sharp LR35902 处理器本质上是一个 Z80 的精简变体,包含 A、F、B、C、D、E、H、L 八个 8 位寄存器以及 SP(栈指针)和 PC(程序计数器)两个 16 位寄存器。F# 可以使用可变更记录(Mutable Record)来建模寄存器组,同时利用 DU(Discriminated Union)类型来表示标志位。

标志寄存器 F 的四个位(Z、负数 N、半进位 H、进位 C)在每条指令执行后需要精确更新。F# 的计算表达式(Computation Expression)可以在指令执行前后自动处理标志位的状态转换。例如,加法指令需要同时设置 Z 标志(结果为零)、N 标志(清除)、H 标志(低四位产生进位)和 C 标志(高八位产生进位)。通过封装一个 updateFlags 函数,可以将这些逻辑集中管理,避免散落在每条指令的处理代码中。

指令解码是模拟器中最复杂的部分。LR35902 的指令集包含约 256 条操作码(opcode),其中部分操作码还有额外的 CB 前缀扩展。传统实现通常使用巨大的 switch-case 分支,但在 F# 中可以利用函数组合(Function Composition)将每条指令的实现分解为更小的单元。一种工程化的做法是构建一个 Map<opcode, InstructionHandler>,其中 InstructionHandler 是一个函数类型,接受当前 CPU 状态并返回执行后的新状态。这种设计允许通过单元测试独立验证每条指令的行为,而不需要运行完整的模拟器。

指令执行的时间同步也是一个关键点。Game Boy 的 CPU 以约 4.19 MHz 的频率运行,每条指令消耗的周期数(T-states)决定了 PPU 的渲染进度。FunGBC 在这方面的做法是让 CPU 执行完一条指令后,返回该指令消耗的周期数,然后由主循环将这个数值传递给 PPU,用于推进扫描线的渲染状态。

三、PPU 渲染管线:状态机与扫描线同步

Game Boy 的 PPU(Picture Processing Unit)是实现中最具挑战性的部分之一。它负责将 VRAM 中的图块数据转换为 LCD 屏幕上的像素,并处理背景、窗口和精灵(Sprite)三个层次的混合。PPU 的工作时序紧密依赖于 CPU 时钟:每一帧被划分为 144 条可见扫描线和 10 条不可见的 VBlank 扫描线,总共 154 条扫描线。

在 F# 中,PPU 可以建模为一个状态机,其状态包括 OAM 查找模式、VRAM 读取模式和 VBlank 模式。每条扫描线的渲染又可以细分为多个时钟周期(dot),每个 dot 对应一个像素的绘制或背景属性的读取。由于 F# 的不可变性特性,每次 PPU 状态更新都会产生新的 PPU 实例,这在高频调用下可能导致显著的内存分配开销。FunGBC 的解决方案是在 PPU 模块内部使用可变字段(mutable fields)来缓存中间状态,只在每条扫描线完成时返回一个不可变的渲染结果。

图块(Tile)系统是 PPU 的核心。Game Boy 的分辨率是 160×144 像素,每个像素使用 2 位表示四种灰度(暗到亮:3、2、1、0)。VRAM 中存放了两套图块数据(0x8000-0x8FFF 和 0x8800-0x97FF),每套包含 256 个 8×8 的图块。每个图块占 16 字节(8 行 × 2 字节 / 行,高低位组合成 2 位像素值)。F# 的位操作函数(如 <<<、>>>、&&&、|||)可以优雅地处理图块数据的解包。

精灵渲染涉及到 OAM 中每个精灵的属性(X 坐标、Y 坐标、图块编号、颜色 Palette 等),以及优先级规则(当背景与精灵像素颜色值不同时,精灵优先;当相同时,背景优先)。FunGBC 在实现这部分时,需要特别注意精灵与窗口层的遮挡关系,这在某些游戏中是常见的兼容性问题。

四、内存管理:Cartridge 与 MBC 策略

Game Boy 的可插拔卡带(Cartridge)是外部内存的载体,容量从 32KB 到 1MB 不等。大型卡带通常包含内存块控制器(MBC),用于_bank" 切换将不连续的内存区域映射到 CPU 可访问的地址空间。F# 在处理 MBC 时可以采用策略模式:为不同类型的 MBC( MBC1、MBC2、MBC3、MBC5 等)分别实现对应的模块,提供统一的读写接口。

Cartridge 的加载通常发生在模拟器启动时。F# 的文件 IO 可以通过 System.IO.File.ReadAllBytes 读取 ROM 文件,然后根据卡带头(Cartridge Header)中的信息判断 MBC 类型和 ROM/RAM 大小。卡带头的校验和检查(通过所有字节相加后与固定值比较)可以用来验证 ROM 文件的有效性,这在实现健壮的模拟器时是必要的。

五、性能优化:函数式与命令式的平衡

虽然 F# 的函数式特性使得代码结构清晰、便于推理,但模拟器本质上是一个对性能极端敏感的系统。FunGBC 在实践中的经验表明,CPU 指令执行循环和 PPU 渲染循环必须使用可变状态才能达到可玩的帧率。一个折中方案是在核心循环内部使用 F# 的 mutable 关键字或 .NET 的 array 进行原地更新,而在模块接口层面保持函数式的纯度。

另一种常见的优化手段是使用内联(inline)函数来消除高阶函数带来的调用开销。F# 的 inline 关键字可以将小型函数(如位操作、边界检查)编译为内联代码,减少函数调用的栈帧开销。此外,利用 .NET 的 SIMD 指令或 unsafe 指针操作可以进一步加速像素处理,但会牺牲一部分代码的安全性,需要谨慎权衡。

六、工程实践建议

在实际使用 F# 构建 Game Boy 模拟器时,以下参数和阈值可作为工程化参考:CPU 时钟频率设为 4.194304 MHz(PPU 渲染以此为基准);每条指令的周期数需要在 1 到 12 之间精确实现;渲染缓冲区建议使用 160×144 的字节数组,每个字节存储 2 位灰度值;主循环采用 60 Hz 的固定帧率更新,通过 System.Threading.Thread.Sleep 或高精度计时器控制节奏。调试方面,可以利用 F# 的 REPL(FSI)交互式加载模块并单步执行指令,这比传统的调试器更接近函数式的调试体验。

总体而言,F# 为 Game Boy 模拟器提供了一种独特的工程化视角:通过强类型系统保证硬件规格的准确性,通过模式匹配简化指令解码的复杂性,通过模块化设计分离关注点。尽管性能仍然是函数式实现的挑战,但 FunGBC 的成功表明,只要在关键路径上合理引入有状态 mutation,就能构建出既优雅又可玩的模拟器。


参考资料

systems