手軽に使えるRaspberry Pi Picoの手軽さを維持しつつ、テストの使い心地(とても重要)のバランスを見極めるのがちょっと悩ましくて楽しかったので共有。
今回は永続化した循環バッファ(リングバッファ、サーキュラーバッファ)をPicoのオンボードフラッシュメモリに保持して、センサーデータを一定期間のあいだ蓄積するサンプルを書いていました。Picoオンボードの温度計を1秒間隔で記録して追記します。蓄積した最古から最新のセンサーデータを昇順の時系列データとして標準出力に出力します。
--------ASCENDING TIME SERIES
timestamp=30.3,temperature=29.95
timestamp=31.4,temperature=29.95
timestamp=32.5,temperature=29.95
timestamp=33.6,temperature=29.95
timestamp=34.7,temperature=30.42
timestamp=35.8,temperature=29.95
timestamp=36.8,temperature=29.95
timestamp=37.9,temperature=30.42
timestamp=39.0,temperature=29.95
--------ASCENDING TIME SERIES
timestamp=31.4,temperature=29.95
timestamp=32.5,temperature=29.95
timestamp=33.6,temperature=29.95
timestamp=34.7,temperature=30.42
timestamp=35.8,temperature=29.95
timestamp=36.8,temperature=29.95
timestamp=37.9,temperature=30.42
timestamp=39.0,temperature=29.95
timestamp=40.1,temperature=29.48
BOOTSELボタンを押してるあいだは最新から最古の降順で時系列データを出力します。
--------ASCENDING TIME SERIES
timestamp=150.2,temperature=30.42
timestamp=151.3,temperature=29.95
timestamp=152.4,temperature=30.42
timestamp=153.5,temperature=30.42
timestamp=154.5,temperature=29.95
timestamp=155.6,temperature=29.01
timestamp=156.7,temperature=30.42
timestamp=157.8,temperature=30.42
timestamp=158.9,temperature=30.42
--------DESCENDING TIME SERIES
timestamp=160.0,temperature=29.95
timestamp=158.9,temperature=30.42
timestamp=157.8,temperature=30.42
timestamp=156.7,temperature=30.42
timestamp=155.6,temperature=29.01
timestamp=154.5,temperature=29.95
timestamp=153.5,temperature=30.42
timestamp=152.4,temperature=30.42
timestamp=151.3,temperature=29.95
APIはシンプルに バッファの作成、アイテム追加、バッファをトラバースする カーソルのopen, カーソルのアイテム取得 の4種類だけ。アイテムは任意サイズのデータを登録できます。
void cb_create(cb_t *cb, uint32_t address, size_t length, size_t item_size,
timestamp_extractor_t get_timestamp, bool force_initialize);
void cb_append(cb_t *cb, const void *data, size_t size);
void cb_open_cursor(cb_t *cb, cb_cursor_t *cursor, cb_cursor_order_t order);
bool cb_get_next(cb_cursor_t *cursor, void *data);
二次記憶装置でうごく循環バッファを書いたことがなくていろいろ手間取ったのですが、これは網羅的に自動テストできる環境じゃないとやってられないなーと感じて用意することにしました。
Raspberry Pi Debug Probe
PicoのBOOTSELボタンを押しながらUSBに挿して、*.uf2ファイルをPicoのUSBメモリにドロップして...の手順は手軽なんですが、回数を重ねてくるとやってられなくなります。俺はmicro USBコネクタを抜き差しするマシンだうぉぉーん。そこでARM Serial Wire Debug (SWD)を使えるよう、Raspberry Pi Debug Probe1をPicoと開発マシン(macos)に接続します。
macosの開発マシンはHomebrewでクロスコンパイルする環境を用意しておきます。これでコマンドラインからプログラムの書き込み・再起動を自動化できます。接続や設定はPico Probeの商品ページ1とGetting started with Pico2のとおりです。接続といってもSWDのスルーホールにピンを差し込むだけなんですが。
刺すだけ。
プログラムの書き込み・再起動はコマンド一つ。
$ openocd -f interface/cmsis-dap.cfg -f target/rp2040.cfg \
-c "adapter speed 5000" -c "program my-project.elf verify reset exit"
ビルドするプログラムはCMakeLists.txt
でUSBへの標準出力を有効にしておきます。
pico_enable_stdio_usb(my-project 1)
ちなみにDebug ProbeとPicoを開発マシンにUSB接続すると、ttyデバイス2つが現れます。
$ ls /dev/tty.usbmodem*
/dev/tty.usbmodem1101 /dev/tty.usbmodem21302
どっちがProbeでどっちがPicoかは分かりません。ボーレート 115,200bpsでそれぞれに繋いでみて確かめましょう。
$ screen /dev/tty.usbmodem21302 115200
ちなみにPicoの標準出力を開発マシンに繋ぐにあたり、
- pico_enable_stdio_usb()している場合(Pico -(USB)-> macos)
- Raspberry Pi Debug ProbeとPicoのUARTを接続している場合(Pico -(UART)-> Debug Probe -(USB)-> macos)
と2パターンあり得ますが、前者のUSB直は手軽で良いのですが、Picoを再起動した際にttyが切れてしまうので、後者の方がテストには都合が良いです。
テストコードの記述
テストコードはプロジェクトにtests
ディレクトリを追加します。
$ ls tests
CMakeLists.txt test_create.c test_restore.c
main.c test_append.c test_cursor.c tests.h
個々のテストをtest_*.c
ファイルに記述します。
プロジェクト最上位のCMakeLists.txt
にtests
ディレクトリの情報を追加します。
add_subdirectory(tests EXCLUDE_FROM_ALL)
EXCLUDE_FROM_ALL
オプションを付与して、プロジェクト本体のビルドには含まないようにします。tests/CMakeLists.txt
はこんな感じ
set(CMAKE_BUILD_TYPE Debug)
add_executable(tests
../circular_buffer.c
main.c
test_create.c
test_append.c
test_cursor.c
test_restore.c
)
target_link_libraries(tests
hardware_adc
hardware_flash
hardware_sync
pico_stdlib
)
target_include_directories(tests
PRIVATE
${CMAKE_CURRENT_LIST_DIR}/../include
)
pico_add_extra_outputs(tests)
pico_enable_stdio_usb(tests 1)
tests/main.c
はテストのエンドポイントになるコードです。test_*.cと一緒に一つのRaspberry Pi Pico用ファームウェアtests.elf
としてビルドします。テストのお作法については割愛。プロジェクトで作成したコードのAPIを操作して期待する状態になるか assert()
して最後まで実行できたらprintf("ok")
を出力するだけです。
pico-sdkの場合、tests/CMakeLists.txt
に
set(CMAKE_BUILD_TYPE Debug)
を記述するか、cmake実行時に
$ cmake -DCMAKE_BUILD_TYPE=Debug ..
しないとassert()
が有効にならないので注意。
カスタムターゲットの追加
あとはopenocd
でテストプログラムを書き込んで標準出力を眺めるだけ...なのですが、openocdのコマンドライン引数は長すぎて覚えられません。なのでユニットテスト用のtests/CMakeLists.txt
にカスタムターゲットrun_tests
を追加します。
add_custom_target(run_tests
COMMAND ${OPENOCD} -f interface/cmsis-dap.cfg -f target/rp2040.cfg -c "adapter speed 5000" -c "program tests.elf verify reset exit"
DEPENDS tests
)
CMakeLists.txt
を編集したので、ビルド環境を作り直しましょう
$ rm -rf build
$ mkdir build
$ cd build
$ cmake ..
これであとは
$ make run_tests
するだけでテストをビルド・実行できるようになりました。今度こそテスト結果を眺めだけ。
Start all tests
create ...................ok
append ...................ok
cursor ...................ok
restore ..................ok
All tests are ok
標準出力を垂れ流しているだけで、終了コードなどはみていないので、CIで自動化して...とかはできません。必要ならttyの入力を監視するプログラムを走らせればいけそうですね。実際のテストコードはGithubのリポジトリを参照して下さい。特別なものは何も入れていません。
テストに失敗すると、
Start all tests
create ...................ok
append ...................ok
cursor ...................assertion "item.timestamp == (uint64_t)(0 + i)" failed: file "src/github.com/oyama/pico-persistent-circular-buffer/tests/test_cursor.c", line 42, function: test_cursor_descending
このような出力で失敗箇所がわかります。
ちなみに永続化循環バッファの実装は落ち度があって、フラッシュメモリの単一領域をerase/programしています。なのでeraseした瞬間にPicoが電源を失うとデータもぜんぶ失います。かわいいですね。 よりロバストでWear-Leveling機能付きなCircular Bufferを実装しました。
フラッシュメモリはデータの更新に消去・書き込みの2ステップが必要で、消去は4096バイト単位、書き込みは256バイト単位など非対称?なのが悩ましいところです。いにしえのHDDではなくフラッシュメモリに最適なデータ構造と操作アルゴリズムの勉強をしないとなーと強く思った次第です。