拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于PIC32的单片机游戏机开发实战

1. 项目概述与硬件选型1.1 为什么选PIC32做游戏机说句实话最开始我是在STC和AVR上捣鼓点阵游戏的8位单片机跑到后面代码是能跑但画面稍微复杂一点就吃力一帧刷新要人老命。直到换到PIC32才真正体会到“拿单片机做游戏机”这件事可以实现得很完整。PIC32MX系列用的是MIPS M4K核心主频80MHz32位数据处理内存也从几KB直接跳到32KB、64KB甚至128KB。这意味着两件事第一屏幕帧缓冲可以大大方方地放进RAM里第二游戏逻辑可以写得像模像样不再是一个纯靠延时撑起来的死循环。再加上它标配多个定时器、SPI、UART、DMA、输出比较PWM这些外设做一台带彩色屏幕、按键输入、蜂鸣器音效的小游戏机硬件上基本没有短板。为什么要选PIC32而不是STM32不是STM32不行而是PIC32的MIPS架构和Microchip自家外设设计风格和ARM体系差别很大。你在PIC32上会接触到很多“寄存器要自己按手册配置”的场景对理解中断优先级、外设时钟树、SPI时序的底层逻辑特别有帮助。而且Microchip的XC32编译器免费版足够用MPLAB X IDE也跨平台折腾门槛低。所以这个项目的定位很明确一台基于PIC32、带TFT彩色屏幕、通过按键操作、能跑多个游戏逻辑的微型游戏机。工程上不算特别复杂但从硬件到软件每个环节都得自己动手非常适合想深入了解嵌入式系统完整工作流程的人。1.2 一套能跑游戏的硬件清单我在实际搭建的时候参考了一份很常见的PIC32游戏机配置整体思路是“够用、便宜、好接线”。下面是我的清单组成型号/规格说明主控PIC32MX575F512H64KB RAM、512KB Flash也可以换PIC32MX795F512LRAM更大更从容开发板裸板手工焊接或chipKIT Uno32想省事可以直接用Uno32起步显示屏1.8英寸TFT LCD128x160ST7735驱动SPI接口实际使用128x128区域上面留出分数栏输入4个轻触按键 1个复位键方向控制确认/发射音频无源蜂鸣器5V或3.3V规格用一个三极管做驱动不能直接挂引脚电源USB供电 AMS1117-3.3稳压电流至少要300mATFT背光比较费电调试工具PICkit4或MPLAB Snap断点调试对排查逻辑问题很重要我这里选了PIC32MX575F512H主要是看重64KB RAM。跑128x128的RGB565帧缓冲单缓冲区要32KB双缓冲要64KB刚好卡在这档。如果你用32KB RAM的PIC32MX270就只能做小分辨率或者局部刷新方案后面我会专门讲这个限制。屏幕我用的是ST7735驱动的1.8寸TFT淘宝几块钱一片SPI接口四线就能驱动和PIC32的SPI模块很搭。按键直接接GPIO上拉输入按下为低电平。蜂鸣器这块要稍微注意PIC32引脚驱动能力有限直接驱动蜂鸣器声音又小又容易把引脚搞坏我加了S8050三极管做开关实测声音干净得多。1.3 裸机开发还是Harmony框架Microchip官方有Harmony软件框架里面带图形库MLA能帮你做彩色GUI界面。我刚开始也尝试过但很快发现一个问题Harmony Graphics是为“界面”设计的它考虑的是按钮、窗口、文本标签这些GUI元素而游戏需要的是高速帧刷新、像素级绘制、逐帧物理更新用GUI框架反而多一层开销。所以我最终选择裸机开发。所谓裸机就是不用操作系统、不用Harmony直接操作寄存器和底层驱动。这样屏幕每次刷新我要干什么完全可控中断优先级自己配置游戏逻辑也能写得非常干净。缺点是要多读一点芯片数据手册尤其对SPI、定时器、中断控制器这些外设的寄存器配置要心里有数。如果你之前只玩过Arduino第一次碰PIC32可能会觉得寄存器“反人类”。我的建议是别怕先照着手册把SPI、定时器、GPIO这几个外设配置好跑通一次“点亮屏幕、按下按键有反应”后面的路就顺了。因为这个项目真正难的不是外设而是游戏循环架构。2. 系统架构设计游戏机是怎么运作的2.1 帧循环定时器中断与主循环的配合游戏机和普通单片机程序最大的区别在于它有一个稳定的时间基准。我们平时写LED闪烁可以用delay但游戏不能因为delay会阻塞一切按键没法扫、屏幕没法刷、逻辑没法跑。我的做法是主循环定时器中断的组合。定时器2设置成16.67ms中断一次对应60Hz的游戏帧率。中断里只做两件耗时很短的事情读取按键状态、给主循环设置一个“帧到了”的标志位。主循环检测到这个标志之后才执行游戏逻辑更新和屏幕渲染。这样设计的原因有几个。第一中断里不能做耗时操作因为PIC32的中断一旦占太久其他中断和实时性都会受影响后面我会用一整节来讲这个坑。第二游戏逻辑放在主循环里可以接受偶发的时间抖动只要下一帧计算时能把时间步长修正回来就行。第三这种结构后续加功能非常方便想加音乐、加道具、加菜单不会破坏整体骨架。volatile uint8_t frame_flag 0; void __ISR(_TIMER_2_VECTOR, IPL4SOFT) T2_Handler(void) { IFS0CLR _IFS0_T2IF_MASK; // 清除中断标志 scan_buttons(); // 读取并记录按键状态 frame_flag 1; } int main(void) { system_init(); // 时钟、GPIO、SPI、定时器、PWM等初始化 while (1) { if (frame_flag) { frame_flag 0; game_update(); // 游戏逻辑 render_frame(); // 把帧缓冲推到屏幕 } } }这个结构很朴素但非常稳。游戏运行期间CPU大部分时间都在等帧标志不会空转浪费也不会因为某帧渲染过慢把整台机器卡死。你把延时函数从代码里清干净整个程序的时间感会完全不同。2.2 128x128帧缓冲与SPI带宽计算做彩色游戏最简单粗暴的方式是双缓冲在RAM里放两张“画布”一张用于当前显示一张用于后台绘制绘制完成后切换显示。这让画面没有任何撕裂感也是我能稳定跑游戏的重要原因。但这里有个绕不开的计算RAM容量和SPI传输时间。先算容量。我的屏幕显示区域是128x128像素每个像素用RGB565格式占2个字节。一帧缓冲是128 x 128 x 2 32,768 字节双缓冲就是64KB。PIC32MX575F512H刚好有64KB RAM整块RAM几乎全给了屏幕。PIC32MX795F512L有128KB RAM更宽裕用起来心里不慌。再算传输时间。ST7735通过SPI接收数据我实际把SPI时钟稳定在20MHz左右。整屏刷新一次需要发送32768 x 8 262,144 bit在20MHz下传输耗时约262144 / 20,000,000 13.1ms这个数字很关键。一帧的时间预算只有16.67ms意味着光传屏幕数据就用掉78%的时间。如果SPI只能跑10MHz一帧要26ms上限帧率只有38FPS游戏就会明显卡顿。所以我强烈建议布线尽量短SPI时钟尽量拉高实在不行就缩小刷新区域。很多人做出来屏幕一直闪、动画不流畅十有八九不是代码逻辑问题而是SPI带宽卡住了。这个计算在项目一开始就应该做而不是等代码写完才发现。2.3 用状态机管理游戏流程游戏不是从开机到结束都在打同一套逻辑的。它会在开机画面、主菜单、游戏中、暂停、Game Over这些状态之间切换。如果把这些状态全塞进一个main函数里代码会乱成一锅粥。我用一个简单的状态机来管理typedef enum { STATE_INIT, STATE_MENU, STATE_PLAYING, STATE_PAUSED, STATE_GAMEOVER } GameState; GameState current_state STATE_INIT;每次帧循环里根据当前状态调用对应的处理函数void game_update(void) { switch (current_state) { case STATE_INIT: load_level(); current_state STATE_MENU; break; case STATE_MENU: update_menu(); break; case STATE_PLAYING: update_gameplay(); break; case STATE_PAUSED: update_pause(); break; case STATE_GAMEOVER: update_gameover(); break; } }状态迁移的时机很明确菜单界面时按下START键就切到STATE_PLAYING游戏过程中按暂停键切到STATE_PAUSED球掉光时切到STATE_GAMEOVER。这个架构的好处是每块逻辑都很独立修菜单的bug不会碰到游戏逻辑给后续加设置界面、最高分记录也留了位置。3. 核心模块实现细节3.1 ST7735驱动初始化与矩形窗口刷新整个显示驱动的核心就是ST7735的初始化序列和窗口写入机制。初始化如果没做对屏幕可能白屏、花屏或者颜色不对。我这里把关键步骤列出来你照着手册细调即可硬件复位RES引脚拉低至少10ms再拉高延时120ms以上发Sleep Out命令0x11延时120ms发Display On命令0x29或其他显示模式命令设置帧格式、Gamma、电源控制等寄存器这些通常用厂商提供的初始化表设定显示区域和像素格式ST7735最方便的一点是支持设置矩形窗口再批量写像素。你在RAM里算好一整行数据然后一次性通过SPI发过去比“每次画一个点都要发命令”快太多了。窗口命令是CASET0x2A和RASET0x2B设置完矩形区域后RAMWR0x2C命令开始连续接收数据。void LCD_SetWindow(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1) { SPI_WriteCmd(0x2A); SPI_WriteData(0x00); SPI_WriteData(x0); SPI_WriteData(0x00); SPI_WriteData(x1); SPI_WriteCmd(0x2B); SPI_WriteData(0x00); SPI_WriteData(y0); SPI_WriteData(0x00); SPI_WriteData(y1); SPI_WriteCmd(0x2C); }我实测下来用整窗口刷新128x128区域配合SPI连续写效果最稳定。有人说可以做“脏矩形”局部刷新确实能省时间但游戏里多个对象高速移动时脏矩形管理容易漏一块、花一块。全帧刷新配合尽量高的SPI时钟反而是最简单可靠的方案。3.2 按键输入与消抖实战按键是最容易出问题但又最不容易被注意到的模块。物理按键按下瞬间触点会弹跳几次如果不消抖一个按键会被识别成好几次。我见过有人用delay(20ms)做消抖这在普通程序里没问题但在游戏里会阻塞帧循环绝对不能这么干。我的消抖方法是把按键扫描放进定时器中断里。由于中断每16.67ms执行一次每次读到的都是很稳定的状态天然消除了弹跳影响。我把当前读到的状态存下来和上一次的状态做对比产生“按下事件”而非“电平状态”。#define BTN_A_PORT PORTDbits.RD6 #define BTN_B_PORT PORTDbits.RD7 static uint16_t btn_now 0; static uint16_t btn_prev 0; static uint16_t btn_event 0; void scan_buttons(void) { btn_now 0; if (BTN_A_PORT 0) btn_now | 1; if (BTN_B_PORT 0) btn_now | 2; // 类似处理其他按键 btn_event (btn_now ^ btn_prev) btn_now; // 检测上升沿即刚按下的按键 btn_prev btn_now; }游戏逻辑里只用btn_event判断“这次操作”不会因为按住不放而反复触发。比如打砖块里按发射键应该是按一下球飞出去而不是按住就一直发射。这种“事件驱动”的思想贯穿整个游戏开发非常重要。3.3 PWM蜂鸣器给游戏加上声音反馈声音对游戏体验的提升很大。这个项目我用无源蜂鸣器加PIC32输出比较模块OC产生PWM方波。PIC32的OC模块原理并不复杂它和定时器配合在周期值处比较输出翻转从而产生指定频率的方波。你需要计算定时器周期寄存器PRx假设外设时钟PBCLK 40MHz想产生440Hz的A音PR2 (40,000,000 / (440 x 2)) - 1 45,454 - 1 ≈ 45,453OC1RS设为PR2的一半就能得到50%占空比void Buzzer_Tone(uint32_t freq) { uint32_t pr (40000000UL / (freq * 2)) - 1; if (pr 0xFFFF) pr 0xFFFF; PR2 pr; OC1RS pr / 2; }音效方面我封装了三个简单函数短音5ms、长音120ms、扫频音频率从高到低滑动。每个函数内部用一个变量记录播放剩余时间在帧循环里递减不阻塞游戏。举一个实际例子打砖块里撞到砖块可以播放一个短促的高音球落地播放一个下滑音胜利时播放一段上行音阶。玩家对这些声音反馈很敏感游戏“手感”一下子就不一样了。3.4 帧率稳定与游戏速度的数学一致游戏开发里有个经典问题同一份代码在不同主频、不同SPI速度的板子上游戏速度会不一样。如果你写的是“每帧球移动3像素”那在60FPS的板子上球每秒跑180像素在38FPS的板子上只跑114像素。解决方法是把速度从“像素/帧”改成“像素/秒”每帧根据实际时间步长来计算位移。由于我固定了16.67ms的逻辑帧周期这一步其实很简单#define FRAME_INTERVAL_MS 16.67f #define BALL_SPEED_X 120.0f // 像素/秒 #define BALL_SPEED_Y 160.0f ball.x (int16_t)(BALL_SPEED_X * (FRAME_INTERVAL_MS / 1000.0f)); ball.y (int16_t)(BALL_SPEED_Y * (FRAME_INTERVAL_MS / 1000.0f));虽然看起来每帧算出来的位移是一个小数取整但长期看累计速度是准确的。我建议所有速度、加速度都统一用“秒”为单位不要混用帧和秒。这个习惯能让你以后换屏幕、调SPI频率时不用再逐个改游戏参数。4. 游戏逻辑实战以打砖块为例4.1 游戏对象与关卡数据结构框架搭好了我用一个经典的打砖块游戏来验证整个系统。打砖块涉及挡板移动、球的物理反弹、砖块碰撞、计分、生命值和难度升级覆盖了2D游戏开发的大部分核心内容。我用一个统一的对象结构来管理所有游戏实体包括球、挡板和砖块typedef struct { int16_t x, y; int16_t w, h; int16_t vx, vy; // 速度单位像素/秒 uint16_t color; uint8_t active; } GameObject; GameObject ball; GameObject paddle; #define BRICK_COLS 8 #define BRICK_ROWS 5 uint8_t brick_map[BRICK_ROWS][BRICK_COLS]; // 1表示存在0表示已消 uint8_t brick_color[BRICK_ROWS][BRICK_COLS];Brick数据不直接用GameObject结构是因为砖块有行列规律用二维数组管理碰撞和绘制都更直观。实际算砖块像素位置时按固定的行列间距砖块宽12px、高8px、间距2px计算即可。屏幕布局是顶部留16px显示分数和生命值下面128x112区域作为游戏场。挡板在底部宽度40px高度6px通过左右按键移动。球直径10px初始贴在挡板正上方按发射键起飞。4.2 碰撞检测与反弹方向修正碰撞检测我用的是AABB矩形相交判断。每个对象都有x、y、w、h两个矩形是否相交只需检查四个方向是否重叠int8_t CheckCollision(GameObject *a, GameObject *b) { if (a-x a-w b-x || b-x b-w a-x) return 0; if (a-y a-h b-y || b-y b-h a-y) return 0; return 1; }但仅仅“相交”还不够你还要判断球从哪个方向撞上来才能修正反弹方向。我的做法是分离轴判断计算球在上一帧的位置根据穿透深度最小的方向来决定是上下反弹还是左右反弹。为了简化这里用“比较重叠量”的思路// ball和brick已经重叠 int overlap_left (ball.x ball.w) - brick.x; int overlap_right (brick.x brick.w) - ball.x; int overlap_top (ball.y ball.h) - brick.y; int overlap_bottom (brick.y brick.h) - ball.y; // 找到最小重叠方向用最小重叠方向判断反弹比单纯固定“碰到就反向Y”准确得多。如果不做这个修正球从侧面碰到砖块时会有概率直接穿过或者反向方向完全错误。还有一个非常经典的坑隧穿。球速度很快时可能这一帧还在砖块左边下一帧已经跑到砖块右边两帧之间没有重叠区域碰撞检测直接漏掉。我限制球的最大速度不超过每帧8像素即每秒480像素这个值小于最小砖块宽度12px从根源上避免了隧穿。挡板反弹这里我做了个更好的设计球打在挡板不同位置反弹的水平速度不同。中心位置反射最正边缘位置加一个侧向速度。这让玩家可以“控制”球的路径可玩性大增float hit_pos (ball.x ball.w/2) - (paddle.x paddle.w/2); float ratio hit_pos / (paddle.w / 2); // -1..1 ball.vx (int16_t)(ratio * 200.0f); // 水平速度按击打位置变化4.3 计分、生命值与难度曲线打砖块的分数我做了个简单的递减规则越靠上的砖块分值越高第一行10分最后一行2分。这样玩家会倾向于优先消上层砖块增加策略性。生命值默认3条球落到底部减一条减完进入Game Over状态。难度曲线不能做得“突然变快”否则玩家会觉得很突兀。我采用的方案是每消除10个砖块球的水平速度和垂直速度各增加5%每次增加时蜂鸣器响一个短短的高音提示玩家。这个设计让玩家能明确感知到“难度提升了”但又不至于一下子失控。顶部分数栏用点阵字模绘制数字。我在代码里放了一个5x7的数字字模数组每个数字用7个字节表示一行数据。绘制时遍历每位的位图把对应像素写入帧缓冲。这样处理16bit RGB565颜色和背景色看起来非常清晰。const uint8_t font_digits[10][7] { {0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E}, // 0 {0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E}, // 1 // ... };游戏每局开始时根据当前关卡号和剩余生命值重新布局砖块而不是直接复用同一份地图。我简单做了两关第二关砖块颜色更多、速度更快。加上高分记录存到Flash里每次开机读取上次最高分整体就是一个很完整的游戏体验了。5. 调试经验与避坑记录5.1 屏幕花屏SPI模式与初始化时序我调试时遇到的第一大坑就是屏幕花屏。画面一开始完全随机色点或者水平条纹代码看起来没问题但就是不显示。排查下来发现两个原因。第一个是SPI模式不匹配。ST7735需要SPI Mode 0或Mode 3大部分stm32示例默认Mode 0但如果你移植时不注意CPOL/CPHA配置就容易出现“有数据但花屏”的情况。第二个是初始化时序里复位延时不够ST7735对启动时序要求比较严格RES低电平保持时间和Sleep Out之后的延时都不能省。我写了一个简单的排查顺序先用逻辑分析仪抓SPI的CS、SCK、SDA波形看看有没有正常输出然后确认SPI时钟极性最后把初始化延时都加到手册推荐值。一套下来屏幕基本就正常了。5.2 按键偶尔失灵与自动重复按键问题的表现很诡异大部分时候正常偶尔按下没反应或者按一下跳两下。后来发现是消抖策略的问题。我之前用“连续读两次相同才确认”但两次读的间隔太短还是会漏。放到16.67ms的中断里扫描之后问题彻底消失因为采样间隔远远大于弹跳时间。还有一个容易忽略的点主循环里的渲染耗时很长如果按键扫描不是放在中断里而是放在主循环中那么渲染期间按键完全没被读取就表现为“按了没反应”。你以为是按键坏了其实是代码结构问题。按键事件里还应该做一个简单的“按住自动重复”功能方便菜单选择。我的做法是如果按钮保持按下超过500ms之后每150ms自动产生一次按下事件。5.3 帧率不足先从传输量下手跑起来之后我发现画面能显示但动画明显掉帧用MPLAB的调试器看了一下整个帧循环跑一趟要30ms以上比16.67ms高一倍。逐项排查下来最耗时的就是整屏SPI刷新。我做了几个优化把SPI时钟从10MHz提升到20MHz传输时间直接减半去掉渲染函数里不必要的颜色转换直接在帧缓冲里存RGB565格式减少每帧发送的命令开销窗口设置命令只在首次刷新时发送一次。优化后整帧时间降到15ms左右60帧稳定。如果你还想压榨性能可以考虑用PIC32的DMA让SPI传输不占用CPU。DMA模式下CPU设置好源地址、目标地址和长度后传输由DMA控制器完成CPU可以继续跑游戏逻辑。这个改动可以再省出5到8ms每帧。5.4 中断里别干重活这是我这个项目里最深刻的教训。当时急于让声音更像样我在定时器中断里调用了音效更新函数而这个函数又要计算频率、设置PWM寄存器本身还好但后来又加了一段“每次播放时延迟几毫秒”的代码。结果是什么主循环里的渲染被拉长按键扫描也变慢整个游戏像慢动作。PIC32的中断处理函数应该是轻量的、快速进出的。它不适合放延时、循环、浮点运算、SPI传输这些耗时操作。我的最终原则是中断里只做三件事读按键、清标志、设置事件标志。其他所有逻辑都在主循环里处理。这个原则让整个系统的稳定性和实时性都得到了保障。还有一个相关的小细节PIC32的中断优先级IPL可以设置我把定时器中断设成IPL4DMA和紧急事件设成更高优先级。这样即使主循环再忙帧标志也能准确产生游戏逻辑不会乱套。这套项目做下来我最深的感受是单片机做游戏机真正难的其实不是某个外设怎么配而是整个系统的架构取舍。你需要在RAM容量、SPI带宽、中断响应和代码清晰度之间找平衡。如果你也想动手试一次建议别一上来就贪心先做一个最简单的乒乓球把显示、输入、逻辑三层拆干净再往里面加砖块、加音效、加菜单。只要架构不倒后面加功能都是顺水推舟的事。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门