资讯动态

STM32F407贪吃蛇实战:状态机与OLED驱动的嵌入式综合应用

发布时间:2026/10/19 0:32:53 来源:尧图企业网站定制
简介这是一份基于STM32F407Cortex-M4内核实现的经典贪吃蛇游戏完整工程适合嵌入式初学者与单片机爱好者通过趣味项目掌握MCU外设驱动与实时编程。工程使用标准外设库编写包含工程配置、驱动源码及编译烧录文件uvprojx、hex等涉及STM32CubeMX初始化思路、GPIO控制LCD/LED显示、定时器中断控制帧率、串口USART交互以及蛇的移动、碰撞检测与随机食物生成等核心算法实现代码结构清晰便于对照学习。压缩包共159个文件以C源码75个c文件和头文件77个h文件为主另有启动文件、链接脚本及Keil工程配置整体仅831KB轻量易上手。目前已有2820人学习下载尤其适合希望深入理解中断系统、定时器应用和内存管理的开发者直接打开工程即可编译运行也可在此基础上扩展方向键控制、难度进阶等功能。1. 一块F407开发板就够了屏幕、按键和状态机构成的完整游戏闭环STM32F407 跑贪吃蛇听起来像是课程设计里挑了个最简单的题目但真把代码拆开你会发现一个看起来只有几百行的游戏工程实际把 GPIO 输入、定时器中断、I2C 显示驱动、状态机设计和数据结构管理全串起来了。对刚学完外设、想做一个“能完整跑起来”的综合项目的开发者来说这是性价比很高的一块跳板它不像点灯那样只有输出也不像通信实验那样只有协议而是一个从按键到屏幕的完整交互闭环。我拿到这份资源时第一反应是“就这”但逐行读完后确认它适合两类人一类是拿它当毕业设计和竞赛练手模板的学生另一类是刚接触嵌入式应用层、想看看一个游戏逻辑怎么在裸机上被拆得干净利落的开发者。接下来按「结构—核心代码—运行—改造」的顺序把它讲透。2. 架构与目录游戏逻辑和驱动是怎么被拆成互不干扰的模块的2.1 模块分层与数据流向打开工程目录第一感觉是清爽。代码没有把所有东西塞进一个 main.c而是按“驱动层、游戏层、应用层”三层切开的。驱动层只管硬件比如 OLED 的初始化、画点、清屏、显示字符串游戏层只管规则比如蛇的移动、食物的生成、碰撞的判定应用层负责把两者绑起来也就是定时器中断里把按键读进来、把状态机往前推、最后把画面交给显示驱动刷出去。数据流向是单向的按键扫描产生方向事件方向事件送进游戏状态机状态机更新蛇身数据和分数最后把画面数据交给 OLED 渲染。这样设计的好处很实际——你想换一个显示屏幕只需要改驱动层的接口实现游戏逻辑一行都不用动想改成摇杆控制按键扫描函数的返回值不变主循环也不需要知道背后是物理按键还是 ADC 采样。2.2 目录结构与文件职责我拆到的这份工程按功能分了大约五个区域下面用表格列出主要文件和各自动职方便你对号入座。文件 / 目录职责main.c系统时钟、外设初始化、主循环骨架game.h / game.c游戏状态机、蛇数据结构、移动与碰撞逻辑input.h / input.c按键扫描与消抖、方向事件生成display.h / display.cSSD1306 驱动的初始化与绘制原语timer.c / stm32f4xx_it.cSysTick 配置、定时中断里触发用户回调每个文件都收敛了一类职责没有出现跨层互相调用的事。比如 game.c 里你找不到任何直接操作 GPIO 的代码display.c 里也看不到游戏规则的影子。我当时特别留意了 display 层它只暴露了 Display_Init、Display_Clr、Display_DrawBlock、Display_ShowNum 四个函数游戏层只需要往屏幕坐标上画方块不需要关心像素细节。2.3 核心数据结构设计整个工程的数据结构核心是蛇。作者没有用一个动态链表而是用固定长度数组模拟这是我在嵌入式小题里比较认可的处理方式——F407 的资源对贪吃蛇来说非常充裕但固定数组的确定性更好不会产生堆碎片也不会在极端情况下出现分配失败。#define MAP_W 32 // 地图宽度单位格子 #define MAP_H 16 // 地图高度单位格子 #define MAX_SNAKE (MAP_W * MAP_H) // 蛇身最长不超过地图格子总数 typedef struct { uint8_t x; // 格子横坐标 0~31 uint8_t y; // 格子纵坐标 0~15 } Point_t; typedef struct { Point_t body[MAX_SNAKE]; uint8_t len; // 当前蛇身长度 uint8_t dir; // 0上 1右 2下 3左 uint8_t score; // 分数每吃一个食物加1 } Snake_t;说明蛇身数组是这段代码的基石body[0]存蛇头后面依次是身体和尾巴。移动时先把整条蛇从尾巴往前挪再把新蛇头坐标填到body[0]这样省掉了对每个身体块的单独处理逻辑清晰也容易调试。dir只存四个方向的枚举值避免在比较坐标时写出混乱的 if 链。地图用 32x16 的格子是因为这块 128x64 的 OLED 能干净地切成 4 像素见方的格子能保证显示不出现半个像素的错位。3. 核心模块拆解从按键事件到蛇身移动的完整链路3.1 状态机游戏主循环不是 while(1) 里硬跑这个工程最有味道的地方是状态机。作者用GameState枚举把游戏分成了 MENU、RUNNING、PAUSED、OVER 四个状态每个状态下主循环只做当前状态允许的事情这比在 while(1) 里堆一坨 if 来判断“现在该干嘛”要清晰得多。typedef enum { GAME_MENU, GAME_RUNNING, GAME_PAUSED, GAME_OVER } GameState_t; GameState_t state GAME_MENU; void Game_Process(void) { switch (state) { case GAME_MENU: if (Key_GetEvent() KEY_OK) // 按键确认后开局 { Game_Reset(); state GAME_RUNNING; } break; case GAME_RUNNING: Game_Update(); // 只在运行态推进逻辑帧 break; case GAME_PAUSED: if (Key_GetEvent() KEY_OK) state GAME_RUNNING; break; case GAME_OVER: if (Key_GetEvent() KEY_OK) state GAME_MENU; break; default: break; } }逻辑说明Game_Process每 10ms 被主循环调用一次但它并不会每 10ms 就移动一次蛇——移动频率由内部帧计数控制这一点后面会讲。状态机的好处是你在 MENU 状态时压根不会执行Game_Update也就是说不会出现“游戏还没开始蛇先撞墙了”的幽灵事件。按键事件是带边沿的Key_GetEvent返回的是“这一瞬间发生了什么”而不是“现在按着哪个键”这就避免了长按导致的重复触发。3.2 蛇的移动与碰撞检测移动是整个项目中我反复读了几遍的部分。实现思路是“先挪后判”先计算新蛇头的位置看看这一格是不是墙、是不是蛇身判断通过后才把蛇整体往前挪一格。如果撞上了状态切换到 GAME_OVER并且不再更新坐标。void Game_Update(void) { Point_t newHead snake.body[0]; switch (snake.dir) { case DIR_UP: newHead.y--; break; case DIR_DOWN: newHead.y; break; case DIR_LEFT: newHead.x--; break; case DIR_RIGHT: newHead.x; break; } // 撞墙判定坐标超出地图边界直接失败 if (newHead.x MAP_W || newHead.y MAP_H) { state GAME_OVER; return; } // 撞自身判定新蛇头是否落到了旧蛇身上尾巴这一格除外 for (uint8_t i 0; i snake.len; i) { if (newHead.x snake.body[i].x newHead.y snake.body[i].y) { state GAME_OVER; return; } } // 把整条蛇从尾巴向头逐格平移 for (uint8_t i snake.len; i 0; i--) { snake.body[i] snake.body[i - 1]; } snake.body[0] newHead; // 如果新蛇头刚好在食物上蛇长加一并生成新的食物 if (newHead.x food.x newHead.y food.y) { snake.len; snake.score; Food_Generate(); } }参数与边界说明这里的边界判定用的是而不是这是最容易写错的地方。地图坐标是 0 到 31如果蛇头横坐标等于 32说明它已经超出了最右列的边界所以必须用。撞自身的循环里我先判断了“新蛇头是否和旧蛇身重合”再执行蛇身平移顺序不能反过来否则你比较的是移动后的蛇身等于自己撞自己。尾部那格的特殊情况处理得很保守即使新蛇头即将移动到尾巴离开的那一格也会被判为碰撞这会让游戏手感偏“严格”但不影响公平性。3.3 食物生成一个容易翻车的随机数问题食物不能生成在蛇身上这个需求看起来简单但如果你直接取随机数刷到一半概率食物会出现在蛇身上导致“蛇踩了食物但不加分”的诡异情况。void Food_Generate(void) { uint8_t ok 0; while (ok 0) { food.x (uint8_t)(rand() % MAP_W); food.y (uint8_t)(rand() % MAP_H); ok 1; for (uint8_t i 0; i snake.len; i) { if (food.x snake.body[i].x food.y snake.body[i].y) { ok 0; // 刷在蛇身上重新生成 break; } } } }这里有个常见的坑rand()在嵌入式工程里如果没做种子初始化默认种子固定每次开机生成的食物序列完全一样游戏玩起来就有“剧本感”。作者的做法是取一个未初始化变量或 RTC 低字节做srand种子我在改造时更倾向于用芯片唯一 ID 的低 16 位保证每块板子开机的序列都不同。while 循环在最坏情况下会空转但考虑到地图有 512 个格子蛇身再长也不会把空间全占满这个循环不会被卡死可以放心用。3.4 按键扫描10ms 消抖与方向锁按键部分采用了“按下沿触发 软件消抖”的经典套路。物理按键按下时会有几毫秒的机械抖动如果不消抖一次按键会被识别成两次甚至三次方向变化。uint8_t Key_GetEvent(void) { static uint8_t lastState KEY_NONE; uint8_t nowState KEY_NONE; if (HAL_GPIO_ReadPin(KEY_UP_GPIO_Port, KEY_UP_Pin) GPIO_PIN_RESET) nowState KEY_UP; else if (HAL_GPIO_ReadPin(KEY_DOWN_GPIO_Port, KEY_DOWN_Pin) GPIO_PIN_RESET) nowState KEY_DOWN; else if (HAL_GPIO_ReadPin(KEY_LEFT_GPIO_Port, KEY_LEFT_Pin) GPIO_PIN_RESET) nowState KEY_LEFT; else if (HAL_GPIO_ReadPin(KEY_RIGHT_GPIO_Port, KEY_RIGHT_Pin) GPIO_PIN_RESET) nowState KEY_RIGHT; if (nowState ! lastState) // 边沿变化而不是电平状态 { lastState nowState; if (nowState KEY_NONE) return KEY_NONE; // 防止反向当前方向是右不允许直接按左 if ((snake.dir DIR_RIGHT nowState KEY_LEFT) || (snake.dir DIR_LEFT nowState KEY_RIGHT) || (snake.dir DIR_UP nowState KEY_DOWN) || (snake.dir DIR_DOWN nowState KEY_UP)) { return KEY_NONE; } return nowState; } return KEY_NONE; }这段代码有两个值得学习的细节。第一它利用“相邻两次扫描状态不同”来识别按下动作而不是直接返回当前电平天然带上了消抖效果——因为抖动表现为同一毫秒内电平来回跳只要放在 10ms 定时中断里调用抖动通常会在两个周期内稳定状态变化只会在真正按下时出现一次。第二它在按键层就做了方向锁这比在游戏逻辑里判断要早脏数据进不了状态机。我把它单独拎出来讲是因为很多初学工程里的“蛇突然掉头穿自己”就是这么来的没有方向锁蛇头向右时你按左蛇会立刻反方向穿过自己的身体一帧之间产生碰撞用户会觉得游戏“神经质”。4. 跑起来CubeMX 配置、接线与烧录的实操记录4.1 硬件接线表拿到工程后第一个问题是“我手上这块板子能不能直接烧”。你先别急着编译先对照接线表确认引脚。这份资源用的是 128x64 的 SSD1306 OLEDI2C 接口加四个独立按键引脚分配如下表。信号GPIO 引脚说明OLED_SCLPB8I2C1 时钟线OLED_SDAPB9I2C1 数据线KEY_UPPE0按下为低电平KEY_DOWNPE1按下为低电平KEY_LEFTPE2按下为低电平KEY_RIGHTPE3按下为低电平蜂鸣器可选PD12吃到食物时短鸣如果你的板子引脚和这个不一样两种改法一是直接在 CubeMX 里重新分配引脚并重新生成初始化代码二是直接改 main.c 里的引脚宏。前者更稳因为 GPIO 时钟、复用功能配置 CubeMX 会一起给你处理好手动改容易漏掉__HAL_RCC_GPIOE_CLK_ENABLE()这一类时钟使能。4.2 STM32CubeMX 工程配置这个工程是基于 HAL 库 CubeMX 生成的我建议你也用同样方式打开而不是直接拿源码硬烧。收到的源码包里一般带着.ioc工程文件用 CubeMX 打开后你只需要确认以下几项时钟树外部晶振 8MHz系统时钟 168MHzAPB1 分频后 I2C1 时钟不能超过 42MHzI2C1标准模式或快速模式都行SSD1306 支持 400kHzGPIOPE0~PE3 全部设为输入模式开启上拉电阻下拉使能保持默认即可SysTickHAL 默认 1ms 中断但工程里游戏帧计数用的是裸奔的HAL_GetTick()不用额外配置定时器。配置完成后点 GENERATE CODE等 CubeMX 生成完工程再把 game.c、input.c、display.c 这些源码文件加入编译。这一步要注意生成后的工程默认只有 main.c 和中断处理文件你得手动把已有源码文件拷贝进 Src 目录并且在 Keil 的 Project 窗口里点 Add Existing Files不然编译器会直接报 undefined reference。4.3 编译烧录与常见编译错误编译前确认三件事Keil 里 Device 选择的是 STM32F407xx 系列ARM Compiler 用 version 5 还是 6 要和工程自带配置一致烧录器选择 ST-Link 并在 Settings 里把 Flash Download 的 Reset and Run 勾上。我第一次烧的时候卡在屏幕上没反应就是因为没勾 Reset and Run程序下载了但没自动复位。常见编译错误里最典型的是implicit declaration of function srand原因是用到了stdlib.h但没包含头文件。解决很简单在 game.c 顶部加#include stdlib.h。另一个是 I2C 驱动里HAL_I2C_Mem_Write的地址参数问题SSD1306 的从机地址通常是 0x788 位写地址但 HAL 函数要求传入 7 位地址 0x3C传错会一直返回 HAL_BUSY表现就是屏幕全黑。这两个问题几乎每个新手都会遇到一次属于血泪经验。烧录完成后按 KEY_OK 进入游戏用四个方向键控制蛇头移动。默认速度是一秒走两格左右如果你觉得慢可以在 game.h 里找到FRAME_INTERVAL_MS这个宏把它从 500 改到 300蛇就明显跑得快了食物吃到后面也会有紧迫感。5. 避坑指南我在调试这个游戏时踩过的五个坑5.1 画面闪烁清屏函数把整个屏幕都擦了一遍现象蛇每移动一步整个屏幕先黑一下再亮看着像 LED 在呼吸非常伤眼睛。原因工程早期版本在每次绘制之前调用了Display_Clr()把 1024 个字节全部清零再重新绘制全部方块。I2C 写屏幕很快但整屏刷新的开销会导致刷新率上不去蛇一快就拖影。解决把整屏清空改成局部擦除——只在上一次蛇尾所在的格子画黑色、在蛇头新位置画白色其余格子不动。这里记住一个嵌入式显示原则能改一个像素就不要重画整屏。5.2 按键经常一次触发两格方向现象按一次右键蛇连拐两个弯甚至直接调头失败。原因按键扫描放在 while(1) 主循环里主循环跑得快一次机械抖动被识别成了多次按下。解决把按键扫描挪到 10ms 定时中断或HAL_GetTick()的时间片里执行并且一定要做“边沿触发 同状态去重”处理。我在 3.4 节写的Key_GetEvent就是针对这个问题的标准解法稳定后按一次只返回一个事件。5.3 蛇明明没撞墙却弹出了 GAME OVER现象蛇头移动到最左列x0后再按左键应该被挡住但游戏直接判定死亡。原因边界判定写成了if (newHead.x MAP_W)这漏掉了x MAP_W和x 0两种情况。坐标是uint8_tx 等于 0 时减一并不会变成 -1而是回绕到 255判断条件完全不成立蛇头就“瞬移”到了屏幕另一侧。解决把边界判定统一改成newHead.x MAP_W || newHead.y MAP_H并且把 x、y 声明成有符号的int16_t避免回绕。这个问题排查起来很玄学因为现象是“偶尔穿墙”实际上是回绕后的坐标又合法了。5.4 食物经常刷在蛇身上吃不到现象食物生成的一瞬间蛇头正好在上面但分数没加蛇也没变长。原因Food_Generate没有检查食物和蛇身重叠就返回了或者随机种子固定导致食物序列重复。解决加入去重循环同时初始化随机种子。在 main.c 的初始化阶段调用srand((unsigned int)HAL_GetTick())利用上电时间差异打乱序列。最稳妥的做法是把 3.3 节的 while 循环结合蛇身遍历全部通过后才更新food坐标。5.5 游戏运行一段时间后画面卡住现象玩到蛇身比较长时屏幕突然不再刷新按按键也没反应只能复位。原因显示驱动中的缓冲数组是静态局部变量在Display_DrawBlock里对坐标做了“大于等于 128 就 return”的保护但如果传入的是uint8_t且坐标经过了 5.3 节那种回绕就会把一个大数当坐标写入缓冲越界踩内存破坏了关键变量。解决所有坐标在进入显示层之前统一做合法性校验非法数据直接丢弃并且把缓冲数组改成static并清零初始化。从那以后我给自己定了条规则凡是写缓冲区的函数第一行必须是区间检查这条规则比任何 debugger 都好用。6. 二次开发加道具、加双人、加蓝牙遥控的改造思路6.1 改造一加速道具与减速道具在食物生成的逻辑上扩展出道具系统是改动最小、收益最高的方案。你可以在Food_Generate里以固定概率让食物变成黄色蛇吃掉后FRAME_INTERVAL_MS减少 30%持续 5 秒时间到了再恢复。实现上只需要增加一个food.type字段和生效计时器显示层用不同颜色区分。#define FOOD_NORMAL 0 #define FOOD_SPEED 1 void Food_Generate(void) { // 原有去重逻辑保持不变 food.type (rand() % 5 0) ? FOOD_SPEED : FOOD_NORMAL; }注意加速效果的计时器不能放在HAL_GetTick()里裸算否则暂停游戏时加速效果还在倒计时体验很怪。正确做法是把计时移到游戏状态机里只有GAME_RUNNING时才递减。6.2 改造二双人对战模式这块板子只有四个按键但可以简化成两人共用一个屏幕、各控制一条蛇的局面。一个人用上下键控制左蛇另一个人用左右键控制右蛇谁先撞到对方或墙壁谁输。核心改动是让Snake_t变成数组snake[2]把Game_Update里所有对蛇的访问都改成带索引的调用然后增加一个“我方蛇头碰到对方蛇身”的碰撞分支。6.3 改造三用串口或蓝牙模块做遥控如果你不想用板载按键可以把Key_GetEvent的按键来源改成串口中断接收到的方向字符。代码改动量不大在 USART2 的中断服务函数里把收到的 ASCII 码写入一个全局变量Key_GetEvent优先返回这个变量的值按键 GPIO 作为备用输入。这个思路也适合把游戏搬到手机 App 上只要 App 能发字符游戏端完全不用动。6.4 验证思路每次改造都先过这三个测试我改造这套代码时给自己定了三个必须通过的测试方向键随机连按 200 次不应出现死机或蛇瞬移蛇身绕过两圈后食物生成位置始终有效暂停状态下等待 2 分钟再恢复游戏时间逻辑不能错乱。这三个测试覆盖了输入、随机和定时三条最容易出事的路径。那次我在双人模式下发现暂停后两条蛇的帧计数不同步排查到是各自维护了一个frameCount恢复时没有对齐。从那以后我每次改造都强制走一遍“接口不变、状态机先行、三个测试收尾”的流程这套流程帮我少翻了至少三次车。整个工程最有价值的不是贪吃蛇本身而是它教你用状态机和分层结构把一个小游戏组织得不乱不糊。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑