【STM32】 工程实战 WS2812B 全彩 LED 灯控制(DMA 驱动)


WS2812B 全彩 LED 灯控制(DMA 驱动)

1. 全彩 LED 原理

1.1 为什么不用单色 LED?

常见的点阵屏、球形灯、灯带、环形灯、模型装饰、隧道式灯阵这些全彩作品,都不是用单色 LED 实现的,原因有二:

  1. 单色 LED 颜色太少:没有那么多颜色,而且颜色无法通过程序改变;
  2. 控制引脚太多:要控制这么多单色 LED,需要的控制引脚数目实在太多。

这时候就需要全彩 LED 出场了。

全彩 LED(Full-color LED):

一种能通过程序控制、发出可见光谱中几乎任何颜色的灯珠。

1.2 单个全彩 LED 的基本原理

一个全彩 LED 其实是"三合一"结构:

它的外壳里封装了三个发光芯片------红(Red)、绿(Green)、蓝(Blue)。

① 三原色原理:

红、绿、蓝是三种基本色光,通过改变它们各自的亮度比例进行叠加,就能组合出自然界中几乎所有的色彩。

cpp 复制代码
最终颜色 = 红色亮度比例 + 绿色亮度比例 + 蓝色亮度比例(光混合)

② 亮度等级怎么定?(韦伯比)

人眼睛能识别出来的亮度差,等级数大约 20~100 个等级,也就是亮度差在 2% 以内,人类就感知不到变化了------这组数据叫做韦伯比(Weber fraction)。

计算机有了基本数据存储单位------字节(8 比特位),表示数据种类范围 0,255。在历史发展之下,慢慢演变成用一个字节表示不同的亮度等级,于是亮度等级演变成了 256 种。

③ 24bit 颜色深度

所以,一个全彩 LED 灯需要给我们提供3×8= 24 个比特位,分别表示 R、G、B 三原色的亮度比例,就可以呈现出不同的色彩效果。

AI 思考题:单个 24 位 RGB 全彩 LED 灯理论上会呈现多少种颜色效果?

解:每个颜色分量 8bit 有 2⁸=256 种取值,三原色独立组合:256³ = 16,777,216 种(约 1677 万色)。这就是"真彩色"的由来。

④ 为什么叫"全彩"LED?

色彩覆盖广、动态可变性;

普通 LED 灯只有一种颜色,最多呈现明暗效果。

1.3 常见全彩 LED 型号

① 内置 IC 型(集成驱动型 LED)------这类灯珠通过单根或双根信号线串联,适合大规模像素化控制:

型号 特点 典型场景 理由
WS2812B 单线控制,最主流 电竞桌面氛围灯带、智能家居背景光、手办展示柜亮化、DIY 像素屏、穿戴设备 仅需 1 根引脚即可串联成百上千颗,布线极简
SK6812 兼容型,支持 RGBW 高档室内辅助照明(吊顶灯带)、化妆镜补光 有专门"白光"通道(W),调出的白光比 RGB 混合更纯净、显色性更好
APA102 双线控制,带时钟线 视觉暂留(POV)旋转屏、高速无人机灯效 刷新率极高且不闪烁,高速运动画面稳定
WS2811 外置 IC 型 户外建筑轮廓亮化、大型节日装饰灯串 配合 12V 供电,长距离传输信号损耗小

② 传统直驱型(无内置 IC)------需要主控芯片(单片机)直接输出多路 PWM 信号来驱动:

型号 典型场景 理由
5050 RGB (贴片直驱) 电脑机箱风扇、汽车仪表盘背光、小型玩具指示灯 成本极低,适合单色整体切换、不需要每颗灯不同颜色
F5/F8 RGB (插件灯珠) 电子设计竞赛、面包板电路实验、自制状态指示灯 长引脚免焊接插面包板,适合新手练习 PWM 调色逻辑

③ IC 全称:Integrated Circuit 的缩写,中文全称是集成电路。

④ 传统直驱 vs 集成驱动

对比 传统直驱 集成驱动
内部结构 内部没有集成电路 LED 内部有一套独立硬件电路, 可自主控制色彩输出
控制方式 颜色只能由 MCU 的针脚驱动 MCU 通过 1 个引脚输出数字协议指令, LED 内部芯片再驱动颜色输出
举例 电脑主机箱电源指示灯:MCU 一个引脚直接连灯珠,高电平亮、低电平灭,完全由引脚"肉身"供电 (我们以前点灯实验都是这种) 电竞外设 RGB 流水灯带:MCU 只发出一串包含"变红、变绿、渐变"信息的数字脉冲指令,灯带芯片收到后自己控制内部电路变色

比特灵犀板使用的全彩 LED 灯就是最主流的 WS2812B 型 LED。

1.4 WS2812B 详解

① 命名格式:WS2812B = Worldsemi(厂商) + 28系列协议 + 12型号 + B版本(4引脚/带保护)

② 基本定位:

WS2812B 是一个集控制电路与发光电路于一体的智能外控 LED 光源,外型与 5050 封装普通灯珠相同,每个灯珠就是一个彩色的像素点。很明显它属于集成驱动型 LED。

  • 5050 封装:灯珠外形尺寸为 5.0mm × 5.0mm。

③ 4 个引脚:

引脚 名称 作用
VCC 电源级 接电源正极
GND 电源负极 接地
DIN Data In 控制信号输入引脚,连接单片机 MCU 引脚或上一级灯珠
DOUT Data Out 控制信号输出引脚,连接下一级灯珠的 DIN,实现串联级联

④ 级联能力:

DIN 输入控制信号、DOUT 连接下一个灯珠的 DIN,把多个灯珠串联起来。

虽然它有 4 个引脚,但控制几十颗甚至上百颗灯珠时,MCU 始终只需要占用 1 个 GPIO 口,连接第一颗灯的 DIN 即可实现控制逻辑。

⑤ 内部机制:

每一颗 WS2812B 内部都有 3 × 8 = 24bit 的 RGB 配置寄存器(准确说是锁存器),更改它,就可以改变不同 RGB 颜色的亮度,进而整体表现出不同颜色。

控制多个灯珠只用 1 个 MCU 引脚------用的正是定时器的输出比较相关通道引脚。

显然,接下来只要理解了怎么控制这些灯的时序(协议),就明白了定时器输出比较功能的作用和应用场景。


2. 单灯珠控制:归零码协议

2.1 什么是归零码?

WS2812B 灯珠控制的数据协议采用**单线归零码(Return-to-Zero, RZ)**的通讯方式。

归零码定义:

一种基础的数字信号编码技术,核心特征是------无论传输的是 0 还是 1,信号在每个比特位周期结束时都会强制回到零电平(通常是低电平)。

为什么要用归零码?(关键考点)

灯珠是一个脉宽敏感的设备:

  • 如果没有归零,用传统的高低电平表示 0/1,那么发送一批 0 或一批 1 时,输出电平会一直保持低或一直保持高,没有任何跳变;
  • 此时必须要求通信双方约定严格的周期时长,差一点都不行------因为只有明确一个周期是多长,才能清楚知道持续发送了多少个 0 或 1,这对硬件的要求太高了;
  • 而归零码通过强制归零,确保每个比特位至少有一次边沿变化,接收电路只需检测脉冲上升沿即可锁定比特边界,降低硬件复杂度;
  • 归零码表示的 0 或 1 都有明确的开始和结束,每个比特独立可辨。

本质结论:

使用归零码的方案,本质是降低对硬件时序的要求,提高可靠性。

归零码把"对时钟同步的极高要求"转化为了"对高低电平时间比例的简单判断"。

旗语类比(帮助记忆):通信过程类似古代旗语,将军和士兵做约定------

  • 周期:每发送一个数字固定耗时 1 秒;
  • 归零动作:每秒结束时,旗子必须回到腰间(0 电平);
  • 逻辑 1(长举):举起 0.8 秒(高),放下休息 0.2 秒(低,归零);
  • 逻辑 0(短举):举起 0.2 秒(高),放下休息 0.8 秒(低,归零)。

这样士兵就可以通过该规则向将军发送 0/1 数据了。

2.2 0 码和 1 码的波形参数(数据手册)

0 码:T0H + T0L = T0 组成 0 码波形周期

参数 符号 Min Typ Max 单位
输入 0 码高电平时间 Tin0h 0.20 0.295 0.35 us
输入 0 码低电平时间 T0L 0.55 0.595 1.2 us
0 码/1 码周期 T0/T1 0.89 - - us
复制代码
0 码周期 = T0H + T0L = 0.295us + 0.595us = 0.89us
0 码占空比 = 0.295 / 0.89 = 33.15%  ← 占空比较低的波形

1 码:T1H + T1L = T1 组成 1 码波形周期

参数 符号 Min Typ Max 单位
输入 1 码高电平时间 Tin1h 0.55 0.595 1.2 us
输入 1 码低电平时间 T1L 0.20 0.295 0.35 us
0 码/1 码周期 T0/T1 0.89 - - us
复制代码
1 码周期 = T1H + T1L = 0.595us + 0.295us = 0.89us
1 码占空比 = 0.595 / 0.89 = 66.85%  ← 占空比较高的波形

细节 1:

0 码并不是持续的低电平,也有高电平**;**

1 码并不是持续的高电平,也有低电平。

通信双方约定特定脉宽的波形来表示 0/1 编码。

细节 2:

0.295 : 0.595 近似为 1 : 2------0 码高电平占 1 份、低电平占 2 份;

1 码高电平占 2 份、低电平占 1 份。

0 码 vs 1 码本质:

观察 0 码、1 码,发现 T*L(T0L、T1L)都是低电平------在同周期情况下,真正区分 0 还是 1,其实是看波形中高电平的持续时间(脉宽)。

T*L 都是低电平就是*归零的表现,确保下一次输出一定是从低电平开始跳变的。*

如果 MCU 向单颗 WS2812B 发送 0110,波形就是:

0码 + 1码 + 1码 + 0码 按顺序组合------每个比特都是"高电平脉宽 + 低电平归零"的完整周期。

2.3 单灯珠 24bit 数据结构

控制单个灯珠的数据由 24bit(3 bytes) 组成,颜色分量各占一个字节。

注意:位序是 G、R、B,不是严格的 RGB!

cpp 复制代码
 24bit 数据结构(MSB first,高位先发)
┌──────────────────────────┬─────────────────────────┬──────────────────────── 
 G7 G6 G5 G4 G3 G2 G1 G0   │ R7 R6 R5 R4 R3 R2 R1 R0 │ B7 B6 B5 B4 B3 B2 B1 B0 │
│        Green 8bit        │        Red 8bit         │        Blue 8bit        │
└──────────────────────────┴─────────────────────────┴─────────────────────────┘
       先发送 ←──────────────      24 bit     ──────────────→       后发送
  • 明显的MSB first(高位先行);
  • 将 24 个 0 码和 1 码按需组合在一起,就构成一个灯珠的控制波形。

2.4 数据帧尾:RESET 复位码

在每一轮控制数据发送(刷新周期)完成后(比如 24 个比特位数据发送完毕),必须再发送一个或多个复位信号波形 RESET 码,做控制数据的帧尾,表示本轮发送结束。

参数 符号 Min Typ Max 单位
RESET 码低电平时间 Trst 80 - - us

RESET 码的含义:

一帧数据发送完毕后,需要保持一定时间长度(>80us)的低电平,这样数据才会真实生效,否则可能和下一周期的数据混叠在一起。

为什么固定 24 个比特位,还要 RESET 码?------和 WS2812B 的工作模式有关:

WS2812B 工作模式分两个阶段:

  1. 搬运阶段:接收 24bit 数据("装填子弹");
  2. 执行阶段:根据 24bit 数据,驱动红、绿、蓝灯珠点亮("扣动扳机")。

所以,RESET 码表示传输结束,但对 WS2812B 来讲,表示的是:"传输完成了,你可以根据数据点亮了"。

复制代码
单个灯珠的控制波形 = 24 个 0/1 码 + 1 个 RESET 码

3. 一组灯珠的控制(级联机制)

有了单灯珠概念,一组灯珠的控制就容易了。

级联传输机制:在一组灯珠上电复位以后,DIN 管脚接受从 MCU 传输过来的数据:

  1. 首先送过来的 24bit 数据被第一个灯珠提取;
  2. 剩余的数据经过内部电路通过 DOUT 管脚转发给下一个级联的灯珠;
  3. 每经过一个灯珠的传输,数据减少 24bit;
  4. 灯珠内部电路能够自动转发,信号传送不受级联灯珠个数的限制,仅受信号传输速度限制。

一组 N 个灯珠的控制波形:

复制代码
控制波形 = N × 24 个 0/1 码 + 1 个 RESET 码

串接灯珠控制时序(3 颗灯珠、两个刷新周期):

引脚 刷新周期 1 刷新周期 2
D1(芯片1) 第 1 位 24bit 第 2 位 24bit
D2(芯片2) (转发后)第 2 位 24bit 第 3 位 24bit
D3(芯片3) (转发后)第 3 位 24bit RESET
D4(最后一颗 DOUT) 悬空/断开 ...

如果串行了一组灯珠,RESET 码的效果就是让多个 RGB LED 同时点亮。

细节:

最后一颗 LED 灯------DIN 接前一颗的 DOUT,VCC/GND 正常接,DOUT 保持断开(悬空)即可。


4. 实操:CubeMX 配置

4.1 目标

通过通用定时器 TIM3 控制开发板的 RGB 灯珠阵列。

根据 0 码/1 码的学习,几乎可以肯定:控制全彩 LED 矩阵的片上外设一定是通用定时器,而且必须是 PWM 模式------因为我们需要灵活的占空比和脉宽设置功能,用 PWM 最方便。

4.2 分析开发板原理图

  • 比特就业课灵犀板上内置了一套 3×3 的 RGB 灯珠阵列(LED9~LED17);
  • 根据灯珠原理,只需要一个输入引脚就能控制所有灯珠;
  • 在开发板原理图中搜索"RGB",找到关键网络标签 RGB_DIN------对应 RGB 灯珠阵列的输入引脚;
  • 细节:物理上是一个矩阵的形状,但其实是串行连接的;
  • 继续查原理图:RGB_DIN 接入 MCU 的 TIM3_CH3 引脚,即 TIM3 的 3 号输出通道对应的引脚;
  • TIM3_CH3 和 PB0 复用同一引脚------配置 PB0 就是在配置 TIM3_CH3(引脚复用表:PB0 → ADC12_IN8 / TIM3_CH3 / TIM8_CH2N);
  • 板子设计时 MCU 和全彩矩阵两个模块都有设计,但物理上没有真正连接,需要你自己连接:用跳帽或杜邦线把定时器的输出引脚和 RGB_DIN 连接起来(H1 排针)。

4.3 配置 CubeMX(分步)

① 开启调试口:System Core → SYS → Debug 选 Serial Wire。

② 配置 printf 输出到串口(略,按之前课程配置)。

③ 配置时钟:

  • RCC → High Speed Clock (HSE) 选 Crystal/Ceramic Resonator(开启外部高速时钟);
  • Clock Configuration:SYSCLK=72MHz、AHB=72MHz、APB1=36MHz(/2)、APB1 Timer clocks = 72MHz(关键!);
  • 可以直接输入要配置的频率值(72),CubeMX 会自动完成其他分频器配置;
  • 实际配置时如果直接输入 72 不行,可以尝试优先把 HCLK 设置为 72MHz。

④ 开启定时器 TIM3:

  • 把 TIM3 的 Channel3 设置为 PWM Generation CH3 模式;
  • 此时 TIM3 就会通过 PB0 引脚向外输出 PWM 信号;
  • 其他选项保持默认即可(其他功能要么和通道 3 类似、要么与定时器级联有关,暂不考虑);
  • 注意:这里看到的配置和普通定时器 TIM6/TIM7 不一样,不用奇怪。

⑤ 配置 PWM(Channel 3 改为 PWM Generation CH3 后新增配置界面):

配置项 值 说明
Prescaler (PSC) 0 预分频器
Counter Mode Up 向上计数
Counter Period (ARR) 63 从 0 变到 63,正好 64 个时钟周期
Internal Clock Division (CKD) No Division 内部时钟分频,用于死区控制或数字滤波,不直接改变计数器频率
auto-reload preload Disable (后面开启) ARR 影子寄存器机制开关
Mode PWM mode 1 向上计数:CNT < Pulse 输出有效电平,否则无效
Pulse (CCR) 0(初始) 对应比较寄存器 CCR 的值。当前设为 0,后续通过代码动态调整,以达到输出 0 码/1 码的效果
Output compare preload Enable CCR 预装载寄存器:修改 Pulse 值后新值不立即生效,等到当前计数周期结束、发生更新事件(UEV)时才载入
Fast Mode Disable 关闭即可
CH Polarity High 决定什么是"有效电平"

注意:

PWM mode 1 在向上计数时,只要计数器值(CNT)小于设定值(Pulse/CCR),通道输出为有效电平(由 CH Polarity 决定);否则输出为无效电平。

如果选中 PWM Generation CH3 后没有弹出新增配置界面,请重启 CubeMX。

⑥ 计算并填写定时器参数(核心推导)

通过开发板原理图,RGB LED 具体型号为 XL-5050RGBC-2812B(成兴光 XINGLIGHT,SMD5050-4P 封装)。

在立创商城搜索该型号可找到数据手册,数据手册中描述了 0 码/1 码的时间参数,这是后续开发配置的关键依据。

数据手册给了时间范围(不是固定值):

只要信号持续时间在这个范围内,都视为正确信号。

因为到了 us 尺度,实践上多少都会存在误差,RGB LED 芯片并没有把时间卡得很死,而是有宽裕的范围,避免误差带来的影响。我们按典型值设置。

计算过程:

cpp 复制代码
① 0 码或 1 码周期(典型值) = 0.295 + 0.595 = 0.89us
② 当前时钟频率 72MHz → 1 秒产生 72000000 个时钟周期
③ 产生 1 个时钟信号的时间 = 1/72000000 ≈ 1.388×10⁻⁸ 秒 ≈ 0.01388us
④ 0.89us / 0.01388us ≈ 64.12 → 产生 64.12 个时钟周期消耗 0.89us
⑤ 0.89 只是典型值,不需要精确凑出,直接取整 64
   64 个时钟信号对应 0.888us,落在芯片时间范围内 ✓
⑥ 配置:Prescaler = 0(不分频)、Counter Period = 63(0~63 正好 64 个周期)

通过这样的设定,可以确保定时器每次进行一轮计数,消耗的时间就对应到一个 0 码/1 码的周期。

pulse 值怎么定?------把 64 按 1:2 分配

0.295 : 0.595 近似 1 : 2,所以把 64 按 1 : 2 的比例分成 21 和 43:

复制代码
0 码:1 份高电平 + 2 份低电平 → Pulse = 21(占空比低 ≈33%)
1 码:2 份高电平 + 1 份低电平 → Pulse = 43(占空比高 ≈67%)
RESET:持续低电平 → Pulse = 0

⑦ 配置 DMA(TIM3 中点击 Add 添加一个 DMA 配置):

配置项 值 说明
DMA Request TIM3_CH3 由 TIM3 通道 3 产生
Channel DMA1 Channel 2 -
Direction Memory To Peripheral 内存到外设:要把内存准备好的数据放到定时器中,这点很重要
Priority Low -
Mode Normal -
Increment Address - Memory ☑ 勾选 内存自增:依次取数组中的下一个值
Data Width Half Word 16 位,默认即可(对应 uint16_t)

至此,CubeMX 配置完成,可以生成项目代码了。


5. 代码实现

5.1 生成 0 码 / 1 码(先验证波形)

pulse 值是通过 CCR 寄存器控制的,通过宏 __HAL_TIM_SET_COMPARE 就可以设置 pulse 的值。

cpp 复制代码
void set_pwm_code(uint16_t code)
{
    if (code == 0) {
        __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 21);  // 0 码
    } else if (code == 1) {
        __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 43);  // 1 码
    }
}

在 main 函数主循环之前:

复制代码
// 启动 PWM 输出
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);
// 设置输出 0 码
set_pwm_code(0);

示波器验证:直接测量 PB0 引脚(正极接 PB0,负极接 GND):

  • 0 码:高电平宽度约 295ns(ΔX≈895ns 为一个周期,其中高电平短);
  • 1 码:高电平宽度约 595ns(高电平长)。

这个环节只是为了让直观看到 0 码和 1 码的效果。

后续驱动 WS2812B 通过 DMA 方式完成,不再使用 set_pwm_code 函数。

5.2 WS2812B 信号数据格式(以 9 个 LED 纯绿色为例)

9 个 LED 均设为纯绿色,绿色 GRB 值是:11111111 00000000 00000000

复制代码
G 字节 = 11111111(绿色拉满)→ 8 个 1 码
R 字节 = 00000000 → 8 个 0 码
B 字节 = 00000000 → 8 个 0 码

根据信号序列设置 pulse 值(调整 PWM 占空比):

复制代码
每个 0 码信号,把 pulse 设为 21
每个 1 码信号,把 pulse 设为 43
针对最终的复位信号,把 pulse 设为 0

关于"复位信号"(RESET)再强调:

对于 WS2812B,由于存在 N 个灯珠,而芯片本身也不知道当前过来的信号要针对几个灯珠操作,因此需要有一个明确的"结束标志"来表明当前控制信号"结束"了,这个信号就是复位信号。

与此同时,RESET 信号还会同步点亮所有 RGB LED。

数据手册要求产生一个不低于 80us 的持续低电平作为复位信号,收到复位信号之后,前面的控制信号才会"正式生效"。

5.3 DMA 触发机制(重点难点)

思路:

通过一个数组,把要填写的 pulse 值按照顺序放进去,再通过 DMA 拷贝到定时器中,此时定时器就可以输出上述的 PWM 信号序列。

什么时候触发一次 DMA 搬迁?------从 DMA 请求映像图推测

DMA1 请求映像图中,TIM3_CH3 对应 DMA1 的硬件请求 2(通道 2)。

那么 TIMx_CHn 和 TIMx_UP 是不同的(要不然图里会写成一样),而定时器产生的事件源要么是溢出、要么是比较匹配,再结合 TIMx_CHn 中 CH 就是"通道"的意思,可以推测出来:

结论:TIMx_CHn 就是"比较匹配"触发 DMA。

  • 什么时候触发一次搬迁? 当 TIM3 通道 3 触发比较匹配的时候,就进行一次 DMA 搬迁;
  • 强调:触发一次搬迁,就是把 21/43 设置到 CCR 的预装载寄存器,定时器下次溢出的时候才会更新(影子寄存器机制);
  • 完整工作流程(结合边沿对齐模式):比较匹配时触发 DMA 搬迁 → 下一次定时器溢出时更新 CCR 影子寄存器 → 配置生效 → 整个过程就滚动起来了。
cpp 复制代码
DMA 流水线(每 0.89us 一个节拍):
比较匹配 → DMA 从数组取下一个 pulse(21/43)→ 写入 CCR 预装载
        → 定时器溢出(UEV)→ CCR 影子更新 → PWM 输出新脉宽 → 下一轮比较匹配 → ...

为什么不用 CPU + 中断?

WS2812B 对输入信号有非常严格的时序要求。

CPU + 中断方案:

效率低且可能不稳定(触发中断嵌套导致 CPU 没能及时构造下一个信号,而且执行一条命令的时间也不稳定)。

用 DMA 的方式是更好的选择:

通过内存数组把要构造的颜色数据提前准备好,DMA 拷贝到定时器 CCR 中即可。

可以理解为:

每当 TIM3 计数器溢出时触发一次 DMA 请求,把数组中的下一个 pulse 值搬到 CCR。

5.4 控制灯珠颜色(完整代码)

① 定义常量:

cpp 复制代码
// 0码和1码的PWM宽度
#define BIT0_PULSE 21
#define BIT1_PULSE 43
#define RESET_PULSE 0

#define ROWS 3
#define COLS 3
// 输出一个 0/1 码(uint16)的时间是 0.89us,即周期是 0.89us
// 此处连续输出 100 个周期的低电平,总时间就得到超过 80us 的时间了
#define RESET_COUNT 100

② 定义结构体描述 LED 颜色:

cpp 复制代码
// 表示一个 LED 的颜色
typedef struct {
    uint8_t r;
    uint8_t g;
    uint8_t b;
} rgb_t;

// 表示 9 个 LED 的颜色
typedef struct {
    rgb_t leds[ROWS][COLS];
} rgb_group_t;

③ 定义描述 pulse 数组的结构体:

由于 DMA 设置的是 Half Word,下列类型均使用 uint16_t 表示;

需要把每一个 LED 的 R/G/B 的 8 位数据的每一个 bit 位,转换成为 PWM 的 CCR 对应的数值,方便在每一个周期内形成对应的 0 码或 1 码。

cpp 复制代码
// 向定时器写入的一个 LED 的 pulse 值
typedef struct {
    uint16_t green_pulse[8];
    uint16_t red_pulse[8];
    uint16_t blue_pulse[8];
} pulse_t;

// 向定时器写入的 9 个 LED 的 pulse 值
typedef struct {
    pulse_t leds[ROWS][COLS];
    uint16_t reset[RESET_COUNT];   // 复位信号序列
} pulse_group_t;

④ 定义全局变量(作为后续 DMA 拷贝数据的数据源):

复制代码
// 全局变量,用于进行 DMA 操作
static pulse_group_t pulse_group = {0};

⑤ 实现编码函数(把 LED 的 RGB 值转换成要写入的 pulse 值):

cpp 复制代码
void convert_rgb_to_pulse(rgb_group_t *rgb_group, pulse_group_t *pulse_group)
{
    for (int i = 0; i < ROWS; i++) {
        for (int j = 0; j < COLS; j++) {
            rgb_t rgb = rgb_group->leds[i][j];
            pulse_t* led_pulse = &pulse_group->leds[i][j];
            // 针对每个 bit 位,填写 pulse 值
            for (int k = 0; k < 8; k++) {
                // 注意,此处是"高位先行",bit7 要写到下标 0 的位置
                if (rgb.r & (1 << (8 - k - 1))) {
                    led_pulse->red_pulse[k] = BIT1_PULSE;
                } else {
                    led_pulse->red_pulse[k] = BIT0_PULSE;
                }
                if (rgb.g & (1 << (8 - k - 1))) {
                    led_pulse->green_pulse[k] = BIT1_PULSE;
                } else {
                    led_pulse->green_pulse[k] = BIT0_PULSE;
                }
                if (rgb.b & (1 << (8 - k - 1))) {
                    led_pulse->blue_pulse[k] = BIT1_PULSE;
                } else {
                    led_pulse->blue_pulse[k] = BIT0_PULSE;
                }
            }
        }
    }
    // 处理最后的复位部分
    for (int i = 0; i < RESET_COUNT; i++) {
        pulse_group->reset[i] = RESET_PULSE;
    }
}

关键点:

  • 注意 bit 位的排列顺序:24bit 数据结构是 G、R、B,且高位先发(MSB first)------针对每个 8bit 的颜色,需要把 bit7 放到 pulse 数组的 0 位置上;
  • 所以代码用 rgb.g & (1 << (8 - pos - 1)):pos=0 时取 bit7,pos=7 时取 bit0。

⑥ 在 main 函数中设置 RGB 颜色:

cpp 复制代码
rgb_group_t rgb_group = {0};

rgb_group.leds[0][0].r = 2;    // 第一行:红 2/20/200(亮度递增)
rgb_group.leds[0][1].r = 20;
rgb_group.leds[0][2].r = 200;

rgb_group.leds[1][0].g = 2;    // 第二行:绿
rgb_group.leds[1][1].g = 20;
rgb_group.leds[1][2].g = 200;

rgb_group.leds[2][0].b = 2;    // 第三行:蓝
rgb_group.leds[2][1].b = 20;
rgb_group.leds[2][2].b = 200;

// 将 RGB 颜色转换为 pulse 值
convert_rgb_to_pulse(&rgb_group, &pulse_group);

// 启动 DMA:把 pulse_group 数组循环搬到 CCR3
HAL_TIM_PWM_Start_DMA(&htim3, TIM_CHANNEL_3, (uint32_t*)&pulse_group,
                      sizeof(pulse_group) / sizeof(uint16_t));

⑦ 编译烧录观察效果:

全部是黑色(RGB=000)就是熄灭。

此时可以根据颜色和亮度区分出当前数组的每个元素对应哪个 LED:leds[0][0]→LED9、leds[0][1]→LED10...leds[2][2]→LED17。(手机相机拍摄存在一定色差,以实际肉眼观察效果为准。)


6. 封装 RGB LED 控制驱动(BSP)

上述代码只是把 RGB LED 用最简单的方式点亮了。

还可以把相关操作进一步封装到单独文件中,后续其他代码/项目需要控制 LED 时可以直接复用。

6.1 编写 .h 代码(rgb_led.h)

cpp 复制代码
#pragma once

#include <stdint.h>
#include "stm32f1xx_hal.h"

// 0 码和 1 码的 PWM 宽度
#define BIT0_PULSE 21
#define BIT1_PULSE 43
#define RESET_PULSE 0

#define ROWS 3
#define COLS 3
// 输出一个 0/1 码(uint16)的时间是 0.89us,此处连续输出 100 个 uint16_t,总时间就得到超过 80us 的时间了
#define RESET_COUNT 100

// 表示一个 LED 的颜色
typedef struct {
    uint8_t r;
    uint8_t g;
    uint8_t b;
} rgb_t;

// 表示 9 个 LED 的颜色
typedef struct {
    rgb_t leds[ROWS][COLS];
} rgb_group_t;

// 向定时器写入的一个 LED 的 pulse 值
typedef struct {
    uint16_t green_pulse[8];
    uint16_t red_pulse[8];
    uint16_t blue_pulse[8];
} pulse_t;

// 向定时器写入的 9 个 LED 的 pulse 值
typedef struct {
    pulse_t leds[ROWS][COLS];
    uint16_t reset[RESET_COUNT];
} pulse_group_t;

// 函数声明
void BSP_WS2812BInit(TIM_HandleTypeDef *htim);
void BSP_WS2812BSetColor(uint8_t x, uint8_t y, rgb_t rgb);
void BSP_WS2812BResetColor(void);
void BSP_ConvertRGB2Pulse(void);
bool BSP_WS2812BRefresh(void);

6.2 编写 .c 代码(rgb_led.c)

cpp 复制代码
#include "rgb_led.h"

// 标志位,DMA 搬运完成
static volatile bool g_is_DMA_done = false;

// 指向全局的 TIM3 句柄
static TIM_HandleTypeDef *ghtim = NULL;

// 对应板子的灯,内部设置
static rgb_group_t rgb_led_group = {0};

// 全局变量,DMA 要搬运的数据
static pulse_group_t pulse_group = {0};

// 辅助函数:RGB → pulse 数组
static void BSP_ConvertRGB2PulseHelper(pulse_group_t *pulse_group_dst,
                                       rgb_group_t *rgb_group_src)
{
    if (!pulse_group_dst || !rgb_group_src) {
        return;
    }
    for (int i = 0; i < ROWS; i++) {
        for (int j = 0; j < COLS; j++) {
            // 一个灯的原始数据
            rgb_t rgb = rgb_group_src->leds[i][j];
            // 一个灯的目标数据
            pulse_t *ppulse = &(pulse_group_dst->leds[i][j]);
            // 扫描比特位,设置 CCR 的值
            for (int pos = 0; pos < 8; pos++) {
                // G
                if (rgb.g & (1 << (8 - pos - 1))) {
                    ppulse->green_pulse[pos] = BIT1_PULSE;
                } else {
                    ppulse->green_pulse[pos] = BIT0_PULSE;
                }
                // R
                if (rgb.r & (1 << (8 - pos - 1))) {
                    ppulse->red_pulse[pos] = BIT1_PULSE;
                } else {
                    ppulse->red_pulse[pos] = BIT0_PULSE;
                }
                // B
                if (rgb.b & (1 << (8 - pos - 1))) {
                    ppulse->blue_pulse[pos] = BIT1_PULSE;
                } else {
                    ppulse->blue_pulse[pos] = BIT0_PULSE;
                }
            }
        }
    }
    // 处理最后的复位部分
    for (int i = 0; i < RESET_COUNT; i++) {
        pulse_group_dst->reset[i] = RESET_PULSE;
    }
}

void BSP_ConvertRGB2Pulse(void)
{
    return BSP_ConvertRGB2PulseHelper(&pulse_group, &rgb_led_group);
}

// 设置一个灯的颜色
void BSP_WS2812BSetColor(uint8_t x, uint8_t y, rgb_t rgb)
{
    if (x >= ROWS || y >= COLS) {
        return;
    }
    rgb_led_group.leds[x][y].r = rgb.r;
    rgb_led_group.leds[x][y].g = rgb.g;
    rgb_led_group.leds[x][y].b = rgb.b;
}

// 重置所有灯的颜色
void BSP_WS2812BResetColor(void)
{
    for (int i = 0; i < ROWS; i++) {
        for (int j = 0; j < COLS; j++) {
            rgb_led_group.leds[i][j].r = 0;
            rgb_led_group.leds[i][j].g = 0;
            rgb_led_group.leds[i][j].b = 0;
        }
    }
}

void BSP_WS2812BInit(TIM_HandleTypeDef *htim)
{
    if (!htim) {
        return;
    }
    ghtim = htim;
}

bool BSP_WS2812BRefresh(void)
{
    // 检测是否初始化
    if (ghtim == NULL) {
        return false;
    }
    HAL_StatusTypeDef state = HAL_TIM_PWM_Start_DMA(ghtim, TIM_CHANNEL_3,
                            (uint32_t *)&pulse_group,
                            sizeof(pulse_group) / sizeof(uint16_t));
    if (state != HAL_OK) {
        return false;
    } else {
        g_is_DMA_done = false;
    }
    return true;
}

// For test
void BSP_WaitDMADone(void)
{
    while (!g_is_DMA_done);
}

// For test:DMA 完成回调
void BSP_DMAPulseCplt(void)
{
    g_is_DMA_done = true;
}

HAL 库回调(在 main.c 中实现):

cpp 复制代码
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim)
{
    if (htim == &htim3) {
        BSP_DMAPulseCplt();
    }
}

注意:

HAL 库中 DMA 传输完成会触发 HAL_TIM_PWM_PulseFinishedCallback(PWM 脉冲完成回调)。在 CubeMX 的 NVIC 中需要使能 TIM3 的 DMA 相关中断(DMA1_Channel2_IRQn),否则回调不会执行,BSP_WaitDMADone() 会死等。

6.4 在 main.c 中调用驱动函数

cpp 复制代码
int main(void)
{
    /* ... HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_TIM3_Init(); ... */

    /* USER CODE BEGIN 2 */
    BSP_WS2812BInit(&htim3);
    BSP_WS2812BResetColor();
    bool flag = true;
    /* USER CODE END 2 */

    /* Infinite loop */
    while (1)
    {
        // 1. 点亮灯
        rgb_t rgb1 = {0, 0, 100};   // 蓝
        BSP_WS2812BSetColor(0, 0, rgb1);
        rgb_t rgb2 = {0, 100, 0};   // 绿
        BSP_WS2812BSetColor(0, 2, rgb2);
        rgb_t rgb3 = {100, 0, 0};   // 红
        BSP_WS2812BSetColor(2, 0, rgb3);
        rgb_t rgb4 = {239, 10, 229}; // 粉紫
        BSP_WS2812BSetColor(2, 2, rgb4);

        // 2. 转化
        BSP_ConvertRGB2Pulse();
        // 3. 刷新
        BSP_WS2812BRefresh();
        // 4. 等待完成
        BSP_WaitDMADone();
        HAL_Delay(1000);
    }
}

后续只需要使用 BSP_WS2812BSetColor 函数,即可完成针对 RGB LED 的颜色设置。


面试题

一、全彩 LED 原理题

Q1. 为什么全彩 LED 用 24bit 表示颜色?理论上能显示多少种颜色?

A:

基于三原色原理(RGB 光混合),人眼亮度分辨等级约 20~100 级(韦伯比,2% 以内变化感知不到),计算机字节 8bit 表示 256 级亮度。每个 LED 的 R/G/B 各占 8bit → 3×8=24bit。颜色总数 = 256³ = 16,777,216 种(约 1677 万色)。

Q2. 传统直驱型 LED 和集成驱动型 LED 有什么区别?

A:

直驱型内部无集成电路,颜色只能由 MCU 引脚直接驱动(引脚输出高电平灯亮、低电平灭,靠引脚"肉身"供电),要控制 N 颗灯就需要 N 路 PWM;

集成驱动型(如 WS2812B)内部有独立硬件电路,MCU 只需 1 个引脚发数字协议指令(一串脉冲),灯珠内部芯片自己控制电路变色,一颗灯一个像素点,可串联成百上千颗。

Q3. WS2812B 的命名和引脚是什么?

A:

WS2812B = Worldsemi(世界半导体,中国公司)+ 28 系列协议 + 12 型号 + B 版本(4 引脚/带保护)。5050 封装(5.0×5.0mm),4 引脚:VCC(电源)、GND(接地)、DIN(数据输入,接 MCU 或上一颗灯的 DOUT)、DOUT(数据输出,接下一颗灯的 DIN 实现级联)。

二、归零码协议题(核心)

Q4. 什么是归零码(RZ)?WS2812B 为什么用它而不是简单的高低电平?

A:

归零码要求每个比特位周期结束时强制回到低电平。

原因是灯珠是脉宽敏感设备:若用简单高低电平表示 0/1,发送长串 0 或 1 时电平持续不变,必须双方约定极严格的周期时长才能数清数量,对硬件时序要求太高;

归零码让每个比特至少有一次边沿跳变,接收电路只需检测上升沿锁定比特边界,把"对时钟同步的极高要求"转化为"对高低电平时间比例的简单判断",降低硬件时序要求、提高可靠性。


Q5. 0 码和 1 码的时间参数是什么?怎么区分它们?

A(典型值):

0 码 = 高电平 0.295us + 低电平 0.595us = 0.89us(占空比 33%);

1 码 = 高电平 0.595us + 低电平 0.295us = 0.89us(占空比 67%)。

两者周期相同,区分看高电平脉宽:0.295:0.595 ≈ 1:2------0 码高 1 份低 2 份,1 码高 2 份低 1 份。


Q6. 单颗 WS2812B 的 24bit 数据结构是怎样的?

A:

24bit(3 bytes)按 G(8bit)、R(8bit)、B(8bit) 顺序排列(注意不是 RGB!),且 MSB first(高位先发)。发送时把每个颜色字节的 bit7 放到 pulse 数组下标 0 位置,依次发到 bit0。


Q7. RESET 码是什么?为什么需要它?

A:

RESET 码是不低于 80us 的持续低电平,作为一帧数据的结束标志。

因为 WS2812B 不知道当前信号要针对几个灯珠操作,需要明确的"结束标志";收到 RESET 后数据才"正式生效"并同步点亮所有 LED。

WS2812B 工作分两阶段:搬运阶段(接收 24bit,装填子弹)→ 执行阶段(驱动点亮,扣动扳机),RESET 就是"可以开火了"的信号。

三、级联与数据帧题

Q8. N 个灯珠级联时,控制波形怎么组成?数据如何逐级传递?

A:

控制波形 = N × 24 个 0/1 码 + 1 个 RESET 码。

传输时第一颗灯珠提取前 24bit,剩余数据经内部电路从 DOUT 转发给下一颗,每经过一颗数据减少 24bit

;灯珠能自动转发,不受级联个数限制(仅受信号传输速度限制)。

最后一颗灯的 DOUT 悬空。


Q9. 9 颗灯都设为纯绿色,GRB 数据是什么?pulse 怎么配?

A:

纯绿色 = G 字节 11111111、R 字节 00000000、B 字节 00000000。

每个 1 码 pulse 设 43、每个 0 码 pulse 设 21、RESET pulse 设 0。

四、定时器/DMA 机制题(重点)

Q10. 为什么定时器参数是 Prescaler=0、Counter Period=63?

A:

0/1 码周期典型值 0.89us,72MHz 下每个时钟周期 1/72MHz≈0.01388us;0.89/0.01388≈64.12,取整 64 个时钟周期(对应 0.888us,落在芯片时间范围内) 。Counter Period=63 是因为计数器从 0 数到 63 正好 64 个周期

(溢出条件 CNT=ARR+1)。


Q11. pulse 值 21 和 43 是怎么算出来的?

A:

一个码周期 64 个时钟周期,按 0 码/1 码高电平时间比 0.295:0.595≈1:2 分配:0 码高电平占 1/3 → 64×1/3≈21;1 码高电平占 2/3 → 64×2/3≈43。


Q12. TIMx_CHn 和 TIMx_UP 触发的 DMA 有什么区别?本次用的是哪个?

A:

TIMx_UP = 计数器溢出(更新事件)触发 DMA;

TIMx_CHn(CH=通道)= 比较匹配触发 DMA。

本次 DMA Request 是 TIM3_CH3,所以是比较匹配触发 DMA:当 CNT==CCR 时触发一次搬迁,把数组中的下一个 pulse 值(21/43)写入 CCR 预装载寄存器,定时器下次溢出(UEV)时才更新 CCR 影子寄存器生效------比较匹配触发搬迁、溢出更新生效,滚动起来输出连续波形。


Q13. 为什么用 DMA 驱动 WS2812B 而不是 CPU+中断?

A:

WS2812B 对输入信号有严格的时序要求(us 级精度)。

CPU+中断方案效率低且不稳定:中断嵌套可能导致 CPU 不能及时构造下一个信号,且执行一条命令的时间也不稳定;

DMA 由硬件自动把内存数组中的数据搬到 CCR,波形精准稳定、CPU 全程不参与。


Q14. DMA 配置要点有哪些?为什么 Data Width 用 Half Word、内存要自增?

A:

Direction = Memory To Peripheral(内存数据放到定时器);

Memory Increment 勾选(依次取数组下一个值);

Data Width = Half Word(16 位,与 pulse 数组 uint16_t 对应);

Mode = Normal(配合回调再启动下一轮)。

五、BSP 与综合题

Q15. 什么是 BSP?为什么 BSP 代码要放在独立目录?

A:

BSP = Board Support Package(板级支持包),介于硬件和应用软件之间的中间软件层,封装特定开发板/硬件的驱动代码,上层应用直接调用函数、不必关心硬件细节。

独立目录存放可以让多个项目共用一份 BSP 代码(配置 Keil 时引入即可),放项目内部则每次开发新项目要拷贝一份;正规公司通常单独放置 BSP 目录。


相关推荐
科芯创展2 小时前
10~120V宽压1.5A降压DC-DC XZ1830技术特性与应用解析
单片机
逐步前行2 小时前
ESP32-S3-PCNT(脉冲计数控制器)
单片机·嵌入式硬件
咖丨喱3 小时前
【中断与收包】
单片机·嵌入式硬件
youshi66684 小时前
平衡车——PID算法优化
stm32·单片机·嵌入式硬件·算法·pid·平衡车
comyccccc4 小时前
高效降压电路布局与铺铜优化技巧学习笔记
单片机·嵌入式硬件
白搞电子4 小时前
ESP32-S31 WASM 应用开发实录:重力弹球从电脑预览到 ESP-Mosaico 实机测试
单片机·嵌入式硬件·物联网·电脑·wasm
蓝天居士4 小时前
PY32F系列MCU在OTA时App区概率性跑不起来的根因分析(6)
mcu·嵌入式
尼喃4 小时前
集成输入反接保护的USB限流保护芯片IC反向电流阻断验证
嵌入式硬件
ye150127774554 小时前
220V降压24V700mA交转直芯片WT5110
stm32·单片机·嵌入式硬件·其他·硬件工程