RP2040 PIO 驱动 WS2812 彩灯:从原理到 MicroPython 实现
1.WS2812 LED 介绍
WS2812 LED 是 Neopixel 可单独寻址的 RGB LED 中的一种,我们可以通过单个数据线来实现多个 WS2812 RGB 三色灯的控制。WS2812 的工作原理是通过单根数据线,将 24 bit 的 RGB 颜色数据逐个传输到每个灯珠。每个 WS2812 芯片都包含一个位移寄存器,用于接收和转发数据。当接收到一帧完整的 RGB 数据后,该灯珠就会显示对应的颜色。通过串联多个 WS2812 灯珠,可以组成灵活可编程的 LED 灯带、 LED 矩阵等。常见的应用包括装饰照明、LED 显示屏、智能灯具等。

WS2812 的主要特点包括:
- 集成度高:一个 WS2812 灯珠内集成了 RGB 三色 LED 和控制芯片,只需要单根数据线即可控制
- 串联控制:多个 WS2812 灯珠可以串联使用,形成 LED 灯带或 LED 阵列
- 编程控制:通过编程控制数据信号,可以实现对每个 LED 的独立调光和颜色控制
- 低功耗:工作电压为 5V,单个 LED 功耗仅 60mW
- 高亮度:LED 发光亮度高,最大亮度可达 6500~8000mcd
- 小尺寸:封装体积仅 5.0mm x 5.0mm x 1.6mm
WS2812 有四个引脚,功能如下:

它采用单极性归零码的通讯方式,每一个码元都必须有低电平:


芯片在上电复位以后,DIN 端接收从控制器传输过来的数据:
- 首先送过来的 24 bit 数据被第一个芯片提取后,送到芯片内部的数据锁存器
- 芯片内部的数据锁存器根据接收到的 24 bit 数据,在 OUTR、OUTG、OUTB 控制端产生不同的占空比控制信号,使得 LED 产生不同颜色
- 剩余的数据经过内部整形处理电路整形放大后,通过 DO 端口开始转发输入给下一个级联的芯片,每经过一个芯片的传输,信号减少 24 bit
- 芯片在没有接收到 DIN 端输入 RESET 信号时,OUTR、OUTG、OUTB 管脚输出保持不便;而当接收到 RESET 信号后,每个控制端会根据接收到的数据改变输出
芯片采用自动整形转发技术,使得该芯片的级联个数不受信号传送的限制,仅仅受限信号传输速度要求。
其通信协议为:复位信号 + 第一颗芯片的 24 位数据 + 第二颗芯片的 24 位数据 + ... + 第 N 颗芯片的 24 位数据。高位在前,按照 GRB 的顺序发送(不同 WS2812 芯片颜色顺序不一样,需要看数据手册):

其中 R0-R7 bit 控制红光颜色输出,G0-G7bit 控制绿光颜色输出,B0-B7bit 控制绿光颜色输出。由于每颗灯珠需要存储 24 bit 的 RGB 色彩数据,当灯带较长,灯珠个数较多时,显存数据会占用大量的 RAM 空间。

其中 D1 位 MCU 端发送的数据,D2、D3、D4 为级联电路自动整形转发的数据。控制器可以连续发送 24 bit 的 RGB 控制信号,第一个灯珠截取前 24 bit 后,会显示在自己的 LED 上,并将其余的数据进行整形后发送给第二颗灯珠。第二颗灯珠会截取第二组 24 bit 数据进行显示,并继续进行转发剩下的,直到最后一组数据被显示为止。
WS2812 电气参数如下:


2.使用 NeoPixel 类驱动 WS2812 灯
我们可以使用 MicroPython 库中自带的 neopixel 模块中的 NeoPixel 类来驱动 WS2812 LED 灯:

它提供了以下几种方法来控制 WS2812 LED 灯:


在下面实验中,我们需要将风雅一号板-七彩触控扩展板插入到风雅一号板-通用兼容扩展板上,同时打开 SWITCH2 拨码开关上 WS2812 选项:


以下代码中,我们使用单排 4 个 WS2812 实现一个流水灯,使用树莓派 Pico 的 GP18 引脚作为 WS2812 的信号线,使用 5V 给 WS2812 灯珠进行供电,以下代码可以在我们提供的资料包中 elegance-devkit v1\Demo\32 NeoPixel 文件夹找到。
其工作基本原理为:定时调用亮灯函数,在亮灯函数中遍历了所有的 LED 灯,并将当前被选中的灯设置为红色,其他灯设置为蓝色。然后,它更新 NeoPixel 条带来显示新的颜色。当每个灯轮询显示一遍红色后,全部变为绿色,然后重复循环。
示例代码如下:
# Python env : MicroPython v1.23.0
# -*- coding: utf-8 -*-
# @Time : 2024/7/27 下午2:55
# @Author : 李清水
# @File : main.py
# @Description : 通过GPIO控制NeoPixel灯带闪烁
# ======================================== 导入相关模块 ========================================
# 导入硬件相关的模块
from machine import Pin, Timer
# 导入时间相关的模块
import time
# 导入NeoPixel相关的模块
from neopixel import NeoPixel
# 导入随机数模块
import random
# ======================================== 全局变量 ============================================
# 定义 NeoPixel 连接的 GPIO 引脚为 18
NEOPIXEL_PIN = 18
# 定义 NeoPixel 灯带上 LED 的数量为 4 个
NUMBER_PIXELS = 4
# 定义一个计数器变量,用于控制 LED 的移动
counter = 0
# ======================================== 功能函数 ============================================
def move_pixel(timer: Timer) -> None:
"""
移动 NeoPixel 灯带上的 LED。
Args:
timer (machine.Timer): 定时器实例。
Returns:
None
Description:
该函数通过定时器周期性调用,控制 NeoPixel 灯带上的 LED 移动。
当前 LED 显示红色,其他 LED 显示蓝色,当计数器超过 LED 数量时,所有 LED 显示绿色并重置计数器。
"""
# 声明 counter 变量为全局变量,以便在函数内部修改它
global counter
# 遍历所有 NeoPixel LED
for i in range(0, NUMBER_PIXELS):
# 如果当前 LED 的索引等于 counter 值,则设置为红色 (10,0,0)
if i == counter:
strip[i] = (55,0,0)
# 否则,设置为蓝色 (0,0,10)
else:
strip[i] = (0,0,55)
# 将更新后的颜色写入 NeoPixel 灯带
strip.write()
# 递增 counter 变量,用于控制下一个 LED 的移动
counter += 1
# 如果 counter 大于 LED 的数量
if counter > NUMBER_PIXELS:
# 设置所有 LED 为绿色 (0,10,0)
strip.fill((0,55,0))
strip.write()
# 将 counter 重置为 0
counter = 0
def random_color_flow(timer: Timer) -> None:
"""
实现随机颜色流水灯效果。
Args:
timer (machine.Timer): 定时器实例。
Returns:
None
Description:
该函数通过定时器周期性调用,控制 NeoPixel 灯带上的 LED 显示随机颜色。
每次调用时,所有 LED 的颜色都会更新为随机颜色。
"""
# 声明 counter 变量为全局变量,以便在函数内部修改它
global counter
# 遍历所有 NeoPixel LED
for i in range(NUMBER_PIXELS):
# 为每个 LED 设置随机颜色 (R, G, B)
strip[i] = (random.randint(0, 55), random.randint(0, 55), random.randint(0, 55))
# 将更新后的颜色写入 NeoPixel 灯带
strip.write()
# ======================================== 自定义类 ============================================
# ======================================== 初始化配置 ==========================================
# 上电延时3s
time.sleep(3)
# 打印调试信息
print("FreakStudio: Using NeoPixel to control WS2812 LED")
# 创建一个 NeoPixel 对象,连接到 GPIO 引脚 18,并设置 LED 的数量为 4 个
strip = NeoPixel(Pin(NEOPIXEL_PIN), NUMBER_PIXELS)
# 创建一个软件定时器对象
timer = Timer(-1)
# ======================================== 主程序 ===========================================
# 初始化定时器,每 200 毫秒触发一次 move_pixel 函数
timer.init(period=200, mode=Timer.PERIODIC, callback=random_color_flow)
while True:
# 打印当前的 counter 值
print('timer counter :', counter)
# 延时 5 秒
time.sleep(5)
其工作流程如下图所示:

将代码烧录到 Pico 中,连接远程终端,可以看到实现了 WS2812 流水灯:

并且终端定时输出计数值:

我们也可以将定时器回调函数修改为 random_color_flow() 函数,实现任意流水灯效果:
# 初始化定时器,每 200 毫秒触发一次 move_pixel 函数
timer.init(period=200, mode=Timer.PERIODIC, callback=random_color_flow)
其基本原理就是使用 MicroPython 内置的 random 库中 randint 函数,生成 RGB 随机颜色数组,烧录代码,效果如下图所示:

3.使用 PIO 驱动 WS2812 灯
使用 PIO 来控制 WS2812 彩灯对比使用内置 neopixel 模块控制 WS2812 彩灯来说,具有高实时性的优势:PIO 是一种专门的硬件外设,它可以独立于 CPU 运行,执行特定的数据传输任务,并且能够生成精确的时序信号而无需 CPU 参与,从而大大减轻了 CPU 的负载,这种硬件加速方式可以实现更高的传输效率和更低的功耗。
在下面实验中,我们需要将风雅一号板-七彩触控扩展板插入到风雅一号板-通用兼容扩展板上,同时打开 SWITCH2 拨码开关上 WS2812 选项:

以下代码可以在我们提供的资料包中 elegance-devkit v1\Demo\33 PIO_NeoPixel 文件夹找到。
接下来,我们看一下如何使用 PIO 状态机来控制 NeoPixel 实现七彩流水灯,示例代码如下:
# Python env : MicroPython v1.23.0
# -*- coding: utf-8 -*-
# @Time : 2024/7/27 下午9:52
# @Author : 李清水
# @File : main.py
# @Description : 通过PIO控制NeoPixel灯带闪烁
# ======================================== 导入相关模块 ========================================
# 导入硬件相关的模块
from machine import Pin
# 导入时间相关的模块
import time
# 导入RP2040相关的模块
import rp2
# ======================================== 全局变量 ============================================
# LED灯带的颜色列表
colors = [
(128, 0, 0), # LED 1,红色,亮度降低
(128, 82, 0), # LED 2,橙色,亮度降低
(128, 128, 0), # LED 3,黄色,亮度降低
(0, 128, 0), # LED 4,绿色,亮度降低
(0, 128, 128), # LED 5,青色,亮度降低
(0, 0, 128), # LED 6,蓝色,亮度降低
(38, 0, 65), # LED 7,紫色,亮度降低
(119, 65, 119), # LED 8,紫罗兰色,亮度降低
(128, 53, 90), # LED 9,热粉色,亮度降低
(128, 10, 74), # LED 10,深粉色,亮度降低
(128, 128, 128),# LED 11,白色,亮度降低
(96, 96, 96), # LED 12,银色,亮度降低
(0, 0, 0), # LED 13,黑色
(64, 64, 64), # LED 14,灰色,亮度降低
(128, 0, 128), # LED 15,品红色,亮度降低
(128, 128, 128) # LED 16,白色,亮度降低
]
# ======================================== 功能函数 ============================================
# 定义一个 asm_pio 函数,用于生成 PIO 程序
# 指定了 side-set 引脚的初始状态为低电平
# 指定了输出数据的移位方向为左移
# 使能autopull数据自动加载,自动加载阈值为24 位数据
@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW,
out_shiftdir=rp2.PIO.SHIFT_LEFT,
autopull=True, pull_thresh=24)
def ws2812() -> None:
"""
定义 WS2812 LED 驱动的 PIO 程序。
Args:
None
Returns:
None
Description:
该 PIO 程序用于驱动 WS2812 LED 灯带,通过状态机控制 LED 的颜色数据输出。
程序通过 side-set 引脚输出高低电平信号,控制 LED 的亮灭。
"""
# 标记程序的起始位置,当程序执行到 wrap() 时,会跳转回此处继续执行
wrap_target()
# 定义一个名为 bitloop 的标签,用于循环处理每个 LED 灯的数据
label('bitloop')
# 将寄存器 osr 中的 1 位数据输出到x寄存器,首先执行侧集操作设置侧集引脚为低电平 2 个周期
out(x, 1).side(0)[2]
# jmp 会判断 X 暂存器的值是不是 0,如果是0则跳转到 do_zero 标签
# jmp 的 delay 会在做完条件判断后执行,所以总共会花 2 + 1 cycles
jmp(not_x, 'do_zero').side(1)[2]
# 如果 X 的值是 1,pioasm 会执行第三行
# 这一行没有改变 side-set 的电位,所以继续维持高电位(3 + 1 cycles),并跳回 bitloop 标签
jmp('bitloop')[3]
# 定义一个名为 do_zero 的标签,用于处理 0 值的位
label('do_zero')
# 若 X 的值是 0,则会执行 nop(),并将 side-set 设为低电位,时间也是 3 + 1 cycles
nop().side(0)[3]
# 程序执行完毕后跳转回 wrap_target() 处继续执行
wrap()
# ======================================== 自定义类 ============================================
# ======================================== 初始化配置 ==========================================
# 上电延时3s
time.sleep(3)
# 打印调试信息
print("FreakStudio: Using PIO to control WS2812 LED")
# 初始化一个状态机对象,并将 ws2812 函数加载到状态机
# WS2812 的通信频率是 800 kHz,将其乘以10可得8 MHz (即一个WS2812时钟周期 = 10个PIO执行时钟周期)
sm = rp2.StateMachine(0, ws2812, freq=8000000, sideset_base=Pin(18))
# ======================================== 主程序 ===========================================
# 启动状态机
sm.active(1)
while True:
# 依次输出每个LED灯的颜色数据
for r, g, b in colors:
# 把红、绿、蓝三个值分别左移8位、16位、24位,然后相加转成一个 24-bit GRB值
value = (g << 16) | (r << 8) | b
# sm.put() 方法实际上会写入一个 32-bit 的数据到 FIFO
# 写入 FIFO 时左移8位
sm.put(value, 8)
# 列表左移
colors = colors[1:] + [colors[0]]
# 延时200ms
time.sleep_ms(200)
简单来说,以上 PIO 程序的工作流程如下:
- 对于每个
LED灯,程序会输出 24 位的RGB数据,每个颜色 8 位 - 对于每个比特位,如果值为 0 ,则输出高电平 0.3 μs 和低电平 0.9 μs;如果值为 1 ,则输出高电平 0.9 μs 和低电平 0.3 μs
接着,我们只需要向状态机写入每个 LED 灯对应的 GBR 值即可实现 WS2812 灯的不同颜色显示。
接下来,我们详细讲解驱动代码的原理。
前面我们讲过,WS2812 采用单极性归零码进行通信,数据 1 和数据 0 用高电平持续时间进行区分:

WS2812 的通信频率是 800 kHz,将其乘以 10 可得 8 MHz (即一个 WS2812 时钟周期 = 10 个 PIO 执行时钟周期),通过控制每个 PIO 执行时钟周期的高低电平输出即可控制输出二进制数据:
- 首先在
DIN上输出 3 个 PIO 执行周期的高电平(这里记为 T1 = 0.375 us) - 根据要传送的数据位调整
DIN上输出的高低电平(1 = 高电位,0 = 低电位),
并维持电平 4 个 PIO 执行周期 (这里记为 T2 = 0.5 us)
- 最后拉低
DIN上的电平持续 3 个PIO执行周期(T3 = 0.375 us)
为方便起见,我们称这三个阶段为 T1、T2 和 T3,其比例为 3:4:3。
T1 T2 T3
└─────┴─────┴─────┘ = 1.25 us
3 : 4 : 3 T1+T2 或 T2+T3 = 0.875 us
1 = 高 高 低
0 = 高 低 低
在 PIO 程序部分,我们首先已经使用 @rp2.asm_pio 装饰器使能 autopull 数据自动加载,自动加载阈值为 24 位数据,在 OSR 中的数据到达 24 位时会自动执行 OUT 指令从 TX FIFO 中加载数据然后进行对应操作,我们只需要 OUT 我们想要发送的数据即可。
PIO 程序运行流程如下:
# 定义一个 asm_pio 函数,用于生成 PIO 程序
# 指定了 side-set 引脚的初始状态为低电平
# 指定了输出数据的移位方向为左移
# 使能autopull数据自动加载,自动加载阈值为24 位数据
@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW,
out_shiftdir=rp2.PIO.SHIFT_LEFT,
autopull=True, pull_thresh=24)
def ws2812() -> None:
"""
定义 WS2812 LED 驱动的 PIO 程序。
Args:
None
Returns:
None
Description:
该 PIO 程序用于驱动 WS2812 LED 灯带,通过状态机控制 LED 的颜色数据输出。
程序通过 side-set 引脚输出高低电平信号,控制 LED 的亮灭。
"""
# 标记程序的起始位置,当程序执行到 wrap() 时,会跳转回此处继续执行
wrap_target()
# 定义一个名为 bitloop 的标签,用于循环处理每个 LED 灯的数据
label('bitloop')
# 将寄存器 osr 中的 1 位数据输出到x寄存器,首先执行侧集操作设置侧集引脚为低电平 2 个周期
out(x, 1).side(0)[2]
# jmp 会判断 X 暂存器的值是不是 0,如果是0则跳转到 do_zero 标签
# jmp 的 delay 会在做完条件判断后执行,所以总共会花 2 + 1 cycles
jmp(not_x, 'do_zero').side(1)[2]
# 如果 X 的值是 1,pioasm 会执行第三行
# 这一行没有改变 side-set 的电位,所以继续维持高电位(3 + 1 cycles),并跳回 bitloop 标签
jmp('bitloop')[3]
# 定义一个名为 do_zero 的标签,用于处理 0 值的位
label('do_zero')
# 若 X 的值是 0,则会执行 nop(),并将 side-set 设为低电位,时间也是 3 + 1 cycles
nop().side(0)[3]
# 程序执行完毕后跳转回 wrap_target() 处继续执行
wrap()
当发送数据 1 时,执行如下:
out(x, 1)
↓
jmp(not_x, 'do_zero')
↓
jmp('bitloop')
当发送数据 0 时,执行如下:
out(x, 1)
↓
jmp(not_x, 'do_zero')
↓
nop()
↓
wrap()
在以上发送数据阶段,总共花费了 7 个时钟周期,完成了 T1 和 T2 阶段。
现在,PIO 程序回到开头,使用 OUT 指令取出一个数据,并将 side-set 侧集引脚 设为低电位,而它的执行时间(2 + 1 个 PIO 执行周期)就会成为上一个 LED 的 T3 阶段。即使已经写完了最后一个 LED 灯的 GBR 数据的最后一位数据,OSR 中已经没有数据了,OUT 指令的 delay 延时仍会继续,完成最后一个数据的 T3 阶段。
整体时序图如下所示:

在 LED 灯的 GBR 数据发送时,我们使用了如下语句:
# 依次输出每个LED灯的颜色数据
for r, g, b in colors:
# 把红、绿、蓝三个值分别左移8位、16位、24位,然后相加转成一个 24-bit GRB值
value = (g << 16) | (r << 8) | b
# sm.put() 方法实际上会写入一个 32-bit 的数据到 FIFO
# 写入 FIFO 时左移8位
sm.put(value, 8)
这里,我们将红、绿、蓝三个值分别左移 8 位、16 位、24 位,然后相加转成一个 24-bit GRB 值,需要注意的是,WS2812 在读取数据时 MSB 位在前 LSB 位在后,同时 OSR 移位寄存器为 32 位大小,为了使 WS2812 能从 MSB 位开始读取数据,我们需要将 GRB 值左移 8 位,对于缺少的低 8 位数据,PIO 状态机会暂停执行并等待下一个 32 位数据的到来。这里,我们在使能 autopull 数据自动加载时设置自动加载阈值为 24 位数据,那么当每次 OUT 完成一个 LED 灯对应的 24 位 GRB 数据后,会自动从 TX FIFO 中加载下一个 LED 灯对应的 24 位 GRB 数据(OUT 实际加载的大小为 32 位数据、低 8 位数据无效)。
在主循环中,我们使用如下语句完成 color 列表的循环左移(每次循环列表中元素循环左移 1 位)从而实现 WS2812 七彩流水灯(视觉效果上,LED 灯对应颜色不断左移)的效果:
colors = colors[1:] + [colors[0]]
其中:
colors[1:]: 这个语法用于获取列表colors中从第二个元素开始到最后一个元素的所有元素。也就是说,这个操作会得到一个新的列表,包含除了第一个元素之外的所有元素[colors[0]]: 这个语法创建了一个新的列表,该列表只包含colors列表的第一个元素colors[1:] + [colors[0]]: 这个操作将上述两个列表进行拼接,得到一个新的列表。新列表的第一个元素是原列表的第二个元素,最后一个元素是原列表的第一个元素
这种左移操作可以用于实现 LED 灯带颜色的循环变换效果。每次执行这行代码,颜色列表中的元素就会向左移动一位,从而显示出不同的颜色组合,以上程序整体流程如下图所示:

将代码烧录到 Pico 中,可以看到 WS2812 灯带中实现了 LED 灯不同颜色循环变化的效果:


WS2812 LED` 是 `Neopixel` 可单独寻址的 RGB LED 中的一种,我们可以通过单个数据线来实现多个 `WS2812 RGB` 三色灯的控制。**WS2812 的工作原理是通过单根数据线,将 24 bit 的 RGB 颜色数据逐个传输到每个灯珠。每个 WS2812 芯片都包含一个位移寄存器,用于接收和转发数据。当接收到一帧完整的 RGB 数据后,该灯珠就会显示对应的颜色。**通过串联多个 WS2812 灯珠,可以组成灵活可编程的 LED 灯带、 LED 矩阵等。常见的应用包括装饰照明、LED 显示屏、智能灯具等。
浙公网安备 33010602011771号