简介一份基于STM32F407的贪吃蛇游戏完整工程面向嵌入式系统初学者与进阶开发者旨在通过经典小游戏的移植实战深入理解Cortex-M4内核编程、中断系统、定时器与GPIO外设控制等核心知识。压缩包共159个文件以77个C头文件、75个C源文件和1个Hex文件为主配合Keil工程文件与清理脚本合计约831KB。工程代码涵盖蛇的移动、碰撞检测、食物生成、按键中断响应及LCD/LED显示控制等关键模块并包含STM32F4标准外设库的定时器、USART、RTC等驱动源码便于直接查阅与二次开发。目前已有2820人学习下载适合通过实际编译与烧录来掌握STM32CubeMX初始化思路、中断服务例程设计以及内存管理方法是一份兼顾理论讲解与动手实践的嵌入式入门项目参考。1. STM32F407贪吃蛇游戏程序从仿真跑到实物的完整工程很多人手里那块STM32F407核心板点亮完LED、跑完串口回环之后就吃灰了。其实F407跑一个小型图形游戏刚刚好主频够高、内存够大、外设丰富做一个贪吃蛇游戏也不算大材小用。这个工程不是课堂上那种把逻辑写在main函数里、用delay()硬控画面的作业而是一份能在真实硬件上流畅运行的完整代码包——包括显示器驱动、按键检测、游戏状态机、蛇身数据结构以及最容易被忽略的时序同步处理。适合刚学完外设、想做第一个综合项目的人也适合想把这套代码改成其他小游戏的熟手。它能回答一个核心问题在一个没有操作系统、没有图形库的MCU上怎么把游戏逻辑和刷新率管理得清清楚楚。2. 硬件选型与接线F407核心板、显示屏和按键的搭配逻辑2.1 显示屏选型OLED与TFT-LCD的取舍这个工程里默认“跑得最稳”的方案是0.96寸OLEDSSD1306I2C或SPI版都行因为驱动简单、刷新速度足够、内存占用小F407用I2C接口接OLED时几乎不占额外资源。OLED的缺点是尺寸小、颜色单调显示蛇身方块时最多就是画几个小矩形视觉效果一般但作为教学和调试完全够用。如果想让游戏更像“游戏”我一般建议换2.4寸TFT-LCD典型驱动ILI9341用FSMC并口接法。F407内置FSMC控制器可以把LCD当成一块外部SRAM映射到地址空间画点速度比SPI方式快一个数量级。代价是接线多16根数据线加控制线而且FSMC时序参数需要根据屏的规格书手动调很多下载了代码的人就卡在这里。所以我的建议是第一版先用OLED跑通逻辑确认游戏没问题后再换TFT不然一开始就被接线和时序问题劝退反而学不到游戏本身的东西。2.2 接线定义与引脚映射以OLED和TFT两种常见接法为例OLEDI2C模式接线最简单F407的PB8、PB9作为I2C1的SCL和SDA。注意用软件模拟I2C还是硬件I2C要看代码实现这个工程采用的是软件模拟I2C因为SSD1306对时序要求不苛刻且软件模拟可以随意改引脚方便在不同板卡间移植。接线表如下OLED引脚F407引脚备注GNDGND公共地VCC3.3V部分模块需要5V供电看模块背面LDOSCLPB8软件模拟SCLSDAPB9软件模拟SDA如果是TFT-LCDILI9341 FSMC接口关键引脚映射如下TFT引脚F407引脚说明CSPD7FSMC NE1片选RS / DCPD11FSMC A0地址线WRPD5FSMC写使能RDPD4FSMC读使能D0-D15PE0-PE15FSMC数据总线FSMC接法的原理是把LCD的寄存器地址和数据地址映射到0x60000000区域写命令和写数据分别用*(volatile uint16_t*)(0x60000000)和*(volatile uint16_t*)(0x60020000)。这块如果代码包里已经配置好了你只需要核对宏定义和你的板子是否一致如果自己移植重点查FSMC_Bank1的地址映射和片选脚NE1对应地址0x60000000NE2对应0x64000000写错地址屏幕只会白屏或花屏。2.3 按键输入方案GPIO扫描与外部中断的差别这个游戏的按键需求是四个方向键加一个确认/暂停键。我见过不少工程把按键接在外部中断引脚上结果掉进“按键抖动导致游戏误动作”的坑。真实工程里我更推荐用GPIO轮询加软件消抖因为游戏主循环本身就有刷新频率轮询按键不会漏掉信号而且消抖逻辑可以非常好地控制。本工程的按键设计是四个方向键分别接PE0、PE1、PE2、PE3确认键接PE4所有按键接10kΩ上拉电阻到3.3V按键另一端接地。按下为低电平松开为高电平。用上拉的原因很简单F407的GPIO内部可以开上拉但内部上拉电阻约40kΩ在潮湿环境下容易被干扰外接10kΩ能更稳定。3. 游戏逻辑与数据结构贪吃蛇不是堆代码是管理状态机3.1 蛇身存储链表还是数组很多人写贪吃蛇第一反应是用结构体数组size固定为最大长度每个元素存xy坐标每移动一次就把所有元素往前挪一位。这种方法代码最简单但有两个问题一是数组长度必须开满浪费RAM二是每次移动是“批量拷贝”在刷新率高的屏幕上看不出问题但如果你想把蛇身做长耗时会线性增加。这个工程用的是“环形数组”方案开一个足够大的数组记录蛇头在数组中的索引和蛇尾索引移动时新蛇头覆盖最老的蛇尾位置通过模运算循环移动。它比链表省内存不需要malloc和指针开销比普通数组少拷贝只有两个索引变动非常适合MCU环境。关键代码片段如下// 蛇身最大长度环形数组 #define SNAKE_MAX_LEN 256 typedef struct { uint16_t x; uint16_t y; } SnakeNode; static SnakeNode snake[ SNAKE_MAX_LEN ]; static uint16_t head_idx; // 蛇头在数组中的位置 static uint16_t tail_idx; // 蛇尾位置 static uint16_t snake_len; // 蛇头向前移动一步新坐标写入 head_idx 指向的位置 static void snake_move(uint16_t new_x, uint16_t new_y) { head_idx (head_idx 1) % SNAKE_MAX_LEN; // 头指针推进 snake[ head_idx ].x new_x; snake[ head_idx ].y new_y; if (snake_len SNAKE_MAX_LEN) { // 还没吃到食物长度不变尾指针也要推进 tail_idx (tail_idx 1) % SNAKE_MAX_LEN; } else { // 已满则覆盖最老的节点这里由上层逻辑控制增长 } }逻辑说明移动时头指针先加一把新坐标存到新位置没吃到食物时尾指针也加一相当于去掉尾巴。吃食物时只推进头指针长度加一。模运算保证数组索引在0~255间循环这就是环形数组的核心。参数上SNAKE_MAX_LEN取256是平衡值F407有192KB RAM但没必要给蛇身开太大256足够支持超长游戏。3.2 移动与碰撞检测边界、自身、食物碰撞检测是分优先级的先判边界再判食物最后判自身顺序不能反。边界检测很简单坐标超出屏幕显示区域就算撞墙。自身检测的做法是从蛇尾向蛇头遍历检查有没有节点和新蛇头坐标重合。注意不能从蛇头开始因为新蛇头下一步可能移动到当前蛇尾的位置如果蛇尾已经在数组里被更新掉就不算撞。代码里常见做法是检查除“即将被移除的尾节点”以外的所有节点static int check_self_collision(uint16_t x, uint16_t y) { for (uint16_t i 0; i snake_len; i) { // tail_idx 是指向当前尾巴如果下一步会移动尾巴 // 那么当 i 等于 tail_idx 且不吃食物时这个点不算碰撞 uint16_t idx (tail_idx i) % SNAKE_MAX_LEN; if (i 0 !food_eaten) { // 尾巴即将移走 continue; } if (snake[ idx ].x x snake[ idx ].y y) { return 1; // 撞到自己 } } return 0; }参数说明food_eaten是一个布尔量表示本帧是否吃到食物。如果吃到食物尾巴不移走那么尾巴节点仍然在不能跳过如果没吃到尾巴节点空闲可以忽略。这是环形数组方案最容易踩的细节很多移植者在这个判断上少了一个条件导致蛇“自己撞自己”误判。3.3 速度控制用定时器还是延时帧率与难度曲线贪吃蛇的“速度”本质上是每走一步的间隔时间。用delay()实现最直接但delay期间按键响应、屏幕刷新都会停滞尤其是在按键长按加速时体验很差。正确做法是用F407的Systick或一个基本定时器产生固定节拍游戏主循环每次轮询这个节拍到达设定值才执行一次移动。这个工程设计三档速度普通350ms、快速200ms、极速120ms。速度切换在游戏进行中用按键调整。难点在于“移动时刻”和“刷新时刻”的耦合——你需要确定在一个时间节拍内是“先移动后刷新”还是“先刷新后移动”。这个工程的顺序是读取按键 → 计算下一方向 → 移动蛇身 → 检测碰撞 → 有变化才刷新屏幕。这样保证屏幕显示的永远是移动后的状态不会出现闪影。定时器用TIM2的Update中断每1ms计数一次主循环里做阈值累加static volatile uint32_t tick_ms; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { tick_ms; TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } } uint32_t last_move 0; while (1) { if (tick_ms - last_move speed_level) { last_move tick_ms; game_step(); // 移动一步 } // 其他低优先级任务 }逻辑说明tick_ms是全局递增毫秒值last_move记录上次移动时间点差值大于当前速度阈值就执行一步。代码里用无符号32位变量做差值计算可以自动处理溢出回绕问题这是嵌入式时间管理的常用技巧。speed_level单位是ms350表示每秒约3步对新手友好。4. 代码工程拆解从初始化到主循环的完整流程4.1 工程结构哪些文件是移植必须的一个完整能跑起来的工程最少包含以下几类文件系统时钟初始化SystemInit、GPIO和复用外设配置mx_gpio.c、mx_i2c.c或mx_fsmc.c、OLED/TFT驱动oled.c、ili9341.c、游戏核心逻辑snake.c、按键扫描key.c、主循环main.c。这个资源包里把这些文件都分好了没有把全部代码揉在一个main.c里方便你定位问题。移植到一个新的F407开发板上你需要改动的文件一般是board.h里面的引脚宏。例如// board.h 里的引脚配置 #define OLED_SCL_PIN GPIO_PIN_8 #define OLED_SCL_PORT GPIOB #define OLED_SDA_PIN GPIO_PIN_9 #define OLED_SDA_PORT GPIOB #define KEY_UP_PIN GPIO_PIN_0 #define KEY_UP_PORT GPIOE常见错误是只改了底层驱动忘了更新Init函数里的GPIO时钟使能。比如改用PE0作为按键没加__HAL_RCC_GPIOE_CLK_ENABLE()按键永远读不到电平。工程里所有初始化都是显式调用你顺着函数挨个查就能找到遗漏。4.2 主循环与事件处理按键消抖、刷新率主循环不能把所有事情都塞在一起。这个工程的主循环状态机分为三块轮询按键、处理游戏事件、按需刷新显示。按键消抖采用状态机方法比纯延时消抖更适合游戏// 按键消抖状态机 #define KEY_STATE_IDLE 0 #define KEY_STATE_DEBOUNCE 1 #define KEY_STATE_PRESSED 2 static uint8_t key_state[5]; static uint16_t key_debounce_cnt[5]; uint8_t key_scan(uint8_t key_id) { uint8_t level HAL_GPIO_ReadPin(key_pin[key_id], key_port[key_id]); switch (key_state[key_id]) { case KEY_STATE_IDLE: if (level 0) { key_state[key_id] KEY_STATE_DEBOUNCE; key_debounce_cnt[key_id] 0; } break; case KEY_STATE_DEBOUNCE: key_debounce_cnt[key_id]; if (level 1) { key_state[key_id] KEY_STATE_IDLE; // 抖动回空闲 } else if (key_debounce_cnt[key_id] 10) { key_state[key_id] KEY_STATE_PRESSED; // 稳定低电平 10ms return 1; // 上报一次按下事件 } break; case KEY_STATE_PRESSED: if (level 1) { key_state[key_id] KEY_STATE_IDLE; // 松开 } break; } return 0; }逻辑说明这个消抖状态机不阻塞CPU每轮询一次就推进一次状态。10ms的判定阈值对机械按键足够太快会漏掉快速连击太慢会导致游戏操作黏滞。注意KEY_STATE_PRESSED状态下没有上报第二次只有首次按下产生一次事件这符合游戏里“按一下转一次方向”的期望如果你想要长按连续加速需要在这个状态下额外判断长超时。4.3 游戏状态机菜单、运行、暂停、结束游戏不再是线性循环而是有明确的状态MENU、PLAY、PAUSE、GAME_OVER。每个状态对应一个处理函数主循环switch跳转。这样写的好处是逻辑清晰而且加“暂停后闪烁提示”这类功能时不用改全局流程。typedef enum { STATE_MENU, STATE_PLAY, STATE_PAUSE, STATE_GAME_OVER } GameState; GameState current_state STATE_MENU; while (1) { uint8_t key key_read(); // 返回上一次扫描到的按键 switch (current_state) { case STATE_MENU: if (key KEY_CONFIRM) { game_reset(); current_state STATE_PLAY; } break; case STATE_PLAY: if (key KEY_PAUSE) { current_state STATE_PAUSE; } else if (key ! KEY_NONE) { set_direction(key); } break; case STATE_PAUSE: if (key KEY_PAUSE) { current_state STATE_PLAY; } break; case STATE_GAME_OVER: if (key KEY_CONFIRM) { current_state STATE_MENU; } break; } game_step_if_needed(current_state); }状态机每个函数都保持短小避免在case里写一堆逻辑。注意game_step_if_needed会内部检查当前状态是否为PLAY只有PLAY状态才推进游戏这样暂停就天然生效不用额外加flag。这里的状态枚举和函数指针表还可以进一步封装但嵌入式项目里用switch足够而且调试时打断点更容易看清当前状态。5. 避坑与常见问题下载后能在F407上跑起来的关键排查5.1 屏幕不亮或花屏时钟配置和FSMC时序背锅现象用OLED时屏幕完全不亮或者亮但全是乱码/雪花。用TFT时白屏或屏幕上只有几条彩色条纹。原因九成是时钟没配对。OLED的I2C是PB8/PB9但没有把GPIOB的AF复用功能设为开漏输出软件模拟I2C不需要AF可GPIO口速度没有设为HIGH导致翻转太慢。更隐蔽的是F407的I2C引脚和USB/以太网其他外设共用初始化顺序不对会造成引脚电平冲突。TFT的FSMC时序问题更多地址保持时间、数据建立时间没按ILI9341手册设置写周期太快或太慢都会花屏。解决先查初始化函数确认GPIO时钟已使能。OLED用软件模拟I2C时要把SCL和SDA配置成推挽输出速度50MHz。TFT则建议直接抄这个工程里FSMC的Timing配置主要参数是AddrSetupTime建议1、DataSetupTime建议4不要自己乱调。如果你用的是信号线比较长的杜邦线还可在FSMC与LCD之间串33Ω电阻能明显减少振铃。5.2 按键失灵上拉电阻和消抖的玄学现象按下按键没反应或者偶尔触发一次按一次却跳两下。原因按键没接外部上拉或者内部上拉没有开启。F407的内部上拉在复位后默认是浮空输入必须在初始化时显式设置GPIO_PuPd_UP。有的开发板按键电路已经有下拉你再用内部上拉就变成上下拉同时接容易锁定在高电平或低电平。另一个常见原因是没有加消抖机械按键在按下瞬间会产生约5-20ms的抖动信号被主循环当成多次按键。解决看过这个工程的board.h里的按键配置统一设为上拉输入并开启外部上拉电阻然后固定用状态机消抖。如果按下有响应但“偶尔掉落”就加大消抖计数从10提高到20代价是操作延迟增加约10ms对贪吃蛇这种游戏感知不明显。5.3 蛇身闪烁缓冲区刷新方式不对现象蛇在移动时屏幕有残影特别是在边框和食物附近或者整个屏闪得像老式CRT。原因刷屏方式不对。很多人用OLED时每帧都是先用清屏函数将全屏填充为0再画蛇身这样每帧都有一次整屏写操作。SSD1306的写操作带宽有限整屏清空再重画会导致中间状态被看到即闪烁。解决改成局部刷新法。这个工程维护了一个“脏矩形”概念只有变化区域才重刷。具体做法是维护前后两帧画面缓冲区在内部RAM里做diff只把变了位置的格子推送到OLED。F407的RAM足够放下OLED的1KB显示缓冲操作也不复杂// 局部刷新比较新旧显示缓冲区只发送变化字节 static uint8_t screen_buf[ 128 * 8 ]; // OLED页缓冲 static uint8_t old_buf[ 128 * 8 ]; void oled_refresh_partial(void) { for (uint16_t i 0; i sizeof(screen_buf); i) { if (screen_buf[i] ! old_buf[i]) { oled_write_byte(i, screen_buf[i]); // 写入对应位置 old_buf[i] screen_buf[i]; } } }注意这种diff刷新的前提是每次更新画面时先把所有内容画到screen_buf最后统一调用oled_refresh_partial。如果中途直接操作OLED硬件新旧缓存就不同步了。TFT-LCD则建议用FSMC直接把显存写在LCD内部的GRAM上不需要局部刷新因为FSMC写速度足够快。5.4 随机食物位置rand()的种子问题现象每次重新开始游戏第一颗食物的位置都一样或者食物固定在某个循环序列里。原因C库的rand()在未设置种子时默认种子为1所以每次上电产生的随机序列完全相同。很多教程只在游戏初始化时调用一次srand(time(NULL))但MCU上没有RTC的话time(NULL)返回的是固定值等价于没设种子。解决用真实随机源作为种子。F407有一个很管用的技巧用片内温度传感器或随机电压噪声也可以用ADC读取一个悬空引脚的连续值做种子。这个工程采用的是利用TIM2的计数低位做种子void game_init_seed(void) { uint16_t seed 0; // 读取定时器计数器的低16位翻转速度快近似随机 seed (uint16_t)(TIM2-CNT 0xFFFF); srand(seed); }注意TIM2的计数器在每次按键、每次系统事件后都会变化所以种子基本不会重复。如果你更讲究可以连续取10次CNT值做CRC但实际游戏中这样已经够用。食物坐标生成后还要确保不出现在蛇身上判断逻辑跟碰撞检测复用即可。6. 进阶玩法与验证加个状态LED、记录最高分顺手用逻辑分析仪确认时序这个资源包里的代码已经能跑通完整游戏但要让它变成一个“拿得出手”的作品我强烈建议你加三个小功能。第一个是状态提示灯在菜单、运行、暂停、结束四个状态让板载LED以不同频率闪烁。这不仅是“好看”在调试时能一眼看到状态机是否切换对了。比如我调试时发现按暂停后蛇还在走就是靠LED频率不对抓出来的。第二个是最高分记录。F407内置RTC和备份寄存器在掉电复位后备份寄存器内容不会丢。你可以把最高分存到BKP寄存器里。注意F407的BKP区域写保护需要解锁先执行PWR_BackupAccessCmd(ENABLE)再操作相关寄存器。这部分代码不需要额外芯片比外挂EEPROM省事。第三个是验证时序。很多人对OLED的I2C波形或TFT的FSMC时序心里没底与其靠肉眼判断不如用逻辑分析仪抓一下。常见的8通道USB逻辑分析仪就能做到抓取SCL、SDA和片选信号看波形是否符合设备手册。我之前一直怀疑TFT花屏是FSMC时序太紧张抓了波形后发现数据建立时间确实只有1个周期调到4后一切正常。从那以后我每次移植这类显示驱动都会强制自己先看一遍关键总线波形再决定要不要改配置。这个习惯节省了大量盲调时间希望帮到你。本文还有配套的精品资源点击获取