基于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引脚,并将该引脚配置为输入上拉模式。当按键按下时,引脚被拉低,程序检测到低电平即视为一次有效输入。为了确保操控手感并防止按键抖动造成的误触发,我们需要在软件中实现消抖逻辑。更进阶一些,可以考虑使用一个五向摇杆模块(上下左右中),它本质上也是多个按键的集合,但集成度更高,操作更符合游戏直觉。在电路连接上,务必为每个按键引脚预留一个104(0.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 硬件连接与初始调试
问题1:OLED屏幕不亮或显示乱码。
- 检查电源:确认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)是非常有效的手段。你可以实时打印出蛇的坐标、食物坐标、按键值、游戏状态等,帮助快速定位逻辑错误。
最后,代码的模块化和可读性至关重要。将硬件驱动、游戏逻辑、图形界面清晰地分离,使用有意义的变量名和函数名,添加必要的注释。这不仅方便你自己日后维护,也便于其他学习者理解和借鉴。当你看到那条由自己编写的代码驱动的像素小蛇,在亲手焊接的电路板上灵活游走时,那种将抽象逻辑转化为物理现实的乐趣,正是嵌入式开发最吸引人的地方。