简介:这是一份面向STM32开发者的WS2812灯珠驱动工程,基于硬件SPI配合DMA方式实现高效、低CPU占用的数据传输,并移植了Adafruit_NeoPixel库,支持多种动态显示效果。工程在main函数中预留了各样式测试函数,使用时只需在头文件中修改灯珠数量,并将控制引脚接到PA7,即可快速验证或二次开发,适合智能照明、氛围灯、小型点阵屏等场景。压缩包共103个文件,包含44个.h头文件、43个.c源文件、8个汇编启动文件以及工程配置、批处理和hex烧录文件等,整体大小约311KB,目录结构规整,便于定位驱动代码与库函数。目前已有6744人学习了解,作者在描述中说明测试未发现明显bug,遇到问题也可直接反馈交流。对于需要快速上手WS2812或希望理解SPI+DMA驱动思路的STM32用户,这份工程能节省不少移植与调测时间。 作为一个常年跟灯带、LED控制器打交道的人,我调过太多次WS2812的驱动了。网上代码一抓一把,但真正能让人放心上线、跑几十上百颗灯珠不闪不乱的项目,多数绕不开“SPI+DMA”这套组合。今天就把这套驱动思路掰开揉碎讲清楚,包括时序怎么算、代码怎么落、烧录后灯不亮应该从哪里查起,希望对正在折腾WS2812的朋友有点用。
这套方案解决的痛点很直接:WS2812对时序极其敏感,靠GPIO翻转模拟太吃MCU性能,靠延时函数硬扛又容易在动画刷新时卡死。SPI+DMA则是把“发送一串精心构造的比特流”这件事交给外设和DMA硬件完成,CPU只在最开始打包数据,剩下的时间可以继续跑动画逻辑,非常适合驱动几十到几百颗灯珠。
1. WS2812驱动到底难在哪:先从时序说起
1.1 灯带协议的本质
WS2812内部有一颗控制IC,它接收串行数据,点亮自己的RGB灯珠,再把多余的数据从DOUT脚转发给下一颗。这种级联方式说白了就是一条单线总线,协议也简单:每个LED的颜色由24个bit决定,按GRB顺序排列,发送完一整串数据后,保持数据线低电平至少50us,表示复位帧,所有灯珠才会同时更新颜色。
难点在于这24个bit不是电平高低表示的,而是靠高低电平的持续时间来区分0和1。以最常用的WS2812B为例,0码是高电平约0.35us、低电平约0.8us,1码是高电平约0.7us、低电平约0.6us。也就是说,同样都是高电平,持续时间不同,含义就完全不同。这对MCU的定时精度要求不算苛刻,但对“一致性”要求很高——同一个码的持续时间不能忽长忽短。
1.2 为什么GPIO翻转方案不靠谱
很多新手第一次驱动WS2812,都是从“GPIO拉高、延时、拉低、延时”开始的,比如:
if (bit) { GPIO_HIGH; delay_ns(700); GPIO_LOW; delay_ns(600); } else { GPIO_HIGH; delay_ns(350); GPIO_LOW; delay_ns(800); }这颗灯的级别编码倒是能出来,但问题也肉眼可见:一旦中断发生、RTOS调度、串口打印什么的插进来,延时就被拉长,时序当场失真,灯珠要么闪、要么颜色错乱。而且一颗灯珠24个bit,100颗就是2400次电平翻转,CPU几乎被占死,动画一复杂就卡顿。
另外一个隐藏问题是,不同编译器优化级别、不同主频,延时函数实际出来的时间都不一样,代码拷到另一块板子上很可能就跑不起来。我见过太多“编译开O2能亮、开O0就不亮”的怪现象,根因都是靠软件死等时序。
2. 为什么选择SPI+DMA方案:思路拆解与参数计算
2.1 四种常见方案对比
现在调WS2812的主流方案有几种:GPIO翻转、定时器中断、PWM/硬件定时器组合、SPI+DMA,以及ESP32平台独有的RMT外设。我先把各自特点列一下。
| 方案 | 实现成本 | CPU占用 | 稳定性 | 适用平台 |
|---|---|---|---|---|
| GPIO翻转+延时 | 最低 | 极高 | 差 | 学习验证 |
| 定时器中断逐位输出 | 中 | 高 | 中 | 少量灯珠 |
| PWM+DMA/SPI+DMA | 较高 | 极低 | 高 | 大量灯珠 |
| ESP32 RMT | 低 | 极低 | 高 | ESP32系列 |
SPI+DMA的思路非常巧妙:SPI本来就是一根时钟线加一根数据线往外推数据的同步串行接口,而WS2812虽然只有一根数据线,但SCK可以接一个固定频率的时钟源,或者干脆利用SPI主模式自带时钟输出的特性,把MOSI上的比特流按固定速率送出去。这样,一个数据位的时长就由SPI时钟频率严格决定,不受CPU负载影响。
2.2 SPI映射的逻辑:从时序到数据字节的换算
核心问题来了:SPI发的是8bit一组的字节,WS2812要的是自定义时长的高低电平,怎么对接?答案是分段映射。我常用的方案是让SPI时钟跑8MHz,这样每个SPI位周期是125ns。WS2812的一个数据位大约1.25us,正好约等于10个SPI位周期。
要实现0码和1码,就用两个特定的SPI字节来表示一个WS2812数据位:
- 0码:二进制11100000,即0xE0。前3个SPI位为高,时长3×125ns=375ns;后5个SPI位为低,时长5×125ns=625ns。
- 1码:二进制11111100,即0xFC。前6个SPI位为高,时长6×125ns=750ns;后2个SPI位为低,时长2×125ns=250ns。
换算规则非常简单:WS2812的一个数据字节有8个bit,每个bit扩展成1个SPI字节,所以一个颜色字节需要8个SPI字节来承载。比如要发送GRB颜色数据0x2A,先得把每个bit拆开,bit7为0就写0xE0,bit6为1就写0xFC,依次类推。
在实际项目里,我经常先把0xE0和0xFC的定义写成宏,方便后续微调:
#define WS2812_BIT_0 0xE0 #define WS2812_BIT_1 0xFC这样即使换成一款时序略不同的芯片,比如SK6812,只需要改这两个宏就可以了。
2.3 DMA为什么省CPU
数据打包完之后,如果还用CPU一个字节一个字节往SPI数据寄存器里写,虽然比GPIO翻转好了不少,但CPU依然被占用。DMA登场后,整个流程变成这样:
- CPU先把整帧灯带数据打包成SPI字节流,放进一块RAM缓冲区。
- CPU配置DMA,让DMA把缓冲区的数据搬运到SPI发送数据寄存器。
- DMA搬运完所有字节后,触发中断通知CPU。
- CPU可以在这段时间里做别的,比如计算下一帧动画、处理按键、刷新屏幕。
这个流程对MCU的负担极低。我用STM32F103测试过,驱动300颗灯珠、30fps刷新率,CPU占用率大概不到10%,大部分开销还是在打包数据和动画计算上,发送本身几乎是零成本。
SPI和IIC的区别在这里也有一个直观体现:IIC是半双工、带应答、速度上限偏低,而SPI是全双工、时序完全由主机掌控,主模式下发数据可以做到非常高的速率和极低的延迟抖动,特别适合这种“伪装成SPI设备”的协议模拟。
3. 代码落地:完整驱动流程与核心配置
3.1 数据映射表的设计
我实际项目中用的打包函数长这样:
void ws2812_frame_pack(uint8_t *dst, const uint8_t *grb, uint16_t led_num) { for (uint16_t i = 0; i < led_num; i++) { // WS2812 数据格式为 GRB,和常见的 RGB 顺序不一样 for (int bit = 7; bit >= 0; bit--) { *dst++ = (grb[i * 3 + 0] & (0x01 << bit)) ? WS2812_BIT_1 : WS2812_BIT_0; } for (int bit = 7; bit >= 0; bit--) { *dst++ = (grb[i * 3 + 1] & (0x01 << bit)) ? WS2812_BIT_1 : WS2812_BIT_0; } for (int bit = 7; bit >= 0; bit--) { *dst++ = (grb[i * 3 + 2] & (0x01 << bit)) ? WS2812_BIT_1 : WS2812_BIT_0; } } // 复位帧:连续50us以上的低电平,我用64个0x00字节,约64us,留足余量 for (int i = 0; i < RESET_BYTES; i++) { *dst++ = 0x00; } }代码本身不复杂,但有几个细节值得说。
第一,颜色顺序一定是GRB,不是RGB。无数人第一次点亮发现“红色和绿色互换了”,就是在这里栽的跟头。第二,数据位顺序要先发高位bit7,发送方向用(0x01 << bit)从高位向低位扫,正好和SPI MSB先行的特性吻合。第三,复位帧的0x00字节不能省。有些SPI外设在字节发送完成后,MOSI引脚会保持最后一笔的电平状态,如果不主动补一段低电平数据,最后一颗灯的复位可能不完整,表现就是灯带尾部颜色异常。补上64字节0x00后,这个坑就彻底避开了。
3.2 SPI和DMA的初始化配置
以STM32 HAL库为例,SPI的初始化有几个关键项:
hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; // 只用MOSI,MISO可以不接 hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; // 72MHz/8=9MHz左右,接近8MHz hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;这里有一个可能踩坑的点:SPI_BAUDRATEPRESCALER_8在72MHz时钟下是9MHz,不是正好8MHz,算下来每个SPI位周期约111ns,比理论值略短。实际测试中WS2812的时序容差可以接受,但如果追求精确,可以用定时器把SPI外设时钟配到合适的频率,或者换用带分数分频的MCU平台。对于大多数项目,9MHz照样能稳定驱动,没必要过分纠结。
DMA配置上,重点是把DMA通道请求连到SPI1_TX:
__HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx); hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.Mode = DMA_NORMAL; hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;Mode用DMA_NORMAL而不是循环模式。理由是灯带数据是分帧发送的,每帧之间需要一个复位帧,循环模式会不停发送,反而破坏了帧边界。
3.3 发送帧与复位帧的组装
启动发送的接口很简单:
HAL_SPI_Transmit_DMA(&hspi1, spi_buffer, spi_buffer_len);发送完成后,在HAL_SPI_TxCpltCallback回调里做下一帧的准备:
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { ws2812_frame_ready = 1; } }主循环里检测到ws2812_frame_ready,就打包下一帧,再次启动DMA发送。这个流程跑起来之后,灯带刷新完全由DMA驱动,CPU只需要在回调里置个标志位就行。
有一个实际中很容易被忽略的问题:HAL的TxCpltCallback是在DMA搬运完所有数据、SPI外设的TXFIFO清空后触发的,但它不保证MOSI引脚上最后一个bit已经完全移出。最早我写驱动时,在回调里立刻拉低电平或者切换SPI用途,结果最后一颗灯总是偶发闪一下。解决办法也很简单:回调里置标志后,主循环再等一下SPI的BSY标志清零,或者干脆在帧尾多塞几十个0x00,让复位帧兜底。
4. 实操中的坑:供电、时序调校与常见故障排查
4.1 硬件接线与供电
软件写得再漂亮,硬件供电不行也白搭。WS2812全白最大亮度下单颗灯珠的电流约60mA,这是个巨大的数字。100颗灯全白要6A,500颗就要30A。很多人灯带一闪一灭、颜色随机跳变,根本不是程序问题,就是电源带不动。
建议按峰值电流的1.2到1.5倍选电源。同时注意,数据地必须跟电源地、MCU地共地,不共地会导致信号参考电平整条漂移,灯珠表现就是乱闪或者干脆不亮。
另外,3.3V的MCU直接驱动WS2812的数据脚,在灯带长度不超过1米、线缆不太长的情况下通常没问题,因为WS2812的逻辑高电平阈值大概在2.5V左右。但如果你用的是较长线缆或者多块板子级联,最好加一个电平转换缓冲芯片,比如74HCT245,把3.3V信号转成5V逻辑。我见过不少项目“电压量出来正常,灯就是不亮”,最后都是卡在信号驱动能力不足上。
4.2 常见症状排查速查表
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
| 整条灯带完全无反应 | 供电、接线、DMA未启动 | 先测DIN静态电压,再查DMA配置 |
| 第一颗亮,后面的不亮 | 复位帧太短或数据包长度不对 | 检查RESET_BYTES是否足够、spi_buffer_len是否算对 |
| 颜色顺序错乱 | GRB和RGB搞混 | 检查打包顺序 |
| 亮度偏低、颜色偏暗 | 时序偏差、供电电压偏低 | 用示波器抓高电平时间,微调WS2812_BIT_0/1 |
| 灯珠随机闪烁 | 供电不足、DMA缓冲区被竞争 | 换大电源,检查是否在DMA发送时改了缓冲区 |
| 某一颗之后全部错乱 | 数据量没对上 | 单个灯珠一定是24bit×8,检查循环边界 |
4.3 几个值得一提的调优技巧
如果灯珠数量很大,缓冲区内存会成为瓶颈。100颗灯珠就要2400字节的SPI缓冲区,500颗就是12000字节,对RAM吃紧的MCU很有压力。我一般用双缓冲乒乓结构:一块缓冲区在DMA发送,另一块在CPU并行打包下一帧,既省了等待时间,又避免了DMA发送期间缓冲区被改写的问题。
如果同一路SPI还要挂屏幕、SD卡之类的设备,建议用硬件片选分开管理。WS2812没有片选脚,但可以通过软件把灯带数据线和其它外设分时复用,只要保证在灯带复位帧内切回其它设备即可。这里顺带说一句SPI硬件片选和软件片选的区别:硬件片选由外设自动拉低拉高,适合严格时序要求的从设备;软件片选更灵活,但必须自己掌握切换时机。
平台选择上也有讲究。如果你用的是ESP32,官方和社区更推荐RMT外设控制WS2812,因为RMT专为脉冲时序设计,驱动代码更简洁,不需要做SPI映射转换。SPI+DMA这套方案主要价值在STM32、GD32这些没有RMT外设的平台上,既能保持低CPU占用,又不需要额外硬件。
最后分享一个我自己的习惯:每次驱动代码写完,我会先把灯带单独接5V电源,不接数据线,确认整条灯带没有异常发热、静态电流正常之后,再接MCU的数据引脚。这个小动作帮我排掉了一半以上的疑难杂症——因为硬件问题不排除干净,软件调起来永远像是在猜谜。
本文还有配套的精品资源,点击获取