RP2040 PIO超入門:UART/I2Cをハード不要で自作

投稿者: | 2025年9月25日

なぜ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 firststop(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/RACK→データ…→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/STOPRESTARTは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 9bitI2Cリード/レジスタバーストクロックストレッチ対応
  • SPI/WS2812/Manchester/1-WireDShotなどタイミング命のプロトコルに横展開。
  • DMA+循環バッファでシリアルストリームを落とさずログ収集。

まとめ

  • PIOは**“足りないペリフェラルを後付けする”**実用的な選択肢。
  • UART/I2Cの最小実装を通じて、分周とディレイ設計FIFO運用命令数節約の勘所が掴める。
  • 次の一歩はDMA連携エラーハンドリングの作り込み。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です