简介:基于STM32F103微控制器和硬件抽象层库,通过串行外设接口配合直接存储器访问传输,驱动WS2812幻彩灯珠实现流水灯效果的完整工程资源。面向正在学习STM32外设驱动、使用图形化配置工具生成初始化代码,以及研究单线通信时序控制的嵌入式开发者,可直接对照源码梳理配置流程。压缩包共144个文件,以C语言源文件、头文件为主体,另外包含集成开发环境工程配置、CubeMX项目配置文件、编译产生的目标文件与调试映射文件,整体体积约377KB,结构轻量紧凑;目前已有1253人学习下载。资源核心是根据WS2812每一位的时序要求,构造合适的串行外设接口发送数据,并通过直接存储器访问将预置缓冲区连续写入发送寄存器,避免中断或软件延时造成的数据错位;利用环形缓冲更新颜色序列,即可循环呈现流水灯效果。同时工程中包含串行外设接口、直接存储器访问、通用输入输出等模块的初始化配置,可作为二次开发的起点。
1. 当流水灯变成 SPI 协议问题:一帧 0.4ms 的硬件搬运方案为什么值得上
我第一次用软件模拟时序驱动 120 颗 WS2812 跑流水灯,现场看着挺流畅。直到设备温度起来、后台定时器一抢 CPU,灯珠就隔三差五闪一下——逻辑分析仪一抓,波形被中断切得千疮百孔。WS2812 的下发本质不是「点灯」,是时序极窄的串行协议搬运。换用 STM32 硬件 SPI + DMA 之后,120 颗灯一帧数据下发只要 0.4ms 左右,CPU 只需要在 DMA 完成中断里往内存写下一帧。这篇实战笔记基于 HAL 库和 CubeMX,不只给代码,还把配置参数、编码方式和几个容易翻车的坑一起说清。适合正在用软件延时点灯、想换硬件方案的人。
2. 选型与配置:为什么是硬件 SPI 而不是 PWM 或 IO 口模拟
2.1 三个可选方案,先把时序账算明白
WS2812 的单线协议有个很硬核的特点:0 和 1 靠高电平宽度区分,典型值是 T0H 220~380ns、T1H 580~1000ns,单个码元周期允许在 0.65~1.85us 之间浮动。这个窗口看着宽,但一旦被中断打断,波形就不是「宽度不对」而是「毛刺乱跳」,灯珠直接误判。
软件 IO 翻转是最容易上手的方案:把数据脚拉高,delay 几百纳秒,再拉低。问题在于这个 delay 不能被抢占,否则一位数据就废了。我见过有人把定时器中断全关掉来点灯,灯是稳了,系统其它功能全废了。这个方案适合灯数少、中断可控的场景,超过二三十颗就建议换方案。
定时器 PWM + DMA 是第二个思路:用定时器比较通道输出不同占空比的方波,DMA 把 CCR 寄存器按预计算的表刷一遍。这个方案能做,但改颜色时要重新生成整个 CCR 表,占用的定时器资源也不便宜,调起来有点绕。
硬件 SPI + DMA 的优势在于:SPI 本身就是移位寄存器,MOSI 数据流由硬件连续搬出,DMA 负责从内存灌数据。我们只需要做一件事——把 WS2812 的每个 bit 映射成 SPI 发送的字节。这样协议时序交给外设,CPU 只改内存里的映射表。注意这个方案能成立的前提是 SPI 时钟分频算得准,下面我用一个实际能跑的组合展开。
| 方案 | 抗中断能力 | 资源开销 | 改色成本 | 适用场景 |
|---|---|---|---|---|
| 软件 IO 翻转 | 差 | 仅 GPIO | 低 | 灯少、裸机、关中断可接受 |
| TIM PWM + DMA | 中 | 定时器+通道 | 高 | 单色/固定颜色灯带 |
| SPI + DMA | 强 | SPI+DMA 通道 | 低 | 多灯、动态变色 |
2.2 CubeMX 里把 SPI + DMA 配好:从分频到回调
下面这套配置是我在 STM32F103 上验证过的:72MHz 系统时钟,SPI1 挂在 APB2 上,选BaudRatePrescaler_8,得到 9MHz SPI 时钟。8 个 SPI bit 对应一个 WS2812 bit,一个码元周期约 0.89us,落在规格范围内。
CubeMX 里打开 SPI1,Mode 选 Full-Duplex Master,硬件 NSS 关掉(我们只用 MOSI,不用片选),Clock Polarity 和 Phase 都选 Low / 1Edge,也就是 CPOL=0、CPHA=0,First Bit 选 MSB First。分频选 8。DMA 设置里给 SPI1_TX 添加一个 DMA 请求,Direction 选 Memory-to-Peripheral,Mode 选 Normal,数据宽度都是 Byte,优先级给 High。
hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;这里每个字段都有讲究。SPI_POLARITY_LOW保证 SPI 空闲时时钟线是低电平,这跟后面 MOSI 空闲电平无关,但会影响边沿采样的一致性。SPI_BAUDRATEPRESCALER_8在 72MHz 下是 9MHz,换算过来每个 SPI bit 约 111ns,后面编码要用这个数去推高电平时长。如果换到 168MHz 的 F4,分频要重新选,不能照抄。
DMA 初始化的关键字段看这几行:
hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPHERAL; 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;PeriphInc必须关掉,因为 SPI 数据寄存器只有一个。Mode我选 Normal 而不是 Circular,原因很实际:Circular 模式下 DMA 发完一帧会自动从头再发,但如果应用层正好在一帧没发完时改了 buffer,灯珠就会在同一帧里看到新旧两段数据,出现花色残影。Normal 模式每次都要手动重启 DMA,看似麻烦,反而逼着你在正确的时机更新数据。
提示:CubeMX 生成的 DMA 请求编号(DMA_REQUEST_SPI1_TX)在不同系列上不一样,F1 和 F4 的 DMA 通道映射表完全不同,移植时先查参考手册的 DMA 请求映射表。
3. 编码与数据流:从 GRB 到 SPI 码元再到流水灯
3.1 码元映射:一个 SPI 字节对应一个 WS2812 bit
现在到了整个方案的核心:把 WS2812 的 bit 流翻译成 SPI 字节流。我用的映射关系很直接:
| WS2812 bit | SPI 字节 | 二进制 | 高电平时长(9MHz) | 码元周期 |
|---|---|---|---|---|
| 0 | 0xC0 | 1100 0000 | 222ns | 0.89us |
| 1 | 0xFC | 1111 1100 | 667ns | 0.89us |
0xC0 的前两位是 1,后面六位是 0,高电平占 222ns,落在 T0H 区间。0xFC 前六位是 1,高电平 667ns,落在 T1H 区间。一个 WS2812 bit 占用 8 个 SPI bit,总共 0.89us,周期也满足规格。这个映射的好处是编码极快,查表就行。
下面是把 24 位 GRB 颜色拆成 24 个 SPI 字节的代码:
static const uint8_t ws2812_bit_map[2] = {0xC0, 0xFC}; void pixel_to_spi(const uint8_t grb[3], uint8_t *spi_out) { for (int i = 0; i < 24; i++) { uint8_t byte_index = i / 8; uint8_t bit_index = 7 - (i % 8); uint8_t bit = (grb[byte_index] >> bit_index) & 0x01; spi_out[i] = ws2812_bit_map[bit]; } }byte_index定位 GRB 的第几个字节,bit_index是高位先出。WS2812 的数据顺序是 GRB,不是 RGB,这里千万别搞反,后面避坑章会细说。外层循环固定 24 次,因为灯珠颜色就是 24 位。这个函数每调用一次输出 24 字节,对应一颗灯。
如果想省内存,可以用 4 位 SPI bit 映射一个 WS2812 bit,但那样高电平时长会掉到 111ns 级别,基本在规格边界外,灯珠能不能识别全看批次人品。我一般不用省这个内存,24 字节一颗灯已经非常宽裕。
3.2 DMA 缓冲与发送框架:Normal 模式下的安全更新
缓冲区设计是整个方案的骨架。我习惯这么声明:
#define LED_COUNT 120 #define RESET_BYTES 512 static uint8_t led_dma_buf[LED_COUNT * 24 + RESET_BYTES]; static volatile uint8_t dma_busy = 0;led_dma_buf前LED_COUNT * 24字节是颜色数据,后面RESET_BYTES个 0x00 是复位脉冲。512 字节在 9MHz 下约 57us,超过 WS2812 要求的 50us 复位时间。为什么不只发 450 字节?因为我见过 450 字节在个别批次灯珠上复位不干净,末尾几颗灯会残留上一帧颜色,多留 60 字节买个心安。
发送函数和完成回调如下:
void ws2812_start(void) { while (dma_busy) { // 等上一帧发完,避免直接改 buffer } dma_busy = 1; HAL_SPI_Transmit_DMA(&hspi1, led_dma_buf, sizeof(led_dma_buf)); } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { dma_busy = 0; } }dma_busy是轻量级的互斥标志。ws2812_start进入时如果上一帧还没发完,就自旋等待。这个自旋不会卡很久,一帧 120 颗灯约 1.2ms,完全可以接受。完成回调在中断上下文执行,只做清标志,不做重活。
注意:
HAL_SPI_Transmit_DMA的第三个参数是字节数,不是灯数。120 颗灯要传120 * 24 + 512 = 3392。很多人第一次写传成LED_COUNT,结果只发了 120 字节,灯全乱闪。
3.3 流水灯效果:把效果逻辑和数据更新解耦
流水灯的逻辑很简单:每一帧让亮灯的起点往前挪一位。难的是让效果刷新和 DMA 发送节奏配合好。我的做法是主循环里按 tick 刷新 buffer,刷新完立即调ws2812_start:
void build_flow_frame(uint32_t start_index, uint8_t r, uint8_t g, uint8_t b) { uint8_t black[3] = {0, 0, 0}; uint8_t color[3] = {g, r, b}; // GRB 顺序 for (int i = 0; i < LED_COUNT; i++) { uint8_t *dst = &led_dma_buf[i * 24]; if (i >= start_index && i < start_index + 10) { pixel_to_spi(color, dst); } else { pixel_to_spi(black, dst); } } for (int i = 0; i < RESET_BYTES; i++) { led_dma_buf[LED_COUNT * 24 + i] = 0x00; } } int main(void) { // HAL_Init 和 SystemClock_Config 这里省略 uint32_t last_tick = HAL_GetTick(); uint32_t flow_index = 0; while (1) { if (HAL_GetTick() - last_tick >= 20) { last_tick = HAL_GetTick(); flow_index = (flow_index + 1) % (LED_COUNT + 10); build_flow_frame(flow_index, 255, 80, 0); ws2812_start(); } } }这段代码把效果计算和底层发送分开了:build_flow_frame只做一件事——往led_dma_buf里填目标图案;ws2812_start只做一件事——把 buffer 交给 DMA。两个函数改任何一个都不会影响另一个的时序。20ms 刷新间隔对应 50Hz,肉眼看不到闪烁。
颜色参数 (255, 80, 0) 是橙色,注意color数组的初始化顺序是{g, r, b},因为pixel_to_spi按 GRB 顺序解析。这里绕一下,能省掉后面好多「颜色不对」的排查。
4. 避坑排查:我踩过的五个 SPI-DMA 驱动 WS2812 的坑
4.1 缓冲改了半天灯不刷新:DMA 缓存一致性问题
现象:在 F407 板子上改了led_dma_buf的值,重新调用ws2812_start,灯珠却显示旧颜色,偶尔重启后才正常。
原因:带 D-Cache 的 Cortex-M4/M7 核,CPU 写内存时数据停留在 Cache 里,DMA 读的是物理内存,读到的是旧值。F103 这类 M3 核没有 D-Cache,不会出这个问题,所以很多人一换 F4 就懵了。
解决:发送前强制把 Cache 刷到内存。在ws2812_start里加一句:
SCB_CleanDCache();这条指令会同步 Cache 和物理内存,之后 DMA 才能拿到最新数据。如果用的是 STM32H7,还可以把 LED buffer 放到不缓存的 DTCM 区域,或者在 MPU 里配置这段内存为 Non-cacheable,但最省事的就是 CleanDCache。
4.2 首尾灯偶发不亮:MOSI 空闲电平和复位长度都不够
现象:整条灯带亮灯正常,但第一颗灯偶尔不亮,最后一颗灯偶发残影,用示波器看复位波形,低电平只有 20us。
原因:两个问题叠在一起。第一个是 SPI 发送结束后 MOSI 引脚回到了空闲电平,如果空闲电平被配置成高,灯珠会误认为还在收数据,复位永远不生效。第二个是复位字节数不够,我在 3.2 节算过 450 字节只是勉强够,还得留余量。
解决:CubeMX 里确认 SPI 的 CPOL 是 Low,SPI 停发后 MOSI 默认拉低。复位段用 512 字节,实测 120 颗灯连续跑一周没有尾灯残留。如果用的是某些国产兼容芯片,复位时间要求更严,我把RESET_BYTES拉大到 800,也就是约 89us,通用性更好。
4.3 红色命令显示成绿色:GRB 顺序是灯珠的铁律
现象:led_set(0, 255, 0, 0)想让第一颗灯亮红,实际亮成了绿色。
原因:WS2812 的数据格式是 GRB,不是 RGB。grb数组的第一个字节是绿色,第二个是红色。如果用习惯性的 RGB 顺序填数据,颜色当然全部错位。
解决:在pixel_to_spi内坚持按 GRB 解析,或者在效果层就把颜色数组按 GRB 排好。我的代码里color[3] = {g, r, b}就是干这个的。这个坑如果不写注释,过两天自己回来看都会忘。
4.4 RTOS 下灯带闪烁:DMA 传输中的共享 buffer 没有保护
现象:加了 RTOS 之后,流水灯在任务切换瞬间会闪一下,频率不固定,像接触不良。
原因:某个任务在 DMA 还在搬数据的时候改了led_dma_buf,前半帧是旧图案,后半帧是新图案,灯珠把半帧当一帧显示。裸机时主循环和 DMA 完成回调天然互斥,上 RTOS 后这种隐式互斥消失了。
解决:两个办法。轻量方案是在ws2812_start里自旋等dma_busy,像我 3.2 节那样。更稳的方案是双缓冲:两个 buffer 交替使用,DMA 读 A 时应用层填 B,DMA 完成后交换角色。双缓冲代码量翻一倍,但彻底消除了互斥等待,适合灯数多、刷新率要求高的场景。
4.5 换芯片后乱闪:分频参数直接照抄是最大的坑
现象:把 F103 的工程移植到 F401 上,同样 8 分频,灯带要么全灭要么乱闪。
原因:F401 的 APB2 时钟是 84MHz,8 分频后 SPI 时钟是 10.5MHz,比 F103 的 9MHz 快了 16%。0xC0 的高电平时长从 222ns 缩到 190ns,掉出了 T0H 的下边界。
解决:换芯片后必须重新算分频。计算公式很简单:SPI 时钟 = 外设总线时钟 / 分频数,要求码元高电平时长 = 2 / SPI_时钟 落在 220~380ns 之间。84MHz 下选 10 分频得到 8.4MHz,2 位高电平约 238ns,安全。每次移植都走一遍这个计算,不要凭经验照抄。
5. 进阶:Gamma 校正和多通道扩展的实际玩法
5.1 用查找表做 Gamma 校正,解决低亮度跳变
WS2812 内部是线性 PWM 调光,但人眼对暗部变化比亮部敏感得多。直接给亮度值 10、20、30,肉眼看前几级差别巨大,后面 200 和 255 反而看不出区别。解决方法是套 Gamma 曲线。我一般用 Python 生成一张 256 项查找表:
gamma = 2.8 table = [round(pow(i / 255.0, gamma) * 255) for i in range(256)] print(", ".join(f"0x{x:02X}" for x in table))把生成的表拷进 C 代码,效果层拿到的用户亮度值先进查表,再进编码:
uint8_t real_green = gamma_table[user_green]; uint8_t real_red = gamma_table[user_red]; uint8_t real_blue = gamma_table[user_blue];Gamma 校正后用 30、60、90 这样的线性输入,肉眼会看到均匀的亮度变化。这个技巧对流水灯里的渐变效果提升非常明显,不做这个,渐变总有一段特别生硬。
5.2 多路 SPI 并行驱动更多灯珠
如果灯珠数量超过 512 颗,单路 SPI 的帧时间会变长。512 颗灯一帧约 6.2ms,30Hz 刷新率还能接受,再往上就要考虑多路了。F103 有 SPI1 和 SPI2 两个外设,可以各接一路灯带,共用同一个效果逻辑。发送时两路 DMA 可以并发启动,帧时间减半。
需要注意的点:两路 DMA 完成回调要分别判断hspi->Instance,更新各自的 busy 标志。效果层如果想把同一个图案发到两路,build_flow_frame要只写共享的图案数据,再分别编码到两个 buffer,不要让一路发完才编码另一路,否则刷新率会掉一半。
另外还有一个实操技巧:逻辑分析仪在这个项目里基本是必需品。我第一次调时序时靠肉眼猜问题,改了三天没找到原因,接上逻辑分析仪一看,复位波形短了 10us。从那以后我每次接 WS2812 新板子,都强制先抓一帧波形,确认 T0H、T1H 和复位长度各就各位,再写效果层。这套流程省下来的排错时间,比买分析仪的钱值太多了,希望帮到你。
本文还有配套的精品资源,点击获取