ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F407驱动WS2812:TIM1+PWM+DMA方案原理与实现

STM32F407驱动WS2812:TIM1+PWM+DMA方案原理与实现 直接说结论STM32F407VGT6驱动WS2812最稳的玩法就是用TIM1的输出比较产生PWM波形再让DMA在后台按帧搬运颜色数据CPU完全不用管每一位的翻转。这套方案把耗时最细的时序交给定时器硬件把大量数据搬运交给DMA主循环只负责算颜色、写缓冲区实测下来100颗灯珠跑动态水流效果都看不到闪屏。这个需求我最早是在一个项目里遇到的要在一个50cm长的灯板上做渐变呼吸用GPIO翻转加延时的方式先跑了一版结果CPU被完全占死只要一开中断就发现时序被切碎灯珠乱跳。后来换了TIM1PWMDMA问题直接消失。这篇文章把从原理、接线、CubeMX配置到代码实现整个链路拆开讲清楚适合刚接触WS2812、或者已经用GPIO驱动但觉得不稳的开发者参考。1. 方案选型为什么是TIM1PWMDMA1.1 WS2812的时序协议回顾WS2812看起来是颗RGB灯珠通信方式却是单总线串行协议。数据线就一根所有灯珠串联数据从第一个灯进入每颗灯内部有移位寄存器消费完自己需要的24bit数据后把剩余的数据从DO引脚透传给下一颗。关键在每一位的时序。WS2812的码元周期固定是1.25us也就是800kHz。在这个周期内0码和1码的区别仅仅是高电平持续的时间码型高电平时间低电平时间周期0码0.35us0.9us1.25us1码0.9us0.35us1.25us第一眼看到这个参数很多人会想到用延时函数翻转IO。确实51单片机的例程很多就是这么干的因为51本来主频低、外设少。但到了STM32F407这种主频168MHz的MCU上再用阻塞式翻转等于把高性能芯片当51用而且中断一多时序就崩。还有一个容易忽略的点WS2812的数据发送顺序不是RGB而是GRB。高字节在前所以发送绿色分量时先发G的最高位然后R最后B。写转换函数的时候这个顺序错了颜色就会整个乱掉。1.2 常见驱动方式的对比驱动WS2812的方式我见过的大概有四种各有各的适用场景GPIO翻转延时阻塞最简单但极占用CPU中断环境基本没法用。SPI外设DMA利用SPI的MOSI产生波形把8bit映射成不同长度的脉冲需要改造数据对SPI速率有要求一些工程上会这么干但逻辑复杂。定时器PWMDMA用PWM的占空比表达0码和1码DMA自动更新占空比硬件全自动。定制外设比如某些MCU带WS2812专用外设F407没有不适用。对比一圈下来STM32F407上最优雅的就是TIM1PWMDMA。原因有三第一PWM一旦启动占空比由比较寄存器CCR决定硬件自己翻转引脚不需要CPU干预。只要提前把CCR的值设置成对应0码或1码的计数值定时器就能精确输出1.25us的脉冲序列。第二DMA可以把一帧所有码元的CCR值按顺序搬运到TIM1的CCR寄存器搬运完一个就触发下一次搬运如此循环CPU只需要更新DMA源缓冲区里的数据。第三TIM1是高级定时器挂在APB2总线上在F407上跑168MHz时钟做时间量化时精度比普通定时器更高。TIM1还带互补输出、刹车等功能以后如果要做多路并联灯带CH1N这些通道也能用上。1.3 定时器时钟与PWM量化计算F407的TIM1属于高级定时器挂在APB2上。很多人配置时钟的时候会在这里踩坑如果APB2预分频设置为2那么TIM1的时钟不是84MHz而是84MHz的两倍也就是168MHz。这是STM32定时器时钟树的规则APB预分频大于1时定时器时钟自动倍频。CubeMX里如果按默认配置跑TIM1时钟就是168MHz。TMI1的PWM频率设计目标每个码元周期1.25us频率800kHz。在这个前提下用168MHz的计数时钟来量化每个计数周期约5.95ns。PWM的自动重载值ARR和分频器PSC满足公式PWM频率 TIM时钟 / ((PSC 1) * (ARR 1))为了让一个PWM周期正好等于一个码元周期直接取PSC 0那么ARR 1 168MHz / 800kHz 210所以ARR 209。确定好周期后剩下就是算0码和1码对应的CCR值。如果用PWM模式1即CNT小于CCR时输出高电平那么高电平时间 CCR / 168MHz。0码需要高电平约0.35us对应CCR 0.35us * 168MHz ≈ 59。1码需要高电平约0.9us对应CCR 0.9us * 168MHz ≈ 151。算出来之后验证一下59计数是351ns151计数是899ns和规格书的350ns、900ns几乎一致余量充足。很多网上抄来的代码里CCR值乱七八糟有写35和140的算一下就知道那是针对不同定时器时钟的直接套用容易出问题。2. 硬件准备与CubeMX工程配置2.1 最小系统板接线与供电注意事项F407VGT6最小系统板很常见WS2812灯带也容易买到接线并不复杂连接对象引脚说明WS2812 DIN任意TIM1_CH1引脚默认PA8其他通道也可以WS2812 5V外部5V电源正极不要直接吃板子上的5VWS2812 GND外部电源GND必须和板子共地板子GND灯带GND共地是通信稳定的前提供电这块要重点说。WS2812单颗灯珠全白时最大电流约60mA100颗全白要6A实际操作中因为不是所有灯都同时拉满电流但也得按5V 5A以上准备。我测试时用的是5V 10A开关电源灯带两端接线用18AWG硅胶线压降很小。数据线电平方面3.3V的IO直接驱动WS2812大部分情况能工作但高温或批次差异下可能误码。稳妥的做法有两种一种是在DIN上串一个100到300欧姆的电阻吸收反射毛刺另一种是加电平转换比如74HC245或者2N7002做单向转换。我实际项目里灯带离板子超过20cm就在DIN线上串了220欧电阻逻辑分析仪看波形依然干净。还有一个细节灯带电源入口处并联一个大电容1000uF电解电容就可以再并一个100nF瓷片电容。WS2812内部恒流驱动电路在刷新时会有瞬态电流变化电源不稳会造成尾部灯珠颜色发暗或闪烁。这个电容放灯带电源输入端效果立竿见影。2.2 CubeMX中TIM1和DMA的配置步骤用CubeMX生成工程前先把系统时钟配置对。RCC选HSE外部晶振Clock Configuration里把HCLK调到168MHzAPB1分频4、APB2分频2。这个配置下TIM1时钟就是168MHzTIM2-TIM5是84MHz。TIM1挂在APB2上所以PWM频率计算按168MHz来。定时器配置配置项值TIM1 Clock SourceInternal ClockChannel1PWM Generation CH1Prescaler0Counter Period209Counter ModeUpAuto-reload PreloadDisablePulse59PWM ModeMode 1CH PolarityHigh这里Pulse先填59只是为了在CubeMX生成的初始化代码里有个默认值后面DMA跑起来之后这个值会被不断覆盖无所谓。DMA配置在DMA Settings标签页里添加一个DMA请求选择TIM1_CH1。CubeMX会自动关联到DMA2 Stream5 Channel6。参数如下配置项值ModeCircularDirectionMemory To PeripheralPeripheral IncrementDisableMemory IncrementEnablePeripheral Data WidthHalf WordMemory Data WidthHalf WordPriorityHigh数据宽度必须选Half Word。CCR寄存器是16位内存缓冲区里存的也是16位计数值。选Byte会导致每次只搬运8位波形完全错乱选Word会把连续的两个16位值拼成一个32位值也是错的。NVIC里可以开DMA传输完成中断也可以不开。不开的时候DMA就在后台循环刷CPU完全无感。开了中断可以做半传输/传输完成回调帧切换更精准但会增加中断负担。做静态显示不用开做动态效果建议开半传输中断做双缓冲。生成代码后主工程里基本不需要改初始化代码CubeMX已经把所有东西准备齐了。接下来是缓冲区的设计。2.3 配置中的几个关键细节先说引脚。PA8是TIM1_CH1的默认引脚但不是唯一选择。如果PA8被其他功能占了还可以看看PB13、PB14这些TIM1通道的复用引脚。不过PA8最省事CubeMX里直接选TIM1_CH1就会自动配置复用功能。再说一个CubeMX里容易忽略的选项TIM1的高级定时器特性。高级定时器比通用定时器多了一个BDTR寄存器里面有个MOE位主输出使能。HAL_TIM_PWM_Start函数会自动处理MOE但如果哪天你自己用寄存器操作写PWM别忘了把BDTR的MOE置1否则CH1引脚上永远没有波形。还有一个我吃过亏的地方CubeMX生成的初始化代码里PWM通道的默认Pulse是0。如果直接调用HAL_TIM_PWM_Start而不启动DMA输出是恒低电平灯带不会有反应。这不算bug但很多新手会以为GPIO没配好。3. 核心代码实现从颜色数据到CCR缓冲3.1 帧缓冲布局与复位段设计WS2812每个灯珠需要24bit数据整串灯的数据位就是 灯珠数*24。我们把这每个bit转换成一个CCR值存成一个uint16_t数组这就是DMA要搬运的源。一个重要的设计点帧与帧之间需要有至少50us的低电平复位信号。如果数据流里没有复位段灯带会把下一帧当成上一帧的延续整个灯带显示就会错乱。实现复位段的办法很巧妙因为CCR0时PWM整个周期输出低电平所以只要在缓冲区最前面放一串全0的CCR值DMA循环搬运时就会先在数据流前面插入足够长的低电平。每个CCR对应1.25us放40个就是50us建议放50个留余量用逻辑分析仪测过是62.5us低电平稳稳满足要求。缓冲区布局如下#define LED_COUNT 96 #define BITS_PER_LED 24 #define RESET_LOW_COUNT 50 #define CCR_BUFFER_SIZE (RESET_LOW_COUNT LED_COUNT * BITS_PER_LED) uint16_t ccr_buffer[CCR_BUFFER_SIZE];数组前50个元素初始化成全0后面依次是每个灯珠每1bit对应的CCR值。DMA的传输长度就设为CCR_BUFFER_SIZE循环模式启动后数据会自动首尾衔接一直输出。如果你不想在缓冲区里塞复位段也可以用定时器更新中断在帧结束时关闭PWM输出保持低电平一段时间再打开。但这样CPU要参与控制缓冲区方案完全不需要CPU所以我一直用前者。3.2 颜色转CCR序列的函数实现有了缓冲区布局接下来就是把RGB颜色写入对应位置。前面说过WS2812的串行数据顺序是GRB最高位在前转换函数写成这样#define ZERO_HIGH_COUNT 59 #define ONE_HIGH_COUNT 151 void led_strip_set_pixel_color(uint16_t index, uint8_t r, uint8_t g, uint8_t b) { if (index LED_COUNT) { return; } uint16_t *p ccr_buffer[RESET_LOW_COUNT index * BITS_PER_LED]; uint8_t byte; // 先发送绿色分量 byte g; for (int i 7; i 0; i--) { *p (byte (1 i)) ? ONE_HIGH_COUNT : ZERO_HIGH_COUNT; } // 再发送红色分量 byte r; for (int i 7; i 0; i--) { *p (byte (1 i)) ? ONE_HIGH_COUNT : ZERO_HIGH_COUNT; } // 最后发送蓝色分量 byte b; for (int i 7; i 0; i--) { *p (byte (1 i)) ? ONE_HIGH_COUNT : ZERO_HIGH_COUNT; } }这个函数逐位判断颜色字节的第i位是1还是0然后把对应的CCR值写入缓冲区。执行完一遍某个灯珠的24位数据就全部填好了。如果想一次设置一整条灯带的颜色可以写个循环调用上面的函数。如果要做彩虹渐变就在循环里用HSV转RGB的公式算出颜色再调用设置函数。3.3 启动PWMDMA与循环刷新CubeMX生成工程后在main函数里先调用MX_TIM1_Init()和MX_DMA_Init()然后把所有灯珠颜色设置好最后启动PWM的DMA传输int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_TIM1_Init(); // 初始化缓冲区前50个为复位低电平 for (int i 0; i RESET_LOW_COUNT; i) { ccr_buffer[i] 0; } // 设置所有灯珠为熄灭状态 for (int i 0; i LED_COUNT; i) { led_strip_set_pixel_color(i, 0, 0, 0); } // 启动DMA搬运数据会循环输出到PWM比较寄存器 HAL_TIM_PWM_Start_DMA(htim1, TIM_CHANNEL_1, (uint32_t *)ccr_buffer, CCR_BUFFER_SIZE); while (1) { // 主循环里直接改缓冲区下一帧就会自动更新 } }HAL_TIM_PWM_Start_DMA这个函数内部做了两件事使能PWM输出以及启动DMA传输。DMA配置成了循环模式所以启动之后不用管它会从头到尾、再从尾到头一直搬运。以后想更新颜色只需要调用led_strip_set_pixel_color修改缓冲区里的数据。DMA每一轮循环都会重新读一遍整个数组所以新的颜色值会在下一帧自动输出不需要再调用任何启动函数。这里有一个需要了解的细节如果一边DMA正在搬运一边CPU写同一个数组就会存在数据竞争风险。比如DMA已经读到第20个元素CPU把第一个元素改了这一帧就会有一半是旧数据、一半是新数据。对于呼吸灯这种颜色渐变不明显的效果偶尔撕裂看不出来。如果做追光、流水这种明暗对比强烈的效果可能会看到一条分割线。解决数据竞争的标准做法是双缓冲开两个同样大小的数组DMA正在读A时CPU写B等DMA完成一轮传输后在中断回调里手动切换DMA的内存地址指向BCPU再写A。我在后面的扩展章节里给方案。3.4 一个可跑的呼吸灯/流动效果Demo给一个可以直接上板的Demo包含呼吸灯和简单的流水灯效果。void demo_breath(void) { for (int brightness 0; brightness 255; brightness 5) { for (int i 0; i LED_COUNT; i) { led_strip_set_pixel_color(i, brightness, 0, 0); } HAL_Delay(20); } for (int brightness 255; brightness 0; brightness - 5) { for (int i 0; i LED_COUNT; i) { led_strip_set_pixel_color(i, brightness, 0, 0); } HAL_Delay(20); } } void demo_flow(void) { static int offset 0; for (int i 0; i LED_COUNT; i) { if ((i offset) % 10 0) { led_strip_set_pixel_color(i, 255, 255, 255); } else { led_strip_set_pixel_color(i, 0, 0, 0); } } offset; HAL_Delay(50); }呼吸灯的效果原理很简单每个亮度值更新一次缓冲区DMA自动把新值刷到灯带上。HAL_Delay控制刷新节奏。注意亮度值不要从0一下子到255每步跨度太大会看到明显的跳变5个步进在20ms延时下肉眼看起来就比较顺滑了。流水的效果则是间隔点亮灯珠每隔固定时间把偏移量加1产生一个白色的光带在灯带上移动。4. 常见问题与排查实录4.1 灯带不亮、颜色错乱、尾部闪烁这几个症状看起来不一样本质上都是时序问题或数据问题排查顺序可以固定下来先看波形。把逻辑分析仪夹到灯带DIN引脚抓一下上电瞬间的波形。正常的情况是一长段低电平复位信号然后是一串周期1.25us的PWM脉冲。如果波形频率不对比如周期变成2.5us多半是TIM1的时钟配置错了定时器时钟不是168MHz。这个时候回去看CubeMX的Clock Configuration确认APB2分频后TIM1时钟是否自动倍频到了168MHz。如果波形里有明显的毛刺或者高电平宽度漂移检查DMA数据宽度是不是Half Word检查内存地址是否递增检查外设地址是否递增了。外设地址必须不递增否则DMA会把数据写到CCR1后面的寄存器里波形会乱成一片。如果波形整体正常但灯带颜色不对检查GRB顺序。我见过很多次代码里按RGB写法也能亮但红蓝互换这是因为转换函数里字节发送顺序写错了。最后一个灯或尾部几个灯异常多半是供电问题。灯带越长尾部压降越大5V降到4V左右WS2812内部逻辑就可能不稳定。4.2 供电与压降问题供电问题最容易迷惑人。灯带前几个灯亮、后面慢慢变暗甚至最后一个灯颜色偏黄偏暗第一反应是数据线有问题其实十有八九是电源线压降。WS2812内部是恒流驱动假设白灯亮度下每颗灯电流20mA100颗灯就是2A。如果灯带两端用普通的杜邦线或者细导线连接线阻会让远端电压跌下去电压不足时芯片直接工作异常。我自己的经验是灯带两端同时供电也就是俗称的环接。电源正极和GND在灯带头部接入同时再从灯带尾部再接一组电源线这样中段压降最小。如果灯带超过5米建议每隔一段再并一条电源线。数据线只能是单线串联但电源线可以到处并联。另外WS2812的GND必须和单片机GND连在一起如果两边各用各的电源但地不共数据信号就没有参考电平逻辑电平判断会出错。这个错出现得很隐蔽第一次遇到时查了半天才发现是忘了共地。4.3 DMA宽度、时序参数等细节排查有个问题值得单独拿出来说HAL_TIM_PWM_Start_DMA的第三个参数是uint32_t*但我们的缓冲区是uint16_t数组。很多人看到这个类型不匹配就懵了直接把uint16_t数组取地址传进去编译报warning也不管。实际上只要DMA配置成Half Word宽度底层搬运时就是按半字处理类型转换只是接口层面的兼容不影响结果。还有一点如果把缓冲区定义成uint32_t数组然后DMA宽度配Half Word内存地址递增步长依然是2字节高16位会被跳过最终DMA读到的都是低16位值看起来也能工作但缓冲区占用的内存翻了一倍没有必要。时序参数如果想微调比如某些灯珠批次对时序更敏感可以单独改ZERO_HIGH_COUNT和ONE_HIGH_COUNT。0码的取值范围大致是200ns到500ns1码是650ns到950ns只要落在区间内就能识别。可以先用逻辑分析仪量一下实际输出的高电平时间再微调CCR值。4.4 与其他DMA/中断外设的冲突F407的DMA资源一共两个控制器16个stream但每个stream同一时刻只能服务一个外设请求。TIM1_CH1的DMA请求固定占用DMA2 Stream5 Channel6。如果工程里其他外设也用到了DMA2 Stream5比如ADC或串口就会冲突外设初始化时CubeMX会检测到。如果必须共用DMA控制器优先把不常用的外设换到其他stream上。比如串口1的TX/RX可以用DMA2 Stream7 Channel4和TIM1的Stream5错开。CubeMX里选择外设的DMA请求时一般会提供多个stream选项调一下就行。中断方面TIM1的PWMDMA方案如果不开启传输完成中断完全可以在一个对实时性要求很高的系统里跑。DMA搬运不会触发任何中断PWM输出是硬件自动完成CPU可以把全部时间留给业务逻辑。但如果开了DMA中断并且中断优先级设置得很高要注意中断回调里不要做耗时操作。HAL的传输完成回调实际是在中断上下文执行的放个标志位就好颜色计算放主循环。5. 扩展方向多路同步、无线控制与双缓冲5.1 多路灯带同步输出TIM1除了CH1还有CH2、CH3、CH4每个通道都可以配置成PWM输出并且每个通道都有对应的DMA请求。这意味着一个TIM1就能独立控制多路灯带各路之间天然同步因为共用了同一个时基。做法很简单在CubeMX里把TIM1的CH1到CH4全部配置成PWM Generation然后给每个通道分配一个DMA stream。缓冲区和传输函数也按通道分开。这样做多路动画时所有灯带的时间基准完全一致不会出现一路快一路慢的漂移。如果灯带数量更多还可以用TIM1_CH1作为主定时器通过主从模式去同步其他定时器。但一般一两个项目里用不上这么复杂先把四路用起来足够应对多数场景。5.2 双缓冲与帧同步要彻底解决CPU写缓冲区和DMA读缓冲区的竞争问题双缓冲是标准解法。简单说就是准备两份同样大小的缓冲区一份给DMA读一份给CPU写一个帧周期结束后交换。开启DMA的半传输完成中断和传输完成中断在回调里切换DMA的内存地址uint16_t ccr_buffer_a[CCR_BUFFER_SIZE]; uint16_t ccr_buffer_b[CCR_BUFFER_SIZE]; uint16_t *current_dma_buffer ccr_buffer_a; uint16_t *current_cpu_buffer ccr_buffer_b; volatile uint8_t buffer_ready 1; void HAL_TIM_PWM_DMAHalfCpltCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM1) { // 前半段传输完毕CPU可以写前半段 } } void HAL_TIM_PWM_DMACpltCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM1) { // 一整轮传输完毕切换DMA源地址到另一块缓冲区 current_dma_buffer current_cpu_buffer; current_cpu_buffer (current_dma_buffer ccr_buffer_a) ? ccr_buffer_b : ccr_buffer_a; HAL_TIM_PWM_Stop_DMA(htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start_DMA(htim1, TIM_CHANNEL_1, (uint32_t *)current_dma_buffer, CCR_BUFFER_SIZE); buffer_ready 1; } }严格来说在传输完成中断里停止再启动DMA中间会有一段低电平时间这段低电平正好可以作为下一帧的复位信号所以不会影响显示。这套方案在高速动画、需要精确帧控制的场景下非常好用只是代码量多一些。对入门项目来说一块缓冲区已经够跑绝大多数效果。5.3 搭配ESP8266实现无线控制聊一个热门方向把ESP8266加进来通过WiFi控制WS2812灯带。比较常见的架构有两种。一种是以ESP8266为主控F407只做时序输出。ESP8266通过串口把颜色数据发给F407F407在串口中断里接收解析然后写入CCR缓冲区。这样ESP8266不需要参与精确时序只负责网络协议和颜色计算F407发挥定时器DMA的优势各干各的活。另一种是ESP8266直接驱动WS2812不经过F407。ESP8266本身没有像F407这样丰富的定时器DMA组合驱动WS2812要么用软件模拟时序要么用I2S外设做特殊映射要么用RMT外设ESP32才有。如果坚持用ESP8266我会选软件模拟方式因为灯带数量少的话也够用但数量一多CPU就忙不过来。我个人更推荐前一种ESP8266负责联网、接收手机或云端下发的颜色指令F407负责灯带驱动。两个芯片各司其职稳定性远好过ESP8266单芯片硬扛。扩展这个方案时串口通信可以采用DMA空闲中断接收不定长数据这样F407的CPU连串口接收都能省下来。注意串口的DMA stream要和TIM1的DMA stream错开。回到这套PWMDMA方案本身我在实际调试中最深的感受是硬件外设组合起来用比单纯用软件去“凑”时序可靠太多。WS2812的时序要求看起来苛刻但只要算准了定时器时钟、配对了DMA参数后面基本不需要维护。缓冲区设计好之后加新灯光效果只是在往数组里填不同的CCR值从“控制硬件”变成了“写颜色数据”思维负担一下子小了很多。如果后面要做更复杂的动画建议先把逻辑分析仪准备好。调WS2812这类时序敏感外设靠肉眼和万用表效率太低几十块钱的逻辑分析仪能帮你把0码1码的时间宽度看得清清楚楚排错快很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进