简介:基于STM32的谷歌小恐龙游戏移植项目,面向嵌入式学习者和游戏开发爱好者,演示如何将浏览器经典小游戏完整落地到STM32F103平台。项目核心涵盖GPIO接入按键与LCD屏、定时器控制游戏帧率、中断响应跳跃、SPI/I2C驱动显示、内存优化以及C语言重写游戏逻辑等关键环节,适合希望综合提升STM32外设编程与嵌入式图形开发能力的中级开发者。压缩包共87个文件,以45个h头文件、20个c源码文件为主,辅以makefile构建脚本、ioc工程配置、md说明文档及图片资源,整体约855KB,目录结构清晰,按Drivers、Core、Src、Inc等模块组织,便于直接导入CubeIDE或阅读移植思路。资源已有1208人学习,包含完整的工程源码、LCD显示驱动、按键控制逻辑和碰撞检测与计分系统,入手后可快速复现并基于此扩展动画、音效或更多关卡。
1. 基于stm32的谷歌小恐龙游戏,练的不是点灯,是整条渲染链路
把Chrome断网页的小恐龙搬到嵌入式屏上,第一反应可能是“一个玩具”。但真要在stm32上跑起来,你面对的是:SPI屏驱动、帧缓冲管理、定时器中断调度、碰撞检测状态机,还有“屏幕刷新跟不上逻辑更新”这个所有嵌入式GUI都要过的坎。这个项目很适合作为stm32入门到进阶的中转站,也能直接当课设或毕业设计底子。文章里我用F103蓝丸加128x64的SSD1306来讲,CPU性能和RAM余量都留出了足够空间,你换F401、F407或者国产APM32也只要对着引脚改下就行。
2. 硬件选型与CubeMX工程:主控、屏幕、引脚先谈清楚
2.1 主控选型:F103C8T6、F401CCU6、F407VET6 怎么选
这个游戏本身计算量不大,真正的瓶颈在屏幕刷新和内存。先把三款常见的stm32芯片放一起比:
| 型号 | Flash/RAM | 主频 | 硬件SPI | 适合度 |
|---|---|---|---|---|
| STM32F103C8T6 | 64KB/20KB | 72MHz | SPI1/SPI2 | 主选:RAM够放帧缓冲,Flash够放sprite |
| STM32F401CCU6 | 256KB/64KB | 84MHz | SPI1/2/3 | 有余量,上彩色ST7735也不怕 |
| STM32F407VET6 | 512KB/128KB | 168MHz | SPI1..3 | 资源过于充足,玩具项目性价比低 |
我一般建议F103C8T6起步,原因很直接:这个项目用到的全部资源就是一块单色屏帧缓冲、几十个sprite数组、两个定时器。F103C8T6的20KB RAM里,SSD1306的帧缓冲只占512字节,剩下的空间足够你做双缓冲或者存曲线数据。64KB Flash在开-O2优化后也剩不少,不至于为了挤空间去手写汇编。
另外提一下,APM32F103系列在寄存器和HAL库兼容性上做得相当好,如果你手上有国产替代芯片,工程文件基本可以直接烧,不用改引脚映射。
2.2 屏幕与接口:SSD1306 SPI是首选,I2C版本要慎用
市面上一搜“stm32 屏幕”会出现一堆型号,但适合这个游戏的就两类:0.96寸的SSD1306和1.8寸的ST7735。我用下面这张表说明差别:
| 屏幕 | 分辨率与颜色 | 帧缓冲大小 | 整屏刷新开销 | 推荐度 |
|---|---|---|---|---|
| SSD1306 0.96寸 SPI | 128x64 单色 | 512字节 | 8MHz SPI下约0.5ms | 首选 |
| SSD1306 0.96寸 I2C | 128x64 单色 | 512字节 | 400kHz下约23ms | 不推荐做游戏 |
| ST7735 1.8寸 SPI | 128x160 RGB565 | 40KB左右 | 局部刷新可用 | 备选 |
| ILI9341 2.4寸 SPI | 240x320 RGB565 | 150KB以上 | F103吃不消 | 不推荐 |
说句不好听的,SSD1306的I2C版本在游戏项目里是个坑。400kHz I2C理论带宽约50KB/s,一屏1024字节大约要20多毫秒,也就是你辛辛苦苦把逻辑跑出60fps,最后被屏幕刷新锁死在四十多帧,而且CPU全程阻塞在发数据上。换成SPI版本后,同样内容一帧几毫秒就发完,完全不给游戏逻辑拖后腿。
2.3 引脚分配、CubeMX配置与最小驱动代码
以SSD1306 4线SPI为例,接线非常固定:
| 信号 | STM32引脚 | 说明 |
|---|---|---|
| SCK | PA5 | SPI1_SCK |
| MOSI | PA7 | SPI1_MOSI |
| CS | PB0 | GPIO输出,低电平有效 |
| DC | PB1 | GPIO输出,0为命令,1为数据 |
| RES | PB10 | GPIO输出,复位 |
| KEY | PA0 | 按键输入,接3.3V |
CubeMX里勾上SPI1的Full-Duplex Master模式,Prescaler选16分频,72MHz主频下得到4.5MHz的SPI时钟,SD1306完全支持这个速度。GPIO配置上,CS、DC、RES全部设成推挽输出,KEY设成下拉输入。
用HAL库写最小驱动只需要两步:初始化序列,以及一个发命令的函数。初始化时要特别注意SSD1306必须先关显示(0xAE),等charge pump配置好再开显示(0xAF),顺序反了大概率花屏。
static void OLED_Cmd(uint8_t cmd) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // CS拉低 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET); // DC=0 命令 HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); // CS拉高 } static void OLED_Init(void) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_10, GPIO_PIN_RESET); HAL_Delay(50); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_10, GPIO_PIN_SET); HAL_Delay(20); OLED_Cmd(0xAE); OLED_Cmd(0xD5); OLED_Cmd(0x80); OLED_Cmd(0xA8); OLED_Cmd(0x3F); OLED_Cmd(0xD3); OLED_Cmd(0x00); OLED_Cmd(0x40); OLED_Cmd(0x8D); OLED_Cmd(0x14); // 打开charge pump OLED_Cmd(0x20); OLED_Cmd(0x00); // 水平寻址模式 OLED_Cmd(0xA1); OLED_Cmd(0xC8); OLED_Cmd(0xDA); OLED_Cmd(0x12); OLED_Cmd(0x81); OLED_Cmd(0xEF); OLED_Cmd(0xA6); OLED_Cmd(0xAF); }这里HAL_SPI_Transmit的最后一个参数是超时毫秒,游戏代码里我习惯写10。另外注意CS和DC的时序必须在发送命令前后手动维护,CubeMX的软件NSS不会帮你管这个。发数据的版本只要把DC拉高,其它逻辑一模一样。
如果你用的是Keil MDK而且还没装F1系列芯片包,打开Pack Installer搜“STM32F1xx_DFP”装上,否则CubeMX生成的工程编译时直接报Device not found,这步是很多刚复现stm32项目的人最先卡住的地方。
3. 定时器调度与状态机:跳跃、障碍和碰撞都在这几毫秒里发生
3.1 游戏逻辑不要裸写在while(1)里,用固定时间片驱动
初学阶段常见的写法是直接在while循环里“刷新一下屏幕,移动一下障碍”,看起来简单,实际跑起来会发现:障碍移动速度忽快忽慢,画面有小撕裂。原因是渲染耗时不稳定,逻辑更新被渲染拖累。这个游戏跟流水灯不一样,物理参数基于时间而不是基于循环次数,所以必须固定时间片。
固定时间片的做法是用stm32定时器产生中断,中断服务函数里只置标志位,主循环检测到标志位后再执行逻辑和渲染。
volatile uint8_t g_gameTick = 0; void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) { TIM2->SR = ~TIM_SR_UIF; g_gameTick = 1; } } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); OLED_Init(); TIM2_Init(); // 71分频,9999重载,10ms中断一次 while (1) { if (!g_gameTick) continue; g_gameTick = 0; ProcessInput(); UpdateGame(); RenderFrame(); } }参数说明:TIM2的预分频器设为71,自动重载值设为9999,72MHz经过72分频后正好是1MHz,再计10000个数就是100Hz,也就是10ms一个tick。ISR里不做游戏逻辑,只翻标志,好处是中断执行时间极短,不会跟主循环里的HAL_Delay之类功能抢时间。有人把逻辑直接写进中断,结果跳跃和障碍更新互相穿插,调参时想骂人。
3.2 状态机:待机、奔跑、撞树三个状态一笔理清
小恐龙游戏的逻辑结构用状态机标注最清晰。开机进入待机,按一下进入奔跑;奔跑中检测到碰撞就进入结束;结束再按一次跳回待机。
typedef enum { ST_READY, // 待机,恐龙原地站立 ST_RUNNING, // 奔跑中,障碍物开始移动 ST_OVER // 碰撞结束,等待复位 } GameState; static GameState g_state = ST_READY; void ChangeState(GameState next) { g_state = next; if (next == ST_READY) { dinoY = GROUND_Y; dinoVY = 0; obstacleX = -1; score = 0; } }这里没有用switch包裹所有状态行为,而是在每个Update函数开头判断当前状态,代码更直白。容易出现的问题是:进入ST_OVER后障碍物还在继续移动。正确做法是在状态切换时把所有动态变量重置,而不是等下一帧去“补救”。
3.3 跳跃物理用整数运算,别让浮点进中断路径
跳跃用重力模型模拟,但嵌入式里我不建议用浮点,原因不是算不动,而是不同编译优化级别下浮点行为不一致,调试麻烦。用整数就够了。
#define GROUND_Y 56 #define GRAVITY 3 #define JUMP_VY -14 static int8_t dinoY = GROUND_Y; static int8_t dinoVY = 0; static uint8_t isJumping = 0; void UpdateDino(uint8_t isKeyPressed) { if (!isJumping && isKeyPressed) { isJumping = 1; dinoVY = JUMP_VY; } if (isJumping) { dinoVY += GRAVITY; dinoY += dinoVY; if (dinoY >= GROUND_Y) { dinoY = GROUND_Y; dinoVY = 0; isJumping = 0; } } }解释一下这组参数的含义:100Hz tick下,起跳速度-14像素/帧,重力加速度3像素/帧平方。最高点大约在1414/(23)约33像素处。太小了跳不过仙人掌,太大了一跳顶到屏幕边缘。这组数据是我反复试过后比较舒服的区间。
| 帧率 | JUMP_VY | GRAVITY | 最高点估算 |
|---|---|---|---|
| 100Hz (10ms) | -14 | 3 | 约33像素 |
| 100Hz (10ms) | -10 | 2 | 约25像素 |
| 200Hz (5ms) | -28 | 6 | 约65像素,偏跳太高 |
调参时有个原则:改跳跃速度就同步缩放重力,两者保持同一比例才不会让跳跃轨迹变“飘”。很多人只把JUMP_VY改大,结果恐龙像蹦床一样半天不下来。
3.4 障碍物生成与碰撞检测的边界留一线
障碍物每隔一段时间从屏幕右侧生成,向左移动,移出左边界后归位。每帧移动两个像素。
static int8_t obstacleX = -1; static uint16_t obstacleCountdown = 30; #define OBSTACLE_W 10 #define OBSTACLE_H 16 #define OBSTACLE_Y (GROUND_Y - OBSTACLE_H) void UpdateObstacle(void) { if (obstacleX < 0) { if (obstacleCountdown > 0) { obstacleCountdown--; return; } obstacleX = 128; obstacleCountdown = 20 + rand() % 30; return; } obstacleX -= 2; if (obstacleX + OBSTACLE_W < 0) obstacleX = -1; }碰撞检测是实现“手感”的关键地方。很多新手直接拿整个sprite矩形去做相交判断,结果玩家觉得“明明没撞上怎么就死了”。原因是黑白的sprite边缘有透明像素,视觉上没接触,几何上已经重叠,所以要做边界内缩。
static uint8_t CheckHit(void) { int8_t dl = dinoX + 2; int8_t dr = dinoX + DINO_W - 3; int8_t dt = dinoY + 2; int8_t db = dinoY + DINO_H - 1; int8_t ol = obstacleX + 1; int8_t orr = obstacleX + OBSTACLE_W - 2; int8_t ot = obstacleY + 1; int8_t ob = obstacleY + OBSTACLE_H - 1; return !(dr < ol || dl > orr || db < ot || dt > ob); }每个方向缩进1到2个像素看起来完全没毛病,玩家体验上会觉得“判定很公平”。这个技巧在纯色数字屏幕上特别管用,因为sprite没有半透明过渡,全包矩形必然偏大。
4. 渲染与显存优化:位图定义、页对齐和局部刷新怎么配合
4.1 帧缓冲:一张512字节的画布,解决所有闪烁问题
SSD1306内部有1KB显存,但游戏里不能直接往屏上画一个点就完事。你操作屏幕内部显存,每次“读改写”都要通过I2C或SPI跟屏幕芯片打交道,速度很慢,还会闪烁。常见的做法是在stm32端再开一块等尺寸的帧缓冲,所有绘制操作先写进这块RAM,最后一次性同步到屏幕。
static uint8_t fb[128][8]; // 128列 x 8页,每页8像素下面这个函数是帧缓冲最基本的操作,所有sprite绘制都建立在它之上:
static void FbSetPixel(uint8_t x, uint8_t y, uint8_t on) { if (x >= 128 || y >= 64) return; uint8_t page = y >> 3; uint8_t bit = 1u << (y & 7); if (on) fb[x][page] |= bit; else fb[x][page] &= (uint8_t)~bit; }y >> 3是页号,0到7;y & 7是页内位偏移。这个函数看着简单,但它是后面所有渲染操作的地基。注意on是uint8_t,别拿1直接进去,展开成int后在位运算里容易出意外。
使用时有两点要注意:第一,帧缓冲坐标原点和屏幕机械方向可能不一致,不同卖家模块的镜像方向不同,如果发现画面反了,调整初始化序列里0xA1和0xC8这两个命令即可;第二,fb是三维数组的话,尽量把列放在第一维,因为后面局部刷新时按列连续读取,cache友好。
4.2 位图存储:按页组织sprite,比BMP数组省事十倍
从网上找一张Chrome恐龙的截图转成数组,最常见的后果是方向不对、颜色反转、烧进去花屏。我的做法是手工按页组织位图:一个16x16的sprite,拆成上下两页,每页16字节,每字节代表8个像素的列。
static const uint8_t dino_run1[2][16] = { { 0x00, 0x7F, 0x40, 0x40, 0x40, 0x7F, 0x00, 0x40, 0x40, 0xE0, 0xE0, 0x40, 0x40, 0x40, 0x40, 0x00 }, { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x00 } };数组里的具体数值只是形状示意,不代表逐像素对应Chrome原版,你可以用取模软件或者手写像素编辑器生成自己的。关键在存储结构:每一行代表一页,16字节对应16列,字节内高位在上。这样绘制时不需要逐像素扫描,整块做位或写进帧缓冲即可。
static void FbBlitPageAligned(uint8_t x, uint8_t page, const uint8_t *spr, uint8_t w) { if (page >= 8) return; for (uint8_t i = 0; i < w; i++) { uint8_t cx = x + i; if (cx >= 128) break; fb[cx][page] |= spr[i]; } }参数含义:x是列起点,page是页号,spr是位图数组指针,w是sprite宽度。这个函数假设绘制位置的y坐标是8的倍数,实际游戏里地面在y=56,正好是页7,天然对齐。如果以后要做非页对齐的动画,比如sprite从y=50开始,就得在位移合成逻辑上再写一层,新手阶段先保证页对齐能让调试成本大幅下降。
素材的Flash占用在这种存储格式下非常低:
| 素材 | 尺寸 | 占用Flash |
|---|---|---|
| 恐龙跑步两帧 | 16x16 | 64字节 |
| 仙人掌 | 10x16 | 20字节 |
| 云 | 16x8 | 16字节 |
| 地面滚动条 | 128x8 | 128字节 |
加起来还不到300字节,这就是单色屏的好处。彩色屏一张sprite动辄几百字节,Flash和RAM都会紧张。
4.3 局部刷新:按脏矩形刷,整屏刷新留给菜单画面
SSD1306支持页地址和列地址设置,利用它的页面模式,可以只刷新地图上真正变化的矩形区域,这在游戏里效果极其明显。恐龙在x=40附近,障碍在100到128之间移动,两个区域根本不重叠,所以每帧只需要发送两个窄条。
void OLED_WriteRect(uint8_t x0, uint8_t page0, uint8_t x1, uint8_t page1) { OLED_Cmd(0x21); // 设置列地址范围 OLED_Cmd(x0); OLED_Cmd(x1); OLED_Cmd(0x22); // 设置页地址范围 OLED_Cmd(page0); OLED_Cmd(page1); for (uint8_t p = page0; p <= page1; p++) { for (uint8_t c = x0; c <= x1; c++) { OLED_Data(fb[c][p]); } } }有了这个函数,渲染流程变成:清帧缓冲,画恐龙,画障碍,画地面,然后调用两次OLED_WriteRect,一次刷恐龙区域,一次刷障碍区域。屏幕数据量从整屏512字节降到几十字节,SPI的阻塞发送时间可忽略,肉眼几乎看不到任何撕裂。
有一点要提醒:局部刷新要处理“擦除旧位置”这一环。最常见的问题是恐龙移动后,旧位置的残影还在。解决思路是在刷新恐龙区域前,先往帧缓冲里写一个全0的区域块,把旧像素清掉,再画新位置。也就是说,每一帧固定区域一定要被完整重建,而不是只在上面叠加新素材。
5. 调参与排错:帧率测量、0x00008200 和按住跳跃长度的标定
5.1 用GPIO翻转法量出真实渲染帧率
不需要J-Link,不需要高级IDE,找一个空闲GPIO配置成推挽输出,在渲染函数末尾翻转一次,用逻辑分析仪或者示波器量方波频率就行:
void RenderFrame(void) { // ... 清缓冲、画恐龙、画障碍、局部刷新 ... HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_8); }PA8输出方波的频率就是渲染帧率,逻辑分析仪看得很直观。如果方波频率不稳定,说明某帧渲染时间长,多半是SPI传输阻塞或整屏刷新混进了局部刷新。理论最大帧率由HAL_SPI_Transmit的阻塞时间决定,不是逻辑代码跑多快。
5.2 stm32常见挂死现场:CFSR值为0x00008200时查BFAR
游戏跑着跑着进了HardFault_Handler,不要乱猜“是不是中断优先级配错了”。在调试器里看SCB->CFSR,如果是0x00008200,这个值可以拆成两部分:0x8000对应BFARVALID,0x0200对应PRECISERR。翻译成人话就是:发生了精确总线错误,而且出错的地址已经记录在SCB->BFAR寄存器里。
在HardFault_Handler里加这样的代码,把现场信息存下来:
void HardFault_Handler(void) { volatile uint32_t cfsr = SCB->CFSR; volatile uint32_t bfar = SCB->BFAR; __asm("bkpt #0"); while (1); }cfsr用来确认是否还是0x00008200,bfar指向具体访问失败的地址,去代码里查那个地址是谁,基本两分钟就能定位。用ST-Link Utility连接目标时,也能在寄存器窗口直接读到这两个值,不用单步执行。这种精确总线错误最常见的原因是把sprite数组下标写越界,例如障碍物x坐标减到负数后还拿去索引fb[cx][page],cx变成负值后在C语言里被隐式转换成了很大的无符号数。
另一个高频挂死点是“delay卡死”,症状是程序停在HAL_Delay里出不来。原因多半是在定时器中断里调用了HAL_Delay,而它依赖SysTick,同等级或更高优先级中断会阻塞SysTick的tick计数,延时时间翻倍直到卡死。小恐龙游戏里我把TIM2中断只置标志位,主循环处理逻辑,就是为了彻底避开这个问题。
5.3 按住跳跃的时间也做成参数,手感靠数据不靠玄学
原版小恐龙有一个很重要的操作细节:按住跳跃键时,起跳上升段会被“充值”,松开后立即下落。这个手感用参数描述很清晰:按住期间每帧附加一点上升量,最多持续12帧。
#define JUMP_HOLD_MAX 12 static uint8_t jumpHoldFrames = 0; void UpdateDinoWithHold(uint8_t isKeyPressed) { if (!isJumping && isKeyPressed) { isJumping = 1; dinoVY = JUMP_VY; jumpHoldFrames = 0; } if (isJumping) { dinoVY += GRAVITY; dinoY += dinoVY; if (isKeyPressed && jumpHoldFrames < JUMP_HOLD_MAX) { dinoY -= 1; jumpHoldFrames++; } if (dinoY >= GROUND_Y) { dinoY = GROUND_Y; dinoVY = 0; isJumping = 0; } } }标定这个参数时,我习惯把起跳初速和按住上升量分开调:先把JUMP_HOLD_MAX设成0,确定基础跳跃高度,保证单次按键能越过最小仙人掌;再逐步增加按住帧数,直到测试者觉得“长按能越过连续大障碍,短按又不会跳太高”。这两个值共同影响跳跃曲线,别在游戏运行中动态修改GRAVITY,不然跳跃弧线会突变,完全没法玩。
本文还有配套的精品资源,点击获取