
简介基于STM32F407的贪吃蛇小游戏是一份面向嵌入式初学者的完整工程源码包覆盖Cortex-M4内核下的LCD显示、定时器中断、GPIO按键检测等典型外设应用适合学习STM32开发或课程设计参考。资源共267个文件核心为50个C源文件与50个头文件另有编译生成的o、d、crf等中间文件及hex、axf烧录文件附带部分说明文档整体压缩包约9.09MB。目前已有3337人学习资源热度和实用性已得到验证。压缩包内含完整Keil工程可直接编译下载到开发板运行游戏支持4级难度选择、按键控制、暂停/继续及最高分记录通过阅读源码可掌握状态机设计、模块化分层和中断驱动的游戏循环写法同时理解STM32F407的GPIO、定时器、ADC等外设的初始化与调用流程。对于想快速上手嵌入式图形交互项目或完成课设的读者这份资料能提供从硬件驱动到应用逻辑的完整参考。1. 基于STM32F407的贪吃蛇难点不在蛇本身把PC上的贪吃蛇搬到STM32F407上最先跑崩的往往是开发者的预期蛇能走画面却糊成一团或者按键一快蛇就反向插入自己身体。原因不难猜——贪吃蛇在PC上只是几十行C语言逻辑换到单片机后蛇身数据结构、TFT刷新、按键消抖、定时节拍这些外围问题全变成了同等级的主角。这个项目适合正在学STM32F407裸机开发的人尤其是想把手里的板子和屏幕真正用起来、而不是只点灯的人。本文按“数据结构 → 显示刷新 → 主循环状态机 → 调试手段”展开每一层都能单独拿去复用最后用串口日志验证整条链路全部代码用标准C写得动标准配套板。2. 用环形队列管理贪吃蛇蛇身碰撞检测给尾巴留一个移动窗口2.1 为什么不用链表贪吃蛇在F407上的数据结构选型常见PC教程会用链表表示蛇身每个节点存坐标用malloc动态分配。这套逻辑搬到STM32F407裸机上会带来两个隐患一是标准库默认堆很小蛇吃到一定长度后malloc可能失败二是链表节点天然带指针开销F407虽然不缺RAM但串口、TFT缓冲、堆栈叠下来没有必要为一条蛇浪费内存。蛇每次移动的本质是“头部加一格、尾部减一格”这正好是先进先出结构环形队列用固定数组加head/tail两个索引就能实现。更实际的原因是检测碰撞需要遍历整条蛇。链表遍历要逐节点跳指针而数组遍历是连续内存访问在Cortex-M4上命中缓存的概率更高。对最大格子数有限制的贪吃蛇数组长度是编译期常量不存在扩容问题整个数据结构的内存占用也能精确算出来这在嵌入式里比“优雅”更重要。2.2 坐标与环形队列定义先用屏幕分辨率反推网格大小。以常见的1.8寸128x160 TFT为例格子取8x8像素屏幕分成16x20格最大蛇长320。每个坐标用uint8_t存储足够蛇身数组总占用只有640字节。#define GRID_W 16 #define GRID_H 20 #define CELL 8 #define MAX_LEN (GRID_W * GRID_H) /* 320填满即胜利 */ typedef struct { uint8_t x, y; } cell_t; static cell_t snake_body[MAX_LEN]; static uint16_t snake_head; /* 指向当前蛇头 */ static uint16_t snake_tail; /* 指向当前蛇尾 */ static uint8_t snake_len; static uint8_t snake_dir; /* 0上 1右 2下 3左 */ static uint8_t pending_dir; static uint8_t food_x, food_y;这里的head和tail是环形缓冲区的读写指针。head指向最后写入的蛇头坐标tail指向最早写入的蛇尾坐标。每次移动时head前进一格写入新头tail前进一格释放旧尾两条索引在数组中绕圈走谁也不用移动数据。之所以用两个uint8_t存坐标而不是合并成一个uint8_t是为了让代码直接可读F407不缺这点RAM。2.3 一次移动封装吃食物、撞墙、撞身把移动逻辑收敛成一个函数返回状态码让主循环决定游戏是否结束。代码里先按当前方向算出新头坐标然后做碰撞判断最后才更新缓冲索引。static inline uint16_t next_idx(uint16_t i) { return (i 1 MAX_LEN) ? 0 : i 1; } static int snake_move(void) { uint8_t nx snake_body[snake_head].x; uint8_t ny snake_body[snake_head].y; switch (snake_dir) { case 0: ny--; break; case 1: nx; break; case 2: ny; break; case 3: nx--; break; } if (nx GRID_W || ny GRID_H) return 2; /* 撞墙 */ uint16_t idx snake_head; uint16_t remain snake_len - 1; /* 尾格即将让出不查 */ while (remain--) { idx next_idx(idx); if (snake_body[idx].x nx snake_body[idx].y ny) return 3; /* 撞自己 */ } uint16_t new_head next_idx(snake_head); snake_body[new_head].x nx; snake_body[new_head].y ny; snake_head new_head; if (nx food_x ny food_y) { snake_len; if (snake_len MAX_LEN) return 4; /* 满屏获胜 */ return 1; /* 吃到食物尾巴不动 */ } snake_tail next_idx(snake_tail); return 0; /* 普通移动 */ }代码里的remain取snake_len - 1是故意的蛇尾巴这一格在当前帧会被让出来新头即使撞到它的旧位置也不算碰撞。如果写成遍历snake_len个格子蛇在掉头瞬间会误判自己撞到自己。环形索引用next_idx换算避免在循环里使用取模运算虽然编译器大概率会优化但这属于嵌入式代码的习惯写法。2.4 边界参数和显示开销对照参数含义推荐值说明GRID_W横向格子数16由屏幕宽128像素除以CELL得到GRID_H纵向格子数20由屏幕高160像素除以CELL得到CELL单格像素边长8太小则食物看不清太大则蛇身短MAX_LEN蛇身上限320等于格子总数占数组640字节帧缓冲整屏RGB56540KB128x160x2字节SRAM可容纳如果屏幕换成240x320的ILI9341CELL取8则网格是30x40、最大蛇长1200蛇身数组变2400字节帧缓冲则暴涨到153KB片内RAM放不下必须缩小CELL或者用按区域刷新。通常CELL取6到10都能接受核心是让蛇身数据总量和显示刷新负担成正比。2.5 禁反向异或2秒判断方向键连按是贪吃蛇最常见的死法。一帧内如果先按上再按左程序若每次都立即改方向蛇可能在同一次移动里先向上再向左实际上等同于反向。解决办法是主循环里只更新pending_dir游戏节拍到达时再统一应用。static uint8_t is_reverse(uint8_t new_dir) { return ((uint8_t)(new_dir ^ snake_dir) 2) ? 1U : 0U; }方向编码0上1右2下3左是成对的异或关系上与下的编码差2左与右的编码差2。用异或判断反向比写四个switch分支更简洁也不容易漏条件。pending_dir在节拍到达时检查一次反向就丢弃不反向才写入snake_dir。3. 贪吃蛇的TFT刷新策略显存预算、差量绘制与CCM RAM限制3.1 先算一笔显存账再选CELL接TFT屏第一件事不是写驱动是算内存储备。16位色即RGB565一个像素占2字节128x160整屏需要40KB如果是240x320的屏整屏需要153KB超过F407整个可DMA访问的SRAM容量直接整帧缓冲必然失败。屏幕分辨率单像素字节整屏缓冲F407片内能否放下128x160240KB可以240x3202153KB不可需分块刷新480x2722261KB不可必须用外部SRAM所以用128x160的屏是最舒服的省出来的SRAM还能做串口缓冲、协议栈之类的东西。若手头只有大屏CELL取4只刷新蛇身覆盖的小矩形也能跑但食物和蛇身都偏小视觉上需要适应。3.2 差量绘制每帧只画头和尾两个格子全量重绘的思路是每帧把320个格子全部重画一遍SPI刷一整屏再加上窗口切换速度会明显跟不上游戏节拍。影子帧缓冲方案则要额外40KB内存而且在F407上很浪费。蛇的移动每帧最多影响两个格子——新增的蛇头格和被释放的蛇尾格所以只重绘这两个矩形就够。static uint8_t old_tail_x, old_tail_y; /* 记录本次移动前的尾巴 */ /* 在 snake_move() 中未吃到食物时保存旧尾坐标 */ static void snake_render(uint8_t ate) { cell_t *h snake_body[snake_head]; tft_fill_rect(h-x * CELL, h-y * CELL, CELL, CELL, GREEN); if (!ate) { tft_fill_rect(old_tail_x * CELL, old_tail_y * CELL, CELL, CELL, BLACK); } }这段代码依赖一个前提snake_move里在更新tail索引前先把旧尾坐标存到old_tail_x和old_tail_y。很多人直接拿snake_body[snake_tail]去擦除但移动后tail已经前进了一格擦掉的反而是新尾巴蛇身中间会莫名其妙缺一块。这是差量刷新最容易踩的坑写驱动时一定要明确“erase用的是移动前的坐标”。3.3 SPI接口下的fill_rect最小实现贪吃蛇只画纯色方块驱动层不需要贴图接口一个填充矩形函数就够。以ST7735这类SPI屏为例填充矩形本质是设置地址窗口后连续写像素数据。static void tft_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { CS_LOW(); DC_LOW(); spi_tx(0x2A); DC_HIGH(); /* 列地址 */ spi_tx(x0 8); spi_tx(x0); spi_tx(x1 8); spi_tx(x1); DC_LOW(); spi_tx(0x2B); DC_HIGH(); /* 行地址 */ spi_tx(y0 8); spi_tx(y0); spi_tx(y1 8); spi_tx(y1); DC_LOW(); spi_tx(0x2C); DC_HIGH(); /* 写显存 */ CS_HIGH(); } static void tft_fill_rect(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint16_t color) { uint32_t n (uint32_t)w * h; uint8_t hi color 8; uint8_t lo color; tft_set_window(x0, y0, x0 w - 1, y0 h - 1); while (n--) { spi_tx(hi); spi_tx(lo); } }地址窗口命令是0x2A、0x2B、0x2C很多屏幕控制器兼容这套命令换屏幕时主要改初始化序列和分辨率。注意RGB565的颜色字节序在不同控制器上可能反转如果蛇身显示成红绿互换把hi和lo对调一下即可。这段代码每次刷新8x8格子要写128字节对SPI时钟20MHz来说开销可忽略。3.4 CCM RAM不能给DMA刷屏F407的CCM RAM限制F407有一块64KB的CCM RAM挂在CPU内核总线上处理器访问它最快但外设和DMA看不到这片区域。很多人把显存或帧缓冲丢进CCM然后开SPI DMA搬运结果DMA一直不完成或数据全错卡半天查不到原因。正确用法是让DMA相关的缓冲区放SRAM1或SRAM2CCM留给CPU高频访问的数据比如蛇身坐标数组、格子计数、中断里的节拍变量。这样既利用CCM的低延迟优势又不跟DMA抢通路。果用FSMC接口的TFT屏数据走并行总线同样不要安排到CCM。4. 贪吃蛇主循环按键状态机、TIM6节拍与食物随机4.1 按键扫描还是EXTI选10ms轮询方向键属于低频输入不需要外部中断。中断里改状态变量还要考虑重入问题而轮询放在主循环里天然串行简单可靠。按键接法按开发板原理图来一般是有上拉电阻、按下为低电平。扫描时加10ms消抖防止一次按下被读成两次。static uint8_t key_scan(void) { static uint32_t last_ms; uint32_t now tick_ms; uint8_t key 0; if (now - last_ms 10) return 0; last_ms now; if (KEY_UP_READ() 0) key 0x01; else if (KEY_DOWN_READ() 0) key 0x02; else if (KEY_LEFT_READ() 0) key 0x03; else if (KEY_RIGHT_READ() 0) key 0x04; else if (KEY_PAUSE_READ() 0) key 0x05; return key; }tick_ms来自定时器中断每1ms自增一次这样消抖时间不会受主循环里TFT刷新耗时影响。如果这里用HAL_GetTick只要中断里关了SysTick误差会直接反映到按键灵敏度上。4.2 游戏状态机菜单、运行、暂停、结束当前状态触发事件动作下一状态MENU开始键初始化蛇身和食物RUNRUN方向键写pending_dirRUNRUN暂停键冻结节拍PAUSERUN撞墙或撞身显示结束标志OVERPAUSE暂停键恢复节拍RUNOVER任意键回到菜单复位全部参数MENU状态机用switch实现每个状态只处理属于自己的按键。方向键在PAUSE和OVER状态下不应该改变蛇头方向否则恢复游戏时会朝一个玩家没见过的方向出发。static void handle_key(void) { uint8_t k key_scan(); if (k 0) return; switch (game_state) { case ST_MENU: if (k 0x05) reset_game(); game_state ST_RUN; break; case ST_RUN: if (k 0x01 !is_reverse(0)) pending_dir 0; else if (k 0x02 !is_reverse(2)) pending_dir 2; else if (k 0x03 !is_reverse(3)) pending_dir 3; else if (k 0x04 !is_reverse(1)) pending_dir 1; else if (k 0x05) game_state ST_PAUSE; break; case ST_PAUSE: if (k 0x05) game_state ST_RUN; break; case ST_OVER: if (k ! 0) game_state ST_MENU; break; } }按键状态机的好处是逻辑全部从主循环里剥离后续加暂停菜单、计分面板都只改这个switch不用动蛇身和渲染代码。4.3 用TIM6产生游戏节拍避免HAL_Delay拖死画面主循环里不能用HAL_Delay做蛇的移动延时。HAL_Delay一旦碰上SPI刷屏关中断计时会变长画面一卡蛇速忽快忽慢。常见做法是开一个定时器产生1ms节拍主循环只检查标志位。uint16_t speed_ms 200; /* 初始200ms移动一格 */ volatile uint16_t tick_ms; volatile uint8_t game_tick; void TIM6_DAC_IRQHandler(void) { if (TIM6-SR TIM_SR_UIF) { TIM6-SR 0; tick_ms; if (tick_ms % speed_ms 0) game_tick 1; } }F407的TIM6挂在APB1上APB1时钟是42MHz定时器时钟自动翻倍为84MHz。预分频器填8400-1得到10kHz计数频率自动重装值填9就是1ms中断一次。这个84MHz的来源常被记错成168MHz如果按168MHz算整条时间轴会快一倍。RCC-APB1ENR | RCC_APB1ENR_TIM6EN; TIM6-PSC 8400 - 1; /* 84MHz / 8400 10kHz */ TIM6-ARR 9; /* 每10次计数产生一次更新1ms */ TIM6-DIER | TIM_DIER_UIE; NVIC_EnableIRQ(TIM6_DAC_IRQn); TIM6-CR1 | TIM_CR1_CEN;吃食物后改speed_ms就能调速。速度档位建议设计成等差数列比如200、160、120、100最低别低于80ms一格否则玩家的眼睛和按键都跟不上游戏体验下降。4.4 食物随机收集空闲格再取一次随机常见错误写法是food_x rand() % GRID_W再判断是否在蛇身不在就重试。蛇身越长空闲格越少重试次数越不可控最坏情况是蛇快满屏时程序在随机循环里空转。正确做法是先把所有空闲格收集到数组里再从数组里随机挑一个。static void gen_food(void) { static uint16_t free_cells[GRID_W * GRID_H]; uint16_t n 0; uint16_t idx; uint16_t i; for (idx 0; idx MAX_LEN; idx) { i idx % MAX_LEN; if (snake_covers(snake_body[i].x, snake_body[i].y)) continue; free_cells[n] (uint16_t)(snake_body[i].y * GRID_W snake_body[i].x); } if (n 0) { i free_cells[rand() % n]; food_x i % GRID_W; food_y i / GRID_W; } }这段代码每次生成食物扫描一遍蛇身320个格子全扫也就几百次循环在168MHz主频下开销可以忽略。rand的种子用tick_ms的低16位即可每次重启机器序列都不同不用额外接传感器。空闲格数组是static避免每次调用在栈上分配2.5KBF407的栈通常只有8KB经不起这么浪费。5. 串口打印贪吃蛇运行状态三步验证按键与时序5.1 一行日志暴露状态而不是打印堆栈把状态节拍、方向、蛇头蛇尾和食物坐标打包成一串格式化输出放在每次移动后执行。串口波特率设115200printf重定向到UART后这行日志就是整个游戏的“实时调试器”。printf([%05lu][%s] dir%u pending%u len%3u head(%2u,%2u) tail(%2u,%2u) food(%2u,%2u)\r\n, tick_ms, state_name(game_state), snake_dir, pending_dir, snake_len, snake_body[snake_head].x, snake_body[snake_head].y, snake_body[snake_tail].x, snake_body[snake_tail].y, food_x, food_y);当游戏在暂停、结束、撞墙之间跳转时tick_ms字段会记录精确时刻比在代码里打断点直观得多尤其适合判断到底是按键没触发、方向被过滤还是蛇身碰撞判断出错。5.2 三步验证方向、节拍、蛇身先按一次方向键观察pending字段是否变成对应值。如果pending从0变1又立刻变回原值说明消抖时间太短或按键扫描逻辑在同一个节拍里被调用了两次需要给key_scan加“已处理”标志。再数两条日志的时间间隔间隔如果稳定等于speed_ms说明TIM6节拍正常如果偶尔翻倍多半是SPI刷屏期间关了中断、中断响应被推迟。最后关注head和tail坐标普通移动时head移动一格、tail移动一格吃到食物时head移动、tail不动。若tail也动了但分数没加去查snake_move里吃食物分支的位置多半是返回状态被主循环提前消耗。5.3 进阶改动四键变两键左右转向让操作更顺如果开发板上只剩两个可用按键可以把方向改成“左转”“右转”模式。左转让方向值减1右转加1方向只在0到3之间循环蛇永远不会因为误按反向键而自杀。uint8_t next_dir_left(uint8_t dir) { return (dir 3) 3; /* 上0 右1 下2 左3十进制角度逆时针 */ }这种模式看似可玩性低但正好可以测试状态机对方向变化的响应时序按住左转不放蛇应该原地转圈串口日志里head坐标回环变化方向值也能看到4、3、2、1的递减循环。把最后这行串口日志留在板上继续跑边界碰撞和长蛇场景一个可用的STM32F407贪吃蛇就真正闭环了。本文还有配套的精品资源点击获取