最近、nRF Connect SDKについてよく聞かれるんだけど、まあ、簡単なLチカ(LEDを点滅させること)みたいなサンプルを動かすだけなら、そんなに深く考えなくてもいいんだよね。でも、もう一歩進んだ、ちゃんとした製品みたいなものを作ろうとすると、途端に壁にぶつかる。正直、そこが面白いところでもあるんだけど。
特に、マルチスレッドのファームウェアとか、後から機能を更新する、いわゆるOTA(Over-The-Air)アップデート…まあDFU(Device Firmware Update)って言ったりもするけど、そういうのを考え始めると、Zephyr RTOSの「中身」をちょっとは知らないと、どうにもならなくなる。
重点一句話
ちゃんとしたIoT製品を作るなら、アプリがどうやって起動して、複数の処理をどうやって同時にこなしてるのか、その仕組みを知らないと、結局どこかで詰む。特に、ファームウェアの更新は避けて通れない道だね。
そもそも、デバイスってどうやって目を覚ますの?
電源を入れた時、デバイスがどうやって僕らが書いたプログラムを実行するのか。これ、意外と知らない人が多い。いきなり`main()`関数が呼ばれるわけじゃないんだよね。
ざっくり言うと、こんな感じ。
- まず、ハードウェアが最低限の準備をする。リセットされた直後の、本当に最初の段階。
- 次に、Zephyrのカーネルが動き出す。クロックとか、タイマーとか、基本的な機能がここで初期化される。
- で、カーネルの準備が終わったら、やっと僕らのアプリケーションの出番。スケジューラが動き出して、僕らが作ったスレッドが起動して…その中の一つとして、あの`main()`関数が呼ばれる。
ここで大事なのが、プログラムがどこに置かれているか。普通、電源を切っても消えないメモリ(不揮発性メモリ)の、決まった番地からプログラムを読み込む。nRFのチップだと、だいたい`0x00000000`番地。ここにアプリケーションを直接書き込んじゃうと、どうなるか。…そう、アップデートするたびに、物理的にデバイスを繋いで書き換えないといけなくなる。これは、現実的じゃないよね。
だから、「ブートローダー」っていう小さなプログラムを、その`0x00000000`番地に置くんだ。彼の仕事は一つ。本当のアプリケーションがどこにあるかを見つけて、それを起動してあげること。これがあるおかげで、僕らはアプリケーションだけを後から安全に入れ替えられるようになる。
NordicのSDKだと、この役割を担うのが`MCUboot`。これは一度書き込んだら変更できないようにロックされることが多くて、だから「Immutable Bootloader(不変のブートローダー)」なんて呼ばれたりもする。信頼できるブートローダーが、変なアプリを起動しないようにデジタル署名をチェックしてくれるから、セキュリティ的にも安心できるってわけ。
複数の作業を同時にこなす「マルチタスク」の考え方
IoTデバイスって、BLEで通信しながら、センサーの値を読んで、時々モーターを動かして…みたいに、同時にいろんなことをやりたい場面がすごく多い。これを実現するのがマルチタスクで、Zephyrでは主に「スレッド」と「割り込み」で成り立ってる。
- スレッド (Threads): なんて言うか、並行して動かしたい作業の単位、みたいなもの。スケジューラっていう監督役がいて、「今はこのスレッドの番」「次はこっち」ってCPU時間を割り振ってくれる。僕らが普通に作るアプリは、だいたい「プリエンプティブスレッド」っていう、他のスレッドに途中で処理を横取り(プリエンプト)されてもOKなやつ。
- 割り込み (Interrupts): これはスレッドとはちょっと違って、もっと緊急性が高いもの。タイマーの時間になったとか、外部から信号が来たとか、そういうハードウェア的なイベントで発生する。今動いているスレッドを強制的に中断して、割り込み処理が実行される。もっと優先度の高い割り込みが来たら、割り込み処理中ですら中断されることもある。
正直、割り込みハンドラの中では、あまり重い処理はしちゃダメ。`k_sleep()`みたいな時間のかかる関数を呼ぶのも御法度。本当に短い、クリティカルな処理だけを書いて、残りはスレッドに任せるのが定石だね。
データが壊れないように同期をとる方法
複数のスレッドが同じデータにアクセスすると、データの整合性が取れなくなって、めちゃくちゃなことになる。例えば、スレッドAが値を更新してる途中で、スレッドBがその中途半端な値を読み取っちゃう、とか。これを防ぐための仕組みがいくつか用意されてる。
どれを使えばいいか、正直迷うと思う。僕も最初はそうだった。だから、ちょっとまとめてみた。
| 仕組み | どんな時に使う? | 気をつけること |
|---|---|---|
| Mutex (ミューテックス) | 「この変数は、今、俺だけが使いたい」っていう時。一つのリソースを排他的にロックする感じ。 | ロックしたら、絶対に解放(アンロック)するのを忘れないこと。さもないと、他のスレッドが永遠に待ちぼうけになる(デッドロック)。 |
| Semaphore (セマフォ) | ミューテックスと似てるけど、複数のリソースを管理できる。「3つまでなら同時にアクセスしていいよ」みたいな。あとは、処理の完了を待つ合図としても使える。 | カウンターの管理がちょっとややこしいかも。最初はミューテックスで十分なことが多いかな。 |
| Message Queue / FIFO | スレッド間でデータを安全に受け渡したい時。生産者(Producer)がデータをキューに入れて、消費者(Consumer)が取り出すイメージ。 | FIFOの場合、`k_fifo_get()`でデータを取り出した後、そのデータが確保してたメモリを`k_free()`で解放しないとメモリリークする。これ、マジでやりがちなミス。 |
あと、FIFOで地味にハマるのが、同じデータ項目を2回追加しちゃうこと。内部的にリンクリストが壊れることがあるらしい。…まあ、普通はやらないと思うけど、念のため。
デバッグはどうする? 問題の見つけ方
ファームウェア開発って、半分はデバッグだよね。nRF Connect SDKをVSCodeで使ってると、かなり強力なデバッグ機能が使える。
ブレークポイントとか、変数のウォッチとかはまあ当然として、僕が特に便利だと思うのはこれ。
- Thread Viewer: これが本当に神。どのスレッドがどれくらいスタックメモリを使ってるか、一目瞭然になる。スタックオーバーフローで原因不明のクラッシュに悩まされてる時、だいたいこれで犯人が見つかる。
- Peripherals Viewer: ペリフェラル(UARTとかI2Cとか)のレジスタの値を直接覗ける。設定がちゃんと反映されてるか確認するのにすごく便利。
デバッグの実行モードにも「Halt mode」と「Monitor Mode」っていうのがあって、HaltモードはブレークポイントでCPUが完全に止まる。一方、Monitorモードは、デバッグ中もBLEの通信みたいなクリティカルな処理を止めずに続けられることがある。…まあ、設定がちょっと面倒だけど、知っておくと役立つかも。
あと、製品が市場に出てからの問題解決には、コアダンプが必須。デバイスがクラッシュした時に、その瞬間のメモリの状態を保存しておく機能。これを後から解析すれば、現場で何が起きたか推測できる。Zephyrではデフォルトで有効になってないから、自分で設定(Kconfig)を追加する必要がある。面倒だけど、やった方がいい。絶対に。
一番の難関? ファームウェア更新(DFU)の仕組み
さて、いよいよ本丸のDFU。Device Firmware Update。
さっき話したブートローダー(MCUboot)が、ここで大活躍する。ファームウェアの更新は、ざっくり2つの方法がある。
- ブートローダーレベルのDFU: デバイスをDFUモードで起動して、UARTみたいな単純な通信で新しいファームウェアを直接ブートローダーに送る方法。シンプルだけど、有線接続が必要だったりする。
- アプリケーションレベルのDFU: こっちが本命。BLEとかWi-Fi経由で、アプリが動いてる最中に新しいファームウェアをダウンロードする。ダウンロードしたファームウェアは、今のアプリとは別の場所(セカンダリスロット)に保存される。ダウンロードと検証が終わったら、再起動して、ブートローダーが新しいファームウェアに切り替えてくれる。もし更新に失敗しても、ブートローダーが古いバージョンに戻してくれる(ロールバック)から安全。
nRF Connect SDKには、このへんをうまくやってくれるライブラリが揃ってる。
- Partition Manager: メモリを「ブートローダー用」「今のアプリ用(プライマリ)」「新しいアプリ用(セカンダリ)」みたいに分割管理してくれるやつ。
- MCUmgr: BLEとかUART経由でのDFUの通信プロトコルを管理してくれる。スマホアプリとかと連携する時に使う。
- FOTA download: 指定したURLからファームウェアをダウンロードしてきてくれるライブラリ。これがあると、自前でHTTPクライアントとか書かなくていいから、めっちゃ便利。
そういえば、昔はブートローダーとアプリを別々にビルドして、手動でマージするみたいな面倒なことやってたけど、最近のSDK(v2.7.0以降かな?)から「Sysbuild」っていう仕組みが標準になった。これで、ブートローダーとアプリのビルドが一つのコマンドで完結するようになったから、すごく楽になったね。
反例と誤解釐清
ここで、よくある間違いとか、勘違いをいくつか。
- 「とりあえず`k_sleep(K_MSEC(10))`を入れとけば動く」という誤解:
これ、やりがち。でも、スリープに頼った設計は、タイミングがシビアな処理では破綻しやすい。スレッド間の通知には、セマフォとかメッセージキューを使うべき。スリープは、あくまで「何もしない時間」を作るためだけのもの。
- 「割り込み処理は速ければ速いほど良い」という誤解:
まあ、基本的には正しいんだけど、速さを追求するあまり、フラグを立てるだけ、みたいなコードを書く人がいる。でも、そのフラグをどのスレッドが、いつ処理するの?って話になる。割り込みでやるべきことと、スレッドに任せるべきことの切り分けが大事。短く、かつ、意味のある単位で処理をすること。
- 「FOTAなら何でも更新できる」という誤解:
これは特に日本で重要。Nordicの公式ドキュメントだけ読んでると見落としがちなんだけど、日本で無線機能付きの製品を売るには、技術基準適合証明(いわゆる技適)が必要。これは総務省の管轄だね。で、この技適って、一度認証されたハードウェアや無線の設定(送信出力とか)をソフトウェアで変更することを基本的に認めていない。だから、FOTAでファームウェアを更新する時は、無線に関わる部分のパラメータを絶対に変えないように、細心の注意を払って設計しないといけない。これをやらないと、法的にアウトになる可能性がある。アメリカのFCCとかとは、このへんの考え方がちょっと違うから、日本市場向けの製品を作るなら覚えておかないとダメなポイント。
結局、nRF Connect SDKとZephyrを使いこなすっていうのは、こういう地道な仕組みを一つ一つ理解していくことなんだと思う。派手さはないけど、安定した製品を作るには、こういう知識がボディブローみたいに効いてくるんだよね。
今回はこのへんかな。…PWMとかSPIとか、デバイスドライバのモデルとか、話したいことはまだあるけど、長くなるし、また今度だね。
あなたがnRF Connect SDKで開発していて、一番「これ、どうなってるの?」って思うのはどの部分ですか? マルチスレッドの同期? それとも、やっぱりDFU周り? よかったらコメントで教えてください。
