ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F103C8T6实战:I2C OLED + 摇杆ADC驱动贪吃蛇游戏

STM32F103C8T6实战:I2C OLED + 摇杆ADC驱动贪吃蛇游戏 这次我们来看一个 STM32 入门项目在 STM32F103C8T6 最小系统板上用一块 0.96 寸 I2C 接口 OLED 屏幕和一颗 PS2 摇杆实现一版“贪吃蛇大作战”。项目不涉及复杂的 GUI 框架也没有多余的外设核心就是把三件事跑通SSD1306 驱动的 I2C OLED 点亮、摇杆的双轴 ADC 采样、以及一整个贪吃蛇游戏循环在单片机上稳定运行。如果你正在学 STM32想做一个小而完整的课设或练手项目这个组合非常合适。代码量不大但覆盖了 GPIO、I2C、ADC、定时器、状态机和数据结构的经典用法。而且游戏效果是实时的OLED 上能看到蛇在移动、吃到食物变长、撞墙或撞到自己后结束这些反馈比点灯和串口打印直观得多。本文会按照“硬件接线 → CubeMX 工程配置 → OLED 驱动 → 摇杆方向解析 → 贪吃蛇逻辑 → 上板调试”的顺序完整展开并针对常见问题进行排查。代码基于 STM32 HAL 库编写支持常见的 STM32F103C8T6 蓝色最小系统板OLED 屏幕的控制器是 SSD1306通讯协议为 I2C。1. 核心能力速览先看项目规格方便判断这套方案是否适合你。能力项说明主控芯片STM32F103C8T6Cortex-M3主频 72MHz显示设备0.96 寸 OLEDSSD1306 控制器128x64 像素I2C 接口输入设备PS2 双轴摇杆模块X/Y 两路模拟电压输出一路按键SW游戏内容贪吃蛇大作战地图移动、随机食物、蛇身增长、碰撞死亡、分数显示软件开发方式STM32CubeMX 生成 HAL 工程 Keil MDK 编译烧录涉及外设GPIOI2C 开漏引脚、I2C1、ADC1双通道、定时器 TIM硬件成本约 15-30 元开发板 OLED 摇杆 杜邦线是否支持批量任务不涉及此类 MCU 项目为单机实时任务无批量接口概念适合读者STM32 入门者、电子课设、想综合使用 I2CADC定时器的开发者这套方案的“门槛”很低STM32F103C8T6 本身是入门型号内置 64KB Flash 和 20KB SRAM跑贪吃蛇绰绰有余。OLED 模块在淘宝或立创商城几块钱就能买到摇杆模块也便宜接线就只有 6-7 根。真正花时间的不是硬件而是写清驱动逻辑和游戏状态机。2. 适用场景与使用边界这个项目最适合三种人第一是刚开始学 STM32、点过灯但没系统用过外设的初学者。把 I2C、ADC、定时器放到一个游戏项目里比逐个点灯更容易理解外设是怎么配合工作的。第二是做课程设计或毕业设计小项目的学生。贪吃蛇虽然常见但完整实现“显示 输入 逻辑 音效可选”并不简单适合作为嵌入式方向的课程设计。第三是想快速复现 OLED 驱动、摇杆采样代码的工程师。SSD1306 的 I2C 驱动模式比较固定你可以把代码提取出来直接用在其它屏幕菜单、仪表盘项目里。使用边界方面要注意几点这个项目面向单机单屏小游戏不适合做复杂 UI、触控交互或需要保存成绩记录的场景那需要更强大的方案比如屏幕 Flash 触摸。摇杆 ADC 采样默认用 3.3V 参考电压采样结果直接和供电电压相关如果你的开发板供电不稳定或 VREF 有偏差方向判定阈值要重新标定。另外SSD1306 的初始化序列和驱动代码各厂商可能略有差异。如果你买到的是 7 引脚 SPI 版而不是 4 引脚 I2C 版需要替换为 SPI 驱动。使用这个项目做课设或毕设时要注意参考代码的开源协议。如果你从 GitHub 或 CSDN 下载了 OLED 驱动库查看是否允许商用和署名多数 SSD1306 驱动是 MIT 或 BSD 协议但务必先确认。3. 硬件准备与接线硬件清单如下硬件型号 / 规格数量主控板STM32F103C8T6 最小系统板蓝色板1 块OLED 屏幕0.96 寸SSD1306I2C 接口4 引脚1 块摇杆模块PS2 摇杆双轴 按键1 个下载器ST-Link V2 或 USB 转 TTL 串口烧录器1 个杜邦线母对母、公对母若干若干面包板可选方便接线1 块OLED 模块的引脚定义比较固定GND 接电源地VCC 接 3.3VSCL 接 I2C 时钟线SDA 接 I2C 数据线。摇杆模块通常有 5 个引脚GND、VCC、VRxX 轴、VRyY 轴、SW按下输出低电平部分模块还会引出 5V 供电但在 3.3V 系统中建议统一接 3.3V。下面给出一组推荐的接线方式方便在 CubeMX 中配置时保持一致外设引脚STM32F103C8T6 引脚说明OLED GNDGND电源地OLED VCC3.3V屏幕供电OLED SCLPB6I2C1_SCLOLED SDAPB7I2C1_SDA摇杆 GNDGND电源地摇杆 VCC3.3V摇杆供电摇杆 VRxPA0ADC1_IN0X 轴摇杆 VRyPA1ADC1_IN1Y 轴摇杆 SWPA2可选数码按键复位/重新开始需要说明的是STM32F103C8T6 的 PB6/PB7 默认就是 I2C1 的复用引脚不需要额外映射PA0/PA1 也是 ADC1 的通道 0 和通道 1接线非常自然。SW 按键引脚如果不想使用可以不接但在游戏结束逻辑里建议用按键触发重新开局体验会好很多。接线时注意几点OLED 模块的 VCC 和 GND 不要接反接反会烧屏幕。I2C 总线上拉电阻在模块上通常已经集成开发板 I2C1 引脚也内置了上拉因此不需要外部再加。摇杆供电建议统一用 3.3V不要用 5V。虽然 5V 供电时摇杆输出范围是 0-5V但 STM32 的 ADC 引脚耐压有限超出 3.3V 会损坏引脚。如果开发板上有 5V 引脚也只能给摇杆供电但输出如果达到 5V 就危险所以稳妥方案是全部 3.3V 供电。4. 软件开发环境与工程创建开发环境选择芯片STM32F103C8T6固件库STM32CubeMX 生成的 HAL 库F1 固件包建议使用较新版本编译烧录Keil MDK 或 IARKeil 最常用辅助工具串口调试助手可选、ST-Link 驱动打开 STM32CubeMX新建一个基于 STM32F103C8Tx 的工程。下面逐个配置外设。4.1 时钟树配置F103 的最高主频是 72MHz。在 RCC 配置中选择 HSE 外部晶振然后进入 Clock Configuration 页面将 HCLK 设为 72MHz。大部分蓝色最小系统板板载 8MHz 晶振CubeMX 会按 8MHz 输入自动计算 PLL 倍频系数生成 72MHz 系统时钟。如果使用的板子没有外部晶振只能使用 HSI 内部时钟此时 HCLK 最大为 64MHz也可以正常跑贪吃蛇只是 OLED 的 I2C 时钟频率按 APB1 总线计算会偏低需要降低 I2C 速度。4.2 I2C 外设配置在 Peripherals 中选择 I2C1开启 I2C。具体配置配置项推荐值I2C Speed ModeStandard Mode 100KHz7-bit Address由从机决定OLED 通常为 0x3CAddress Size7-bit其它保持默认注意STM32 HAL 库初始化 I2C 时只有作为主机发送时才需要指定“从机地址”而在 CubeMX 的 I2C 配置页里这里的地址是给当前 MCU 作为从机时用的OLED 驱动中真正使用的是发送函数里的从机地址参数需要在代码里写为 0x3C或者 0x3C 1 即 0x78视驱动写法而定。4.3 ADC 配置在 Analog 中选择 ADC1开启两个通道IN0PA0对应摇杆 X 轴IN1PA1对应摇杆 Y 轴设置 ADC 为 Scan Mode 或独立多次采样均可。更简单的做法是配置两个通道连续采集使用 ADC_Start_DMA 或逐通道单次转换。对贪吃蛇游戏来说主循环中逐个采样即可不需要高速 ADC。推荐配置配置项推荐值ADC ModeIndependentADC Number of Conversion2Scan Mode 或 Non-Scan Mode 请按实际需求Clock Prescaler根据 ADC 时钟不超过 14MHz 设置典型 PCLK2/6Sampling Time55.5 Cycles 或更高避免阻抗影响Continuous Conversion ModeDisabled主循环单次触发DMA可不用简化代码用库函数读取如果使用单次转换模式检测代码大致是uint16_t adc_read_x(void) { ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel ADC_CHANNEL_0; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_55CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 10); uint16_t val HAL_ADC_GetValue(hadc1); HAL_ADC_Stop(hadc1); return val; }如果开启 Scan Mode则需要配置 Rank 1 和 Rank 2然后可以用 DMA 连续读两个通道这样代码更简洁。4.4 定时器配置贪吃蛇的移动需要固定频率刷新可以用 TIM2 做固定时间中断比如每 150ms 触发一次蛇移动。也可以不配置定时器直接把移动逻辑放到主循环并用 HAL_GetTick() 做时间戳判断。推荐用定时器中断因为主循环还要做 ADC 采样和 OLED 刷新时间分配更可控。配置 TIM2Prescaler71即 72MHz / 72 1MHz 计数频率Counter Period149即 1MHz / 150 ≈ 6.67ms输出中断频率约 150Hz当然这里不是最终蛇的移动频率中断里只做标记累加蛇每 20-30 次中断移动一次就可以做到 5-8Hz 的蛇移动速度具体看手感也可以在 CubeMX 中直接把中断周期设为 10ms然后在中断回调里每 15 次移动一格。5. OLED 驱动原理与代码实现SSD1306 是常见的 OLED 控制器支持 I2C 和 SPI 接口。0.96 寸 128x64 屏在 I2C 模式下通过控制字节区分数据和命令。I2C 从机地址通常是 0x3C写地址或 0x3C1即 0x78视驱动实现不同这里采用比较通用的写法发送控制字节 0x00 表示后续是命令发送控制字节 0x40 表示后续是显存数据下面提供一套简化的驱动实现做三件事初始化 OLED在内部 RAM 中维护一个 128x8 字节的显存数组每页 8 像素共 8 页将显存数组通过 I2C 发送到 SSD1306 的 GDDRAM#define OLED_ADDR 0x3C #define OLED_CMD 0x00 #define OLED_DATA 0x40 uint8_t oled_buffer[128][8]; // 128列 x 8页每字节对应一列中的8个上下像素点 static void oled_write_byte(uint8_t type, uint8_t data) { uint8_t buf[2]; buf[0] type; buf[1] data; HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, buf, 2, 100); } // 初始化序列参考SSD1306数据手册 static void oled_init_cmd(void) { HAL_Delay(100); oled_write_byte(OLED_CMD, 0xAE); // Display off oled_write_byte(OLED_CMD, 0x20); // Set Memory Addressing Mode oled_write_byte(OLED_CMD, 0x00); // Horizontal Addressing Mode oled_write_byte(OLED_CMD, 0xB0); // Set Page Start Address oled_write_byte(OLED_CMD, 0xC8); // COM Output Scan Direction 0..63 oled_write_byte(OLED_CMD, 0x00); // Set low column address oled_write_byte(OLED_CMD, 0x10); // Set high column address oled_write_byte(OLED_CMD, 0x40); // Set Start Line oled_write_byte(OLED_CMD, 0x81); // Set Contrast Control oled_write_byte(OLED_CMD, 0x7F); // Contrast value oled_write_byte(OLED_CMD, 0xA1); // Segment Re-map 0 to 127 oled_write_byte(OLED_CMD, 0xA6); // Normal display oled_write_byte(OLED_CMD, 0xA8); // Set Multiplex Ratio oled_write_byte(OLED_CMD, 0x3F); // 64 multiplex oled_write_byte(OLED_CMD, 0xA4); // Output follows RAM content oled_write_byte(OLED_CMD, 0xD3); // Set Display Offset oled_write_byte(OLED_CMD, 0x00); // No offset oled_write_byte(OLED_CMD, 0xD5); // Set Display Clock Divide oled_write_byte(OLED_CMD, 0x80); // Suggested ratio oled_write_byte(OLED_CMD, 0xD9); // Set Pre-charge Period oled_write_byte(OLED_CMD, 0x22); // Phase1/Phase2 period oled_write_byte(OLED_CMD, 0xDA); // Set COM Pins Hardware Configuration oled_write_byte(OLED_CMD, 0x12); // Alternative COM pin configuration oled_write_byte(OLED_CMD, 0xDB); // Set VCOMH Deselect Level oled_write_byte(OLED_CMD, 0x20); // VCOMH ~0.77 x VCC oled_write_byte(OLED_CMD, 0xAF); // Display ON }画点的核心操作就是把格子坐标换算到显存位void oled_set_pixel(uint8_t x, uint8_t y, uint8_t on) { if (x 128 || y 64) return; uint8_t page y / 8; uint8_t bit y % 8; if (on) { oled_buffer[x][page] | (1 bit); } else { oled_buffer[x][page] ~(1 bit); } }刷新函数则把整块显存发到屏幕void oled_refresh(void) { uint8_t i, page; for (page 0; page 8; page) { oled_write_byte(OLED_CMD, 0xB0 page); // 设置页地址 oled_write_byte(OLED_CMD, 0x00); // 列地址低字节 oled_write_byte(OLED_CMD, 0x10); // 列地址高字节 for (i 0; i 128; i) { uint8_t data oled_buffer[i][page]; // 为了方便也可以直接用 HAL_I2C_Mem_Write 写入连续地址 // 这里逐字节写入简单清晰但刷新速度稍慢。 uint8_t buf[2]; buf[0] OLED_DATA; buf[1] data; HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, buf, 2, 100); } } }这段代码可以正常工作但逐个字节发送在 100KHz I2C 下会有一定的刷新延迟。实际项目里可以把一页的 128 字节数据拼成一个数组一次 I2C 发送完成刷新速度更快推荐这样改。OLED 的显示缓存机制是整个游戏画面不闪烁的关键。我需要把“清屏”、“画蛇”、“画食物”、“显示分数”都写在同一个显存数组上最后统一刷屏。如果每画一个物体就发送一次 I2C刷新过程中会出现明显的闪烁和撕裂。6. 摇杆 ADC 采样与方向解析PS2 摇杆内部是两个电位器X 轴和 Y 轴分别输出一个模拟电压。摇杆居中时电压大约是 VCC 的一半向某个方向推动时电压会随之偏移。通过 ADC 读取电压值就能判断摇杆的方向。3.3V 供电时STM32F103 的 ADC 是 12 位最大读数为 40951 LSB 约 0.8mV。摇杆居中时读数大约在 1900-2200 之间这个值会因为器件差异浮动所以方向判定要用死区。方向判断逻辑#define ADC_CENTER_LOW 1800 #define ADC_CENTER_HIGH 2300 typedef enum { DIR_NONE 0, DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT } Direction; uint16_t adc_read_channel(uint32_t channel) { ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel channel; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_55CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 100); uint16_t value HAL_ADC_GetValue(hadc1); HAL_ADC_Stop(hadc1); return value; } Direction joystick_read(void) { uint16_t x adc_read_channel(ADC_CHANNEL_0); // X轴 uint16_t y adc_read_channel(ADC_CHANNEL_1); // Y轴 if (x ADC_CENTER_LOW) return DIR_LEFT; if (x ADC_CENTER_HIGH) return DIR_RIGHT; if (y ADC_CENTER_LOW) return DIR_DOWN; if (y ADC_CENTER_HIGH) return DIR_UP; return DIR_NONE; }注意摇杆的 Y 轴电压方向和屏幕上下的关系取决于模块的方向。如果发现上下相反就把判断逻辑反过来。这里给出的阈值只是参考值实际开发板上运行时要先通过串口打印观察中位值再微调阈值。抗抖动处理摇杆是模拟量在临界位置可能会抖动导致蛇频繁变向。解决方法是采样多次取平均或者加入“有效方向保持”逻辑只有连续两次读到同一方向才更新蛇的移动方向。一般游戏中只要不是向反方向变道轻微抖动不会造成大问题因为有蛇移动的频率限制天然就有滤波效果。反方向变道问题要注意贪吃蛇游戏中蛇如果当前向左移动不能立刻向右移动否则蛇头会直接撞到脖子。在代码中更新方向时需要增加合法性检查bool is_valid_direction(Direction old_dir, Direction new_dir) { if (new_dir DIR_NONE) return false; if (old_dir DIR_LEFT new_dir DIR_RIGHT) return false; if (old_dir DIR_RIGHT new_dir DIR_LEFT) return false; if (old_dir DIR_UP new_dir DIR_DOWN) return false; if (old_dir DIR_DOWN new_dir DIR_UP) return false; return true; }这个判断在贪吃蛇逻辑中非常重要不加会对游戏体验造成严重影响。7. 贪吃蛇核心逻辑实现贪吃蛇的逻辑本身不难但要在嵌入式环境下写出不闪烁、不卡顿、不死循环的版本需要先把数据结构和状态机设计清楚。这里定义地图大小为 32 列 x 16 行每个“格子”在 OLED 上占 4x4 像素这样可以充分利用 128x64 的屏幕。顶部不额外留分数栏而是将分数画在底部一行也可以在地图外再加一行提示。更常用的做法是顶部 2 行显示“Score: xxx”下面 15 行作为游戏地图不过这样地图格子数量会变为 32x15。为了简化这里按 32x16 全屏地图处理分数显示在 OLED 底部的固定区域或者用 4x4 像素的“蛇身方块”配合 1 像素间隔来区分单元格。考虑到实现复杂度可以这样设计地图宽高128 x 64 像素每个格子 4 像素即 32 列 x 16 行蛇身坐标用两个数组存 x 和 y最大长度 200食物坐标随机生成不与蛇身重叠方向0 上、1 下、2 左、3 右分数吃一个食物加 10 分速度初始移动间隔 200ms每吃 5 个食物加速一次数据结构定义如下#define MAP_W 32 #define MAP_H 16 #define CELL_SIZE 4 #define MAX_SNAKE_LEN 200 typedef struct { uint8_t x[MAP_W * MAP_H]; uint8_t y[MAP_W * MAP_H]; uint16_t len; uint8_t dir; // 当前实际移动方向 uint8_t pendingDir; // 下一次移动的方向 } Snake; typedef struct { uint8_t x; uint8_t y; } Food; Snake snake; Food food; uint8_t gameState; // 0: READY, 1: RUNNING, 2: GAME_OVER uint16_t score; uint8_t speedLevel;网格坐标在 OLED 上的绘制方式void draw_block(uint8_t gx, uint8_t gy, uint8_t on) { uint8_t px gx * CELL_SIZE; uint8_t py gy * CELL_SIZE; for (uint8_t dx 0; dx CELL_SIZE; dx) { for (uint8_t dy 0; dy CELL_SIZE; dy) { oled_set_pixel(px dx, py dy, on); } } }蛇移动的逻辑void snake_move(void) { // 保存蛇尾用于后续可能的重绘清除 uint8_t tail_x snake.x[snake.len - 1]; uint8_t tail_y snake.y[snake.len - 1]; // 从尾到头倒序移动让每个节都跟着前一节走 for (uint16_t i snake.len; i 0; i--) { snake.x[i] snake.x[i - 1]; snake.y[i] snake.y[i - 1]; } // 根据方向更新蛇头 switch (snake.dir) { case DIR_UP: snake.y[0] (snake.y[0] 0) ? MAP_H - 1 : snake.y[0] - 1; break; case DIR_DOWN: snake.y[0] (snake.y[0] MAP_H - 1) ? 0 : snake.y[0] 1; break; case DIR_LEFT: snake.x[0] (snake.x[0] 0) ? MAP_W - 1 : snake.x[0] - 1; break; case DIR_RIGHT: snake.x[0] (snake.x[0] MAP_W - 1) ? 0 : snake.x[0] 1; break; default: break; } }上面的实现是“穿墙”模式蛇撞到边界会从另一侧出来。如果希望撞墙死亡则不用取模改为判断越界后触发游戏结束。碰撞检测bool check_self_collision(void) { uint8_t head_x snake.x[0]; uint8_t head_y snake.y[0]; for (uint16_t i 1; i snake.len; i) { if (snake.x[i] head_x snake.y[i] head_y) { return true; } } return false; } bool check_wall_collision(void) { return (snake.x[0] MAP_W || snake.y[0] MAP_H); }食物生成void generate_food(void) { bool valid false; uint16_t tries 0; while (!valid tries 500) { food.x rand() % MAP_W; food.y rand() % MAP_H; valid true; for (uint16_t i 0; i snake.len; i) { if (snake.x[i] food.x snake.y[i] food.y) { valid false; break; } } tries; } // 地图几乎被填满时随机可能失败此时直接把食物放到第一个空格 if (!valid) { for (uint8_t gy 0; gy MAP_H; gy) { for (uint8_t gx 0; gx MAP_W; gx) { bool occupied false; for (uint16_t i 0; i snake.len; i) { if (snake.x[i] gx snake.y[i] gy) { occupied true; break; } } if (!occupied) { food.x gx; food.y gy; return; } } } } }这里注意C 标准库的 rand() 需要初始化种子。在嵌入式环境下可以用一个简单的伪随机函数比如取 ADC 低 8 位或者用系统时间戳做种子避免每次上电游戏食物位置相同。分数显示使用 OLED 自带的 8x6 点阵字符。为了减小代码量可以写一个 16 进制字符点阵表只显示“Score: 0123456789”。简单做法在顶部区域用 5x7 字号显示分数同时把游戏地图下移一格。但如前所述统一 32x16 地图后分数可以放在最底部一行显示为十进制数字或者通过 LED 闪烁表示得分。这里推荐在地图内保留底部一行专门显示分数地图实际为 32x15蛇在 0-14 行范围内移动底部第 15 行显示分数。这样代码改动不大清晰度更高。分数绘制void draw_score(uint16_t s) { char str[12]; sprintf(str, %d, s); // 在坐标 (4, 120) 位置显示即第15行 uint8_t x 4; for (uint8_t i 0; str[i] ! \0; i) { draw_char(x, 60, str[i]); // 自定义字符点阵函数 x 6; } }游戏状态机void game_update(void) { if (gameState ! 1) return; // 只有 RUNNING 才更新 snake.dir snake.pendingDir; snake_move(); // 撞墙或撞自己 if (check_wall_collision() || check_self_collision()) { gameState 2; // GAME_OVER return; } // 吃食物 if (snake.x[0] food.x snake.y[0] food.y) { snake.len; score 10; // 蛇尾增加一节坐标暂时用旧尾巴位置下一帧移动时会自然展开 snake.x[snake.len - 1] food.x; snake.y[snake.len - 1] food.y; generate_food(); if (snake.len % 5 0) { speedLevel; // 移动间隔减小比如 (200 - speedLevel * 10)ms但最小不低于 80ms } } }主循环中可以用 HAL_GetTick() 实现非阻塞的移动速度控制uint32_t move_interval 200; uint32_t last_move 0; while (1) { Direction d joystick_read(); if (d ! DIR_NONE is_valid_direction(snake.dir, d)) { snake.pendingDir d; } if (HAL_GetTick() - last_move move_interval) { game_update(); render(); last_move HAL_GetTick(); } }这样把“方向采样”、“蛇逻辑更新”、“屏幕绘制”拆开代码结构清晰也方便将来扩展暂停、加速道具等玩法。8. 功能测试与效果验证上板测试按照下面几个步骤进行这样可以快速定位问题出在 OLED、摇杆还是逻辑层。8.1 基础显示测试先不接摇杆只烧录 OLED 驱动测试程序在屏幕上画一个从左上到右下的对角线和几个方块。如果能正常显示说明 I2C 接线、地址、初始化序列都是对的。如果屏幕全黑或花屏先检查 3.3V 供电再检查 I2C 地址是 0x3C 还是 0x3D部分 OLED 模块的地址通过电阻选择必要时用 I2C 扫描程序打印从机地址。8.2 摇杆数据观察使用串口打印摇杆 X、Y 轴 ADC 值让摇杆处于居中、上、下、左、右位置观察数值范围。重点记录居中值并据此修正方向判断阈值。如果串口数据显示数值稳定但方向总是错误可能是 X/Y 接反或判定逻辑反了交换 PA0/PA1 或修改判断分支即可。8.3 蛇移动与食物生成测试将蛇的初始方向设为向右蛇长为 3 或 4测试蛇移动蛇头是否正常前进、身体是否跟随、食物是否随机出现。任何异常都优先检查地图坐标换算是否正确尤其是“格子坐标 → 像素坐标”的映射小屏上常见错误是行列写反导致绘制位置偏移。8.4 边界控制测试尝试将蛇移动到地图边界确认是穿墙还是撞墙死亡是否符合预期。如果使用的是穿墙模式检查取模边界条件如果是撞墙死亡模式检查越界判断是否在移动后立即执行。8.5 游戏结束与重启测试让蛇撞墙或咬到自己确认 gameState 变为 2并显示 GAME OVER。按摇杆 SW 键后重置蛇、食物、分数和方向重新进入运行状态。如果没有接 SW 键可以把按键检测暂时改为直接检测摇杆方向或在主循环中判断“游戏结束且摇杆超过死区”作为重启条件。测试过程中建议配合仿真器观察变量开启 Keil 的 Watch 窗口查看 snake.len、snake.x[0]、snake.y[0]、gameState 和 score 的变化。如果屏幕表现和变量值不一致那问题一定出在绘制函数或刷新逻辑而不用猜游戏逻辑。8.6 显存与性能观察游戏刷新过程中主要瓶颈是 I2C 传输速度。SSD1306 在 100KHz 的 I2C 下整屏刷 1KB 显存大约需要 80ms 左右如果每次移动都整屏刷新移动频率约 12Hz实际画面会比较流畅。但需要注意如果代码用逐字节发送方式一屏刷新可能超过 200ms蛇会明显卡顿。优化方案有两个一是用连续发送方式把 8 页显存数据分 8 次每次发 128 字节速度能显著提升。二是按需刷新只更新蛇移动前后变化的区域即旧蛇尾消失的位置和新蛇头出现的位置。相比整屏刷新只刷新局部区域可以把单帧更新时间降到几毫秒视觉流畅度大幅提升。显存占用方面128x8 字节的 OLED 缓冲只占 1KB SRAM蛇的坐标数组即使开 200 节容量也远小于 20KB SRAM因此本项目的资源占用非常低。关键性能瓶颈不在 RAM 和 Flash而在 I2C 刷新速度。9. 常见问题与排查方法问题现象可能原因排查方式解决方案OLED 上电后不显示I2C 地址错误、接线反、供电不足测 GND/VCC 电压I2C 扫描程序检测从机地址确认地址为 0x3C 或 0x3D检查 SCL/SDA 是否接反屏幕显示花屏或乱码OLED 初始化序列不完整、刷新页地址错误屏幕型号确认是 SSD1306 还是 SH1106使用对应控制器的初始化序列摇杆方向混乱或无法回中ADC 参考电压不稳定、摇杆没有复位串口打印 ADC 值观察中值用 3.3V 统一供电抬高死区范围蛇移动方向反向Y 轴方向和屏幕方向相反串口打印摇杆方向对应的 ADC 值与屏幕移动方向对比修改方向判断逻辑DOWN 和 UP 对调蛇移动卡顿I2C 逐字节发送速度慢检查 oled_refresh 耗时改为连续发送或局部刷新蛇瞬间死亡转向合法性判断缺失或 boundary 处理错误单步调试看蛇头坐标变化加入 is_valid_direction检查边界取模逻辑分数不刷新绘制字符函数坐标错误或只清屏不重绘检查 draw_score 是否在 render 中调用先清屏再重绘所有内容编译器报错 undefined reference torand使用了未包含的 C 库函数Keil 中开启 MicroLIB或在工程中包含 stdlib.h自行实现简易随机数函数10. 常见踩坑与优化建议这个项目最容易踩的坑有三个第一个是 I2C 地址问题。SSD1306 在不同模块上地址可能不同最稳妥的方式是写一个 I2C 扫描函数把所有从机地址读出来如果扫描不到设备优先怀疑接线和供电而不是代码。第二个是摇杆的 ADC 稳定性。摇杆模块的电位器精度不高长时间使用后中值会漂移再加上开发板的 VREF 不一定精确等于 3.3V如果死区设置太窄蛇会在不动的时候自己变向。建议在实际运行环境中先打印一下中值再设定死区。第三个是屏幕刷新闪烁。原因通常是画图函数和刷新函数分离不够彻底。你需要把所有绘制操作都改到显存缓冲上最后统一刷新一次不要在绘制中间穿插 HAL_I2C_Master_Transmit。工程实践上有几个推荐做法。代码分层要清晰。建议把 OLED 驱动、摇杆采样、游戏逻辑分成三个文件方便复用和调试。oled.c放 SSD1306 的 I2C 初始化和画点、清屏、刷新函数。joystick.c放 ADC 初始化和方向解析。game.c放贪吃蛇的数据结构、状态机和绘制逻辑。主函数只保留外设初始化和主循环。建议设计一个可调的移动速度参数。蛇的移动间隔不要写死可以用宏或变量定义这样在调试手感时无需频繁改代码重烧。建议按需刷新而不是整屏刷新。这个优化非常关键只更新蛇头和蛇尾变化区域可以大幅降低 I2C 负载让移动变得更流畅。建议加入系统运行状态提示。在 OLED 屏幕的角落显示 FPS 或刷新时间戳方便判断游戏循环是否被阻塞。如果实测帧率远低于预期优先检查 I2C 刷屏耗时。烧录时建议使用 ST-Link 的 SWD 接口。使用串口 ISP 下载也可以但需要 BOOT0 和 BOOT1 跳线切换比较麻烦ST-Link 更方便调试。11. 总结与后续扩展这个项目的核心价值不是“贪吃蛇本身”而是把 STM32 的几个经典外设放在一个实时应用里协同工作。你做完之后相当于同时掌握了 I2C 外设驱动、ADC 多通道采样、定时器节拍管理和基于状态机的嵌入式游戏程序设计方法。最先要验证的功能是 OLED 的基础显示先把屏幕点亮再写摇杆和游戏这样问题定位最简单。最容易踩的坑是 I2C 地址和 OLED 控制器型号不一致拿到模块后先确认型号。后续扩展可以考虑的方向有不少增加 OLED 休眠和唤醒、保存最高分到片内 Flash、增加难度等级选择、使用按键中断实现暂停、把蛇的移动改为基于定时器中断的队列式调度、用 SPI 接口 OLED 提升刷新速度、甚至把游戏逻辑移植到 FreeRTOS 任务里跑。如果要把项目用于课程设计或比赛建议做两个增强一是加入 OLED 开机动画和菜单选择难度后才开始游戏这样可以展示状态机的设计能力二是换用 SPI 接口 OLED 或者加入独立按键控制避免摇杆电位器漂移影响演示效果。这个小项目做一遍代码量可能不到 400 行但能从原理到实现都吃透对后续学 DMA、中断优先级、RTOS甚至嵌入式 UI 框架都有直接的帮助。建议自己从 CubeMX 建工程开始一行一行把代码敲进去不要直接复制整包工程这样学到的东西会多得多。
返回列表