
简介面向STM32开发者特别是需要驱动WS2812B灯带并追求高效刷新的人群这份资源提供了一套基于STM32F107VC的SPIDMA完整实现方案。它解决了传统IO时序模拟占用CPU资源、刷新率受限的问题利用SPI硬件产生精确的1.25us周期脉冲将逻辑1与逻辑0分别映射为不同高电平宽度从而稳定控制灯带。工程代码共84个文件以48个H头文件与22个C源文件为主体涵盖HAL库驱动、GPIO/SPI/DMA初始化、主程序逻辑及中断处理同时附有MDK-ARM工程、CubeMX配置文件和说明文档压缩包仅532KB目录结构规整方便直接移植或按需裁剪。已有1140人学习适合中高级单片机爱好者、竞赛学生及产品开发者参考。通过本包可深入理解SPI时序映射原理、DMA搬运机制并能直接在STM32F107系列芯片上跑通灯带控制缩短开发调试周期。1. 为什么 WS2812B 的 800kHz 时序可以用 SPIDMA 拉出来WS2812B 的数据线是一条不能停的线。800kbps 意味着每个 bit 以 1.25us 为一个周期由高电平和低电平拼成DIN 上的数据必须连续到达中间一旦出现几十微秒的低电平整条灯带会认为这是一次复位。GPIO 逐位翻转当然能做但每颗灯要送 24 个 bit几百颗灯就是几千次翻转循环体里的判断和跳转都会变成抖动最后反应在颜色偏差上。SPI 在这里的价值不是和灯珠做标准 SPI 通信而是当一台硬件移位器只要把数据写进 SPI 数据寄存器它就会自动把字节拆成等宽的位流从 MOSI 送出去DMA 再把内存里排好的帧搬到 SPICPU 只在帧与帧之间碰一次数据。如果你的驱动方案进化到第三步前面大概率已经试过 GPIO 翻转或定时器 PWM。SPIDMA 这条路的区别在于不改中断时序不占用定时器通道只要一个 SPI 外设和一条 DMA 请求线整帧数据就能在后台发完。下面从编码、CubeMX 配置、HAL 发射到排错把这条链路完整拉通。2. 位流编码写 SPI 字节前先把 RGB 拆成 5/6 位符号2.1 WS2812B 的时序窗口没有想象那么窄灯珠数据手册里的 1.25us 周期经常被理解成“必须精确到 50ns 以内”实际上大部分批次给了相当宽的容忍范围。下面是一组常见参考值不同批次会有出入量产前以手头规格书为准参数常见范围说明T0H0.2 ~ 0.5us0 码高电平宽度T0L0.65 ~ 0.95us0 码低电平宽度T1H0.55 ~ 0.85us1 码高电平宽度T1L0.45 ~ 0.75us1 码低电平宽度Reset大于 50us帧结束后的低电平间隔也就是说只要高位和低位的宽度落在区间里总周期在 1.1us 到 1.4us 附近灯珠都能正常采样。这个窗口正是 SPI 编码能成立的前提我们可以用几个 SPI 位拼一个 WS2812B 位而不是要求 SPI 时钟恰好等于 800kHz。2.2 三/五/六位模板把一个灯珠位拆成几个 SPI 位SPI 发出的是连续的 SCK 时钟和 MOSI 数据流没有“帧”的概念。要让一个 WS2812B bit 持续约 1.25us就得把这一 bit 拆成若干个等宽的 SPI bit再把所有符号按顺序拼进字节数组。以 STM32F103 的 SPI1 为例APB2 时钟是 72MHzSPI 预分频可选 /16 4.5MHz或者 /32 2.25MHz。下面是两种能落在灯珠窗口里的模板模板SPI 频率每个 SPI 位逻辑 1 符号逻辑 0 符号每灯珠 SPI 字节6 位模板4.5MHz222ns0x381110000x30110000183 位模板2.25MHz444ns0x061100x0410096 位模板下逻辑 1 的高电平是 3 个 SPI 位即 666ns低电平也是 666ns逻辑 0 的高电平是 444ns低电平是 888ns。总周期 1.333us落在灯珠的接受范围内。3 位模板用更少的位拼出同样的周期内存占用小但脉冲宽度步进是 444nsT1H 会到 888ns接近上限所以我会优先用 6 位模板。2.3 编码器的最小实现逐位填充有了符号定义剩下的问题就是把 RGB 字节按位展开填入 SPI 字节流。先看最直观的逐位版本#define CODE_1 0x38u /* 二进制 111000 */ #define CODE_0 0x30u /* 二进制 110000 */ #define BITS_PER_SYMBOL 6u static void append_symbol(uint8_t *buf, uint32_t *bitpos, uint8_t sym) { for (int i BITS_PER_SYMBOL - 1; i 0; i--) { if (sym (1u i)) { buf[*bitpos 3] | (uint8_t)(0x80u (*bitpos 7u)); } (*bitpos); } }append_symbol先把符号的最高位写到当前字节的最高位然后逐位右移。*bitpos 3定位到第几个字节*bitpos 7定位到字节内第几位。这个函数只负责把符号流写进连续内存不关心 Linux 还是裸机也不关心 HAL 版本。逐位调用方便理解但几百颗灯下性能太差。实际工程里我会在建帧前把 256 个颜色值对应的 6 字节序列全部算好运行时用 memcpy 拼帧这个做法放到第 4 章展开。3. CubeMX 配置 SPIDMAPrescaler、Transmit Only 和 DMA 通道3.1 SPI 定时决定编码模板别照抄预分频CubeMX 里把 SPI1 配成 MasterDirection 选 Transmit Only Master。如果手头 HAL 版本比较老没有这个枚举就保持 Full-Duplex Master不接 MISO 一样能跑。NSS 一定要设置成 Software 模式并禁用硬件片选否则 NSS 引脚的电平变化可能打断 DMA 发送。这里最容易抄错的是波特率。我的默认模板是 6 位/4.5MHz对应 F103 SPI1 的SPI_BAUDRATEPRESCALER_16。改成 3 位模板时预分频要换成SPI_BAUDRATEPRESCALER_32得到 2.25MHz。换到 F0、F4 或 G 系列时不要再抄 /16 这个值要按实际 APB 时钟反推先查芯片时钟树里 SPI 外设挂在哪个总线用“总线时钟 / 目标 SPI 时钟”选最接近的 2 的幂次预分频用目标 SPI 时钟反推每个 SPI 位时间再回算 T0H/T0L/T1H/T1L3.2 接线只接 MOSISCK 空着SPI 在这里只是把 MOSI 变成连续位流的工具不是和 WS2812B 做标准 SPI 通信。灯珠 DIN 只接 MCU 的 MOSI 引脚SCK 不要接更不能把 SCK 当成灯珠时钟线。WS2812B 自己从 DIN 上恢复时钟它不认识 SCK。CubeMX 的引脚视图里只要确认 MOSI 被映射到目标引脚SCK 即使同一个 SPI 外设也可以不留意。唯一要注意的是 SCK 引脚如果复用了其他功能别把那个功能也配出来。3.3 DMA 参数表和中断优先级DMA 配置按下面这张表核对任何一项不一致都会出现错位或死等参数推荐值原因DMA RequestSPI1_TX选择 SPI 发送请求DirectionMemoryToPeripheral从内存搬到 SPI 数据寄存器ModeNormal单帧发送静态画面可改 CircularPeripheral IncrementNoSPI 数据寄存器地址固定Memory IncrementYes一帧数据在内存里连续Data WidthByte / Byte与 SPI 数据宽度一致DMA 中断Enable用传输完成回调刷新 busy 标志F103 的 SPI1_TX 固定在 DMA1 的 Channel3F4 系列可能分配到不同的 DMA 流和通道CubeMX 会自动选不要手动抄别人代码里的通道号。中断优先级我会把 DMA 中断设成高于 SysTick避免 HAL_Delay 或其他低优先级中断把帧尾拖出问题HAL_NVIC_SetPriority(DMA1_Channel3_IRQn, 1, 0); HAL_NVIC_EnableIRQ(DMA1_Channel3_IRQn);SPI 外设本身的中断可以不勾。DMA 完成中断会触发HAL_SPI_TxCpltCallback帧与帧之间的逻辑在回调里处理不需要 SPI 中断介入。4. HAL 库 DMA 发射代码编码表、帧缓冲和一次触发4.1 预计算颜色查表避免逐位编码一个 RGB 字节有 8 个灯珠位每个灯珠位拆成 6 个 SPI 位一共 48 个 SPI 位正好 6 个字节。预先算好 256 种颜色值对应的 6 字节序列建帧就变成三次 memcpy#define CODE_1 0x38u /* 111000 */ #define CODE_0 0x30u /* 110000 */ #define BITS_PER_SYMBOL 6u #define BYTES_PER_LED 18u /* 24 * 6 / 8 */ #define RESET_BYTES 32u /* 32 * 8 / 4.5MHz 56.9us */ static uint8_t code_lut[256][6]; static uint8_t frame_buf[MAX_LEDS * BYTES_PER_LED RESET_BYTES]; static void build_lut(void) { for (uint16_t v 0; v 256; v) { uint32_t bitpos 0; memset(code_lut[v], 0, sizeof(code_lut[v])); for (int bit 7; bit 0; bit--) { uint8_t sym (v (1u bit)) ? CODE_1 : CODE_0; for (int i BITS_PER_SYMBOL - 1; i 0; i--) { if (sym (1u i)) { code_lut[v][bitpos 3] | (uint8_t)(0x80u (bitpos 7u)); } bitpos; } } } } static void build_frame(uint8_t *dst, const uint8_t *grb, uint16_t nled) { for (uint16_t i 0; i (uint16_t)(nled * 3u); i) { memcpy(dst i * 6u, code_lut[grb[i]], 6u); } memset(dst nled * BYTES_PER_LED, 0, RESET_BYTES); }build_lut遍历 0 到 255把每个颜色字节对应的 6 字节 SPI 序列预先算好。build_frame把 GRB 数据按字节查出 6 字节模板连续拷贝到帧缓冲最后补 32 个零字节让 MOSI 在帧尾保持足够低的电平形成 WS2812B 的复位信号。注意颜色顺序是 GRB不是 RGB。grb[0]是绿色grb[1]是红色grb[2]是蓝色。顺序错了的表现是红色和绿色互换这是这类驱动最常见的低级错误。4.2 用 DMA 触发一帧并用回调清 busy发送侧用一个 volatile 变量做忙标志。DMA 传输完成回调里清标志主循环发下一帧前先确认上一帧已经发完static volatile uint8_t dma_busy 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { dma_busy 0; } } void ws2812b_send(const uint8_t *grb, uint16_t nled) { while (dma_busy); build_frame(frame_buf, grb, nled); dma_busy 1; HAL_SPI_Transmit_DMA(hspi1, frame_buf, (uint16_t)(nled * BYTES_PER_LED RESET_BYTES)); }HAL_SPI_Transmit_DMA只是把 DMA 请求挂上函数本身很快返回。真正的发送在后台进行dma_busy会在 DMA 完成中断里被清零。volatile是必须的否则编译器可能把 flag 优化进寄存器主循环永远看不到中断里的修改。帧长按 300 颗灯算300 * 18 32 5432 字节在 4.5MHz SPI 下传输约 9.7ms刷新率还能到 100fps 级别。1000 颗灯时帧长 18032 字节已经接近 F103C8 的 20KB SRAM 上限要考虑换大容量型号或改用 3 位模板。4.3 不同灯珠数下的帧长和刷新时间灯珠数帧长4.5MHz 下传输时间601112 字节约 2.0ms1703092 字节约 5.5ms3005432 字节约 9.7ms100018032 字节约 32.1ms复位字节已经算进帧长。只要应用层不在 DMA 发送期间改同一个 buffer这个时间就是实际能跑到的刷新下限。5. WS2812B 时序排错电平、帧间隔和灯数增多后的波形5.1 先用单灯确认波形再看整条灯带任何“灯多了就乱”的问题都先从单颗灯开始查。下面这段代码发一颗纯绿色灯GRB 顺序下绿色在第一个字节void ws2812b_test_single(void) { uint8_t grb[3] { 0xFF, 0x00, 0x00 }; /* G255, R0, B0 */ while (dma_busy); build_frame(frame_buf, grb, 1); dma_busy 1; HAL_SPI_Transmit_DMA(hspi1, frame_buf, (uint16_t)(1u * BYTES_PER_LED RESET_BYTES)); }用示波器夹 DIN 和 GND应该看到 24 个连续脉冲然后是一段约 57us 的低电平。重点量三件事逻辑 1 的高电平宽度、逻辑 0 的高电平宽度、帧尾低电平是否大于 50us。逻辑分析仪采样率低于 20MHz 就不要拿来量这几个参数采样点太少会给出完全错误的脉宽。5.2 3.3V 电平和电源地线STM32 的 MOSI 输出是 3.3V而 WS2812B 数据手册里的输入高电平阈值一般参考 5V 供电。短距离、单颗灯实验时 3.3V 通常能点亮但灯珠批次老化后阈值会漂正规方案要加电平转换74HCT245经典 5V 电平转换注意方向位要拉对74AHCT1G125单路缓冲占用面积小串联 220 到 330 欧电阻靠近 DIN压低振铃电源问题会在灯数增多时集中爆发。整条灯带要用 5V 外部电源供电MCU 和灯带必须共地。灯带总线处放一个 1000uF 电解电容每 50 颗灯附近再补一个 100uF不然全白场景电流一冲DIN 波形会被电源噪声埋掉。5.3 症状、原因和对应解法现象常见原因处理方向第一颗灯正常后面全乱数据位流错位或时序超窗用示波器量 T0H/T1H检查模板颜色周期性和 GRB 通道互换GRB 顺序写反交换 grb[0] 和 grb[1]整条灯带偶发闪烁帧尾复位不足 50us加大 RESET_BYTES高亮时画面整体漂移电源压降导致阈值漂移加强 5V 电源和电容画面出现撕裂更新DMA 发送期间改了同一帧 buffer使用第 6 章的双缓冲还有一类软件坑HAL_SPI_Transmit_DMA返回前不会检查上一次是否发完连续调用两次会踩掉上一次传输。所有发送路径都要经过dma_busy判断不要只靠肉眼觉得“主循环够慢所以不会冲突”。5.4 软件模拟 SPI 的边界软件模拟 SPI 能编出同样的位流但每个 bit 之间多了while读 GPIO 和循环递减SCK 边沿和 MOSI 数据的相位关系完全依赖指令周期中断一来就断。SPIDMA 的优势恰恰是把这种时序交给硬件外设CPU 只负责在帧边界切换。如果发现自己开始在发送循环里加__disable_irq()来保时序那就该退回 SPI 外设方案。6. 双缓冲和 DMA 回调动态画面怎么做到不停帧刷新6.1 用两块帧缓冲轮换发送DMA 正在读取 frame_buf 时改它的内容会出现上半帧是旧颜色、下半帧是新颜色的撕裂。解决办法是准备两块同样大小的帧缓冲主循环写不参与发送的那块上一帧发完后再切换static uint8_t frame_buf[2][MAX_LEDS * BYTES_PER_LED RESET_BYTES]; static uint8_t tx_idx 0; void ws2812b_update(const uint8_t *grb, uint16_t nled) { uint8_t back tx_idx ^ 1u; build_frame(frame_buf[back], grb, nled); while (dma_busy); tx_idx back; dma_busy 1; HAL_SPI_Transmit_DMA(hspi1, frame_buf[tx_idx], (uint16_t)(nled * BYTES_PER_LED RESET_BYTES)); }back 永远指向当前不在发送的那块缓冲build_frame 可以先跑发完后再把 tx_idx 切过去启动下一帧。while (dma_busy)的等待时间通常会被 build_frame 掩盖因为建帧耗时往往比 DMA 传输更长。实际刷新率约等于 1 除以 max(建帧时间, 传输时间)不会再叠加额外的同步开销。如果连这个等待都想省可以把 build_frame 放进HAL_SPI_TxHalfCpltCallback在 DMA 发前半帧时就开始填后半帧。那个回调会在 SPI 数据寄存器半空时触发时间窗口刚好覆盖另一块 buffer 的填充。复杂度高一些适合需要把刷新率压到极限的场景。6.2 静态画面可以直接用 Circular 模式如果灯带内容固定不变比如只显示一组静态颜色适合用 DMA 的 Circular 模式DMA 自动循环搬运同一帧SPI 永远不停CPU 完全不用管刷新。关键点仍然在帧尾的 Reset 字节必须留在帧缓冲里否则 Circular 回绕后最后一帧和下一帧直接连上灯带会把复位间隔当成数据整条乱掉。6.3 动态效果的性能瓶颈跑流光、呼吸这类效果时真正吃 CPU 的是 build_frame。300 颗灯每帧要 memcpy 900 个 6 字节模板如果场景还带颜色渐变算法单帧建帧时间很容易超过 10ms。这时可以做两级优化先把原始 GRB 数组算好再一次性 build_frame颜色计算和编码拆分到两个缓冲区让编码发生在 DMA 发送上一帧的同时。如果看到帧率上不去先别怀疑 SPI 和 DMA去量dma_busy置位和清零的间隔再量 build_frame 的耗时瓶颈通常都在这两段相对时间的匹配上。本文还有配套的精品资源点击获取