尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

PIC32嵌入式游戏开发实战:从硬件选型到DMA屏幕刷新完整指南

PIC32嵌入式游戏开发实战:从硬件选型到DMA屏幕刷新完整指南 我做了几年的PIC32项目大多数时候都是在搞一些传感器采集、电机控制之类的活偶尔也会做点带界面的东西但基本就是处理一下屏幕显示。直到有一次我想给自己家小朋友做一个掌上游戏机才开始认真琢磨“在MCU上做游戏”这件事。一开始我的想法很简单——MCU嘛主频不高、内存不大跑个简单的小游戏应该不成问题。但真正动起手来才发现PIC32上做游戏和PC上做游戏完全是两回事它不只是把代码“改一改”就能跑起来的涉及显示刷新、输入轮询、内存规划、帧率控制等一系列问题每一个环节都有一套嵌入式特有的玩法。这篇文章我就拿自己的实际项目“Designing and Building a PIC32 Video Game”当例子把从硬件选型到最终跑出画面的完整过程拆开讲讲包括为什么选PIC32MX而不是其他型号、显示方案怎么定、游戏循环怎么搭、按键抖动怎么处理、DMA在图形里怎么用以及我在调试时踩过的那些坑。希望能给想在MCU上做游戏的朋友一些可以直接落地的参考。1. 项目整体设计与硬件选型思路1.1 为什么选PIC32MX而不是Arduino或STM32在开始这个项目之前我手上也有几块STM32的开发板用起来也很顺手。那为什么最终选了PIC32MX呢一个很现实的原因是项目需求比较特殊我需要一个单芯片方案内置足够大的Flash和RAM能直接驱动一块TFT屏幕同时还要有充足的外设接口。对比下来PIC32MX270F256B是个不错的切入点。它有一颗80MHz的MIPS内核256KB Flash、64KB RAM虽然放在今天来看不算什么但在MCU里已经够跑一个完整的游戏逻辑加屏幕缓冲区了。更重要的是它有足够的SPI接口、定时器、DMA通道还有一个让我很在意的特性——DMA可以配合SPI做屏幕刷新CPU只需要往缓冲区里写数据剩下的事情交给硬件完成。如果用Arduino这种平台开发的确很快但受限于AVR的架构跑复杂一点的小游戏会比较吃力帧率上来之后CPU占用会非常难看。STM32在性能上确实更强但当时考虑到调试工具和开发环境我手上刚好有Microchip的工具链用PIC32MX算是顺理成章的选择。1.2 显示方案SPI TFT屏幕做游戏屏幕是绕不开的话题。我最终选了一款2.4英寸的SPI接口TFT屏幕分辨率320×240驱动IC是ILI9341。为什么不用并口屏幕因为并口占用的IO太多了PIC32MX270F256B的引脚本来就不富裕如果全部用来接屏幕数据线按键、SD卡这些就没办法接了。SPI屏幕只需要几根线数据速率也够用80MHz主频下SPI跑到40MHz没有问题。不过SPI屏幕有个需要认真对待的点全屏刷新数据量很大。320×240像素RGB565格式一帧完整画面大约是150KB。就算SPI能跑到40MHz刷一帧也需要差不多30ms的时间算下来只有30帧左右还是在“不干别的活”的情况下。所以直接整屏刷是不可行的实际项目里我用的是“脏矩形”方案只更新变化的区域静止的画面不重复发送数据这样大部分时间帧率都能稳定在50帧以上。这部分我会在后面详细展开这里先把整个项目的硬件结构画个轮廓主控PIC32MX270F256B80MHz屏幕2.4英寸SPI TFTILI9341320×240输入4个方向键2个功能键共6个GPIO按键存储板载MicroSD卡槽用来存储游戏素材和关卡数据音频无因为要做到设备简单实际用了一个蜂鸣器做简单音效电源USB供电板载LDO降压到3.3V提示做MCU游戏最容易犯的错就是一上来就把PC游戏那套“整帧图片”的思路搬过来。MCU的内存和Flash都有限素材和代码必须“够用就好”不能追求华丽效果先跑起来再谈优化。2. 游戏框架搭建与核心处理逻辑2.1 游戏循环MCU上不能用“线程”思维做过PC游戏的朋友肯定熟悉“游戏循环”的概念——每帧处理输入、更新状态、渲染画面。这个思路在MCU上依然成立但实现方式完全不同。MCU上一般没有操作系统也没有多线程概念所有事情都挤在一个大循环里跑。我的游戏循环结构大致是这样的while (1) { uint32_t frameStart _readTimestamp(); processInput(); // 读取按键状态处理逻辑输入 updateGameState(); // 更新游戏角色、敌人、碰撞检测 render(); // 生成要显示的图像数据 // 控制帧率保持每帧耗时稳定 while ((_readTimestamp() - frameStart) FRAME_TIME_MS); }这个循环初看不起眼但它的核心价值在于“节奏控制”。由于MCU的时钟是确定的我可以利用定时器精确计算每一帧的耗时然后强制对齐到一个固定时间槽上。比如目标帧率是60fps一帧的预算就是16.7ms。如果update和render加起来只花了10ms那就空等6.7ms如果超了就得想办法优化否则画面会出现闪动或卡顿。这里有一个容易被忽略的细节MCU读取按键不能用“按下就触发”的方式它是低电平有效按下时GPIO读到0松开时读到1实际扫描中会有抖动必须做软件去抖。2.2 按键输入GPIO扫描与软件去抖PIC32MX270F256B的按键接的是普通GPIO没有专门的按键控制器所以所有去抖工作都要靠软件完成。我采用了经典的“采样计数”去抖方案。每2ms采样一次按键状态如果连续3次采样值一致才认为按键状态有效。同时我维护了一个“状态变化标志”只有当按键从“未按下”变成“按下”时才触发一次逻辑事件这样就能避免游戏里出现“按一下跳了两次”的问题。#define DEBOUNCE_SAMPLES 3 #define INPUT_SAMPLE_MS 2 volatile uint8_t keyRaw[6]; uint8_t keyStable[6]; uint8_t keyEvent[6]; void scanKeys(void) { // 在定时器中断中调用每2ms一次 static uint8_t sampleCount[6] {0}; for (int i 0; i 6; i) { uint8_t current getKeyPin(i); if (current keyRaw[i]) { if (sampleCount[i] DEBOUNCE_SAMPLES) sampleCount[i]; if (sampleCount[i] DEBOUNCE_SAMPLES) { keyStable[i] current; keyEvent[i] 1; // 去抖后的状态变化事件 } } else { sampleCount[i] 0; // 采样值不一致重新计数 keyRaw[i] current; } } }这个方案在实测中表现很稳机械按键的抖动通常在几毫秒到十几毫秒不等2ms采样3次相当于给了6ms的去抖窗口既能滤除大部分抖动又不会让按键响应变得拖沓。注意游戏里的按键处理逻辑和普通嵌入式程序有一个明显区别——游戏关心的是“边沿触发”而不是“电平触发”。比如跳跃动作玩家按一下跳一下如果用电平触发按的时间长一点就会跳好几下。所以我在updateGameState里只消费keyEvent标志消费完立即清零确保一个动作只对应一次物理按键。2.3 画面渲染脏矩形与局部刷新屏幕刷新是MCU游戏最大的性能瓶颈。前面提过320×240的RGB565整帧数据是150KB全屏刷新一次要占用大量时间所以必须做局部刷新。我的做法是把屏幕分成8×8像素的小块维护一张“脏标记表”。当一个区域内有任何像素变化时就把对应的小块标记为“脏”。渲染阶段只把所有脏块的数据发送到屏幕。这个方案有几个好处帧率显著提升尤其是画面大部分区域不变时内存占用少不需要维护整帧的framebuffer逻辑简单只需在绘图函数里顺手把脏块标记上不过脏矩形有一个使用前提屏幕控制器必须支持“窗口设置”。ILI9341支持用CASET/RASET命令设置帧内存中的窗口区域然后连续写入该窗口内的像素数据。这个特性正好可以用来做局部刷新。void drawRect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { // 设置刷新窗口 setWindow(x, y, x w - 1, y h - 1); // 连续发送像素数据 for (uint16_t i 0; i ((uint32_t)w * h); i) { sendPixel(color); } // 标记对应的脏块 markDirty(x, y, w, h); }实际使用中我大部分时间只刷新很小的区域比如一个20×20的玩家精灵刷新一次只需要400个像素不到1KB的数据量几十微秒就能传完。这也是整个项目里最重要的优化措施没有之一。2.4 精灵与背景透明处理的艺术MCU游戏里经常要面对透明像素的问题。比如一个圆形的敌人周围一圈是全透明的如果直接把整块矩形数据画上去背景会被罩上一层黑框。处理这个问题的思路是“逐像素判断”void drawSprite(uint16_t x, uint16_t y, const uint8_t *sprite, uint16_t w, uint16_t h) { for (uint16_t j 0; j h; j) { for (uint16_t i 0; i w; i) { // sprite数据用调色板索引表示0号色为透明 uint8_t index sprite[j * w i]; if (index ! 0) { drawPixel(x i, y j, palette[index]); } } } markDirty(x, y, w, h); }把精灵数据保存为调色板索引而不是直接的RGB值可以大幅节省Flash空间。比如每个像素用1字节索引而不是2字节RGB一个32×32的精灵能从2048字节缩减到1024字节这对MCU的Flash容量来说相当可观。调色板的方式还有一个好处换色非常方便。比如同一个敌人如果要做红、蓝两种配色只需要复制一份调色板改几个值就行不需要重做整个精灵图。3. 实操过程从零到能玩的小游戏3.1 开发环境的搭建与配置工欲善其事必先利其器。这个项目我用的是MPLAB X IDE XC32编译器。MPLAB X虽然界面有点老旧但它的调试功能做得不错尤其是对PIC32MX系列的支持很完善。新建项目时的设置有几个容易踩坑的地方器件型号要选对PIC32MX270F256B编译器版本建议用2.15以上老版本对MIPS指令集的优化不够好项目属性里要开启优化级别至少-O1否则代码体积和速度都无法接受调试器用板载的PICkit 4或ICD 4都可以注意接线方向初始化的顺序也很重要。PIC32MX系列上电后默认使用内部FRC时钟频率是8MHz。我通过配置系统寄存器把时钟切换到外部晶振再通过PLL倍频到80MHz// 系统时钟初始化8MHz晶振PLL倍频到80MHz #pragma config FNOSC PRIPLL #pragma config POSCMOD XT #pragma config FPLLIDIV DIV_2 #pragma config FPLLMUL MUL_20 #pragma config FPLLODIV DIV_1 #pragma config FPBDIV DIV_2这几行配置的意思是外部晶振8MHz先二分频到4MHz再倍频20倍到80MHz外设总线时钟再二分频到40MHz。这样CPU跑80MHz外设跑40MHz能保证SPI等外设工作稳定。3.2 用DMA驱动SPI刷新屏幕SPI刷屏看起来很简单——往SPI缓冲区里丢数据就行但实际性能瓶颈在CPU。如果没有DMA每发一个像素都要CPU参与一次数据搬运做满帧画面时CPU几乎被SPI中断占满。PIC32MX270F256B的DMA模块就是为解决这个问题而生的。我的做法是CPU在内存中准备好一段要发送的像素数据配置DMA通道源地址指向内存数据目的地址指向SPI发送寄存器启动DMACPU就可以去处理游戏逻辑了DMA传输完成后产生中断通知CPU数据已发送完毕这样SPI总线在后台高速刷屏CPU同时在做下一帧的游戏逻辑计算两者互不干扰。void dmaSendData(const uint8_t *data, uint32_t len) { DMA1CON 0; // 停止DMA DCH1SSA (uint32_t)data; // 源地址 DCH1DSA (uint32_t)SPI1BUF; // 目的地址SPI发送寄存器 DCH1SSIZ len; // 数据长度 DCH1DSIZ 1; DCH1CSIZ 1; DCH1INTCLR 0xFFFFFFFF; // 清中断标志 DCH1CON DMA_CHN_EN | DMA_SOURCE_START; // 启动DMA }这个方案的瓶颈变成了“在内存里准备好数据”的速度而不用再担心SPI总线的效率。实测40MHz SPI DMA连续发送一整行数据几乎不占CPU时间这为游戏逻辑留出了充足的余量。提示DMA发送时必须保证源数据地址是物理地址。PIC32MX的内存有KSEG0/KSEG1之分如果用KSEG0地址访问缓存需要注意缓存一致性问题。最简单粗暴的办法是把缓冲区定义在KSEG1的uncached区或者每次发送前手动做cache flush。3.3 简单的碰撞检测与游戏逻辑实现游戏逻辑本身不复杂但有几个在MCU上特别需要注意的地方。碰撞检测我采用了AABB轴对齐包围盒方案简单、计算量小、足够用。两个矩形是否相交只需要比较四条边的位置bool checkCollision(int16_t ax, int16_t ay, int16_t aw, int16_t ah, int16_t bx, int16_t by, int16_t bw, int16_t bh) { if (ax aw bx || bx bw ax) return false; if (ay ah by || by bh ay) return false; return true; }这个函数在游戏循环里会被调用很多次所以我没有用浮点数全部用整型运算。PIC32MX虽然支持硬件浮点但整型运算在MCU上更可控也不会因为浮点库没启用到而出现莫名其妙的链接错误。关卡设计我是用数组直接写在代码里的。比如一个简单的平台跳跃关卡用1表示地面0表示空白const uint8_t level1[] { 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, 1,0,0,0,1,1,0,0,0,0,1,0,0,0,0,1, 1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, };用这种方式做简单的关卡设计非常直观也便于后期用地图编辑器生成代码。我当时是把地图数据做成了.h头文件用一个小脚本从Tiled地图编辑器导出省了不少手工敲键盘的功夫。3.4 内存分配与优化64KB要怎么分PIC32MX270F256B的64KB RAM看起来不多但合理规划后其实很够用。我的内存分配策略是这样的屏幕缓冲区8KB双缓冲每个4KB只用来缓存改动不大的静态画面精灵数据缓冲区12KB加载常用精灵到RAM减少从Flash读取的时间游戏状态变量4KB关卡数据加载到RAM的部分约4KB栈空间8KB其他剩余作为堆空间和临时变量内存规划上有一个原则能放在Flash里的数据尽量放Flash比如精灵的原始数据、关卡数组这些都放Flashconst关键字只有在运行时需要频繁修改的数据才放RAM。// 放在Flash中的精灵数据占用Flash不占用RAM const uint8_t playerSprite[] { 0,0,1,1,1,0,0, 0,1,1,1,1,1,0, 1,1,1,1,1,1,1, 0,1,0,1,0,1,0, 0,1,1,0,1,1,0, }; // 放在RAM中的运行时缓冲区 uint8_t spriteCache[64 * 64];这样做的好处是Flash容量256KB远大于RAM容量64KB把静态数据放在Flash能最大化利用MCU的存储资源同时也不会出现RAM不够用的尴尬。4. 常见问题与调试技巧实录4.1 画面撕裂与卡顿排查跑游戏最怕画面撕裂左边是上一帧右边是下一帧看起来非常难受。原因通常是“写入屏幕的时机”和“屏幕刷新时序”不同步。ILI9341这类屏幕内部控制器的刷新是从上到下逐行扫描的如果在刷新过程中修改了帧内存的内容就会出现撕裂。解决办法是打开垂直空白中断TE信号或者设置“只在扫描间隙更新”。不过我的屏幕模块没有引出TE引脚所以用了另一个笨办法把整个游戏帧率限制在60fps并且只在帧开始时更新画面。这种方法在实际测试中能解决大部分撕裂问题但有代价——如果游戏逻辑计算超过了16.7ms那就会丢帧。后来我换了一种更灵活的策略把“渲染”和“屏幕传送”分开。游戏逻辑计算完图形数据后先缓存到RAM里的一个“待发送队列”屏幕刷新的间隙再通过DMA把数据发出去。这样即使逻辑计算偶尔超时也不会影响屏幕刷新的节奏。4.2 按键响应延迟与抖动误触按键问题是我调试过程中最烦人的部分之一。最初的版本里我用的是简单的“扫描逻辑判断”结果是按下跳跃键角色有时候跳有时候不跳还会出现一次按跳跃了好几次的情况。后来我把排查方向放在按键扫描和游戏循环的交互上。最终采用的方案是在定时器中断里扫描按键并更新去抖状态游戏循环只读取去抖后的稳定状态和事件标志。这样无论游戏循环怎么忙按键扫描都有固定的时间节奏不会因为某一帧逻辑复杂导致按键检测被推迟。实测下来2ms采样一次、连续3次一致才判定有效的方案对于普通机械按键来说刚刚好。如果换成手感比较差的按键可以考虑把采样间隔调整到3ms但不要超过5ms否则按键响应会变得非常迟钝。4.3 Flash写入导致游戏暂停有个奇怪的现象游戏跑着跑着突然卡一下时间间隔还很有规律。排查了很久最后定位到是“存档功能”的问题。我做的存档是把玩家进度写到内部Flash的某个扇区。PIC32MX的Flash写入需要先擦除扇区而擦除操作会阻塞CPU时间长达几十毫秒。如果游戏在跑的过程中触发存档画面就会卡顿。解决方案是“延后存档”进入存档点时先把要写的数据放在RAM缓存里等游戏循环进入“页面切换”空隙时再执行Flash写入。这样用户会感到从一关到另一关之间多花了一点时间但游戏过程中不会有任何卡顿。4.4 典型问题速查表问题表现根本原因解决办法画面出现横纹撕裂屏幕刷新和内容更新竞争限制帧率帧首更新或先写内存后传送按键按一下跳多次缺少边沿检测使用电平触发用事件标志只在按下瞬间触发逻辑游戏越跑越卡内存碎片或RAM泄漏用静态分配代替malloc/free帧率忽高忽低页面刷新被Flash写入阻塞把Flash写入延后到切换场景时屏幕颜色偏色SPI速率过高导致信号质量差降低SPI分频或检查线材和上拉电阻休眠唤醒后画面花屏屏幕控制器未完全配置恢复在唤醒后重新初始化ILI93414.5 硬件层面的调试心得做MCU游戏硬件上的坑比软件还多。我遇到过几个比较典型的问题屏幕数据线过长一开始我用10cm的杜邦线连接屏幕SPI速率一高就出现颜色错乱。后来换成5cm的短线并且把SPI时钟降到20MHz现象才消失地线没接好屏幕模块的电流突变容易拉低电源电压导致MCU复位。必须在电源附近加一个100uF的电解电容和100nF的陶瓷电容按键悬空误触发按键引脚必须内部上拉或者外部上拉电阻否则按键悬空时电平可能在高低之间跳变导致误触发生如果你用别人的开发板建议先在“裸机”状态下测试屏幕和按键的回环确认硬件没问题再开始写游戏逻辑。很多时候游戏代码看着没错但硬件不稳最后排错排到崩溃才发现是电源纹波的问题。5. 进一步扩展这个项目还能做什么5.1 从单屏游戏到关卡管理器目前的版本是单关卡游戏内容全都写在代码里。如果想做真正有“可玩性”的游戏关卡管理是绕不开的。我的思路是做一个简单的关卡系统把每个关卡的结构体统一定义包括关卡地图指针、背景色、敌人类型和数量、过关条件等。玩家通关后自动加载下一关中间插入一个简单的转场动画。typedef struct { const uint8_t *map; uint16_t mapWidth; uint16_t mapHeight; uint16_t bgColor; uint8_t enemyType; uint8_t enemyCount; uint16_t targetScore; } Level; const Level levels[] { { level1_map, 16, 12, RGB565_BLUE, ENEMY_SLIME, 3, 100 }, { level2_map, 20, 14, RGB565_GREEN, ENEMY_BAT, 5, 200 }, // ... };这样每新加一个关卡只需要增加一组地图数组和一个结构体条目不用改游戏主逻辑。5.2 增加音效与震动反馈游戏缺少音效会感觉少了点什么。但PIC32MX270F256B没有内置DAC音频输出只能用PWM模拟。一个取巧的方案是用定时器PWM直接驱动蜂鸣器通过改变PWM频率来播放音效。比如跳跃音效是频率从800Hz快速升到1200Hz再落回去吃金币音效是1500Hz持续50ms。我写了一个不阻塞的音效播报器void playToneSeq(const uint16_t *freqs, const uint16_t *durations, uint8_t count) { // 在定时器中断里逐步改变PWM频率 // 不阻塞主循环不影响游戏逻辑 }考虑到PWM只有一个通道音效和背景音乐不能同时播放。我的优先级策略是音效优先背景音乐在音效播放时短暂静音。这个策略在后期测试中效果还不错不至于出现“音效声音完全盖住背景乐”的问题。5.3 素材制作与工具链不想手写精灵数组的话可以用工具自动导出。我当时用的是Python写了一个小工具把PNG格式的素材转换成C语言数组同时生成调色板。这样在PC上用绘图软件做好像素画一键导出到工程目录就能直接编译烧录了。from PIL import Image def png_to_c_array(filename, output, palette): img Image.open(filename).convert(RGB) w, h img.size with open(output, w) as f: f.write(fconst uint8_t sprite_{w}x{h}[] {{\n) for y in range(h): for x in range(w): r, g, b img.getpixel((x, y)) # 找到最接近的调色板索引 idx find_closest_palette_index((r, g, b), palette) f.write(f{idx},) f.write(\n) f.write(};\n)这种“像素画→工具导出→C数组”的工作流比手工画精灵舒服太多而且修改素材也方便不用重新写代码。6. 写在最后的一点实操经验这个PIC32游戏项目是我做过的最有意思的嵌入式项目之一因为做的时候要同时考虑到硬件资源的限制和“好玩”这个目标很多东西都是逼出来的。回头来看有几个经验让我印象很深第一MCU游戏的优化不能靠“感觉”来调要量化。我在开发过程中给每个模块都加了时间戳统计算过一次后就清楚知道瓶颈到底在哪里。当时我统计后发现SPI刷屏占了超过60%的CPU时间这才下决心做脏矩形和DMA。如果没有这组数据我可能还在傻傻地往全屏刷上一条路走到黑。第二Flash空间永远比RAM空间“多”但也不代表可以随便浪费。256KB Flash看着很大但如果设计得不好一个10秒的音效素材就能吃光一半。素材一定要以索引形式存储颜色信息放调色板里这样才能在有限的存储空间内塞进足够丰富的内容。第三做MCU游戏最大的成就感不是“游戏好玩”而是“我能用一个比指甲盖大不了多少的芯片把一段逻辑和一堆像素变成能让小朋友玩得津津有味的东西”。这也是我决定把这个过程写出来的原因——希望有更多人能体会到这种乐趣。最后再分享一个小技巧如果你也打算在PIC32或者类似的MCU上做游戏建议一开始就接上一块可以查看变量变化和帧率的调试工具接口。MCU项目做久了会发现真正难缠的不是“跑不起来”而是“跑起来了但感觉不对”这时候能实时看到帧率、按键事件、内存使用量排查问题会轻松很多。
返回列表