从WS2812驱动到消消乐游戏-嵌入式开发实战复盘

从 WS2812 驱动到消消乐游戏:嵌入式开发实战复盘
两天时间,从零驱动 WS2812 灯带到完成消消乐游戏。这篇文章记录了过程中遇到的核心问题、排查路径和设计决策,重点是那些"差点没搞定"的环节。
项目背景
硬件平台:STM32F103C8T6(Cortex-M3, 72MHz, 64KB Flash, 20KB RAM)
外围设备:WS2812 灯带(60 颗 LED)、SSD1306 OLED(I2C)、蜂鸣器、4 个按键
开发环境:VS Code + EIDE 扩展、ARM Compiler 5.06、J-Link OB-Mini Plus (SWD)
项目目标:在已有 PCB 板上完成灯带驱动、光效系统、消消乐游戏的完整开发。全程使用 AI(Claude Code)辅助,验证"给 AI 配工具"的嵌入式开发模式是否可行。
系统架构:
┌─────────────┐ SWD ┌──────────────────┐
│ J-Link │ ◄──────────► │ STM32F103C8T6 │
└─────────────┘ │ │
│ ┌─ TIM2_CH2(PA1)──► WS2812 (60 LED)
┌─────────────┐ UART │ ├─ I2C1(PB6/PB7)──► SSD1306 OLED
│ PC 串口 │ ◄──────────► │ ├─ GPIO ──────────► 蜂鸣器
└─────────────┘ │ └─ GPIO ──────────► KEY1-4
└──────────────────┘
开发周期:
| 日期 | 阶段 | 内容 |
|---|---|---|
| 2026-05-02 | 环境搭建 | EIDE 工具链、J-Link 调试、串口监视 |
| 2026-05-02 ~ 05-03 | 驱动开发 | WS2812 PWM+DMA 方案,解决引脚冲突、渐变色 |
| 2026-05-03 | 光效系统 | 8 种光效、查表法优化 |
| 2026-05-04 | 非阻塞改造 | 蜂鸣器/按键状态机、OLED 移植 |
| 2026-05-04 | 游戏开发 | 消消乐逻辑、菜单系统、界面优化 |
一、WS2812 驱动开发
1.1 方案选择
目标:驱动 60 颗 WS2812 RGB LED 灯带
方案对比:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| GPIO 软件模拟 | 循环翻转 GPIO | 简单 | 时序不精确,受中断影响 |
| SPI + DMA | 1位 WS2812 = 3位 SPI | 精确、通用 | 编码复杂度高 |
| PWM + DMA | 占空比表示 0/1 | 精确、直观 | 占用定时器 |
选择:PWM + DMA 方案,使用 TIM2_CH2 (PA1)
关键参数:
- PWM 频率:72MHz / 90 = 800kHz
- 0 码占空比:30/90 = 33%
- 1 码占空比:60/90 = 67%
1.2 问题一:引脚冲突
现象:
设置红色 → LED 不亮
设置白色 → 部分异常闪烁
排查过程:
LED 不亮
↓
检查 GPIO 配置 → 正常
↓
检查 TIM 通道映射 → PA0 对应 TIM2_CH1,正确
↓
检查 PCB 原理图
↓
发现 PA0 被 KEY3 占用!
根因:PA0 同时连接 WS2812 数据线和 KEY3 按键,按键的上拉电阻将 PWM 信号拉高。
解决方案:迁移到 PA1(TIM2_CH2)
// 修改前
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; // PA0
TIM_OC1Init(TIM2, &TIM_OCInitStructure); // CH1
// 修改后
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; // PA1
TIM_OC2Init(TIM2, &TIM_OCInitStructure); // CH2
教训:开发前必须检查引脚复用表!
1.3 问题二:颜色顺序错误
现象:
SetColor(0, 255, 0, 0); // 期望红色
// 实际显示:绿色
排查:通过串口打印 PWM 缓冲区,确认编码正确。
根因:WS2812 标准协议使用 GRB 顺序,不是 RGB。
| 字节位置 | 标准 WS2812 |
|---|---|
| 第 1 字节 | G (绿色) |
| 第 2 字节 | R (红色) |
| 第 3 字节 | B (蓝色) |
解决方案:
#define WS2812_COLOR_ORDER_GRB // 标准WS2812
1.4 问题三:渐变色异常(核心问题)
现象:
SetAll(255, 0, 0); // 设置全红
// 实际显示:红→绿→蓝 渐变色
最令人困惑的是:单独设置几颗 LED 完全正常,全白 (255,255,255) 也正常,唯独"全红"出现渐变。这意味着数据本身大概率没问题,问题出在传输环节——但具体在哪里?
关键线索:
- 单独设置 LED0/10/20 颜色正常
- 全白 (255,255,255) 显示正常
排查过程:
第一步:验证数据编码
添加诊断代码打印缓冲区:
// 颜色缓冲区 - 正确
LED0: R=255 G=0 B=0
// PWM 编码 - 正确
LED0: 30 30 30 30 30 30 30 30 60 60 60 60 60 60 60 60 30...
↑--- R=255 ---↑ ↑-------- G=0 --------↑
结论:软件编码完全正确,问题在硬件传输层!
第二步:尝试多种修复方案
| 尝试 | 修改内容 | 结果 |
|---|---|---|
| 1 | 内存对齐 __align(4) |
无效 |
| 2 | 增加复位周期 50 个 | 无效 |
| 3 | 修改占空比 | 无效 |
| 4 | 提高 DMA 优先级 | 无效 |
| 5 | 修改 DMA 触发方式 | 解决! |
第三步:分析 DMA 触发方式
CC2 事件触发(有问题):
PWM 周期: |←————1.25μs————→|
┌──────┐
─────┘ └─────
↑ DMA 在周期中间更新 CCR2,当前周期被破坏
Update 事件触发(正确):
PWM 周期: |←————1.25μs————→|
┌──────┐
─────┘ └─────
↑ DMA 在周期结束前更新,新周期正确
根因:DMA 使用 CC2 事件触发,在 PWM 周期中间更新 CCR2,导致波形畸变。
解决方案:
// 1. 启用 CCR2 预装载
TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Enable);
// 2. 使用 Update 事件触发 DMA
TIM_DMACmd(TIM2, TIM_DMA_Update, ENABLE);
// 3. 使用正确的 DMA 通道 (TIM2_Update → DMA1_Channel2)
DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&TIM2->CCR2;
关键发现:
| 触发方式 | 触发时机 | 适用场景 | 问题 |
|---|---|---|---|
| CC 事件 | CNT=CCR 时 | 单次传输 | 时机不可控 |
| Update 事件 | CNT=ARR 时 | 连续传输 | 时机可控 |
这个排查过程花了相当长时间。前 4 次尝试都失败了,直到从 PWM 波形的角度重新审视 DMA 触发时机,才找到根因。教训是:当软件逻辑确认无误时,问题往往在硬件交互的时序细节上。
1.5 WS2812 驱动经验总结
分层诊断法:
问题: LED 显示异常
↓
{ 数据层 } → 打印颜色缓冲区
↓
{ 编码层 } → 打印 PWM 缓冲区
↓
{ 传输层 } → 检查 DMA 配置
↓
{ 物理层 } → 示波器抓波形
可复用经验:
| 问题类型 | 排查方法 |
|---|---|
| 颜色异常 | 检查 GRB 顺序 |
| 显示不稳定 | 检查 DMA 触发方式 |
| 部分正常 | 检查引脚冲突 |
二、光效系统开发
2.1 模块化设计
文件架构:
HARDWARE/WS2812/
├── WS2812.h/c # 底层驱动
├── WS2812_ColorUtils.h/c # 颜色工具
└── WS2812_Effects.h/c # 光效系统
2.2 八种光效实现
| 模式 | 名称 | 算法要点 |
|---|---|---|
| 0 | 彩虹流动 | HSV 色相映射到 LED 位置,相位递增 |
| 1 | 彗星追逐 | 3 个彗星,拖尾使用线性衰减 |
| 2 | 呼吸灯 | 正弦波调制亮度,暖白色调 |
| 3 | 星空闪烁 | 随机产生星星,淡入淡出过渡 |
| 4 | 闪电效果 | 随机位置快速闪亮后衰减 |
| 5 | 颜色波浪 | 正弦波形状的颜色波动 |
| 6 | 火焰效果 | 暖色调随机波动 |
| 7 | 能量脉冲 | 从中心向两边扩散 |
2.3 嵌入式优化技巧
查表法替代浮点运算:
// 原方案:浮点运算(Cortex-M3 无 FPU,效率低)
u8 fade = (u8)(255 * (1.0f - (float)i / length * 0.6f));
// 优化方案:查表法
static const u8 fade_table[4] = {255, 191, 128, 64};
u8 fade = fade_table[i < 4 ? i : 3];
正弦表预计算:
// 预计算 360 点正弦表,避免实时浮点运算
static const u8 sine_table[360] = {
128, 130, 132, ... // 0-359 度对应的正弦值 (0-255)
};
u8 Sine_U8(u16 angle) {
return sine_table[angle % 360];
}
2.4 编译问题排查
问题:按键宏未定义
Error: #20: identifier "KEY0_PRES" is undefined
原因:代码中使用 KEY0_PRES,但项目中实际定义是 KEY1_PRESS
解决:查阅 KEY.h,使用正确的宏名称
三、非阻塞改造
3.1 为什么需要非阻塞
原始阻塞式代码:
void BEEP_Beep(u8 times, u16 on_ms, u16 off_ms)
{
while (times--) {
BEEP_On();
delay_ms(on_ms); // CPU 阻塞在这里
BEEP_Off();
if (times > 0) delay_ms(off_ms);
}
}
问题:
- CPU 在延时期间完全被占用
- 无法响应其他任务
- 多任务场景下任务相互阻塞
3.2 解决方案:状态机 + SysTick
核心思想:把时间消耗转化为状态迁移
阻塞式:
主循环: 鸣叫 → 阻塞等待 → 返回 → 继续
非阻塞式:
SysTick 中断 (1ms): 更新状态机
主循环: 非阻塞查询 → 处理业务
3.3 蜂鸣器状态机
状态定义:
typedef enum {
BEEP_STATE_IDLE = 0, // 空闲
BEEP_STATE_ON, // 鸣叫中
BEEP_STATE_OFF // 间歇中
} BEEP_State_t;
状态迁移:
times > 0
IDLE ───────────────> ON ───────> OFF
^ │ │
│ │ on_ms 到期 │ off_ms 到期
│ v v
└─────────────────────┴──────────────┘
times == 0 时回到 IDLE
核心实现:
void BEEP_Update(void) // 在 SysTick_Handler 中调用
{
if (beep_state == BEEP_STATE_IDLE) return;
beep_counter++;
switch (beep_state) {
case BEEP_STATE_ON:
if (beep_counter >= beep_on_ms) {
BEEP_Off();
beep_times_left--;
beep_counter = 0;
beep_state = (beep_times_left == 0) ? BEEP_STATE_IDLE : BEEP_STATE_OFF;
}
break;
// ...
}
}
3.4 按键状态机
状态定义:
typedef enum {
KEY_STATE_IDLE = 0,
KEY_STATE_DEBOUNCE_PRESS, // 按下消抖
KEY_STATE_PRESSED,
KEY_STATE_DEBOUNCE_RELEASE // 释放消抖
} KEY_State_t;
状态迁移:
检测到按下 20ms 后确认
IDLE ───────────────> DEBOUNCE_PRESS ─────────────> PRESSED
^ │
│ 20ms 后确认 │
└────────────── DEBOUNCE_RELEASE <───────────────────┘
检测到释放
3.5 关键技术点
volatile 必不可少:
// 中断中修改的变量必须用 volatile
static volatile KEY_State_t key_state = KEY_STATE_IDLE;
static volatile u8 key_pressed_event = KEY_NONE;
为什么需要 volatile:
- 防止编译器优化导致读取到旧值
- 确保每次都从内存读取变量
临界区保护:
u8 KEY_GetPressed(void)
{
u8 event;
__disable_irq(); // 进入临界区
event = key_pressed_event;
key_pressed_event = KEY_NONE;
__enable_irq(); // 退出临界区
return event;
}
为什么需要临界区:
- 读-修改-写序列需要原子性
- 防止中断打断导致数据不一致
3.6 OLED 驱动移植踩坑
问题:OLED 屏幕不显示
排查:
- 万用表测量 SCL/SDA 引脚电平 - 异常
- 对照 PCB 标注 - 发现引脚定义错误
根因:移植代码时直接使用原代码的引脚定义,未对照实际 PCB
// 错误(原代码)
#define OLED_SCL PBout(10)
#define OLED_SDA PBout(11)
// 正确(根据 PCB)
#define OLED_SCL PBout(6) // PB6
#define OLED_SDA PBout(7) // PB7
教训:移植驱动时,必须先确认硬件引脚映射!
四、消消乐游戏开发
4.1 设计过程
通过 brainstorming 工作流,逐层澄清需求:
布局方案:单向布局
- 玩家区域:LED 0-2
- 轨道:LED 3-56
- 敌方区域:LED 57-59
炮弹设计:多灯带拖尾效果
碰撞规则:
- 颜色匹配 → 消除得分
- 颜色不匹配 → 玩家炮弹消失(不扣命)
按键映射:
- 游戏中:KEY1/2/3 发射红/黄/绿,KEY4 暂停
- 菜单中:KEY1/2 切换光效,KEY3 调亮度,KEY4 进入游戏
4.2 架构设计
轻量模块化:
| 模块 | 职责 |
|---|---|
Game_Core |
游戏逻辑:状态机、炮弹、碰撞、分数 |
Game_UI |
界面渲染:LED 显示、OLED 界面、音效 |
4.3 玩法说明
基本流程:
- 玩家在 LED 0-2 区域,有红/黄/绿三种炮弹
- 按 KEY1/2/3 发射对应颜色的炮弹,沿轨道(LED 3-56)向敌方推进
- 敌方炮弹从另一端向玩家推进
- 炮弹相遇时:颜色匹配则消除得分,颜色不匹配则玩家炮弹消失
- 敌方炮弹到达玩家区域则扣生命值
- 生命值归零则游戏结束
OLED 界面显示:
┌──────────────────┐
│ 得分: 1200 │
│ 生命: ████░ 4/5 │
│ 连击: x3 │
│ ★★☆ 难度 2 │
└──────────────────┘
音效系统:五种音效对应不同游戏事件——发射、击中、连击、失败、游戏结束,均由蜂鸣器状态机非阻塞驱动。
4.4 优化迭代
游戏手感调整:
- 敌方炮弹初始速度从 2 降到 1
- 发射冷却从 500ms 降到 200ms
碰撞机制优化:
- 原设计:颜色不匹配扣命
- 修改后:错误颜色只消耗玩家炮弹
OLED 显示优化:
- 移除模式文字,改用星号显示难度等级
- 生命值用方块字符直观显示:
生命: ████░ (4/5)
菜单系统:
- 8 种预设光效供选择
- 4 档亮度循环切换
4.5 最终成果
编译结果:
ROM: 13.67KB (21.4% of 64KB)
RAM: 6.05KB (30.3% of 20KB)
零警告、零错误
游戏功能:
- 四种游戏状态:菜单、游戏中、暂停、结束
- 炮弹系统:多灯带拖尾、颜色匹配消除
- 双显示:OLED(分数/生命/连击)+ LED(光效)
- 五种音效:发射、击中、连击、失败、游戏结束
五、经验教训总结
5.1 非阻塞改造三步法
- 找到时间源(SysTick、定时器)
- 设计状态机(状态定义 + 迁移条件)
- 分离"触发"和"更新"(触发在主循环,更新在中断)
5.2 分层诊断法
问题 → 数据层 → 编码层 → 传输层 → 物理层
软件确认无误时,问题往往在硬件交互层。
5.3 移植驱动检查清单
- 确认硬件引脚映射(对照 PCB 原理图)
- 确认通信方式(I2C 地址、SPI 模式)
- 确认时序要求(初始化延时、命令间隔)
5.4 常见踩坑速查
| 问题 | 根因 | 预防方法 |
|---|---|---|
| LED 不亮 | 引脚冲突 | 开发前检查引脚复用表 |
| 颜色错误 | GRB 顺序 | 查阅数据手册确认协议 |
| 显示渐变 | DMA 触发时机 | 优先使用 Update 事件 |
| 状态异常 | 缺少 volatile | ISR 共享变量必须加 volatile |
| 移植失败 | 引脚配置错误 | 先对照 PCB 确认引脚 |
相关文章
- [[00-Inbox/博客_AI驱动嵌入式开发-Harness-Engineering实践指南]] — 给 AI 配工具的方法论总结
- [[00-Inbox/博客_STM32命令行工具链手册-编译烧录调试自动化指南]] — 编译烧录调试命令参考
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)