【stm32】全彩led灯(PWM)

🔥铅笔小新z:个人主页

🎬博客专栏:stm32学习

💫滴水不绝,可穿石;步履不休,能至渊。


用 PWM + DMA 驱动 WS2812B:从 0/1 码到 3×3 全彩灯阵列

适用平台 :STM32F103ZET6(STM32F1 系列,HAL 库,STM32CubeMX + Keil)

外设与技术点 :TIM3_CH3、PWM 模式 1、DMA、WS2812B 单线归零码

最终效果 :3×3 全彩阵列按指定颜色点亮,CPU 不需要逐位拼波形

适合读者:学完 GPIO、定时器基础和 C 语言结构体/指针,能看懂基本外设配置,但还缺少完整项目经验的初学者

如果你已经能用 GPIO 点亮一颗普通 LED,下一步很容易卡在 WS2812B:代码看起来只是往数组里填数字,灯却不亮;或者第一颗正常,后面的颜色全乱;又或者示波器上明明有波形,灯珠就是不认。

这篇只围绕一条主线讲透:

颜色值 → 24 位 GRB → 每个 bit 变成 CCR 的 21/43 → DMA 逐项写入 TIM3_CCR3 → PB0 输出归零码 → 9 颗灯串行锁存。
#mermaid-svg-DItMqzQlXtZ94CAV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DItMqzQlXtZ94CAV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DItMqzQlXtZ94CAV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DItMqzQlXtZ94CAV .error-icon{fill:#552222;}#mermaid-svg-DItMqzQlXtZ94CAV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DItMqzQlXtZ94CAV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DItMqzQlXtZ94CAV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DItMqzQlXtZ94CAV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DItMqzQlXtZ94CAV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DItMqzQlXtZ94CAV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DItMqzQlXtZ94CAV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DItMqzQlXtZ94CAV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DItMqzQlXtZ94CAV .marker.cross{stroke:#333333;}#mermaid-svg-DItMqzQlXtZ94CAV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DItMqzQlXtZ94CAV p{margin:0;}#mermaid-svg-DItMqzQlXtZ94CAV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DItMqzQlXtZ94CAV .cluster-label text{fill:#333;}#mermaid-svg-DItMqzQlXtZ94CAV .cluster-label span{color:#333;}#mermaid-svg-DItMqzQlXtZ94CAV .cluster-label span p{background-color:transparent;}#mermaid-svg-DItMqzQlXtZ94CAV .label text,#mermaid-svg-DItMqzQlXtZ94CAV span{fill:#333;color:#333;}#mermaid-svg-DItMqzQlXtZ94CAV .node rect,#mermaid-svg-DItMqzQlXtZ94CAV .node circle,#mermaid-svg-DItMqzQlXtZ94CAV .node ellipse,#mermaid-svg-DItMqzQlXtZ94CAV .node polygon,#mermaid-svg-DItMqzQlXtZ94CAV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DItMqzQlXtZ94CAV .rough-node .label text,#mermaid-svg-DItMqzQlXtZ94CAV .node .label text,#mermaid-svg-DItMqzQlXtZ94CAV .image-shape .label,#mermaid-svg-DItMqzQlXtZ94CAV .icon-shape .label{text-anchor:middle;}#mermaid-svg-DItMqzQlXtZ94CAV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DItMqzQlXtZ94CAV .rough-node .label,#mermaid-svg-DItMqzQlXtZ94CAV .node .label,#mermaid-svg-DItMqzQlXtZ94CAV .image-shape .label,#mermaid-svg-DItMqzQlXtZ94CAV .icon-shape .label{text-align:center;}#mermaid-svg-DItMqzQlXtZ94CAV .node.clickable{cursor:pointer;}#mermaid-svg-DItMqzQlXtZ94CAV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DItMqzQlXtZ94CAV .arrowheadPath{fill:#333333;}#mermaid-svg-DItMqzQlXtZ94CAV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DItMqzQlXtZ94CAV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DItMqzQlXtZ94CAV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DItMqzQlXtZ94CAV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DItMqzQlXtZ94CAV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DItMqzQlXtZ94CAV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DItMqzQlXtZ94CAV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DItMqzQlXtZ94CAV .cluster text{fill:#333;}#mermaid-svg-DItMqzQlXtZ94CAV .cluster span{color:#333;}#mermaid-svg-DItMqzQlXtZ94CAV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DItMqzQlXtZ94CAV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DItMqzQlXtZ94CAV rect.text{fill:none;stroke-width:0;}#mermaid-svg-DItMqzQlXtZ94CAV .icon-shape,#mermaid-svg-DItMqzQlXtZ94CAV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DItMqzQlXtZ94CAV .icon-shape p,#mermaid-svg-DItMqzQlXtZ94CAV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DItMqzQlXtZ94CAV .icon-shape .label rect,#mermaid-svg-DItMqzQlXtZ94CAV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DItMqzQlXtZ94CAV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DItMqzQlXtZ94CAV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DItMqzQlXtZ94CAV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 颜色值

R、G、B 各 8 位
按 GRB、高位先行

排成 24 位
每个 bit 映射成 CCR

0 码→21,1 码→43
DMA 逐项写入

TIM3->CCR3
PB0 / TIM3_CH3

输出 0.889µs 周期波形
9 颗 WS2812B

逐级转发并锁存
RESET 低电平 ≥80µs

整帧颜色同时生效

图 1:全文只有这一条数据通路,后面的参数、代码和排错都围绕它展开。

先拆主线:6 个必须讲清的知识点

  1. WS2812B 是"灯珠 + 驱动芯片"的串联像素。 每颗灯吃下最近的 24 位颜色,再把剩下的数据从 DOUT 转发给下一颗。
  2. 0 码和 1 码靠高电平宽度区分。 两者都有高、低电平,接收端看的是高电平持续了多久。
  3. 3.3V 逻辑驱动 5V 灯珠,硬件比代码更容易出问题。 电源、共地、去耦、电平转换和数据方向,任何一项不对都会表现成"程序有 bug"。
  4. ARR 决定一个 bit 有多长,CCR 决定这个 bit 里高电平有多长。 把 72MHz 的计数变成 0.889µs,再把 64 个计数分成 21 和 43。
  5. DMA 在比较匹配时把下一个 CCR 值送进预装载寄存器。 当前周期不受打扰,下一次溢出时新值生效,形成"提前准备下一颗子弹"的流水线。
  6. CubeMX 和 HAL 的关键配置只有少数几项。 TIM3_CH3 的 PWM 模式、DMA1 Channel2、半字传输、内存自增,以及启动函数和完成回调。

SK6812 的 RGBW、APA102 的时钟线、伽马校正和渐变算法,和本文的"定时器 + DMA 造波形"不是同一条主线,这里只提一句,不展开。


一、原理:一颗灯珠里到底发生了什么

1.1 全彩不是"三种颜色轮流亮",而是三个发光芯片一起调光

普通单色 LED 只有一个发光芯片,通电就亮,断电就灭。全彩 LED 的外壳里通常封装了红、绿、蓝三个发光芯片,每个芯片的亮度都可以单独调节。

红、绿、蓝三种光按不同比例叠在一起,就能混合出很宽的颜色范围。每个颜色通道用 8 位表示亮度,范围是 0~255,三个通道一共 24 位,理论上可以组合出 16,777,216 种颜色。

WS2812B 更进一步:它把控制电路和发光电路封装在一起,外形仍然是常见的 5050 封装,也就是约 5.0mm × 5.0mm。它只有 4 个引脚:

  • VDD:电源正极,常见为 5V。
  • GND:电源地。
  • DIN:数据输入,接 MCU 或上一颗灯的 DOUT。
  • DOUT:数据输出,接下一颗灯的 DIN。

所以它不是"MCU 直接给三个颜色引脚供电"的普通三色灯,而是一颗自带驱动芯片的智能像素。MCU 发给它的是数字编码,不是三路模拟亮度。

1.2 9 颗灯为什么只占 1 根引脚

把多颗 WS2812B 的 DOUT 接到下一颗的 DIN,就形成了一条串行链。MCU 只需要接第一颗灯的 DIN,后面的灯会自己把多余的数据往下传。

本文的实验板把 9 颗灯排成 3×3 的矩阵,但物理上是矩阵,电气上仍然是一条链:LED9 → LED10 → LED11 → ... → LED17。第一颗灯取走最前面的 24 位,第二颗灯取走接下来的 24 位,依此类推。
#mermaid-svg-FXbEkuDvKxMGAQVc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FXbEkuDvKxMGAQVc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FXbEkuDvKxMGAQVc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FXbEkuDvKxMGAQVc .error-icon{fill:#552222;}#mermaid-svg-FXbEkuDvKxMGAQVc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FXbEkuDvKxMGAQVc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FXbEkuDvKxMGAQVc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FXbEkuDvKxMGAQVc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FXbEkuDvKxMGAQVc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FXbEkuDvKxMGAQVc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FXbEkuDvKxMGAQVc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FXbEkuDvKxMGAQVc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FXbEkuDvKxMGAQVc .marker.cross{stroke:#333333;}#mermaid-svg-FXbEkuDvKxMGAQVc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FXbEkuDvKxMGAQVc p{margin:0;}#mermaid-svg-FXbEkuDvKxMGAQVc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FXbEkuDvKxMGAQVc .cluster-label text{fill:#333;}#mermaid-svg-FXbEkuDvKxMGAQVc .cluster-label span{color:#333;}#mermaid-svg-FXbEkuDvKxMGAQVc .cluster-label span p{background-color:transparent;}#mermaid-svg-FXbEkuDvKxMGAQVc .label text,#mermaid-svg-FXbEkuDvKxMGAQVc span{fill:#333;color:#333;}#mermaid-svg-FXbEkuDvKxMGAQVc .node rect,#mermaid-svg-FXbEkuDvKxMGAQVc .node circle,#mermaid-svg-FXbEkuDvKxMGAQVc .node ellipse,#mermaid-svg-FXbEkuDvKxMGAQVc .node polygon,#mermaid-svg-FXbEkuDvKxMGAQVc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FXbEkuDvKxMGAQVc .rough-node .label text,#mermaid-svg-FXbEkuDvKxMGAQVc .node .label text,#mermaid-svg-FXbEkuDvKxMGAQVc .image-shape .label,#mermaid-svg-FXbEkuDvKxMGAQVc .icon-shape .label{text-anchor:middle;}#mermaid-svg-FXbEkuDvKxMGAQVc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FXbEkuDvKxMGAQVc .rough-node .label,#mermaid-svg-FXbEkuDvKxMGAQVc .node .label,#mermaid-svg-FXbEkuDvKxMGAQVc .image-shape .label,#mermaid-svg-FXbEkuDvKxMGAQVc .icon-shape .label{text-align:center;}#mermaid-svg-FXbEkuDvKxMGAQVc .node.clickable{cursor:pointer;}#mermaid-svg-FXbEkuDvKxMGAQVc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FXbEkuDvKxMGAQVc .arrowheadPath{fill:#333333;}#mermaid-svg-FXbEkuDvKxMGAQVc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FXbEkuDvKxMGAQVc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FXbEkuDvKxMGAQVc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FXbEkuDvKxMGAQVc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FXbEkuDvKxMGAQVc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FXbEkuDvKxMGAQVc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FXbEkuDvKxMGAQVc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FXbEkuDvKxMGAQVc .cluster text{fill:#333;}#mermaid-svg-FXbEkuDvKxMGAQVc .cluster span{color:#333;}#mermaid-svg-FXbEkuDvKxMGAQVc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FXbEkuDvKxMGAQVc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FXbEkuDvKxMGAQVc rect.text{fill:none;stroke-width:0;}#mermaid-svg-FXbEkuDvKxMGAQVc .icon-shape,#mermaid-svg-FXbEkuDvKxMGAQVc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FXbEkuDvKxMGAQVc .icon-shape p,#mermaid-svg-FXbEkuDvKxMGAQVc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FXbEkuDvKxMGAQVc .icon-shape .label rect,#mermaid-svg-FXbEkuDvKxMGAQVc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FXbEkuDvKxMGAQVc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FXbEkuDvKxMGAQVc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FXbEkuDvKxMGAQVc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} H1 的 3-4 脚短接
PB0

TIM3_CH3
RGB_DIN
LED9

DIN→DOUT
LED10
LED11
LED12
LED13
LED14
LED15
LED16
LED17
DOUT 悬空
VIN+5V
GND

图 2:3×3 只是排列方式,信号仍然从第一颗一路串到第九颗。

这也解释了一个常见现象:如果第一颗灯能亮,后面的灯不亮,先不要怀疑颜色编码,优先检查第一颗的 DOUT 有没有接到第二颗的 DIN,以及链路上的 5V 和 GND 有没有断。

知识小结 1:WS2812B 是"带驱动的串联像素",不是普通 RGB 三色灯。

每颗灯内部都有控制电路和 24 位颜色暂存单元,MCU 只通过一根数据线发送串行编码。第一颗灯取走前 24 位,剩余的位再从 DOUT 转发给下一颗,所以 N 颗灯只需要 1 个 GPIO。关键参数是每颗 24 位、GRB 顺序、DOUT→DIN 级联;常见误区是把 3×3 矩阵当成 9 根独立控制线,或者把 DOUT 和 DIN 接反。

1.3 0 码和 1 码:看的不是高低,而是高电平有多长

WS2812B 使用的是单线归零码。所谓"归零",是指每个 bit 结束时,信号都要回到低电平。这样无论下一位是 0 还是 1,接收端都能从一次新的上升沿开始判断。

它没有单独的时钟线,接收端靠高电平持续时间区分 0 和 1:

  • 0 码:高电平约 0.295µs,低电平约 0.595µs。
  • 1 码:高电平约 0.595µs,低电平约 0.295µs。

两者的总周期都约 0.89µs。也就是说,0 码不是"一直低",1 码也不是"一直高";它们都是一次短促的高电平,再加一段低电平回零。
#mermaid-svg-b2bOTqkDgwxo8Nyc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-b2bOTqkDgwxo8Nyc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-b2bOTqkDgwxo8Nyc .error-icon{fill:#552222;}#mermaid-svg-b2bOTqkDgwxo8Nyc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-b2bOTqkDgwxo8Nyc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-b2bOTqkDgwxo8Nyc .marker.cross{stroke:#333333;}#mermaid-svg-b2bOTqkDgwxo8Nyc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-b2bOTqkDgwxo8Nyc p{margin:0;}#mermaid-svg-b2bOTqkDgwxo8Nyc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-b2bOTqkDgwxo8Nyc .cluster-label text{fill:#333;}#mermaid-svg-b2bOTqkDgwxo8Nyc .cluster-label span{color:#333;}#mermaid-svg-b2bOTqkDgwxo8Nyc .cluster-label span p{background-color:transparent;}#mermaid-svg-b2bOTqkDgwxo8Nyc .label text,#mermaid-svg-b2bOTqkDgwxo8Nyc span{fill:#333;color:#333;}#mermaid-svg-b2bOTqkDgwxo8Nyc .node rect,#mermaid-svg-b2bOTqkDgwxo8Nyc .node circle,#mermaid-svg-b2bOTqkDgwxo8Nyc .node ellipse,#mermaid-svg-b2bOTqkDgwxo8Nyc .node polygon,#mermaid-svg-b2bOTqkDgwxo8Nyc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-b2bOTqkDgwxo8Nyc .rough-node .label text,#mermaid-svg-b2bOTqkDgwxo8Nyc .node .label text,#mermaid-svg-b2bOTqkDgwxo8Nyc .image-shape .label,#mermaid-svg-b2bOTqkDgwxo8Nyc .icon-shape .label{text-anchor:middle;}#mermaid-svg-b2bOTqkDgwxo8Nyc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-b2bOTqkDgwxo8Nyc .rough-node .label,#mermaid-svg-b2bOTqkDgwxo8Nyc .node .label,#mermaid-svg-b2bOTqkDgwxo8Nyc .image-shape .label,#mermaid-svg-b2bOTqkDgwxo8Nyc .icon-shape .label{text-align:center;}#mermaid-svg-b2bOTqkDgwxo8Nyc .node.clickable{cursor:pointer;}#mermaid-svg-b2bOTqkDgwxo8Nyc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-b2bOTqkDgwxo8Nyc .arrowheadPath{fill:#333333;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-b2bOTqkDgwxo8Nyc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b2bOTqkDgwxo8Nyc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-b2bOTqkDgwxo8Nyc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b2bOTqkDgwxo8Nyc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-b2bOTqkDgwxo8Nyc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-b2bOTqkDgwxo8Nyc .cluster text{fill:#333;}#mermaid-svg-b2bOTqkDgwxo8Nyc .cluster span{color:#333;}#mermaid-svg-b2bOTqkDgwxo8Nyc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-b2bOTqkDgwxo8Nyc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-b2bOTqkDgwxo8Nyc rect.text{fill:none;stroke-width:0;}#mermaid-svg-b2bOTqkDgwxo8Nyc .icon-shape,#mermaid-svg-b2bOTqkDgwxo8Nyc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b2bOTqkDgwxo8Nyc .icon-shape p,#mermaid-svg-b2bOTqkDgwxo8Nyc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-b2bOTqkDgwxo8Nyc .icon-shape .label rect,#mermaid-svg-b2bOTqkDgwxo8Nyc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b2bOTqkDgwxo8Nyc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-b2bOTqkDgwxo8Nyc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-b2bOTqkDgwxo8Nyc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1 码:周期 0.89µs
高

0.595µs
低

0.295µs
0 码:周期 0.89µs
高

0.295µs
低

0.595µs

图 3:色块只表示高/低顺序,宽度以标注为准;0 码是"短高 + 长低",1 码是"长高 + 短低",周期相同。

可以用旗语来理解:每个 bit 都规定在同一拍结束前把旗子放回腰间,区别只在于这一次旗子举多久。短举表示 0,长举表示 1。接收方不需要和你共享一个非常精确的时钟,只需要判断"这次举得久不久"。

这也是归零码的价值:它把"双方时钟必须完全同步"的压力,转化成了"测量高电平宽度"的简单判断,降低了接收端的硬件复杂度。

1.4 24 位 GRB:先发绿,再发红,最后发蓝

一颗灯需要 24 位颜色数据,但发送顺序不是 R、G、B,而是 G、R、B。每个颜色 8 位,并且是高位先行:

text 复制代码
G7 G6 G5 G4 G3 G2 G1 G0
R7 R6 R5 R4 R3 R2 R1 R0
B7 B6 B5 B4 B3 B2 B1 B0

所以想要纯绿,应该让 r=0、g=255、b=0,编码后先发 8 个 1 表示绿色亮度,再发 16 个 0。不要直接按"RGB 三个字节"去拼帧,否则很容易把红色和绿色写反。

9 颗灯就是 9 × 24 = 216 位,加上帧尾的 RESET,才构成一次完整的刷新。

1.5 RESET:24 位只是"装填",RESET 才是"生效"

每颗灯收完 24 位后,并不会立刻把颜色显示出来,而是先放进暂存区。整条链的数据发送完后,MCU 还要保持一段足够长的低电平,作为 RESET 信号。

RESET 有两个作用:

  1. 告诉灯珠"这一帧结束了"。
  2. 让所有灯珠同时把暂存区里的颜色应用到发光单元。

对本文使用的灯珠,RESET 的低电平要求不低于 80µs。注意,这不是把一个 PWM 周期拉长到 80µs,而是连续发送很多个"CCR=0"的 bit 周期。每个周期仍然是 0.889µs,只是每个周期的高电平都是 0,所以输出看上去是一条持续的低电平。
#mermaid-svg-iFMEkpuV6YKrTwKO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iFMEkpuV6YKrTwKO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iFMEkpuV6YKrTwKO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iFMEkpuV6YKrTwKO .error-icon{fill:#552222;}#mermaid-svg-iFMEkpuV6YKrTwKO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iFMEkpuV6YKrTwKO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iFMEkpuV6YKrTwKO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iFMEkpuV6YKrTwKO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iFMEkpuV6YKrTwKO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iFMEkpuV6YKrTwKO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iFMEkpuV6YKrTwKO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iFMEkpuV6YKrTwKO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iFMEkpuV6YKrTwKO .marker.cross{stroke:#333333;}#mermaid-svg-iFMEkpuV6YKrTwKO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iFMEkpuV6YKrTwKO p{margin:0;}#mermaid-svg-iFMEkpuV6YKrTwKO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iFMEkpuV6YKrTwKO .cluster-label text{fill:#333;}#mermaid-svg-iFMEkpuV6YKrTwKO .cluster-label span{color:#333;}#mermaid-svg-iFMEkpuV6YKrTwKO .cluster-label span p{background-color:transparent;}#mermaid-svg-iFMEkpuV6YKrTwKO .label text,#mermaid-svg-iFMEkpuV6YKrTwKO span{fill:#333;color:#333;}#mermaid-svg-iFMEkpuV6YKrTwKO .node rect,#mermaid-svg-iFMEkpuV6YKrTwKO .node circle,#mermaid-svg-iFMEkpuV6YKrTwKO .node ellipse,#mermaid-svg-iFMEkpuV6YKrTwKO .node polygon,#mermaid-svg-iFMEkpuV6YKrTwKO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iFMEkpuV6YKrTwKO .rough-node .label text,#mermaid-svg-iFMEkpuV6YKrTwKO .node .label text,#mermaid-svg-iFMEkpuV6YKrTwKO .image-shape .label,#mermaid-svg-iFMEkpuV6YKrTwKO .icon-shape .label{text-anchor:middle;}#mermaid-svg-iFMEkpuV6YKrTwKO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iFMEkpuV6YKrTwKO .rough-node .label,#mermaid-svg-iFMEkpuV6YKrTwKO .node .label,#mermaid-svg-iFMEkpuV6YKrTwKO .image-shape .label,#mermaid-svg-iFMEkpuV6YKrTwKO .icon-shape .label{text-align:center;}#mermaid-svg-iFMEkpuV6YKrTwKO .node.clickable{cursor:pointer;}#mermaid-svg-iFMEkpuV6YKrTwKO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iFMEkpuV6YKrTwKO .arrowheadPath{fill:#333333;}#mermaid-svg-iFMEkpuV6YKrTwKO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iFMEkpuV6YKrTwKO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iFMEkpuV6YKrTwKO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iFMEkpuV6YKrTwKO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iFMEkpuV6YKrTwKO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iFMEkpuV6YKrTwKO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iFMEkpuV6YKrTwKO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iFMEkpuV6YKrTwKO .cluster text{fill:#333;}#mermaid-svg-iFMEkpuV6YKrTwKO .cluster span{color:#333;}#mermaid-svg-iFMEkpuV6YKrTwKO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iFMEkpuV6YKrTwKO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iFMEkpuV6YKrTwKO rect.text{fill:none;stroke-width:0;}#mermaid-svg-iFMEkpuV6YKrTwKO .icon-shape,#mermaid-svg-iFMEkpuV6YKrTwKO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iFMEkpuV6YKrTwKO .icon-shape p,#mermaid-svg-iFMEkpuV6YKrTwKO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iFMEkpuV6YKrTwKO .icon-shape .label rect,#mermaid-svg-iFMEkpuV6YKrTwKO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iFMEkpuV6YKrTwKO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iFMEkpuV6YKrTwKO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iFMEkpuV6YKrTwKO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 第 1 颗灯的 24 位
第 2 颗灯的 24 位
......
第 9 颗灯的 24 位
连续 100 个 CCR=0 的周期

低电平约 88.9µs
所有灯同时更新颜色

图 4:一帧 = N×24 个颜色位 + 一段 RESET 低电平。

知识小结 2:WS2812B 用"高电平宽度"表示 0/1,用"长低电平"表示一帧结束。

0 码和 1 码的周期都约 0.89µs,区别只在高低电平的比例;接收端在每个 bit 结束后都回到低电平,所以下一位总能从新的上升沿开始。每颗灯按 G、R、B 的顺序接收 24 位,高位先行;N 颗灯需要 N×24 位,最后再保持至少 80µs 低电平作为 RESET。关键参数是 T0H、T1H、T0L、T1L 和 RESET 时间;常见误区是把 0 码理解成"没有高电平",或者把 RESET 当成普通的数据位。


二、参数与硬件:先把 0.89µs 变成定时器能懂的数字

2.1 一张表看懂 WS2812B 的时间窗口

参数 符号 最小值 典型值 最大值 本文取值
0 码高电平 T0H 0.20µs 0.295µs 0.35µs 0.292µs
0 码低电平 T0L 0.55µs 0.595µs 1.20µs 0.597µs
1 码高电平 T1H 0.55µs 0.595µs 1.20µs 0.597µs
1 码低电平 T1L 0.20µs 0.295µs 0.35µs 0.292µs
0/1 码周期 T0/T1 --- 0.89µs --- 0.889µs
RESET 低电平 reset 80µs --- --- 88.9µs

表中"本文取值"不是随便写的,它来自 72MHz 定时器时钟和 64 个计数:

text 复制代码
1 个计数 = 1 / 72MHz ≈ 13.889ns
64 个计数 = 64 × 13.889ns ≈ 888.9ns ≈ 0.889µs
21 个计数 ≈ 291.7ns
43 个计数 ≈ 597.2ns
100 个周期 = 100 × 0.889µs = 88.9µs

21 + 43 = 64。于是:

  • CCR = 21:高电平 21 个计数,低电平 43 个计数,得到 0 码。
  • CCR = 43:高电平 43 个计数,低电平 21 个计数,得到 1 码。
  • CCR = 0:高电平 0 个计数,整个周期都是低电平,用来组成 RESET。

这里要特别区分两个概念:ARR 决定"一个 bit 有多长",CCR 决定"这个 bit 里高电平有多长"。 ARR 固定为 63,所以每个 bit 都是 64 个计数;CCR 在 21、43、0 之间变化,所以同一个周期里可以产生 0 码、1 码或低电平。

2.2 板卡硬件:3×3 只是外形,电气上是一条链

从实验板的实物和原理图可以看到这些关键信息:

  • 灯珠型号是 XL-5050RGBC-2812B,共 9 颗,标号为 LED9~LED17。
  • 9 颗灯由 VIN+5V 供电,GND 与 MCU 共地。
  • 每颗灯的 VDD 旁边都有一个 100nF 去耦电容,图中对应 C56~C64。
  • 数据入口网络叫 RGB_DIN。原理图上入口处还画有 R57(1kΩ)、R58(10kΩ)、Q5(SS8050)和 R56(1kΩ)组成的整形/驱动级,再送到第一颗灯的 DIN;实际板卡是否贴装、是否反相,以 PB0 和 DIN 的实测波形为准。
  • 板上有 H1 排针,3 脚是 TIM3_CH3,4 脚是 RGB_DIN。需要用跳线帽或杜邦线把 3、4 脚短接。
  • PB0 与 TIM3_CH3 复用同一个物理引脚,所以配置 PB0 的复用功能,本质上就是让 TIM3 的第 3 通道从 PB0 输出。

#mermaid-svg-9Px8RXhaVfI2yaP4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9Px8RXhaVfI2yaP4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9Px8RXhaVfI2yaP4 .error-icon{fill:#552222;}#mermaid-svg-9Px8RXhaVfI2yaP4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9Px8RXhaVfI2yaP4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9Px8RXhaVfI2yaP4 .marker.cross{stroke:#333333;}#mermaid-svg-9Px8RXhaVfI2yaP4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9Px8RXhaVfI2yaP4 p{margin:0;}#mermaid-svg-9Px8RXhaVfI2yaP4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9Px8RXhaVfI2yaP4 .cluster-label text{fill:#333;}#mermaid-svg-9Px8RXhaVfI2yaP4 .cluster-label span{color:#333;}#mermaid-svg-9Px8RXhaVfI2yaP4 .cluster-label span p{background-color:transparent;}#mermaid-svg-9Px8RXhaVfI2yaP4 .label text,#mermaid-svg-9Px8RXhaVfI2yaP4 span{fill:#333;color:#333;}#mermaid-svg-9Px8RXhaVfI2yaP4 .node rect,#mermaid-svg-9Px8RXhaVfI2yaP4 .node circle,#mermaid-svg-9Px8RXhaVfI2yaP4 .node ellipse,#mermaid-svg-9Px8RXhaVfI2yaP4 .node polygon,#mermaid-svg-9Px8RXhaVfI2yaP4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9Px8RXhaVfI2yaP4 .rough-node .label text,#mermaid-svg-9Px8RXhaVfI2yaP4 .node .label text,#mermaid-svg-9Px8RXhaVfI2yaP4 .image-shape .label,#mermaid-svg-9Px8RXhaVfI2yaP4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-9Px8RXhaVfI2yaP4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9Px8RXhaVfI2yaP4 .rough-node .label,#mermaid-svg-9Px8RXhaVfI2yaP4 .node .label,#mermaid-svg-9Px8RXhaVfI2yaP4 .image-shape .label,#mermaid-svg-9Px8RXhaVfI2yaP4 .icon-shape .label{text-align:center;}#mermaid-svg-9Px8RXhaVfI2yaP4 .node.clickable{cursor:pointer;}#mermaid-svg-9Px8RXhaVfI2yaP4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9Px8RXhaVfI2yaP4 .arrowheadPath{fill:#333333;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9Px8RXhaVfI2yaP4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9Px8RXhaVfI2yaP4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9Px8RXhaVfI2yaP4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9Px8RXhaVfI2yaP4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9Px8RXhaVfI2yaP4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9Px8RXhaVfI2yaP4 .cluster text{fill:#333;}#mermaid-svg-9Px8RXhaVfI2yaP4 .cluster span{color:#333;}#mermaid-svg-9Px8RXhaVfI2yaP4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9Px8RXhaVfI2yaP4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9Px8RXhaVfI2yaP4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-9Px8RXhaVfI2yaP4 .icon-shape,#mermaid-svg-9Px8RXhaVfI2yaP4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9Px8RXhaVfI2yaP4 .icon-shape p,#mermaid-svg-9Px8RXhaVfI2yaP4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9Px8RXhaVfI2yaP4 .icon-shape .label rect,#mermaid-svg-9Px8RXhaVfI2yaP4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9Px8RXhaVfI2yaP4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9Px8RXhaVfI2yaP4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9Px8RXhaVfI2yaP4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} PB0

TIM3_CH3
H1

3-4 脚短接
RGB_DIN
R57 1kΩ
Q5 SS8050

输入整形/驱动级
LED9 DIN
R56 1kΩ

上拉到 5V
R58 10kΩ

下拉到 GND
VIN+5V
LED13
LED17
GND

图 5:从 PB0 到第一颗灯,中间还经过了排针和板载输入级,任何一个环节断开都不会亮。

自己搭电路时,还要注意两个容易被忽略的点:

  1. 3.3V 高电平对 5V 供电的 WS2812B 可能不够。 灯珠的 VIH 通常按 0.7×VDD 计算,5V 供电时约为 3.5V,而 STM32 的推挽输出只有 3.3V。短线和低亮度可能碰巧能亮,但温度、线长和批次一变就不稳定。建议用 74AHCT1G125 这类非反相电平转换器,或者选择能在 3.3V 下可靠工作的方案。
  2. 电源和地去耦不能省。 每颗灯旁边放 100nF,整条灯带入口再放 470µF~1000µF 的电解电容,数据线串 330Ω 电阻抑制振铃。9 颗灯全白时峰值电流可达 9×60mA≈540mA,电源余量要留够。

知识小结 3:WS2812B 项目里,硬件连接和时序参数同等重要。

9 颗灯虽然排成 3×3,但电气上是 PB0→H1→RGB_DIN→LED9→...→LED17 的一条链,每颗灯都需要 5V、GND、DIN、DOUT 四个连接。本文板卡用 100nF 做本地去耦,并在数据入口安排了 SS8050 及 1kΩ/10kΩ 的整形/驱动级;自己搭电路时,要特别检查 3.3V 能否满足 5V 灯珠的 VIH、电源电流是否足够、MCU 与灯带是否共地。常见误区是只盯着代码,忽略了跳线、DOUT 方向和地线。


三、配置:CubeMX 里真正要动的只有这些

3.1 时钟树:TIM3 必须拿到 72MHz

TIM3 挂在 APB1 总线上。本文的时钟配置是:

  • 外部高速晶振 HSE:8MHz。
  • PLL ×9,SYSCLK 和 HCLK:72MHz。
  • APB1 预分频:/2,PCLK1 为 36MHz。
  • 因为 APB1 预分频不为 1,定时器时钟会再乘 2,所以 TIM3 的计数时钟是 72MHz。

这一步非常关键。如果 TIM3 实际只拿到 36MHz,那么 64 个计数就是 1.78µs,0 码和 1 码都会超出灯珠的时间窗口,现象就是"有波形但灯不认"。

在 CubeMX 的时钟树里,先把 HCLK 设到 72MHz,再检查 APB1 Timer Clocks 是否为 72MHz。不要只看 PCLK1 的 36MHz,那个是外设总线时钟,不是定时器计数时钟。

3.2 TIM3:PSC、ARR、CCR 三个数决定波形

把 TIM3 的 Channel3 设为 PWM Generation CH3,引脚会自动落到 PB0。关键参数如下:

  • Prescaler = 0:PSC 寄存器写 0,表示不分频,计数时钟就是 72MHz。
  • Counter Period = 63:ARR 寄存器写 63,计数器从 0 数到 63,一共 64 个计数。
  • Counter Mode = Up:向上计数,和 PWM 模式 1 的"CNT<CCR 输出高"配合使用。
  • PWM Mode = PWM mode 1:向上计数时,CNT < CCR 输出有效电平,CNT ≥ CCR 输出无效电平。
  • Pulse = 0:初始 CCR 为 0,启动前先让输出保持低电平。
  • CH Polarity = High:有效电平为高,0 码和 1 码的"高电平宽度"才和 CCR 成正比。
  • Output compare preload = Enable:修改 CCR 时先写预装载寄存器,等更新事件再生效,避免破坏当前正在输出的脉冲。
  • Fast Mode = Disable:本案例不需要快速输出比较。
  • auto-reload preload = Enable:ARR 固定时开不开都不影响波形,但建议开启,和"影子寄存器"机制保持一致。CubeMX 界面如果默认显示 Disable,也可以保持默认。

用一张流程图看定时器内部的分工:
#mermaid-svg-K5BG9qzPSkiko3rA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-K5BG9qzPSkiko3rA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-K5BG9qzPSkiko3rA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-K5BG9qzPSkiko3rA .error-icon{fill:#552222;}#mermaid-svg-K5BG9qzPSkiko3rA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-K5BG9qzPSkiko3rA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-K5BG9qzPSkiko3rA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-K5BG9qzPSkiko3rA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-K5BG9qzPSkiko3rA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-K5BG9qzPSkiko3rA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-K5BG9qzPSkiko3rA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-K5BG9qzPSkiko3rA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-K5BG9qzPSkiko3rA .marker.cross{stroke:#333333;}#mermaid-svg-K5BG9qzPSkiko3rA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-K5BG9qzPSkiko3rA p{margin:0;}#mermaid-svg-K5BG9qzPSkiko3rA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-K5BG9qzPSkiko3rA .cluster-label text{fill:#333;}#mermaid-svg-K5BG9qzPSkiko3rA .cluster-label span{color:#333;}#mermaid-svg-K5BG9qzPSkiko3rA .cluster-label span p{background-color:transparent;}#mermaid-svg-K5BG9qzPSkiko3rA .label text,#mermaid-svg-K5BG9qzPSkiko3rA span{fill:#333;color:#333;}#mermaid-svg-K5BG9qzPSkiko3rA .node rect,#mermaid-svg-K5BG9qzPSkiko3rA .node circle,#mermaid-svg-K5BG9qzPSkiko3rA .node ellipse,#mermaid-svg-K5BG9qzPSkiko3rA .node polygon,#mermaid-svg-K5BG9qzPSkiko3rA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-K5BG9qzPSkiko3rA .rough-node .label text,#mermaid-svg-K5BG9qzPSkiko3rA .node .label text,#mermaid-svg-K5BG9qzPSkiko3rA .image-shape .label,#mermaid-svg-K5BG9qzPSkiko3rA .icon-shape .label{text-anchor:middle;}#mermaid-svg-K5BG9qzPSkiko3rA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-K5BG9qzPSkiko3rA .rough-node .label,#mermaid-svg-K5BG9qzPSkiko3rA .node .label,#mermaid-svg-K5BG9qzPSkiko3rA .image-shape .label,#mermaid-svg-K5BG9qzPSkiko3rA .icon-shape .label{text-align:center;}#mermaid-svg-K5BG9qzPSkiko3rA .node.clickable{cursor:pointer;}#mermaid-svg-K5BG9qzPSkiko3rA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-K5BG9qzPSkiko3rA .arrowheadPath{fill:#333333;}#mermaid-svg-K5BG9qzPSkiko3rA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-K5BG9qzPSkiko3rA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-K5BG9qzPSkiko3rA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K5BG9qzPSkiko3rA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-K5BG9qzPSkiko3rA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K5BG9qzPSkiko3rA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-K5BG9qzPSkiko3rA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-K5BG9qzPSkiko3rA .cluster text{fill:#333;}#mermaid-svg-K5BG9qzPSkiko3rA .cluster span{color:#333;}#mermaid-svg-K5BG9qzPSkiko3rA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-K5BG9qzPSkiko3rA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-K5BG9qzPSkiko3rA rect.text{fill:none;stroke-width:0;}#mermaid-svg-K5BG9qzPSkiko3rA .icon-shape,#mermaid-svg-K5BG9qzPSkiko3rA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K5BG9qzPSkiko3rA .icon-shape p,#mermaid-svg-K5BG9qzPSkiko3rA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-K5BG9qzPSkiko3rA .icon-shape .label rect,#mermaid-svg-K5BG9qzPSkiko3rA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K5BG9qzPSkiko3rA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-K5BG9qzPSkiko3rA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-K5BG9qzPSkiko3rA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
TIM3 计数时钟

72MHz
PSC=0

不分频
ARR=63

CNT 从 0 数到 63
64 个计数

周期 0.889µs
CCR3

21 / 43 / 0
CNT < CCR3 ?
输出高电平
输出低电平

图 6:ARR 管周期,CCR 管高电平时间,极性决定"高"是不是有效电平。

从寄存器角度看,CubeMX 生成的初始化代码最终会落到这些位:

寄存器/位 作用 本案例
TIM3_PSC 计数时钟分频 0,不分频
TIM3_ARR 自动重装载值 63,64 个计数一周期
TIM3_CCR3 通道 3 比较值 21、43 或 0
TIM3_CCMR2.OC3PE 通道 3 输出比较预装载 1,开启
TIM3_CCMR2.OC3M 通道 3 输出模式 110,PWM 模式 1
TIM3_CCER.CC3E 通道 3 输出使能 1,开启
TIM3_CCER.CC3P 通道 3 极性 0,高电平有效
TIM3_CR1.CEN 计数器使能 1,启动后计数

在代码里修改 CCR 时,常用宏是:

c 复制代码
/* 这个宏最终写的是 TIM3->CCR3。
 * 因为前面开启了 OC3PE,写入值先进入预装载寄存器,
 * 等下一次更新事件才搬到真正参与比较的影子寄存器,
 * 所以不会把当前正在输出的高电平突然截断。 */
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 21);

知识小结 4:PSC 和 ARR 把 72MHz 变成 0.889µs,CCR 把每个周期分成 0 码和 1 码。

PSC=0 表示 72MHz 不分频,ARR=63 表示计数器从 0 数到 63,共 64 个计数,约 0.889µs。PWM 模式 1 下,CNT 小于 CCR 时输出高电平,所以 CCR=21 得到约 0.292µs 高电平,CCR=43 得到约 0.597µs 高电平。关键参数是 PSC、ARR、CCR 和极性;常见误区是把 ARR=63 当成 63 个计数、把 Pulse 当成微秒数,或者忘了 TIM3 的计数时钟其实是 72MHz 而不是 PCLK1 的 36MHz。

3.3 DMA:让硬件替 CPU 逐位"喂"CCR

如果每个 bit 都用中断修改 CCR,CPU 每秒要进出一百多万次中断,任何一次中断延迟或嵌套都会让波形抖动。WS2812B 对 0.3µs 级别的脉宽很敏感,这种方案既累又不可靠。

DMA 的思路很直接:CPU 只准备一整帧 CCR 值,之后每个比较事件由 DMA 自动搬一个半字到 CCR3。

配置时注意这几项:

  • DMA Request = TIM3_CH3:请求源是通道 3 的比较匹配,不是 TIM3_UP 更新事件。
  • Direction = Memory To Peripheral:内存里的 CCR 数组搬到定时器外设。
  • DMA1 Channel2:STM32F103 上 TIM3_CH3 固定映射到 DMA1 Channel2。
  • Memory Increment = Enable:每搬一个数,内存地址加一,依次走过整帧。
  • Peripheral Increment = Disable :外设地址始终是 &TIM3->CCR3,不能递增。
  • Data Width = Half Word :CCR3 是 16 位,内存数组也用 uint16_t。
  • Mode = Normal:一帧搬完就停,下一帧由软件重新启动。
  • Priority = Low:每个周期只搬一个半字,低优先级通常够用;如果工程里还有大量 DMA 流量,可以调到 Medium。
  • DMA1 Channel2 中断 = Enable:HAL 通过 DMA 传输完成中断调用帧完成回调。

一次比较事件内部发生的事情可以这样理解:
CPU DMA 中断 DMA1 Channel2 CCR3 预装载 TIM3 计数器 CPU DMA 中断 DMA1 Channel2 CCR3 预装载 TIM3 计数器 #mermaid-svg-c9TUxRCsN310eOOH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-c9TUxRCsN310eOOH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-c9TUxRCsN310eOOH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-c9TUxRCsN310eOOH .error-icon{fill:#552222;}#mermaid-svg-c9TUxRCsN310eOOH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-c9TUxRCsN310eOOH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-c9TUxRCsN310eOOH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-c9TUxRCsN310eOOH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-c9TUxRCsN310eOOH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-c9TUxRCsN310eOOH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-c9TUxRCsN310eOOH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-c9TUxRCsN310eOOH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-c9TUxRCsN310eOOH .marker.cross{stroke:#333333;}#mermaid-svg-c9TUxRCsN310eOOH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-c9TUxRCsN310eOOH p{margin:0;}#mermaid-svg-c9TUxRCsN310eOOH .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-c9TUxRCsN310eOOH text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-c9TUxRCsN310eOOH .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-c9TUxRCsN310eOOH .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-c9TUxRCsN310eOOH .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-c9TUxRCsN310eOOH .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-c9TUxRCsN310eOOH #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-c9TUxRCsN310eOOH .sequenceNumber{fill:white;}#mermaid-svg-c9TUxRCsN310eOOH #sequencenumber{fill:#333;}#mermaid-svg-c9TUxRCsN310eOOH #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-c9TUxRCsN310eOOH .messageText{fill:#333;stroke:none;}#mermaid-svg-c9TUxRCsN310eOOH .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-c9TUxRCsN310eOOH .labelText,#mermaid-svg-c9TUxRCsN310eOOH .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-c9TUxRCsN310eOOH .loopText,#mermaid-svg-c9TUxRCsN310eOOH .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-c9TUxRCsN310eOOH .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-c9TUxRCsN310eOOH .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-c9TUxRCsN310eOOH .noteText,#mermaid-svg-c9TUxRCsN310eOOH .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-c9TUxRCsN310eOOH .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-c9TUxRCsN310eOOH .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-c9TUxRCsN310eOOH .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-c9TUxRCsN310eOOH .actorPopupMenu{position:absolute;}#mermaid-svg-c9TUxRCsN310eOOH .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-c9TUxRCsN310eOOH .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-c9TUxRCsN310eOOH .actor-man circle,#mermaid-svg-c9TUxRCsN310eOOH line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-c9TUxRCsN310eOOH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 每个 bit 重复一次,当前周期不被破坏 CNT = CCR3,当前周期输出低 CC3 比较事件,发出 DMA 请求 写入下一个 16 位 pulse 溢出更新事件,预装载值生效 316 个半字搬完,传输完成 HAL_DMA_IRQHandler() HAL_TIM_PWM_PulseFinishedCallback()

图 7:DMA 在比较匹配时"预装下一颗子弹",更新事件时才让新值真正参与下一个周期。

为什么请求源是 CC3,而不是更新事件?因为 HAL 在 HAL_TIM_PWM_Start_DMA() 里为通道 3 打开的是 TIM_DMA_CC3,对应 TIM3->DIER 的 CC3DE 位。比较匹配发生时,当前周期的高电平已经结束,正好可以把下一个值写进预装载寄存器;下一次溢出再把预装载值搬到影子寄存器,形成稳定的流水线。

这里还有一个容易混淆的点:在本案例的 Normal 模式 + DMA 模式下,HAL_TIM_PWM_PulseFinishedCallback() 不是"每个 PWM 脉冲都回调一次",而是整段 DMA 传输完成时回调一次。 它的名字容易让人误解,但实际调用链是 DMA 传输完成 → TIM_DMADelayPulseCplt() → HAL_TIM_PWM_PulseFinishedCallback()。

回调发生时,最后一个半字刚刚写进 CCR 预装载寄存器,最后一小段 RESET 低电平可能还在输出。普通实验里后面有 1 秒延时,完全感觉不到;如果要做高速动画,回调后再留 100µs 左右再启动下一帧,帧尾才不会被截断。

整帧结束后,DMA 停了,定时器还在计数;因为最后一个 CCR 值是 0,PB0 会继续保持低电平,正好等待下一帧。

知识小结 5:DMA 把"每个 bit 改一次 CCR"的工作从 CPU 手里接走,关键是比较匹配触发、预装载生效。

CPU 先把整帧 316 个半字准备好,DMA 在每个 CC3 比较事件到来时搬一个值到 CCR3 预装载寄存器,定时器在更新事件时把新值装入影子寄存器。这样当前脉冲不受影响,下一个周期自动使用新值,CPU 只需要处理整帧完成中断。关键参数是内存自增、外设不自增、半字宽度、Normal 模式和 CC3 请求源;常见误区是选成更新事件触发、用字节传输,或者在 DMA 还没完成时修改帧缓冲区。

3.4 配置总表

配置位置 参数 设置 原因
RCC HSE、PLL、HCLK 8MHz、×9、72MHz 让 TIM3 拿到 72MHz 计数时钟
RCC APB1 Prescaler /2 PCLK1=36MHz,定时器时钟仍为 72MHz
GPIO PB0 AF Push-Pull、High Speed 输出 0.3µs 级边沿,需要复用推挽
TIM3 Channel3 PWM Generation CH3 从 PB0 输出 TIM3_CH3
TIM3 Prescaler 0 72MHz 不分频
TIM3 Counter Period 63 64 个计数约 0.889µs
TIM3 PWM Mode PWM mode 1 CNT<CCR 时输出高电平
TIM3 Pulse 0 启动前保持低电平
TIM3 Output compare preload Enable 新 CCR 在下个周期生效
TIM3 CH Polarity High 高电平宽度与 CCR 成正比
DMA Request TIM3_CH3 比较匹配触发搬运
DMA Channel DMA1 Channel2 F103 的固定映射
DMA Direction Memory To Peripheral 内存数组写到 CCR3
DMA Memory Increment Enable 依次走过整帧
DMA Peripheral Increment Disable 始终写同一个 CCR3
DMA Data Width Half Word CCR3 为 16 位
DMA Mode Normal 一帧一启动
DMA Priority Low 每周期只搬一个半字
NVIC DMA1 Channel2 Enable 让 HAL 能调用完成回调

四、代码:先跑通最小核心

4.1 最小可运行代码

下面的代码假设 CubeMX 已经生成好 htim3、MX_DMA_Init() 和 MX_TIM3_Init()。先只点亮 9 颗纯绿灯,把整条链路跑通,再谈颜色和动画。

真正上电时的调用顺序是:

  1. HAL_Init():初始化 HAL、SysTick 和中断优先级分组。
  2. SystemClock_Config():把 HCLK 配到 72MHz,并让 TIM3 拿到 72MHz 计数时钟。
  3. MX_GPIO_Init():初始化普通 GPIO。
  4. MX_DMA_Init():打开 DMA1 时钟并使能 DMA1 Channel2 中断。
  5. MX_TIM3_Init():配置 PSC、ARR、PWM 模式、CCR,并调用 HAL_TIM_MspPostInit() 配置 PB0 复用功能。
  6. BSP_WS2812B_Init():保存定时器句柄和通道,并把 CCR 先清 0。
  7. 填充帧缓冲区:最小示例里是 build_green_frame(),完整驱动里是 build_frame(),最终都得到 316 个半字。
  8. HAL_TIM_PWM_Start_DMA():同时启动 PWM 通道、CC3 DMA 请求和计数器。
c 复制代码
#include "main.h"

/* 72MHz 下,64 个计数约为 0.889µs,正好对应一个 WS2812B 位周期 */
#define BIT0_PULSE      21U
#define BIT1_PULSE      43U
#define RESET_PULSE     0U

/* 实验板是 3×3,共 9 颗灯 */
#define LED_COUNT       9U

/* 100 个 0.889µs 的低电平周期,约 88.9µs,满足 ≥80µs 的 RESET 要求 */
#define RESET_COUNT     100U

/* 一帧 = 9×24 个颜色位 + 100 个 RESET 位 */
#define FRAME_WORDS     (LED_COUNT * 24U + RESET_COUNT)

/* DMA 的源缓冲区必须一直有效,所以放在静态区,不能放函数局部栈 */
static uint16_t g_pwm_frame[FRAME_WORDS];

/* 回调在中断里置位,主循环读取,必须加 volatile */
static volatile uint8_t g_dma_done = 0U;

/* 把一个字节按"高位先行"展开成 8 个 CCR 值。
 * 这里从 bit7 开始取,是因为 WS2812B 要求 MSB first;
 * 如果从 bit0 开始,颜色数值会整体倒过来。 */
static void encode_byte_msb(uint16_t *dst, uint8_t value)
{
    for (uint8_t i = 0U; i < 8U; i++)
    {
        dst[i] = (value & (uint8_t)(0x80U >> i)) ? BIT1_PULSE : BIT0_PULSE;
    }
}

/* 把 9 颗灯的颜色排成 WS2812B 需要的 G-R-B 顺序。
 * 每颗灯的 24 位必须连续存放,因为 DMA 不会帮我们跳字节。 */
static void build_green_frame(void)
{
    uint16_t idx = 0U;

    for (uint8_t led = 0U; led < LED_COUNT; led++)
    {
        /* 纯绿:G=255,R=0,B=0。
         * 注意发送顺序是 G→R→B,不是 R→G→B。 */
        encode_byte_msb(&g_pwm_frame[idx], 255U);
        idx += 8U;
        encode_byte_msb(&g_pwm_frame[idx], 0U);
        idx += 8U;
        encode_byte_msb(&g_pwm_frame[idx], 0U);
        idx += 8U;
    }

    /* 帧尾连续送 0,让输出保持低电平,形成 RESET */
    for (uint16_t i = 0U; i < RESET_COUNT; i++)
    {
        g_pwm_frame[idx++] = RESET_PULSE;
    }
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_DMA_Init();          /* DMA 时钟和中断要先于 TIM3 配置 */
    MX_TIM3_Init();

    build_green_frame();

    /* 只调用这一句就够了:它同时启动 PWM 通道和 DMA。
     * 不要先调用 HAL_TIM_PWM_Start(),否则通道会进入 BUSY,
     * 再调用 _Start_DMA() 会返回 HAL_BUSY。 */
    if (HAL_TIM_PWM_Start_DMA(&htim3, TIM_CHANNEL_3,
                              (uint32_t *)g_pwm_frame,
                              FRAME_WORDS) != HAL_OK)
    {
        Error_Handler();
    }

    while (1)
    {
        /* 一帧发完后,DMA 回调会把 g_dma_done 置 1 */
        if (g_dma_done)
        {
            g_dma_done = 0U;
            HAL_Delay(1000);
        }
    }
}

/* HAL 的弱回调函数,用户代码里重新定义同名强符号即可覆盖。
 * 它运行在 DMA 中断上下文,只做置位,不做打印和延时。 */
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim)
{
    if (htim == &htim3)
    {
        g_dma_done = 1U;
    }
}

4.2 这段代码为什么能跑

这段代码做了三件事:

  1. 把 9 颗灯的颜色按 G、R、B 的顺序展开成 216 个 CCR 值。
  2. 在末尾追加 100 个 0,形成 88.9µs 的 RESET。
  3. 把这 316 个半字交给 DMA,让 DMA 在每个比较事件自动写 CCR3。

g_pwm_frame 必须是静态数组或全局数组。DMA 在后台搬运时,CPU 继续执行;如果数组放在函数局部栈里,函数返回后栈空间被覆盖,DMA 就会搬出一堆随机值,表现为灯光乱闪或颜色随机。

启动瞬间 CCR3 还是 0,PB0 先保持低电平;第一个 DMA 值要等到更新事件才进入影子寄存器,所以第一位从下一个完整周期开始。前面多出来的一小段低电平只是帧前空闲,不会被灯珠当成颜色位。

FRAME_WORDS 是半字数量,不是字节数。调用 HAL_TIM_PWM_Start_DMA() 时,长度参数要和 DMA 的数据宽度匹配;如果这里传成 sizeof(g_pwm_frame),DMA 就会以为有 632 个半字,越过数组末尾继续搬。

回调里只置位,不调用 HAL_Delay()、不打印串口日志。中断上下文里做耗时操作,会拖慢其他中断,严重时还会让下一帧启动不及时。

4.3 HAL 函数展开:Start_DMA 到底做了什么

函数原型如下:

c 复制代码
/* htim:已初始化的定时器句柄;Channel:通道号;
 * pData:CCR 值数组;Length:半字数量;返回 HAL_OK / HAL_ERROR / HAL_BUSY */
HAL_StatusTypeDef HAL_TIM_PWM_Start_DMA(TIM_HandleTypeDef *htim,
                                        uint32_t Channel,
                                        const uint32_t *pData,
                                        uint16_t Length);

参数含义:

  • htim:已经初始化好的定时器句柄,本案例是 &htim3。
  • Channel:要启动的通道,本案例是 TIM_CHANNEL_3。
  • pData:CCR 值数组的首地址。HAL 的形参写成 const uint32_t *,但本案例 DMA 配置为半字,实际只使用每个元素的低 16 位,所以需要强制转换。
  • Length:要搬运的半字数量,本案例是 316。

返回值:

  • HAL_OK:启动成功。
  • HAL_ERROR:参数为空、长度为 0,或 DMA 启动失败。
  • HAL_BUSY:通道已经在运行,常见于前面已经调用过 HAL_TIM_PWM_Start()。

把 HAL 源码压缩成关键路径,大致是这样:

c 复制代码
/* 简化源码,只保留和本案例有关的路径,省略参数检查和错误处理 */
HAL_StatusTypeDef HAL_TIM_PWM_Start_DMA(TIM_HandleTypeDef *htim,
                                        uint32_t Channel,
                                        const uint32_t *pData,
                                        uint16_t Length)
{
    /* 1. 让 DMA 把内存数据搬到 CCR3,而不是搬到别的寄存器 */
    HAL_DMA_Start_IT(htim->hdma[TIM_DMA_ID_CC3],
                     (uint32_t)pData,
                     (uint32_t)&htim->Instance->CCR3,
                     Length);

    /* 2. 打开 TIM3 的 CC3 DMA 请求:DIER.CC3DE = 1
     *    以后每次通道 3 比较匹配,都会向 DMA 发一次请求 */
    __HAL_TIM_ENABLE_DMA(htim, TIM_DMA_CC3);

    /* 3. 打开通道 3 输出:CCER.CC3E = 1
     *    没有这一位,PB0 不会输出 PWM 波形 */
    TIM_CCxChannelCmd(htim->Instance, Channel, TIM_CCx_ENABLE);

    /* 4. 启动计数器:CR1.CEN = 1 */
    __HAL_TIM_ENABLE(htim);

    return HAL_OK;
}

这段简化代码对应的关键寄存器动作是:

  • TIM3->DIER |= TIM_DIER_CC3DE:允许 CC3 比较事件触发 DMA。
  • TIM3->CCER |= TIM_CCER_CC3E:打开通道 3 输出。
  • TIM3->CR1 |= TIM_CR1_CEN:计数器开始跑。
  • DMA 的 CPAR 指向 &TIM3->CCR3,CMAR 指向 g_pwm_frame,CNDTR 初值为 316。

这也解释了为什么"只调用 HAL_TIM_PWM_Start() 不够":它只会打开通道和计数器,不会配置 DMA,也不会打开 CC3 的 DMA 请求。反过来,先调用 HAL_TIM_PWM_Start() 再调用 _Start_DMA(),通道状态已经是 BUSY,第二次启动会失败。

知识小结 6:HAL 的启动函数把 PWM 通道、CC3 DMA 请求和计数器一次性打开,用户只需要准备帧缓冲区并处理完成回调。

HAL_TIM_PWM_Start_DMA() 的核心动作是:把 DMA 目标设为 TIM3->CCR3,打开 DIER.CC3DE,打开 CCER.CC3E,再置 CR1.CEN。它的 Length 是半字数量,pData 的 const uint32_t * 只是 API 形式,实际宽度由 DMA 配置决定。关键点是不要重复启动、缓冲区要长期有效、回调要短;常见误区是把 HAL_TIM_PWM_Start() 和 _Start_DMA() 一起调用,或者在回调里做耗时操作。


五、验证:先看波形,再看颜色

5.1 示波器或逻辑分析仪看三个数

把探头接在 PB0,地夹接 GND,先让程序只输出 0 码,再改成只输出 1 码。本文对应的实测现象是:

  • 0 码:高电平约 295ns ,周期约 890ns。
  • 1 码:高电平约 595ns ,周期约 890ns。
  • RESET:连续低电平约 88.9µs。

如果周期不是 0.89µs,先查 TIM3 时钟和 ARR;如果周期对了但高电平宽度不对,查 CCR 和极性;如果 PB0 波形正确但灯不亮,查 H1 跳线、5V、GND 和 DIN 方向。

没有示波器时,可以用采样率不低于 10MS/s 的逻辑分析仪。0.3µs 的脉冲很窄,采样率太低会把 0 码和 1 码都看成一样的宽脉冲。

5.2 颜色验证:从一颗灯到九颗灯

不要一上来就写彩虹渐变。按下面的顺序验证,出问题时更容易定位:

  1. 只发第一颗灯的 24 位,再补 RESET;后面的灯不发颜色数据。 第一颗应显示设定颜色,其余保持熄灭或随机上电状态。这样可以确认编码和时序基本正确。
  2. 9 颗全部设为纯绿。 如果全绿,说明 GRB 顺序、级联和 RESET 都通了。
  3. 9 颗全部设为纯红、纯蓝、纯白。 检查颜色通道有没有互换,白光是否因供电不足偏色。
  4. 按 3×3 设置不同亮度。 例如左上红 2、上中红 20、右上红 200;中左绿 2、中中绿 20、中右绿 200;下左蓝 2、下中蓝 20、下右蓝 200。

实验现象中,左上到右下依次呈现红、橙、黄白,绿、青绿、白绿,蓝、青、白蓝。高亮度灯珠在手机相机里容易过曝,颜色以肉眼观察为准;但"索引顺序和物理位置一一对应"这个结论是可靠的。

如果颜色整体偏红或偏绿,先检查是不是把 RGB 当成 GRB 发送;如果只有第一颗正确,检查 DOUT→DIN 和后续灯的供电;如果亮度越高越不稳定,检查电源和地去耦。

5.3 验证阶梯

  1. 看 PB0 波形。 周期约 0.89µs,0/1 码高电平约 0.29/0.60µs。
  2. 只点第一颗灯。 能单独显示设定的颜色,说明编码和时序基本正确。
  3. 九颗显示同一种纯色。 全部显示绿色、红色或蓝色,且没有明显闪烁。
  4. 验证 3×3 位置。 九个位置与数组索引一一对应,没有整体错位。
  5. 连续刷新。 长时间运行不随机变色、不丢帧,电源和 DMA 重启都稳定。

六、排错:按"电→波→码→灯"四层查

#mermaid-svg-zvOfNBPQCcJnOFN2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zvOfNBPQCcJnOFN2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zvOfNBPQCcJnOFN2 .error-icon{fill:#552222;}#mermaid-svg-zvOfNBPQCcJnOFN2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zvOfNBPQCcJnOFN2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zvOfNBPQCcJnOFN2 .marker.cross{stroke:#333333;}#mermaid-svg-zvOfNBPQCcJnOFN2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zvOfNBPQCcJnOFN2 p{margin:0;}#mermaid-svg-zvOfNBPQCcJnOFN2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zvOfNBPQCcJnOFN2 .cluster-label text{fill:#333;}#mermaid-svg-zvOfNBPQCcJnOFN2 .cluster-label span{color:#333;}#mermaid-svg-zvOfNBPQCcJnOFN2 .cluster-label span p{background-color:transparent;}#mermaid-svg-zvOfNBPQCcJnOFN2 .label text,#mermaid-svg-zvOfNBPQCcJnOFN2 span{fill:#333;color:#333;}#mermaid-svg-zvOfNBPQCcJnOFN2 .node rect,#mermaid-svg-zvOfNBPQCcJnOFN2 .node circle,#mermaid-svg-zvOfNBPQCcJnOFN2 .node ellipse,#mermaid-svg-zvOfNBPQCcJnOFN2 .node polygon,#mermaid-svg-zvOfNBPQCcJnOFN2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zvOfNBPQCcJnOFN2 .rough-node .label text,#mermaid-svg-zvOfNBPQCcJnOFN2 .node .label text,#mermaid-svg-zvOfNBPQCcJnOFN2 .image-shape .label,#mermaid-svg-zvOfNBPQCcJnOFN2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-zvOfNBPQCcJnOFN2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zvOfNBPQCcJnOFN2 .rough-node .label,#mermaid-svg-zvOfNBPQCcJnOFN2 .node .label,#mermaid-svg-zvOfNBPQCcJnOFN2 .image-shape .label,#mermaid-svg-zvOfNBPQCcJnOFN2 .icon-shape .label{text-align:center;}#mermaid-svg-zvOfNBPQCcJnOFN2 .node.clickable{cursor:pointer;}#mermaid-svg-zvOfNBPQCcJnOFN2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zvOfNBPQCcJnOFN2 .arrowheadPath{fill:#333333;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zvOfNBPQCcJnOFN2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zvOfNBPQCcJnOFN2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zvOfNBPQCcJnOFN2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zvOfNBPQCcJnOFN2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zvOfNBPQCcJnOFN2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zvOfNBPQCcJnOFN2 .cluster text{fill:#333;}#mermaid-svg-zvOfNBPQCcJnOFN2 .cluster span{color:#333;}#mermaid-svg-zvOfNBPQCcJnOFN2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zvOfNBPQCcJnOFN2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zvOfNBPQCcJnOFN2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-zvOfNBPQCcJnOFN2 .icon-shape,#mermaid-svg-zvOfNBPQCcJnOFN2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zvOfNBPQCcJnOFN2 .icon-shape p,#mermaid-svg-zvOfNBPQCcJnOFN2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zvOfNBPQCcJnOFN2 .icon-shape .label rect,#mermaid-svg-zvOfNBPQCcJnOFN2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zvOfNBPQCcJnOFN2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zvOfNBPQCcJnOFN2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zvOfNBPQCcJnOFN2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 没有
有
不对
对
不一致
一致
不能
能
错位
闪烁
上电不亮或颜色异常
PB0 有 0.89µs 周期波形吗?
查 TIM3 时钟、PB0 复用、启动函数
0/1 码高电平宽度对吗?
查 PSC、ARR、CCR、极性
DIN 上的波形和 PB0 一致吗?
查 H1 跳线、输入级、线材和地
第一颗灯能亮吗?
查 5V、GND、DIN/DOUT 方向
后续灯错位、闪烁或偏色?
查 GRB、MSB、RESET 和灯数
查电源、去耦、电平和地线

图 8:先确认"有没有波",再确认"波对不对",最后才查灯珠和数据顺序。

6.1 常见现象与原因

  • PB0 完全没有波形。 先看 HAL_TIM_PWM_Start_DMA() 的返回值;如果是 HAL_BUSY,检查是不是前面已经调用了 HAL_TIM_PWM_Start()。再看 PB0 是否配置成 AF Push-Pull,TIM3 时钟是否使能。
  • 周期是 1.78µs,不是 0.89µs。 TIM3 实际拿到的是 36MHz,而不是 72MHz。回到时钟树检查 APB1 Timer Clocks。
  • 高电平宽度整体偏大或偏小。 检查 CCR 是 21/43 还是被误写成 21/64;再检查 CH Polarity 是否为 High。
  • 0 码和 1 码反了。 先看 PB0 波形是否符合预期,再看 DIN 波形。如果 DIN 与 PB0 反相,说明板卡输入级或外部电平转换是反相的;不要只改代码里的 21/43 来"凑",先确认硬件极性。
  • 第一颗亮,后面不亮。 检查第一颗 DOUT 到第二颗 DIN 的连接,以及后续灯的 VDD/GND。级联链上任何一颗虚焊,后面的灯都收不到数据。
  • 颜色红绿互换。 发送顺序写成了 RGB。WS2812B 要按 G、R、B 发送。
  • 颜色整体错位一颗灯。 检查帧头是否多了或少了一个 bit,RESET 是否足够长,以及数组长度是否和实际灯数一致。
  • 低亮度正常,全白闪烁或重启。 电源电流不够或地线压降过大。9 颗全白约 540mA,要留余量,并在灯带入口加储能电容。
  • 手碰数据线就变色。 数据线太长、没有串阻、地线回路大,或者 3.3V 高电平裕量不足。缩短线、加 330Ω 串阻、共地,必要时加电平转换。
  • 回调不进。 检查 DMA1 Channel2 中断是否使能,stm32f1xx_it.c 里是否有 DMA1_Channel2_IRQHandler() 调用 HAL_DMA_IRQHandler(&hdma_tim3_ch3),以及回调函数名是否拼写正确。
  • 调试器一停就正常,全速跑就乱。 多半是缓冲区被提前修改、DMA 还没完成就重启,或者中断优先级/栈空间有问题。确保一帧完成后再改数组。

排错时不要同时改五个地方。先固定一个可观测点,例如 PB0 波形,再一次只改一个变量。嵌入式问题最怕"看起来像软件,实际上是地线"。


七、完整工程代码

下面的代码把最小示例整理成可复用的 BSP 驱动。CubeMX 生成的时钟、GPIO、DMA 和 TIM3 初始化代码不需要手写;你只需要新增 BSP_WS2812B.h、BSP_WS2812B.c,然后在 main.c 的用户代码区调用。

7.1 文件结构

text 复制代码
Project/
├─ Core/
│  ├─ Inc/
│  │  └─ main.h
│  └─ Src/
│     └─ main.c
└─ BSP/
   └─ WS2812B/
      ├─ Inc/
      │  └─ BSP_WS2812B.h
      └─ Src/
         └─ BSP_WS2812B.c

把 BSP/WS2812B/Src/BSP_WS2812B.c 加入 Keil 工程,把 BSP/WS2812B/Inc 加到 C/C++ 的 Include Paths。

7.2 BSP_WS2812B.h

c 复制代码
#ifndef BSP_WS2812B_H
#define BSP_WS2812B_H

#ifdef __cplusplus
extern "C" {
#endif

#include "stm32f1xx_hal.h"
#include <stdbool.h>
#include <stdint.h>

/* 72MHz、ARR=63 时,一个计数约 13.889ns。
 * 21 个计数约 0.292µs,43 个计数约 0.597µs。 */
#define WS2812_BIT0_PULSE       21U
#define WS2812_BIT1_PULSE       43U
#define WS2812_RESET_PULSE      0U

/* 实验板是 3×3 阵列,按行优先编号:0~8 */
#define WS2812_LED_COUNT        9U

/* 100 × 0.889µs ≈ 88.9µs,覆盖 ≥80µs 的 RESET 要求。
 * 如果你的灯珠手册要求 280µs,把它改成 320 以上。 */
#define WS2812_RESET_COUNT      100U

/* 一帧包含 N×24 个颜色位和一段 RESET 低电平 */
#define WS2812_FRAME_WORDS      (WS2812_LED_COUNT * 24U + WS2812_RESET_COUNT)

/* 用户视角的颜色结构,顺序仍然是 R、G、B,编码时再换成 G、R、B */
typedef struct
{
    uint8_t r;
    uint8_t g;
    uint8_t b;
} ws2812_color_t;

/* 初始化驱动:传入已经配置为 PWM 的定时器句柄和通道 */
bool BSP_WS2812B_Init(TIM_HandleTypeDef *htim, uint32_t channel);

/* 设置一颗灯的颜色,index 从 0 开始,按行优先排列 */
bool BSP_WS2812B_SetPixel(uint8_t index, uint8_t r, uint8_t g, uint8_t b);

/* 清除所有颜色,只改软件缓存,不立即刷新 */
void BSP_WS2812B_Clear(void);

/* 启动一帧 DMA 传输;如果上一帧还没完成,返回 false */
bool BSP_WS2812B_Show(void);

/* 等待一帧完成,timeout_ms 为超时时间;返回 true 表示完成 */
bool BSP_WS2812B_WaitDone(uint32_t timeout_ms);

#ifdef __cplusplus
}
#endif

#endif

7.3 BSP_WS2812B.c

c 复制代码
#include "BSP_WS2812B.h"
#include <string.h>

/* DMA 源缓冲区:必须静态分配,保证整个传输期间地址有效 */
static uint16_t s_frame[WS2812_FRAME_WORDS];

/* 软件颜色缓存:用户先改这里,Show 时再统一编码 */
static ws2812_color_t s_pixels[WS2812_LED_COUNT];

static TIM_HandleTypeDef *s_htim = NULL;
static uint32_t s_channel = 0U;

/* 中断和主循环共享,必须 volatile */
static volatile bool s_busy = false;
static volatile bool s_done = true;

/* 把一个字节展开成 8 个 CCR 值,高位先行 */
static void encode_byte_msb(uint16_t *dst, uint8_t value)
{
    for (uint8_t i = 0U; i < 8U; i++)
    {
        dst[i] = (value & (uint8_t)(0x80U >> i))
                 ? WS2812_BIT1_PULSE
                 : WS2812_BIT0_PULSE;
    }
}

/* 把软件颜色缓存编码成 DMA 能直接搬运的 CCR 序列 */
static void build_frame(void)
{
    uint16_t idx = 0U;

    for (uint8_t led = 0U; led < WS2812_LED_COUNT; led++)
    {
        /* 先 G,再 R,最后 B;每个字节都是 bit7 先发 */
        encode_byte_msb(&s_frame[idx], s_pixels[led].g);
        idx += 8U;
        encode_byte_msb(&s_frame[idx], s_pixels[led].r);
        idx += 8U;
        encode_byte_msb(&s_frame[idx], s_pixels[led].b);
        idx += 8U;
    }

    /* 连续 100 个 0,输出保持低电平,形成帧尾 RESET */
    for (uint16_t i = 0U; i < WS2812_RESET_COUNT; i++)
    {
        s_frame[idx++] = WS2812_RESET_PULSE;
    }
}

bool BSP_WS2812B_Init(TIM_HandleTypeDef *htim, uint32_t channel)
{
    if (htim == NULL)
    {
        return false;
    }

    s_htim = htim;
    s_channel = channel;
    s_busy = false;
    s_done = true;

    /* 启动前先把 CCR 清 0,保证 DIN 先保持低电平,
     * 避免上电瞬间的毛刺被灯珠误认为一帧数据 */
    __HAL_TIM_SET_COMPARE(s_htim, s_channel, 0U);
    return true;
}

bool BSP_WS2812B_SetPixel(uint8_t index, uint8_t r, uint8_t g, uint8_t b)
{
    if (index >= WS2812_LED_COUNT)
    {
        return false;
    }

    s_pixels[index].r = r;
    s_pixels[index].g = g;
    s_pixels[index].b = b;
    return true;
}

void BSP_WS2812B_Clear(void)
{
    memset(s_pixels, 0, sizeof(s_pixels));
}

bool BSP_WS2812B_Show(void)
{
    if ((s_htim == NULL) || s_busy)
    {
        return false;
    }

    /* 编码会改写 s_frame,所以必须在确认上一帧结束后再调用 */
    build_frame();

    s_done = false;
    s_busy = true;

    /* 一次启动 PWM 和 DMA:316 个半字,每个比较事件搬一个 */
    if (HAL_TIM_PWM_Start_DMA(s_htim,
                              s_channel,
                              (uint32_t *)s_frame,
                              WS2812_FRAME_WORDS) != HAL_OK)
    {
        s_busy = false;
        return false;
    }

    return true;
}

bool BSP_WS2812B_WaitDone(uint32_t timeout_ms)
{
    uint32_t start = HAL_GetTick();

    while (!s_done)
    {
        if ((HAL_GetTick() - start) > timeout_ms)
        {
            return false;
        }
    }

    return true;
}

/* 中断里只置标志,耗时操作留给主循环 */
static void on_pulse_finished(TIM_HandleTypeDef *htim)
{
    if (htim == s_htim)
    {
        s_done = true;
        s_busy = false;
    }
}

/* 覆盖 HAL 的弱定义;如果工程里已经有同名回调,
 * 把这里的判断合并到已有回调中,不要重复定义。 */
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim)
{
    on_pulse_finished(htim);
}

7.4 main.c 中需要补的部分

下面把相关调用连起来展示;CubeMX 已经生成的 HAL_Init()、SystemClock_Config() 和 MX_*_Init() 保持原样,你只需要在 USER CODE 区域补上驱动调用和颜色设置。

c 复制代码
#include "main.h"

/* USER CODE BEGIN Includes */
#include "BSP_WS2812B.h"
/* USER CODE END Includes */

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_DMA_Init();
    MX_TIM3_Init();

    /* USER CODE BEGIN 2 */
    if (!BSP_WS2812B_Init(&htim3, TIM_CHANNEL_3))
    {
        Error_Handler();
    }

    BSP_WS2812B_Clear();

    /* 3×3 按行优先编号:
     * 0 1 2
     * 3 4 5
     * 6 7 8
     * 这里先给左上、中心、右下三个位置不同颜色 */
    BSP_WS2812B_SetPixel(0U, 80U, 0U, 0U);   /* 左上:红 */
    BSP_WS2812B_SetPixel(4U, 0U, 80U, 0U);   /* 中心:绿 */
    BSP_WS2812B_SetPixel(8U, 0U, 0U, 80U);   /* 右下:蓝 */
    /* USER CODE END 2 */

    /* USER CODE BEGIN WHILE */
    while (1)
    {
        if (!BSP_WS2812B_Show())
        {
            Error_Handler();
        }

        /* 一帧约 281µs;这里等 5ms,留足余量。
         * 如果以后做高速动画,等待时间不要低于 100µs,
         * 让帧尾 RESET 完整输出后再启动下一帧。 */
        if (!BSP_WS2812B_WaitDone(5U))
        {
            Error_Handler();
        }

        HAL_Delay(1000);
    }
    /* USER CODE END WHILE */
}

这份驱动把"颜色"和"定时器 CCR"分开:用户只操作 s_pixels,build_frame() 负责把 G、R、B 和高位先行转换成 DMA 序列。以后要做流水灯、呼吸灯或渐变,只需要改颜色缓存,不需要动定时器和 DMA 配置。


八、一页纸总结

一句话主线: CPU 把颜色排成 24 位 GRB,再把每个 bit 换成 CCR=21 或 43;DMA 在每次比较匹配时把下一个 CCR 值送进预装载寄存器,TIM3_CH3 从 PB0 输出 0.889µs 的归零码,9 颗灯串行取数,最后用 ≥80µs 的低电平统一锁存。

把整件事拆成四层:

  • 协议层:0 码短高、1 码长高,周期约 0.89µs;每颗 24 位 GRB,高位先行;帧尾 RESET。
  • 定时器层:72MHz、PSC=0、ARR=63,得到 64 个计数;CCR=21/43 决定高电平宽度。
  • 传输层:TIM3_CH3 比较事件触发 DMA1 Channel2,半字搬运,内存自增,外设地址固定。
  • 硬件层:5V、共地、每颗 100nF、数据入口整形/电平转换、H1 的 3-4 脚跳线。

关键参数表

项目 值 说明
TIM3 计数时钟 72MHz APB1 定时器时钟
PSC 0 不分频
ARR 63 64 个计数一周期
位周期 0.889µs 约 1.125Mbit/s
0 码 CCR 21 高约 0.292µs
1 码 CCR 43 高约 0.597µs
RESET CCR 0 整个周期低电平
RESET_COUNT 100 约 88.9µs
灯珠数量 9 3×3 阵列
每颗数据 24 位 G、R、B 各 8 位,MSB first
帧半字数 316 9×24+100
帧时长 约 281µs 不含软件开销
DMA 通道 DMA1 Channel2 TIM3_CH3 固定映射
DMA 宽度 Half Word 对应 16 位 CCR3
DMA 模式 Normal 一帧一启动

6 个自测问题

  1. 为什么 0 码和 1 码都有高电平,接收端却能区分它们?
  2. ARR 和 CCR 各自改变什么?如果把 ARR 从 63 改成 127,而 CCR 仍为 21/43,波形会变成什么样?
  3. 为什么 DMA 请求选 TIM3_CH3 的比较匹配,而不是 TIM3_UP 的更新事件?
  4. 72MHz、ARR=63 时,为什么 0 码用 21、1 码用 43?这两个数分别对应多少微秒?
  5. 为什么不能先调用 HAL_TIM_PWM_Start(),再调用 HAL_TIM_PWM_Start_DMA()?
  6. 第一颗灯能正常显示,后面八颗不亮或颜色错位,你会按什么顺序检查?

如果这 6 个问题你都能用自己的话讲清楚,再回头看代码,21、43、316、DMA1 Channel2 这些数字就不再是死记的常量,而是从时序和硬件关系里自然推出来的结果。


相关推荐
小鸡脚来咯1 小时前
【无标题】
单片机·嵌入式硬件
澄纸气2 小时前
STM32 外部中断实战:对射式计数器和编码器计数器,两个实验跑通 EXTI
stm32·单片机·嵌入式硬件
2501_922639582 小时前
率能半导体 SS6286L直流双向马达驱动芯片:18V/7A,覆盖电子锁、玩具、无线充电、机器人四大应用场景
stm32·单片机·嵌入式硬件·硬件工程
搁浅小泽3 小时前
IGBT 与散热片间未加绝缘垫片导致桥臂直通原理分析
单片机·嵌入式硬件·设计规范
动词ing3 小时前
【51单片机】开始软件安装和构成
单片机·嵌入式硬件·51单片机
铅笔小新z4 小时前
【stm32】PWM呼吸灯
stm32·单片机·嵌入式硬件
深圳老胡4 小时前
RTX 与其他 RTOS 操作系统的差异及优缺点分析
笔记·stm32·单片机·代码规范
洞察物理世界4 小时前
【SI_PRBS 01】快速了解光通信PRBS
单片机·嵌入式硬件
happygrilclh4 小时前
LVGL 移植整体流程
stm32