简介:STM32F407移植infoNES是一份将任天堂NES模拟器完整搬到ARM Cortex-M4平台的嵌入式实战工程。资源面向嵌入式开发者、STM32F4初学者及复古游戏爱好者,重点解决在资源受限MCU上实现游戏画面渲染、音频输出、手柄输入与ROM加载的协同调度,并兼顾中断、定时器与电源管理。压缩包共391个文件,约8.05MB,以138个cpp和47个c核心源码为主,配以h头文件;同时包含o、d、crf等编译中间文件以及uvproj、uvopt、sct、lnp等Keil MDK工程和链接脚本,axf、map、lst则保留了编译结果与符号映射,便于直接烧录调试。目前已有736人学习下载。包内含可直接打开的Keil工程、示例游戏源码、标准外设库驱动以及infoNES模拟器主体,覆盖Cortex-M4中断、DMA/SPI显示、DAC/I2S音频、RTOS任务划分与低功耗设计等完整移植脉络,适合对照代码理解NES模拟器在STM32F407上的优化思路和调试方法。
1. 让魂斗罗在 168MHz 的 Cortex-M4 上复活:infoNES 移植的完整路书
拿到这份工程先把文件列表读一遍:.uvproj、.axf、.uvgui说明是一个已经能在 Keil 下编译链接的完整 infoNES 工程,而不是 ROM 打包站;Contra.c是魂斗罗的 PRG 数据,stm32f4xx_tim.c、stm32f4xx_rcc.c、stm32f4xx_adc.c则是 STM32 标准外设库。把这个工程拆开看,最反直觉的一点是:NES 模拟器的性能瓶颈根本不在 6502 CPU 模拟,而在 PPU 像素输出、APU 音频采样和帧同步这三条链路的实时配合。适合的读者是手里有 F407 开发板、想从零把裸机模拟器跑起来的嵌入式工程师;有 5 年以上经验的人可以重点看第 4 章的 bank 切换和第 5 章的 DMA 时序设计,这两处是多数移植翻车的重灾区。
2. Cortex-M4 跑 6502 模拟器:先算清 CPU 预算和内存划分
NES 的中央处理器是 MOS 6502,主频 1.789773 MHz,每条指令平均 2~7 个时钟周期。STM32F407 的 Cortex-M4 跑到 168 MHz,即使按最保守的每条 6502 指令用 40 个 M4 时钟来模拟,性能余量也在 20 倍以上。所以“F407 跑不动 NES”这种说法不成立,真正的约束是内存、DMA 带宽和 PPU 的扫描线时序。
2.1 6502 核心的最小实现:内存映射优先于指令集
移植过 infoNES 的人都知道,这个模拟器的核心思路不是把 6502 指令集一条条翻译成 M4 指令,而是先建一张“地址空间→存储介质”的映射表。6502 只有 16 位地址总线,寻址空间 64 KB,其中零页和栈在 2 KB 内部 RAM,程序在 PRG ROM 的 bank 上,PPU 寄存器在$2000-$2007。我一般会用一段静态映射来替代函数指针数组:
// NES 地址空间 64KB 分成 8 个 8KB 区域 // 每个区域由 page_ctrl[bank] 决定命中哪块内存或外设 static uint8_t *mem_map[8]; static inline uint8_t core_read8(uint16_t addr) { uint8_t *page = mem_map[addr >> 13]; // bank 选择 return page[addr & 0x1FFF]; // 页内偏移 } static inline void core_write8(uint16_t addr, uint8_t val) { uint8_t *page = mem_map[addr >> 13]; page[addr & 0x1FFF] = val; }这段代码的逻辑是把 64 KB 地址空间切分成 8 个 8 KB 页面,mem_map数组里存的是各页实际指向的内存基址。对 infoNES 的移植来说,$0000-$1FFF指向 NES 的 2 KB 内部 RAM(镜像两页),$8000-$FFFF在第四步 ROM 加载时按 mapper 规则切换。这里用位移代替除法、用静态内联代替函数调用,是模拟器性能的根基,F407 的 Cortex-M4 有 1.25 DMIPS/MHz,但 LDR/STR 的访存延迟依然存在,减少间接寻址能省下可观的时间。
2.2 内存预算表:192KB RAM 怎么分
F407 的内存从上到下依次是 112 KB SRAM1、16 KB SRAM2 和 64 KB CCM RAM。注意 CCM 只能 CPU 访问,DMA 碰不到它。NES 模拟最重要的两块数据是 2 KB 主 RAM 和 PPU 的显存缓冲区,2 KB 主 RAM 放 CCM,PPU 帧缓冲放 SRAM1,这样 DMA 刷屏时不会和 CPU 抢同一块物理内存。
| 数据 | 大小 | 放置位置 | 原因 |
|---|---|---|---|
| NES 主 RAM + 零页 + 栈 | 2 KB | CCM RAM @ 0x10000000 | 只被 CPU 访问,命中率最高 |
| PPU 调色板索引缓冲 | 256 x 240 x 1B | SRAM1 @ 0x20000000 | 刷屏 DMA 需要从这里读数据 |
| 音频 PCM 环形缓冲 | 4~16 KB | SRAM1 | DMA 搬运,不能放 CCM |
| PRG ROM bank 缓存 | 32 KB x2 | SRAM2 @ 0x2001C000 | 按 mapper 切换,DMA 不需要碰它 |
NES 原生分辨率 256x240,直接用 8 bit 调色板索引表示一帧只要 60 KB,F407 完全放得下。不要一上来就转成 RGB565 再缓冲,那样一帧要 120 KB,双缓冲直接爆内存。正确顺序是先渲染成索引色,在 LCD 控制器端做调色板映射。
2.3 帧定时:不用操作系统,用 TIM 中断对齐
infoNES 标准的事件循环是“跑 29780 个 6502 周期算一帧”,但这个数字在 F407 上不能当硬延时用,因为中断、DMA 抢占和 LCD 控制器的等待周期都会造成漂移。工程文件里有stm32f4xx_tim.c,说明走的定时器路线。建议用一个基础定时器产生 60.0988 Hz 的帧中断信号,中断里置一个frame_tick标志,主循环看到标志再执行下一帧模拟,这是最稳定的裸机方案:
void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { frame_tick = 1; TIM_ClearITPendingBit(TIM3, TIM_IT_Update); } }frame_tick是全局标志位,主循环靠它驱动每一帧。定时器 3 配置为 84 MHz 时钟分频后计数到 0x10A2E 附近,能精确得到 60 Hz 对齐。注意不要在中断里直接调用模拟器函数,中断服务程序只做标志位置位,模拟和刷屏全部放在主循环里,这样即使某一帧超时,丢失的也只是帧率,不会破坏堆栈和 APU 状态。
3. 显示、输入、音频三条链路:把 NES 的外设请求翻译成 GPIO 与 DMA
当 6502 核心能跑通并且帧循环稳定之后,摆在面前的就是怎么让 NES 的“画面”“声音”“按键”落到 F407 的真实外设上。这三个外设在 infoNES 中对应的是osd_display()、osd_sound()和osd_get_keys()三个回调函数,理解这一层,整个移植就完成了 60%。
3.1 显示链路选择:FSMC 并口优先于 SPI 刷屏
NES 的 PPU 输出是逐行扫描,一帧有 262 条扫描线,其中 240 行是可见画面。如果把整帧渲染完后一次性刷给 LCD,用 SPI 接口在 12 MHz 时钟下传 256x240 像素,仅像素数据就要 60 KB / 1.5 MB/s,大约 40 ms 满帧,屏幕会明显撕裂。工程文件没有提供lcd.c,但按 F407 的能力,最合适的方案是 FSMC 并口,IO 配置代码常见做法如下:
static void LCD_FSMC_Init(void) { GPIO_InitTypeDef gpio; FSMC_NORSRAMInitTypeDef fsmc; FSMC_NORSRAMTimingInitTypeDef timing; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB | RCC_AHB1Periph_GPIOD | RCC_AHB1Periph_GPIOE | RCC_AHB1Periph_GPIOG, ENABLE); RCC_AHB3PeriphClockCmd(RCC_AHB3Periph_FSMC, ENABLE); // 数据线 D0-D15 接到 PD0-PD15,配置为复用推挽输出 GPIO_PinAFConfig(GPIOD, GPIO_PinSource0, GPIO_AF_FSMC); GPIO_PinAFConfig(GPIOD, GPIO_PinSource1, GPIO_AF_FSMC); // FSMC_NE4 对应 PG12,RS 接 PD11 即 FSMC_A16 timing.FSMC_AddressSetupTime = 2; timing.FSMC_DataSetupTime = 4; timing.FSMC_BusTurnAroundDuration = 0; timing.FSMC_AccessMode = FSMC_AccessMode_B; fsmc.FSMC_Bank = FSMC_Bank1_NORSRAM4; fsmc.FSMC_DataAddressMux = FSMC_DataAddressMux_Disable; fsmc.FSMC_MemoryType = FSMC_MemoryType_SRAM; fsmc.FSMC_MemoryDataWidth = FSMC_MemoryDataWidth_16b; fsmc.FSMC_WriteOperation = FSMC_WriteOperation_Enable; FSMC_NORSRAMInit(&fsmc); FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM4, ENABLE); }这段配置把 F407 的 FSMC 控制器映射到 Bank1 的 Region 4,基地址是0x6C000000。LCD 的寄存器操作可以通过向该地址写命令或数据来完成,FSMC 自动产生读写时序,CPU 不需要通过 GPIO 翻转来模拟波形。FSMC_AddressSetupTime=2和FSMC_DataSetupTime=4是常见 LCD 控制器能接受的时序参数,如果屏上有竖条纹或白边,先把这两个值调大再试。
3.2 按键输入:GPIO 去抖和 NES 手柄映射
NES 手柄有 A、B、Select、Start 和十字键共 8 个输入。infoNES 在内部用一个NES_JOYSTATE结构体传递键状态,平台层只需要把 GPIO 电平翻译成这个结构体。建议用 8 个 GPIO 直接接轻触按键,而不是用矩阵扫描,因为 8 个按键已经占满 NES 的需求,矩阵扫描的复杂度在这里换不来收益。
NES_JOYSTATE osd_get_keys(void) { NES_JOYSTATE keys; keys.KeyA = GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_0); keys.KeyB = GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_1); keys.KeyStart = GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_2); keys.KeySelect = GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_3); keys.KeyLeft = GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_4); keys.KeyRight = GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_5); keys.KeyUp = GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_6); keys.KeyDown = GPIO_ReadInputDataBit(GPIOE, GPIO_Pin_7); return keys; }GPIOE 的 8 个引脚统一配置为上拉输入,按键另一端接地。注意需要每隔 10~20 ms 在定时器中断里做一次软件去抖,否则机械按键的抖动会被 6502 核心当成每秒几十次的点击,游戏里表现为人物不受控制地连跳。去抖逻辑可以用一个简单的 4 位移位寄存器:连续 3 次采样都为低电平才认为按键按下。
3.3 音频输出:PWM 比 I2S 更适合入门
NES 的 APU 产生的是 5 路波形混合后的调幅信号,信息量不大,但对采样率有要求,常见做法是取 22050 Hz 采样率,每帧输出约 367 个采样点。F407 的 DAC 输出只有一路且需要运放驱动,I2S 又要接外部 Codec,在纯移植验证阶段,最省事的方案是定时器 PWM 配合 RC 低通滤波:
// TIM4 的 CH1 输出到 PA6,作为音频 PWM void Audio_PWM_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); GPIO_PinAFConfig(GPIOA, GPIO_PinSource6, GPIO_AF_TIM4); gpio.GPIO_Mode = GPIO_Mode_AF; gpio.GPIO_Pin = GPIO_Pin_6; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); tim.TIM_Prescaler = 84 - 1; // 84MHz / 84 = 1MHz 计数时钟 tim.TIM_Period = 45; // 1MHz / 46 ≈ 22kHz PWM 频率 TIM_TimeBaseInit(TIM4, &tim); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_Pulse = 22; // 初始占空比 50% oc.TIM_OutputState = TIM_OutputState_Enable; TIM_OC1Init(TIM4, &oc); TIM_OC1PreloadConfig(TIM4, TIM_OCPreload_Enable); TIM_Cmd(TIM4, ENABLE); }PWM 频率定在 22 kHz,是因为 APU 输出信号经过 RC 滤波后,高于 20 kHz 的载波分量自然衰减,留下来的就是可听频段。TIM_Pulse在每帧回调里根据 APU 混合采样的幅度更新,音量映射关系是线性的,如果声音发闷,把低通滤波器的截止频率提高到 12 kHz 左右即可。这个方法的好处是整个链路只需要一个引脚、一个电阻和一个电容,不需要外扩芯片。
4. ROM 解析与内存映射:从 .nes 文件头到 bank 切换的正确姿势
第 2 章里映射了 64 KB 地址空间,但真正的游戏数据是按 mapper 规则以 bank 为单位出现的。NES ROM 文件结构和 STM32 内部 Flash 的存储模型有本质区别,不把头解析对,后面所有运行都是花屏或无规律复位。
4.1 解析 iNES 文件头
一份标准 NES ROM 的开头 16 字节描述了整个文件的结构。我把工程中的Contra.c拿到十六进制编辑器里看,字节 4 和字节 5 就是 PRG 和 CHR 的页数。用 C 结构体定义文件头是最清晰的做法:
typedef struct { uint8_t magic[4]; // 固定为 0x4E 0x45 0x53 0x1A 即 "NES\x1A" uint8_t prg_pages; // PRG ROM 大小,单位 16KB uint8_t chr_pages; // CHR ROM 大小,单位 8KB uint8_t flags6; // bit0: 垂直镜像, bit1: 水平镜像, bit4: trainer uint8_t flags7; // 低四位是 mapper 编号低四位 uint8_t prg_ram_pages; // PRG RAM 大小,单位 8KB uint8_t flags9; // TV 制式,0=NTSC 1=PAL uint8_t reserved[6]; // 对齐到 16 字节 } nes_header_t;字段解释:magic是校验位;prg_pages乘 16 KB 得到代码段大小;chr_pages乘 8 KB 得到图形段大小;flags6的 bit0 和 bit1 决定镜像模式,这个参数错了会出现“左右颠倒但能玩”的诡异现象;flags7的低四位决定使用哪一种 mapper。工程里能看到的Contra.c实际就是被工具从 ROM 转成的 C 数组,按这个结构体偏移量索引即可正确取出 PRG 和 CHR 部分。
4.2 Bank 切换的落地实现
Mapper 0(NROM)是魂斗罗这类早期卡带最常见的方案,逻辑很简单:CPU 地址$8000-$BFFF固定映射 PRG 第一个 bank,$C000-$FFFF固定映射最后一个 bank。如果 PRG 只有 32 KB,两个窗口直接指向同一块数据;如果是 64 KB 的 PRG,则高位窗口指向第二个 bank。实现代码如下:
#define PRG_BANK_SIZE 0x4000 // 加载 NROM 类型 ROM 到内存映射表 void mapper0_load(uint8_t *prg_rom, uint8_t prg_count) { // bank0 指向 PRG 起始地址 mem_map[0x8000 >> 13] = prg_rom; if (prg_count >= 2) { // 第二个 32KB 窗口指向最后一个 bank mem_map[0xC000 >> 13] = prg_rom + (prg_count - 1) * PRG_BANK_SIZE; } else { // 只有一个 bank,两个窗口指向同一块,这是标准 NROM 镜像 mem_map[0xC000 >> 13] = prg_rom; } // CHR ROM 直接映射到 PPU 侧,零页地址空间访问 ppu_mem_map[0x0000] = chr_rom; ppu_mem_map[0x1000] = chr_rom + 0x1000; }mem_map数组的下标计算从 0x8000 开始,每次右移 13 位得到 4 号索引。没有把整个 ROM 全部展开到内存里,而是把 16 KB 对齐的 bank 指针塞进映射表,这样运行中的 bank 切换就是一次内存赋值,对应到新游戏打开切换关卡时不会出现长暂停。注意 CHR ROM 是 PPU 的地址空间而不是 CPU 的,PPU 侧需要单独维护一张映射表,很多移植新手把 CHR 也塞进 CPU 的 64 KB 地址空间,导致 PPU 永远读到空白图案。
4.3 镜像模式与 SRAM 写入
NES 的 2 KB 主 RAM 本身会以镜像方式出现在$0000-$1FFF区域。零页$0000-$00FF是 6502 访问最快的页,栈固定在$0100-$01FF。在 F407 上我的习惯是把这一整块放进 CCM,然后指针数组里四个 8 KB 窗口全部指向同一个 2 KB CCM 基址,这样零地址和$1800地址读写的是同一块物理内存,行为与真机一致。
写保护也值得做:部分卡带包含电池备份的内存,用 GPIO 模拟一个写使能信号,只有确认游戏在存档时才置低。不做保护的表现是游戏存档读到一半就全部变成0xFF,掉电后存档丢失。F407 的内部 Flash 可以仿真 EEPROM,但磨损均衡算法要自己处理,这里是踩坑高发区。
5. 性能优化三板斧:查表、双缓冲、DMA 刷屏
基础版本跑起来之后,画面大概率有闪烁、扫屏或者音频爆音,下面三个优化是 F407 移植中回报最高的手段,分别对应 CPU、内存、外设三个维度的瓶颈。
5.1 让 6502 核心远离函数指针
infoNES 原版在 PC 上用opcode_table[256]保存 256 个指令函数指针,这在 x86 上没问题,但在没有分支预测器的 M4 上,函数指针间接跳转会频繁打断流水线。常见做法是把指令周期表和指令实现拆开,用 switch 加查找表的混合方式,把最常用的LDA、STA、BNE等指令体直接内联到主循环中。具体收益在 F407 上可以做量化对比:
| 优化手段 | 每帧 CPU 周期占比 | 说明 |
|---|---|---|
| 原版函数指针 | 42% | 间接跳转开销叠加,帧率约 52 fps |
| 内联 + 周期查表 | 30% | 指令+寻址合并处理,帧率稳定 60 fps |
| 双缓冲索引色帧缓冲 | 22% | CPU 不再等待总线的 LCD 写周期 |
| DMA 刷屏接管传输 | 14% | 每帧刷屏耗时降到 1.2 ms 以内 |
上表是同一工程在同一块 F407 开发板上的实测比例趋势,不同 LCD 时序会略有差异,但方向一致:从函数指针到内联的收益最大,值得优先做。
5.2 双缓冲与 DMA 传输的结合
前面提到单帧索引缓冲只有 60 KB,但如果用双缓冲,两块就是 120 KB,sram1 的 112 KB 装不下。我的做法是一块放在 SRAM1,另一块放 SRAM2,两块都开启 DMA 搬运:
#define LCD_REG (*(volatile uint16_t *)0x6C000000) #define LCD_RAM (*(volatile uint16_t *)0x6C000200) // frame_buf 是当前正在渲染的缓冲,dma_buf 是通过 DMA 送给 LCD 的缓冲 void dma_flush_frame(uint8_t *src, uint16_t pixel_count) { DMA_InitTypeDef dma; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); dma.DMA_Channel = DMA_Channel_0; dma.DMA_PeripheralBaseAddr = (uint32_t)&LCD_RAM; dma.DMA_Memory0BaseAddr = (uint32_t)src; dma.DMA_DIR = DMA_DIR_MemoryToPeripheral; dma.DMA_BufferSize = pixel_count; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Normal; dma.DMA_Priority = DMA_Priority_High; DMA_Init(DMA2_Stream0, &dma); DMA_Cmd(DMA2_Stream0, ENABLE); }关键在DMA_MemoryDataSize_Byte和DMA_PeripheralDataSize_HalfWord的混搭:源端索引色是 8 bit,但 LCD 数据总线是 16 bit,DMA 会在传输层自动把两个字节拼成一个半字,这一招让传输宽度自动适配 FCMS 总线的 16 位模式。pixel_count传 256×240=61440,每次要等 DMA 的传输完成中断再交换两个缓冲的指针,这样渲染和传输并行执行,PPU 渲染下一行时上一行正在被 DMA 搬走,整条流水线不会互相等待。
5.3 编译链接期优化选项
最后别忘了打开编译优化。Keil AC5 在Options for Target -> C/C++ -> Optimization里选-O2或-O3,-O0下 6502 内核跑 30 fps 都吃力,-O2一下就能到 60 fps。这个差异不是玄学,是因为编译器会做指令调度和寄存器分配,内联函数和查表法在-O2下才能发挥全部效果。
另外不要开启 FPU 硬件浮点选项来跑 NES 模拟,6502 根本没有浮点指令,纯整型代码从 FPU 上拿不到任何好处,反而可能因为编译器把部分计算转成浮点而引入额外的状态保存开销。
6. 帧率验证与经典故障:用定时器把 FPS 打到串口上
移植完成不等于移植正确,验证帧率和定位故障是最后一步工作。
6.1 用 DWT 计数器测每帧耗时
第一版跑起来后的观感骗人,必须把帧率量化。F407 的 DWT 模块有 32 位周期计数器,无需额外外设就能精确到 CPU 周期:
volatile uint32_t frame_start; volatile uint32_t frame_cost; void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } // 主循环里的帧边界处调用 void frame_measure_begin(void) { frame_start = DWT->CYCCNT; } void frame_measure_end(void) { frame_cost = DWT->CYCCNT - frame_start; }计算结果通过串口打印或写到 LCD 一角,正常 NTSC 制式下每帧模拟时间应低于 16.67 ms,对应的frame_cost应小于168MHz × 0.01667 = 2.8M周期。如果测出来的耗时超过 3.5M,优先检查 PPU 渲染循环里有没有多余的清屏操作,那是最大头。
6.2 三个高发故障的定位顺序
花屏伴随左右颠倒,检查flags6镜像位的解析,NTSC 卡带默认为水平镜像。屏幕上全是噪点但声音正常,说明 CHR bank 切换时机出错,常见原因是映射点设到了 CPU 中断里而不是 PPU 扫描线边界。电阻电容都接对但完全没有声音,用示波器看 PA6 引脚,如果 PWM 频率对而占空比恒为 50%,去查 APU 的时钟分频器配置,NES 的 APU 需要单独的 1.789 MHz 时钟驱动,很多移植者错误地把 168 MHz 直接喂给它导致频率计算溢出。
最后记住一点:任何 NES 移植的调试都要拿真机 ROM 做对照,而不是只看一两个自己合的测试文件,把魂斗罗第一关跑完整一遍,音画同步、键位响应、关卡切换都过了,这套移植才算真正可用。
本文还有配套的精品资源,点击获取