0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Shrike-LiteでAtCoder問題を解く(28):4bitパラレル通信を作る(2) - Cで高速化する

0
Last updated at Posted at 2026-08-05

はじめに

前回の記事では、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で公開しています。

第28回コード全文

MicroPythonをbuildすることになった

User C ModuleはMicroPythonのfirmwareへ組み込む必要があります。

そのため、公開されているShrike-Lite用MicroPythonのソースコードから、自分でfirmwareをbuildすることにしました。

ソースコードは、Vicharak社が公開しているShrikeのGitHubリポジトリから取得できます。

Vicharak Shrike GitHubリポジトリ

MicroPython本体は、このリポジトリのlib/micropythonから参照されるsubmoduleです。

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に記載されています。

MicroPython:RP2 portのbuild手順

また、CMakeやArm GNU Toolchain、Pico SDKなど、RP2040向けC/C++ build環境の基本については、Raspberry Pi公式資料を参照しました。

Raspberry Pi公式:The C/C++ SDK

これが成功すれば、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.hshrike-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番号の並びが一致していません。

そのため送信前には、

  1. byteを二つのnibbleへ分ける
  2. 論理nibbleをGPIO上のbit配置へ変換する
  3. 8個のnibbleを一つの32bit wordへ詰める
  4. packet長を示す制御wordを追加する

というpack処理を行います。

受信側では、反対のunpack処理が必要です。

  1. DMAで受信した32bit wordからnibbleを取り出す
  2. GPIO上のbit配置を論理nibbleへ戻す
  3. 二つのnibbleを1byteへ戻す
  4. 最終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(...)

へ置き換えるだけで済みます。

この段階のコードは次にあります。

pack/unpack C化版のコード

変換結果が同じことを確認する

高速化しても、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化版を公開する

完成したコードは次にあります。

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パラレル通信を使ってみる

今回のコード:

第28回コード全文

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?