用STM32的DMA+PWM驱WS2812,折腾了半天发现一个坑

说实话,WS2812这灯珠真的又爱又恨。爱的是它一根线就能串一堆灯,RGB随便调;恨的是它的时序要求太特么精确了,稍微偏一点就给你闪成鬼畜。
之前有个小项目,要在设备上做一圈氛围灯,大概30颗WS2812。图省事我直接上了网上最常见的方案——STM32的TIM PWM+DMA。结果呢?灯亮是亮了,但每隔几颗就有一个在瞎闪,颜色也不对。折腾了整整一个下午,最后发现问题出在一个让我哭笑不得的地方。
WS2812的时序到底有多变态
先快速说下这玩意儿的通信协议,不然没法聊后面的坑。WS2812单线归零码,意思就是用高低电平的时长来区分0和1:
T0H ≈ 350ns (高电平)
T0L ≈ 800ns (低电平)
T1H ≈ 700ns (高电平)
T1L ≈ 600ns (低电平)
RESET ≥ 50μs
一颗灯珠需要24bit(GRB各8位),所有灯珠串在一起送数据,送完拉低50μs以上表示一帧结束。
STM32的PWM+DMA方案思路很直接:把24bit * N颗灯的PWM占空比数据提前算好放数组里,DMA一轮轮搬运到TIM的CCR寄存器,PWM输出引脚直接连灯珠数据线。只要TIM的频率配得对,PWM一个周期刚好等于WS2812的一个bit周期,占空比50%表示0,占空比75%表示1。
听着挺合理对吧?
我用的是哪套配置
主控是STM32F103C8T6(蓝色pill板子,很多人入门的第一块板子),TIM2的CH1输出PWM,PA0引脚。时钟72MHz,预分频器设0,ARR设90,这样PWM频率就是 72MHz / 90 ≈ 800KHz,周期约1.25μs。
按WS2812的时序:
- 表示0:CCR设45,高电平≈562ns(实际45/90*1.25μs)
- 表示1:CCR设67,高电平≈837ns
跟标准规格比,T0H偏长了点,T0L偏短了点,但这玩意儿兼容范围其实挺宽的,多数灯珠吃这套。
DMA配置:TIM2_UP触发,外设地址&CCR1,内存地址是那个预计算好的数据数组,循环模式,传输完成中断打开。
#define LED_NUM 30
#define LED_BITS 24
uint16_t pwm_buf[LED_NUM * LED_BITS];
void ws2812_init(void) {
GPIO_InitTypeDef gpio = {0};
gpio.GPIO_Pin = GPIO_Pin_0;
gpio.GPIO_Mode = GPIO_Mode_AF_PP;
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &gpio);
TIM_TimeBaseInitTypeDef tim = {0};
tim.TIM_Prescaler = 0;
tim.TIM_Period = 90;
tim.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &tim);
TIM_OCInitTypeDef oc = {0};
oc.TIM_OCMode = TIM_OCMode_PWM1;
oc.TIM_OutputState = TIM_OutputState_Enable;
oc.TIM_Pulse = 0;
oc.TIM_OCPolarity = TIM_OCPolarity_High;
TIM_OC1Init(TIM2, &oc);
DMA_InitTypeDef dma = {0};
dma.DMA_PeripheralBaseAddr = (uint32_t)&TIM2->CCR1;
dma.MemoryBaseAddr = (uint32_t)pwm_buf;
dma.DMA_DIR = DMA_DIR_PeripheralDST;
dma.DMA_BufferSize = LED_NUM * LED_BITS;
dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
dma.DMA_MemoryInc = DMA_MemoryInc_Enable;
dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
dma.DMA_Mode = DMA_Mode_Normal;
dma.DMA_Priority = DMA_Priority_High;
DMA_Init(DMA1_Channel6, &dma);
TIM_DMACmd(TIM2, TIM_DMA_Update, ENABLE);
TIM_Cmd(TIM2, ENABLE);
}
懒得看CubeMX的点这里,我直接手写寄存器库了。上面这段用的标准外设库,跟HAL库差别不大,对着配就行。
问题来了
代码写完,下载,上电。第一颗灯正常亮白色,第二颗也还凑合,从第三颗开始颜色就飘了,第五颗干脆不亮,后面整个一锅粥。
第一反应:时序不对。用逻辑分析仪抓了一下PA0,发现波形确实有问题——每24个脉冲之后会出现一个异常的窄脉冲,DMA重装载的时候TIM还在跑,导致CCR被更新到一半就被采样了。
查了半天手册才发现,TIM_DMACmd(TIM2, TIM_DMA_Update, ENABLE) 这个配置触发的是 Update Event,但DMA传输需要跟TIM的更新事件严格同步。问题在于DMA的传输不是瞬时的——它需要一个HCLK时钟周期来完成,而这个周期里如果TIM计数器刚好走到CCR采样点,就会读到错误的值。
更坑的是,STM32F103的DMA1不支持TIM的CCR DMA请求,必须要用TIM_DMA_Update。网上90%的教程都这么写,但都没说这个模式下会有时序竞争。
怎么修的
试了好几种方法,最后有效的是两个:
方法一:空闲通道法(推荐)
TIM2同时开CH1和CH2,CH1正常输出PWM,CH2配置为比较输出但引脚不使能。DMA用CH2的CCR2做传输目标,然后让TIM2_CH2的输出在内部覆盖CH1的PWM输出。
这个有点绕,说白了就是让DMA写一个"备用"CCR,然后让TIM自己把值同步到真正的输出通道,绕开那个时序竞争点。
方法二:修改ARR+预分频(简单但有限)
把ARR改成44,预分频设1(APB1=36MHz),这样PWM频率还是800KHz,但ARR变小了,每个周期的CCR更新时间窗口更大。实测对部分批次的WS2812有效,但不是100%靠谱。
// 方法一的实现思路
TIM_OCInitTypeDef oc2 = {0};
oc2.TIM_OCMode = TIM_OCMode_PWM1;
oc2.TIM_OutputState = TIM_OutputState_Disable; // 引脚不输出
oc2.TIM_Pulse = 0;
TIM_OC2Init(TIM2, &oc2);
// DMA写CCR2,不是CCR1
DMA_InitTypeDef dma = {0};
dma.DMA_PeripheralBaseAddr = (uint32_t)&TIM2->CCR2; // 关键
// 其余配置不变...
DMA_Init(DMA1_Channel6, &dma);
// 使能TIM2的CC2 DMA请求
TIM_DMACmd(TIM2, TIM_DMA_CC2, ENABLE); // 不是Update!
改成CC2的DMA请求后,问题迎刃而解。原因是CCR的DMA请求直接跟比较事件同步,不存在Update模式下那种"计数器过零→触发DMA→DMA写CCR→计数器可能已经跑过一个节拍"的窗口竞争。
其实吧,回头看这个问题在参考手册的"TIM DMA"章节里提过一嘴,说用Update事件做DMA传输时,要确保DMA传输时间小于一个TIM周期。但当时谁会注意这种细节?都是网上找个例程就往上怼。
色彩转换的一点小经验
搞定时序之后,顺手写了个RGB转GRB的函数。注意WS2812的24bit顺序是G-R-B,不是R-G-B,而且高位在前。
void set_led_color(uint16_t idx, uint8_t r, uint8_t g, uint8_t b) {
uint32_t grb = ((uint32_t)g << 16) | ((uint32_t)r << 8) | b;
for (int i = 0; i < 24; i++) {
pwm_buf[idx * 24 + i] = (grb & (1 << (23 - i))) ? HIGH_CCR : LOW_CCR;
}
}
HIGH_CCR和LOW_CCR就是我前面说的那两个占空比值,实测67和45就行。如果你用的晶振不是8MHz或者主频不是72MHz,这两个值要重新算。
最终效果
修完以后连跑48小时没出过问题,颜色过渡也丝滑。30颗灯珠全串一根线上,亮度随便调。后来还加了呼吸灯效果,用TIM的另一个通道做软件PWM调亮度系数,这就是后话了。
顺便说一句,如果你用的是STM32G0或者G4系列,新款的TIM支持RCR(重复计数器),做WS2812驱动比F1省事多了,根本不用操心DMA时序竞争。F1毕竟老架构,有些东西就只能用土办法绕过去。
踩完这个坑,我把这方案写进了项目里。以后再用WS2812,F4以上直接上HAL的TIM+DMA,F1就老实走空闲通道法。吃过的亏,不用再吃第二遍。
更多推荐

所有评论(0)