はじめに
前回の記事では、Shrike-Lite基板上の4bit配線を使い、MicroPythonでPIOとDMAによる半二重パラレル通信を実装してみました。
今回は、Pythonの限界を超えるべく、C言語を使って、このパラレル通信の高速化を進めます。
具体的にはMicroPythonのfirmwareに、Cで記述されたShrike-Lite専用パラレル通信制御モジュールを組み込みます。
さらに、MicroPythonによる通信制御を少しずつ、Cによる制御へ置き換えていきます。
今回の記事では、
- なぜMicroPythonのfirmwareを自分でbuildする必要があるのか
- User C Moduleをどのように追加したのか
- pack/unpackをCへ移すと、どの程度速くなったのか
- DMA通信をCへ移すと、さらに何が変わったのか
を、測定結果とともに少し詳しく見ていきます。
C SDKのアプリケーションには移らない
RP2040のPIOやDMAをCから制御するだけなら、Pico SDKで専用アプリケーションを作るのが素直です。
しかし、この連載では、
Shrike-LiteはThonnyとMicroPythonから操作する
というルールを決めています。
C SDKで作った専用アプリケーションに置き換えると、ThonnyからPythonコードを実行する現在の使い方ではなくなります。
そこで、MicroPython自体は残し、高速化したい部分だけをCで実装します。
利用するのが、MicroPythonのUser C Moduleです。
User C Moduleとして組み込んだCの処理は、MicroPythonから通常のmoduleと同じように呼び出せます。
import shrike_parallel_c
print(shrike_parallel_c.version())
ユーザーから見える操作方法はMicroPythonのままです。
一方、module内部ではPIOやDMAをCから直接制御できます。
つまり今回は、
C SDKのアプリケーションへ移るのではなく、MicroPythonの中へCで記述された専用通信エンジンを追加する
という方針で高速化します。
「それはハードルが高いな」と感じる方のために、build済みのUF2ファイルもGitHubで公開しています。
MicroPythonをbuildすることになった
User C ModuleはMicroPythonのfirmwareへ組み込む必要があります。
そのため、公開されているShrike-Lite用MicroPythonのソースコードから、自分でfirmwareをbuildすることにしました。
ソースコードは、Vicharak社が公開しているShrikeのGitHubリポジトリから取得できます。
MicroPython本体は、このリポジトリのlib/micropythonから参照されるsubmoduleです。
今回のbuildでは、Shrikeリポジトリのsubmoduleが参照していたMicroPythonのcommit 7b9155b907c373847f3863ff01f507f3bc938729を使用しました。
まさかAtCoderの問題を解くためにCMakeを使うことになるとは思いませんでした。
専用のbuild環境を用意する
build環境には、専用のWSLを用意して、既存の開発環境とは切り離しました。
Shrike-Lite用の作業だけを行うUbuntu環境を新しく作り、MicroPython、ARM GCC、CMakeなどを導入しました。
今回使用したbuild環境は次の通りです。
| 項目 | バージョン |
|---|---|
| Host OS | Windows 11 Pro |
| Build環境 | WSL2/Ubuntu 24.04.4 LTS |
| Git | 2.43.0 |
| GNU Make | 4.3 |
| CMake | 3.28.3 |
| GCC/G++ | 13.3.0 |
| Python | 3.12.3 |
| Arm GNU Toolchain |
arm-none-eabi-gcc 13.2.1 |
最初に、MicroPythonの標準board定義であるRPI_PICO向けfirmwareをbuildしました。
標準のRaspberry Pi Pico向けMicroPython firmwareは、MicroPython公式サイトから入手できます。
MicroPython:Raspberry Pi Pico用firmware
MicroPythonのRP2040/RP2350向けportのbuild方法は、次のREADMEに記載されています。
また、CMakeやArm GNU Toolchain、Pico SDKなど、RP2040向けC/C++ build環境の基本については、Raspberry Pi公式資料を参照しました。
これが成功すれば、CMake、Arm GNU Toolchain、Pico SDK、linker、UF2生成までの基本的なbuild環境は動作していると判断できます。
公開board設定を1行修正する
build環境の確認が済んだら、Shrike公式GitHubリポジトリから入手したMicroPythonを、SHRIKE_lite board設定でbuildします。
make -C ports/rp2 BOARD=SHRIKE_lite -j"$(nproc)"
しかし、buildの途中で次のエラーが発生しました。
Unable to find definition of board 'shrike-lite'
Looked for shrike-lite.h
Looked for shrike-lite.cmake
SHRIKE_lite用の設定を確認すると、Pico SDKへ渡すboard名が次のようになっていました。
set(PICO_BOARD "shrike-lite")
しかし、使用しているPico SDKには、shrike-lite.hやshrike-lite.cmakeというboard定義がありません。
Shrike-LiteのRP2040部分は標準Pico相当として扱えそうなため、次のように修正しました。
set(PICO_BOARD "pico")
この1行の修正で、Shrike-Lite用firmwareをbuildできました。
buildしたfirmwareを確認する
新しいfirmwareを書き込む前に、Shrike-Liteの書き換え前のflash全体をUF2とBINでPCに退避しました。
その後、clean buildしたfirmwareを書き込み、
import shrike
import rp2
が動作することを確認しました。
さらに、退避させたPhase 4用PythonコードとFPGA bitstreamも変更せずに戻しました。
100kHz、500kHz、1MHz、2MHz、4MHzの全周波数で試験し、4MHz・256byte・1000packetの試験もすべてPASSしました。
これで、公開ソースから再構築したfirmwareでも、これまでの4bitパラレル通信を利用できることが確認できました。
最小のUser C Moduleを追加してみる
次に、MicroPython本体とは別のリポジトリへ、User C Moduleを作成しました。
最初からpack/unpackやDMAを実装するのではなく、まずはversion文字列を返すだけのmoduleを作ります。
実装したCコードの主要部分は次の通りです。
#include "py/runtime.h"
static const char shrike_parallel_c_version_string[] = "0.1.0-a2";
static mp_obj_t shrike_parallel_c_version(void) {
return mp_obj_new_str(
shrike_parallel_c_version_string,
sizeof(shrike_parallel_c_version_string) - 1
);
}
static MP_DEFINE_CONST_FUN_OBJ_0(
shrike_parallel_c_version_obj,
shrike_parallel_c_version
);
static const mp_rom_map_elem_t shrike_parallel_c_module_globals_table[] = {
{
MP_ROM_QSTR(MP_QSTR___name__),
MP_ROM_QSTR(MP_QSTR_shrike_parallel_c)
},
{
MP_ROM_QSTR(MP_QSTR_version),
MP_ROM_PTR(&shrike_parallel_c_version_obj)
},
};
static MP_DEFINE_CONST_DICT(
shrike_parallel_c_module_globals,
shrike_parallel_c_module_globals_table
);
const mp_obj_module_t shrike_parallel_c_user_cmodule = {
.base = { &mp_type_module },
.globals = (mp_obj_dict_t *)&shrike_parallel_c_module_globals,
};
MP_REGISTER_MODULE(MP_QSTR_shrike_parallel_c, shrike_parallel_c_user_cmodule);
Cで作成したshrike_parallel_c_version()を、MicroPythonからversion()として呼び出せるようにmoduleへ登録しています。
このUser C Moduleを組み込んだfirmwareを書き込み、MicroPythonから呼び出しました。
import shrike_parallel_c
print(shrike_parallel_c.version())
実行結果は次のようになりました。
0.1.0-a2
これで、
自作Cコードをfirmwareへ組み込み、MicroPythonからimportできる
ことを確認できました。
module登録で一度詰まる
User C Moduleの登録には、次のマクロを使います。
MP_REGISTER_MODULE(
MP_QSTR_shrike_parallel_c,
shrike_parallel_c_user_cmodule
);
当初はこのように複数行で記述しました。
Cの構文としては問題なさそうですが、module登録を検出するscannerに認識されず、object fileが最終リンクから除外されました。
そこで、登録部分を1行に変更しました。
MP_REGISTER_MODULE(MP_QSTR_shrike_parallel_c, shrike_parallel_c_user_cmodule);
これでmoduleがfirmwareへ組み込まれ、importできるようになりました。
CMakeまで通ったのに、最後に問題になったのは改行位置でした。
pack/unpackは何をしているのか
さて、User C Moduleの組み込みもうまくいきましたので、ここからは通信帯域アップを目指していきます。
まず最初にCへ移したのは、送受信データのpack/unpackです。
今回の4bit通信では、MicroPythonが扱うbyte列を、そのままPIOのFIFOへ渡すことはできません。
1byteは、上位と下位の二つの4bitデータに分けます。
さらに、Shrike-Lite基板上のDATA線は、論理bitとGPIO番号の並びが一致していません。
そのため送信前には、
- byteを二つのnibbleへ分ける
- 論理nibbleをGPIO上のbit配置へ変換する
- 8個のnibbleを一つの32bit wordへ詰める
- packet長を示す制御wordを追加する
というpack処理を行います。
受信側では、反対のunpack処理が必要です。
- DMAで受信した32bit wordからnibbleを取り出す
- GPIO上のbit配置を論理nibbleへ戻す
- 二つのnibbleを1byteへ戻す
- 最終wordの有効部分だけを処理する
256byteを送信する場合、少なくとも512個のnibbleを順番に変換します。
これをMicroPythonのloopでpacketごとに繰り返していたため、実データ転送よりも長い時間がかかっていました。
pack/unpackだけをCへ移す
最初の高速化では、pack/unpackだけをUser C Moduleへ移しました。
PIOプログラム、DMAの設定方法、REQ制御、timeout、復旧処理、FPGA側の回路は変更していません。
Python側では、これまで使用していた関数を、
pack_tx_words(...)
unpack_rx_words(...)
から、
shrike_parallel_c.pack_tx_words(...)
shrike_parallel_c.unpack_rx_words(...)
へ置き換えるだけで済みます。
この段階のコードは次にあります。
変換結果が同じことを確認する
高速化しても、Python版と異なる挙動をしては意味がありません。
そこで、request長1~258byte、response長1~257byteについて、Python版とC版の結果を比較しました。
実機試験では100kHzから4MHzまでの全周波数を試し、各周波数でINVERTとBURSTを1000回ずつ実行しました。
すべてPASSしています。
pack/unpackのC化でどこまで速くなったか
4MHz・256byte・1000packetで測定したところ、pack/unpack C化版の処理時間は次のようになりました。
合計時間 12.241秒
1packetあたり 12.241ms
DMA転送区間 0.470ms / packet
256byteのpayloadを基準に単純換算すると、約167kbpsです。
前回のMicroPython版は約66.8kbpsだったため、pack/unpackをCへ移しただけで、通信全体は約2.5倍になりました。
PIOプログラムやDMAの転送方法は変えていません。
それでも大きく改善したことから、byteとnibbleの変換が、MicroPython版の主要なボトルネックだったことが分かります。
一方、1packetあたり約12.2msという結果では、4MHz SPIにはまだ届きません。
DMA転送区間は約0.47msしかないため、pack/unpack以外にもMicroPython側の処理が多く残っています。
DMAを呼び出す処理もCへ移す
さきほどはデータ変換だけをCへ移し、DMA自体は従来通りMicroPythonから操作していました。
この状態では、packetを1回送受信するたびに、Python側で、
pack
→ RX buffer clear
→ RX DMA設定
→ TX DMA設定
→ RX DMA開始
→ TX DMA開始
→ DMAとREQの状態を監視
→ completion marker確認
→ unpack
という処理を行っています。
そこで、この一連の処理を一つの同期C関数へまとめました。
shrike_parallel_c.transfer_dma(
request_buffer,
request_length,
response_buffer,
response_length,
tx_words,
rx_words,
raw_nibbles,
)
PythonからCを1回呼び出すと、C側でpackからunpackまでを実行します。
すべてをCへ移したわけではない
この段階でCへ移したのは、packet内部のDMA処理です。
次の処理は、まだPython側に残しています。
- PIOプログラムの定義とロード
- PIO State Machineの作成と周波数設定
- packet開始前のREQ、CLK、FIFO確認
- packet終了後のREQ Low確認
- PIOが待機状態へ戻ったことの確認
- responseの内容が正しいかの判定
- 異常後のGPIO、PIO、FPGAの完全復旧
C側が担当するのは、DMAを利用する通信のhot pathです。
Python側は、通信全体の準備、境界確認、復旧を担当します。
DMA channelをC側で所有する
DMA通信をCへ移すにあたり、DMA channelの確保と解放もC側で管理することにしました。
そうすると、MicroPythonのrp2.DMA objectと、Cから直接操作するDMA channelを混在させると、同じchannelを双方が操作する可能性があります。
そこで今回の実装では、
通信エンジンが使用する2本のDMA channelは、C側だけが所有する
という構成にしました。
通信エンジンの状態は、主に次の三つです。
IDLE
RUNNING
ABORTED
正常な通信では、
IDLE
→ RUNNING
→ IDLE
と遷移します。
timeout、DMA error、marker不一致、KeyboardInterruptなどが発生した場合は、
RUNNING
→ ABORTED
へ移ります。
C側はDMAを停止しますが、PIO、GPIO、FPGAまで含む完全な復旧はPython側で行います。
復旧が終わった後にdma_rearm()を呼び、再びIDLEへ戻します。
速度だけを考えるなら、ここまでの仕組みは不要に見えるかもしれません。
しかし、DMAをCから直接操作する以上、異常終了時にchannelを放置しないための管理が必要になります。
DMA通信 C化版を公開する
完成したコードは次にあります。
build済みUF2ファイルは、記事冒頭、及び末尾の「第28回コード全文」から取得できます。
DMA通信をCへ移すと、どこが速くなったのか
完成したfirmwareを使い、これまでと同じ4MHz・256byte・1000packetで測定しました。
通常のbulk試験では、合計時間が約9.052秒になりました。
pack/unpack C化版 約12.241秒
DMA通信 C化版 約 9.052秒
DMA通信もCへ移したことで、約3.2秒、割合にして約26%短縮できました。
ただし、この約9.052秒には通信だけでなく、
- payloadの準備
- Python側のpacket loop
- response全byteの比較
- 診断情報の更新
なども含まれています。
そこで今回は、通常のbulk試験に含まれる処理を、外側から順番に分けて測定しました。
| 測定範囲 | 1packetあたり | 主に含まれる処理 |
|---|---|---|
| 通常のbulk試験全体 | 約9.052ms | payload準備、Python loop、response比較を含む |
| packet開始直前から終了確認まで | 約2.281ms | packet前後の境界確認とC関数呼び出し |
Cのtransfer_dma()内部 |
約650.2µs | pack、DMA通信、unpack |
| 実際に通信線を流れている区間 | 約260.1µs | PIO+DMAによる4bit転送 |
測定方法は異なりますが、測っている処理の範囲は入れ子になっています。
約9.052msの処理時間を、外側から内側へ順番に見たものです。
通常のbulk試験 約9.052ms
└─ そのうちpacket開始直前から終了確認まで 約2.281ms
└─ そのうちtransfer_dma() 約0.650ms
└─ そのうち実際の通信線 約0.260ms
通常のbulk試験とpacket境界の測定との差は、約6.77msあります。
ここには、payload生成、Python側のloop、response比較などが含まれています。
また、packet境界の測定とtransfer_dma()との差は、約1.63msです。
ここには、REQやFIFOの確認、C関数を呼び出す前後の処理などが含まれます。
一方、Cで実行するtransfer_dma()の約650.2µsは、次のように分かれています。
| 処理 | 1packetあたり |
|---|---|
| pack | 約190.6µs |
| RX buffer clear | 約2.3µs |
| DMA設定 | 約5.2µs |
| DMA/REQのpoll | 約262.7µs |
| marker確認 | 約2.2µs |
| unpack | 約187.2µs |
| 合計 | 約650.2µs |
DMA/REQをpollしている約262.7µsのうち、実際に通信線へデータを流している時間は約260.1µsです。
packとunpackを合わせると約377.8µsとなり、transfer_dma()全体の約58%を占めています。
Cへ移しても、256byteをnibbleへ分解し、GPIO上のbit配置へ並べ替え、受信後に元へ戻す処理そのものがなくなるわけではありません。
一方、DMA registerの設定は約5.2µsしかかかっていません。
MicroPythonからDMA objectを何度も操作する構造をやめたことで、DMAを設定する処理自体は非常に短くなりました。
実際の通信線はほぼ理論値だった
実際の通信区間は約260.1µsでした。
通信線を流れたデータ量から換算すると、1byteあたり約0.505µsです。
4bit・4MHz通信の理論値は、1byteあたり0.500µsです。
実測値 約0.505µs / wire byte
理論値 0.500µs / wire byte
PIO、DMA、4bit信号線による転送は、ほぼ理論値で動作しています。
しかし、通常のbulk試験全体は1packetあたり約9.052msです。
実際に通信線を使用している約0.260msは、その一部にすぎません。
残っている時間の多くは、
- pack/unpack
- packet前後の境界確認
- Python側のpacket loop
- payload準備
- response比較
に使われています。
第27回では「通信路は速いが、その前後が遅い」と分かりました。
今回は、C化によって、その「前後」のどこに時間がかかっているのかを、さらに分解できました。
4MHz SPIと同等になるには、どこまで縮めればよいか
ここまでで各処理の時間は分かりました。
では、4bitパラレル通信は、あとどの程度速くなれば4MHz SPIと同等になるのでしょうか。
第15回で測定した4MHz SPI改善版は、256byte単位の転送で約2.46µs/byteでした。
今回の1packetでは、requestとresponseを合わせて、通信線上を約515byte流しています。
したがって、第15回の実測値を使って単純換算すると、
515byte × 2.46µs/byte ≒ 1.27ms
となります。
今回は、この値を4MHz SPI相当の初期目標とします。
現在、packet開始直前から終了確認までには約2.281msかかっています。
その内側にあるtransfer_dma()は約650.2µsです。
SPI相当の目標 約1.267ms
transfer_dma() 約0.650ms
外側の処理に使える時間 約0.617ms
現在、transfer_dma()の外側には、
2.281ms - 0.650ms = 約1.631ms
かかっています。
したがって、この部分を約1.631msから約0.617msまで、約62%短縮できれば、第15回の4MHz SPI実測値を単純換算した初期目標へ届きます。
一方、transfer_dma()自体はすでにSPI相当の時間より短くなっています。
実際の通信線も約260.1µsで、4MHz SPIの理論的な転送時間より十分高速です。
次の高速化ターゲットは、通信線やDMA設定ではなく、
- packet開始前のREQ、CLK、FIFO、PIO確認
- packet終了後のREQ Low確認
- PIOが待機状態へ戻ったことの確認
といった、通信エンジンの外側に残っているpacket前後の処理がよさそうです。
まずはこれらをCへ移し、packet開始直前から終了確認までの約2.281msを、約1.27msへ近づけます。
Python側のpacket loopやresponse比較は、その後に通常bulk試験全体を縮めるための対象になります。
今回のまとめ
今回は、MicroPythonの操作方法を維持したまま、4bitパラレル通信の一部をCへ移しました。
| 段階 | Cへ移した処理 | Python側に残った主な処理 |
|---|---|---|
| MicroPython版 | なし | pack、unpack、DMA操作、packet境界 |
| pack/unpack C化版 | pack、unpack | DMA操作、packet境界 |
| DMA通信 C化版 | pack、unpack、DMA通信 | packet境界、packet loop、response比較 |
4MHz・256byte・1000packetのbulk試験は、約12.241秒から約9.052秒へ短縮されました。
Cの通信エンジン内部は1packetあたり約650µsとなり、実際の通信線はほぼ理論値で動作しています。
一方、packet開始直前から終了確認までは約2.281msかかっています。
次は、通信エンジンの外側に残っているpacket前後の境界確認をCへ移し、4MHz SPI相当の初期目標である約1.27msを目指します。
次回
次回は、packet前後の境界確認と、繰り返し処理、response比較をCへ移します。
packetごとにPythonへ戻る回数を減らし、4bitパラレル通信で4MHz SPI超えを目指します。
お楽しみに。
前回:
Shrike-LiteでAtCoder問題を解く(27):4bitパラレル通信を作る(1)
次回:
Shrike-LiteでAtCoder問題を解く(29):ABC469A/B - 4bitパラレル通信を使ってみる
今回のコード: