简介:面向STM32F103开发者的WS2812灯带驱动工程,基于HAL库使用PWM+DMA硬件方式发送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来写。这篇文章不会讲太多空泛的原理,核心就三件事:为什么我最终选了PWM+DMA方案、CubeMX里到底怎么配置、呼吸/跑马/水滴三种效果在工程层面怎么落地。顺带把我在这个过程中踩过的坑一块儿记录下来,给后来人省点时间。
先说结论:用PWM+DMA驱动WS2812B,是F103这类没有硬件单线协议外设的单片机最省心的方案。CPU几乎零负担,时序稳定不飘,所有灯效的差异只体现在内存缓冲区里一个uint16_t数组的排列方式上。而且这套思路不挑芯片,换到G系列、F4系列,只要定时器有DMA请求能力,代码迁移成本极低。
1. 为什么是PWM+DMA而不是“点灯延时算法”
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长度也是负担。
PWM+DMA方案最优雅的地方在于:PWM外设本身就是为输出精确波形设计的,DMA是硬件搬运,不需要CPU参与每个bit的发送。CPU的活儿只剩下“提前算好CCR值数组”,然后启动一次DMA传输,剩下时间干别的。定时器每溢出一次,DMA自动往CCR寄存器写入一个新的比较值,下一个PWM周期的占空比就变了。高电平宽度完全由定时器的时钟精度保证,不依赖软件延时的稳定性。
1.3 800kHz下CCR的数学账:90个时钟一拍
STM32F103的主频是72MHz,1个时钟周期约13.89ns。把定时器的Prescaler设为0,即不分频,计数时钟就是72MHz。要使PWM频率为800kHz,那么一个PWM周期需要的时钟个数是:
72MHz / 800kHz = 90所以ARR(自动重载值)要设为89(因为计数从0到89一共是90个时钟)。在这个前提下,设CCR=20,高电平宽度就是20×13.89≈278ns,对应逻辑0;设CCR=60,高电平宽度就是60×13.89≈833ns,对应逻辑1。这两个值都落在WS2812B的判定区间内,而且离边界有足够余量。
这里有个容易忽略的点:CCR写入的是比较值,不是占空比百分比。ARR决定PWM周期长度,CCR决定高电平宽度。只有在ARR固定为89的前提下,CCR=20和CCR=60才分别代表逻辑0和逻辑1。很多人一上来改ARR或者Prescaler,导致PWM频率不是800kHz,时序自然就乱了。
2. CubeMX里面的配置动作:选对TIM、DMA和数据宽度
2.1 时钟树:TIM3的输入时钟是72MHz而不是36MHz
这是我在CubeMX里栽过最狠的跟头。配完时钟树后打开TIM3,发现PWM频率怎么算都不对,一开始以为Prescaler设错了,查了半天才发现问题出在APB1预分频上。
STM32F103的默认时钟配置通常是:SYSCLK=72MHz,APB1预分频为2(所以APB1外设时钟是36MHz),但定时器时钟有个特殊规则——如果APB1预分频不为1,定时器时钟自动×2,变成72MHz。所以CubeMX里看到的往往只是APB1=36MHz,但定时器时钟树分支上其实写着72MHz。在CubeMX的Clock Configuration页面里选中TIM3的时钟源,能看到“APB1 Timer Clock”这一项,得确认它是72MHz,不是36MHz。这一步错了,后面所有CCR计算全部作废。
2.2 参数组合与DMA通道
我用的是TIM3的CH1,对应引脚PA6,CubeMX里这样配置:
- TIM3 Clock Source:Internal Clock
- Channel1:PWM Generation CH1
- Prescaler:0
- Counter Period:89
- Auto-reload preload:Enable
- Initial Pulse(初始CCR):0
- DMA请求:Add TIM3_CH1,Mode选Normal,Data Width全部选Half Word,Memory Increment选Enable
DMA配置里有个细节,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的PA6,GND必须和STM32共地。信号线上串一个330欧姆电阻,能有效抑制振铃,特别是灯带供电线较长时,这个电阻能减少数据波形过冲。
很多人拿着ST-Link的USB口给灯带供电,这是最大的坑。单颗WS2812B全白亮度时电流约60mA,30颗就是1.8A,USB口一般只能给500mA,带不动。实测结果是:灯带亮度一拉满,系统电压被拉低,STM32直接复位,或者最后一两米灯珠颜色明显偏色。所以实验阶段最好用独立5V电源,功率至少按灯珠数量×60mA再留50%余量,并把电源地、灯带地、STM32地三点连在一起。
另一个需要注意的点是输入逻辑电平。WS2812B在5V供电时,有些批次的VIH最低要求是0.7×VDD=3.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×24+50=1490。注意这个参数类型是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的数据。因为CCR=0意味着整个PWM周期都是低电平,一个周期1.25us,50个就是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 水滴灯:高斯衰减模拟重力拖尾
水滴灯和跑马灯的区别在于水滴有“弹性拖尾”,头部亮、后面拖着一条逐渐变暗的尾巴,而且尾巴形状更像水滴而不是线性衰减。我用了固定衰减模板,避免每次计算expf:
const 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_t
HAL库里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数据宽度,其他逻辑可以直接搬过去。
本文还有配套的精品资源,点击获取