1. 项目概述当经典游戏遇上嵌入式硬件贪吃蛇这个几乎刻在每个人数字记忆里的经典游戏如今早已不局限于诺基亚手机的屏幕。作为一名嵌入式开发者我总喜欢用一些有趣的小项目来验证和巩固对硬件的理解。这次我选择了用一块性价比极高的STM32F103C8T6核心板搭配一块小巧的0.96寸OLED显示屏来亲手复现这个经典。这不仅仅是一个简单的代码移植它更像是一次对MCU资源管理、外设驱动和实时逻辑控制的综合演练。对于刚接触STM32的新手来说它能让你快速建立起对GPIO、I2C通信、定时器中断以及状态机编程的直观认识而对于有经验的开发者如何在这个资源有限的“蓝色药丸”STM32F103C8T6开发板的昵称上优雅地组织代码、实现流畅的动画和灵敏的操控也是一个值得琢磨的挑战。最终你将得到一个可以握在掌心、完全独立运行的迷你游戏机那种从零到一创造交互体验的成就感是单纯调用API无法比拟的。2. 核心硬件选型与电路设计思路2.1 为什么是STM32F103C8T6在开始写代码之前硬件平台的选型决定了项目的天花板和地基。STM32F103C8T6这颗被广大电子爱好者戏称为“蓝色药丸”的MCU几乎是入门STM32世界的标准答案。它基于ARM Cortex-M3内核主频72MHz拥有64KB的Flash和20KB的SRAM。对于贪吃蛇游戏来说这个配置堪称“豪华”。游戏逻辑、显示缓存、状态变量所需的内存和存储空间远小于其资源上限这意味着我们不必在代码优化上绞尽脑汁可以更专注于游戏逻辑本身。其丰富的GPIO、多个定时器和通信接口如I2C、SPI、USART也为未来扩展比如增加蜂鸣器、更多按键或无线模块预留了充足的空间。市面上围绕它构建的最小系统板价格低廉、资料海量极大地降低了学习和试错成本。2.2 OLED显示屏的驱动选择I2C vs SPI显示部分我选择了0.96寸的OLED屏分辨率通常是128x64。这类屏的驱动芯片多为SSD1306。驱动方式主要有I2C和SPI两种。I2C接口只需要两根信号线SCL SDA加上电源和地接线极其简洁特别适合引脚资源紧张或追求布线整洁的项目。SPI接口则需要更多的线至少SCLK MOSI CS DC 有时还有RST但通信速率更高刷新屏幕更快。对于贪吃蛇这种帧率要求不高通常10-20FPS足矣的游戏I2C模式完全够用而且能节省出宝贵的GPIO用于其他功能比如更多的控制按键。因此本项目采用I2C通信方式驱动OLED。在连接时需要注意OLED模块的供电电压常见有3.3V和5V务必使其与STM32的IO电平匹配通常直接使用STM32板的3.3V输出即可。2.3 控制输入的设计简约而不简单游戏离不开交互。一个最直接的方案是使用四个独立按键分别对应上下左右。将按键一端接地另一端通过一个上拉电阻连接到STM32的GPIO引脚并将该引脚配置为输入上拉模式。当按键按下时引脚被拉低程序检测到低电平即视为一次有效输入。为了确保操控手感并防止按键抖动造成的误触发我们需要在软件中实现消抖逻辑。更进阶一些可以考虑使用一个五向摇杆模块上下左右中它本质上也是多个按键的集合但集成度更高操作更符合游戏直觉。在电路连接上务必为每个按键引脚预留一个1040.1uF的电容到地这在硬件上能滤除一部分抖动干扰。3. 软件架构与核心模块解析3.1 工程骨架从零搭建开发环境我使用的是Keil MDK-ARM作为IDE配合STM32标准外设库进行开发。为什么不直接用HAL库或CubeMX生成对于这样一个明确、外设简单的项目标准库能让你更清晰地掌控每一个寄存器理解底层硬件是如何被操作的这对于打牢基础至关重要。工程目录结构应该清晰划分User/存放主循环main.c、游戏主逻辑snake_game.c/h。Hardware/存放硬件驱动层如oled.c/h、key.c/h。Library/存放STM32标准库文件。System/存放系统初始化、延时函数等。首先在main.c中完成系统时钟初始化通常设置为72MHz、SysTick定时器初始化用于毫秒级延时、GPIO和I2C外设的初始化。然后初始化OLED显示屏和按键模块。最后进入主循环循环内不断扫描按键、更新游戏逻辑、刷新显示。3.2 OLED驱动层实现让屏幕亮起来驱动OLED的核心是与SSD1306芯片通信。我们需要实现几个基本函数OLED_Write_Cmd(uint8_t cmd)发送命令字节。OLED_Write_Data(uint8_t data)发送数据字节。OLED_Init(void)初始化序列包括设置对比度、显示模式、扫描方向等。OLED_Clear(void)清屏。OLED_Refresh(void)将内存中的显存缓冲区更新到OLED硬件。这里有一个关键技巧使用页缓存Page Buffer而非全屏缓存。SSD1306的显存结构是8页Page0-Page7每页128列每列8位即一个字节代表8个垂直像素。我们可以在STM32的RAM中开辟一个二维数组uint8_t OLED_Buffer[8][128]作为显存缓冲区。任何画点、画线、显示字符的操作都只修改这个缓冲区。当需要刷新整屏时调用OLED_Refresh函数通过I2C将整个缓冲区数据一次性发送给SSD1306。这种方式比每次画图都直接操作硬件快得多也避免了屏幕闪烁。// 示例在缓冲区中画一个点 void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t mode) { // mode: 1点亮0熄灭 if(x 127 || y 63) return; // 边界检查 uint8_t page y / 8; uint8_t bit_pos y % 8; if(mode) { OLED_Buffer[page][x] | (1 bit_pos); } else { OLED_Buffer[page][x] ~(1 bit_pos); } }3.3 游戏逻辑层设计状态机与数据结构贪吃蛇游戏的核心逻辑可以用一个清晰的状态机来描述状态1菜单MENU。显示游戏标题、分数记录等待玩家按下开始键。状态2进行中PLAYING。核心游戏状态处理蛇的移动、食物生成、碰撞检测。状态3结束GAME_OVER。显示“Game Over”和最终得分等待玩家按键重启或返回菜单。蛇的身体用链表或一个定长数组来存储每个关节的坐标(x, y)。由于STM32内存有限且贪吃蛇长度不会无限增长使用一个足够大的结构体数组是更简单高效的选择。例如SnakeSegment_t snake[MAX_SNAKE_LEN];并用一个变量snake_length记录当前长度。蛇的移动逻辑是在移动方向上前进一格新的头部坐标成为新的第一节原有的每一节身体坐标依次向后覆盖一节最后一节坐标被丢弃如果没吃到食物或保留如果吃到食物。食物的生成需要随机数。STM32没有硬件随机数发生器我们可以利用ADC读取一个悬空引脚的电噪声作为随机种子或者使用一个不断累加的计时器值作为伪随机数源。生成食物坐标后必须检查其是否与蛇身重合。碰撞检测包括撞墙判断蛇头坐标是否超出屏幕边界。撞自身遍历蛇身数组从第二节开始检查是否有坐标与蛇头坐标相同。3.4 定时器与按键扫描游戏的节拍与响应游戏需要稳定的帧率。我们可以使用一个硬件定时器如TIM2产生固定间隔的中断例如100ms一次即10FPS。在定时器中断服务函数中设置一个标志位如game_tick_flag 1。在主循环中检测到这个标志位就执行一次游戏逻辑更新蛇移动、检测碰撞等然后清除标志位。这样无论主循环其他部分执行多久蛇的移动速度都是恒定、可预测的。按键扫描则放在主循环中采用非阻塞式扫描。不建议在中断中处理复杂的按键逻辑。我们可以每隔10-20ms扫描一次按键状态并使用一个状态机来消抖typedef enum {KEY_IDLE, KEY_DOWN, KEY_PRESSED, KEY_UP} KeyState; KeyState key_state KEY_IDLE; void Key_Scan(void) { static uint8_t key_debounce_cnt 0; uint8_t current_pin_state GPIO_ReadInputDataBit(KEY_GPIO_PORT, KEY_PIN); switch(key_state) { case KEY_IDLE: if(current_pin_state 0) { // 按键按下低电平有效 key_debounce_cnt; if(key_debounce_cnt DEBOUNCE_THRESHOLD) { key_state KEY_DOWN; key_debounce_cnt 0; } } break; case KEY_DOWN: key_state KEY_PRESSED; // 确认按下 break; case KEY_PRESSED: if(current_pin_state 1) { // 按键释放 key_debounce_cnt; if(key_debounce_cnt DEBOUNCE_THRESHOLD) { key_state KEY_UP; key_debounce_cnt 0; } } break; case KEY_UP: key_state KEY_IDLE; // 回到空闲一次完整的按键周期结束 break; } }在KEY_DOWN或KEY_PRESSED状态我们可以去改变蛇的移动方向。注意通常需要禁止蛇直接反向移动例如正在向右移动时不能立即按左键转向。4. 核心代码实现与分步详解4.1 主循环与游戏状态调度主函数main.c的结构是游戏的总调度中心。它的职责是初始化一切然后在一个永不结束的循环中根据当前游戏状态调用不同的处理函数。int main(void) { // 1. 系统初始化 System_Init(); // 时钟、SysTick GPIO_Init(); // 按键GPIO I2C_Init(); // I2C用于OLED TIM2_Init(); // 游戏时钟定时器 // 2. 外设初始化 OLED_Init(); OLED_Clear(); Key_Init(); // 3. 游戏初始化 Game_Init(); // 设置初始状态为MENU初始化蛇和食物 // 4. 主循环 while(1) { Key_Scan(); // 持续扫描按键 switch(game_state) { case MENU: Game_Menu_Process(); // 显示菜单处理开始键 break; case PLAYING: if(game_tick_flag) { // 定时器触发了逻辑更新 game_tick_flag 0; Game_Logic_Update(); // 移动蛇检测碰撞 } Game_Draw(); // 绘制当前帧蛇、食物、分数 break; case GAME_OVER: Game_Over_Process(); // 显示结束画面处理重启键 break; } OLED_Refresh(); // 将缓冲区内容刷到屏幕 Delay_ms(5); // 短暂延时避免主循环空跑耗电 } }4.2 蛇的移动与生长算法实现Game_Logic_Update()函数是游戏的核心引擎。其伪代码如下void Game_Logic_Update(void) { // 1. 根据当前方向计算新的蛇头坐标 int8_t new_head_x snake[0].x; int8_t new_head_y snake[0].y; switch(current_direction) { case UP: new_head_y--; break; case DOWN: new_head_y; break; case LEFT: new_head_x--; break; case RIGHT: new_head_x; break; } // 2. 碰撞检测 if( new_head_x 0 || new_head_x SCREEN_WIDTH || new_head_y 0 || new_head_y SCREEN_HEIGHT || Is_Snake_Body(new_head_x, new_head_y) ) { game_state GAME_OVER; return; } // 3. 检查是否吃到食物 uint8_t eat_food 0; if(new_head_x food.x new_head_y food.y) { eat_food 1; score; Generate_Food(); // 在随机且不与蛇身重合的位置生成新食物 } // 4. 移动蛇身 // 如果没吃到食物蛇尾需要被“剪掉”即整体前移 // 如果吃到食物蛇尾保留即长度增加 if(!eat_food) { // 从蛇尾向前覆盖为新的蛇头腾出位置 for(int i snake_length-1; i 0; i--) { snake[i] snake[i-1]; } } else { // 吃到食物所有身体节向后移动一格新的蛇头插入在最前面 // 需要先扩大数组将原有身体后移 for(int i snake_length; i 0; i--) { snake[i] snake[i-1]; } snake_length; // 长度增加 } // 设置新的蛇头 snake[0].x new_head_x; snake[0].y new_head_y; }这里Is_Snake_Body函数需要遍历蛇身数组从索引1开始因为索引0是即将成为旧头的当前头检查坐标是否重合。4.3 图形绘制与分数显示绘制函数Game_Draw()负责将游戏状态可视化。它需要做以下几件事清空OLED缓冲区或用背景色填充。绘制蛇身遍历蛇身数组将每个坐标对应的像素点点亮。为了美观蛇头可以用一个稍大的矩形或不同的颜色如果OLED支持表示。绘制食物在food.x, food.y坐标绘制一个简单的图形比如一个实心小方块或一个“*”号。绘制分数和边界在屏幕的顶部或底部区域用小型字体显示当前得分score。还可以在屏幕四周画一个边框。绘制时要注意坐标转换。我们的游戏逻辑坐标可能是以像素为单位的如0-127 0-63而OLED的绘图函数需要直接操作这些坐标。画蛇身和食物时直接调用前面实现的OLED_DrawPoint或基于它构建的OLED_DrawRectangle函数。分数显示需要用到字库。我们可以使用一个取模软件生成8x16或6x8点阵的ASCII字符集将其以常量数组的形式存储在代码中。显示时根据字符的ASCII码找到对应的点阵数据逐行写入OLED缓冲区。5. 调试技巧、常见问题与优化方案5.1 硬件连接与初始调试问题1OLED屏幕不亮或显示乱码。检查电源确认OLED的VCC和GND是否正确连接电压是否匹配3.3V。检查I2C地址SSD1306的I2C地址通常是0x78写或0x7A读。用逻辑分析仪或STM32的I2C扫描程序确认地址是否正确。有些模块需要焊接电阻来选择地址。检查初始化序列SSD1306的初始化命令顺序必须严格按照数据手册。一个命令错误就可能导致屏幕无法正常显示。建议将初始化命令列表与官方例程或成熟驱动库进行比对。检查上拉电阻I2C总线的SCL和SDA线需要接上拉电阻通常4.7KΩ到10KΩ模块内部可能已经集成如果没有需要外部添加。问题2按键控制不灵或连击。消抖是关键务必实现可靠的软件消抖如前面状态机所示。消抖时间阈值DEBOUNCE_THRESHOLD对应的实际时间通常设置在10-50ms之间需要通过实验调整。检查GPIO模式按键GPIO应配置为“输入上拉”模式。这样当按键断开时引脚被内部电阻拉高按下时被外部接地拉低。防止反向移动在改变蛇方向的代码里加入判断。例如如果当前方向是RIGHT那么按下LEFT键的输入应该被忽略。5.2 软件逻辑与性能问题问题3蛇移动卡顿或不流畅。帧率与移动速度分离确保游戏逻辑更新Game_Logic_Update的频率由定时器控制和屏幕刷新频率OLED_Refresh是解耦的。逻辑更新可以10Hz屏幕刷新可以30Hz。屏幕刷新只负责将当前缓冲区画出来不受逻辑计算耗时影响。优化绘制不要每帧都清空全屏再重绘所有元素。可以采用“脏矩形”技术只重绘发生变化的部分如蛇头新位置、蛇尾旧位置、食物被吃的位置。但对于这个小游戏全屏重绘在72MHz的STM32上压力不大。检查阻塞操作避免在Game_Logic_Update或绘制函数中使用长时间的Delay。所有延时都应使用非阻塞的方式如检查SysTick计数器。问题4随机食物生成在蛇身上。生成算法当检测到食物坐标与蛇身重合时必须重新生成直到找到一个空闲位置。为了避免在蛇很长时陷入长时间循环可以设置一个最大尝试次数比如100次如果超过次数可以遍历整个游戏区域寻找空闲点空间换时间。5.3 项目扩展与进阶优化基础功能实现后可以考虑以下扩展让项目更具挑战性和学习价值增加游戏特性加入不同类型的食物加速、减速、穿墙道具设计关卡随着分数增加蛇的移动速度加快或地图出现障碍物。优化显示效果实现平滑移动像素间插值虽然OLED是单色但可以通过多帧动画让蛇的转向看起来更圆滑。为菜单和游戏结束界面设计更精美的图形。引入声音连接一个无源蜂鸣器用PWM驱动为吃到食物、撞墙、游戏结束等事件添加简单的音效。保存最高分利用STM32F103C8T6内部的Flash模拟EEPROM保存玩家的历史最高分即使断电也不会丢失。改用RTOS尝试在FreeRTOS上重构这个游戏。可以创建不同的任务一个任务处理按键和游戏逻辑一个任务负责显示刷新一个任务管理声音。这能让你学习多任务编程和任务间通信。在调试过程中善用STM32的串口打印调试信息printf重定向到USART是非常有效的手段。你可以实时打印出蛇的坐标、食物坐标、按键值、游戏状态等帮助快速定位逻辑错误。最后代码的模块化和可读性至关重要。将硬件驱动、游戏逻辑、图形界面清晰地分离使用有意义的变量名和函数名添加必要的注释。这不仅方便你自己日后维护也便于其他学习者理解和借鉴。当你看到那条由自己编写的代码驱动的像素小蛇在亲手焊接的电路板上灵活游走时那种将抽象逻辑转化为物理现实的乐趣正是嵌入式开发最吸引人的地方。
基于STM32F103C8T6与OLED的贪吃蛇游戏开发实战
1. 项目概述当经典游戏遇上嵌入式硬件贪吃蛇这个几乎刻在每个人数字记忆里的经典游戏如今早已不局限于诺基亚手机的屏幕。作为一名嵌入式开发者我总喜欢用一些有趣的小项目来验证和巩固对硬件的理解。这次我选择了用一块性价比极高的STM32F103C8T6核心板搭配一块小巧的0.96寸OLED显示屏来亲手复现这个经典。这不仅仅是一个简单的代码移植它更像是一次对MCU资源管理、外设驱动和实时逻辑控制的综合演练。对于刚接触STM32的新手来说它能让你快速建立起对GPIO、I2C通信、定时器中断以及状态机编程的直观认识而对于有经验的开发者如何在这个资源有限的“蓝色药丸”STM32F103C8T6开发板的昵称上优雅地组织代码、实现流畅的动画和灵敏的操控也是一个值得琢磨的挑战。最终你将得到一个可以握在掌心、完全独立运行的迷你游戏机那种从零到一创造交互体验的成就感是单纯调用API无法比拟的。2. 核心硬件选型与电路设计思路2.1 为什么是STM32F103C8T6在开始写代码之前硬件平台的选型决定了项目的天花板和地基。STM32F103C8T6这颗被广大电子爱好者戏称为“蓝色药丸”的MCU几乎是入门STM32世界的标准答案。它基于ARM Cortex-M3内核主频72MHz拥有64KB的Flash和20KB的SRAM。对于贪吃蛇游戏来说这个配置堪称“豪华”。游戏逻辑、显示缓存、状态变量所需的内存和存储空间远小于其资源上限这意味着我们不必在代码优化上绞尽脑汁可以更专注于游戏逻辑本身。其丰富的GPIO、多个定时器和通信接口如I2C、SPI、USART也为未来扩展比如增加蜂鸣器、更多按键或无线模块预留了充足的空间。市面上围绕它构建的最小系统板价格低廉、资料海量极大地降低了学习和试错成本。2.2 OLED显示屏的驱动选择I2C vs SPI显示部分我选择了0.96寸的OLED屏分辨率通常是128x64。这类屏的驱动芯片多为SSD1306。驱动方式主要有I2C和SPI两种。I2C接口只需要两根信号线SCL SDA加上电源和地接线极其简洁特别适合引脚资源紧张或追求布线整洁的项目。SPI接口则需要更多的线至少SCLK MOSI CS DC 有时还有RST但通信速率更高刷新屏幕更快。对于贪吃蛇这种帧率要求不高通常10-20FPS足矣的游戏I2C模式完全够用而且能节省出宝贵的GPIO用于其他功能比如更多的控制按键。因此本项目采用I2C通信方式驱动OLED。在连接时需要注意OLED模块的供电电压常见有3.3V和5V务必使其与STM32的IO电平匹配通常直接使用STM32板的3.3V输出即可。2.3 控制输入的设计简约而不简单游戏离不开交互。一个最直接的方案是使用四个独立按键分别对应上下左右。将按键一端接地另一端通过一个上拉电阻连接到STM32的GPIO引脚并将该引脚配置为输入上拉模式。当按键按下时引脚被拉低程序检测到低电平即视为一次有效输入。为了确保操控手感并防止按键抖动造成的误触发我们需要在软件中实现消抖逻辑。更进阶一些可以考虑使用一个五向摇杆模块上下左右中它本质上也是多个按键的集合但集成度更高操作更符合游戏直觉。在电路连接上务必为每个按键引脚预留一个1040.1uF的电容到地这在硬件上能滤除一部分抖动干扰。3. 软件架构与核心模块解析3.1 工程骨架从零搭建开发环境我使用的是Keil MDK-ARM作为IDE配合STM32标准外设库进行开发。为什么不直接用HAL库或CubeMX生成对于这样一个明确、外设简单的项目标准库能让你更清晰地掌控每一个寄存器理解底层硬件是如何被操作的这对于打牢基础至关重要。工程目录结构应该清晰划分User/存放主循环main.c、游戏主逻辑snake_game.c/h。Hardware/存放硬件驱动层如oled.c/h、key.c/h。Library/存放STM32标准库文件。System/存放系统初始化、延时函数等。首先在main.c中完成系统时钟初始化通常设置为72MHz、SysTick定时器初始化用于毫秒级延时、GPIO和I2C外设的初始化。然后初始化OLED显示屏和按键模块。最后进入主循环循环内不断扫描按键、更新游戏逻辑、刷新显示。3.2 OLED驱动层实现让屏幕亮起来驱动OLED的核心是与SSD1306芯片通信。我们需要实现几个基本函数OLED_Write_Cmd(uint8_t cmd)发送命令字节。OLED_Write_Data(uint8_t data)发送数据字节。OLED_Init(void)初始化序列包括设置对比度、显示模式、扫描方向等。OLED_Clear(void)清屏。OLED_Refresh(void)将内存中的显存缓冲区更新到OLED硬件。这里有一个关键技巧使用页缓存Page Buffer而非全屏缓存。SSD1306的显存结构是8页Page0-Page7每页128列每列8位即一个字节代表8个垂直像素。我们可以在STM32的RAM中开辟一个二维数组uint8_t OLED_Buffer[8][128]作为显存缓冲区。任何画点、画线、显示字符的操作都只修改这个缓冲区。当需要刷新整屏时调用OLED_Refresh函数通过I2C将整个缓冲区数据一次性发送给SSD1306。这种方式比每次画图都直接操作硬件快得多也避免了屏幕闪烁。// 示例在缓冲区中画一个点 void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t mode) { // mode: 1点亮0熄灭 if(x 127 || y 63) return; // 边界检查 uint8_t page y / 8; uint8_t bit_pos y % 8; if(mode) { OLED_Buffer[page][x] | (1 bit_pos); } else { OLED_Buffer[page][x] ~(1 bit_pos); } }3.3 游戏逻辑层设计状态机与数据结构贪吃蛇游戏的核心逻辑可以用一个清晰的状态机来描述状态1菜单MENU。显示游戏标题、分数记录等待玩家按下开始键。状态2进行中PLAYING。核心游戏状态处理蛇的移动、食物生成、碰撞检测。状态3结束GAME_OVER。显示“Game Over”和最终得分等待玩家按键重启或返回菜单。蛇的身体用链表或一个定长数组来存储每个关节的坐标(x, y)。由于STM32内存有限且贪吃蛇长度不会无限增长使用一个足够大的结构体数组是更简单高效的选择。例如SnakeSegment_t snake[MAX_SNAKE_LEN];并用一个变量snake_length记录当前长度。蛇的移动逻辑是在移动方向上前进一格新的头部坐标成为新的第一节原有的每一节身体坐标依次向后覆盖一节最后一节坐标被丢弃如果没吃到食物或保留如果吃到食物。食物的生成需要随机数。STM32没有硬件随机数发生器我们可以利用ADC读取一个悬空引脚的电噪声作为随机种子或者使用一个不断累加的计时器值作为伪随机数源。生成食物坐标后必须检查其是否与蛇身重合。碰撞检测包括撞墙判断蛇头坐标是否超出屏幕边界。撞自身遍历蛇身数组从第二节开始检查是否有坐标与蛇头坐标相同。3.4 定时器与按键扫描游戏的节拍与响应游戏需要稳定的帧率。我们可以使用一个硬件定时器如TIM2产生固定间隔的中断例如100ms一次即10FPS。在定时器中断服务函数中设置一个标志位如game_tick_flag 1。在主循环中检测到这个标志位就执行一次游戏逻辑更新蛇移动、检测碰撞等然后清除标志位。这样无论主循环其他部分执行多久蛇的移动速度都是恒定、可预测的。按键扫描则放在主循环中采用非阻塞式扫描。不建议在中断中处理复杂的按键逻辑。我们可以每隔10-20ms扫描一次按键状态并使用一个状态机来消抖typedef enum {KEY_IDLE, KEY_DOWN, KEY_PRESSED, KEY_UP} KeyState; KeyState key_state KEY_IDLE; void Key_Scan(void) { static uint8_t key_debounce_cnt 0; uint8_t current_pin_state GPIO_ReadInputDataBit(KEY_GPIO_PORT, KEY_PIN); switch(key_state) { case KEY_IDLE: if(current_pin_state 0) { // 按键按下低电平有效 key_debounce_cnt; if(key_debounce_cnt DEBOUNCE_THRESHOLD) { key_state KEY_DOWN; key_debounce_cnt 0; } } break; case KEY_DOWN: key_state KEY_PRESSED; // 确认按下 break; case KEY_PRESSED: if(current_pin_state 1) { // 按键释放 key_debounce_cnt; if(key_debounce_cnt DEBOUNCE_THRESHOLD) { key_state KEY_UP; key_debounce_cnt 0; } } break; case KEY_UP: key_state KEY_IDLE; // 回到空闲一次完整的按键周期结束 break; } }在KEY_DOWN或KEY_PRESSED状态我们可以去改变蛇的移动方向。注意通常需要禁止蛇直接反向移动例如正在向右移动时不能立即按左键转向。4. 核心代码实现与分步详解4.1 主循环与游戏状态调度主函数main.c的结构是游戏的总调度中心。它的职责是初始化一切然后在一个永不结束的循环中根据当前游戏状态调用不同的处理函数。int main(void) { // 1. 系统初始化 System_Init(); // 时钟、SysTick GPIO_Init(); // 按键GPIO I2C_Init(); // I2C用于OLED TIM2_Init(); // 游戏时钟定时器 // 2. 外设初始化 OLED_Init(); OLED_Clear(); Key_Init(); // 3. 游戏初始化 Game_Init(); // 设置初始状态为MENU初始化蛇和食物 // 4. 主循环 while(1) { Key_Scan(); // 持续扫描按键 switch(game_state) { case MENU: Game_Menu_Process(); // 显示菜单处理开始键 break; case PLAYING: if(game_tick_flag) { // 定时器触发了逻辑更新 game_tick_flag 0; Game_Logic_Update(); // 移动蛇检测碰撞 } Game_Draw(); // 绘制当前帧蛇、食物、分数 break; case GAME_OVER: Game_Over_Process(); // 显示结束画面处理重启键 break; } OLED_Refresh(); // 将缓冲区内容刷到屏幕 Delay_ms(5); // 短暂延时避免主循环空跑耗电 } }4.2 蛇的移动与生长算法实现Game_Logic_Update()函数是游戏的核心引擎。其伪代码如下void Game_Logic_Update(void) { // 1. 根据当前方向计算新的蛇头坐标 int8_t new_head_x snake[0].x; int8_t new_head_y snake[0].y; switch(current_direction) { case UP: new_head_y--; break; case DOWN: new_head_y; break; case LEFT: new_head_x--; break; case RIGHT: new_head_x; break; } // 2. 碰撞检测 if( new_head_x 0 || new_head_x SCREEN_WIDTH || new_head_y 0 || new_head_y SCREEN_HEIGHT || Is_Snake_Body(new_head_x, new_head_y) ) { game_state GAME_OVER; return; } // 3. 检查是否吃到食物 uint8_t eat_food 0; if(new_head_x food.x new_head_y food.y) { eat_food 1; score; Generate_Food(); // 在随机且不与蛇身重合的位置生成新食物 } // 4. 移动蛇身 // 如果没吃到食物蛇尾需要被“剪掉”即整体前移 // 如果吃到食物蛇尾保留即长度增加 if(!eat_food) { // 从蛇尾向前覆盖为新的蛇头腾出位置 for(int i snake_length-1; i 0; i--) { snake[i] snake[i-1]; } } else { // 吃到食物所有身体节向后移动一格新的蛇头插入在最前面 // 需要先扩大数组将原有身体后移 for(int i snake_length; i 0; i--) { snake[i] snake[i-1]; } snake_length; // 长度增加 } // 设置新的蛇头 snake[0].x new_head_x; snake[0].y new_head_y; }这里Is_Snake_Body函数需要遍历蛇身数组从索引1开始因为索引0是即将成为旧头的当前头检查坐标是否重合。4.3 图形绘制与分数显示绘制函数Game_Draw()负责将游戏状态可视化。它需要做以下几件事清空OLED缓冲区或用背景色填充。绘制蛇身遍历蛇身数组将每个坐标对应的像素点点亮。为了美观蛇头可以用一个稍大的矩形或不同的颜色如果OLED支持表示。绘制食物在food.x, food.y坐标绘制一个简单的图形比如一个实心小方块或一个“*”号。绘制分数和边界在屏幕的顶部或底部区域用小型字体显示当前得分score。还可以在屏幕四周画一个边框。绘制时要注意坐标转换。我们的游戏逻辑坐标可能是以像素为单位的如0-127 0-63而OLED的绘图函数需要直接操作这些坐标。画蛇身和食物时直接调用前面实现的OLED_DrawPoint或基于它构建的OLED_DrawRectangle函数。分数显示需要用到字库。我们可以使用一个取模软件生成8x16或6x8点阵的ASCII字符集将其以常量数组的形式存储在代码中。显示时根据字符的ASCII码找到对应的点阵数据逐行写入OLED缓冲区。5. 调试技巧、常见问题与优化方案5.1 硬件连接与初始调试问题1OLED屏幕不亮或显示乱码。检查电源确认OLED的VCC和GND是否正确连接电压是否匹配3.3V。检查I2C地址SSD1306的I2C地址通常是0x78写或0x7A读。用逻辑分析仪或STM32的I2C扫描程序确认地址是否正确。有些模块需要焊接电阻来选择地址。检查初始化序列SSD1306的初始化命令顺序必须严格按照数据手册。一个命令错误就可能导致屏幕无法正常显示。建议将初始化命令列表与官方例程或成熟驱动库进行比对。检查上拉电阻I2C总线的SCL和SDA线需要接上拉电阻通常4.7KΩ到10KΩ模块内部可能已经集成如果没有需要外部添加。问题2按键控制不灵或连击。消抖是关键务必实现可靠的软件消抖如前面状态机所示。消抖时间阈值DEBOUNCE_THRESHOLD对应的实际时间通常设置在10-50ms之间需要通过实验调整。检查GPIO模式按键GPIO应配置为“输入上拉”模式。这样当按键断开时引脚被内部电阻拉高按下时被外部接地拉低。防止反向移动在改变蛇方向的代码里加入判断。例如如果当前方向是RIGHT那么按下LEFT键的输入应该被忽略。5.2 软件逻辑与性能问题问题3蛇移动卡顿或不流畅。帧率与移动速度分离确保游戏逻辑更新Game_Logic_Update的频率由定时器控制和屏幕刷新频率OLED_Refresh是解耦的。逻辑更新可以10Hz屏幕刷新可以30Hz。屏幕刷新只负责将当前缓冲区画出来不受逻辑计算耗时影响。优化绘制不要每帧都清空全屏再重绘所有元素。可以采用“脏矩形”技术只重绘发生变化的部分如蛇头新位置、蛇尾旧位置、食物被吃的位置。但对于这个小游戏全屏重绘在72MHz的STM32上压力不大。检查阻塞操作避免在Game_Logic_Update或绘制函数中使用长时间的Delay。所有延时都应使用非阻塞的方式如检查SysTick计数器。问题4随机食物生成在蛇身上。生成算法当检测到食物坐标与蛇身重合时必须重新生成直到找到一个空闲位置。为了避免在蛇很长时陷入长时间循环可以设置一个最大尝试次数比如100次如果超过次数可以遍历整个游戏区域寻找空闲点空间换时间。5.3 项目扩展与进阶优化基础功能实现后可以考虑以下扩展让项目更具挑战性和学习价值增加游戏特性加入不同类型的食物加速、减速、穿墙道具设计关卡随着分数增加蛇的移动速度加快或地图出现障碍物。优化显示效果实现平滑移动像素间插值虽然OLED是单色但可以通过多帧动画让蛇的转向看起来更圆滑。为菜单和游戏结束界面设计更精美的图形。引入声音连接一个无源蜂鸣器用PWM驱动为吃到食物、撞墙、游戏结束等事件添加简单的音效。保存最高分利用STM32F103C8T6内部的Flash模拟EEPROM保存玩家的历史最高分即使断电也不会丢失。改用RTOS尝试在FreeRTOS上重构这个游戏。可以创建不同的任务一个任务处理按键和游戏逻辑一个任务负责显示刷新一个任务管理声音。这能让你学习多任务编程和任务间通信。在调试过程中善用STM32的串口打印调试信息printf重定向到USART是非常有效的手段。你可以实时打印出蛇的坐标、食物坐标、按键值、游戏状态等帮助快速定位逻辑错误。最后代码的模块化和可读性至关重要。将硬件驱动、游戏逻辑、图形界面清晰地分离使用有意义的变量名和函数名添加必要的注释。这不仅方便你自己日后维护也便于其他学习者理解和借鉴。当你看到那条由自己编写的代码驱动的像素小蛇在亲手焊接的电路板上灵活游走时那种将抽象逻辑转化为物理现实的乐趣正是嵌入式开发最吸引人的地方。