搞WS2812灯带的人,基本都会经历这么几个阶段:先是拿GPIO死等延时刷时序,然后发现中断一多颜色就错乱;接着换成定时器中断模拟,又发现CPU被占得死死的,呼吸灯都喘不过气。后来我换成STM32的定时器PWM + DMA方案,才算是真正把这条灯带“托管”给了硬件外设。今天就把这套方案的参数计算、缓冲区设计、CubeMX配置和呼吸灯完整代码全部分享出来,照着做基本不会踩我踩过的坑。
这套方案适合谁?手里有STM32F103或类似Cortex-M3/M4芯片,想驱动WS2812/WS2812B灯带,又希望把CPU从无休止的延时刷屏里解放出来的朋友。看完你不仅能跑起呼吸灯,还能顺势理解为什么“PWM+DMA”是驱动这类单总线灯带最稳的路子之一。
1. 为什么WS2812呼吸灯总容易“闪”:先看懂协议再选实现方案
1.1 WS2812的归零码协议,到底“硬”在哪里
WS2812灯带单总线通信,每个灯珠接收24bit数据,数据格式是Green、Red、Blue各8bit。所有灯珠串在一条数据线上,数据从第一颗开始依次经过内部整形电路传给下一颗。协议层是典型的归零码:
- 逻辑0:高电平约0.4us,低电平约0.85us
- 逻辑1:高电平约0.8us,低电平约0.45us
- 每位周期:1.25us
- RESET信号:低电平持续50us以上,代表一帧数据结束
注意这里的单位是微秒,不是毫秒。一个bit只有1.25us,高电平宽度差0.4us左右就是0和1的区别。如果主程序里有一个高优先级中断恰好在这个节骨眼上打断你,哪怕延迟个两三微秒,后续所有灯珠的数据就会整个错位。这就是为什么很多新手作业可以点亮,但一到“呼吸灯”这种需要持续刷帧的场景就露馅——你每隔几十毫秒就要重新刷新整条灯带,刷新期间被打断的概率直线上升。
1.2 GPIO模拟时序为什么在真实项目里不省心
网上很多教程是用GPIO翻转加空延时来模拟,代码大概长这样:拉高GPIO,延时几百纳秒,拉低,再延时。这套做法在单颗灯珠、没有其他中断的裸机demo里确实能跑,但放到稍微真实一点的项目里就有问题。
首先是CPU占用率问题。一条100颗灯珠的灯带,一帧数据就是2400个bit,每个bit至少两次GPIO写操作加两次延时,期间CPU什么都干不了。哪怕每帧间隔只用10ms刷新,CPU也会被吃掉相当大一部分。其次是延时不精确。空循环延时依赖编译优化等级和主频设置,换一个优化级别,时序就变了。再有就是中断延迟。任何一个稍微长一点的中断处理都会把bit时序撑破,灯带花屏。
我见过不少人为了不让灯带花屏,干脆把所有中断都关掉,或者把刷新灯带的函数放到最高优先级中断里跑,最后整个系统活得战战兢兢。这种体验,用一次就再也不想用第二次。
1.3 PWM+DMA方案的思路:把bit“写成”占空比数组
PWM+DMA的思路其实很直白:WS2812的0和1,本质是两种不同占空比的脉冲信号。定时器PWM外设最擅长的就是按预定节奏翻转电平,DMA最擅长的就是在一段时间里不停地往寄存器里搬运数据。两者一结合,就变成了一件很优雅的事:
- 定时器PWM通道作为信号输出口,每个PWM周期就是WS2812的一个bit周期,周期固定为1.25us。
- 在一个bit周期内,高电平持续多长时间,由比较寄存器CCR的数值决定。
- DMA在每次PWM周期到来时,自动把下一个bit对应的CCR值写入定时器,实现“逐bit换占空比”。
CPU只需要在发送一帧数据前,把每个bit对应的CCR值准备好塞进DMA缓冲区,然后启动一次DMA传输,剩下的全部交给硬件。一个PWM周期触发一次DMA搬运,整个帧发完,DMA产生传输完成中断,CPU这才醒过来处理下一帧。
这种方案最直接的收益是:中断随便开,主循环随便跑,灯带波形照样纹丝不乱。因为0和1的切换完全由定时器硬件比较器完成,DMA搬运也由硬件请求触发,跟CPU的指令执行节奏没有半点关系。
2. 开始前先把参数算清楚:ARR、CCR和定时器时钟
2.1 WS2812时序参数换算到定时器计数值
既然要让PWM周期等于WS2812的bit周期,就得先搞明白定时器计数频率和周期之间的关系。以STM32F103为例,定时器时钟典型值是72MHz,也就是一个计数时钟周期约13.89ns。
WS2812一个bit周期是1.25us,用72MHz计数,1.25us内可以数:
1.25us / 13.89ns ≈ 90个计数
所以自动重装载寄存器ARR设为89,计数器从0数到89正好90个计数,对应90个计数时钟,也就是1.25us,正好一个bit周期。
接下来算逻辑1和逻辑0的CCR值。使用PWM模式1,计数器CNT小于CCR时输出高电平,CNT大于等于CCR时输出低电平。
逻辑1的高电平约0.8us,对应计数个数:
0.8us / 13.89ns ≈ 57.6,四舍五入取58
也就是说,把CCR设为58,高电平就是58个计数时钟,约806ns,低电平是剩下的32个计数周期,约444ns。这个值在WS2812B的容差范围内,非常稳妥。
逻辑0的高电平约0.4us,对应计数个数:
0.4us / 13.89ns ≈ 28.8,四舍五入取29
CCR设为29,高电平约403ns,低电平是剩下的61个计数周期,约847ns,同样在容差范围内。
我实际工程里的取值就是ARR=89、CCR_ONE=58、CCR_ZERO=29。如果你手里的灯珠是其他兼容芯片,比如WS2811、SK6812,时序规格可能有细微差异,但按照上面的计算方式重新推一遍并不麻烦。
2.2 一个容易忽略的坑:F103定时器时钟不是APB1简简单单的36MHz
新手在CubeMX里经常看到APB1外设时钟显示36MHz,然后下意识以为挂在APB1上的TIM2、TIM3、TIM4也是36MHz,于是ARR和CCR算出来的值全都不对。
STM32F103有一个隐藏规则:如果APB1的分频系数不是1,那么APB1上的定时器时钟会自动倍频到APB1时钟的两倍。CubeMX把系统时钟配置成72MHz时,APB1默认是36MHz,分频系数为2,所以APB1上的定时器时钟是36 * 2 = 72MHz。APB2上的TIM1、TIM8更直接,APB2就是72MHz,定时器时钟也是72MHz。
换句话说,在标准72MHz主频的F103工程里,只要CubeMX时钟树配置正确,所有定时器时钟都按72MHz算,不需要额外倍频代码。如果你自己写寄存器配置,或者改过时钟树,最好在调试器里看一眼定时器时钟的实际值,再去算ARR和CCR。
2.3 不同主频下的取值参考表
很多同学手里不一定是F103,也可能是F407、GD32、AT32之类的芯片。不同主频下ARR和CCR取值差异不小,我直接给一组常用主频的计算结果供参考。
| 定时器时钟 | tick(ns) | ARR | CCR_ONE | CCR_ZERO |
|---|---|---|---|---|
| 72MHz | 13.89 | 89 | 58 | 29 |
| 64MHz | 15.63 | 79 | 51 | 26 |
| 48MHz | 20.83 | 59 | 38 | 19 |
| 84MHz | 11.90 | 104 | 67 | 34 |
注意这组数值是按典型值计算的,实际还要留一点时序容差。一般上游厂商给出的时序范围比较宽,按表格里的值直接跑基本没问题。如果不放心,用逻辑分析仪或者示波器抓一下PWM输出的波形,确认高电平宽度。
3. 缓冲区怎么设计,决定了能不能“无脑DMA”
3.1 颜色顺序是GRB,bit顺序是MSB先行
这里有一个非常经典的坑:WS2812的颜色顺序不是RGB,而是GRB。也就是说,24bit数据从高位到低位依次是:G7~G0、R7~R0、B7~B0。如果你把常见的RGB颜色编码直接塞上去,灯珠显示出来的颜色就会和预期不符,比如你想发红色,它可能显示成绿色。
我在初始化颜色数组时,习惯用一个临时数组按GRB顺序重新排一下,避免在编码函数里反复写错判断条件。
另外每个字节内部的bit顺序是高位先行,MSB first。也就是说,对0xC0这个字节,先发bit7=1,再发bit6=1,后面6个bit都是0。很多编码函数写错就是因为循环方向搞反了,从bit0开始发,结果整条灯带颜色完全不对。
3.2 从颜色值到DMA数组的编码函数
有了GRB顺序和MSB先行的规则,就可以把灯珠颜色转换成DMA缓冲区了。DMA缓冲区里的每个元素,就是该bit对应的CCR值。逻辑1放CCR_ONE,逻辑0放CCR_ZERO,这样定时器每个PWM周期输出对应宽度的脉冲,连起来就是WS2812的一整帧数据。
#define NUM_LEDS 8 #define DMA_BUF_LEN (NUM_LEDS * 24 + 64) #define CCR_ONE 58 #define CCR_ZERO 29 #define CCR_RESET 0 uint16_t dma_buf[DMA_BUF_LEN]; uint8_t led_rgb[NUM_LEDS][3]; // 顺序:R, G, B void led_set_pixel(uint16_t index, uint8_t r, uint8_t g, uint8_t b) { if (index >= NUM_LEDS) return; led_rgb[index][0] = r; led_rgb[index][1] = g; led_rgb[index][2] = b; } void led_update(void) { uint16_t pos = 0; for (uint16_t i = 0; i < NUM_LEDS; i++) { uint8_t data[3]; data[0] = led_rgb[i][1]; // G data[1] = led_rgb[i][0]; // R data[2] = led_rgb[i][2]; // B for (uint8_t byte = 0; byte < 3; byte++) { for (int8_t bit = 7; bit >= 0; bit--) { if ((data[byte] >> bit) & 0x01) { dma_buf[pos++] = CCR_ONE; } else { dma_buf[pos++] = CCR_ZERO; } } } } // 末尾追加RESET周期 while (pos < DMA_BUF_LEN) { dma_buf[pos++] = CCR_RESET; } }这里有一个很小的细节:int8_t bit = 7; bit >= 0; bit--这个循环条件,如果bit被定义成无符号类型,就会产生死循环,因为无符号数减到0再减会变成255。所以我明确用int8_t,避免低级又难查的bug。
3.3 复位周期留多少才保险
WS2812规定的RESET信号是低电平持续超过50us。按照每bit周期1.25us算,理论上40个全低周期就够50us。但实际工程里,我会在帧尾追加64个CCR_RESET,也就是80us。为什么多留一些?
因为很多灯珠芯片在不同批次、温度下,RESET判断阈值会有些偏移,40个周期卡得太紧容易在连续刷新时出现最后几颗灯偶尔闪一下的问题。多给一点低电平时间,对数据帧没有任何损害,只是稍微增加一点刷新周期。如果你用的是WS2813或者SK6812这类带双线备份的芯片,也建议查一下手册确认RESET要求,有些芯片要求280us以上,那就得把尾部追加长度翻倍。
缓冲区长度定义时,我给NUM_LEDS * 24 + 64,这64个多出来的元素全部是CCR_RESET。灯珠数量多时,这个开销可以忽略。
4. CubeMX配置与HAL库代码实战
4.1 定时器PWM和DMA的配置要点
CubeMX里配置定时器,我以TIM2为例。系统时钟72MHz配置好后,TIM2时钟自动是72MHz。
定时器参数按下面设置:
- Prescaler分频系数:0
- Counter Period计数器周期:89
- auto-reload preload:Enable
- PWM mode:PWM mode 1
- Pulse初始值:0
- Output compare preload:Disable
- CH Polarity:High
这里有一个非常关键的配置:Output compare preload一定要设为Disable,也就是关闭CCR预装载。如果你保持默认的Enable,DMA往CCR里写入的新值,要到下下个PWM周期才会真正生效,导致整个数据流偏移一个bit,表现就是“只亮了一颗灯”或者“第一颗灯颜色不对”。我刚开始用HAL_TIM_PWM_Start_DMA就踩过这个坑,当时一度以为是DMA配置错了,后来查时序才发现是预装载开关的问题。
DMA配置里,方向选Memory To Peripheral,外设地址是TIM2的CCR1寄存器地址。在HAL_TIM_PWM_Start_DMA这个函数里,外设地址不需要手动填,函数内部会根据通道号自动绑定。你需要关注的是数据宽度,两个都设为Half Word,也就是16位,因为CCR寄存器是16位,内存里的dma_buf元素也是uint16_t。
内存地址递增要打开,外设地址递增关闭。Mode选Normal,不选Circular。如果你选Circular循环模式,DMA会不停地把同一块缓冲区循环搬运到CCR,适合做静态图案,但不适合后面要动态刷新的呼吸灯。
4.2 启动PWM+DMA的正确姿势
启动一次PWM+DMA发送,最直接的方式是调用HAL_TIM_PWM_Start_DMA。但这里有一个很容易被忽略的启动竞争问题。
定时器启动瞬间,计数器从0开始跑。如果CCR的初始值是0,PWM比较匹配事件会在计数器等于0的瞬间立刻发生,此时DMA通道可能还没完全就绪,导致第一个bit数据没有被正确送到CCR。我试过几次,表现就是第一个灯偶尔显示异常。
为了避免这种竞争,我习惯在启动DMA前手动向TIM2->CCR1写入dma_buf[0],然后再启动PWM+DMA传输。这样第一个PWM周期一定能输出正确的第一个bit,后续每个周期由DMA自动更新CCR,数据流就完全对齐了。
void led_dma_start(void) { // 先手动写入第一个bit的CCR值,避免启动瞬间数据错位 TIM2->CCR1 = dma_buf[0]; HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t *)dma_buf, DMA_BUF_LEN); }这里需要注意的是HAL库函数原型要求传入uint32_t *类型的pData,但我们实际定义的是uint16_t dma_buf[]。强制转换是常规操作,因为DMA的数据宽度由配置决定,内存里读出来的还是16位半字。只要数组首地址是2字节对齐的,强制转换没有问题。如果你比较讲究,可以在定义数组时使用__ALIGN_BEGIN uint16_t dma_buf[DMA_BUF_LEN] __ALIGN_END;来显式保证对齐。
4.3 完整工程代码示例
下面是一个可以跑的完整示例骨架,基于STM32F103C8T6和HAL库。工程里TIM2的PWM通道PA0输出连到WS2812数据引脚。
#define NUM_LEDS 8 #define DMA_BUF_LEN (NUM_LEDS * 24 + 64) #define CCR_ONE 58 #define CCR_ZERO 29 #define CCR_RESET 0 uint16_t dma_buf[DMA_BUF_LEN]; uint8_t led_rgb[NUM_LEDS][3]; volatile uint8_t dma_done = 1; extern TIM_HandleTypeDef htim2; void led_set_pixel(uint16_t index, uint8_t r, uint8_t g, uint8_t b); void led_update(void); void led_dma_start(void); void led_update(void) { uint16_t pos = 0; for (uint16_t i = 0; i < NUM_LEDS; i++) { uint8_t data[3] = { led_rgb[i][1], led_rgb[i][0], led_rgb[i][2] }; for (uint8_t byte = 0; byte < 3; byte++) { for (int8_t bit = 7; bit >= 0; bit--) { dma_buf[pos++] = ((data[byte] >> bit) & 0x01) ? CCR_ONE : CCR_ZERO; } } } while (pos < DMA_BUF_LEN) { dma_buf[pos++] = CCR_RESET; } } void led_dma_start(void) { TIM2->CCR1 = dma_buf[0]; HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t *)dma_buf, DMA_BUF_LEN); } // DMA传输完成回调 void HAL_TIM_PWM_DMA_CpltCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { HAL_TIM_PWM_Stop_DMA(&htim2, TIM_CHANNEL_1); dma_done = 1; } }在main函数里,初始化好定时器和DMA后,先把所有灯珠设为全灭,调用一次led_update()和led_dma_start(),触发第一帧发送。之后每帧发送完成,dma_done变为1,主循环里就可以更新颜色、重新编码、再次启动DMA。
5. 呼吸灯效果:让亮度按正弦变化并动态刷新
5.1 呼吸曲线怎么选
呼吸灯的视觉核心是“亮度从暗到亮,再从亮到暗”的周期变化。如果直接用线性步进,比如从0一直加到255再减回0,肉眼会明显感觉到增亮过程不均匀,中间突然变得很快,两头又很慢。这是因为人眼对亮度的感知近似对数关系,不是线性关系。
我推荐用正弦曲线生成亮度值:
brightness = (sin(2π * phase / period) + 1) / 2 * 255
这样亮度在最低点和最高点附近的斜率小,变化平缓,视觉效果更像人的呼吸节奏。如果你对浮点运算有顾虑,可以提前生成一张256点的正弦查找表,用查表代替实时计算。
5.2 在DMA发送完成间隙更新颜色
呼吸灯需要不断刷新整条灯带。刷新频率建议控制在20到30Hz,也就是每帧间隔约30到50ms。这个频率下肉眼看到的是连续动画,又不会因为刷太快造成不必要的功耗。DMA发送一帧数据的时间是:
(NUM_LEDS * 24 + 64) * 1.25us
8颗灯珠约250us,60颗灯珠约1.9ms。这和30ms的帧间隔相比非常小,CPU有大量时间做其他事情。
我采用的流程是:DMA发送完成回调里置dma_done = 1,主循环检测到标志后,更新下一帧的灯珠颜色,编码DMA缓冲区,启动下一次DMA发送。这样每一帧数据都不会重叠,也不会漏掉。
uint16_t phase = 0; while (1) { if (dma_done) { dma_done = 0; uint8_t brightness = sine_brightness(phase); for (uint16_t i = 0; i < NUM_LEDS; i++) { led_set_pixel(i, brightness, brightness / 8, brightness / 8); } led_update(); led_dma_start(); phase++; if (phase >= 1024) phase = 0; } }sine_brightness(phase)可以用查表实现,phase范围取0到1023,对应正弦表精度足够。
如果你希望呼吸速度更慢,可以每次把phase加2,或者把phase的增量改成一个递增步长。呼吸周期大致等于:
1024 * frame_interval / step
按帧间隔35ms、步长1算,一个完整周期约35.8秒,呼吸节奏比较安静。步长2约17.9秒,步长4约9秒。我自己喜欢步长2到3这个档位。
5.3 改成彩虹呼吸或多段波浪的扩展思路
共享同一套PWM+DMA框架,改灯效只是改颜色生成逻辑。呼吸灯是所有灯珠同一亮度,你可以把每个灯珠的R、G、B值改成跟随位置变化,比如彩虹效果:
uint8_t hue = (uint8_t)(phase + i * 32); led_set_pixel(i, hue, 255 - hue, (hue * 3) / 4);更进阶一些,可以做一个“呼吸波浪”,让同一个亮度波峰沿着灯带方向移动,视觉上就像光在呼吸的同时还在流动。这种效果在ESP8266/ESP32的灯带库里很常见,但原理并不复杂,就是把正弦函数的自变量从“时间”扩展成“时间 + 灯珠位置”。
只要把编码函数和数据发送流程固定下来,上面的灯效就是纯软件层面的事情,硬件时序完全不用再碰,这种隔离感正是PWM+DMA方案最舒服的地方。
6. 常见问题排查与我的调试心得
6.1 现象到原因速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 灯完全不亮 | 供电不足或电源错误 | 外接5V电源,避免从开发板引脚取大电流 |
| 第一个灯颜色不对 | 启动瞬间DMA竞争或CCR初始值错误 | 启动前手动写入dma_buf[0]到CCR1 |
| 颜色错乱但能亮 | GRB顺序写反或bit顺序方向错误 | 检查编码循环,先发高位bit |
| 只有第一颗灯亮 | DMA缓冲区长度不足或发送中断提前 | 确认DMA_BUF_LEN足够覆盖所有灯珠加RESET |
| 灯珠偶发闪烁 | 供电不稳定或数据线太长 | 加330Ω电阻,缩短数据线,加滤波电容 |
| 3.3V单片机驱动异常 | 高电平驱动能力不足 | 用74HCT245等电平转换芯片 |
6.2 我踩过比较深的三个坑
第一个坑是CCR预装载。默认配置里CCR预装载是Enable,DMA写入CCR的值要到下一个更新事件才生效,导致整个帧的bit流整体偏移一位。排查时我看到波形整体延迟了一个bit,颜色数据全部错位。把Output compare preload改成Disable之后,问题立刻消失。
第二个坑是DMA完成回调名字。网上代码里有人用HAL_TIM_PWM_PulseFinishedCallback,这个回调在PWM每一个比较事件时都会触发,频率是800kHz,也就是说一秒钟触发80万次。如果在里面写了大段处理代码,系统基本卡死。PWM的DMA传输完成回调应该是HAL_TIM_PWM_DMA_CpltCallback,不同HAL库版本名称可能有差异,最好在stm32f1xx_hal_tim.h里搜索一下PWM_DMA_Cplt确认名称,再重写对应的弱回调。
第三个坑是启动瞬间的CCR初始值。HAL_TIM_PWM_Start_DMA启动后,第一个PWM周期使用的CCR值是定时器里原本的值。如果登记器初值是0,启动瞬间就会先发一个低电平占空比周期,把整个数据流挤偏。我在启动前手动写入TIM2->CCR1 = dma_buf[0]之后就彻底稳定了。
6.3 调试工具和现场经验
调试WS2812时序,示波器是最直接的,但我个人更推荐逻辑分析仪。用逻辑分析仪抓PA0引脚的波形,直接在软件里量每个高电平脉冲的宽度,一眼就能看出CCR_ONE和CCR_ZERO是否在容差范围内。如果没有逻辑分析仪,也可以先固定输出单一颜色,再用万用表测数据引脚的占空比,大概判断发送是否正常。
调灯带的时候我习惯先做“单灯固定颜色测试”。只接一颗灯珠,让它发红色,确认颜色编码没问题,然后逐颗增加灯珠数量。这样如果出现问题,能快速定位是不是数据偏移、缓冲区长度还是硬件驱动能力的问题。
电源方面再提醒一句:不要指望开发板的USB口给长灯带供电。单颗WS2812全白电流能到60mA,100颗就是6A,必须单独接5V电源,并且给灯带两端都并上大电容。我调试时曾经偷懒用开发板5V引脚带30颗灯珠,颜色一调高就疯狂闪烁,最后发现不是时序问题,是电源被拉垮了。
这套PWM+DMA方案我后来又沿用到了彩色波浪、音频频谱等效果上。核心没变,变的只是缓冲区里那些表示亮度值的CCC数据。这也是我用过所有WS2812驱动方案里,最省心、最稳的一种。