简介:这是一份基于STM32硬件SPI+DMA方式驱动WS2812灯珠的完整工程源码,面向嵌入式开发者、电子爱好者和正在学习STM32驱动外设的初学者。传统IO模拟方式需要频繁操作引脚、占据大量CPU时间,还容易受中断干扰;而本方案利用STM32的SPI外设与DMA通道,把像素数据自动移位输出,显著降低处理器负载,提高刷新稳定性。工程中已移植Adafruit_NeoPixel库,提供颜色设置、亮度调节等接口,并配有呼吸、彩虹、追逐等多种样式测试函数,拿到后只需在头文件中修改灯珠数量,将控制引脚接到PA7即可编译运行。整个压缩包共103个文件,大小约311KB;核心代码包含44个h头文件和43个c源文件,另有Keil工程文件、启动汇编文件、编译好的hex固件以及bat清理脚本,拿到后可直接在Keil中打开工程,便于移植和二次开发。目前已有6808人学习下载,作者表示实测未发现明显bug,遇到问题可在评论区反馈。
1. 为什么WS2812要用SPI+DMA:GPIO模拟时序迟早翻车
调试WS2812灯带时,最怕的是用GPIO逐bit翻转电平:一边要算准时序,一边还要躲着系统中断,只要中断晚了几微秒,整条灯带就开始随机闪,断电重启都没用。WS2812的驱动其实有个反直觉的结论——它明明是单线协议,但让STM32用SPI+DMA来驱动反而最稳:SPI外设负责把0码和1码对应的电平波形按固定时钟吐出去,DMA再把编码好的数据搬到SPI数据寄存器,CPU全程不需要卡时序。这篇笔记从800kHz单线协议讲起,给出可复现的CubeMX配置、完整驱动代码和五条实测踩坑记录,适合刚接触WS2812驱动、以及已经被时序问题折腾过一段时间的开发者。
2. 时序与选型:先看懂800kHz单线协议,再决定用SPI还是GPIO
2.1 WS2812的bit时序:0码和1码不是简单的高低电平
WS2812的数据脚只有一根,所有颜色和指令都靠脉冲宽度区分。每个bit周期固定约1.25us,也就是800kHz的基频,0码和1码的区别在高电平持续时长。
| 码型 | 高电平时长 | 低电平时长 | 特例说明 |
|---|---|---|---|
| 0码 | 200~380ns | 580~1000ns | 高电平越短越接近0 |
| 1码 | 580~1000ns | 200~380ns | 高电平越长越接近1 |
芯片内部先测高电平宽度,再判断这个bit是0还是1,最后用整个周期做同步对齐。所以高电平必须落在对应区间内,一旦超过容差,芯片可能把0码识别成1码,或者直接丢弃这个bit。很多人用GPIO慢慢翻转也能点亮,是因为单片机的延时刚好踩在容差中间,但中断一多、延时误差一大,波形就开始漂移,表现就是“某一段灯颜色错乱”。
数据帧部分也要注意:每颗LED占用24bit,按GRB顺序发送且高位在前。大部分WS2812系列是Green先发,Red其次,Blue最后。换用其他兼容芯片时,这个顺序必须重新查手册确认,否则会出现红色写进绿色通道的翻车现象。整帧发送完成后,数据线要拉低至少50us作为RESET信号,表示一帧结束,下一帧从这个下降沿之后重新开始。
2.2 为什么选SPI:把位流转换成硬件时序,CPU不再管脉宽
GPIO模拟方案的核心问题有两个:一是延时不精确,二是无法被中断保护。微秒级延时一旦被高优先级中断抢占,当前bit的高电平时间直接超容差,灯就乱闪;要修复这个问题,必须把时序生成从CPU里挪出去。
SPI恰好能做这件事。SPI是同步串行外设,发送每个字节时,内部移位寄存器按固定时钟把每一位推到MOSI引脚,这个过程完全由硬件完成。我们只需要把WS2812的0码和1码提前编码成SPI字节,让SPI以合适的时钟频率输出这些字节,波形就自动满足协议要求。
以SPI时钟8MHz为例,每个SPI位周期是125ns。0码用0xE0(二进制11100000),先输出高电平375ns,再拉低625ns,正好落在0码的高电平和低电平容差范围内;1码用0xF8(二进制11111000),先输出高电平625ns,再拉低375ns,也落在1码区间内。这样WS2812的一个bit,就用SPI的一个字节替代了,24bit的LED颜色数据变成24个SPI字节。
DMA负责把这24×N个字节搬到SPI数据寄存器。CPU只需要在DMA启动前把编码缓冲准备好,传输过程中可以做颜色计算、按键扫描或者跑动画逻辑。因为DMA传输是硬件搬运,不会被中断打断,时序稳定性比GPIO方案高了一个量级。
SPI时钟选择上有个简单公式:如果WS2812的1bit对应SPI的8个时钟周期,SPI时钟就等于8×800kHz,约6.4MHz。6.4MHz到8MHz之间通常都能工作,因为0码和1码都有200ns以上的容差。实际工程里更关心分频是否取整,比如某平台APB时钟为32MHz时,SPI分频4正好得到8MHz;如果APB时钟是72MHz,分频9不合法,那就选分频8得到9MHz,再实测确认波形落在容差范围内。
2.3 硬件接线与供电:MOSI接DIN,SCK别接,电源独立
接线看起来很简单,但这里有个常见误区:SPI有四根线,MOSI、MISO、SCK、NSS,而WS2812只需要一根数据线。正确接法是SPI的MOSI引脚接灯带DIN,SCK和NSS全部悬空,因为单线协议里没有时钟线。如果误把SCK当数据线接进去,灯带永远点不亮,因为SCK输出的是时钟脉冲而不是编码数据。
电平匹配也需要确认。很多WS2812的DIN高电平阈值是0.7×VDD,如果灯带供电是5V,阈值约3.5V,3.3V主控的MOSI高电平可能踩在临界区,长时间运行会出现偶发误码。稳妥做法是加一个3.3V到5V的电平转换芯片,或者在DIN串联一个330欧电阻后再接灯带,用来抑制长走线的振铃反射。地线必须共地,否则数据电平参考点漂移,时序再准也白搭。
供电是另一个容易被轻视的问题。单颗WS2812全白时电流约60mA,60颗灯全白就是3.6A,这个电流不能从开发板取,必须独立5V电源供电。灯带电源入口要并联100~1000uF电解电容,离第一颗灯越近越好,否则几十颗灯同时点亮瞬间会把电压拉低,表现是灯光变暗、闪烁,甚至逻辑电平失效。
3. 驱动代码实现:从外设配置到能跑起来的发送函数
3.1 CubeMX配置:SPI时钟、DMA、GPIO的推荐参数
在某个STM32平台上,外设初始化我一般用CubeMX先生成框架,再手写编码和DMA回调。SPI部分建议按下面这张表配置,前三个参数错了会直接导致波形异常,后两个决定了DMA传输是否干净。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| SPI Mode | Master | MCU产生移位时钟,主动输出数据 |
| Data Size | 8 bit | 一个SPI字节对应WS2812一个bit |
| CPOL / CPHA | 0 / 0 | 空闲低电平、第一个边沿采样 |
| BaudRate | 6.4~8MHz | 根据APB分频选最接近的可整除值 |
| DMA Direction | Memory to Peripheral | 内存往SPI数据寄存器搬 |
| DMA Mode | Normal | 每次发送完自动停止,不循环 |
| DMA Data Width | Byte | 必须8位,16位会导致字节错位 |
时钟树配置上,把SPI外设时钟来源设成APB时钟,再按分频系数选择最接近8MHz的频率。DMA通道建议开传输完成中断,但这个中断只用来置标志,不能在里面做耗时操作。GPIO部分,MOSI引脚复用为SPI功能,SCK不初始化或者保持普通GPIO输入,避免误输出时钟。
另一点容易被忽略:CubeMX默认生成的DMA句柄中,Peripheral Increment要关掉,Memory Increment要打开。SPI数据寄存器地址固定,DMA每次往同一个地址写数据;而源地址内存需要递增,才能把编码缓冲逐字节发出去。
3.2 编码与发送代码:GRB缓冲、查找表与DMA启动
先定义灯数和缓冲区。SPI编码缓冲的长度是灯数×24,因为每颗灯的24bit颜色映射成24个SPI字节。LED数据结构按GRB顺序排列,编码时逐位查表得到对应的SPI字节。
#define LED_NUM 60 #define SPI_BUF_SIZE (LED_NUM * 24) /* 0码对应0xE0,1码对应0xF8 */ static const uint8_t bit_to_byte[2] = {0xE0, 0xF8}; struct led_rgb { uint8_t g; /* 绿色 */ uint8_t r; /* 红色 */ uint8_t b; /* 蓝色 */ }; struct led_rgb led_data[LED_NUM]; uint8_t spi_buf[SPI_BUF_SIZE]; static void ws2812_encode(void) { uint16_t idx = 0; for (uint16_t i = 0; i < LED_NUM; i++) { /* WS2812发送顺序:绿色先发,红色其次,蓝色最后 */ uint8_t grb[3] = {led_data[i].g, led_data[i].r, led_data[i].b}; for (uint8_t c = 0; c < 3; c++) { for (int8_t b = 7; b >= 0; b--) { uint8_t bit = (grb[c] >> b) & 0x01; spi_buf[idx++] = bit_to_byte[bit]; } } } } static void ws2812_send(void) { ws2812_encode(); HAL_SPI_Transmit_DMA(&hspi1, spi_buf, SPI_BUF_SIZE); }这段逻辑里,led_data数组保存的是用户设置的颜色值,ws2812_encode负责把它转换成SPI能直接输出的波形数据。查找表代替if判断,避免循环里出现分支预测,实测编码速度提升明显。idx从0递增到LED_NUM×24,正好覆盖整个spi_buf,没有越界风险。
GRB顺序是由芯片发送格式决定的,如果某天换成RGB顺序的兼容芯片,只需要调整grb数组里的排列顺序,其他代码不用动。HAL_SPI_Transmit_DMA启动后,函数立即返回,CPU不用等待传输完成。
DMA传输完成会触发中断,回调里只做一件事:把busy标志清零,告诉主循环可以继续更新下一帧了。RESET信号需要把数据线拉低至少50us,因为MOSI此时还处于复用功能模式,直接写GPIO电平不会生效,要先临时把引脚切回普通输出模式。完整发送函数可以写成这样:
volatile uint8_t ws2812_busy = 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { ws2812_busy = 0; } } static void ws2812_reset(void) { GPIO_InitTypeDef gpio = {0}; /* 先把MOSI切成普通推挽输出,拉低至少50us */ gpio.Pin = MOSI_Pin; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(MOSI_GPIO_Port, &gpio); HAL_GPIO_WritePin(MOSI_GPIO_Port, MOSI_Pin, GPIO_PIN_RESET); delay_us(60); /* 切回复用功能,准备下一帧SPI发送 */ gpio.Mode = GPIO_MODE_AF_PP; HAL_GPIO_Init(MOSI_GPIO_Port, &gpio); }提示:DMA数据宽度必须是Byte。如果配成HalfWord,DMA会把两个字节合并成16位传输,发送顺序完全错乱,后面的灯全灭或者颜色乱跳。
3.3 主循环调用顺序与缓冲区更新:不要边传边写
正常的主循环写法和很多人想的不一样,必须先改颜色、再启动DMA、然后等busy清零。代码结构大概是这样的:
while (1) { /* 第一步:更新led_data数组里的颜色 */ ws2812_set_pixel(0, 255, 0, 0); ws2812_set_pixel(1, 0, 255, 0); /* 第二步:编码并启动DMA发送 */ ws2812_send(); /* 第三步:等待DMA传输完成 */ while (ws2812_busy) { /* 空转等待,RTOS环境下可换信号量 */ } /* 第四步:拉低RESET,再延时等下一帧 */ ws2812_reset(); delay_ms(20); }第二步和第三步的顺序不能颠倒。ws2812_busy在DMA传输前是0,但如果发送函数里先清标志再启动DMA,主循环可能立刻进入下一次ws2812_encode,把正在被DMA读取的spi_buf覆盖掉,导致当前帧波形被改得支离破碎。
RESET放在busy清零之后,是因为RESET拉低期间DMA不能启动。如果RESET还没发完就开新帧,第一颗灯会把RESET的低电平误认为数据产生乱码。延时20ms只是示例,实际帧率取决于动画效果,通常30帧每秒已经非常流畅,20ms对应50fps,余量足够。
提示:如果动画计算耗时较长,把颜色计算逻辑放在ws2812_send之前。DMA发送期间CPU是空闲的,可以在这段时间里准备下一帧颜色,等上一帧RESET结束后立刻启动新传输。
4. 常见问题与排查:五条实测踩坑记录
4.1 现象:灯带全灭,或只有第一个灯偶尔闪一下
接好线通电后整根灯带不亮,偶尔第一个灯闪一下。原因通常有两个:一是SPI时钟频率算错,导致0码的高电平被芯片识别成1码,整个帧数据错乱;二是MOSI引脚没有正确复用为SPI功能,引脚还停留在普通GPIO模式。
解决方法是先用示波器或逻辑分析仪抓DIN波形,对照协议检查0码和1码的高电平宽度。再检查CubeMX里GPIO复用配置,确认MOSI的AF映射到SPI外设而非其他功能。如果波形没问题但灯不亮,把灯带只接第一颗灯测试,排除后续灯损坏的可能性。
4.2 现象:第一个灯颜色正常,后面的灯颜色全乱
第一颗灯颜色正确,之后所有灯完全错乱,通常是DMA数据宽度配置问题。当DMA的Peripheral和Memory数据宽度被设成16位时,DMA会按16位边界搬运数据,导致每两个字节交换位置,SPI接收到的字节序列和spi_buf里的原始排列完全不同。
解决方法是把DMA数据宽度全部改成Byte,同时检查spi_buf长度是否等于LED_NUM×24,缓冲区基地址建议按4字节对齐。另外还要确认Memory Increment已打开,如果DMA不递增源地址,它只会反复发送spi_buf里的第一个字节,结果就是灯带全灭或者全部一个色。
4.3 现象:没开中断一切正常,开了中断动画就卡顿或闪
主循环不开中断时一切正常,开启某个外设中断后动画开始闪烁。原因是中断处理函数里做了耗时操作,或者RESET延时函数依赖的时间基准被中断篡改。部分开发者会在中断里直接调用HAL_SPI_Transmit_DMA,导致上一帧还没发送完,新帧又把缓冲区覆盖了。
解决方法是把耗时计算全部移到主循环,中断回调只置标志或用消息队列通知任务。RESET的延时最好用定时器计数值或者独立的微秒延时函数,不要用会被中断反复打断的普通循环延时。DMA传输完成回调里也绝对不能调用发送函数,一旦嵌套启动传输,行为完全不可控。
4.4 现象:跑一段时间后整根灯带亮度下降或部分颜色偏移
长时间运行后亮度下降,或者某一段颜色变淡,一般是电源电压跌落引起的逻辑错误。60颗灯全白时电流接近3.6A,劣质USB线或接插件的接触电阻都会让5V跌到4.5V以下,WS2812内部逻辑开始不稳定,表现为偶发误码。
解决方法是检查电源供电线径,电流超过3A时至少用0.75平方毫米线缆。灯带供电入口并联1000uF电解电容,再就近放一个104陶瓷电容滤高频。如果电源没问题,还要确认spi_buf没有被重复写入——DMA传输期间任何对spi_buf的写操作都会破坏当前帧,必须用ws2812_busy标志做互斥。
4.5 现象:颜色偏色,红色变暗或白色偏黄
颜色整体偏色,或者某些颜色看起来不太对劲。原因很多:GRB顺序搞错是最常见的,其次是电源波形不稳造成的亮度不均,最后是低亮度下没有做Gamma校正,暗部颜色断层严重。
解决方法是先查芯片手册确认通道顺序,如果是RGB的芯片,把led_data结构体里的g和r位置互换即可。如果顺序正确但白色偏黄,用示波器看5V波形是否有严重的纹波。低亮度动画建议做Gamma校正,直接用查找表把8bit颜色值映射成校正后的值,比每次运行时计算快得多。
5. 进阶技巧:HSV调色、帧率控制与波形验证
5.1 用HSV换色做彩虹动画,避免手算RGB
动画效果里最常用的是彩虹渐变,直接手动计算RGB非常痛苦,改成HSV后只需要递增色相值。下面这个函数把H(0~360)、S(0~255)、V(0~255)转成RGB,通常用在主循环里生成颜色。
typedef struct { uint16_t h; /* 色相 0~359 */ uint8_t s; /* 饱和度 0~255 */ uint8_t v; /* 亮度 0~255 */ } hsv_t; void hsv2rgb(hsv_t hsv, uint8_t *r, uint8_t *g, uint8_t *b) { uint8_t region = hsv.h / 60; uint8_t remainder = (hsv.h % 60) * 255 / 60; uint8_t p = hsv.v * (255 - hsv.s) / 255; uint8_t q = hsv.v * (255 - hsv.s * remainder / 255) / 255; uint8_t t = hsv.v * (255 - hsv.s * (255 - remainder) / 255) / 255; switch (region) { case 0: *r = hsv.v; *g = t; *b = p; break; case 1: *r = q; *g = hsv.v; *b = p; break; case 2: *r = p; *g = hsv.v; *b = t; break; case 3: *r = p; *g = q; *b = hsv.v; break; case 4: *r = t; *g = p; *b = hsv.v; break; default: *r = hsv.v; *g = p; *b = q; break; } }彩虹动画只需要每帧把hsv.h加一,超过360就归零,再调用hsv2rgb把当前颜色写到对应LED。相比直接调RGB,HSV的好处是色相变化是均匀的,不会出现某个颜色区间跳跃特别快的情况。亮度值v也可以同时参与呼吸灯控制,只需要改变v的值即可。
5.2 帧率控制与波形验证,形成固定检查习惯
动画帧率不要靠delay凑,最好用定时器计数控制。每次tick里把颜色更新函数执行一次,确保每帧之间的间隔稳定。帧率太高会让RESET时间不足,建议每帧间隔保持在20ms以上。
还有一个高风险点:首次点灯前必须用示波器验证DIN波形。把示波器接在MOSI引脚与GND之间,触发方式设为单次,观察0码和1码的实际脉宽是否落在容差内。这一步能省掉大量玄学排查时间——很多看似灯带兼容性的问题,根源都在SPI时钟分频不准确。从那以后我每次新接一块灯带,都会先抓一遍DIN波形再写动画,确认高电平脉宽符合规格书后才继续往下做。希望帮到你。
本文还有配套的精品资源,点击获取