在這個系列中,我們要來看看 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 概念,敬請見諒。
RP2040 的 PIO、state machine 與 pioasm
RP2040 有兩個 PIO,每一個能儲存 32 道 pioasm 指令,而且能分成四個 state machine(狀態機)。下面是一個 PIO 的內容:
這些 state machine 共用同一個 PIO 的指令,但能彼此獨立運作、甚至能用不同的速度運行,它們也是我們操作 PIO 時的主要使用對象。下面是一個 state machine 的內容:
看起來好像很複雜吧?下面我會試著從 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。
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 基礎。
以 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 程式碼的內容。
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 程式了。
[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 timeled = 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 毫秒。有沒有辦法讓這個時間更長呢?
用計數器來延時的 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)時,跳躍條件就不再成立,使它繼續執行下面的程式。
對 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 以一秒的間隔閃滅了。
一次控制多個腳位:步進馬達
前面我們說過,set 其實可以設定多個腳位的電位。我們來看看 set 指令在 RP2040 datasheet 內的規格:
pioasm 的指令都是 16-bit,最高的三位代表指令本身,然後是 5-bit 的 delay/side-set(後面會來看什麼是 side-set)。接著的三位代表寫入的目的地(pins、x 或 y),最後則是資料。資料有 5-bit,也就是說每一位數可以對應一個腳位的電位 — — 至多 5 個 GPIO。
步進馬達的控制
步進馬達(stepping motor)是一種特殊馬達,它的內部周圍有一圈電磁鐵。只要輪流對這些電磁鐵通電,馬達就會穩定地轉動,雖然不快但轉動幅度非常精確。
以常見且普通的 28BYJ-48 步進馬達搭配 ULN2003A/ULN2004 控制板為例,它有 IN1~IN4 或 A~D 四個腳位,你得照特定的順序拉高其電位,才能讓馬達轉動:
上面有三種控制模式,最簡單的是 wave drive,也就是輪流拉高其中一個腳位的電位、讓其它的降為 0。用 MicroPython 寫的話如下:
from machine import Pin
import timepins = (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
接線方式如下:
使用 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),但實際上能到多低,大概還是取決於各位使用的馬達。
在 pioasm 根據使用者傳入的資料來控制腳位
在前面的範例中,我們都是直接在 pioasm 中對腳位設定電位,但如果你希望根據使用者的命令來開關 LED 或控制腳位呢?
最偷懶的方法,是在需要的時候才執行特定的 pioasm 指令:
import rp2, time
from machine import Pin@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW)
def blink():
passsm = 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,而它會在最低位(最右位),所以要右移使它最先被讀到。後面的更高位數因為用不到,我們就直接扔了。
將資料寫入 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(重複以上流程)
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 沒有資料才會停下來等待。這下子我們每次只要在有資料時取一位元即可,不必再浪費力氣跟時間取額外的空資料了。
side-set
這裡也要順便來提一個概念叫做 side-set,這對於本文最後一段要討論的東西很重要。
前面我們看到 set 指令分了 5-bit 來表示額外的延遲時間,但這五位元也能用於所謂的 side-set。side-set 其實代表另一組腳位,你在必要時可以從這五位元「偷」一部分來控制至多 5 個腳位。但為什麼要這樣呢?
對諸如 I2C、SPI 這類通訊協定來說,當中的時脈腳位(SCL 或 SCK)會不斷在 1 與 0 之間震盪,而資料腳位(SDA,DIN 或 MOSI 等)會在時脈腳位低電位時改變、並在高電位時維持固定,好讓接收方讀取資料腳位的電位,判讀該 bit 是 1 或 0。
若以 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 了。
最後,一個真實範例: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 的通訊協定
WS2812 的資料格式是 24-bit,每 8 bits 代表一個顏色(包含亮度),順序是綠、紅、藍,從最高位往最低位讀。每讀完 24 bits 後,下一組 24 bits 資料就會被傳給燈條的下一個 LED。這麼一來,若燈條內有 N 個燈,傳送 N 組顏色資料就能通通設定好它們。
綠 紅 藍
| 76543210 | 76543210 | 76543210 |
24 16 8 位元
但 WS2812 的通訊協定很有趣:它不是以電位高低來代表 0 或 1,而是用時間長短。若高電位的時間較短就是 0,較長則是 1。
WS2812 的通訊頻率是 800 kHz,而我們要把這種速度下 1 cycle 切成 10 等分,所以 state machine 的通訊速度設為 8 MHz(8000000 Hz)。這使 1 cycle 變成 0.125 us(微秒)。我們只要根據通訊中的三個階段加上適當的延遲即可:
- 首先高電位 3 cycle。(T1 = 0.375 us)
- 根據要傳送的位元調整電位(1 = 高電位,0 = 低電位),並維持 4 cycle。(T2 = 0.5 us)
- 最後拉低電位 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 us1 = 高 高 低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 取資料即可。
程式執行過程如下:
- 第一行用 out 從 OSR 取出 1 bit 和寫到 X 暫存器。注意在這行指令執行時,side-set base pin(即 WS2812 的 DIN 腳位)被設為低電位。這是因為這一行代表的是 T3 階段。我們等一下再回過來看這部分。
- 第二行 jmp 會判斷 X 暫存器的值是不是 0,是的話就跳到標籤 do_zero。jmp 的 delay 會在做完條件判斷後執行,所以總共會花 2 + 1 cycles(T1 階段)。
- 如果 X 的值是 1,pioasm 會執行第三行,這一行沒有改變 side-set 的電位,所以繼續維持高電位(3 + 1 cycles),並跳回 bitloop 標籤(T2 階段)。
- 反之若 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 順序的寫法,但沒完全成功)。但可以想見,官方會選擇把這個簡短漂亮的寫法當成文件的第一範例,大概也不是沒有原因吧。
進一步簡化 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 了。
傳送顏色資料給 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)
使用 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 驅動程式。