☰
STM32 PWM+DMA驱动WS2812呼吸灯:原理、CubeMX配置与代码详解
2026/10/5 1:10:22 网站建设 项目流程

玩WS2812灯带的人,早晚会撞上同一个问题:代码写得挺顺,但灯珠一多、效果一上强度,CPU就彻底被“拉去干苦力”了。我最早用普通IO口加延时函数驱动,30颗灯珠跑个呼吸灯还行,到了144颗灯珠做彩虹渐变,基本是灾难现场。后来换成STM32的PWM+DMA方案,整套逻辑一下子就通了:所有码位由定时器硬件产生,DMA负责把每个码位的比较值自动送进寄存器,CPU只负责“编舞”,不用自己上台“跳舞”。这篇文章就围绕这套方案,把WS2812呼吸灯的实现原理、CubeMX配置、完整代码和常见的坑一次讲透。

如果你是刚接触WS2812或者想把手上的灯带跑得更稳,这篇文章应该能让你少走很多弯路。我用的是STM32F103C8T6,30颗WS2812B灯珠,PA0输出数据信号,主频72MHz。整套工程在Keil环境下编译通过,效果跑起来丝滑,不卡顿、不占用额外CPU资源。

1. 为什么会选PWM+DMA这个方案

1.1 先聊聊WS2812的时序到底有多“矫情”

WS2812这类灯珠用的是单总线归零码协议,数据和时钟混在一根信号线上。它怎么区分0和1呢?靠的是高电平持续时间的长短。0码的高电平典型值是350ns,1码是700ns左右,整个码位的周期大约1.25μs。你看,这精度要求是微秒级甚至纳秒级的,用普通延时翻转IO口的方式虽然能跑,但误差一大就会翻车。

更麻烦的是灯珠的级联机制。一颗WS2812内部集成了驱动IC,它从DI引脚接收数据,吃掉属于自己的24bit(颜色数据),剩下的数据从DO口原样转发给下一颗灯珠。也就是说,如果你有30颗灯珠,你必须一口气往DI口灌入30×24=720个码位,中间不能有超过50μs以上的低电平,否则灯珠会认为这一帧结束了,直接把当前已经收到的数据锁存显示。这个时序约束,就决定了驱动方案不能太“随意”。

1.2 常见驱动方案横向对比

作为一个玩过好几种方案的人,我先把它们摆在一起对比一下,你看完就明白为什么最终选了PWM+DMA。

方案CPU占用率稳定性代码复杂度适合场景
延时翻转IO极高差,依赖延时精度低几颗灯珠、入门学习
定时器中断翻转高受中断优先级影响中中等灯数、对时序要求一般
SPI+DMA低较高中高有多余SPI外设时
PWM+DMA极低高中中长灯带、花式动态效果

延时翻转的方案,代码确实短,但MCU在发送几百上千个码位期间什么事都干不了。你想让灯带一边跑动态效果,一边还要处理按键、串口、传感器数据,那基本没有余量。定时器中断方案比纯延时好一些,但中断频繁进出一样有开销,而且中断优先级设置不好时会产生抖动。

PWM+DMA方案的优势在于:定时器负责产生稳定的PWM波形,DMA负责高速搬运数据,CPU在发送期间完全可以去做别的事情。这在做复杂效果时优势特别明显,呼吸灯、彩虹渐变、音乐频谱这类需要频繁刷新灯带数据的效果,都不会把主控卡死。

1.3 这套方案能扩展到哪里去

学会了PWM+DMA驱动WS2812,你能做的事其实很多:灯带氛围灯、矩阵屏、像素屏、鱼缸灯、桌面律动灯、甚至一些工业设备的状态指示系统。核心套路是一样的,变的只是上面的数据处理算法。把底层驱动搞定之后,上面跑的是单色呼吸、彩虹渐变,还是音乐频谱,就是纯软件层面的事了。

2. 原理拆解:一个码位是怎么“画”出来的

2.1 PWM周期里藏着0和1的秘密

先说说PWM怎么产生WS2812需要的码位。简单理解,定时器就像一个“节拍器”,每个节拍输出一个完整的高低电平周期;而CCR比较值决定这个周期里高电平占多少个时钟周期。

以STM32F103的72MHz主频为例,如果我设置自动重装寄存器ARR=89,预分频PSC=0,那么PWM频率就是72MHz/(89+1)=800kHz,对应周期1.25μs。这个时间刚好是一个WS2812码位的长度,非常工整。

那怎么表示0和1呢?我们设置两个固定的比较值:

  • 逻辑0:CCR=25,高电平时间=25/72MHz≈0.347μs
  • 逻辑1:CCR=51,高电平时间=51/72MHz≈0.708μs

这两个时间都在WS2812规定的范围内。0码高电平允许0.2~0.5μs,1码高电平允许0.55~0.85μs,所以这个配置是很稳的。

想象一下,定时器的计数器CNT每个周期从0加到89,当CNT小于CCR时引脚输出高电平,大于等于CCR时输出低电平。所以CCR值不同,输出的高电平宽度就不同,WS2812就能据此识别出这一位是0还是1。我们干的其实就是“把每个数据位翻译成对应的CCR值”这件事。

2.2 DMA在这场接力赛里负责什么

如果自己手动改CCR,那还是会有很大的CPU开销。DMA的作用,就是替我们完成“每次PWM周期结束后,把下一个码位对应的CCR值自动写入比较寄存器”这个重复劳动。

具体流程是这样的:我们先在内存里准备好一段数组,数组的每个元素就是每个码位的CCR值,比如数组内容是 [25, 51, 51, 25, ...],分别对应0、1、1、0…… 然后启动PWM和DMA,DMA会在定时器每个事件触发下,把数组里的元素依次搬运到定时器的CCR1寄存器。整个过程完全由硬件完成,CPU不需要干预。

等到整段数组搬运完毕,DMA会产生传输完成中断。在中断里,我们停止这一帧的发送,准备下一帧数据,再重新启动。这个“准备数据-启动DMA-等待完成-准备下一帧”的循环,就是动态效果的基本模型。

我用个生活化类比:PWM是节拍器,DMA是翻谱员,CPU是指挥。指挥把乐谱(数组)交给翻谱员,翻谱员按节拍一张一张翻,乐队的每个乐手(WS2812灯珠)按谱子演奏自己那部分。指挥可以在旁边干别的事,直到整首曲子(一帧数据)演奏完,再换下一份谱子。

2.3 RESET复位信号的处理细节

WS2812要求每帧数据之后,必须有一个至少50μs的低电平作为RESET信号,灯珠才会把收到的数据显示出来。这个细节很多人会忽略。

如果帧与帧之间靠程序延时50μs,那会白白占用CPU,而且控制不好容易出错。我的做法是在DMA发送数据的末尾,额外追加40个值为0的码位。因为每个码位周期是1.25μs,40个码位正好是50μs。这样,当DMA发完这些0值码位时,信号线上自然就保持了一段连续低电平,RESET信号自动生成了。

这个“在帧尾多塞40个0”的小技巧,是我试过好几种方案后觉得最省事的,不用额外开定时器,也不用延时函数,直接把RESET时间“编”进数据流里。

3. 硬件连接与CubeMX配置(STM32F103为例)

3.1 材料清单和接线

先列一下我用到的硬件:

  • STM32F103C8T6最小系统板一块
  • WS2812B灯带或灯环,30颗灯珠,5V供电
  • 逻辑分析仪或示波器(可选,排查时序问题时很有用)
  • 5V/2A以上的电源适配器
  • 1000μF电解电容(并联在电源端,防止浪涌)
  • 1kΩ电阻(串联在信号线上,减少噪声干扰)

接线其实非常简单:灯带DI接PA0,灯带5V接5V,灯带GND接GND。这里有两个关键点一定要提醒你:一是MCU和灯带必须共地,否则信号电平参考点不一样,数据容易乱;二是在灯带供电端并联一个大电容,WS2812启动瞬间电流很大,不加电容很容易把电源拉垮,甚至导致MCU复位。

3.2 CubeMX里的定时器与DMA配置

打开STM32CubeMX,芯片选STM32F103C8Tx,按下面配置:

时钟树:外部8MHz晶振,SYSCLK=72MHz,APB1=36MHz,但定时器时钟自动翻倍为72MHz。

TIM2配置:

  • Channel1设置为PWM Generation CH1,引脚映射到PA0
  • Prescaler=0
  • Counter Mode=Up
  • Period=89
  • Pulse=0(初始值先置0,等发送时再让DMA填充)
  • Output Polarity=High

DMA配置:

  • 选择TIM2_CH1,添加DMA
  • Direction=MemoryToPeripheral
  • Mode=Normal(不是Circular)
  • Data Width,内存和外设都选Half Word(16位)

这里要注意:DMA的数据宽度必须选Half Word,因为CCR是比较寄存器,16位宽度;pwm_buf数组也是uint16_t。如果选错了Byte,搬运数据就会错位,灯珠颜色和位置全乱。还有DMA模式要选Normal,选成Circular会让同一帧数据无限循环发送,效果直接乱套。

3.3 关键参数是怎么推算出来的

我直接告诉大家我在工程里最终用的参数,并附上计算过程。

目标码位周期约1.25μs,主频72MHz,所以一个周期内计数个数是72MHz×1.25μs=90,ARR=89。接着选0码的CCR值。0码高电平约0.35μs,计数个数=72×0.35≈25,所以CCR_ZERO=25。1码高电平约0.7μs,计数个数=72×0.7≈51,所以CCR_ONE=51。

这两个值并不需要特别死板,在WS2812允许的时序容限内,CCR_ZERO在20~30之间、CCR_ONE在45~60之间都可以。但不要设置得太极端,比如把1码的高电平做成0.85μs以上,虽然单个灯珠能吃,但换一批灯珠或线材较长时,容错率就下降了。

4. 完整代码实现

代码部分我分成三个文件:ws2812.h、ws2812.c和main.c。CubeMX生成的外设初始化代码就不全贴了,按上面的配置生成即可。

4.1 头文件 ws2812.h

#ifndef __WS2812_H #define __WS2812_H #include "main.h" #define PIXEL_NUM 30 // 灯珠数量 #define CCR_ZERO 25 // 逻辑0对应的CCR值 #define CCR_ONE 51 // 逻辑1对应的CCR值 #define RESET_SPACE 40 // 帧尾RESET长度,40个空码位约50us #define PWM_BUF_LEN (PIXEL_NUM * 24 + RESET_SPACE) void ws2812_init(void); void ws2812_set_rgb(uint16_t index, uint8_t r, uint8_t g, uint8_t b); void ws2812_build_frame(void); void ws2812_show(void); void ws2812_frame_done(void); #endif

4.2 核心驱动文件 ws2812.c

#include "ws2812.h" #include <string.h> extern TIM_HandleTypeDef htim2; extern DMA_HandleTypeDef hdma_tim2_ch1; static uint8_t led_rgb[PIXEL_NUM * 3]; // 显存:每个灯珠3字节R,G,B static uint16_t pwm_buf[PWM_BUF_LEN]; // DMA搬运的码位比较值数组 static volatile uint8_t sending = 0; // 发送状态标志 void ws2812_init(void) { memset(led_rgb, 0, sizeof(led_rgb)); memset(pwm_buf, 0, sizeof(pwm_buf)); // 初始让PA0保持低电平,避免空闲时发出一串无意义码位 __HAL_TIM_SET_AUTORELOAD(&htim2, 89); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); } void ws2812_set_rgb(uint16_t index, uint8_t r, uint8_t g, uint8_t b) { if (index >= PIXEL_NUM) { return; } index *= 3; led_rgb[index] = r; led_rgb[index + 1] = g; led_rgb[index + 2] = b; } void ws2812_build_frame(void) { uint16_t idx = 0; for (uint16_t p = 0; p < PIXEL_NUM; p++) { uint8_t r = led_rgb[p * 3]; uint8_t g = led_rgb[p * 3 + 1]; uint8_t b = led_rgb[p * 3 + 2]; // WS2812的颜色顺序是GRB,不是RGB uint32_t grb = ((uint32_t)g << 16) | ((uint32_t)r << 8) | b; // 从高位到低位逐个展开成CCR值 for (int8_t bit = 23; bit >= 0; bit--) { pwm_buf[idx++] = (grb & (1UL << bit)) ? CCR_ONE : CCR_ZERO; } } // 帧尾补40个空码位,相当于RESET信号 for (uint16_t i = 0; i < RESET_SPACE; i++) { pwm_buf[idx++] = 0; } } void ws2812_show(void) { // 上一帧没发完就等一下,避免缓冲区被改写 while (sending); ws2812_build_frame(); HAL_TIM_PWM_Stop_DMA(&htim2, TIM_CHANNEL_1); __HAL_DMA_CLEAR_FLAG(&hdma_tim2_ch1, DMA_FLAG_TC); sending = 1; HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t*)pwm_buf, PWM_BUF_LEN); } void ws2812_frame_done(void) { // 停止DMA发送,Pin脚保持低电平,等待下一帧 HAL_TIM_PWM_Stop_DMA(&htim2, TIM_CHANNEL_1); sending = 0; }

ws2812_build_frame是整个驱动里最核心的函数,先把颜色数据从RGB顺序转成WS2812需要的GRB顺序,再按位拆开,每一位对应一个CCR值。展开之后,一个灯珠24bit,30个灯珠就是720个码位,再加上40个RESET码位,总共760个元素。这就是pwm_buf数组的实际用途。

很多人第一次写的时候会把颜色顺序搞反,直接按RGB往里面塞。结果测试的时候,想让灯珠亮红色,实际亮出来的是绿色。这个问题我后面也会专门说。

4.3 DMA传输完成回调

在某一个地方,需要把DMA传输完成的事件接到ws2812_frame_done上。用HAL库的话,在stm32f1xx_it.c或者自己建的回调文件里加:

void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { ws2812_frame_done(); } }

这个回调是HAL库在DMA传输完成后自动调用的。注意,前提是CubeMX里使能了TIM2的DMA请求,并且DMA中断已经开启。

4.4 main.c里的呼吸灯效果

下面我给出三个实用的效果函数,你可以直接在main函数里循环调用。

先放一个HSV转RGB的辅助函数,色彩控制阶段会经常用到:

static void hsv2rgb(uint8_t h, uint8_t s, uint8_t v, uint8_t *r, uint8_t *g, uint8_t *b) { uint8_t region, remainder, p, q, t; if (s == 0) { *r = v; *g = v; *b = v; return; } region = h / 43; remainder = (h - region * 43) * 6; p = (v * (255 - s)) >> 8; q = (v * (255 - ((s * remainder) >> 8))) >> 8; t = (v * (255 - ((s * (255 - remainder)) >> 8))) >> 8; switch (region) { case 0: *r = v; *g = t; *b = p; break; case 1: *r = q; *g = v; *b = p; break; case 2: *r = p; *g = v; *b = t; break; case 3: *r = p; *g = q; *b = v; break; case 4: *r = t; *g = p; *b = v; break; default: *r = v; *g = p; *b = q; break; } }

然后是三个效果函数。

单色呼吸:

#define PI_F 3.14159265f void effect_single_breath(void) { static uint16_t frame = 0; float phase = ((float)(frame++) / 200.0f) * 2.0f * PI_F; uint8_t brightness = (uint8_t)((sinf(phase) + 1.0f) * 127.5f); for (uint16_t i = 0; i < PIXEL_NUM; i++) { ws2812_set_rgb(i, brightness, 0, 0); } ws2812_show(); HAL_Delay(5); }

彩虹呼吸:

void effect_rainbow_breath(void) { static uint16_t frame = 0; float phase = ((float)(frame++) / 200.0f) * 2.0f * PI_F; uint8_t brightness = (uint8_t)((sinf(phase) + 1.0f) * 127.5f); uint8_t hue = (uint8_t)(frame / 4); for (uint16_t i = 0; i < PIXEL_NUM; i++) { uint8_t r, g, b; hsv2rgb(hue + i * 2, 255, brightness, &r, &g, &b); ws2812_set_rgb(i, r, g, b); } ws2812_show(); HAL_Delay(5); }

流动呼吸:

void effect_flow_breath(void) { static uint16_t frame = 0; frame++; for (uint16_t i = 0; i < PIXEL_NUM; i++) { float local_phase = ((float)((frame * 2 + i * 4) % 400) / 400.0f) * 2.0f * PI_F; uint8_t b = (uint8_t)((sinf(local_phase) + 1.0f) * 127.5f); ws2812_set_rgb(i, b, (uint8_t)(b * 0.2f), (uint8_t)(b * 0.8f)); } ws2812_show(); HAL_Delay(6); }

main函数里这样调用:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_TIM2_Init(); ws2812_init(); while (1) { effect_rainbow_breath(); // 想换效果,就换这里 // effect_single_breath(); // effect_flow_breath(); } }

整套代码跑下来,你可以看到一个很明显的效果:所有灯珠一起缓慢变亮变暗,同时颜色逐渐变化,整个过渡过程非常顺滑,没有肉眼可见的闪烁。30颗灯珠、每秒刷新接近150帧,完全是溢出状态。

5. 呼吸灯效果调优笔记

5.1 亮度曲线决定呼吸的“高级感”

如果你直接用线性变化去控制亮度,比如从0均匀增加到255再降回来,实际看上去会有一种“突然亮起”“突然熄灭”的突兀感。原因是人眼对亮度的感知不是线性的,在低亮区域,人眼对亮度变化特别敏感,同样的步长,暗的时候感觉跳变很大,亮的时候反而不明显。

所以我一直用正弦曲线来生成呼吸灯亮度值。正弦函数自然地从0升到峰值再回落,变化率在两端小、中间大,和人眼的感知还算契合。如果想要更细腻,还可以对正弦值做平方或gamma处理,让低亮度段的变化更平缓。

比如可以这样简化处理:

uint8_t b = (uint8_t)(255.0f * sinf(phase)); b = (uint8_t)(((uint16_t)b * b) / 255);

这一行相当于把亮度曲线做了一次平方校正,效果会比纯正弦更柔和。缺点是在Cortex-M3上做除法运算稍微耗时,但呼吸灯这个量级的计算量,完全不影响速度。

5.2 帧率与平滑度的平衡

帧率太低了,你会看到亮度是一格一格跳过去的;帧率太高了,又没有必要,纯浪费CPU。以呼吸灯为例,每秒60到100帧已经非常顺滑了,人眼基本上感觉不到阶梯感。再往上,收益很小。

我的建议是:效果更新不要放在DMA中断里,而是在主循环中做。因为DMA中断应该越短越好,它只是一个“发完信号”,把耗时很长的三角函数计算放在中断里会让下一次DMA启动时间抖动,造成帧率不稳。我在工程里就是主循环计算颜色、调用ws2812_show,DMA完成回调只做状态复位。

如果你想让呼吸周期固定在某个时间,可以算一下:一个呼吸周期是“亮→暗→亮”,按200帧计算,每帧延时5ms,总时间是1秒左右。想要更慢,可以把帧数200改成400,或者每次HAL_Delay多一点。

5.3 长灯带时的RAM优化思路

PWM_BUF_LEN的计算公式是灯珠数乘以24,再加40。30颗灯珠时,pwm_buf大小是760个uint16_t,约1.5KB RAM,无所谓。但如果灯带加到256颗灯珠,pwm_buf就是6184个uint16_t,约12.4KB;加到512颗,直接超过24KB,F103C8T6的20KB RAM已经放不下了。

如果你需要驱动很长的灯带,我常用的思路是分帧发送加双缓冲。具体做法是:把pwm_buf拆成两个大小相等的缓冲区,一个正在被DMA发送,另一个CPU正在填充下一帧数据。DMA发送完成中断里切换缓冲索引,主循环马上开始填另一块。这样既不会让DMA闲着,也不会出现“等待发送完再填数据”的空窗期。代价是内存占用又翻了一倍,所以RAM紧张时要综合考虑灯带数量和缓冲区策略。

还有一个更省RAM的策略:在STM32F103这类16位数组存放CCR值的方案中,一个码位占2字节,其实有压缩空间。比如可以用位图存储每个码位的CCR值对应的索引,再用内存资源换取计算时间。不过对于多数场景,双缓冲已经够用了。

6. 常见问题与排查实录

6.1 问题速查表

现象可能原因解决办法
灯带完全不亮DI接错引脚、PWM未启动、DMA配置错误检查PA0接线;确认TIM2的PWM初始化成功;检查DMA请求选择TIM2_CH1
只有第一颗灯亮,后面全不亮帧间RESET不足或数据长度不够确认RESET_SPACE设置为40以上;检查PIXEL_NUM与实际灯珠数量一致
颜色对不上,红灯变成了绿灯GRB顺序问题在ws2812_build_frame里确认GRB拼接顺序
亮度闪烁、不均匀供电不足或共地不良使用5V/2A以上电源;MCU和灯带共地;电源端并联电容
动态效果一卡一卡DMA中断里做耗时操作把效果计算放主循环,中断里只置标志
刷新率上不去DMA模式误设为Circular将DMA模式改为Normal
长时间运行后花屏数据线受干扰信号线上串联100Ω到1kΩ电阻;缩短信号线长度

6.2 我踩过的几个典型坑

第一个坑是RESET时间不够。最初我做的灯带只有8颗灯珠,每帧数据只有8×24=192个码位,约240μs。当时我没在帧尾补RESET码位,而是直接靠DMA完成到下一次启动之间的间隙充当RESET。结果就是,灯少的时候偶尔能亮,灯一多或者效果一变,花屏、错位、颜色乱跳,各种奇怪问题都来了。后来我固化了40个RESET码位之后,问题全部消失。所以RESET信号这事,真的别省。

第二个坑是CubeMX配置里DMA数据宽度选成了Byte。当时灯带输出看着有点样子,但颜色和位置完全不对,用逻辑分析仪抓波形才发现,每个16位CCR数据被拆成两次搬运,高字节和低字节错位了。改成Half Word之后,一切正常。这个问题表面上是“灯带乱亮”,实际上是DMA宽度配置错误,排查过程曲折,但原理很清晰。

第三个坑是电源问题。我试过直接用USB口给30颗WS2812B供电,全白亮度下电流接近2A,USB口直接掉电压,MCU跟着复位。后来老老实实外接5V/2A电源,并在灯带两端的电源端各并联了一个1000μF电容,才算稳定下来。玩WS2812千万别只注意代码,电源这块翻车的概率比代码大得多。

6.3 怎么用逻辑分析仪快速定位问题

如果你的灯带还是不正常,别盲猜,打开逻辑分析仪抓DI引脚上的波形。用200kHz以上的采样率抓一小段,然后数一下高电平持续时间。如果你看到周期不是1.25μs左右,说明PWM配置有问题;如果0和1的高电平时间不在允许范围内,说明CCR值需要调整;如果两帧之间没有明显的50μs低电平,说明RESET信号处理不对。

没有逻辑分析仪的话,至少可以用示波器看个大概。没有示波器,那就只能靠现象和排查表了,但效率会低很多。做嵌入式这行,调试工具早晚要用上,早买早省心。

最后再分享一个小经验:PWM+DMA这套驱动逻辑,不一定要死绑在STM32F103上。换到F4、G0、G4,或者国产GD32、AT32,只要定时器和DMA行为类似,基本就是改个时钟频率和DMA请求号的事。底层驱动一旦吃透,后面换芯片、加效果,都是水到渠成的事情。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询