3129 字
16 分钟
从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 + DMA1位 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 屏幕不显示

排查

  1. 万用表测量 SCL/SDA 引脚电平 - 异常
  2. 对照 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 玩法说明#

基本流程

  1. 玩家在 LED 0-2 区域,有红/黄/绿三种炮弹
  2. 按 KEY1/2/3 发射对应颜色的炮弹,沿轨道(LED 3-56)向敌方推进
  3. 敌方炮弹从另一端向玩家推进
  4. 炮弹相遇时:颜色匹配则消除得分,颜色不匹配则玩家炮弹消失
  5. 敌方炮弹到达玩家区域则扣生命值
  6. 生命值归零则游戏结束

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 非阻塞改造三步法#

  1. 找到时间源(SysTick、定时器)
  2. 设计状态机(状态定义 + 迁移条件)
  3. 分离”触发”和”更新”(触发在主循环,更新在中断)

5.2 分层诊断法#

问题 → 数据层 → 编码层 → 传输层 → 物理层

软件确认无误时,问题往往在硬件交互层。

5.3 移植驱动检查清单#

  • 确认硬件引脚映射(对照 PCB 原理图)
  • 确认通信方式(I2C 地址、SPI 模式)
  • 确认时序要求(初始化延时、命令间隔)

5.4 常见踩坑速查#

问题根因预防方法
LED 不亮引脚冲突开发前检查引脚复用表
颜色错误GRB 顺序查阅数据手册确认协议
显示渐变DMA 触发时机优先使用 Update 事件
状态异常缺少 volatileISR 共享变量必须加 volatile
移植失败引脚配置错误先对照 PCB 确认引脚

相关文章#

  • AI 驱动嵌入式开发:Harness Engineering 实践指南 — 给 AI 配工具的方法论总结
  • STM32 命令行工具链手册:编译烧录调试自动化指南 — 编译烧录调试命令参考
从WS2812驱动到消消乐游戏 - 嵌入式开发实战复盘
https://wmm-site.pages.dev/posts/ws2812-embedded-development-case-study/
作者
WMM
发布于
2026-05-06
许可协议
CC BY-NC-SA 4.0