Raspberry Pi PicoのSocである RP2040 は、専用のプログラムによってさまざまなI/Oインターフェースをソフトウェアで実現できる PIO (Programmable I/O)という機能ブロックを2つ搭載しています。
NuttX for Raspberry Pi PicoでPico Audio Packを使う で実装したI2Sインターフェースも、このPIOを使用して実現しています。ここでは、このPIOでのI2Sインターフェースの実装について説明します。
RP2040 PIO (Programmable I/O) について
PIOの機能詳細は、RP2040 Datasheet の「Chapter 3. PIO」や Raspberry Pi Pico C/C++ SDK の 「Chapter 3. Using Programmable I/O (PIO)」などで説明されています。また、この仕様書については以下の日本語ページの解説が詳しいです。
PIOには、データのシリアル送受信やそれに同期したクロック生成に特化した命令セットを持った、ごく小さなプロセッサが載っています。この命令セットを用いて所望のプロトコルで入出力を行うプログラムを作ることで、さまざまなインターフェースをソフトウェアで実現できるわけです。
I2Sについて
I2Sは、オーディオのPCMデータをシリアル通信で転送するためのインターフェースです。Wikipediaの I2S などで説明されています。
通信にはSerial clock(SCK)1、Word Select(WS)2、Serial data (SD)の3本の信号線が使われ、下図のようにデータが転送されます。
プロトコルの特徴をおおまかに上げると以下のようになります。
- データは最上位ビット(MSB)から1ビットずつ送る
- データの送出は Serial clock(SCK) に同期して行われる。SCKの立ち下がり時(1→0)にデータをセットし、立ち上がり時(0→1)にそのデータが取り込まれる
- Word select(WS) によって送出するデータが左チャンネル(=0)なのか、右チャンネル(=1)なのかを示す。WSはデータ送出より1ビット分早く変化していることに注意
PIOによるI2Sの実装 (16bitステレオ)
PIOの命令セットを使って、入力データをI2Sで出力するインターフェースを実装してみます。
I2Sで16bitステレオPCMデータを送るプログラムは、既に SDK extrasのpico_audio_i2sで提供されているのですが、今回は16bitステレオ以外のフォーマットもサポートするために、1から作り直します。
クロック(SCK, WS)の出力
まず最初に、SCK, WS 2本のクロック線の出力を記述します。
PIOにはSide-setという、命令の実行に並行して複数のビットに特定の値を出力する機能があります。この機能を使用します。
命令は空白のままで、Side-setによるSCK, WSの設定値のみを並べてみました。16bitステレオ信号なので、左右合わせて32bitをSCKの0/1の繰り返し32回分で出力します。その間、16bitごとにWSで左右チャンネルの区別を表します。ステレオの左チャンネルを青、右チャンネルを赤で表していますが、前述したとおりWSはデータ出力より1ビット分早く変化する仕様のため、命令欄とSide-set欄では青赤の色分けが2行分(1ビット分)ずれています。
この左右合わせて32bit、命令コードで64命令をかけて、ステレオPCMの1サンプル分が出力されます。PIOの命令コードは1命令1クロックで実行するのが基本なので、このPCMのサンプリングレートが44.1kHzだった場合は、PIOのクロックには44.1k×64 = 2.8224MHzを与えることになります。
データの出力
次に、Side-setで送出するクロックに合わせて1ビットずつデータを出力する命令を記述します。
出力するデータはまずTX FIFOからOutput Shift Register(OSR)に読み込まれ、それをPIOのOUT命令で出力ピンに送ります。OSRから1ビットを出力する命令 out pins, 1
を、SCKの立ち下がりに合わせて、0を出力する際に実行します。
送出データの供給
続いて、OUT命令でデータを出力するOSRへのデータ供給について考えます。
TX FIFOから1ワード(32bit)のデータを引き抜いてOSRに書き込むには、本来はPULL命令を使用するのですが、PIOにはOSRの出力データが尽きると自動的にTX FIFOからデータを供給する Autopull という機能があり、これを使うとPULL命令を明示的に実行しなくても済むようになります。
SDK extrasのI2S実装では、PCMの1サンプルが左右16bitずつの合計32bitあることから、データを32bit単位でTX FIFOにDMA転送してそのまま Autopull を使ってOSRに送っています。ところが、wavファイルのPCMデータは左チャンネルから順にLRLR…と並んでいて、これを32bitごとにリトルエンディアンで読みだすと、MSB側16bitが右チャンネル、LSB側16bitが左チャンネルとなります。I2Sの仕様ではビットはMSBから送るので、これをそのまま出力すると右チャンネルのデータから先に出力されてしまいます3。
これでも普通に音は出るのでおそらく問題はないのだと思いますが、右チャンネルからデータを送り始めるのがI2Sの規格的に全く問題がないのかがちょっと微妙なのと4、どのみちこの後に出てくるモノラル対応や8bit PCM対応ではこのやり方は使えないので、今回は敢えて真面目に(?)、16bitずつデータを取り込んで左チャンネルから出力するように書いてみます。
メモリからTX FIFOへの転送を16bit単位とすると、FIFOの32bitワードにはLSB側の16bitにデータが詰められます。OSRではこれをMSB側から送らなければならないので、まず最初に上位16bit分のデータを捨てるところから始めます。
これは、out null, 16
という命令で行えます。これを左、右それぞれ最初の1bitを送出するOUT命令の直前に実行することで、OSRへのデータ供給が正しく行われるようになります。
ループ化
以上の記述で、TX FIFOに送ったデータをI2Sで出力できるようになりました。ただ、見て分かる通り、左右とも0~14ビット目の出力では全く同じ命令が続いていて、明らかに無駄です5。
そこで最後に、同じ命令の繰り返しをループで置き換えます。ループカウンタとしてはスクラッチレジスタ X を使用しますが、最初にカウンタの初期化で1命令必要なので、ループの対象となるのはその後の14ビット分(出力の1ビット~14ビット目)となります。
SET命令でカウンタを初期化し、JMP命令でカウンタのデクリメントとジャンプを行います。JMP命令の条件判定はデクリメントの前に行われるので、14回ループするためには初期値として13を設定します。
コードの最初のout命令は、1つ前のサンプルの最後のビットを出力するためのものなので、プログラムの開始時はその次の命令から実行を始めます。この命令が先頭に来るように順番をずらすと、最終的には以下のようなコードになりました。
out null, 16 side 0b01
out pins, 1 side 0b00
set x, 13 side 0b01
L1: out pins, 1 side 0b00
jmp x--, L1 side 0b01
out pins, 1 side 0b10
out null, 16 side 0b11
out pins, 1 side 0b10
set x, 13 side 0b11
L2: out pins, 1 side 0b10
jmp x--, L2 side 0b11
out pins, 1 side 0b00
I2Sの16bitモノラル対応
続いて、SDK extrasでもやっていない実装として、元データが16bitモノラルだった場合のI2S出力のコードを実装してみます。PCM5100Aはデータを必ずステレオで送る必要があるので、左右チャンネルに対してそれぞれ同じデータを送ることでモノラル対応を実現します。
PIO動作周波数の変更
左右チャンネルで同じデータを送るためには、OSRに取り込んだデータを左チャンネル送出前に保存して、右チャンネル送出ではそれを使うようにする必要があります。そのためには、1つ前のサンプルの最後のデータ(右チャンネルのLSB)を送ってから次のサンプルの最初のデータ(左チャンネルのMSB)を送るまでの間に、以下を実行する必要があります。
- TX FIFOから次のサンプルのデータをOSRにPULLする
- OSRの上位16bitを捨てる (
out null, 16
) - 右チャンネル用に、OSRの値をスクラッチレジスタ(Y)にコピーする (
mov y, osr
)
16bitステレオ時のクロック設定ではOSRからのデータ送出の間に1クロック分しか空きがなく、上記3命令が入りません。そこで、モノラルデータを送信する場合は、PIOの動作周波数をステレオの時の2倍に設定し、SCKのSide-set出力は同じ値を2回ずつ出力することで、ビットクロックとしては変わらないようにします。
送出データの供給
PIOの動作周波数を倍にしたことでビット出力のOUT命令同士の間に3命令分の空きが出来たので、前述の3命令を実行することができるようになりました。右チャンネルの出力ではFIFOの代わりにYレジスタの値をOSRに書き戻すことで、左と同じデータが出力されるようになります。
16bitステレオの時にはAutopullを使うことでTX FIFOからのPULL命令を省略できていましたが、モノラルの場合はYレジスタからOSRへの書き戻しの際にAutopullのためのカウンタがクリアされてしまうため、右チャンネルを送った後のデータ供給が期待通りに行われなくなります。そこで、モノラル対応ではAutopullによるデータ供給を使わず、明示的にPULL命令を実行してデータを取り込むようにしています。
ループ化とwait値設定
ステレオの際と同じく、同じ命令列の繰り返しについてはXレジスタをカウンタとして用いたループを導入します。
空白のまま残っている命令欄は何もしないNOP命令を書き込むことで実行可能なコードになるのですが、PIOではある命令実行から次の命令実行までの間にDelay値として待つクロック数を指定することができるので、これを使うとNOP命令とその手前の命令を合わせて、コードサイズを更に縮めることができます。命令の後にある[1]
で、その命令の実行後に1クロック待つことを表します。
最後に、ループ開始位置を調整してやることで、16bitモノラルPCMにも対応することができました。
pull block side 0b00
out null, 16 side 0b01
mov y, osr side 0b01
out pins, 1 side 0b00 [1]
set x, 13 side 0b01 [1]
L1: out pins, 1 side 0b00 [1]
jmp x--, L1 side 0b01 [1]
out pins, 1 side 0b10 [1]
mov osr, y side 0b11
set x, 14 side 0b11
L2: out pins, 1 side 0b10 [1]
jmp x--, L2 side 0b11 [1]
out pins, 1 side 0b00
I2Sの8bitPCM対応
更に、元データが8bitPCMだった場合の対応もやってみます。
I2Sで送るデータを単に16bitから8bitに減らせばよいのかと思いきや、以下の2つの注意点がありました。
- PCM5100Aは16bit未満のPCMデータには対応していないので、元データが8bitの場合はそれを16bitに拡張する必要があります。データはMSBから送っているので、8bit分送った後に8bit分の0を付け足すことで実現します。
- 16bit以上のPCMデータでは各サンプル値は符号付き整数として表現するのですが(無音時は0)、なぜか8bitPCMだけは符号なしの0x00~0xffでサンプル値を表します(無音時は0x80)。I2Sで送るデータには符号付き整数を使うため、送出の際にデータを変換しなければなりません。具体的には、最上位ビットのみ0/1を反転する必要があります。
8bitステレオPCM
まず、8bit分のデータを送った後に0を固定値で出力するようにします。set pins, 0
で行います。この後、LSB側の8bit分についてはSide-setでSCK, WSを送るのみで、命令コード側は何もしなくても良いです。
OSRへのデータ供給処理としては、16bitモノラル時と同様、Autopullを使わずにpull block
を実行します。今回は送るデータが8bitなので、8bitデータのMSBを出力するために out null, 24
で残り24bitを捨てます。
更に、最上位ビットのみのビット反転が必要となるのですが、PIOには特定のビットのみを反転させるような命令がありません。mov osr, !osr
という命令で全ビットの反転はできるので、まず最初のビット出力の前にこれで全ビットを反転し、1ビット出力後にまた全ビット反転してやることで、所望の出力を実現します。
この後行うことは、これまでと同様です。同じ命令の繰り返しをループ化して、何もしない命令と並んでいる命令はwait値によって1命令にまとめます。0を固定値で出力する箇所にだけNOP命令が残りました。
以上で、8bitステレオPCMもI2Sで転送できるようになりました。
pull block side 0b00
out null, 24 side 0b00
mov osr, !osr side 0b01 [1]
out pins, 1 side 0b00 [1]
mov osr, !osr side 0b01
set x, 6 side 0b01
L1: out pins, 1 side 0b00 [1]
jmp x--, L1 side 0b01 [1]
set pins, 0 side 0b00 [1]
set x, 5 side 0b01 [1]
L2: nop side 0b00 [1]
jmp x--, L2 side 0b01 [1]
pull block side 0b10
out null, 24 side 0b10
mov osr, !osr side 0b11 [1]
out pins, 1 side 0b10 [1]
mov osr, !osr side 0b11
set x, 6 side 0b11
L3: out pins, 1 side 0b10 [1]
jmp x--, L3 side 0b11 [1]
set pins, 0 side 0b10 [1]
set x, 5 side 0b11 [1]
L4: nop side 0b10 [1]
jmp x--, L4 side 0b11 [1]
8bitモノラルPCM
モノラル対応で必要なのは、16bitの際と同様に左チャンネル送出前にOSRの値を保存して、右チャンネル送出にはそれを使うようにすることです。ループ化とwait値設定も入れると以下のようになります。
最終的なコードは以下のようになりました。
pull block side 0b00
out null, 24 side 0b00
mov osr, !osr side 0b01
mov y, osr side 0b01
out pins, 1 side 0b00 [1]
mov osr, !osr side 0b01
set x, 6 side 0b01
L1: out pins, 1 side 0b00 [1]
jmp x--, L1 side 0b01 [1]
set pins, 0 side 0b00 [1]
set x, 5 side 0b01 [1]
L2: nop side 0b00 [1]
jmp x--, L2 side 0b01 [1]
nop side 0b10 [1]
mov osr, y side 0b11 [1]
out pins, 1 side 0b10 [1]
mov osr, !osr side 0b11
set x, 6 side 0b11
L3: out pins, 1 side 0b10 [1]
jmp x--, L3 side 0b11 [1]
set pins, 0 side 0b10 [1]
set x, 5 side 0b11 [1]
L4: nop side 0b10 [1]
jmp x--, L4 side 0b11 [1]
動作確認
実装したPIOのプログラムの動作を確認します。
I2Sを出力するために必要なGPIOやPIOの設定を追加して、単体で実行可能なPico SDKのアプリにしたソースコードを以下に置いています。
PIOのプログラムを実行すると、TX FIFOにデータが入ってくるのを待ってそれをI2Sで出力します。hardware_pio APIを用いた以下のコードでTX FIFOに直接データを書き込みます。
pio_sm_put_blocking(pio, sm, 0x1234);
pio_sm_put_blocking(pio, sm, 0x5678);
:
GPIOピンにロジアナを繋いで実行すると、以下のように書き込んだデータがI2Sで、設定した44.1kHzで出力されていることが分かります。
-
Bit clock(BCK)と表記される場合もあります ↩
-
LR clock(LRCK)と表記される場合もあります ↩
-
なので、SDK extrasのI2SではSide-setの指定も右チャンネル(WS=1)から始まっています。 ↩
-
規格上、左右どちらから送るのが正しいのか、規格の元となったPhilipsの文献からは記述を確認できなかったのですが、I2Sを説明している図ではどれもデータ送出が左から書かれていて、文献によってははっきりと「左から送る」と明言しているものもあったりします。 ↩
-
そしてそもそも、この時点ではコードが全部で64命令ありますが、PIOの命令メモリは32命令分しかないのでこのままだとPIOに収まりません。 ↩