资讯动态

FC模拟器DIY:ROM加载、内存映射与Mapper实现全解析

发布时间:2026/9/8 4:42:29 来源:尧图企业网站定制
终于写到这个系列的第四篇了。前几篇我拉着大家把6502核心调到能跑完指令集测试、PPU渲染出第一帧画面、APU也勉强有了声音但一直有个问题悬着游戏ROM到底是怎么被模拟器吃进去的现在轮到这块最难啃的骨头了——ROM加载、内存映射和Mapper。这也是整个FC模拟器DIY里最绕的一层很多人不是CPU写不出来而是卡在“游戏卡带到底把什么数据放在了哪里CPU和PPU又是怎么读到它们的”。如果你已经写好了CPU和PPU的基本框架却一加载游戏就白屏、花屏、闪退那这篇文章就是为你准备的。顺着我自己的调试历程把ROM文件结构、地址总线、bank切换这几个硬骨头掰开揉碎讲清楚。1. 从ROM文件开始先搞懂卡带里装了什么模拟器吃掉ROM的第一步是搞清楚“游戏卡带”这个文件到底长什么样。早期FC游戏卡带里其实就两样东西程序代码PRG ROM和图形数据CHR ROM。PRG就是6502 CPU要执行的机器码卡带被读取后它被映射到CPU的地址空间CHR则是一张张8x8像素的图形块数据PPU渲染时直接从里面取。你可以把PRG理解成“代码文件夹”CHR理解成“素材包”两者缺一不可。网上流传的.nes格式ROM就是这两种数据再加一个文件头打包出来的。所以模拟器加载ROM第一步永远是解析文件头。1.1 iNES文件头128字节里的秘密iNES格式在文件最前面固定放16字节128位的文件头之后紧跟着PRG数据再往后是CHR数据。很多新手直接跳过文件头、把整个文件当PRG读进内存结果CPU执行了一堆垃圾指令——这就是白屏的常见原因之一。文件头的结构用表格看就非常清楚偏移长度内容说明0x004固定magic必须是 4E 45 53 1A0x041PRG ROM大小单位是16KB0x051CHR ROM大小单位是8KB0x061flags6Mapper低4位、镜像位等0x071flags7Mapper高4位、平台标志0x081PRG RAM大小常见为0或10x091TV制式0为NTSC1为PAL0x0A-0x0F6保留通常全0解析这个头时有一个特别容易踩的坑C语言结构体字节对齐。如果直接用fread读入一个没有加__attribute__((packed))或#pragma pack(push, 1)的struct编译器会在字段之间填充空白字节导致整个文件头偏移量错乱。我第一次做的时候就因为忘了处理对齐读出来的PRG大小变成了256KB程序直接分配失败崩溃。正确的做法是强制1字节对齐。以C语言为例可以直接定义一个紧凑结构体#pragma pack(push, 1) typedef struct { uint8_t magic[4]; uint8_t prg_size; uint8_t chr_size; uint8_t flags6; uint8_t flags7; uint8_t prg_ram_size; uint8_t tv_system; uint8_t reserved[6]; } INesHeader; #pragma pack(pop)然后是magic校验。前四个字节必须是NES\x1a即十六进制的4E 45 53 1A。这个校验能帮你挡掉大量损坏文件也解释了为什么有些游戏ROM用记事本打开会看到“NES”字样。1.2 文件加载的完整流程解析完头就要把PRG和CHR分别读入内存。这里有一个重要概念文件头里记录的PRG大小和CHR大小都不是字节数而是“16KB的单位数”和“8KB的单位数”。比如prg_size2表示有2×16KB32KB的PRG数据chr_size1表示有1×8KB8KB的CHR数据。一份最基本的ROM加载代码长这样int load_ines(const char* path, Cartridge* cart) { FILE* fp fopen(path, rb); if (!fp) return -1; INesHeader header; if (fread(header, 1, sizeof(header), fp) ! sizeof(header)) { fclose(fp); return -2; } if (memcmp(header.magic, NES\x1a, 4) ! 0) { fclose(fp); return -3; } // 计算真实字节数 cart-prg_size header.prg_size * 16 * 1024; cart-chr_size header.chr_size * 8 * 1024; // 注意 trainerflags6 的 bit2 为 1 时头后面还有 512 字节 if (header.flags6 0x04) { fseek(fp, 512, SEEK_CUR); } cart-prg malloc(cart-prg_size); cart-chr malloc(cart-chr_size); if (fread(cart-prg, 1, cart-prg_size, fp) ! cart-prg_size) { fclose(fp); return -4; } if (cart-chr_size 0 fread(cart-chr, 1, cart-chr_size, fp) ! cart-chr_size) { fclose(fp); return -5; } // 组合 Mapper 编号 cart-mapper ((header.flags7 4) 4) | (header.flags6 4); fclose(fp); return 0; }注意我在代码里处理了trainer段。有些旧dump卡带会在文件头之后额外塞512字节的“训练数据”如果把它当成PRG读进去代码就会全部错位。这也是一个极其隐蔽的坑。关于ROM来源想多说一句请务必使用你有权使用的卡带dump或者直接自己写一个简单测试ROM来验证模拟器。开发社区有很多专门用来测试CPU和PPU的测试ROM比对着盗版游戏镜像瞎猜问题要高效得多。2. 内存映射与总线CPU和PPU眼中的世界ROM数据读进内存只是第一步接下来要让CPU和PPU能访问到它们。FC是一个双总线结构CPU通过自己的16根地址线访问程序和数据PPU另有自己的16位地址空间。两者独立互不干扰所以“CPU读ROM”和“PPU读图形数据”是两条完全不同的通路。很多模拟器新手在这里犯迷糊为什么CPU读了PRGPPU还要再读CHR因为FC就是这样设计的。CPU运行游戏逻辑PPU负责渲染图形它们各看各的数据数据由卡带的Mapper负责按需提供。模拟器要做的事情就是实现一套“总线仲裁”当CPU发出某个地址模拟器要判断这个地址落在哪块硬件上然后从正确的地方读回数据。2.1 CPU侧地址空间分配FC的CPU寻址空间是64KB也就是0x0000到0xFFFF。但这64KB并不全是卡带的数据其中很多区域被主机内置硬件占用了。完整的分布如下地址范围用途说明0x0000-0x1FFFCPU RAM实际只有2KB每2KB镜像一次0x2000-0x3FFFPPU寄存器8字节寄存器镜像重复0x4000-0x4017APU/IO寄存器音频、手柄等0x4018-0x401F预留家用机通常不用0x4020-0xFFFF卡带区域PRG ROM / SRAMCPU RAM只有2KB但地址线低11位有效所以访问0x0000到0x1FFF都会被映射到同一块物理RAM上。用代码实现就是addr 0x07FF。PPU寄存器同理靠addr 0x0007落到8个寄存器上。我习惯把所有总线访问集中到两个函数里方便以后加日志、加断点uint8_t cpu_read(uint16_t addr) { if (addr 0x2000) { // CPU RAM 2KB 镜像 return cpu_ram[addr 0x07FF]; } if (addr 0x4000) { // PPU 寄存器 return ppu_read_reg(addr 0x0007); } if (addr 0x4016) { // APU 寄存器这里直接返回0 return apu_read(addr); } if (addr 0x4018) { // 手柄等 IO return controller_read(addr); } if (addr 0x4020) { // 卡带区域交给 Mapper 处理 return mapper_cpu_read(addr); } return 0xFF; // open bus }看到最后那个return 0xFF了吗这是FC一个著名的行为未映射地址会读到当前总线上残留的数据业界叫open bus。模拟器不能对这个细节太过随意因为有些游戏尤其是判定按键和随机数会利用总线残留值。不过初期可以先固定返回0xFF等核心稳定了再回来细抠。2.2 PPU侧的CHR访问与镜像PPU侧有自己独立的16位地址空间用来读取pattern table、nametable和调色板。最关键的是前0x2000字节——它们存放图形地址范围用途0x0000-0x0FFFPattern Table 0背景/精灵图形0x1000-0x1FFFPattern Table 10x2000-0x23BFName Table 00x23C0-0x23FF属性表00x2400-0x27FFName Table 10x2800-0x2BFFName Table 20x2C00-0x2FFFName Table 30x3000-0x3EFFName Table镜像0x3F00-0x3FFF调色板PPU地址空间里的0x2000-0x2FFF是“显存”区域用来记录屏幕上每个字符的位置。FC主板上只有2KB VRAM所以四个nametable是共用内存的。具体怎么共用由卡带的镜像方式决定水平镜像还是垂直镜像。这个配置就在文件头的flags6里——bit0为0表示水平镜像1表示垂直镜像。镜像是个特别容易搞混的概念。简单说水平镜像是把第0、1个nametable映射到同一块内存第2、3个映射到另一块垂直镜像则是第0、2共用第1、3共用。用代码实现时只需要在PPU读nametable地址时做一个重映射uint8_t ppu_read(uint16_t addr) { if (addr 0x2000) { // pattern table来自 CHR ROM/RAM return mapper_ppu_read(addr); } if (addr 0x3F00) { // nametable处理镜像 addr 0x0FFF; if (cart.mirroring MIRROR_HORIZONTAL) { // 0x0000-0x03FF 0x0000-0x03FF // 0x0400-0x07FF 0x0000-0x03FF // 0x0800-0x0BFF 0x0400-0x07FF // 0x0C00-0x0FFF 0x0400-0x07FF addr (addr 0x0400) ? (addr 0x03FF) | 0x0400 : addr 0x03FF; } else { // 垂直镜像 addr 0x03FF; } return vram[addr]; } // 调色板 addr 0x001F; if (addr 0x0010 || addr 0x0014 || addr 0x0018 || addr 0x001C) { addr 0x000F; } return palette[addr]; }这里有几个细节很值得注意。0x3F00以后的调色板区域有四个地址是“透明色镜像”它们会把颜色索引映射到0x00、0x04、0x08、0x0C等基础色。如果漏掉这一层渲染出来的颜色可能整体偏一格。这也是那种“画面能出但颜色全不对”的常见元凶。另一个容易忽略的点CHR既可能是ROM也可能是RAM。很多早期游戏用CHR ROM图形是固定的但后来不少游戏改用CHR RAM让CPU在运行过程中自己往显存里写图形。模拟器遇到这种情况必须在CPU侧留出一段可写的RAM并在mapper_ppu_read里优先返回它。这就是为什么Mapper要实现ppu_read和ppu_write两个方向的访问。3. Mapper实现容量不够bank来凑读到这里ROM文件头解析完了CPU和PPU的总线也通了理论上可以把游戏跑起来了吧不还差最关键的一层——Mapper。FC主机的CPU只有16根地址线最多访问64KB地址空间扣掉RAM、寄存器、IO占用的部分留给卡带的只有0x4020-0xFFFF这不到48KB。但《超级马里奥3》这类游戏的PRG超过了64KB《勇者斗恶龙4》更是上百KB。那么多代码怎么塞进这么小的可见窗口答案就是换页。3.1 为什么需要Mapper你可以把Mapper想成一个物业管理处。整栋卡带大楼有几十个房间但CPU的“视力”只能看到固定几个窗户。窗口对应的房间由物业管理处通过几个寄存器来指定。你想看第5层的房间就往寄存器写个“5”物业把第5层的房间搬到窗口后面CPU就能看到了。这个“搬房间”的动作就叫bank切换。当时的FC游戏卡带就是为了节省成本在卡带上加了一块或多块逻辑芯片MMC系列就是任天堂官方出的这类芯片用游戏代码向特定地址写入控制值来切换映射。这个设计和现代操作系统里的虚拟内存分页很相似只不过FC是硬实时、直接暴露给程序员管理的。不做Mapper的话超过CPU可见范围的ROM数据永远也无法访问到。这也是为什么很多“简单模拟器”只能跑个位数的游戏它们把PRG卡死在固定bank里一遇到切换就出问题轻则画面错乱重则直接死机。3.2 常见Mapper的对比与实现常见的Mapper号很多但模拟器核心功能稳定后最常遇到的其实就这几个Mapper名称典型游戏特点0NROM超级马里奥兄弟无切换PRG最大32KB1MMC1塞尔达传说支持大容量PRG移位寄存器控制2UxROM魂斗罗切换0x8000处bank实现简单4MMC3超级马里奥3带扫描线IRQ可切换CHR bank模拟器的Mapper设计我建议直接抽象成一个接口让每个Mapper都实现相同的方法typedef struct Mapper Mapper; struct Mapper { int id; uint8_t (*cpu_read)(Mapper* m, uint16_t addr); void (*cpu_write)(Mapper* m, uint16_t addr, uint8_t val); uint8_t (*ppu_read)(Mapper* m, uint16_t addr); void (*ppu_write)(Mapper* m, uint16_t addr, uint8_t val); void (*scanline)(Mapper* m); };这样每种Mapper只是实现自己的函数指针主循环完全不需要关心具体类型。实际模拟器里会把Mapper结构体分配成大一点的子结构体但对外统一用基类指针操作。先从最简单的Mapper0说起。NROM卡带最多只能有32KB PRG和8KB CHRCPU把0x8000-0xBFFF映射到PRG前16KB0xC000-0xFFFF映射到PRG后16KB。如果PRG只有16KB那两个区域就映射到同一份数据也就是镜像uint8_t nrom_cpu_read(uint16_t addr) { if (addr 0xC000) { return prg[prg_size - 0x4000 (addr - 0xC000)]; } if (prg_size 0x4000) { return prg[addr - 0x8000]; } return prg[addr 0x3FFF]; // 16KB 镜像 }Mapper2UxROM稍微多了一点逻辑它允许CPU往0x8000-0xFFFF写入一个bank号用来切换0x8000-0xBFFF处的16KB区域而0xC000-0xFFFF固定映射到最后16KB。魂斗罗就是用它来切换关卡的typedef struct { Mapper base; uint8_t bank; } Mapper2; uint8_t mapper2_cpu_read(Mapper* m, uint16_t addr) { Mapper2* m2 (Mapper2*)m; if (addr 0xC000) { return prg[prg_size - 0x4000 (addr - 0xC000)]; } int base m2-bank * 0x4000; return prg[base (addr - 0x8000)]; } void mapper2_cpu_write(Mapper* m, uint16_t addr, uint8_t val) { ((Mapper2*)m)-bank val 0x0F; }看起来简单但要注意一个细节Mapper2的bank选择寄存器是对0x8000-0xFFFF整段生效的。很多游戏会往这个范围内的不同地址写多次如果模拟器只监听0x8000-0x9FFF就会漏掉后半段切换导致游戏到后期关卡时bank没换对。3.3 Mapper1和MMC3的实现要点Mapper1MMC1是第一个让我头疼的Mapper。它用串行移位寄存器来接收控制数据CPU每次向指定地址写一个值只有最低位被采样写入5次后才凑齐一个5位数据。这么设计是为了节约卡带引脚但模拟器里就得模拟这种“比特流”协议void mapper1_cpu_write(Mapper* m, uint16_t addr, uint8_t val) { Mapper1* m1 (Mapper1*)m; if (val 0x80) { m1-shift 0; m1-shift_count 0; m1-control (m1-control 0x0E) | 0x0C; return; } m1-shift (m1-shift 1) | ((val 1) 4); m1-shift_count; if (m1-shift_count 5) { // 根据地址的bit12/13决定是哪个寄存器 uint8_t reg (addr 13) 0x03; switch (reg) { case 0: m1-control m1-shift; break; case 1: m1-chr_bank0 m1-shift; break; case 2: m1-chr_bank1 m1-shift; break; case 3: m1-prg_bank m1-shift; break; } m1-shift 0; m1-shift_count 0; } }写成这样之后我发现仍然有游戏跑不对后来查文档才知道MMC1的bank切换不是立即生效的控制寄存器里还有一个PRG banking模式位决定PRG bank的切换粒度是16KB还是32KB。模拟器必须完整实现这个模式位而不是简单地把bank号拿来就用。MMC3Mapper4就更复杂了。它不仅支持细粒度的CHR bank切换还有一个基于扫描线的IRQ计数器。很多游戏包括马里奥3用它来实现屏幕分割滚动和特殊特效——当PPU渲染到某一条扫描线时MMC3触发CPU中断CPU立刻切换滚动位置造成上下半屏滚动不同的效果。模拟器实现MMC3的扫线IRQ大概思路是在PPU每次渲染完一条扫描线后调用scanline回调void mmc3_scanline(Mapper* m) { MMC3* mmc3 (MMC3*)m; if (mmc3-irq_reload) { mmc3-irq_counter mmc3-irq_reload; mmc3-irq_reload 0; } else if (mmc3-irq_counter 0) { mmc3-irq_counter--; } if (mmc3-irq_counter 0 mmc3-irq_enable) { cpu_irq(1); // 触发中断 mmc3-irq_enable 0; // 部分版本会自动清除 } }注意MMC3有个著名的坑IRQ触发后要不要自动disable不同批次芯片行为不一样。网上很多“玩到某关就死机”的bug报告最后都指向这个flag。稳妥做法是做一个可配置项在加载ROM时根据游戏实测调整。4. 常见问题与排查技巧实录这部分是重点因为FC模拟器DIY到了这个阶段拼的不再是“能不能启动”而是“启动后出了毛病能不能快速定位”。我自己踩过的坑几乎全集中在这一层。4.1 故障现象与对策速查表先给出一张基于实际经验总结的排查表遇到问题直接对照故障现象大概率原因排查方向启动白屏文件头解析错误或PRG加载失败打印magic、PRG大小确认文件头字节对齐画面全是噪点/色块CHR地址映射错误打印PPU pattern table读取时的地址检查mapper的ppu_read能进游戏但文字花屏nametable镜像配置错误确认flags6的镜像位取值对照游戏类型按键没反应手柄寄存器读取时序错误检查0x4016/0x4017的读写序列是否完整特定游戏崩溃Mapper bank切换未实现确认ROM头里的mapper号检查实现与选择寄存器动画特效撕裂MMC3扫描线IRQ不触发给PPU渲染循环加scanline调用跟踪计数器这张表不是让我们背答案而是帮助建立排查直觉。比如白屏最可能的问题就是“CPU根本没执行到正确代码”那就要从文件头解析、数据加载、Mapper的cpu_read这条线去查。4.2 排查思路从最小系统到渐进调试我的调试思路可以浓缩成一句话把模拟器当成一台只能跑固定程序的单片机每加一块功能就验证一次不要一口气把整个游戏跑通作为目标。第一步先用诊断ROM验证CPU。社区里流传的nestest就是一个经典CPU测试工具它会对每条指令进行预期和实际值的比对。你只需要把0xC000地址开始的PRG加载好执行后逐条对比PC、A、X、Y、SP和状态寄存器的值。这个阶段不用管PPU不用管Mapper连ROM文件头都不需要解析——直接把测试ROM塞进内存即可。第二步验证PPU。自己写一个最简单的测试程序往指定pattern table里填充几个有规律的色块然后用截屏工具查看渲染输出。我第一版PPU跑出来是一堆斜纹后来发现是高地址位的掩码写错导致向pattern table写入的数据全部落在相邻地址上。第三步才把Mapper接进来。先用一个已知游戏跑通Mapper0再逐步增加Mapper1、2、4。每换一个Mapper都先用一个该Mapper类型下的经典游戏验证确认bank切换逻辑没有漏掉。最后一步接上输入和音频。手柄输入有一个特别容易忽略的点CPU读取手柄时需要先写0x01到0x4016再写0x00之后才能逐位读回按键状态。如果忘了写这个复位序列很多游戏会认为“所有按键都没按下”。看门狗式的排查就会走到“CPU执行没问题游戏却不响应按键”的死胡同。4.3 三个容易忽略的坑第一个坑是trainer段。这个前面提过文件头里flag6的bit2如果为1后面还有512字节的trainer需要跳过。很多ROM加载器根本不管这个位就会把trainer数据当成PRG前半段导致后面所有地址全部错位。修复方式很简单解析文件头时检查flag然后fseek(fp, 512, SEEK_CUR)。第二个坑是PRG和CHR的大小单位。我见过有人直接用文件头里的prg_size当作字节数去malloc结果申请了2字节内存写入时崩溃。记住PRG单位是16KBCHR单位是8KB计算真实字节数一定要乘上对应的1024。第三个坑是小端序和数据结构对齐。FC的ROM数据本身就是一堆字节流直接用结构体去读没问题但前提是你的编译器没有在字段之间插入填充。我在macOS上用Clang和Linux上用GCC编译得到的结构体大小居然不一样后来统一用#pragma pack(push, 1)解决。另外从flags6和flags7组合mapper号时记得先右移再左移组合顺序错了就会得出完全错误的mapper号。还有一个小技巧写代码的时候在所有总线函数入口加printf日志控制通过环境变量或者调试开关打开。我没有用断点调试器因为模拟器主循环每秒跑几十万次断点一点用没有。反而是日志后处理脚本能快速定位出“哪个地址被异常访问了”。写在进度的最后这几篇写下来FC模拟器已经能加载ROM、解析文件头、跑通CPU和PPU的总线、执行常见的bank切换了。很多朋友做到这一步就迫不及待地拿起《魂斗罗》或者《超级马里奥》来测试但我的建议是先把手头几个最简单的测试ROM跑得万无一失再慢慢上难度。毕竟唯一的“真相”不在模拟器代码里而在那个被模拟的硬件设计乘以游戏自身的复杂度里出错点远比我们想象的多。我个人在实际调试中体会最深的一点是模拟器做得越久就越佩服当年那些家用机的硬件工程师——他们用几块钱的逻辑芯片和极其有限的内存硬是撑起了一个时代的游戏体验。我们能在现代电脑上复制那台机器靠的不只是编程能力还有对细节的敬畏。马上可以继续的下一步是把音频通道、手柄检测的那些边角问题彻底磨平然后找一个真正复杂的MMC3游戏把scanline IRQ调得和真机一样精准。到那时候这个DIY项目才算真正封箱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价