
🔥铅笔小新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 个必须讲清的知识点
- WS2812B 是"灯珠 + 驱动芯片"的串联像素。 每颗灯吃下最近的 24 位颜色,再把剩下的数据从 DOUT 转发给下一颗。
- 0 码和 1 码靠高电平宽度区分。 两者都有高、低电平,接收端看的是高电平持续了多久。
- 3.3V 逻辑驱动 5V 灯珠,硬件比代码更容易出问题。 电源、共地、去耦、电平转换和数据方向,任何一项不对都会表现成"程序有 bug"。
- ARR 决定一个 bit 有多长,CCR 决定这个 bit 里高电平有多长。 把 72MHz 的计数变成 0.889µs,再把 64 个计数分成 21 和 43。
- DMA 在比较匹配时把下一个 CCR 值送进预装载寄存器。 当前周期不受打扰,下一次溢出时新值生效,形成"提前准备下一颗子弹"的流水线。
- 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 有两个作用:
- 告诉灯珠"这一帧结束了"。
- 让所有灯珠同时把暂存区里的颜色应用到发光单元。
对本文使用的灯珠,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 到第一颗灯,中间还经过了排针和板载输入级,任何一个环节断开都不会亮。
自己搭电路时,还要注意两个容易被忽略的点:
- 3.3V 高电平对 5V 供电的 WS2812B 可能不够。 灯珠的 VIH 通常按 0.7×VDD 计算,5V 供电时约为 3.5V,而 STM32 的推挽输出只有 3.3V。短线和低亮度可能碰巧能亮,但温度、线长和批次一变就不稳定。建议用 74AHCT1G125 这类非反相电平转换器,或者选择能在 3.3V 下可靠工作的方案。
- 电源和地去耦不能省。 每颗灯旁边放 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 颗纯绿灯,把整条链路跑通,再谈颜色和动画。
真正上电时的调用顺序是:
HAL_Init():初始化 HAL、SysTick 和中断优先级分组。SystemClock_Config():把 HCLK 配到 72MHz,并让 TIM3 拿到 72MHz 计数时钟。MX_GPIO_Init():初始化普通 GPIO。MX_DMA_Init():打开 DMA1 时钟并使能 DMA1 Channel2 中断。MX_TIM3_Init():配置 PSC、ARR、PWM 模式、CCR,并调用HAL_TIM_MspPostInit()配置 PB0 复用功能。BSP_WS2812B_Init():保存定时器句柄和通道,并把 CCR 先清 0。- 填充帧缓冲区:最小示例里是
build_green_frame(),完整驱动里是build_frame(),最终都得到 316 个半字。 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 这段代码为什么能跑
这段代码做了三件事:
- 把 9 颗灯的颜色按 G、R、B 的顺序展开成 216 个 CCR 值。
- 在末尾追加 100 个 0,形成 88.9µs 的 RESET。
- 把这 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 颜色验证:从一颗灯到九颗灯
不要一上来就写彩虹渐变。按下面的顺序验证,出问题时更容易定位:
- 只发第一颗灯的 24 位,再补 RESET;后面的灯不发颜色数据。 第一颗应显示设定颜色,其余保持熄灭或随机上电状态。这样可以确认编码和时序基本正确。
- 9 颗全部设为纯绿。 如果全绿,说明 GRB 顺序、级联和 RESET 都通了。
- 9 颗全部设为纯红、纯蓝、纯白。 检查颜色通道有没有互换,白光是否因供电不足偏色。
- 按 3×3 设置不同亮度。 例如左上红 2、上中红 20、右上红 200;中左绿 2、中中绿 20、中右绿 200;下左蓝 2、下中蓝 20、下右蓝 200。
实验现象中,左上到右下依次呈现红、橙、黄白,绿、青绿、白绿,蓝、青、白蓝。高亮度灯珠在手机相机里容易过曝,颜色以肉眼观察为准;但"索引顺序和物理位置一一对应"这个结论是可靠的。
如果颜色整体偏红或偏绿,先检查是不是把 RGB 当成 GRB 发送;如果只有第一颗正确,检查 DOUT→DIN 和后续灯的供电;如果亮度越高越不稳定,检查电源和地去耦。
5.3 验证阶梯
- 看 PB0 波形。 周期约 0.89µs,0/1 码高电平约 0.29/0.60µs。
- 只点第一颗灯。 能单独显示设定的颜色,说明编码和时序基本正确。
- 九颗显示同一种纯色。 全部显示绿色、红色或蓝色,且没有明显闪烁。
- 验证 3×3 位置。 九个位置与数组索引一一对应,没有整体错位。
- 连续刷新。 长时间运行不随机变色、不丢帧,电源和 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 个自测问题
- 为什么 0 码和 1 码都有高电平,接收端却能区分它们?
- ARR 和 CCR 各自改变什么?如果把 ARR 从 63 改成 127,而 CCR 仍为 21/43,波形会变成什么样?
- 为什么 DMA 请求选 TIM3_CH3 的比较匹配,而不是 TIM3_UP 的更新事件?
- 72MHz、ARR=63 时,为什么 0 码用 21、1 码用 43?这两个数分别对应多少微秒?
- 为什么不能先调用
HAL_TIM_PWM_Start(),再调用HAL_TIM_PWM_Start_DMA()? - 第一颗灯能正常显示,后面八颗不亮或颜色错位,你会按什么顺序检查?
如果这 6 个问题你都能用自己的话讲清楚,再回头看代码,21、43、316、DMA1 Channel2 这些数字就不再是死记的常量,而是从时序和硬件关系里自然推出来的结果。
