☰
STM32 WS2812灯带驱动:SPI+DMA解决时序抖动
2026/10/26 18:42:30 网站建设 项目流程

简介:面向STM32嵌入式开发者与灯带效果调试人员的WS2812驱动工程,采用硬件SPI+DMA方式产生灯珠所需时序,可缓解GPIO翻转占用CPU的问题。工程移植了Adafruit_NeoPixel库函数,保留多种显示效果测试入口,只需在头文件配置灯珠数量、将控制引脚接到PA7即可运行,适合学习SPI+DMA时序构造、NeoPixel效果组织及Keil工程移植。压缩包共103个文件,约311KB,包含44个.h头文件、43个.c源文件及8个.s启动/汇编文件,另有Keil工程配置、HEX固件和说明文本,便于直接打开编译与对照修改。已有6818人学习下载,可作为WS2812驱动入门、课程实验与灯效二次开发的参考。

1. WS2812 驱动为什么要用 SPI+DMA 重写:一次 8 毫秒的刷新,别让 CPU 干等

去年帮人调一条 120 灯的 WS2812 灯带,原本是 GPIO 翻转加__NOP延时,静态彩虹效果跑得挺稳,一加上串口打印和按键扫描,颜色就开始发飘,偶尔整条灯闪一下白。示波器挂上去才发现,只要有一个中断插进 bit 流里,归零码的高电平就被拉长,后面的像素全被误判。后来换成 SPI+DMA 方案:SPI 按固定波特率把预先编码好的比特流吐出去,DMA 只管搬内存,CPU 在开头启动一次、结束时收一个回调,中间完全不参与。灯带稳定了,主循环也空出来了。这套驱动适合正在用 STM32 做氛围灯、像素屏、灯带同步的开发者,尤其适合已经被时序问题折磨过、不想再靠delay_us碰运气的人。

2. 时序换算:把 WS2812 的 0/1 码翻译成 SPI 波形

2.1 归零码的容差比想象中窄

WS2812 是单线归零码协议:一根数据线,每个像素 24 bit(顺序是 GRB 不是 RGB),高位先出,标称 800kHz,也就是一位 1.25µs。0 码靠一个短高电平加长低电平表示,1 码靠长高电平加短低电平表示,收完 24 bit 之后总线拉低超过 50µs,芯片才把内部移位寄存器里的数据锁存到 PWM 输出。

关键在于容差。厂商标称 T0H 0.35µs、T1H 0.7µs,允许偏差大约 ±150ns。150ns 在 72MHz 的 STM32 上差不多是 11 个指令周期。这意味着任何一次进中断保存现场、任何一次 Flash 等待周期抖动,都可能让某一位的高电平超出窗口。血泪经验是:用for循环空转做延时,编译器一开-O2,循环被优化掉或者重排,时序直接崩;改用DWT或者定时器做延时能好一些,但只要中断优先级没排好,依然是不可靠的。

参数WS2812 典型值允许范围(约)含义
T0H0.35µs0.20 ~ 0.50µs0 码高电平
T0L0.80µs0.65 ~ 0.95µs0 码低电平
T1H0.70µs0.55 ~ 0.85µs1 码高电平
T1L0.60µs0.45 ~ 0.75µs1 码低电平
RES≥50µs无上限锁存低电平

提示:不同批次、不同封装的灯珠容差不一样,老款 WS2812 的复位低电平要求 280µs 以上,WS2812B 之后才降到 50µs。手上灯带来源不明时,复位段按 280µs 给,代价只是刷新率低一点。

2.2 SPI 位宽怎么选:3 个 SPI 位表示 1 个 WS 位

思路很直接:把 WS2812 的每一位,用 SPI 的多个位去「拼」出来。选 3 个 SPI 位表示 1 个 WS 位,0 码编成100,1 码编成110。这样 SPI 的比特率就是 WS2812 位率的 3 倍,即 2.4MHz,一个 SPI 位 417ns,对应 0 码高电平 417ns、1 码高电平 833ns,正好落在容差窗口中间。

/* SPI 时钟由 APB 总线分频得到,目标是接近 2.4MHz。 APB2 = 72MHz 时,分频 32 得到 2.25MHz,位周期 444ns, 0 码高电平 444ns、1 码高电平 888ns,仍在容差内。 */ hspi2.Instance = SPI2; hspi2.Init.Mode = SPI_MODE_MASTER; hspi2.Init.Direction = SPI_DIRECTION_1LINE; /* 只用 MOSI */ hspi2.Init.DataSize = SPI_DATASIZE_8BIT; /* 8 位一装,便于编码 */ hspi2.Init.CLKPolarity = SPI_POLARITY_LOW; /* 空闲低电平 */ hspi2.Init.CLKPhase = SPI_PHASE_1EDGE; hspi2.Init.NSS = SPI_NSS_SOFT; hspi2.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_32; hspi2.Init.FirstBit = SPI_FIRSTBIT_MSB; /* 必须高位先出 */ hspi2.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;

这里几个参数不能随手改。SPI_DIRECTION_1LINE是单线发送模式,只占 MOSI 一根线,省掉 MISO 引脚,也不会因为接收逻辑干扰时序;SPI_POLARITY_LOW保证空闲时数据线是低电平,符合归零码的「低电平为基准」;SPI_FIRSTBIT_MSB决定了编码时字节内比特的顺序,配错的话颜色会整体错位成一团噪声。

换个主频就要重新算分频。2.4MHz 附近没有整数分频时,宁可往下取(比如 2.25MHz),也不要用 3MHz 以上去逼近上限,因为高电平一旦偏长,1 码会更容易被误判成复位。

也可以用 4 个 SPI 位表示 1 位,SPI 跑到 3.2MHz,每颗灯的缓冲区会变成 12 字节,占用上升、时序更紧,好处是编码查表更简单。我一般还是用 3 位方案,字节数少、对 DMA 更友好。

2.3 缓冲区怎么算:一只灯 9 个字节

24 个 WS 位乘 3 个 SPI 位等于 72 bit,正好 9 个字节。所以一只灯在 DMA 缓冲区里占 9 字节,是逻辑像素数据(3 字节)的三倍。120 只灯就是 1080 字节,再加上复位段。

#define WS2812_LED_NUM 120u #define WS2812_BYTES_PER_LED 9u /* 24bit * 3 / 8 */ #define WS2812_RESET_BYTES 64u /* 64*3.56us ≈ 228us */ #define WS2812_DMA_LEN (WS2812_LED_NUM * WS2812_BYTES_PER_LED + WS2812_RESET_BYTES) static uint8_t g_pixel[WS2812_LED_NUM * 3]; /* 逻辑像素,GRB */ static uint8_t g_dma_buf[WS2812_DMA_LEN]; /* 编码后的 SPI 流 */

复位段用全 0 字节实现,因为 SPI 发 0x00 就是 8 个位周期的低电平,连续发 64 个字节,在 2.25MHz 下是 228µs,安全覆盖老款 280µs 以内的需求。用宏把长度算出来,而不是在运行时算,是为了让sizeof和 DMA 长度在编译期就对齐,少一处手写数字就少一次翻车。

顺便对比一下另一条常见路线:定时器 PWM+DMA。它靠每个比特一个 CCR 值来产生波形,一个 WS 位要 2 字节 CCR,一只灯 48 字节,是 SPI 方案的 5 倍多;好处是不依赖 SPI 外设,坏处是长灯带时内存吃得厉害,而且 PWM 输出的高低电平比例还得单独调。灯带超过 300 颗时,SPI 方案的内存优势就非常明显了。

方案每灯缓冲区占用外设时序精度来源
GPIO 翻转3 字节无CPU 指令周期,易被中断打断
TIM PWM + DMA48 字节1 个定时器 + DMA定时器分辨率
SPI + DMA(3 位编码)9 字节1 个 SPI + DMASPI 波特率,硬件保证

3. 模块拆解:一份像素数据从 GRB 数组走到 MOSI 引脚

3.1 目录结构与三层职责

这套驱动我习惯拆成三层,硬件层只认 HAL 句柄,编码层是纯函数不碰寄存器,应用层负责效果。分层的好处是换 MCU 时只改第一层,换灯珠型号(比如从 WS2812B 换到 SK6812 的 RGBW)时只改编码层的字节顺序。

User/ ├── Drivers/ │ └── ws2812/ │ ├── ws2812.h # 对外接口与结构体定义 │ ├── ws2812.c # 编码 + 传输 + 回调 │ └── ws2812_cfg.h # LED 数量、复位长度、SPI 句柄绑定 └── App/ ├── led_effect.c # 彩虹、呼吸、流水等效果 └── main.c # 初始化与主循环调度
文件职责改动频率
ws2812_cfg.h灯珠数量、SPI 句柄、复位字节数换硬件时改
ws2812.c位编码、整帧打包、DMA 启动、完成回调很少改
led_effect.c颜色计算与动画推进经常改
main.c初始化顺序、帧率控制偶尔改

把「编码」和「效果」分开有个实际好处:调效果的时候不用反复烧录验证时序,编码层一旦跑通就不用再动,出问题也好定位是哪一层的锅。

3.2 编码层:一次把 8 个 WS 位压成 3 个字节

编码的核心是把一个 GRB 字节展开成 24 个 SPI 位,再塞进 3 个字节里。用移位累加比查表更省 Flash,也比逐位写数组更快。

/* 把一个逻辑字节展开成 3 个 SPI 字节。 dst 需要至少 3 字节空间;bit 顺序为高位先出,与 SPI 配置一致。 */ static inline void ws2812_encode_byte(uint8_t *dst, uint8_t val) { uint32_t bits = 0; for (int i = 7; i >= 0; --i) { bits <<= 3; /* 给新的一位腾出 3 bit */ /* 0 码 -> 100(0x4),1 码 -> 110(0x6) */ bits |= ((val >> i) & 0x01u) ? 0x6u : 0x4u; } dst[0] = (uint8_t)(bits >> 16); dst[1] = (uint8_t)(bits >> 8); dst[2] = (uint8_t)(bits); }

循环 8 次,每轮左移 3 位再或上编码值,最后bits里是 24 个有效位,从高到低正好对应时间轴上的先后顺序。0x4是二进制100,0x6是110,和 2.2 节选的编码表一致。这里用inline是有意的:单只灯调用 3 次,120 只灯就是 360 次,函数调用开销不可忽略,内联之后编译器还能把移位进一步优化。

整帧打包时唯一的坑是字节顺序。WS2812B 走的是 GRB,SK6812 走 GRBW,某些兼容型号走 RGB,写错了颜色会整体错位——红色显示成绿色是最常见的症状。

/* 把逻辑像素缓冲编码进 DMA 缓冲,并补齐复位段。 调用前请确保上一帧传输已完成,否则会出现半帧撕裂。 */ void ws2812_pack(ws2812_t *s) { uint8_t *p = s->dma_buf; for (uint16_t i = 0; i < s->leds; ++i) { ws2812_encode_byte(p + 0, s->pixel[i * 3 + 1]); /* G */ ws2812_encode_byte(p + 3, s->pixel[i * 3 + 0]); /* R */ ws2812_encode_byte(p + 6, s->pixel[i * 3 + 2]); /* B */ p += WS2812_BYTES_PER_LED; } memset(p, 0x00, WS2812_RESET_BYTES); /* 复位低电平 */ }

p每次前进 9 字节,三个颜色字节各占 3 字节,顺序被硬编码成 G、R、B。如果要做 RGBW 灯珠,把每灯的步长改成 12、追加一个 W 通道就行,其余逻辑不用动。

3.3 传输层:DMA 启动、忙标志与完成回调

传输层的状态机只有两个状态:空闲和忙。所有对外接口在忙的时候直接返回HAL_BUSY,绝不允许两帧数据同时往 SPI 里塞。

typedef struct { SPI_HandleTypeDef *hspi; /* 绑定的 SPI 句柄 */ uint8_t *pixel; /* 逻辑像素,长度 leds*3 */ uint8_t *dma_buf; /* 编码缓冲,长度 leds*9+RESET */ uint16_t leds; uint16_t dma_len; volatile uint8_t busy; /* 1 表示 DMA 正在发送 */ } ws2812_t; HAL_StatusTypeDef ws2812_show(ws2812_t *s) { if (s->busy) { return HAL_BUSY; /* 上一帧没发完,直接丢弃本次请求 */ } ws2812_pack(s); /* 编码到 dma_buf */ s->busy = 1; #if defined(__DCACHE_PRESENT) && (__DCACHE_PRESENT == 1) /* Cortex-M7 带 D-Cache,必须写回,否则 DMA 可能读到旧数据 */ SCB_CleanDCache_by_Addr((uint32_t *)s->dma_buf, s->dma_len); #endif return HAL_SPI_Transmit_DMA(s->hspi, s->dma_buf, s->dma_len); }

busy声明成volatile,因为它会在中断里被清零、在主循环里被读取,不加编译器可能把判断优化成死循环。SCB_CleanDCache_by_Addr那一段是给 H7、F7 这类带数据缓存的内核准备的,F1/F4 没有 D-Cache,条件编译直接跳过。

完成回调里只做一件事:清标志。

/* SPI 发送完成回调,HAL 在 DMA 传输结束中断里调用 */ void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI2) { g_ws2812.busy = 0; /* 不要在这里直接调 ws2812_show 刷下一帧: 编码耗时几百微秒,会拉长中断,也会和主循环抢缓冲 */ } }

很多示例代码喜欢在回调里立刻刷新下一帧,看起来省事,实际是把编码工作搬进了中断上下文。120 只灯的编码要循环 360 次,在 168MHz 上大约几百微秒,这期间其他中断全被压住,串口接收都可能丢字节。正确做法是回调只负责解锁,下一帧由主循环按自己的节奏发起。

3.4 双缓冲:为什么长灯带一定要考虑它

当主循环一边在改pixel数组、DMA 一边在搬dma_buf时,只要ws2812_pack不在传输期间被调用,就不会撕裂。但有一种场景会踩到:动画效果计算和发包在同一个循环里,效果算到一半去改了pixel,而上一帧还没发完——这帧数据就作废了。

稳妥的写法是两份dma_buf交替使用,一份正在被 DMA 读,另一份可以随便编码。代价是内存翻倍,120 只灯多出 1KB 左右,在 F103C8 这种 20KB RAM 的片子上要算清楚;灯珠超过 500 颗时,双缓冲基本是必需品。

4. 从空工程到点亮:CubeMX 配置与首帧验证

4.1 SPI 与 DMA 的具体配置项

用 CubeMX 生成骨架时,逐项对照下表,配错一项就要多花半天查波形。

配置项取值说明
ModeTransmit Only Master不需要接收,避免 MISO 干扰
Data Size8 Bit编码按字节打包
First BitMSB First决定字节内比特顺序
CPOL / CPHALow / 1Edge空闲低电平,第一个边沿采样
NSSSoftware单主机不需要片选
Baud Rate Prescaler得 2.25~2.4MHz 的分频见 2.2 节
DMA RequestSPIx_TX必须是发送通道
DMA ModeNormal一帧一次,不用循环模式
DMA PriorityHigh 或 Very High降低被其他 DMA 打断的概率
DMA 数据宽度Byte / Byte两边都是 8 位,增量都开

GPIO 这块还有个容易忽略的点:SPI 初始化之前,MOSI 引脚处于浮空输入状态,上电瞬间可能被灯带第一颗芯片当成有效数据读进去,表现为「第一次上电总有一颗灯颜色不对」。在 MOSI 上加一个 4.7k~10k 的下拉电阻,或者在初始化时先把引脚配成推挽输出拉低,再切到复用功能,能规避掉大部分随机首帧。

4.2 初始化顺序和第一帧

顺序错了会出现「DMA 启动了但没波形」这类问题,按下面这段走。

int main(void) { HAL_Init(); SystemClock_Config(); /* 先定主频,SPI 分频才有意义 */ MX_GPIO_Init(); MX_DMA_Init(); /* 必须在 SPI 之前,DMA 时钟要先开 */ MX_SPI2_Init(); static ws2812_t strip = { .hspi = &hspi2, .pixel = g_pixel, .dma_buf = g_dma_buf, .leds = WS2812_LED_NUM, .dma_len = WS2812_DMA_LEN, .busy = 0, }; /* 首帧:先全灭,清掉上电时芯片里的随机内容 */ memset(g_pixel, 0x00, sizeof(g_pixel)); while (ws2812_show(&strip) != HAL_OK) { HAL_Delay(1); } /* 再点亮第一颗为红色,验证字节顺序 */ while (strip.busy) { } g_pixel[0] = 0x00; /* R */ g_pixel[1] = 0x20; /* G */ g_pixel[2] = 0x00; /* B */ while (1) { if (!strip.busy) { ws2812_show(&strip); } } }

先全灭再点单颗,是为了把「字节顺序错」和「时序错」区分开。如果全灭能成功、第一颗亮红色,说明链路是通的,剩下就是效果层的事;如果全灭之后灯带是花的,八成是编码或者 SPI 配置有问题,别急着调效果代码。

MX_DMA_Init放在MX_SPI2_Init前面这一点特别容易漏,CubeMX 生成的代码里顺序是对的,手动合并工程时经常被打乱,结果就是HAL_SPI_Transmit_DMA返回HAL_ERROR,但没有任何报错提示,纯黑匣子。

4.3 常用效果与帧率控制

灯带跑起来之后,帧率是第一个要量的指标。120 只灯的数据量是 1080 字节加 64 字节复位,在 2.25MHz 下大约 4.1ms 发完,理论刷新率上限 240fps 左右;300 只灯约 9.7ms,上限 100fps。做呼吸灯、流水灯这类效果,30~60fps 就够了,留出的时间可以干别的。

/* 简单的亮度缩放 + 近似 gamma,暗部过渡会顺滑一些 */ static inline uint8_t gamma_scale(uint8_t v, uint8_t brightness) { uint32_t x = (uint32_t)v * brightness / 255u; /* 先按亮度缩放 */ return (uint8_t)((x * x) / 255u); /* 近似平方曲线 */ } /* 主循环里按固定节拍推进,不追着 DMA 跑 */ uint32_t next_tick = 0; while (1) { uint32_t now = HAL_GetTick(); if (now >= next_tick && !strip.busy) { next_tick = now + 20; /* 50fps */ led_effect_step(g_pixel, WS2812_LED_NUM); /* 只改 pixel,不碰 dma_buf */ ws2812_show(&strip); } }

gamma_scale用平方近似 gamma 2.0,比查表省 Flash,但暗部会丢阶——亮度打到 10 以下时,颜色会显得断层。对画质有要求的话换成 256 字节的 gamma 2.2 查表,再配一点时间抖动(dithering),肉眼观感会好很多。这也是低亮度下颜色发灰、发绿的根本原因,不是灯珠质量问题。

4.4 用逻辑分析仪做最小验证

手上没有示波器也能查:逻辑分析仪接 MOSI 和 GND,采样率开到 4MHz 以上,抓一帧看三个数——0 码高电平宽度、1 码高电平宽度、帧末低电平长度。三个数落在容差窗口内,剩下的问题就都在软件层;如果 0 码和 1 码的高电平区分不出来,先查 SPI 分频是不是配错了。

我一般会先在ws2812_show里塞一个临时测试帧,只点第 0 颗和第 N-1 颗,中间全灭。这两颗能同时正确点亮,说明整条链路的时序余量够;只有第一颗正常、最后一颗偏色,那多半是供电压降,不是代码问题。

5. 避坑与排查:五种「灯不亮、闪一下、尾巴变色」的现场处理

5.1 波形与时序类

第一种:整条灯不亮,SPI 有波形但灯毫无反应。现象是逻辑分析仪能看到数据在发,但灯珠一点动静都没有。原因通常是 MOSI 接错了引脚,或者 SPI 配成了 16 位数据宽度——16 位模式下字节序会被硬件重排,编码出来的100序列被打乱,灯珠全当噪声丢弃。解决办法是核对 CubeMX 里 MOSI 的复用引脚编号,确认DataSize是SPI_DATASIZE_8BIT,并且FirstBit是 MSB。

第二种:只有第一颗灯正常,后面一片乱码或颜色错位。现象是第 0 颗颜色对,第 1 颗开始偏色,越往后越乱。原因是复位段太短,芯片没来得及锁存就开始收下一帧,数据被顺延到了下一颗。解决办法是把WS2812_RESET_BYTES加大到能覆盖 280µs,同时确认dma_len包含了复位段长度——只算像素部分是最常见的漏项。

第三种:静态效果稳定,一加中断就开始闪。现象是跑纯色没事,一旦开了串口接收中断或者定时器中断,灯带偶尔抖一下。原因多半不是 DMA 被打断,而是主循环在busy还没清零时就改了pixel,或者回调里直接刷新下一帧导致重入。解决办法是回调里只清标志,发包统一走主循环,并且在ws2812_show开头做busy检查。

5.2 内存与并发类

第四种:H7/F7 上偶发花屏,重启后有时正常。现象是同一份代码在 F4 上跑得好好的,换到带 D-Cache 的片子上,灯带偶尔出现一段随机颜色。原因是 DMA 读的是物理内存,CPU 写的是缓存,缓存没写回时 DMA 拿到的是旧数据。解决办法是在启动 DMA 前调用SCB_CleanDCache_by_Addr,或者把dma_buf放到链接脚本里定义的非缓存段(MPU 配置成 write-through 也可以)。

第五种:长灯带尾巴发暗、末端颜色偏。现象是前 100 颗颜色饱满,最后几十颗明显偏暗偏红。这个大概率不是代码问题:5V 灯带每颗灯全白时约 60mA,300 颗就是 18A,走线压降会让末端电压掉到 4V 以下,灯珠欠压工作。解决办法是电源两端注入、加粗地线,或者在软件上给末尾像素做一个轻微的亮度补偿。先量电压再改代码,别一上来就怀疑时序。

注意:还有一种隐蔽情况是 DMA 通道和别的外设共用同一个 DMA 控制器,优先级配低了会被别的传输插队。表现是灯带刷新偶尔延迟一整帧,看起来像「动画卡了一下」。把 SPI_TX 的 DMA 优先级提到 High 以上基本能解决。

6. 二次开发:把这套驱动收成一个能带走的组件

拆过几次之后,我现在的习惯是把它做成「一份配置 + 两个函数」的形态,换项目直接拷目录。配置全塞进ws2812_cfg.h,用宏把灯珠数量、颜色顺序、复位长度、SPI 句柄都参数化,业务代码只调ws2812_set_pixel和ws2812_show,不碰缓冲区。

改造点做法适用场景
换灯珠型号改WS2812_COLOR_ORDER宏和每灯字节数WS2812B / SK6812 / RGBW
提高刷新率增加灯珠数量时改用双缓冲,或换 4 位编码500 颗以上
多灯带并行每条灯带一个ws2812_t实例,共享一个 SPI 时用状态机串行发矩阵屏分行驱动
降低 CPU 占用效果层改成增量计算,只改变化的像素高帧率动画

验证方法我固定成三步:先用单颗红灯验证字节顺序,再用「首尾两颗 + 中间全灭」验证时序余量,最后跑一个满屏白色的最坏情况,用电流表和逻辑分析仪同时看压降和复位长度。三步都过,这套参数就可以固化了。

有个具体的坑值得记下来:多灯带共用一个 SPI 时,别在HAL_SPI_TxCpltCallback里直接切到下一路,因为 SPI 外设的State在回调触发时还没完全回到 READY,紧接着调用HAL_SPI_Transmit_DMA会返回HAL_BUSY。稳妥做法是在回调里置一个「待发送队列」的索引,由主循环取出来发。从那以后我每次写多路灯带驱动,都会先把这个队列跑通再写效果代码,省下来的调试时间比写队列本身多得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询