メモ:rp-hal(RustでRaspberry Pi Picoをプログラミングするやつ)でPIOを使ってPWMする

PIOでPWMを実現するのは、公式のexamplesにコードがある。

rp-halでもpio-rs経由でPIOが使えて、このコードをRustに書き直したバージョンが以下のコードらしい。 これを理解するのにちょっと手間取ったのでメモ。

PIOのコードはまったく同じものが使われている。

違うのは、% c-sdk {} にコードが埋め込まれているが、Rustの場合これは完全に無視されるっぽい(パーサの処理)。

% c-sdk {
static inline void pwm_program_init(PIO pio, uint sm, uint offset, uint pin) {
   pio_gpio_init(pio, pin);
   pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true);
   pio_sm_config c = pwm_program_get_default_config(offset);
   sm_config_set_sideset_pins(&c, pin);
   pio_sm_init(pio, sm, offset, &c);
}
%}

ではどうするかというと、このあたりの処理は PIO ファイルではなくて通常の Rust のファイルの側に記述することになる。 PIOファイルと見比べないといけないので可読性が下がるのは悪い点だが、Rust の方だと補完が効くので便利でもある。

PIOをプログラムをビルドして設定する処理

main()の序盤の方はこんな感じ。

let mut pac = pac::Peripherals::take().unwrap();

// ...snip...

// PIO0を使う
let (mut pio0, sm0, _, _, _) = pac.PIO0.split(&mut pac.RESETS);

// PIOファイルからプログラムを選択する
let program = pio_file!("./examples/pwm.pio", select_program("pwm"),);

// PIO0に選んだプログラムを割り当てる(たぶん、同じプログラムを複数のPIOで共有することもできる)
let installed = pio0.install(&program.program).unwrap();

// LEDのピン(GPIO25)をPio0用のモードに切り替える
let led: hal::gpio::Pin<_, hal::gpio::FunctionPio0> = pins.led.into_mode();

// PIOの引数にはオブジェクトではなく数字のIDを渡すので、ここで取り出しておく
let led_pin_id = led.id().num;

// PIOプログラムをビルドし、ピンの割り当てを行う
let (mut sm, _, mut tx) = PIOBuilder::from_program(installed)
    .set_pins(led_pin_id, 1)        // SET命令に使うピンの指定
    .side_set_pin_base(led_pin_id)  // サイドセット命令に使うピンの指定
    .build(sm0);

// set_pins()で指定したピンがインプット用かアウトプット用かを指定する
sm.set_pindirs([(led_pin_id, hal::pio::PinDir::Output)]);

// ステートマシンを開始する
let sm = sm.start();

ピンの割り当てのところで補足すると、 データシートによれば、ピンには4種類の役割があるらしい。

  • OUT命令に使うピン
  • SET命令に使うピン
  • IN命令に使うピン
  • サイドセット命令に使うピン

それぞれ、複数使う場合は連続したピンを選ぶ必要がある。ピンがかぶることは問題ない(同時に操作が行われた場合、サイドセット命令が優先されるらしい)。具体的にはこう書かれている(3.2.5. Pin Mapping):

There is a range for each of OUT, SET, IN and side-set operations. Each range can cover any of the GPIOs accessible to a given PIO block (on RP2040 this is the 30 user GPIOs), and the ranges can overlap.

連続しているということは、

  • 連続するのピンのうち最初のピン
  • 本数

さえ指定すれば使われるピンは一意に定まる。 サイドセットピンの本数はPIOファイル中に定義されているので、side_set_pin_base()では引数を与える必要はない。一方、set_pins()の方は本数を指定する必要がある、という違いがある。

ちなみに、この例の場合、SET用のピンは実際にはサイドセットピンとしてしか使われないので、set_pins()は必要ない気がする。実際、なくても動く。

このフォーラムの回答を見る感じ、set_pindirs()しなくてもout_pins()だけすれば動きそうな感じするけど、それはそんなことなかった。謎...

PIOのコードの中身

仕組みは単純で、

  • ISRyにコピーされる)には1周期の長さが入っている
  • OSRxにコピーされる)にはduty cycleが入っている
  • yをデクリメントしていって、x = yになるまではオフ、それ以降はオンになる

という感じ。指定するサイドセットピンを3つに増やしたバージョンはこんな感じ。

.program pwm
.side_set 3 opt                ; opt なので、サイドセットピンの値は明示的に side で指定したとき以外は前の値を維持する

    pull noblock    side 0b000 ; TXに新しいduty cycleが書き込まれていればそれをOSRに読み込み、なければxをOSRに読み込む
                               ; それと同時に、サイドセットピンをlowにする
    mov x, osr                 ; 新しいduty cycleをxに書き込む
    mov y, isr                 ; yはカウンタとして使われる。ISRは1周期の長さ(カウント上限)が入っている
countloop:
    jmp x!=y noset             ; xがyと一致しない間はnoset(nopなので何もしない)に飛ぶ
                               ; 一致した場合は、次の行に飛ぶ
    jmp skip        side 0b111 ; サイドセットピンをhighにし、skipに飛ぶ
noset:
    nop
skip:
    jmp y-- countloop          ; yをデクリメントしていって、0になったら最初に戻る

で、ISROSRにどこから書き込まれているかというと、それぞれRustの関数がある。 OSRはこれ。TXに書き込んでPIOのpull命令が実行されればOSRに入る。

fn pio_pwm_set_level<T: ValidStateMachine>(tx: &mut Tx<T>, level: u32) {
    // Write duty cycle to TX Fifo
    tx.write(level);
}

一方、関数のコメントにあるように、ISRはややトリッキーなことをしている。 通常、CPUから書き込めるのはOSRだけだが、一度ステートマシンを停止して、 Rustから直接OUT命令を実行してISRに書きこんでいる。 ステートマシンが止まっちゃうけど、まあ1周期の長さは最初に設定してその後変えないことが多いだろうし、 まあ合理的だなと思う。

fn pio_pwm_set_period<T: ValidStateMachine>(
    sm: StateMachine<(hal::pac::PIO0, SM0), Running>,
    tx: &mut Tx<T>,
    period: u32,
) -> StateMachine<(hal::pac::PIO0, SM0), Running> {
    // To make sure the inserted instructions actually use our newly written value
    // We first busy loop to empty the queue. (Which typically should be the case)
    while !tx.is_empty() {}

    let mut sm = sm.stop();
    tx.write(period);
    sm.exec_instruction(Instruction {
        operands: InstructionOperands::PULL {
            if_empty: false,
            block: false,
        },
        delay: 0,
        side_set: None,
    });
    sm.exec_instruction(Instruction {
        operands: InstructionOperands::OUT {
            destination: OutDestination::ISR,
            bit_count: 32,
        },
        delay: 0,
        side_set: None,
    });
    sm.start()
}

やりたいこと

このPIOを使ってシフトレジスタIC越しにPWMしたい。 Arduinoのライブラリがあったのでこれを真似したいけど、まだPIOよくわからないので修行あるのみ...