简介:面向STM32F407及嵌入式初学者的贪吃蛇游戏完整工程,基于Cortex-M4内核与HAL库开发,可帮助理解中断管理、定时器应用、GPIO显示控制、USART通信及碰撞检测等实时系统关键环节。代码采用模块化组织,配合STM32CubeMX生成的初始化框架,读者可对照工程清晰地体会从外设配置到游戏逻辑的实现流程。工程共159个文件,以77个C源文件与75个H头文件为主,另有Keil工程配置(uvprojx/uvoptx)、可直接烧录的hex固件及清理脚本,压缩包仅831KB。已有2810人学习下载。实现中覆盖定时器中断控制帧率、按键中断驱动、蛇身坐标数组管理、食物随机生成、边界与自身碰撞判断等核心算法,并预留了LCD/LED显示及串口调试接口,既适合课程设计,也可作为嵌入式入门实战的高性价比参考工程。 用STM32F407做贪吃蛇,听起来是个入门级练手项目,但我做完一轮之后想说:这活儿真不简单,它能把嵌入式开发里一大半基础技能串起来。从GPIO和定时器,到中断优先级、LCD显示驱动、状态机设计,再到串口日志和触摸校准,每一个环节都能“恰到好处”地出点幺蛾子。如果你手头正好有一块STM32F407开发板,又不想天天跑LED流水灯,贪吃蛇是一个性价比极高的选择——它不挑复杂外设,只用一块彩屏、几个按键,就能把MCU的资源调度和代码思路完整练一遍。
这篇内容会从整体方案设计讲起,拆LCD驱动和游戏逻辑的实现,再给一套基于CubeMX+HAL库的完整工程配置流程,最后把调试时最容易踩的坑和触摸屏扩展一起聊透。适合三类人看:刚学完标准库想进阶HAL库的、想把手里F407板子玩出花样的、以及做课程设计想抄一份能跑的完整方案的。
1. 项目整体思路与硬件选型
1.1 为什么选STM32F407做这种小游戏
在很多人眼里,贪吃蛇用51单片机、用ESP32甚至直接用电脑写个C语言控制台版本都行,非要选STM32F407,看着像小题大做。但实际上,F407这颗芯片的核心优势在于主频和FSMC接口——168MHz的Cortex-M4内核跑游戏逻辑绰绰有余,FSMC可以把LCD当成“外部SRAM”直接映射到内存地址空间,刷屏速度比GPIO模拟时序快一个量级。
这个项目真正考验的不是“能不能跑贪吃蛇”,而是“在有多余性能的情况下,代码结构能不能撑得住后续扩展”。比如后面想加触摸屏校准、加计分动画、加历史记录存储,MCU性能冗余足够,你只需要在软件架构上做文章,不用担心底层算力不够。
我选的是经典的STM32F407ZGT6开发板,板载2.8寸TFT彩屏,驱动芯片是ILI9341,接口方式是FSMC 16位并口。方向控制用板载独立按键,没有直接上触摸屏——先把基本功练熟,后面再在第四节把电阻触摸校准补上。
1.2 显示方案对比:FSMC并口屏、SPI屏、OLED屏
很多人会纠结用哪种屏幕,我给个直观对比:
| 屏幕类型 | 接口 | 刷新表现 | 驱动难度 | 适合场景 |
|---|---|---|---|---|
| 2.8寸TFT ILI9341 | FSMC并口 | 快,全屏刷新不掉帧 | 中 | 本项目的首选 |
| 1.44寸TFT ST7735 | SPI | 慢,全屏刷新能看到闪烁 | 低 | 小型项目、引脚受限 |
| 0.96寸OLED SSD1306 | I2C/SPI | 中,分辨率低 | 低 | 简单展示、省电应用 |
| 3.5寸TFT ILI9488 | FSMC/SPI | 取决于接口方式 | 中 | 需要更大显示面积时 |
如果手头是SPI屏,也能做,但建议尽量把游戏区域缩小一点,用局部刷新替代全屏刷新,不然蛇一长,肉眼可见地在闪。FSMC方案的优势是在写入像素时,CPU只需要执行“向某个地址写数据”的指令,硬件自动完成时序拼接,不占GPIO翻转的开销。
还有一点值得注意:F407的FSMC外设虽然叫“FMC”,实际上在F1系列上是FSMC,F4系列叫FMC,只是名称差异,用法差不多。CubeMX里配置时选“FMC”选项卡,然后配成NOR/SRAM控制器模式就行。
1.3 按键输入与状态机设计思路
贪吃蛇的方向控制,最直接的实现就是读GPIO电平判断按键按下。但这里有个细节:机械按键存在抖动,单独用延时消抖会占用CPU,最好的做法是用定时器扫描。我开了两个定时器,一个负责游戏主循环节拍(比如200ms一帧),一个负责按键扫描消抖(每10ms读一次按键状态,连续两次相同才判定有效)。
状态机是整个游戏程序的灵魂。我设计了四个状态:START、RUNNING、PAUSE、GAME_OVER。截图说明可能不太直观,但代码逻辑其实很清晰:按键1启动/暂停,按键2重置,方向键控制蛇头朝向。状态切换只发生在按键事件里,游戏逻辑只关心当前状态是什么,这样代码的可维护性好很多。
比较建议在状态机外层套一个“事件触发”模式——按键按下时不直接改游戏数据,而是把事件发到一个环形队列里,主循环再从队列取事件处理。虽然F407性能冗余,但这个设计能防止在定时器中断里做太多事情导致逻辑混乱,后面扩展触摸操作也能直接兼容。
2. 显示驱动与贪吃蛇核心逻辑拆解
2.1 用FSMC驱动ILI9341的底层原理
FSMC驱动LCD的核心是把LCD屏当成一个静态存储器设备。ILI9341的接口上有一根DC(数据/命令选择)引脚,通常接到FSMC的地址线A6上。CS片选接到NE1,对应的映射地址是0x60000000。由于A6是第6根地址线,当CPU写0x60000000时,A6保持低电平,代表命令;写0x60000040(即第6位置1)时,A6是高电平,代表数据。
代码里一般这样定义:
#define LCD_CMD_ADDR ((uint32_t)0x60000000) #define LCD_DATA_ADDR ((uint32_t)0x60020000) // A18为高,或者按实际接线调整 #define LCD_WR_CMD(cmd) (*(volatile uint16_t *)LCD_CMD_ADDR = (cmd)) #define LCD_WR_DATA(data) (*(volatile uint16_t *)LCD_DATA_ADDR = (data))有人可能会问,为什么数据地址要单独设一个偏移?因为DC引脚接到哪根地址线,偏移就对应哪一位。我的板子上DC接了A18,所以数据地址是0x60020000(第二位置1)。你在实际移植时,先确认原理图的DC引脚接的是FSMC的哪个地址线,再算偏移量,这一步做错的话,屏幕要么花屏,要么根本点不亮。
像素填充方面,ILI9341支持直接写GRAM的方式,设置好坐标窗口后,连续写数据就能快速填充矩形区域。贪吃蛇的背景网格和食物绘制,本质就是填充多个小矩形,所以我把“画矩形填充”封装成了一个基础函数,后面所有界面都复用它。
2.2 贪吃蛇的数据结构选型与坐标规划
贪吃蛇的移动本质上是一个“头进尾出”的过程。最简单的做法是用一个二维数组标记整张地图,每次移动都遍历更新。但我推荐用链表:蛇的每一节是一个节点,头节点根据方向移动,新节点插入头部,尾部节点删除,这样每一步移动的复杂度是O(1)。
typedef struct SnakeNode { uint8_t x; uint8_t y; struct SnakeNode *next; } SnakeNode;游戏区域我规划成25列×19行,每个格子对应屏幕上12×12像素。这样整屏游戏区正好是300×228像素,留出底部区域显示得分和提示信息。格子大小太大会让游戏显得很空,太小则手指或者按键判断容易误触,12像素在2.8寸屏上表现刚好。
食物的生成逻辑有一处容易忽视:新食物不能生成在蛇身上。如果图省事用随机数直接产生坐标,蛇比较长时很容易刷到蛇身上,出现“食物刚出现就消失”的错觉。我建议先生成一个候选坐标,如果命中蛇身坐标就重新随机,最多尝试50次,超过则判定游戏胜利(因为蛇已经快占满屏幕了)。
2.3 碰撞检测与移动节奏控制
碰撞检测有两种:撞墙检测和撞自己检测。撞墙很简单——蛇头坐标超出边界就算死;撞自己则需要遍历蛇身节点,把蛇头坐标和每一个身体节点比对。实际测试中,遍历判定会造成一个“视觉延迟”:蛇头看起来还没到,就提前判死了。原因在于头部移动后、尾部未删除前,头坐标和原来的颈部坐标重合,误判成撞自己。
解决办法是先把尾巴节点信息保存下来,等新头坐标计算完成后再删除尾部。伪代码如下:
// 保存尾部 oldTail = snakeTail; // 删除尾部 removeTail(); // 更新头部 newHead.x = oldHead.x + dirX; newHead.y = oldHead.y + dirY; // 检查是否撞到自己 if (checkCollision(newHead)) gameOver(); // 把新头插入链表头 insertHead(newHead);移动节奏方面,我建议用一个10ms一次的定时器作为“心跳”,在中断里对计数变量累加,到达“移动间隔”时置一个标志位,主循环检测到标志位再执行蛇移动和画面刷新。这样调整游戏速度只需要改变移动间隔数字,比如初始300ms,每吃一个食物减5ms,最低100ms,避免无限加速导致无法操控。
3. 基于STM32CubeMX + HAL库的完整实现流程
3.1 CubeMX工程配置要点:时钟、FSMC、定时器
如果你还在手写寄存器配置LCDC驱动时序,我个人强烈建议换成CubeMX+HAL库。不是因为寄存器不好,而是FSMC和时钟树的配置太繁琐,手写容易漏引脚复用,而CubeMX几秒钟生成代码,省下来的时间能用来调试游戏逻辑。
CubeMX里需要做这些关键配置:
- RCC选择HSE外部晶振,时钟树调到168MHz(F407最高主频,USB等外设依赖这个频率)
- FMC选NOR/SRAM Bank1,配置NE1片选、16位数据宽度、读写时序参数
- FSMC引脚自动分配,检查一下有没有和板载其它外设冲突
- TIM3设为10ms定时中断,负责按键扫描和游戏节拍统计
- USART1启用,用于printf日志输出
FSMC的时序参数需要关注读/写建立时间和保持时间。LCD的数据手册里有最小时序要求,CubeMX里可以直接填。如果屏幕花屏,不要急着查代码,先检查时序参数是不是设置得太极限;我吃过亏,把建立时间从15ns压到10ns,屏幕直接出噪点。
3.2 显示层封装:画点、填充、字符串显示
游戏界面显示内容比较杂:背景、蛇身、食物、文字、分数。强烈建议把显示层按功能拆分,不要全部写在main.c里。我是这样规划的:
// lcd_driver.c 底层 void LCD_Init(void); void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1); void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color); // gui.c 界面层 void GUI_DrawGameBg(void); void GUI_DrawSnake(SnakeNode *head); void GUI_DrawFood(uint8_t x, uint8_t y); void GUI_ShowScore(uint16_t score);界面层只管把游戏数据变成像素,不关心数据怎么变。数据层只改链表和状态,不关心界面。这样在跑逻辑的时候,可以先关掉界面刷新,用串口日志打印数据变化来定位问题,调试效率翻倍。
字符串显示是很多新手容易卡住的地方。ILI9341没有内置字库,需要往控制器里写字符点阵数据。我用的是网上找的12×12和16×24 ASCII字库,取模方式为“逐行取模,高位在前”。如果你用的屏显示文字是镜像或者缺笔画,99%是取模方式设错了,对照字库数据检查一下高低位和扫描方向即可。
3.3 游戏逻辑完整代码框架
这里我把最核心的游戏逻辑结构放出来。它不是完整代码,但搭出了清晰骨架,你照着填就行:
typedef enum { GAME_START, GAME_RUNNING, GAME_PAUSE, GAME_OVER } GameState; GameState state = GAME_START; uint8_t dirX = 1, dirY = 0; uint8_t pendingDirX = 1, pendingDirY = 0; uint16_t score = 0; uint16_t moveSpeed = 300; uint16_t speedCounter = 0; void Game_OnTimerTick(void) { if (state == GAME_RUNNING) { speedCounter += 10; if (speedCounter >= moveSpeed) { speedCounter = 0; Game_MoveSnake(); } } } void Game_MoveSnake(void) { // 先应用待处理方向,再移动 dirX = pendingDirX; dirY = pendingDirY; // 计算新头部 uint8_t newX = snakeHead->x + dirX; uint8_t newY = snakeHead->y + dirY; // 撞墙判断 if (newX >= MAP_W || newY >= MAP_H) { state = GAME_OVER; return; } // 临时保留尾巴 SnakeNode *oldTail = snakeTail; removeTail(); // 检查撞自身 if (isSnakeNode(newX, newY)) { state = GAME_OVER; return; } // 插入新头 insertHead(newX, newY); // 吃到食物 if (newX == foodX && newY == foodY) { score++; moveSpeed = (moveSpeed > 100) ? moveSpeed - 5 : 100; // 把旧尾巴加回来,相当于蛇变长 appendNode(oldTail); generateFood(); } }按键扫描部分我开了独立按键中断,但中断里只做“按键事件入队”,真正的状态切换在game_task里完成。为什么这么做?因为如果在中断里直接改游戏状态,可能会在移动逻辑执行了一半时打断操作,导致链表指针混乱。这是个非常隐蔽的bug来源,在中断服务函数里操作数据结构务必要三思。
3.4 串口日志与调试技巧:printf重定向
调试嵌入式应用,没有日志就像闭着眼睛开车。我用的调试方案是:串口重定向printf,加开关宏控制日志等级。
#define DEBUG_LEVEL 1 #if DEBUG_LEVEL > 0 #define DBG_INFO(fmt, ...) printf("[INFO] " fmt "\r\n", ##__VA_ARGS__) #else #define DBG_INFO(fmt, ...) #endif重定向printf需要重写fputc函数:
int fputc(int ch, FILE *f) { while (!(USART1->SR & USART_FLAG_TXE)); USART1->DR = (uint8_t)ch; return ch; }游戏运行时,每次移动都打印蛇头坐标、方向和当前状态,这样逻辑错误可以快速定位。比如明明按了向上,蛇却往左走了,大概率是方向键的取反逻辑问题,看一眼日志就能判断。
4. 调试踩坑、触摸屏扩展与细节优化
4.1 常见问题速查:花屏、闪屏、方向反转
我在调试中积累了一些高频问题,整理成速查表:
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 屏幕白屏或花屏 | FSMC时序配置过极限 | 放宽读/写建立时间 |
| 屏幕白屏或花屏 | DC地址线偏移算错 | 核对原理图DC接入地址线 |
| 屏幕显示镜像 | 字库取模方向不对 | 检查逐行/逐列、高位/低位 |
| 蛇移动时闪屏 | 全屏刷新导致 | 改为局部刷新脏矩形 |
| 按键方向偶尔不响应 | 按键未消抖或扫描周期太长 | 定时器10ms扫描,连续采样 |
| 蛇刚移动就算撞自己 | 头部计算和尾部删除顺序错误 | 先删尾部,再插入新头 |
| 食物刷在蛇身上 | 随机坐标未检测蛇身 | 添加碰撞检测循环 |
| 串口输出乱码 | 波特率不匹配或时钟配置错误 | 核对USART配置与终端参数 |
屏幕闪屏问题的根因值得展开说说。如果每帧都调用LCD_Clear全屏清屏,ILI9341的GRAM刷新需要时间,肉眼就能看到闪烁。优化方案是“脏矩形”刷新:只重绘蛇尾擦除区域和蛇头新增区域。由于背景是纯色,把旧蛇尾画成背景色,再把新蛇头画成蛇身色,视觉上就是平滑移动了,CPU占用也低了很多。
4.2 串口日志存储到Flash:简易日志记录方法
根据热词“基于stm32f407的日志存储记录方法”,这其实是一个很实用的扩展。在调试阶段,数据能通过串口实时打印;但有些问题只在掉电重启后复现,串口日志来不及看,就需要把日志写入Flash,下次开机时可以回看。
F407内置Flash容量大,可以通过HAL库的Flash接口擦写扇区。我在片内Flash末尾开辟了一个LOGDATA扇区,用于记录最近一次运行的关键日志:
#define LOG_SECTOR_ADDR 0x080E0000 // 按实际芯片容量选末尾扇区 void Log_Write(uint16_t line, uint16_t data) { HAL_FLASH_Unlock(); // 擦除扇区 FLASH_EraseInitTypeDef erase = {0}; erase.TypeErase = FLASH_TYPEERASE_SECTORS; erase.Sector = FLASH_SECTOR_11; erase.NbSectors = 1; erase.VoltageRange = FLASH_VOLTAGE_RANGE_3; HAL_FLASHEx_Erase(&erase, &err); // 写入数据 HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, LOG_SECTOR_ADDR, line); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, LOG_SECTOR_ADDR + 2, data); HAL_FLASH_Lock(); }注意Flash擦写次数有限,不能在游戏死循环里频繁调用,只适合在异常状态、关键节点写入。我通常只在碰撞检测触发时记录一次,保存当时蛇的长度和出错码,方便分析是逻辑误判还是外部干扰。
4.3 触摸屏扩展:TFT电阻触摸四点校准法
如果你手头的板子是TFT电阻触摸屏,或者想把这个项目升级成触控操作,那就绕不开触摸校准。热词里提到的“四点校准法”是电阻触摸屏的主流校准方案。其原理是:LCD坐标和ADC采样坐标之间存在一个线性变换关系,通过四个已知点的坐标对,求出变换矩阵的六个系数。
线性变换公式可以写成:
// 假设ADC采样值 adcX, adcY,LCD坐标为 lcdX, lcdY // lcdX = (A * adcX + B * adcY + C) / K // lcdY = (D * adcX + E * adcY + F) / K四点在屏幕上尽量靠边的位置各放一个校准点,依次点击后得到四组坐标,联立方程解出K、A、B、C、D、E、F。实际工程中一般不用自己手推矩阵,网上有现成的触摸校准库,比如Xnake项目里的touch_calibrate.c,把四点坐标结构体填进去,自动算出参数。
我自己在实际操作中验证过,四点校准比三点校准稳定性好很多。三点校准虽然少按一次,但碰到边缘区域偏差较大。校准完成后别忘了加一个“参数保存”功能,把计算出的系数存到EEPROM(没有EEPROM就存Flash末尾扇区),否则每次开机都要重新校准,很考验耐心。
4.4 基于模拟I2C的扩展与外设补充
最后再说一个F407开发中绕不开的知识点:模拟I2C。如果你后续想给贪吃蛇项目加外部存储EEPROM、加OLED小屏显示分数、或者加传感器记录环境数据,I2C是最高频的通信之一。热词里特意提了“stm32f407使用hal库模拟i2c”,说明很多人都踩过硬件I2C的坑。
HAL库的硬件I2C在F4上有时候会卡死在事件等待上,模拟I2C反而更可靠。模拟I2C的核心就是两个GPIO引脚,手动拉高拉低模拟时钟和数据时序。I2C通信协议分四段:起始信号、从机地址+读写位、数据字节、停止信号,每发一个字节后还要等待从机拉低SCL应答位。
贪吃蛇项目用I2C能扩展什么?最简单的场景是:把当前最高分存到外部EEPROM,掉电不丢失。这样每次开机都能显示历史成绩,比内置Flash擦写方便,也不占用Flash寿命。如果你在调试I2C时发现数据读回来全是0xFF,先查上拉电阻——很多板载EEPROM电路偷工减料没加上拉,模拟I2C模式下需要外部上拉到VCC才能通信。
写在最后的经验
做完这个贪吃蛇项目,我最深的体会是:嵌入式的乐趣不在于代码能跑,而在于你能彻底掌控从“按键按下”到“屏幕上显示变化”的完整链路。相比之下,单纯用C语言在电脑上写一个贪吃蛇控制台版本,虽然也能锻炼逻辑,但少了中断、外设、时序这些硬件层面的约束,缺少了那层“在资源受限条件下做取舍”的刺激感。
如果你准备复刻这个项目,我的建议是:先别急着加触摸和其它高级功能,把FSMC驱动、链表、状态机三个核心点吃透,能在LCD上稳定跑出200ms一帧不闪屏,再谈扩展。触摸校准、I2C存储这些都可以逐步叠加上去。遇到花屏不要慌,先怀疑时序和地址映射;遇到逻辑错乱不要急,打开串口日志看数据流,绝大多数问题都能在十分钟内定位。
最后再分享一个小技巧:把游戏区域的颜色做成可配置的,比如深色背景配亮色蛇身,视觉疲劳会减轻很多。如果后续有精力,可以再把AI自动寻路的贪吃蛇加进去,那就是另一个维度的算法挑战了。
本文还有配套的精品资源,点击获取