1. 项目概述与硬件选型
1.1 为什么选PIC32做游戏机
说句实话,最开始我是在STC和AVR上捣鼓点阵游戏的,8位单片机跑到后面代码是能跑,但画面稍微复杂一点就吃力,一帧刷新要人老命。直到换到PIC32,才真正体会到“拿单片机做游戏机”这件事可以实现得很完整。
PIC32MX系列用的是MIPS M4K核心,主频80MHz,32位数据处理,内存也从几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游戏机配置,整体思路是“够用、便宜、好接线”。下面是我的清单:
| 组成 | 型号/规格 | 说明 |
|---|---|---|
| 主控 | PIC32MX575F512H(64KB RAM、512KB Flash) | 也可以换PIC32MX795F512L,RAM更大更从容 |
| 开发板 | 裸板+手工焊接,或chipKIT Uno32 | 想省事可以直接用Uno32起步 |
| 显示屏 | 1.8英寸TFT LCD,128x160,ST7735驱动,SPI接口 | 实际使用128x128区域,上面留出分数栏 |
| 输入 | 4个轻触按键 + 1个复位键 | 方向控制+确认/发射 |
| 音频 | 无源蜂鸣器(5V或3.3V规格) | 用一个三极管做驱动,不能直接挂引脚 |
| 电源 | USB供电 + AMS1117-3.3稳压 | 电流至少要300mA,TFT背光比较费电 |
| 调试工具 | 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发过去,比“每次画一个点都要发命令”快太多了。窗口命令是CASET(0x2A)和RASET(0x2B),设置完矩形区域后,RAMWR(0x2C)命令开始连续接收数据。
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,453
OC1RS设为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)可以设置,我把定时器中断设成IPL4,DMA和紧急事件设成更高优先级。这样即使主循环再忙,帧标志也能准确产生,游戏逻辑不会乱套。
这套项目做下来,我最深的感受是:单片机做游戏机,真正难的其实不是某个外设怎么配,而是整个系统的架构取舍。你需要在RAM容量、SPI带宽、中断响应和代码清晰度之间找平衡。如果你也想动手试一次,建议别一上来就贪心,先做一个最简单的乒乓球,把显示、输入、逻辑三层拆干净,再往里面加砖块、加音效、加菜单。只要架构不倒,后面加功能都是顺水推舟的事。