STM32F103C8T6+OLED摇杆贪吃蛇:嵌入式入门完整项目实战

STM32F103C8T6+OLED摇杆贪吃蛇:嵌入式入门完整项目实战 很多刚入门 STM32 的同学都有过这样的经历跟着教程点完流水灯、调通了串口打印再往上就不知道该做什么了。买来的 0.96 寸 OLED 屏幕能点亮但只会显示“Hello World”摇杆模块把 ADC 数值读出来了却不知道这些数值除了打印还能干什么。如果列表里正好有 stm32f103c8t6、OLED、贪吃蛇、摇杆这几个关键词那这篇文章就是为你准备的。用 STM32F103C8T6 最小系统板、一块 0.96 寸 IIC 接口 OLED 屏幕、一个双轴摇杆做一个能用手柄玩的贪吃蛇游戏是我眼中嵌入式入门阶段性价比最高的练手项目。它不依赖串口调试助手不需要上位机界面所有交互都发生在硬件本身它涉及 OLED 显示驱动、ADC 采样、定时器、游戏算法四个核心模块几乎把单片机开发的主干知识都过了一遍。更关键的是这个项目能帮你建立“显示 输入 逻辑”三线协同的开发习惯而这正是后面做复杂嵌入式项目的基本功。这篇文章会从硬件选型开始讲清楚每一根线怎么接OLED 的 IIC 驱动怎么写摇杆的 ADC 数据怎么变成游戏方向贪吃蛇的数据结构和碰撞检测怎么做最后给出完整可复制的代码、运行验证方法和踩坑排查表。即使你之前只点亮过 LED按这篇文章的顺序走一遍也能做出一个可以实际游玩的贪吃蛇游戏。1. 这篇文章真正要解决的问题很多嵌入式新手在“点灯之后”陷入了迷茫期。点灯只能证明 GPIO 能输出高低电平但这离“做一个有用的东西”还很远。市场上有大量教程讲 STM32 的单个外设IIC 怎么初始化、ADC 怎么采样、定时器怎么翻转电平但很少有一篇文章能把它们组合成一个完整的产品级小程序。用 STM32F103C8T6 做贪吃蛇正好填补这个空白。这个项目解决的三个核心问题第一OLED 屏幕驱动的实际应用。网上关于 SSD1306 驱动 OLED 的代码很多但大多是“显示固定字符”真正要显示一个动态变化的小方块矩阵时很多新手不知道该怎么组织显存和刷新逻辑。第二摇杆输入的有效利用。摇杆本质上是两个电位器加上一个按键输出的是模拟电压。直接读 ADC 很容易但如何处理抖动、如何判断方向、如何消除误触是另一个层面的问题。第三嵌入式游戏逻辑的落地。贪吃蛇看起来简单但它需要你处理数据结构蛇身坐标存储、定时刷新移动速度控制、碰撞检测撞墙、咬自己和随机生成食物出现位置。这些逻辑用 PC 端高级语言写很容易但在内存只有 20KB、CPU 主频 72MHz 的 STM32F103C8T6 上写你就不得不考虑资源占用和代码效率。换句话说这个项目真正锻炼的不是“某个外设怎么用”而是“把多个外设组合成一个系统”的工程能力。它适合以下读者已经学过 STM32 基础能独立配置 GPIO、串口、定时器的初级开发者。想要从“复制例程”过渡到“写着属于自己的小项目”的学习者。需要准备电子设计竞赛或课程设计想找一个低成本、视觉效果又不错的小项目的学生。如果你现在正处于“会点灯但不会做东西”的阶段读完这篇文章并动手实践你会发现自己对单片机的理解会发生一个质变。2. 系统架构与核心原理2.1 整机架构拆解贪吃蛇游戏的完整数据流可以概括为摇杆输入 → ADC 采样 → 方向判断 → 游戏逻辑更新 → OLED 显存刷新 → 屏幕显示。STM32F103C8T6 在整个系统中扮演的角色是大脑。它通过 ADC 外设读取摇杆两个电位器的电压值解析出用户想要的方向通过定时器产生固定周期的中断控制游戏逻辑的刷新频率然后在主循环或中断回调中更新蛇身坐标、检测碰撞、生成食物最后通过 IIC 接口把一帧画面写入 OLED 的显存。这里要特别注意一个设计选择游戏逻辑和屏幕刷新不是同步进行的。屏幕刷新速度远快于游戏逻辑更新速度如果每移动一格就刷新一次屏幕画面会闪烁得很厉害。正确做法是用定时器控制游戏逻辑按固定节奏比如 200ms 移动一格更新更新完把整个画面画到 OLED 显存中OLED 控制器负责把显存内容持续显示在屏幕上。2.2 OLED 与 SSD1306 的基本原理市面上最常见的 0.96 寸 OLED 屏幕绝大多数使用 SSD1306 驱动芯片支持 IIC 和 SPI 两种接口。IIC 版本只需要接四根线VCC、GND、SCL、SDA连接简单代码也相对简洁适合新手。SSD1306 的核心是一个 128×64 像素的显存矩阵。它的显存被分成 8 页Page0 到 Page7每页对应 8 行像素每列用一个字节表示字节的每一位对应一行的亮灭状态。写入显存时你可以按页按列操作也可以整屏刷新。这就带来一个嵌入式开发中常见的取舍用时间换显存还是用显存换时间。SSD1306 自带 1KB 显存刚好覆盖 128×64 位图。如果你的 MCU 内存充足可以维护一份镜像显存需要改哪个点就改内存里的对应位然后一次性把整块显存刷到屏幕上如果内存紧张也可以直接操作屏幕内部显存但刷屏次数会多很多。STM32F103C8T6 的 SRAM 是 20KB游戏程序本身用不了多少完全可以维护一个 1KB 的显存缓冲区。推荐做法是在内存中定义u8 oled_buffer[1024]所有绘图操作先写入这个缓冲区需要显示时一次性通过 IIC 传送到屏幕。2.3 摇杆与 ADC 的工作原理双轴摇杆模块内部有两个电位器X 轴和 Y 轴和一个按键。电位器会随摇杆位置输出不同的电压电压范围一般在 0V 到 3.3V 之间。STM32 的 ADC 会把模拟电压转换成数字量F103C8T6 的 ADC 是 12 位分辨率也就是把 0 到 3.3V 映射到 0 到 4095。摇杆静止时X 和 Y 轴输出大约 1.65V对应 ADC 值约 2048。当你把摇杆推向某个方向对应的 ADC 值会明显变大或变小。游戏程序只需判断 ADC 值是高于一个高阈值比如 3000还是低于一个低阈值比如 1000就能判断方向。需要注意的是摇杆 ADC 值往往伴随轻微抖动直接读取可能会造成方向误判。工程上一般用“迟滞比较”判断方向时设置高阈值为 3000、低阈值为 1000只有越过阈值才改变方向同时在游戏逻辑更新时才采样而不是每次 ADC 转换完都立刻响应。2.4 贪吃蛇的游戏算法本质贪吃蛇从算法角度看可以理解为一个“带方向移动的队列”。蛇身可以看成一段坐标数组snake_x[0]和snake_y[0]是蛇头后面的元素依次是蛇身。每次移动时把蛇头往当前方向移动一格蛇身依次往前挪一个位置如果吃到了食物就不删除尾部否则删除尾部。这个“头部前进、尾部收缩”的过程本质上就是数据结构里的队列操作。碰撞检测也基于坐标比较蛇头坐标与墙壁边界比较判断是否撞墙蛇头坐标与蛇身任意一节坐标比较判断是否咬到自己。食物生成则是随机选择一个空坐标点把食物标志设置为有效。理解了这个本质你就知道为什么很多人说贪吃蛇是“最简单的完整游戏”——它不需要复杂的物理引擎不需要资源管理器几个数组和几行 if 判断就能实现但它确实涵盖了游戏程序常见的全部核心模块。3. 硬件选型与接线3.1 硬件清单这个项目的硬件成本非常低核心元件一共四样元件型号/规格数量说明主控板STM32F103C8T6 最小系统板1蓝色 pill 或国产兼容板均可OLED 屏幕0.96 寸 IIC 接口SSD130614 针 IIC 版本接线最简单摇杆模块PS2 双轴摇杆模块1含 X、Y 模拟输出和按键输出杜邦线母对母或公对母若干预留备用从材料看stm32f103c8t6 最小系统板是目前入门 STM32 的主流选择国产替代型号也很常见。这类最小系统板通常板载一个 LEDPC13和一个复位按键引出全部 GPIO价格低廉烧录方式支持 ST-Link、串口 ISP 等。OLED 选择时要注意接口类型。同样是 0.96 寸屏幕有 IIC 版本、SPI 版本、4 针 / 7 针等区别。本项目的所有代码示例基于 IIC 版本接线只需要 SCL、SDA 两根信号线。如果你手里的屏幕是 SPI 版需要自行移植驱动代码本文不展开。摇杆模块一般有 5 个引脚VCC、GND、X、Y、SW。其中 X 和 Y 是模拟输出SW 是按键输出。摇杆模块的供电范围通常是 3.3V 到 5V本项目统一使用 3.3V与 STM32 逻辑电平匹配避免电平不兼容的问题。3.2 接线对照表这里给出一个完整接线方案。不同最小系统板的丝印可能略有差异接线前请对照你的板子原理图确认引脚位置。OLED 引脚STM32F103C8T6 引脚说明VCC3.3VOLED 电源GNDGND共地SCLPB6I2C1 时钟线SDAPB7I2C1 数据线摇杆引脚STM32F103C8T6 引脚说明VCC3.3V摇杆电源GNDGND共地XPA1ADC1 通道 1YPA0ADC1 通道 0SWPB0按键输入接 GPIO 输入模式PB6 和 PB7 是 STM32F103C8T6 上 I2C1 的默认映射引脚用 STM32CubeMX 配置时直接选择 I2C1 就会自动绑定到这两个引脚。PA0 和 PA1 是 ADC1 的通道 0 和通道 1同样可以在 CubeMX 的可视化界面里直接选择。关于摇杆的 SW 引脚本项目用它来“复位游戏”或“重新开始”。按下摇杆时按键接地通过检测输入电平从高变低来触发复位。如果你暂时不想用按键也可以悬空处理游戏逻辑里不读它即可。3.3 接线注意事项接线是整个项目最常见的故障源头。第一次上电调试前请按以下清单检查供电不要接反。OLED 模块和摇杆模块都有正负极标识接反会烧坏模块。共地必须保证。所有模块的 GND 必须连到 STM32 的 GND否则 IIC 通信和 ADC 采样都会出现随机错误。信号线尽量短。杜邦线过长会引入干扰IIC 在 100kHz 到 400kHz 速率下对干扰敏感如果 OLED 偶尔花屏先缩短信号线试试。PB6/PB7 不要被其他外设占用。如果你的板子上 PB6、PB7 还接了其他模块可能会造成总线冲突。摇杆电源建议接 3.3V。部分摇杆模块在 5V 供电时输出范围会超过 3.3V直接接到 STM32 的 ADC 引脚有风险。4. 开发环境准备4.1 工具链选择本项目推荐使用 STM32CubeMX HAL 库 Keil MDK 或 STM32CubeIDE 的开发组合。理由有三个STM32CubeMX 可以可视化配置引脚、时钟、I2C、ADC、定时器自动生成初始化代码大幅降低配置出错概率。HAL 库的代码可读性好适合学习也基于 STM32F103C8T6 搜索热度较高遇到问题容易搜到解决方案。无论后续转向 FreeRTOS 还是 LVGLHAL 库都是官方主推的接口层迁移成本低。如果你偏爱标准库也可以基于同样的工程思路改写但本文章所有代码示例默认使用 HAL 库。具体软件清单软件用途说明STM32CubeMX图形化配置生成工程骨架Keil MDK 或 STM32CubeIDE编译、烧录、调试二选一ST-Link 驱动下载器驱动如果使用 ST-Link 烧录串口助手调试输出可选用于打印 ADC 值和调试点版本以你实际安装为准本文不绑定特定版本号。STM32CubeMX 的安装和 Keil 的配置这里不展开网上资料很多假设你已经具备基础的编译烧录能力。4.2 STM32CubeMX 关键配置打开 STM32CubeMX选择芯片型号 STM32F103C8Tx然后按以下方案配置RCC 配置High Speed Clock 选择 Crystal/Ceramic Resonator。这是为了让外部晶振作为系统时钟源。时钟树默认配置下 HCLK 设置为 72MHz。STM32F103C8T6 最高主频就是 72MHz不用刻意超频。I2C1 配置选择 I2C1在 Pinout 视图中确认 PB6-SCL、PB7-SDA。Parameter Settings 中 Speed Mode 可选 Standard Mode100kHz或 Fast Mode400kHz。OLED SSD1306 通常支持到 400kHz但为了稳定可以先选 Standard Mode。ADC1 配置启用 ADC1通道 0 和通道 1 使能。在 Parameter Settings 中设置分辨率 12-bit扫描模式可以关闭因为本项目在需要时才读取单通道不需要连续扫描所有通道。定时器配置选择 TIM2 作为游戏逻辑定时器设置定时周期为 200ms。这个值决定蛇的初始移动速度后续可以做成变量随游戏分数提高而缩短。GPIO 配置摇杆 SW 接 PB0设置为 GPIO_Input。一些摇杆模块没有上拉电阻STM32 内部可以启用上拉。在 GPIO 设置中把 PB0 配置为 Pull-up这样没按下时读到高电平按下时读到低电平。NVIC 配置使能 TIM2 全局中断I2C 不使用中断OLED 采用阻塞式写入代码更直观。生成工程时选择 Toolchain 为 MDK-ARM 或 STM32CubeIDE生成代码。5. 核心代码实现5.1 OLED 底层驱动与显存管理先建立oled.c和oled.h两个文件。底层依赖 HAL 库的 I2C 接口。oled.h中声明主要接口// 文件路径Core/Inc/oled.h #ifndef OLED_H #define OLED_H #include main.h #define OLED_WIDTH 128 #define OLED_HEIGHT 64 // I2C 设备地址SSD1306 默认从机地址 0x787位地址 0x3C #define OLED_I2C_ADDR (0x3C 1) extern uint8_t oled_buffer[1024]; void OLED_Init(void); void OLED_Clear(uint8_t fill); void OLED_DrawPixel(uint8_t x, uint8_t y, uint8_t on); void OLED_Refresh(void); void OLED_ShowChar(uint8_t x, uint8_t y, char ch); void OLED_ShowString(uint8_t x, uint8_t y, char *str); #endifoled.c中写入命令和写入数据的底层函数是核心。SSD1306 在 IIC 模式下控制字节用于区分命令和数据0x00 表示后续是命令0x40 表示后续是数据。// 文件路径Core/Src/oled.c #include oled.h #include oled_font.h #include i2c.h uint8_t oled_buffer[1024]; static void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] {0x00, cmd}; HAL_I2C_Master_Transmit(hi2c1, OLED_I2C_ADDR, buf, 2, 20); } static void OLED_WriteData(uint8_t data) { uint8_t buf[2] {0x40, data}; HAL_I2C_Master_Transmit(hi2c1, OLED_I2C_ADDR, buf, 2, 20); }初始化函数遵循 SSD1306 的标准初始化序列包含关闭显示、设置显示时钟分频、设置复用率、设置显示偏移、开启内部升压、选择列地址范围等步骤void OLED_Init(void) { HAL_Delay(100); OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0xD5); // 设置显示时钟分频 OLED_WriteCmd(0x80); OLED_WriteCmd(0xA8); // 设置多路复用率 OLED_WriteCmd(0x3F); // 64 OLED_WriteCmd(0xD3); // 设置显示偏移 OLED_WriteCmd(0x00); OLED_WriteCmd(0x40); // 设置显示起始行 OLED_WriteCmd(0x8D); // 开启内部电荷泵 OLED_WriteCmd(0x14); OLED_WriteCmd(0x20); // 设置内存寻址模式 OLED_WriteCmd(0x00); // 水平寻址模式 OLED_WriteCmd(0xA1); // 段重映射 OLED_WriteCmd(0xC8); // COM 扫描方向 OLED_WriteCmd(0xDA); // 设置 COM 引脚 OLED_WriteCmd(0x12); OLED_WriteCmd(0x81); // 设置对比度 OLED_WriteCmd(0xCF); OLED_WriteCmd(0xD9); // 设置预充电周期 OLED_WriteCmd(0xF1); OLED_WriteCmd(0xDB); // 设置 VCOMH 取消选择级别 OLED_WriteCmd(0x40); OLED_WriteCmd(0xA4); // 使用 RAM 内容显示 OLED_WriteCmd(0xA6); // 正常显示非反色 OLED_WriteCmd(0xAF); // 打开显示 }清屏函数负责把显存缓冲区清零。注意这里的参数fill为 0 时全灭为 0xFF 时全亮适合测试屏幕void OLED_Clear(uint8_t fill) { for (int i 0; i 1024; i) { oled_buffer[i] fill; } }OLED_DrawPixel是游戏中最常用的绘图原语。屏幕宽 128 像素高 64 像素显存地址按页组织。x对应列y对应行y / 8得到页号y % 8得到页内位偏移void OLED_DrawPixel(uint8_t x, uint8_t y, uint8_t on) { uint16_t idx; uint8_t page, bit; if (x OLED_WIDTH || y OLED_HEIGHT) { return; } idx x (y / 8) * OLED_WIDTH; page y / 8; bit y % 8; if (on) { oled_buffer[idx] | (1 bit); } else { oled_buffer[idx] ~(1 bit); } }整屏刷新函数通过 IIC 把显存缓冲区写入 SSD1306。这里采用逐页写入的方式每写一页先发送数据控制字节再发送 128 字节数据。为了减少函数调用开销可以合并为一次连续发送void OLED_Refresh(void) { uint8_t page; uint8_t buf[130]; for (page 0; page 8; page) { buf[0] 0x40; // 数据模式 for (int i 0; i 128; i) { buf[i 1] oled_buffer[page * 128 i]; } HAL_I2C_Master_Transmit(hi2c1, OLED_I2C_ADDR, buf, 129, 50); } }字符和字符串显示函数依赖一个 6×8 点阵字库。字库存放 ASCII 字符的位图数据显示时按列取出每个字节用OLED_DrawPixel逐位绘制void OLED_ShowChar(uint8_t x, uint8_t y, char ch) { uint8_t idx, offset; uint8_t col; if (ch 32 || ch 127) { ch ?; } idx ch - 32; for (col 0; col 6; col) { offset oled_font6x8[idx][col]; for (int row 0; row 8; row) { if (offset (0x01 row)) { OLED_DrawPixel(x col, y row, 1); } else { OLED_DrawPixel(x col, y row, 0); } } } } void OLED_ShowString(uint8_t x, uint8_t y, char *str) { while (*str) { OLED_ShowChar(x, y, *str); x 6; if (x 6 OLED_WIDTH) { break; } } }字库文件oled_font.h是 ASCII 0x20 到 0x7F 的 96 个字符位图体积在常规项目中直接用数组定义即可。为了节约文章篇幅这里不贴完整字库你可以在网络上搜索“oled_font.h 6x8”找到现成文件。注意选择与你代码匹配的字库格式每个字符 6 个字节、每字节的每一位对应一列的亮灭。5.2 摇杆数据采集与方向解析摇杆的 X、Y 轴分别接入 ADC1 的通道 0 和通道 1。HAL 库中读取 ADC 的标准流程是启动转换、等待转换完成、读取值。由于本项目不需要高速连续采样直接每次读取时调用即可。建立joystick.c和joystick.h封装摇杆相关功能。// 文件路径Core/Inc/joystick.h #ifndef JOYSTICK_H #define JOYSTICK_H #include main.h typedef enum { DIR_NONE 0, DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT } Joystick_Dir; void Joystick_Init(void); uint16_t Joystick_ReadX(void); uint16_t Joystick_ReadY(void); Joystick_Dir Joystick_GetDirection(void); uint8_t Joystick_IsPressed(void); #endifADC 通道读取函数// 文件路径Core/Src/joystick.c #include joystick.h #include adc.h #include gpio.h static uint32_t ADC_ReadChannel(uint32_t channel) { ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel channel; sConfig.Rank ADC_REGULAR_RANK_1; sConfig.SamplingTime ADC_SAMPLETIME_55CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 10); uint32_t value HAL_ADC_GetValue(hadc1); HAL_ADC_Stop(hadc1); return value; } uint16_t Joystick_ReadX(void) { return (uint16_t)ADC_ReadChannel(ADC_CHANNEL_1); // PA1 } uint16_t Joystick_ReadY(void) { return (uint16_t)ADC_ReadChannel(ADC_CHANNEL_0); // PA0 }方向判断函数是输入处理的核心。注意处理逻辑静态变量last_direction记录上一次方向用于防止同一方向重复触发。判定阈值比静止值有明显偏置避免摇杆轻微回弹导致误触。X 轴和 Y 轴同时有效时可定义优先级。Joystick_Dir Joystick_GetDirection(void) { uint16_t x Joystick_ReadX(); uint16_t y Joystick_ReadY(); if (x 3000) { return DIR_RIGHT; } else if (x 1000) { return DIR_LEFT; } else if (y 3000) { return DIR_DOWN; } else if (y 1000) { return DIR_UP; } return DIR_NONE; }按键检测函数读取 PB0 电平按下时返回 1uint8_t Joystick_IsPressed(void) { return (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) GPIO_PIN_RESET) ? 1 : 0; }这里的阈值和 Y 轴方向与你摇杆模块的品牌有关。如果你发现推上摇杆的时候方向是向下把 Y 轴的两个判断条件互换即可。这是一个典型的“先看现象再调整参数”的问题不需要怀疑代码结构。5.3 贪吃蛇游戏逻辑游戏逻辑放在snake.c和snake.h中。这里有几个关键设计决定蛇身最大长度定义为 256足够在 128×64 屏幕上占满整屏。蛇身坐标用一维数组存储snake_len表示当前长度。蛇头坐标直接用snake_x[0]和snake_y[0]后续身体索引依次递增。食物坐标用food_x、food_y表示food_exist标记当前食物是否有效。游戏状态用枚举表示运行中、撞墙结束、咬到自己结束。// 文件路径Core/Inc/snake.h #ifndef SNAKE_H #define SNAKE_H #include main.h #include joystick.h #define SNAKE_MAX_LEN 256 #define MAP_X_MIN 0 #define MAP_X_MAX 127 #define MAP_Y_MIN 0 #define MAP_Y_MAX 63 typedef enum { GAME_RUNNING 0, GAME_WALL_CRASH, GAME_SELF_CRASH } Game_Status; extern volatile uint8_t game_over_flag; extern volatile uint8_t score; void Snake_Init(void); void Snake_UpdateDirection(Joystick_Dir dir); void Snake_Move(void); void Snake_GenerateFood(void); Game_Status Snake_CheckCollision(void); #endifsnake.c中的数据结构定义// 文件路径Core/Src/snake.c #include snake.h #include oled.h #include stdlib.h static int16_t snake_x[SNAKE_MAX_LEN]; static int16_t snake_y[SNAKE_MAX_LEN]; static uint16_t snake_len; static uint8_t snake_dir; // 当前移动方向 static uint8_t snake_dir_valid; // 是否已设置有效方向 static int16_t food_x; static int16_t food_y; static uint8_t food_exist; volatile uint8_t game_over_flag 0; volatile uint8_t score 0;游戏初始化函数。初始蛇身长度为 3蛇头在屏幕中央附近初始方向向右void Snake_Init(void) { snake_len 3; snake_dir DIR_RIGHT; snake_dir_valid 1; snake_x[0] 64; snake_y[0] 32; snake_x[1] 62; snake_y[1] 32; snake_x[2] 60; snake_y[2] 32; game_over_flag 0; score 0; food_exist 0; Snake_GenerateFood(); }方向更新函数要防止“直接反向”导致蛇头立刻撞上身体。比如当前方向是向左用户快速向右拨动摇杆如果直接执行蛇头就会钻进身体。这里做一层保护只有新方向不是当前方向的相反方向时才允许更新void Snake_UpdateDirection(Joystick_Dir dir) { if (dir DIR_NONE) { return; } if ((snake_dir DIR_UP dir DIR_DOWN) || (snake_dir DIR_DOWN dir DIR_UP) || (snake_dir DIR_LEFT dir DIR_RIGHT) || (snake_dir DIR_RIGHT dir DIR_LEFT)) { return; } snake_dir dir; snake_dir_valid 1; }蛇移动函数实现队列操作。从尾部开始每一节坐标向前移动一位void Snake_Move(void) { int16_t new_head_x snake_x[0]; int16_t new_head_y snake_y[0]; switch (snake_dir) { case DIR_UP: new_head_y--; break; case DIR_DOWN: new_head_y; break; case DIR_LEFT: new_head_x--; break; case DIR_RIGHT: new_head_x; break; default: break; } // 身体前移 for (int32_t i snake_len - 1; i 0; i--) { snake_x[i] snake_x[i - 1]; snake_y[i] snake_y[i - 1]; } snake_x[0] new_head_x; snake_y[0] new_head_y; // 吃到食物 if (food_exist new_head_x food_x new_head_y food_y) { snake_len; score; food_exist 0; if (snake_len SNAKE_MAX_LEN) { Snake_GenerateFood(); } } snake_dir_valid 0; }食物生成函数使用伪随机数。C 标准库的rand()在嵌入式环境中可用但需要通过srand()设定种子。这里可以在初始化时用 ADC 值作为种子增加随机性void Snake_GenerateFood(void) { uint8_t valid 0; uint16_t tries 0; while (!valid tries 1000) { food_x rand() % (MAP_X_MAX - MAP_X_MIN 1) MAP_X_MIN; food_y rand() % (MAP_Y_MAX - MAP_Y_MIN 1) MAP_Y_MIN; valid 1; for (uint16_t i 0; i snake_len; i) { if (snake_x[i] food_x snake_y[i] food_y) { valid 0; break; } } tries; } food_exist 1; }碰撞检测函数返回当前游戏状态Game_Status Snake_CheckCollision(void) { // 撞墙 if (snake_x[0] MAP_X_MIN || snake_x[0] MAP_X_MAX || snake_y[0] MAP_Y_MIN || snake_y[0] MAP_Y_MAX) { return GAME_WALL_CRASH; } // 咬到自己从第 1 节开始检查 for (uint16_t i 1; i snake_len; i) { if (snake_x[i] snake_x[0] snake_y[i] snake_y[0]) { return GAME_SELF_CRASH; } } return GAME_RUNNING; }这里有一个隐藏问题值得注意蛇吃食物后长度增加新增长的身体坐标其实没有真实位置。上面的移动逻辑中“吃到食物”发生在身体前移之后蛇身整体前移一格后最后一节坐标被保留了下来等效于在尾部延长了一格。这是贪吃蛇经典实现方式代码顺序必须保持“先移动再判断食物”。5.4 OLED 游戏画面绘制游戏画面绘制函数Game_Render负责把游戏状态绘制到 OLED 显存缓冲区。它分为三个部分清屏、绘制蛇身、绘制食物、绘制 UI 信息。// 文件路径Core/Src/snake.c 中追加 void Game_Render(void) { OLED_Clear(0); // 绘制蛇身 for (uint16_t i 0; i snake_len; i) { OLED_DrawPixel(snake_x[i], snake_y[i], 1); // 为了让蛇看起来更粗可以把每个点放大成 2x2 小方块 OLED_DrawPixel(snake_x[i] 1, snake_y[i], 1); OLED_DrawPixel(snake_x[i], snake_y[i] 1, 1); OLED_DrawPixel(snake_x[i] 1, snake_y[i] 1, 1); } // 绘制食物用 2x2 方块 if (food_exist) { OLED_DrawPixel(food_x, food_y, 1); OLED_DrawPixel(food_x 1, food_y, 1); OLED_DrawPixel(food_x, food_y 1, 1); OLED_DrawPixel(food_x 1, food_y 1, 1); } // 绘制分数 char score_str[16]; sprintf(score_str, Score:%d, score); OLED_ShowString(0, 0, score_str); OLED_Refresh(); }绘制 2x2 方块时需要注意边界。如果snake_x[i]已经等于 127那么snake_x[i] 1就超出屏幕边界。OLED_DrawPixel中有坐标越界保护所以不会产生数组越界问题但视觉上会少画一格属于正常现象。游戏结束时可以绘制结束画面。这里用最简单的字符串提示也支持用像素图绘制 Game Over 字样void Game_RenderGameOver(Game_Status status) { OLED_Clear(0); OLED_ShowString(32, 24, GAME OVER); if (status GAME_WALL_CRASH) { OLED_ShowString(20, 40, WALL CRASH); } else if (status GAME_SELF_CRASH) { OLED_ShowString(20, 40, SELF CRASH); } char score_str[16]; sprintf(score_str, Score:%d, score); OLED_ShowString(35, 52, score_str); OLED_Refresh(); }5.5 主程序整合与定时器回调主程序的核心逻辑在main.c中完成。游戏循环分成两个层次定时器中断每 200ms里更新方向、移动蛇、检测碰撞、刷新画面。主循环里只负责读取摇杆方向并调用Snake_UpdateDirection以及处理游戏结束后的复位。main.c中用户代码区的大致结构如下// 文件路径Core/Src/main.c 中用户代码区 #include snake.h #include joystick.h #include oled.h Game_Status current_status GAME_RUNNING; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); MX_ADC1_Init(); MX_TIM2_Init(); // 用 ADC 值作为随机种子 uint32_t seed Joystick_ReadX() * 7 Joystick_ReadY() * 13 HAL_GetTick(); srand(seed); OLED_Init(); OLED_Clear(0); OLED_Refresh(); Snake_Init(); // 使能 TIM2 中断 HAL_TIM_Base_Start_IT(htim2); while (1) { if (game_over_flag) { // 游戏结束等待摇杆按键复位 if (Joystick_IsPressed()) { Snake_Init(); current_status GAME_RUNNING; } continue; } // 读取方向并更新 Joystick_Dir dir Joystick_GetDirection(); Snake_UpdateDirection(dir); HAL_Delay(20); } }定时器回调函数写在中断回调中。注意这里是中断上下文代码要尽量短避免耗时操作。OLED 的 IIC 写入是阻塞操作在中断里刷新屏幕会对系统实时性有一些影响但在本项目这种轻载场景下可以接受。后续优化方向是改成“中断里只置标志位主循环里执行移动和刷新”。// 文件路径Core/Src/stm32f1xx_it.c 或 main.c 中 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { static uint16_t move_tick 0; if (htim-Instance TIM2) { move_tick; if (move_tick 1) // 由 TIM2 周期决定移动频率 { move_tick 0; if (!game_over_flag) { Snake_Move(); current_status Snake_CheckCollision(); if (current_status ! GAME_RUNNING) { game_over_flag 1; Game_RenderGameOver(current_status); } else { Game_Render(); } } } } }这个回调里的move_tick可以预留给后续“加速”功能。如果你希望每吃 5 个食物提速一次可以通过比较score来调整move_tick的门槛或者直接调用__HAL_TIM_SET_AUTORELOAD修改定时器周期。6. 运行结果与效果验证6.1 编译与下载工程配置完成后在 Keil 或 STM32CubeIDE 中编译。如果使用的是 HAL 库默认配置第一次编译可能遇到以下情况提示缺少stm32f1xx_hal_conf.h中的某些外设声明需要检查 CubeMX 生成时是否勾选了对应外设。提示HAL_TIM_PeriodElapsedCallback重复定义因为你手动写了回调同时 CubeMX 也可能在stm32f1xx_it.c里生成了一个弱定义空函数。解决办法是注释掉 CubeMX 自带的那个判断 TIM2 的空白回调保留自己的实现。提示sprintf占用资源过大可以在 Keil 的 MicroLIB 选项下重编或改用自定义的整数转字符串函数。编译通过后使用 ST-Link 连接板子的 SWD 接口在 Keil 中点 Download 烧录。烧录完成后按一下复位键程序开始运行。在接线正确、代码正常的前提下开机后屏幕应该先显示一段短暂的花屏或全亮因为初始化时序随后 OLED 显示Score:0屏幕中央或右侧出现一个闪烁的白色食物点蛇身以 2x2 小方块组成的条状从屏幕中部向右移动。6.2 功能测试步骤按以下顺序验证系统功能查看基础画面屏幕是否正常显示分数文字和 Snake 方块。如果屏幕是花的或全黑先检查 IIC 接线和 OLED 地址。测试摇杆方向分别把摇杆推向上下左右观察蛇头是否朝对应方向移动。如果方向反了调整joystick.c中的判断逻辑。测试食物生成蛇头吃到食物后分数是否增加蛇身是否变长新的食物是否出现在空白位置。测试碰撞检测控制蛇头撞墙屏幕是否显示GAME OVER和WALL CRASH控制蛇身绕圈咬到自己是否显示SELF CRASH。测试摇杆按键复位游戏结束后按下摇杆屏幕是否重新开始新一局。调试时建议先用串口打印辅助定位。你可以把Joystick_ReadX()和Joystick_ReadY()的返回值通过串口发送到串口助手观察摇杆在上下左右极限位置的数值范围再据此微调方向判断阈值。这是最稳妥的方式比凭经验判断要可靠得多。6.3 预期效果说明默认配置下蛇的移动周期是 200ms也就是每秒移动 5 格。这个速度在 OLED 像素级别下看起来适中不会太快导致操控疲劳也不会太慢显得迟钝。两个连续方向变化之间存在防反向保护因此你快速从向右拨到向左时蛇不会立刻掉头而是先向上或向下走一格再转向。这是贪吃蛇游戏的行业标准设计能有效避免误操作导致的“秒死”。游戏过程中分数显示在屏幕左上角。OLED 的刷新频率是游戏每移动一格刷新一屏肉眼几乎感觉不到闪烁这是 SSD1306 内置显存带来的优势。7. 常见问题与排查方法下面是这个项目中最常遇到的 7 个问题按出现频率排序问题现象可能原因排查方式解决方案OLED 完全不亮IIC 接线错误用万用表测量 SCL/SDA 电平检查 PB6/PB7 接线确认 VCC 接 3.3VOLED 显示乱码或花屏IIC 速率太高把 I2C 速度从 400kHz 降低为 100kHz在 CubeMX 中修改 I2C Speed ModeOLED 显示一半花屏显存寻址模式配置错误检查初始化序列中 0x20 后的参数确认内存寻址模式是水平模式 0x00串口打印 ADC 值全为 0 或 4095ADC 通道配置错误检查 CubeMX 中通道号是否正确PA0 对应 ADC_CHANNEL_0PA1 对应 ADC_CHANNEL_1摇杆方向总是不对Y 轴方向感相反单独打印 X/Y ADC 值并记录推杆方向修改 joystick.c 中阈值判断顺序游戏结束后复位无反应PB0 没有配置上拉测 PB0 电平变化CubeMX 中启用 GPIO Pull-up蛇移动一卡一卡屏幕刷新耗时太长检查 IIC 波特率提高到 400kHz或用 DMA 方式发送整帧数据食物没有出现随机种子未初始化检查是否调用 srand在 main 中初始化随机种子蛇直接反向秒死防反向判断逻辑错误检查方向常量定义是否一致确保 joystick.h 中枚举值和 snake.c 中 switch 分支一致重点说明两个问题。第一OLED 花屏问题。SSD1306 初始化序列必须严格按顺序执行尤其是0x20寻址模式指令和0x00参数如果你从网上找了一段初始化代码但寄存器顺序不完整就会出现花屏或显示错位。排查时最有效的方法是找到芯片手册的初始化时序图逐条核对。第二ADC 数值全为 0。这通常不是接线问题而是 ADC 通道配置与物理引脚不一致。PA0 是 ADC1 的通道 0PA1 是 ADC1 的通道 1你必须在 CubeMX 中正确选择通道。如果你在代码中直接调用HAL_ADC_ConfigChannel时填错了通道号也会出现读取错误。第三个隐藏较深的问题是如果你在 cubeMX 中同时使能了 ADC 的定时器触发并在软件里又调用HAL_ADC_Start可能会导致转换一直在进行但无法正常获取结果。项目初期建议只用软件触发即调用HAL_ADC_Start后主动轮询等待。8. 工程实践建议8.1 模块化设计思维从这个项目开始就应该养成模块化编程的习惯。把 OLED 驱动放在oled.c、摇杆驱动放在joystick.c、游戏逻辑放在snake.c不要把所有代码都塞进main.c。这样做的直接好处是后续换用其他屏幕时只需要改oled.c的底层接口。后续增加新外设时不需要改动游戏逻辑。调试时可以单独测试某个模块减少问题定位范围。8.2 关于 HAL 库与标准库的选择从网络上的搜索热度看HAL 库目前是 STM32F103C8T6 学习的主流选择。如果你刚开始学习建议直接用 HAL 库它能让你避免陷入寄存器配置细节把精力集中在系统设计上。但不是说寄存器不重要当你遇到性能瓶颈时还是要回到寄存器层看问题。本文所有代码基于 HAL 库但游戏算法部分的逻辑完全可以跨平台复用。8.3 IIC 通信的工程思考本项目使用阻塞式 IIC 发送。在 400kHz 速率下刷新一屏需要传输 1024 字节数据大约耗时 20ms 左右。这个时间对于 200ms 移动一格的贪吃蛇来说可以接受但如果后续把移动周期降到 50ms你就需要考虑用 DMA 方式发送数据或者改为只刷新游戏区域而不是整屏刷新。另一个 IIC 通信的常见坑是如果 OLED 的 SCL/SDA 线没有接上拉电阻某些模块在较线上会通信异常。大多数市面上卖的 OLED 模块已经集成上拉电阻但如果你用的是裸屏转接板需要自己加上 4.7kΩ 上拉电阻到 VCC。8.4 中断回调函数的设计边界游戏测试过程中你可能会想增加一些声音提示、LED 闪烁等操作。注意不要把这些操作直接塞进定时器中断回调里。中断回调的黄金法则是中断里只做影响系统状态的关键操作耗时操作放到主循环里处理。在本项目中Snake_Move()涉及一个长度可能达到 256 的循环Game_Render()涉及清屏和 IIC 通信这些代码在中断里执行会导致主循环的摇杆扫描被延迟。如果后续想要提高刷新率建议改成“中断里置标志位主循环检测到标志位后执行移动和渲染”。8.5 代码版本管理建议嵌入式项目虽然小但也应该使用 Git 进行版本管理。在项目根目录初始化仓库后把 CubeMX 生成代码和用户代码都纳入版本管理每次修改前提交一次这样当你把代码改乱时可以快速回滚到上一个可用状态。具体操作上建议.gitignore忽略 Keil 的中间文件如Objects/、Listings/但保留.ioc文件和用户代码。.ioc文件是 CubeMX 的工程配置保存了引脚和外设配置它的重要性不亚于源代码不要忽略。8.6 硬件调试顺序工程项目最容易犯的错误是一上来就写代码写完再发现问题。推荐的调试顺序是先烧录一个简单的 GPIO 翻转程序确认最小系统板和下载链路正常。再单独测试 OLED初始化后显示全亮、全灭、固定字符。再单独测试摇杆通过串口打印 X/Y 值确认数据范围。最后把两者结合实现贪吃蛇主循环。这种“先模块后集成”的顺序能帮你把问题限定在最小范围内。如果你上来就烧完整代码遇到花屏时你很难判断是 OLED 驱动问题、摇杆采样问题还是主循环逻辑问题。9. 总结与后续扩展方向回到文章开头的问题很多人学会点灯后不知道下一步做什么。用 STM32F103C8T6 驱动 OLED 和摇杆做贪吃蛇是摆脱“例程依赖”的好起点。它不复杂但把显示、输入、定时、算法这四个嵌入式核心模块串在了一起每一步都是可以实际看到、玩到、调试到的。这个项目做出来之后你可以继续扩展的方向非常多难度等级系统根据吃到的食物数量动态提升移动速度让游戏从休闲变成挑战。最高分记录把最高分写入 STM32 内部 Flash 的某个扇区掉电不丢失。方向敏感与长按加速把摇杆的按压力度ADC 值大小映射到移动速度推得越重走得越快。像素图标和动画使用更大数组绘制蛇头方向、食物图标增加游戏表现力。FreeRTOS 移植把摇杆扫描、游戏逻辑、OLED 刷新拆分为三个独立任务学习实时操作系统的任务调度。LVGL 图形库移植在 OLED 上尝试更高级的图形界面开发了解嵌入式 GUI 的工作方式。无论你选择哪个方向建议先把本文的代码原样跑通一遍再开始修改。不要一上来就追求完美嵌入式开发的节奏本来就是“先跑起来再逐步优化”。如果你在某个步骤卡住了优先检查硬件接线和打印调试信息这两步能解决 80% 的问题。