STM32入门实战:OLED贪吃蛇与摇杆控制完整教程
用STM32F103C8T6驱动0.96寸OLED屏幕,再用摇杆控制贪吃蛇,是一个很典型、也很适合入门的嵌入式综合小项目。它把I2C显示、ADC输入、游戏逻辑、定时刷新全部串在一起,做完之后你会对“一个单片机系统是如何运转的”有更完整的体感。
如果你是刚学会GPIO和串口,正在找下一个练手项目,这个组合很合适。它不需要昂贵的开发板,不用接摄像头,也不用学复杂的触摸屏框架,一块STM32F103C8T6最小系统板、一块SSD1306 OLED、一个双轴摇杆模块就能搭起来。它的难点不在于某一个外设,而在于如何把外设数据转换成游戏行为,并稳定地显示在屏幕上。
我建议把它当成一个真实的小型系统来做,而不是只跑一个现成Demo。因为它的完整链路很长:摇杆电压变成ADC数字量,ADC数字量变成方向,方向驱动蛇头移动,蛇头坐标和食物坐标决定是否加分,最终这些状态全部通过I2C送到OLED显存上。任何一环出了问题,整个游戏都会有明显表现。
1. 先把它拆成四块:显示、输入、游戏逻辑、任务调度
拿到这个题目,不要直接想着“我要写一个贪吃蛇”。应该先拆模块,因为整条链路上的四个环节分别对应不同的外设和代码职责。
1.1 显示模块:OLED为什么比LCD、数码管合适
常见的0.96寸OLED模块一般是SSD1306控制芯片,I2C接口只需要两根信号线:SCL和SDA,加上VCC和GND总共四根线就能工作。128x64像素的分辨率对贪吃蛇来说刚刚好,可以划分成32x16个格子,每格用4x4像素来绘制,视觉上不会显得太小。
相比LCD1602,OLED能显示绘图内容,可以自由画点、画线、画方块,而不是只能显示字符。相比8x8点阵屏,OLED不需要自己不断扫描刷新,驱动的复杂度低很多。再加上OLED模块本身带驱动芯片,不需要额外的显示RAM管理,对STM32F103C8T6这种64KB Flash、20KB RAM的芯片来说非常友好。
所以选OLED做显示,核心原因就是:接口简单、显示灵活、开发成本低。
1.2 输入模块:摇杆比按键矩阵好在哪
双轴摇杆模块会输出两个模拟电压值,分别对应X轴和Y轴,另外还有一个SW引脚,按下时输出低电平。它本质上是两个电位器加一个按键。
如果用按键矩阵来做贪吃蛇,需要至少四个方向键,要占用四个GPIO,而且按键扫描还有消抖和组合判断的问题。摇杆则只需要两个ADC通道加一个普通IO,而且摇杆有个天然优势:操作手感更接近游戏设备。玩家把摇杆往左一推,蛇就应该往左走,这种模拟量输入比按键的离散状态更自然。
代价是摇杆需要读ADC,而ADC采样值不会像按键那样稳定在0和1,它会浮动,需要做阈值判断和滤波处理。
1.3 整体数据流和各模块之间的协作方式
这个项目的核心数据流可以这样理解:
摇杆电压 -> ADC数字量 -> 方向判定 -> 修改蛇头方向 游戏逻辑 -> 刷新蛇身坐标 -> 碰撞检测 -> 更新食物和分数 OLED显存 -> 绘制蛇、食物、文字 -> 通过I2C发送到屏幕 主循环/定时器 -> 控制游戏帧率 -> 让每次移动发生在固定时间间隔模块化不是形式主义。如果OLED驱动、ADC读取、游戏逻辑全部写在main函数里,一旦出问题,你根本不知道是显示格式错误,还是方向判定错误,还是蛇的数据结构改坏了。
实际开发时,我会先在main.c里定义好对外接口,再去分别实现。比如OLED只管理“在哪个坐标画点什么”,游戏逻辑只负责“蛇头下一格在哪里”,ADC部分只负责“当前摇杆方向是什么”。这样每一层都能单独验证。
2. 硬件准备与接线,先确认板子能正常下载
硬件选型不复杂,但接线一定要先理清楚。很多“程序没问题但屏幕不亮”的情况,最后检查下来都是VCC、GND接反或者SDA、SCL接反了。
2.1 器件清单和最小系统板说明
需要准备的器件如下:
- STM32F103C8T6最小系统板一块。
- 0.96寸OLED模块,推荐I2C接口、SSD1306控制芯片,常见蓝色或黄色双色显示。
- 双轴摇杆模块,上面有VRX、VRY、SW、VCC、GND五个引脚。
- STLink V2下载器一个,或者你用串口ISP下载也可以,但STLink更省事。
- 面包板、杜邦线若干。
STM32F103C8T6是LQFP48封装,市面上很多最小系统板已经集成了8MHz晶振、复位电路、LDO稳压和USB口,接上USB线就能供电。C8T6的Flash是64KB,RAM是20KB,跑贪吃蛇这个量级的程序完全够用。
现在市面上也有不少国产P2P兼容芯片,引脚和寄存器基本兼容,同一个工程能直接烧进去。但如果你用的是这类替代芯片,建议先单独跑一遍GPIO、ADC、I2C的最小用例,确认外设行为没有差异,再开始整个项目。
2.2 接线对应关系
推荐接线表如下:
| 模块引脚 | 连接到STM32F103C8T6 | 说明 |
|---|---|---|
| OLED VCC | 3V3 | 屏幕供电,统一3.3V |
| OLED GND | GND | 共地 |
| OLED SCL | PB6 | I2C1时钟,CubeMX默认引脚 |
| OLED SDA | PB7 | I2C1数据 |
| 摇杆 VCC | 3V3 | 摇杆供电 |
| 摇杆 GND | GND | 共地 |
| 摇杆 VRX | PA0 | ADC1通道0,对应X轴 |
| 摇杆 VRY | PA1 | ADC1通道1,对应Y轴 |
| 摇杆 SW | PA2 | 按键输入,配置上拉输入 |
| STLink SWDIO | PA13 | 下载调试 |
| STLink SWCLK | PA14 | 下载调试 |
这里有一个关键点:很多STM32F103C8T6最小系统板只引出3.3V,有些板子也有5V引脚。OLED和摇杆模块电流都不大,直接从板载3.3V取电最安全。如果你买的OLED模块同时支持3.3V和5V供电,建议也统一用3.3V,因为STM32的IO电平是3.3V,避免电平不匹配。
下载接线使用SWD方式,除了SWDIO和SWCLK,还要把GND也连上,部分STLink在下载时对地线很敏感,不共地会出现“找不到目标芯片”。
注意:STLink下载后,如果板载PC13指示灯只是闪烁,只能说明程序已经跑起来,不能说明屏幕或摇杆正常。外设是否正常,需要单独验证。
3. 创建工程:HAL库与CubeMX配置要重点设置哪些地方
工程创建这一步,我用CubeMX生成HAL库工程。虽然手写寄存器也能做,但HAL库在这类综合项目里效率更高,代码也更容易读懂。
搜索热词里经常出现“hal库驱动oled代码”“stm32f103c8t6 hal库手工创建项目”,说明很多人卡在工程初始化和外设配置上。
3.1 时钟与调试接口
CubeMX里选择芯片STM32F103C8T6后,第一步不是配置I2C,而是先配置RCC和SYS。
在System Core -> RCC里,把HSE设置为Crystal/Ceramic Resonator,这样板载8MHz晶振会被启用。然后在Clock Configuration里把系统时钟配到72MHz。
接着在System Core -> SYS里,把Debug设置为Serial Wire。这一步非常关键。FM3L的开发板如果用STLink,需要SWD接口。如果不开启Serial Wire,CubeMX可能把PA13和PA14配置成普通GPIO,导致第一次下载完程序后,第二次就连接不上调试器。
3.2 I2C1配置
在Connectivity -> I2C1里:
- I2C1 Speed Mode选Fast Mode,400kHz。
- 引脚默认映射到PB6为SCL、PB7为SDA。
- 其他参数可以保持默认。
F103的I2C硬件模块在HAL库下整体是可用的,但如果你的板子或传感器比较特殊,会出现I2C总线锁死、读写超时等问题。真遇到这种情况,我建议直接在代码里切换到软件模拟I2C,用两个GPIO时序模拟。不过在标准SSD1306 OLED上,硬件I2C通常没有问题。
3.3 ADC配置与采样稳定
在Analog -> ADC1里,开启通道IN0和IN1,也就是PA0和PA1。
配置时可以把扫描模式打开,使用连续转换或者单次转换。对于贪吃蛇来说,单次转换已经足够。关键点是采样时间不要用最短档位,建议选55.5周期或239.5周期。采样时间越长,采样电容充放电越充分,读数越稳定。
如果CubeMX里用了ADC扫描模式,后续通过HAL_ADCEx_MultiMode或HAL_ADC_Start_DMA读取时,要注意数组下标和通道顺序。我的做法是干脆不用DMA,直接在主循环里按通道逐个读取,每次读取前先重新配置通道,然后启动一次转换,等转换完成取结果。这样直观,也更容易调试。
3.4 GPIO、定时器和下载设置
摇杆SW按键接到PA2,在CubeMX里配置为GPIO_Input,上拉模式。因为摇杆模块按下SW时输出低电平,上拉输入保证不按下时读到高电平。
定时器方面,可以配置一个TIM3做10ms周期中断,也可以不在CubeMX里开定时器,直接在主循环里用HAL_GetTick()做非阻塞延时。我更推荐后者,因为贪吃蛇的“每帧移动间隔”本身是可变的,会随分数增加而变短,用变量控制比用固定定时器周期更灵活。
在Project Manager里设置好工程名和路径,Toolchain选择MDK-ARM。生成后会得到HAL库工程。
4. OLED驱动:屏幕能显示,后面调试才有意义
OLED不亮,后面所有东西都白搭。所以我建议先把屏幕点亮,能显示一个点、一条线、一个字符,再开始写游戏逻辑。
4.1 SSD1306 I2C地址和控制字节
SSD1306的I2C地址有两种常见情况,取决于模块上SA0引脚的电平。7位地址通常是0x3C或0x3D。但HAL库里的I2C写函数需要的是8位地址,所以你在代码里看到的地址可能是0x78或0x7A。
如果写0x78没反应,可以试试0x7A。很多模块默认7位地址0x3C,左移一位后就是0x78。
I2C通信里还需要区分命令和数据。对SSD1306来说,发送给它的第一个控制字节如果是0x00,表示后续字节是命令;如果是0x40,表示后续字节是显示数据。
4.2 初始化序列和关键命令
SSD1306上电后需要一组初始化命令,顺序不要乱。核心命令包括:
static void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2]; buf[0] = 0x00; // 控制字节:命令 buf[1] = cmd; HAL_I2C_Master_Transmit(&hi2c1, 0x78, buf, 2, 20); }初始化序列里比较重要的是:
- 0xAE:关闭显示。
- 0x8D 0x14:开启内部电荷泵。
- 0x20 0x00:设置水平寻址模式。
- 0xC8:设置COM扫描方向,从下到上。
- 0xA1:设置段重映射。
- 0xAF:打开显示。
其中0x8D和0x14如果漏写,屏幕会一直黑屏。这不是代码逻辑问题,而是电荷泵没启动,屏幕没有驱动电压。
4.3 显存和画点函数
SSD1306的128x64像素被分成8页,每页8行,每页128字节。写屏幕前,先在内存里维护一个1024字节的显存数组:
uint8_t oled_buf[1024];画点函数是后面的基础。任意一个像素坐标(x, y)要转换成显存下标:
void OLED_DrawPixel(uint8_t x, uint8_t y, uint8_t color) { if (x >= 128 || y >= 64) return; uint16_t idx = (y / 8) * 128 + x; uint8_t bit = 1 << (y % 8); if (color) { oled_buf[idx] |= bit; } else { oled_buf[idx] &= ~bit; } }这个函数的意义在于,游戏逻辑只需要告诉它“蛇头的第3个像素点在最左上角”,剩下的位运算由画点函数处理。
4.4 全量刷新和字符显示
绘制完成后,把整个显存数组通过I2C发送到屏幕:
void OLED_Refresh(void) { uint8_t buf[17]; buf[0] = 0x40; // 数据模式 for (uint8_t page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); OLED_WriteCmd(0x00); OLED_WriteCmd(0x10); HAL_I2C_Master_Transmit(&hi2c1, 0x78, buf, 1, 10); for (uint8_t col = 0; col < 128; col++) { buf[1] = oled_buf[page * 128 + col]; HAL_I2C_Master_Transmit(&hi2c1, 0x78, buf, 2, 10); } } }注意这里发送每列数据时,每次都要带0x40控制字节。如果全屏刷新卡顿,也可以只刷新需要变化的小区域,但贪吃蛇项目先全量刷新已经足够。
字符显示可以用内置5x7或8x16点阵字模。贪吃蛇界面我建议用8x16英文和数字,因为数字更大,显示分数更清晰。
5. 摇杆数据读取:ADC采样、阈值、方向判定
摇杆部分最容易出现的问题是“方向乱跳”和“方向不灵敏”。这两个问题本质上是同一个问题:没有正确理解摇杆的模拟量特性。
5.1 摇杆输出与ADC读数的关系
摇杆内部是两个电位器。X轴和Y轴输出电压随摇杆位置变化。供电3.3V时,静止状态下输出电压大约在1.65V左右,12位ADC换算过来大概是2048。推到底时接近3.3V,ADC值接近4095;反方向接近0V,ADC值接近0。
注意“大约”这个词。每个摇杆的机械位置不可能完全一致,电源电压也会有波动,所以实际中点不一定是2048,可能是1980,也可能是2100。写死中间值,容易导致某一边灵敏度很差。
5.2 读取ADC并做平均
我一般会写一个简单函数,对同一通道连续采样多次取平均:
uint16_t adc_read_average(uint32_t channel) { ADC_ChannelConfTypeDef sConfig = {0}; uint32_t sum = 0; for (int i = 0; i < 4; i++) { sConfig.Channel = channel; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); sum += HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); } return sum / 4; }4次平均就能把大部分随机噪声滤掉。如果还觉得抖动厉害,可以增加采样次数,但不要超过16次,否则方向响应会变慢。
5.3 开机校准中心点
更稳的做法是开机时校准。在进入游戏前,提示玩家不要动摇杆,程序连续读取50次X轴和Y轴数值,取平均值作为中心点。
uint16_t center_x, center_y; void Joystick_Calibrate(void) { uint32_t sum_x = 0, sum_y = 0; for (int i = 0; i < 50; i++) { sum_x += adc_read_average(ADC_CHANNEL_0); sum_y += adc_read_average(ADC_CHANNEL_1); } center_x = sum_x / 50; center_y = sum_y / 50; }这里有一个判断标准:偏离中心点多少才算有效方向。太小,人没推动摇杆就开始乱动;太大,推到底才有效果。我建议的阈值是400到600之间。比如X轴读数小于center_x减去400,判定为向左;X轴读数大于center_x加上400,判定为向右。
5.4 方向判定与边缘触发
方向判定函数返回四个方向之一:
uint8_t Joystick_GetDir(void) { uint16_t x = adc_read_average(ADC_CHANNEL_0); uint16_t y = adc_read_average(ADC_CHANNEL_1); if (x < center_x - 500) return DIR_LEFT; if (x > center_x + 500) return DIR_RIGHT; if (y < center_y - 500) return DIR_UP; if (y > center_y + 500) return DIR_DOWN; return DIR_NONE; }这里要注意的不只是方向判断,还有“边缘触发”。如果玩家抓住摇杆一直往左推,主循环每一轮都读到DIR_LEFT,然后每一轮都执行“蛇转向左”。这本身没问题,因为蛇已经在往左走,方向不会重复变化。但如果上一帧是向右走,这一帧突然变成向左,蛇就会瞬间掉头,直接穿到自己身体上。
所以必须在游戏逻辑里过滤反向:
if (new_dir != DIR_NONE && new_dir != opposite_dir(snake.dir)) { snake.dir = new_dir; }这样蛇只能向非反方向转向,不会主动撞死自己。
6. 贪吃蛇核心逻辑:数据结构和状态机怎么设计
贪吃蛇本身不复杂,但数据结构设计得好,后面所有判定都会很轻松。
6.1 地图划分与蛇的数据结构
OLED分辨率128x64,每格占4x4像素,那么地图就是32列、16行。用两个int16_t存坐标,可以避免无符号数在边界判断时出问题。
#define MAP_W 32 #define MAP_H 16 #define CELL_SIZE 4 typedef struct { int16_t x; int16_t y; } point_t; point_t snake_body[256]; uint16_t snake_len; uint8_t snake_dir; point_t food; uint16_t score; uint8_t game_state;蛇身数组长度为256,足够覆盖大部分游戏过程。实际上地图只有512个格子,加上墙壁和自身长度限制,长度到一两百已经很夸张。
初始状态可以把蛇放在地图中间偏左位置,初始长度3,方向向右。
6.2 移动逻辑
每一帧移动时,先根据当前方向计算新蛇头坐标,然后判断是否吃到食物,再决定是尾部不动还是尾部队列移出。
void Snake_Move(void) { point_t next_head = snake_body[0]; switch (snake_dir) { case DIR_UP: next_head.y--; break; case DIR_DOWN: next_head.y++; break; case DIR_LEFT: next_head.x--; break; case DIR_RIGHT: next_head.x++; break; } if (Snake_CheckCollision(&next_head)) { game_state = GAME_OVER; return; } if (next_head.x == food.x && next_head.y == food.y) { score += 10; snake_len++; Food_Generate(); } else { for (int i = snake_len - 1; i > 0; i--) { snake_body[i] = snake_body[i - 1]; } } snake_body[0] = next_head; }这里有一个顺序问题:必须先计算新蛇头,再做碰撞判断,最后再移动数组。如果反过来,先移动整条蛇、再判断碰撞,边界条件会变得混乱。
6.3 碰撞检测的细节
碰撞检测分为撞墙和撞自己。
uint8_t Snake_CheckCollision(point_t *new_head) { if (new_head->x < 0 || new_head->x >= MAP_W || new_head->y < 0 || new_head->y >= MAP_H) { return 1; } for (int i = 0; i < snake_len - 1; i++) { if (snake_body[i].x == new_head->x && snake_body[i].y == new_head->y) { return 1; } } return 0; }注意这里遍历的是snake_len-1,也就是不包含蛇尾。原因是:如果这一帧没有吃到食物,蛇尾会向前移动一格。新蛇头撞到蛇尾原本所在的格子,理论上不会被撞到,因为蛇尾已经离开了。如果遍历所有蛇身,包括蛇尾,就会出现“明明没撞到,游戏却结束了”的误判。
这个细节是我实际调试时最容易踩的坑。
6.4 食物生成与分数
食物生成需要随机数。标准库里可以使用rand,但需要先srand。嵌入式环境也可以用“开机时间”和“上次ADC值”组合出一个伪随机种子。
void Food_Generate(void) { do { food.x = rand() % MAP_W; food.y = rand() % MAP_H; } while (Snake_IsOnFood()); }生成后检查是否落在蛇身上,如果落在蛇身上就重新生成。地图总共512格,这个循环不会卡太久。
分数显示可以在OLED上画一个数字。显示函数基于8x16字模,把score变量转成字符串,再逐字符绘制到显存。每次得分后,只需要调用一次绘制函数,再全量刷新屏幕即可。
6.5 游戏状态机
游戏至少三个状态:开始、运行、结束。
typedef enum { GAME_START, GAME_RUN, GAME_OVER } game_state_t;- 在GAME_START状态,显示标题和“Push to Start”,检测到摇杆按键按下后进入GAME_RUN,同时复位蛇身、分数、食物。
- 在GAME_RUN状态,每帧间隔到期后执行Snake_Move。
- 在GAME_OVER状态,显示得分和“Press Key to Restart”,检测到按键后重新初始化并回到GAME_RUN。
这个状态机实际上是整个项目的核心骨架。如果一开始就把所有逻辑堆在主循环里,改到后面会非常痛苦。
7. 主循环与中断协作:怎么让游戏跑起来不卡
有了屏幕、输入、游戏逻辑,最后一步是把它们按正确的节奏组织起来。
7.1 主循环结构
我建议的主循环结构如下:
uint32_t last_move_tick = 0; uint16_t move_interval = 200; while (1) { OLED_Refresh(); uint8_t dir = Joystick_GetDir(); if (dir != DIR_NONE && game_state == GAME_RUN) { if (dir != opposite_dir(snake_dir)) { snake_dir = dir; } } if (game_state == GAME_RUN && HAL_GetTick() - last_move_tick >= move_interval) { Snake_Move(); last_move_tick = HAL_GetTick(); if (score > 20) { move_interval = 180; } else if (score > 60) { move_interval = 150; } } if (HAL_GPIO_ReadPin(JOY_SW_GPIO_Port, JOY_SW_Pin) == GPIO_PIN_RESET) { if (game_state == GAME_START || game_state == GAME_OVER) { Game_Reset(); } } }这里没有用HAL_Delay。原因是HAL_Delay会阻塞整个循环,导致摇杆读取和屏幕刷新全部停下。使用HAL_GetTick判断时间差,是非阻塞延时,游戏移动和输入读取可以并行处理。
7.2 显示刷新的节奏
OLED的全量刷新本身需要时间,因为1024字节显存要通过I2C发送。400kHz I2C模式下,全量刷新可能需要几十毫秒。如果主循环里每帧都全量刷新,然后每帧还移动一次蛇,实际游戏帧率会明显偏高,蛇会显得很迟钝。
更好的做法是把“游戏移动间隔”和“屏幕刷新间隔”分开。比如蛇每200ms移动一次,但OLED每50ms刷新一次。这样无论蛇有没有移动,屏幕上的内容都能保持刷新,操作时也能很快看到反馈。
7.3 按键消抖与重新开始
摇杆SW按键按下时,电平会抖动。直接在主循环里检测,可能一次按下被当成多次。简单处理方式是在检测到按下后,加一个10ms延时再确认;或者记录按下时间,间隔小于50ms就忽略。
重新开始时要复位蛇身长度、方向、分数、食物位置,还有moves_interval。如果不复位移动间隔,上一次玩到高分的加速效果会保留到下一局,很难玩。
8. 常见问题排查:屏幕不亮、摇杆乱跳、方向失灵、死机复位
项目做到这里,真正花时间的往往是排查问题。下面几个问题几乎每个玩家都会遇到。
8.1 OLED黑屏或花屏
先检查接线,再检查I2C地址,最后检查初始化序列。
- 确认OLED的VCC接3.3V,GND接GND,SCL接PB6,SDA接PB7。
- 确认I2C地址是0x78还是0x7A。写个测试代码,分别试一下。
- 确认初始化序列里有0x8D和0x14,也就是开启电荷泵。没有电荷泵,屏幕会一直黑。
- 如果屏幕部分区域有微弱显示但内容乱,通常是I2C读写时序有问题,降低I2C速度到100kHz再试。
8.2 摇杆方向乱跳
先不要急着调游戏逻辑。用一个简单程序读取摇杆原始ADC值,通过串口打印出来。
- 如果静止时数值一直在2000到2100之间大幅跳动,说明采样不稳,增大采样时间或者增加平均次数。
- 如果静止时数值偶尔跳到3000以上,检查摇杆VCC供电是否稳定,杜邦线是否松动。
- 如果推动摇杆后数值变化很小,检查是不是把VRX和VRY接反了,或者接到别的ADC通道。
方向乱跳大多数情况下不是“程序逻辑错”,而是“输入数据不干净”。
8.3 蛇移动太快或太慢
调整move_interval变量就行。初学建议初始间隔200ms,玩家适应后再逐步缩短到150ms、100ms。
如果用了定时器中断控制移动,而不是HAL_GetTick方式,要注意中断周期和游戏帧率是两回事。定时器中断只负责产生一个“时间标志”,不要在中断里执行蛇身移动和OLED刷新。
8.4 程序跑一段时间死机或复位
这种问题多半是内存越界或I2C超时阻塞。
先看snake_len有没有可能超过数组长度,比如数组定义了256,但长度增加到512后继续写数据就会越界。可以在Snake_Move里加长度上限判断。
再看I2C读写函数里的超时参数。如果超时设置得太短,比如只有10ms,OLED刷新慢时可能
