☰
STM32F407贪吃蛇游戏设计:状态机与定时器驱动嵌入式入门项目
2026/10/26 2:28:42 网站建设 项目流程

简介:面向嵌入式初学者与进阶开发者的STM32F407贪吃蛇游戏工程,以经典游戏为载体串联Cortex-M4内核、GPIO显示控制、定时器中断、USART通信与基础游戏算法等知识点,适合用来练习外设驱动、中断服务和状态管理,也可作为课程设计或毕业设计的参考项目。压缩包共159个文件,约831KB,主体为77个头文件与75个C源文件,配套Keil工程文件、启动汇编、HEX烧录文件及批处理脚本,覆盖底层库、外设驱动和应用逻辑,目录结构便于按模块查阅与二次移植。已有2820人学习下载。工程将蛇身移动、碰撞检测、食物生成和按键响应拆分为可读性较强的实现,读者可据此理解帧率控制、内存管理与中断服务例程的配合方式,并借助Keil MDK或STM32CubeIDE完成编译、下载与调试,积累从寄存器配置到游戏逻辑落地的完整实践经验。

1. 需求切入:一块 STM32F407 开发板上跑通可玩的贪吃蛇

很多人拿到 STM32F407 开发板后,第一件事是点灯,第二件事是串口打印,第三件事就卡住了——不知道做什么能把 GPIO、定时器、中断、显示驱动串成一条线。贪吃蛇看着简单,却是嵌入式入门里少有的「全链路小项目」:它同时逼你处理按键输入、定时刷新、屏幕绘制、游戏状态机和随机数,任何一环没想清楚,蛇就会穿墙、抖动或者按键失灵。这套 STM32F407 贪吃蛇游戏程序,就是围绕一块常见开发板把上述环节完整跑通的方案,适合正在做课程设计、想把外设驱动写扎实的学生,也适合需要一个可演示小项目的嵌入式初学者。它的价值不在游戏本身,而在于给你一份能拆开看、能改参数的工程骨架。

2. 总体方案设计:状态机内核与四大模块的职责划分

先把「游戏」这个词从脑子里拿掉一会儿。在这个项目里,屏幕只是输出设备,按键只是输入设备,真正决定程序行为的是一个每隔固定时间被调用一次的游戏逻辑函数。理解这一点,后面所有模块的边界才划得清。

我一般的做法是把工程切成四块:显示驱动层负责把像素画到屏上,输入层负责把按键的物理电平翻译成「上/下/左/右/确认」这类语义,游戏逻辑层持有蛇身坐标、食物坐标、分数、当前状态,主循环加定时器负责驱动节奏。四块之间只通过明确的函数接口和少量全局状态交互,谁都不去碰别人的内部变量。

2.1 游戏状态机:四个状态撑起整个流程

贪吃蛇的流程其实非常有限:等玩家按键开始、正在玩、撞死、暂停。把这四个状态用枚举写清楚,主循环就变成「根据当前状态决定做什么」,而不是一堆 if 堆在一起。

/* 游戏状态枚举:所有流程跳转都围绕这四个值 */ typedef enum { GAME_READY = 0, /* 上电后的等待开始界面 */ GAME_RUNNING, /* 正常游戏进行中 */ GAME_PAUSED, /* 暂停,画面保持不动 */ GAME_OVER /* 撞墙或撞到自己 */ } GameState; /* 每个状态下允许的按键语义不同,用函数指针表隔离 */ static void Game_Update(void) { switch (g_game_state) { case GAME_READY: if (Input_GetKeyEvent() == KEY_OK) { Game_Reset(); /* 重置蛇身、分数、食物 */ g_game_state = GAME_RUNNING; } break; case GAME_RUNNING: Game_MoveSnake(); /* 真正的核心逻辑 */ break; case GAME_PAUSED: if (Input_GetKeyEvent() == KEY_OK) { g_game_state = GAME_RUNNING; } break; case GAME_OVER: if (Input_GetKeyEvent() == KEY_OK) { g_game_state = GAME_READY; /* 回到待开始,不直接重开 */ } break; } }

这段代码的关键不在 switch 语法,而在「状态的进入条件」和「状态里允许发生什么」被分开写了。GAME_OVER 不直接回到 RUNNING,而是先回 READY,是为了逼玩家再按一次确认键,避免死亡瞬间手指还压在方向键上导致误重开——这个细节在答辩演示时很能体现你想过交互。

2.2 定时器驱动节奏:为什么不用 HAL_Delay

新手最容易踩的坑是用 HAL_Delay 控制蛇的移动速度,结果是按键响应迟钝、画面卡顿。正确做法是让一个硬件定时器周期性产生中断或设置标志位,主循环检测标志位再更新一次游戏逻辑。

/* TIM3 配置为 1ms 中断,软件分频得到蛇的移动节拍 */ volatile uint32_t g_tick_ms = 0; volatile uint8_t g_move_flag = 0; void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim3, TIM_FLAG_UPDATE) != RESET) { __HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE); g_tick_ms++; /* 每 250ms 允许蛇走一格,速度参数就改这里 */ if ((g_tick_ms % SNAKE_STEP_MS) == 0) { g_move_flag = 1; } } } /* 主循环里只做判断,不阻塞 */ while (1) { Input_Scan(); /* 每圈都扫按键,保证响应 */ if (g_move_flag) { g_move_flag = 0; Game_Update(); /* 只有到点才推进逻辑 */ Display_Refresh(); /* 逻辑更新后统一重绘 */ } }

这里 SNAKE_STEP_MS 就是难度参数,改成 150 蛇就快,改成 400 蛇就慢,不需要动任何逻辑代码。把「时间基准」和「游戏速度」分离,是这个项目里最值钱的一个设计决定:后来你要加速度递增、要做暂停、要做死亡动画,全都可以挂在这套节拍上。

2.3 蛇身数据结构:环形缓冲比数组搬移更省事

蛇身用数组存坐标,最直观的写法是每次移动把整个数组后移一位。这样写能跑,但每走一步都是 O(n) 的内存搬移,蛇越长越慢,而且逻辑上很别扭。我推荐用环形缓冲加头尾指针:蛇头前进就是写入新坐标并移动头指针,蛇尾跟着走就是移动尾指针,吃到食物时尾指针不动,蛇自然变长。

#define SNAKE_MAX_LEN 64 typedef struct { int16_t x; int16_t y; } Point; static Point s_body[SNAKE_MAX_LEN]; /* 环形队列 */ static uint8_t s_head; /* 头指针,指向最新写入位置 */ static uint8_t s_tail; /* 尾指针,指向最老一节 */ static uint8_t s_len; /* 当前长度 */ /* 蛇头前进一格:先算新头,再判断是否吃到食物 */ static void Game_MoveSnake(void) { Point next = s_body[s_head]; switch (g_dir) { case DIR_UP: next.y--; break; case DIR_DOWN: next.y++; break; case DIR_LEFT: next.x--; break; case DIR_RIGHT: next.x++; break; } if (Game_HitWall(next) || Game_HitSelf(next)) { g_game_state = GAME_OVER; return; } s_head = (s_head + 1) % SNAKE_MAX_LEN; s_body[s_head] = next; s_len++; if (next.x == s_food.x && next.y == s_food.y) { g_score += 10; /* 吃到食物:尾不动,长度+1 */ Game_SpawnFood(); } else { s_tail = (s_tail + 1) % SNAKE_MAX_LEN; /* 没吃到:尾巴跟上 */ s_len--; } }

注意这里长度 s_len 是先加后减,逻辑上等价于「吃到就净增一节,没吃到就净增零节」。环形缓冲的容量必须大于游戏允许的最大蛇长,否则触发覆盖,画面上会出现蛇身断开这种诡异现象——这算是这个结构唯一的代价。

2.4 显示与坐标映射:像素坐标和格子坐标必须分层

屏幕驱动的接口通常按像素画点画矩形,而游戏逻辑只认「第几行第几列」。这中间必须有一层换算,否则改格子大小就要改遍所有逻辑代码。

层级坐标单位主要职责改动频率
游戏逻辑层格子(col, row)碰撞判断、食物生成、计分低
映射层像素(x, y)格子尺寸、边距、坐标换算中
显示驱动层像素画点、填充矩形、清屏高

映射层就两个宏或者两个内联函数:GridToPixelX、GridToPixelY,把 col 乘格子宽加边距。好处是当你把格子从 8×8 改成 12×12,或者换一块分辨率不同的屏,只需要改映射层和顶部定义的网格行列数,游戏逻辑一行都不用动。这就是分层设计在小项目里的实际收益,不是什么架构洁癖。

3. 关键模块实现:从按键消抖到食物随机

模块划分清楚之后,真正容易翻车的是细节。这一章按「输入 → 逻辑 → 输出」的顺序,把每个模块里必须想明白的地方写透。

3.1 按键扫描:方向反转与消抖的处理

贪吃蛇里有一个经典规则:蛇在向右走的时候不能直接反向向左,否则会立刻撞到自己。这条规则不该写在按键扫描里,而应该写在方向设置函数里,因为按键层不应该知道游戏规则。

/* 按键层只负责产出「事件」,不关心游戏规则 */ typedef enum { KEY_NONE, KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, KEY_OK } KeyEvent; static KeyEvent s_last_key = KEY_NONE; void Input_Scan(void) { KeyEvent key = Key_ReadRaw(); /* 读 GPIO,含 20ms 软件消抖 */ if (key != KEY_NONE && key != s_last_key) { s_last_key = key; Input_PushEvent(key); /* 只在「新按下」时入队 */ } else if (key == KEY_NONE) { s_last_key = KEY_NONE; /* 松手后复位,允许连按 */ } } /* 游戏侧拿到方向请求后再做合法性判断 */ static void Game_SetDirection(Direction want) { if (want == DIR_UP && g_dir == DIR_DOWN) return; if (want == DIR_DOWN && g_dir == DIR_UP) return; if (want == DIR_LEFT && g_dir == DIR_RIGHT) return; if (want == DIR_RIGHT && g_dir == DIR_LEFT) return; g_dir = want; }

把「按键事件」和「方向合法性」分开,后果是你可以放心地在暂停界面复用同一套按键,也可以在 GAME_OVER 状态里让方向键失效而只认确认键,不需要在按键扫描里加状态判断。消抖参数 20ms 是个经验值,按键质量差可以加到 30ms,但不要超过 50ms,否则快速连按会被吃掉。

3.2 食物生成:伪随机加去重,避免食物落进蛇身

食物位置必须随机,而且不能生成在蛇身上。很多人的随机数直接调 rand() 取模,结果每次上电食物都在同一个位置——因为没播种。另一个常见问题是随机到了蛇身上没检查,画面出现「蛇头压着食物却吃不到」的诡异情况。

/* 用 SysTick 的毫秒计数做种子,保证每次上电序列不同 */ static void Game_SpawnFood(void) { uint8_t tries = 0; Point p; do { p.x = (int16_t)(rand() % GRID_COLS); p.y = (int16_t)(rand() % GRID_ROWS); tries++; if (tries > 100) break; /* 防死循环:蛇太长时必然重叠 */ } while (Game_IsOnSnake(p)); s_food = p; }

种子在初始化时播种一次即可:srand(HAL_GetTick())。tries 上限是必须加的,理论上蛇占满棋盘后永远找不到空位,虽然实际玩不到那一步,但防御性代码能让你在调试时少一次整机复位。另外食物生成后要先清掉旧食物位置,再画新食物,否则屏上会留下两个食物。

3.3 画面刷新:整屏重绘还是局部擦除

贪吃蛇画面简单的项目,整屏重绘最省脑子:清屏、画蛇、画食物、画分数,一帧画完。代价是如果屏是 SPI 接口、刷新率不高,肉眼能看到闪烁。局部擦除则是只擦蛇尾那一格、只画蛇头那一格,效率高但代码复杂,一旦擦错一格就留残影。

我的建议是:先写整屏重绘把功能跑通,确认逻辑无误后再优化。真要优化,也不要一格一格擦,而是把「这一帧变化的格子」收集起来统一处理。下面是最小可用的整屏重绘骨架:

void Display_Refresh(void) { LCD_Clear(COLOR_BG); /* 背景一次清干净 */ LCD_DrawRect(0, 0, GRID_COLS * CELL_W, GRID_ROWS * CELL_H, COLOR_BORDER); for (uint8_t i = 0; i < s_len; i++) { uint8_t idx = (s_tail + i) % SNAKE_MAX_LEN; /* 从尾到头按顺序取 */ Point p = s_body[idx]; LCD_FillRect(p.x * CELL_W, p.y * CELL_H, CELL_W - 1, CELL_H - 1, COLOR_SNAKE); } LCD_FillRect(s_food.x * CELL_W, s_food.y * CELL_H, CELL_W - 1, CELL_H - 1, COLOR_FOOD); Display_DrawScore(g_score); }

CELL_W - 1 这种减一是为了格之间留 1 像素缝,视觉上有网格感;如果屏很小想要饱满效果就改成 CELL_W。遍历蛇身必须从尾指针开始按环形顺序取,直接从 0 遍历到 s_len 是错的——这是环形缓冲最容易翻车的地方,画出来的蛇会缺节或者错位。

3.4 分数与难度:让速度随长度递增

纯粹能玩还不够,课程设计一般还要有一点「游戏性」。最简单的加分项是让移动间隔随分数缩短,实现上只改定时器里的比较值,逻辑层完全无感。

/* 分数越高跑得越快,下限 100ms 防止快到不可玩 */ static uint32_t Game_GetStepMs(void) { uint32_t ms = 260 - (g_score / 10) * 10; if (ms < 100) ms = 100; return ms; }

在定时器中断里把固定的 SNAKE_STEP_MS 换成 Game_GetStepMs() 的返回值即可。要注意的是,在中断里调用这种纯计算的函数没问题,但千万别在中断里调用 LCD 绘制,那样一定会卡死或者花屏。

4. 避坑与排查:四个最容易翻车的点

这一节是我在类似小项目里见过最多次的问题,基本每个都会在调试阶段浪费半天。

现象一:上电后画面正常,但按键完全没反应。原因通常是 GPIO 时钟没使能,或者按键上下拉配置和实际电路相反,导致读到的电平永远是同一个值。解决办法是把按键读原始电平的结果通过串口打印出来,不看逻辑只看电平;如果电平不随按键变化,先查硬件电路和引脚复用,再查代码。

现象二:蛇移动时画面闪烁严重,甚至出现拖影。原因多半是整屏刷新太慢,或者刷新函数在定时器中断里被调用。解决办法是把绘制全部挪回主循环,中断只置标志;如果还是闪,就检查是不是每画一个格子都调用了一次清屏函数。

现象三:吃到食物后分数加了,但蛇没有变长。原因九成在环形缓冲的头尾指针更新顺序写反了,或者 s_len 是先加后减的写法里写成了先减后加。解决办法是把蛇的坐标序列打印出来看,长度变化和头尾指针的位置必须能对上,对不上就是这里错了。

现象四:随机食物偶尔出现在蛇身上,撞上去没有反应。原因是没有做「是否在蛇身上」的循环检查,或者检查函数遍历蛇身时用了错误的索引顺序(同样是从 0 到 len 直接遍历了环形数组)。解决办法是把遍历统一封成一个函数 Game_IsOnSnake,所有需要判断蛇身的地方都调它,不要在别处再手写一遍循环。

提示:调试这类游戏程序,最有效的工具不是仿真器,而是一个能在屏角上打印内部变量的调试页——当前方向、蛇长、头尾指针、食物坐标,一屏全看见,比打断点快得多。

5. 验收与答辩:怎么证明这套程序是「真跑通了」

程序能玩只是及格线,验收和答辩考的是你有没有把设计讲清楚。我一般会准备三样东西:一段三十秒的完整演示、一张模块与调用关系图、一份参数对照说明。

演示环节不要从开机慢慢玩,要按功能点分段演:先展示待开始界面,按键进入游戏;然后故意撞墙展示 GAME_OVER 和重新开始;接着演示暂停与恢复;最后展示吃到食物后速度变快。分段演示的意义在于,评委能清楚看到每个状态确实存在,而不是怀疑你只有一个循环在跑。

答辩提问高频集中在几个地方,提前把答案想好。比如「蛇为什么会变长」,答案要落到环形缓冲的头尾指针,而不是「加了个变量」;「速度怎么控制的」,答案要落到定时器节拍和比较值,能顺带说出为什么不用 HAL_Delay 就更好了;「按键会不会冲突」,答案要落到事件队列和方向合法性判断分开处理。

下面这张表可以直接放进你的设计说明里,用来说明关键参数的取值依据,比空口说「可调节」有说服力得多。

参数典型取值作用调整建议
蛇初始长度3 格太短不好操作,太长开局难2 到 4 之间
移动间隔260ms 起决定基础难度180 到 400 之间
速度下限100ms防止后期快到不可玩不低于 80ms
按键消抖20ms过滤机械抖动按键差可到 30ms
格子尺寸8×8 像素兼顾屏分辨率与观感按屏幕宽度整除取整

最后说一个我自己的习惯:每次交这类项目前,我都会用一块全新的板子、一根没插过的下载线,从零走一遍「编译、下载、上电、玩三分钟」的流程。因为开发过程中我改了无数遍参数、注释过若干行代码,本地能跑不代表干净工程能跑,血泪经验就是被这种「在我电脑上没问题」坑过。从那以后我每次交付前都强制走一遍全新环境的完整流程,希望这套结构和这份清单帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询