简介:面向STM32嵌入式开发者与NeoPixel灯带爱好者,这套WS2812B控制工程包聚焦PWM+DMA驱动方式,提供可直接编译运行的固件源码,解决单线通信协议时序严格、刷新过程占用处理器资源过高等工程痛点。包内共216个文件,压缩后约7.53MB,以C语言源码和头文件为主体,同时包含启动文件、项目工程配置、烧录固件、链接映射与编译列表等文件,便于查看构建过程并快速移植到自己的硬件平台,目前已有1699人学习下载。代码示范了定时器单脉冲模式的配置方法,通过直接存储器访问将预置的RGB数据序列自动送入PWM输出寄存器,从而在满足WS2812B脉冲宽度要求的同时,使处理器可以并行处理其他任务。读者可以借此掌握从底层时序生成、DMA缓冲区设计到流水灯与渐变效果实现的一整套思路,适合希望深入理解PWM、DMA与智能LED协同工作的嵌入式开发者参考。
1. WS2812B 用 PWM+DMA 而不是 GPIO 翻转,先想清楚为什么
拿到一个命名成WS2812B(PWM+DMA).zip的 STM32 工程时,通常打开就是定时器、DMA、一个 uint16_t 数组,外加注释里写着的 NeoPixel。这个组合解决的不是“能不能点亮”,而是灯多了以后 CPU 还干不干别的事。WS2812B 是单总线灯珠,0 码和 1 码只是高电平宽度不同,但每颗灯要 24 bit 数据,60 颗灯一帧就是 1440 bit,全部靠 GPIO 翻转加延时,中断一来时序立刻乱。
PWM+DMA 的思路是让硬件接管这个过程:定时器保持 800kHz 的位周期,DMA 每个周期把 CCR 比较值换成新的,PWM 输出的高电平宽度自然就成了 0 码或 1 码。CPU 只需要把这一帧的 CCR 序列写进内存,剩下全是外设自己在跑。适合从 Arduino NeoPixel 代码迁移到 STM32、以及对裸机时序稳定性有要求的开发者。
2. WS2812B 时序与 STM32 定时器 PWM 的映射关系
2.1 0 码和 1 码的区别在于高电平持续时间
WS2812B 看起来是一根信号线串行发数据,实际上它的物理层是“单线归零码”。每个 bit 的周期固定不变,判断 0 还是 1 只看高电平在这个周期里占多长。下表是各版本手册里比较常见的一组参考值:
| 参数 | 参考值 | 说明 |
|---|---|---|
| T0H | 0.35us ± 0.15us | 0 码高电平宽度 |
| T0L | 0.80us ± 0.15us | 0 码低电平宽度 |
| T1H | 0.70us ± 0.15us | 1 码高电平宽度 |
| T1L | 0.55us ± 0.15us | 1 码低电平宽度 |
| 位周期 | 1.25us | 对应 800kHz |
| RES | 大于 50us | 帧间复位信号,整条低电平 |
注意这里的 T0L 和 T1L 是从位周期里减出来的,不同批次官方文档会给不同表述,但位周期 1.25us 和两个高电平窗口基本一致。灯珠内部在每位周期的 250ns 附近采样一次电平,所以 0.35us 和 0.70us 只要稳定落在窗口内就不会误判。
2.2 为什么 GPIO 翻转方案很难维持 800kHz 时序
很多人第一次点亮 WS2812B 用的是 GPIO 拉高、延时、拉低、再延时,这个做法在 Arduino 上能跑,是因为 Arduino 没有复杂中断环境。STM32 上不同:72MHz 主频下,一个位周期只有 90 个时钟周期。一次 GPIO 翻转加一个空循环大约要 20 到 30 个周期,本来够用,但系统里只要有定时器中断、串口中断、ADC 中断插进来,某一位的高电平宽度就会被拉长或缩短。
灯珠对单个位的容忍范围通常在几百纳秒,一次 1us 的中断打断就会让这一位失效。更麻烦的是 GPIO 方案下 CPU 必须全程参与每一 bit 的翻转,灯带长度增加时,刷新率会被拉低。常见做法是把数据算好后一次性交给外设,这也是 PWM+DMA 方案存在的根本原因。
2.3 PWM 用占空比表达 0/1 码,但位周期必须锁死
用定时器 PWM 模拟 WS2812B 时,让定时器工作在 800kHz,也就是每个 PWM 周期都是 1.25us。在这个固定周期里,把 CCR 比较值改小,高电平就短,输出的是 0 码;把 CCR 比较值改大,高电平就长,输出的是 1 码。从外部看,这就是一个占空比约 28% 和约 56% 的 PWM 波,但本质上每一位仍然是完整的一个位周期。
这里的隐藏约束是:ARR 值一旦定下来就不能动,位周期必须始终是 1.25us 左右。如果只想着调占空比而把 ARR 改大改小,T0H 和 T1H 会一起偏移,灯珠可能把整个数据流当成无效信号。后面 4.3 会说明,WS2812B 也不能像普通 LED 那样靠调 PWM 占空比调亮度,所有亮度处理都必须在颜色数据层面做。
2.4 72MHz 主频下 PSC、ARR、CCR 怎么配
以 STM32F103 或 F401 这类 72MHz 主频的芯片为例,定时器时钟取 72MHz,预分频 PSC 设 0,那么一个计数周期是 1/72MHz。要得到 1.25us 的位周期:
ARR = 1.25us / (1/72MHz) - 1 = 90 - 1 = 890 码高电平 0.35us 对应 25.2 个计数周期,取整为 25;1 码高电平 0.70us 对应 50.4 个计数周期,取整为 50。于是推荐参数如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| PSC | 0 | 不分频 |
| ARR | 89 | 位周期 90 个 tick,共 1.25us |
| CCR_ZERO | 25 | 高电平约 347ns,落在 0 码窗口 |
| CCR_ONE | 50 | 高电平约 694ns,落在 1 码窗口 |
手头几份 WS2812B 中文资料里,有的会把周期写成 1.3us 或把 T1H 上限放宽到 1.0us,实际驱动时不必逐字对齐,只要高电平落进 0.2us 到 0.85us 这个区间就能稳定工作。最怕的是 CCR_ONE 太大,导致高电平接近甚至超过一个位周期,灯带会把一串 1 码当成复位段。
提示:如果用 HAL 库,必须先确认 SystemCoreClock 和 APB1 定时器时钟倍频关系。F1 系列 APB1 预分频为 1 时定时器时钟是 36MHz,预分频为 2 时定时器时钟反而回到 72MHz。把 PSC 算错一倍是这类驱动最常见的起步错误。
3. 搭建最小驱动:TIM PWM 和 DMA 的初始化与缓冲区
3.1 引脚和定时器选型
通用定时器 TIM2、TIM3、TIM4、TIM5 的通道都可以输出 PWM,也都有对应的 DMA 请求线。示例选择 PA6 作为 TIM3_CH1 输出,DMA1 通道 6 对应 TIM3_CH1 的请求。选引脚时注意避开调试口、USB 引脚和晶振引脚,同时确认该引脚在这个系列芯片上的 AF 编号。
F4 系列可以把 PA6 配置成GPIO_AF2_TIM3,F1 系列没有 AF 寄存器,直接把引脚配置成复用推挽输出即可。命名成WS2812B(PWM+DMA)的工程包大多是网上流传的 HAL 工程,沿用的也是这个套路:一个定时器通道加一条 DMA 请求线,不改动其他外设。
3.2 TIM PWM 初始化代码
TIM_HandleTypeDef htim3; TIM_OC_InitTypeDef soc; void WS2812_TIM_PWM_Init(void) { __HAL_RCC_TIM3_CLK_ENABLE(); htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.Period = 89; /* 位周期 1.25us @72MHz */ htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim3); soc.OCMode = TIM_OCMODE_PWM1; soc.Pulse = 0; /* 初始 CCR=0,避免上电误亮 */ soc.OCPolarity = TIM_OCPOLARITY_HIGH; soc.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim3, &soc, TIM_CHANNEL_1); }PSC 为 0,ARR 为 89,计数从 0 到 89,溢出周期正好是 90 个 tick。Period=89对应的是 1.25us 而不是 90us,因为每个 tick 约 13.9ns。Pulse=0让 CCR1 初始为 0,PWM1 模式下输出常低,上电瞬间灯带不会有杂波。等 DMA 把第一个值送进 CCR1 后,输出才开始按数据位跳变。
3.3 DMA 配置和缓冲区结构
#define PIXEL_NUM 60 #define BITS_EACH 24 #define RESET_BITS 48 #define BUF_LEN (PIXEL_NUM * BITS_EACH + RESET_BITS) uint16_t pwm_buf[BUF_LEN] __attribute__((aligned(4))); DMA_HandleTypeDef hdma_tim3_ch1; void WS2812_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_tim3_ch1.Instance = DMA1_Channel6; /* TIM3_CH1 的 DMA 映射 */ hdma_tim3_ch1.Init.Direction = DMA_MEMORY_TO_PERIPHERAL; hdma_tim3_ch1.Init.PeriphInc = DMA_PINC_DISABLE; /* 外设地址固定为 CCR1 */ hdma_tim3_ch1.Init.MemInc = DMA_MINC_ENABLE; /* 每次读取 pwm_buf 下一项 */ hdma_tim3_ch1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_tim3_ch1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_tim3_ch1.Init.Mode = DMA_CIRCULAR; /* 循环模式,相当于 continuous requests */ hdma_tim3_ch1.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_tim3_ch1); __HAL_LINKDMA(&htim3, hdma[TIM_DMA_ID_CC1], hdma_tim3_ch1); }pwm_buf 里每个元素是一个 uint16_t,这个值最终会被 DMA 写进 TIM3 的 CCR1 寄存器。CCR1 是 16 位寄存器,所以外设和内存的数据宽度都必须配置成 HALFWORD;如果配置成字宽度,DMA 会把相邻寄存器一起覆盖掉,表现就是灯带完全乱掉。
DMA_PINC_DISABLE很关键,因为要写入的外设地址永远是同一个 CCR1,而不是连续地址。内存地址要递增,所以MemInc打开。Mode 选 CIRCULAR,DMA 发送完整个缓冲区后会自动从头开始,灯带才能持续刷新,这也是数据手册里说的 DMA continuous requests 的含义。
GPIO 复用输出配置同样不能省:
GPIO_InitTypeDef gpio; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin = GPIO_PIN_6; gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; gpio.Alternate = GPIO_AF2_TIM3; /* F4/F7 写法,F1 改用 GPIO_MODE_AF_PP 即可 */ HAL_GPIO_Init(GPIOA, &gpio);这里必须是复用推挽输出而不是普通推挽输出,否则定时器不能控制引脚电平。上拉和下拉都关掉,避免在信号线上叠加多余的充放电时间,影响高电平宽度。
3.4 把 RGB 颜色转换成 CCR 序列
#define CCR_ZERO 25 #define CCR_ONE 50 void SetPixelColor(uint16_t idx, uint8_t r, uint8_t g, uint8_t b) { uint32_t base = idx * 24; uint8_t grb[3] = { g, r, b }; /* WS2812B 数据顺序是 GRB */ for (int i = 0; i < 24; i++) { uint8_t bit = (grb[i / 8] >> (7 - (i % 8))) & 0x01; pwm_buf[base + i] = bit ? CCR_ONE : CCR_ZERO; } }每次调用只更新对应灯珠的 24 个半字。循环里从每个字节的最高位开始取,i/8决定当前取的是 G、R 还是 B,7 - (i % 8)决定是字节里的第几位。颜色顺序写成 GRB 是 WS2812B 最常见的坑,上位机传来的颜色对不上时,先改这里而不是改驱动。
缓冲区末尾的 48 个元素保持 0,那是复位段。这个长度后面会算。
4. 从 1 颗灯到 N 颗灯:帧缓冲、复位段与亮度控制
4.1 帧缓冲的长度怎么算
缓冲区长度由三部分组成:灯珠数乘每颗 24 bit,加上复位段占用的 bit 数。以 60 颗灯、复位段 48 bit 为例:
#define BUF_LEN (60 * 24 + 48) /* 1488 个 uint16_t */不同灯珠数量对应的内存占用如下:
| 灯珠数 | 数据半字数 | SRAM 占用 |
|---|---|---|
| 30 | 768 | 1536 字节 |
| 60 | 1488 | 2976 字节 |
| 144 | 3504 | 7008 字节 |
| 600 | 14448 | 约 28KB |
DMA 的传输计数器是 16 位,单轮最多传 65535 个半字,也就是最多约 2730 颗灯。实际工程中一般不会到极限,因为大容量灯带更常见的是多条并联,每条用独立的定时器通道和 DMA 通道。
4.2 复位段长度:50us 低电平要留余量
WS2812B 要求相邻两帧之间至少 50us 低电平。一个复位位是 1.25us,50us 对应 40 个 bit,工程上一般多留一点空间:
50us / 1.25us = 40 bit 取 48 bit,实际低电平 60us每帧发送完成后,这 48 个元素的 CCR 都是 0,PWM 输出保持低电平。由于用的是循环 DMA 模式,48 个零位之后下一帧第一颗灯的数据立即开始,空档正好是 60us,满足灯珠复位条件。
启动传输的函数可以写成:
void WS2812_Show(void) { HAL_TIM_PWM_Stop_DMA(&htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start_DMA(&htim3, TIM_CHANNEL_1); }Stop 后再 Start,确保 DMA 从头开始发送。如果已经在 CIRCULAR 模式下运行,停止后再启动不会破坏复位段长度,因为复位段就在缓冲区末尾。要注意别在主循环里频繁调用这两个函数,每次调用都会让 DMA 重新装载,帧率会受影响。
注意:Stop_DMA 只是停止 DMA 搬运,PWM 输出不会立刻变成固定低电平,引脚状态由此时 CCR1 的值决定。为了确保复位段干净,初始化时就把 pwm_buf 清零,并且每次更新完所有灯珠数据后再调用 Show。
4.3 调亮度不能改 CCR,要用 Gamma LUT
WS2812B 的数据位高电平宽度必须落在 0 码或 1 码的判定窗口内,所以不能靠把 CCR_ONE 从 50 改成 30 来降低亮度,这样会被灯珠识别成 0 码。亮度的唯一正确做法是修改传递给灯珠的颜色值。
常见的做法是预计算一张 256 项的 Gamma 查找表:
uint8_t gamma8[256]; void BuildGammaTable(float gamma) { for (int i = 0; i < 256; i++) { float v = (float)i / 255.0f; gamma8[i] = (uint8_t)(powf(v, gamma) * 255.0f + 0.5f); } }gamma 取 2.2 到 2.8 之间的值,低灰阶的跳变会平滑很多。在 SetPixelColor 里调用前先过一遍表:
r = gamma8[r]; g = gamma8[g]; b = gamma8[b];这样亮度控制全部集中在数据映射层,PWM 的 CCR_ONE 和 CCR_ZERO 始终保持不变。环形缓冲区的刷帧频率也可以通过降低主循环的更新频率来控制,但不要把刷新率降到 20fps 以下,否则灯带会有可见闪烁。
5. 排错与验证:这些配置会让灯只亮第一颗或整条闪
5.1 只亮第一颗灯的检查顺序
只亮第一颗灯,颜色还不对,绝大多数问题在 DMA 配置或缓冲区填充。按照下面的顺序排查:
| 现象 | 最常见原因 | 检查点 |
|---|---|---|
| 只有第一颗亮 | DMA 方向配反 | Direction 应为 MEMORY_TO_PERIPHERAL |
| 第一颗灯颜色错乱 | 数据位顺序反了 | 检查 7 - (i % 8) 的移位方向 |
| 第一颗亮一下后全灭 | GPIO 复用没配对 | 对照芯片 AF 表查 Alternate 值 |
| 整条灯带随机闪 | DMA 模式配成 Normal | Mode 应为 CIRCULAR |
| 颜色为红色却显示绿色 | GRB / RGB 顺序 | 确认 grb[3] 的顺序 |
如果缓冲区里每一位都正确,但物理上只有第一颗收到数据,最可能的原因是 DMA 没进循环模式,发送完一轮后不再触发,后续灯珠收不到完整数据。
5.2 高级定时器的 PWM 故障保护别忽略
使用 TIM1 或 TIM8 这类高级定时器时,如果 BRK 功能被意外打开,输出会被故障信号强制拉低。现象是灯带偶尔亮一瞬就灭,或者完全没输出。通用定时器 TIM2/TIM3 没有这个机制,所以很多驱动例子刻意避开 TIM1。
必须用 TIM1 时,初始化后确认 BDTR 寄存器的 MOE 位为 1。HAL 的HAL_TIM_PWM_Start会自动打开 MOE,但如果用户代码里又配置了 BreakInput 并使能了极性不正确的信号,输出仍然会被关闭。最简单的方法是先检查原理图上有没有外部故障信号接到 BKIN 引脚,没有的话别在HAL_TIMEx_ConfigBreak里使能任何 break 源。
5.3 用逻辑分析仪验证位宽而不是靠人眼
人眼只能判断灯带亮不亮,判断不了 0 码和 1 码是否落在窗口内。信号线上放一个 24MHz 采样率以上的逻辑分析仪,抓一段已知数据,例如让第一颗灯显示纯绿色,期望波形应是:
- 24 位里先看到 1 码,GRB 顺序下 G 是 0xFF,所以 G 的 8 位全是长高电平;
- R 和 B 都是 0x00,对应 16 个短高电平;
- 帧尾能看到一段 60us 左右的低电平。
如果逻辑分析仪不支持解码 WS2812 协议,可以直接量两个值:1 码高电平是否在 0.55us 到 0.85us 之间、0 码高电平是否在 0.20us 到 0.50us 之间。只要这两个窗口对,灯带就能稳定工作。
最后一个实用的验证技巧:启动前把 CCR1 预置成 pwm_buf[0] 的值,DMA 启动后的第一个位周期就不会出现一次未知宽度的毛刺。做法是在HAL_TIM_PWM_Start_DMA之前执行一行寄存器写入:
TIM3->CCR1 = pwm_buf[0];这样整个缓冲区的第一帧从第一个数据位开始就是干净的,示波器上看不到起始位的异常拉高,灯带首尾的颜色也不会出现一位偏移。
本文还有配套的精品资源,点击获取