尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

STM32入门实战:OLED贪吃蛇与摇杆控制完整教程

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像素来绘制视觉上不会显得太小。相比LCD1602OLED能显示绘图内容可以自由画点、画线、画方块而不是只能显示字符。相比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是64KBRAM是20KB跑贪吃蛇这个量级的程序完全够用。现在市面上也有不少国产P2P兼容芯片引脚和寄存器基本兼容同一个工程能直接烧进去。但如果你用的是这类替代芯片建议先单独跑一遍GPIO、ADC、I2C的最小用例确认外设行为没有差异再开始整个项目。2.2 接线对应关系推荐接线表如下模块引脚连接到STM32F103C8T6说明OLED VCC3V3屏幕供电统一3.3VOLED GNDGND共地OLED SCLPB6I2C1时钟CubeMX默认引脚OLED SDAPB7I2C1数据摇杆 VCC3V3摇杆供电摇杆 GNDGND共地摇杆 VRXPA0ADC1通道0对应X轴摇杆 VRYPA1ADC1通道1对应Y轴摇杆 SWPA2按键输入配置上拉输入STLink SWDIOPA13下载调试STLink SWCLKPA14下载调试这里有一个关键点很多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 WireCubeMX可能把PA13和PA14配置成普通GPIO导致第一次下载完程序后第二次就连接不上调试器。3.2 I2C1配置在Connectivity - I2C1里I2C1 Speed Mode选Fast Mode400kHz。引脚默认映射到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.3VADC值接近4095反方向接近0VADC值接近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.3VGND接GNDSCL接PB6SDA接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读写函数里的超时参数。如果超时设置得太短比如只有10msOLED刷新慢时可能
返回列表