1. 为什么WS2812B值得用DMA来驱动
如果你玩过WS2812B,大概率经历过这样的场景:用GPIO翻转模拟时序,主循环里塞一个for循环逐位输出,灯带一长,CPU就被彻底绑死,稍微来个串口中断,灯珠就开始随机闪烁、颜色错乱。这不是代码写得不好,而是驱动方式从根上就选错了。
WS2812B这类灯珠的通信协议非常"挑"——它用的是单线归零码,靠高电平持续时间的长短来区分0和1。典型参数是:码元周期1.25微秒,0码高电平约0.4微秒,1码高电平约0.8微秒,误差窗口只有±150纳秒左右。这意味着你必须在几百纳秒的精度上稳定输出波形,任何一次中断打断、任何一次函数调用开销,都可能让某一帧数据崩掉。
STM32的普通GPIO翻转方式,在72MHz的F103上,一条GPIO_SetBits加上循环开销,勉强能凑出这个时序,但代价是全程关中断,系统其他任务全部停摆。灯珠数量一多,比如144颗,一帧数据就是144×24×1.25微秒≈4.3毫秒,这4.3毫秒里你什么都干不了。
DMA驱动思路则完全不同:把要发送的波形数据预先算好,塞进内存缓冲区,让DMA控制器在定时器的节拍下自动把每一位搬到GPIO的输出寄存器。CPU只负责在开始前填好缓冲区,发送过程完全不占用CPU,也不怕中断打断。这就是"驱动艺术"的核心——把时序敏感的工作交给硬件,把CPU解放出来干正事。
这篇文章我会从CubeMX配置、缓冲区编码、DMA与定时器的联动、到实际调试中踩过的坑,完整走一遍。适合已经会点STM32、想把手里的灯带项目做稳做漂亮的同学,也适合想搞明白"定时器+DMA+GPIO"这套组合拳到底怎么配合的开发者。
2. 硬件层的关键认知:PWM加DMA为什么能还原WS2812B时序
2.1 WS2812B的码元到底长什么样
先把协议说透,不然后面配置全是玄学。WS2812B每颗灯珠接收24位数据,顺序是GRB(注意不是RGB),高位先发。每一位的编码规则如下:
| 数据位 | 高电平时间 | 低电平时间 | 总周期 |
|---|---|---|---|
| 0码 | 0.35微秒 | 0.90微秒 | 1.25微秒 |
| 1码 | 0.70微秒 | 0.55微秒 | 1.25微秒 |
| RESET | 大于50微秒低电平 | - | - |
关键点在于:0码和1码的总周期都是1.25微秒,也就是800kHz的刷新率。区别只在高电平占多少。这正好可以用PWM的占空比来模拟——固定频率800kHz,占空比约28%代表0,约56%代表1。
2.2 用定时器PWM加DMA搬运占空比
思路是这样的:让一个定时器工作在PWM模式,频率设成800kHz。它的CCR寄存器(捕获比较寄存器)决定占空比。正常情况下你写一次CCR,输出就固定了。但如果开启DMA,让DMA在每次定时器更新事件(UEV)时,自动把内存里的下一个CCR值搬进CCR寄存器,那么输出的占空比就会随内存数据逐位变化。
于是,一颗灯珠的24位数据,就对应内存里24个CCR值。整条灯带N颗灯珠,就是N×24个CCR值。DMA按顺序把这些值喂给定时器,定时器输出对应波形,WS2812B就收到了正确的数据。
这里有个细节必须想清楚:DMA的搬运触发源要选定时器的更新事件(TIMx_UP),而不是别的。因为每一位的周期是1.25微秒,定时器每溢出一次正好是一个码元周期,此时更新CCR,下个周期就输出新的占空比。时序天然对齐。
2.3 为什么不用SPI加DMA
网上还有一种流行方案是用SPI的MOSI线加DMA,把0码编码成0x80这类字节,靠SPI的位速率凑时序。这个方案也能跑,但有几个麻烦:SPI的速率要精确匹配,比如用2.5MHz或6.67MHz,不同主频的芯片要重新算分频;而且SPI一个字节8位,编码WS2812B的24位数据要凑成3个字节,边界处理容易出错。相比之下,PWM加DMA的方案,占空比直接对应高电平时间,物理意义清晰,参数好算,我个人更推荐。
3. CubeMX里的定时器与DMA配置实操
3.1 时钟树先算清楚
以STM32F103C8T6为例,系统主频72MHz,APB1定时器时钟也是72MHz(因为APB1预分频不为1时定时器时钟会倍频)。我们要800kHz的PWM频率,那么定时器计数周期 = 72MHz / 800kHz = 90。所以ARR(自动重装载寄存器)设为89(从0计到89是90个计数)。
占空比方面,0码高电平0.35微秒,对应计数值 = 0.35微秒 × 72 = 25.2,取25;1码高电平0.70微秒,对应50.4,取50。这两个值就是我们要往CCR里写的核心数据。实际调试时可以根据灯珠表现微调,比如有些批次的灯珠对0码高电平容忍度低,可以调到22到28之间试。
3.2 定时器参数配置
在CubeMX里选一个定时器,比如TIM2或TIM3,配置如下:
- Clock Source:Internal Clock
- Channel:选一个通道,比如Channel 1,模式设为PWM Generation CH1
- Prescaler:0
- Counter Period(ARR):89
- Pulse(初始CCR):0
- PWM Mode:PWM Mode 1
- CH Polarity:High
这样定时器就会在对应引脚输出800kHz的PWM波。注意引脚要选对,比如TIM2_CH1对应PA0,TIM3_CH1对应PA6,具体看你的板子。
3.3 DMA请求配置
在定时器的DMA Settings标签页里,点Add,添加一个DMA请求:
- DMA Request:TIMx_CHx(对应你选的通道)
- Direction:Memory to Peripheral
- Priority:High
- Mode:Normal(不是Circular,因为一帧发完就停)
这里Mode选Normal很关键。如果用Circular,DMA会无限循环发送,灯带会一直重复同一帧,而且你没法知道什么时候发完。Normal模式下,DMA搬完指定数量的数据后自动停止,并置位传输完成标志,我们可以用中断或轮询来确认一帧结束。
3.4 数据宽度与地址对齐
DMA的源地址是内存缓冲区,目标地址是定时器的CCR寄存器。CCR是16位寄存器,所以:
- Peripheral Data Width:Half Word(16位)
- Memory Data Width:Half Word(16位)
如果你用Word(32位),DMA会一次搬4字节,CCR只取低16位,虽然也能跑,但内存浪费且容易搞错。统一用Half Word最干净。
3.5 生成代码后要补的关键几行
CubeMX生成的初始化代码里,DMA和定时器都配好了,但还差两件事:一是启动PWM输出,二是把DMA和定时器的更新事件关联起来。标准做法是在初始化后调用:
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);但光这样还不够,因为PWM启动后DMA还没触发。真正让DMA在每次更新事件搬运数据的,是定时器的DMA请求使能。CubeMX在配置DMA时其实已经通过__HAL_TIM_ENABLE_DMA做了关联,但保险起见,你可以在启动PWM后手动确认一下:
__HAL_TIM_ENABLE_DMA(&htim3, TIM_DMA_UPDATE);然后每次要发送一帧数据时,调用:
HAL_TIM_PWM_Start_DMA(&htim3, TIM_CHANNEL_1, (uint32_t *)ledBuffer, bufferSize);注意这里的ledBuffer是16位数组,但HAL库函数签名要uint32_t *,强转一下即可,DMA会按Half Word宽度搬运,不会出错。bufferSize是搬运的次数,也就是CCR值的个数,等于灯珠数×24。
4. 缓冲区编码:把颜色数据翻译成CCR序列
4.1 编码逻辑
假设你有LED_COUNT颗灯珠,每颗24位,那么缓冲区长度是LED_COUNT * 24个16位值。编码时,对每颗灯珠的颜色值(GRB顺序),从最高位开始逐位判断:是1就填CCR_ONE,是0就填CCR_ZERO。
#define CCR_ZERO 25 #define CCR_ONE 50 #define LED_COUNT 16 uint16_t ledBuffer[LED_COUNT * 24]; void encodeLed(uint8_t g, uint8_t r, uint8_t b, uint16_t *buf) { uint32_t color = ((uint32_t)g << 16) | ((uint32_t)r << 8) | b; for (int i = 23; i >= 0; i--) { buf[23 - i] = (color & (1UL << i)) ? CCR_ONE : CCR_ZERO; } }这段代码把一颗灯珠的24位从高到低展开,填进缓冲区。注意GRB顺序,很多新手直接按RGB填,结果颜色全错,红色变绿色,这是最常见的坑之一。
4.2 整条灯带的填充
void updateLedBuffer(uint16_t *buf) { for (int i = 0; i < LED_COUNT; i++) { encodeLed(ledData[i].g, ledData[i].r, ledData[i].b, &buf[i * 24]); } }ledData是你应用层的颜色数组,改完颜色后调用updateLedBuffer刷新缓冲区,再启动DMA发送。
4.3 RESET信号怎么处理
WS2812B要求一帧数据发完后,保持低电平至少50微秒,灯珠才会锁存并刷新。DMA发完最后一个CCR值后,定时器还在跑,会继续输出PWM。如果最后一个CCR值是CCR_ZERO,那输出就是低电平占大部分,但高电平还有25个计数,不够干净。
解决办法有两个:一是发完DMA后立刻停止PWM输出,让引脚保持低电平,延时50微秒以上再发下一帧;二是在缓冲区末尾追加若干个CCR_ZERO,让DMA多发一段,凑够50微秒的低电平时间。50微秒对应50/1.25=40个码元周期,所以追加40个CCR_ZERO即可。我一般用第一种,简单直接:
void sendLedFrame(void) { updateLedBuffer(ledBuffer); HAL_TIM_PWM_Start_DMA(&htim3, TIM_CHANNEL_1, (uint32_t *)ledBuffer, LED_COUNT * 24); // 等待DMA完成 while (htim3.hdma[TIM_DMA_ID_UPDATE]->State != HAL_DMA_STATE_READY); HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); // 保持低电平50微秒以上 delay_us(60); }这里用轮询等待DMA完成,简单可靠。如果你不想阻塞,可以用DMA传输完成中断,在回调里停PWM并启动下一帧。
5. 实测中那些让人抓狂的坑
5.1 第一颗灯珠总是颜色不对
这个现象非常普遍。原因通常是DMA启动瞬间,定时器可能已经跑了一段时间,第一个CCR值被写入的时机和更新事件没对齐,导致第一位码元被拉长或截断。解决办法是在启动DMA前先停止定时器,清零计数器,再启动。或者用HAL_TIM_PWM_Start_DMA之前先调用__HAL_TIM_SET_COUNTER(&htim3, 0),让计数器从0开始,保证第一个更新事件和第一个CCR值同步。
5.2 灯带中间随机闪烁
如果灯带不是全亮,而是中间某几颗随机闪,八成是DMA传输被其他高优先级中断打断了。DMA本身不会被中断打断,但如果你的系统里有比DMA优先级更高的中断频繁触发,可能影响DMA请求的响应。检查一下NVIC优先级,把DMA和定时器中断设为较高优先级。另外,确保DMA的Mode是Normal,如果是Circular,发完一帧后DMA会从头再来,灯带会重复显示旧数据。
5.3 颜色整体偏暗或偏亮
这是CCR_ZERO和CCR_ONE的值没调好。不同批次的WS2812B对高电平时间的要求略有差异。如果0码的高电平太长,灯珠可能把它误判成1码,导致颜色偏亮;反之偏暗。用示波器抓一下实际波形最靠谱,没有示波器就二分法试:先固定CCR_ONE=50,把CCR_ZERO从20到30逐个试,找到颜色最正的那个值。
5.4 灯珠数量多了之后刷新率下降
这是正常的。N颗灯珠一帧需要N×24×1.25微秒,16颗是0.48毫秒,144颗是4.32毫秒。如果你要跑动画,刷新率就是1/4.32毫秒≈231Hz,够用。但如果灯珠超过300颗,一帧超过9毫秒,刷新率降到110Hz以下,肉眼可能感觉到闪烁。这时候要考虑分区刷新或者换更高刷新率的灯珠型号。
5.5 CubeMX重新生成代码后DMA配置丢失
CubeMX有个坑:如果你在代码里手动改了DMA配置,下次在CubeMX里重新生成,这些改动会被覆盖。所以所有手动补充的代码,要么放在/* USER CODE BEGIN */和/* USER CODE END */之间,要么单独写成函数在main里调用。我习惯把LED驱动单独放一个ws2812.c和ws2812.h,CubeMX只负责生成底层初始化,应用层逻辑全在用户文件里,互不干扰。
6. 从能跑到好用:几个进阶优化思路
6.1 双缓冲让刷新不卡顿
如果你要做流畅动画,一帧发完才能改下一帧数据,中间有等待时间。用双缓冲可以解决:准备两个缓冲区,DMA发缓冲区A的时候,CPU往缓冲区B填下一帧数据,发完切换。这样CPU和DMA并行工作,刷新率能提上去。实现上就是两个数组加一个标志位,DMA完成中断里切换。
6.2 用DMA完成中断替代轮询
轮询等待DMA完成会阻塞CPU,虽然一帧只有几毫秒,但在多任务系统里不优雅。改成中断方式:
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { HAL_TIM_PWM_Stop_DMA(&htim3, TIM_CHANNEL_1); // 这里可以启动下一帧或置位标志 } }注意回调函数名,不同HAL版本可能不一样,有的是HAL_TIM_PWM_PulseFinishedCallback,有的是DMA传输完成回调。查一下你用的HAL库版本对应的回调名。
6.3 颜色渐变与亮度调节
WS2812B本身不支持全局亮度调节,但你可以用软件做。最简单的做法是在编码前对每个颜色值做缩放:
uint8_t scale(uint8_t val, uint8_t brightness) { return (uint16_t)val * brightness / 255; }brightness从0到255,0全灭,255全亮。这样调亮度不会改变颜色比例,比直接改CCR值安全。
6.4 电源与电平匹配
WS2812B每颗灯珠全白时约60毫安,16颗就是接近1安培。别直接用STM32的3.3V引脚供电,一定要外接5V电源,并且STM32的GPIO输出是3.3V电平,WS2812B要求高电平至少0.7×VDD=3.5V(5V供电时),3.3V可能识别不稳。稳妥做法是加一个电平转换,比如用74HCT245,或者把灯带供电降到4.5V左右让阈值降下来。我实测3.3V直驱在短灯带上能跑,但长灯带或电源波动时就容易出问题,别省这个转换电路。
7. 我个人在实际操作中的几点体会
这套PWM加DMA的驱动方式,我从F103用到F407,再到G0系列,核心逻辑没变过,变的只是时钟频率和ARR的计算。每次换芯片,第一件事就是重新算ARR和CCR值,别照搬旧参数。
调试阶段,我强烈建议先只点一颗灯珠,把红绿蓝三色轮流显示,确认时序和颜色顺序都对,再扩展到整条灯带。一上来就点144颗,出了问题你根本不知道是编码错、时序错还是电源不够。
还有一点,CubeMX虽然方便,但别把它当黑盒。生成的代码要能看懂,尤其是定时器初始化里ARR、PSC、CCR这几个值,以及DMA配置里的方向、宽度、模式。出问题时,这些就是你排查的入口。
最后,如果你要做产品,记得在DMA发送期间不要频繁改缓冲区,否则可能发出半新半旧的数据。用双缓冲或者加个发送忙标志,等一帧发完再改,这是稳定性的底线。