简介这份资源面向使用STM32驱动WS2812灯珠的嵌入式开发者提供基于硬件SPIDMA的完整驱动程序解决传统GPIO模拟时序占用CPU、刷新率低的问题。包内移植了Adafruit_NeoPixel库函数可实现多种灯珠显示效果main函数中保留了各类样式的测试函数只需在头文件中配置灯珠数量、将控制引脚接到PA7即可运行适合具备一定STM32基础、希望快速实现灯带控制的读者。资源共103个文件以44个h头文件与43个c源文件为主另含8个汇编文件及Keil工程、hex烧录文件、批处理脚本等压缩包约311KB目录结构清晰便于按模块查阅与移植。目前已有6817人学习下载测试中未发现明显bug作者也欢迎使用者反馈问题。借助这份驱动读者可直接获得SPIDMA时序实现、NeoPixel效果函数与现成测试用例省去从零调试灯珠协议的环节快速搭建灯带显示项目。1. 从一次灯带翻车说起为什么我最后选了 SPIDMA 这条路去年帮朋友做一个桌面氛围灯灯珠用的是 WS2812B一条 144 颗。最开始我用的是 GPIO 翻转加__NOP()延时那种最朴素的写法单颗点亮没问题颜色也对但只要把整条灯带刷一遍颜色就开始乱闪尾部的灯珠偶尔还会自己变成白色。示波器一挂上去就明白了中断一进来时序直接被拉长WS2812 的 0 码和 1 码全靠高电平持续时间区分差个几百纳秒它就认错。后来换成 SPIDMA 的方案CPU 只负责把颜色数据编码好丢进缓冲区剩下的波形由 SPI 外设配合 DMA 自动搬出去全程不占 CPU、不怕中断打断144 颗灯珠刷起来稳得一批。这份 WS2812 驱动程序SPIDMA就是围绕这个思路写的核心是把每 bit 的颜色数据用 SPI 的若干位去模拟 WS2812 的高低电平再靠 DMA 把整块缓冲区连续推出去。它适合正在用 STM32 驱动灯带、被时序问题折磨过的嵌入式开发者也适合想搞明白「外设模拟时序」这套玩法的同学。2. 原理拆解SPI 怎么把 0 和 1 编出来2.1 WS2812 的时序到底卡在哪WS2812 是单总线归零码协议每个 bit 靠高电平的持续时间来区分。常见的判定标准是高电平约 0.35µs 加低电平约 0.8µs 表示逻辑 0高电平约 0.7µs 加低电平约 0.6µs 表示逻辑 1整个 bit 周期大约 1.25µs。复位信号则是拉低超过 50µs。问题就出在这个「约」字上。它没有时钟线接收端完全靠自己的内部振荡器去采样所以对时序抖动非常敏感。你用 GPIO 加延时循环一旦有中断、有 RTOS 任务切换、有 Flash 等待周期波形就变形。我见过最玄学的一次代码逻辑完全没动只是把编译优化等级从-O0改成-O2灯带颜色就变了——因为延时循环被优化掉了。2.2 用 SPI 的位模式去「画」波形既然软件延时不可靠那就找一个能产生精确、稳定、不被打断的波形的外设。SPI 就是最合适的选择它有固定的时钟输出波形由硬件保证而且可以配 DMA。思路是这样的把 WS2812 的一个 bit 用 SPI 的 3 个 bit 来表示。假设 SPI 时钟配到 2.4MHz那每个 SPI bit 的时间就是 1/2.4M ≈ 0.4167µs。用 3 个 SPI bit 拼成一个 WS2812 bit总时长约 1.25µs正好对上。逻辑 0SPI 输出100即高电平 0.4167µs低电平 0.833µs逻辑 1SPI 输出110即高电平 0.833µs低电平 0.4167µs这两个组合的高电平时间分别落在 WS2812 的 0 码和 1 码判定窗口里余量足够。一个字节 8 bit编码后就是 24 个 SPI bit也就是 3 个字节。一颗灯珠 24 bit 颜色GRB 各 8 bit编码后就是 72 个 SPI bit即 9 个字节。2.3 编码表与内存布局为了编码快通常预先算好一张 256 项的查找表把每个字节展开成 3 个字节的 SPI 数据。这样刷灯的时候只需要查表加拷贝不用现场做位运算。// 每个字节 - 3 个 SPI 字节的编码表 // 逻辑0用0b100表示逻辑1用0b110表示 // 一个字节8位编码后24位正好3字节 const uint8_t ws2812_spi_table[256][3] { {0x88, 0x88, 0x88}, // 0x00 - 全0 {0x8E, 0x88, 0x88}, // 0x01 - 最低位为1 {0xE8, 0x88, 0x88}, // 0x02 // ... 中间省略实际由脚本生成 {0xEE, 0xEE, 0xEE}, // 0xFF - 全1 };这里0x88展开成二进制是10001000每 3 位一组就是100 010 00高位对齐后就是连续的 0 码。0xEE是11101110对应110 110 11就是连续的 1 码。这张表一般用脚本生成不用手写。提示SPI 的位序要设成 MSB FirstCPOL/CPHA 选模式 0这样空闲时时钟为低数据在上升沿被采样输出波形干净。3. 工程落地从 CubeMX 配置到刷灯函数3.1 外设配置与 DMA 通道以 STM32 为例SPI 要配成主机发送模式只发不收所以 MOSI 接灯带 DINSCK 和 MISO 不用管。时钟分频要算准目标是 2.4MHz 左右。假设 APB2 是 72MHz分频系数取 32 得到 2.25MHz或者取 16 得到 4.5MHz 再配合 6 位编码都能用关键是编码位数和时钟匹配。DMA 配成 Memory-to-Peripheral方向是内存到 SPI 数据寄存器模式选 Normal不是 Circular因为每次刷灯的数据长度是固定的刷完就停。数据宽度方面内存和 peripheral 都设成 Byte这样缓冲区可以直接用uint8_t数组。// SPIDMA 初始化片段以 HAL 库为例 hdma_spi1_tx.Instance DMA1_Channel3; hdma_spi1_tx.Init.Direction DMA_MEMORY_TO_PERIPH; 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; hdma_spi1_tx.Init.Mode DMA_NORMAL; hdma_spi1_tx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_spi1_tx); __HAL_LINKDMA(hspi1, hdmatx, hdma_spi1_tx);MemInc必须使能否则 DMA 会一直搬同一个字节。Priority给高一点避免被其他 DMA 请求挤掉导致波形断裂。3.2 缓冲区大小与复位信号缓冲区大小 灯珠数 × 9 字节。144 颗灯珠就是 1296 字节。另外 WS2812 要求每帧结束后拉低至少 50µs 作为复位SPI 发完数据后 MOSI 会保持最后一位的状态所以要在缓冲区末尾再补一段全 0 的数据长度按 SPI 速率算。2.4MHz 下50µs 对应 120 个 SPI bit也就是 15 个字节保险起见补 20 到 30 个字节。#define LED_NUM 144 #define BYTES_PER_LED 9 #define RESET_BYTES 30 #define BUF_SIZE (LED_NUM * BYTES_PER_LED RESET_BYTES) static uint8_t led_buffer[BUF_SIZE];复位段用memset清零即可因为 0 码编码后是0x88不是0x00所以复位段要单独置 0不能混进编码区。3.3 刷灯函数与颜色写入刷灯分两步先把颜色数据编码进缓冲区再启动 DMA 发送。颜色顺序是 GRB不是 RGB这个坑我踩过写反了红色变绿色。// 设置第 n 颗灯珠的颜色输入是 RGB void ws2812_set_color(uint16_t n, uint8_t r, uint8_t g, uint8_t b) { if (n LED_NUM) return; uint8_t *p led_buffer[n * BYTES_PER_LED]; // WS2812 顺序是 G R B memcpy(p, ws2812_spi_table[g], 3); memcpy(p 3, ws2812_spi_table[r], 3); memcpy(p 6, ws2812_spi_table[b], 3); } // 启动一次 DMA 传输把整条灯带刷出去 void ws2812_flush(void) { // 等待上一次传输完成避免覆盖 while (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY); HAL_SPI_Transmit_DMA(hspi1, led_buffer, BUF_SIZE); }ws2812_set_color只改缓冲区不触发发送这样可以先把整条灯带的颜色都设好再调一次ws2812_flush统一刷出去避免刷到一半灯带显示中间状态。ws2812_flush里的等待是为了防止连续调用时前一次 DMA 还没发完就启动下一次导致数据错乱。3.4 主循环里的调用节奏刷灯不需要每毫秒都刷人眼也看不出来。一般 30 到 60Hz 的刷新率就够了也就是每 16 到 33ms 刷一次。如果做呼吸灯、流水灯这类效果把颜色计算放在主循环刷灯按固定间隔调用。while (1) { // 计算每一颗灯珠的颜色 for (uint16_t i 0; i LED_NUM; i) { uint8_t v (i tick) % 255; ws2812_set_color(i, v, 0, 255 - v); } ws2812_flush(); HAL_Delay(20); // 约 50Hz 刷新 tick; }HAL_Delay会阻塞如果项目里有 RTOS换成osDelay或者用定时器触发刷灯更好。注意ws2812_flush是异步的HAL_Delay期间 DMA 正好把数据发完节奏刚好。4. 避坑与排查那些让我熬夜的时序问题4.1 灯带尾部随机亮白现象前几十颗颜色正常越往后越容易乱尾部偶尔整颗变白。 原因DMA 传输被更高优先级的中断打断SPI 时钟出现间隙WS2812 把间隙当成了复位信号后面的数据被当成新一帧的起始。 解决把 DMA 通道优先级调到最高同时检查有没有其他外设共用同一个 DMA 控制器。如果用了 RTOS刷灯期间关掉调度或者把刷灯任务优先级提到最高。4.2 颜色整体偏移一位现象第一颗灯珠不亮第二颗显示第一颗的颜色整体后移。 原因复位段的 0 字节被算进了编码区或者缓冲区起始地址偏移了一个字节。 解决确认led_buffer的前 9 个字节对应第 0 颗灯珠复位段在数组末尾。用调试器看缓冲区前几个字节应该是0x88开头而不是0x00。4.3 编译优化后颜色变了现象Debug 编译正常Release 编译颜色错乱。 原因编码表被优化或者volatile没加导致编译器认为缓冲区没变化。 解决编码表加const没问题但led_buffer如果被 DMA 访问要确保它不被优化掉。HAL 库的 DMA 函数内部会处理但自己写寄存器版本时要注意加volatile或者用__attribute__((section))固定地址。4.4 SPI 时钟算错导致全白现象所有灯珠都是白色或者颜色完全不对。 原因SPI 实际时钟和编码位数不匹配0 码和 1 码的高电平时间都超出了 WS2812 的判定窗口接收端全部按 1 处理。 解决用示波器量 SCK 频率确认是预期的 2.4MHz 左右。如果偏差超过 10%重新算分频系数。也可以临时把编码改成 4 位或 6 位看颜色是否恢复以此反推时钟问题。4.5 灯带第一颗总是闪一下现象上电瞬间第一颗灯珠闪一下白色之后正常。 原因上电时 MOSI 引脚状态不确定或者 SPI 初始化前引脚被拉高了一段时间被 WS2812 当成了数据。 解决在 SPI 初始化之前先把 MOSI 引脚配成推挽输出并拉低延时几毫秒再切到 SPI 复用功能。这样上电期间灯带看到的是持续低电平不会误触发。5. 二次开发把驱动改成你自己的形状这份驱动的结构很清晰ws2812_spi_table负责编码led_buffer负责数据ws2812_set_color和ws2812_flush负责接口。你要做二次开发基本就是围绕这三块动刀。先说编码表。如果你换了一款时序略有差异的灯珠比如某些兼容型号的 0 码高电平窗口更窄那就需要重新生成编码表。生成脚本的逻辑很简单遍历 0 到 255对每个字节的每一位如果是 0 就拼上100如果是 1 就拼上110最后把 24 位按 3 字节输出。改 SPI 时钟或者改编码位数时这张表要跟着重算不能直接复用。再说缓冲区。如果你要驱动上千颗灯珠led_buffer会很大STM32 内部 RAM 可能放不下。常见做法是把缓冲区放到外部 SDRAM或者用双缓冲加 DMA 半传输中断发一半的时候编码另一半。双缓冲的写法是把led_buffer拆成两块DMA 发第一块时 CPU 编码第二块发完切换。这样内存占用不变但刷新率能翻倍。最后说接口层。如果你用的是 FreeRTOS建议把ws2812_flush包成一个任务用队列接收颜色数据任务里统一刷灯。这样业务代码只管往队列里丢颜色不用关心 DMA 状态。我一般会加一个ws2812_show()的阻塞版本和一个ws2812_show_async()的非阻塞版本前者用于初始化阶段后者用于实时效果。还有一个技巧是颜色空间转换。WS2812 的亮度是线性的但人眼对亮度的感知是非线性的直接调 RGB 值做渐变会显得中间段跳变。我习惯在应用层做一次 gamma 校正把 0 到 255 的输入映射到一张校正表上再送给ws2812_set_color。这张表可以放在 Flash 里不占 RAM。// gamma 校正表示例2.2 次方近似 const uint8_t gamma_table[256] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // ... 中间省略实际由脚本生成 255 }; void ws2812_set_color_gamma(uint16_t n, uint8_t r, uint8_t g, uint8_t b) { ws2812_set_color(n, gamma_table[r], gamma_table[g], gamma_table[b]); }从那以后我每次调灯带效果都强制先用示波器量一遍 SCK 和 MOSI 的波形确认 0 码和 1 码的高电平时间落在窗口中间再往下写业务逻辑。这个习惯帮我省掉了至少三次通宵排查。希望这份驱动和这些踩坑记录能帮到你少走点弯路。本文还有配套的精品资源点击获取