简介:一套基于STM32的WS2812B(NeoPixel)灯带控制工程源码包,面向嵌入式开发者和智能灯控爱好者,解决利用PWM+DMA方式高效驱动WS2812B灯串、实现流畅动态灯光效果的问题。资源共216个文件,压缩包约7.53MB,以C源码、头文件为主,辅以编译生成的o、crf、d等中间文件及hex、axf烧录文件,包含定时器配置、DMA传输、时序编码等核心代码,便于直接查阅、二次开发和烧录验证。已有1699人学习下载,适合需要掌握STM32高级定时器与DMA协同驱动WS2812B的开发者参考。内容涵盖WS2812B单线时序协议、PWM输出配置、DMA缓冲区加载与同步机制,同时通过工程示例展示如何同步PWM更新事件与DMA传输,避免数据丢失,可帮助读者理解并实践高效LED控制方案,结合工程源码与编译产物,能够快速上手移植和调试,适用于智能家居、氛围灯、广告屏等场景。
1. WS2812B(PWM+DMA):把 NeoPixel 颜色流拆成定时器的占空比序列
网络上下载的驱动工程里经常看到“WS2812B(PWM+DMA)”这种压缩包名,光看名字其实已经把路线说清楚了:不是教你写 GPIO 翻转加延时,而是用 STM32 定时器生成 800kHz 左右的 PWM 波,再把每个数据位的占空比交给 DMA 连续写入。WS2812B 这类 NeoPixel 灯的难点在于 0 码和 1 码都靠同一根线上的脉宽区分,CPU 参与越少,越不容易被中断打断。本文把这条链路的时序计算、CubeMX 配置、常用参数和调试手段列一遍,适合手上有 STM32 项目、需要驱动几十颗以上灯珠的中级工程师,也适合第一次用逻辑分析仪验证信号的人。先把“频率、比较值、DMA 长度”这三个数算对,后面所有坑都能在示波器上解释清楚。
2. WS2812B 时序与定时器 PWM 参数计算:为什么一个位就是一次 ARR 溢出
2.1 先看懂 WS2812B 的比特编码
WS2812B 数据线是单线制,以大约 800kbit/s 的频率连续发送颜色数据。每个 LED 按 GRB 顺序接收 24bit,数据全部进入移位寄存器后,复位信号(>50µs 低电平)触发锁存。关键点在于:0 码和 1 码不是靠电平高低区分,而是靠高电平持续时间区分。
| 编码 | 高电平宽度 | 低电平宽度 | 典型周期 |
|---|---|---|---|
| 0 码 | 0.25~0.5µs | 0.7~1.05µs | 1.25µs |
| 1 码 | 0.55~0.9µs | 0.3~0.7µs | 1.25µs |
| 复位 | 持续低 ≥50µs | — | — |
从表中能看出,0 码和 1 码的高电平宽度并不要求绝对精确,但低电平不能过短,否则下一颗灯采样时可能把前一位的下降沿误判成复位。软件延时方案最大的问题不是常数算错,而是中断会随时打断延时循环。一个定时器 PWM 周期固定成 1.25µs,用硬件比较器来决定每个位的高电平宽度,天然避开中断抖动。
2.2 PWM 模式下一个比特等于一次 ARR 溢出
当定时器工作在 PWM 模式 1 且向上计数时,输出电平由计数器 CNT 和比较寄存器 CCR 决定:CNT < CCR 时输出高,CNT ≥ CCR 时输出低。这里把 ARR 设置为 89,计数器从 0 数到 89 后溢出,周期正好是 90 个计数。如果定时器时钟是 72MHz,90 个计数就是 1.25µs,也就是 WS2812B 的一个数据位。
这样设计后,每个 PWM 周期的 CCR 值就代表这一位的高电平时间:
#define TIM_CLK 72000000UL // TIM2 实际时钟,不是系统主频 #define BIT_PERIOD 1250UL // 位周期,单位 ns #define T0H_NS 420UL // 0 码高电平目标值 #define T1H_NS 830UL // 1 码高电平目标值 #define PERIOD_ARR (TIM_CLK * BIT_PERIOD / 1000000000UL - 1UL) #define CCR_FOR_0 (TIM_CLK * T0H_NS / 1000000000UL) #define CCR_FOR_1 (TIM_CLK * T1H_NS / 1000000000UL)这组宏的含义是:先算出一位需要的时钟周期数并减 1 得到 ARR;再用目标高电平时间乘定时器频率得到 CCR。72MHz 下算出来 ARR=89,CCR_0=30,CCR_1=59 左右,实际工程里我会直接取 CCR_0=30、CCR_1=60,让 0 码高电平 0.42µs、1 码高电平 0.83µs,两边都留出余量。注意 ARR 和 CCR 的单位都是“定时器计数个数”,不是纳秒。
2.3 用 DMA 连续请求把 CCR 表灌进定时器
PWM 定时器本身只负责产生固定周期的波,真正让每一位的脉宽不同,要靠 DMA 在每个 PWM 周期把下一个比较值写进 CCR。DMA 配置成内存到外设、内存地址自增、外设地址固定,外设地址就指向定时器的 CCR1 寄存器。每次触发 DMA 的是定时器比较事件或更新事件,这就是“连续请求”的工作方式。
为了让写入的新 CCR 不在位周期中间生效,必须开启 CCR 预装载。否则 CCR 在计数过程中被改写,输出就会在同一个周期内产生一个不必要的毛刺边沿。开启预装载后,DMA 写入的值先保存在预装载寄存器,等下一次 ARR 溢出才进入工作寄存器,正好对应下一个 PWM 周期。实际显示中,启动 DMA 后的第一个周期会保持初始 CCR=0,整段低电平相当于复位的一部分,不会影响第一颗灯。
2.4 常用主频下的 ARR 与 CCR 对照表
不同 STM32 系列的定时器时钟来源不同,F1 的 APB1 定时器可能有 2 倍频,F4 也可能有,最好以 CubeMX 时钟树显示的实际 TIMxCLK 为准。下面这组参数按一个位周期 1.25µs 计算:
| TIM_CLK | ARR | CCR_0 | CCR_1 | 0 码高/低 | 1 码高/低 | 60 位复位时间 |
|---|---|---|---|---|---|---|
| 72MHz | 89 | 30 | 60 | 0.42µs / 0.83µs | 0.83µs / 0.42µs | 75µs |
| 84MHz | 104 | 35 | 70 | 0.42µs / 0.83µs | 0.83µs / 0.42µs | 75µs |
| 64MHz | 79 | 26 | 52 | 0.41µs / 0.84µs | 0.81µs / 0.44µs | 78µs |
实际灯珠对时序容差比数据手册标称值宽,0 码高电平 0.4µs 上下 0.1µs 都能正常工作。如果换了 MCU 或改了主频,直接用 ARR = TIM_CLK × 1.25µs 再减 1 重新计算即可。
3. 用 STM32CubeMX 把 PWM+DMA 跑起来:配置、位编码、启动
3.1 CubeMX 中 TIM2 的 PWM 与 DMA 最小配置
以 STM32F103C8T6 为例,先打开 TIM2,选择 Internal Clock,Channel1 设为 PWM Generation CH1。参数页里 Prescaler 填 0,Counter Period 填 89,Counter Mode 选 Up。PWM 模式选 PWM1,Pulse 初始值填 0,Polarity 保持 High。
然后在 DMA Settings 页添加请求。不同系列和 CubeMX 版本显示的名称略有差异,常见是 TIM2_CH1 或 TIM2_CH1/UP,这一路对应 PWM 通道的比较事件,HAL 的 HAL_TIM_PWM_Start_DMA 内部走的就是这个请求。如果只看到 TIM2_UPDATE,启动和停止逻辑需要按更新中断重写,不建议和 PWM Start_DMA 混用。
| 配置项 | 值 | 说明 |
|---|---|---|
| Prescaler | 0 | 定时器时钟不分频 |
| Counter Period | 89 | 90 个计数 = 1.25µs |
| PWM Mode | PWM1 | CNT < CCR 输出高 |
| Pulse | 0 | 初始 CCR,启动时输出低 |
| CCR Preload | Enable | 关键,必须开启 |
| DMA Direction | Memory to Peripheral | 内存向 CCR 写值 |
| Mem Inc | Enable | 每周期取下一个比较值 |
| Periph Inc | Disable | 目标始终是 CCR1 |
| Data Width | Half Word | CCR 是 16 位寄存器 |
| DMA Mode | Normal 或 Circular | 一帧一次用 Normal,持续刷新用 Circular |
有个容易漏掉的初始化顺序:CubeMX 生成的代码里,MX_DMA_Init 必须在 MX_TIM2_Init 之前调用,否则 DMA 句柄未初始化,HAL_TIM_Base_Init 之后找不到通道。代码生成后检查 main 函数调用顺序,这是“工程能编译但灯不亮”的高频原因之一。
3.2 把 RGB 颜色编码成 CCR 比较值表
WS2812B 发送顺序是 G、R、B,不是常用的 R、G、B。下面的函数把每颗灯的 24bit 展开成 24 个 CCR 值,每个 bit 为 1 时填 CCR_1,为 0 时填 CCR_0,最后补一段全零比较值作为复位信号。
#define LED_COUNT 24 #define RESET_BITS 60 uint16_t ws2812_ccr_buf[LED_COUNT * 24 + RESET_BITS]; void ws2812_fill_frame(uint8_t *rgb) { for (uint16_t led = 0; led < LED_COUNT; led++) { uint8_t g = rgb[led * 3 + 0]; uint8_t r = rgb[led * 3 + 1]; uint8_t b = rgb[led * 3 + 2]; uint16_t *dst = &ws2812_ccr_buf[led * 24]; for (int bit = 0; bit < 8; bit++) { dst[bit] = (g & (0x80 >> bit)) ? CCR_1 : CCR_0; dst[bit + 8] = (r & (0x80 >> bit)) ? CCR_1 : CCR_0; dst[bit + 16] = (b & (0x80 >> bit)) ? CCR_1 : CCR_0; } } // 复位段:全部写 0,CCR=0 时 PWM 整个位周期保持低电平 memset(ws2812_ccr_buf + LED_COUNT * 24, 0, RESET_BITS * sizeof(uint16_t)); }这里用 0x80 配合右移取出一个字节的最高位到最低位,保证先发高位。发送顺序按 G、R、B 排进 24bit 窗口。注意缓冲区类型是 uint16_t,每个比较值占 2 字节;RESET_BITS 取 60 是为了让复位时间达到 75µs,比数据手册要求的 50µs 多出 50% 余量。如果用的是 RGB 顺序写代码,第一颗灯的颜色就会固定错位。
3.3 启动 DMA 并控制一帧的结束
缓冲区填好后,启动传输只有一行 HAL 调用:
HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t *)ws2812_ccr_buf, LED_COUNT * 24 + RESET_BITS);第三个参数被 HAL 声明成 uint32_t*,但缓冲区实际是 uint16_t 数组,强制转换只是满足函数签名,DMA 仍按半字宽度从内存读取,长度计数单位是“半字个数”,不是字节数。第四个参数是总比较值个数,24 颗灯就是 576 个颜色位加 60 个复位位。
DMA 用 Normal 模式时,传输结束自动停,但定时器 PWM 还会继续按最后一个 CCR 值输出。要在新帧写入前把输出拉低,最干净的做法是在 PulseFinished 回调里停掉通道并把 CCR 清零:
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { HAL_TIM_PWM_Stop_DMA(htim, TIM_CHANNEL_1); __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_1, 0); ws2812_busy = 0; } }ws2812_busy 是全局标志,主循环在发送下一帧前先等它变为 0。不要在回调里直接做动画计算或长时间阻塞操作,只清标志,耗时的颜色数据生成放到主循环里。如果每帧刷新率不高,这种“填表、启动、等待完成”的模式已经足够稳定。
4. WS2812B(PWM+DMA)调试清单:从灯不亮到颜色错乱
4.1 引脚有信号但灯全灭:先检查极性和初始 CCR
灯完全不亮时,先把示波器或逻辑分析仪夹在数据引脚上,看是否有周期性的 1.25µs 波形。如果波形存在但呈现“高电平时间长、低电平时间短”的反向状态,多半是 PWM 极性选成了 Low。WS2812B 是在上升沿采样,低电平为主的数据流会被芯片解读为复位。
另一个容易被忽略的问题是 PWM 模式选错。PWM1 和 PWM2 的 CNT 与 CCR 比较方向相反,CSDN 上不少“PWM 故障保护”相关讨论最后都落在这一项。把 OC Mode 固定成 PWM1,CCR=0 时应整周期为低,这一步在启动代码里加一句__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0)即可验证。
4.2 第一颗灯正常、后面全灭:复位长度和 DMA 长度不对
级联情况下,所有灯的数据是流水线式的:第一颗灯接收并锁存自己的 24bit,剩余数据继续往下一颗传。只有复位信号才会让每颗灯把移位寄存器里的数据锁存到输出。如果复位段短于 50µs,最后一颗灯可能没有收满 24bit,整条灯带的数据整体错位。
检查复位段最简单的独立测试如下:
uint16_t reset_probe[80]; memset(reset_probe, 0, sizeof(reset_probe)); HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t *)reset_probe, 80);正常时数据线应保持低电平 100µs。如果这里能测到完整低电平,问题就回到主发送函数:DMA 长度是否写成了LED_COUNT * 24而漏了复位位,或者数组越界把复位段覆盖掉了。还有一类情况是 DMA 的 MemDataAlignment 配成了 Word,此时 DMA 每次从内存取 32 位,而 CCR 只读低 16 位,位序会每隔一个周期跳变,表现为只有第一颗灯偶尔闪。
4.3 颜色不对:GRB 顺序和 CCR 取值偏差
先发个全绿测试色,代码里写入rgb[0]=0, rgb[1]=255, rgb[2]=0。如果第一颗灯显示红色,说明发送顺序被写成了 RGB;如果显示白色或偏色,说明 0 码和 1 码的高电平宽度偏离太多。部分灯珠对低电平比较敏感,CCR_0 如果超过 0.5µs,0 码会被识别成 1 码;CCR_1 如果低于 0.55µs,1 码又会掉成 0 码。
用示波器抓一个字节中最简单的 0xAA 序列,应当是“0 码高 0.4µs 左右、低 0.85µs 左右;1 码高 0.83µs 左右、低 0.42µs 左右”交替。我一般把 CCR_0 固定为 30,CCR_1 在 58~62 之间微调,优先保证 1 码高电平不低于 0.8µs,因为绝大多数 WS2812B 兼容芯片对高电平宽度更敏感。
4.4 定时器时钟不是 72MHz:APB1 预分频的典型坑
F103 的 TIM2 挂在 APB1 总线上,CubeMX 时钟树里 APB1 显示 36MHz,并不代表 TIM2 时钟是 36MHz。只要 APB1 分频系数大于 1,定时器时钟会自动乘 2,得到 72MHz。如果手工初始化工程时算错这一层,用 ARR=89 配 36MHz 定时器时钟,位周期会变成 2.5µs,灯带会以远低于规格的速率采样,出现颜色错乱或整条不亮。
判断方法很简单:示波器量一个完整位周期。如果测到 1.25µs 说明 ARR 算对;如果测到 2.5µs,先把 APB1 分频逻辑改对。不要直接改 ARR 到 44 来凑 1.25µs,因为 CCR_0 和 CCR_1 也要同步缩放,否则占空比比例不对。
4.5 多外设共用 DMA 时的优先级和连续请求冲突
串口 DMA、ADC 多通道 DMA、灯带 DMA 同时工作时,偶发花屏先查 DMA 通道优先级。灯带对实时性要求不高,但要求每个 1.25µs 周期内 DMA 能及时响应一次半字传输。把灯带 DMA 优先级设为 Very High,串口和 ADC 降一级,通常能解决大部分偶发错帧。
如果现象是固定在某几帧出错,检查是否在中断函数里反复启停 DMA。动态刷帧时先停止 DMA、改缓冲区、再重新启动,这个过程不能放在高优先级中断里,否则 DMA 会请求还没清完就又重新使能,造成第一个周期的 CCR 值错位。把启停动作放到主循环或低优先级任务中,中断里只置标志位。
5. 灯带多了之后的可靠写法:双帧缓冲与参数复用
5.1 双缓冲让动画刷新不闪烁
单缓冲刷新时,主循环一边往缓冲区写数据,DMA 一边往外发同一段内存,容易出现画面撕裂。做法是准备两块长度相同的比较值缓冲区,DMA 正在发送 A 区时,主循环往 B 区生成下一帧;DMA 完成回调里把对比值切换过去。
#define FRAME_LEN (LED_COUNT * 24 + RESET_BITS) uint16_t ws2812_buf[2][FRAME_LEN]; volatile uint8_t ws2812_use_buf = 0; volatile uint8_t ws2812_busy = 1; void hal_tim_pwm_pulsefinishedcallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { HAL_TIM_PWM_Stop_DMA(htim, TIM_CHANNEL_1); __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_1, 0); ws2812_use_buf ^= 1; ws2812_busy = 0; } } void ws2812_draw(uint8_t *rgb_frame) { while (ws2812_busy) { // 等待上一帧走完 } ws2812_fill_frame(ws2812_buf[ws2812_use_buf], rgb_frame); ws2812_busy = 1; HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t *)ws2812_buf[ws2812_use_buf], FRAME_LEN); }这段代码里,ws2812_use_buf 在回调中翻转,主循环始终写当前发送缓冲区的对侧。要注意为 0 的全局变量初值在未开始 DMA 时把 busy 置 1,防止第一次调用时不等待直接覆盖缓冲区。如果追求更高帧率,可以把 DMA 改成 Circular 模式并在 Half Transfer 和 Transfer Complete 两个回调中交换缓冲,但那样必须保证每次填入的数据长度完全一致。
5.2 大点数灯带:注意 16 位 DMA 长度上限
STM32 DMA1 的传输计数器 CNDTR 是 16 位,一次最多传 65535 个数据项。对 WS2812B 来说,一个数据项对应一个位周期,所以最多约 2728 颗灯(65535 减去复位位再除以 24)。超过这个数量时,把 DMA 配成 Circular 模式,分多次更新缓冲区,但复位段必须完整放入每一轮。实际项目中 1024 颗以内用 Normal 模式更简单,每帧完成后拉低引脚,复位期间灯带自然熄灭。
5.3 换芯片时直接套用的参数公式
换到 F401、G0、H7 系列时,不要照抄 ARR=89,而是先确认 TIMx 的实际输入时钟。用下面这组公式计算:
ARR = TIM_CLK × 1.25µs - 1 CCR_0 = TIM_CLK × 0.42µs CCR_1 = TIM_CLK × 0.83µs RESET_BITS = 60G0 系列如果主频是 64MHz,ARR 应为 79,CCR_0 取 26,CCR_1 取 53;H7 系列如果 TIM 时钟 240MHz,ARR 应为 299,CCR_0 取 96,CCR_1 取 199。注意 H7 的 DMA 请求映射和 F1 不完全一样,HAL_TIM_PWM_Start_DMA 在部分 H7 型号上需要把 DMA 的 FIFO 关闭或开启 Direct Mode,否则数据宽度和突发长度设置不当会导致 CCR 更新滞后。测试时把测试颜色换成 R=255,再观察是不是第一颗灯先亮红色,确认改写的顺序函数没有在字节序上再翻一次。
本文还有配套的精品资源,点击获取