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

STM32贪吃蛇实战:按键控制、状态机与嵌入式系统设计

简介基于STM32的贪吃蛇小游戏工程以按键控制为核心完整展示了嵌入式系统开发中GPIO输入、外部中断、定时器动画刷新、LCD显示及游戏逻辑设计的综合应用适合单片机课程设计、STM32入门实践及电子竞赛备赛参考。压缩包共149个文件包含39个h头文件、38个c源文件、Keil工程文件、hex/axf编译产物以及LCD驱动与STM32标准外设库相关代码整体3.64MB结构清晰便于直接打开工程查看与二次修改。目前已有329人学习下载是同类课程设计中较为完整的可运行案例。项目中蛇身位置管理、食物随机生成、碰撞检测、按键方向切换等关键算法均已落地配合定时器中断与液晶屏刷新机制能帮助学习者快速理解状态机与中断驱动编程思路同时附带的编译链接文件与烧录脚本也为环境部署提供了便利。1. 基于STM32的贪吃蛇从 Show 项目到系统设计的最小闭环拿到“基于STM32的贪吃蛇小游戏按键控制”这个标题很多人的第一反应是“这不就是个练手 Demo 吗”。但真要动手做你会发现真正卡壳的从来不是游戏逻辑而是按键响应不灵、方向反转、食物刷在蛇身上、屏幕残影这类“看起来很小”的问题。作为最早期的嵌入式实战项目它的价值在于把 GPIO 输入、定时器中断、状态机、数据结构串成一条完整链路而这些恰恰是后面做遥控小车、四轴飞控、甚至搞 STM32 车载以太网节点都要复用到的底层能力。本文按“硬件链路 → 交互逻辑 → 游戏核心 → 排错与进阶”四步展开完整覆盖按键消抖、蛇身数据结构、移动判定刷新、Flash 掉电保存最高分等内容。读完你不仅能跟着搭出一个可玩的游戏还能顺手把按键扫描方式、延时卡死排查、CFSR 硬件错误定位这些高频问题一起解决掉。内容适用于标准库和 HAL 库两条路线工程搭建细节我按最常见的“STM32 标准库新建工程”流程来说你得会 Keil5 的魔术棒配置。2. 硬件链路与按键控制GPIO 输入、消抖与扫描方式选择2.1 按键模块的接线方式独立按键与矩阵的取舍贪吃蛇只需要 4 个方向键因此“独立按键控制 LED 亮灭”那种最简接法可以原样搬过来——每个按键一端接 GPIO另一端接 GND开启内部上拉电阻后按下读到低电平松开恢复高电平。这种接法省掉外部上拉电阻一根杜邦线就能完成验证是 STM32 开发板上的标准资源。如果你手里的板子是矩阵键盘或者摇杆模块也可以复用但代码逻辑要从“读 4 个独立 IO”变成“扫描行列”复杂度会明显上升。对贪吃蛇这种需要“即时响应、误触尽量少”的应用我建议优先使用独立按键配合轮询扫描而不是外部中断。理由后面具体展开。按键接线参考标准库为例按键GPIO按下电平用途KEY_UPPA0高电平需外部下拉加速/开始KEY_DOWNPA1低电平方向下KEY_LEFTPA2低电平方向左KEY_RIGHTPA3低电平方向右注意 KEY_UP 在很多开发板上是接 VCC 的按下为高与其余三个不同。复用现有板子时看原理图确认否则按键判定会整体反相。2.2 按键扫描状态机从消抖到边沿检测机械按键按下瞬间会持续抖动 5-10ms直接读 GPIODR 会被仲裁出乱序方向。你需要的是一套完整按键扫描状态机核心就三件事稳定读取两次间隔 10ms 采样电平一致才认为有效。边沿检测只识别“按下瞬间”连续按住不重复触发。方向裁决记录当前按键方向供游戏状态机使用。以下代码可直接用于标准库工程HAL 库思路一致换 API 即可#define KEY_UP_PIN GPIO_Pin_0 #define KEY_DOWN_PIN GPIO_Pin_1 #define KEY_LEFT_PIN GPIO_Pin_2 #define KEY_RIGHT_PIN GPIO_Pin_3 #define KEY_GPIO GPIOA #define KEY_RCC RCC_APB2Periph_GPIOA typedef struct { uint8_t state; // 0: 待检测 1: 按下确认 uint8_t last_level; // 上次采样电平 uint16_t counter; // 稳定计数 } key_t; key_t key_state[4]; void KEY_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(KEY_RCC, ENABLE); GPIO_InitStructure.GPIO_Pin KEY_UP_PIN | KEY_DOWN_PIN | KEY_LEFT_PIN | KEY_RIGHT_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 内部上拉 GPIO_Init(KEY_GPIO, GPIO_InitStructure); } uint8_t KEY_Scan(void) { uint8_t level[4], i, ret 0; uint16_t pin_map[4] {KEY_UP_PIN, KEY_DOWN_PIN, KEY_LEFT_PIN, KEY_RIGHT_PIN}; for (i 0; i 4; i) { level[i] (GPIO_ReadInputDataBit(KEY_GPIO, pin_map[i]) Bit_SET) ? 1 : 0; if (level[i] key_state[i].last_level) { key_state[i].counter; } else { key_state[i].last_level level[i]; key_state[i].counter 0; } if (key_state[i].counter 2 key_state[i].state 0 level[i] 0) { key_state[i].state 1; ret | (0x01 i); // 返回按下事件的掩码 } if (level[i] 1) { key_state[i].state 0; // 松开复位 } } return ret; }这段代码放在 10ms 定时器中断里调用counter 达到 2 表示电平连续 20ms 稳定。state 用于防重复触发只有“从 0 变 1”的那次才返回事件。方向键使用电平反转设计主循环里根据 KEY_Scan 返回值更新蛇的方向变量即可。2.3 查询还是中断为什么按键不用外部中断看到这里你可能想问直接用 EXTI 外部中断不是更省 CPU 吗但实际工程里我不建议把四个方向键都配置为外部中断。原因有三层抖动会触发多次中断每次都要消抖代码量反而比轮询还大。GUI 状态机绘制、移动、碰撞检测本身就运行在 5-20ms 级别定时器中中断优先级一旦和游戏主循环的定时器冲突会出现“按一下方向跳两格”的经典 Bug。贪吃蛇检测玩家输入不需要微秒级响应10ms 轮询和中断的效果对玩家来说没有差别。轮询扫描放在 TIM 定时器中断里游戏刷新放在另一个更低优先级的定时器或主循环里优先级配置采用“中断服务函数只置标志位主循环做游戏逻辑”的做法这是 STM32 嵌入式开发中最稳妥的架构。游戏中常见的“按键无效”问题有九成以上是消抖没做或扫描函数与刷新函数使用同一中断导致临界区竞争。3. 游戏状态机与数据结构蛇身移动、碰撞检测与食物生成3.1 蛇身数据结构选型定长数组模拟双端队列贪吃蛇长度上限由屏幕格点数决定比如 240x320 分辨率的 TFTLCD 按 8x8 像素一格划分就是 30x40 个格子。用定长数组模拟队列既能避免动态内存分配嵌入式里 malloc 是万恶之源又方便访问“蛇头”和“蛇尾”。我的做法是定义结构体#define SNAKE_MAX_LEN 600 #define GRID_WIDTH 30 #define GRID_HEIGHT 40 typedef struct { uint8_t x; uint8_t y; } Point; typedef struct { Point body[SNAKE_MAX_LEN]; // 蛇身数组body[0]为蛇头 uint16_t len; uint8_t direction; // 0:上 1:下 2:左 3:右 uint8_t next_dir; // 本次逻辑中实际生效的方向 } Snake;这里 direction 是游戏逻辑当前方向next_dir 是待生效方向两者分离的原因后面讲方向反转锁定时解释。3.2 移动与生长的核心逻辑头插尾删每次游戏节拍执行“头插一格、尾删一格”只有吃到食物时才“只插不删”。代码上用一个 memmove 把整个数组后移一位再更新 body[0]对于最大 600 字节的数组来说时间完全可接受uint8_t Snake_Move(Snake *snake, Point *food, uint8_t *score) { Point new_head snake-body[0]; switch (snake-next_dir) { case 0: new_head.y--; break; // 上 case 1: new_head.y; break; // 下 case 2: new_head.x--; break; // 左 case 3: new_head.x; break; // 右 default: break; } // 碰撞检测撞墙 if (new_head.x GRID_WIDTH || new_head.y GRID_HEIGHT) { return 0; // Game Over } // 碰撞检测撞自己除尾部外 for (uint16_t i 0; i snake-len; i) { if (snake-body[i].x new_head.x snake-body[i].y new_head.y) { return 0; } } memmove(snake-body 1, snake-body, snake-len * sizeof(Point)); snake-body[0] new_head; if (new_head.x food-x new_head.y food-y) { snake-len; (*score); return 2; // 吃到食物 } return 1; // 正常移动 }撞墙和撞自身都返回 0。吃到食物时蛇尾不动蛇身长度加 1食物要在 FPGA 或 Flash 存储的随机数基础上取模生成避免破坏当前蛇身覆盖。3.3 食物生成策略边界处理与随机可用的细节食物不能生成在蛇身上也不能生成在边界外这是最常被忽略的边界问题。使用硬件 RNG 或伪随机都可以关键是取模后的范围要限定在有效格点内void Food_Generate(Point *food, Snake *snake) { uint8_t valid 0; while (!valid) { food-x (uint8_t)(rand() % GRID_WIDTH); food-y (uint8_t)(rand() % GRID_HEIGHT); // 保证不落在蛇身 valid 1; for (uint16_t i 0; i snake-len; i) { if (snake-body[i].x food-x snake-body[i].y food-y) { valid 0; break; } } } }这里的 rand() 需要在初始化时种下种子常见做法是读取 ADC 空闲通道的低位噪声值或者直接读 SYSTICK 的当前计数值作为种子。很多初学者直接用 rand() 不设种子结果每次上电食物生成序列都一样的这也是一个值得注意的坑。3.4 完整游戏状态机READY、RUN、PAUSE、GAMEOVER按键控制除了方向还需要“开始/暂停/重开”的入口。用一个枚举描述状态typedef enum { GS_READY 0, // 等待开始 GS_RUN, // 游戏中 GS_PAUSE, // 暂停 GS_OVER // 游戏结束 } GameState;状态转移规则当前状态按下按键动作READYKEY_UP初始化蛇身生成食物进入 RUNRUNKEY_UP暂停进入 PAUSEPAUSEKEY_UP恢复进入 RUNRUN / PAUSEKEY_DOWN_LONG返回 READY重开OVERKEY_UP返回 READY这个设计让“重新开始”和“暂停”共用 KEY_UP 按键但在 OVER 状态屏蔽方向键。长按判定在 KEY_Scan 里的 counter 超过 20 次即 200ms时置位不需要额外增加按键资源。4. 屏幕绘制与刷新LCD驱动、坐标系与双缓冲思路4.1 硬件驱动层从正点原子/野火 LCD 驱动到自绘 API贪吃蛇对显示部分的要求是“格子绘制 清屏 局部刷新”。大多数开发板的 LCD 驱动已经提供了底层画点函数例如 LCD_Fast_DrawPoint(x, y, color)。在此基础上你只需要封装一个 DrawBlock把逻辑格点映射到物理像素坐标#define GRID_PIXEL 8 void Draw_Block(uint8_t gx, uint8_t gy, uint16_t color) { uint16_t x0 gx * GRID_PIXEL; uint16_t y0 gy * GRID_PIXEL; LCD_Fill(x0, y0, x0 GRID_PIXEL - 1, y0 GRID_PIXEL - 1, color); }LCD_Fill 是大多数驱动库自带的高效矩形填充函数避免逐像素循环写点。如果使用 OLED 屏幕SSD1306同理把 GRID_PIXEL 设为 8 或 16128x64 分辨率对应 16x8 格点游戏区域偏小但可玩。4.2 局部刷新与双缓冲McU 内存够就别全屏重绘主流 TFTLCD 控制器如 ILI9341、ST7789通过 SPI 或 FSMC 接口写入显存。全屏 240x320 的 RGB565 数据量是 153600 字节通过 SPI 刷一帧延迟能到 30-80ms游戏体验极差。因此必须只用局部刷新蛇移动一格 → 画新蛇头格、擦掉蛇尾格吃到食物 → 画食物格蛇尾不清除状态切换 → 一次性清屏后全量绘制核心代码是这组操作void Snake_Render(Snake *snake, Point *food, uint16_t old_tail_x, uint16_t old_tail_y) { // 画新蛇头 Draw_Block(snake-body[0].x, snake-body[0].y, SNAKE_HEAD_COLOR); // 蛇尾移动如果没吃到食物擦除旧蛇尾 if (snake-body[snake-len-1].x ! old_tail_x || snake-body[snake-len-1].y ! old_tail_y) { Draw_Block(old_tail_x, old_tail_y, BG_COLOR); } // 画食物 Draw_Block(food-x, food-y, FOOD_COLOR); }注意这里 old_tail_x 必须在调用 Snake_Move 之前保存否则移动后旧蛇尾已经被覆盖。另一种做法是每次移动前记录 body[len-1]移动完成后再决定擦除与否顺序不要反。4.3 刷新节奏与 MCU 主频的关系刷新函数的调用频率决定游戏速度我一般放在基础定时器中断里通过一个全局节拍变量控制难度void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); tick_10ms; // 节拍计数达到当前速度阈值才执行游戏逻辑 if (tick_10ms speed_threshold) { tick_10ms 0; game_tick_flag 1; } // 每个 10ms 周期做按键扫描 key_event KEY_Scan(); } }速度阈值是一个可动态调整的变量比如初始 5即 50ms 一格吃到食物后 5 - score/5下限 1。单位时间内移动越快玩家决策压力越大这种加速方式比线性加速更有挑战性也更符合原版贪吃蛇的节奏。4.4 按键方向与游戏刷新两个中断的时序关系这是最容易出 Bug 的地方。如果你的按键扫描和游戏刷新在同一个中断里顺序上“先扫按键 → 再刷新游戏”那按键事件的生效会延后一个节拍反过来则可能“扫到的按键下次才生效”。我采用的做法是TIM2 中断10ms按键扫描、设置 key_event。主循环检测 game_tick_flag 置位读取 key_event更新方向执行 Snake_Move。如果主循环因为绘制耗时超过 10ms按键扫描可能被拖慢但绝不会丢失。这样设计的核心是中断里只置标志位主循环做运算。中断里做绘制的后果是按键响应忽快忽慢几乎无法调试这个也是值得记住的原则。5. 必调参数与典型踩坑方向锁定、Flash 保存最高分与显示残影5.1 方向不能 180 度反转记录历史方向最经典的 Bug蛇向右运动时按左游戏应该只忽略这次按键而不是先右后左合成“向左”导致蛇掉头撞自己。解决方式是维护 next_dir 字段和 direction 字段更新 next_dir 时判断是否和当前方向互为反向uint8_t Is_Reverse(uint8_t dir1, uint8_t dir2) { return (dir1 0 dir2 1) || (dir1 1 dir2 0) || (dir1 2 dir2 3) || (dir1 3 dir2 2); } void Snake_SetDirection(Snake *snake, uint8_t dir) { if (Is_Reverse(snake-direction, dir)) { return; // 忽略反向控制 } snake-next_dir dir; }注意这里比较的是 direction 而不是 next_dir。如果比较 next_dir会出现“右-上-左”三连击时第二次按上后 direction 还是右第三次按左和右相反被正确忽略。如果比较 next_dir则第二次按上后 next_dir上第三次按左和上不反向逻辑上就会在蛇头还未转向时提前改变方向造成连续两个节拍都转向等于 180 度。这是最隐蔽的坑之一。5.2 最高分保存片内 Flash 操作的标准流程想让最高分掉电不丢不需要外挂 EEPROM直接用 STM32 片内 Flash 的最后几个扇区比如扇区 11地址 0x08080000 附近。写 Flash 前先擦扇区写入时按半字16bit为单位#define HIGHSCORE_ADDR 0x0807F000 // 最后一个扇区尾部注意避开程序区 uint16_t Score_ReadHigh(void) { return *(volatile uint16_t*)HIGHSCORE_ADDR; } void Score_WriteHigh(uint16_t score) { FLASH_Unlock(); FLASH_ErasePage(HIGHSCORE_ADDR); FLASH_ProgramHalfWord(HIGHSCORE_ADDR, score); FLASH_Lock(); }要点有两个一是地址必须按 2 字节对齐偏移量是 0、2、4 这样的偶地址写错会导致硬件错误二是擦除会损毁整页数据所以把最高分单独放一页不要和代码或者其他变量混在一起。如果代码量较大直接使用最后一页的最后 128 字节配合编译器分散加载文件避免冲突。5.3 显示残影问题的三种根因与排查顺序残影在贪吃蛇项目里几乎是必现现象但根因不止一个。按出现频率排名擦除蛇尾失败Move 之后没有记录 tail 坐标导致旧蛇尾残留。绝大多数属于此类。清屏用的坐标范围错误LCD_Fill 传入的终点坐标超出屏宽或者 GRID_PIXEL 设置与实际屏幕不匹配。屏幕刷新率过低SPI 分频系数设置太高每次写入的像素存在串扰。127MHz 主频下 SPI 预分频 16 通常足够预分频 64 以上就有明显拖影。排查方法很简单在擦除蛇尾位置额外画一个白色块确认坐标换算没有偏差。若单独调用正常、组合调用异常优先考虑 DMA 或缓存区未同步问题。5.4 CFSR 0x00008200 的定位方法非对齐访问热词里出现的“STM32 CFSR 为 0x00008200”硬件错误在这个项目中最有可能的诱因是 Score_WriteHigh 地址没有按 2 字节对齐。0x00008200 对应 CFSR 中的 UNALIGNED 位说明 Cortex-M 内核检测到了非对齐的内存访问。定位顺序是查看 SCB-CFSR 寄存器确认是哪一位置位。在 HardFault_Handler 中断里设置断点查看栈帧中的 PC 寄存器。反汇编定位到出错函数重点排查指针运算、结构体 offset、地址对齐问题。具体到本项目打开 Keil 的“Options for Target → Target → IROM1”确认代码区终点地址Flash 数据写入要放在程序区之外。如果你改了 Flash 地址导致程序重启后直接进 HardFault优先看这个错误位而不是去查 GPIO 配置。5.5 按键偶尔触发两次按键消抖不足与变量可见性这个问题在按键扫描封装不当时很容易出现典型特征是“按一下方向键蛇连续转了两个弯”。原因有三类扫描次数不足。counter 只计了一次就判定有效抖动期间的毛刺会被当两次按下。至少要求间隔 20ms 两次采样一致。key_event 在主循环中读取后没有清零。建议每次处理完方向更新后立即 key_event 0否则下一个节拍会重复使用。全局变量没有加 volatile 修饰。中断和主循环共享的 tick_10ms、key_event 需要用 volatile 声明否则编译器优化后主循环读到的可能一直是寄存器缓存值。关于最后一点GCC 和 Keil AC5 的优化级别不同AC5 默认 O0 不会立刻暴露问题但换成 O2 甚至 -Otime 之后共享变量不 volatile 几乎必出异常。6. 进阶扩展用状态机编码模式把贪吃蛇冗余到串口调试图形化做到这一步你的贪吃蛇已经是一个完整的“输入-处理-输出”闭环系统。最后一个值得落地的技巧是把游戏的内部状态机以文本形式镜像到 USART 串口这样不用 LCD 就能验证按键逻辑是否正常。做法是在每次状态转移时通过串口打印一条结构化日志void Debug_LogFrame(GameState state, Snake *snake, Point *food) { char buf[64]; snprintf(buf, sizeof(buf), [%d] len%d dir%d head(%d,%d) food(%d,%d) score%d\r\n, state, snake-len, snake-direction, snake-body[0].x, snake-body[0].y, food-x, food-y, score); USART_SendString(DEBUG_USART, buf); }关键参数解读顺序state 告诉你当前在哪个游戏阶段、dir 是方向字段还是 next_dir 字段head 坐标可以与 LCD 显示对照检查画点换算。用串口调试助手观察每次按键对应的方向和坐标变化能迅速定位按键反相和方向翻转问题比盯着 LCD 猜逻辑高效得多。再进一步可以把每个格点的占用情况映射成 30x40 的字符矩阵通过串口“0/1”文本画出每一帧蛇的轮廓。虽然性能上看这是个浪费但在调试定位“撞自己”判定是否准确时打印矩阵比看屏幕直接得多。另外还有一个实用建议在游戏 OVER 状态时把蛇的完整轨迹和按键事件序列按时间戳打印到串口可以用来回放复盘验证“设备连不上/跟不上操作”是代码问题还是人手速问题。这一招在后续做其他交互设备时同样可用。本文还有配套的精品资源点击获取
分享:

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

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