RP-series PIO(三)-PIO 使用入门之 WS2812 LEDs(1)
WS2812 LED(NeoPixel1 品牌产品)是一种可寻址 RGB LED。可以单独控制灯光的红、绿、蓝三种颜色分量,而且可以仅用一个控制输入就能对多个 WS2812 LED 进行单独控制。每个 LED 都有一对电源端子、一个串行数据输入接口和一个串行数据输出接口。
当串行数据从 LED 的输入接口输入时,WS2012 提取开头的三个字节供自己使用(分别对应红、绿、蓝三种颜色),其余的数据则会传递到其串行数据输出端。通常这些 LED 会连接成一条长长的链,每个 LED 都连接到同个公共电源上,并且每个 LED 的数据输出端会连接到下一个 LED 的输入端。向链中的第一个 LED(其数据输入端未连接其他设备的那个)发送一长串串行数据,就会在每个 LED 中存入三个字节的 RGB 数据,这样它们的颜色和亮度就可以单独进行编程设置。
WS2812 线路格式。宽的正脉冲表示 1,窄的正脉冲表示 0,非常长的负脉冲用于锁存使能。
LED 接收和转发串行数据的格式比较特别。每个比特都以正脉冲的形式传输,脉冲的宽度决定了它是 1
还是 0
。同一系列的 WS2812 LED ,它们的时序往往略有不同,而且对精度要求也不一样。可以通过逐位操作(bit-bang)该协议,或者将预先设定好的位组合写入某些通用串行外设(如SPI或I2S)来更可靠地保证时序,但生成这些位组合仍然存在一定的软件复杂性和成本。
理想情况下,希望能对所有 CPU 周期充分利用,要么用来生成要在灯光上显示的颜色模式,要么来处理 LED 的任何其他任务。
本文以
pico-examples
中的pio/ws2812
示例为参照进行讲解。
PIO 程序
.program ws2812
.side_set 1
; The following constants are selected for broad compatibility with WS2812,
; WS2812B, and SK6812 LEDs. Other constants may support higher bandwidths for
; specific LEDs, such as (7,10,8) for WS2812B LEDs.
.define public T1 3
.define public T2 3
.define public T3 4
.lang_opt python sideset_init = pico.PIO.OUT_HIGH
.lang_opt python out_init = pico.PIO.OUT_HIGH
.lang_opt python out_shiftdir = 1
.wrap_target
bitloop:
out x, 1 side 0 [T3 - 1] ; Side-set still takes place when instruction stalls
jmp !x do_zero side 1 [T1 - 1] ; Branch on the bit we shifted out. Positive pulse
do_one:
jmp bitloop side 1 [T2 - 1] ; Continue driving high, for a long pulse
do_zero:
nop side 0 [T2 - 1] ; Or drive low, for a short pulse
.wrap
《RP-series PIO(二)-PIO 使用入门之第一个 PIO 应用程序》只是对 PIO 应用程序的结构进行了一番走马观花式的介绍。这次我们将逐行剖析代码。第一行告诉汇编器,我们正在定义一个名为 ws2812
的程序:
.program ws2812
可以在同一个 .pio
文件中包含多个程序,并且每个程序都会有其自己的 .program
指令,名称各不相同。汇编器会依次处理每个程序,所有经过汇编的程序都会出现在输出文件中。
每条 PIO 指令的大小都为 16 位。通常,每条指令中的 5 位会用于“延迟”,其延迟时间一般为 0 到 31个 周期(在指令完成之后且进入下一条指令之前)。这 5 位可以用于其他目的:
.side_set 1
这条 .side_set 1
指令表示要占用其中一个延迟位来进行 “side-set”。除了指令本身所执行的操作之外,状态机将使用这个位来驱动某些引脚的值,每条指令执行一次。这对于高频用例(例如数字平面接口(DPI)面板的像素时钟)非常有用,而且对于缩减程序大小以便能装入共享指令内存也很有帮助。
需要注意的是,占用了一位后,我们的延迟范围变成了0到15(4位),但这是很正常的,因为你很少会想把 side-set 与低频操作混合在一起。由于我们没有使用 .side_set 1 opt
(这意味着 side-set 是可选的,但需要再用一位来表明指令是否进行 side-set),所以我们必须为程序中的每条指令都指定一个 side-set。这就是在清单中每条指令上会看到的 side N
。
.define public T1 2
.define public T2 5
.define public T3 3
.define
用于声明常量。public
关键字意味着汇编器会在输出文件中暴露该常量,供其他软件使用:在 SDK 的语境下,相当于一个 #define
。我们将使用 T1、T2 和 T3 来计算每条指令的延迟周期。
.lang_opt python
在使用 MPicroython PIO 库时,可以通过这条指令指定 PIO 硬件默认设置。在 SDK 应用程序的语境下,可以不用关心这些设置。
.wrap_target
现在先忽略这个,等遇到它的相关内容 .wrap
时再回过头来看它。
bitloop:
这是一个标签。标签告知汇编器,代码中的这个位置是有意义的,之后可以通过名称来引用它。标签主要与跳转( jmp
)指令一起使用。
out x, 1 side 0 [T3 - 1] ; Side-set still takes place when instruction stalls
终于,我们看到了一行包含PIO指令的内容。这里有不少值得关注的地方。
- 这是一条输出
out
指令。输出指令会从输出移位寄存器(OSR3)中取出一些比特位,并将它们写入到其他地方。在这种情况下,OSR 将包含要发送给 LED 的像素数据。 [T3 - 1]
是延迟周期的数量( T3 减去 1)。T3
是在前面定义的一个常量。x
(两个暂存寄存器中的一个;另一个被称为y
)是写入数据的目标位置。状态机利用它们的暂存寄存器来保存和比较临时数据。side 0
:将 side-set 的引脚置为低电平(0)。;
之后的所有内容都是注释,它们只是供人阅读的说明,汇编器会忽略注释。
所以,当状态机执行这条指令时,会进行以下操作:
- 将 side-set 引脚置为0(即便因为输出移位寄存器(OSR)中无数据可用而导致指令停滞,这一步操作也会进行)。
- 将输出移位寄存器(OSR)中的一位数据移入 x 寄存器。x寄存器的值将为 0 或 1。
- 在指令执行后等待
T3 - 1
个周期(由于指令本身执行需要一个周期,所以整个操作过程需要T3
个周期)。需要注意的是,当我们说周期时,指的是状态机的执行周期:通过配置其时钟分频器,可以使状态机以比系统时钟更慢的速率执行。
让我们来看一下程序中的下一条指令。
jmp !x do_zero side 1 [T1 - 1] ; Branch on the bit we shifted out. Positive pulse
- 将 side-set 引脚置为高电平(脉冲的上升沿)。
- 如果
x==0
,就跳转到标记为do_zero
的指令,否则按顺序继续执行下一条指令。 - 指令执行后延迟
T1 - 1
个周期(无论是否进行了分支跳转)。
让我们看看在程序中到目前为止我们的输出引脚都做了些什么。
该引脚处于低电平的状态持续时间为 T3,处于高电平状态的持续时间为 T1。如果 x 寄存器的值为1(记住,这里面包含 1 比特的像素数据),那么将接着执行标记为 do_one
的指令:
do_one:
jmp bitloop side 1 [T2 - 1] ; Continue driving high, for a long pulse
该分支下,要进行如下操作:
- 将 side-site 引脚置为高电平(延续脉冲)。
- 无条件跳转到
bitloop
(在程序开头定义的标签);状态机已经处理完这个数据位,将会从其输出移位寄存器(OSR)中获取另一个数据位。 - 在指令执行后延迟
T2 - 1
个周期。
此时引脚处的波形看起来是这样的:
这解释了将一个数据位 1 移入 x 寄存器的情况。对于数据位 0,我们将会跳过刚才看过的最后一条指令,跳转到标记为 do_zero
的指令:
do_zero:
nop side 0 [T2 - 1] ; Or drive low, for a short pulse
- 将 side-site 引脚置为低电平(我们脉冲的下降沿)。
nop
4 表示无操作。没有其他特别想做的事情,所以就浪费一个周期。- 这条指令总共需要T2个周期。
对于 x == 0
的情况,在输出引脚处会得到如下情况:
程序的最后一行是这样的:
.wrap
这与程序开头的 .wrap_target
指令相匹配。Wrapping 是状态机的一项硬件特性,其表现就像虫洞一样:通过 .wrap
语句进入,零周期后就会出现在 .wrap_target
处,除非 .wrap
语句之前紧接着是一个条件为真的跳转(jmp
)指令。这对于那些必须快速运行且需要精确计时的程序来说非常重要,而且通常还能在指令内存中为节省一个存储槽位。
通常情况下,明确的
.wrap_target/.wrap
指令对并非必需,因为如果没有指定的话,由pioasm
生成的默认配置会有一个从程序末尾隐式循环回到程序开头的操作。
现在大家就能逐渐明白为什么计时参数 T1、T2、T3 要这样编号了,因为 LED 灯带所接收到的情况实际上就是以下这两种情况之一:
还剩下一个问题:数据是从哪里来的?这在《RP2350数据手册》中有更详尽的解释,从输出移位寄存器(OSR)移出的数据来自状态机的 TX FIFO。TX FIFO 是状态机与RP系列微控制器其他元件之间的数据缓冲区,可以通过CPU直接写入数据来填充,或者由速度快得多的系统直接内存访问(DMA)来填充。
输出(out
)指令将数据从输出移位寄存器(OSR)移出,同时从另一端移入零来填补空位。因为输出移位寄存器(OSR)是32位宽的,所以一旦总共移出了32位,就会开始出现零。拉取(pull
)指令,从TX FIFO 获取数据并将其放入输出移位寄存器(OSR)中(如果先入先出队列是空的,就会使状态机停滞)。
然而,在大多数情况下,配置自动拉取(autopull
)会更简单,在这种模式下,当已移出配置好数量的位时,状态机就会自动从 TX FIFO 拉取数据并重新填充输出移位寄存器(OSR)。自动拉取在后台进行,与状态机可能正在进行的其他任何操作并行(换句话说,它的耗时为零个周期)。
NeoPixel 是 Adafruit2 公司对 WS2812 等可寻址 RGB LED 的品牌命名。这些 LED 内部集成了控制电路,使得它们能够实现复杂的色彩控制。其核心原理是基于一种单总线(One - Wire)通信协议,通过一个数据输入引脚就可以控制多个 LED。 ↩︎
Adafruit 是一家知名的开源电子硬件公司。它专注于开发、制造和销售各种电子元件、开发板、传感器和相关的配件。该公司以其高质量的产品和对开源社区的大力支持而闻名。其产品涵盖了广泛的领域,包括但不限于微控制器扩展板(如用于 Arduino 和树莓派的扩展板)、各种传感器(温度传感器、光线传感器等)、显示模块(LCD、OLED 等)以及像 NeoPixel 这样的可寻址 LED 产品系列。 ↩︎
输出移位寄存器(OSR)是一个暂存区域,用于处理通过 TX FIFO 进入状态机的数据。数据每次以32位的块从 TX FIFO 提取到输出移位寄存器中。当执行输出(
out
)指令时,输出移位寄存器可以通过向左或向右移位将这些数据拆分成更小的部分,并将从末端移出的比特发送到几个不同的目标位置之一,比如引脚。要移位的数据量由输出指令进行编码,而移位的方向(向左还是向右)是预先配置好的。如需完整详情和图表,请参阅《RP2350数据手册》。 ↩︎NOP,即无操作,意思就是确切地什么都不做!在指令集参考资料中并没有定义
nop
指令:在 PIO 汇编中,nop
实际上是mov y , y
的同义词。在这个例子中,本可以使用跳转(jmp
)指令,为什么却插入了一个nop
指令呢?这是特意设计的一种手段,主要用来讨论nop
指令和.wrap
指令。一般来说,当除了进行 side-set 之外无事可做,或者需要比单条指令所能提供的延迟稍长一点的延迟时,nop
指令就很有用了。 ↩︎