ARTICLE DETAIL

资讯详情

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

STM32F4贪吃蛇小游戏开发:从CubeMX配置到状态机实现

STM32F4贪吃蛇小游戏开发:从CubeMX配置到状态机实现 简介基于STM32F4开发板的贪吃蛇小游戏完整工程包面向嵌入式初学者与趣味项目开发者采用C语言编写整合触摸屏、LCD显示、四向按键与LED状态指示解决综合外设应用与游戏逻辑设计的练习需求。工程在经典玩法之上加入EASY/HARD难度选择、STOP/START触摸按钮、积分/等级/蛇身长度实时显示及失败边框闪烁提示蛇吃果后速度逐渐加快撞墙或咬尾即结束并具备LED0/LED1在运行与暂停时的不同闪烁逻辑完整呈现状态机与中断配合的交互思路。资源为RAR压缩包共246个文件以60个C源文件、60个H头文件为主体辅以编译生成的O目标文件、CRF交叉引用、HEX烧录文件及D调试信息总体积约6.96MB结构清晰适合导入Keil工程直接查看或二次开发。已有3500余人学习下载适合希望通过实际项目综合训练STM32F4 GPIO、定时器、LCD驱动、触摸屏检测和软件状态管理的读者从中获取完整的工程组织方式与外设初始化、事件处理等可复用经验。1. 基于STM32F4开发板的贪吃蛇小游戏先当外设体检再当程序练手“基于STM32F4开发板的贪吃蛇小游戏”在嵌入式社区里出镜率极高但多数人只把它当入门作业忽略了题目本身的含金量。贪吃蛇规则再简单落到开发板上也要同时处理四条链路按键输入要消抖定时器要提供稳定的游戏节拍OLED或TFT上要绘制蛇身、食物和分数逻辑层还要管理转向缓存与碰撞判定。任何一环出问题都会表现出按键失灵、蛇自己消失、跑几局就卡死这类让新手抓狂的典型现象。对刚接触F4的人这是第一个能看到完整交互闭环的程序对做过多年嵌入式的人一块陌生开发板到手用它验证GPIO、定时器、显示总线和中断优先级远比翻手册快。下面直接从CubeMX配置讲到可玩版本不依赖RTOS。2. STM32F4贪吃蛇的硬件选型与CubeMX初始化显示、按键与定时器心跳2.1 显示方案怎么选0.96寸I2C OLED、SPI TFT与开发板的接口现状贪吃蛇要求画面有格子、蛇身、食物、分数其实只需要一块能寻址像素的屏。常见STM32F4开发板上能见到的显示外设大致有三类I2C接口的0.96寸OLEDSSD1306、SPI接口的1.8寸或2.4寸TFTST7735 / ILI9341、以及走FSMC总线的3.5寸大屏。对贪吃蛇这种逻辑帧率只有10Hz的游戏I2C OLED的带宽完全够128x64分辨率、1KB显存、400kHz I2C下全屏刷新约20ms剩下的时间足够留给按键扫描和逻辑计算。SPI TFT的刷新速度快一个量级代价是多占SCK、MOSI、DC、CS、RST五个引脚FSMC大屏接线和配置复杂属于杀鸡用牛刀。显示方案接口占用引脚全屏刷新耗时估适用场景0.96寸 OLED SSD1306I2CSCL、SDA共2根约20ms 400kHz贪吃蛇默认选择1.8寸 TFT ST7735SPI5根约1ms 18MHz彩色版小游戏2.8寸 TFT ILI9341SPI/FSMC6根以上更快高分辨率界面我一般把OLED的SCL、SDA接到PB8、PB9F4的I2C1这两个引脚在大多数开发板和核心板上都引到了排针对线方便。决定用哪组引脚前先翻一下自己开发板的原理图确认没有和其他外设冲突F4上有I2C1、I2C2、I2C3三组可选引脚冲突时换一组就行驱动代码不需要改只换HAL里的句柄初始化。2.2 CubeMX配置四个方向键GPIO输入模式与内部上拉方向控制不需要摇杆或编码器四个轻触按键是最稳的方案。按键一端接GPIO另一端接GNDCubeMX里把对应引脚设为GPIO输入并开启内部上拉这样松开时读到高电平按下时读到低电平不需要外接上拉电阻。以PA0到PA3为例初始化代码长这样GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOA, GPIO_InitStruct);GPIO_MODE_INPUT表示纯输入GPIO_PULLUP表示内部上拉。这里刻意不用GPIO_MODE_IT_RISING_FALLING双边沿中断原因在第4章的按键扫描里讲清楚动作游戏的转向需要周期性确认按键电平边沿中断只在按下瞬间触发一次不能满足连续转向需求。2.3 用TIM2更新中断做游戏心跳为什么不用HAL_Delay贪吃蛇需要一个稳定的逻辑节拍。常见误用是在main循环里HAL_Delay(100)这会让按键扫描和显示刷新全部阻塞在延时里一旦在中断里改速度或暂停延时误差会越积越大。正确做法是让TIM2产生周期更新中断中断里只置一个标志位由主循环消费这个节拍。/* APB1定时器时钟84MHzPSC8399、ARR999时中断频率84MHz/8400/100010Hz */ htim2.Init.Prescaler 8399; htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 999; HAL_TIM_Base_Init(htim2); HAL_TIM_Base_Start_IT(htim2); volatile uint8_t g_tick 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { g_tick 1; /* 只在中断里置标志不处理游戏逻辑 */ } }中断频率公式是定时器时钟 / ((PSC1) * (ARR1))。84MHz除以8400再除以1000得到10Hz即每100ms跳一次作为蛇的初始爬行速度正好。回调里绝不调HAL_GPIO_ReadPin、不刷屏、不printf把中断代码压到最短避免和主循环共享变量时出现临界区问题。CubeMX里还要把TIM2的抢占优先级调成低于SysTick的值保证HAL_Delay这类基础服务不被饿死。注意游戏变速不要靠改PSC或ARR的数值组合实现。ARR在后面的难度阶梯里会被运行时改写PSC始终保持不变这样速度表只影响节拍频率不影响定时器配置本身。3. 贪吃蛇的数据结构与状态机蛇身存储、转向缓存与碰撞判定3.1 定长数组加环形队列存蛇身裸机上不用链表的原因蛇身数据结构有三种常见写法。链表最直观但每次吃到食物都要malloc裸机工程没有配套的内存回收策略时跑几十局就出现碎片系统行为变得不可预测。数组平移法每走一步要把全部节点向前搬一格蛇长80时一次移动要做80个赋值操作虽然F4主频压得住代码却不好看。推荐做法是定长数组加环形队列数组一次开够head指向最新一节tail指向最老一节移动只是修改两个下标。#define GRID_W 16 /* 128像素/8像素每格16列 */ #define GRID_H 8 /* 64像素/8像素每格8行 */ #define MAX_SNAKE_LEN (GRID_W * GRID_H) typedef struct { uint8_t x; uint8_t y; } Cell; typedef struct { Cell body[MAX_SNAKE_LEN]; uint16_t head; uint16_t tail; uint16_t len; uint8_t dir; /* 0上 1右 2下 3左 */ uint8_t want_dir; /* 缓存玩家想转的方向 */ } Snake;用uint8_t存坐标范围上限是16和8蛇身最多占用128*2256字节SRAM对F4来说可以忽略。环形下标的精髓是取模(head 1) % MAX_SNAKE_LEN当head走到数组末尾时自动回到0蛇身像在循环缓冲区里滚动。整个游戏生命周期内没有一次堆分配也不存在越界风险。3.2 四个状态的状态机与want_dir转向缓存贪吃蛇至少有四个稳定状态主菜单、游戏中、暂停、游戏结束。用枚举加switch实现足够清晰不必引入状态机框架。状态迁移如下当前状态输入下一状态处理动作ST_MENU确认键ST_PLAY重置蛇长、蛇头位置、分数生成第一个食物ST_PLAY暂停键ST_PAUSE冻结节拍计数ST_PAUSE确认键ST_PLAY恢复节拍计数ST_PLAY撞墙/咬尾ST_OVER显示最终分数等待确认返回菜单转向缓存是这里最容易忽略的细节。蛇向右移动时玩家快速按“上”再按“左”如果每次都直接修改dir第一次按键后dir变成上第二次按键时系统看到当前还是向右仍然判合法结果蛇在下一帧直接掉头咬进自己身体。处理办法是只更新want_dir等蛇头真正前进时先做一次180度反转检查if (snake.want_dir ! snake.dir (snake.want_dir 2) % 4 ! snake.dir) { snake.dir snake.want_dir; }这段代码放在Game_Tick入口逻辑上是“对向禁止、同向与垂直才放行”玩家快速连按也只保留最后一次合法方向。3.3 碰撞检测顺序与食物生成中的随机种子每帧核心逻辑按下述顺序执行先做转向检查再计算新蛇头坐标先判墙再判自身最后判食物。顺序不能反过来——先写蛇头再查自身会把“吃到自己”误判成合法移动。void Game_Tick(void) { /* 转向保护禁止180度反转 */ if (snake.want_dir ! snake.dir (snake.want_dir 2) % 4 ! snake.dir) { snake.dir snake.want_dir; } Cell new_head { snake.body[snake.head].x dx[snake.dir], snake.body[snake.head].y dy[snake.dir] }; int eat (new_head.x food.x new_head.y food.y); /* 1. 撞墙判定x/y下溢-1会回绕成255也会被拦下 */ if (new_head.x GRID_W || new_head.y GRID_H) { Game_SetOver(); return; } /* 2. 咬自身判定没吃到时蛇尾会前移尾格不算障碍 */ uint16_t check_len eat ? snake.len : snake.len - 1; for (uint16_t i 0; i check_len; i) { Cell *c snake.body[(snake.tail i) % MAX_SNAKE_LEN]; if (c-x new_head.x c-y new_head.y) { Game_SetOver(); return; } } /* 3. 落子写入新头决定是否缩尾 */ snake.head (snake.head 1) % MAX_SNAKE_LEN; snake.body[snake.head] new_head; if (eat) { snake.len; Game_ScoreUp(); Food_Generate(); } else { snake.tail (snake.tail 1) % MAX_SNAKE_LEN; } }dx/dy方向表建议写成常量数组static const int8_t dx[4] {0,1,0,-1}; static const int8_t dy[4] {-1,0,1,0};让方向枚举值和坐标偏移一一对应避免写四个if分支。咬自身检查里(snake.tail i) % MAX_SNAKE_LEN必须取模环形队列回绕后最老一节的物理位置可能在数组中间。食物生成最省事的随机源是TIM5的计数器值取出来给srand当种子循环检查新食物是否落在蛇身上在就重新生成蛇快占满全屏时这个循环可能退化成长时间空转限定最多重试64次超过直接判赢处理。4. 基于HAL库的OLED渲染与按键扫描跑通最小可玩版本4.1 SSD1306画点函数页地址模式下的显存缓冲思路SSD1306把128x64像素划分为8个page每个page高8像素。芯片内部显存布局是128列x8页写一像素最稳妥的做法是本地先存一份1KB缓冲修改缓冲对应字节后再整字节回写。注意I2C模式下直接读SSD1306内部显存是不标准的做法在线回读慢且容易出错。static uint8_t oled_buf[128 * 8]; /* 1KB显存缓冲 */ void OLED_DrawPixel(uint8_t x, uint8_t y, uint8_t on) { if (x 128 || y 64) return; uint16_t idx x (y / 8) * 128; uint8_t bit 1u (y % 8); if (on) oled_buf[idx] | bit; else oled_buf[idx] ~bit; } void OLED_Flush(void) { HAL_I2C_Mem_Write(hi2c1, 0x3C 1, 0x00, 1, oled_buf, 128 * 8, 100); }画点函数里y / 8算出像素落在哪个pagey % 8算出页内位偏移。oled_buf的顺序和SSD1306的页地址模式保持一致Flush时把1024字节一次连续写出。HAL_I2C_Mem_Write的参数分别是I2C句柄、器件地址0x3C左移一位、控制字节0x00表示后续是显存数据、数据长度与超时毫秒数。像素Y范围所在Page缓冲偏移范围0 ~ 7Page 00 ~ 1278 ~ 15Page 1128 ~ 25556 ~ 63Page 7896 ~ 1024如果换用SPI接TFT画点逻辑类似只是y/8变成直接操作RGB565的每个像素双字节显存缓冲要扩到3202402约150KBF4的192KB SRAM放得下Flush建议改DMA。4.2 渲染管线选择整帧缓冲刷新与局部涂擦每帧画面其实只变化两处蛇头前进一格蛇尾缩回一格。整帧刷新实现简单但I2C上约20ms的传输时间里主循环被堵住玩起来能感觉到闪烁。局部涂擦的渲染函数如下void Game_Render(void) { /* 先擦老尾巴再画新蛇头顺序不能反 */ Cell *tail snake.body[snake.tail]; OLED_FillBlock(tail-x, tail-y, 0); Cell *head snake.body[snake.head]; OLED_FillBlock(head-x, head-y, 1); if (food_dirty) { OLED_FillBlock(old_food.x, old_food.y, 0); food_dirty 0; } OLED_FillBlock(food.x, food.y, 1); }OLED_FillBlock对8x8像素格子做两层循环逐点画整个格子点亮或熄灭。局部刷新把每帧I2C传输量从1024字节降到几十字节玩家视角里蛇身移动就是两个小方块的变化画面干净对I2C带宽压力也小。擦尾和画头不要颠倒否则蛇头到达新格而旧尾还没擦必须等下一帧补齐视觉上会断帧。4.3 10ms轮询消抖的按键扫描按键到want_dir的映射表按键扫描放在主循环里配合另一个10ms的定时器标志执行连续读到3次相同电平才认为按键有效。它比EXTI更适合这个游戏因为玩家按住方向键时动作游戏需要周期性读键边沿中断只给一次机会。uint8_t key_state[4]; /* 消抖后状态1按下 */ void Key_Scan(void) { static uint8_t stable[4], cnt[4]; for (uint8_t i 0; i 4; i) { uint8_t level !HAL_GPIO_ReadPin(KEY_PORT[i], KEY_PIN[i]); if (level ! stable[i]) { stable[i] level; cnt[i] 1; } else if (cnt[i] 3) { cnt[i]; } if (cnt[i] 3) { key_state[i] level; } } }消抖原理是电平连续稳定20ms才认账。KEY_PORT[i]和KEY_PIN[i]用宏定义集中管理方便换引脚。拿到key_state后按表映射到want_dir按键方向方向枚举dxdyKEY_UP上00-1KEY_RIGHT右110KEY_DOWN下201KEY_LEFT左3-10映射只更新want_dir不直接写dir把第3章的180度反转保护放在Game_Tick入口统一执行。按键扫描、逻辑移动、渲染三者完全解耦后续换摇杆或编码器只需要改映射层。5. STM32F4贪吃蛇的速度阶梯与在线调试技巧5.1 速度阶梯表放在数据段运行时改ARR的注意事项游戏好玩与否难度曲线比画面细节更关键。把等级对应的时间放到const数组里每吃4个食物升一级在主循环里按分数改写ARR。const uint16_t speed_table[] {1000, 800, 600, 450, 300, 200, 150}; uint8_t level score / 4; if (level 6) level 6; __HAL_TIM_SET_AUTORELOAD(htim2, speed_table[level] - 1);__HAL_TIM_SET_AUTORELOAD在运行时修改ARRTIM2的更新中断频率立即变化PSC保持不变。修改ARR前先调用__HAL_TIM_DISABLE(htim2)关中断改完再重新启动避免ARR切换瞬间产生一次多余的更新中断打乱节拍。难度曲线只改表里的数值不动游戏逻辑。5.2 用逻辑帧串口打印替代断点板子脱机后调试信息很难看最有效的手段是打开串口按逻辑帧输出状态。每10帧打印一次蛇头坐标、方向、蛇长与当前状态足以定位大多数转向或碰撞错误。串口输出走HAL_UART_Transmit阻塞版本时打印期间主循环被暂停按键采样会掉拍建议把波特率拉到115200以上打印内容精简到最短。验证方法很直接先用固定方向表跑20局记录每局时长和死亡位置若在某关必死说明该等级速度下转向窗口不够回查速度表和want_dir缓存逻辑。5.3 后续三个可动手的方向DMA刷屏、掉电保存最高分、ADC摇杆在现有代码上还剩三个改动点。第一把OLED_Flush改成DMA传输游戏在Flush进行时继续处理按键帧率提升同时主循环不再被I2C阻塞第二把最高分写入F4内置Flash扇区HAL里对应操作是先擦除再编程写到最后一个扇区避免覆盖程序区第三把四个按键换成ADC摇杆键值映射依然留在main.csnake模块完全不感知输入来源这种分层方式继续上FreeRTOS时逻辑层可以原样搬进独立任务只需重写显示和输入两个驱动。本文还有配套的精品资源点击获取
返回列表