白話解密 Raspberry Pi Pico 的 PIO(Programmed I/O) — — 使用 MicroPython 撰寫 pioasm:Part I

Alan Wang
38 min readJun 14, 2021

--

在這個系列中,我們要來看看 Raspberry Pi Pico — — 或者應該說 RP2040 微控制器 — — 的一個特殊功能,也就是 PIO(Programmed I/O,程式化輸出入)。

在許多開發板上,會有事先針對特定通訊方式設定好的 GPIO 腳位,例如 I2C、SPI、UART… 但假如你希望增加這類腳位的數量,甚至使用一些更特殊的通訊協定如 I2S、VGA、DPI,可能就只能用軟體模擬,讓主控制器來扮演腳位控制者了,而這就是所謂的「bit banging」。然而每個協定的通訊速度不同,這麼做也會占用處理器資源,並不是很有效率。

為解決這個問題,RP2040 提供了兩組 PIO,相當於獨立的迷你處理器,可以用稱為 PIO Assembler(pioasm)的組合語言來設定腳位行為。

可程式化的腳位並非新概念:這在 FPGA 和 CPLD 上已經存在,卻是第一次應用在這種平價、親民的開發板產品上。而透過 MicroPython,你不需要什麼很困難的技術就能存取 PIO。只是在我寫這篇之前,官方文件的解釋一直很有限,網路上又很難找到夠充分的討論和介紹,所以卯起來做了點研究。實際上來說,本篇就是這段研究的成果記錄。

當然我只能寫到很基礎的東西,且第一篇都是關於腳位的基本輸出。下篇應該就會談到輸入跟一些稍微更進階的應用。

本篇比較進階,假設讀者已經有基本的創客和 Python 概念,敬請見諒。

Photo by Eric Weber on Unsplash

RP2040 的 PIO、state machine 與 pioasm

RP2040 有兩個 PIO,每一個能儲存 32 道 pioasm 指令,而且能分成四個 state machine(狀態機)。下面是一個 PIO 的內容:

來源:RP2040 datasheet

這些 state machine 共用同一個 PIO 的指令,但能彼此獨立運作、甚至能用不同的速度運行,它們也是我們操作 PIO 時的主要使用對象。下面是一個 state machine 的內容:

來源:RP2040 datasheet

看起來好像很複雜吧?下面我會試著從 pioasm 指令的角度來逐一解釋。

state machine 的內容如下:

  • 32-bit In Shift Register(ISR)和 Out Shift Register(OSR)
  • 32-bit X/Y Scratch Register
  • Program Counter(PC) — — 指向 pioasm 程式位置的計數器
  • Clock Divider — — 常數,state machine 的執行時脈即為處理器時脈除以此常數

Clock Divider 的值範圍是 1~65536,增減最小單位為 1/256。如果 Pico 使用預設的 125 MHz 時脈,那麼 state machine 能執行的最低時脈就是 125000000 / 65536 = 1908 Hz。

Photo by Adolfo Félix on Unsplash
來源:https://www.seeedstudio.com/blog/2021/01/25/programmable-io-with-raspberry-pi-pico/

state machine 跟外界的聯絡管道則是兩個 FIFO bus(4 x 32 bits),分別用於跟外界接收或傳送資料。不過上面這個圖寫錯了,從外界輸入的資料會放在 OSR,而要外傳的資料則得放進 ISR。X 與 Y 則是讓使用者利用的暫存器,有點像變數儲存區。

透過這種架構,並用 pioasm 來讀寫這些暫存器的資料,state machine 就可以視情況用 1 或 0 設定或讀取腳位的高低電位,達到使用者想要的最終目的。

pioasm 有 9 個指令,它們分別是:

  • in
  • out
  • push
  • pull
  • mov
  • irq
  • set
  • wait
  • jmp

看起來指令很少,但要怎麼組合和創造出複雜的行為,就大有學問了。

本篇不會所有指令都解釋,這些在網路上的 Pio 官方文件都有。我的目的是以比較容易理解的方式循序漸進講解 PIO 基礎。

Photo by Mikael Kristenson on Unsplash

以 PIO 控制 LED 閃爍

首先來看官方範例(微幅修改過):

import rp2
from machine import Pin
@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)
def blink():
wrap_target()
set(pins, 1) [31]
nop() [31]
nop() [31]
nop() [31]
nop() [31]
set(pins, 0) [31]
nop() [31]
nop() [31]
nop() [31]
nop() [31]
wrap()
sm = rp2.StateMachine(0, blink, freq=2000, set_base=Pin(25))
sm.active(1)

這支程式會讓 Pico 內建 LED(GPIO 25)不斷一亮一滅,而且在主程式結束後依舊有作用,直到你重設板子的程式為止。

這裡分為兩部分:首先定義一個函式,並加上 rp2.asm_pio() 裝飾器

@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)

裝飾器可以替函式套上額外的功能,有些 Python 書會講解,但這裡就不多解釋。這個函式的內容就是我們要撰寫的 pioasm 程式;MicroPython 將它包裝成了 Python 函式的形式,但寫法和你在 C++ SDK 看到的原始 pioasm 檔是大同小異的。裝飾器有一個參數 set_init,這個稍後再來討論。

接著是用 rp2.StateMachine 建立一個狀態機物件,並輸入一些基本參數:

sm = rp2.StateMachine(0, blink, freq=2000, set_base=Pin(25))
  • 第一個數字 id 代表 state machine 編號(0~7)
  • 接著是包含 pioasm 程式的 Python 函式
  • 接著是一系列參數,取決於你的需要:這裡只有設定 state machine 的執行速度(2000 Hz)以及 set 指令的 base pin 或起始腳位(GPIO 25)。

這篇我們反正只會使用一個 state machine,坦白說我還沒試過使用多個狀態機的情境。

最後主程式得啟用這個 state machine,使它的 pioasm 程式發揮作用:

sm.active(1)

接著我們來看看 pioasm 程式碼的內容。

Photo by Annie Spratt on Unsplash

wrap

首先,pioasm 程式的頭尾分別是

wrap_target()
...
wrap() # 跳回 wrap_target()

當 pioasm 執行到 wrap 時,它會自動跳回 wrap_target 的位置,變成一個無窮迴圈。當然這並不是唯一的迴圈寫法;你也可以設定一個 label 和使用 jmp 指令:

label('loop')
...
jmp('loop') # 跳回 'loop'

這麼做的差別在於,jmp(jump)指令執行時需要用到 1 cycle 處理時間,而 wrap 不會花費任何時間。當然 jmp 也可以依條件決定是否要跳躍到特定 label,後面我們會再看到它。

由於上面設定 state machine 以 2000 Hz 執行,因此 1 cycle 的時間就是 1/2000 = 0.0005 秒或 0.5 ms(毫秒)。

set

set 用來設定腳位的電位:

set(pins, 1)  # 高電位
set(pins, 0) # 低電位

pins 是 pioasm 會自動解讀的一個名稱,代表 set 指令要寫入的腳位,這我們會透過 rp2.StateMachine() 的 set_base 參數來指定,也就是 GPIO 25。其實 set 可以同時設定不只一個腳位 — — 這就是為何 pins 是複數 — — 但之後我們會再來看這部分。

順便注意在 asm_pio 裝飾器有一個參數如下:

set_init=rp2.PIO.OUT_LOW

這意思是由 set_base 參數指定的腳位,其初始狀態會設為低電位。

nop 與 delay

上面的程式中有好幾行 nop(),這是 no operation 的意思。它其實代表以下這個指令:

mov(y, y)

mov(move)指令會把一個來源的資料搬到另一個地方,而既然來源跟目的地一樣都是 Y 暫存器,那就什麼事也不會發生了。換言之,就是多延遲 1 cycle 的意思。

但注意到所有的 set 和 nop 後面都有這個數字:

set(pins, 1)   [31]
nop() [31]

這個數字是 delay,也就是要進一步延遲的 cycle 數量。這個中括號跟前面的指令隔多少空白其實無所謂,只是為了維持 pioasm 的語法而寫成如此。

在 Python 中,中括號是字串、list 和 dict 等容器取值時的語法,但在這裡 MicroPython 很巧妙地拿它重現了 pioasm 的指令風格。當然話說回來,asm_pio 裝飾器會改寫函式的特性,所以似乎也沒辦法拿來放正規的 Python 程式了。

Photo by Erik Mclean on Unsplash

[31] 表示額外延遲 31 cycles,而指令本身占 1 cycle。因此,前面程式中除了 wrap_target/wrap 以外,每一行 set 和 nop 的執行時間都是 32 cycles。整個程式重複一輪的總時間是 32 x 10 = 320 cycles,而 320 x 0.5 毫秒 = 160 毫秒。

由於程式中平均分成兩部分,也就是 LED 的開和關,所以 LED 會每 80 毫秒亮或滅一次:

wrap_target()# LED 設為高電位並等待 80 毫秒
set(pins, 1) [31]
nop() [31]
nop() [31]
nop() [31]
nop() [31]
# LED 設為低電位並等待 80 毫秒
set(pins, 0) [31]
nop() [31]
nop() [31]
nop() [31]
nop() [31]
# 重複 pioasm 程式
wrap()

這樣就相當於透過 MicroPython 本身寫以下的程式:

from machine import Pin
import time
led = Pin(25, Pin.OUT)while True:
led.value(1) # led.on()
time.sleep(0.08)
led.value(0) # led.off()
time.sleep(0.08)

80 毫秒蠻短的,但只要增加等待的 cycle 數量,或者進一步降低 RP2040 的時脈,就能拉長這個時間了。

只是 pioasm 能寫的指令有限,理論上最多是 32 個指令,而筆者試過把上面的 nop 增加、到總共 28 行就是極限了(超過這數字會得到 OSError: [Errno 12] ENOMEM 例外)。因此在不變更時脈的前提下,LED 亮和滅的最大長度就只有 32 x 14 x 0.5 = 224 毫秒。有沒有辦法讓這個時間更長呢?

Photo by Nathan Dumlao on Unsplash

用計數器來延時的 LED 閃燈

在下面的第二支程式,我使用 X 暫存器來當成計數器。就我所能找到的許多範例,以及 pioasm 本身的設計方式,X 與 Y 的主要用途之一正是作為計數器使用。

import rp2
from machine import Pin
@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)
def blink():
wrap_target()

set(pins, 1)
set(x, 24) [23]
label('high_loop')
nop() [19]
nop() [19]
nop() [19]
jmp(x_dec, 'high_loop') [18]

set(pins, 0)
set(x, 24) [23]
label('low_loop')
nop() [19]
nop() [19]
nop() [19]
jmp(x_dec, 'low_loop') [18]

wrap()
sm = rp2.StateMachine(0, blink, freq=2000, set_base=Pin(25))
sm.active(1)

程式中多了兩個子迴圈,分別用來控制 LED 高電位和低電位的延遲時間,而亮與滅的時間都會是 1 秒。

label/jmp 迴圈

為了能達到 1 秒的延遲時間,我們需要延遲 1000 / 0.5 = 2000 cycles。為了計算方便,我們可以用 80 x 25 cycles,也就是讓迴圈重複 25 次。我們可以借用 X 暫存區來做「倒數」:

set(x, 24)  # 在 X 寫入 24
label('high_loop') # 設定 label
# ... 等待 80 cyclesjmp(x_dec, 'high_loop') # X 遞減 1, 若仍 > 0 就跳回 high_loop

由於 set 能寫入的資料最多是 5 bits,所以它能寫給 X 或 Y 暫存器的最大數字是 31。

這裡的 jmp 指令相當於以下的意思:

x -= 1
if x > 0 then goto 'high_loop'

x_dec 會讓 X 內的值遞減 1,然後 jmp 會判斷它是否等同於 true(值為 1 以上)。因此若 X 仍然大於 0,pioasm 程式就會跳回標籤 high_loop 的位置,實現迴圈的效果。等到 X 在最後一次減到 0(相當於 false)時,跳躍條件就不再成立,使它繼續執行下面的程式。

Photo by Kelly Sikkema on Unsplash

對 X、Y 的操作

X 和 Y 是暫存器而不是變數,而在使用 jmp 時,pioasm 只提供以下幾種判斷和操作:

  • x_dec/y_dec:即 X-- 或 Y--,把 X 或 Y 的值遞減 1,並判斷是否 > 0
  • not_x/not_y:即 !X 或 !Y,判斷 X 或 Y 的值是否 == 0
  • x_not_y:即 X != Y,判斷 X 與 Y 的值是否不同

其他還有一些別的條件,像是判斷輸入腳位是否為高電位、OSR 暫存器是否不為空,但在此先不討論。

cycle 時間的控制

所以,理論上你只要在子迴圈內延遲 80 cycles,這樣重複 25 次(X 從 24 減到 0)就等於 1 秒了:

set(x, 24)
label('high_loop')
nop() [19] # 20 cycles
nop() [19] # 20 cycles
nop() [19] # 20 cycles
nop() [19] # 20 cycles
jmp(x_dec, 'high_loop')

不過這兒有個問題:set 和 jmp 本身執行都會占 1 cycle,更別提我們還要設定腳位。此外,就和我們在第一支程式看到的那樣,任何 pioasm 指令都可以設額外的 delay。因此最後的改寫如下:

set(pins, 1)                    # 1 cycle
set(x, 24) [23] # 24 cycles
label('high_loop')
nop() [19] # 20 cycles
nop() [19] # 20 cycles
nop() [19] # 20 cycles
jmp(x_dec, 'high_loop') [18] # 19 cycles

我們把子迴圈中每一次重複的時間扣掉 1 cycle,再把這 25 cycles 補到開頭。開頭的兩個 set 各需 1 cycle,所以第二個 set 後面就補上 23 cycles。於是 (1 + 24) + (79 x 25) = 2000 cycles = 1000 毫秒。

接著只要對 LED 設低電位和重複一樣的事,並用 wrap 重複以上兩段過程,就能讓 LED 以一秒的間隔閃滅了。

Photo by Jacek Dylag on Unsplash

一次控制多個腳位:步進馬達

前面我們說過,set 其實可以設定多個腳位的電位。我們來看看 set 指令在 RP2040 datasheet 內的規格:

pioasm 的指令都是 16-bit,最高的三位代表指令本身,然後是 5-bit 的 delay/side-set(後面會來看什麼是 side-set)。接著的三位代表寫入的目的地(pins、x 或 y),最後則是資料。資料有 5-bit,也就是說每一位數可以對應一個腳位的電位 — — 至多 5 個 GPIO。

步進馬達的控制

步進馬達(stepping motor)是一種特殊馬達,它的內部周圍有一圈電磁鐵。只要輪流對這些電磁鐵通電,馬達就會穩定地轉動,雖然不快但轉動幅度非常精確。

來源:http://masters.donntu.org/2016/etf/malakhov/diss/indexe.htm

以常見且普通的 28BYJ-48 步進馬達搭配 ULN2003A/ULN2004 控制板為例,它有 IN1~IN4 或 A~D 四個腳位,你得照特定的順序拉高其電位,才能讓馬達轉動:

來源:https://medium.com/jungletronics/stepper-motors-precise-position-control-4b7b079ca9a4

上面有三種控制模式,最簡單的是 wave drive,也就是輪流拉高其中一個腳位的電位、讓其它的降為 0。用 MicroPython 寫的話如下:

from machine import Pin
import time
pins = (Pin(2, Pin.OUT), # 使用 GPIO 2~5
Pin(3, Pin.OUT),
Pin(4, Pin.OUT),
Pin(5, Pin.OUT))
while True:
for step in range(4): # step 0~3
for index, pin in enumerate(pins):
# 把索引符合目前 step 值的腳位電位拉高, 其它拉低
pin.value(index == step)
time.sleep_ms(4) # 等待 4 ms

接線方式如下:

電源 → 3V3,IN1~4 → GPIO 2~5。雖說 28BYJ-48 理論上是 5V 馬達,但接 3.3V 也能作用。

使用 pioasm 控制步進馬達

在前面建立 state machine 時,我們用 set_base 參數來指定 set 指令要控制的腳位:

set_base=Pin(25)

事實上這個是 base pin,也就是一系列當中腳位的第一個。如果我指定 base pin 為 GPIO 2,那麼另外可控制的腳位就會是 GPIO 3~6。

來看以下程式碼:

import rp2
from machine import Pin
@rp2.asm_pio(set_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW,
rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW)
)
def step_motor():
wrap_target()
set(pins, 0b0001) [7]
set(pins, 0b0010) [7]
set(pins, 0b0100) [7]
set(pins, 0b1000) [7]
wrap()
sm = rp2.StateMachine(0, step_motor, freq=2000, set_base=Pin(2))
sm.active(1)

注意到現在 rp2.asm_pio 裝飾器中的 set_init 參數,後面變成一個有四個項目的 tuple,此舉便是在告訴 pioasm 說 set pin 共有四個,而且初始化電位為何。

而在程式中,set 後面寫入的是個 4-bit 二進位值,每一位數會對應到一個腳位(最低位是 base pin,往左類推):

 P5   P4   P3   P2 (base) 0    0    0    1    (= 1)
in4 in3 in2 in1
0 0 1 0 (= 2)
in4 in3 in2 in1
0 1 0 0 (= 4)
in4 in3 in2 in1
1 0 0 0 (= 8)
in4 in3 in2 in1

所以你也可以在 set 填入十進位的 1、2、4、8 或 16 進位的 0x1、0x2…,效果是一樣的。

至於上面的 pioasm 程式,每一個 step 的執行時間是 1 + 7 = 8 cycles(4 ms),但你會發現步進馬達轉動的速度居然比前面的 MicroPython 版快。這點就能說明,儘管 pioasm 比 MicroPython 難寫,但擺脫了主處理器執行 MicroPython 的包袱後,腳位的控制效率就更好,也不會卡死主程式。

其實,筆者測試可以把每一 step 的時間壓到 6 cycle(3 ms),但實際上能到多低,大概還是取決於各位使用的馬達。

Photo by Markus Winkler on Unsplash

在 pioasm 根據使用者傳入的資料來控制腳位

在前面的範例中,我們都是直接在 pioasm 中對腳位設定電位,但如果你希望根據使用者的命令來開關 LED 或控制腳位呢?

最偷懶的方法,是在需要的時候才執行特定的 pioasm 指令:

import rp2, time
from machine import Pin
@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)
def blink():
pass
sm = rp2.StateMachine(0, blink, freq=2000, set_base=Pin(25))
sm.active(1)
while True:
sm.exec('set(pins, 1)')
time.sleep(1)
sm.exec('set(pins, 0)')
time.sleep(1)

state machine 物件的 exec() 方法,能用字串的方式直接執行 pioasm 指令,因此 blink() 函式內不必寫任何東西。不過對主程式來說,算不上是優雅的操作介面。

使用 OSR 暫存器

在下面第二個版本,我們會先將資料(1 或 0)寫入 state machine,後者則會根據它來設定腳位。先看程式:

import rp2, time
from machine import Pin
@rp2.asm_pio(out_init=rp2.PIO.OUT_LOW,
out_shiftdir=rp2.PIO.SHIFT_RIGHT
)
def blink():
wrap_target()
pull()
out(pins, 1)
out(null, 31)
wrap()
sm = rp2.StateMachine(0, blink, out_base=Pin(25))
sm.active(1)
while True:
sm.put(1)
time.sleep(1)
sm.put(0)
time.sleep(1)

這個 blink() 函式的 pioasm 程式會等待使用者輸入 1 或 0,然後以這個值來點亮或關閉內建 LED 燈。

注意到這次 state machine 沒有設定時脈,因為這次它跑 1 cycle 要多久已經無所謂了。預設時脈會沿用 RP2040 的時脈。

pull 和 out

pull 指令會從 FIFO 讀取 32-bit 資料並存入 OSR,如果沒有資料就會等待(stalling)。

OSR 有了資料後,就可以用 out 指令取出來:

out(pins, 1)  # 從 OSR 取出 1 bit 和寫入 out pins

我們稍後會再解釋什麼是 out pins。問題就在於,我們只需要 1 個 bit 來設定一個腳位,可是 put 會一口氣讀滿 32-bit。因此在拿到第一個 bit 後,我們得多讀 31 bits 和把它們丟掉:

out(null, 31)

在這裡 null 就像 pins、X、Y 等等一樣是個寫入的目的地,但在此的實際意義就是捨棄資料。

OSR 是個位移暫存器,也就是資料會依序往最高或最低位移動。因此 rp2.asm_pio 裝飾器也有一個參數 out_shiftdir,讓你指定 OSR 的位移方向:

out_shiftdir=rp2.PIO.SHIFT_RIGHT

我們只需要 1 bit,而它會在最低位(最右位),所以要右移使它最先被讀到。後面的更高位數因為用不到,我們就直接扔了。

Photo by Drew Farwell on Unsplash

將資料寫入 out pins

現在拿到資料之後,要怎麼寫入到腳位呢?pioasm 不像 Python,set 指令可沒辦法從其他地方讀取資料。還好 out 也可以把資料寫入 pins,但這組腳位跟前面的 set pin 就不同了。

在建立 state machine 時,out pin 的起點得用 out_base 參數指定:

out_base=Pin(25)

而在 rp2.asm_pio 裝飾器中,out 腳位的預設電位則由 out_int 參數設定:

out_init=rp2.PIO.OUT_LOW

最後在主程式的 while 迴圈中,會用 state machine 的 put() 方法把資料寫入 FIFO:

sm.put(1)
sm.put(0)

如此一來,pioasm 就會等待使用者輸入資料,有資料進來就設定 LED 電位、然後重新等待:

使用者
↓ ← sm.put()
FIFO
↓ ← pull()
OSR
↓ ← out()
pins
(重複以上流程)
Photo by David Ballew on Unsplash

autopull 和 pull_thresh

要手動從 FIFO 拉資料到 OSR、再從 OSR 寫入目的地,有時感覺有點繁瑣。因此 pioasm 設計了叫做 autopull 的機制,可以在你使用 out 指令時就自動從 FIFO 讀資料到 OSR

請看以下程式:

import rp2, time
from machine import Pin
@rp2.asm_pio(out_init=rp2.PIO.OUT_LOW,
out_shiftdir=rp2.PIO.SHIFT_RIGHT,
autopull=True)
def blink():
wrap_target()
out(pins, 1)
out(null, 31)

wrap()
sm = rp2.StateMachine(0, blink, out_base=Pin(25))
sm.active(1)
while True:
sm.put(1)
time.sleep(1)
sm.put(0)
time.sleep(1)

注意到我們這次只是在 rp2.asm_pio 裝飾器把參數 autopull 設為 True,pioasm 程式內的 pull 就不再需要了。這是因為現在 state machine 會自動從 FIFO 讀滿 32-bit 資料放進 OSR,等到你用 out 全部取完後就再讀一次。最棒的一點是,autopull 是不占 cycle 時間的,這動作會由硬體來完成。

不過,有時我們並不需要一次讀那麼多資料,比如這裡我們其實每次只想要 1 bit 而已。那麼,我們可以給 autopull 設一個門檻,讓它知道你每次讀了多少資料後,就要從 FIFO 拉新資料進來:

import rp2, time
from machine import Pin
@rp2.asm_pio(out_init=rp2.PIO.OUT_LOW,
out_shiftdir=rp2.PIO.SHIFT_RIGHT,
autopull=True, pull_thresh=1)
def blink():
wrap_target()
out(pins, 1)
wrap()
sm = rp2.StateMachine(0, blink, out_base=Pin(25))
sm.active(1)
while True:
sm.put(1)
time.sleep(1)
sm.put(0)
time.sleep(1)

rp2.asm_pi o裝飾器的參數 pull_thresh 用來設定 autopull 門檻,預設是 32。這裡我們將它設為 1,表示 out 每次從 OSR 取出 1 bit,state machine 就會自動「補充」資料到 OSR 暫存器,直到 FIFO 沒有資料才會停下來等待。這下子我們每次只要在有資料時取一位元即可,不必再浪費力氣跟時間取額外的空資料了。

Photo by Vitolda Klein on Unsplash

side-set

這裡也要順便來提一個概念叫做 side-set,這對於本文最後一段要討論的東西很重要。

前面我們看到 set 指令分了 5-bit 來表示額外的延遲時間,但這五位元也能用於所謂的 side-set。side-set 其實代表另一組腳位,你在必要時可以從這五位元「偷」一部分來控制至多 5 個腳位。但為什麼要這樣呢?

對諸如 I2C、SPI 這類通訊協定來說,當中的時脈腳位(SCL 或 SCK)會不斷在 1 與 0 之間震盪,而資料腳位(SDA,DIN 或 MOSI 等)會在時脈腳位低電位時改變、並在高電位時維持固定,好讓接收方讀取資料腳位的電位,判讀該 bit 是 1 或 0。

來源:https://www.analog.com/en/technical-articles/i2c-primer-what-is-i2c-part-1.html#

若以 I2C 為例,當 SDA 從使用者提供的資料讀出 1 位元和設定其電位時,SCL 就要同時拉低,不然可能會造成判讀上的混亂。若你分開設定 SCL 與 SDA 的電位,就至少會有 1 cycle 的時間落差:

# 事先指定 base pin = SCL, out pin = SDAlabel('loop')
set(pins, 0) # SCL → 0
out(pins, 1) # 設定 SDA
set(pins, 1) # SCL → 1
jmp('loop') # 跳回開頭

上面其實多浪費了 2 cycle,因為還得用 jmp 跳回前面繼續讀資料。I2C 協定還有開始跟結束階段要應付,因此不能只用 wrap 當作迴圈。

這時 side-set 就派上用場了:

# 事先指定 side-set base pin = SCL, out pin = SDAlabel('loop')
out(pins, 1) .side(0) # 設定 SDA 並將 SCL 設為 0
jmp('loop') .side(1) # 跳回開頭並將 SCL 設為 1

pioasm 的每個指令都有 delay/side-set。當你設定了 delay 時,它會用正常的方式儲存(從最低位)。然而,side-set 的儲存順序是從這 5-bit 的最高位開始,而且這會影響到所有指令。不管你有沒有在指令後面呼叫 .side(),所有可用的 delay 值就會被吃掉幾個位元了。

我們來拿前面的步進馬達程式當例子,雖然這是個不太有效的例子。假設我們想在控制馬達的同時,讓內建 LED 燈同步閃動,但又不能影響馬達每一步的時間,我們可以把 LED 腳位設為 side-set 的 base pin:

import rp2
from machine import Pin, freq
@rp2.asm_pio(set_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW,
rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW),
sideset_init=rp2.PIO.OUT_LOW)
def step_motor():
wrap_target()

set(pins, 0b0001) .side(1) [7]
set(pins, 0b0011) [7]
set(pins, 0b0010) [7]
set(pins, 0b0110) [7]
set(pins, 0b0100) .side(0) [7]
set(pins, 0b1100) [7]
set(pins, 0b1000) [7]
set(pins, 0b1001) [7]

sm = rp2.StateMachine(0, step_motor, freq=2000,
set_base=Pin(2), sideset_base=Pin(25))
sm.active(1)

這次是步進馬達的 half step 版本,所以會有 8 步。LED 會在第一步點亮,並在第 5 步關閉。當然實際執行時由於時間太短,你幾乎看不出來 LED 在閃動。不過這仍可以展示,side-set 腳位不需要跟 set pin 連號,而且可以在你做某些事情時同步控制。

有趣的是,這支程式裡能用的最大 delay 值是 7 — — 理論上 side-set 只用掉最高一位數,所以 4 bits 最大應該是 15 吧?我個人尚不清楚這是 PIO 的設計還是什麼問題,但總之使用了一個 side-set pin 後,整個 pioasm 的 delay 就只能設到 7 了。

Photo by Julian Hochgesang on Unsplash

最後,一個真實範例:WS2812

在 RP2040 datasheet、C++/MicroPython SDK 講到 PIO 時,一開始都拿了 WS2812 當作範例,但初學者若沒有經過上面的介紹,是不可能當場看得懂的。

WS2812(以及它的近親 WS2811、WS2812B、SK6812)是一種很受歡迎的 RGB LED 控制器,只要一個腳位就能控制大量的彩燈,而且亮度驚人,非常適合拿來做創客專案。美國 Adafruit 公司給它取了個好聽的名字叫 NeoPixel,在電子商店不難買到不同形狀的燈條。

來源:不明

先來看程式:

import rp2
from machine import Pin
@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW,
out_shiftdir=rp2.PIO.SHIFT_LEFT,
autopull=True, pull_thresh=24
)
def ws2812():
wrap_target()

label('bitloop')

out(x, 1) .side(0) [2]
jmp(not_x, 'do_zero') .side(1) [2]
jmp('bitloop') [3]

label('do_zero')
nop() .side(0) [3]

wrap()


sm = rp2.StateMachine(0, ws2812, freq=8000000, sideset_base=Pin(28))
sm.active(1)

接線方式如下:

WS2812 只需 3.3V 即可驅動,但能點亮的燈數量取決於供電。Din 腳位接 GPIO 28。

WS2812 的通訊協定

WS2812 的資料格式是 24-bit,每 8 bits 代表一個顏色(包含亮度),順序是綠、紅、藍,從最高位往最低位讀。每讀完 24 bits 後,下一組 24 bits 資料就會被傳給燈條的下一個 LED。這麼一來,若燈條內有 N 個燈,傳送 N 組顏色資料就能通通設定好它們。

     綠         紅          藍
| 76543210 | 76543210 | 76543210 |
24 16 8 位元

但 WS2812 的通訊協定很有趣:它不是以電位高低來代表 0 或 1,而是用時間長短。若高電位的時間較短就是 0,較長則是 1。

來源:https://mcuoneclipse.com/2016/05/22/nxp-flexio-generator-for-the-ws2812b-led-stripe-protocol/

WS2812 的通訊頻率是 800 kHz,而我們要把這種速度下 1 cycle 切成 10 等分,所以 state machine 的通訊速度設為 8 MHz(8000000 Hz)。這使 1 cycle 變成 0.125 us(微秒)。我們只要根據通訊中的三個階段加上適當的延遲即可:

  1. 首先高電位 3 cycle。(T1 = 0.375 us)
  2. 根據要傳送的位元調整電位(1 = 高電位,0 = 低電位),並維持 4 cycle。(T2 = 0.5 us)
  3. 最後拉低電位 3 cycle。(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 = 高 低 低

用 pioasm 控制 WS2812

現在來看 pioasm 程式的核心部分:

out(x, 1)               .side(0)   [2]  # T3, 3 - 1
jmp(not_x, 'do_zero') .side(1) [2] # T1, 3 - 1
jmp('bitloop') [3] # T2, 4 - 1 (值 = 1)

label('do_zero')
nop() .side(0) [3] # T2, 4 - 1 (值 = 0)

rp2.asm_pio 裝飾器已經設定啟用 autopull,且門檻是 24-bit,因此這裡只要用 out 取資料即可。

程式執行過程如下:

  1. 第一行用 out 從 OSR 取出 1 bit 和寫到 X 暫存器。注意在這行指令執行時,side-set base pin(即 WS2812 的 DIN 腳位)被設為低電位。這是因為這一行代表的是 T3 階段。我們等一下再回過來看這部分。
  2. 第二行 jmp 會判斷 X 暫存器的值是不是 0,是的話就跳到標籤 do_zero。jmp 的 delay 會在做完條件判斷後執行,所以總共會花 2 + 1 cycles(T1 階段)。
  3. 如果 X 的值是 1,pioasm 會執行第三行,這一行沒有改變 side-set 的電位,所以繼續維持高電位(3 + 1 cycles),並跳回 bitloop 標籤(T2 階段)。
  4. 反之若 X 的值是 0,則會執行 nop(),並將 side-set 設為低電位,時間也是 3 + 1 cycles,最後透過 wrap() 回到開頭,這是另一個 T2 階段。由上可見,pioasm 藉由 X 值來決定 T2 階段的電位高低。
要傳送位元 = 1 時:out(x, 1)

jmp(not_x, 'do_zero') # x = 1

jmp('bitloop') # 回到開頭
---------------要傳送位元 = 0 時:out(x, 1)

jmp(not_x, 'do_zero') # x = 0

nop()

wrap() # 回到開頭

現在,pioasm 程式回到開頭,用 out 取下一位元,並將 side-set 設為低電位,而它的執行時間(2 + 1 cycles)就會成為上一個 LED 的 T3 階段。而若已經寫完最後一個燈的資料、OSR 沒有資料了,out 指令的 delay 仍然會發揮作用,完成最終的 T3 階段。

坦白說我對 WS2812 協定的運作方式理解有限,所以我不知道是不是能用其他方式寫 pioasm(筆者有試一下照 T1、T2 和 T3 順序的寫法,但沒完全成功)。但可以想見,官方會選擇把這個簡短漂亮的寫法當成文件的第一範例,大概也不是沒有原因吧。

Photo by Tyler Lastovich on Unsplash

進一步簡化 WS2812 的 pioasm 程式

當然官方文件還是引起了一些困惑,特別是它的原始程式是長這樣的:

@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_LEFT, autopull=True, pull_thresh=24)
def ws2812():
T1 = 2
T2 = 5
T3 = 3
wrap_target()
label("bitloop")
out(x, 1) .side(0) [T3 - 1]
jmp(not_x, "do_zero") .side(1) [T1 - 1]
jmp("bitloop") .side(1) [T2 - 1]
label("do_zero")
nop() .side(0) [T2 - 1]
wrap()

為什麼官方的 T1、T2 和 T3 是 2:5:3 呢?根據 Github 上的討論,這個數字似乎是從一個叫 FastLED 的 Arduino 函式庫借過來的。問題就在於幾種類似產品的 datasheet 記載的通訊時間稍有出入。

有人指出 T1、T2 和 T3 其實也可以照等比例分配,這也可以被 WS2812 跟幾種近親接受。下面的程式便把 state machine 時脈改成 2.4 MHz,這使得單一 cycle 的時間變成 0.416 us(0.416 x 3 = 1.248 us):

def ws2812():
wrap_target()

label('bitloop')

out(x, 1) .side(0) # T3
jmp(not_x, 'do_zero') .side(1) # T1
jmp('bitloop') # T2 (值 = 1)

label('do_zero')
nop() .side(0) # T2 (值 = 0)

wrap()

sm = rp2.StateMachine(0, ws2812, freq=2400000, sideset_base=Pin(28))
sm.active(1)

顯然這種高低電位為 1/3 : 2/3 的比例也是能作用的,而且就不需要再設額外 delay 了。

Photo by Arno Smit on Unsplash

傳送顏色資料給 WS2812

最後,我們要來看如何把資料傳給上面的 WS2812 pioasm 程式。pioasm 只負責讀取把每一位元的資料寫給 WS2812,所以準備資料的責任還是落在使用者身上。

在國外的許多 NeoPixel 驅動程式中,每個 LED 的顏色會如下表示:

(r, g, b)

r、g、b 值都是 0~255:

(255, 255, 0) → 黃色, 最大亮度
(0, 64, 64) → 青色, 1/4 亮度

而對於 pioasm 來說,你必須把這三個值轉成單一一個 24-bit 值:

value = (g << 16) | (r << 8) | b

只要把這個值用 state machine 的 put 方法寫給它(放進 FIFO)即可。但要注意的是:

  • WS2812 是從最高位往最低位讀。
  • OSR 是 32-bit 大小,而且為了從最高位讀,得設為往左位移。
  • sm.put() 方法實際上會寫入一筆 32-bit 資料到 FIFO(若沒有空位就等待)。

那麼,24 bits 資料頭上缺少的 8 bits 怎麼辦呢?這便是為何 sm.put() 方法有第二個參數,代表它在寫入資料到 FIFO 時會先將它往左位移幾位元:

sm.put(value, 8)  # 寫入 FIFO 時左移 8 位元 (等於 value << 8)

因此實際傳入 FIFO 的位元組會變成「靠左對齊」:

[FIFO]     綠         紅          藍
| 76543210 | 76543210 | 76543210 | |
32 24 16 8 位元

這是我猜測的;官方文件並沒有講得很清楚。

既然 pioasm 的自動讀取門檻是 24-bit,每次 out 讀完綠紅藍三色的資料後,state machine 會搬下一組資料進來。但你不能自己先把資料往左位移變成 32 bit,這樣 WS2812 解讀上會產生錯誤。

於是我們就能像下面這樣寫入資料:

colors = [
(64, 64, 64), # LED 1
(64, 0, 0), # LED 2
(64, 64, 0), # LED 3
(0, 64, 0), # LED 4
(0, 64, 64), # LED 5
(0, 0, 64), # LED 6
(64, 0, 64), # LED 7
]
for r, g, b in colors:
value = (g << 16) | (r << 8) | b
sm.put(value, 8)
Photo by Michał Parzuchowski on Unsplash

使用 array.array

不過官方範例中並不是用迴圈來連續寫入資料到 state machine,而是用 array.array。這是 Python 的陣列模組,能用類似 C/C++ 的方式儲存資料:

import arraycolors = [
(64, 64, 64),
(64, 0, 0),
(64, 64, 0),
(0, 64, 0),
(0, 64, 64),
(0, 0, 64),
(64, 0, 64),
]
values = array.array('I', [(g << 16) | (r << 8) | b for r, g, b in colors])sm.put(values, 8)

MicroPython 一直以來都有 array 模組,這大概是它少數派上用場的時候。array.array() 的第一個參數代表每個元素的數值型別,「I」意即 unsigned int(4 bytes),剛好能拿來裝每個顏色值(3 bytes)。sm.put() 會將這個陣列的值逐一寫入 FIFO,直到它讀完為止。對 sm.put() 傳入 list 是不被接受的。

所以一般在驅動程式中,會用一個獨立的 list 記錄每個燈的顏色和便於使用者修改,然後等到需要時才讓所有顏色生效。

寫入資料給一個 LED 的時間是 0.416 x 3 x 8 = 29.952 us 或差不多 0.00003 秒,這使得只要供電足夠(一個 WS2812 LED 可以吃到至多 50 mA 電),要很快速的控制幾百顆燈是非常輕鬆的。而 PIO 的存在便能將這些運算從主處理器分擔出來,並確保腳位控制的精確性。

如果這篇文章有下篇的話,則會談到輸入跟中斷腳位,並會來看網路上有人使用 PIO 實做的 DHT11/DHT22 驅動程式。

Photo by Etienne Girardet on Unsplash

參考資料

--

--