【ESP32笔记】RGB灯WS2812实验

本节目标:看懂 WS2812 的单线归零码时序,用 ESP-IDF 新版 RMT 发送通道 + 自定义编码器驱动一串级联灯珠,实现红绿蓝跑马灯

一、本节要解决的问题

前面的 LED 实验只有"亮 / 灭 / 亮度"三个维度,一个 IO 也只能带动一盏单色灯。做智能家居的氛围灯、状态灯时,需要的是多种颜色、多颗灯珠、能一根线级联。

WS2812 正好满足这三点:它把驱动芯片和 RGB 灯珠封装成一个器件,只用一根数据线 就能让一串灯珠各自显示不同颜色。代价是它对时序极其挑剔:0 码和 1 码不是高低电平本身,而是高低电平持续时间的比例,时间尺度在百纳秒级,软件翻转 GPIO 根本抖不出这么准的波形。

所以本节要解决两件事:

  1. 弄懂 WS2812 的单线归零码协议(0 码 / 1 码、24 位颜色、级联、复位信号);
  2. 学会用 ESP32 的 RMT 外设让硬件自动产生精确波形------这是全篇的技术核心。

二、硬件基础

单个 WS2812 有 4 个引脚:

引脚序号 引脚名称 功能
1 VDD 电源 3.5~5.3V(教程实测 3.3V 也能驱动)
2 DOUT 数据输出,接下一颗灯珠的 DIN
3 VSS 接地
4 DIN 数据输入,第一颗接 ESP32 的 IO

级联方式:ESP32 GPIO → 灯珠 1 的 DIN,灯珠 1 的 DOUT → 灯珠 2 的 DIN,依次串联。仓库 ws2812/main/main.c 里的两个宏决定了数据脚和灯珠数量:

c 复制代码
#define WS2812_GPIO_NUM     GPIO_NUM_26
#define WS2812_LED_NUM      12

供电与电平提示 :规格书要求高电平输入 Vih ≥ 0.7 × VDD。灯珠若用 5V 供电,这个门槛就是 3.5V,而 ESP32 只能输出 3.3V,属于"压线偏下"。实践中多数灯珠能识别 3.3V(教程实测可行),但灯多、线长时容易闪烁或串色,此时先检查是否共地、灯带是否单独供电,而不是先怀疑代码。

三、知识点

3.1 0 码和 1 码:靠高低电平的时间比例区分

WS2812 每一位数据都是一段"先高后低"的电平,0 和 1 的差别只在高电平占整个位周期的比例:

编码 高电平时间 低电平时间 位周期
0 码 0.3us(T0H) 0.9us(T0L) 1.2us
1 码 0.9us(T1H) 0.3us(T1L) 1.2us
  • 每一位都以"回到低电平"结束,所以这种编码也叫归零码;
  • 接收方不看电平绝对值,只看高电平持续了多久,因此对时间精度要求极高。

核对规格书:本板配套的 XL-5050RGBC-WS2812B 规格书给出的范围是 T0H 0.2~0.35us、T0L 0.55~1.2us、T1H 0.55~1.2us、T1L 0.2~0.35us。代码里用的 0.3us / 0.9us 都落在范围内;每比特 1.2us 对应约 833kHz,也落在规格书 800~1100kHz 之内。

3.2 一颗灯珠的 24 位数据:顺序是 GRB

所有颜色都是三原色合成,WS2812 用 24 位表示一颗灯珠的颜色,但内部的字节顺序是 G-R-B(绿、红、蓝),不是我们习惯的 RGB ,而且高位先发(MSB first)。这就是后面 ws2812_write 里要交换 r、g 写入顺序的原因。

3.3 级联:一根数据线为什么能控制多颗灯珠

WS2812 内部其实有一个"移位 + 锁存"的小流程:

  1. 主机始终从第一颗灯珠的 DIN 发数据;
  2. 第一颗灯珠把收到的前 24 位留下并锁存,剩下的数据(会自动整形)从 DOUT 转发给第二颗;
  3. 第二颗收到后再留下自己的 24 位,继续往后转发,依此类推;
  4. 主机发完 N × 24 位后,发送一个复位信号(一段较长的低电平),本次传输结束;下一次发送又从第一颗灯珠开始记录。

所以"第几颗灯珠"完全由数据在流里的位置决定,ws2812_write 里 index = 4 控制的就是第 5 颗。

3.4 为什么选 RMT 外设

RMT 的全称是 Remote Control Transceiver,它本来是为红外遥控设计的收发模块。教程里介绍的 NEC 红外编码:38kHz 载波,起始码 9ms 高电平(38kHz 脉冲)+ 4.5ms 低电平,逻辑 0 是 562.5us 高 + 562.5us 低,逻辑 1 是 562.5us 高 + 1.6875ms 低。

驱动 WS2812 时不需要载波(WS2812 自己解析脉宽),但红外模块"按描述输出精确电平序列"的能力正好对口:

  • 用 IO 软件模拟:需要 0.1us 级精度,一次中断或任务切换就把波形拉歪;
  • 用 SPI / I2C:SPI 只能按固定位宽打时钟,I2C 是开漏的地址型协议,都拼不出这种自定义的归零码;
  • 用 RMT:把"接下来是什么电平、持续多少个 tick"描述成**符号(symbol)**写进通道内存,硬件照着自动翻转 IO,CPU 只在内存块取空时补数据,抖动只有几十纳秒。

RMT 通道的分辨率决定了时间粒度。例程把通道配置成 10MHz ,即 1 tick = 0.1us,远远够用;从此"时间"在代码里都要换算成 tick:tick 数 = 微秒数 × 分辨率 / 1000000。

3.5 新版 RMT 驱动的三个概念

概念 对应对象 说明
通道 rmt_channel_handle_t 一份硬件资源,绑定 GPIO、分辨率、内存块;创建后要 rmt_enable 才能用
符号 rmt_symbol_word_t 硬件能理解的最小描述单位:level0 + duration0 和 level1 + duration1 两段
编码器 rmt_encoder_t 把用户数据翻译成符号序列的"翻译官"

编码器 ESP-IDF 已经自带两个:

  • 拷贝编码器 (rmt_new_copy_encoder):把一段固定内容原样转成符号,适合恒定的复位段;
  • 字节编码器 (rmt_new_bytes_encoder):按 bit0 / bit1 模板把每个字节展开成若干符号,正好用来表达 WS2812 的 0 码、1 码。

本节既有变长的 RGB 数据、又有固定的复位段,所以再自定义一个编码器把两个内置编码器串起来。代码里的注释把发送流程写得很清楚:

c 复制代码
/* 发送WS2812数据的函数调用顺序如下
 * 1、调用rmt_transmit,需传入RMT通道、发送的数据、编码器参数
 * 2、调用编码器的encode函数,在本例程中就是调用rmt_encode_led_strip函数
 * 3、调用由rmt_new_bytes_encoder创建的字节编码器编码函数bytes_encoder->encode,将用户数据编码成rmt_symbol_word_t RMT符号
 * 4、调用由rmt_new_copy_encoder创建的拷贝编码器编码函数copy_encoder->encode,将复位信号安装既定的电平时间进行编码
 * 5、rmt_encode_led_strip函数返回,在底层将信号发送出去(本质上是操作IO管脚高低电平)
*/

四、代码分析

4.1 工程结构

复制代码
ws2812/
├── CMakeLists.txt
├── main/
│   ├── CMakeLists.txt      # idf_component_register(SRCS "led_ws2812.c" "main.c" INCLUDE_DIRS ".")
│   ├── led_ws2812.c        # RMT 通道 + 自定义编码器 + 对外三个接口
│   ├── led_ws2812.h        # 只暴露 init / deinit / write 三个接口
│   └── main.c              # app_main():红绿蓝跑马灯
├── sdkconfig               # CONFIG_IDF_TARGET="esp32"
└── README.md

教程说"可以直接将这两个 led_ws2812.h、led_ws2812.c 应用在自己工程中"------这正是本节的设计意图:把它当现成驱动用,学一次、以后直接搬。

4.2 两个结构体:驱动描述 与 自定义编码器

c 复制代码
//WS2812驱动的描述符
struct ws2812_strip_t
{
    rmt_channel_handle_t led_chan;          //rmt通道
    rmt_encoder_handle_t led_encoder;       //rmt编码器
    uint8_t *led_buffer;                    //rgb数据
    int led_num;                            //led个数
};

这个结构体由 ws2812_init 里 calloc 出来,作为句柄返回给用户,相当于把"通道 + 编码器 + 颜色缓存 + 灯珠数量"打包成一个对象。

c 复制代码
//自定义编码器
typedef struct {
    rmt_encoder_t base;                     //编码器,里面包含三个需要用户实现的回调函数,encode,del,ret
    rmt_encoder_t *bytes_encoder;           //字节编码器,调用rmt_new_bytes_encoder函数后创建
    rmt_encoder_t *copy_encoder;            //拷贝编码器,调用rmt_new_copy_encoder函数后创建
    int state;                              //状态控制
    rmt_symbol_word_t reset_code;           //结束位的时序
} rmt_led_strip_encoder_t;

base 必须放在第一个成员 的位置,这样 &led_encoder->base 可以安全地当作通用的 rmt_encoder_handle_t 使用------C 语言里结构体的首成员地址与结构体地址相同,这是驱动层实现"面向对象"的常用套路。

4.3 初始化:ws2812_init

c 复制代码
//定义一个RMT发送通道配置
rmt_tx_channel_config_t tx_chan_config = {
    .clk_src = RMT_CLK_SRC_DEFAULT,         //默认时钟源
    .gpio_num = gpio,                       //GPIO管脚
    .mem_block_symbols = 64,                //内存块大小,即 64 * 4 = 256 字节
    .resolution_hz = LED_STRIP_RESOLUTION_HZ,   //RMT通道的分辨率10000000hz=0.1us,也就是可以控制的最小时间单元
    .trans_queue_depth = 4,                 //底层后台发送的队列深度
};

//创建一个RMT发送通道
ESP_ERROR_CHECK(rmt_new_tx_channel(&tx_chan_config, &led_handle->led_chan));

//创建自定义编码器(重点函数),所谓编码,就是发射红外时加入我们的时序控制
ESP_ERROR_CHECK(rmt_new_led_strip_encoder(&led_handle->led_encoder));

//使能RMT通道
ESP_ERROR_CHECK(rmt_enable(led_handle->led_chan));

几个关键参数:

  • resolution_hz = 10MHz:这是整个驱动的"时间基准",1 tick = 0.1us;
  • mem_block_symbols = 64:通道一次能装 64 个符号(约 256 字节内存)。注意 12 颗灯珠需要 288 个符号,装不下也没关系 ------rmt_transmit 是异步的,驱动会在内存块发空后反复调用编码器接着编码,这正是 4.5 节里 RMT_ENCODING_MEM_FULL 分支存在的意义;
  • trans_queue_depth = 4:最多允许 4 次发送请求排队。

通道创建后默认是关闭的,所以最后必须 rmt_enable() 才能开始发送。

4.4 核心:生成 WS2812 编码器

rmt_new_led_strip_encoder 先注册三个回调(encode、del、reset),再配置两个内置编码器。重点是字节编码器的配置:

c 复制代码
//新建一个编码器配置(0,1位持续时间,参考芯片手册)
rmt_bytes_encoder_config_t bytes_encoder_config = {
    .bit0 = {
        .level0 = 1,
        .duration0 = 0.3 * LED_STRIP_RESOLUTION_HZ / 1000000, // T0H=0.3us
        .level1 = 0,
        .duration1 = 0.9 * LED_STRIP_RESOLUTION_HZ / 1000000, // T0L=0.9us
    },
    .bit1 = {
        .level0 = 1,
        .duration0 = 0.9 * LED_STRIP_RESOLUTION_HZ / 1000000, // T1H=0.9us
        .level1 = 0,
        .duration1 = 0.3 * LED_STRIP_RESOLUTION_HZ / 1000000, // T1L=0.3us
    },
    .flags.msb_first = 1 //高位先传输
};

0.3 * 10000000 / 1000000 = 3,即 T0H 是 3 个 tick。可以看到 3.1 节的时序表被原样翻译 成了结构体:0 码是"高 3 tick + 低 9 tick",1 码反过来。msb_first = 1 对应 WS2812 高位在前的约定。

复位段用拷贝编码器发送,它的符号是手工拼出来的:

c 复制代码
//设定结束位时序
uint32_t reset_ticks = LED_STRIP_RESOLUTION_HZ / 1000000 * 50 / 2; //分辨率/1M=每个ticks所需的us,然后乘以50就得出50us所需的ticks
led_encoder->reset_code = (rmt_symbol_word_t) {
    .level0 = 0,
    .duration0 = reset_ticks,
    .level1 = 0,
    .duration1 = reset_ticks,
};

reset_ticks = 10 × 50 / 2 = 250,即 25us。为什么除以 2?因为一个 rmt_symbol_word_t 由两段组成,这里让两段都是 25us 低电平,两段电平相同会被硬件连起来,合计正好是 50us 的复位低电平。

注意 :本板配套灯珠的规格书里,TRST(复位低电平)最小值是 80us ,而这里合成的是 50us。多数灯带实测 50us 也能正常锁存(因为两帧之间本来就有较长空闲),但如果你遇到"最后一颗灯颜色不对 / 刷新不干净",可以先把注释里的 50 改成 80 再试。

4.5 编码状态机:rmt_encode_led_strip

自定义编码器的 encode 回调在每次 rmt_transmit 时被驱动反复调用,用一个状态变量决定"现在该编哪一段":

c 复制代码
switch (led_encoder->state) {   //led_encoder->state是自定义的状态,这里只有两种值,0是发送RGB数据,1是发送复位码
case 0: // send RGB data
    encoded_symbols += bytes_encoder->encode(bytes_encoder, channel, primary_data, data_size, &session_state);
    if (session_state & RMT_ENCODING_COMPLETE) {    //字节编码完成
        led_encoder->state = 1; // switch to next state when current encoding session finished
    }
    if (session_state & RMT_ENCODING_MEM_FULL) {    //缓存不足,本次退出
        state |= RMT_ENCODING_MEM_FULL;
        goto out; // yield if there's no free space for encoding artifacts
    }
// fall-through
case 1: // send reset code
    encoded_symbols += copy_encoder->encode(copy_encoder, channel, &led_encoder->reset_code,
                                            sizeof(led_encoder->reset_code), &session_state);
    if (session_state & RMT_ENCODING_COMPLETE) {
        led_encoder->state = RMT_ENCODING_RESET; // back to the initial encoding session
        state |= RMT_ENCODING_COMPLETE;
    }
    ...
}

两个容易忽略的设计:

  • case 0 故意不写 break (注释 // fall-through):如果 RGB 数据编完时内存块还有空位,就在同一轮里把复位段也编进去,省一次调度;
  • RMT_ENCODING_MEM_FULL 时立刻返回 :告诉驱动"内存满了,先发一会儿再叫我"。RMT_ENCODING_RESET 的值就是 0,表示一轮新会话从状态 0 开始。

回调里那句 __containerof 也值得一读,代码注释解释得很直白:

c 复制代码
/*
__containerof宏的作用:
通过结构的成员来访问这个结构的地址
*/
rmt_led_strip_encoder_t *led_encoder = __containerof(encoder, rmt_led_strip_encoder_t, base);

驱动层只持有 base 的地址,而回调需要访问整个 rmt_led_strip_encoder_t,__containerof 就负责"由成员地址反推结构体首地址"。

4.6 写数据:ws2812_write

c 复制代码
esp_err_t ws2812_write(ws2812_strip_handle_t handle,uint32_t index,uint32_t r,uint32_t g,uint32_t b)
{
     rmt_transmit_config_t tx_config = {
        .loop_count = 0, //不循环发送
    };
    if(index >= handle->led_num)
        return ESP_FAIL;
    uint32_t start = index*3;
    handle->led_buffer[start+0] = g & 0xff;     //注意,WS2812的数据顺序时GRB
    handle->led_buffer[start+1] = r & 0xff;
    handle->led_buffer[start+2] = b & 0xff;

    return rmt_transmit(handle->led_chan, handle->led_encoder, handle->led_buffer, handle->led_num*3, &tx_config);
}

三个要点:

  1. GRB 顺序:写进缓存的第一个字节是 g,第二个才是 r,和 3.2 节对应;
  2. 每次都发整条灯带 :虽然只改了第 index 颗的颜色,但 rmt_transmit 的长度参数是 handle->led_num * 3。led_buffer 是累积的"全灯带颜色表",所以跑马灯时前面点亮的灯不会被冲掉;
  3. loop_count = 0 表示只发一遍。另外 rmt_transmit 是异步 的,函数立刻返回,后台由 RMT 中断接着发------因此传入的缓冲必须在发完前保持有效。这里传的是结构体内长期存在的 led_buffer,天然满足;如果你自己传栈上的临时数组,一定要在返回前调用 rmt_tx_wait_all_done() 等它发完。

4.7 应用:main.c 跑马灯

c 复制代码
while(1)
{
    //红色跑马灯
    for(index = 0;index < WS2812_LED_NUM;index++)
    {
        uint32_t r = 230,g = 20,b = 20;
        ws2812_write(ws2812_handle,index,r,g,b);
        vTaskDelay(pdMS_TO_TICKS(80));
    }
    //绿色、蓝色跑马灯:结构相同,只是 r/g/b 的取值互换
    ...
}

每 80ms 点亮一颗,12 颗灯珠依次亮过去,就形成"跑"的视觉效果。颜色用 230/20 而不是 255/0,是为了压低亮度、留一点其他通道,看起来更柔和。

补充 :仓库的 main.c 里还有一个教程没提到的 led_strip_hsv2rgb()(HSV 转 RGB,注释写着"暂无用到"),它是为后面做彩虹渐变预留的工具函数,本节可以先跳过。

五、关键 API 速查

API 作用 所在头文件
rmt_new_tx_channel() 创建 RMT 发送通道(GPIO、分辨率、内存块、队列深度) driver/rmt_tx.h
rmt_enable() 使能通道,收发前必须调用 driver/rmt_tx.h
rmt_new_bytes_encoder() 创建字节编码器:按 bit0/bit1 模板把字节展开成符号 driver/rmt_encoder.h
rmt_new_copy_encoder() 创建拷贝编码器:把固定内容原样转成符号(复位段) driver/rmt_encoder.h
rmt_del_encoder() 删除编码器(自定义编码器内部的两个子编码器也要删) driver/rmt_encoder.h
rmt_encoder_reset() 复位编码器状态,准备下一次会话 driver/rmt_encoder.h
rmt_transmit() 异步启动一次发送:通道 + 编码器 + 数据 + 配置 driver/rmt_tx.h
rmt_tx_wait_all_done() 等待发送完成(传栈上数据时才需要) driver/rmt_tx.h
rmt_disable() / rmt_del_channel() 关闭 / 删除通道(本工程未调用,见易错点) driver/rmt_tx.h

六、编译与现象验证

bash 复制代码
cd esp32-board/ws2812
idf.py build                  # sdkconfig 中目标已是 esp32,一般无需再 set-target
idf.py -p COM<端口> flash monitor

烧录后应看到:红、绿、蓝三种颜色依次从第 1 颗"跑"到第 12 颗,每颗停留约 80ms,循环不止。如果颜色顺序错乱或只有第一颗亮,先查数据脚是否接对、灯珠数量宏是否与实物一致。

相关推荐
Aurora_eye1 小时前
Git 笔记
笔记·git
西城微科方案开发1 小时前
激光测距仪模组选择——M01还是M03?
单片机·嵌入式硬件
yi0111 小时前
DAY28: LeetCode 111. 二叉树的最小深度
人工智能·笔记·python·算法·leetcode·二叉树·递归
zjxtxdy1 小时前
STM32hal库开发-中断与串口和通信协议
stm32·单片机·嵌入式硬件
霖霖7141 小时前
【ESP32笔记】开发环境的搭建
单片机·mcu·物联网
wuyk5551 小时前
27.LeetCode 2265. 统计值等于子树平均值的节点数 —— 后序遍历实战
stm32·单片机·嵌入式硬件·算法
xxwl5852 小时前
三、区域赛算法模版第三章---字符串相关算法
笔记·学习·算法
码流子2 小时前
高速场景视频接入:GB/T 28181 级联与 RTSP 取流,一条链路讲清楚
大数据·数据库·人工智能·物联网·音视频
沐欣工作室_lvyiyi2 小时前
基于单片机的智能窗帘控制设计(论文+源码)
单片机·嵌入式硬件·单片机设计·江苏理工学院·伊犁师范大学·延安大学·兰州城市学院