资讯动态

FC模拟器DIY第五版:攻克ROM加载、时序、延迟与音频四大难题

发布时间:2026/9/8 4:41:48 来源:尧图企业网站定制
FC模拟器DIY写到第五篇说明前面那些从零到一的事已经基本干完了——板子能上电内核能加载画面上终于蹦出了熟悉的红帽子水管工。但说实话做到这个阶段你会发现真正的折腾才刚刚开始。前几版我面临的问题是游戏能不能跑起来而到了这一版问题变成了跑起来之后为什么玩起来这么难受帧率没有稳定性可言、按键永远像隔了层棉花、声音一开大就嗡嗡作响。这一篇我不打算再复述模拟器的原理框架而是聚焦四个最影响实战体验的硬骨头ROM文件怎么加载才不乱、CPU和PPU这对老搭档的时序怎么对齐、手柄输入延迟怎么从能按出来降到想按就有、APU音频模拟怎么调才不爆音。如果你正在拿ESP32、树莓派Pico或者STM32做FC模拟器或者只是对一台自制游戏机背后的工程细节感兴趣这篇应该能帮你省掉不少夜宵时间。1. 第五版到底改了啥从能开机到能通关1.1 前四版的进度和这一版的目标我的前几版基本是这么走过来的第一版先点亮屏幕能在TFT上刷出测试色块第二版把6502 CPU核心跑起来了可以执行最简单的汇编循环第三版接入了真正的模拟器核心开始尝试解析NES文件头第四版已经能显示静态画面和卷轴但问题是动态画面掉帧明显输入也经常卡顿可以说处在能开机和能玩之间的尴尬地带。第五版的核心目标非常明确让一台游戏能够在30分钟的实际游玩里保持稳定。所谓稳定不只是帧率数字好看而是你在跳关卡时画面没有明显撕裂在密集敌人出现时声音不突然破音按键按下时角色的反应跟手。为了达到这个目标我不惜推翻一部分前几版偷懒写出来的代码尤其是显示同步、输入扫描和音频输出这三块。一个容易被忽略的认知是模拟器的难度不在运行指令而在还原时序。FC的CPU是理光2A03主频只有1.7897725 MHz换算下来每秒只能执行大约50万条指令这个性能放今天任何一个MCU面前都不够看。真正的难点是它要跟PPU图形处理单元严格执行周期级配合——CPU慢没关系但CPU和PPU之间的先后关系一旦错乱画面和声音的体验就会迅速劣化。1.2 硬件方案ESP32-S3为主力平台的取舍我第五版的主力平台是ESP32-S3这颗芯片240MHz双核、自带PSRAM接口无论是价格还是社区资料都比早期版本友好得多。前几版我试过用普通ESP32经典版虽然也有240MHz但没有PSRAM是个致命伤——很多中文汉化ROM的CHR数据动辄几百KB没有外部内存根本放不下。树莓派Pico也是备选双核133MHz、价格便宜但同样受限于内存且没有PSRAM可扩跑大型Mapper游戏会比较吃力。最终硬件方案大概是这样的ESP32-S3模组带8MB PSRAM和16MB Flash搭配一块3.2寸320x240的ILI9341 SPI屏幕SD卡走SDMMC接口音频用I2S接PCM5102A DAC输入按键直接接GPIO矩阵。这套方案的优点是很通用每一部分你都能在淘宝单独买到出了问题也方便替换缺点是SD卡和屏幕同时工作对IO口占用较多引脚分配需要仔细规划。后面我会专门说引脚的坑。我并不是说ESP32-S3是唯一解而是它在性能、内存、外设、成本四个维度上最平衡。做DIY项目最忌讳一开始就追求极限先让整条链路跑通再去想怎么用更便宜的芯片替代这是比较务实的路线。2. ROM加载与烧录把卡带从文件变成运行中的游戏2.1 先看懂 .nes 文件头很多人做模拟器时最不重视的就是ROM加载模块觉得不就是读文件吗实际上ROM加载是整个项目里最容易看似正常、实则白屏的部分。FC游戏ROM最常见的封装格式叫iNES也就是.nes文件开头16字节是文件头后面紧跟PRG ROM和CHR ROM的数据段。PRG是CPU执行的程序代码CHR是PPU用的图形数据两者缺一不可。文件头16字节长这样前4个字节固定是NES\x1A用来识别文件类型偏移4表示PRG ROM的大小单位是16KB偏移5表示CHR ROM的大小单位是8KB偏移6和7组合出镜像模式、电池记忆、Mapper编号等信息后面8个字节是扩展字段新版NES 2.0规范里还会包含PRG-RAM大小、区域制式等。我早期犯过一个低级错误拿到一个ROM直接读偏移4和5然后按偏移6判断Mapper就开跑结果很多游戏画面花屏、声音诡异。后来才意识到偏移6的那一位还包括水平/垂直镜像标志如果不单独拆出来PPU的卷轴寻址会整体错乱画面自然不对。所以加载ROM切忌图省事建议把文件头每个位域拆分清楚再初始化模拟器核心。2.2 SD卡加载还是直接烧进FlashROM的存放路径无非两种SD卡和Flash。SD卡的优点是灵活拷游戏不需要重新编译固件整理一个游戏合集很方便缺点是文件系统读取存在延迟偶尔会卡顿而且SPI模式的SD卡速度上限不高。我建议如果你手头的屏幕也是SPI屏最好把SD卡接到SDMMC接口或者至少用独立的SPI总线避免两个外设抢总线导致画面和读卡互相拖慢。Flash烧录则是把几个ROM直接编进固件里用一个简单的菜单索引结构切换游戏。这种方式启动速度极快也没有SD卡接触不良的问题但每换一次游戏就要重新下载固件适合已经把游戏清单定下来的情况下使用。我的方案是两者共存开机先扫SD卡如果SD卡没插好就启用Flash内置的几款测试ROM这样既能开发调试也能在展示的时候不依赖外设。无论用哪种方式文件读取后都应该先做一层缓冲ESP32-S3有PSRAM可以把整个.nes文件加载到PSRAM再交给模拟器核心解析。这里有个容易被忽略的性能问题模拟器是逐帧运行的如果每一帧都从SD卡读数据哪怕只多出一两次文件系统寻址也会给帧循环带来几毫秒的抖动。一次性读入内存让帧循环变成纯SRAM/PSRAM操作是保证稳定性的重要基础。2.3 加载失败场景白屏、控制器无响应、死循环我在调试过程中遇到的ROM加载问题大致可以归成三类如果你也遇到了可以直接对号入座。第一类是白屏或花屏。如果文件头里的PRG/CHR大小正确、Mapper也正确但屏幕仍然全白或者满屏噪点大概率是CHR数据没有正确写入PPU的pattern表。有些ROM使用的是CHR RAM而不是CHR ROM也就是说文件里没有CHR数据段画面是运行中由CPU写进PPU的。这类游戏如果只按有CHR段去加载自然什么都画不出来。第二类是游戏能进菜单但按键没反应。这个现象通常不是ROM的问题而是文件头里PRG-RAM大小字段没被正确解析。很多老游戏依赖卡带上的电池记忆或工作RAM如果模拟器没有给Mapper分配对应的PRG-RAM空间CPU会写到一个错误地址导致整个逻辑跑飞。特别是Mapper 4MMC3家族的游戏PRG-RAM分配一定要按头字段或者Mapper特性严格处理。第三类是无限Reset循环。现象是屏幕一闪一闪地重启常见于中文汉化ROM。汉化组经常为了塞入更多字库而修改Mapper配置或者扩展PRG-RAM很多简化版加载器只按Mapper 0/2几个常见类型处理遇到改动过的ROM就崩。就是从这里开始我养成了一个习惯手边常备一份原版英文ROM作为对照如果原版能跑、汉化版不能跑先怀疑加载器对特殊头的支持而不是怀疑模拟器核心。另外多说一句关于游戏合集的事网上所谓全集里鱼龙混杂很多是改版、Hack版或者各种Mapper的随意混合。我的做法是只保留测试过的、能在自己模拟器上稳定运行的原版或优质汉化ROM并且尽量用自己从正版卡带备份出来的文件——做DIY项目是兴趣爱好尊重版权这条底线还是要守住的。3. CPU 与 PPU 的时序对齐帧率不稳的真正根源3.1 三个时钟与一条扫描线的账FC显示系统的核心是三个时钟频率NTSC主时钟21.47727MHzPPU跑主时钟的1/4约5.37MHzCPU跑主时钟的1/12约1.79MHz。换句话说CPU每执行一个机器周期其实一个6502指令周期对应2个PPU像素时钟的倍数PPU已经输出了3个像素点。这就是CPU和PPU必须绑在一起模拟的根本原因——两者不是独立的它们由同一个主时钟派生任何异步处理都会积累偏差。具体到一帧数据NTSC下水平一条扫描线约341个PPU时钟垂直总共262条扫描线。算一下5.37MHz除以341再除以262接近60.098Hz这就是FC所谓的60帧。注意这个60不是PC上那种一秒钟固定交换60次缓冲而是由扫描线持续输出推动的。模拟器的目标是模拟这个过程而不是简单运行完整帧CPU指令后再画一帧——如果真那么干你的画面会有至少一帧的延迟动态画面还会因为批量提交而变得不流畅。3.2 整帧模拟与逐行渲染的差别早期我图简单用的是整帧模拟先把这个帧的所有CPU指令全部执行完再统一渲染一帧图像并推到屏幕。这种做法代码结构最清晰但有两个致命问题。第一是延迟从按键输入到屏幕显示结果中间隔了整整一帧CPU执行时间和一帧渲染时间在30-60帧的游戏里玩家会明显感到手感黏滞。第二是内存整帧图像需要一整个RGB565帧缓冲虽然ESP32-S3有PSRAM不差这点但显示刷新时全屏数据从内存搬进SPI屏DMA传输时间本身就占了很大比例导致帧率波动。第五版我改成了逐行渲染模拟器执行约113.67个CPU周期对应一条扫描线后立刻把这行像素写入屏幕的DMA缓冲区然后继续下一条扫描线。这样画面是从上往下一行一行刷出来的天然接近真实CRT的扫描方式游戏ROM里依赖扫描线状态切换的特殊效果比如某些状态在特定扫描线修改背景色也能正确呈现。逐行渲染的代价是CPU不能连续跑一大段指令每次执行一小段就要停下来处理PPU同步事件代码里挂着很多当前扫描线到了吗的判断。但在240MHz的ESP32-S3上这个开销完全可以接受。3.3 帧时间实测与双核任务划分你可以用ESP32的esp_timer或者直接在帧循环里记录微秒数观察一帧究竟花了多少时间。我实测下来纯模拟部分CPUPPU逻辑不含屏幕DMA传输等待大约需要7-9毫秒比理想值16.66毫秒少不少说明算力是够用的。真正吃掉帧时间的是屏幕DMA传输和等待SPI总线一次全屏刷新的传输大约要3-5毫秒如果再遇上SD卡读文件抢总线帧时间就飙到20毫秒以上游戏立刻掉帧。针对这个瓶颈我把任务拆成了两个核心0号核跑模拟主循环CPU/PPU/APU逻辑1号核专门负责与屏幕、SD卡、音频DMA相关的I/O调度。在代码里设置一个环形缓冲模拟核心往里面写渲染行I/O核负责把这些行数据送到屏幕。这个架构的好处是模拟核心不被SPI传输阻塞代价是双核通信需要处理同步稍不留神就出现生产者消费者模式的死锁。我给每个缓冲区加上满/空标志并用轮询而不是中断的方式检查虽然浪费了一点CPU但代码简单得多稳定性也好得多。如果你也遇到帧率跳变我建议先别急着优化算法先用GPIO翻转加示波器测出模拟耗时和传输耗时各占多少。很多时候你以为的CPU跑不动其实是DMA等待或锁竞争。4. 手柄跟手问题输入延迟是怎么磨掉的4.1 为什么按下去总感觉慢半拍把ROM加载和时序稳定搞定之后画面已经比较流畅了但我在试玩《超级马里奥兄弟》时明显感到按键发肉。按跳键之后角色大约要晚个两三帧才起跳这种延迟在平台跳跃游戏里几乎是致命的。排查之后发现第一版输入读取放在帧循环的最后读取进去的值要到下一帧的CPU指令里才被消费如果屏幕刷新又因为DMA等待晚了几毫秒延迟就更严重。输入延迟的本质是一个生产者消费者距离问题玩家按下按键是外部事件模拟器读取它是在某个特定时间点而屏幕把这个帧画出来是最后的时间点。想让按下去和看到结果尽可能接近就必须让读取按键的时刻尽量靠近该帧渲染的开始。我最终把输入扫描放在了一帧可见扫描线全部渲染完毕、进入VBlank前后这个位置然后立刻把状态寄存器更新给APU/输入系统模拟核心在下一行开始执行前就能拿到最新状态。4.2 串行读取的细节真实FC手柄其实是一个8位移位寄存器CPU通过两根线操作写$4016拉高一个锁存脉冲把8个按键状态并行装进移位寄存器之后每读一次$4016寄存器移位输出一位从D0引脚读走。游戏程序会连续读8次依次得到A、B、选择、开始、上、下、左、右。模拟器必须在内存映射层面完整复刻这个过程哪怕只是少模拟一位都会导致后续键位错乱。我最初用手写GPIO轮询每次游戏读$4016就去读所有按键的引脚状态再拼成一位返回。结果因为每次读都重新采样某些高速读操作游戏扫描不止读一次会把中间状态读错。后来我改成锁存模式当写$4016低到高跳变时把当前GPIO按键值全部锁存进一个变量之后读$4016只是从这个变量移位返回。这个方案既准确又高效在ESP32的GPIO矩阵上实现很方便。4.3 USB 手柄接线和 HID 轮询的坑如果只用GPIO矩阵按键延迟能做到很低但手感还是不如标准手柄。我参考网上经验尝试用ESP32-S3的USB OTG接了一个USB小手柄走TinyUSB的HID主机模式。结果发现USB手柄轮询间隔通常默认是8毫秒也就是最多约半个帧周期才能上报一次按键加上信号在USB底层还要经过协议解析总延迟反而比GPIO直连还高。所以我的结论是如果你追求极致跟手GPIO矩阵或SPI扫描键盘是首选USB手柄适合演示时方便操作的场景不适合作为主要输入方案。还有一种容易忽略的情况是按键抖动机械按键直接接GPIO如果不做消抖一次按下会被模拟成两次角色会抖一下。简单方案是RC低通滤波或者在定时器中断里做5-10ms的防抖判断但要注意消抖时间不能太长否则又引入延迟。我最终设定为5ms消抖实测手感飞鼠游戏比较合适。5. APU音频模拟不爆音、不炸耳朵的调校5.1 五路声音通道分别是什么FC的声音由APU产生一共五条通道两个矩形波通道、一个三角波通道、一个噪声通道、一个DPCM采样通道。矩形波通道是游戏主旋律和音效的主力能输出12.5%、25%、50%、75%四种占空比还带音量包络和扫频效果三角波通道声音柔和适合做低音部噪声通道用15位线性反馈移位寄存器产生伪随机序列用来做爆炸、踩踏这类打击音效DPCM通道则可以播放1-bit采样音频早期很多游戏用它做语音或鼓点。许多新手做模拟器时会想我能不能直接用音频文件播放背景音乐——这完全不行。因为FC的音乐和音效是通过CPU实时写寄存器控制APU发声的换了一帧的音乐音符就必须由模拟器实时计算出来任何预渲染的思路都行不通。所以APU必须和CPU指令同步推进。5.2 采样率转换与混音权值APU本身以CPU时钟域工作而音频DAC需要的是固定采样率比如44100Hz或48000Hz。这中间需要一个每多少CPU周期产生一个采样点的换算1.7897725MHz除以44100约等于40.58所以大约每40.58个CPU周期要采一次样。实际实现时我用一个累加器累积分数周期保证长期来看采样率准确而不是简单地整数除法。混音权重没有标准答案不同游戏听感不一样。我参考了不少开源模拟器的做法最终使用的权重大概是这样两个矩形波各占0.45左右三角波0.3噪声0.4DPCM 0.5然后整体乘一个系数防止削波。这些数值不是拍脑袋定的而是反复试听得到的。如果某一通道权重太高游戏音效会刺耳太低主旋律会被音效盖住。建议你先把各通道独立录出波形再混合试听这样调整起来更有方向。5.3 I2S DMA缓冲区下的爆音处理音频输出最常见的故障是爆音POP/PIP和周期性杂音。爆音绝大多数来源于I2S DMA缓冲区欠载——也就是DAC已经把缓冲区读空新数据还没填进去导致输出跳变。解决思路很简单把DMA缓冲设大一些比如填4个1200字节的buffer同时用一个较高优先级的任务或中断去填数据。我用的是ESP32-S3的I2S外设配置为48kHz、16位单声道DMA描述符链好之后把混音结果持续写入环形缓冲由硬件中断触发搬运实测下来欠载率从最初的一分钟好几次降到了几乎为零。还有一类杂音来源于CPU和音频线程的竞争如果模拟核心和混音逻辑都在同一核上某帧模拟耗时过长音频缓冲区就得不到及时补充。解决方法是把音频混音放在模拟核心内联执行在一帧的时间里均匀生成输出采样点而不是等帧结束后一次性生成大量数据。这样即使某帧模拟时间偏长音频缓冲区也只会少补一小段不至于断流。我最后又加了一层一阶低通滤波和直流偏置校零因为PCM5102A虽然对直流不敏感但截掉高次谐波后整体听感会更软不会像早期那样滋滋地刺耳。这个优化纯粹是听感方面的不影响帧率但做完之后整台设备从电子玩具变成了有点游戏机味道的设备。6. 调试工具链与避坑清单6.1 用FCEUX当标准答案DIY模拟器开发过程中最难的一件事是当游戏运行异常你不知道是自己的CPU核心错了、PPU渲染错了、还是ROM加载错了。我的办法是同时打开PC上的FCEUX和Mesen作为标准答案先在它们上面跑同一个ROM观察帧数、音量、画面是否符合预期如果某处行为怪异再去模拟器源码里翻对应的逻辑。更进阶的做法是trace对比FCEUX支持输出CPU执行的指令流日志我的模拟器在调试模式下也输出同样格式的日志两个日志逐条对比很快就能定位到是哪一条指令、哪个地址的分支产生了偏差。这个方法看似笨拙但对于6502核心这种指令集相对有限的CPU排查效率极高。我第五版里有两次莫名其妙的乱跳都是靠这个办法查出来的一次是ADC指令的十进制标志处理少了进位另一次是RTI中断返回时没恢复状态寄存器。6.2 这条链路里最容易翻车的地方总结这五次迭代我觉得几个位置是最容易让你卡一个周末的。第一SD卡和SPI屏幕共用总线。刚开始图省事把SD卡和屏幕都挂在同一个SPI上结果屏幕刷新常被读卡打断掉帧极其严重。后来哪怕只用片选切换也会出现偶发的文件系统卡顿。最终改成SD卡走SDMMC接口屏幕单独走SPI问题才彻底解决。做硬件连接之前先把外设的总线占用画出来真的能省很多事。第二帧缓冲和DMA的时序。如果双缓冲切得不对画面会出现撕裂或者上半帧新下半帧旧的现象。我最后的方案是三缓冲一个缓冲区在DMA传输中一个在渲染中一个等待传输由I/O核统一调度。三缓冲虽然多占一点内存但彻底消除了撕裂强度一上来也稳。第三电源供电。这个坑藏得比较深ESP32-S3在Wi-Fi/蓝牙关闭的情况下电流不大但接上屏幕和DAC之后瞬时电流可能超过USB口供电能力。我早期用充电宝供电一开游戏就自动重启排查了很久才发现是压降问题。后来换成了正规5V 2A适配器并在3.3V侧加了低ESR电容这才稳定下来。6.3 下一步还能玩什么到了第五版这台FC模拟器对我来说已经不是能不能跑游戏的验证品而是一个可以持续折腾的平台。接下来的方向我觉得至少有三个值得做一是接入蓝牙手柄通过BLE HID实现无线操作虽然延迟比GPIO高但演示场景方便很多二是加扫描线滤镜和CRT模拟效果在ILI9341上模拟出老电视的辉光感观感会完全不同三是把菜单做得更完整支持封面预览和存档管理这样就更接近一台量产掌机的操作体验。第三个方向一定程度上依赖你把文件系统加载做扎实因为封面图片、存档文件、游戏列表都要来自SD卡。这也是为什么我这一版没有急着做花哨UI而把大量精力放在ROM加载和I/O调度上——地基不打牢上面盖什么都是歪的。最后分享一个我自己的调试习惯我会在PCB上专门留一个复位按键和一个状态LED复位按键方便异常时快速重来状态LED则接到一个GPIO代码里在不同阶段翻转它。配合示波器或者逻辑分析仪你一眼就能看出当前卡在模拟循环还是卡在等待DMA这比在串口上打日志要直观得多。做嵌入式DIY项目光看log经常会被时序问题骗过去物理信号的直观反馈反而是最靠谱的朋友。

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

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

免费获取报价