首页 / 资讯中心 / 文章详情

STM32驱动WS2812灯带:PWM+DMA实现高效无CPU占用方案

STM32驱动WS2812灯带:PWM+DMA实现高效无CPU占用方案 ★ FEATURED ARTICLE
WS2812这颗灯珠玩过嵌入式灯带项目的朋友应该都不陌生。单总线协议、5V供电、每颗灯独立24bit颜色一颗接一颗串成灯带能做流水灯、渐变、彩虹、音乐律动。但真到驱动层面很多人第一次写会卡壳直接用GPIO翻转配延时函数发数据灯少还能凑合灯一多或者要加别的任务CPU直接被打满显示效果还一顿一顿的。用STM32CubeMX配合PWMDMA来驱动WS2812是目前我在实际项目里最推荐的做法——硬件定时器负责掐时序DMA负责搬运数据CPU几乎完全解放出来干别的事。这篇文章就把这套方案从头到尾掰开揉碎讲清楚从WS2812的时序协议到CubeMX里的具体配置步骤再到完整的驱动代码和几种常用灯效最后把我这些年踩过的几个典型坑也一并列出来。适合刚接触STM32和WS2812的初学者照着做也给已经装过灯带但想优化性能的朋友提供参考。整个工程以STM32F103C8T6为例72MHz主频你手里的F103/F401/其他STM32只要稍微改下时钟配置就能移植。1. 方案选型与原理拆解这部分很重要先把原理搞清楚配置代码才有底。1.1 为什么选PWMDMA而不是GPIO翻转WS2812常见的驱动方式有三种GPIO翻转加延时、SPI加外部逻辑、定时器PWM加DMA。我做完几个项目之后基本锁定了最后一种。先说GPIO翻转加延时。这种方案最直观把0码和1码的高低电平时间用delay_us()强制卡出来代码写起来也快。但有两个致命问题第一延时函数受中断影响极大只要串口中断、定时器中断一来时序就飘灯带立刻闪烁第二发送一个灯珠要24bit100颗灯就是2400bit每bit一个周期1.25us也要3ms期间CPU全被占用。你要是还想同时跑按键扫描、OLED刷新、传感器读取基本不可能。这个方案只适合验证用比如手头就5颗灯、没有任何中断的场景。再说SPI方案。WS2812的时序虽然和SPI不完全一样但可以用4bit或3bit映射法把SPI的时钟抬高到原来的数倍用多个SPI bit拼出一个WS2812 bit。优点是SPIDMA也可以不占CPU而且SPI外设几乎每个单片机都有。缺点是需要做电平转换或者用74HCT245之类的芯片把MOSI信号转出来而且映射逻辑要把颜色数据先拆成SPI位流调试波形时容易绕晕。实际项目里也可以用但我个人感觉不如定时器PWM方案直接。定时器PWMDMA的思路是让定时器以800kHz的频率输出PWM波形每个PWM周期对应WS2812的一个bit周期。0码和1码的区别就是PWM的占空比不同。然后DMA负责把一个预先编码好的CCR值数组不断搬运到定时器的捕获比较寄存器里硬件会自动改变每个周期的占空比从而在引脚上直接产生WS2812需要的波形。整个过程CPU只在更新显示数据时介入平时完全闲着。1.2 WS2812的时序和PWM参数换算WS2812的通信协议是单总线NRZ编码所有bit都在800kHz的bit周期里传输。具体时序要求如下表信号高电平时间低电平时间总周期0码0.35us0.8us1.25us1码0.7us0.6us1.25usRESET低电平至少50us--注意WS2812只认高电平的持续时间0码和1码的单bit时间一样都是1.25us。所谓1.25us就是800kHz频率。这组参数手册上给出的容差其实比较宽0码高电平在150ns到500ns之间都行1码高电平在500ns到850ns之间都行所以实际做PWM参数换算时只要取中间值附近都算安全。以STM32F103C8T6跑到72MHz主频为例。一个PWM周期要等于1.25us那么周期计数就是72MHz × 1.25us 90个时钟周期所以定时器预分频PSC设为0自动重装值ARR设为89即可这样计数器从0数到89正好90个时钟周期对应1.25us。0码的高电平要0.35us对应72MHz × 0.35us 25.2个时钟周期取整后用25。1码的高电平要0.7us对应72MHz × 0.7us 50.4个时钟周期取整后用50。最终PWM参数就是PWM参数数值说明PSC072MHz不分频ARR89PWM周期1.25usCCR00码25高电平约0.347usCCR11码50高电平约0.694usCCR25时落在0码的容差范围内CCR50时落在1码的容差范围内都离边界有足够余量。这个余量非常关键因为主频、APB时钟总线上的微小误差、温度漂移都可能让波形稍微偏移余量大的方案在量产时才不会翻车。如果你的主频不是72MHz换算公式也很简单ARR 主频 × 1.25us - 1CCR0约等于主频 × 0.35usCCR1约等于主频 × 0.7us。比如168MHz的F407ARR就是209CCR0约58CCR1约117。1.3 一个bit一个PWM周期DMA缓冲区怎么组织我用的是“一个bit对应一个PWM周期”的做法即DMA缓冲区里每个数值直接就是某个CCR值。这样60颗灯需要的DMA缓冲区长度是60颗 × 24bit 64个RESET周期 1504个uint16_t数值发送时DMA把整个缓冲区循环搬到TIM的CCR1寄存器里定时器就会持续输出波形循环往复。缓冲区里每个数值代表一个PWM周期数值是25就输出0码波形是50就输出1码波形是0就是低电平。这套逻辑清晰、缓冲区小、代码好调试。也有另一种常见做法是“每bit占用3个PWM周期”比如发送0码时依次写[CCR0, 0, 0]发送1码时依次写[CCR1, 0, 0]。这样每个bit之间强制插入一个低电平周期进一步拉开0码和1码的波形差异容错更强但缓冲区体积变成3倍。早期很多Arduino库和部分STM32教程喜欢这么干。我在实际项目里测试下来只要PWM参数计算正确一个bit一个周期完全没问题而且效率更高。这篇文章我会按一bit一周期的方案写代码。还有一个细节DMA必须配置成Circular循环模式。因为WS2812灯带需要周期性刷新循环模式下DMA搬完一轮会自动从头继续搬不需要CPU反复干预。如果把模式配成Normal搬完一轮就停了灯带只亮一瞬间看起来就像没驱动成功一样。2. STM32CubeMX一步一步配置原理清楚了下面进入实操。我以STM32CubeMX新版本界面为例芯片选STM32F103C8T6IDE生成Keil MDK工程。其他芯片操作路径基本相同。2.1 时钟树与PWM通道配置打开CubeMX新建工程选择STM32F103C8T6。第一步先把RCC时钟配好在左侧System Core里选择RCCHSE选Crystal/Ceramic Resonator。如果你用的是板上不带外部晶振的板子可以跳过外部晶振直接用内部HSI但HSI精度不如外部晶振对WS2812时序有一定风险建议还是用HSE。打开Clock Configuration标签页在PLL Source MUX里选择HSE把PLL Mul设为x9系统时钟就变成8MHz × 9 72MHz。确认APB1 Prescaler为/2此时APB1外设时钟是36MHz但定时器时钟会自动加倍到72MHzCubeMX时钟树上一般会直接显示Timer2 clock为72MHz。如果你用的是其他主频的板子目标就是确保最终Timer clocks那一栏是72MHz即可。接着配置定时器TIM2。在左侧Timers里选择TIM2Clock Source选Internal Clock。Channel1选PWM Generation CH1。在Parameter Settings里设置Prescaler 0Counter Period (ARR) 89Pulse设成0或随便一个值。确认auto-reload preload已开启避免更新ARR时出现瞬间异常波形。这里我特意选TIM2而不是TIM1因为TIM2的CC1 DMA请求路径在F103上非常成熟CubeMX里配置也方便。用TIM1也行但高级定时器还涉及主输出使能TIM_BDTR的MOE位不使能的话引脚根本没输出新手容易漏所以先用TIM2会比较省心。2.2 DMA配置与关键开关DMA配置是整个方案最容易翻车的地方。在CubeMX的DMA Settings标签页里点击Add然后做如下设置DMA配置项设置值DMA RequestTIM2_CH1DirectionMemory To PeripheralPriorityHighMemory IncrementYesPeripheral IncrementNoModeCircularData WidthPeripheralHalf WordData WidthMemoryHalf Word为什么数据宽度要选Half Word而不是Byte或Word因为TIM的CCR1寄存器是16位的DMA搬运的数据宽度必须和寄存器保持一致Half Word刚好。如果你选Byte每次只搬8位CCR的高8位永远不对选Word又会把相邻内存数据也搬进去。这个细节排查起来很隐蔽一定要在CubeMX里就配对。另外Memory Increment必须选Yes这样才能让DMA每搬一个数值后自动指向缓冲区里的下一个元素Peripheral Increment必须选No因为目标始终是CCR1寄存器地址固定。生成代码前建议在NVIC设置里把TIM2的全局中断打开。虽然纯循环DMA模式不一定需要中断但后期做双缓冲和灯效切换时中断是必须的提前开好省得再回CubeMX改。2.3 生成工程后的三个关键动作CubeMX生成工程后直接编译能不能亮大概率不能。因为生成代码里只配置了定时器和DMA外设的初始化但并没有告诉定时器“你现在可以去搬运DMA数据了”。你需要手动补三件事。第一在初始化完TIM2和DMA后启动PWM输出HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1);第二使能TIM2的CC1 DMA请求。这点最容易被忽略__HAL_TIM_ENABLE_DMA(htim2, TIM_DMA_CC1);不使能这个请求DMA虽然配置好了但定时器不会主动拉DMACCR寄存器永远是初始值引脚上只输出固定占空比的PWM灯带完全没反应。第三不要忘记把WS2812数据脚对应的GPIO模式核对一下。TIM2_CH1默认引脚是PA0CubeMX会自动复用成TIM2_CH1功能。但有的板子扩展引脚和LED引脚不在PA0上你需要看原理图确认。如果灯带数据线接在别的引脚上就得换定时器通道或者用引脚重映射功能不能硬接。3. 代码实现与灯效设计工程配置好之后代码部分就是核心了。我把驱动拆成独立的模块方便移植和扩展。3.1 数据结构与GRB顺序WS2812每个灯珠接收24bit数据发送顺序是G、R、B不是常见的RGB。很多第一次玩的人RGB顺序写反结果红色数据显示成了蓝色。为了避免踩坑我在颜色存储结构上直接用GRB顺序存储。头文件定义如下#ifndef __WS2812_H #define __WS2812_H #include main.h #define WS2812_LED_NUM 60 #define WS2812_RESET_NUM 64 #define WS2812_BUF_SIZE (WS2812_LED_NUM * 24 WS2812_RESET_NUM) extern uint16_t ws2812_buf[WS2812_BUF_SIZE]; void ws2812_init(void); void ws2812_set_color(uint16_t index, uint8_t r, uint8_t g, uint8_t b); void ws2812_clear(void); void ws2812_update(void); #endifWS2812_RESET_NUM设成64表示64个低电平周期即64 × 1.25us 80us远大于WS2812要求的50us RESET时间很稳妥。颜色存储使用一个二维数组static uint8_t led_color[WS2812_LED_NUM][3];led_color[n][0]存Gled_color[n][1]存Rled_color[n][2]存B。中间的编码函数直接按这个顺序发。3.2 编码函数与DMA缓冲更新核心函数ws2812_update()的作用是把led_color数组里的颜色值按位拆解写入ws2812_bufuint16_t ws2812_buf[WS2812_BUF_SIZE]; static uint8_t led_color[WS2812_LED_NUM][3]; #define WS2812_CCR0 25 #define WS2812_CCR1 50 void ws2812_update(void) { uint32_t idx 0; uint16_t led, bit; for (led 0; led WS2812_LED_NUM; led) { for (bit 0; bit 24; bit) { uint8_t data led_color[led][bit / 8]; if (data (0x80 (bit % 8))) { ws2812_buf[idx] WS2812_CCR1; } else { ws2812_buf[idx] WS2812_CCR0; } } } for (idx WS2812_LED_NUM * 24; idx WS2812_BUF_SIZE; idx) { ws2812_buf[idx] 0; } }编码顺序是从每字节最高位开始发送0x80 (bit % 8)配合bit / 8选择G/R/B字节正好对应WS2812的高位在前时序。颜色写入函数void ws2812_set_color(uint16_t index, uint8_t r, uint8_t g, uint8_t b) { if (index WS2812_LED_NUM) { return; } led_color[index][0] g; led_color[index][1] r; led_color[index][2] b; } void ws2812_clear(void) { uint16_t i; for (i 0; i WS2812_LED_NUM; i) { ws2812_set_color(i, 0, 0, 0); } }初始化函数void ws2812_init(void) { ws2812_clear(); ws2812_update(); HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1); __HAL_TIM_ENABLE_DMA(htim2, TIM_DMA_CC1); }启动PWM之后DMA开始循环搬运ws2812_buf里的数据引脚上就有持续的信号了。之后需要更新灯效时只要修改led_color数组再调用ws2812_update()重新编码即可。这里有个很实际的优化点如果你在DMA正在运行的过程中直接重写ws2812_buf理论上DMA可能在某个瞬间读到一半新数据、一半旧数据造成短暂的杂色。不过由于写入速度极快总共才1500多个uint16实际人眼几乎注意不到。真正要求高的场合我在第5章会讲双缓冲方案。3.3 基础灯效呼吸、流水、彩虹基础驱动跑通后灯效就很灵活了。我给出三个常用效果直接放主循环里就能看效果。呼吸灯效果uint16_t brightness 0; int8_t step 2; while (1) { for (uint16_t i 0; i WS2812_LED_NUM; i) { ws2812_set_color(i, brightness, 0, 0); } ws2812_update(); brightness step; if (brightness 80 || brightness 0) { step -step; } HAL_Delay(10); }注意呼吸灯不要直接拉到255满亮度不然60颗红光全开电流很大开发板的USB口供电很容易被拉垮。限制在80以内视觉上已经很柔和了。彩虹渐变效果static void wheel(uint8_t pos, uint8_t *r, uint8_t *g, uint8_t *b) { if (pos 85) { *r 255 - pos * 3; *g pos * 3; *b 0; } else if (pos 170) { pos - 85; *r 0; *g 255 - pos * 3; *b pos * 3; } else { pos - 170; *r pos * 3; *g 0; *b 255 - pos * 3; } } uint8_t hue_offset 0; while (1) { for (uint16_t i 0; i WS2812_LED_NUM; i) { uint8_t pos (uint8_t)(i * 256 / WS2812_LED_NUM hue_offset); uint8_t r, g, b; wheel(pos, r, g, b); ws2812_set_color(i, r, g, b); } ws2812_update(); hue_offset; HAL_Delay(20); }流水灯效果更简单直接把点亮位置按时间往后挪uint16_t pos 0; while (1) { ws2812_clear(); ws2812_set_color(pos, 0, 0, 60); ws2812_set_color((pos 1) % WS2812_LED_NUM, 0, 40, 0); ws2812_update(); pos (pos 1) % WS2812_LED_NUM; HAL_Delay(50); }这些效果代码没有用复杂算法核心都是先更新颜色数组再统一刷新。因为DMA在后台循环输出刷新一帧数据只要执行ws2812_update()整个过程不到2ms肉眼看到的效果非常流畅。3.4 串口与无线控制扩展灯效写死进单片机里玩一段时间就会腻。更实用的做法是加一个串口或者无线控制通道动态改灯效和颜色。最简单的串口控制思路用USART1接收一帧固定协议的数据比如帧头0xA5 0x5A后面跟灯珠编号、R、G、B和校验字节。CubeMX里把USART1配成115200-8-N-1可以用轮询接收也可以用DMA加空闲中断接收。轮询方式会阻塞主循环但协议简单、调试方便适合起步。如果是做无线控制可以用ESP8266或者蓝牙模块通过串口透传把命令转发给STM32。核心逻辑不变只是把数据来源从串口外设换成了透传模块。我之前做过一套“手机App蓝牙调灯色”的小玩具灯带端的代码和这篇文章里的完全一样只是主循环里多了一个解析蓝牙指令的步骤。所以把这套PWMDMA驱动吃透后续接ESP8266、蓝牙、CAN、RS485都是水到渠成。4. 常见问题排查与调试经验驱动WS2812遇到的坑我基本都在实际项目中踩过。下面按现象分类遇到问题直接对着排查。4.1 灯完全不亮或只有第一颗亮灯带完全没反应先别怀疑代码检查四件事。第一供电是否到位。WS2812的VCC必须接5V电源GND和STM32共地。如果你把灯带接到开发板的3.3V引脚上灯珠大概率不会亮即使亮了颜色也完全不对。第二上电后有没有给WS2812足够的复位时间。WS2812上电后需要等待至少50us才能接收数据但很多电源上电瞬间有抖动我习惯在ws2812_init()前加HAL_Delay(100)100ms后灯带肯定稳定了。第三DMA请求使能没有。按照我的写法__HAL_TIM_ENABLE_DMA(htim2, TIM_DMA_CC1);这行代码必须有。很多人用CubeMX生成工程后直接跑忘了这行PWM输出了但CCR值永远是初始值灯带引脚上只有一个固定占空比的方波不是数据信号灯自然不亮。第四只有第一颗灯亮。这个现象通常是数据波形幅度不够或者时序完全错了。如果只有第一颗闪烁或者亮一下就不动基本说明后面的灯因为第一颗没能正确转发数据而停止工作。WS2812是串联移位寄存器结构第一颗灯收到的数据会重新整形后再传给第二颗如果第一颗收到的波形本身就不合格它传出去的信号也必然是乱的。4.2 颜色偏色、闪烁、拖影颜色明显不对第一怀疑GRB顺序。你把ws2812_set_color(i, 255, 0, 0)设置成红色显示出来如果是绿色或蓝色那就要检查编码函数里的字节顺序。如果只是某些颜色偏、颜色不正常还有一种可能是CCR余量不够。比如0码的CCR设成了20或30虽然也在容差范围内但如果你设置成10这种极端值0码的T0H只有0.14us低于WS2812的最低要求灯珠就会把0码误判成1码产生系统性花屏。闪烁和拖影问题优先检查时钟配置。如果TIM2时钟不是72MHzARR89对应的PWM周期就不是1.25us所有时序都偏了。用逻辑分析仪或者示波器看PA0引脚的波形最直接正常0码应该是一个约0.35us高电平加0.9us低电平的脉冲1码应该是一个约0.7us高电平加0.55us低电平的脉冲。如果没有示波器可以写一个测试代码让所有灯常亮同一颜色如果满屏花点多半是时序参数不对。还有一种容易忽略的闪屏原因主循环里更新显示用的HAL_Delay被高优先级中断打断太久。如果串口中断里有耗时操作或者DMA中断处理写得繁琐主循环的灯效更新会被延迟人眼看到的就是一顿一顿。解决办法是把耗时操作拆开或者把灯效更新放到有明确时间基准的定时器中断里确保每帧间隔稳定。4.3 供电与长距离传输的经典坑供电问题在长灯带上尤其突出。单颗WS2812全白最高亮度下电流约60mA60颗就是3.6A这已经远超普通USB口的输出能力。如果你用电脑USB供电60颗灯跑全白效果时电压会被拉低导致灯珠颜色漂移、闪烁甚至自动复位。正确做法是外接5V电源电流按实际灯珠数量乘以50~60mA留足余量。信号线和电源线之间尽量用粗一点的线避免压降。信号电平方面STM32的GPIO输出是3.3V而WS2812在5V供电时输入高电平阈值理论上是0.7 × VDD约3.5V3.3V信号严格来说不够高。实际很多灯带因为内部IC的施密特触发器阈值没那么严格3.3V也能驱动但长距离传输后信号衰减更明显特别是在灯带长度超过1米、开始出现第一颗亮而后面全不亮的情况时大概率是电平问题。批量产品建议加一片74HCT245或者74AHCT125做3.3V到5V的电平转换并把信号线串联一个33欧姆左右的电阻减少振铃。灯带如果很长还要注意信号单向传输。WS2812灯带上有箭头指示方向数据从DIN进、DOUT出接反了灯完全不亮。5. 进阶优化与方案对比基础功能做完后可以再往深走一步。这里讲几个实际的优化方向。5.1 DMA更新数据的安全姿势前面提到直接在DMA运行期间重写缓冲区理论上有脏数据风险。实际项目里如果灯珠数量多、主频不高、编码函数执行时间长这个风险会被放大。更稳妥的做法是双缓冲加中断切换。思路是准备两个ws2812_buf一个给DMA使用另一个给CPU写入新数据。每次CPU写完新缓冲后在DMA传输完成中断里切换两个缓冲区的指针。因为hdma_tim2_ch1的实例里保存了当前内存地址切换需要重新配置DMA的Memory Base Address这个操作在HAL库里可以通过停止DMA、改SConfigM0.Memory0BaseAddress、重新启动来实现。但频繁停DMA也有可能导致瞬间波形中断所以更平缓的做法是利用半传输中断和传输完成中断DMA搬完前半段时更新前半段数据搬完后半段时更新后半段数据这样DMA永远不会去读正在被写入的区域。对大多数DIY项目用一个缓冲区直接覆盖已经够用。我这套代码在60颗灯、72MHz、主循环20ms刷新一帧的场景下跑过很长时间没有出现肉眼可见的花屏。真正需要极致稳定性的是商业显示屏产品那种场景建议上双缓冲或者换用带RMT外设的芯片。5.2 多通道多灯带扩展一个定时器有多个通道比如TIM2有CH1、CH2、CH3、CH4四个通道每个通道都可以独立输出PWM也都可以触发DMA。这意味着一个TIM2理论上就能驱动四路WS2812灯带。在CubeMX里你只需要把TIM2的Channel2、Channel3、Channel4全部选为PWM Generation然后在DMA Settings里分别添加对应的DMA请求每个通道都有独立的内存缓冲区。这样四个通道可以同时刷新四条不同内容的灯带非常适合做氛围灯、窗帘灯、桌子灯环绕场景。需要注意F103的DMA通道数量有限TIM2的四个通道请求会占用DMA1的多个通道CubeMX会自动帮你分配但在项目里如果用到了ADC、UART的DMA要留意DMA通道冲突。这个在CubeMX的DMA配置界面就能看到。5.3 与SPI方案、ESP32 RMT方案对比写到最后把几种主流方案放在一起做个对比方便不同场景选型方案硬件依赖CPU占用信号质量适用场景GPIO翻转延时无极高差受中断影响教学验证SPI映射SPI外设电平转换低DMA中映射复杂通用MCU定时器PWMDMA定时器DMA低好调试方便STM32全系ESP32 RMTRMT外设极低极好ESP32定时器PWMDMA这套方案在STM32上最大的优势是通用性F1、F4、G0、L4都能用相同思路实现代码移植成本低。如果你换到ESP32平台官方RMT外设天然支持这类脉冲协议连PWMDMA都不用操心了但那属于另一套生态。SPI方案在只有SPI却不想折腾定时器的场景也有价值但调试繁琐度整体高于本方案。我个人在实际项目里凡是STM32平台做WS2812闭眼选PWMDMA就对了。唯一需要认真算的就是主频和ARR、CCR的对应关系算准了基本一次点亮。希望这套代码和调试经验能帮你少走点弯路。
阅读完成 · 觉得有帮助?
咨询建站