简介一套基于 STM32C103C8T6 微控制器的 WS2812B 灯带驱动工程面向嵌入式开发者和 STM32 学习者采用 DMAPWM 模式实现高稳定性时序控制解决传统软件中断驱动时的信号抖动问题适用于 RGB 全彩显示、智能照明、动态装饰等场景。压缩包共 183 个文件大小 6.15MB包含 31 个 C 源文件、32 个头文件、汇编启动文件以及 Keil 工程配置、编译生成的 HEX 固件和一键批处理文件代码基于 ST 标准外设库编写目录结构清晰便于定位定时器、DMA 和颜色数据编码等关键模块。已有 569 人学习下载。从中可掌握定时器与 DMA 协同生成多路 PWM 脉冲的方法理解 WS2812B 单线协议中 “0” 码与 “1” 码的占空比差异并通过示例工程直接烧录验证灯带效果也可参考其硬件配置思路调整驱动参数适配不同数量的 LED 或自定义显示特效。 做WS2812B灯带驱动市面上方案很多但我这次在项目里选了STM32F103C8T6配DMAPWM这套打法。标题里写的“STM32C103C8T6”其实就是这颗料淘宝和代理包里经常串字母引脚、外设都按F103C8T6处理就行。整套方案跑得很稳36颗灯珠的灯板刷新率做到了七八百赫兹CPU占用几乎为零。这篇文章把原理、配置、代码、踩坑一次讲透适合正在折腾WS2812B灯带、想做氛围灯或桌面RGB的开发者参考。1. 项目概述与方案选型思路1.1 这个项目到底要做什么WS2812B是一颗内置驱动IC的全彩RGB LED单线串联通信内部三路恒流驱动外部只需要一根数据线就能控制无数颗灯珠。每颗灯珠内部有移位寄存器数据按GRB顺序、高位在前逐bit送入24个bit组成一个完整颜色数据数据经过第一个灯珠的整形放大后继续往下一颗传递这就实现了串联级联。传统做法是MCU用GPIO口模拟时序每送一个bit都要精确翻转引脚0码高电平约0.35us、1码高电平约0.8us一旦被中断打断时序就飘了。我这次改用STM32F103C8T6的TIM1高级定时器输出800kHz PWM再通过DMA把颜色数据自动搬运到定时器的比较寄存器CCR让硬件自己去卡时序。CPU只需要把GRB颜色值编码成对应的占空比数据放在内存里剩下的事情完全交给外设。这套方案的直接好处是刷新率稳定、CPU开销极小、灯珠级联不受代码执行干扰还可以顺便让MCU去跑按键扫描、蓝牙通信、音乐频谱分析等任务不用守着GPIO翻引脚。1.2 为什么选DMAPWM而不是其他方案不少人在CSDN或GitHub上见过用SPI、I2S、甚至纯GPIO延时做WS2812B驱动的实现我也都试过各有各的坑。先看纯GPIO翻转方案。WS2812B的bit周期是1.25us0码和1码靠高电平宽度区分这意味着每控制一颗灯珠要精确翻转24次36颗灯就是864次。如果你在中断里做系统几乎什么都干不了如果while循环里做一个定时器中断就可能导致帧数据错乱。核心问题在于人眼对RGB灯效的刷新率要求不高但WS2812B对时序的敏感度极高软件翻转的方式很难在跑RTOS或多任务时保证时序。再看SPI方案。SPI的SCK可以配置到很高频率用MOSI发送预编码的9bit或10bit数据来模拟0码和1码理论上可行但数据编解码比较绕SCK频率和bit模式要重新推导发送9bit时还要处理字节对齐代码可读性差。I2S方案也存在类似问题而且I2S更适合音频流不是每个工程都想引入这种外设。DMAPWM方案的优势在于定时器本身就为精确方波而生定时器时钟72MHz设置预分频0、自动重装值89就能得到800kHz的PWM周期每个周期内CCR值决定高电平宽度CCR30就是0码的高电平宽度CCR60就是1码的高电平宽度。DMA负责把内存里预先编好的CCR值数组不断写入CCR寄存器整个过程CPU零干预。这个方案最贴合WS2812B的物理时序需求也没有SPI那种数据对齐的麻烦。2. WS2812B时序与定时器原理2.1 WS2812B通信协议剖析WS2812B的数据手册上写得很清楚数据速率800kbps每一位的周期是1.25us。0码要求高电平时间0.22us到0.38us之间1码要求高电平时间0.58us到1.0us之间高低电平切换后必须有一个低电平阶段整个位周期固定1.25us。发送完一帧所有灯珠的数据后还需要至少50us的低电平复位码让所有灯珠锁存当前颜色并准备接收下一帧。还有一点容易被忽略WS2812B的数据线在T0时刻的高电平意味着该bit是0这与常见的UART空闲高电平相反。很多人用软件模拟时序时容易把极性搞反导致所有灯都显示异常颜色。用PWM模式时只要把PWM极性配置为高电平有效并让CCR30的波形落在0码要求区间、CCR60落在1码要求区间即可天然满足协议根本不需要纠结极性反转的问题。有人用PWM输出2.5V逻辑电平驱动WS2812B也能点亮但不同批次灯珠的输入阈值有差异供电电压越高稳定裕度越小。我建议在数据线上串一个330Ω电阻用于抑制反射和毛刺这也是数据手册明确推荐的。2.2 定时器参数推导从72MHz到800kHzSTM32F103C8T6的主频是72MHzTIM1挂在APB2总线上时钟源就是72MHz。要使PWM频率等于800kHz计算方式很直接定时器计数频率 72MHz / (PSC 1) PWM频率 72MHz / ((PSC 1) * (ARR 1))取PSC0、ARR89得到PWM频率 72MHz / 90 800kHz每个PWM周期时长 1 / 800kHz 1.25us正好等于WS2812B的一个bit周期。此时ARR89意味着占空比调节精度是1/90每个CCR步进对应的高电平时间是1.25us / 90 ≈ 13.9ns。这个13.9ns的步进对WS2812B来说足够精细了0码CCR取30高电平约416ns落在0.22us到0.38us区间的边缘这里要注意市面常见灯珠的实际判定范围比手册标称更宽实测CCR30时的416ns高电平能稳定被识别为0如果你追求更保守可以取CCR27或28对应375ns到389ns。我实测用CCR30、CCR60跑几十颗不同批次灯珠都没问题。1码CCR60高电平约833ns也在1us上限以内。如果选通用定时器TIM2/CH2PA1或TIM3只要把这个定时器时钟从APB1的36MHz倍频到72MHz同样的PSC和ARR也能得到800kHz。区别只是TIM1是高级定时器多了一个主输出使能位MOE配置时需要注意后面会专门讲坑。2.3 DMA在里扮演的角色有了PWM还不够因为颜色数据是一连串的CCR值不可能每bit都让CPU手动改CCR寄存器。DMA的作用就是把内存里预编码好的CCR值数组按照定时器触发事件自动写入TIM1-CCR1寄存器。工作流程是这样的定时器每个PWM周期产生一次更新或比较事件这个事件会作为DMA请求DMA控制器从内存读取一个16位数据也就是CCR值写入CCR1寄存器完成一个bit的输出。因为DMA配置了循环模式数组发完后又从头开始PWM输出就一直连续不断。整个过程CPU只负责在需要更新颜色时修改内存数组里的数值DMA和定时器自己把数据流跑起来。这里的关键在于DMA连接的请求源必须选TIM1_CH1而不是TIM1_UPDATE。如果选错请求源DMA传输频率和PWM周期对不上Out波形就乱套。CubeMX里DMA Settings添加TIM1_CH1的DMA通道即可。3. CubeMX配置与引脚安排3.1 时钟与引脚分配我用的引脚是TIM1_CH1对应的PA8。在CubeMX里先把RCC的HSE配好让系统时钟跑到72MHz。然后在Pinout视图里找到PA8把它复用为TIM1_CH1。如果你用的是PA1或PA3那就对应TIM2_CH2和TIM2_CH3配置思路一样只是定时器不同后面启动DMA时通道参数换一下。PA8直接接WS2812B的数据线中间串330Ω电阻。电源部分WS2812B需要5V供电不要用3.3V给灯珠供电白色高亮度时会偏色甚至闪烁。3.2 TIM1 PWM参数配置在CubeMX的Timers里选中TIM1Channel1选择PWM Generation CH1。然后进入Parameter Settings配置Prescaler (PSC) 0Counter Mode UpCounter Period (ARR) 89Auto-reload preload EnablePWM Generation Channel 1 PWM Mode1PulsePWM初始占空比 30或60都行后面DMA会改写这里PWM Mode1意味着计数器的值小于CCR时输出高电平。如果你的极性配反了灯珠会显示反码也就是0、1反转解决办法是改成PWM Mode2或者检查灯珠逻辑。我习惯用PWM Mode1省心。TIM1是高级定时器生成代码后如果发现PA8没有波形十有八九是主输出没使能。HAL库已经帮你在MX_TIM1_Init之后的HAL_TIM_MspPostInit里调用了HAL_TIM_PWM_Start或使能了MOE但如果你用寄存器版本或标准库必须手动执行TIM_CtrlPWMOutputs(TIM1, ENABLE)。这个坑我至少踩过两次。3.3 DMA配置要点在CubeMX的DMA Settings页签里添加一个DMA请求选择TIM1_CH1。参数配置如下Direction Memory To PeripheralPriority HighDMA Mode CircularPeripheral Increment DisableMemory Increment EnablePeripheral Data Width Half WordMemory Data Width Half Word这个配置的重点在于数据宽度必须统一。TIM1的CCR寄存器是16位WS2812B的CCR值用uint16_t存放最合适所以DMA数据宽度选Half Word。如果你选Word也能工作但内存里会被多读一个空字节实际输出结果依赖数组的初始化值容易出莫名其妙的问题所以别偷懒。还有一个非常关键的选项是DMA Continuous Requests。注意看CubeMX里DMA Request Settings里面有个Continuous Requests Mode必须设为Enable。如果设成DisableDMA只在收到一次外设请求后传一次之后不会持续搬运表现就是灯只亮一帧就再也不刷新了。网上不少人的问题就出在这。生成代码后检查一下main.c里MX_DMA_Init()必须在MX_TIM1_Init()之前调用否则DMA通道可能关联不上。4. 驱动代码实现4.1 颜色展开从RGB字节到CCR占空比WS2812B需要的是GRB字节序不是RGB。颜色展开就是把每个颜色字节拆成8个bit每个bit根据是0还是1输出对应的CCR值30或60。因为WS2812B要求高位先发所以要从最高位开始取。我定义一个全局数组作为DMA数据源#define WS2812_NUM 36 // 灯珠个数 #define RESET_CNT 128 // 复位码半字个数 #define WS2812_ZERO 30 // 0码CCR值 #define WS2812_ONE 60 // 1码CCR值 static uint16_t ws2812_buf[WS2812_NUM * 24 RESET_CNT];编码函数如下void ws2812_encode_pixel(uint16_t *buf, uint8_t r, uint8_t g, uint8_t b) { uint8_t grb[3] {g, r, b}; for (int i 0; i 3; i) { for (int j 0; j 8; j) { *(buf) (grb[i] (0x80 j)) ? WS2812_ONE : WS2812_ZERO; } } }0x80 j表示从最高位开始逐个判断这样生成的顺序就是高位在前符合WS2812B协议。每调用一次这个函数就把一颗灯珠的24个bit写到buf里的对应位置。4.2 复位码与缓冲区布局WS2812B要求帧与帧之间至少50us的低电平。因为PWM周期是1.25us50us需要约40个bit周期的低电平也就是40个CCR0的half word。我取128个也就是160us稳妥有余符合“宁长勿短”的原则。复位码对应的half word值就是0。因为CCR0时整周期内PWM输出都是低电平。把缓冲区末尾的128个half word初始化为0就行for (int i 0; i RESET_CNT; i) { ws2812_buf[WS2812_NUM * 24 i] 0; }这里有个细节有些人会在启动DMA前把整个buf清零但DMA是循环模式如果复位码区域在帧结束后又立刻回到帧头那帧之间的间隔就是128个PWM周期约160us完全足够。4.3 启动DMA与刷新逻辑启动DMA需要调用HAL库的PWM启动函数注意第四个参数传的是uint32_t指针但实际数据宽度在DMA配置里已经设成了Half WordHAL_TIM_PWM_Start_DMA(htim1, TIM_CHANNEL_1, (uint32_t *)ws2812_buf, WS2812_NUM * 24 RESET_CNT);这个函数调用之后TIM1的PWM输出就自动开始通过DMA从数组搬运数据到CCR1。因为DMA是循环模式数组会一直循环输出所以灯珠会保持当前颜色不会因为一次传输结束而熄灭。更新灯珠颜色时只需要修改ws2812_buf中的数据下一帧DMA循环到该位置时就会输出新颜色。不需要停止DMA再启动除非你要做全局灭灯或进入低功耗模式。为了简化操作我封装一个刷新函数void ws2812_refresh(void) { for (int i 0; i WS2812_NUM; i) { ws2812_encode_pixel(ws2812_buf[i * 24], led_r[i], led_g[i], led_b[i]); } }然后在main循环里修改led_r/g/b数组后再调用ws2812_refresh()即可。复位码区域不需要每次重新写只要初始化一次就行。5. 级联灯珠的应用与硬件经验5.1 多灯珠的数据排列和刷新率估算多灯珠级联在数据层面很简单第一个灯的数据在前24个half word第二个灯的数据在接下来的24个half word依次排列。DMA发送时数据依次经过灯珠内部移位寄存器第一个灯吃掉前24bit剩余数据继续传给下一个灯所以数组顺序就是灯珠顺序。刷新率计算方式总半字数 WS2812_NUM * 24 RESET_CNT 帧时间 总半字数 * 1.25us 刷新率 1 / 帧时间36颗灯珠RESET_CNT128总半字数为1032帧时间约1.29ms刷新率约775Hz。100颗灯珠时总半字数2528帧时间约3.16ms刷新率约316Hz。即使300颗灯珠刷新率也有约108Hz动画依然流畅。这个方案的上限主要受RAM大小和刷新帧率需求限制F103C8T6有20KB RAM300颗灯珠需要约14.6KB缓冲区加上其它变量后还能跑但如果要做音乐频谱等高交互应用建议用F103RCT6或F103ZET6RAM更充裕。5.2 供电和硬件注意事项WS2812B最大的坑其实是供电。每颗灯珠全白全亮时电流约60mA36颗全白就超过2A。如果用USB口直接供电轻则压降闪烁重则USB口保护。项目里我直接用5V/3A电源适配器给灯板供电MCU板子用独立USB供电两边共地。数据线从MCU到灯板第一颗灯之间串联330Ω电阻并在灯板电源两端并联1000μF电解电容靠近第一颗灯珠放置。千万别把WS2812B的DIN接到MCU的3.3V电源上而不做任何处理。虽然很多灯板反着接也能亮但长期高温、高负载下灯珠内部IC的输入阈值可能漂移3.3V逻辑电平在5V供电下余量不足偶尔出现个别灯珠颜色跳变的诡异问题。要彻底规避可以用74HCT245做电平转换或者直接选一颗支持3.3V逻辑的兼容灯珠型号。我为了省事在长线传输时用了一颗74HCT245波形实测干净很多。6. 常见问题与排查技巧实录6.1 第一个灯永远是绿色这是社区里问得最多的问题。我用了一下午定位这个问题最后发现是GRB字节序没处理正确。WS2812B的数据协议明确规定是GRB顺序如果你在颜色展开时按RGB顺序发送绿色分量恰好被当成第一个字节发出去导致第一个灯看起来是绿色、后面灯颜色错乱或者不亮。解决办法就是代码里按{g, r, b}的顺序取字节。还有一种情况是编码顺序里位序反了。如果0x80 j被写成0x01 j就是最低位先发送WS2812B会认不出完整数据表现也是第一个灯绿色或颜色怪异严重时灯不亮。最后一个原因是DMA数据宽度不匹配。如果你用Half Word配置但实际上把数组定义成了uint8_tDMA搬运时会错位导致每一bit的CCR值整体偏移第一个灯也会出现错误颜色。6.2 DMA只跑一帧就不动了这个我一开始也遇到过现象是上电后灯亮了一下颜色之后怎么更新都不变。检查配置后会发现问题出在DMA Mode没有设成Circular或者Continuous Requests是Disable。如果是Normal模式DMA传输完就停止需要每次都在DMA完成中断里重新调用HAL_TIM_PWM_Start_DMA但那样会引入中断开销和时序间隙不如直接开Circular省事。如果你确认DMA是Circular但更新buf后还是不动检查一下是否在刷新函数里写了类似HAL_TIM_PWM_Stop_DMA再Start_DMA的代码。stop再start会导致当前PWM周期中断产生一小段额外的低电平如果恰好打断复位码区域灯珠状态就会卡住。我的建议是启动一次后就不再操作DMA只改数组。6.3 TIM1没有PWM波形高级定时器TIM1的特殊之处在于有个主输出使能位MOE。如果用标准库或寄存器开发必须执行TIM_CtrlPWMOutputs(TIM1, ENABLE)才能让PWM引脚真正输出。HAL库生成的代码里已经包含了这一步但如果你手动关过刹车引脚或修改过GPIO初始化顺序也可能导致MOE被清零现象就是PA8一直是低电平或高电平。另外如果用CubeMX配置串口或调试器占用了PA8引脚冲突也会导致没波形检查Pinout视图里的复用功能是否冲突。6.4 排查时序问题的核心建议调试这类时序敏感的外设时逻辑分析仪是刚需。我习惯在DMA输出的数据线上接一个IO逻辑分析仪采样率拉到12MHz以上抓一帧数据看0码和1码的脉宽分布。正常情况下0码高电平约416ns、1码高电平约833ns周期1.25us。如果波形显示高电平宽度比预期大或小优先检查定时器时钟源和PSC/ARR是否正确。如果手上没有逻辑分析仪也可以用示波器看PWM波形。实在都没有就把CCR值打印出来通过串口确认ws2812_buf里的值是否正确。至少能排除编码逻辑和DMA配置的问题。环境变量层面电源波纹是另一个隐形杀手如果灯珠一全白就疯狂闪烁先看电源电压是不是被拉垮了而不是怀疑代码。最后分享一个个人习惯我把WS2812B的DMA缓冲区定义为static全局变量而不是栈上的局部变量。虽然F103的RAM不小但DMA访问栈地址理论上没问题栈大小不够时系统直接hardfault而且局部变量每次函数返回就失效容易让DMA读到非法内存。全局变量的地址固定、生命周期长配合DMA这种异步搬运机制最可靠。本文还有配套的精品资源点击获取