☰
STM32标准库贪吃蛇实战:GPIO、OLED与定时器中断全解析
2026/10/8 19:06:50 网站建设 项目流程

简介:基于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 寸 OLED,SSD1306 方案成熟,驱动代码满天飞,分辨率 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 算。Prescaler=7199就是 7200 分频,计数频率变成72M / 7200 = 10kHz,再设置Period=99,每隔 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.0,Keil5 打开工程后目录组是这样:

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 启动就是 BOOT0=0、BOOT1 任意,大多数板子默认就是这个。如果用 ST-Link 下载时一直提示No Target Connected,先把 BOOT0 拉高,给板子断电再上电,用 ST-Link Utility 全片擦除,然后恢复 BOOT0=0,重新下载。这一步能救回很多“假砖”。

烧录软件我用过 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 包里已经带上了完整的工程和模块说明,下载后按我上面说的搭好环境,直接烧进去就能跑。如果编译不通,照着“常见问题”那张表逐项排查,大多数问题十分钟内就能解决。

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

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

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

立即咨询