请添加图片描述

从 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 屏幕不显示

排查

  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 事件
状态异常 缺少 volatile ISR 共享变量必须加 volatile
移植失败 引脚配置错误 先对照 PCB 确认引脚

相关文章

  • [[00-Inbox/博客_AI驱动嵌入式开发-Harness-Engineering实践指南]] — 给 AI 配工具的方法论总结
  • [[00-Inbox/博客_STM32命令行工具链手册-编译烧录调试自动化指南]] — 编译烧录调试命令参考
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐