十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F407移植infoNES实战:内存布局、外设对接与性能优化

STM32F407移植infoNES实战:内存布局、外设对接与性能优化 简介这是一份将经典NES模拟器移植到STM32F407微控制器上的嵌入式工程资源面向嵌入式开发爱好者、STM32玩家及想学习游戏模拟器原理的读者。项目以可运行代码为基础完整展示如何把红白机游戏搬到单芯片上从RTOS多任务调度、液晶显示、音频与手柄输入到游戏ROM加载、解码与执行均有实现。压缩包共391个文件容量8.05MB核心源码以cpp、c、h为主另有编译生成的o、d、crf中间文件以及Keil工程配置、链接镜像、分散加载文件可直接打开工程查看编译流程。目前已有736人学习下载。阅读这套工程可深入理解中断管理、帧同步、外设驱动和性能调优方法也能借鉴低功耗设计与调试排错做法是学习STM32游戏移植不可多得的参考资料。1. 为什么要在STM32F407上跑infoNESSTM32F407移植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 VRAMinfoNES把这两块区域实现成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主SRAM0x2001C000起始的16KB SRAM以及0x10000000起始的64KB CCM RAM。很多人把最后这段叫CCRAM它在总线矩阵上少一路DMA访问能力外设不能通过DMA直接读写它但CPU可以正常单周期访问。这个限制恰恰适合放NesVRAM这类只由模拟器代码读写、不经过DMA搬运的数据。数据对象容量估算放置位置原因NesRAM2KB主SRAM6502 CPU仿真每指令周期都在读写放主SRAM路径最短NesVRAM8KBCCM RAM只有模拟器代码访问躲开CCM不能挂DMA的限制调色板索引帧缓冲约60KB主SRAMLCD搬数据要经过DMA或FSMC必须放主SRAMAPU混音缓冲4KB主SRAMDAC经DMA取数时从主SRAM读游戏ROM按ROM体积Flash或SD卡运行期不修改放Flash不动用SRAM配额提示CCM RAM这段空间不属于默认启动代码清零的bss段使用前必须手动清一遍否则NesVRAM里的初值是随机的画面上会出现没有规律的花点。2.3 内存账算完能装下多大的ROM和多少款游戏128KB主SRAM里去掉NesRAM、60KB索引帧缓冲、4KB音频FIFO、栈和HAL库占用的空间运行期剩余几十KB给模拟器做临时数据完全够用。真正的限制在Flash1MB容量里固件约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清一次。参数说明ORIGIN0x10000000是CCM RAM唯一入口写错成主SRAM的地址编译不会报错但数据实际落点完全错误LENGTH1024K对应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接口引脚不够再退到SPIinfoNES渲染输出要求每帧把所有行数据送到屏幕传输时间是帧率的第一瓶颈。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_INPUTPull选GPIO_PULLUPSpeed没有要求因为读取是静态电平。按键回跳用两次扫描间隔超过10ms来处理不需要外部中断。3.3 音频选DAC加DMA比PWM更稳也更好调infoNES的APU混音后生成样本序列F407自带的12位DAC在这里比定时器PWM更合适。常见做法是把样本写进一个环形缓冲区由定时器触发DAC转换DMA从缓冲区取数CPU只在缓冲区低于水位时补充数据。这样音频时钟由硬件保证不占用模拟器主循环时间。PWM方案虽然引脚更少但要求占空比更新在中断里严格按时隙完成一旦模拟器帧耗时抖动声音就会周期性发劈。用DAC时的关键配置是触发源选定时器更新事件而不是软件触发。比如APU采样率定22050Hz那定时器更新频率就定22050HzDMA配置成循环模式每次转换从FIFO取一个样本。DAC分辨率设12位infoNES生成的8位样本左移4位后写入音量不够再在混音阶段加增益避免在DAC输出级做模拟放大引入噪声。3.4 ROM来源决定要不要挂FatFsSD卡加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很多人换板子不换参数PLLN336、PLLP2算出的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分频得1MHzPLLN倍频336倍得336MHzPLLP二分频后SYSCLK为168MHz。APB1四分频得42MHzAPB2二分频得84MHz后面配定时器和DAC时钟都依赖这组数。参数说明FLASH_LATENCY_5对应168MHz下闪存读取需要五个等待周期这个值低了会随机死机PLLQ7不为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再写LCDvoid 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.09HzNES 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到3msPPU却要吃掉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.6ms4.1ms减少扫描线附加逻辑调色板直映射4.1ms3.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分开处理比一帧传完再开始下一帧至少能省出半帧的等待时间。本文还有配套的精品资源点击获取
返回列表