
简介基于STM32库函数版本实现的贪吃蛇项目面向嵌入式入门学习者适合通过完整游戏代码理解微控制器外设控制、中断服务与OLED显示的综合运用也可作为课程设计或个人练手的参考资料。项目采用库函数开发方式覆盖从启动文件配置、系统时钟初始化到游戏界面呈现的完整链路帮助读者快速建立工程化开发思维。资源包共112个文件压缩后仅1.75MB包含22个头文件、19个C源码文件、4个启动汇编文件、2个HEX固件以及Keil工程配置文件头文件与C源文件承载核心游戏逻辑启动汇编文件完成MCU初始化HEX固件可直接烧录验证工程文件便于二次编译。已有647人学习实践参考价值获得开发者认可。项目亮点在于OLED驱动的拓展实现以及蛇身移动、食物随机生成、方向按键控制等游戏逻辑的落地涉及GPIO、SPI/I2C、定时器、中断机制、按键扫描等STM32关键知识点同时附带delay、sys、rtc等基础模块方便系统阅读和二次开发。 最近把一个用 STM32 标准外设库写的贪吃蛇小项目整理成了 zip 包文件名就叫“STM32贪吃蛇项目-库函数版本.zip”。项目不大但该有的东西都有GPIO 配置、OLED 显示、按键外部中断、定时器中断、游戏状态机、环形队列存蛇身、随机食物生成一套链路完整跑通。如果你刚开始学 STM32想找一个“看得见、玩得动”的练手项目这个贪吃蛇很合适。它不像跑马灯那样只有点灯也不像四轴那种复杂到劝退刚好卡在能把基础外设串起来的难度上。我把库函数版本作为主推就是想避开寄存器版本那种“对着数据手册画寄存器”的劝退节奏。标准外设库有结构体、有初始化函数你看得懂每一步在干嘛也能顺手翻手册验证。这篇博文就当是我带你把这个 zip 内部的工程从头到尾拆一遍从模块设计、关键代码到常见坑一次说透。1. 项目整体设计与思路拆解1.1 为什么选了库函数版本而不是寄存器或 HAL我最早学 STM32 的时候也纠结过这个问题后来发现择库函数版本有几个非常现实的好处。寄存器版本操作感强但代码可读性太差。比如你点一个 GPIO要写GPIOB-CRL ...如果不是对着 Reference Manual 看新手根本不知道哪个位控制哪个模式。而标准外设库封装了一层GPIO_InitTypeDef把引脚、模式、速度都变成了结构体字段你只要照着手册填就行GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure);这段代码填完基本就能猜到它是在把 PB0 配置成推挽输出、频率 50MHz。就算不记得打开 ST 官方库函数手册一搜GPIO_Init里面的参数解释和寄存器映射关系写得明明白白这就是库函数版最适合学习的原因。HAL 库也不是不行但它把中断回调、句柄、超时判断都抽象得太厚新手很容易被一堆__HAL_xxx宏绕晕。库函数版本虽然老但不代表没用反而在大量教学项目、开源小车上沉淀了很多现成代码遇到问题一搜一大把照着改就完事了。1.2 项目模块划分和状态机设计这个贪吃蛇的思路不是把所有代码堆在main.c里而是按功能拆成几个独立模块文件职责main.c系统初始化、游戏主循环delay.c延时函数封装SysTick 实现oled.cSSD1306 OLED 驱动显示游戏画面key.c按键扫描/外部中断处理timer.c定时器初始化提供游戏节奏中断game.c贪吃蛇核心逻辑移动、食物、碰撞、分数游戏逻辑本身就一个典型的状态机。我用枚举定义了四个状态typedef enum { GAME_IDLE, GAME_RUNNING, GAME_PAUSE, GAME_OVER } GameState;main.c里的主循环只在GAME_RUNNING状态下把定时器里置的刷新标志消费掉然后调用Game_Update()更新蛇的位置。其它状态下就只处理按键不做移动计算。这个状态机看着简单但避免了“游戏结束了蛇还在跑”之类的逻辑混乱后面加菜单、加暂停、加加速都是在这个基础上扩展。2. 核心细节解析与实操要点2.1 显示方案选择OLED、LCD 还是 LED 点阵贪吃蛇一定要有实时画面反馈。我见过有人用 8x8 LED 点阵搭配 MAX7219 做能玩但画面太粗糙而且显示不了分数和提示信息。做这个项目我更推荐 0.96 寸 OLEDSSD1306 方案成熟驱动代码满天飞分辨率 128x64 足够画一个 14x6 的格子地图。我手里的这块 OLED 是 I2C 接口四根线VCC、GND、SCL、SDA。我直接用软件模拟 I2C只要两个 GPIO不占用硬件 I2C 外设移植性也更好。把 OLED 的内存当成一个 128x64 的位图绘制游戏区域时我按 8x8 像素划分成格子蛇头和蛇身用实心方块表示食物用一个 4x4 的小点。这里有个关键细节OLED 的 I2C 通信速度不快特别是全屏刷的时候所以我不会在中断里调用任何 OLED 绘图函数而是在主循环里统一刷新。这也是很多新手写贪吃蛇画面卡顿的主要原因——把耗时操作塞进了定时器中断。2.2 按键输入外部中断比轮询更顺手按键这里有两种做法主循环轮询 GPIO或者用外部中断 EXT1。轮询简单但主循环一旦被延时卡住就容易丢按键用外部中断则在按下瞬间置标志位主循环空了再处理体感好很多。我在 F103 上用了三个按键方向“左”“右”“下一键”外加一个复位键。方向键用外部中断复位键直接接在单片机 NRST 引脚上按下就硬件复位省事。外部中断配置的关键是NVIC和EXTI的绑定。例如 PA0、PA1、PA2 分别接三个方向键那么需要配置三条 EXTI 线。代码片段EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); EXTI_InitStructure.EXTI_Line EXTI_Line0; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; NVIC_InitStructure.NVIC_IRQPreemptionPriority 2; NVIC_InitStructure.NVIC_SubPriority 2; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure);需要注意的是PA0、PB0、PC0 这些引脚共用同一个EXTI0_IRQn你只能让其中一个引脚作为 EXTI0 的中断源不能同时用。如果想让多路按键都进 EXTI引脚号必须不一样这是我踩过的坑刚开始把两个按键放在同一个编号的 GPIO 上结果只有一个能触发。2.3 定时器与游戏节奏控制贪吃蛇的移动靠定时器中断驱动不是 CPU 死循环。我用 TIM2 产生一个固定节拍比如 10ms 中断一次然后在中断里累加计数器每 5 次就置一个Game_Need_Update 1代表 50ms 移动一格。这样调节速度只需要改计数器阈值不用动定时器配置。先看一下 TIM2 初始化TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Prescaler 7199; // 72MHz / 7200 10kHz TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 99; // 10kHz / 100 100Hz即10ms TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE);这里的计算逻辑STM32F103 默认外部 8MHz 晶振PLL 倍频到系统时钟 72MHz。APB1 总线时钟通常分频后是 36MHz但 TIM2 挂在这个总线时又有 2 倍频实际定时器时钟按 72MHz 算。Prescaler7199就是 7200 分频计数频率变成72M / 7200 10kHz再设置Period99每隔 100 个计数脉冲产生一次更新中断中断频率10kHz / 100 100Hz。中断服务函数名必须写对不能随便起void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) SET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); tick; if (tick 5) { tick 0; Game_RequestUpdate(); // 只是置标志位 } } }2.4 蛇的数据结构与移动算法贪吃蛇最核心的地方就是蛇身怎么存、怎么移动。我第一版用数组存整个蛇身坐标每次移动都把后面所有节往前拷贝复杂度高不说还容易出错。后来换成了环形队列一下子清爽很多。具体思路用两个固定长度数组snake_x[]和snake_y[]存每节坐标再用headIndex和tailIndex两个索引分别指向队头和队尾。移动时新蛇头坐标算出来后放到headIndex的下一个位置同时如果没吃到食物tailIndex也要往前走一步。这本质上就是“先进先出”的队列只是头和尾在一个环上转。关键代码大概这样void Game_Move(void) { uint8_t old_tail_x snake_x[tailIndex]; uint8_t old_tail_y snake_y[tailIndex]; // 根据方向更新蛇头坐标 snake_head_x dir_x; snake_head_y dir_y; // 碰撞检测 if (Game_CheckHit()) { Game_State GAME_OVER; return; } // 吃到食物 if (snake_head_x food_x snake_head_y food_y) { snake_len; Food_Generate(); } else { // 没吃到尾部收缩等价于把旧尾覆盖掉 tailIndex (tailIndex 1) % MAX_SNAKE_LEN; } headIndex (headIndex 1) % MAX_SNAKE_LEN; snake_x[headIndex] snake_head_x; snake_y[headIndex] snake_head_y; // 在 OLED 上只更新 head 和 tail 两处 OLED_DrawBlock(snake_head_x, snake_head_y, 1); if (!(snake_head_x old_tail_x snake_head_y old_tail_y)) { OLED_DrawBlock(old_tail_x, old_tail_y, 0); } }食物生成也要注意不能让食物出现在蛇身上void Food_Generate(void) { do { food_x rand() % GRID_WIDTH; food_y rand() % GRID_HEIGHT; } while (Game_IsSnakeNode(food_x, food_y)); }rand()默认每次上电取值都一样所以要在初始化时种下随机种子我直接拿 TIM2 运行后的计数器值做种子srand(TIM_GetCounter(TIM2) 1);等效于让食物第一次出现的位置不可预测。3. 实操过程与核心环节实现3.1 搭建标准库工程这一块别偷懒这个 zip 我发布的时候会把Project目录整体带上但你还是需要自己会搭标准库工程以后换芯片能少吃很多苦。先说大框架我用的是标准外设库STM32F10x_StdPeriph_Lib_V3.5.0Keil5 打开工程后目录组是这样USER/ main.c, stm32f10x_it.c, system_stm32f10x.c CMSIS/ core_cm3.c/h, startup_stm32f10x_md.s FWLib/ stm32f10x_gpio.c/h, stm32f10x_tim.c/h, ... HARDWARE/ oled.c, key.c, timer.c, game.c用 Keil5 新建标准库工程时有几个地方很容易漏第一魔术棒 C/C 选项卡里必须定义USE_STDPERIPH_DRIVER和STM32F10X_MD。USE_STDPERIPH_DRIVER是让外设库函数能编译STM32F10X_MD表示芯片是中等容量比如 STM32F103C8T6。如果漏了宏编译时函数声明都对不上。第二Include Path 要加全CMSIS、FWLib/inc、HARDWARE、USER都要加少一个就报xxx.h not found。第三启动文件选startup_stm32f10x_md.s。如果选成hd高容量也能编译但在 C8T6 上跑起来偶尔会莫名其妙复位因为启动文件里的堆栈和向量布局不完全匹配。工程建好后可以先用一个 LED 点灯测试确认 GPIO 和延时没问题再往里面加 OLED 和游戏逻辑。千万不要一步到位出了 bug 都不好定位是硬件还是软件。3.2 除了代码本身还有几个“看不见”的关键环节编译通过只是开始真正把程序烧进板子还要注意启动模式。STM32 的 BOOT0/BOOT1 引脚决定从哪启动正常从 Flash 启动就是 BOOT00、BOOT1 任意大多数板子默认就是这个。如果用 ST-Link 下载时一直提示No Target Connected先把 BOOT0 拉高给板子断电再上电用 ST-Link Utility 全片擦除然后恢复 BOOT00重新下载。这一步能救回很多“假砖”。烧录软件我用过 ST-Link Utility也直接在 Keil5 里点 Download。Keil 里配置 Debug 选择 ST-Link Debugger然后确认 Flash Download 勾选了Reset and Run不然下载完程序不会自动跑还要手动按复位键。在代码里我设置系统时钟用的是库函数自带的SystemInit()默认从外部 8MHz 晶振启动。如果你的板子上没有外部晶振或者想强制用内部 RC 振荡器标准外设库用户一般这样改在system_stm32f10x.c里把SYSCLK_FREQ_72MHz宏注释掉再开启SYSCLK_FREQ_36MHz其实最简单的办法是直接用 HSI 并把 PLL 倍频调低不然系统时钟会卡在等待 HSE 就绪上导致全工程延时变成“死等”。这个问题很典型出现“延时函数 delay 卡死”的人八成都是卡在这。3.3 编译烧录之后怎么验证游戏状态机下载完程序如果 OLED 正常显示按“下一键”进入游戏用左/右键控制蛇头转向吃到食物蛇身变长撞墙或撞自己进入 GAME_OVER。这里我给你一个验证思路先不急着玩用 Keil 硬件仿真或 Debug 模式下打断点看看按键按下后dir_x/dir_y是否变化定时器中断里tick是否累加。我调试时会打开 Keil 的 Watch 窗口把snake_head_x、snake_head_y、snake_len、Game_State加进去。只要蛇头坐标跟着按键变化说明 GPIO、定时器、外部中断都通了。之后再去管显示细节。4. 常见问题与排查技巧实录4.1 屏幕不亮、显示乱码或只有一半像素先查 OLED 的 I2C 地址常见 SSD1306 是0x78或0x7A。如果初始化后屏幕全暗用逻辑分析仪看 SDA/SCL 有没有波形没有波形说明 GPIO 配置错了或 SDA/SCL 引脚接反了。还有一种情况是初始化时序不对SSD1306 上电后必须延时一段时间再发送初始化命令我习惯在OLED_Init()开头加 100ms 以上延时。如果显示乱码检查是不是用了 SPI 接口的 OLED 却按 I2C 时序发数据。这个项目 zip 里我默认给的是 I2C 版驱动如果你用的是四线 SPI需要在oled.h里改底层发送函数把I2C_WriteByte换成SPI_WriteByte。4.2 按键忽然失灵或者一次按下触发多次移动这是外部中断最常见的两个问题。第一个是触发沿抖动。机械按键按下瞬间会连续跳变几次如果不做处理一次按键可能触发很多次中断蛇会连续转向甚至反向。我的做法是在中断里只记录“哪个方向有请求”主循环里每次只处理一个方向请求并且处理完就清掉这样天然过滤掉快速抖动。再加上在EXTI中断服务函数里建议不要调用delay_ms因为 SysTick 优先级可能比较低中断嵌套会卡死系统。第二个是共用中断线的问题。前面说过PA0 和 PB0 物理上不同但中断号都映射到EXTI0_IRQn如果你两个按键接到这两个引脚需要在一个中断服务函数里同时判断两个 EXTI Line 的挂起位不能只写一个。所以我的方案是干脆把三个按键接到 PA0、PA1、PA2每个按键独立中断号清爽得很。4.3 蛇移动有拖影、画面闪烁、OLED 刷新不过来屏幕闪烁大概率和全屏刷新的策略有关。如果每次移动都调用OLED_Clear()再全屏重绘OLED 的像素会有明显的残影感。我的做法是维护一个游戏区域的二维数组映射game_map[x][y]为 1 表示有蛇身或食物为 0 表示空白。移动时只用新蛇头坐标去置位对应块旧蛇尾坐标去清除对应块然后只把这两个格子同步给 OLED。这样不但闪烁问题没了I2C 上的数据量也小了很多。另外用 I2C 时尽量把速度配到 400kHz如果是模拟 I2C 就要让延时短一点但也不要过短导致信号不稳定。实测稳定就好。4.4 编译报一堆莫名其妙的错多半是工程配置问题这个项目如果用库函数版本最常见的编译报错包括报错现象常见原因解决方法stm32f10x.h: No such file or directoryInclude Path 没加对把CMSIS、FWLib/inc都加进去identifier GPIOA is undefined忘了包含stm32f10x.h或没有定义 USE_STDPERIPH_DRIVER确保预处理宏已定义TIM2_IRQnundefined没包含stm32f10x.h或芯片宏不对预定义STM32F10X_MD链接时报fputc相关错误用 printf 但没重定向在串口发送中重定向fputc或勾选 MicroLIB如果工程里只用了库函数但没定义USE_STDPERIPH_DRIVER外设库函数源码里的函数体是空的或者没有被包含程序可以编译但调用不到实际功能表面看就是“代码没问题但外设不工作”。5. 这个项目还能怎么继续扩展现在这个贪吃蛇只是单机简单版但你已经把几个基础外设打通了后续扩展空间很大。比如加一个按键加速每吃 5 个食物把定时器中断里的threshold从 5 改成 4、3、2速度一阶阶提上去游戏没多久就会变得很刺激。再加一个 OLED 显示分数和最高分分数可以退出前保存到 Flash或者把最高分写在内部备份寄存器里。再把方向控制改成摇杆或编码器手感又会不一样。如果你想往通信方向走还可以把这条蛇做成双人对抗两块板子通过串口或 SPI 互传各自蛇头的位置谁先撞到对方谁输。库函数版本的 SPI、串口代码都是现成的网上也有大量参考。我觉得学嵌入式最重要的不是看懂多少例程而是自己做一个小项目把串起来之后再往里面加东西心里就有底了。我在写这个贪吃蛇的过程中踩得最多的坑反而是那些看起来最不起眼的地方宏定义漏了、启动文件选错、OLED 地址不对、中断服务函数名拼错。每次卡住的时候打开库函数手册对照一下参数描述或者用 Keil 单步走一遍初始化代码问题基本都能浮现出来。最后分享一个我自己常用的调试习惯在 OLED 上把蛇头的坐标直接画出来同时串口打印一份或者在 OLED 上临时显示snake_len。这样你玩游戏的时候随时能看到游戏状态有没有异常比盲调快得多。这个 zip 包里已经带上了完整的工程和模块说明下载后按我上面说的搭好环境直接烧进去就能跑。如果编译不通照着“常见问题”那张表逐项排查大多数问题十分钟内就能解决。本文还有配套的精品资源点击获取