公司动态

STM32驱动WS2812B:PWM+DMA方案与灯效实现

📅 2026/9/2 5:41:04
STM32驱动WS2812B:PWM+DMA方案与灯效实现
简介面向STM32F103开发者的WS2812灯带驱动工程基于HAL库使用PWMDMA硬件方式发送RGB数据彻底规避传统延时翻码占用MCU线程、易被中断打断导致显示错乱的问题适合需要兼顾灯效与主控实时性的嵌入式场景。工程内置呼吸灯、跑马灯、水滴灯等炫彩模式驱动函数均已封装好只需修改形参即可配置灯珠数量、颜色以及呼吸/流水速度可快速移植到智能家居、氛围灯或入门教学项目中。压缩包共137个文件约5.36MB以C/H源码、Keil工程文件uvprojx、调试信息文件o/crf/axf及hex固件、ioc配置为主其中h/c文件对应HAL库驱动源码o/crf等调试文件有助于问题定位整体结构清晰。已有3855人学习下载。通过学习本例可掌握定时器PWM输出与DMA搬运在WS2812时序控制中的配合方式同时获得一套可直接编译运行的Keil工程便于在此基础上继续扩展更多动画效果。 趁中午吃饭的空档把这段时间折腾STM32F103驱动WS2812灯带的经历整理一下。先说明一个事标题里的“SW2812”我猜大概率是WS2812B的笔误因为市面单线协议灯带基本都是WS2812B这个型号下文统一用WS2812B来写。这篇文章不会讲太多空泛的原理核心就三件事为什么我最终选了PWMDMA方案、CubeMX里到底怎么配置、呼吸/跑马/水滴三种效果在工程层面怎么落地。顺带把我在这个过程中踩过的坑一块儿记录下来给后来人省点时间。先说结论用PWMDMA驱动WS2812B是F103这类没有硬件单线协议外设的单片机最省心的方案。CPU几乎零负担时序稳定不飘所有灯效的差异只体现在内存缓冲区里一个uint16_t数组的排列方式上。而且这套思路不挑芯片换到G系列、F4系列只要定时器有DMA请求能力代码迁移成本极低。1. 为什么是PWMDMA而不是“点灯延时算法”1.1 WS2812的时序账先把这个单线协议讲透WS2812B的数据线只有一根但数据是串行流每个灯珠吃24位数据典型的是GRB三色各8位。芯片内部靠“高电平持续宽度”来区分逻辑0和逻辑1不是靠上升沿或下降沿。所以一旦高电平宽度发错整条灯带就会乱收数据。时序要求大致是这样以800kHz刷新率为例即每个bit周期1.25us信号高电平时间低电平时间备注逻辑0约220~380ns约870~1030ns高电平占比约20%逻辑1约580~1000ns约250~420ns高电平占比约70%RESET低电平持续50us以上-数据帧结束标志只要高电平宽度落在表格范围内芯片就能正确识别。这给PWM方案留下了充足的操作空间一个800kHz的PWM周期正好是1.25us通过改变CCR寄存器比较值来改变每个周期里高电平的宽度一个PWM周期就是一个bit。24个周期就是一颗灯珠的GRB数据24×N个周期就是整条灯带的一帧。1.2 三种驱动方案的真实对比很多新手第一次点WS2812第一反应是GPIO翻转加延时。我也干过这事结果就是主循环被点灯函数死死按住延时期间中断全部排队蓝牙串口接收直接卡死。就算把延时精度调到us级别F103上GPIO翻转本身就有几十ns的开销再加上中断延迟的不确定性高电平宽度很容易飘出规格范围。这条路线只适合一次点亮一两颗灯珠做教学验证做动态灯效基本是死路。第二个常见方案是用SPI。SPI MOSI输出频率8Mbps用三个bit编码一个数据bit例如发送110表示逻辑1发送100表示逻辑0再配合一段小的查表逻辑。这个方案的优点是稳定缺点是SPI外设被独占了你没法同时用SPI挂传感器或屏幕。而且三倍数据量对内存和DMA长度也是负担。PWMDMA方案最优雅的地方在于PWM外设本身就是为输出精确波形设计的DMA是硬件搬运不需要CPU参与每个bit的发送。CPU的活儿只剩下“提前算好CCR值数组”然后启动一次DMA传输剩下时间干别的。定时器每溢出一次DMA自动往CCR寄存器写入一个新的比较值下一个PWM周期的占空比就变了。高电平宽度完全由定时器的时钟精度保证不依赖软件延时的稳定性。1.3 800kHz下CCR的数学账90个时钟一拍STM32F103的主频是72MHz1个时钟周期约13.89ns。把定时器的Prescaler设为0即不分频计数时钟就是72MHz。要使PWM频率为800kHz那么一个PWM周期需要的时钟个数是72MHz / 800kHz 90所以ARR自动重载值要设为89因为计数从0到89一共是90个时钟。在这个前提下设CCR20高电平宽度就是20×13.89≈278ns对应逻辑0设CCR60高电平宽度就是60×13.89≈833ns对应逻辑1。这两个值都落在WS2812B的判定区间内而且离边界有足够余量。这里有个容易忽略的点CCR写入的是比较值不是占空比百分比。ARR决定PWM周期长度CCR决定高电平宽度。只有在ARR固定为89的前提下CCR20和CCR60才分别代表逻辑0和逻辑1。很多人一上来改ARR或者Prescaler导致PWM频率不是800kHz时序自然就乱了。2. CubeMX里面的配置动作选对TIM、DMA和数据宽度2.1 时钟树TIM3的输入时钟是72MHz而不是36MHz这是我在CubeMX里栽过最狠的跟头。配完时钟树后打开TIM3发现PWM频率怎么算都不对一开始以为Prescaler设错了查了半天才发现问题出在APB1预分频上。STM32F103的默认时钟配置通常是SYSCLK72MHzAPB1预分频为2所以APB1外设时钟是36MHz但定时器时钟有个特殊规则——如果APB1预分频不为1定时器时钟自动×2变成72MHz。所以CubeMX里看到的往往只是APB136MHz但定时器时钟树分支上其实写着72MHz。在CubeMX的Clock Configuration页面里选中TIM3的时钟源能看到“APB1 Timer Clock”这一项得确认它是72MHz不是36MHz。这一步错了后面所有CCR计算全部作废。2.2 参数组合与DMA通道我用的是TIM3的CH1对应引脚PA6CubeMX里这样配置TIM3 Clock SourceInternal ClockChannel1PWM Generation CH1Prescaler0Counter Period89Auto-reload preloadEnableInitial Pulse初始CCR0DMA请求Add TIM3_CH1Mode选NormalData Width全部选Half WordMemory Increment选EnableDMA配置里有个细节Peripheral Data Width和Memory Data Width都要是Half Word。因为TIM3的CCR寄存器是16位的DMA搬运的数据宽度必须与之匹配。如果选成Byte高8位会一直被清零CCR永远写不进正确的值。Memory Increment必须开否则DMA每次从同一个内存地址取数据灯带看到的数据全是同一个bit。Mode选Normal而不是Circular。Normal模式意味着DMA传输完缓冲区长度后自动停止符合“发完一帧就歇着”的思路。Continuous Requests这个选项在Normal模式下没有实际意义它主要配合Circular模式做连续刷新我们这里用不上。2.3 硬件接线与最小电源方案硬件部分看起来简单但坑都在电源上。WS2812B的VDD接5V电源DIN信号线接STM32的PA6GND必须和STM32共地。信号线上串一个330欧姆电阻能有效抑制振铃特别是灯带供电线较长时这个电阻能减少数据波形过冲。很多人拿着ST-Link的USB口给灯带供电这是最大的坑。单颗WS2812B全白亮度时电流约60mA30颗就是1.8AUSB口一般只能给500mA带不动。实测结果是灯带亮度一拉满系统电压被拉低STM32直接复位或者最后一两米灯珠颜色明显偏色。所以实验阶段最好用独立5V电源功率至少按灯珠数量×60mA再留50%余量并把电源地、灯带地、STM32地三点连在一起。另一个需要注意的点是输入逻辑电平。WS2812B在5V供电时有些批次的VIH最低要求是0.7×VDD3.5V而STM32的GPIO高电平输出只有3.3V存在临界风险。实际使用中很多灯带确实在3.3V下能工作但为了稳定我一般会加一个简单的电平转换比如74AHCT1G125或者退一步把灯带供电电压降到4.5V左右来降低VIH阈值。手头没有电平转换芯片时至少要用万用表量一下DIN脚在高电平时的实际电压。3. 数据缓冲区就是一张“CCR映射表”3.1 GRB码序与dma_buf布局整个方案的核心数据结构就一个uint16_t数组。每个灯珠对应24个元素每个元素的值是T0H_CCR逻辑0或T1H_CCR逻辑1由该位的颜色数据决定。WS2812B的发送顺序是GRB不是RGB这一点非常容易搞反。我最早调通第一帧时看到红色要求变成了绿色还以为是硬件接错线了。#define LED_COUNT 60 #define TIM_PERIOD 89 #define T0H_CCR 20 #define T1H_CCR 60 #define LED_BIT_NUM 24 #define RESET_PULSE_NUM 50 uint16_t dma_buf[LED_COUNT * LED_BIT_NUM RESET_PULSE_NUM]; void led_set_pixel_rgb(uint16_t index, uint8_t r, uint8_t g, uint8_t b) { uint32_t code ((uint32_t)g 16) | ((uint32_t)r 8) | b; uint16_t *p dma_buf[index * LED_BIT_NUM]; for (int i LED_BIT_NUM - 1; i 0; i--) { *p (code (1UL i)) ? T1H_CCR : T0H_CCR; } }code用了uint32_t因为24位数据在移位过程中会超过16位范围。循环从最高位出发保证了发送顺序是G的最高位先发出去。位顺序搞反的话灯珠会出现颜色对但亮度完全错乱的现象。3.2 发送一帧Start_DMA启动背后的机制缓冲区准备好了之后启动发送只需要一行HAL_TIM_PWM_Start_DMA(htim3, TIM_CHANNEL_1, (uint32_t *)dma_buf, DMA_BUF_SIZE);这个函数内部做了三件事配置并使能PWM输出使能TIM3的CC1 DMA请求启动DMA传输。从此每个PWM周期结束时定时器都会产生一个更新事件DMA控制器自动从dma_buf里取一个16位数据写入TIM3-CCR1下一个周期的占空比就变了。有个类型上的小陷阱HAL库的HAL_TIM_PWM_Start_DMA第三个参数是uint32_t *但我们的缓冲区是uint16_t数组。这里需要强转不是“类型不匹配”的错误。因为DMA真正关心的是内存地址和数据宽度数据宽度已经在CubeMX里配成Half Word了所以地址强转后DMA依然按16位为单位搬运。DMA_BUF_SIZE是缓冲区总长度也就是60×24501490。注意这个参数类型是uint16_t所以缓冲区长度超过65535要换思路。60颗灯珠的缓冲区才1490×2≈3KB对F103C8T6的20KB RAM来说非常轻松。发送完成后HAL库会触发PulseFinished中断回调。这是判断“一帧发完”的最可靠信号volatile uint8_t dma_busy_flag 0; void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { dma_busy_flag 0; } }3.3 复位周期的安排最后50个0值不是必需的但很省事WS2812B收到完整一帧数据后需要一段低电平复位信号来锁存数据。如果复位时间不够灯带可能把当前帧的末尾误当成下一帧的开头导致最后一颗灯珠显示异常或者整条灯带颜色错乱。最简单的做法就是在dma_buf末尾追加一小段值为0的数据。因为CCR0意味着整个PWM周期都是低电平一个周期1.25us50个就是62.5us超过50us的复位要求。DMA发完这些0值后CCR寄存器会保持在0引脚持续输出低电平灯带保持当前帧的画面不会熄灭。如果不用这个尾巴也可以在主函数里等PulseFinished回调后手动把PA6拉低延时60us再进入下一帧。两种方式都行但追加0值的方式不占用CPU时间而且代码里少一个延时函数。4. 三种灯效的工程实现呼吸、跑马、水滴三种效果做到最后会发现它们共享同一套流程先更新逻辑层颜色数组再把颜色数组编码到dma_buf最后调用led_show发送。所以主循环可以统一成typedef enum { MODE_BREATH, MODE_RUNNER, MODE_DROP } led_mode_t; led_mode_t mode MODE_BREATH; uint32_t next_update 0; while (1) { if (HAL_GetTick() next_update) { next_update 20; // 每20ms更新一帧约50fps switch (mode) { case MODE_BREATH: breath_update(next_update); break; case MODE_RUNNER: runner_update(); break; case MODE_DROP: drop_update(); break; } led_show(); } }led_show负责把颜色数组批量编码到dma_buf并启动DMA三种模式只需要各自准备颜色数据。4.1 呼吸灯先过一版Gamma再谈呼吸呼吸灯看着简单实际做出来容易给人一种“只有20%到30%亮度在呼吸”的感觉。原因是人眼对亮度的感知不是线性的如果直接把0到255的线性值写进灯珠中间段亮度变化会很不明显。解决办法是Gamma校正。我做了一个查表uint8_t gamma8[256]; void gamma_init(void) { for (int i 0; i 256; i) { float v i / 255.0f; gamma8[i] (uint8_t)(powf(v, 2.2f) * 255.0f 0.5f); } }呼吸节奏用正弦波最自然但M3没有FPU频繁调用sinf会有软浮点开销。虽然每20ms算一次影响不大我还是更推荐用查找表或者整型方式void breath_update(uint32_t tick) { float phase (float)(tick % 2000) / 2000.0f * 2.0f * 3.1415926f; uint8_t bright (uint8_t)((sinf(phase) 1.0f) * 0.5f * 255.0f); uint8_t b gamma8[bright]; for (int i 0; i LED_COUNT; i) { led_set_pixel_rgb(i, (uint16_t)R_BASE * b 8, (uint16_t)G_BASE * b 8, (uint16_t)B_BASE * b 8); } }R_BASE/G_BASE/B_BASE是呼吸灯的基础颜色。先归一化到0-255的亮度值再过Gamma表最后乘基础色。这样呼吸过程中暗部和亮部的变化都比较均匀看起来才叫“呼吸”。4.2 跑马灯头灯和尾巴的窗口滑动跑马灯最原始形态是一个亮点从头跑到尾但只有一个亮点在工程上显得很“干”。我习惯给亮点加一个衰减尾巴视觉效果柔和很多。实现上用一个循环索引head表示亮点的当前位置亮度和灯珠与head之间的循环距离挂钩#define TRAIL_LEN 6 void runner_update(void) { static uint8_t head 0; for (int i 0; i LED_COUNT; i) { uint8_t bright 0; int dist (i - head LED_COUNT) % LED_COUNT; if (dist 0) { bright 255; } else if (dist TRAIL_LEN) { bright 255 * (TRAIL_LEN - dist) / TRAIL_LEN; } led_set_pixel_rgb(i, (uint16_t)255 * bright 8, (uint16_t)60 * bright 8, (uint16_t)30 * bright 8); } head (head 1) % LED_COUNT; }用循环距离的好处是光点扫到末尾后能无缝从头部接上整条灯带看起来是一条连续的光带在循环滚动。如果不想首尾衔接把(i - head LED_COUNT) % LED_COUNT换成绝对距离判断就行。4.3 水滴灯高斯衰减模拟重力拖尾水滴灯和跑马灯的区别在于水滴有“弹性拖尾”头部亮、后面拖着一条逐渐变暗的尾巴而且尾巴形状更像水滴而不是线性衰减。我用了固定衰减模板避免每次计算expfconst uint8_t drop_profile[12] { 255, 220, 170, 120, 80, 50, 30, 18, 10, 5, 2, 0 }; void drop_update(void) { static int pos 0; for (int i 0; i LED_COUNT; i) { int idx i - pos 1; uint8_t bright 0; if (idx 0 idx 12) { bright drop_profile[idx]; } led_set_pixel_rgb(i, (uint16_t)80 * bright 8, (uint16_t)180 * bright 8, (uint16_t)255 * bright 8); } pos; if (pos LED_COUNT 8) { pos 0; } }这个实现里pos会一直走到灯带长度加8让水滴完全“落出”灯带底部再重新开始。固定模板的好处是每帧只做查表和乘法没有浮点运算F103跑起来毫无压力。调整模板数组的数值就能改变水滴头部的锐利程度和尾巴长度。5. 实测踩坑记录DMA中断、引脚状态和时序余量5.1 Start_DMA的参数陷阱uint32_t还是uint16_tHAL库里HAL_TIM_PWM_Start_DMA的pData参数是uint32_t *很多第一次用的人会顺理成章地定义一个uint32_t数组。实际上因为DMA数据宽度被配成Half Word缓冲区应该是uint16_t强转成(uint32_t *)不会被改变地址DMA依然按16位读取。如果真用了uint32_t数组DMA会按16位切分读取一个32位数据会被拆成两次传输颜色直接错乱。所以正确做法是缓冲区定义成uint16_t调用时强转即可。5.2 中断回调里别急着Stop_DMA我有段时间在PulseFinished回调里做了Stop_DMA再Start_DMA的操作结果偶尔出现整个系统卡死。问题出在HAL_TIM_PWM_Start_DMA和Stop_DMA内部都有关中断和开中断的临界区操作在中断回调里反复进入这些临界区如果与主循环里的调用发生嵌套句柄状态就乱了。我现在只在中断回调里置一个标志位主循环检测到这个标志位允许修改缓冲区时才操作DMA状态。任何对外设的启动、停止、参数修改都放在主循环里做。这个习惯帮我避开了大量偶发问题且代码可读性也更好。5.3 灯带数量、内存上限和刷新节奏的取舍dma_buf长度等于灯珠数×24再加50每个元素是2字节。F103C8T6只有20KB RAM如果灯带拉满400颗缓冲区就要19.2KB几乎吃掉全部内存。我实际建议120颗以内缓冲区约5.8KB系统还有余量跑逻辑和协议栈。灯带数量再多要么换大内存芯片要么分多次发送但没有静态内存作为“完整帧”做双缓冲会非常痛苦。刷新节奏上20ms一帧就是50fps的灯效刷新率。WS2812本身一帧数据的物理传输时间很短60颗灯只需约1.8ms所以灯效的更新瓶颈不在DMA而在你CPU计算颜色数组的速度。我实测过把更新周期压到10ms也没问题只是肉眼很难分辨50fps和100fps的区别。日常灯效给20ms足够了省下来的时间留给串口和传感器任务。最后分享一个我用下来最顺手的检查流程先不接灯带把dma_buf前几个数据打印出来确认0码的CCR是20、1码的CCR是60然后只接一颗灯珠发一个已知颜色验证码序确认无误后再接整条灯带调效果。每一步都有明确的判断依据出问题时能顺着链路很快定位。这套方案在我手上跑了几种不同批次的WS2812B稳定性都很好后续你换到STM32G0系列或者F4系列只需要重新核对一下定时器时钟频率和DMA数据宽度其他逻辑可以直接搬过去。本文还有配套的精品资源点击获取