cover

说实话,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就老实走空闲通道法。吃过的亏,不用再吃第二遍。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐