ESP32 RMT 驱动 WS2812B 的精确时序控制与 DMA 中断抖动规避实战

一、为什么 RMT 是驱动 WS2812B 的最佳选择?

WS2812B 单线协议要求每个数据位(0 或 1)的脉冲宽度误差不超过 ±150ns,且信号周期为 1.25μs。若使用 GPIO 模拟时序,CPU 中断响应延迟(通常 5-20μs)会直接导致颜色错乱。ESP32 的 RMT(Remote Control)外设专为红外遥控设计,但因其可编程波形生成能力,成为驱动 WS2812B 的理想方案。

RMT 核心优势:

  • 硬件生成精确波形,不占用 CPU 周期
  • 支持 DMA 自动搬运数据,可连续输出数千个脉冲
  • 每个通道独立时钟分频,可灵活调整时序

二、RMT 时序控制原理

2.1 时钟分频与符号周期

RMT 时钟源通常为 APB(80MHz),通过 divider 分频得到计数时钟。WS2812B 时序参数:

  • T0H:0.35μs(高电平)
  • T0L:0.80μs(低电平)
  • T1H:0.70μs(高电平)
  • T1L:0.60μs(低电平)
  • 周期:1.25μs

若设置 divider=8,则计数时钟为 10MHz(周期 100ns)。此时:

  • 逻辑 0:高电平 3.5 个时钟,低电平 8 个时钟
  • 逻辑 1:高电平 7 个时钟,低电平 6 个时钟

但 RMT 符号(symbol)由 duration0duration1 组成,分别对应高低电平的时钟数。需注意 RMT 硬件会自动在符号间插入一个时钟周期的间隔,因此实际低电平时间需减去 1。

2.2 符号数据结构

typedef struct {
    uint16_t duration0; // 高电平持续时间(时钟数)
    uint16_t duration1; // 低电平持续时间(时钟数)
    uint8_t level0;     // 高电平极性,通常为 1
    uint8_t level1;     // 低电平极性,通常为 0
} rmt_symbol_word_t;

2.3 精确计算示例(divider=8)

  • 逻辑 0:duration0 = 3(0.3μs),duration1 = 8-1 = 7(0.7μs)
  • 逻辑 1:duration0 = 7(0.7μs),duration1 = 6-1 = 5(0.5μs)

注意:实际高电平时间会因硬件延迟略有偏差,建议用示波器校准。

三、DMA 中断抖动问题剖析

当灯带长度超过 64 个灯时,RMT 数据量超过单次 FIFO 容量(64 符号),必须启用 DMA。DMA 传输完成后触发中断,若在中断中填充新数据,会引入以下抖动:

  1. 中断响应延迟:FreeRTOS 中断服务函数执行时间不确定(通常 2-10μs),导致下一个符号周期被拉长。
  2. DMA 重载延迟:重新配置 DMA 描述符需要时间,若超过符号周期,波形出现毛刺。
  3. 缓存未命中:频繁访问外部 PSRAM 时,延迟可达数百纳秒。

四、规避抖动的三种策略

策略一:双缓冲 + 中断标志位优化

使用两个 DMA 缓冲区交替填充,在中断中仅设置标志位,由主循环负责填充数据。

#define LED_NUM 256
#define BUF_SIZE (LED_NUM * 24) // 每灯 24 位

rmt_symbol_word_t buf0[BUF_SIZE];
rmt_symbol_word_t buf1[BUF_SIZE];
volatile bool buf0_ready = false;
volatile bool buf1_ready = false;

void IRAM_ATTR rmt_isr_handler(void* arg) {
    // 仅设置标志,不做数据填充
    if (rmt_get_status() & RMT_STATUS_MEM_RD_ERR) {
        // 处理错误
    }
    if (rmt_get_memory_owner(0) == RMT_MEM_OWNER_TX) {
        buf0_ready = true;
    } else {
        buf1_ready = true;
    }
    rmt_clear_interrupt();
}

void fill_buffer(rmt_symbol_word_t* buf, uint32_t* colors, int len) {
    for (int i = 0; i < len; i++) {
        uint32_t c = colors[i];
        for (int bit = 23; bit >= 0; bit--) {
            if (c & (1 << bit)) {
                buf[i*24 + (23-bit)] = (rmt_symbol_word_t){ .duration0 = 7, .duration1 = 5, .level0 = 1, .level1 = 0 };
            } else {
                buf[i*24 + (23-bit)] = (rmt_symbol_word_t){ .duration0 = 3, .duration1 = 7, .level0 = 1, .level1 = 0 };
            }
        }
    }
}

void update_leds(uint32_t* colors) {
    // 等待当前缓冲区空闲
    while (!buf0_ready && !buf1_ready) { vTaskDelay(1); }
    if (buf0_ready) {
        fill_buffer(buf0, colors, LED_NUM);
        rmt_set_memory_owner(0, RMT_MEM_OWNER_TX);
        buf0_ready = false;
    } else {
        fill_buffer(buf1, colors, LED_NUM);
        rmt_set_memory_owner(0, RMT_MEM_OWNER_TX);
        buf1_ready = false;
    }
}

策略二:中断服务函数内直接填充(仅限短灯带)

若灯带长度小于 64,可不用 DMA,直接在中断中填充 FIFO。但需将中断函数放入 IRAM,并避免调用 FreeRTOS API。

void IRAM_ATTR rmt_isr_handler(void* arg) {
    // 直接填充 FIFO,注意时间紧迫
    for (int i = 0; i < 32; i++) {
        rmt_write_item(0, &symbols[tx_index++], 1);
    }
    rmt_clear_interrupt();
}

策略三:纯 DMA 轮询模式(推荐)

完全不用中断,主循环轮询 DMA 状态。由于 RMT 输出是连续的,只要在 DMA 传输完成前更新下一个缓冲区即可。利用双缓冲,轮询 rmt_get_memory_owner() 判断当前使用的缓冲区。

void update_leds_polling(uint32_t* colors) {
    static int cur_buf = 0;
    rmt_symbol_word_t* cur = cur_buf ? buf1 : buf0;
    rmt_symbol_word_t* next = cur_buf ? buf0 : buf1;
    
    // 填充下一个缓冲区
    fill_buffer(next, colors, LED_NUM);
    
    // 等待当前 DMA 传输完成
    while (rmt_get_memory_owner(0) == RMT_MEM_OWNER_TX) {
        // 空转,或可做其他非阻塞任务
    }
    
    // 切换内存所有权
    rmt_set_memory_owner(0, RMT_MEM_OWNER_TX);
    cur_buf = !cur_buf;
}

五、完整配置示例(ESP-IDF)

#include "driver/rmt.h"

#define RMT_TX_CHANNEL 0
#define LED_NUM 256

void rmt_config_ws2812b(void) {
    rmt_config_t config = {
        .rmt_mode = RMT_MODE_TX,
        .channel = RMT_TX_CHANNEL,
        .gpio_num = 18,
        .clk_div = 8,
        .mem_block_num = 2,  // 双缓冲,每个块 64 符号
        .tx_config = {
            .carrier_freq_hz = 0,
            .carrier_level = RMT_CARRIER_LEVEL_LOW,
            .idle_level = RMT_IDLE_LEVEL_LOW,
            .idle_output_en = true,
        }
    };
    rmt_config(&config);
    rmt_driver_install(config.channel, 0, 0);
    
    // 启用 DMA(需在驱动安装后)
    rmt_set_dma_chan(config.channel, 1);
}

void app_main(void) {
    rmt_config_ws2812b();
    
    uint32_t colors[LED_NUM];
    // 初始化颜色数据...
    
    while (1) {
        update_leds_polling(colors);
        vTaskDelay(pdMS_TO_TICKS(100)); // 更新频率
    }
}

六、注意事项与调试技巧

  • 时序校准:使用逻辑分析仪测量实际波形,微调 duration 值。例如,若实测 T0H 为 0.4μs,则减少 duration0 为 2。
  • IRAM 放置:中断服务函数必须用 IRAM_ATTR 修饰,且不能调用 Flash 中的函数。
  • 电源去耦:灯带供电需独立 5V 电源,并在 ESP32 输出端加 100Ω 电阻和 470pF 电容滤波。
  • DMA 描述符对齐:缓冲区需 4 字节对齐,使用 HEAP_CAPS_DMA 分配内存。
  • 错误处理:检查 RMT_STATUS_MEM_RD_ERR 标志,若出现说明 DMA 读取速度跟不上,需降低时钟分频或增大缓冲区。
  • 多通道扩展:若需控制多路灯带,可分配不同 RMT 通道,但注意共享 DMA 资源。

七、总结

通过合理配置 RMT 时钟和符号参数,结合双缓冲与轮询模式,可完全规避 DMA 中断抖动,实现 WS2812B 的稳定驱动。推荐使用纯 DMA 轮询方案,它既简单又可靠,适合大多数应用场景。对于超长灯带(>1024 灯),可考虑使用 I2S 外设或专用 LED 驱动芯片,但 RMT 仍是 ESP32 上的首选方案。