WS2812这颗灯珠,玩过嵌入式灯带项目的朋友应该都不陌生。单总线协议、5V供电、每颗灯独立24bit颜色,一颗接一颗串成灯带,能做流水灯、渐变、彩虹、音乐律动。但真到驱动层面,很多人第一次写会卡壳:直接用GPIO翻转配延时函数发数据,灯少还能凑合,灯一多或者要加别的任务,CPU直接被打满,显示效果还一顿一顿的。用STM32CubeMX配合PWM+DMA来驱动WS2812,是目前我在实际项目里最推荐的做法——硬件定时器负责掐时序,DMA负责搬运数据,CPU几乎完全解放出来干别的事。
这篇文章就把这套方案从头到尾掰开揉碎讲清楚:从WS2812的时序协议,到CubeMX里的具体配置步骤,再到完整的驱动代码和几种常用灯效,最后把我这些年踩过的几个典型坑也一并列出来。适合刚接触STM32和WS2812的初学者照着做,也给已经装过灯带但想优化性能的朋友提供参考。整个工程以STM32F103C8T6为例,72MHz主频,你手里的F103/F401/其他STM32只要稍微改下时钟配置就能移植。
1. 方案选型与原理拆解
这部分很重要,先把原理搞清楚,配置代码才有底。
1.1 为什么选PWM+DMA而不是GPIO翻转
WS2812常见的驱动方式有三种:GPIO翻转加延时、SPI加外部逻辑、定时器PWM加DMA。我做完几个项目之后,基本锁定了最后一种。
先说GPIO翻转加延时。这种方案最直观,把0码和1码的高低电平时间用delay_us()强制卡出来,代码写起来也快。但有两个致命问题:第一,延时函数受中断影响极大,只要串口中断、定时器中断一来,时序就飘,灯带立刻闪烁;第二,发送一个灯珠要24bit,100颗灯就是2400bit,每bit一个周期1.25us也要3ms,期间CPU全被占用。你要是还想同时跑按键扫描、OLED刷新、传感器读取,基本不可能。这个方案只适合验证用,比如手头就5颗灯、没有任何中断的场景。
再说SPI方案。WS2812的时序虽然和SPI不完全一样,但可以用4bit或3bit映射法,把SPI的时钟抬高到原来的数倍,用多个SPI bit拼出一个WS2812 bit。优点是SPI+DMA也可以不占CPU,而且SPI外设几乎每个单片机都有。缺点是需要做电平转换或者用74HCT245之类的芯片把MOSI信号转出来,而且映射逻辑要把颜色数据先拆成SPI位流,调试波形时容易绕晕。实际项目里也可以用,但我个人感觉不如定时器PWM方案直接。
定时器PWM+DMA的思路是:让定时器以800kHz的频率输出PWM波形,每个PWM周期对应WS2812的一个bit周期。0码和1码的区别,就是PWM的占空比不同。然后DMA负责把一个预先编码好的CCR值数组,不断搬运到定时器的捕获比较寄存器里,硬件会自动改变每个周期的占空比,从而在引脚上直接产生WS2812需要的波形。整个过程CPU只在更新显示数据时介入,平时完全闲着。
1.2 WS2812的时序和PWM参数换算
WS2812的通信协议是单总线NRZ编码,所有bit都在800kHz的bit周期里传输。具体时序要求如下表:
| 信号 | 高电平时间 | 低电平时间 | 总周期 |
|---|---|---|---|
| 0码 | 0.35us | 0.8us | 1.25us |
| 1码 | 0.7us | 0.6us | 1.25us |
| RESET | 低电平至少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参数 | 数值 | 说明 |
|---|---|---|
| PSC | 0 | 72MHz不分频 |
| ARR | 89 | PWM周期1.25us |
| CCR0(0码) | 25 | 高电平约0.347us |
| CCR1(1码) | 50 | 高电平约0.694us |
CCR=25时落在0码的容差范围内,CCR=50时落在1码的容差范围内,都离边界有足够余量。这个余量非常关键,因为主频、APB时钟总线上的微小误差、温度漂移都可能让波形稍微偏移,余量大的方案在量产时才不会翻车。
如果你的主频不是72MHz,换算公式也很简单:ARR = 主频 × 1.25us - 1,CCR0约等于主频 × 0.35us,CCR1约等于主频 × 0.7us。比如168MHz的F407,ARR就是209,CCR0约58,CCR1约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新版本界面为例,芯片选STM32F103C8T6,IDE生成Keil MDK工程。其他芯片操作路径基本相同。
2.1 时钟树与PWM通道配置
打开CubeMX,新建工程选择STM32F103C8T6。第一步先把RCC时钟配好:
- 在左侧
System Core里选择RCC,HSE选Crystal/Ceramic Resonator。如果你用的是板上不带外部晶振的板子,可以跳过外部晶振直接用内部HSI,但HSI精度不如外部晶振,对WS2812时序有一定风险,建议还是用HSE。 - 打开
Clock Configuration标签页,在PLL Source MUX里选择HSE,把PLL Mul设为x9,系统时钟就变成8MHz × 9 = 72MHz。确认APB1 Prescaler为/2,此时APB1外设时钟是36MHz,但定时器时钟会自动加倍到72MHz,CubeMX时钟树上一般会直接显示Timer2 clock为72MHz。 - 如果你用的是其他主频的板子,目标就是确保最终
Timer clocks那一栏是72MHz即可。
接着配置定时器TIM2。在左侧Timers里选择TIM2:
Clock Source选Internal Clock。Channel1选PWM Generation CH1。- 在
Parameter Settings里设置Prescaler = 0,Counter Period (ARR) = 89,Pulse设成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 Request | TIM2_CH1 |
| Direction | Memory To Peripheral |
| Priority | High |
| Memory Increment | Yes |
| Peripheral Increment | No |
| Mode | Circular |
| Data Width(Peripheral) | Half Word |
| Data Width(Memory) | Half 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虽然配置好了,但定时器不会主动拉DMA,CCR寄存器永远是初始值,引脚上只输出固定占空比的PWM,灯带完全没反应。
第三,不要忘记把WS2812数据脚对应的GPIO模式核对一下。TIM2_CH1默认引脚是PA0,CubeMX会自动复用成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]存G,led_color[n][1]存R,led_color[n][2]存B。中间的编码函数直接按这个顺序发。
3.2 编码函数与DMA缓冲更新
核心函数ws2812_update()的作用是把led_color数组里的颜色值,按位拆解,写入ws2812_buf:
uint16_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蓝牙调灯色”的小玩具,灯带端的代码和这篇文章里的完全一样,只是主循环里多了一个解析蓝牙指令的步骤。所以把这套PWM+DMA驱动吃透,后续接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时钟不是72MHz,ARR=89对应的PWM周期就不是1.25us,所有时序都偏了。用逻辑分析仪或者示波器看PA0引脚的波形最直接:正常0码应该是一个约0.35us高电平加0.9us低电平的脉冲,1码应该是一个约0.7us高电平加0.55us低电平的脉冲。如果没有示波器,可以写一个测试代码,让所有灯常亮同一颜色,如果满屏花点,多半是时序参数不对。
还有一种容易忽略的闪屏原因:主循环里更新显示用的HAL_Delay被高优先级中断打断太久。如果串口中断里有耗时操作,或者DMA中断处理写得繁琐,主循环的灯效更新会被延迟,人眼看到的就是一顿一顿。解决办法是把耗时操作拆开,或者把灯效更新放到有明确时间基准的定时器中断里,确保每帧间隔稳定。
4.3 供电与长距离传输的经典坑
供电问题在长灯带上尤其突出。单颗WS2812全白最高亮度下电流约60mA,60颗就是3.6A,这已经远超普通USB口的输出能力。如果你用电脑USB供电60颗灯,跑全白效果时电压会被拉低,导致灯珠颜色漂移、闪烁甚至自动复位。正确做法是外接5V电源,电流按实际灯珠数量乘以50~60mA留足余量。信号线和电源线之间尽量用粗一点的线,避免压降。
信号电平方面,STM32的GPIO输出是3.3V,而WS2812在5V供电时,输入高电平阈值理论上是0.7 × VDD,约3.5V,3.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 |
| 定时器PWM+DMA | 定时器+DMA | 低 | 好,调试方便 | STM32全系 |
| ESP32 RMT | RMT外设 | 极低 | 极好 | ESP32 |
定时器PWM+DMA这套方案在STM32上最大的优势是通用性:F1、F4、G0、L4都能用相同思路实现,代码移植成本低。如果你换到ESP32平台,官方RMT外设天然支持这类脉冲协议,连PWM+DMA都不用操心了,但那属于另一套生态。SPI方案在只有SPI却不想折腾定时器的场景也有价值,但调试繁琐度整体高于本方案。
我个人在实际项目里,凡是STM32平台做WS2812,闭眼选PWM+DMA就对了。唯一需要认真算的就是主频和ARR、CCR的对应关系,算准了基本一次点亮。希望这套代码和调试经验能帮你少走点弯路。