
简介基于STM32F103的HAL库工程演示如何用硬件SPI配合DMA精准驱动WS2812 RGB灯珠实现幻彩流水灯效果。资源包采用7z压缩仅377KB内含144个文件除Keil工程源代码.c/.h与CubeMX配置.ioc外还包含编译生成的.o、.d、.axf、.hex、.map等目标文件以及uvprojx/uvoptx工程文件便于直接打开、编译和烧录。工程的核心思路是先将WS2812的时序码型映射为SPI发送的字节再利用DMA持续向SPI数据寄存器填充预置缓冲区从而在不占用CPU的情况下稳定刷新各灯珠颜色源码中对DMA通道、SPI速率和流水灯循环均有明确配置并保留调试排查所需的关键文件。目前已有1253人学习下载适合正在学习STM32 HAL库、CubeMX配置或希望实现多灯珠RGB控制的开发者参考可通过阅读代码快速理解SPIDMA驱动WS2812的完整流程并直接烧录验证、在此基础上扩展更多动态效果。1. 硬件SPIDMA驱动WS2812流水灯一条让CPU彻底喘口气的路最早调WS2812灯带时多数人习惯用定时器中断去翻转引脚一个灯珠24个bit每个bit都要算好高电平时间灯一多中断里全是时序主循环基本干不了别的。后来换成硬件SPI配合DMA驱动WS2812流水灯思路完全变了把每个灯珠的bit翻译成SPI的码字把整帧数据展开成一段SPI字节流交给DMA搬运CPU在发送过程中几乎不参与只需要在DMA完成回调里更新一下下一帧效果。这样CPU占用低效果帧率稳定代码也比精确延时更容易维护。本文基于HAL库把从时序换算、码字选择、数据展开到DMA传输和常见坑一次性梳理清楚适合已经会建STM32工程、但没正经用过SPIDMA驱动WS2812的开发者。2. WS2812跟SPI是怎么互相“听懂”的脉冲宽度换算与码字选择2.1 WS2812不看协议帧只看高电平持续时间WS2812是单线协议数据线只有一根没有独立的时钟线。每个bit由一个“高电平 低电平”组成灯珠内部靠检测高电平持续的时间来判断这一位是0还是1。传统观念里T0H大约是350nsT1H大约是700ns但不同批次的灯珠、不同产地的兼容芯片判定窗口会有差异。更准确的说法是它不在乎SPI的SCK频率只在乎MOSI上每个高脉冲持续了多少纳秒。所以用SPI模拟WS2812的关键不是“频率对不对”而是“高电平宽度对不对”。SPI在发送字节时MOSI会在SCK的驱动下逐bit输出连续发送0x00会让MOSI一直为低连续发送0xFF会让MOSI一直为高。控制一个字节里“哪几位是1、哪几位是0”就能拼出我们想要的高脉冲宽度。这就是“码字”的由来。一个SPI字节有8个bit如果SPI时钟是8MHz那么每个bit持续125ns。一个字节8位全部发完需要1us正好可以作为WS2812一个bit的基础时间窗口。我们在这个字节里放几个1就等价于高电平持续几百纳秒。为了便于理解和调试我用“一个WS2812 bit 一个SPI字节”的映射方式而不是把一个WS2812 bit拆成四五个SPI bit去跨字节拼接后者虽然也能工作但展开代码复杂新手容易把索引算错。2.2 把WS2812的每个bit翻译成SPI码字0xC0与0xF0我常用的码字组合是WS2812的0SPI发送0xC0二进制是1100 0000高电平占2个bit约250ns。WS2812的1SPI发送0xF0二进制是1111 0000高电平占4个bit约500ns。这个组合在8MHz到9MHz的SPI时钟下都能工作。注意SPI字节是按MSB先发也就是从左往右发所以0xC0实际输出的是“1、1、0、0、0、0、0、0”这串电平。如果改成LSB first同样的0xC0输出顺序就反了时序会变但也不是不能用只是调试时不好对照波形所以我习惯固定用MSB first。如果你手里的灯珠对时序敏感可以微调码字。下面这张表是我调灯带时常用的备选码字WS2812 bitSPI码字8MHz下高电平时长8MHz下低电平时长适用场景00xC0250ns750ns默认大多数灯珠没问题00xE0375ns625ns0被误判成1时加宽0的高电平10xF0500ns500ns默认均衡10xF8625ns375ns1被误判成0时加宽1的高电平改码字不需要动时序逻辑只改宏定义即可。但要注意不是码字越宽越好。WS2812的每个bit之间需要留出低电平时间如果1的码字高电平太宽低电平太短灯珠可能来不及在一个bit结束后完成判定下一颗灯的数据就串了。所以我在实际项目中很少用0xFE这种几乎全是高的码字除非确认灯带规格书允许。2.3 帧长度、复位周期与刷新预算先算清楚再写代码WS2812的一颗灯珠需要24个bit展开成SPI字节就是24个字节。N颗灯的数据量是N × 24字节。但灯带识别一帧数据的结束靠的是RESET信号也就是数据线持续拉低至少50us。在8MHz SPI下发一个0x00字节正好耗时1us所以至少要发50个0x00。我一般给RESET留100个字节也就是100us比很多灯带的最低要求宽裕一倍避免某些兼容芯片复位不彻底导致下一帧开头丢数据。帧长度计算公式总字节数 N × 24 RESET字节数总耗时 ≈ (N × 24 RESET字节数) / SPI_MHz 微秒以8MHz、60颗灯珠为例数据字节 60 × 24 1440字节加RESET 100字节 1540字节一帧耗时 ≈ 1540us 1.54ms如果按60fps刷新率算每帧周期16.7ms总线占用约9%剩下超过90%的时间CPU完全可以去跑别的任务。这也是硬件SPI配合DMA方案最核心的价值发送过程是异步的CPU不需要守在GPIO翻转上。3. 写展开与流水灯效果从“颜色数据”到“SPI字节流”3.1 数据帧结构GRB顺序、MSB在前、末尾RESETWS2812的24bit颜色数据并不是我们习惯的RGB顺序而是GRB也就是字节顺序依次是Green、Red、Blue。这一点是新手最容易翻车的地方按RGB顺序填数据显示出来的红蓝互换绿色串到红色通道。我一般直接定义一个三通道数组固定用G、R、B顺序写入。另外SPI发码字时要保证MSB first也就是颜色字节的最高位先发。这样展开循环里从bit 7往bit 0取位语义最直观。展开后的SPI buffer末尾必须补一串0x00作为RESET否则灯带会把上一帧的结尾和下一帧的开头连在一起表现为整条灯带乱闪或者颜色残留。3.2 展开函数8bit颜色转成24个SPI字节下面这段代码是把一段GRB颜色数据展开成SPI发送buffer的核心函数#include stdint.h #define WS2812_0 0xC0 #define WS2812_1 0xF0 #define RESET_BYTES 100 void ws2812_build_frame(const uint8_t *pixels, uint16_t led_count, uint8_t *spi_buf) { uint32_t idx 0; for (uint32_t i 0; i (uint32_t)led_count * 3; i) { uint8_t byte pixels[i]; for (int bit 7; bit 0; bit--) { spi_buf[idx] (byte (1u bit)) ? WS2812_1 : WS2812_0; } } for (uint16_t r 0; r RESET_BYTES; r) { spi_buf[idx] 0x00; } }这段代码的逻辑很直接pixels数组里每3个字节代表一颗灯珠的G、R、B值循环取每个字节的8个bitbit为1就放WS2812_1码字bit为0就放WS2812_0码字。展开完成后spi_buf的长度应为 led_count × 24 RESET_BYTES。调用前要确保spi_buf足够大。比如60颗灯珠至少需要60 × 24 100 1540字节。如果LED数量是可变的建议按最大数量定义数组或者用动态内存分配但嵌入式里我习惯直接开一个静态大数组省去malloc带来的碎片风险。3.3 流水灯效果在原始颜色数组上做滚动流水灯效果不需要动SPI buffer只需要动颜色数组pixels。每次刷新时把“灯头”位置往后移动一格灯头最亮后面几颗灯亮度递减形成拖尾效果。下面是一个简单的红色流水灯static uint16_t head 0; void running_light_step(uint8_t *pixels, uint16_t led_count) { for (uint16_t i 0; i led_count; i) { uint16_t dist (i led_count - head) % led_count; uint8_t level 0; if (dist 0) { level 0xFF; // 灯头最亮 } else if (dist 1) { level 0xA0; // 第一级拖尾 } else if (dist 2) { level 0x50; // 第二级拖尾 } pixels[i * 3 0] 0; // G pixels[i * 3 1] level; // R pixels[i * 3 2] 0; // B } head (head 1) % led_count; }这里head表示当前灯头的位置dist计算当前灯到灯头的循环距离。每调用一次灯头往前移动一颗灯。如果每帧都调用60颗灯转一圈正好1秒效果比较快想让流水灯慢一点可以在DMA完成回调里加一个帧计数器每3帧或者每5帧才调用一次。4. 基于HAL库把SPI和DMA接起来CubeMX配置与最小代码4.1 CubeMX里的6个关键配置点在CubeMX里配置一个能输出WS2812时序的SPIDMA关键点其实就6个其他默认配置基本不用动SPI模式选“Transmit Only Master”只用MOSI不用MISO。时钟极性CPOL选Low时钟相位CPHA选1st Edge也就是SPI Mode 0。数据宽度选8bitMSB first。NSS配置为Software模式避免硬件片选信号干扰数据线。SPI时钟频率尽量落在8MHz到9MHz。如果APB2时钟是72MHzSPI1预分频选8就是9MHz如果是64MHz选8就是8MHz。DMA添加SPI1_TX通道模式Normal数据宽度Byte方向MemoryToPeripheral。DMA中断一定要打开因为我们要在传输完成回调里启动下一帧。CubeMX生成代码后DMA的初始化会在SPI初始化之后自动完成不需要手动调HAL_DMA_Start_IT。4.2 用HAL_SPI_Transmit_DMA启动一帧假设全局变量已经定义好#define NUM_LEDS 60 #define FRAME_BYTES (NUM_LEDS * 24 RESET_BYTES) uint8_t led_data[NUM_LEDS * 3]; // G,R,B 顺序 uint8_t spi_buf[FRAME_BYTES]; // SPI发送buffer extern SPI_HandleTypeDef hspi1;上电后先调用一次展开函数然后启动DMA传输void ws2812_send_frame(void) { ws2812_build_frame(led_data, NUM_LEDS, spi_buf); HAL_SPI_Transmit_DMA(hspi1, spi_buf, FRAME_BYTES); }HAL_SPI_Transmit_DMA这个函数的三个参数分别是SPI句柄、发送buffer地址、发送长度。需要注意buffer地址必须是内存地址不能是const常量区地址因为DMA要从内存读数据。还有一个容易被忽略的点如果上一次传输还没结束不要再次调用这个函数否则DMA配置会被覆盖出现只发一半就卡住的情况。4.3 在DMA完成回调里让流水灯自己跑下去DMA把整帧数据发完后HAL库会调用HAL_SPI_TxCpltCallback。在这个回调里更新颜色数组、重新展开、再启动下一帧就能让灯带持续刷新void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance ! SPI1) { return; } running_light_step(led_data, NUM_LEDS); ws2812_build_frame(led_data, NUM_LEDS, spi_buf); HAL_SPI_Transmit_DMA(hspi1, spi_buf, FRAME_BYTES); }回调里不要做耗时操作。展开60颗灯只要1440次循环在中端MCU上也就几十微秒放在中断回调里问题不大。但如果你用的是120颗以上灯珠或者展开了大量数据建议在回调里只置一个标志位主循环检测到标志后再执行展开和下一次传输。这样中断上下文更干净也方便以后扩展其他任务。注意HAL_SPI_TxCpltCallback会在多个SPI外设共用时被调用所以一定要用hspi-Instance判断是哪个SPI不然另一个SPI传输完成也会误触你的流水灯逻辑。5. 硬件SPI驱动WS2812的避坑清单5.1 现象整条灯带发白、颜色像“褪色”现象上电后所有灯都亮白色或者接近白色完全看不出设置的红色流水效果。原因最常见的是展开逻辑里0和1的码字放反了或者SPI数据空闲电平为高。WS2812的复位信号要求数据线拉低如果SPI在帧间空闲时输出高电平灯带会把两帧之间的空隙当成一堆1最后解出的颜色全为0xFFFFFF也就是白色。解决先单独发一帧纯红色测试数据也就是G0、R255、B0然后看示波器或者逻辑分析仪。确认展开后spi_buf里的码字确实是0xC0表示0、0xF0表示1。再看SPI发送结束后MOSI引脚电平正常应该是低。如果空闲为高检查CPOL是否配置成了High改回Low即可。5.2 现象第一颗灯正常后面的灯乱闪或全灭现象第一颗灯颜色正确从第二颗开始颜色错乱或者干脆全灭但第一颗一直没变。原因大概率是帧长度算错了。WS2812每个灯珠24bit展开成SPI字节是24个字节。如果定义的FRAME_BYTES少算了RESET字节或者数组只给了颜色数据的大小DMA会多发或者少发后面的灯就会解析到错位数据。另一个常见原因是主循环频繁修改led_data而DMA还在后台读spi_buf数据不一致导致后面的灯收到残帧。解决把FRAME_BYTES写成 (NUM_LEDS * 24 RESET_BYTES)不要用魔法数字。更新颜色数据时保证上一帧DMA传输已经结束最简单的方式就是像上面那样在HAL_SPI_TxCpltCallback里更新不要在main循环里一边发一边改。5.3 现象DMA只发了一半就卡住灯带停在灰色现象上电瞬间灯带亮一下然后停在某个灰度之后再也不刷新。原因HAL_SPI_Transmit_DMA被重复调用了。比如上一帧还没发完主循环又调了一次HAL会重新配置DMA但SPI外设状态处于BUSY第二次调用没有真正启动灯带就停在半截数据上。还有一种情况是DMA中断优先级太低被其他高优先级中断打断后一直没有完成回调自然就不会启动下一帧。解决在启动前检查SPI状态if (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY) { return; } HAL_SPI_Transmit_DMA(hspi1, spi_buf, FRAME_BYTES);DMA中断优先级建议设置为比系统滴答定时器略高尤其是在运行RTOS时不要让其他频繁中断把DMA完成信号饿死。5.4 现象靠近电源的灯正常末端的灯偏色变暗现象灯带开头几颗颜色正常越靠近末端颜色越暗白色灯珠明显偏黄或者偏红。原因这是供电问题不是SPI时序问题。WS2812灯珠全亮时单颗电流可达60mA60颗全亮就是3.6A。灯带走线细、供电线长末端压降大灯珠工作电压不足就会偏色变暗。硬件SPI只能保证时序正确解决不了物理供电。解决灯带两端都接电源或者每隔一段距离注入一次5V电源。数据线地线也要和电源地共地否则数据参考电平不稳更容易出现乱闪。我习惯在灯带电源输入端并一个470uF以上电容再在靠近灯带数据线入口加一个100到330欧电阻。这个电阻不是为了限流而是为了吸收信号反射尤其在灯带线长超过50cm时效果明显。5.5 排查工具用逻辑分析仪看MOSI别靠肉眼猜现象灯带症状千奇百怪有时候闪一下、有时候全灭、有时候颜色随机变改码字也没规律。原因SPI码字的实际波形和理论值有偏差。MCU时钟不准、SPI分频后的实际频率不是整数、灯带线材电容大导致边沿变缓都会让T0H和T1H偏离预期。靠肉眼猜永远只能试错。解决用逻辑分析仪抓MOSI引脚采样率至少16MHz以上看第一个灯珠数据开头的波形。重点测0码和1码的高电平宽度确认和设想一致。如果0xC0实测高电平只有150ns说明SPI时钟不是预想的8MHz而是被配置到了更高频率。再加上一条把逻辑分析仪的触发模式设为下降沿抓整个帧的起始部分会比直接在满屏波形里翻找高效得多。6. 最后一公里刷新率预算、双缓冲与我的调试习惯6.1 一帧数据要发多久给手头的LED数量算一次不同灯珠数量对应不同的发送耗时直接决定你能跑多高的刷新率。下表按8MHz SPI、RESET 100us计算灯珠数量SPI字节数一帧耗时60fps下的总线占用30颗820字节0.82ms约5%60颗1540字节1.54ms约9%120颗2980字节2.98ms约18%240颗5860字节5.86ms约35%总线占用高不代表CPU占用高因为发送期间CPU在休眠或者干别的。但如果你同时要驱动屏幕、跑算法、做通信就得把刷新率从60fps降到30fps或者减少灯珠数量。这个在项目前期就要算清楚不要等灯带焊上去才发现跑不动。6.2 从“单缓冲”升级到“双缓冲”彻底消除画面撕裂单缓冲下如果在DMA传输期间更新led_data并重新展开正在发送的spi_buf可能被新数据覆盖画面会出现一道撕裂边界。最简单可靠的升级是用双缓冲两个spi_buf轮流使用static uint8_t spi_buf[2][FRAME_BYTES]; static uint8_t active_buf 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance ! SPI1) { return; } active_buf ^ 1; running_light_step(led_data, NUM_LEDS); ws2812_build_frame(led_data, NUM_LEDS, spi_buf[active_buf]); HAL_SPI_Transmit_DMA(hspi1, spi_buf[active_buf], FRAME_BYTES); }第一次启动时发送spi_buf[0]完成后切到spi_buf[1]展开新一帧并发出去等spi_buf[1]发送完成再切回spi_buf[0]。每次DMA读的都是“上一帧已经填好的buffer”当前帧无论如何修改led_data都不会影响正在发送的内容。代价只是内存翻倍但对大多数MCU来说完全可接受。6.3 我的收尾习惯和一句忠告我调WS2812这几年最大的习惯是拿到一块没点过的灯带先不发流水灯效果而是先写一段固定颜色测试代码纯红、纯绿、纯蓝各发一秒。这样做能最快暴露三个问题GRB顺序对不对、码字时序对不对、供电够不够。颜色对了再上动态效果永远不要在一开始就把流水灯和硬件调试混在一起。另一条经验是不要迷信某个码字“一定兼容”。同一型号的灯带批号不同、芯片版本不同时序窗口可能差几十纳秒。SPI码字是软件定义的东西改起来成本极低多备几组常量遇到偏色时逐个试比反复刷固件盲调快得多。希望这套基于HAL库的硬件SPI配合DMA驱动WS2812流水灯方案能让你少走几次弯路一次点亮。本文还有配套的精品资源点击获取