RP2040 PIO 驱动 WS2812 彩灯:从原理到 MicroPython 实现

1.WS2812 LED 介绍

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

1280X1280.PNG

WS2812 的主要特点包括:

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

WS2812 有四个引脚,功能如下:

1280X1280 (1).PNG

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

1280X1280 (2).PNG

ScreenShot_2026-06-09_190423_207.png

芯片在上电复位以后,DIN 端接收从控制器传输过来的数据:

  1. 首先送过来的 24 bit 数据被第一个芯片提取后,送到芯片内部的数据锁存器
  2. 芯片内部的数据锁存器根据接收到的 24 bit 数据,在 OUTR、OUTG、OUTB 控制端产生不同的占空比控制信号,使得 LED 产生不同颜色
  3. 剩余的数据经过内部整形处理电路整形放大后,通过 DO 端口开始转发输入给下一个级联的芯片,每经过一个芯片的传输,信号减少 24 bit
  4. 芯片在没有接收到 DIN 端输入 RESET 信号时,OUTR、OUTG、OUTB 管脚输出保持不便;而当接收到 RESET 信号后,每个控制端会根据接收到的数据改变输出

芯片采用自动整形转发技术,使得该芯片的级联个数不受信号传送的限制,仅仅受限信号传输速度要求。

其通信协议为:复位信号 + 第一颗芯片的 24 位数据 + 第二颗芯片的 24 位数据 + ... + 第 N 颗芯片的 24 位数据。高位在前,按照 GRB 的顺序发送(不同 WS2812 芯片颜色顺序不一样,需要看数据手册):

1280X1280 (3).PNG

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

b48b8923-52b6-4ceb-9c92-d7eee03a662c.png

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

WS2812 电气参数如下:

c0d6c004-2623-4b6d-a3df-ae16127ad582.png

d6071a32-b7fe-4e80-a78d-eaec0689c9e2.png

2.使用 NeoPixel 类驱动 WS2812 灯

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

7ea9b98d-27d8-4346-825c-311a72b49b65.png

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

407e818e-307c-4724-8b38-f6e4c1d55ac8 (1).png

ScreenShot_2026-06-09_190506_896.png

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

4746937f-5ae5-4a7d-9b10-a443e8309220.png

dcf40541-c9cc-4e47-8c6b-17f30988d9e6.jfif

60870c6f-f2e6-43c8-95d3-96f778c17c6c.png

以下代码中,我们使用单排 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)

其工作流程如下图所示:

447f0424-a76f-4188-bd08-3f497cd01af4.png

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

4.gif

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

d7a3f797-d876-4a50-bbe6-caf802579bd6.png

我们也可以将定时器回调函数修改为 random_color_flow() 函数,实现任意流水灯效果:

# 初始化定时器,每 200 毫秒触发一次 move_pixel 函数
timer.init(period=200, mode=Timer.PERIODIC, callback=random_color_flow)

其基本原理就是使用 MicroPython 内置的 random 库中 randint 函数,生成 RGB 随机颜色数组,烧录代码,效果如下图所示:

3.gif

3.使用 PIO 驱动 WS2812 灯

使用 PIO 来控制 WS2812 彩灯对比使用内置 neopixel 模块控制 WS2812 彩灯来说,具有高实时性的优势:PIO 是一种专门的硬件外设,它可以独立于 CPU 运行,执行特定的数据传输任务,并且能够生成精确的时序信号而无需 CPU 参与,从而大大减轻了 CPU 的负载,这种硬件加速方式可以实现更高的传输效率和更低的功耗。

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

4746937f-5ae5-4a7d-9b10-a443e8309220.png

dcf40541-c9cc-4e47-8c6b-17f30988d9e6.jfif

9dfee791-f0c1-490f-843a-e90e7ea91c6e.jfif

以下代码可以在我们提供的资料包中 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 程序的工作流程如下:

  1. 对于每个 LED 灯,程序会输出 24 位的 RGB 数据,每个颜色 8 位
  2. 对于每个比特位,如果值为 0 ,则输出高电平 0.3 μs 和低电平 0.9 μs;如果值为 1 ,则输出高电平 0.9 μs 和低电平 0.3 μs

接着,我们只需要向状态机写入每个 LED 灯对应的 GBR 值即可实现 WS2812 灯的不同颜色显示。

接下来,我们详细讲解驱动代码的原理。

前面我们讲过,WS2812 采用单极性归零码进行通信,数据 1 和数据 0 用高电平持续时间进行区分:

c698b4f9-c646-4bfb-8422-1c552d174ad3.png

WS2812 的通信频率是 800 kHz,将其乘以 10 可得 8 MHz (即一个 WS2812 时钟周期 = 10 个 PIO 执行时钟周期),通过控制每个 PIO 执行时钟周期的高低电平输出即可控制输出二进制数据:

  1. 首先在 DIN 上输出 3 个 PIO 执行周期的高电平(这里记为 T1 = 0.375 us)
  2. 根据要传送的数据位调整 DIN 上输出的高低电平(1 = 高电位,0 = 低电位),

并维持电平 4 个 PIO 执行周期 (这里记为 T2 = 0.5 us)

  1. 最后拉低 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 阶段。

整体时序图如下所示:

b45e9153-7e0b-4cd1-8f9c-c0e7755b692e.png

在 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]]

其中:

  1. colors[1:]: 这个语法用于获取列表 colors 中从第二个元素开始到最后一个元素的所有元素。也就是说,这个操作会得到一个新的列表,包含除了第一个元素之外的所有元素
  2. [colors[0]]: 这个语法创建了一个新的列表,该列表只包含 colors 列表的第一个元素
  3. colors[1:] + [colors[0]]: 这个操作将上述两个列表进行拼接,得到一个新的列表。新列表的第一个元素是原列表的第二个元素,最后一个元素是原列表的第一个元素

这种左移操作可以用于实现 LED 灯带颜色的循环变换效果。每次执行这行代码,颜色列表中的元素就会向左移动一位,从而显示出不同的颜色组合,以上程序整体流程如下图所示:

7d79e463-8081-4252-b724-9e72d9305175.png

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

1f6f62d1-c3ff-4177-a663-134ce2040a18.jfif

3.gif

posted @ 2026-09-01 16:18  FreakStudio  阅读(4)  评论(0)    收藏  举报