☰
STM32F407贪吃蛇实战:定时器中断与状态机驱动LCD小系统
2026/10/26 18:42:52 网站建设 项目流程

简介:这是一份面向嵌入式初学者与中级开发者的 STM32F407 贪吃蛇游戏工程,适合用作 Cortex-M4 平台外设编程与实时逻辑的练手项目。工程将经典贪吃蛇移植到 MCU,覆盖 GPIO 驱动显示、定时器中断控制帧率、按键扫描与中断响应,以及蛇身移动、碰撞检测、食物随机生成和游戏状态保存等核心环节,可帮助读者理解中断服务例程与前后台程序的组织方式。压缩包共 159 个文件,以 75 个 c 源文件和 77 个 h 头文件为主体,另含 Keil 工程文件、启动汇编、批处理脚本和可直接烧录的 hex,整体约 831KB,目录与工程配置较完整,便于编译验证和二次修改。已有 2820 人学习,读者可从中参考外设初始化、内存管理、帧率调节与调试排错思路,并对照源码完成功能扩展,也可作为课程设计、毕业设计或自学复盘的参考案例。

1. 从点灯到能玩:STM32F407 贪吃蛇程序到底补了哪块短板

手头有一块 STM32F407 开发板,屏幕和按键都接好了,程序却一直停在点灯和串口打印?STM32F407 贪吃蛇这个程序包,补的正是从“单个外设能跑”到“一个小系统能完整跑”的断层。它把 LCD 刷屏、按键扫描、定时器节拍、随机数、分数存储串成闭环,不是演示单个 API 的例程。适合已经会 C 语言、能看懂 GPIO 初始化,但没做过状态机项目的人。反直觉的是,贪吃蛇最难的不是蛇怎么走,而是刷新节拍和输入采样怎么不打架;程序里用定时器中断推进游戏、主循环只负责画,先把这两件事拆开,后面的碰撞检测和速度分级才有意义。下面按硬件骨架、游戏逻辑、显示刷新、避坑和进阶存储逐层拆。

2. 硬件骨架与工程初始化:引脚表、LCD 和 1 ms 节拍怎么定

2.1 先定引脚和总线,别让 LCD 和按键抢资源

STM32F407 的 GPIO 复用很灵活,灵活带来的问题是新手容易把 SPI 的 CS、DC 和按键挤到同一个端口,后面改一处牵动全身。我一般先把资源分成三类:显示总线、人机输入、节拍与存储。显示用 SPI 接 ILI9341 这类 240×320 的 TFT 屏,输入用 4 个独立按键或一个五向摇杆,节拍用通用定时器,最高分放内部 Flash 的最后一个扇区。下面这张表是一个可跑的示例分配,不是唯一答案,读者按自己板子改宏定义即可。

功能外设/引脚示例参数说明
LCD_SCKSPI1_SCK PA59~18 MHz先低后高,花屏再降
LCD_MOSISPI1_MOSI PA78 位、主机只写不读,MISO 可省
LCD_CSPB6推挽输出低有效
LCD_DCPB7推挽输出命令/数据切换
LCD_RSTPB8推挽输出低复位,至少 10 ms
LCD_BLPB9推挽输出背光控制,可常亮
KEY_UP/DOWN/LEFT/RIGHTPE0~PE3上拉输入按下为低
GAME_TIMERTIM31 ms 更新中断只置步进标志
RNG硬件 RNG需要开时钟食物随机
SCORE_FLASH最后扇区按芯片手册查地址保存最高分

引脚定完以后,先写一个board_config.h,把屏幕尺寸、格子大小、引脚和定时器句柄全部收口。后面换板子只改这个文件,不要从main.c里翻引脚。屏幕是竖屏 240×320,格子用 16×16,横向 15 格、纵向 20 格,底部留 20 像素给分数和状态提示,这样算下来游戏区正好 15×20,不会出现半格。SPI 初始分频先按 18 MHz 配,屏幕排线长、杜邦线飞线多的时候直接降到 9 MHz,先保证稳定,再谈帧率。

/* board_config.h:换板子只改这里 */ #define LCD_SPI_HANDLE hspi1 #define LCD_CS_PORT GPIOB #define LCD_CS_PIN GPIO_PIN_6 #define LCD_DC_PORT GPIOB #define LCD_DC_PIN GPIO_PIN_7 #define LCD_RST_PORT GPIOB #define LCD_RST_PIN GPIO_PIN_8 #define KEY_UP_PORT GPIOE #define KEY_UP_PIN GPIO_PIN_0 #define KEY_DOWN_PIN GPIO_PIN_1 #define KEY_LEFT_PIN GPIO_PIN_2 #define KEY_RIGHT_PIN GPIO_PIN_3 #define CELL_W 16 #define CELL_H 16 #define GRID_COLS 15 #define GRID_ROWS 20 #define ORIGIN_X 0 #define ORIGIN_Y 0

这段宏定义把“硬件差异”和“游戏逻辑”隔开,CELL_W、CELL_H决定像素换算,GRID_COLS、GRID_ROWS决定碰撞边界。参数上最容易翻车的是ORIGIN_X/Y,屏幕有边框或者游戏区要居中时,像素坐标必须加偏移,不能只改格子坐标。按键配置成上拉输入,按下读低电平;如果板子已经焊了下拉电阻,就把按键有效电平反过来,否则一上电蛇就自己走。

2.2 用 1 ms 定时器中断推进节拍,不在主循环里 delay

贪吃蛇的步进节奏必须稳定,不能靠HAL_Delay(180)这种阻塞延时。主循环里一旦有延时,按键扫描会被拖慢,屏幕刷新也会卡。常见做法是开一个 1 ms 定时器中断,中断里只累加毫秒计数、按速度阈值置一个g_step_flag,真正的蛇移动放在主循环里消费这个标志。这样中断很短,不会和 SPI 刷屏抢时间,速度等级也只需要改一个毫秒变量。

/* TIM3:84 MHz 定时器时钟,PSC=83,ARR=999,得到 1 kHz 更新中断 */ htim3.Instance = TIM3; htim3.Init.Prescaler = 83; htim3.Init.Period = 999; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim3); HAL_TIM_Base_Start_IT(&htim3); volatile uint32_t g_ms = 0; volatile uint8_t g_step_flag = 0; volatile uint16_t g_speed_ms = 180; /* 初始 180 ms 走一步 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { g_ms++; static uint16_t acc = 0; acc++; if (acc >= g_speed_ms) { acc = 0; g_step_flag = 1; /* 只置标志,绝不在这里刷屏 */ } } }

分频计算是84 MHz / (83 + 1) = 1 MHz,再1 MHz / (999 + 1) = 1 kHz,所以每 1 ms 进一次中断。g_speed_ms初始 180,分数每涨 50 减 10,最低降到 80 左右,再低按键采样和画面刷新都会吃力。中断回调里不要调用 LCD 函数,也不要用HAL_Delay,SPI 刷屏一次可能几百微秒到几毫秒,放在中断里会把整个系统拖乱。

注意:TIM3 的 PSC 是按 84 MHz 时钟算的,换时钟树后必须重算。APB1 分频不是 2 的时候,定时器时钟会变,表现就是蛇一步快一步慢。

2.3 按键扫描要状态机,不要 while 等释放

按键是贪吃蛇里第二容易出问题的地方。用HAL_Delay(20)消抖、用while等按键释放,都会让主循环卡住,表现是画面一顿一顿,或者按一下方向键蛇连转两次。我一般把按键做成 10 ms 扫描的状态机:记录原始电平、稳定电平、计数器和重复计数,只有稳定后的下降沿才返回一次“按下事件”。贪吃蛇的方向键不建议做长按连发,一次按下只认一个方向,避免快速连按导致反向自杀。

typedef struct { uint8_t stable; /* 消抖后的稳定状态 */ uint8_t last; /* 上一次原始电平 */ uint16_t cnt; /* 消抖计数 */ } Key_t; Key_t key_up; uint8_t Key_Scan(GPIO_TypeDef *port, uint16_t pin, Key_t *k) { uint8_t raw = (HAL_GPIO_ReadPin(port, pin) == GPIO_PIN_RESET) ? 1 : 0; if (raw != k->last) { k->last = raw; k->cnt = 0; return 0; } if (k->cnt < 10) { /* 10 ms 消抖窗口 */ k->cnt++; return 0; } if (raw && !k->stable) { /* 稳定按下,产生一次事件 */ k->stable = 1; return 1; } if (!raw) { k->stable = 0; } return 0; }

Key_Scan每 1 ms 或 10 ms 调一次,返回 1 表示一次有效按下。参数cnt < 10对应 10 ms 消抖,机械按键一般够用;如果按键弹簧软、抖动长,加到 20 ms,但不要超过 30 ms,否则快速转向会丢。四个方向键分别用四个Key_t实例,主循环里把返回值翻译成Game_RequestDir(),不要直接在按键函数里改蛇的方向。

2.4 初始化顺序和时钟:先屏后节拍,先 GPIO 后中断

初始化顺序不对,会出现“屏幕还没初始化,定时器中断已经进来要求刷屏”的怪事。我习惯按下面顺序走:先开 GPIO、SPI、TIM3、RNG 的时钟;再配 SPI 复用引脚、CS/DC/RST 推挽、按键上拉、背光输出;然后复位 LCD,延时足够后发初始化序列;接着初始化游戏状态和蛇身数组;最后才启动定时器中断。RNG 初始化完先读几次丢弃,避免上电前几个值偏差。

int main(void) { HAL_Init(); SystemClock_Config(); /* 确认 TIM3 时钟为 84 MHz */ MX_GPIO_Init(); MX_SPI1_Init(); LCD_Init(); LCD_Clear(BLACK); Game_Init(); /* 蛇身、食物、分数、状态机 */ MX_TIM3_Init(); MX_RNG_Init(); HAL_TIM_Base_Start_IT(&htim3); while (1) { KeyTask(); /* 10 ms 扫描一次即可 */ if (g_step_flag) { g_step_flag = 0; Game_Step(); /* 只有这里推进蛇 */ } Game_DrawDirty(); /* 只画脏格子 */ } }

主循环里没有HAL_Delay,节奏靠g_step_flag和脏矩形控制。SystemClock_Config里要确认 APB1 定时器时钟,如果用的是 168 MHz 主频、APB1 分频 4,TIM3 时钟可能是 42 MHz,PSC 要改成 41。参数上,SPI 先 18 MHz,LCD 初始化序列里的延时不能省,复位拉低 10 ms、拉高后等 120 ms 再发命令,这些延时是屏幕内部电源稳定的时间,不是玄学。

3. 蛇身数组与状态机:从方向锁到碰撞检测的四个判定

3.1 蛇身用环形数组,头尾指针比链表稳

贪吃蛇的蛇身最适合用环形数组,不用链表。链表每个节点动态申请内存,在裸机小内存里容易碎片化,调试时也不直观;环形数组固定长度,头指针入队、尾指针出队,吃食物时只入队不出队,逻辑很干净。F407 的 RAM 够用,但也没必要一上来就开 300 格,先给 64 格,够玩到很高的分数;真要满屏,再把MAX_SNAKE改成 300,同时检查栈和 LCD 缓冲的余量。

#define MAX_SNAKE 64 typedef struct { uint8_t x[MAX_SNAKE]; uint8_t y[MAX_SNAKE]; uint8_t head; /* 当前蛇头下标 */ uint8_t tail; /* 当前蛇尾下标 */ uint8_t len; /* 当前长度 */ } Snake_t; static Snake_t snake; void Snake_Reset(void) { snake.len = 3; snake.head = 0; snake.tail = 2; snake.x[0] = 7; snake.y[0] = 10; /* 头 */ snake.x[1] = 7; snake.y[1] = 11; snake.x[2] = 7; snake.y[2] = 12; /* 尾 */ } /* 新头入队;grow=1 表示吃到食物,尾巴不动 */ uint8_t Snake_PushHead(uint8_t nx, uint8_t ny, uint8_t grow) { uint8_t nh = (snake.head + 1) % MAX_SNAKE; if (nh == snake.tail && !grow) { return 0; /* 环形数组满,理论不会到 */ } snake.head = nh; snake.x[nh] = nx; snake.y[nh] = ny; snake.len++; if (!grow) { snake.tail = (snake.tail + 1) % MAX_SNAKE; snake.len--; } return 1; }

这段代码的关键是head和tail的语义:head是当前蛇头所在下标,新头写到(head + 1) % MAX_SNAKE,然后把head移过去;不吃食物时tail后移一位,相当于删掉旧尾巴。MAX_SNAKE取 64 时,蛇最长 63 节左右,实际游戏很难到;如果改成 300,要保证x、y两个数组加起来 600 字节,对 F407 不算大,但别在中断里做整数组拷贝。

3.2 方向输入要加反向锁,一次只认一个有效转向

方向处理最容易写错的一步是“按键按下就直接改方向”。如果一步之内先按右再按上,蛇头可能在同一毫秒里转两次,反向判断失效,直接撞到自己。正确做法是维护g_dir和g_next_dir两个变量:按键只写g_next_dir,并且用当前g_dir做反向锁;定时步进真正执行时,再把g_next_dir赋给g_dir。这样一步只认一个方向,快速连按也不会翻车。

typedef enum { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT } Dir_t; static Dir_t g_dir = DIR_UP; static Dir_t g_next_dir = DIR_UP; static uint8_t Dir_IsOpposite(Dir_t a, Dir_t b) { return (a == DIR_UP && b == DIR_DOWN) || (a == DIR_DOWN && b == DIR_UP) || (a == DIR_LEFT && b == DIR_RIGHT) || (a == DIR_RIGHT && b == DIR_LEFT); } void Game_RequestDir(Dir_t d) { if (!Dir_IsOpposite(d, g_dir)) { g_next_dir = d; } } void Game_ApplyDir(void) { g_dir = g_next_dir; /* 只在步进时应用 */ }

反向锁比较的是g_dir,不是g_next_dir。假设蛇当前向上,玩家快速按右再按下,如果比较g_next_dir,第二次“下”不会被判反向,下一步就会直接掉头。参数上不需要额外配置,关键是调用时机:Game_RequestDir在按键任务里调,Game_ApplyDir在Game_Step开头调,中间不要有其他代码改g_dir。

3.3 食物生成:RNG 加占用检测,别用 rand()% 了事

食物随机是另一个高频坑。用标准库rand()不播种,每次上电食物位置一模一样;用rand() % GRID_COLS只随机了横坐标,纵坐标忘了变;硬件 RNG 时钟没开,HAL_RNG_GenerateNumber一直返回错误,食物停在默认位置。F407 有硬件随机数外设,开时钟后读DR寄存器,失败时用定时器计数做种子兜底。生成后必须遍历蛇身做占用检测,蛇很长时还要加线性扫描兜底,避免重试死循环。

typedef struct { uint8_t x, y; } Food_t; static Food_t food; static uint8_t Food_IsOnSnake(uint8_t x, uint8_t y) { for (uint8_t i = 0; i < snake.len; i++) { uint8_t idx = (snake.tail + i) % MAX_SNAKE; if (snake.x[idx] == x && snake.y[idx] == y) { return 1; } } return 0; } void Food_Spawn(void) { uint32_t r; for (uint8_t tries = 0; tries < 200; tries++) { if (HAL_RNG_GenerateNumber(&hrng, &r) != HAL_OK) { continue; } uint8_t x = r % GRID_COLS; uint8_t y = (r >> 8) % GRID_ROWS; if (!Food_IsOnSnake(x, y)) { food.x = x; food.y = y; return; } } /* 蛇占满大部分格子时的兜底:线性扫描第一个空格 */ for (uint8_t y = 0; y < GRID_ROWS; y++) { for (uint8_t x = 0; x < GRID_COLS; x++) { if (!Food_IsOnSnake(x, y)) { food.x = x; food.y = y; return; } } } }

r % GRID_COLS和(r >> 8) % GRID_ROWS用的是同一个随机数的不同字节,避免横纵坐标完全相关。取模会有轻微偏差,对贪吃蛇无所谓。重试 200 次是经验值,蛇占 90% 格子时可能不够,所以后面加了线性扫描。参数上GRID_COLS=15、GRID_ROWS=20,如果改成 20×20,食物坐标上限也要跟着改,别把y算到 20 以外。

3.4 游戏状态机:READY/RUN/PAUSE/OVER 四个状态

游戏逻辑不要写成一大堆if嵌套,用状态机最稳。四个状态够用:READY 显示“按 K1 开始”,RUN 正常步进,PAUSE 冻结节拍但保留画面,OVER 停止步进并保存最高分。按键 K1 负责开始/暂停,K2 负责重置。状态切换集中在一个switch里,后面加“难度选择”“排行榜”也不会把主循环搅乱。

typedef enum { ST_READY, ST_RUN, ST_PAUSE, ST_OVER } GameState_t; static GameState_t g_state = ST_READY; static uint16_t score = 0; void Game_Step(void) { if (g_state != ST_RUN) { return; } Game_ApplyDir(); int8_t nx = snake.x[snake.head] + (g_dir == DIR_RIGHT) - (g_dir == DIR_LEFT); int8_t ny = snake.y[snake.head] + (g_dir == DIR_DOWN) - (g_dir == DIR_UP); if (nx < 0 || nx >= GRID_COLS || ny < 0 || ny >= GRID_ROWS) { g_state = ST_OVER; return; } if (Snake_HitBody(nx, ny)) { g_state = ST_OVER; return; } uint8_t eat = (nx == food.x && ny == food.y); if (!Snake_PushHead(nx, ny, eat)) { g_state = ST_OVER; return; } if (eat) { score += 10; Food_Spawn(); if (score % 50 == 0) { Speed_Up(); /* g_speed_ms 减 10,最低 80 */ } } }

碰撞检测的顺序是先算新头坐标,再判墙,再判身体,最后入队。Snake_HitBody要跳过尾巴,因为不吃食物时尾巴会移走,新头可以走到旧尾位置;如果吃到食物,尾巴不动,这时候旧尾仍算身体。状态表可以写成:READY 只响应开始键;RUN 响应方向和暂停;PAUSE 只响应继续和重置;OVER 响应重置,并在进入时比较最高分。参数上score % 50 == 0表示每 50 分提一档速度,实际玩下来节奏比较顺。

4. 显示刷新:20×15 格子、局部重绘和字体缓存的取舍

4.1 先算清楚一帧要刷多少像素

显示刷新要先算账,不要凭感觉写“每帧全屏清屏”。屏幕 240×320,RGB565 每个像素 2 字节,整屏 76800 像素,数据量 153.6 KB。SPI 跑 18 MHz,理论带宽 2.25 MB/s,整屏刷一遍至少 68 ms,算上命令开销和窗口设置,实际 80~100 ms。贪吃蛇一步 180 ms,整屏刷新占掉一半时间,画面看起来就会拖,按键也跟着迟滞。局部

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

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

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

立即咨询