1. 项目全局与硬件选型思路
1.1 为什么是ESP32-S3而不是其它开发板
我最早想弄个掌机方案的时侯,手边同时有ESP32经典款、STM32F407和树莓派Pico,最后真正跑起红白机模拟器并且愿意长期放在桌面上的,反而是ESP32-S3。核心原因就三个字:省心、够用、便宜。ESP32-S3是双核Xtensa LX7,主频能到240MHz,带2MB左右的片上SRAM,很多模组还直接焊了8MB或16MB的PSRAM,也就是说你可以在内存里同时放下模拟器本体、游戏ROM和两三个帧缓冲,这在经典款ESP32上是很难想象的。
红白机模拟器这件事,CPU强度其实不是最大的瓶颈。FC的6502处理器主频才1.79MHz,哪怕是纯解释执行指令循环,240MHz的S3也是绰绰有余的。真正的压力在PPU(图像处理单元)和APU(音频合成单元)上。FC是逐行扫描输出,一帧画面要处理256x240的分辨率,每个像素还要经过调色板转换,如果你每次写屏幕都走一次SPI,速度直接崩。所以S3的优势不是"CPU跑得快",而是它内置了SPI-DMA、多路硬件I2S、以及足够大的内存可以让你做双缓冲甚至三缓冲,这才是能把模拟器跑稳的关键。
对比之下,STM32F407主频虽然有168MHz,但内存只有192KB,跑一个完整FC模拟器加ROM很吃力,而且图形接口得自己搭DMA逻辑;树莓派Pico要外扩PSRAM芯片,麻烦不说,双核架构调试起来也不如ESP-IDF顺手。所以如果你问我"第一台自制的红白机模拟器用什么平台",我的答案永远是:先拿S3把游戏跑起来再说,剩下的板子等你有具体需求再去折腾。
1.2 硬件物料与接线方案
硬件清单不复杂,我列一个自己实测稳定的组合给大家抄作业。主控选择的是ESP32-S3-DevKitC-1,带8MB PSRAM的版本,普通的4MB版本也能跑,但很多汉化版ROM和带存档的大游戏会吃紧,所以我建议直接上8MB。屏幕我用的是ST7789方案的240x240 IPS屏,SPI接口,1.3寸或1.54寸都行,分辨率正好能把FC画面做整数缩放,不会出现像素拉伸变形的问题。如果你喜欢大屏,也可以换成ILI9341的320x240,但要做好帧率掉几帧的心理准备。
音频方面,最简单的方案是直接用GPIO输出PWM音频,再接一个简单的RC低通滤波器和耳机放大器模块。注意不要直接驱动耳机,S3的输出电流扛不住,声音会破音还会损伤引脚。更稳的方案是用I2S接口外接一个MAX98357A功放模块,或者用UDA1334A这类I2S DAC芯片,音质会比PWM好很多,还能省掉一堆阻容元件。
按键输入这块,我一开始是用GPIO直连轻触开关,后来发现S3的GPIO很多,但也不能乱接,要避开SPI屏幕和I2S占用的引脚。我把自己的接线表放在下面,供参考。
| 功能 | 引脚 | 备注 |
|---|---|---|
| ST7789 SPI SCK | GPIO12 | 与PSRAM共用SPI,需要检查板级冲突 |
| SPI MOSI | GPIO11 | 同上 |
| 屏幕CS | GPIO10 | 软件控制片选 |
| 屏幕DC | GPIO9 | 数据/命令切换 |
| 屏幕RST | GPIO8 | 复位控制 |
| 按键矩阵ADDR0-3 | GPIO4-7 | 行扫描 |
| 按键矩阵DATA0-3 | GPIO15-18 | 列读取 |
| I2S BCLK | GPIO41 | 音频位时钟 |
| I2S LRCLK | GPIO42 | 音频声道时钟 |
| I2S DIN | GPIO40 | 音频数据 |
这里有个大家很容易忽略的点:PSRAM和SPI屏幕有时候会共用底层SPI总线,如果你用的模组布线不合理,屏幕刷新时会出现随机花屏,这时候就要查一下芯片的GPIO矩阵配置,把屏幕挪到非PSRAM共用引脚上。合宙的S3模组和官方DevKitC的引脚定义不太一样,接线前一定要看自己板子的原理图,别直接照抄网上的。
1.3 供电与功耗考虑
做掌机的话,电池方案是一个绕不开的坑。S3在全速跑模拟器加屏幕刷新时,峰值电流很容易到400mA以上,如果你用的是廉价的LDO供电,掉压导致重启是家常便饭。我建议用一节18650锂电池加TP4056充电模块,再加一个MT3608升压到5V给屏幕和主板供电,或者更简单点,直接用带充放电保护的锂电池升压一体模块。实测下来,1200mAh的电池能连续玩4个多小时,基本够用。
如果你的屏幕是通过3.3V供电的,注意不要让电池直接连到屏的VCC,否则充满电的4.2V会把屏幕烧掉。我之前就烧过一块屏,后来学乖了,所有外设统一从5V升压母线上取电,再经稳压到3.3V。
2. 模拟器的核心细节与关键技术
2.1 FC模拟器是怎么在单片机上跑起来的
很多朋友第一次接触这个项目时都以为只要下载一个"红白机模拟器固件"烧进去就行,实际上把一个PC上成熟的FC模拟器移植到单片机,涉及的工作量远比想象中大。FC的硬件结构由三个大块组成:CPU(2A03,实际是6502内核加APU)、PPU(图像处理单元)、以及游戏卡带里的Mapper(内存映射器)。
CPU部分是最容易的,6502指令集只有56条操作码,加上寻址模式也就一百多个case,只要你把内存、标志位、寻址方式写对,游戏逻辑就能跑。真正把人卡住的是同步问题。FC的CPU和PPU是严格同步运行的:PPU扫描一行画面需要113.67个CPU时钟周期,每帧262行,所以CPU跑一阵子就必须停下来等PPU。如果你不做这个同步,游戏的物理判定和音乐节奏会全部错乱。
PPU模拟是另一个大头。FC的PPU提供256x240的名牌表(Name Table),上面存的是瓦片索引(Tile Index),然后通过属性表(Attribute Table)定义每16x16像素块的调色板归属。模拟器要做的事情,是在每个扫描行开始时检查游戏程序是否修改了PPU寄存器,然后按扫描线渲染这一行到帧缓冲里。在S3上这种逐行渲染很适合用行中断来驱动,一帧画面相当于262次行中断,每次渲染一行就刷新一行的数据到屏幕。
音频APU部分更麻烦,FC的APU有2个矩形波通道、1个三角波通道、1个噪声通道、1个DPCM采样通道,每个通道都有自己的频率和音量寄存器。模拟器在每个CPU周期都要累加相位,产生对应的采样值,然后混合输出。如果你开的是44100Hz采样率,那一帧(60分之1秒)里要产生735个采样点,每个采样点都要跑完五个通道的合成逻辑,计算量不比PPU小。
我之前在GitHub上整理过一份简易FC模拟器的框架,核心就是这个循环:每执行若干个CPU周期,检查一次PPU行状态,行结束时跑一次音频采样合成。只要这三个模块的节奏对齐了,游戏画面和声音就基本不会漂移。
2.2 Mapper机制与ROM兼容性
FC游戏卡带五花八门,不同的卡带用了不同的Mapper芯片来管理ROM和存档。模拟器如果不支持对应的Mapper,游戏就是白屏或者永远重复开头那一段。最常见的是Mapper 0(NROM,无扩展)、Mapper 1(MMC1,支持存档)、Mapper 2(UROM,常见于魂斗罗这类)、Mapper 3(CNROM)和Mapper 4(MMC3,大量动作游戏都在用)。
S3的内存虽然大,但你不太可能把所有Mapper的模拟逻辑都写一遍。我的做法是先把Mapper 0、2、4跑通,这三个加起来覆盖了市面上七成以上的中文卡游戏。Mapper 4的模拟要注意IRQ定时器:很多游戏靠它来做竖屏滚动和分屏特效,如果IRQ触发时机不对,画面就会出现奇怪的重复行或者分裂线。
ROM文件本身也有讲究。很多网上下载的ROM是加密过的(比如带有CIC锁区校验),在模拟器里要做解密处理。如果你的模拟器跑起来黑屏但CPU有动静,十有八九是ROM需要做解密或校验绕过。另一个常见的坑是ROM尾部有垃圾数据,会影响存档大小判断,最好用工具先清理一下再放进文件系统。
2.3 性能优化三板斧:渲染加速、音频合成、输入响应
第一板斧是渲染加速。240x240的屏幕在SPI模式下,全屏刷新一帧需要大概12ms,如果每帧都全刷,理论帧率只有80多帧,看上去还行,但你还要同时跑CPU和音频,争抢总线会让整个系统卡顿。我实测的最佳方案是:在PSRAM里开辟两个帧缓冲,CPU和PPU合成逻辑往后台缓冲写,每完成一帧就通过SPI-DMA把整块缓冲区送到屏幕,期间CPU完全不阻塞,DMA自己搞定传输。这样画面的撕裂和残影会明显减少。
第二板斧是音频合成。用纯浮点计算合成声音在S3上会很慢,我建议把音量衰减和波形累加全部改成定点数运算,比如用16位整数表达0~1的振幅,用查表法替代正弦函数。FC的矩形波很简单,本质就是查表比较产生高低电平,三角波也是查表,所以根本用不着浮点库。实测定点化之后,音频合成的CPU占用从30%降到了10%以内。
第三板斧是输入响应。FC游戏对按键延迟很敏感,尤其是动作游戏。我做的按键扫描放在一个1ms定时器中断里,扫描结果直接写到一个全局变量里,游戏循环在每次帧开始时读取这个变量。这样做的好处是,按键不会被CPU的渲染循环卡住,就算这一帧渲染慢了,按键状态还是能及时更新。
3. 实操流程:从刷环境到跑出第一帧画面
3.1 搭建编译环境与板卡配置
我推荐用ESP-IDF而非Arduino来干这件事。Arduino的框架对SPI-DMA和I2S的低层控制没有IDF那么直接,虽然Arduino也能跑,但你想做双缓冲、调整DMA描述符、或者细调中断优先级时会非常别扭。当然如果你只是想在开发板上快速亮个画面试试,Arduino也完全可以,很多现成的库(比如TFT_eSPI)对ST7789的支持做得非常好,省掉你不少时间。
装好ESP-IDF之后,第一步是选目标芯片。在menuconfig里设置Target为esp32s3,然后确认PSRAM选项打开。S3的PSRAM有几种模式:Octal(8线)和Quad(4线),不同模组用的不一样。如果你用的是官方DevKitC,默认Quad PSRAM没问题,但如果你用的是合宙的AirM2M Core板,很可能是Octal PSRAM,选错模式会直接导致启动时无法初始化PSRAM,日志里会报PSRAM ID read error。我看到好几个人在这个问题上卡了一整天。
分区表也要改成适合放ROM的结构。我的建议是自定义一个分区表,给storage分区划8MB(如果你用的是16MB Flash版本),这样你可以同时塞进去几十个游戏。默认的factory分区只有几MB,够呛。
编译优化等级选-Os还是-O2?我测试下来,在S3上跑模拟器,-O2比-Os整体帧率能提升8%左右,代价是固件体积大了约50KB。考虑到Flash容量完全够用,我建议直接上-O2,甚至可以开-O3,但要注意有些编译器优化在信号处理循环里可能引入指令重排问题,如果跑起来声音有爆音,可以试试关掉优化重编一次。
3.2 模拟器移植与烧录完整步骤
先说思路:你要移植一个FC模拟器到S3,最省力的办法是在GitHub上找一个现有的ESP32 FC模拟器项目(比如ino-FC或ESP32-NES),然后把它的显示驱动、音频输出和按键扫描换成你自己板子对应的实现。如果你像我一样想自己写核心逻辑,那就按照第2节的框架,把CPU、PPU、APU三块实现好再对接底层驱动。
这里给出一个可复现的烧录流程:
- 克隆模拟器项目到本地,进入目录。
- 打开menuconfig,确认以下配置:
Serial flasher config -> Flash size设为你的Flash实际大小;Component config -> ESP32S3-Specific -> Support for external, SPI-connected RAM开启;Component config -> ESP32S3-Specific -> SPI RAM config -> Mode (QUAD/OCT)选择正确模式;Partition Table -> Custom partition table CSV指向你自定义的分区表。
- 把游戏ROM文件转换成二进制格式,并用
parttool.py烧到storage分区:
parttool.py --port /dev/ttyUSB0 \ --partition-name storage \ write_partition --filename ninja.nes如果一次要烧多个ROM,可以用Python脚本循环写入,或者直接把文件系统打包成镜像,上电后模拟器自己从分区里扫描ROM列表。
- 编译烧录主固件:
idf.py build idf.py -p /dev/ttyUSB0 flash monitor第一次上电如果你看到屏幕亮起来但全白,不要慌。先检查PSRAM是否初始化成功,用esp_psram_get_size()打印一下容量,然后检查ROM文件是否读取成功。我遇到过好几次因为分区表大小配置不对,导致ROM读取时地址越界,结果读回来全是0xFF,画面自然就是纯白。
3.3 第一次上电调试的检查顺序
我调试这类项目有一套固定的顺序,能少走很多弯路。第一步是串口日志,先确认系统启动没有panic,PSRAM和屏幕驱动都初始化成功。第二步是屏幕,用一块纯色填充的测试代码,看显示是否正常、有没有花屏条纹。第三步是按键,按下一个键在串口打印对应的键值,确认引脚定义没搞反。
第四步才是加载ROM进模拟器。我会用一个简单的启动ROM,就是那种只显示Logo的测试卡带,先验证CPU和PPU的最小闭环能跑通,如果画面能出现稳定的图像,再换成完整的游戏ROM。这一步非常关键,因为如果直接把动作游戏丢进去,出了问题你会分不清是PPU同步的问题还是Mapper的问题。
最后一步是音频。先播放一个单音调的测试音,确认I2S数据链路的时钟和左右声道配置正确,再接上模拟器的APU合成输出。如果你用的是PWM方案,注意PWM的基频最好不要低于30kHz,否则人耳能听到明显的齿音。
4. 常见问题与排查技巧实录
4.1 画面相关:花屏、残影、撕裂
花屏的原因一般是PPU的瓦片表读取地址不对。FC游戏在运行时经常切换PPU Address寄存器,如果你模拟器里的地址总线逻辑写错了,画面就会出现"马赛克乱飞"的现象。排查方法是:在每次PPU地址寄存器写入时,在串口打印地址和值,然后对照游戏正常运行的调试记录去检查,一两个小时就能定位到问题。
残影和拖尾一般出在LCD面板响应时间上,和模拟器本身无关。如果你用的是普通IPS屏,像素响应时间在30ms左右,玩高速滚动的游戏就会感觉"前面一帧还没消失后面一帧又叠上来"。解决方案有两种:一是换更高刷新率的屏,成本高;二是在代码里降低对比度或加快翻页速度,效果有限但能缓解。
画面撕裂则是DMA传输中间切帧导致的。我一开始在DMA传输到一半时去写后台缓冲,结果屏幕中间出现一条明显的断裂线。解决办法是给后台缓冲和DMA传输加一个互斥标志,只有上一帧DMA完成中断触发时,才允许后台缓冲开始写下一帧数据。这样就把"写缓冲"和"传屏幕"隔离开了。
4.2 音频问题:爆音、杂音、静音
爆音最常见的原因是混音时整数溢出。五个APU通道的音量值加起来很容易超过16位整数的最大值,你需要在混合前先按比例衰减,比如每个通道的采样值右移3位再相加,最后再做限幅裁剪。
还有一个我踩过很多次的坑:I2S的数据位宽配置。S3的I2S可以输出16位或32位数据,如果你外接的DAC是16位,但代码里面写的是32位,声音会变成"沙沙"的电流声,听感上就像中间隔着马蜂窝。
另外,有朋友在群里问过"inmp441连接esp32s3一直都是峰值1"这种问题。inmp441是一个I2S接口的数字麦克风,它的数据格式和DAC不一样:麦克风输出的是24位有符号数据,而且极性和DAC是相反的。如果你把麦克风的SCK和WS接反了,或者BCLK和LRCLK搞混,读出来的数据就会永远是一个固定值(比如全1),看起来就像一直处于爆音峰值。排查时先用逻辑分析仪看SCK和WS的波形是否正常,再把数据的位宽改成24位左对齐模式,一般就能解决。你要是拿这块麦克风做模拟器配套的"声控菜单"或者"环境音效显示",这个问题必须搞定,不然数据全是1,啥也干不了。
如果声音完全静音,先检查GPIO矩阵里音频引脚的mux配置是否正确,特别是当音频引脚和屏幕像素同步信号共用时,有时候会被初始化代码错误地重新配置成普通GPIO。
4.3 运行稳定性:死机、复位、帧率不稳
死机九成出在内存访问越界。S3的PSRAM虽然大,但MMU配置有时候会和你自己的malloc逻辑冲突,尤其是当你用heap_caps_malloc分配PSRAM内存时,如果指针被错误地当作内部SRAM指针使用,系统会随机异常复位。建议所有分配PSRAM内存的代码统一走EXT_RAM_BSS_ATTR或heap_caps_malloc(... MALLOC_CAP_SPIRAM),并把模拟器的帧缓冲、ROM缓存区都放进去,内部的SRAM只放少量热数据和堆栈。
帧率不稳定,先看日志里的CPU占用率。如果CPU占用连续几帧都超过85%,基本是CPU模拟部分出现了性能瓶颈。优先检查6502的指令循环里是不是用了大量不必要的内存拷贝,比如每次执行指令都从ROM缓冲区拷贝一个字节出来,这种操作在C++里面看起来没问题,但在高频循环里累积起来还是可观的。改成直接用指针访问缓冲区,能明显提速。
还有一颗隐藏地雷是WiFi和蓝牙。S3的无线模块在开启状态下会占用CPU周期和电源,导致模拟器帧率波动。模拟器运行时强制关闭WiFi和蓝牙:
esp_wifi_stop(); esp_bt_controller_disable();这一行能让你的帧率稳定一个明显的台阶。
4.4 串口调试技巧
S3的串口日志默认会输出大量I(123) xxx: ...信息,调试时还好,正式运行时这些日志打印会消耗I/O时间,导致画面卡顿。建议在最终版本里把日志等级调到ERROR:
esp_log_level_set("*", ESP_LOG_ERROR);或者干脆把所有ESP_LOGI用宏封起来,编译时直接裁剪掉。
我在实际的调试过程中还会用串口配合一个简易的性能监视器:每100帧打印一次当前帧耗时、最小耗时、最大耗时和音频缓冲占用率。这四个指标能快速定位瓶颈在PPU渲染、APU合成还是总线争抢上。特别是音频缓冲占用率,如果它稳定在50%以下,说明音频合成速度足够;如果经常顶到90%以上,就要优化混音代码了。
5. 进阶玩法与个人体会
5.1 加上金手指和存档
FC模拟器的魅力在于可以随意改游戏。在S3上做金手指很简单,原理就是在每帧CPU执行前扫描一个作弊码列表,把对应的内存地址强制写成一个指定值。比如魂斗罗的30条命,其实就是一个特定的内存地址被不断重置为30。你可以在命令行菜单里输入作弊码,模拟器解析后放进一个哈希表,每帧循环应用即可。
存档功能则需要借助S3的Flash文件系统。最简单的做法是每5秒把PRG RAM和存档RAM的内容整体快照到SPIFFS或LittleFS里,游戏内按组合键手动存档时再做一次强制快照。恢复存档时,把所有内存状态恢复,然后重置PPU同步状态,接着就能从存盘点继续玩。我在实测中玩《重装机兵》这种超长流程RPG时,这个功能简直是救命级别的存在。
5.2 更多扩展思路
如果你觉得红白机不过瘾,S3完全有能力跑GB/GBC模拟器、甚至一部分MD模拟器。我自己试过在S3上跑GB模拟器,帧率可以稳定在60fps,画面还比FC更精细。如果你愿意牺牲一点帧率换成更高的分辨率,S3也是能做SFC部分游戏的(当然比较吃力,要看具体游戏)。
外设扩展方面,可以加一个SD卡槽来扩充ROM库,免去反复烧录Flash的烦恼。也可以加I2C接口的键盘控制器来做菜单操作。我甚至见过有大佬给S3掌机加了振动马达和陀螺仪,玩《气球大战》时倾斜机身就能控制风向,已经超出了"模拟器"本身的范畴。
5.3 一点真心话
我做这个项目前前后后折腾了快一个月,中间有无数次想砸板子。最难的不是写6502的指令集,而是感觉"什么都能跑,但又什么都差一点"——画面偏了一像素、声音快了一拍、某个游戏开头过不去。但当你第一次把《超级马里奥》完整跑起来,听到地面的节奏声和跳跃的音效完美咬合时,那种成就感秒杀很多所谓的高级项目。
后来我复盘时发现,这类项目的真正价值不在"模拟器"本身,而在于它是一个绝佳的嵌入式全栈练习:你同时用到了CPU体系结构知识、实时操作系统思维、图像处理、数字音频、文件系统、DMA和低功耗设计,每一项单独拿出来都够写好几篇文章。所以我很推荐有嵌入式基础、又想玩点"不太正经"项目的朋友试一次。从零开始移植一个FC模拟器,你会重新理解什么叫"看似简单,实则复杂"。
最后分享一个小技巧:调试模拟器时,不要用那种画面华丽的游戏来验证,先用《坦克大战》或者《俄罗斯方块》这类逻辑简单、画面元素少的游戏做测试目标,出问题了容易定位。等整套流程稳定了,再上《最终幻想》和《勇者斗恶龙》这种大块头,才能在你满头大汗修Bug的时候,保住最后的耐心。