WS2812B 全彩 LED 灯控制(DMA 驱动)
1. 全彩 LED 原理
1.1 为什么不用单色 LED?
常见的点阵屏、球形灯、灯带、环形灯、模型装饰、隧道式灯阵这些全彩作品,都不是用单色 LED 实现的,原因有二:
- 单色 LED 颜色太少:没有那么多颜色,而且颜色无法通过程序改变;
- 控制引脚太多:要控制这么多单色 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引脚/带保护)
- Worldsemi = 世界半导体,中国公司(官网 www.world-semi.com)。
② 基本定位:
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!
cpp24bit 数据结构(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 工作模式分两个阶段:
- 搬运阶段:接收 24bit 数据("装填子弹");
- 执行阶段:根据 24bit 数据,驱动红、绿、蓝灯珠点亮("扣动扳机")。
所以,RESET 码表示传输结束,但对 WS2812B 来讲,表示的是:"传输完成了,你可以根据数据点亮了"。
单个灯珠的控制波形 = 24 个 0/1 码 + 1 个 RESET 码
3. 一组灯珠的控制(级联机制)
有了单灯珠概念,一组灯珠的控制就容易了。
级联传输机制:在一组灯珠上电复位以后,DIN 管脚接受从 MCU 传输过来的数据:
- 首先送过来的 24bit 数据被第一个灯珠提取;
- 剩余的数据经过内部电路通过 DOUT 管脚转发给下一个级联的灯珠;
- 每经过一个灯珠的传输,数据减少 24bit;
- 灯珠内部电路能够自动转发,信号传送不受级联灯珠个数的限制,仅受信号传输速度限制。
一组 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 的值。
cppvoid 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 影子寄存器 → 配置生效 → 整个过程就滚动起来了。
cppDMA 流水线(每 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 值):
cppvoid 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 颜色:
cpprgb_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 中实现):
cppvoid 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 中调用驱动函数
cppint 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 目录。





