
简介基于STM32微控制器和LCD1602液晶屏的贪吃蛇游戏完整工程面向嵌入式初学者与电子设计爱好者演示了将经典游戏移植到STM32F103平台上的全过程涉及GPIO控制、定时器中断、按键检测、液晶驱动和状态机编程等知识点。包内共85个文件以C语言源代码为主包含37个.c源文件和38个.h头文件同时附带Keil工程文件、可直接烧录的HEX固件、成品演示GIF、Xmind设计思维导图、README说明文档以及清理脚本目录结构清晰便于按模块对照学习。压缩包大小约6.95MB已有388人学习。资源除完整的贪吃蛇算法外还涵盖蛇的移动、食物生成、碰撞检测等核心逻辑以及LCD1602驱动和STM32F10x标准外设库并包含HARDWARE硬件配置目录读者可快速掌握嵌入式项目模块划分思路也适合在此工程基础上继续扩展或移植到其他开发板。1. STM32贪吃蛇游戏真正值钱的是工程骨架拿到“基于STM32的贪吃蛇游戏.zip”这类压缩包多数人以为主角是那条蛇的算法。实际上贪吃蛇逻辑上不复杂链表或数组都能实现难点全在STM32这套外围按键怎么消抖、屏幕怎么刷新不闪烁、定时器怎么切分帧率与移动周期、以及游戏状态机怎么跟这些中断不打架。整个项目背后是“怎么在MCU上把实时游戏跑顺”的完整思路单看代码只看得出结果看不出为什么这么设计。“基于STM32的贪吃蛇游戏.zip”这个标题对应的是嵌入式经典练手项目常见载体是STM32F103C8T6核心板加OLED/LCD屏幕代码多为C语言工程编译环境通常是Keil或STM32CubeIDE。适合人群很明确正在学STM32想找综合练习的开发者或者要做课设、毕业设计的学生。如果只想把蛇跑起来照抄代码也能通但真正有意义的是理解中断、定时器、状态机和显示刷新怎么协作——这五样其实就是大部分嵌入式产品的五脏六腑。下文从工程切入把每条蛇、每个方块背后的选型理由和落地细节拆开讲。2. 先从工程结构选型HAL库还是标准库屏幕选OLED还是LCD贪吃蛇项目的成品效果不复杂但工程怎么搭、外设怎么选决定了代码好不好写、后期好不好调。嵌入式的库战在这里第一次暴露出来。2.1 HAL库 vs 标准库不同版本包在代码组织上的差异如果解压后看到的是FWlib风格文件夹那是标准库如果是Core/Inc、Core/Src加Drivers那是HAL库工程。两者没有绝对优劣但写法差别很大标准库直接操作寄存器底下的API例如GPIO_SetBits、Tim_Cmd代码执行直观HAL库用句柄初始化例如HAL_GPIO_WritePin、HAL_TIM_PeriodElapsedCallback。/* HAL库方式LED翻转 */ HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0); /* 标准库方式LED翻转 */ if (GPIO_ReadOutputDataBit(GPIOB, GPIO_PIN_0)) GPIO_ResetBits(GPIOB, GPIO_PIN_0); else GPIO_SetBits(GPIOB, GPIO_PIN_0);提示HAL库的代码可读性好但回调函数机制容易让人忽略中断发生时机标准库需要自己查寄存器手册但更能理解底层行为。收到“基于STM32的贪吃蛇游戏.zip”先看主文件里#include来源——引的是stm32f1xx_hal.h还是stm32f10x.h一眼就能分辨库版本。两种工程我都写过如果是日常DIY、想快速改出效果HAL库快得多如果要抠Flash占用率、追求极致响应标准库更直给。2.2 OLED还是LCD刷新策略左右代码结构贪吃蛇对屏幕要求不高但播放帧率高于一般显示场景。常见方案是0.96寸I2C OLEDSSD1306和2.4寸SPI TFT LCDST7789/ILI9341。两者在选择上差异极大。I2C OLED只有I2C时序与地址问题数据引脚只需SDA和SCL但全屏刷新要4帧缓冲80×30点I2C速率400kHz下帧率受限。SPI LCD有D/C、CS、RST、SCK、MOSI五根线全屏写像素很快但驱动代码长、显存占用是OLED的好几倍。/* SSD1306 I2C写命令/数据的基础函数 */ static void SSD1306_WriteCmd(uint8_t cmd) { HAL_I2C_Mem_Write(hi2c1, 0x3C 1, 0x00, 1, cmd, 1, 10); } static void SSD1306_WriteData(uint8_t data) { HAL_I2C_Mem_Write(hi2c1, 0x3C 1, 0x40, 1, data, 1, 10); }I2C的每个像素点都要走两次内存操作所以OLED方案必须做局部刷新——只更新蛇头和蛇尾两个格子而不是每次全部重绘。SPI LCD因为像素写入快可以承受全屏缓冲重绘代码反而简单。选择依据很简单如果想到手就能跑选I2C OLED如果想让画面流畅到“丝滑”选SPI LCD全屏刷新。2.3 按键接口独立引脚加外部中断还是轮询扫描贪吃蛇需要四个方向键加一个确认/暂停键。最简单的做法是每个按键接一个GPIO循环里读电平。但这样CPU一直跑在轮询上按键抖动和漏检都难处理。/* 轮询方式读取按键并做软件消抖 */ uint8_t Key_Scan(void) { static uint8_t key_up 1; if (key_up HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_0) GPIO_PIN_RESET) { HAL_Delay(10); // 10ms消抖 if (HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_0) GPIO_PIN_RESET) { key_up 0; return KEY_PRESSED; } } if (HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_0) GPIO_PIN_SET) key_up 1; return KEY_NONE; }这种消抖方式在贪吃蛇里会有个隐患游戏蛇移动周期可能设为150ms按键扫描插入在主循环中主循环被HAL_Delay卡住时按键响应会滞后。更好的方案是用外部中断EXTI触发方向更新中断里只置标志位不处理游戏逻辑。中断里存放方向变量主循环读取方向变量来决定蛇转向——这样就避免了中断和主循环同时改一条蛇的数据结构。3. 贪吃蛇核心逻辑地图、移动、碰撞、食物与状态机设计贪吃蛇行为很经典但MCU版和网页版逻辑差异很大。网页游戏可以用坐标数组和事件驱动的循环轻松实现但STM32上没有操作系统、没有浏览器引擎每帧计算必须轻、存储必须可控。这个章节把核心逻辑按状态机拆开分析。3.1 地图建模方阵数组还是带环绕的处理OLED 128×64分辨率下一个方格占8×8像素可用横16格×竖8格128/81664/88就是16×8的网格。TFT 240×320下格子可以设为12×12像素就是20×26格过大不好看。常见做法是地图用二维数组保存0表示空地非0表示蛇身编号#define MAP_W 16 #define MAP_H 8 uint8_t map[MAP_H][MAP_W]; // 存储蛇身编号0为空 uint8_t snake_len; uint8_t snake_dir; // 0上 1下 2左 3右 uint8_t head_x, head_y;这样的地图只需要16×8128字节的数组体量极小。但有个新问题蛇头移动后蛇身每个格子的编号要整体更新开销大。更优解是用一个固定长度数组存蛇坐标序列头部在索引0尾部在末尾。typedef struct { int16_t x; int16_t y; } Point; Point snake[MAX_SNAKE_LEN]; // 按顺序存蛇身head在[0]每次移动时从尾部向头部依次复制坐标snake[i]snake[i-1]更新头部位置即可。这样地图只用于绘制不用于逻辑判断。逻辑复杂度和数组版差不多但绘制效率更高——绘制只需要遍历snake数组不需要遍历地图。3.2 移动逻辑细节边界、自咬与转向相反这一小段是贪吃蛇翻车率最高的地方。方向管理上最容易出bug的是“反向移动判定”蛇向右走时按左键蛇应该无视这次输入。void SetDirection(uint8_t new_dir) { if ((new_dir DIR_LEFT snake_dir ! DIR_RIGHT) (new_dir DIR_RIGHT snake_dir ! DIR_LEFT) (new_dir DIR_UP snake_dir ! DIR_DOWN) (new_dir DIR_DOWN snake_dir ! DIR_UP)) { snake_dir new_dir; } }注意不要在主循环里简单接收键盘值后直接更新snake_dir否则蛇按下方向时可能穿过自己身体。正确做法是先用旧方向计算头部新位置再用新位置做碰撞检测。移动完成后首先要查边界如果head_x超出0到15、head_y超出0到7游戏结束。其次查自咬遍历蛇身数组不包括尾巴因为尾巴即将移动看新头部是否与已有蛇身重合。3.3 食物生成与吃食物增长生食物时要注意两点食物不能生成在蛇身上且要保证地图上至少有一个空地。因为蛇身是动态的每一帧扫描整个地图找空位是小开销最多128格但更高效的做法是维护一个“空地列表”每次生成时从列表里随机取一个。void GenerateFood(void) { uint8_t empty_cells[MAP_W * MAP_H]; uint8_t empty_count 0; for (uint8_t y 0; y MAP_H; y) { for (uint8_t x 0; x MAP_W; x) { if (!IsSnakeCell(x, y)) { empty_cells[empty_count] (uint8_t)(y * MAP_W x); } } } if (empty_count 0) { GameWin(); return; } uint8_t food_cell empty_cells[rand() % empty_count]; food_x food_cell % MAP_W; food_y food_cell / MAP_W; }吃食物的判断很简单新蛇头坐标等于食物坐标。吃下后先把食物位置设为新头部蛇尾不移动长度加一。核心点在rand()的实现标准库的rand在MCU上通常需要种子初期固定种子会导致每次开局食物位置相同。可以用HAL_GetTick()低字节做种子或者用一个LFSR线性反馈移位寄存器生成伪随机坐标。3.4 游戏状态机初始化、运行、暂停、结束有了逻辑还得有状态管理。STM32裸机环境没有多线程主循环里用枚举状态切GameState是最常见的做法。typedef enum { STATE_INIT, STATE_RUNNING, STATE_PAUSE, STATE_OVER } GameState; GameState state STATE_INIT; uint32_t last_step_time 0; uint16_t speed_delay_ms 200; while (1) { Key_Scan_Irq(); switch (state) { case STATE_RUNNING: if (HAL_GetTick() - last_step_time speed_delay_ms) { Snake_Step(); // 核心移动逻辑 last_step_time HAL_GetTick(); Display_Refresh(); // 只刷新变化格子 } break; case STATE_INIT: Display_Welcome(); break; case STATE_PAUSE: break; case STATE_OVER: break; } }状态机的好处是让每个状态的进入和退出非常清晰比如STATE_RUNNING里收到暂停键就切换至STATE_PAUSE并且保存当前蛇的位置阵型。STATE_INIT按任意键进入新一轮。这个结构也为后续增加难度递进吃到食物后缩短speed_delay_ms留了位置。4. 工程落地实操从零搭建STM32贪吃蛇工程的完整步骤理论说完了这一章真正动手。贪吃蛇工程从建CubeMX配置、写驱动到调参运行按步骤展开。推荐环境是STM32CubeIDE外部库集成简单也可以用Keil MDK。4.1 CubeMX工程配置引脚分配和时钟树关键参数新建STM32F103C8T6工程CubeMX里要分配以下外设外设引脚/通道说明I2C1PB8 (SCL), PB9 (SDA)OLED数据通信GPIOPA0-PA3四个方向按键上拉输入GPIOPB0暂停/确认键上拉输入TIM4内部时钟非必要用于延时或蜂鸣器USART1PA9, PA10调试打印用时钟树注意F103最高到72MHzHSE选8MHz外部晶振PLL倍频9倍得到72MHz系统时钟。APB1时钟36MHzAPB2时钟72MHz。I2C1挂在APB1上所以I2C外设时钟基准是36MHz。CubeMX自动计算I2C时序参数只要预设目标速率400kHz系统会自动算出寄存器值。注意F103的I2C兼容性坑比较多如果通讯不稳可以把速率降到100kHz。按键引脚上拉输入另一端接地——平时读为高按下为低。按键除了引脚还要打开GPIO EXTI外部中断NVIC里把EXTI4_15_IRQn优先级设为中低。设定优先级时避免和被Tick定时器抢占冲突否则触摸方向键可能丢事件。4.2 SSD1306 OLED驱动接入最小驱动函数集要在贪吃蛇里画方块屏幕驱动要提供最基础的几个函数OLED_FillRect(x, y, w, h, color)、OLED_Clear()、OLED_Refresh()。I2C OLED要对接层的写命令和写数据函数。void OLED_FillRect(uint8_t x, uint8_t y, uint8_t w, uint8_t h, uint8_t color) { for (uint8_t i 0; i w; i) { for (uint8_t j 0; j h; j) { // SSD1306按页(8像素)划分计算像素所在页与位 uint8_t page (y j) / 8; uint8_t bit (y j) % 8; uint8_t addr page * 128 (x i); if (color 1) { ssd1306_buffer[addr] | (0x01 bit); } else { ssd1306_buffer[addr] ~(0x01 bit); } } } }这里的ssd1306_buffer是1024字节128×64/8的全局显存。绘制时所有像素操作只改RAM缓冲最后统一调用OLED_Refresh()发送一帧。局部刷新要求控制器只更新变化区域这个函数是贪吃蛇实时刷新的核心。注意用这种方式画方框蛇移动时如果只更新新旧坐标格子刷新量在1~2字节刷新时间极短。4.3 核心工程代码骨架主程序与中断联动写主程序要把四个模块串起来方向键用中断游戏循环用状态机显示用缓冲刷新定时用HAL_GetTick()辅助。volatile uint8_t pending_dir 0xFF; // 中断里暂存方向 uint8_t current_dir DIR_RIGHT; // 主循环当前方向 void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin GPIO_PIN_0) pending_dir DIR_UP; else if (GPIO_Pin GPIO_PIN_1) pending_dir DIR_DOWN; else if (GPIO_Pin GPIO_PIN_2) pending_dir DIR_LEFT; else if (GPIO_Pin GPIO_PIN_3) pending_dir DIR_RIGHT; else if (GPIO_Pin GPIO_PIN_4) pending_dir DIR_PAUSE; } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); OLED_Init(); Game_Init(); while (1) { if (pending_dir ! 0xFF) { if (pending_dir DIR_PAUSE) { if (state STATE_RUNNING) state STATE_PAUSE; else if (state STATE_PAUSE) state STATE_RUNNING; } else { SetDirection_WithGuard(pending_dir); } pending_dir 0xFF; } Game_Loop(); } }pending_dir用volatile修饰的原因中断修改它主循环读它没有volatile很可能被编译器优化缓存到寄存器导致方向永远不变。带符号的int8_t会牵扯到数据竞争读写对齐问题但MCU内核不会产生撕裂写所以这个方案是安全的。按键动作要调用SetDirection_WithGuard而不是直接赋值current_dir就是为了防反向。4.4 编译与下载调试避免“error: no stm32 target found”这一小节是比较容易被卡住的硬骨头。第一次把工程烧到板子时常见报错是Error: No STM32 target found! If your product embeds debug authentication...。这个错误本质是ST-Link连不上目标MCU原因有四层# 查看开发板是否被系统识别Linux下 lsusb | grep -i stlink # 查看调试器是否支持当前模式 st-info --probe排查顺序确认St-Link驱动装好Win7优先旧版驱动确认接线SWDIO/SWCLK没有接反或虚接确认板子供电正常独立USB供电不依赖ST-Link如果板子进入低功耗模式或读保护按复位键的同时点击下载大概率能救回来。工程里如果开了ReadOut Protection先要解除读保护再连接否则就会出现debug authentication提示。另一种常见坑是Keil5兼容C51和STM32安装时只装了C51的芯片包导致工程报找不到F103芯片。解决方式是在Pack Installer里安装Keil.STM32F1xx_DFP芯片支持包或者直接用STM32CubeIDE零配置自动识别。4.5 关键参数调优与实机验证参数推荐值调节依据初始蛇速200ms/帧过快新手跟不上过慢无聊每吃一个食物加速-10ms下限100ms低于100ms手感僵硬按键消抖时间20ms外部中断无效10ms即可机械按键弹跳时间约5~20msI2C刷新模式局部刷新全屏刷新帧率低于25fps会闪烁验证标准简单直接按下方向键到蛇转向的延迟小于一帧周期200ms画面不闪烁蛇吃到食物后身体不跳跃、不出现重叠格暂停恢复后蛇身与暂停位置一致蛇撞墙和撞自己都稳定进入GAME OVER界面。用串口打印Game_Log把每次移动后的蛇头/尾坐标打出来比对可以快速定位碰撞逻辑的边界边界bug。5. 从裸机过渡到进阶中断优先级、DMA与RTOS移植的优化点这个项目做完别急着删代码。把贪吃蛇当成一个嵌入式综合环境能延伸出三个进阶方向实时性优化、显示刷新带宽优化、以及向带系统的工程演进。5.1 中断优先级配置的隐藏坑按键与定时器的抢占F103的NVIC支持4级抢占子优先级以2位2位为例。按键外部中断如果优先级高于SysTick那么主循环里HAL_GetTick()在高频按键中断下会滞后游戏节奏时间差变大如果低于SysTick按键又可能被定时打断导致漏触发。经验做法按键EXTI设为抢占优先级2子优先级0SysTick设为抢占优先级3子优先级0最低。这样按键打断SysTick不积累延迟而Tick被打断一下也无碍。中断里绝对不调用OLED_Refresh或任何包含I2C传输的函数I2C是阻塞式协议会拖挂整个中断流程。5.2 SPI TFT方向用DMA刷屏摆脱CPU占满0.96寸OLED因为数据量小I2C一帧最多1024字节实际上刷屏开销没那么大。但如果换到SPI TFT320×240。整屏一帧是320×240×2153600字节CPU刷屏占用会非常恐怖。void TFT_FillRect_DMA(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { uint16_t pixels w * h; uint16_t *buf (uint16_t *)malloc(pixels * 2); for (uint16_t i 0; i pixels; i) buf[i] color; HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)buf, pixels * 2); }大部分驱动库还停留在阻塞传数据CPU从头到尾都在等待。用DMA代替阻塞发送前提是DMA缓冲区生命周期要在传输结束前保持有效。贪吃蛇这类场景只要传入一个静态数组发完用回调函数置标志位就能腾出CPU去做碰撞检测画面响应和键手感双双受益。5.3 代码抽象把游戏逻辑与硬件驱动解耦RTOS移植变为可能裸机版本最大的学习价值在于把逻辑层和驱动层分开。蛇的移动、食物生成、碰撞检测不关心屏幕是I2C还是SPI不关心按键是EXTI还是轮询所以这些函数可以写成纯C模块。移植到FreeRTOS时可以用一个任务跑游戏逻辑另一个任务跑UI刷新任务间用消息队列传按键方向。// 逻辑层接口与硬件无关 void Game_Init(void); void Game_Step(void); uint8_t Game_IsOver(void); // 驱动层接口可替换 void Drv_Display_Clean(void); void Drv_Display_Pixel(uint8_t x, uint8_t y, uint8_t color); void Drv_Key_RegisterCallback(void (*cb)(uint8_t dir));如果前期就按这个接口分层写后面换屏、换处理器、加RTOS进场都很顺。反之如果把SSD1306的操作直接写进Game_Step每换一种屏就要翻整个游戏逻辑这是很多毕业设计越改越乱的根本原因。5.4 一个可快速验证的边界测试技巧给蛇加“自动寻路”这个技巧既好玩又能验证逻辑正确性。写一个测试宏让蛇按固定路线自动走比如循环右转到达地图边界然后下转回来每步用断言检查蛇头和蛇身坐标是否重叠、是否越界。能跑满一个完整闭合路线不崩溃逻辑冲突的概率基本归零。之后再接入真实按键输入问题就只会出现在输入链路不会出现在游戏逻辑里。本文还有配套的精品资源点击获取