用 SPI 的占空比编码驱动 WS2812 灯珠
WS2812 只有一根数据线,对逻辑 0 和逻辑 1 的识别依赖高电平持续时间。直接用 GPIO 翻转需要严格控制每个脉冲,代码执行时间和中断都可能扰动波形。
这个驱动借用了 SPI 的稳定时钟:先把每个 WS2812 数据位展开成一个 SPI 字节,再让 SPI 和 DMA 连续输出。完整代码见 STM32 HAL SPI WS2812 驱动仓库。
WS2812 单线脉宽约束
WS2812 接收的是固定周期内不同高电平宽度的脉冲。逻辑 0 的高电平较短,逻辑 1 的高电平较长。SPI 本身不会生成 WS2812 协议,但 MOSI 引脚可以按固定速率连续输出 0 和 1,这给了我们一个转换入口。
驱动规定一个 WS2812 数据位占用 8 个 SPI 位。只要 SPI 使用 MSB 优先发送,就能用一个字节中连续高电平位的数量控制脉宽。
SPI 字节展开脉宽编码
驱动只定义了两个编码值:
#define BIT0_PULSE 0x80 // 1000 0000
#define BIT1_PULSE 0xC0 // 1100 00000x80 发送时先输出 1 个高电平位,再输出 7 个低电平位。0xC0 先输出 2 个高电平位,再输出 6 个低电平位。两种编码的总周期相同,高电平宽度不同,正好对应 WS2812 对逻辑 0 和逻辑 1 的判定方式。
这样处理后,CPU 不再逐个翻转 GPIO。SPI 外设负责稳定输出每一个小时间片,软件只需要准备字节序列。
GRB 数据缓冲区
每颗 WS2812 需要 24 位颜色数据,发送顺序是 G、R、B。驱动逐位读取三个颜色分量,并把原始 1 位转换成一个 SPI 字节:
uint8_t *p = ws2812->led_buffer + num * 24;
for (int i = 0; i < 8; i++) {
*p++ = (g & 0x80) ? BIT1_PULSE : BIT0_PULSE;
g <<= 1;
}R 和 B 分量使用相同的展开过程。一颗灯最终占用 24 字节缓冲区,多颗灯的数据直接首尾相连。缓冲区消耗增加了 8 倍,换来了简单、连续且可交给外设发送的波形数据。
DMA 连续发送
颜色全部编码完成后,更新函数只做一次 DMA 发送:
void WS2812_Update(WS2812_HandleTypeDef *ws2812) {
HAL_SPI_Transmit_DMA(
ws2812->spi_handle,
ws2812->led_buffer,
WS2812_LED_NUM * 24
);
}DMA 按顺序搬运整个缓冲区,SPI 持续输出 MOSI 波形。发送期间不需要 CPU 逐位等待,长灯带也沿用同一套编码方式,只需扩大灯珠数量和缓冲区。
SPI 配置与波形校准
0x80 和 0xC0 只决定高电平占整个编码字节的比例,实际脉宽仍由 SPI 时钟决定。移植时需要检查以下条件:
- MOSI 连接 WS2812 数据输入,SPI 使用 MSB 优先。
- SPI 时钟配合 8 位展开后,逻辑 0、逻辑 1 的高低电平落在当前灯珠数据手册范围内。
- DMA 发送完成后保留足够的低电平复位时间,再开始下一帧。
- 调整 SPI 分频或编码值后,用示波器确认 MOSI 波形。
这段驱动的核心很小:用 SPI 字节中的高电平位数表达 WS2812 脉宽,再用 DMA 发送整帧。它把严格时序转换成普通的缓冲区编码问题,适合已经具备 SPI 和 DMA 外设的 MCU 工程。