☰
STM32F407开发板贪吃蛇实战:FSMC显示与状态机
2026/10/26 2:30:42 网站建设 项目流程

简介:这份STM32F407贪吃蛇游戏程序面向嵌入式初学者与课程设计开发者,以经典贪吃蛇为载体,帮助读者在Cortex-M4平台上理解GPIO驱动显示、定时器中断控制帧率、按键中断响应、数组状态管理与碰撞检测等核心机制。工程共含159个文件,以77个.h头文件和75个.c源文件为主体,搭配启动汇编文件、Keil工程与hex可执行文件,以及批处理和说明页面等辅助内容,压缩包约831KB,结构接近完整MDK工程,便于直接编译、烧录和二次修改。已有2820人学习,说明该案例在STM32入门与项目实践中有较高参考价值。读者可据此梳理外设库组织方式、游戏主循环与中断服务例程的协作关系,并借助可运行固件快速验证显示与输入逻辑,适合用作嵌入式课程实验、毕业设计原型或自学练手项目。

1. 一块吃灰的F407开发板,被我改成了能连玩的贪吃蛇

某实验室的柜子里躺着一块STM32F407开发板,板载3.2寸并口TFT、四个独立按键、一个蜂鸣器。学完GPIO点灯和串口打印之后,很多人就把它放回去了,因为找不到一个"规模刚好"的练手项目。这个STM32F407贪吃蛇程序干的就是这件事:把一块开发板变成一个能开机、能计分、能掉电保存最高分的小游戏机。它不适合当产品,但适合当一次完整工程演练——屏幕驱动、定时器节拍、状态机、输入防抖、Flash存储,一条链上全是真实开发里躲不开的东西。想从"会点灯"跨到"能跑起一个小系统"的开发者,从它入手比啃例程库划算得多。

2. 硬件底子与工程骨架:从时钟树到屏幕点亮的第一层落地

拿到这份工程,第一件事不是看游戏逻辑,而是看它怎么把一块裸板变成"有显示、有时间基准"的运行环境。游戏逻辑再花哨,时钟配错、屏幕不亮、节拍不准,后面全是白搭。这一层也是新手最容易翻车的地方——引脚表看错一个、FSMC 时序差一个周期,现象就是花屏或者干脆白屏,排查起来像黑匣子。

2.1 板级资源盘点与引脚分配

整份工程围绕三类外设展开:显示、输入、计时。显示用 FSMC 并口驱动 3.2 寸 TFT(常见驱动芯片为 ILI9341 一类,320×240 分辨率,RGB565 色深);输入是四个独立按键;计时靠一个基本定时器产生毫秒节拍。蜂鸣器负责吃食物和撞墙的音效,串口只在调试阶段打印状态,正式运行时关掉。

我一般会先把引脚占用整理成一张表贴在工程目录的 README 里,改板子的时候照着改,比翻代码快:

功能外设/信号典型引脚备注
LCD 数据线FSMC_D0~D15PD14/PD15/PD0/PD1 等16 位并口,一次写半个字
LCD 片选FSMC_NE1PD7Bank1 子区 1
LCD 命令/数据选择FSMC_A16PD11高电平写数据、低电平写命令
LCD 写使能FSMC_NWEPD5由 FSMC 自动产生
LCD 背光TIM4_CH3PB8PWM 调亮度,省电也护眼
按键 上/下/左/右GPIO 输入PE0~PE3内部上拉,按下为低
蜂鸣器TIM12_CH1PB14有源蜂鸣器直接给电平即可
调试串口USART1PA9/PA10115200-8-N-1

选并口屏而不是 SPI 屏,理由很实际:320×240 的 RGB565 整屏是 153600 字节,SPI 跑 42MHz 理论也要 30ms 左右,实际还要翻倍;FSMC 16 位并口一次写一个半字,整屏刷新的时间差一个数量级。贪吃蛇大部分时间是局部刷新,SPI 其实也够用,但菜单切换、清屏、游戏结束全屏重绘那几下,体验差别非常明显。

2.2 时钟树配置:168MHz 的地基怎么打

F407 的标准配置是 HSE 8MHz 晶振经 PLL 倍频到 168MHz,AHB 168MHz,APB1 42MHz,APB2 84MHz。这里有个容易忽略的点:APB1 预分频不为 1 时,挂在 APB1 上的定时器时钟会再乘 2,所以 TIM6 实际拿到的是 84MHz,不是 42MHz。分频算错,游戏节拍就会整体慢一倍,蛇爬得像蜗牛。

void SystemClock_Config(void) { RCC_OscInitTypeDef osc = {0}; RCC_ClkInitTypeDef clk = {0}; __HAL_RCC_PWR_CLK_ENABLE(); /* 168MHz 必须开 Scale1 电压档,否则高频下不稳定 */ __HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1); osc.OscillatorType = RCC_OSCILLATORTYPE_HSE; osc.HSEState = RCC_HSE_ON; osc.PLL.PLLState = RCC_PLL_ON; osc.PLL.PLLSource = RCC_PLLSOURCE_HSE; osc.PLL.PLLM = 8; /* 8MHz / 8 = 1MHz 鉴相输入 */ osc.PLL.PLLN = 336; /* 1MHz * 336 = 336MHz VCO */ osc.PLL.PLLP = RCC_PLLP_DIV2; /* 336 / 2 = 168MHz 系统时钟 */ osc.PLL.PLLQ = 7; /* 48MHz,USB/SDIO 用得上 */ HAL_RCC_OscConfig(&osc); clk.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; clk.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; clk.AHBCLKDivider = RCC_SYSCLK_DIV1; /* HCLK = 168MHz */ clk.APB1CLKDivider = RCC_HCLK_DIV4; /* PCLK1 = 42MHz,定时器 84MHz */ clk.APB2CLKDivider = RCC_HCLK_DIV2; /* PCLK2 = 84MHz */ HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_5); /* 168MHz 需 5 个等待周期 */ }

PLLM/PLLN/PLLP 三个参数是联动的,改任何一个都要重新核对最终频率,别只改 N。FLASH_LATENCY_5也不能省,168MHz 下 Flash 读速度跟不上,等待周期少了会随机死机,而且这种死机很玄学——跑着跑着卡住,复位又好了,查起来极其难受。

2.3 FSMC 并口屏初始化:让第一帧图像亮起来

屏幕点亮分三步:FSMC 控制器初始化、面板寄存器序列下发、读 ID 校验。读 ID 这一步别省,它是判断"接线对不对、时序对不对"最直接的证据——ID 读出来是 0x0000 或者 0xFFFF,基本就是数据线虚焊或者片选错了。

/* NE1 基址 0x60000000,RS 接 A16,所以数据口地址偏移 0x20000 */ #define LCD_BASE ((uint32_t)0x60000000) #define LCD_REG (*(volatile uint16_t *)(LCD_BASE)) #define LCD_RAM (*(volatile uint16_t *)(LCD_BASE + 0x00020000)) static void LCD_FSMC_Init(void) { FSMC_NORSRAM_TimingTypeDef t = {0}; hfsmc.Instance = FSMC_NORSRAM_DEVICE; /* 16 位数据宽度、NORSRAM 模式、写使能,B 模式异步访问 */ t.AddressSetupTime = 5; /* 地址建立 5 个 HCLK */ t.AddressHoldTime = 0; t.DataSetupTime = 8; /* 数据建立 8 个 HCLK,太快会花屏 */ t.BusTurnAroundDuration = 0; t.CLKDivision = 0; t.DataLatency = 0; t.AccessMode = FSMC_ACCESS_MODE_B; HAL_SRAM_Init(&hsram, &timing, &t); GPIO_InitTypeDef g = {0}; g.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_14 | GPIO_PIN_15; g.Mode = GPIO_MODE_AF_PP; g.Pull = GPIO_NOPULL; g.Speed = GPIO_SPEED_FREQ_VERY_HIGH; g.Alternate = GPIO_AF12_FSMC; HAL_GPIO_Init(GPIOD, &g); } /* 写命令/写数据,靠地址线 A16 区分,不用手动拉 IO */ static inline void LCD_WriteCmd(uint16_t cmd) { LCD_REG = cmd; } static inline void LCD_WriteData(uint16_t d) { LCD_RAM = d; } /* 设置矩形窗口,局部刷新全靠它 */ void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); /* 列地址设置 */ LCD_WriteData(x0 >> 8); LCD_WriteData(x0 & 0xFF); LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteCmd(0x2B); /* 行地址设置 */ LCD_WriteData(y0 >> 8); LCD_WriteData(y0 & 0xFF); LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteCmd(0x2C); /* 进入写显存模式 */ }

DataSetupTime是这里最需要试的参数,手册给的典型值是 8~15 个 HCLK,设太小屏幕会出现雪花点或者整行错位,设太大刷新变慢。板子上延长排线的话,建议直接拉到 15,宁可慢一点也别花屏。LCD_SetWindow是后面所有局部刷新的基础,每次只刷一个格子(比如 12×12 像素)时,窗口设成这一格的范围,然后连续写 144 个像素值,比全屏刷新快得多。

3. 游戏状态机与主循环:一个 tick 怎么串起按键、蛇身和屏幕

工程跑起来之后,最值得看的是主循环的组织方式。很多学生写的贪吃蛇是在定时器中断里直接刷屏、直接处理按键,跑起来能动,但一改速度或者一加音效就开始卡顿。这份工程把"时间推进"和"渲染"拆开,中断只负责计一个数,剩下的都在主循环里按状态分发,改起来心里有底。

3.1 四态状态机与它的流转

游戏一共四个状态:菜单、运行中、暂停、结束。状态机不是什么高深设计,它的价值在于把"哪个状态下该响应什么按键"写死,避免出现暂停时蛇还在动、结束后还能转向这类交互 bug。

typedef enum { ST_MENU = 0, /* 开机菜单:等待开始键 */ ST_RUNNING, /* 运行中:接受方向输入,按节拍前进 */ ST_PAUSED, /* 暂停:画面冻结,只响应继续/返回 */ ST_OVER /* 结束:显示分数,等待重开 */ } GameState_e; typedef struct { GameState_e state; uint32_t ms_tick; /* 由定时器中断累加,单位 ms */ uint16_t step_ms; /* 当前速度等级对应的步进间隔 */ uint16_t score; uint16_t best; uint8_t dirty; /* 有画面变化时置 1,主循环据此刷新 */ } GameCtx_t; static GameCtx_t game = { .state = ST_MENU, .step_ms = 220 };

ms_tick在中断里自增,主循环拿它和step_ms比较,够了就执行一步逻辑并清零。这种"软件分频"的好处是速度可以随时改,改step_ms就行,不用动定时器寄存器。

3.2 定时器中断与主循环的分工

我一般用一个基本定时器(TIM6 或 TIM7,没有 IO 占用)产生 1ms 节拍,中断服务函数里只做一件事:

void TIM6_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim6, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE); game.ms_tick++; /* 只累加,绝不在这里刷屏 */ } } void Game_Loop(void) { for (;;) { Input_Poll(); /* 按键扫描+防抖 */ Key_Dispatch(); /* 按状态分发按键 */ if (game.ms_tick >= game.step_ms) { game.ms_tick = 0; if (game.state == ST_RUNNING) { Game_Step(); /* 蛇前进一格 */ game.dirty = 1; } } if (game.dirty) { Render_Flush(); /* 只重绘变化的部分 */ game.dirty = 0; } } }

中断里坚决不刷屏,原因很直接:一次全屏刷新是毫秒级的,放进 1ms 中断里会把中断撑爆,其他中断全被推迟,按键响应立刻变迟钝,严重时看门狗都会咬。Input_Poll每轮都跑,不受游戏节拍限制,这样即使蛇走得很慢,按键也不会丢。

3.3 按键扫描与方向缓冲区

按键用 20ms 的状态机防抖:连续采样到稳定电平才认一次按下,并且记录"上一次状态",只在上升沿(从松开到按下)产生事件。方向输入不能直接赋值给"下一方向",因为一帧之内可能连按两次——比如蛇向右走,玩家快速按下"上"再按"左",如果直接覆盖,下一帧蛇就往左走了,等于原地掉头撞自己。

#define DIR_QUEUE_LEN 3 static int8_t dir_queue[DIR_QUEUE_LEN]; static uint8_t dir_head, dir_tail, dir_count; /* 入队:只在队列不满时接受,重复方向直接丢弃 */ void Dir_Push(int8_t d) { if (dir_count > 0 && dir_queue[(dir_head + DIR_QUEUE_LEN - 1) % DIR_QUEUE_LEN] == d) return; /* 和上一个待处理方向相同,不重复入队 */ if (dir_count >= DIR_QUEUE_LEN) return; dir_queue[dir_head] = d; dir_head = (dir_head + 1) % DIR_QUEUE_LEN; dir_count++; } /* 出队:每次逻辑步进只取一个方向 */ int8_t Dir_Pop(int8_t cur) { if (dir_count == 0) return cur; int8_t d = dir_queue[dir_tail]; dir_tail = (dir_tail + 1) % DIR_QUEUE_LEN; dir_count--; /* 反向输入直接忽略,这是防"自杀"最后一道闸 */ if ((d == DIR_UP && cur == DIR_DOWN) || (d == DIR_DOWN && cur == DIR_UP) || (d == DIR_LEFT && cur == DIR_RIGHT) || (d == DIR_RIGHT && cur == DIR_LEFT)) return cur; return d; }

队列长度给 3 就够,再多也没意义——人的手指快不过逻辑帧。Dir_Push里那个"和上一个待处理方向相同就丢弃"的判断,是为了防止长按导致队列塞满无效方向,松开后蛇还继续拐好几次,手感会非常怪。

4. 核心逻辑拆解:蛇身队列、食物生成与碰撞检测

游戏逻辑部分代码量不大,但每一处都藏着选择。用链表还是数组、碰撞检测遍历还是查表、食物怎么随机才不刷进蛇身里,这些决定直接影响后期改玩法时的难度。这一层的设计原则是:数据结构尽量扁平,别引入动态内存,F407 的 RAM 虽然有 192KB,但游戏机这种东西跑十年不该有一丝内存碎片。

4.1 用环形数组表示蛇身

蛇的移动本质是"头进尾出",除了吃到食物那一步,长度不变。这种特性用环形数组最合适:头指针加一、尾指针加一,长度不变;吃到食物时只动头指针,尾指针不动,长度加一。

#define GRID_W 20 /* 横向 20 格 */ #define GRID_H 15 /* 纵向 15 格 */ #define CELL_PX 16 /* 每格 16 像素,画面上留边 */ #define MAX_SNAKE (GRID_W * GRID_H) typedef struct { int8_t x[MAX_SNAKE]; int8_t y[MAX_SNAKE]; uint16_t head; /* 头指针,指向蛇头所在下标 */ uint16_t tail; /* 尾指针,指向蛇尾所在下标 */ uint16_t len; int8_t dir; /* 当前方向 */ } Snake_t; /* 前进一步:dir 已由 Dir_Pop 给出合法方向 */ int Snake_Move(Snake_t *s, int8_t dir, int8_t fx, int8_t fy, uint8_t ate) { int8_t nx = s->x[s->head] + DX(dir); int8_t ny = s->y[s->head] + DY(dir); /* 撞墙判定 */ if (nx < 0 || nx >= GRID_W || ny < 0 || ny >= GRID_H) return -1; /* 撞自身:吃食物时不判尾巴(尾巴这一帧不动) */ uint16_t end = ate ? s->len : s->len - 1; for (uint16_t i = 0; i < end; i++) { uint16_t idx = (s->tail + i) % MAX_SNAKE; if (s->x[idx] == nx && s->y[idx] == ny) return -1; } s->head = (s->head + 1) % MAX_SNAKE; s->x[s->head] = nx; s->y[s->head] = ny; if (!ate) { s->tail = (s->tail + 1) % MAX_SNAKE; /* 尾进头出,长度不变 */ } else { s->len++; } s->dir = dir; return ate ? 1 : 0; }

MAX_SNAKE取格点总数,蛇最长也就是铺满整屏,不会溢出。ate参数很关键:吃食物那一步尾巴不动,如果还按"尾巴那一格可以走"来判定,会出现蛇头咬到自己尾巴尖却判定通过的情况,视觉上很别扭。返回值 -1 表示撞了,0 表示正常走一步,1 表示吃到食物,调用方根据返回值处理音效和分数。

长度到几百之后,每次移动都遍历一遍蛇身是 O(n),在 168MHz 上完全无压力。如果想把玩法扩展到更大的棋盘,可以再维护一张uint8_t grid[GRID_H][GRID_W]位图,蛇头蛇尾移动时同步更新,碰撞检测变成 O(1)。

4.2 食物生成:别让食物刷进蛇肚子里

最简单的食物生成是"随机取坐标,是蛇身就重试"。短蛇时没问题,蛇长了之后重试次数暴涨,极端情况下能卡住几十毫秒,表现为画面一顿。稳妥做法是先扫一遍所有空格,收进数组,再随机取一个下标。

static uint16_t empty_idx[GRID_W * GRID_H]; void Food_Spawn(Snake_t *s, int8_t *fx, int8_t *fy) { uint16_t cnt = 0; for (int8_t y = 0; y < GRID_H; y++) { for (int8_t x = 0; x < GRID_W; x++) { uint8_t occupied = 0; for (uint16_t i = 0; i < s->len; i++) { uint16_t idx = (s->tail + i) % MAX_SNAKE; if (s->x[idx] == x && s->y[idx] == y) { occupied = 1; break; } } if (!occupied) empty_idx[cnt++] = y * GRID_W + x; } } if (cnt == 0) { *fx = -1; return; } /* 满屏,通关 */ uint32_t r = Rand_Next() % cnt; /* 均匀取一个空格 */ *fx = empty_idx[r] % GRID_W; *fy = empty_idx[r] / GRID_W; }

随机数源有两种选择。F407 自带硬件 RNG 外设,开时钟后读RNG->DR就行,质量好;如果没有启用 RNG,用 SysTick 计数值加一个自增种子做线性同余也能用,但要注意别用固定的初始种子,否则每次开机食物位置完全一样,玩家两把就摸清了规律。这份工程里两种都留了接口,工程里通过宏切换。

4.3 局部刷新:只重绘变化的三格

贪吃蛇每走一步,画面真正变化的只有三处:新的蛇头、旧的蛇尾、可能出现的食物。全屏刷新是最省事的写法,但 320×240 全刷一次大约十几毫秒,速度等级调高之后会明显掉帧。局部刷新把每次重绘压缩到三个 16×16 的方块,耗时降到全屏的百分之一。

/* 画一格:擦除用背景色,画蛇用前景色 */ static void Draw_Cell(int8_t gx, int8_t gy, uint16_t color) { uint16_t px = BOARD_X + gx * CELL_PX; uint16_t py = BOARD_Y + gy * CELL_PX; LCD_SetWindow(px, py, px + CELL_PX - 1, py + CELL_PX - 1); for (uint16_t i = 0; i < CELL_PX * CELL_PX; i++) LCD_WriteData(color); } void Render_Flush(void) { /* 1. 擦掉上一帧的尾巴 */ if (last_tail_valid) Draw_Cell(last_tail_x, last_tail_y, COLOR_BG); /* 2. 画新蛇头,顺便给个圆角效果就多画两笔 */ Draw_Cell(snake.x[snake.head], snake.y[snake.head], COLOR_HEAD); /* 3. 头部变回普通身体色(上一帧的头) */ if (prev_head_valid) Draw_Cell(prev_head_x, prev_head_y, COLOR_BODY); /* 4. 食物只在刚生成时画一次 */ if (food_fresh) { Draw_Cell(food_x, food_y, COLOR_FOOD); food_fresh = 0; } }

这里的顺序有讲究:先擦尾巴再画头,中间不能有任何一次全屏清屏,否则屏幕上会出现一闪。BOARD_X、BOARD_Y是棋盘左上角的屏幕坐标,留出顶部分数栏和底部提示区。COLOR_HEAD和COLOR_BODY区分开,蛇头一眼能认出来,这个小细节对手感提升意外地大。

4.4 分数与最高分的掉电保存

分数在 RAM 里存着没问题,最高分要跨掉电保留。F407 有两条路:内部 Flash 的一个扇区,或者备份寄存器/BKPSRAM。备份寄存器只有几十字节,存个uint16_t最高分绰绰有余,而且不用擦除,写一次就完事;内部 Flash 容量大但擦除一个扇区要一两秒,而且必须整扇区擦,写之前还得先擦。

/* 方案一:RTC 备份寄存器,简单可靠,需要 VBAT 或主电不断 */ #define BEST_REG RTC->BKP0R uint16_t Best_Load(void) { uint16_t v = (uint16_t)(BEST_REG & 0xFFFF); return (v == 0xFFFF) ? 0 : v; /* 首次上电是 0xFFFF,归零 */ } void Best_Save(uint16_t score) { HAL_PWR_EnableBkUpAccess(); /* 备份域默认写保护,必须先开 */ BEST_REG = score; } /* 方案二:内部 Flash 末扇区,适合没有电池的场景 注意:擦除会阻塞 1~2 秒,只在游戏结束时写,且要关中断 */

HAL_PWR_EnableBkUpAccess()这一句漏掉的话,写备份寄存器完全不生效,而且不报错——现象就是最高分永远存不住,查半天以为是掉电太快。这是很典型的一个坑,第一次遇到能折腾一下午。

5. 避坑与排查:花屏、串键、蛇穿墙这几类问题

这份工程在调试阶段踩过的坑基本集中在三块:显示、输入、逻辑。每一块的现象都很有迷惑性,比如花屏未必是屏幕坏了,蛇穿墙未必是碰撞检测写错了。下面按"现象 → 原因 → 解决"把高频问题过一遍,照着对照能省不少时间。

5.1 显示层:花屏、残影与清屏慢

现象一:上电后屏幕一片雪花点,偶尔能看出一点图像轮廓。原因通常是 FSMC 的DataSetupTime设得太小,或者数据线有一根虚焊。并口 16 根线,只要有一根接触不良,颜色通道就会整体偏移,看起来就是花的。解决顺序是:先把DataSetupTime从 8 调到 15,看是否改善;还没好就写一个测试函数,往显存里循环写 0xFFFF 和 0x0000,用万用表量每根数据线对地电压,异常的补焊。

现象二:蛇移动时身后拖一条残影,尾巴擦不干净。原因是擦尾巴和画新头用了不同的坐标基准,或者窗口设置少算了一个像素。LCD_SetWindow(px, py, px + CELL_PX - 1, py + CELL_PX - 1)的结束坐标是闭区间,少减这个 1 就会多刷一格;反过来坐标偏移一格,残影就留在原地。排查时可以先固定画一个纯色方块,看它落在网格的哪个位置,对上了再改整体逻辑。

现象三:菜单切到游戏时明显卡一下,像死机了半秒。这是全屏清屏导致的。320×240×2 字节写下去,即使并口也要几毫秒到十几毫秒,如果清屏函数里还套了逐点判断颜色的逻辑,会更慢。解决是把背景色填充改成连续写同一个值,中间不做任何分支判断,一次窗口设置到底。

5.2 输入层:按键抖动、方向丢失与反向自杀

现象一:按一下方向键,蛇连拐两次,或者明明按了没反应。前者是防抖没做干净,后者是扫描周期太长。独立按键的机械抖动一般 5~20ms,20ms 采样周期加状态机足够;如果主循环里某处有阻塞(比如擦 Flash),按键就会整段丢失。解决是扫描放主循环最前面,任何耗时操作之后强制扫一次。

现象二:急转弯时蛇莫名其妙撞死,回放看是"原地掉头"。这就是第 3 章那个方向队列存在的意义。玩家在一帧内按了"上"和"左",如果两个方向都被立刻采纳,蛇就会从上直接拐到左,而上一帧它是向右的,等于掉头。加了Dir_Pop里的反向过滤之后,这类死亡基本消失。

现象三:暂停后再继续,蛇会往暂停前的反方向冲一下。原因是暂停期间方向队列还在被填充,恢复时一次性弹出。解决很简单,进入暂停状态时清空队列,恢复时重新从当前方向开始。

5.3 逻辑层:食物刷进蛇身、加速失控与满屏死循环

现象一:食物偶尔出现在蛇身体中间,吃不到也消不掉。典型的"随机重试"漏洞——重试次数有上限,达到上限还没找到空格就随便给了个坐标。换成 4.2 节那种"先收集空格再随机取"的写法,这个问题从根上消失。

现象二:速度等级拉到最高之后,蛇像瞬移,按键完全跟不上。step_ms设到了 40ms 以下,而局部刷新加按键扫描一轮就要几毫秒,加上人眼反应时间,体验直接崩。速度下限建议卡在 80ms,再快就不是难度问题了。解决是在速度升级函数里加一个下限钳位。

现象三:蛇长到接近满屏时,游戏卡死不动。多数是食物生成里的双重循环在蛇很长时退化,加上满屏判定缺失导致除零或者死循环。在Food_Spawn里加if (cnt == 0)的满屏通关分支,问题解决,顺便白得一个通关结局。

6. 二次开发:换玩法、换屏幕、加音效的改造路径

这份工程的骨架是"状态机 + 固定节拍 + 局部渲染",这套骨架不只能跑贪吃蛇。把Game_Step换掉,状态机的调度、按键队列、渲染接口都能直接复用,改成俄罗斯方块或者推箱子,工作量主要在新玩法本身,而不是外围框架。

6.1 骨架复用:从贪吃蛇到方块类玩法

关键是把逻辑层和渲染层的接口定清楚。现在工程里渲染层对外只暴露三个函数:Draw_Cell画单格、Render_Flush增量刷新、Render_All整屏重绘。换玩法时只要新的数据结构能映射到"格子 + 颜色",渲染层一行不用改。

改造点贪吃蛇俄罗斯方块需要动的文件
数据结构环形数组蛇身7×7 方块矩阵 + 10×20 场地game_logic.c
节拍来源固定 step_ms下落间隔随等级变化game.c的调度段
碰撞检测边界 + 蛇身遍历场地位图与方块矩阵与运算collision.c
输入语义四方向左右移动 + 旋转 + 硬降input_map.h

改造顺序建议自下而上:先定数据结构,再写碰撞,再写渲染映射,最后接状态机。反着来会陷入"界面画出来了但逻辑对不上"的反复返工。我一般会在改之前先把新玩法的核心状态用 PC 上的小程序验证一遍,逻辑对了再往板子上搬,能省掉大量烧录等待。

6.2 换屏幕与加音效时的兼容处理

换屏是最常见的二次开发需求。如果新屏还是 FSMC 并口、只是驱动 IC 不同,那只需要重写初始化寄存器序列和LCD_SetWindow里的命令字,其余全不动;如果换成 SPI 屏,就要把LCD_WriteData换成 SPI 发送,并重新评估刷新耗时——原来按"局部刷新几毫秒"设计的节拍可能要放宽。这里有个检查习惯:换屏后先跑一个全屏色块测试,红绿蓝白黑依次刷一遍,确认没有偏色和错位,再跑游戏。

音效部分,无源蜂鸣器要用 PWM 出不同频率,有源蜂鸣器只需给高低电平。工程里把音效封装成Buz_Beep(freq, ms),内部用定时器输出指定频率、用一个软件计数控制时长,调用方不关心底层。加音效最容易出的问题是阻塞——HAL_Delay放在主循环里等音效播完,蛇就卡住了。做法是只启动定时器就返回,时长到了在中断里关掉。

6.3 改完之后怎么验证没把节拍搞坏

改动越大,越要有一个快速自检流程。我的习惯是每次改完跑三步:第一步,用串口打印每 100 步的实际耗时,和step_ms对一下,偏差超过 10% 说明主循环里有阻塞;第二步,把速度等级拉到最高跑五分钟,看会不会掉帧或者按键丢失;第三步,反复进出菜单和暂停十次,确认状态机没有残留状态。

/* 节拍自检:每 100 步打印一次平均步进耗时,单位 ms */ static uint32_t t_last, steps; void Tick_Probe(void) { if (++steps % 100 == 0) { uint32_t now = HAL_GetTick(); printf("avg step = %lu ms, target = %u ms\r\n", (now - t_last) / 100, game.step_ms); t_last = now; } }

这段探针代码平时可以一直留着,用宏关掉输出就行。有一回我把局部刷新改成"每帧全刷",功能看着正常,探针一打印平均步进 180ms,目标 120ms,才知道渲染拖了后腿——肉眼还真不一定看得出来。从那以后我每次动渲染或者动节拍相关的代码,都强制走一遍这个三步自检,宁可多花十分钟,也别在演示的时候翻车。希望帮到你。

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

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

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

立即咨询