なぜPIOで通信を自作するのか
- 空きペリフェラル不足を回避。UART/I2Cハードが足りないときに“もう1本”追加できる。
- タイミングを自分で制御。特殊プロトコルや非標準ボーレートにも対応。
- CPU負荷を抑えつつ柔軟。DMA連携でゼロコピーも可能。
- 学べばSPI/WS2812/マンチェスタ/1-Wireなどにも応用可能。
PIO超速概要
- SM(State Machine)×8基(PIO×2ブロック * 各4基)。
- 各SMに命令メモリ32語(各16bit)。短いがDMAとFIFOで拡張可能。
- IN/OUT/MOV/WAIT/SET/JMPなどの小さな命令でビットバンギング。
- FIFOでCPUとデータ交換。CLK分周で信号速度を調整。
準備:Pico SDKでCプロジェクト作成
# 例:ディレクトリ作成
mkdir -p ~/pico/uart_i2c_pio && cd ~/pico/uart_i2c_pio
# CMakeLists.txt と main.c を配置(下記参照)
mkdir build && cd build
cmake ..
make -j
# 書き込み:PicoをBOOTSELでマスストレージ化 → .uf2をドラッグ&ドロップ
CMakeLists.txt(最小)
cmake_minimum_required(VERSION 3.13)
include(pico_sdk_import.cmake)
project(uart_i2c_pio C CXX)
pico_sdk_init()
add_executable(uart_i2c_pio
main.c
uart_tx_rx.pio
i2c_master.pio
)
pico_generate_pio_header(uart_i2c_pio ${CMAKE_CURRENT_LIST_DIR}/uart_tx_rx.pio)
pico_generate_pio_header(uart_i2c_pio ${CMAKE_CURRENT_LIST_DIR}/i2c_master.pio)
target_link_libraries(uart_i2c_pio
pico_stdlib
hardware_pio
hardware_dma
hardware_irq
hardware_clocks
)
pico_enable_stdio_usb(uart_i2c_pio 1)
pico_enable_stdio_uart(uart_i2c_pio 0)
pico_add_extra_outputs(uart_i2c_pio)
まずはUART:TX/RXをPIOで作る
UARTの仕様おさらい
- ここでは8N1(8bit、パリティなし、ストップ1bit)。
- TXは
start(0)
→data LSB first
→stop(1)
。 - RXはアイドル
1
から立下り検出→ビット中心でサンプリング。
uart_tx_rx.pio
(PIOアセンブリ)
.program uart_tx
; Pin mapping: set pindirs appropriately. One output pin: TX
.side_set 1 opt
; OSRに [LSB-firstの8bitデータ] を詰めてpush
; 1 start(0) + 8 data + 1 stop(1) を一定ボーレートで出力
; Xにビット数を入れてループ
; クロックはclkdivで設定
pull ; OSR <- data
set x, 7 ; 8 bits
set pins, 0 side 0 [7] ; start bit=0(side-setは使わないが例示)
txloop:
out pins, 1 [7] ; LSBから1bit出力
jmp x-- txloop
set pins, 1 [7] ; stop bit=1
; 以上で1文字送信完了
.program uart_rx
; Pin mapping: one input pin: RX
; スタートビット立下り検出→0.5bit遅延→以降1bit間隔で8回IN
wait 1 pin ; line idle = 1 を待つ
wait 0 pin ; start bit フォール
set x, 7
; 0.5bit待ち(中心サンプル)。[delay]はclkdivと併せて調整
nop [3] ; 半ビット待機(分周と併用で調整)
rxloop:
in pins, 1 [7] ; 1bit取り込み
jmp x-- rxloop
; stop bit を捨てるために読み取りだけ調整可
push ; ISR -> RX FIFO
備考
[...]
は各命令のディレイスロット。分周(後述)と合わせてボーレート精度を出す。- TXは
out pins,1
でOSRから1bitずつ出す。RXはin pins,1
でISRに1bitずつ貯める。- 実用ではエラー処理(フレーミング、オーバーラン)やパリティ追加も可能。
C側の初期化と送受信(main.c
抜粋)
#include "pico/stdlib.h"
#include "hardware/pio.h"
#include "hardware/clocks.h"
#include "uart_tx_rx.pio.h"
#define UART_TX_PIN 4
#define UART_RX_PIN 5
#define UART_BAUD 115200
static void uart_tx_init(PIO pio, uint sm, uint pin, uint32_t baud) {
uint offset = pio_add_program(pio, &uart_tx_program);
pio_sm_config c = uart_tx_program_get_default_config(offset);
sm_config_set_sideset_pins(&c, pin);
sm_config_set_out_pins(&c, pin, 1);
pio_gpio_init(pio, pin);
pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true);
float div = (float)clock_get_hz(clk_sys) / (baud * 8.0f); // 各命令[7] delayを踏まえた係数
sm_config_set_clkdiv(&c, div);
pio_sm_init(pio, sm, offset, &c);
pio_sm_set_enabled(pio, sm, true);
}
static void uart_rx_init(PIO pio, uint sm, uint pin, uint32_t baud) {
uint offset = pio_add_program(pio, &uart_rx_program);
pio_sm_config c = uart_rx_program_get_default_config(offset);
sm_config_set_in_pins(&c, pin);
pio_gpio_init(pio, pin);
pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, false);
float div = (float)clock_get_hz(clk_sys) / (baud * 8.0f);
sm_config_set_clkdiv(&c, div);
pio_sm_init(pio, sm, offset, &c);
pio_sm_set_enabled(pio, sm, true);
}
static void uart_tx_putc(PIO pio, uint sm, uint8_t c) {
// OSRへLSB-firstで流すために8bitをそのまま
pio_sm_put_blocking(pio, sm, c);
}
static bool uart_rx_getc(PIO pio, uint sm, uint8_t *out) {
if (pio_sm_is_rx_fifo_empty(pio, sm)) return false;
uint32_t v = pio_sm_get(pio, sm);
*out = (uint8_t)(v & 0xFF);
return true;
}
int main() {
stdio_init_all();
sleep_ms(1000);
PIO pio = pio0;
uint sm_tx = 0, sm_rx = 1;
uart_tx_init(pio, sm_tx, UART_TX_PIN, UART_BAUD);
uart_rx_init(pio, sm_rx, UART_RX_PIN, UART_BAUD);
const char *hello = "Hello from PIO UART\r\n";
for (const char *p = hello; *p; ++p) uart_tx_putc(pio, sm_tx, (uint8_t)*p);
while (true) {
uint8_t c;
if (uart_rx_getc(pio, sm_rx, &c)) {
// エコーバック
uart_tx_putc(pio, sm_tx, c);
}
}
}
配線テスト
- TX:Picoの
GPIO4
→USB-シリアルのRXへ。 - RX:Picoの
GPIO5
←USB-シリアルのTXから。 - GND共通。PC側を115200 8N1で開く。受信がそのままエコーされればOK。
次にI2C:スタート/ストップ/ACKをPIOで生成
I2C最小仕様
- 標準モード 100 kHzで開始。慣れたら400 kHzへ。
- Open-Drain(プルアップ必須)。外付け2.2〜4.7kΩをSDA/SCLに。
- 基本要素:
START
(SDA↓@SCL=H)→ADDR+W/R
→ACK
→データ…→STOP
(SDA↑@SCL=H)。
i2c_master.pio
(PIOアセンブリ:概念実装)
.program i2c_master
; pins: SDA as OUT/IN, SCL as OUT
; sideset: 1 bit for SCL drive
.side_set 1
; OSRから1byte取り出してMSB→LSBへ出力。ACKはINで取得。
; 分周でSCL周期を作り、各ビットでSDAをセット→SCL High→サンプル→SCL Low
; 出力1バイト
pull ; OSR <- data (addr/data)
set x, 7
bitloop:
out pindirs, 1 side 0 [0] ; SDAを出力に(1bit出す準備)
out pins, 1 side 0 [0] ; SDAにビット出力(MSB-first想定)
nop side 1 [3] ; SCL High期間(ここでスレーブが見る)
nop side 0 [3] ; SCL Low戻し
jmp x-- bitloop
; ACKサイクル(SDA解放→スレーブ駆動を読む)
set pindirs, 0 side 0 [0] ; SDAを入力(High-Z)
nop side 1 [3] ; SCL High
in pins, 1 side 0 [3] ; SDAをINしてISRに1bit
push ; ACK bitをRX FIFOへ(0=ACK, 1=NACK)
これは1バイト送出+ACK受信の最小骨格。
START/STOP
やRESTART
はC側でSDA/SCLのトグル専用命令を別SMやSET
で出す構成など複数手がある。命令32語の制約があるため、START/STOPを別プログラム化し、切替・再初期化で運用するのが実装しやすい。
C側ユーティリティ(main.c
追加例)
#include "i2c_master.pio.h"
#define I2C_SDA_PIN 2
#define I2C_SCL_PIN 3
#define I2C_SM 2
#define I2C_KHZ 100
static void i2c_pio_init(PIO pio, uint sm, uint pin_sda, uint pin_scl, uint32_t khz) {
uint offset = pio_add_program(pio, &i2c_master_program);
pio_sm_config c = i2c_master_program_get_default_config(offset);
sm_config_set_sideset_pins(&c, pin_scl);
sm_config_set_out_pins(&c, pin_sda, 1);
sm_config_set_in_pins(&c, pin_sda);
pio_gpio_init(pio, pin_sda);
pio_gpio_init(pio, pin_scl);
// Open-Drain風:通常は外部プルアップ。出力'0'のみ駆動、'1'は入力でHi-Zとする設計をC側で徹底。
gpio_pull_up(pin_sda);
gpio_pull_up(pin_scl);
pio_sm_set_consecutive_pindirs(pio, sm, pin_sda, 1, false); // 初期は入力
pio_sm_set_consecutive_pindirs(pio, sm, pin_scl, 1, true); // SCLは出力
float div = (float)clock_get_hz(clk_sys) / (khz * 2000.0f); // ディレイ合成例(要調整)
sm_config_set_clkdiv(&c, div);
pio_sm_init(pio, sm, offset, &c);
pio_sm_set_enabled(pio, sm, true);
}
// START/STOPは簡便にGPIO直叩きで生成(学習用)
static inline void scl_high(uint pin){ gpio_set_dir(pin, true); gpio_put(pin, 1); }
static inline void scl_low (uint pin){ gpio_set_dir(pin, true); gpio_put(pin, 0); }
static inline void sda_release(uint pin){ gpio_set_dir(pin, false); } // 入力=Hi-Z
static inline void sda_low(uint pin){ gpio_set_dir(pin, true); gpio_put(pin, 0); }
static void i2c_start(uint sda, uint scl){
sda_release(sda); scl_high(scl); tight_loop_contents();
sda_low(sda); tight_loop_contents();
scl_low(scl);
}
static void i2c_stop(uint sda, uint scl){
sda_low(sda); scl_high(scl); tight_loop_contents();
sda_release(sda);
}
// 1バイト送出+ACK取得(PIOプログラムへ)
static bool i2c_write_byte(PIO pio, uint sm, uint8_t byte){
pio_sm_put_blocking(pio, sm, byte);
// ACK bit取得:RX FIFOから1bit
uint32_t ack = pio_sm_get_blocking(pio, sm) & 0x1;
return ack == 0; // 0=ACK
}
static bool i2c_write(PIO pio, uint sm, uint8_t addr7, const uint8_t *buf, size_t len){
// START + [ADDR<<1 | 0(W)] + data... + STOP
i2c_start(I2C_SDA_PIN, I2C_SCL_PIN);
if (!i2c_write_byte(pio, sm, (addr7 << 1) | 0)) { i2c_stop(I2C_SDA_PIN, I2C_SCL_PIN); return false; }
for(size_t i=0;i<len;i++){
if (!i2c_write_byte(pio, sm, buf[i])) { i2c_stop(I2C_SDA_PIN, I2C_SCL_PIN); return false; }
}
i2c_stop(I2C_SDA_PIN, I2C_SCL_PIN);
return true;
}
配線テスト
- SDA=GPIO2、SCL=GPIO3。各線にプルアップ抵抗(例:4.7kΩ→3.3V)。
- 例:I2C温度センサ(アドレス0x48)へ1バイト書き込み。
int main(){
stdio_init_all(); sleep_ms(1000);
PIO pio = pio0;
i2c_pio_init(pio, I2C_SM, I2C_SDA_PIN, I2C_SCL_PIN, I2C_KHZ);
uint8_t buf[2] = {0x01, 0x00}; // レジスタ1に0を書き込む例
bool ok = i2c_write(pio, I2C_SM, 0x48, buf, 2);
printf("I2C write: %s\n", ok ? "OK" : "NACK");
while(1) tight_loop_contents();
}
実戦ではSCLストレッチ対応や読取り(Rビット)、リピーテッドスタートも必要。PIOとGPIO直叩きの役割分担を調整し、命令32語制約を越えないよう分割するのがコツ。
精度とチューニング
- ボーレート/I2Cクロックは
clk_sys / div / サイクル
で決まる。[delay]
スロットも含めて式を作り、ロジアナで検証。 - ジッタはほぼ無し。PIOが直接ピンを駆動するためCPU割込みの影響を受けにくい。
- DMAでFIFOへ連続投入すると長文や大量転送でもCPU負荷が低い。
典型トラブルと切り分け
- UARTがときどき文字化け:分周誤差。
clk_sys
の実周波数を取得し再計算。遅延スロット[n]
も再調整。 - I2CがNACK:プルアップ値が大きすぎる/小さすぎる。SCL波形立上りをロジアナで確認。アドレス7bitか確認。
- PIO命令が足りない:START/STOPを別プログラム化し切替。あるいはC側でGPIO直叩きし、データビットのみPIOに任せる。
- 多機能化で混乱:SMをTX/RX/CTLに分割。役割単純化でデバッグ容易。
応用への道
- 奇数パリティ/UART 9bit、I2Cリード/レジスタバースト、クロックストレッチ対応。
- SPI/WS2812/Manchester/1-WireやDShotなどタイミング命のプロトコルに横展開。
- DMA+循環バッファでシリアルストリームを落とさずログ収集。
まとめ
- PIOは**“足りないペリフェラルを後付けする”**実用的な選択肢。
- UART/I2Cの最小実装を通じて、分周とディレイ設計、FIFO運用、命令数節約の勘所が掴める。
- 次の一歩はDMA連携とエラーハンドリングの作り込み。