简介:这是一份将经典NES模拟器移植到STM32F407微控制器上的嵌入式工程资源,面向嵌入式开发爱好者、STM32玩家及想学习游戏模拟器原理的读者。项目以可运行代码为基础,完整展示如何把红白机游戏搬到单芯片上:从RTOS多任务调度、液晶显示、音频与手柄输入,到游戏ROM加载、解码与执行,均有实现。压缩包共391个文件,容量8.05MB,核心源码以cpp、c、h为主,另有编译生成的o、d、crf中间文件,以及Keil工程配置、链接镜像、分散加载文件,可直接打开工程查看编译流程。目前已有736人学习下载。阅读这套工程,可深入理解中断管理、帧同步、外设驱动和性能调优方法,也能借鉴低功耗设计与调试排错做法,是学习STM32游戏移植不可多得的参考资料。
1. 为什么要在STM32F407上跑infoNES
STM32F407移植infoNES,本质是拿一颗168MHz的Cortex-M4去还原8位游戏机的完整执行环境。infoNES是专为嵌入式设计的NES模拟器,代码量小、依赖少,CPU、PPU、APU全部由软件仿真,移植时只需要把显示、输入、音频三个出口接到芯片外设上。F407恰好处在性能甜区:主频和内存比F1系高一截,跑infoNES能逼近60帧;相比再上层的MPU又不需要跑系统。这颗芯片搭配一块TFT屏就是最典型的落地组合。接下来的内容按真正动手的顺序推进:先对齐资源需求,再定外设方案,然后写工程代码,最后调帧率和声音,适合想从裸板看到游戏画面的嵌入式工程师。
2. 把infoNES的资源账算完,看STM32F407还剩多少余量
2.1 infoNES用掉的不是堆,是三块固定数组
动手之前要先把模拟器的内存模型理清。NES主机的内存分CPU侧2KB RAM和PPU侧8KB VRAM,infoNES把这两块区域实现成NesRAM、NesVRAM这类全局数组。此外还有PRG-ROM和CHR-ROM映射,指向加载进来的游戏ROM。移植时会发现一个对嵌入式很友好的事实:infoNES不依赖malloc,核心模拟逻辑里没有堆分配,所有空间在编译期就是确定的。
这样做的好处是内存占用完全可预期,坏处是这些数组一旦在链接脚本里定错了地址,运行后会出现随机花屏、跑飞这类最难查的故障。PPU渲染这边还要单独算一笔账:infoNES渲染的中间结果不是RGB数据,而是调色板索引。NES主机一帧画面是256x240,如果每个像素用8位索引表示,一帧约60KB;如果在模拟器内部直接转成RGB565,一帧就是120KB。这个决定直接影响F407够不够用。
2.2 对照F407的三段SRAM,把每块数据放到该放的位置
STM32F407的RAM分三段:0x20000000起始的128KB主SRAM,0x2001C000起始的16KB SRAM,以及0x10000000起始的64KB CCM RAM。很多人把最后这段叫CCRAM,它在总线矩阵上少一路DMA访问能力,外设不能通过DMA直接读写它,但CPU可以正常单周期访问。这个限制恰恰适合放NesVRAM这类只由模拟器代码读写、不经过DMA搬运的数据。
| 数据对象 | 容量估算 | 放置位置 | 原因 |
|---|---|---|---|
| NesRAM | 2KB | 主SRAM | 6502 CPU仿真每指令周期都在读写,放主SRAM路径最短 |
| NesVRAM | 8KB | CCM RAM | 只有模拟器代码访问,躲开CCM不能挂DMA的限制 |
| 调色板索引帧缓冲 | 约60KB | 主SRAM | LCD搬数据要经过DMA或FSMC,必须放主SRAM |
| APU混音缓冲 | 4KB | 主SRAM | DAC经DMA取数时从主SRAM读 |
| 游戏ROM | 按ROM体积 | Flash或SD卡 | 运行期不修改,放Flash不动用SRAM配额 |
提示:CCM RAM这段空间不属于默认启动代码清零的bss段,使用前必须手动清一遍,否则NesVRAM里的初值是随机的,画面上会出现没有规律的花点。
2.3 内存账算完,能装下多大的ROM和多少款游戏
128KB主SRAM里,去掉NesRAM、60KB索引帧缓冲、4KB音频FIFO、栈和HAL库占用的空间,运行期剩余几十KB给模拟器做临时数据,完全够用。真正的限制在Flash:1MB容量里固件约200KB,剩下近800KB都可以放ROM。魂斗罗、超级玛丽、坦克大战这样的游戏ROM多为40KB到128KB不等,用一张Flash能静态装下好几款。相比SD卡加载方案,把ROM编进固件可以让上电启动时间缩短到一眨眼。
2.3.1 链接脚本写不好,后续优化全部白做
GCC工程里在ld脚本加一段CCM区域声明:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K CCM (rw) : ORIGIN = 0x10000000, LENGTH = 64K } SECTIONS { .ccm_nes (NOLOAD) : { . = ALIGN(4); KEEP(*(.ccm_nes)) . = ALIGN(4); } > CCM }代码里给数组加段属性:
__attribute__((section(".ccm_nes"))) uint8_t NesVRAM[0x2000];逻辑说明:NOLOAD告诉链接器这段空间不参与初始化,上电后内容是随机的。Cortex-M的启动文件只默认清零bss和data段,.ccm_nes不在其中,所以main函数最前面必须用memset清一次。参数说明:ORIGIN=0x10000000是CCM RAM唯一入口,写错成主SRAM的地址编译不会报错,但数据实际落点完全错误;LENGTH=1024K对应F407的Flash容量,MDK下换成IROM1和IRAM1页签里相应字段即可。
2.4 不用文件系统时,ROM怎么静态编进固件
最省事的做法是把nes文件作为const数组放进固件。GCC下先执行objcopy转换二进制文件:
arm-none-eabi-objcopy -I binary -O elf32-littlearm -B arm nes_game.nes nes_game.o逻辑说明:把nes文件包装成一个ELF目标文件,里面自动生成_binary_nes_game_nes_start这类符号,代码里用extern声明符号地址就能访问原始ROM数据。参数说明:-I binary表示输入是未加工二进制;-O elf32-littlearm指定输出小端ARM ELF;-B arm是架构参数,三个选项缺一不可,否则生成的符号地址和实际装载地址对不上。
3. 给infoNES选外设:STM32F407的LCD、按键、音频与ROM接线
3.1 屏幕优先走FSMC接口,引脚不够再退到SPI
infoNES渲染输出要求每帧把所有行数据送到屏幕,传输时间是帧率的第一瓶颈。F407的FSMC可以把LCD当作一块SRAM来写,16位数据总线一个写周期送两个字节,一个像素RGB565正好一拍。240x320的整屏写空需要几毫秒,这个开销平摊在16.66ms的帧周期里,CPU还能腾出来继续跑模拟器。SPI屏不是不能跑,但即使跑到40MHz,每像素也要8个时钟才能移出去,不加DMA时CPU占用率会超过40%,掉帧明显。
如果板子只能接SPI屏,建议开启DMA传输并把分辨率裁剪到240x240以下,同时让模拟器输出8位调色板索引而不是RGB565,数据量直接减半。实际项目里我一般把LCD驱动封装成三个函数:LCD_WriteLine(uint16_t y, uint16_t* buf)、LCD_SetWindow、LCD_Update,模拟器侧只关心LCD_WriteLine的内部实现,换屏只需改封装,不动模拟器。
3.2 按键直接GPIO读,NES的八个键一次扫描搞定
NES手柄逻辑键只有上、下、左、右、A、B、Select、Start八个,F407引脚充足,直接一个键接一个GPIO最可靠。读取用轮询不用外部中断,模拟器每帧开始时调用一次扫描函数,顺便做10ms消抖。按键电气接法是一端接GND、另一端接GPIO,内部上拉使能后读取时返回0表示按下。
uint8_t NES_ReadKeys(void) { uint8_t keys = 0; if (HAL_GPIO_ReadPin(KEY_UP_GPIO_Port, KEY_UP_Pin) == GPIO_PIN_RESET) keys |= 0x01; if (HAL_GPIO_ReadPin(KEY_DOWN_GPIO_Port, KEY_DOWN_Pin) == GPIO_PIN_RESET) keys |= 0x02; if (HAL_GPIO_ReadPin(KEY_LEFT_GPIO_Port, KEY_LEFT_Pin) == GPIO_PIN_RESET) keys |= 0x04; if (HAL_GPIO_ReadPin(KEY_RIGHT_GPIO_Port, KEY_RIGHT_Pin) == GPIO_PIN_RESET) keys |= 0x08; if (HAL_GPIO_ReadPin(KEY_A_GPIO_Port, KEY_A_Pin) == GPIO_PIN_RESET) keys |= 0x10; if (HAL_GPIO_ReadPin(KEY_B_GPIO_Port, KEY_B_Pin) == GPIO_PIN_RESET) keys |= 0x20; if (HAL_GPIO_ReadPin(KEY_SEL_GPIO_Port, KEY_SEL_Pin) == GPIO_PIN_RESET) keys |= 0x40; if (HAL_GPIO_ReadPin(KEY_STA_GPIO_Port, KEY_STA_Pin) == GPIO_PIN_RESET) keys |= 0x80; return keys; }逻辑说明:返回值的bit位按NES标准键位排列,0x01是上、0x02是下、0x04是左、0x08是右、0x10是A、0x20是B、0x40是Select、0x80是Start。这个排列要和infoNES输入掩码保持一致,否则会出现按上是左跳这种错位。参数说明:GPIO初始化为GPIO_MODE_INPUT,Pull选GPIO_PULLUP,Speed没有要求,因为读取是静态电平。按键回跳用两次扫描间隔超过10ms来处理,不需要外部中断。
3.3 音频选DAC加DMA,比PWM更稳也更好调
infoNES的APU混音后生成样本序列,F407自带的12位DAC在这里比定时器PWM更合适。常见做法是把样本写进一个环形缓冲区,由定时器触发DAC转换,DMA从缓冲区取数,CPU只在缓冲区低于水位时补充数据。这样音频时钟由硬件保证,不占用模拟器主循环时间。PWM方案虽然引脚更少,但要求占空比更新在中断里严格按时隙完成,一旦模拟器帧耗时抖动,声音就会周期性发劈。
用DAC时的关键配置是触发源选定时器更新事件而不是软件触发。比如APU采样率定22050Hz,那定时器更新频率就定22050Hz,DMA配置成循环模式,每次转换从FIFO取一个样本。DAC分辨率设12位,infoNES生成的8位样本左移4位后写入,音量不够再在混音阶段加增益,避免在DAC输出级做模拟放大引入噪声。
3.4 ROM来源决定要不要挂FatFs
SD卡加FATFS是灵活路线,nes文件放进SD卡,开机后用f_open、f_read读入内存。FatFs本身不挑底层介质,但实现disk_read时要分清是SDIO接口还是SPI接口。SDIO速度快,接线六根,初始化时序更讲究;SPI速度低,胜在引脚少,很多F407核心板就把SD卡座接在SPI上。只有静态装固件这一个需求时,跳过文件系统,直接把ROM编进Flash,上电即玩,稳定性最高。
如果走SD卡路径,读入函数是这个流程的骨架:
FIL rom_file; UINT bytes_read; FRESULT res = f_open(&rom_file, "0:/game.nes", FA_READ); if (res == FR_OK) { f_read(&rom_file, rom_buffer, f_size(&rom_file), &bytes_read); f_close(&rom_file); }逻辑说明:f_size先拿到ROM真实长度,然后一次性读入预先声明的rom_buffer。参数说明:0:是FatFs的卷号,不同移植配置里卷前缀可能不同;bytes_read必须校验是否等于文件长度,SD卡读取时发生短读说明底层驱动有问题,不要继续执行模拟器初始化。
4. STM32F407承载infoNES的工程骨架:时钟、ROM读取和三个核心回调
4.1 168MHz是硬指标,时钟配置先核对PLL路径
模拟器的帧耗时和主频严格成反比,F407必须跑满168MHz,帧率才能压在16.6ms内。CubeMX生成的时钟配置里有个高频踩坑点:外部晶振25MHz时PLLM要设25,晶振8MHz时PLLM要设8,很多人换板子不换参数,PLLN=336、PLLP=2算出的168MHz直接变成53.76MHz,游戏速慢如幻灯片。
void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; __HAL_RCC_PWR_CLK_ENABLE(); __HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1); RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLM = 25; RCC_OscInitStruct.PLL.PLLN = 336; RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV2; RCC_OscInitStruct.PLL.PLLQ = 7; HAL_RCC_OscConfig(&RCC_OscInitStruct); RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV4; RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV2; HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_5); }逻辑说明:25MHz外部晶振经PLLM 25分频得1MHz,PLLN倍频336倍得336MHz,PLLP二分频后SYSCLK为168MHz。APB1四分频得42MHz,APB2二分频得84MHz,后面配定时器和DAC时钟都依赖这组数。参数说明:FLASH_LATENCY_5对应168MHz下闪存读取需要五个等待周期,这个值低了会随机死机;PLLQ=7不为DAC服务,是给USB提供48MHz,不启用USB时也要保留这个值,否则RCC配置会报错。
4.2 ROM从SD卡读入后的正确落点
从SD卡读出的ROM如果直接放数组当普通内存用,会占用宝贵的128KB主SRAM。推荐做法是放进一个const修饰的Flash数组,让链接器把它安排在0x08000000之后的空闲Flash区域。步骤是先定义一个不超过剩余容量的buffer,再在f_read后启动一次Flash编程,把数据搬进去。之后的每次运行都直接从Flash读ROM,不再依赖SD卡。
4.3 infoNES的对外接线点:PPU行输出、输入读取、音频推流
infoNES这类模拟器通常把硬件无关的仿真逻辑和硬件相关的输出分离,移植时只要找到三个对接位置:PPU渲染一行后回调、输入读取函数被模拟器调用、APU生成一个样本后回调。PPU侧把调色板索引转成RGB565再写LCD:
void NES_PPU_OutputLine(uint16_t y, uint8_t *palette_indexes) { static uint16_t lcd_line[240]; for (int x = 0; x < 240; x++) { lcd_line[x] = nes_palette_rgb565[palette_indexes[x]]; } LCD_WriteLine(y, lcd_line); }逻辑说明:palette_indexes是infoNES内部产出的一行NES调色板索引,不能直接给LCD显示。nes_palette_rgb565是一张长64的查表,把NES标准色板映射成这块LCD的RGB565格式。如果画面出现整体偏绿或偏红,问题几乎都在查表,不在接线。参数说明:这里循环宽度写的是240而不是256,因为我这边LCD可视区是240列,NES原生256列里裁掉左右各8列像素,多数游戏画面主体不受影响,还能节省约6%的输出时间。
音频推流和输入读取按上一章的封装对接。音频侧要保证FIFO有水:
void NES_AudioPush(uint8_t sample) { audio_fifo_push(sample); }逻辑说明:每次模拟器生成一个样本就立即推入FIFO,不要在模拟器跑完一帧后批量推,那样DAC中期会断供,出现有规律的咔哒声。参数说明:sample是8位无符号样本,FIFO内部设为4096深度,约合185ms缓冲,足以吸收主循环的帧时间波动。
4.4 关键参数:帧率、采样率和DMA配置对照
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 模拟器帧率 | 60.09Hz | NES NTSC标准,用作帧同步时间基准 |
| PPU输出分辨率 | 256x240裁为240x240 | 与LCD实际可视区对齐 |
| APU采样率 | 22050Hz | 再高CPU开销显著上升,再低音质毛刺明显 |
| DAC触发源 | TIM2更新事件 | 必须与采样率严格一致 |
| 调色板表 | 64 x uint16_t | 放Flash常量区,避免占RAM |
这几个参数之间是联动的:APU采样率22050Hz要求定时器更新频率22050Hz,而定时器的时钟来自APB1定时器时钟84MHz,预分频和自动重载值要按这个总线频率推算。帧率方面,如果主循环以模拟器内部帧完成为节拍,不需要单独开帧定时器;如果需要和外部60.09Hz对齐,可以配置一个定时器中断来做帧同步。
5. infoNES在STM32F407上跑起来的FPS测量、PPU瓶颈和破音排查
5.1 用SysTick挂在后台数帧,别靠眼睛判断卡不卡
模拟器性能问题必须量化。很多卡顿是偶发的,目测不可靠,用SysTick做一个后台帧率统计,每渲染完一帧计数一次,每秒刷新显示。统计代码要极短,不能影响模拟器主循环。
volatile uint32_t frame_counter = 0; volatile uint32_t fps_now = 0; void SysTick_Handler(void) { static uint32_t frames_this_sec = 0; static uint32_t last_sec = 0; uint32_t now = HAL_GetTick(); if (now - last_sec >= 1000) { fps_now = frames_this_sec; frames_this_sec = 0; last_sec = now; } } void NES_FrameDone(void) { frame_counter++; frames_this_sec++; }逻辑说明:fps_now每秒更新一次,主循环把它的值格式化到LCD角落即可实时观察。frames_this_sec用静态变量保护,避免中断和主循环同时写同一变量造成脏读。参数说明:SysTick默认配置为1ms中断一次,HAL_GetTick由它驱动;如果工程里已经改了SysTick重装值,这个统计间隔也要同步调整。
5.2 PPU是F407上最明显的短板,优先优化这四处
跑起来后先看数据:裸移植的infoNES在168MHz下,CPU仿真约占每帧2到3ms,PPU却要吃掉6到8ms。PPU才是帧耗时的第一贡献者。结合热词里出现的stm32f407 dcmi、freertos移植这类高负载场景,跑模拟器时最好别开RTOS,裸循环反而更可控。
优化项按收益排列:
- 关闭不用的PPU渲染开关。有些游戏只开背景或只开精灵,把掩码里不需要的部分关掉,能省掉一大段扫描线逻辑。
- 关闭画面缩放。infoNES自带的2x平滑缩放对F407是负担,改成1:1输出,只做最近邻裁剪。
- 调整调色板查表方式。把64项色板展开成256项的直接映射表,让每像素只做一次数组取址。
- 裁剪上下黑边。很多NES游戏实际画面不到240行,黑边区直接跳过,不送LCD。
优化前后对比数据:
| 优化项 | 优化前帧耗时 | 优化后帧耗时 | 说明 |
|---|---|---|---|
| 关闭2x平滑 | 约8.2ms | 约5.6ms | 去掉每像素插值计算 |
| 关闭冗余PPU开关 | 5.6ms | 4.1ms | 减少扫描线附加逻辑 |
| 调色板直映射 | 4.1ms | 3.3ms | 去掉每像素色板偏移计算 |
5.3 破音的第一排查对象是FIFO欠载,不是CPU不够
模拟器速度达标但声音一顿一顿,绝大多数是音频FIFO欠载。DMA循环读DAC时,FIFO空转,DAC持续输出上一次样本,听感就是周期性咔哒声。排查时在DMA传输完成中断里统计欠载次数:
uint32_t audio_underrun_count = 0; void DAC_DMA_IRQHandler(void) { if (audio_fifo_level() < 16) { audio_underrun_count++; } }逻辑说明:DMA每取走一个样本触发一次中断,这时FIFO剩余不足16个样本,说明DAC消费速度略快于APU生产速度。参数说明:audio_underrun_count持续增长时,把FIFO深度从4096提到8192,或者把APU采样率从22050Hz提到32000Hz,让模拟器生成样本的节奏更密,但32000Hz会让CPU占用上升约5%,F407仍能承受。注意不要在中断里直接做音量调整或滤波,那会拉长中断时间,反而加剧欠载。
6. infoNES移植收尾的三件事:CCM加速、色板直查表、DMA刷屏
6.1 把NesRAM和NesVRAM一起挪到CCM
前文把NesVRAM放进CCM后,如果主SRAM仍然偏紧,连NesRAM也一起放进去。6502核心仿真对NesRAM的读写频率极高,CCM的单周期访问特性对纯ALU负载反而友好;只要确认没有DMA会访问这两块区域,这个挪法是安全的。注意链接脚本里.ccm_nes段要同时包含两个数组,并且main开头先memset清零。
6.2 色板表展开成256项直查,丢掉每像素运算
NES色板索引是6位有效位,infoNES输出时可能把高位置0或携带属性位。与其在每像素循环里做index & 0x3F再查64项表,不如在初始化时预展开一张256项的表,下标直接使用原始索引值。代码里把查表循环改成一次数组访问,60000多个像素能省下几万次位运算,帧耗时下降约0.4ms,同时代码更短。
6.3 DMA刷屏,让CPU去仿真下一帧
FSMC写LCD本身很快,但数据搬移依然占用CPU周期。配置一个DMA通道把帧缓冲里的行数据自动送到FSMC数据寄存器,启动一次传输后CPU立刻返回模拟器主循环,等DMA传输完成中断再去提交下一行。选择M2P方向,外设地址固定为LCD的数据寄存器地址,内存地址按行递增。行完成中断和传输完成中断分开处理:每传完一行只清标志,整帧传完再触发下一帧渲染,避免下一帧覆盖上一帧导致画面撕裂。DMA_IT_TC和DMA_IT_HT分开处理,比一帧传完再开始下一帧,至少能省出半帧的等待时间。
本文还有配套的精品资源,点击获取