本节目标:看懂 WS2812 的单线归零码时序,用 ESP-IDF 新版 RMT 发送通道 + 自定义编码器驱动一串级联灯珠,实现红绿蓝跑马灯
一、本节要解决的问题
前面的 LED 实验只有"亮 / 灭 / 亮度"三个维度,一个 IO 也只能带动一盏单色灯。做智能家居的氛围灯、状态灯时,需要的是多种颜色、多颗灯珠、能一根线级联。
WS2812 正好满足这三点:它把驱动芯片和 RGB 灯珠封装成一个器件,只用一根数据线 就能让一串灯珠各自显示不同颜色。代价是它对时序极其挑剔:0 码和 1 码不是高低电平本身,而是高低电平持续时间的比例,时间尺度在百纳秒级,软件翻转 GPIO 根本抖不出这么准的波形。
所以本节要解决两件事:
- 弄懂 WS2812 的单线归零码协议(0 码 / 1 码、24 位颜色、级联、复位信号);
- 学会用 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 内部其实有一个"移位 + 锁存"的小流程:
- 主机始终从第一颗灯珠的 DIN 发数据;
- 第一颗灯珠把收到的前 24 位留下并锁存,剩下的数据(会自动整形)从 DOUT 转发给第二颗;
- 第二颗收到后再留下自己的 24 位,继续往后转发,依此类推;
- 主机发完 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);
}
三个要点:
- GRB 顺序:写进缓存的第一个字节是 g,第二个才是 r,和 3.2 节对应;
- 每次都发整条灯带 :虽然只改了第
index颗的颜色,但rmt_transmit的长度参数是handle->led_num * 3。led_buffer是累积的"全灯带颜色表",所以跑马灯时前面点亮的灯不会被冲掉; 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,循环不止。如果颜色顺序错乱或只有第一颗亮,先查数据脚是否接对、灯珠数量宏是否与实物一致。