Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

micro::bit v2 Embedded Discovery Book

Rust を通してマイクロコントローラの世界を発見しましょう!

本書は、通常の C/C++ ではなく Rust を学習言語として用いる、マイクロコントローラベースの 組み込みシステムに関する入門コースです。

扱う内容

以下のトピックを扱う予定です(いずれ、そうなればと思っています):

  • 「組み込み」(Rust) プログラムの書き方、ビルド、フラッシュ、デバッグの方法。

  • マイクロコントローラで一般的に見られる機能(「ペリフェラル」): デジタル入力と出力、 パルス幅変調 (PWM)、アナログ-デジタルコンバータ (ADC)、シリアル、I2C、SPI などの 一般的な通信プロトコル、など。

  • マルチタスクの概念: 協調型 vs プリエンプティブ型マルチタスク、割り込み、スケジューラ、など。

  • 制御システムの概念: センサー、キャリブレーション、デジタルフィルタ、アクチュエータ、 開ループ制御、閉ループ制御、など。

進め方

  • 初心者向けです。マイクロコントローラや組み込みシステムの事前経験は必要ありません。

  • ハンズオンです。理論を実践に移すための演習を豊富に用意しています。 あなた が行うのは ここでの作業の大部分です。

  • ツール中心です。開発を容易にするツールを積極的に活用します。GDB を使った “本物の” デバッグやロギングも早い段階で導入します。LED をデバッグ手段として使う余地はありません。

対象外

本書の対象外となるものは次のとおりです:

  • Rust を教えること。その話題については、すでに十分な教材があります。ここではマイクロ コントローラと組み込みシステムに焦点を当てます。

  • 電気回路理論や電子工学についての包括的な教科書であること。ここでは、一部のデバイスが どのように動作するかを理解するために必要な最小限の内容だけを扱います。

  • リンカスクリプトやブートプロセスのような詳細を扱うこと。たとえば、既存のツールを使って コードをボードに書き込めるようにしますが、それらのツールの仕組みまでは詳しく扱いません。

また、この教材を他の開発ボード向けに移植するつもりもなく、本書では micro:bit 開発ボード のみを使用します。

問題の報告

本書のソースは this repository にあります。誤字やコードの問題を見つけた場合は、 issue tracker で報告してください。

その他の組み込み Rust リソース

この Discovery book は、Embedded Working Group が提供する複数の組み込み Rust リソース の 1 つにすぎません。全体の一覧は The Embedded Rust Bookshelf にあります。ここには Frequently Asked Questions の一覧も含まれています。

背景

あなたはこれから、マイクロコントローラ向けに「ベアメタル」Rustを書こうとしています。もしかすると、これまでにこのようなことをした ことは一度もないかもしれません。それは 素晴らしいこと です — すばらしい冒険へようこそ!

まずは、あなたが抱いているかもしれない基本的な疑問に答えることから始めましょう。

  • マイクロコントローラとは何ですか?

    マイクロコントローラは、1つのチップ上に実装された システム です。あなたのコンピュータが、プロセッサ、RAM、 ストレージ、Ethernetポートなどの複数の個別のコンポーネントで構成されているのに対し、マイクロコントローラはそれら すべての種類のコンポーネントを単一の「チップ」またはパッケージに内蔵しています。これにより、より少ない部品で システムを構築できます。

  • マイクロコントローラで何ができますか?

    たくさんあります! マイクロコントローラは、「組み込みシステム」として知られるものの中核です。 組み込みシステムはいたるところにありますが、普段それと意識することはあまりありません。組み込みシステムは、 あなたの衣類を洗い、文書を印刷し、食べ物を調理する機械を制御しています。組み込みシステムは、あなたが暮らし、 働く建物を快適な温度に保ち、あなたが移動に使う車両を停止・走行させる部品も制御しています。

    ほとんどの組み込みシステムは、ユーザーの介入なしに動作します。洗濯機のようにユーザーインターフェースを 備えている場合であっても、その動作の大部分は自律的に行われます。

    組み込みシステムは、物理的なプロセスを 制御 するために使われることがよくあります。これを可能にするために、 外界の状態を知らせる1つ以上のデバイス(「センサー」)と、物事を変化させることを可能にする1つ以上の デバイス(「アクチュエータ」)を備えています。たとえば、建物の空調制御システムには次のようなものがあります:

    • さまざまな場所の温度と湿度を測定するセンサー。
    • ファンの速度を制御するアクチュエータ。
    • 建物に熱を加えたり取り除いたりするアクチュエータ。
  • どのような場合にマイクロコントローラを使うべきですか?

    上に挙げた組み込みシステムの多くは、Linuxを実行するコンピュータ(たとえば「Raspberry Pi」)で 実装することもできます。それなのになぜ、代わりにマイクロコントローラを使うのでしょうか? プログラムの 開発はむしろ難しそうに思えます。

    理由としては、次のようなものがあります:

    • コスト: マイクロコントローラは、汎用コンピュータよりもはるかに安価です。マイクロコントローラ 自体が安いだけでなく、動作に必要な外付けの電気部品もずっと少なくて済みます。 そのため、プリント基板(PCB)はより小さくなり、設計や製造のコストも下がります。

    • 消費電力: ほとんどのマイクロコントローラが消費する電力は、本格的なプロセッサのごく一部です。 バッテリーで動作するアプリケーションでは、これは非常に大きな違いになります。

    • 応答性: その目的を果たすために、一部の組み込みシステムは常に限られた時間内に反応しなければ なりません(たとえば、自動車の「アンチロック」ブレーキシステム)。システムがこの種の デッドライン に 間に合わないと、壊滅的な障害が発生する可能性があります。このようなデッドラインは「ハードリアル タイム」要件と呼ばれます。そのようなデッドラインに拘束される組み込みシステムは、「ハード リアルタイムシステム」と呼ばれます。汎用コンピュータとOSには通常、多数のソフトウェアコンポーネントがあり、 コンピュータの処理リソースを共有しています。そのため、厳しい時間制約の中でプログラムの実行を 保証することが難しくなります。

    • 信頼性: ハードウェアとソフトウェアの両方で構成要素が少ないシステムでは、問題が起きる要素も それだけ少なくなります!

  • どのような場合にマイクロコントローラを使うべきではないですか?

    マイクロコントローラは、重い計算処理を行うのが得意でないことがよくあります。コストと消費電力を 低く抑えるために、マイクロコントローラで利用できる計算資源は限られています。

    マイクロコントローラは通常、「大きな」プロセッサよりも1秒あたりに実行できる命令数が少なくなります。最も低速 なものでは、1秒あたり「たった」数百万命令しか実行できない場合もあります。さらに、1命令あたりの 仕事量も一般に少なめです。マイクロコントローラは通常「32ビット」ですが、「16ビット」の ものも珍しくなく、これは典型的なRustのデータ型を扱うためにより多くの命令が必要になることを意味する場合があります。ほとんどの マイクロコントローラには「キャッシュ」がないか、あってもわずかであるため、命令は主記憶にアクセス できる速度でしか実行できません。

    一部のマイクロコントローラには、浮動小数点演算のためのハードウェアサポートがありません。そのような デバイスでは、単精度数の単純な加算ですら数百CPUサイクルかかることがあります。

    最後に、マイクロコントローラには通常、限られたメモリしかありません。メモリ容量は、プログラム命令用に16KB、 データ用に4KBしかないこともあり、この種のシステム向けプログラミングはかなり難しくなります。 単位コストおよび消費電力あたりの内部メモリ容量は絶えず増加していますが、これから扱う プロセッサでも、プログラム命令用が「わずか」512KB、データ用が256KBしかありません — それでも 「本物のコンピュータ」に比べればはるかに少ないのです。

  • なぜCではなくRustを使うのですか?

    おそらくここで私があなたを説得する必要はないでしょう。RustとCの言語としての 違いには、すでによく馴染みがあるはずだからです。ただし、ぜひ触れておきたい点が1つあります。それはパッケージ管理です。Cには 公式で広く受け入れられたパッケージ管理ソリューションがありませんが、RustにはCargoがあります。これにより、開発は はるかに 容易になります。そして、IMO、簡単なパッケージ管理はコードの再利用を促進します。ライブラリを アプリケーションへ容易に統合できるからです。これは、ライブラリがより多くの「実運用での テスト」を受けることにもつながるため、良いことでもあります。

  • なぜRustを使うべきではないのでしょうか?

    あるいは、なぜRustよりもCを選ぶべきなのでしょうか?

    Cのエコシステムはより成熟しています。いくつかの問題に対しては、既製のソリューションがすでに存在します。厳密な タイミングが求められるプロセスを制御する必要があるなら、既存の商用リアルタイム オペレーティングシステム(RTOS)のどれかを使って問題を解決できます。Rustには商用の本番運用レベルの RTOSは存在しないため(本稿執筆時点)、自分で1つ作るか、開発中のものを試すかのどちらかになります。 それらの一覧は Awesome Embedded Rust リポジトリにあります。

必要なハードウェアと知識

この本を読むうえで主に必要となる知識は、Rust を ある程度 知っていることです。ここでいう ある程度 をどのくらいか定量化するのは難しいです。ジェネリクスとトレイトの基本に慣れているとかなり役立ちます。クロージャを どう 使う かは知っておく必要があります。また、現在の Rust edition のイディオムにも慣れている必要があります。

また、この教材を進めるには次のものが必要です。

  • Micro:Bit v2(MB2)ボード。

    このボードは、Amazon や Ali Baba を含む多くの販売元から購入できます。 販売元の一覧は、MB2 の製造元である BBC から 直接入手できます。

    利用可能な V2 ボードには複数のバージョンがあります。 ここでの教材は V2.00 向けに書かれていますが、 どの V2 ボードでも問題なく動作するはずです。

  • micro-B USB ケーブル(特別なものではありません — おそらく何本も持っているでしょう)。これは、 バッテリーを使わないときに micro:bit ボードへ給電し、ボードと通信するために必要です。
    ケーブルによっては機器の充電しかできないものもあるため、 データ転送に対応していることを確認してください。

    注記 一部の micro:bit キットには、このようなケーブルが同梱されています。 他のモバイル 機器で使っている USB ケーブルでも、micro-B でデータ伝送が可能であれば使えるはずです。

    公式の micro:bit Go キットには、USB ケーブルと、USB なしで MB2 に給電するための便利なバッテリーパックの両方が含まれています。

FAQ: ちょっと待って、なぜこの特定のハードウェアが必要なのですか?

そのほうが、私にとってもあなたにとってもずっと楽になるからです。

ハードウェアの違いを気にしなくてよければ、この教材ははるかに、はるかに取り組みやすくなります。 これについては私を信じてください。

FAQ: 別の開発ボードでもこの教材を進められますか?

たぶん可能です。主に次の 2 つに依存します。あなたがマイクロコントローラーについてどれだけ これまでの経験を持っているか、そして/または あなたの開発ボード向けの高レベル crate がどこかにすでに存在するかどうかです。おそらく最低でも ここで使っている nrf52833-hal のような HAL crate は欲しいでしょう。ここで使っている microbit-v2 のような Board Support crate があるボードのほうが望ましいかもしれません。 別のマイクロコントローラーを使うつもりなら、Awesome Embedded Rust を見たり、 単に Web を検索したりして、サポートされている crate を探せます。

私の考えでは、別の開発ボードを使うと、この文章は初心者向けの親しみやすさと 「追いやすさ」の大半、あるいはほとんどすべてを失います。あらかじめ警告しておきます。

別の Arm ベースの開発ボードを持っていて、自分を完全な 初心者だとは思わないのであれば、quickstart プロジェクトテンプレートから始めることを検討してもよいでしょう。

開発環境のセットアップ

マイクロコントローラーを扱うには複数のツールが必要です。というのも、コンピューターとは異なるアーキテクチャを扱うことになり、さらに「リモート」デバイス上でプログラムを実行してデバッグする必要があるためです。

ドキュメント

とはいえ、ツールだけですべてが揃うわけではありません。ドキュメントがなければ、マイクロコントローラーを扱うことはほぼ不可能です。MB2 の公式技術ドキュメントは https://tech.microbit.org にあります。本書を通して、ほかの技術ドキュメントも参照していきます。

ツール

以下に挙げるすべてのツールを使用します。最小バージョンが指定されていないものについては、最近のバージョンであればどれでも動作するはずですが、ここではテストに使用したバージョンを記載しています。

  • Rust 1.79.0 以降のツールチェーン。

  • gdb-multiarch。これはデバッグツールです。テスト済みの最も古いバージョンは 10.2 ですが、ほかのバージョンでもおそらく動作します。お使いのディストリビューションやプラットフォームで gdb-multiarch が利用できない場合は、arm-none-eabi-gdb でも問題ありません。さらに、通常の gdb バイナリの中にも multiarch 機能付きでビルドされているものがあります。これについての詳細は、本書のデバッグの章を参照してください。

  • cargo-binutils。バージョン 0.3.6 以降。

  • probe-rs-tools。バージョン 0.24.0 以降。

  • Linux および macOS では minicom。テスト済みバージョンは 2.7.1 です。ただし、ほかのバージョンでもおそらく動作します。

  • Windows では PuTTY

次に、いくつかのツールについて OS に依存しないインストール手順に従ってください。

rustc & Cargo

https://rustup.rs の手順に従って rustup をインストールしてください。

すでに rustup をインストール済みであれば、stable チャンネルを使用しており、stable ツールチェーンが最新であることを再確認してください。rustc -V は、以下に示すもの以上に新しい日付とバージョンを返すはずです。

$ rustc -V
rustc 1.79.0 (129f3b996 2024-06-10)

cargo-binutils

$ rustup component add llvm-tools
$ cargo install cargo-binutils --vers '^0.3'
$ cargo size --version
cargo-size 0.3.6

probe-rs-tools

注記 すでに古いバージョンの probe-runprobe-rs、または cargo-embed がシステムにインストールされている場合は、この手順を始める前にそれらを削除してください。将来的に問題を引き起こす可能性があるためです。特に、probe-run はもはや公式には存在しません。必要に応じて、次を試してください。

$ cargo uninstall cargo-embed
$ cargo uninstall probe-run
$ cargo uninstall probe-rs
$ cargo uninstall probe-rs-cli

probe-rs-tools をインストールするには、https://probe.rs にアクセスし、そこにある最新のインストール手順に従ってください。

  • 注記 cargo install を使って probe-rs-tools をインストールしたい場合は、以下の手順を試すことができます。この方法では失敗が頻発したという報告がありますが、試してみるのは自由です。

    1. 最新の stable Rust にアップグレードします。

    2. probe-rs-tools バイナリの 前提条件 をインストールします。(リンク先の 手順は、より一般的な probe-rs 組み込みデバッグ ツールキットのドキュメントの一部です。)

    3. インストールを試します

      $ cargo install --locked probe-rs-tools
      

probe-rs-tools をインストールすると、probe-rscargo-embed(通常は Cargo コマンドとして実行されます)を含む、いくつかの便利なツールがインストールされます。先に進む前に、正しく動作していることを確認してください。

$ cargo embed --version
cargo-embed 0.24.0 (git commit: crates.io)

このリポジトリ

この本には、各章で使用する小規模な Rust コードベースもいくつか含まれています。これらを使う最も簡単な方法は、本のソースコードをダウンロードすることです。これは次のいずれかの方法で行えます。

  • リポジトリ にアクセスし、緑色の “Code” ボタンをクリックしてから “Download Zip” をクリックします。

  • git を使ってクローンします(git を知っているなら、おそらくすでにインストール済みでしょう)。クローン元は、Zip の方法でリンクしたものと同じリポジトリです。

OS 固有の手順

次に、使用している OS に対応する手順に従ってください。

Linux

以下は、いくつかの Linux ディストリビューション向けのインストールコマンドです。

Ubuntu 20.04 以降 / Debian 10 以降

NOTE gdb-multiarch は、Arm Cortex-M プログラムをデバッグする際に使用する GDB コマンドです。

$ sudo apt install gdb-multiarch minicom libunwind-dev

Fedora 32 以降

NOTE gdb は、Arm Cortex-M プログラムをデバッグする際に使用する GDB コマンドです。

$ sudo dnf install gdb minicom libunwind-devel

Arch Linux

NOTE gdb は、Arm Cortex-M プログラムをデバッグする際に使用する GDB コマンドです。

$ sudo pacman -S arm-none-eabi-gdb minicom libunwind

その他のディストリビューション

NOTE arm-none-eabi-gdb は、Arm Cortex-M プログラムをデバッグする際に使用する GDB コマンドです。

Arm のビルド済み ツールチェーン のパッケージがないディストリビューションでは、「Linux 64-bit」ファイルをダウンロードし、その bin ディレクトリをパスに追加してください。やり方の一例を示します。

$ mkdir -p ~/local
$ cd ~/local
$ tar xjf /path/to/downloaded/XXX.tar.bz2

次に、使用しているシェルの適切な初期化ファイル (たとえば ~/.zshrc~/.bashrc)で、好みのエディターを使って PATH に次の内容を追加します。

PATH=$PATH:$HOME/local/XXX/bin

udev ルール

これらのルールにより、micro:bit のような USB デバイスを root 権限、つまり sudo なしで使用できます。

以下の内容で、このファイルを /etc/udev/rules.d に作成してください。

$ cat /etc/udev/rules.d/69-microbit.rules
# micro:bit 用 CMSIS-DAP
ACTION!="add|change", GOTO="microbit_rules_end"
SUBSYSTEM=="usb", ATTR{idVendor}=="0d28", ATTR{idProduct}=="0204", TAG+="uaccess"
LABEL="microbit_rules_end"

次のコマンドで udev ルールを再読み込みします。

$ sudo udevadm control --reload

コンピューターにボードが接続されている場合は、いったん取り外してから再度接続するか、次の コマンドを実行してください。

$ sudo udevadm trigger

権限の確認

USB ケーブルを使って micro:bit をコンピューターに接続します。

micro:bit は /dev/bus/usb に USB デバイス(ファイル)として現れるはずです。どのように 列挙されたかを確認してみましょう。

$ lsusb | grep -i "NXP Arm mbed"
Bus 001 Device 065: ID 0d28:0204 NXP Arm mbed
$ # ^^^        ^^^

私の場合、micro:bit はバス #1 に接続され、デバイス #65 として列挙されました。これは ファイル /dev/bus/usb/001/065 micro:bit であることを意味します。ファイルの権限を確認しましょう。

$ ls -l /dev/bus/usb/001/065
crw-rw-r--+ 1 nobody nobody 189, 64 Sep  5 14:27 /dev/bus/usb/001/065

権限は crw-rw-r--+ になっているはずです。末尾の + に注意してください。そのうえで、次のコマンドを実行してアクセス権を確認します。

$ getfacl /dev/bus/usb/001/065
getfacl: Removing leadin '/' from absolute path names
# file: dev/bus/usb/001/065
# owner: nobody
# group: nobody
user::rw-
user:<YOUR-USER-NAME>:rw-
group::rw-
mask::rw-
other::r-

上の一覧に、自分のユーザー名が rw- 権限付きで表示されるはずです。そうでない場合は、 udev rules を確認し、次のコマンドで再読み込みを試してください。

$ sudo udevadm control --reload
$ sudo udevadm trigger

それでは、next section に進んでください。

Windows

arm-none-eabi-gdb

Arm は Windows 向けの .exe インストーラーを提供しています。こちら から入手して、指示に従ってください。 インストール処理が完了する直前に、“Add path to environment variable” オプションにチェックを入れるか、選択してください。その後、ツールが %PATH% に入っていることを確認してください。

$ arm-none-eabi-gcc -v
(..)
gcc version 5.4.1 20160919 (release) (..)

PuTTY

最新の putty.exeこのサイト からダウンロードし、%PATH% 内のどこかに配置してください。

それでは、次のセクション に進んでください。

macOS

すべてのツールは Homebrew を使ってインストールできます:

$ # GDB デバッガー - brew のバージョンは、すべての ARM 組み込みコアを含むすべてのアーキテクチャ向けにビルドされています
$ brew install gdb

$ # Minicom
$ brew install minicom

$ # lsusb は USB ポートを一覧表示します
$ brew install lsusb

これで完了です!next section に進んでください。

インストールを確認する

すべてのツールが正しくインストールされたことを確認しましょう。

cargo-embed の確認

まず、USB ケーブルを使って micro:bit をコンピューターに接続します。

micro:bit の USB ポートのすぐ横にあるオレンジ色の LED が少なくとも点灯するはずです。さらに、これまでに micro:bit に別のプログラムを書き込んだことがない場合は、micro:bit に最初から入っているデフォルトのプログラムが 背面の赤い LED を点滅させ始めるはずです。これらは無視してもかまいませんし、デモアプリで遊んでもかまいません。

それでは、probe-rs と、ひいては cargo-embed が micro:bit を認識できるか確認しましょう。これには、 次のコマンドを実行します:

$ probe-rs list
The following debug probes were found:
[0]: BBC micro:bit CMSIS-DAP -- 0d28:0204:990636020005282030f57fa14252d446000000006e052820 (CMSIS-DAP)

また、micro:bit のデバッグ機能についてさらに詳しい情報が必要であれば、次を実行できます:

$ probe-rs info
Probing target via JTAG

Error identifying target using protocol JTAG: The probe does not support the JTAG protocol.

Probing target via SWD

Arm Chip with debug port Default:
Debug Port: DPv1, DP Designer: Arm Ltd
├── 0 MemoryAP
│   └── ROM Table (Class 1), Designer: Nordic VLSI ASA
│       ├── Cortex-M4 SCS   (Generic IP component)
│       │   └── CPUID
│       │       ├── IMPLEMENTER: Arm Ltd
│       │       ├── VARIANT: 0
│       │       ├── PARTNO: Cortex-M4
│       │       └── REVISION: 1
│       ├── Cortex-M3 DWT   (Generic IP component)
│       ├── Cortex-M3 FBP   (Generic IP component)
│       ├── Cortex-M3 ITM   (Generic IP component)
│       ├── Cortex-M4 TPIU  (Coresight Component)
│       └── Cortex-M4 ETM   (Coresight Component)
└── 1 Unknown AP (Designer: Nordic VLSI ASA, Class: Undefined, Type: 0x0, Variant: 0x0, Revision: 0x0)


Debugging RISC-V targets over SWD is not supported. For these targets, JTAG is the only supported protocol. RISC-V specific information cannot be printed.
Debugging Xtensa targets over SWD is not supported. For these targets, JTAG is the only supported protocol. Xtensa specific information cannot be printed.

次に、この本のソースコードの src/03-setup ディレクトリにいることを確認してください。続いて、次のコマンドを実行します:

$ rustup target add thumbv7em-none-eabihf
$ cargo embed --target thumbv7em-none-eabihf

すべてが正しく動作していれば、cargo-embed はまずこのディレクトリ内の小さなサンプルプログラムを コンパイルし、その後それを書き込み、最後に Hello World を表示する見やすいテキストベースのユーザーインターフェースを 開くはずです。

(そうならない場合は、general troubleshooting の手順を確認してください。)

この出力は、今 micro:bit に書き込んだ小さな Rust プログラムから送られてきています。 すべて正常に動作しています。次の章へ進んでください!

IDE を最大限に活用する

この本のすべてのコードは、シンプルなターミナルを使ってコードをビルドし、 実行し、操作することを前提としています。また、テキストエディタについても何も前提としていません。

しかし、自動補完、型注釈、 好みのショートカットなど、さまざまな機能を提供するお気に入りの IDE があるかもしれません。このセクションでは、この本のリポジトリから取得したコードを使って、 IDE を最大限に活用する方法を説明します。

IDE の設定

以下では、この本を最大限に活用できるように IDE を設定する方法を説明します。 お使いの IDE が以下に載っていない場合は、次の 読者が最良の体験を得られるよう、この本にセクションを追加して改善してください。

IntelliJ でのビルド方法

IntelliJ のビルド設定を編集する際は、デフォルト以外の値として次の点があります。

  • コマンドを編集する必要があります。この本で cargo embed FLAGS を実行するよう指示された場合は、 デフォルト値の run をコマンド embed FLAGS に置き換える必要があります
  • “Emulate terminal in output console” を有効にする必要があります。そうしないと、プログラムがターミナルにテキストを出力できません
  • 作業ディレクトリが microbit/src/N-name になっていることを確認する必要があります。ここで N-name は、 読んでいる章のディレクトリです。src ディレクトリには cargo ファイルが含まれていないため、そこから実行することはできません。

ハードウェアを知る

これから扱うハードウェアに慣れていきましょう。

micro:bit

以下は、このボード上にある数多くのコンポーネントの一部です。

  • 1つのマイクロコントローラー
  • 複数の LED、特に背面の LED マトリクス
  • 2つのユーザーボタンと、リセットボタン(USB ポートの隣にあるもの)。
  • 1つの USB ポート。
  • 磁力計加速度計の両方の機能を持つセンサー

これらのコンポーネントの中で最も重要なのはマイクロコントローラーで(「microcontroller unit」の略で「MCU」と 呼ばれることもあります)、これは USB ポートがある側のボード上にある 2つの黒い四角のうち大きい方です。MCU がコードを実行します。 「ボードをプログラムする」という表現を目にすることもあるかもしれませんが、 実際に行っているのは、ボードに搭載されている MCU をプログラムすることです。

ボードのより詳しい説明に興味があれば、 micro:bit のウェブサイト を確認できます。

MCU は非常に重要なので、このボードに載っているものをもう少し詳しく見てみましょう。

Nordic nRF52833(「nRF52」、micro:bit v2)

この MCU には、その真下に 73 本の小さな金属製の ピン があります(いわゆる aQFN73 チップです)。 これらのピンは トレース に接続されています。トレースとは、基板上で部品同士をつなぐ配線として機能する、小さな「道路」のようなものです。 MCU は、ピンの電気的特性を動的に変化させることができます。 これは、回路内を電流がどのように流れるかを変えるという点で、照明のスイッチに似ています。 特定のピンに電流を流せるようにしたり流れないようにしたりすることで、そのピンに(トレースを介して)接続された LED をオン/オフできます。

メーカーごとに部品番号の付け方は異なりますが、多くの場合、部品番号を見るだけでその部品に関する情報をある程度読み取れます。 この MCU の部品番号を見ると、N52833 QIAAA0 2024AL とあります。おそらく肉眼では見えませんが、チップ上に記されています。 (MB2 のより新しいリビジョンを持っている場合、この番号は多少異なるかもしれません。これは問題ありません。ただし、N52833 の部分はあるはずです。) 先頭の N は、これが Nordic Semiconductor 製の部品であることを示唆しています。 その部品番号を同社のウェブサイトで調べると、すぐに product page が見つかります。 そこから、このチップの主な売り文句が「Bluetooth Low Energy and 2.4 GHz SoC」であることがわかります(SoC は「System on a Chip」の略です)。また、RF は radio frequency の略なので、これで製品名に RF が入っている理由も説明できます。 さらに、product page からたどれるこのチップのドキュメントを少し調べると、product specification が見つかります。この文書には、第 10 章「Ordering Information」があり、この奇妙なチップ名の説明に割かれています。ここから次のことがわかります。

  • N52 は MCU のシリーズであり、ほかにも nRF52 MCU があることを示しています
  • 833 は部品コードです
  • QI はパッケージコードで、aQFN73 の略です
  • AA はバリアントコードで、MCU がどれだけの RAM とフラッシュメモリを持つかを示します。今回の場合は 512 キロバイトのフラッシュと 128 キロバイトの RAM です
  • A0 はビルドコードで、ハードウェアバージョン(A)と製品構成(0)を示します
  • 2024AL はトラッキングコードなので、あなたのチップでは異なっているかもしれません

もちろん、product specification にはこのチップに関する有用な情報がほかにもたくさん含まれています。 たとえば、このチップが Arm® Cortex™-M4 32 ビット・プロセッサであることなどです。

Arm? Cortex-M4?

このチップが Nordic によって製造されているのだとしたら、Arm とは誰なのでしょうか。 そして、このチップが nRF52833 だとしたら、Cortex-M4 とは何なのでしょうか。

「Arm ベース」のチップがかなり普及している一方で、「Arm」という商標の元になっている企業(Arm Holdings)は、実際には購入できるチップを製造していないと聞くと、驚くかもしれません。 その代わり、彼らの主なビジネスモデルは、チップの一部を 設計 することです。 そして、その設計をメーカーにライセンスし、メーカー側がその設計を(独自の調整を加えることもありますが)実際に販売可能な物理ハードウェアとして実装します。 ここでの Arm の戦略は、自社のチップを設計し、さらに 製造も行う Intel のような企業とは異なります。

Arm はさまざまな設計をライセンスしています。 その中でも「Cortex-M」ファミリーの設計は、主にマイクロコントローラーのコアとして使われます。 たとえば、Cortex-M4(このチップのベースとなっているコア)は、低コストかつ低消費電力向けに設計されています。 Cortex-M7 はそれより高コストですが、その分、より多くの機能と高い性能を備えています。

幸い、この本を読むうえで、さまざまな種類のプロセッサや Cortex の設計について深く知る必要はありません。 ただ、これで自分のデバイスに関する用語について、少し理解が深まったはずです。 あなたが具体的に扱っているのは nRF52833 ですが、nRF52833 は Cortex-M の設計をベースにしているため、Cortex-M ベースのチップ向けのドキュメントを読んだりツールを使ったりすることがあるかもしれません。

Rust Embedded の用語

micro:bit のプログラミングに入る前に、今後のすべての章で重要になるライブラリと 用語を手短に見ておきましょう。

抽象化レイヤー

完全にサポートされたマイクロコントローラ、またはマイクロコントローラを搭載したボードでは、 その抽象化レベルを表す用語として、通常は次のものを耳にするでしょう。

Peripheral Access Crate (PAC)

PAC の役割は、チップのペリフェラルに対する(ある程度)安全な直接インターフェースを提供し、 すべてのビットを望むとおりに設定できるようにすることです(もちろん、誤った設定もできてしまいます)。通常、 PAC を直接扱う必要があるのは、より上位のレイヤーでは要件を満たせない場合か、 それらのためのより高水準なコードを開発している場合だけです。 当然ながら、ここで私たちが (その多くは暗黙的に)使うことになる PAC は nRF52 用のものです。

Hardware Abstraction Layer (HAL)

HAL の役割は、チップの PAC の上に構築され、このチップ固有の特殊な振る舞いをすべて 知らない人でも実際に使える抽象化を提供することです。 通常、 HAL はペリフェラル全体を単一の struct に抽象化し、たとえばそのペリフェラルを使って データをやり取りできるようにします。 私たちは nRF52-hal を使います。

Board Support Crate (BSP)

(Rust 以外では、これは通常 Board Support Package と呼ばれるため、この略称になっています。)

BSP の役割は、ボード全体(micro:bit など)をまとめて抽象化することです。 つまり、 マイクロコントローラだけでなく、そのボード上に搭載されている可能性のあるセンサーや LED なども 利用できる抽象化を提供する必要があります。 かなり多くの場合(特にカスタムメイドのボードでは)、 あらかじめ用意された BSP は存在しません。 その代わり、チップ向けの HAL を使い、 センサー用のドライバは自分で実装するか、crates.io で探すことになります。 ただ幸いなことに、micro:bit には BSP があるので、HAL に加えてそれも使っていきます。

レイヤーの統一

次に、Rust Embedded の世界で非常に中心的なソフトウェア である embedded-hal を見ていきます。 その名前が示すとおり、これは 先ほど見た第 2 の抽象化レベル、つまり HAL に関係しています。 embedded-hal の考え方は、 各 HAL における特定のペリフェラルのすべての実装で通常共有される 振る舞いを記述する trait の集合を提供することです。 たとえば、 ピンの電源をオンまたはオフにできる関数は常にあると期待するでしょう。 これは、ボード上の LED を点灯・消灯したり、そのほかの何かに使ったりするためです。

embedded-hal を使うと、たとえば温度 センサーのようなハードウェア向けのドライバを、embedded-hal trait の実装が 存在するあらゆるチップで使える形で書けます。 これは、ドライバを embedded-hal trait にのみ依存するように書くことで 実現されます。 そのように書かれたドライバは プラットフォーム非依存 と呼ばれます。 幸い、crates.io から入手するドライバのほとんどはプラットフォーム非依存です。

さらに読む

これらの抽象化レベルについてさらに学びたい場合は、Franz Skarman(別名 TheZoq2)が Oxidize 2020 でこのトピックについて講演しています: An Overview of the Embedded Rust Ecosystem

ソフトウェアに触れてみましょう

この章では、非常に単純なプログラムをいくつかビルド、実行、デバッグする方法を学びます。ここでの目標は、 MB2 Rust プログラミングの詳細に立ち入ること(まだ)ではなく、そのプロセスの仕組みに慣れることです。

まず、この本の以降の部分で使われる慣例について簡単に説明します。私たちは、次のようにして 本全体のコピーを取得することを想定しています。

git clone http://github.com/rust-embedded/discovery-mb2

この本の「ソースコード」は discovery-mb2/mdbook/src にあります。あなたのコピーでもそこへ移動して、 少し見て回ってください。各章のディレクトリには、Markdown の原文テキストと、その章にあるすべてのプログラムの 完全なソースの両方が含まれています。src/main.rs のようなパスを参照する場合、それは作業中の章を起点とした 場所を意味します。たとえば、あなたの discovery-mb2 には mdbook/src/05-meet-your-software/examples/init.rs というファイルがあります。この章では、そのファイルを単に examples/init.rs として参照します。

Rust コードには基本的に 2 種類あります。「binary」の実行可能プログラムと、「library」コードです。この本では、 library コードはそれほど大きな役割を果たしません。binary プログラムのソースコードは、いくつかの場所に置けます。

  • src/main.rs にあるプログラムは、cargo embed または cargo run によって自動的にコンパイルおよび実行されます。特別なフラグは必要ありません。

  • examples/foo.rs にあるプログラムは、cargo embed --example foo または cargo run --example foo でコンパイルおよび実行できます。

  • src/bin/bar.rs にあるプログラムは、cargo embed --bin bar または cargo run --bin bar でコンパイルおよび実行できます。

これは紛らわしいですが、Cargo の標準的な慣例です。

それでは先に進み、これらすべてを実際に扱っていきましょう。

組み込みのセットアップ

それでは、最初にコンパイルするプログラムを見てみましょう。examples/init.rs ファイルを確認してください。

#![deny(unsafe_code)]
#![no_main]
#![no_std]

use cortex_m::asm;
use cortex_m_rt::entry;
use microbit as _;
use panic_halt as _;

#[entry]
fn main() -> ! {
    #[allow(clippy::needless_late_init)]
    let _y;
    let x = 42;
    _y = x;

    // infinite loop; just so we don't leave this stack frame
    loop {
        asm::nop();
    }
}

マイクロコントローラ向けプログラムは、標準的なプログラムと 2 つの点で異なります。#![no_std]#![no_main] です。

no_std 属性は、このプログラムが基盤となる OS を前提とする std クレートを使わないことを意味します。 その代わりに、プログラムは std のサブセットであり、ベアメタルシステム(つまり、ファイルやソケットのような OS の抽象化がないシステム)上で動作できる core クレートを使います。

no_main 属性は、このプログラムが、引数を受け取るコマンドラインアプリケーション向けに作られた標準の main インターフェースを使わないことを意味します。標準の main の代わりに、cortex-m-rt クレートの entry 属性を使って、独自のエントリポイントを定義します。このプログラムでは、エントリポイントに main という名前を付けていますが、他のどんな名前でも使えます。エントリポイント関数は fn() -> ! というシグネチャでなければなりません。この型は、その関数がリターンできないことを示します。 これは、このプログラムが main から戻ることで終了することは決してない、という意味です。コンパイラが それが可能だと検出した場合、あなたのプログラムのコンパイルを拒否します。

注意深く見ていると、Cargo プロジェクト内に、隠しディレクトリになっている可能性のある .cargo ディレクトリがあることにも気づくでしょう。このディレクトリには Cargo の設定ファイル .cargo/config.toml が含まれています。

[build]
target = "thumbv7em-none-eabihf"

[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip nRF52833_xxAA"
rustflags = [
  "-C", "linker=rust-lld",
]

このファイルは、ターゲットデバイスの要件に合わせてプログラムのメモリレイアウトを調整するために、リンク処理に 手を加えています。このように変更されたリンク処理は、cortex-m-rt クレートの要件です。.cargo/config.toml ファイルは、Cargo に対して、MB2 上でコードをビルドして 実行する方法も伝えます。

ここには Embed.toml ファイルもあります。

[default.general]
chip = "nrf52833_xxAA"

[default.reset]
halt_afterwards = true

[default.rtt]
enabled = false

[default.gdb]
enabled = true

このファイルは、cargo-embed に次のことを伝えます。

  • 使用しているのは NRF52833 であること。
  • フラッシュ後にチップを停止させ、プログラムが main に入る前で止まるようにしたいこと。
  • RTT を無効にしたいこと。RTT は、チップがデバッガーにテキストを送信できるようにするプロトコルです。 RTT が実際に使われているところはすでに見ています。第 3 章で “Hello World” を送っていたのが、そのプロトコルでした。
  • GDB を有効にしたいこと。これはデバッグ手順で必要になります。

何が起きているのかを確認したので、まずはこのプログラムをビルドするところから始めましょう。

ビルドする

最初のステップは、“binary” クレートをビルドすることです。マイクロコントローラーはあなたのコンピューターとは異なるアーキテクチャを持っているため、クロスコンパイルする必要があります。Rust の世界では、クロスコンパイルは rustc または Cargo に追加の --target フラグを渡すだけで済むほど簡単です。複雑なのは、そのフラグの引数、つまりターゲットの 名前 を見つけることです。

すでに知っているとおり、micro:bit v2 のマイクロコントローラーには Cortex-M4F プロセッサーが搭載されています。rustc は Cortex-M アーキテクチャ向けのクロスコンパイル方法を知っており、そのアーキテクチャ内の異なるプロセッサーファミリーをカバーする複数のターゲットを提供しています。

  • thumbv6m-none-eabi、Cortex-M0 および Cortex-M1 プロセッサー向け
  • thumbv7m-none-eabi、Cortex-M3 プロセッサー向け
  • thumbv7em-none-eabi、Cortex-M4 および Cortex-M7 プロセッサー向け
  • thumbv7em-none-eabihf、Cortex-M4F および Cortex-M7F プロセッサー向け
  • thumbv8m.main-none-eabi、Cortex-M33 および Cortex-M35P プロセッサー向け
  • thumbv8m.main-none-eabihf、Cortex-M33F および Cortex-M35PF プロセッサー向け

ここでの “Thumb” は、コードサイズ削減のためにより小さい命令を持つ Arm 命令セットの一種を指します(しゃれです)。hf/F の部分は、ハードウェア浮動小数点アクセラレーションを意味します。これにより、小数を含む数値計算(「浮動小数点」計算)がずっと高速になります。

micro:bit v2 では、thumbv7em-none-eabihf ターゲットを使います。

クロスコンパイルの前に、ターゲット向けの標準ライブラリのプリコンパイル済みバージョン(実際にはその縮小版)をダウンロードする必要があります。これは rustup を使って行います。

$ rustup target add thumbv7em-none-eabihf

上の手順は一度だけ行えば十分です。その後、ツールチェーンを更新するたびに rustup がこのターゲットを更新します(使用する core ライブラリを含む標準ライブラリ rust-std コンポーネントを再インストールします)。したがって、verifying your setup のときに必要なターゲットをすでに追加しているなら、この手順は省略できます。

rust-std コンポーネントが配置されたので、これで Cargo を使ってプログラムをクロスコンパイルできます。Git リポジトリの mdbook/src/05-meet-your-software ディレクトリにいることを確認してから、ビルドしてください。この初期コードはサンプルなので、そのようにコンパイルします。

$ cargo build --example init
   Compiling semver-parser v0.7.0
   Compiling proc-macro2 v1.0.86
   ...

    Finished dev [unoptimized + debuginfo] target(s) in 33.67s

NOTE このクレートは必ず最適化 なし でコンパイルしてください。提供されている Cargo.toml ファイルと上記のビルドコマンドにより、cargo--release フラグを渡さない限り、最適化は無効になります。

これで、実行可能ファイルが生成されました。この実行可能ファイルはまだ LED を点滅させません。これは、この章の後半で土台として使う簡略化版にすぎません。動作確認として、生成された実行可能ファイルが実際に Arm バイナリであることを確認してみましょう。(以下のコマンドは、

readelf -h ../../../target/thumbv7em-none-eabihf/debug/examples/init

readelf があるシステムでは、これと等価です。)

$ cargo readobj --example init -- --file-headers
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Arm
  Version:                           0x1
  Entry point address:               0x117
  Start of program headers:          52 (bytes into file)
  Start of section headers:          793112 (bytes into file)
  Flags:                             0x5000400
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         4
  Size of section headers:           40 (bytes)
  Number of section headers:         21
  Section header string table index: 19

これらの数値が完全に一致しなくても心配はいりません。これらの多くは、現在のビルド環境にかなり依存します。

次は、プログラムをマイクロコントローラーに書き込みます。

フラッシュする

フラッシュとは、プログラムをマイクロコントローラの不揮発性メモリに書き込むことです。いったん フラッシュされると、マイクロコントローラは電源が入るたびに書き込まれたプログラムを実行します。

このプログラムは、マイクロコントローラのメモリ上で動く 唯一の プログラムになります。つまり、 マイクロコントローラ上ではほかに何も動いていません。OS も、「デーモン」も、何もありません。 プログラムがデバイスを完全に制御します。

バイナリ自体のフラッシュは、cargo embed のおかげでとても簡単です。

ただし、そのコマンドを実行する前に、それが実際には何をしているのかを見てみましょう。USB コネクタが 上を向くように micro:bit の側面を見ると、実はそこに黒い四角が 3 つあることに気付くはずです。 一番大きいものはスピーカーです。もう 1 つはすでに説明した MCU ですが……では、残る 1 つは 何のためにあるのでしょうか? このチップは 別の MCU で、これからプログラミングする NRF52833 に匹敵するほど高性能な NRF52820 です! このチップには主に 3 つの目的があります。

  1. USB コネクタ経由で NRF52833 MCU の電源制御とリセット制御を可能にする。
  2. MCU 用の serial to USB bridge を提供する。
  3. NRF52833 をプログラムし、デバッグするためのインターフェイスを提供する(現時点ではこれが関係する目的です)。

このチップは、コンピュータ(USB 経由で接続される)と MCU(配線パターンで接続され、SWD プロトコル を使って通信する)の間にある、一種のブリッジとして機能します。このブリッジにより、新しいバイナリを MCU にフラッシュしたり、デバッガ経由でプログラムの状態を調べたり、そのほかの便利なことを 行ったりできます。

それでは、フラッシュしてみましょう!

$ cargo embed --example init
  (...)
     Erasing sectors ✔ [00:00:00] [####################################################################################################################################################]  2.00KiB/ 2.00KiB @  4.21KiB/s (eta 0s )
 Programming pages   ✔ [00:00:00] [####################################################################################################################################################]  2.00KiB/ 2.00KiB @  2.71KiB/s (eta 0s )
    Finished flashing in 0.608s

cargo-embed は最後の行を出力したあとも終了しないことに気付くでしょう。これは意図された動作です。 次のステップ、つまりデバッグのためにこの状態が必要なので、cargo-embed を閉じないでください! さらに、cargo buildcargo embed には実際には同じフラグが渡されていることにも気付いた でしょう。これは、cargo embed が実際にはビルドを実行し、その結果できたバイナリをチップに フラッシュしているからです。つまり、今後すぐにコードをフラッシュしたい場合は、cargo build の ステップを飛ばせます。

デバッグしてみよう

この小さなプログラムをどうデバッグするのか見ていきましょう。まだ特に面白いバグはありませんが、 デバッグを学ぶにはそういうプログラムが一番です。

そもそもこれはどう動いているのでしょうか?

プログラムをデバッグする前に、ここで実際に何が起きているのかを少しだけ素早く理解しておきましょう。 前の章では、基板上の 2 つ目のチップの役割と、それが私たちのコンピュータとどのように通信するかを すでに説明しましたが、では実際にそれをどう使うのでしょうか?

Embed.toml の小さなオプション default.gdb.enabled = true によって、フラッシュ後に cargo embed がいわゆる「GDB stub」を起動するようになっています。これは、GDB が接続して 「アドレス X にブレークポイントを設定する」といったコマンドを送れるサーバーです。するとサーバーは、 そのコマンドをどう処理するかを自分で判断できます。cargo embed の GDB stub の場合は、 そのコマンドを USB 経由で 2 つ目のチップ上の「デバッグプローブ」に転送します。このチップが、 私たちの代わりに MCU とやり取りする役割を担ってくれます。

デバッグしてみましょう!

現在のシェルでは cargo-embed が動作しています。新しいシェルを開いて、プロジェクトディレクトリに 戻ることができます。そこに移動したら、まず次のようにして gdb でバイナリを開く必要があります。

$ gdb ../../../target/thumbv7em-none-eabihf/debug/examples/init

注記 インストールした GDB によっては、起動に別のコマンドを使う必要があります。 どのコマンドだったか忘れた場合は chapter 3 を確認してください。

このコマンドの ../../.. が必要なのは、各サンプルプロジェクトが書籍全体を含む「ワークスペース」の 中にあるためです。ワークスペースでは、単一の共有 target ディレクトリが使われます。詳しくは Workspaces chapter in Rust Book を参照してください。

注記 ここで cargo-embed が大量の警告を表示しても心配しないでください。現時点では GDB プロトコルを完全には実装していないため、GDB が送るすべてのコマンドを認識できないことが あります。GDB がクラッシュしない限り、問題ありません。

次に、GDB stub に接続する必要があります。デフォルトでは localhost:1337 で動作しているので、 接続するには次を実行します。

(gdb) target remote :1337
Remote debugging using :1337
0x00000116 in nrf52833_pac::{{impl}}::fmt (self=0xd472e165, f=0x3c195ff7) at /home/nix/.cargo/registry/src/github.com-1ecc6299db9ec823/nrf52833-pac-0.9.0/src/lib.rs:157
157     #[derive(Copy, Clone, Debug)]

注記 この章のリポジトリ内のサンプルは、時間の経過とともに変更される可能性があります。 そのため、行番号やその他のソースの詳細は、ここや以下に示されているものと異なる場合があります。

プログラム開始後に停止せず、次のようにプログラムのより深い場所まで進んでしまう場合は、 リセットするために monitor reset halt を実行してみてください。これは probe-rsa bug によるもので、詳しくは issue #27 を参照してください。

(gdb) target remote :1337
Remote debugging using :1337
init::__cortex_m_rt_main () at mdbook/src/05-meet-your-software/examples/init.rs:19
19              asm::nop();
(gdb) monitor reset halt
Resetting and halting target
Target halted

次にやりたいのは、プログラムの main 関数まで進むことです。これを行うには、まずそこに ブレークポイントを設定し、その後そのブレークポイントに到達するまでプログラムの実行を続けます。

(gdb) break main
Breakpoint 1 at 0x104: file src/05-meet-your-software/examples/init.rs, line 9.
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) continue
Continuing.

Breakpoint 1, init::__cortex_m_rt_main_trampoline () at src/05-meet-your-software/examples/init.rs:9
9       #[entry]

ブレークポイントは、プログラムの通常の流れを止めるために使えます。continue コマンドを使うと、 プログラムはブレークポイントに到達するまで自由に実行されます。この場合は、そこに ブレークポイントがあるため、main 関数に到達するまでです。

GDB の出力に「Breakpoint 1」と表示されていることに注意してください。プロセッサが使える ブレークポイントの数には限りがあるので、こうしたメッセージに注意を払うのは良い考えです。 もしブレークポイントが足りなくなった場合は、info break で現在のブレークポイントを一覧表示し、 delete <breakpoint-num> で不要なものを削除できます。

より快適にデバッグするために、ここでは GDB の Text User Interface (TUI) を使います。その モードに入るには、GDB のシェルで次のコマンドを入力します。

(gdb) layout src

注記 Windows ユーザーの皆さんには申し訳ありません。GNU Arm Embedded Toolchain に 同梱されている GDB は、この TUI モードをサポートしていません :-(

GDB セッション

GDB の break コマンドは、関数名だけに対して機能するわけではありません。特定の行番号でも 停止できます。13 行目で停止したい場合は、単に次のようにします。

(gdb) break 13
Breakpoint 2 at 0x110: file src/05-meet-your-software/examples/init.rs, line 13.
(gdb) continue
Continuing.

Breakpoint 2, init::__cortex_m_rt_main () at src/05-meet-your-software/examples/init.rs:13
(gdb)

いつでも、次のコマンドで TUI モードを終了できます。

(gdb) tui disable

現在、私たちは _y = x 文の「上」にいます。この文はまだ実行されていません。つまり、x は 初期化されていますが、_y には何が入っているか分かりません。print コマンドを使って x を 調べてみましょう。

(gdb) print x
$1 = 42
(gdb) print &x
$2 = (*mut i32) 0x20003fe8
(gdb)

予想どおり、x には値 42 が入っています。print &x コマンドは、変数 x のアドレスを 表示します。ここで興味深いのは、GDB の出力に参照の型が表示されていることです。*mut i32 は、 可変な i32 値へのポインタです。

プログラムの実行を 1 行ずつ進めたい場合は、next コマンドを使います。loop {} 文まで 進んでみましょう。

(gdb) next
16          loop {}

これで _y も初期化されているはずです。

(gdb) print _y
$5 = 42

ローカル変数を 1 つずつ表示する代わりに、info locals コマンドを使うこともできます。

(gdb) info locals
x = 42
_y = 42
(gdb)

loop {} 文の上で再び next を使うと、プログラムはその文を決して通過しないため、 そこで詰まってしまいます。そこで代わりに、layout asm コマンドで逆アセンブル表示に切り替え、 stepi を使って 1 命令ずつ進めます。あとで再び layout src コマンドを実行すれば、 いつでも Rust のソースコード表示に戻れます。

注記 うっかり nextcontinue コマンドを使って GDB が詰まってしまった場合は、 Ctrl+C を押せば抜け出せます。

(gdb) layout asm

GDB セッション

TUI モードを使っていない場合は、disassemble /m コマンドを使って、現在いる行の周辺を 逆アセンブルできます。

(gdb) disassemble /m
Dump of assembler code for function _ZN12init18__cortex_m_rt_main17h3e25e3afbec4e196E:
10      fn main() -> ! {
   0x0000010a <+0>:     sub     sp, #8
   0x0000010c <+2>:     movs    r0, #42 ; 0x2a

11          let _y;
12          let x = 42;
   0x0000010e <+4>:     str     r0, [sp, #0]

13          _y = x;
   0x00000110 <+6>:     str     r0, [sp, #4]

14
15          // 無限ループ。このスタックフレームから抜けないようにするためだけのもの
16          loop {}
=> 0x00000112 <+8>:     b.n     0x114 <_ZN12init18__cortex_m_rt_main17h3e25e3afbec4e196E+10>
   0x00000114 <+10>:    b.n     0x114 <_ZN12init18__cortex_m_rt_main17h3e25e3afbec4e196E+10>

End of assembler dump.

左側の太い矢印 => が見えますか?これは、プロセッサが次に実行する命令を示しています。

TUI モードに入っていない場合、stepi コマンドを実行するたびに、GDB はプロセッサが次に実行する命令 に対応する文とその行番号を表示します。

(gdb) stepi
16          loop {}
(gdb) stepi
16          loop {}

もっと興味深いものに進む前に、最後に 1 つ小技を紹介します。次のコマンドを GDB に入力してください。

(gdb) monitor reset
(gdb) c
Continuing.

Breakpoint 1, init::__cortex_m_rt_main_trampoline () at src/05-meet-your-software/src/main.rs:9
9       #[entry]
(gdb)

これで main の先頭に戻りました!

monitor reset はマイクロコントローラをリセットし、プログラムのエントリポイントで停止させます。 続く continue コマンドは、ブレークポイントが設定されている main 関数に到達するまで、プログラムをそのまま自由に実行させます。

この組み合わせは、誤って調べたかったプログラムの一部を通り過ぎてしまったときに便利です。 プログラムの状態を簡単にそのいちばん最初まで巻き戻せます。

細かい注意点: この reset コマンドは RAM をクリアしたり変更したりしません。そのメモリには前回の実行時の 値が残ります。とはいえ、通常それは問題にならないはずです。ただし、プログラムの挙動が 未初期化 変数の値に依存している場合は別です — ですが、それはまさに未定義 動作 (UB) の定義です。

このデバッグセッションはこれで終了です。quit コマンドで終えられます。

(gdb) quit
A debugging session is active.

        Inferior 1 [Remote target] will be detached.

Quit anyway? (y or n) y
Detaching from program: $PWD/target/thumbv7em-none-eabihf/debug/meet-your-software, Remote target
Ending remote debugging.
[Inferior 1 (Remote target) detached]

注記 デフォルトの GDB CLI が好みに合わない場合は、gdb-dashboard を試してみてください。これは Python を使って、デフォルトの GDB CLI をレジスタ、ソースビュー、アセンブリ ビューなどを表示するダッシュボードに変えます。

GDB で何ができるのかをもっと知りたい場合は、セクション GDB の使い方 を参照してください。

次は何でしょう?私が約束した高水準 API です。

点灯させよう

この章の最後に、MB2 上にある多数の LED のうち 1 つを点灯させます。この作業を行うために、embedded-hal が提供するトレイトの 1 つ、具体的にはピンをオンまたはオフにできる OutputPin トレイトを使います。

micro:bit の LED

micro:bit の背面には 5x5 の正方形に並んだ LED があり、通常は LED マトリクスと呼ばれます。このマトリクス配置により、各 LED を個別に駆動するために 25 本の別々のピンを使う代わりに、どの列とどの行を点灯させるかを制御するための 10 本(5+5)のピンだけで済みます。

ここでは LED を操作するために microbit-v2 クレートを使います。次の章では、利用可能なすべての選択肢を詳しく見ていきます。

実際に点灯させよう!

LED マトリクス内の LED を点灯させるために必要なコードは、実際にはかなりシンプルですが、少しだけ準備が必要です。まず src/main.rs を見て、そのあとで順を追って確認していきましょう。

#![deny(unsafe_code)]
#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use microbit::board::Board;
use panic_halt as _;

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();

    board.display_pins.col1.set_low().unwrap();
    board.display_pins.row1.set_high().unwrap();

    loop {
        core::hint::spin_loop();
    }
}

main 関数までの最初の数行では、これまでに見てきた基本的なインポートとセットアップを行っているだけです。しかし、main 関数は、これまで見てきたものとはかなり違って見えます。

最初の行は、Rust で書かれたほとんどの HAL が内部でどのように動作しているかに関係しています。 前に説明したように、これらはチップのすべての周辺機器を(Rust の意味で)所有する PAC クレートの上に構築されています。次のように書くと

let mut board = Board::take().unwrap();

PAC からそれらの周辺機器を取り出し、変数に束縛します。この具体的なケースでは、HAL だけでなく BSP 全体を扱っているため、ボード上のほかのチップの Rust 上の表現についても所有権を取得することになります。

: なぜここで unwrap() を呼ばなければならないのか疑問に思っているなら、理論上は take() が複数回呼ばれる可能性があるからです。そうなると、周辺機器が 2 つの別々の変数で表現されることになり、2 つの変数が同じリソースを変更するため、多くの混乱を招く可能性があります。これを避けるため、PAC は周辺機器を 2 回取得しようとすると panic するように実装されています。

(繰り返しになりますが、ここまでの話で混乱しているなら、次の章でもう一度、さらに詳しく説明します。)

これで、row1 ピンを high に設定することで、row1col1 に接続された LED を点灯させられます(つまりオンにします)。col1 を low のままにしておける理由は、LED マトリクスの回路の仕組みにあります。さらに、embedded-hal は、ピンのオン/オフ切り替えのような操作であっても、ハードウェアに対するあらゆる操作がエラーを返しうるように設計されています。今回のケースではその可能性は極めて低いため、結果に対して単に unwrap() を使えば十分です。

試してみる

この小さなプログラムのテストはとても簡単です。cargo embed を実行して、これまでと同じように書き込みます。次に GDB を開き、GDB スタブに接続します。

$ gdb ../../../target/thumbv7em-none-eabihf/debug/meet-your-software
(gdb) target remote :1337
Remote debugging using :1337
cortex_m_rt::Reset () at /home/nix/.cargo/registry/src/github.com-1ecc6299db9ec823/cortex-m-rt-0.6.12/src/lib.rs:489
489     pub unsafe extern "C" fn Reset() -> ! {
(gdb)

続いて、GDB の continue コマンドでプログラムを実行します。 micro:bit 前面の LED の 1 つが点灯するはずです。

Hello World

前のセクションでは、一種の「Hello World」プログラムを書きました。しかし、組み込みプログラマにとっての 「本当の Hello World」は、LED を 1 秒ごとに点滅させることです — どの LED でもかまいません。これを行う プログラムは、一般に「blinky」として知られています。

なぜ blinky なのでしょうか? それは、この単純なタスクを実行できる程度には、作業対象のボードを十分に 制御できていることを示すからです。マシンにプログラムをロードして実行できること、MCU 上の適切なピンを 見つけてオンにできること、一定時間の遅延を入れられること。ここまで制御できれば、ほかのタスクはずっと 取り組みやすくなります。

前の章では、プログラムを MB2 にロードする方法をいくつか見つけました。あとは、どのピンをオン/オフするか、 そしてそれらの操作の間にどう遅延を入れるかという問題です。

まずは、必要なピンの扱い方を調べるところから始めましょう。電子回路の「schematic」図の読み方がわかるなら、 このためにたどれる道筋があります。MB2 schematic を見つけ、その回路図上でオン/オフしたい LED を見つけ、 その LED に接続されている nRF52833 上の GPIO ピンがどれかを調べられます。(この点で MB2 は少し特殊です。 通常、LED はオン/オフを切り替える 1 本のピンにだけ接続されています。MB2 の LED「ディスプレイ」は、 LED の組み合わせを一度にオン/オフできるよう、より複雑な方法で接続されています。これは、このあとすぐに 使う機能です。)

MB2 ディスプレイの左上隅にある LED を扱います。この LED が接続されている ROW1COL1 の配線をたどると、それらが nRF52833 上の AC17/P0.21 および B11/AIN4/P0.28 とラベル付けされた ピンにつながっていることがわかります。さらにドキュメントを調べると、AC17B11 はチップの 底面にある物理ピン(実際にははんだボールです)の行と列のインデックスだとわかります — これは私たちには 役に立ちません。AIN4 は、このピンが「アナログ入力」として動作できることを意味するだけで、これも今の ところ私たちには役に立ちません。(これはあとで関係してきます。)

残るのは P0.21P0.28 です。これらのラベルは、LED を点灯させるためにオン/オフできる nRF52833 の メモリ内のビットに対応しています。電気的な理由により、ピン P0.21 をオンにして(つまり 3.3V を出力し)、 ピン P0.28 をオフにして(つまり電圧を受ける状態にすると)、LED が点灯します。

では、これを起こすにはソフトウェアで何をすればよいのでしょうか? ここでは nrf52833-hal crate のレベルで 作業します。Hardware Abstraction Layer(HAL)は、特定のマイクロコントローラーを扱いやすくするために 設計されたソフトウェアのまとまりです。名前からわかるとおり、MB2 上のマイクロコントローラー用のものが あります。そして都合のよいことに、目的の LED を点灯させるのに必要なものがすべて含まれています。

この章のディレクトリにある examples/light-up.rs を見てから、実際に実行してみてください。 前と同じように凝った方法を使ってもよいですが、次のようにして実行できるよう設定してあります。

cargo run --example light-up

とすると、プログラムがロードされて実行されます。その 1 つの LED が明るく点灯しているはずです!

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use nrf52833_hal::{gpio, pac};
use panic_halt as _;

#[entry]
fn main() -> ! {
    let peripherals = pac::Peripherals::take().unwrap();
    let p0 = gpio::p0::Parts::new(peripherals.P0);
    let _row1 = p0.p0_21.into_push_pull_output(gpio::Level::High);
    let _col1 = p0.p0_28.into_push_pull_output(gpio::Level::Low);

    #[allow(clippy::empty_loop)]
    loop {}
}

このチップ用の Peripheral Access Crate(PAC)には、HAL crate を通してアクセスしていることに注意してください。ピンにアクセスするには、少し複雑な手順が必要です。最後に、ピンは正しいレベルで初期化するだけでよいので、あらためて設定する必要はありません。ピンを切り替えて動かす話は、次のセクションのテーマです。

トグルしてみよう

LED を繰り返しオンとオフにしてみましょう。そうやって点滅させるのですよね?

examples/fast-blink.rs には、私たちの blinky の次のバージョンがあります。元の LED は点灯したままにして、その隣の LED を点滅させることにしました。これは簡単な変更です。

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use nrf52833_hal::{gpio, pac};
use panic_halt as _;

#[entry]
fn main() -> ! {
    let peripherals = pac::Peripherals::take().unwrap();
    let p0 = gpio::p0::Parts::new(peripherals.P0);
    let _row1 = p0.p0_21.into_push_pull_output(gpio::Level::High);
    let mut row2 = p0.p0_22.into_push_pull_output(gpio::Level::Low);
    let _col1 = p0.p0_28.into_push_pull_output(gpio::Level::Low);

    loop {
        row2.set_high().unwrap();
        row2.set_low().unwrap();
    }
}

ここでは、LED を点灯・消灯するために必要な Rust のトレイトを提供するために embedded-hal クレートが使われています。これは、このコードのこの部分が、私たちの HAL と同様に embedded-hal トレイトを実装している任意の Rust HAL に移植可能であることを意味します。

でも待ってください。どちらの LED も点滅していません! 2 つ目のほうが 1 つ目より少し暗いですが、どちらもしっかり点灯したままです……本当にそうでしょうか? MB2 は箱から出したそのままの状態で、1 秒あたり 64 百万 命令を実行します。内部的には、LED を点灯または消灯するのに数十命令かかると仮定しましょう。(おそらくそれくらいかかるのは debug モードでコンパイルした場合で、release モードならずっと少ないでしょう。とはいえ、ピンが状態を変えるのには少し時間がかかります。よく分かりません。) ともかく、その 2 つ目の LED は実際には 1 秒ごとに数十万回 — おそらくは数百万回 — 点灯と消灯を繰り返しています。人間の目ではとても追いつけません。

トグルの間にしばらく待つ必要があります。実は、待つことがいちばん難しいのです。

スピンウェイト

LED を点滅させるには、状態が切り替わるたびに約 0.5 秒待つ必要があります。どうすればよいでしょうか?

では、まずは間抜けなやり方があります。良いやり方ではありませんが、出発点にはなります。examples/spin-wait.rs を見てみましょう。

#![no_main]
#![no_std]

use cortex_m::asm::nop;
use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use nrf52833_hal::{gpio, pac};
use panic_halt as _;

fn wait() {
    for _ in 0..4_000_000 {
        nop();
    }
}

#[entry]
fn main() -> ! {
    let peripherals = pac::Peripherals::take().unwrap();
    let p0 = gpio::p0::Parts::new(peripherals.P0);
    let mut row1 = p0.p0_21.into_push_pull_output(gpio::Level::High);
    let _col1 = p0.p0_28.into_push_pull_output(gpio::Level::Low);

    loop {
        wait();
        row1.set_high().unwrap();
        wait();
        row1.set_low().unwrap();
    }
}

cargo run --release --example spin-wait でこれを実行してください。ここでは --release が本当に重要です。すると、MB2 の LED が だいたい 1 秒に 1 回のペースで点いたり消えたりするはずです。

気になるかもしれないこと:

  • その数値中の _ 文字は何ですか? Rust では数値の中にこれを入れることができ、無視されます。 大きな数を読みやすくするのにとても便利です。ここでは、これをカンマ(あるいは、 あなたの国で 3 桁ごとの区切りに使う任意の区切り記号)として使っています。

  • nRF52833 は 64MHz で動いているのに、なぜ待機ループは 4M 回しか反復しないのですか? 32M になるべきでは? 待機ループは、1 回まわるごとに複数の命令を実行します。nop(次の節を参照)、 いくつかの管理処理、そしてループの先頭への分岐です。生成されるコードは、最初の wait() 呼び出しでは おおよそ次のようになります

    .LBB1_4:
        adds r3, #1
        nop
        cmp  r3, r2
        bne  .LBB1_4
    

    そして 2 回目では次のようになります

    .LBB1_6:
        subs	r3, #1
        nop
        bne	.LBB1_6
    

    これは 3 命令か 4 命令しかありませんが、後方分岐には少し余計なコストがかかるかもしれません。 これらが 同じではない ことに注意してください。コンパイラは、最初と 2 回目の待機ループで異なる命令を 出力することを選んでいます。下の「状況によって変わる」を見てください。

    それでも、ループ 1 回あたり約 4 命令を実行しています。つまり、64MHz の CPU では、0.5 秒のスピンは 完了までに 64M/2/4 = 8M 回の反復が必要なはずです。ということは、何かが私たちを 2 倍遅くしています。 何かって? さあ。これ全体がひどいのです。

  • なぜ --release がそんなに重要なのですか? これなしで試してみてください。LED はまだ点いたり消えたり していますが、その周期は 何秒も になります。待機ループが最適化されておらず、1 回まわるたびに多くの命令を 実行しているためです。

  • その nop() 呼び出しとは何で、なぜそこにあるのですか? これについては次の節で答えます。

  • なぜこれを「間抜けなやり方」と呼ぶのですか?

    • 正確ではありません。 そのループを調整して毎回きっちり 0.5 秒に合わせるというのは……あまり現実的では ありません。

    • 状況によって変わります。 CPU が違う? コンパイルフラグが違う? 実際、何かが違う? それだけでタイミングが 変わります。

    • 電力を食います。 CPU は、その場にとどまるだけのために、できる限り高速に命令を実行し続けています。 ほかにやることがないなら、再び必要になるまで静かにスリープすべきです。USB 給電なら、これはそれほど 問題になりません。しかし、バッテリーパックで MB2 をつないでいると、これを本当に実感するでしょう。

次の節では、nop() について説明します。その後で、この blinky のほかに改善が必要な点についてさらに話します。

こんなに単純なプログラムにしては、これはかなり複雑なプログラムです。だからこそ、最初に blinky から始めるのです。

NOP

src/bin/spin-wait.rswait() ループ内にある nop() 呼び出しが何をしているのか、不思議に思うかもしれません。

答えは、文字どおり何もしていない、ということです。nop() 関数は、その箇所に NOP という Arm の機械命令をプログラム内に配置するようコンパイラに指示します。NOP は特別な命令で、 CPU はそれを飛ばします。無視します。文字どおり、それに対して何の操作も行いません(名前の由来もそこにあります)。

では、その行を削除してプログラムを再コンパイルしましょう。--release モードを忘れないでください。その後、実行します。

また、少し暗めの単色 LED に戻りました。ループ本体がなくなったことで、コンパイラの最適化器は wait() 関数が何もしていないと判断しました。そこで、コンパイル時にそれを丸ごと取り除いてしまったのです。ありがとう、最適化器。 おかげで私の待機ループは無限に高速になりました。

nop() はどのようにしてその役目を果たしているのでしょうか? nop() の実装を見ると、 (あちこちかなり掘り下げた先で)次のように実装されていることがわかります。

#![allow(unused)]
fn main() {
asm!("nop", options(nomem, nostack, preserves_flags));
}

nop() 関数は「インライン化」されるため、それを「呼び出す」と、その場所に実際の Arm の NOP アセンブリ命令が プログラムのコードへ挿入されます。細かい事情により、この NOP はコンパイラによって削除されたり 別の場所へ移動されたりしません。あなたが置いたその場所に、きちんと留まります。

必要な箇所にアセンブリコードをプログラムへ挿入できる能力は、組み込みプログラミングではときに非常に重要です。 CPU には、コンパイラが知らない命令が存在することがありますが、それでも CPU を効果的に使うためには それらが必要になることがあります。Rust の asm!() ディレクティブは、それを行う手段を提供します。

私たちのスピンウェイトは、まだひどいままです。もっと良くする方法について話しましょう。

タイマー

「ベアメタル」の組み込みシステムの大きな利点の 1 つは、自分のマシンで起こるあらゆることを自分で制御できることです。これにより、時間を本当に高精度に制御できます。自分でそうしない限り、何かによって処理が遅くなることはありません。

しかし、時間を本当に正確に扱いたいのであれば、おそらく助けが必要になることもこれまでに見てきました。nRF52833 のような組み込み MCU はすべて、「タイマー」という形でこの種の支援を提供しています。タイマーは、その名のとおり、時間を非常に高精度に追跡する小さな時計のように動作するペリフェラルです。

nRF52833 には 4 つのタイマーが搭載されています。チップのドキュメントを見ると、それらのセットアップや使用方法はかなり複雑であることがわかります。幸い、HAL はタイマーをラップしており、一般的な用途を簡単に扱えるようにしてくれます。タイマーの最も一般的な用途は、正確な時間だけ待機することです。これは、前のセクションの wait() 関数がまさに実現しようとしていたことです。

examples/timer-blinky.rs を見てみましょう。このコードはタイマーをセットアップし、各トグルの間に 500ms(0.5 秒)の遅延を入れるために使用しています。

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use nrf52833_hal::{gpio, pac, timer};
use panic_halt as _;

#[entry]
fn main() -> ! {
    let peripherals = pac::Peripherals::take().unwrap();

    let p0 = gpio::p0::Parts::new(peripherals.P0);
    let mut row1 = p0.p0_21.into_push_pull_output(gpio::Level::High);
    let _col1 = p0.p0_28.into_push_pull_output(gpio::Level::Low);

    let mut timer0 = timer::Timer::new(peripherals.TIMER0);

    loop {
        timer0.delay_ms(500);
        row1.set_high().unwrap();
        timer0.delay_ms(500);
        row1.set_low().unwrap();
    }
}

このコードを cargo run --release --example timer-blinky で実行し、ストップウォッチで時間を測ってみてください。オン・オフの各サイクルがちょうど 1 秒であることがわかるはずです。

気づくかもしれない点:

  • 使用している delay_ms() メソッドを利用するには、embedded_hal::Delay トレイトを使う必要があります。

  • これまでと同様に、PAC のペリフェラル構造体からそのペリフェラルを取り出して HAL に渡します。

これで、実運用レベルの blinky になりました。では、これが持つ意味について少し話しましょう。

移植性

(このセクションは任意です。next section に進んでも構いません。そこではコードを少し整理して、今日はこれで終わりにします。)

この手の凝ったエコシステムにそれだけの価値があるのか、疑問に思うかもしれません。今回の blinky のセットアップは かなり凝っていて、このような単純な仕事のために Rust のクレートや機能をたくさん使っています。

ただし、優れた利点の 1 つは、コードの移植性が非常に高くなることです。別のボードではセットアップは 異なるかもしれませんが、実際の blinky ループはまったく同じです!

Sipeed Longan Nano 向けの blinky を見てみましょう。これは 5 ドルほどの小さなボードで、MB2 と同様に、 MCU を備えた組み込みボードです。そのほかの点では、まったく異なります。プロセッサも異なり (GD32VF103 で、私たちが使っている Arm 命令セットとは完全に異なる RISC-V 命令セットを持っています)、 周辺機器も異なり、ボードも異なります。しかし、GPIO ピンに LED が接続されているので、blinky できます。

#![no_std]
#![no_main]

use panic_halt as _;
use riscv_rt::entry;
use gd32vf103xx_hal::{pac, prelude::*, delay::McycleDelay};
use embedded_hal::{blocking::delay::DelayMs, digital::v2::OutputPin};

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();

    let mut rcu = dp.RCU.configure().ext_hf_clock(8.mhz()).sysclk(108.mhz()).freeze();

    let gpioc = dp.GPIOC.split(&mut rcu);
    let mut led = gpioc.pc13.into_push_pull_output();
    let mut delay = McycleDelay::new(&rcu.clocks);

    loop {
        delay.delay_ms(500);
        led.set_high().unwrap();
        delay.delay_ms(500);
        led.set_low().unwrap();
    }
}

ここでのセットアップの違いは、一部はハードウェアが異なるためであり、また一部はこのコードが まだ embedded-hal 1.0 に更新されていない古い HAL クレートを使っているためです。それでも、メインループは 予告どおり同一で、残りのコードもかなり見覚えのあるものです。Rust の容易なクロスコンパイルと 組み込み Rust エコシステムがもたらす移植性のおかげで、blinky はどこでも blinky なのです。

すべての詳細を見たい場合や、自分のボードを手に入れて実際に試してみたい場合は、GitHub で完全に動作する nanoblinky の例を見つけることができます。

ボードサポートクレート

PAC と HAL を直接扱うのはかなり便利です。Rust がコンパイルできるほとんどの Arm MCU と、多くのそのほかの MCU には PAC クレートがあります。もしそれがないものを扱っている場合でも、PAC クレートを書くのは面倒ではありますが、かなり単純です。PAC クレートがある多くの MCU には HAL クレートもあります — これも、もし存在しなければ作るのは基本的には面倒な作業であるだけです。PAC および HAL レベルで書かれたコードでは、MCU の細かな詳細にアクセスできます。

しかし、これまで見てきたように、nRF52833 と MB2 の残りの部分とのインターフェースで何が起きているのかを把握し続けるのは、かなり厄介になってきます。オフボードのハードウェアをどう使うかを知るために、回路図などを読む必要がありました。

「ボードサポートクレート」 — Rust 以外の組み込みコミュニティでは Board Support Package (BSP) として知られています — は、ボード向けに HAL と PAC の上に構築され、詳細を隠蔽して利便性を提供するクレートです。これまで使ってきたボードサポートクレートは microbit-v2 クレートです。

microbit-v2 を使って、最終的な、整理された blinky (src/main.rs) を作ってみましょう。

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use microbit::hal::{gpio, timer};
use panic_halt as _;

#[entry]
fn main() -> ! {
    let board = microbit::Board::take().unwrap();

    let mut row1 = board
        .display_pins
        .row1
        .into_push_pull_output(gpio::Level::High);
    let _col1 = board
        .display_pins
        .col1
        .into_push_pull_output(gpio::Level::Low);

    let mut timer0 = timer::Timer::new(board.TIMER0);

    loop {
        timer0.delay_ms(500);
        row1.set_high().unwrap();
        timer0.delay_ms(500);
        row1.set_low().unwrap();
    }
}

この場合、ほとんど変更していません。ボードサポートクレートが PAC を隠してくれています(今のところは)。さらに重要なのは、LED の行および列の GPIO ピンに対して妥当な名前をそのまま使えるようにすることで、それを実現している点です。

microbit-v2 クレートは、それらの「ディスプレイ」LED に対してさらに高度なサポートも提供しています。このサポートは、近いうちに blinky よりももっと楽しいことをするために使われるのを見ていきます。

LEDルーレット

それでは、「本物の」アプリケーションを作ってみましょう。目標は、この回転するライト表示を実現することです。

LEDピンを個別に扱うのはかなり面倒なので(特に今回のように、基本的にそのほとんどすべてを 使わなければならない場合は)、前に説明した microbit-v2 BSPクレートを使って、 MB2 の LED「ディスプレイ」を扱うことができます。これは次のように動作します(examples/light-it-all.rs):

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::Timer};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

#[entry]
fn main() -> ! {
    rtt_init_print!();

    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);
    let light_it_all = [
        [1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1],
    ];

    loop {
        // Show light_it_all for 1000ms
        display.show(&mut timer, light_it_all, 1000);
        // clear the display again
        display.clear();
        timer.delay_ms(1000_u32);
    }
}

例で示した Rust 配列 light_it_all には、LED が点灯している場所に 1、消灯している場所に 0 が 入っています。show() の呼び出しには、BSP のディスプレイコードが遅延に使用するタイマー、配列の コピー、 そしてこの表示を表示してから復帰するまでの時間(ミリ秒)を渡します。

課題

これで、課題に挑む準備は万全です! 繰り返しになりますが、アプリケーションは次のようになるはずです:

ここで何が起きているのかよく分からない場合は、もっと遅いバージョンはこちらです:

ヒントが必要なら、templates/solution.rs に、仕上げるためのほぼ完成したコード片があります。とはいえ、まずは自分で試してみることをおすすめします。もう今なら できるはずです……

分かりましたか?

私の解答

どんな解答になりましたか?

これが私のものです。必要なマトリクスを生成する方法の中では、おそらく最も単純なものの 1 つです(もちろん、いちばん美しいものではありませんが)。

#![deny(unsafe_code)]
#![no_main]
#![no_std]

use cortex_m_rt::entry;
use microbit::{board::Board, display::blocking::Display, hal::Timer};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

#[rustfmt::skip]
const PIXELS: [(usize, usize); 16] = [
    (0, 0),
    (0, 1),
    (0, 2),
    (0, 3),
    (0, 4),
    (1, 4),
    (2, 4),
    (3, 4),
    (4, 4),
    (4, 3),
    (4, 2),
    (4, 1),
    (4, 0),
    (3, 0),
    (2, 0),
    (1, 0),
];

#[entry]
fn main() -> ! {
    rtt_init_print!();

    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);
    #[rustfmt::skip]
    let mut leds = [
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0],
    ];

    let mut last_led = (0, 0);

    loop {
        for current_led in PIXELS {
            leds[last_led.0][last_led.1] = 0;
            leds[current_led.0][current_led.1] = 1;
            display.show(&mut timer, leds, 200);
            last_led = current_led;
        }
    }
}

もう 1 つあります! あなたの解答が「release」モードでコンパイルした場合にも動作することを確認してください。

$ cargo embed --release

「release」モードのバイナリをデバッグしたい場合は、別の GDB コマンドを使う必要があります。

$ gdb ../../../target/thumbv7em-none-eabihf/release/led-roulette

Rust コンパイラは、コードをより高速またはより小さくしようとして、release ビルドで生成されるマシン命令を変更します(ときには大幅に)。残念ながら、その後に何が起きているのかを GDB が把握するのは困難です。その結果、GDB を使った release ビルドのデバッグは難しくなることがあります。

バイナリサイズは、常に注意しておくべきものです! あなたの解答はどれくらいの大きさでしょうか? release バイナリに対して size コマンドを使うと確認できます。

$ cargo size --release -- -A
    Finished release [optimized + debuginfo] target(s) in 0.02s
led-roulette  :
section              size        addr
.vector_table         256         0x0
.text                6332       0x100
.rodata               648      0x19bc
.data                   0  0x20000000
.bss                 1076  0x20000000
.uninit                 0  0x20000434
.debug_loc           9036         0x0
.debug_abbrev        2754         0x0
.debug_info         96460         0x0
.debug_aranges       1120         0x0
.debug_ranges       11520         0x0
.debug_str          71325         0x0
.debug_pubnames     32316         0x0
.debug_pubtypes     29294         0x0
.Arm.attributes        58         0x0
.debug_frame         2108         0x0
.debug_line         19303         0x0
.comment              109         0x0
Total              283715

コードのビルド方法によって、数値はいくらか異なる場合があります。これは問題ありません。

この出力の読み方はわかりますか? text セクションにはプログラムの命令が含まれます。rodata セクションには、プログラム命令と一緒に格納される読み取り専用データが含まれます。data および bss セクションには、RAM に静的に割り当てられた変数(static 変数)が含まれます。micro:bit 上のマイクロコントローラの仕様を覚えていれば、そのフラッシュメモリがこの非常に単純なバイナリのサイズの 2 倍にも満たないことに気づくはずです。これは本当に正しいのでしょうか? サイズ統計からわかるとおり、バイナリの大部分は実際にはデバッグ関連のセクションでできています。しかし、それらがマイクロコントローラに書き込まれることはありません。結局のところ、実行には関係ないからです。

入力とポーリング

これまでの章では、GPIO ピンを主に出力として扱い、LED を点灯・消灯してきました。しかし、GPIO ピンは入力として設定することもでき、ボタンの押下やスイッチの切り替えのような物理世界からの信号をプログラムで読み取れます。この章では、これらの入力信号を読み取り、それを使って何か役に立つことを行う方法を学びます。

ボタン状態の読み取り

micro:bit v2 には、入力として設定された GPIO ピンに接続された 2 つの物理ボタン、Button A と Button B があります。具体的には、Button A はピン P0.14 に、Button B はピン P0.23 に接続されています。(これは公式の pinmap table で確認できます。)

GPIO 入力の状態を読み取るには、そのピンの電圧レベルがハイ(3.3V、論理レベル 1)かロー(0V、論理レベル 0)かを確認します。micro:bit の各ボタンは 1 本のピンに接続されています。ボタンが 押されていない とき、そのピンはハイに保たれます。ボタンが押されると、そのピンはローに保たれます。

では、この知識を使って、ボタンが「ロー」(押下状態)かどうかを確認しながら Button A の状態を読み取ってみましょう。

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::digital::InputPin;
use microbit::Board;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();

    let mut button_a = board.buttons.button_a;
    let mut button_state = false;

    loop {
        if button_a.is_low().unwrap() {
            if !button_state {
                button_state = true;
                rprintln!("Button A pressed");
            }
        } else if button_state {
            button_state = false;
            rprintln!("Button A not pressed");
        }
    }
}

ボタンの状態を監視し続け、その状態が変化するたびに報告します。

ポーリング

GPIO 入力の読み取り方法を学んだので、次はこれらの読み取りを実際にどのように使えるかを考えてみましょう。たとえば、Button A が押されたときに LED を点灯し、Button B が押されたときに消灯するようにプログラムしたいとします。これは、ループの中で両方のボタンの状態をポーリングし、ボタンが押されていると読み取られたときにそれに応じて処理を行うことで実現できます。このプログラムは次のように書けます。

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::{InputPin, OutputPin};
use microbit::hal::timer::Timer;
use microbit::{hal::gpio, Board};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);

    // Configure buttons
    let mut button_a = board.buttons.button_a;
    let mut button_b = board.buttons.button_b;

    // Configure LED (top-left LED at row1, col1)
    let mut row1 = board
        .display_pins
        .row1
        .into_push_pull_output(gpio::Level::Low);
    let _col1 = board
        .display_pins
        .col1
        .into_push_pull_output(gpio::Level::Low);

    loop {
        let on_pressed = button_a.is_low().unwrap();
        let off_pressed = button_b.is_low().unwrap();
        match (on_pressed, off_pressed) {
            // Stay in current state until something is pressed.
            (false, false) => (),
            // Change to on state.
            (true, false) => row1.set_high().unwrap(),
            // Change to off state.
            (false, true) => row1.set_low().unwrap(),
            // Stay in current state until something is released.
            (true, true) => (),
        }
        timer.delay_ms(10_u32);
    }
}

このようにループの中で入力を繰り返し確認する方法は、ポーリングと呼ばれます。ある入力の状態を確認するとき、その入力を ポーリングしている と言います。この場合、Button A と Button B の両方をポーリングしています。

ポーリングは単純ですが、外界に応じて興味深いことを行えます。デバイスのすべての入力について、それらをループの中で「ポーリング」し、その結果に何らかの形で 1 つずつ応答できます。この種の方法は概念的に非常に単純で、多くのプロジェクトにとって良い出発点です。ポーリングがすべてのケース(あるいは大半のケース)にとって最善の方法ではないかもしれない理由は、すぐにわかるでしょう。しかし、まずは試してみましょう。

「ポーリング」という語は、粒度の異なる 2 つのレベルで使われることがよくあります。ひとつのレベルでは、「ポーリング」は、入力の状態がどうなっているかを(1 回だけ)問い合わせることを指します。より高いレベルでは、「ポーリング」、あるいは「ループの中でのポーリング」は、上で使ったような単純な制御フローで、入力の状態がどうなっているかを(繰り返し)問い合わせることを指します。このように、制御フローを指してこの語が使われるのはごく単純なプログラムだけであり、本番環境ではほとんど使われません(すぐにわかるように、実用的ではないためです)。そのため、一般に組み込みエンジニアがポーリングについて話すときは、前者、すなわち入力の状態がどうなっているかを(1 回だけ)問い合わせることを意味します。

課題

今度は、あなたがポーリングを実践する番です。課題は、ボタンのポーリングを使って、ユーザー入力に応じた方向矢印を表示するシンプルなプログラムを実装することです。

  • ボタン A が押された場合は、LED マトリックスに左向きの矢印 (←) を表示します。
  • ボタン B が押された場合は、LED マトリックスに右向きの矢印 (→) を表示します。
  • どちらのボタンも押されていない場合は、マトリックスの中央に 1 つだけ点灯した LED を表示します。

必要なことは次のとおりです。

  • LED とボタン用の変数を初期化する。
  • ボタン A とボタン B を継続的にポーリングする。
  • ボタンの状態に応じて、各状態(左、右、またはニュートラル)が明確にわかるように LED 表示を更新する。

失敗しないことを願っています! ウインカーをちゃんと出さない人と道路を共有するのは、本当に大変ですからね。

私の解答

これが私の解答です(src/main.rs 内)。おそらく、とても簡単だったでしょう。すぐに、このような単純なポーリングはあまり実用的ではないことがわかります。

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::digital::InputPin;
use microbit::{board::Board, display::blocking::Display, hal::Timer};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

// Define LED patterns
const LEFT_ARROW: [[u8; 5]; 5] = [
    [0, 0, 1, 0, 0],
    [0, 1, 0, 0, 0],
    [1, 1, 1, 1, 1],
    [0, 1, 0, 0, 0],
    [0, 0, 1, 0, 0],
];

const RIGHT_ARROW: [[u8; 5]; 5] = [
    [0, 0, 1, 0, 0],
    [0, 0, 0, 1, 0],
    [1, 1, 1, 1, 1],
    [0, 0, 0, 1, 0],
    [0, 0, 1, 0, 0],
];

const CENTER_LED: [[u8; 5]; 5] = [
    [0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0],
    [0, 0, 1, 0, 0],
    [0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0],
];

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);

    let mut display = Display::new(board.display_pins);
    let mut button_a = board.buttons.button_a;
    let mut button_b = board.buttons.button_b;

    loop {
        if button_a.is_low().unwrap() {
            display.show(&mut timer, LEFT_ARROW, 10);
        } else if button_b.is_low().unwrap() {
            display.show(&mut timer, RIGHT_ARROW, 10);
        } else {
            display.show(&mut timer, CENTER_LED, 10);
        }
    }
}

ポーリングは、実のところイマイチです

そういえば、方向指示器は普通点滅しますよね? ボタンが押されたときに方向指示器の LED を点滅させるには、プログラムをどう拡張すればよいでしょうか。 Hello World プログラムで LED を点滅させる方法はすでに知っています。LED を点灯し、しばらく待ってから消灯します。 しかし、ボタンの押下も確認しながら、これをメインループの中でどう実現すればよいのでしょうか? たとえば、次のようなことを試せます。

#![allow(unused)]
fn main() {
    loop {
        if button_a.is_low().unwrap() {
            // 左矢印を点滅
            display.show(&LEFT_ARROW);
            timer.delay_ms(500_u32);
            display.show(&BLANK);
            timer.delay_ms(500_u32);
        } else if button_b.is_low().unwrap() {
            // 右矢印を点滅
            display.show(&RIGHT_ARROW);
            timer.delay_ms(500_u32);
            display.show(&BLANK);
            timer.delay_ms(500_u32);
        } else {
            display.show(&BLANK);
        }
        timer.delay_ms(10_u32);
    }
}

問題がわかりますか? ここでは同時に 2 つのことをしようとしています。

  1. ボタンの押下を確認する
  2. LED を点滅させる

しかし、プロセッサは一度に 1 つのことしかできません。 点滅のための遅延中にボタンを押すと、遅延が終わってループが再開されるまで、プロセッサは応答できません。 その結果、ほとんど応答しないプログラムになってしまいます(実際に試して、ボタンの反応がどれだけ遅いか見てみてください)。

もっと「賢い」プログラムなら、点滅の遅延が動いている間、プロセッサは実際には何もしていないことを理解しています。 遅延が終わるのを待っている間にも、プログラムは別のことを十分に行えます。つまり、ボタンの押下を確認できるのです。

スーパーループ

組み込みシステムにおける スーパーループ という用語は、順番にいくつものことを行うメイン制御ループを指します。 これは、これまで使ってきた単純な制御フローを自然に拡張したものです。 一見すると複数のことが同時に起きているように見えるロジックを扱うには、イベントに対して十分に応答できるよう、プログラムの構造をもう少し賢く組み立てる必要があります。

方向指示器のプログラムのように、ボタンが押されている間は LED を点滅させ、ボタンが離されたら素早く点滅を止めたい場合、プログラムのさまざまな状態を表す「状態機械」を作れます。 ボタンについては 3 つの状態があります。

  1. どのボタンも押されていない
  2. ボタン A が押されている
  3. ボタン B が押されている

表示についても 3 つの状態があります。

  1. どの LED も点灯していない
  2. 表示がアクティブな点滅状態にある(LED が点灯している)
  3. 表示が非アクティブな点滅状態にある(LED は消灯しており、点滅周期が終わったら再び点灯するのを待っている)

応答性を確保する必要があるため、これらの異なる状態を組み合わせなければなりません。 プログラムのすべての状態を完全に表すと、次のようになります。

  1. どのボタンも押されていない
  2. ボタン A が押されており、アクティブな点滅状態にある(左矢印が表示に出ている)
  3. ボタン A が押されており、非アクティブな点滅状態にある(表示には何も出ていない)
  4. ボタン B が押されており、アクティブな点滅状態にある(右矢印が表示に出ている)
  5. ボタン B が押されており、非アクティブな点滅状態にある(表示には何も出ていない)

いずれかのボタンが最初に押されて、状態 (1) から状態 (2) または (4) に遷移したとき、ボタンが押された瞬間からカウントアップするタイマーカウンタを初期化します。 タイマーがあるしきい値(たとえば 0.5 秒)に達し、かつボタンがまだ押されたままであれば、それぞれ状態 (3) または (5) に遷移し、タイマーカウンタを再初期化します。 その後、タイマーが再びあるしきい値に達したら、それぞれ状態 (2) または (4) に戻ります。 状態 (2)、(3)、(4)、(5) のいずれかにいる間に、ボタンがもう押されていないことがわかった場合は、状態 (1) に戻ります。

メインのスーパーループ制御フローでは、ボタンを繰り返しポーリングし、現在のタイマーカウンタ(あれば)をしきい値と比較し、上記の条件のいずれかを満たしたら状態を変更します。

このスーパーループはデモとして実装してあります(examples/blink-held.rs)。ただし、状態機械は、ボタン A が押されている間だけ LED を点滅させるように単純化しています。

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::{InputPin, OutputPin};
use microbit::hal::timer::Timer;
use microbit::{hal::gpio, Board};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

const ON_TICKS: u16 = 25;
const OFF_TICKS: u16 = 75;

#[derive(Clone, Copy)]
enum Light {
    Lit(u16),
    Unlit(u16),
}

impl Light {
    fn flip(self) -> Self {
        match self {
            Light::Lit(_) => Light::Unlit(OFF_TICKS),
            Light::Unlit(_) => Light::Lit(ON_TICKS),
        }
    }

    fn tick_down(self) -> Self {
        match self {
            Light::Lit(ticks) => Light::Lit(ticks.max(1) - 1),
            Light::Unlit(ticks) => Light::Unlit(ticks.max(1) - 1),
        }
    }
}

#[derive(Clone, Copy)]
enum Indicator {
    Off,
    Blinking(Light),
}

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);

    // Configure buttons
    let mut button_a = board.buttons.button_a;

    // Configure LED (top-left LED at row1, col1)
    let mut row1 = board
        .display_pins
        .row1
        .into_push_pull_output(gpio::Level::Low);
    let _col1 = board
        .display_pins
        .col1
        .into_push_pull_output(gpio::Level::Low);

    let mut state = Indicator::Off;
    loop {
        let button_pressed = button_a.is_low().unwrap();
        match (button_pressed, state) {
            // Turn indicator off when no button.
            (false, _) => {
                row1.set_low().unwrap();
                state = Indicator::Off;
            }
            //
            (true, Indicator::Off) => {
                row1.set_high().unwrap();
                state = Indicator::Blinking(Light::Lit(ON_TICKS));
            }
            (true, Indicator::Blinking(light)) => match light {
                Light::Lit(0) | Light::Unlit(0) => {
                    let light = light.flip();
                    match light {
                        Light::Lit(_) => row1.set_high().unwrap(),
                        Light::Unlit(_) => row1.set_low().unwrap(),
                    }
                    state = Indicator::Blinking(light);
                }
                Light::Lit(_) | Light::Unlit(_) => {
                    state = Indicator::Blinking(light.tick_down());
                }
            },
        }
        timer.delay_ms(10_u32);
    }
}

これはまだ少し複雑です。10ms のループ遅延でも ボタンの変化を捉えるには十分すぎるほどです。

スーパーループは機能しますし、組み込みシステムでもよく使われますが、プログラマはイベントに対する高い応答性を維持できるよう注意しなければなりません。 このスーパーループのプログラムが、前の単純なポーリングの例とどう違うかに注目してください。 上に示したスーパーループにおける各状態遷移のステップは、かなり短い時間で終わるべきです(たとえば、プロセッサを長時間ブロックしてイベントを見逃す可能性のある遅延は、もはやありません)。 すべての状態遷移が高速で、比較的ブロッキングしないスーパーループへと単純なポーリングプログラムを変換するのは、必ずしも簡単ではありません。そのような場合には、同時に実行されるさまざまなイベントを処理するための別の手法に頼る必要があります。

並行性

複数のことを同時に行うことを 並行 プログラミングと呼びます。 並行性はプログラミングのさまざまな場面に現れますが、特に組み込みシステムでは重要です。 高い応答性を維持しながら周辺機器とやり取りするシステムを実装するための手法は数多くあります(たとえば、割り込み処理、協調的マルチタスク、イベントキューなど)。 これらのいくつかは後の章で見ていきます。

組み込みの文脈における並行性のよい入門が here にあるので、 先に進む前に読んでおくとよいでしょう。

ひとまず、button_a.is_low()display_pins.row1.set_high() を呼び出したときに何が起きているのかを、もう少し深く見ていきましょう。

レジスタ

この章は技術的な深掘りです。今のところは安全に読み飛ばして、あとで好きなときに戻ってきてもかまいません。 とはいえ、ここには良い内容がたくさんあるので、ぜひ掘り下げてみることをおすすめします。


display_pins.row1.set_high()button_a_pin.is_high() を呼び出すと、その裏側で何が起きているのかを探っていきましょう。

一言で言えば、display_pins.row1.set_high() の呼び出しは、いくつかの特別なメモリ領域に書き込むだけです。09-registers ディレクトリに移動し、 スターターコードを文ごとに実行してみましょう(src/main.rs)。

#![no_main]
#![no_std]

#[allow(unused_imports)]
use registers::entry;

#[entry]
fn main() -> ! {
    registers::init();

    unsafe {
        // A magic address!
        const PORT_P0_OUT: u32 = 0x50000504;

        // Turn on the top row
        *(PORT_P0_OUT as *mut u32) |= 1 << 21;

        // Turn on the bottom row
        *(PORT_P0_OUT as *mut u32) |= 1 << 19;

        // Turn off the top row
        *(PORT_P0_OUT as *mut u32) &= !(1 << 21);

        // Turn off the bottom row
        *(PORT_P0_OUT as *mut u32) &= !(1 << 19);
    }

    loop {
        core::hint::spin_loop();
    }
}

この魔法は何なのでしょうか?

アドレス 0x50000504レジスタ を指しています。レジスタとは、ペリフェラル を制御する特別なメモリ領域です。ペリフェラルとは、 マイクロコントローラのパッケージ内でプロセッサのすぐ隣にある電子回路の一部で、プロセッサに追加の機能を提供します。 結局のところ、プロセッサは単体では演算と論理処理しかできません。

この特定のレジスタは General Purpose Input/Output(GPIO)ピン を制御します(GPIO ペリフェラルです)。そして、それらの各ピンを low または high駆動 するために使用できます。

(nRF52833 には 32 本を超える GPIO がありますが、CPU は 32 ビットです。そのため、GPIO ピンは “P0” と “P1” の 2 つのグループに整理されており、各グループごとに 読み取り、書き込み、設定のためのレジスタ群があります。上のアドレスは P0 ピン用の出力レジスタのアドレスです。)

余談: LED、デジタル出力、電圧レベル

駆動? ピン? Low? High?

ピンとは電気的な接点です。私たちのマイクロコントローラにはそのような接点がいくつもあり、その一部は 発光ダイオード(LED)に接続されています。LED は電圧が加わると発光します。名前が示すとおり、 LED は「ダイオード」としても振る舞います。ダイオードは電気を一方向にしか流しません。 LED を「正方向」に接続すると光ります。「逆方向」に接続しても何も 起こりません。

幸いなことに、マイクロコントローラのピンは、LED を正しい向きで駆動できるように接続されています。 やるべきことは、LED を点灯させるのに十分な電圧をピン間に加えることだけです。LED に接続された ピンは通常、デジタル出力 として設定されており、2 種類の異なる 電圧レベルを出力できます。“low”、0 ボルト、または “high”、3 ボルトです。“high” の(電圧)レベルは LED を点灯させ、 “low” の(電圧)レベルは消灯させます。

これらの “low” と “high” の状態は、デジタル論理の概念に直接対応します。“low” は 0 または false であり、 “high” は 1 または true です。これが、このピン設定がデジタル出力として知られている理由です。

デジタル出力の反対はデジタル入力です。デジタル出力が 0 または 1 のどちらかを取り得るのと同じように、デジタル入力も 0 または 1 のどちらかです。違いは、デジタル出力は電圧を駆動できますが、デジタル入力は電圧を 読み取る ことです。マイクロコントローラが高しきい値を超える電圧レベルを読み取ると、それを 1 と解釈し、低しきい値を下回る電圧レベルを読み取ると、それを 0 と解釈します。


OK。では、このレジスタが何をするのかは、どうすれば分かるのでしょうか? RTRM(Read the Reference Manual)の時間です!

RTRM: リファレンスマニュアルを読む

これまでに、nRF52833 の GPIO ピンを見てきました。このチップでは(そして多くのほかのチップでも同様に)、GPIO ピンは ポート にグループ化されています。ポートは 2 つあり、それぞれ Port 0 と Port 1 で、略して P0P1 と呼ばれます。各ポート内のピンには 0 から始まる番号が付けられています。Port 0 には 32 本のピンがあり、P0.00 から P0.31 まで、Port 1 には 10 本のピンがあり、P1.00 から P1.09 まであります。

まず思い出す必要があるのは、どのピンがどの LED に接続されているかです。以前は回路図をたどってこれを調べました。しかし、それは実はハードモードです。必要な情報は MB2 の pinmap table にあります。

その表には次のように書かれています。

  • ROW1、つまり最上段の LED 行は、ピン P0.21 に接続されています。P0.21 は「Port 0 の Pin 21」の短縮表記です。
  • ROW5、つまり最下段の LED 行は、ピン P0.19 に接続されています。

ここまでで、最上段と最下段の行をオン/オフするには、P0.21P0.19 の状態を変更したいのだと分かりました。これらのピンは Port 0 の一部なので、設定には P0 ペリフェラルを使います。

各ペリフェラルには、それに対応するレジスタ ブロック があります。レジスタブロックとは、連続したメモリ上に割り当てられたレジスタの集合です。レジスタブロックの開始アドレスは、そのベースアドレスと呼ばれます。P0 ペリフェラルのベースアドレスが何かを調べる必要があります。その情報は、マイクロコントローラの Product Specification の次の節にあります。

Section 4.2.4 Instantiation - Page 22

その表によると、P0 レジスタブロックのベースアドレスは 0x5000_0000 です。

また、各ペリフェラルにはドキュメント内にそれぞれ専用の節があります。これらの節の末尾には、そのペリフェラルのレジスタブロックに含まれるレジスタの表があります。GPIO ファミリのペリフェラルについては、その表は次の場所にあります。

Section 6.8.2 Registers - Page 144

OUT は、セット/リセットに使用するレジスタです。P0 のベースアドレスからのオフセット値は 0x504 です。OUTProduct Specification で確認できます。

そのレジスタの仕様は、GPIO レジスタ表のすぐ下にあります。

Subsection 6.8.2.1 OUT - Page 145

とにかく、0x5000_0000 + 0x504 = 0x50000504 です。見覚えがありますね! ついに!

これが、私たちが書き込みを行っていたレジスタです。ドキュメントにはいくつか興味深いことが書かれています。まず、このレジスタは書き込みも読み出しもできます。次に、このレジスタは 32 ビットのメモリであり、各ビットが対応するピンの状態を表します。つまり、たとえば bit 19 は pin 19 に対応します。そのビットを 1 に設定するとピン出力が有効になり、0 に設定するとリセットされます。さらに、すべてのビットのリセット値が 0 なので、すべてのピン出力はデフォルトで無効になっていることも分かります。

GDB の examine コマンド x を使います。GDB サーバーの設定によっては、GDB は指定されていないメモリの読み出しを拒否します。次を実行すると、この挙動を無効にできます。

set mem inaccessible-by-default off

それでは始めましょう。まず inaccessible-by-default フラグをオフにしてから、いくつかブレークポイントを設定し、デバイスをリセットして停止します。

(gdb) set mem inaccessible-by-default off
(gdb) break 16
Breakpoint 1 at 0x172: file src/07-registers/src/main.rs, line 16.
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) break 19
Breakpoint 2 at 0x17c: file src/07-registers/src/main.rs, line 19.
(gdb) break 22
Breakpoint 3 at 0x184: file src/07-registers/src/main.rs, line 22.
(gdb) break 25
Breakpoint 4 at 0x18c: file src/07-registers/src/main.rs, line 25.
(gdb) monitor reset halt
Resetting and halting target
Target halted

よし。では最初のブレークポイント、つまり 16 行目の直前まで実行を進めて、アドレス 0x50000504 にあるレジスタの内容を表示してみましょう。

(gdb) c
Continuing.

Breakpoint 1, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:16
16              *(PORT_P0_OUT as *mut u32) |= 1 << 21;
(gdb) x 0x50000504
0x50000504:     0x00000000

この時点で、レジスタの値が 0x00000000、つまり 0 であることが分かります。これは Product Specification の記述と一致しており、そこでは 0 がこのレジスタの「リセット値」だとされています。つまり MCU がリセットされると、このレジスタの値は 0 になります。

先に進みましょう。この行はいくつかの命令(読み出し、ビット単位 OR、書き込み)から成っているので、次のブレークポイントに到達するまで、デバッガに複数回実行を継続させる必要があります。

(gdb) c
Continuing.

Program received signal SIGINT, Interrupt.
0x00000174 in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:16
16              *(PORT_P0_OUT as *mut u32) |= 1 << 21;
(gdb) c
Continuing.

Breakpoint 2, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:19
19              *(PORT_P0_OUT as *mut u32) |= 1 << 19;

19 行目の直前で停止しました。つまり、この時点で 16 行目は完全に実行されています。もう一度 OUT レジスタの内容を見てみましょう。

(gdb) x 0x50000504
0x50000504:     0x00200000

この時点での OUT レジスタの値は 0x00200000 で、10 進数では 2097152、つまり 2^21 です。これは bit 21 が 1 に設定され、それ以外のビットは 0 に設定されていることを意味します。これは 16 行目のコードと対応しており、1 << 21、つまり 1 を左に 21 ビットシフトした値を、OUT の現在の値(このときは 0)とビット単位 OR したうえで、OUT レジスタに書き込んでいます。

OUT1 << 21OUT[21]= 1)を書き込むと、P0.21ハイ になります。これにより、最上段の LED 行が オン になります。実際に最上段が点灯していることを確認してください。

(gdb) c
Continuing.

ええ、今それを言おうとしていました。では、もう一度 ‘c’ を押して次のブレークポイントまで実行を進め、その値を表示してみましょう。

Program received signal SIGINT, Interrupt.
0x0000017e in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:19
19              *(PORT_P0_OUT as *mut u32) |= 1 << 19;
(gdb) c
Continuing.

Breakpoint 3, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:22
22              *(PORT_P0_OUT as *mut u32) &= !(1 << 21);
(gdb) x 0x50000504
0x50000504:     0x00280000

19 行目では、bit 21 をそのままにして OUT の bit 19 を 1 に設定しました。その結果が 0x00280000 で、10 進数では 2621440、つまり 2^19 + 2^21 です。これは bit 19 と bit 21 の両方が 1 に設定されていることを意味します。

OUT1 << 19OUT[19]= 1)を書き込むと、P0.19ハイ になります。これにより、最下段の LED 行が オン になります。したがって、この時点で最下段が点灯しているはずです。

続く行では、これらの行を再びオフにします。まず最上段、次に最下段です。今回は、ビット単位 AND 演算にビット単位 NOT を組み合わせています。!(1 << 21) を計算すると、bit 21 以外のすべてのビットが 1 になります。次にそれを OUT の現在の値とビット単位 AND することで、ほかのビットの値を保ったまま、bit 21 だけを 0 にします。

実行を続けて、報告される OUT レジスタの値が期待どおりかどうかを確認してください。main 関数の末尾にある無限ループにデバイスが入ったら、CTRL+C を押して実行を一時停止できます。

(gdb) c
Continuing.

Program received signal SIGINT, Interrupt.
0x00000186 in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:22
22              *(PORT_P0_OUT as *mut u32) &= !(1 << 21);
(gdb) c
Continuing.

Breakpoint 4, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:25
25              *(PORT_P0_OUT as *mut u32) &= !(1 << 19);
(gdb) x 0x50000504
0x50000504:     0x00080000
(gdb) c
Continuing.

Program received signal SIGINT, Interrupt.
0x0000018e in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:25
25              *(PORT_P0_OUT as *mut u32) &= !(1 << 19);
(gdb) c
Continuing.
^C
Program received signal SIGINT, Interrupt.
0x00000196 in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:28
28          loop {}
(gdb) x 0x50000504
0x50000504:     0x00000000

そして、この時点ですべてのLEDは再び消灯しているはずです!

(誤)最適化

レジスタへの読み書きはかなり特殊です。私は、これは副作用そのものの体現だとさえ言っても よいと思っています。前の例では、同じレジスタに 4 つの異なる値を書き込みました。その アドレスがレジスタだと知らなければ、最後の値 0x00000000 をレジスタに書き込むだけにロジックを単純化してしまっていたかもしれません。

実際には、コンパイラのバックエンド / オプティマイザである LLVM は、私たちがレジスタを扱っていることを知らず、 書き込みを統合してしまうため、プログラムの動作が変わってしまいます。それを手早く確認してみましょう。

まず、cargo objdump を使って、最適化ビルドと 非最適化ビルドの両方のビルド成果物のアセンブリを取得します。

# 非最適化
cargo objdump -- --disassemble --no-show-raw-insn --source > debug.dump
# 最適化あり
cargo objdump --release -- --disassemble --no-show-raw-insn --source > release.dump

中身を見てみましょう。具体的には、OUT レジスタを操作しているアセンブリを探してみます。

まず、非最適化ビルドのアセンブリである debug.dump の内容を見てみましょう。 かなりの部分を飛ばし、; <-- の後ろに私のコメントを追加しています。これは、その命令に対応するソース コードの行番号を示しています。

$ cat debug.dump
[...]
00000158 <main>:
     158:      	push	{r7, lr}
     15a:      	mov	r7, sp
     15c:      	bl	0x160 <registers::__cortex_m_rt_main::h0b7888ca966441cf> @ imm = #0x0

00000160 <registers::__cortex_m_rt_main::h0b7888ca966441cf>:
     160:      	push	{r7, lr}
     162:      	mov	r7, sp
     164:      	sub	sp, #0x8
     166:      	bl	0x198 <registers::init::hb6346637538e8ec5> @ imm = #0x2e
     16a:      	movw	r1, #0x504        ; <-- `OUT` レジスタのアドレスの下位半分をレジスタ `r1` に読み込む
     16e:      	movt	r1, #0x5000       ; <-- `OUT` レジスタのアドレスの上位半分をレジスタ `r1` に読み込む
     172:      	str	r1, [sp, #0x4]
     174:      	ldr	r0, [r1]          ; <-- (16) `r1` にあるアドレスの値を `r0` に読み込む。
     176:      	orr	r0, r0, #0x200000 ; <-- (16) `r0` の値と `0x200000` のビット単位 OR を取り、その結果を `r0` に格納する
     17a:      	str	r0, [r1]          ; <-- (16) `r0` の内容を、`r1` にあるアドレスが指すメモリに格納する
     17c:      	ldr	r0, [r1]          ; <-- (19) `r1` にあるアドレスの値を `r0` に読み込む。
     17e:      	orr	r0, r0, #0x80000  ; <-- (19) `r0` の値と `0x80000` のビット単位 OR を取り、その結果を `r0` に格納する
     182:      	str	r0, [r1]          ; <-- (19) `r0` の内容を、`r1` にあるアドレスが指すメモリに格納する
     184:      	ldr	r0, [r1]          ; <-- (22) `r1` にあるアドレスの値を `r0` に読み込む。
     186:      	bic	r0, r0, #0x200000 ; <-- (22) `r0` の値と `0x200000` をビット反転した値のビット単位 AND を取り、その結果を `r0` に格納する
     18a:      	str	r0, [r1]          ; <-- (22) `r0` の内容を、`r1` にあるアドレスが指すメモリに格納する
     18c:      	ldr	r0, [r1]          ; <-- (25) `r1` にあるアドレスの値を `r0` に読み込む。
     18e:      	bic	r0, r0, #0x80000  ; <-- (25) `r0` の値と `0x80000` をビット反転した値のビット単位 AND を取り、その結果を `r0` に格納する
     192:      	str	r0, [r1]          ; <-- (25) `r0` の内容を、`r1` にあるアドレスが指すメモリに格納する
     194:      	b	0x196 <registers::__cortex_m_rt_main::h0b7888ca966441cf+0x36> @ imm = #-0x2
     196:      	b	0x196 <registers::__cortex_m_rt_main::h0b7888ca966441cf+0x36> @ imm = #-0x4
[...]

ご覧のとおり、非最適化アセンブリには 4 回のロード、4 回のストア、そして 4 つのビット操作 命令があります。 これらは、私たちが書いたコードにきれいに対応しています。では、 最適化されたアセンブリを見てみましょう。

$ cat release.dump
[...]
00000158 <main>:
     158:      	push	{r7, lr}
     15a:      	mov	r7, sp
     15c:      	bl	0x160 <registers::__cortex_m_rt_main::h1f38525e07b97485> @ imm = #0x0

00000160 <registers::__cortex_m_rt_main::h1f38525e07b97485>:
     160:      	push	{r7, lr}
     162:      	mov	r7, sp
     164:      	bl	0x17a <registers::init::h4390f1d4f8a071f7> @ imm = #0x12
     168:      	movw	r0, #0x504          ; <-- `OUT` レジスタのアドレスの下位半分をレジスタ `r0` に読み込む
     16c:      	movt	r0, #0x5000         ; <-- `OUT` レジスタのアドレスの上位半分をレジスタ `r0` に読み込む
     170:      	ldr	r1, [r0]                ; <-- (?) `r0` にあるアドレスの値を `r1` に読み込む。
     172:      	bic	r1, r1, #0x280000       ; <-- (?) `r1` の値と `0x280000` をビット反転した値のビット単位 AND を取り、その結果を `r1` に格納する
     176:      	str	r1, [r0]                ; <-- (?) `r0` の内容を、`r0` にあるアドレスが指すメモリに格納する
     178:      	b	0x178 <registers::__cortex_m_rt_main::h1f38525e07b97485+0x18> @ imm = #-0x4
[...]

えっ? ロード - ビット操作 - ストアが 1 回あるだけ? 今回は LED の状態が変わりませんでした! str 命令は、値をレジスタに書き込む命令です。debug(非最適化) プログラムにはそれが 4 回あり、レジスタへの各書き込みに 1 回ずつ対応していましたが、release(最適化)プログラム には 1 回しかありません。

LLVM が私たちのプログラムを誤って最適化しないようにするにはどうすればよいでしょうか? 通常の 読み書きではなく volatile 操作を使います(examples/volatile.rs):

#![no_main]
#![no_std]

use core::ptr;

#[allow(unused_imports)]
use registers::entry;

#[entry]
fn main() -> ! {
    registers::init();

    unsafe {
        // A magic address!
        const PORT_P0_OUT: u32 = 0x50000504;

        // Turn on the top row
        let out = ptr::read_volatile(PORT_P0_OUT as *mut u32);
        ptr::write_volatile(PORT_P0_OUT as *mut u32, out | 1 << 21);

        // Turn on the bottom row
        let out = ptr::read_volatile(PORT_P0_OUT as *mut u32);
        ptr::write_volatile(PORT_P0_OUT as *mut u32, out | 1 << 19);

        // Turn off the top row
        let out = ptr::read_volatile(PORT_P0_OUT as *mut u32);
        ptr::write_volatile(PORT_P0_OUT as *mut u32, out & !(1 << 21));

        // Turn off the bottom row
        let out = ptr::read_volatile(PORT_P0_OUT as *mut u32);
        ptr::write_volatile(PORT_P0_OUT as *mut u32, out & !(1 << 19));
    }

    loop {
        core::hint::spin_loop();
    }
}

では、最適化を有効にして、もう一度 cargo objdump を実行しましょう。

cargo objdump -q --release --example volatile -- --disassemble --no-show-raw-insn  > release.volatile.dump

では、中身を見てみましょう。

$ cat release.volatile.dump
[...]
00000158 <main>:
     158:      	push	{r7, lr}
     15a:      	mov	r7, sp
     15c:      	bl	0x160 <registers::__cortex_m_rt_main::h1f38525e07b97485> @ imm = #0x0

00000160 <registers::__cortex_m_rt_main::h1f38525e07b97485>:
     160:      	push	{r7, lr}
     162:      	mov	r7, sp
     164:      	bl	0x192 <registers::init::h4390f1d4f8a071f7> @ imm = #0x2a
     168:      	movw	r0, #0x504
     16c:      	movt	r0, #0x5000
     170:      	ldr	r1, [r0]
     172:      	orr	r1, r1, #0x200000
     176:      	str	r1, [r0]
     178:      	ldr	r1, [r0]
     17a:      	orr	r1, r1, #0x80000
     17e:      	str	r1, [r0]
     180:      	ldr	r1, [r0]
     182:      	bic	r1, r1, #0x200000
     186:      	str	r1, [r0]
     188:      	ldr	r1, [r0]
     18a:      	bic	r1, r1, #0x80000
     18e:      	str	r1, [r0]
     190:      	b	0x190 <registers::__cortex_m_rt_main::h1f38525e07b97485+0x30> @ imm = #-0x4
[...]

おお、見てください! これで 4 回のロード - 操作 - ストアのサイクルが戻ってきました。 GDB を使ってもう一度コードをステップ実行し、volatile 操作が実際に動作する様子を確認してください!

0xBAAAAAAD アドレス

すべてのペリフェラルメモリにアクセスできるわけではありません。このプログラム(examples/bad.rs)を見てください。

#![no_main]
#![no_std]

use core::ptr;

#[allow(unused_imports)]
use registers::entry;

#[entry]
fn main() -> ! {
    registers::init();

    unsafe {
        ptr::read_volatile(0x5000_A784 as *const u32);
    }

    loop {
        core::hint::spin_loop();
    }
}

このアドレスは前に使った OUT アドレスに近いですが、このアドレスは 無効 です。つまり、 このアドレスにはレジスタが存在しません。

では、試してみましょう。

$ cargo run
(..)
Resetting and halting target
Target halted
(gdb) continue
Continuing.

Breakpoint 1, registers::__cortex_m_rt_main_trampoline () at src/07-registers/src/main.rs:9
9	#[entry]
(gdb) continue
Continuing.

Program received signal SIGINT, Interrupt.
registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:10
10	fn main() -> ! {
(gdb) continue
Continuing.

Breakpoint 3, cortex_m_rt::HardFault_ (ef=0x2001ffb8) at src/lib.rs:1046
1046	    loop {}
(gdb) 

存在しないメモリを読むという無効な操作を行おうとしたため、プロセッサは 例外、 つまり ハードウェア 例外を発生させました。

多くの場合、例外はプロセッサが無効な操作を実行しようとしたときに発生します。 例外はプログラムの通常の流れを中断し、プロセッサに 例外ハンドラ を実行させます。 これは単なる関数/サブルーチンです。

例外にはさまざまな種類があります。各種類の例外は異なる条件で発生し、 それぞれ別の例外ハンドラによって処理されます。

registers クレートは cortex-m-rt クレートに依存しており、このクレートは HardFault_ という名前のデフォルトの ハードフォールト ハンドラを定義しています。 これは「無効なメモリアドレス」例外を処理します。embed.gdbHardFault に ブレークポイントを設定していたため、デバッガは例外ハンドラの実行中にプログラムを 停止しました。デバッガからこの例外についてさらに詳しい情報を得ることができます。 見てみましょう:

(gdb) list
1040  #[allow(unused_variables)]
1041	#[doc(hidden)]
1042	#[cfg_attr(cortex_m, link_section = ".HardFault.default")]
1043	#[no_mangle]
1044	pub unsafe extern "C" fn HardFault_(ef: &ExceptionFrame) -> ! {
1045	    #[allow(clippy::empty_loop)]
1046	    loop {}
1047	}
1048	
1049	#[doc(hidden)]
1050	#[no_mangle]

ef は、例外が発生する直前のプログラム状態のスナップショットです。これを調べてみましょう:

(gdb) print/x *ef
$1 = cortex_m_rt::ExceptionFrame {
  r0: 0x5000a784,
  r1: 0x3,
  r2: 0x2001ff24,
  r3: 0x0,
  r12: 0x1,
  lr: 0x4403,
  pc: 0x43ea,
  xpsr: 0x1000000
}

ここにはいくつかのフィールドがありますが、最も重要なのはプログラムカウンタレジスタである pc です。この レジスタ内のアドレスは、例外を発生させた命令を指しています。問題のある命令の周辺を逆アセンブルしてみましょう。

(gdb) disassemble /m ef.pc
Dump of assembler code for function core::ptr::read_volatile<u32>:
1654	pub unsafe fn read_volatile<T>(src: *const T) -> T {
   0x000043d2 <+0>:	push	{r7, lr}
   0x000043d4 <+2>:	mov	r7, sp
   0x000043d6 <+4>:	sub	sp, #16
   0x000043d8 <+6>:	str	r0, [sp, #4]
   0x000043da <+8>:	str	r0, [sp, #8]

1655	    // SAFETY: 呼び出し元は `volatile_load` の安全性契約を守らなければならない。
1656	    unsafe {
1657	        assert_unsafe_precondition!(
   0x000043dc <+10>:	b.n	0x43de <core::ptr::read_volatile<u32>+12>
   0x000043de <+12>:	ldr	r0, [sp, #4]
   0x000043e0 <+14>:	movs	r1, #4
   0x000043e2 <+16>:	bl	0x43f4 <core::ptr::read_volatile::precondition_check>
   0x000043e6 <+20>:	b.n	0x43e8 <core::ptr::read_volatile<u32>+22>

1658	            check_language_ub,
1659	            "ptr::read_volatile requires that the pointer argument is aligned and non-null",
1660	            (
1661	                addr: *const () = src as *const (),
1662	                align: usize = align_of::<T>(),
1663	            ) => is_aligned_and_not_null(addr, align)
1664	        );
1665	        intrinsics::volatile_load(src)
   0x000043e8 <+22>:	ldr	r0, [sp, #4]
   0x000043ea <+24>:	ldr	r0, [r0, #0]          ; <-- これです!
   0x000043ec <+26>:	str	r0, [sp, #12]
   0x000043ee <+28>:	ldr	r0, [sp, #12]

1666	    }
1667	}
   0x000043f0 <+30>:	add	sp, #16
   0x000043f2 <+32>:	pop	{r7, pc}

End of assembler dump.

この例外は、読み取り命令である ldr r0, [r0, #0] 命令によって引き起こされました。この命令 は、r0 CPU レジスタ が示すアドレスのメモリを読み取ろうとしました。ちなみに、CPU (プロセッサ)レジスタはメモリマップドレジスタではありません。したがって、たとえば OUT のような対応するアドレスはありません。

例外が発生したまさにその瞬間に、r0 レジスタの値が何だったのか確認できたら便利だと 思いませんか。実は、もう確認しています!先ほど表示した ef の値にある r0 フィールド が、例外発生時の r0 レジスタの値です。もう一度示します:

(gdb) print/x *ef
$1 = cortex_m_rt::ExceptionFrame {
  r0: 0x5000a784,
  r1: 0x3,
  r2: 0x2001ff24,
  r3: 0x0,
  r12: 0x1,
  lr: 0x4403,
  pc: 0x43ea,
  xpsr: 0x1000000
}

r0 には 0x5000_A784 という値が入っており、これは read_volatile 関数を呼び出すときに渡した無効なアドレスです。

離れた場所での不気味な作用

OUT は、ポート P0 のピンを制御できる唯一のレジスタではありません。OUTSET レジスタも ピンの値を変更できますし、OUTCLR も同様です。ただし、OUTSETOUTCLR では ポート P0 の現在の出力状態を取得することはできません。

OUTSETProduct Specification に次のように記載されています。

6.8.2.2 項。OUTSET - 145 ページ

次のプログラムを見てみましょう。このプログラムの要となるのは fn print_out です。この関数は、 OUT の現在の値を RTT コンソールに出力します(examples/spooky.rs)。

#![no_main]
#![no_std]

use core::ptr;

#[allow(unused_imports)]
use registers::{entry, rprintln};

// Print the current contents of P0.OUT
fn print_out() {
    const P0_OUT: u32 = 0x5000_0504;

    let out = unsafe { ptr::read_volatile(P0_OUT as *const u32) };

    rprintln!("P0.OUT = {:#08x}", out);
}

#[entry]
fn main() -> ! {
    registers::init();

    unsafe {
        // A bunch of magic addresses!
        const P0_OUTSET: u32 = 0x5000_0508;
        const P0_OUTCLR: u32 = 0x5000_050C;

        // Print the initial contents of OUT
        print_out();

        // Turn on the top LED row
        ptr::write_volatile(P0_OUTSET as *mut u32, 1 << 21);
        print_out();

        // Turn on the bottom LED row
        ptr::write_volatile(P0_OUTSET as *mut u32, 1 << 19);
        print_out();

        // Turn off the top LED row
        ptr::write_volatile(P0_OUTCLR as *mut u32, 1 << 21);
        print_out();

        // Turn off the bottom LED row
        ptr::write_volatile(P0_OUTCLR as *mut u32, 1 << 19);
        print_out();
    }

    loop {
        core::hint::spin_loop();
    }
}

このプログラムを実行すると、次のように表示されます。

$ cargo embed
# cargo-embed's console
(..)
15:13:24.055: P0.OUT = 0x000000
15:13:24.055: P0.OUT = 0x200000
15:13:24.055: P0.OUT = 0x280000
15:13:24.055: P0.OUT = 0x080000
15:13:24.055: P0.OUT = 0x000000

副作用です!実際には変更していない同じアドレスを何度も読み取っているにもかかわらず、 OUTSET または OUTCLR に書き込むたびに、その値が変化していることがわかります。

型安全な操作

P0 のレジスタの 1 つである IN レジスタは、読み取り専用レジスタとして文書化されています。

6.8.2.4 IN - 145 ページと 146 ページ

表の ‘Access’ 列では、このレジスタには ‘R’ しか示されていないことに注意してください。 このレジスタに書き込むべきではなく、さもないとまずいことが起こるかもしれません。

レジスタには異なる読み取り / 書き込み権限があります。書き込み専用のものもあれば、読み書きできるものもあり、当然、読み取り専用のものもあります。

16 進アドレスを直接扱うのもエラーの元です。すでに見たように、無効なメモリアドレスにアクセスしようとすると例外が発生し、プログラムの実行が妨げられました。

レジスタを「安全」に操作できる API があったらよいと思いませんか? 理想的には、その API は私が挙げた次の 3 点を表現しているべきです。実際のアドレスを直接いじらないこと、読み取り / 書き込み権限を尊重すること、そしてレジスタの予約済み部分の変更を防ぐことです。

実は、あります! registers::init() は実際に、P0 および P1 ポートのレジスタを型安全に操作する API を提供する値を返します。

覚えているかもしれませんが、あるペリフェラルに関連付けられたレジスタのグループはレジスタブロックと呼ばれ、連続したメモリ領域に配置されます。この型安全 API では、各レジスタブロックは struct としてモデル化され、その各フィールドが 1 つのレジスタを表します。各レジスタフィールドは、たとえば u32 に対する別々の newtype であり、読み取り / 書き込み権限に応じて readwritemodify の組み合わせのメソッドを公開します。最後に、これらのメソッドは u32 のようなプリミティブ値を受け取るのではなく、ビルダーパターンを使って構築でき、レジスタの予約済み部分の変更を防ぐさらに別の newtype を受け取ります。

この API に慣れる最良の方法は、実行中のサンプルをこれに移植することです (examples/type-safe.rs)。

#![no_main]
#![no_std]

#[allow(unused_imports)]
use registers::entry;

#[entry]
fn main() -> ! {
    let (p0, _p1) = registers::init();

    // Turn on the top row
    p0.out.modify(|_, w| w.pin21().set_bit());

    // Turn on the bottom row
    p0.out.modify(|_, w| w.pin19().set_bit());

    // Turn off the top row
    p0.out.modify(|_, w| w.pin21().clear_bit());

    // Turn off the bottom row
    p0.out.modify(|_, w| w.pin19().clear_bit());

    loop {
        core::hint::spin_loop();
    }
}

最初に気づくのは、マジックアドレスが一切出てこないことです。代わりに、P0 ポートのレジスタブロック内の OUT レジスタを参照するために、p0.out という、より人にとってわかりやすい方法を使います。

このレジスタブロックにはクロージャを受け取る modify メソッドがあります。このクロージャが呼び出される前に、OUT レジスタの値が読み取られ、その値が r パラメータとしてクロージャに渡されます。r の値をもとに、そのメソッドを使って w をレジスタの望ましい新しい値へ操作できます。クロージャが返ると、その結果がレジスタに書き込まれます。今回のケースでは、レジスタの現在の値も w パラメータに渡されるため、レジスタの残りのビットをそのままにしておきたい場合は、w だけを操作すれば済みます。

modify メソッドは、書き込みと読み取りの両方を許可するレジスタに対して定義されています。レジスタの値を読み取るだけで更新したくない場合は、read メソッドを使えます。あるいは、読み取らずに単にレジスタ値を書き込みたい場合は、write メソッドがあります。

読み取り専用レジスタは read だけを公開し、書き込み専用レジスタは write だけを公開します。これにより、許可されていない方法でユーザーがレジスタへアクセスすることを防げるため、呼び出しを unsafe ブロックで囲む必要はありません。また、正確なレジスタアドレスやビット位置を自分で突き止める必要もありません!

このプログラムを実行してみましょう! プログラムをデバッグしている にできる、興味深いことがいくつかあります。

p0P0 ポートのレジスタブロックへの参照です。print p0 は レジスタブロックのベースアドレスを返し、print *p0 はその値を表示します。

$ cargo run
(..)
Target halted
(gdb) set mem inaccessible-by-default off
(gdb) break main.rs:12
Breakpoint 4 at 0x162: main.rs:12. (2 locations)
(gdb) continue
Continuing.

Program received signal SIGINT, Interrupt.
cortex_m_rt::DefaultPreInit () at src/lib.rs:1058
1058	pub unsafe extern "C" fn DefaultPreInit() {}
(gdb) continue
Continuing.

Breakpoint 1, registers::__cortex_m_rt_main_trampoline () at src/07-registers/src/main.rs:7
7	#[entry]
(gdb) continue
Continuing.

Program received signal SIGINT, Interrupt.
registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:8
8	fn main() -> ! {
(gdb) continue
Continuing.

Breakpoint 4.2, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:12
12	    p0.out.modify(|_, w| w.pin21().set_bit());
(gdb) print *p0                                               ; ⬅️ ここで `*p0` を表示します!
$1 = nrf52833_pac::p0::RegisterBlock {
  _reserved0: [0 <repeats 1284 times>],
  out: nrf52833_pac::generic::Reg<nrf52833_pac::p0::out::OUT_SPEC> {
    register: vcell::VolatileCell<u32> {
      value: core::cell::UnsafeCell<u32> {
        value: 0
      }
    },
    _marker: core::marker::PhantomData<nrf52833_pac::p0::out::OUT_SPEC>
  },
  outset: nrf52833_pac::generic::Reg<nrf52833_pac::p0::outset::OUTSET_SPEC> {
    register: vcell::VolatileCell<u32> {
      value: core::cell::UnsafeCell<u32> {
        value: 0
      }
    },
    _marker: core::marker::PhantomData<nrf52833_pac::p0::outset::OUTSET_SPEC>
  },
  outclr: nrf52833_pac::generic::Reg<nrf52833_pac::p0::outclr::OUTCLR_SPEC> {
    register: vcell::VolatileCell<u32> {
      value: core::cell::UnsafeCell<u32> {
        value: 0
      }
    },
    _marker: core::marker::PhantomData<nrf52833_pac::p0::outclr::OUTCLR_SPEC>
  },
  in_: nrf52833_pac::generic::Reg<nrf52833_pac::p0::in_::IN_SPEC> {
    register: vcell::VolatileCell<u32> {
      value: core::cell::UnsafeCell<u32> {
        value: 0
      }
    },
    _marker: core::marker::PhantomData<nrf52833_pac::p0::in_::IN_SPEC>
  },
  dir: nrf52833_pac::generic::Reg<nrf52833_pac::p0::dir::DIR_SPEC> {
    register: vcell::VolatileCell<u32> {
      value: core::cell::UnsafeCell<u32> {
        value: 3513288704
      }
    },
    _marker: core::marker::PhantomData<nrf52833_pac::p0::dir::DIR_SPEC>
  },
  dirset: nrf52833_pac::generic::Reg<nrf52833_pac::p0::dirset::DIRSET_SPEC> {
    register: vcell::VolatileCell<u32> {
      value: core::cell::UnsafeCell<u32> {
        value: 3513288704
      }
    },
    _marker: core::marker::PhantomData<nrf52833_pac::p0::dirset::DIRSET_SPEC>
  },
  dirclr: nrf52833_pac::generic::Reg<nrf52833_pac::p0::dirclr::DIRCLR_SPEC> {
    register: vcell::VolatileCell<u32> {
      value: core::cell::UnsafeCell<u32> {
        value: 3513288704
      }
    },
    _marker: core::marker::PhantomData<nrf52833_pac::p0::dirclr::DIRCLR_SPEC>
  },
  latch: nrf52833_pac::generic::Reg<nrf52833_pac::p0::latch::LATCH_SPEC> {
    register: vcell::VolatileCell<u32> {
      value: core::cell::UnsafeCell<u32> {
        value: 0
      }
    },
    _marker: core::marker::PhantomData<nrf52833_pac::p0::latch::LATCH_SPEC>
  },
  detectmode: nrf52833_pac::generic::Reg<nrf52833_pac::p0::detectmode::DETECTMODE_SPEC> {
    register: vcell::VolatileCell<u32> {
      value: core::cell::UnsafeCell<u32> {
        value: 0
      }
    },
    _marker: core::marker::PhantomData<nrf52833_pac::p0::detectmode::DETECTMODE_SPEC>
  },
  _reserved9: [0 <repeats 472 times>],
  pin_cnf: [nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    } <repeats 11 times>, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 3
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
--Type <RET> for more, q to quit, c to continue without paging--c
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 3
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 3
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 3
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 3
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 3
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 3
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 2
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 3
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
      register: vcell::VolatileCell<u32> {
        value: core::cell::UnsafeCell<u32> {
          value: 3
        }
      },
      _marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
    }]
}

こうした newtype やクロージャは、巨大で肥大化したプログラムを生成しそうに聞こえるかもしれません。しかし実際には、LTO を有効にしてプログラムをリリースモードでコンパイルすると、write_volatile と16進数アドレスを使っていた「unsafe」版とまったく同じ命令が生成されていることがわかります。

cargo objdump を使ってアセンブリコードを release.type-safe.dump に出力します:

cargo objdump -q --release --example type-safe -- --disassemble --no-show-raw-insn  > release.type-safe.dump

次に、release.type-safe.dump の中で main を検索します

00000158 <main>:
     158:      	push	{r7, lr}
     15a:      	mov	r7, sp
     15c:      	bl	0x160 <registers::__cortex_m_rt_main::h0e9b57c6799332fd> @ imm = #0x0

00000160 <registers::__cortex_m_rt_main::h0e9b57c6799332fd>:
     160:      	push	{r7, lr}
     162:      	mov	r7, sp
     164:      	bl	0x192 <registers::init::hec71dddc40be11b5> @ imm = #0x2a
     168:      	movw	r0, #0x504
     16c:      	movt	r0, #0x5000
     170:      	ldr	r1, [r0]
     172:      	orr	r1, r1, #0x200000
     176:      	str	r1, [r0]
     178:      	ldr	r1, [r0]
     17a:      	orr	r1, r1, #0x80000
     17e:      	str	r1, [r0]
     180:      	ldr	r1, [r0]
     182:      	bic	r1, r1, #0x200000
     186:      	str	r1, [r0]
     188:      	ldr	r1, [r0]
     18a:      	bic	r1, r1, #0x80000
     18e:      	str	r1, [r0]
     190:      	b	0x190 <registers::__cortex_m_rt_main::h0e9b57c6799332fd+0x30> @ imm = #-0x4

これによって、ptr::read_volatileptr::write_volatile の呼び出しを使ったものとまったく同じバイナリが生成されることを確認できます。

この話のいちばん素晴らしい点は、GPIO API を実装するためのコードを誰一人として1行も書く必要がなかったことです。すべてのコードは、svd2rust ツールを使って System View Description (SVD) ファイルから自動生成されました。この SVD ファイルは実際にはマイクロコントローラのベンダーが提供する XML ファイルで、各マイクロコントローラのレジスタマップが含まれています。このファイルには、レジスタブロックのレイアウト、ベースアドレス、各レジスタの読み取り/書き込み権限、レジスタのレイアウト、レジスタに予約済みビットがあるかどうか、その他多くの有用な情報が含まれています。

シリアルポート

現代の組み込みボードにおいて、ほぼ普遍的な I/O 標準に最も近いものが「シリアルポート」です。ほとんどすべてのマイクロコントローラーには、いくつかのピンをシリアルポートとして動作させる方法があり、またほとんどすべてのマイクロコントローラーボードではそれらのピンに簡単にアクセスできるようになっています。MB2 も例外ではありません。

この章では、まずシリアルポートとはそもそも何かを説明します。そのうえで、USB を使ってコンピューターに「仮想シリアルポート」を設定し、その仮想ポートを「ターミナルソフトウェア」とともに使って MB2 上のシリアルポートとやり取りする方法を示します。

では、このserial portとは何でしょうか。これは、2 つのデバイスが、各方向に 1 本ずつのデータ線(全二重で)と共通グラウンドを使い、データを 1 ビットずつ(直列に)やり取りする場所です。シリアルポートはもともと「RS-232」として始まりました。詳しくはこの章の後半にある歴史の節を参照してください。ただし、送信線と受信線で使われるプロトコルには、私の知る限り正式名称がありません。単に「serial」、あるいは「async serial」や「UART serial」と呼ばれる程度です。

はっきりさせておくと、現代のコンピューターにおける通信チャネルの大半はシリアルです。USB(「Universal Serial Bus」)はシリアルチャネルですし、I2C(これについては後で説明します)もシリアルチャネルです。この章と次章は、シリアル通信という一般概念についての話では ありません。これらの章で扱うのは、独自の実装と歴史を持つ「シリアルポート」と呼ばれる特定のものです。

シリアルポート通信は、共有されるどの線にもクロック信号が載っていないという意味で 非同期 です。その代わり、通信を行う 前に、双方がワイヤ上でデータをどれくらいの速さで送るかをおおよそ合意しておく必要があります。Universal Asynchronous Receiver/Transmitter(UART)と呼ばれる周辺回路は、指定された速度で出力線にビットを送信し、入力線上でビットの開始を監視します。

シリアルポート通信プロトコルはフレーム単位で動作し、各フレームは 1 バイトのデータを運びます。各フレームには 1 つの スタート ビット、5〜9 ビットのペイロードデータ(lsb から msb の順に送信されます。現代のアプリケーションで 9 ビットバイトを送ることはまれです。フレーム内のビット数が 7 以下の場合は、左側を 0 で埋めて 8 ビットバイトになります)、そして 1〜2 個の ストップビット があります。上の図では、ASCII の ‘E’ 文字が 8 データビットと 1 ストップビットを使って送信されています。

このプロトコルの速度は ボーレート と呼ばれ、1 秒あたりのビット数(bps)で表されます。(これが変だと思ったなら、そのとおりです。「Baud」は本来、1 秒あたりの シンボル数 を表すべきものです。1 つのシンボルは 1 つのフレームに対応すべきですし、仮に 1 データビットを「シンボル」と見なしたとしても、プロトコルの他の部分があるため、それらはこのレートでは送られません。これは慣習であって、理にかなっている必要はありません。)UART シリアルで歴史的によく使われてきたボーレートは 9600bps、19200bps、115200bps ですが、現代では 921,600bps でデータを送ることも珍しくありません。

「通常」の構成である、1 スタートビット、8 データビット、1 ストップビット、ビットレート 921.6K bps では、毎秒 92.16K バイトの送受信ができます。これは単一チャネルの非圧縮 CD オーディオを伝送するのに十分な速さです。今回使用する 115,200 bps のビットレートでは、毎秒 11.52K バイトの送受信ができます。これはほとんどの用途では十分です。

私たちは、MB2 とあなたのコンピューターの間でデータをやり取りするために(間接的に)シリアルポートを使います。ここでこう思うかもしれません。なぜ前と同じように RTT を使わないのでしょうか。RTT は、デバッグ専用に使うことを意図したプロトコルです。RTT を使って他のデバイスと通信するデバイスに出会うことはないでしょう。しかし、シリアル通信はかなり頻繁に使われます。たとえば、一部の GPS 受信機は、受信した位置情報をシリアル経由で送信します。さらに RTT は、多くのデバッグプロトコルと同様に、シリアルの転送速度と比べると低速です。

今日のコンピューターには通常シリアルポートがありませんし、仮にあったとしても、その電圧(現代のシリアルポートでは +5V、古い RS-232 ポートでは ±12V)は MB2 のハードウェアが受け入れられる範囲外であり、損傷の原因になるおそれがあります。コンピューターをマイクロコントローラーに直接接続することはできません。

しかし、ほとんどの現代的なマイクロコントローラーボードの +3.3V 入力に対応した、安価な(たいてい US$5 未満の)USB←→シリアル変換器を 購入できます。上に示したボードは、私が日常的によく使っている一般的なものです。私たちは MB2 に内蔵された USB ポートを通して MB2 のシリアルポートと通信します。しかし、MB2 であれ他のボードであれ、ハードウェアのシリアルポートに直接接続したい場合は、シリアル変換器を使うのがよい方法です。

MB2 の USB ポート上の別の USB チャネルを使って、MB2 内蔵の USB←→シリアル変換器と通信できます。(これは上図の右側の経路です。)この USB←→シリアル変換は、MB2 の「communications microcontroller」を使って実装されています。通信マイクロコントローラーは、マイクロコントローラーに対してはシリアルインターフェイスを、コンピューターに対しては仮想 USB シリアルインターフェイスを公開します。コンピューターは、USB CDC-ACM(「Communications Device Class - Abstract Control Model」――やれやれ)デバイスクラスを介して仮想シリアルインターフェイスを提供します。MB2 のマイクロコントローラーからは、あなたのコンピューターがそのハードウェアシリアルポートに接続されたデバイスとして見えます。あなたのコンピューターからは、MB2 のシリアルポートが仮想シリアルデバイスとして見えます。

それでは、OS が提供する USB シリアルポートインターフェイスに慣れていきましょう。次のいずれかを選んでください。

MacOS の場合は Linux のドキュメントを参照してください。ただし、多少勝手が異なる可能性があります。

Linux での USB←→シリアル用ツール

MB2 の USB エミュレートシリアルデバイスは、MB2 を Linux の USB ポートに接続すると Linux 上に現れます。

MB2 ボードの接続

MB2 ボードをコンピューターに接続すると、/dev に新しい TTY デバイスが現れるはずです。

$ sudo dmesg -T | tail | grep -i tty
[63712.446286] cdc_acm 1-1.7:1.1: ttyACM0: USB ACM device

これが USB←→シリアルデバイスです。Linux では、これは tty(“TeleTYpe” の略です。信じられないかもしれませんが)という名前になっています。これは ttyACM0、あるいは ttyUSB0 として表示されるはずです。ほかの “ACM” デバイスが接続されている場合は、番号はより大きくなります。(Mac OS では ls /dev/cu.usbmodem* でシリアルデバイスを表示できます。)

では、ttyACM0 とは正確には何なのでしょうか?もちろんファイルです! Unix では、すべてがファイルです。

$ ls -l /dev/ttyACM0
crw-rw----+ 1 root plugdev 166, 0 Jan 21 11:56 /dev/ttyACM0

このデバイスを読み書きするには、root として実行する(推奨されません)か、ls の出力に表示されるグループ(通常は plugdev または dialout)のメンバーである必要があることに注意してください。そうすれば、このファイルに単純に書き込むだけでデータを送信できます。

$ echo 'Hello, world!' > /dev/ttyACM0

このコマンドを入力すると、USB ポートのすぐ横にある MB2 のオレンジ色の LED が一瞬点滅するはずです。ビットレートやその他のシリアルパラメーターは MB2 のシリアルポートに対して正しく設定されていない可能性がありますが、それでも MB2 はシリアルデータが送られてきていることを認識できます。

minicom

キーボードを使ってシリアルデバイスと対話するために、minicom というプログラムを使います。ここでは最新の minicom のデフォルト設定、つまり 115200 bps、8 データビット、1 ストップビット、パリティビットなし、フロー制御なしを使います。(115200 bps は、たまたま MB2 で動作する速度です。)

$ minicom -D /dev/ttyACM0

これは、minicom/dev/ttyACM0 のシリアルデバイスを開くよう指示しています。すると、テキストベースのユーザーインターフェイス(TUI)が表示されます。

これでキーボードを使ってデータを送信できます。実際に何か入力してみてください。テキスト UI は、入力した内容をエコーバックしないことに注意してください。ただし、MB2 上部の黄色い LED に注目すると、何か入力するたびに点滅することが分かります。

minicom のコマンド

minicom はキーボードショートカットを通じてコマンドを提供します。Linux では、ショートカットは Ctrl+A で始まります。(Mac では、ショートカットは Meta キーで始まります。)以下は便利なコマンドです。

  • Ctrl+A + Z. Minicom コマンド一覧
  • Ctrl+A + C. 画面をクリア
  • Ctrl+A + X. 終了してリセット
  • Ctrl+A + Q. リセットせずに終了

注記 Mac ユーザー: 上記のコマンドでは、Ctrl+AMeta に置き換えてください。

Windows ツール

まず、MB2 を取り外します。

MB2 を再び接続する前に、ターミナルで次のコマンドを実行します。

$ mode

すると、コンピューターに接続されているデバイスの一覧が表示されます。名前が COM で始まるものはシリアルデバイスです。これが今回扱う種類のデバイスです。シリアルモジュールを接続する の、すべての COM ポートの mode 出力をメモしておいてください。

次に、MB2 を接続し、再度 mode コマンドを実行します。一覧に新しい COM ポートが表示されたら、それが MB2 の シリアル機能に割り当てられた COM ポートです。

次に putty を起動します。GUI が表示されます。

起動画面では、“Session” カテゴリーが開いているはずです。そこで “Connection type” として “Serial” を 選択します。“Serial line” フィールドには、前の手順で確認した COM デバイスを入力します。 たとえば COM3 です。

次に、左側のメニューから “Connection/Serial” カテゴリーを選択します。この新しい画面で、 シリアルポートが次のように設定されていることを確認してください。

  • “Speed (baud)”: 115200
  • “Data bits”: 8
  • “Stop bits”: 1
  • “Parity”: None
  • “Flow control”: None

最後に、Open ボタンをクリックします。すると、コンソールが表示されます。

このコンソールで入力すると、MB2 の上部にある黄色の LED が点滅します。キーを 1 回押すごとに、 LED が 1 回点滅するはずです。なお、コンソールには入力した内容はエコーバックされないため、画面には 何も表示されないままです。

RS-232

RS-232

この節は間違いなく任意ですが、今後シリアルについて人と話すときに、こうしたことを知っていると役に立つかもしれません。

私が知るかぎり、最も初期の標準的なシリアルポートは 1960 年ごろのもので、そのころ RS-232(Revised Standard 232。どうやらこの規格には「未改訂」の版は存在しないようです)という標準がありました。RS-232 Standard を見てみるのも面白いでしょう。Telecommunications Industry Association に喜んで渡したい US$65 が手元にあるなら、ぜひどうぞ。(業界団体というのは、「恥」という概念を完全に見失いがちです。やれやれ。)

RS-232 は、Data Communications Equipment(DCE; モデム)を Data Terminal Equipment(DTE; 端末)に接続するための方式で、各方向のデータ送信には 1 本の線だけを使っていました。データに使われるプロトコルについては、この章の別の箇所で説明しています。通信速度は調整可能でした。モデムが接続されている場合はそのモデムの通信速度を使うのが一般的だったため、「標準的な」RS-232 の速度は「標準的な」モデムの速度でもあることが多く、300bps、600bps、1200bps、2400bps、9600bps などがありました。

RS-232 は、ハードウェア「ハンドシェイク」を行うためのいくつかの追加の線もサポートしていました(各端は、忙しいので今はデータを受け取れないことを通知できました)。また、さまざまな電話機能(「電話は鳴っているか?」「電話を off hook にする」など)もサポートしていました。RS-232 では 25 ピンの「D」コネクタを使用し、信号電圧はハイが -12V、ローが +12V で、これは古い電話会社の標準でした。

コンピュータが加わると、RS-232 は扱いづらくなりました。大型コンピュータは端末と通信するため DCE として配線される傾向があり、マイクロコンピュータはモデムと通信するため DTE として配線される傾向がありました。

IBM-PC AT によって、より小型の 9 ピンコネクタ(DE-9。しばしば DB-9 と呼ばれます)版の RS-232 ポートが広まりました。

やがて、シリアルポートではデータ本体とは別の信号線のほとんど、あるいはすべてを省いて、3 線式インターフェース(送信・受信・グラウンド)にするのが一般的になりました。また、「変な」電圧やインターフェースの問題を避けるため、信号レベルとして 0V/+5V、さらに後には 0V/+3.3V を使うことも一般的になりました。

Microsoft は、RS-232 を置き換える標準として USB を意図的に設計しました。ごつごつした配線、さまざまなコネクタ、さまざまな電圧、さまざまな転送速度を持つ RS-232 では、ごく普通の消費者が自分の PC に機器を接続するにはあまりにも難しすぎたのです。USB が登場した当時も、ほとんどのモデムはまだ RS-232 だったため、Microsoft は、それらのモデムで使われる RS-232 アダプタと通信するための USB の「デバイスクラス」として CDC-ACM を設計しました。

UART

このマイクロコントローラ(多くのものと同様)には、UART(「Universal Asynchronous Receiver/Transmitter」)ペリフェラルがあります。MB2 には 2 種類の UART ペリフェラルがあり、古い UART と新しい UARTE(「Easy DMA を備えた UART」)があります。ハードウェアシリアルポートと通信するために、UARTE ペリフェラルを使用します。

この章全体を通して、シリアル通信を使用してマイクロコントローラとコンピュータの間で情報をやり取りします。

1バイトを送信する

最初の課題は、シリアル接続を介してマイクロコントローラーからコンピューターへ1バイトを送信することです。

そのために、次のスニペットを使います(これはすでに 11-uart/examples/send-byte.rs にあります):

#![no_main]
#![no_std]

use cortex_m::asm::wfi;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

use microbit::{
    hal::uarte,
    hal::uarte::{Baudrate, Parity},
};

use serial_setup::UartePort;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let mut serial = {
        let serial = uarte::Uarte::new(
            board.UARTE0,
            board.uart.into(),
            Parity::EXCLUDED,
            Baudrate::BAUD115200,
        );
        UartePort::new(serial)
    };

    serial.write(b'X').unwrap();
    serial.flush().unwrap();

    loop {
        wfi();
    }
}

ここで使われているライブラリの1つである serial_setup モジュールは、crates.io 由来ではなく、このプロジェクトのために書かれたものだと気づくかもしれません。serial_setup の目的は、UARTE ペリフェラルを扱いやすくラップすることです。必要なら、このモジュールが具体的に何をしているのか確認しても構いませんが、この章全体を理解するうえでは必須ではありません。

次に、UARTE の初期化について説明します。UARTE は次のコードで初期化します:

uarte::Uarte::new(
    board.UARTE0,
    board.uart.into(),
    Parity::EXCLUDED,
    Baudrate::BAUD115200,
);

この関数は、Rust における UARTE ペリフェラルの表現 (board.UARTE0) と、ボード上の TX/RX ピン (board.uart.into()) の所有権を受け取ります。これにより、それらを使用している間は、ほかの誰も UARTE ペリフェラルやピンを勝手に触れなくなります。その後、コンストラクタに2つの設定オプションを渡します。ボーレート(これはもうおなじみでしょう)と、「パリティ」と呼ばれるオプションです。パリティは、シリアル通信回線が受信したデータが伝送中に破損していないかを確認できるようにする仕組みです。ここではそれを使いたくないので、単に除外します。その後、使えるようにそれを UartePort 型でラップします。

初期化の後、新しく作成した UART インスタンス経由で X(ASCII のバイト値 88)を送信します。これらのシリアル関数は「ブロッキング」です。データが送信されるまで待ってから戻ります。これは常に望ましいとは限りません。マイクロコントローラーは、バイトが線上に送出されるのを待っている間にも多くの処理を行えるからです。しかし、この場合はそのほうが都合がよく、そもそもほかにやる作業もありませんでした。

最後に、flush() でシリアルポートをフラッシュします。これは、UARTE が送信するバイト数がある程度たまるまで出力をバッファリングする可能性があるためです。flush() を呼び出すと、さらに待つのではなく、その時点で保持しているバイトをすぐに書き出すよう強制できます。

テストする

これをフラッシュする前に、必ず minicom/PuTTY を起動しておいてください。シリアル通信で受信したデータは保存されたりしないので、その場で確認する必要があります。シリアルモニターの準備ができたら、第5章と同じようにプログラムを書き込めます:

$ cargo embed --example send-byte
  (...)

書き込みが終わると、minicom/PuTTY の端末に文字 X が表示されるはずです。おめでとうございます!

見逃した場合は、MB2 の背面にあるリセットボタンを押してください。するとプログラムが最初から開始され、再び X を送信します。

文字列を送信する

次の課題は、マイクロコントローラーからコンピューターへ文字列全体を送信することです。

マイクロコントローラーから コンピューターへ文字列 "The quick brown fox jumps over the lazy dog." を送信してください。

プログラムを書くのはあなたの番です。

素朴なアプローチと write!

素朴なアプローチ

おそらく、次のようなプログラムを思いついたでしょう(examples/naive-send-string.rs):

#![no_main]
#![no_std]

use cortex_m::asm::wfi;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

use microbit::hal::uarte::{self, Baudrate, Parity};

use serial_setup::UartePort;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let mut serial = {
        let serial = uarte::Uarte::new(
            board.UARTE0,
            board.uart.into(),
            Parity::EXCLUDED,
            Baudrate::BAUD115200,
        );
        UartePort::new(serial)
    };

    for byte in b"The quick brown fox jumps over the lazy dog.\r\n".iter() {
        serial.write(*byte).unwrap();
    }
    serial.flush().unwrap();

    loop {
        wfi();
    }
}

これは完全に有効な実装ですが、いずれは引数のフォーマットなど、print! の持つ便利な 機能をすべて使いたくなるかもしれません。どうすればそれができるのか気になるなら、このまま読み進めてください。

write!core::fmt::Write

core::fmt::Write トレイトを使うと、それを実装する任意の構造体を、std の世界で print! を使うのと基本的に同じ方法で使えるようになります。この場合、nrf HAL の Uart 構造体は core::fmt::Write を実装しているので、先ほどのプログラムを次のようにリファクタリング できます(examples/send-string.rs):

#![no_main]
#![no_std]

use core::fmt::Write;
use cortex_m::asm::wfi;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

use microbit::hal::uarte::{self, Baudrate, Parity};

use serial_setup::UartePort;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let mut serial = {
        let serial = uarte::Uarte::new(
            board.UARTE0,
            board.uart.into(),
            Parity::EXCLUDED,
            Baudrate::BAUD115200,
        );
        UartePort::new(serial)
    };

    write!(serial, "The quick brown fox jumps over the lazy dog.\r\n").unwrap();
    serial.flush().unwrap();

    loop {
        wfi();
    }
}

このプログラムを micro:bit に書き込むと、あなたが思いついたイテレータベースのプログラムと 機能的に等価であることがわかるでしょう。

1バイトを受信する

ここまでは、マイクロコントローラからコンピュータへデータを送信できました。次は逆を試しましょう。 つまり、コンピュータからデータを受信します(examples/receive-byte.rs)。

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::hal::uarte::{self, Baudrate, Parity};

use serial_setup::UartePort;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let mut serial = {
        let serial = uarte::Uarte::new(
            board.UARTE0,
            board.uart.into(),
            Parity::EXCLUDED,
            Baudrate::BAUD115200,
        );
        UartePort::new(serial)
    };

    loop {
        let byte = serial.read().unwrap();
        rprintln!("{}", byte);
    }
}

バイト送信プログラムと比べて変更された唯一の部分は、main() の最後にあるループです。ここでは、 serial.read() 関数を使って、1バイトが利用可能になるまで待機し、それを読み取ります。その後、その バイトを RTT デバッグコンソールに出力して、実際にデータが届いているかどうかを確認します。

このプログラムを書き込んで、minicom の中で文字を入力してマイクロコントローラに送信し始めても、 RTT コンソール内には数字しか表示されないことに注意してください。これは、受信した u8 を実際の char に変換していないためです。u8 から char への変換は非常に簡単なので、本当に RTT コンソール内で文字を見たいのであれば、この作業はあなたにお任せします。

エコーサーバー

送信と受信を単一のプログラムに統合し、エコーサーバーを書いてみましょう。エコー サーバーは、受信したのと同じテキストをクライアントに送り返します。このアプリケーションでは、マイクロコントローラー がサーバーとなり、あなたとあなたのコンピューターがクライアントになります。

これは簡単に実装できるはずです。(ヒント: 1バイトずつ行ってください)

文字列を逆順にする

では次に、クライアントが送信したテキストを逆順にして返すようにして、サーバーをもう少し面白くしてみましょう。サーバーは、クライアントが ENTER キーを押すたびに応答を返します。サーバーからの各応答は新しい行に出力されます。

今回はバッファが必要です。heapless::Vec を使えます。以下がスターターコードです。

#![no_main]
#![no_std]

use cortex_m_rt::entry;
use core::fmt::Write;
use heapless::Vec;
use rtt_target::rtt_init_print;
use panic_rtt_target as _;

use microbit::{
    hal::prelude::*,
    hal::uarte,
    hal::uarte::{Baudrate, Parity},
};

mod serial_setup;
use serial_setup::UartePort;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let mut serial = {
        let serial = uarte::Uarte::new(
            board.UARTE0,
            board.uart.into(),
            Parity::EXCLUDED,
            Baudrate::BAUD115200,
        );
        UartePort::new(serial)
    };

    // 32 バイトの容量を持つバッファ
    let mut buffer: Vec<u8, 32> = Vec::new();

    loop {
        buffer.clear();

        // TODO ユーザーリクエストを受信する。各ユーザーリクエストは ENTER で終わる
        // 注 `buffer.push` は `Result` を返す。エラーは
        // エラーメッセージを返して処理すること。

        // TODO 逆順にした文字列を送り返す
    }
}

私の解答

私の解答は src/main.rs にあります:

#![no_main]
#![no_std]

use core::fmt::Write;
use cortex_m_rt::entry;
use heapless::Vec;
use microbit::hal::uarte::{self, Baudrate, Parity};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
use serial_setup::UartePort;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let mut serial = {
        let serial = uarte::Uarte::new(
            board.UARTE0,
            board.uart.into(),
            Parity::EXCLUDED,
            Baudrate::BAUD115200,
        );
        UartePort::new(serial)
    };

    // A buffer with 32 bytes of capacity
    let mut buffer: Vec<u8, 32> = Vec::new();

    loop {
        buffer.clear();

        loop {
            // We assume that the receiving cannot fail
            let byte = serial.read().unwrap();

            if buffer.push(byte).is_err() {
                write!(serial, "error: buffer full\r\n").unwrap();
                break;
            }

            if byte == b'\r' {
                for byte in buffer.iter().rev().chain(b"\r\n") {
                    serial.write(*byte).unwrap();
                }
                break;
            }
        }
        serial.flush().unwrap()
    }
}

I2C

先ほど、UART シリアル通信の形式を見ました。UART シリアルは、単純で、ほとんど太古の昔から使われているため、広く利用されています。(ホストデバイスが “TeleTYpe” に由来して “tty” と呼ばれることを覚えていますか? そう、そのことです。)この普及度と単純さにより、簡単な通信の手段として人気があります。

配線長 vs 信号品質に関するハードウェア上の制約と、正確にデコードすることの難しさのため、UART シリアルは通常、理想的な条件でも約 115200 ボー程度が上限です。UART シリアルポートは、帯域幅が低く(11.5KB/s)、レイテンシが高い(87µs/byte)という特徴があります。

UART シリアルはポイントツーポイントです。同じ配線に 3 台以上のデバイスを接続する方法はなく、また各配線の両端には専用のハードウェアデバイスが必要です。

良い知らせ(であると同時に悪い知らせ)として、組み込みの分野には、これらの制約を克服する、ハードウェア支援のシリアル通信プロトコルがたくさんあります。その一部はデジタルセンサーで広く使われています。

私たちが使っている micro:bit ボードには、2 つのモーションセンサー、つまり加速度計と磁力計が搭載されています。これら 2 つのセンサーは 1 つのコンポーネントにまとめられており、I2C バス経由でアクセスできます。

I2C は “EYE-SQUARED-CEE” と発音し、Inter-Integrated Circuit の略です。I2C は同期式のシリアル バス 通信プロトコルです。データをやり取りするために 2 本の線、データ線(SDA)とクロック線(SCL)を使います。クロック線は通信を同期させるために使われます。同期シリアルは、非同期シリアルよりも高速かつ高信頼で動作できます。I2C デバイスにはバスアドレスがあります。ハードウェア実装により、特定のデバイスに対してバイトを送信でき、同じ配線に接続された他のデバイスはこの通信を無視します。

I2C は controller/target モデルを使用します。controller は、target デバイスとの通信を開始し、主導するデバイスです。複数のデバイスを同じバスに同時に接続でき、それぞれが controller としても target としても振る舞うことを選べます。controller デバイスは、まず target アドレスをバスにブロードキャストすることで、特定の target デバイスと通信できます。このアドレスは 7 ビットまたは 10 ビット長です。controller が target との通信を開始すると、controller がその通信を終了するまで、controller と target 以外のデバイスはバス上で送信することを許されません。

NOTE “Controller/target” は以前は “master/slave” と呼ばれていました。文献やボード上のラベルでは、今でもその表記を見かけることがあります。この用語は現在、公式規格でも新しい文書でも非推奨ですが、私たちの nRF52833 部品向けの Nordic マニュアルや、一部の組み込み Rust ドキュメントでは使われています。

クロック線によって、どれくらいの速さでデータをやり取りできるかが決まります。MB2 I2C インターフェースは 100、250、400 Kbps の速度で動作できます。他のデバイスでは、さらに高速なモードも可能です。

一般的なプロトコル

I2C プロトコルは、複数のデバイス間の構造化された通信をサポートしているため、シリアル通信プロトコルよりも複雑です。どのように動作するのかを見てみましょう。

コントローラー → ターゲット

コントローラーがターゲットにデータを送信したい場合:

  1. コントローラー: START をブロードキャスト
  2. C: ターゲットアドレス(7 ビット)+ WRITE に設定された R/W(8 番目)ビットをブロードキャスト
  3. ターゲット: ACK(確認応答)を返す
  4. C: 1 バイト送信
  5. T: ACK を返す
  6. 手順 4 と 5 を 0 回以上繰り返す
  7. C: STOP をブロードキャストするか、新しい読み取りトランザクションを開始する

注記 ターゲットアドレスは、7 ビット長ではなく 10 ビット長であってもかまいません。それ以外は何も変わりません。

コントローラー ← ターゲット

コントローラーがターゲットからデータを読み取りたい場合:

  1. C: START をブロードキャスト
  2. C: ターゲットアドレス(7 ビット)+ READ に設定された R/W(8 番目)ビットをブロードキャスト
  3. T: ACK を返す
  4. T: 1 バイト送信
  5. C: ACK を返す
  6. 手順 4 と 5 を 0 回以上繰り返す
  7. C: STOP をブロードキャストするか、新しい書き込みトランザクションを開始する

注記 ターゲットアドレスは、7 ビット長ではなく 10 ビット長であってもかまいません。それ以外は何も変わりません。

「デバイスレジスタ」

多くの I2C ターゲットは、内部的に「デバイスレジスタ」を持つように構成されており、それぞれが 8 ビットのアドレスと 8 ビットの内容を持ちます。通常、デバイスレジスタは 2 バイトの書き込みで書き込まれます。1 バイト目がレジスタアドレスで、2 バイト目が新しいレジスタ値です。

いわゆる「combined」または「split」トランザクションは、上の図に示したように、ターゲットへの書き込みの直後にターゲットから読み戻す処理で構成されることがあります。通常、デバイスレジスタはこの方法で読み取られます。つまり、デバイスレジスタアドレスを書き込み、その直後に現在のデバイスレジスタ値を読み戻します。

一部の I2C ターゲットでは、何らかの「アドレス自動インクリメント」によって、隣接するアドレスを持つ複数のデバイスレジスタを読み書きできます。これにより、最初のデバイスレジスタアドレスだけを送信し、その後の読み取りまたは書き込みではデバイスがアドレスをインクリメントすることに任せられます。

I2C は複雑なプロトコルであり、さまざまなバリエーションや特別な機能が存在します。ターゲットと通信するために何をする必要があるかを確認するには、そのターゲットのマニュアルを注意深く読んでください。

LSM303AGR

micro:bit 上の 2 つのモーションセンサー、磁力計と加速度計は、LSM303AGR 集積回路という単一のコンポーネントに搭載されています。これら 2 つのセンサーには、I2C バス経由でアクセスできます。各センサーは I2C ターゲットのように振る舞い、異なる アドレスを持ちます。

各センサーにはそれぞれ独自のメモリがあり、そこで周囲をセンシングした結果を保持します。これらのセンサーとのやり取りは、主にそのメモリの読み取りを行うことになります。

これらのセンサーのメモリは、バイトアドレス可能なレジスタとしてモデル化されています。これらのセンサーは設定も可能で、それはそれらのレジスタに書き込むことで行います。したがって、ある意味では、これらのセンサーはマイクロコントローラー 内部 のペリフェラルと非常によく似ています。違いは、それらのレジスタがマイクロコントローラーのメモリにマップされていないことです。代わりに、それらのレジスタには I2C バス経由でアクセスする必要があります。

LSM303AGR に関する主な情報源は、その Data Sheet です。そこに目を通して、センサーのレジスタをどのように読み取れるかを確認してください。その部分は次にあります。

Section 6.1.1 I2C Operation - Page 38 - LSM303AGR Data Sheet

この本に関連するドキュメントのもう 1 つの部分は、レジスタの説明です。その部分は次にあります。

Section 8 Register description - Page 46 - LSM303AGR Data Sheet

単一のレジスタを読み取る

ここまでの理論を実践してみましょう!

まず最初に、チップ内の加速度計と磁力計の両方の対象アドレスを知る必要があります。これらは LSM303AGR のデータシートの 39 ページに記載されており、次のとおりです:

  • 加速度計: 0011001
  • 磁力計: 0011110

注記 これらはアドレスの先頭 7 ビットにすぎず、8 ビット目は 読み取りを行うのか書き込みを行うのかを決定するビットになることを忘れないでください。

次に必要になるのは、読み取り元のレジスタです。多くの I2C チップには、コントローラが読み取るための 何らかのデバイス識別レジスタが用意されています。世の中には何千、あるいは何百万もの I2C チップが 存在することを考えると、いつか同じアドレスを持つ 2 つのチップが作られてしまう可能性は非常に高いです (アドレス幅は「たった」7 ビットしかないのですから)。このデバイス ID レジスタがあれば、ドライバは 実際に LSM303AGR と通信しているのであって、たまたま同じアドレスを持つ別のチップではないことを 確認できます。LSM303AGR のデータシート(具体的には 46 ページと 61 ページ)を読むとわかるとおり、 このデバイスには 2 つのレジスタ — アドレス 0x0fWHO_AM_I_A と、アドレス 0x4fWHO_AM_I_M — があり、そこにはこのデバイス固有のビットパターンが入っています。 (“A” は “Accelerometer”、“M” は “Magnetometer” を表します。)

ここで欠けているのはソフトウェアの部分だけです。つまり、このために microbit または HAL クレートのどの API を使うべきかを判断する必要があります。nRF52833 Product Specification を 読むと、これには実際には I2C 専用のペリフェラルがないことがすぐにわかります。代わりに、 TWI(“Two-Wire Interface”)、TWIM(“Two-Wire Interface Master”)、TWIS(“Two-Wire Interface Slave”) と呼ばれる、より汎用的な I2C 互換ペリフェラルがあります。通常はコントローラモードで動作するため、 より新しい TWIM を使用します。これは “Easy DMA” をサポートしています — TWI は主に古いデバイスとの 後方互換性のために提供されています。

ここまでに集めた他の情報と microbit クレートの twi(m) module のドキュメントを 組み合わせると、2 つのデバイス ID を読み出して表示する次のコード (examples/chip-id.rs)になります:

#![deny(unsafe_code)]
#![no_main]
#![no_std]

use cortex_m::asm::wfi;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use embedded_hal::i2c::I2c;
use microbit::{hal::twim, pac::twim0::frequency::FREQUENCY_A};

const ACCELEROMETER_ADDR: u8 = 0b0011001;
const MAGNETOMETER_ADDR: u8 = 0b0011110;

const ACCELEROMETER_ID_REG: u8 = 0x0f;
const MAGNETOMETER_ID_REG: u8 = 0x4f;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let mut i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };

    let mut acc = [0u8];
    let mut mag = [0u8];

    // First write the address + register onto the bus, then read the chip's responses
    i2c.write_read(ACCELEROMETER_ADDR, &[ACCELEROMETER_ID_REG], &mut acc)
        .unwrap();
    i2c.write_read(MAGNETOMETER_ADDR, &[MAGNETOMETER_ID_REG], &mut mag)
        .unwrap();

    rprintln!("The accelerometer chip's id is: {:#b}", acc[0]);
    rprintln!("The magnetometer chip's id is: {:#b}", mag[0]);

    loop {
        wfi();
    }
}

初期化を除けば、このコードは、前述した I2C プロトコルを理解していれば素直に読めるはずです。 ここでの初期化は UART の章のものと同様に機能します。ペリフェラル本体と、チップとの通信に使用する ピンをコンストラクタに渡し、その後でバスを動作させたい周波数を渡します。この場合は 100 kHz (K100、識別子は数字で始められないため)です。

テストする

いつものように

$ cargo embed --example chip-id

を実行して、この小さなサンプルプログラムをテストします。

ドライバを使う

第5章ですでに説明したとおり、embedded-hal は、ハードウェアとやり取りできるプラットフォームに依存しないコードを書くために使える抽象化を提供します。実際、LED ルーレット の章とこの章のここまででハードウェアとやり取りするために使ってきたすべてのメソッドは、embedded-hal で定義されたトレイトのものでした。ここで初めて、embedded-hal が提供するトレイトを実際に使います。

embedded Rust がサポートするすべてのプラットフォーム向けに、私たちの LSM303AGR 用ドライバを実装するのは無意味です(そして、今後登場するかもしれない新しいものについても同様です)。これを避けるために、embedded-hal のトレイトを実装したジェネリック型を受け取るドライバを書くことで、プラットフォームに依存しないバージョンのドライバを提供できます。幸いなことに、これはすでに lsm303agr クレートで行われています。そのため、実際の加速度計と磁力計の値の読み取りは、基本的にはプラグアンドプレイになります(加えて、少しドキュメントを読むだけです)。実際、crates.io のページには、Raspberry Pi を使って加速度計データを読み取るために必要なことがすべてすでに書かれています。あとはそれを私たちのチップ向けに合わせるだけです。

リンク先のページにある Raspberry Pi Linux のサンプルコードを見てください。

すでに 前のページembedded_hal::blocking::i2c トレイトを実装するオブジェクトのインスタンスを作成する方法はわかっているので、サンプルコードを適応させるのは簡単です(examples/show-accel.rs):

#![deny(unsafe_code)]
#![no_main]
#![no_std]

use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::{twim, Timer},
    pac::twim0::frequency::FREQUENCY_A,
};

use lsm303agr::{AccelMode, AccelOutputDataRate, Lsm303agr};

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
    let mut timer0 = Timer::new(board.TIMER0);

    // Code from documentation
    let mut sensor = Lsm303agr::new_with_i2c(i2c);
    sensor.init().unwrap();
    sensor
        .set_accel_mode_and_odr(
            &mut timer0,
            AccelMode::HighResolution,
            AccelOutputDataRate::Hz50,
        )
        .unwrap();
    loop {
        if sensor.accel_status().unwrap().xyz_new_data() {
            let (x, y, z) = sensor.acceleration().unwrap().xyz_mg();
            // RTT instead of normal print
            rprintln!("Acceleration: x {} y {} z {}", x, y, z);
        }
    }
}

前のスニペットと同じように、次のようにして試せるはずです。

$ cargo embed --example show-accel

さらに、micro:bit を少し(物理的に)動かすと、表示されている加速度の値が変化するのがわかるはずです。

課題

この章の課題は、前の章で導入したシリアルインターフェイスを介して外部と通信する小さなアプリケーションを作成することです。シリアルポートから、磁力計を表すコマンド "mag" と加速度計を表すコマンド "acc" を受信することを想定します。その後、それに応じて対応するセンサーデータをシリアルポートへ送り返せるようにしてください。

今回は、必要なものはすべて UART とこの章ですでに提供されているため、テンプレートコードは用意されていません。ただし、いくつかヒントがあります。

  • バッファ内のバイト列を &str に変換するために core::str::from_utf8 が役に立つかもしれません。"mag" および "acc" と比較する必要があるためです。

  • 磁力計 API とその機能に関するドキュメントを読む必要があります。lsm303agr クレートは API インターフェイスを提供しますが、LSM303AGR datasheet には、このセンサーの磁場測定パラメータの詳細が記載されています。センサー特性については 13〜15 ページを、そして特に出力レジスタ形式については 66〜67 ページを参照してください。

私の解答

私の解答は src/main.rs にあります。

#![no_main]
#![no_std]

use core::str;

use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::uarte::{self, Baudrate, Parity},
    hal::{twim, Timer},
    pac::twim0::frequency::FREQUENCY_A,
};

use core::fmt::Write;
use heapless::Vec;
use lsm303agr::{AccelMode, AccelOutputDataRate, Lsm303agr, MagMode, MagOutputDataRate};

use serial_setup::UartePort;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let serial = uarte::Uarte::new(
        board.UARTE0,
        board.uart.into(),
        Parity::EXCLUDED,
        Baudrate::BAUD115200,
    );
    let mut serial = UartePort::new(serial);

    let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
    let mut timer0 = Timer::new(board.TIMER0);

    let mut sensor = Lsm303agr::new_with_i2c(i2c);
    sensor.init().unwrap();
    sensor
        .set_accel_mode_and_odr(
            &mut timer0,
            AccelMode::HighResolution,
            AccelOutputDataRate::Hz50,
        )
        .unwrap();
    sensor
        .set_mag_mode_and_odr(
            &mut timer0,
            MagMode::HighResolution,
            MagOutputDataRate::Hz50,
        )
        .unwrap();
    let mut sensor = sensor.into_mag_continuous().ok().unwrap();
    let mut buffer: Vec<u8, 32> = Vec::new();
    rprintln!("setup complete");

    loop {
        buffer.clear();

        loop {
            let byte = serial.read().unwrap();
            serial.write(byte).unwrap();

            if byte == b'\r' {
                serial.write(b'\n').unwrap();
                break;
            }

            if buffer.push(byte).is_err() {
                write!(serial, "error: buffer full\r\n").unwrap();
                break;
            }
        }

        if str::from_utf8(&buffer).unwrap().trim() == "acc" {
            while !sensor.accel_status().unwrap().xyz_new_data() {
                timer0.delay_ms(1u32);
            }

            let (x, y, z) = sensor.acceleration().unwrap().xyz_mg();
            write!(serial, "Accelerometer: x {} y {} z {}\r\n", x, y, z).unwrap();
        } else if str::from_utf8(&buffer).unwrap().trim() == "mag" {
            while !sensor.mag_status().unwrap().xyz_new_data() {
                timer0.delay_ms(1u32);
            }

            let (x, y, z) = sensor.magnetic_field().unwrap().xyz_nt();
            write!(serial, "Magnetometer: x {} y {} z {}\r\n", x, y, z).unwrap();
        } else {
            write!(serial, "error: command not detected\r\n").unwrap();
        }
    }
}

LED コンパス

このセクションでは、MB2 の LED を使ってコンパスを実装します。本物のコンパスと同じように、 この LED コンパスも何らかの方法で北を指さなければなりません。これは外周の LED の 1 つを点灯することで実現します。点灯した LED が北の方向を指すようにします。

磁場には、ガウスまたはテスラで測定される 大きさ と、方向 の両方があります。MB2 上の 磁力計は外部磁場の大きさと方向の両方を測定しますが、返してくるのは、その磁場を その軸 に沿って 分解 した成分です。

磁力計には、それに対応する 3 つの軸があります。ボードを平らに持ち、LED が上向き、 ロゴが前を向くようにすると、X 軸と Y 軸は床の平面を張ります。X 軸はボードの左端を 指します。Y 軸はボードの下端(カードコネクタ側の端)を指します。Z 軸は「床の中」、つまり下向きを 指します。チップが背面に実装されているため、「上下逆」になっているわけです。これは「右手系」の 座標系です。報告される磁場強度は磁場ベクトルの成分なので、少しわかりにくいところです。

I2C の章の内容から、磁力計のデータを RTT コンソールに継続的に表示する プログラムは、もう書けるはずです。そのプログラム (examples/show-mag.rs)を書いたら、今いる場所で北がどちらにあるかを確認してください。次に、 MB2 をその方向に合わせて、センサーの X と Y の測定値がどのようになるか観察しましょう。

次に、ボードを地面と平行に保ったまま 90 度回転させてください。このとき、X、Y、Z の値は どう見えますか。その後、さらにもう 90 度回転させてください。どのような値が見えますか。

NOTE これを書いている時点で手元にある 2 台の MB2 のうち、1 台は 磁力計が少し壊れているようです。Z 軸に実用にならないほどのオフセットがあります。メーカーには、 これを検出するためのセルフテスト手順と、この種の「ハードアイアン」 障害を軽減するためのキャリブレーション手順があります。これは通常、MB2 がどこかの時点で 強い磁場にさらされた結果です。しかし、lsm303agr クレートは現時点ではそのどちらも サポートしておらず、組み込みシステム入門ガイドで扱うには少し荷が重いように思えます。 MB2 が 1 台しかなく、それがうまく動いていないようなら、next chapter に スキップした方がよいかもしれません。安価なハードウェアですからね。どうしようもありません。

地球の磁北は気まぐれなものです。地球上のほとんどの場所では真北と異なり、 ときにはかなり大きくずれます。かなり地面の中へ向いていることもあります。 また、時間とともに変化します。こうしたことをすべて考慮しなければ、MB2 の磁力計 が完璧であったとしても(実際にはそうではありません)、あまり正確なコンパスは得られません。 米国 NOAA のこの計算機 https://www.ngdc.noaa.gov/geomag/calculators/mobileDeclination.shtml はモバイル デバイスから利用でき、真北だけでなく磁北についても良い推定値を得られます。英国 BGS の calculator に緯度、経度、高度を与えると、偏角と伏角の両方を取得できます。私の いる場所では、「declination」(真北と磁北の差)は約 15° で、 「inclination」は驚くべきことに地面の中へ 67° です。

NOTE LSM303AGR 磁力計は、箱から出してすぐの状態では特別高精度なデバイスではありません。メーカーは、 磁力計の読み取り値に対する補正値を求めるための凝ったキャリブレーション手順を推奨しています。 詳細情報、キャリブレーション実装のサンプル、およびもう少し凝った コンパスのグラフィックスについては appendix 3 を参照してください。この章では磁力計で かなり基本的なことしかしないので、ここでは気にしないことにします。

大きさ

地球の磁場はどれくらい強いのでしょうか? magnetic_field() メソッドのドキュメントによると、取得している x y z の値の単位はナノテスラです。つまり、磁場の大きさをナノテスラで求めるために計算しなければならないのは、x y z の値が表す 3 次元ベクトルの大きさだけです。学校で習ったのを覚えているかもしれませんが、これは単に次のように求められます:

#![allow(unused)]
fn main() {
use libm::sqrtf;
let magnitude = sqrtf(x * x + y * y + z * z);
}

Rust の core には sqrtf() のような浮動小数点数学関数がないため、no_std プログラムではどこかから実装を入手する必要があります。そのために libm クレートを使います。

これらをすべてプログラム (examples/magnitude.rs) にまとめると、次のようになります:

#![deny(unsafe_code)]
#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use libm::sqrtf;

use microbit::{
    hal::{twim, Timer},
    pac::twim0::frequency::FREQUENCY_A,
};

use lsm303agr::{Lsm303agr, MagMode, MagOutputDataRate};

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };

    let mut timer0 = Timer::new(board.TIMER0);

    let mut sensor = Lsm303agr::new_with_i2c(i2c);
    sensor.init().unwrap();
    sensor
        .set_mag_mode_and_odr(
            &mut timer0,
            MagMode::HighResolution,
            MagOutputDataRate::Hz10,
        )
        .unwrap();
    let mut sensor = sensor.into_mag_continuous().ok().unwrap();

    loop {
        while !sensor.mag_status().unwrap().xyz_new_data() {
            timer0.delay_ms(1u32);
        }
        let (x, y, z) = sensor.magnetic_field().unwrap().xyz_nt();
        let (x, y, z) = (x as f32, y as f32, z as f32);
        let magnitude = sqrtf(x * x + y * y + z * z);
        rprintln!("{} mG", magnitude / 100.0);
    }
}

これを cargo run --example magnitude で実行してください。

このプログラムは、磁場の大きさ(強さ)をナノテスラ (nT) と ミリガウス (mG、1 mG = 100 nT) で報告します。地球の磁場の大きさは 250 mG から 650 mG の範囲にあります(大きさは地理的な位置によって変化します)ので、理想的にはその範囲にだいたい収まる値が見えるはずです。センサーがまだキャリブレーションされていないため、値はかなりずれている可能性があります。キャリブレーションについては appendix 3 を参照してください。キャリブレーションすると、私はおよそ 340 mG の大きさを確認しています。

いくつか質問があります:

  • ボードを動かさない状態では、どのような値が見えますか? 常に同じ値が見えますか?

  • ボードを回転させると、大きさは変化しますか? 変化するべきでしょうか?

課題

磁場が磁力計の X 軸および Y 軸となす正確な角度を求めるために、少し高度な数学を使います。これにより、どの LED が北を向いているかを特定できます。

atan2 関数を使います。この関数は、-PI から PI の範囲の角度を返します。下の図は、この角度がどのように測定されるかを示しています。

明示的には示されていませんが、この図では X 軸は右向き、Y 軸は上向きです。私たちの座標系はこれから 180° 回転していることに注意してください。

以下がスターターコードです(templates/compass.rs 内)。ラジアン単位の theta は、すでに計算されています。theta の値に基づいて、どの LED を点灯させるかを選ぶ必要があります。

#![deny(unsafe_code)]
#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

// You'll find these useful ;-).
use core::f32::consts::PI;
use libm::{atan2f, floorf};

use microbit::{
    display::blocking::Display,
    hal::{Timer, twim},
    pac::twim0::frequency::FREQUENCY_A,
};

use lsm303agr::{Lsm303agr, MagMode, MagOutputDataRate};

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };

    let mut timer0 = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);

    let mut sensor = Lsm303agr::new_with_i2c(i2c);
    sensor.init().unwrap();
    sensor.set_mag_mode_and_odr(
        &mut timer0,
        MagMode::HighResolution,
        MagOutputDataRate::Hz10,
    ).unwrap();
    let mut sensor = sensor.into_mag_continuous().ok().unwrap();

    let mut leds = [[0u8; 5]; 5];

    // Indexes of the 16 LEDs to be used in the display, and their
    // compass directions.
    #[rustfmt::skip]
    let indices = [
        (2, 0) /* W */, (3, 0) /* W-SW */, (3, 1) /* SW */, (4, 1) /* S-SW */,
        (4, 2) /* S */, (4, 3) /* S-SE */, (3, 3) /* SE */, (3, 4) /* E-SE */,
        (2, 4) /* E */, (1, 4) /* E-NE */, (1, 3) /* NE */, (0, 3) /* N-NE */,
        (0, 2) /* N */, (0, 1) /* N-NW */, (1, 1) /* NW */, (1, 0) /* W-NW */,
    ];

    loop {
        // Measure the magnetic field.
        let (x, y) = todo!();

        // Get an angle between -180° and 180° from the x axis.
        let theta = atan2f(y as f32, x as f32);

        // Figure out what LED index to blink.
        let index = todo!();

        // Blink the given LED.
        let (r, c) = indices[index];
        leds[r][c] = 255u8;
        display.show(&mut timer0, leds, 50);
        leds[r][c] = 0u8;
        display.show(&mut timer0, leds, 50);
    }
}

提案/ヒント:

  • 円を 1 周回転すると 360 度です。
  • PI ラジアンは 180 度に相当します。
  • theta が 0 なら、どの方向を指していますか?
  • では、theta が 0 に非常に近い場合は、どの方向を指していますか?
  • theta が増え続けるとしたら、どの値で方向を変えるべきでしょうか

私の解答

これが私の解答です(src/main.rs 内):

#![deny(unsafe_code)]
#![no_main]
#![no_std]

use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

// You'll find these useful ;-).
use core::f32::consts::PI;
use libm::{atan2f, floorf};

use microbit::{
    display::blocking::Display,
    hal::{twim, Timer},
    pac::twim0::frequency::FREQUENCY_A,
};

use lsm303agr::{Lsm303agr, MagMode, MagOutputDataRate};

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };

    let mut timer0 = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);

    let mut sensor = Lsm303agr::new_with_i2c(i2c);
    sensor.init().unwrap();
    sensor
        .set_mag_mode_and_odr(
            &mut timer0,
            MagMode::HighResolution,
            MagOutputDataRate::Hz10,
        )
        .unwrap();
    let mut sensor = sensor.into_mag_continuous().ok().unwrap();

    let mut leds = [[0u8; 5]; 5];

    // Indexes of the 16 LEDs to be used in the display, and their
    // compass directions.
    #[rustfmt::skip]
    let indices = [
        (2, 0), /* W */
        (3, 0), /* W-SW */
        (3, 1), /* SW */
        (4, 1), /* S-SW */
        (4, 2), /* S */
        (4, 3), /* S-SE */
        (3, 3), /* SE */
        (3, 4), /* E-SE */
        (2, 4), /* E */
        (1, 4), /* E-NE */
        (1, 3), /* NE */
        (0, 3), /* N-NE */
        (0, 2), /* N */
        (0, 1), /* N-NW */
        (1, 1), /* NW */
        (1, 0), /* W-NW */
    ];

    loop {
        while !sensor.mag_status().unwrap().xyz_new_data() {
            timer0.delay_ms(1u32);
        }
        let (x, y, _) = sensor.magnetic_field().unwrap().xyz_nt();

        // Get an angle between -180° and 180° from the x axis.
        let theta = atan2f(y as f32, x as f32);

        // Cut the unit circle into thirty-two segments,
        // with pairs of adjacent segments corresponding to
        // each compass direction.
        let seg = floorf(16.0 * theta / PI) as i8;

        // Figure out what LED index to blink.
        let index = if seg >= 15 || seg <= -15 {
            8
        } else if seg >= 0 {
            (seg / 2) as usize
        } else {
            ((31 + seg) / 2) as usize
        };

        // Blink the given LED.
        let (r, c) = indices[index];
        leds[r][c] = 255u8;
        display.show(&mut timer0, leds, 50);
        leds[r][c] = 0u8;
        display.show(&mut timer0, leds, 50);
    }
}

Punch-o-meter

このセクションでは、ボードに搭載されている加速度センサーを使っていきます。

今回は何を作るのでしょうか? Punch-o-meter です! ジャブの威力を測定します。もっとも、 実際に測定するのは到達できる最大加速度です。というのも、加速度センサーが測定するのは 加速度だからです。ただし、力と加速度は比例するので、これは良い近似になります。

前の章ですでに見たように、加速度センサーは LSM303AGR パッケージの内部に組み込まれています。 磁力計と同様に、I2C バスを使ってアクセスできます。加速度センサーも、磁力計と同じ座標系を 持っています。

重力は上向き?

最初に何をするのでしょうか?

簡単な動作確認をしましょう!

ここまで来ていれば、I2C の章で、加速度センサーのデータを RTT コンソールに継続的に表示するプログラムをすでに書けるはずです。私のものは、その章の examples/show-accel.rs にあります。基板の裏側を上に向けて床と平行に持っただけでも、何か興味深いことに気づきませんか? (加速度センサーは基板の裏側に実装されているので、このように上下逆さまに持つと Z 軸は上を向くことを思い出してください。)

このように基板を持ったときに見えるはずなのは、X と Y の値がどちらも 0 にかなり近く、一方で Z の値はおよそ 1000 になっていることです。これは奇妙です。基板は動いていないのに、加速度は 0 ではありません。何が起きているのでしょうか? これは重力に関係しているはずですよね? なぜなら、重力加速度は 1 g だからです(なるほど、加速度センサーでは 1 g = -1000 です)。しかし、重力は物体を下向きに引っ張るので、Z 軸方向の加速度は負ではなく正であるはずです。

プログラムが Z 軸の向きを逆に扱っているのでしょうか? いいえ。基板を回転させて重力が X 軸や Y 軸に揃うようにして試してみても、加速度センサーが測定する加速度は常に上向きです。

ここで起きているのは、加速度センサーが測定しているのは 固有加速度 であって、あなた が観測している加速度ではない、ということです。この固有加速度とは、自由落下している観測者から見た基板の加速度のことです。自由落下している観測者は、地球の中心に向かって 1g の加速度で移動しています。その観測者から見ると、基板は実際には 1g の加速度で上向き(地球の中心から遠ざかる向き)に動いているのです。だから、固有加速度は上向きになるのです。これはまた、基板が自由落下していれば、加速度センサーは 0 の固有加速度を報告することも意味します。どうか、家では試さないでください。あるいは、落として MB2 を危険にさらす覚悟があるなら、どうぞ。

ええ、物理は難しいですね。先に進みましょう。

課題

ソフトウェアをシンプルに保つため、ボードは地面と平行な状態でパンチすると仮定します。パンチの大きさを 測定するには、X 軸と Y 軸の加速度の両方を考慮する必要があります (Z 軸は単に重力を反映しているだけなので無視します)。さらに簡単にするため、B ボタンが自分に近く、 A ボタンが自分から遠くなるようにボードを持ち、そのまま自分から遠ざける方向にパンチすると仮定します。 これは、正の X 方向にパンチすることを意味します。

punch-o-meter は次のように動作しなければなりません。

  • デフォルトでは、アプリはボードの加速度を「観測」していません。
  • 大きな X 軸加速度が検出されたとき(つまり、加速度があるしきい値を超えたとき)、 アプリは新しい測定を開始しなければなりません。
  • その測定区間のあいだ、アプリは観測された最大加速度を追跡し続ける必要があります
  • 測定区間が終了したら、アプリは観測された最大加速度を報告しなければなりません。値の報告には rprintln! マクロを使えます。

ぜひ試して、どれくらい強くパンチできるか教えてください ;-)

この課題に役立つ追加の API がもう 1 つありますが、まだ 説明していません。それが、高い g 値を測定するために必要な set_accel_scale です。

私の解答

こちらが私の解答(src/main.rs)です。MB2 の端をテーブルに打ち付けると、かなり高い G 値を得られることがあります。また、これで加速度センサーが壊れる可能性もあるので、たぶんやらないほうがよいでしょう。

#![deny(unsafe_code)]
#![no_main]
#![no_std]

const TICKS_PER_SEC: u32 = 400;
const THRESHOLD: f32 = 1.5;

use cortex_m::asm::nop;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::{twim, Timer},
    pac::twim0::frequency::FREQUENCY_A,
};

use lsm303agr::{AccelMode, AccelOutputDataRate, AccelScale, Lsm303agr};

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = microbit::Board::take().unwrap();

    let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };

    let mut delay = Timer::new(board.TIMER0);
    let mut sensor = Lsm303agr::new_with_i2c(i2c);
    sensor.init().unwrap();
    sensor
        .set_accel_mode_and_odr(&mut delay, AccelMode::Normal, AccelOutputDataRate::Hz400)
        .unwrap();
    // Allow the sensor to measure up to 16 G since human punches
    // can actually be quite fast
    sensor.set_accel_scale(AccelScale::G16).unwrap();

    let mut max_g = 0.;
    let mut countdown_ticks = None;

    loop {
        while !sensor.accel_status().unwrap().xyz_new_data() {
            nop();
        }
        // x acceleration in g
        let (x, _, _) = sensor.acceleration().unwrap().xyz_mg();
        let g_x = x as f32 / 1000.0;

        if let Some(ticks) = countdown_ticks {
            if ticks > 0 {
                // countdown isn't done yet
                if g_x > max_g {
                    max_g = g_x;
                }
                countdown_ticks = Some(ticks - 1);
            } else {
                // Countdown is done: report max value
                rprintln!("Max acceleration: {}g", max_g);

                // Reset
                max_g = 0.;
                countdown_ticks = None;
            }
        } else {
            // If acceleration goes above a threshold, we start measuring
            if g_x > THRESHOLD {
                rprintln!("START!");

                max_g = g_x;
                countdown_ticks = Some(TICKS_PER_SEC);
            }
        }
    }
}

割り込み

割り込み

ここまでで、MB2 上のさまざまなハードウェアに触れてきました。ボタンを読み取り、タイマーを待ち、シリアル通信を行い、I2C を使ってデバイスとやり取りしてきました。これらはいずれも、1 つ以上のペリフェラルの準備が整うまで待つ必要がありました。これまでは、その待機を「ポーリング」で行っていました。つまり、完了したかどうかをペリフェラルに繰り返し問い合わせ、完了するまでそれを続ける方法です。

このマイクロコントローラには CPU コアが 1 つしかないので、待っている間はほかのことができません。さらに、CPU コアがペリフェラルを継続的にポーリングすると電力を無駄にしますし、多くのアプリケーションではそれは許容できません。もっと良い方法はあるでしょうか?

幸い、それは可能です! この小さなマイクロコントローラは処理を並列には実行できませんが、実行中に異なるタスクを簡単に切り替え、外界からのイベントに応答できます。この切り替えは「割り込み」と呼ばれる機能で実現されます。

割り込みという名前はまさにその通りで、ペリフェラルがコアのプログラム実行を実際にいつでも中断できるようにします。MB2 の nRF52833 では、ペリフェラルはコアの Nested Vectored Interrupt Controller(NVIC)に接続されています。NVIC は CPU をその場で停止させ、別の処理を行うよう指示し、それが終われば割り込まれる前にしていた作業へ CPU を戻せます。割り込みコントローラの Nested と Vectored の部分については後で扱います。まずは、コアがどのようにタスクを切り替えるかに注目しましょう。

割り込みの処理

NRF52833 が使う計算モデルは、ほとんどすべての現代的な CPU で使われているものです。CPU の内部には、「CPU レジスタ」と呼ばれる作業用の記憶領域があります。(やや紛らわしいですが、この CPU レジスタは、前の Registers 章で説明した「デバイスレジスタ」とは別物です。)計算を実行するために、CPU は通常、メモリから CPU レジスタへ値を読み込み、レジスタ内の値を使って計算を行い、その結果をメモリへ書き戻します。(これは「ロードストアアーキテクチャ」として知られています。)

CPU が現在実行している計算に関する情報はすべて、CPU レジスタに保存されています。コアがタスクを切り替えるなら、新しいタスクがレジスタを自分の作業領域として使えるように、CPU レジスタの内容をどこかに保存しなければなりません。新しいタスクが完了すると、CPU はレジスタの値を復元して元の計算を再開できます。実際、それこそがコアが割り込み要求に応答して最初に行うことです。すぐに現在の処理を停止し、CPU レジスタの内容をスタックに保存します。

次の段階では、割り込みに応答して実行すべきコードへ実際にジャンプします。Interrupt Service Routine(ISR)は、しばしば割り込み「ハンドラ」とも呼ばれ、割り込みに応答してコアから呼び出される、アプリケーションコード内の特別な関数です。メモリ上の「割り込みテーブル」には、起こり得るすべての割り込みに対応する「割り込みベクタ」が含まれています。割り込みベクタは、特定の割り込みを受信したときにどの ISR を呼び出すかを示します。ISR のベクタリングの詳細は NVIC and Interrupt Priority セクションで説明します。

ISR 関数は、特別な割り込み復帰(return-from-interrupt)機械命令を使って「リターン」します。この命令により、CPU は CPU レジスタを復元し、ISR が呼び出される前の位置へジャンプして戻ります。

MB2 をつつく

ISR を定義し、Button A が押されたときに MB2 を「つつく」割り込みを設定してみましょう (examples/poke.rs)。ボードは「ouch」と言ってパニックを起こします。

#![no_main]
#![no_std]

use cortex_m::asm;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::{
        gpiote,
        pac::{self, interrupt},
    },
    Board,
};

/// This "function" will be called when an interrupt is received. For now, just
/// report and panic.
#[interrupt]
fn GPIOTE() {
    rprintln!("ouch");
    panic!();
}

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let button_a = board.buttons.button_a.into_floating_input();

    // Set up the GPIOTE to generate an interrupt when Button A is pressed (GPIO
    // wire goes low).
    let gpiote = gpiote::Gpiote::new(board.GPIOTE);
    let channel = gpiote.channel0();
    channel
        .input_pin(&button_a.degrade())
        .hi_to_lo()
        .enable_interrupt();
    channel.reset_events();

    // Set up the NVIC to handle GPIO interrupts.
    unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
    pac::NVIC::unpend(pac::Interrupt::GPIOTE);

    loop {
        // "wait for interrupt": CPU goes to sleep until an interrupt.
        asm::wfi();
    }
}

ISR ハンドラ関数は「特別」です。ここでは名前を GPIOTE にする必要があり、これは この ISR が割り込みテーブル内の GPIOTE 割り込み用エントリに格納されるべきことを示しています。

#[interrupt] デコレーションは、コンパイル時に関数を ISR として特別扱いする印を付けるために 使われます。(これは「proc macro」です。必要なら Rust book で詳しく読めます。)

要するに、「proc macro」はソースコードを別のソースコードへ変換します。特定のマクロの使用がどのようなコードに変換されるのか気になるなら、 そのマクロ呼び出しを展開できます。Rust Playground の Tools か、IDE の「rust-analyzer: Expand macro」コマンドを使えばできます。

#[interrupt] を関数に付けると、その関数にはいくつかの特別な性質が生じます。

  • コンパイラは、その関数が引数を取らず、戻り値も返さないこと(あるいは決して戻らないこと)を検査します。CPU には ISR に渡す 引数がなく、ISR からの戻り値を置く場所もありません。これは、割り込みハンドラが独自のコールスタックを持つからです(少なくとも 概念上は、実際には常にそうとは限りません)。

  • この関数を指すベクタ(つまり関数ポインタ)が、その関数名に対応する割り込みテーブル内の位置に 配置されます。

  • コンパイラは、通常のコードから ISR を直接呼び出すことを防ぎます。

割り込みを設定するには 2 つの手順があります。まず、Button A につながったピンの電圧が high から low に変化したときに割り込みを生成するよう、 GPIOTE を設定しなければなりません。次に、その割り込みを許可するよう NVIC を設定する必要があります。順序は少し重要で、「間違った」順番で行うと、 処理する準備ができる前に割り込みが発生することがあります。

ほとんどのマイクロコントローラと同様に、GPIOTE がいつ割り込みを生成するかには大きな柔軟性があります。割り込みは、ピンが low から high に遷移したとき、high から low に遷移したとき(ここで使っているもの)、任意の変化(「エッジ」)、low のとき、または high のときに生成できます。nRF52833 では、割り込みはイベントを生成し、そのイベントは同じ割り込みに対して ISR が 2 回目に呼び出されないよう、ISR 内で手動でクリアしなければなりません。ほかのマイクロコントローラでは少し動作が異なることがあります。別のボードでの詳細を理解するには、Rust crate とマイクロコントローラのドキュメントを読むべきです。

A ボタンを押すと、「ouch」というメッセージが表示され、その後パニックになります。なぜ割り込み ハンドラは panic!() を呼ぶのでしょうか? panic!() の呼び出しをコメントアウトして、ボタンを押したときに何が起こるか見てみてください。画面には 「ouch」というメッセージが流れ続けるはずです。NVIC は割り込みが発行されたことを記録します。その「イベント」は、実行中のプログラムが明示的にクリアするまで保持されます。panic!() がないと、 割り込みハンドラが戻ったときに NVIC は(この場合)割り込みを再び有効化し、まだ保留中の割り込みイベントがあることに気付いて、ハンドラを再度実行します。これは 永遠に続きます。割り込みハンドラが戻るたびに、また呼び出されます。すぐに見るように、割り込みの通知は割り込みハンドラの内側から reset_event() ペリフェラルメソッドを使ってクリアできます。

I2C の準備ができたとき、タイマーが期限に達したときなど、さまざまな割り込み要因に対して ISR を定義できます。ISR の中ではほぼ何でもできますが、 割り込みハンドラは短く素早く保つのがよい実践です。

通常、ISR が完了すると、メインプログラムは割り込みが起きなかったかのようにそのまま実行を続けます。しかし、ここには少し問題があります。ISR が実行されて何らかの処理を行ったことを、アプリケーションはどうやって知ればよいのでしょうか。ISR には入力引数も結果もないので、ISR のコードはどのようにアプリケーションコードとやり取りできるのでしょうか?

NVIC と割り込み優先度

NVIC と割り込み優先度

これまで見てきたように、割り込みによってプロセッサはコード内の別の関数へ即座にジャンプできます。では、これを可能にしている背後では何が起きているのでしょうか? この節では、本書の残りを読むうえでは必須ではない技術的な詳細をいくつか扱いますので、興味がなければ先へ進んでも構いません。

割り込みコントローラ

割り込みによって、プロセッサは GPIO 入力ピンの状態変化、タイマーの周期完了、UART が新しいバイトを受信したことなどのペリフェラルイベントに応答できます。ペリフェラルには、そのイベントを検知して専用の割り込み処理用ペリフェラルに通知する回路が含まれています。Arm プロセッサでは、この割り込み処理用ペリフェラルは NVIC — Nested Vector Interrupt Controller と呼ばれます。

注記 RISC-V のような他のマイクロコントローラアーキテクチャでは、ここで説明する名前や詳細は異なりますが、根底にある原理は一般的によく似ています。

NVIC は、多くのペリフェラルから割り込み発生要求を受け取れます。1 つのペリフェラルに複数の割り込み候補があることも一般的で、たとえば GPIO ポートが各ピンごとの割り込みを持っていたり、UART が「データ受信」と「データ送信完了」の両方の割り込みを持っていたりします。NVIC の役割は、これらの割り込みに優先順位を付け、どの割り込みがまだ処理待ちかを記憶し、そのうえで関連する割り込みハンドラコードをプロセッサに実行させることです。

割り込み優先度

NVIC には、各割り込みに対して設定可能な「優先度」があります。設定によっては、NVIC は新しい割り込みを実行する前に現在の割り込みが完全に処理されることを保証できますし、より高い優先度の別の割り込みを処理するために、ある割り込みの途中でプロセッサを「プリエンプト」することもできます。

プリエンプションにより、プロセッサは重大なイベントに非常に素早く応答できます。たとえば、ロボットコントローラでは、低優先度の割り込みを使ってオペレータへのステータス情報送信を管理しつつ、センサーが差し迫った衝突を検知したときには高優先度の割り込みを受けて、モーターを即座に停止できるようにするかもしれません。停止する前にデータパケットの送信が終わるまでロボットが待つようでは困ります!

同一優先度またはそれより低い優先度の割り込みが ISR の実行中に発生した場合、その割り込みは「保留」されます。つまり、NVIC はその新しい割り込みを記憶し、現在の ISR が完了した後のどこかの時点でその ISR を実行します。ISR 関数が戻ると、NVIC は ISR の実行中に処理が必要な他の割り込みが発生していないかを確認します。もしあれば、NVIC は割り込みテーブルを確認し、そこにベクタリングされている最優先の ISR を呼び出します。そうでなければ、CPU は実行中だったプログラムに戻ります。

割り込みが完全に無効化されている場合、入ってくるすべての割り込みは保留されることに注意してください。保留中の割り込みは、割り込みが再び有効になった時点で処理されます。

組み込み Rust では、cortex-m crate を使って NVIC をプログラムできます。この crate は、割り込みの有効化と無効化(unmaskmask と呼ばれます)、割り込み優先度の設定、ソフトウェアからの割り込み発生を行うメソッドを提供します。RTIC のようなフレームワークは NVIC の設定を代行でき、NVIC の柔軟性を活用して、便利なリソース共有やタスク管理を提供します。

NVIC についての詳細は Arm’s documentation で読むことができます。

ベクタテーブル

NVIC を説明する際に、「関連する割り込みハンドラコードをプロセッサに実行させる」と述べました。しかし、実際にはそれはどのように動作するのでしょうか?

まず、各割り込みに対してどのコードを実行すべきかをプロセッサが知るための仕組みが必要です。Cortex-M プロセッサでは、これはベクタテーブルと呼ばれるメモリ領域に関係します。これは通常、私たちのコードを含むフラッシュメモリの先頭に配置されており、そのフラッシュメモリは新しいコードをプロセッサに書き込むたびに再プログラムされます。そしてそこには、すべての割り込み関数のアドレス、つまりメモリ上の位置の一覧が含まれています。メモリ先頭部分の具体的なレイアウトは Architecture Reference Manual で Arm によって定義されています。ここでは、64 バイト目から 256 バイト目までに、私たちが使用する nRF プロセッサの 48 個すべての割り込みハンドラのアドレスが、1 アドレスあたり 4 バイトで格納されていることが重要です。各割り込みには 0 から 47 までの番号があります。たとえば、TIMER0 は割り込み番号 8 なので、96 バイト目から 100 バイト目にはその割り込みハンドラの 4 バイトアドレスが含まれています。NVIC がプロセッサに割り込み番号 8 を処理するよう指示すると、CPU はそのバイト列に格納されたアドレスを読み取り、そこへ実行をジャンプします。

このベクタテーブルは、私たちのコードではどのように生成されるのでしょうか? 私たちは cortex-m-rt crate を使用しており、これがその処理を担ってくれます。この crate は、使用されていない各位置に対してデフォルトの割り込みを提供し(すべての位置が埋まっていなければならないため)、独自の割り込みハンドラを指定したい場合には、このデフォルトをコード側で上書きできるようにしています。これには #[interrupt] マクロを使います。このマクロでは、関数に、その関数が処理する割り込みに関連した特定の名前を付ける必要があります。その後、cortex-m-rt crate がそのリンカスクリプトを使って、その関数のアドレスがメモリ内の正しい場所に配置されるようにします。

Rust におけるこれらの割り込みハンドラの管理方法についてさらに詳しく知りたい場合は、Embedded Rust Book の Exceptions 章および Interrupts 章を参照してください。

グローバル変数でデータを共有する

グローバル変数とのデータ共有

NOTE この内容の一部は、James Munns によるブログ記事 Interrupts Is Threads から(許可を得て)引用しています。この記事には、この トピックに関するより詳しい議論が含まれています。

前にも述べたように、割り込みが発生しても、こちらには何の引数も渡されず、 結果を返すこともできません。これにより、プログラムがペリフェラルや、メイン プログラムのほかの状態とやり取りするのが難しくなります。このベアメタル組み込み 特有の問題について考える前に、まずは “std” Rust におけるスレッドについて考えて みる価値があります。

“std” Rust: スレッドとのデータ共有

“std” Rust でも、スレッドを spawn するようなことをするときには、データ共有を 考える必要があります。

何かをスレッドに 渡したい ときは、所有権を伴ってクロージャに渡すことがあります。

#![allow(unused)]
fn main() {
// 現在のスレッドで文字列を作成する
let data = String::from("hello");

// 新しいスレッドを起動し、今作成した文字列の所有権を
// それに渡す
std::thread::spawn(move || {
    std::thread::sleep(std::time::Duration::from_millis(1000));
    println!("{data}");
});
}

何かを 共有したい うえで、元のスレッドからも引き続きアクセスしたい場合、通常は その参照を渡すことはできません。次のようにすると:

use std::{thread::{sleep, spawn}, time::Duration};

fn main() {
    // 現在のスレッドで文字列を作成する
    let data = String::from("hello");
    
    // 渡すための参照を作る
    let data_ref = &data;
    
    // 新しいスレッドを起動し、今作成した文字列の所有権を
    // それに渡す
    spawn(|| {
        sleep(Duration::from_millis(1000));
        println!("{data_ref}");
    });
    
    println!("{data_ref}");
}

次のようなエラーが出ます:

error[E0597]: `data` does not live long enough
  --> src/main.rs:6:20
   |
3  |       let data = String::from("hello");
   |           ---- binding `data` declared here
...
6  |       let data_ref = &data;
   |                      ^^^^^ borrowed value does not live long enough
...
10 | /     spawn(|| {
11 | |         sleep(Duration::from_millis(1000));
12 | |         println!("{data_ref}");
13 | |     });
   | |______- argument requires that `data` is borrowed for `'static`
...
16 |   }
   |   - `data` dropped here while still borrowed

現在のスレッドと、これから作成する新しいスレッドの両方に対して、データが十分長く 生存することを確実にする 必要があります。これは、次のように Arc (Atomically Reference Counted なヒープ割り当て)に入れることで実現できます:

use std::{sync::Arc, thread::{sleep, spawn}, time::Duration};

fn main() {
    // 現在のスレッドで文字列を作成する
    let data = Arc::new(String::from("hello"));
    
    let handle = spawn({
        // 新しいスレッドに渡すため、ハンドルのコピーを作る。
        // `data` と `new_thread_data` はどちらも
        // 同じ文字列を指している!
        let new_thread_data = data.clone();
        move || {
            sleep(Duration::from_millis(1000));
            println!("{new_thread_data}");
        }
    });
    
    println!("{data}");
    // スレッドが停止するのを待つ
    let _ = handle.join();
}

これは素晴らしいことです! これで、メインスレッドでも好きなだけ長くデータに アクセスできるようになります。では、両方の場所でデータを 変更したい 場合は どうでしょうか?

このためには通常、何らかの「内部可変性」が必要になります。つまり、変更するために &mut を必要としない型です。デスクトップでは、通常 Mutex のような型を使い、 それを lock() してデータへの可変アクセスを取得します。

これは例えば次のようになります:

use std::{sync::{Arc, Mutex}, thread::{sleep, spawn}, time::Duration};

fn main() {
    // 現在のスレッドで文字列を作成する
    let data = Arc::new(Mutex::new(String::from("hello")));
    
    // 元のスレッドからロックする
    {
        let guard = data.lock().unwrap();
        println!("{guard}");
        // ガードはスコープの終わりでここでドロップされる!
    }
    
    let handle = spawn({
        // 新しいスレッドに渡すため、ハンドルのコピーを作る。
        // `data` と `new_thread_data` はどちらも
        // 同じ `Mutex<String>` を指している!
        let new_thread_data = data.clone();
        move || {
            sleep(Duration::from_millis(1000));
            {
                let mut guard = new_thread_data.lock().unwrap();
                // データを変更できる!
                guard.push_str(" | thread was here! |");                
                // ガードはスコープの終わりでここでドロップされる!
            }
        }
    });
    
    // スレッドが停止するのを待つ
    let _ = handle.join();
    {
        let guard = data.lock().unwrap();
        println!("{guard}");
        // ガードはスコープの終わりでここでドロップされる!
    }
}

このコードを実行すると、次のように表示されます:

hello
hello | thread was here! |

なぜ “std” Rust ではこのようなことをしなければならないのでしょうか? Rust は、 次の 2 つのことを考えるよう私たちを助けてくれています:

  1. データが十分長く生存すること(場合によっては「永遠に」!)
  2. 一度に可変アクセスできるコードは 1 つだけであること

Rust が、十分長く生存しないかもしれないデータ、たとえばあるスレッドから別の スレッドへ借用されたデータへのアクセスを許してしまうと、問題が起こるかもしれません。 元のスレッドが終了したり panic したりしたあとで、2 つ目のスレッドがすでに無効に なったデータにアクセスしようとすると、データが壊れてしまう可能性があります。Rust が、 同じデータを 2 つのコード片が同時に変更しようとすることを許してしまうと、データ競合が 起きたり、データが壊れたりする可能性があります。

組み込み Rust: ISR とのデータ共有

組み込み Rust でも、割り込みハンドラとデータを共有する際には、同じことを気に しなければなりません! スレッドと同様に、割り込みはいつでも発生し得ます。これは、 ある共有データにアクセスするためにスレッドが目を覚ますようなものです。つまり、 割り込みと共有するデータは十分長く生存しなければならず、また ISR が実行されて 同じく そのデータを扱おうとしたときに、メインコードが ISR と共有されたデータを ちょうど処理している途中ではないよう、注意しなければなりません!

実際、組み込み Rust では、Rust におけるスレッドのモデル化と似たやり方で割り込みを モデル化します。同じ理由で、同じルールが適用されます。ただし、組み込み Rust には いくつか重要な違いがあります:

  • 割り込みはスレッドとまったく同じように動くわけではありません。あらかじめ設定して おき、何らかのイベントが起こるまで待機します(たとえばボタンが押されたり、タイマーが 期限切れになったりするときです)。その時点で実行されますが、渡されたコンテキストには アクセスできません。

  • 割り込みは、そのイベントが発生するたびに、複数回トリガーされる可能性があります。

割り込みに関数引数としてコンテキストを渡せないので、そのデータを保存する別の場所を 見つける必要があります。「ベアメタル」の組み込み Rust では、ヒープ割り当てにアクセス できません。したがって、Arc やそれに類するものは使えません。

値渡しすることもできず、データを保存するヒープもないとなると、ISR がアクセスできる 共有データを置く場所は 1 つしかありません。それが static なグローバル変数です。

組み込み Rust における ISR データ共有: 「標準的な方法」

Rust ではグローバル変数はかなり二級市民的な扱いで、ローカル変数と比べると 多くの制限があります。グローバルな状態変数は次のように宣言できます:

#![allow(unused)]
fn main() {
static COUNTER: usize = 0;
}

もちろん、これはあまり実用的ではありません。COUNTER を変更できるようにしたいはずです。次のようにも 書けます

#![allow(unused)]
fn main() {
static mut COUNTER: usize = 0;
}

ただし、これであらゆるアクセスが unsafe になります。

#![allow(unused)]
fn main() {
unsafe { COUNTER += 1 };
}

ここで unsafe になるのには理由があります。COUNTER の更新の途中で割り込み ハンドラが実行され、そのハンドラも COUNTER を更新しようとする状況を想像して ください。いつもの混乱が起こります。明らかに、何らかのロックが必要です。

critical-section クレートは一種の Mutex 型を提供しますが、その API と操作は少し 変わっています。この章の Cargo.toml を見ると、cortex-m クレートで critical-section-single-core 機能が有効になっていることがわかるでしょう。この 機能は、このシステムにはプロセッサコアが 1 つしかなく、したがってクリティカル セクションの間は単に割り込みを無効化するだけで同期を行える、という前提を置いて います。割り込みの外であれば、これによってグローバルにアクセスできるのはメイン プログラムだけになります。割り込みの中であれば、メインプログラムはグローバルに アクセスできず(プログラム制御は割り込みハンドラ内にあります)、さらにそれより 高い優先度の別の割り込みハンドラも発火できないことが保証されます。

critical_section::Mutex は、相互排他は提供するものの、それ自体では可変性を 提供しないという点で少し変わっています。データを可変にするには、内部可変な型 — 通常は RefCell — をこの mutex で保護する必要があります。この Mutex は、 .lock() しないという点でも少し変わっています。代わりに、他のプログラム実行が 阻止されていることを証明する「critical section token」を受け取るクロージャで クリティカルセクションを開始します。このトークンを Mutexborrow() メソッドに渡すことで、アクセスが可能になります。

これらをすべて組み合わせると、ISR とメインプログラムの間で状態を共有できるように なります(examples/count-once.rs)。

#![no_main]
#![no_std]

use core::cell::RefCell;

use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section::Mutex;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::{
        gpiote,
        pac::{self, interrupt},
    },
    Board,
};

static COUNTER: Mutex<RefCell<usize>> = Mutex::new(RefCell::new(0));

/// This "function" will be called when an interrupt is received. For now, just
/// report and panic.
#[interrupt]
fn GPIOTE() {
    critical_section::with(|cs| {
        let mut count = COUNTER.borrow(cs).borrow_mut();
        *count += 1;
        rprintln!("count: {}", count);
    });
    panic!();
}

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let button_a = board.buttons.button_a.into_floating_input();

    // Set up the GPIOTE to generate an interrupt when Button A is pressed (GPIO
    // wire goes low).
    let gpiote = gpiote::Gpiote::new(board.GPIOTE);
    let channel = gpiote.channel0();
    channel
        .input_pin(&button_a.degrade())
        .hi_to_lo()
        .enable_interrupt();
    channel.reset_events();

    // Set up the NVIC to handle GPIO interrupts.
    unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
    pac::NVIC::unpend(pac::Interrupt::GPIOTE);

    loop {
        // "wait for interrupt": CPU goes to sleep until an interrupt.
        asm::wfi();
    }
}

まだ ISR から安全に return することはできませんが、いまやそれに対処できる位置に います。GPIOTE を ISR と共有して、ISR が割り込みをクリアできるようにするのです。

グローバルを使ってペリフェラル(など)を共有する

解決すべき問題がもう 1 つあります。Rust のグローバルは、プログラム開始前に静的に 初期化されていなければなりません。カウンタなら簡単で、0 に初期化するだけでした。 しかし GPIOTE ペリフェラルを共有したい場合は、そうはいきません。その ペリフェラルは Board 構造体から取り出して、プログラム開始後にセットアップ しなければなりません。これに対する const イニシャライザは存在しません (そして、妥当な形で存在し得るものでもありません)。

ボタンカウンタを少し書き換えてみましょう。まず、実際のカウントは AtomicUsize に 移します。いずれにせよ、こちらのほうがこのグローバルにはより自然な型です。次に、 critical-section-lock-mut クレートの LockMut 型を使って、グローバル変数 GPIOTE_PERIPHERAL を追加します。このクレートは、前の節のパターンを扱いやすく したラッパーです。

メインプログラムが GPIOTE ペリフェラルをセットアップし、それを割り込みハンドラ から使えるようにできるようになったので、もう panic するのはやめて、ボタンが 押されるたびにカウンタを増やせるようになります。カウント表示はメインループに 移して、カウントが割り込みハンドラとプログラムの残りの部分との間で共有されて いることを示しましょう。

この例(examples/count.rs)を実行して、MB2 A ボタンを押すたびにカウントが 1 ずつ 増えることを確認してください。

#![no_main]
#![no_std]

use core::sync::atomic::{AtomicUsize, Ordering::AcqRel};

use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::{
        gpiote,
        pac::{self, interrupt},
    },
    Board,
};

static COUNTER: AtomicUsize = AtomicUsize::new(0);
static GPIOTE_PERIPHERAL: LockMut<gpiote::Gpiote> = LockMut::new();

#[interrupt]
fn GPIOTE() {
    let count = COUNTER.fetch_add(1, AcqRel);
    rprintln!("ouch {}", count + 1);
    GPIOTE_PERIPHERAL.with_lock(|gpiote| {
        gpiote.channel0().reset_events();
    });
}

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let button_a = board.buttons.button_a.into_floating_input();

    // Set up the GPIOTE to generate an interrupt when Button A is pressed (GPIO
    // wire goes low).
    let gpiote = gpiote::Gpiote::new(board.GPIOTE);
    let channel = gpiote.channel0();
    channel
        .input_pin(&button_a.degrade())
        .hi_to_lo()
        .enable_interrupt();
    channel.reset_events();
    GPIOTE_PERIPHERAL.init(gpiote);

    // Set up the NVIC to handle GPIO interrupts.
    unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
    pac::NVIC::unpend(pac::Interrupt::GPIOTE);

    loop {
        // "wait for interrupt": CPU goes to sleep until an interrupt.
        asm::wfi();
    }
}

注記 割り込み処理を含む例は、常に --release でコンパイルするのが よい考えです。長い割り込みハンドラは多くの混乱を招く可能性があります。

とはいえ、割り込みハンドラ内の rprintln!() は実際には悪い作法です。割り込み ハンドラが出力コードを実行している間は、ほかの何も先に進めません。報告はメイン ループに移し、wfi()(「割り込み待ち」)のすぐ後で行うことにしましょう。そう すれば、割り込みハンドラが終了するたびにカウントが表示されます (examples/count-bounce.rs)。

#![no_main]
#![no_std]

use core::sync::atomic::{
    AtomicUsize,
    Ordering::{AcqRel, Acquire},
};

use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::{
        gpiote,
        pac::{self, interrupt},
    },
    Board,
};

static COUNTER: AtomicUsize = AtomicUsize::new(0);
static GPIOTE_PERIPHERAL: LockMut<gpiote::Gpiote> = LockMut::new();

#[interrupt]
fn GPIOTE() {
    let _ = COUNTER.fetch_add(1, AcqRel);
    GPIOTE_PERIPHERAL.with_lock(|gpiote| {
        gpiote.channel0().reset_events();
    });
}

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let button_a = board.buttons.button_a.into_floating_input();

    // Set up the GPIOTE to generate an interrupt when Button A is pressed (GPIO
    // wire goes low).
    let gpiote = gpiote::Gpiote::new(board.GPIOTE);
    let channel = gpiote.channel0();
    channel
        .input_pin(&button_a.degrade())
        .hi_to_lo()
        .enable_interrupt();
    channel.reset_events();
    GPIOTE_PERIPHERAL.init(gpiote);

    // Set up the NVIC to handle GPIO interrupts.
    unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
    pac::NVIC::unpend(pac::Interrupt::GPIOTE);

    loop {
        // "wait for interrupt": CPU goes to sleep until an interrupt.
        asm::wfi();
        let count = COUNTER.load(Acquire);
        rprintln!("ouch {}", count);
    }
}

この例では、MB2 A ボタンを押すたびにカウントが 1 増えます。たぶん。特に MB2 が 古い場合(!)は、1 回押しただけでカウンタが数回分増えることがあるかもしれません。 これはソフトウェアのバグではありません。 たいていは。次の節では、何が起きて いる可能性があるのか、そしてそれにどう対処すべきかについて説明します。

デバウンシング

デバウンス

前のセクションで述べたように、ハードウェアは少し……特殊なことがあります。これは MB2 の ボタンでまさに当てはまり、実際にはほぼあらゆるシステムのほぼあらゆる押しボタンやスイッチでも同様です。1 回のキー押下で複数の割り込みが発生しているなら、その原因はおそらくスイッチの 「バウンス」として知られている現象です。これは文字どおり名前のとおりの現象です。スイッチの 電気接点が接触するとき、しっかり接続が確立されるまでの間に、いったん離れて再び接触することを 短時間のうちに何度か繰り返すことがあります。残念ながら、私たちのマイクロプロセッサは機械的な 基準では 非常に 高速です。このバウンスの 1 回 1 回が新しい割り込みを発生させます。

スイッチを「デバウンス」するには、ボタン押下の割り込みを 1 回受け取ったあと、短時間はそれを 処理しないようにする必要があります。通常は 50〜100ms が適切なデバウンス間隔です。デバウンスの タイミング処理は難しそうです。割り込みハンドラの中でビジーウェイトするのは絶対に避けたいですし、 かといってメインプログラムでこれを扱うのも簡単ではありません。

この解決策は、別の形のハードウェア並行性、つまりこれまでも何度も使ってきた TIMER ペリフェラルにあります。「有効な」ボタン割り込みを受け取ったときにタイマーを設定し、そのボタンに ついてはタイマーペリフェラルが十分な時間を数え終えるまで、それ以降の割り込みには応答しないように できます。nrf-hal のタイマーは、32 ビットのカウント値と 1 MHz の「ティックレート」 (1 秒あたり 100 万ティック)で設定されています。100ms のデバウンスなら、タイマーに 100,000 ティック数えさせるだけです。ボタンの割り込みハンドラは、タイマーが動作中であることを 確認したら、何もしなければよいのです。

これらすべての実装は次の例(examples/count-debounce.rs)で確認できます。この例を実行すると、 ボタンを 1 回押すごとに 1 回だけカウントされるはずです。

#![no_main]
#![no_std]

use core::sync::atomic::{
    AtomicUsize,
    Ordering::{AcqRel, Acquire},
};

use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::{
        self, gpiote,
        pac::{self, interrupt},
    },
    Board,
};

static COUNTER: AtomicUsize = AtomicUsize::new(0);
static GPIOTE_PERIPHERAL: LockMut<gpiote::Gpiote> = LockMut::new();
static DEBOUNCE_TIMER: LockMut<hal::Timer<pac::TIMER0>> = LockMut::new();

// 100ms at 1MHz count rate.
const DEBOUNCE_TIME: u32 = 100 * 1_000_000 / 1000;

#[interrupt]
fn GPIOTE() {
    DEBOUNCE_TIMER.with_lock(|debounce_timer| {
        if debounce_timer.read() == 0 {
            let _ = COUNTER.fetch_add(1, AcqRel);
            debounce_timer.start(DEBOUNCE_TIME);
        }
    });
    GPIOTE_PERIPHERAL.with_lock(|gpiote| {
        gpiote.channel0().reset_events();
    });
}

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let button_a = board.buttons.button_a.into_floating_input();

    // Set up the GPIOTE to generate an interrupt when Button A is pressed (GPIO
    // wire goes low).
    let gpiote = gpiote::Gpiote::new(board.GPIOTE);
    let channel = gpiote.channel0();
    channel
        .input_pin(&button_a.degrade())
        .hi_to_lo()
        .enable_interrupt();
    channel.reset_events();
    GPIOTE_PERIPHERAL.init(gpiote);

    // Set up the debounce timer.
    let mut debounce_timer = hal::Timer::new(board.TIMER0);
    debounce_timer.disable_interrupt();
    debounce_timer.reset_event();
    DEBOUNCE_TIMER.init(debounce_timer);

    // Set up the NVIC to handle interrupts.
    unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
    pac::NVIC::unpend(pac::Interrupt::GPIOTE);

    // Because we're not disabling GPIOTE interrupts even during the debounce timer countdown,
    // we can get extra button interrupts even during the debounce interval.
    // It would be reasonable to disable button interrupts when the debounce timer is started
    // and re-enable them when it expires, but this would require a debounce timer interrupt handler.
    // To make a simple "fix" for this that doesn't hurt the readability,
    //  we introduce the cur_count "guard" variable.
    let mut cur_count = 0;
    loop {
        // "wait for interrupt": CPU goes to sleep until an interrupt.
        asm::wfi();
        let count = COUNTER.load(Acquire);
        if count > cur_count {
            rprintln!("ouch {}", count);
            cur_count = count;
        }
    }
}

MB2 のボタンは少し扱いにくく、「カチッ」とした感触がある程度まで押しても、実際には スイッチが接触していないことがよくあります。テストするときは、爪でボタンを押すことをおすすめします。

割り込みを待つ

メインループで asm::nop() のようなものではなく asm::wfi()(割り込み待ち)を使ってきたのはなぜだろう、と疑問に思ったかもしれません。

前に説明したとおり、asm::nop() は no-op(eration) を意味し、CPU が何もせずに実行する命令です。もちろん、メインループで代わりに asm::nop() を使うこともでき、その場合でもプログラムの振る舞いは同じです。一方で、マイクロコントローラの振る舞いは異なります。

asm::wfi() を呼び出すと、CPU は “Wait For Interrupt”(WFI)モードに入ります。CPU が WFI モードの間は、割り込みによって起こされるまでスリープします。スリープ中は、CPU は命令のフェッチを停止し、一部のクロックやペリフェラルをオフにして低消費電力状態に入りますが、コア自体は動作を維持します。割り込みが発生すると、CPU は復帰して通常どおり実行を続けます。

asm::wfi()asm::nop() の主な違いは、NOP 命令は即座に完了するため、ループ内で繰り返し実行されることです。NOP は、その実行自体は何もしなくても、プログラムメモリからフェッチされて実行される必要があります。世の中のほとんどのマイクロコントローラには低消費電力モードがあり(中には複数備えているものもあり、何を有効のままにするかや消費電力特性がそれぞれ異なります)、それを電力節約のために利用できますし、多くの場合そうすべきです。WFI 命令は、割り込みを受信するまで 低消費電力モードで 実行を停止します。

メインループが asm::wfi() だけで構成され、プログラムのロジックのすべてが割り込みハンドラに実装されているような、割り込み駆動のプログラムもあります。

MB2 のスピーカー

MB2 にはスピーカーが内蔵されています。これは、ボード裏面の中央にある、 “SPEAKER” とラベル付けされた大きな黒い正方形のデバイスです。

このスピーカーは GPIO ピンに応答して空気を動かすことで動作します。スピーカーのピンが ハイ (3.3V) のとき、内部の振動板 — 「speaker cone」 — はいちばん外側まで押し出されます。 スピーカーのピンがロー (GND) のときには、いちばん内側まで引き戻されます。空気が押し出され、 また吸い戻されると、デバイスの側面にある小さな長方形の穴 — 「speaker port」 — を通って 空気が出入りします。これを十分に速く行うと、圧力の変化によって音が出ます。

適切なハードウェアで駆動できれば、このスピーカーコーンは、適切な電流によってその可動範囲内の 任意の位置へ実際に移動させることができます。そうすれば、「普通の」スピーカーのように、 どんな音でもかなりよく再現できます。残念ながら、スピーカーを制御する MB2 のハードウェアには 制約があるため、簡単に利用できるのは、完全に内側と完全に外側の位置だけです。

では、スピーカーコーンを 1 秒間に 220 回、外側へ押し出してから内側へ戻してみましょう。 これにより、1 秒あたり 220 周期の「矩形」圧力波が生じます。「cycles-per-second」という単位は ヘルツです。つまり、220Hz の音(音楽でいう「A3」)を生成することになります。これは、この 甲高いスピーカーでもそれほど不快ではありません。

この音を 5 秒間鳴らしてから止めることにします。ここで覚えておくべき重要な点は、この プログラムが MB2 のフラッシュ上にあることです — つまり、MB2 をリセットするたびに、さらには 電源を入れるたびにも、その音は再び鳴り始めます。音を永遠に鳴らし続けるようにすると、この挙動は すぐにかなり煩わしいものになりかねません。

コードは次のとおりです (examples/square-wave.rs)。

#![no_main]
#![no_std]

use cortex_m::asm;
use cortex_m_rt::entry;
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

use microbit::{
    hal::{gpio, timer},
    Board,
};

/// The "period" is the time per cycle. It is
/// 1/f where f is the frequency in Hz. In this
/// case we measure time in milliseconds.
const PERIOD: u32 = 1000 / 220;

/// Number of cycles for 5 seconds of output.
const CYCLES: u32 = 5000 / PERIOD;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let mut speaker_pin = board.speaker_pin.into_push_pull_output(gpio::Level::Low);
    let mut timer = timer::Timer::new(board.TIMER0);

    for _ in 0..CYCLES {
        speaker_pin.set_high().unwrap();
        timer.delay_ms(PERIOD / 2);
        speaker_pin.set_low().unwrap();
        timer.delay_ms(PERIOD / 2);
    }

    loop {
        asm::wfi();
    }
}

課題

MB2 をサイレンにしてみましょう! ただし、ただのサイレンではなく、割り込み駆動のサイレンです。そうすれば、サイレンをオンにしたあとも、プログラムの残りの部分はそれを無視して動き続けることができます。

サイレンのピッチが、1 秒周期で 440Hz から 660Hz まで上がり、また戻るようにしてください。メインプログラムはサイレンを開始し、その後 10 から 1 までの 10 秒間のカウントダウンを表示し、次にサイレンを停止して "launch!" と表示する必要があります。カウントダウン中、メインプログラムはサイレンに手を出してはいけません。サイレンは完全に割り込み駆動であるべきです。

ヒント: 私は、サイレンの状態と、それを動作させるのに必要な周辺機器を所有する、グローバルなロック付き Siren 構造体を使うのがいちばん簡単だと感じました。

これは多くの新しい考え方を導入する、少し高度なプログラムです。理解するのに少し時間がかかっても、驚かないでください。

私の解答

割り込みハンドラが、サイレンを鳴らし続けるために 次の割り込み時刻をどのように計算すべきかを理解するのが、 少し難しいと感じました。最終的に、スピーカーピンが オンかオフかを追跡するための状態変数をいくつか用意し (ハードウェアを確認してもよかったのですが)、さらに サイレンが上下サイクルのどの時点にあるかも追跡するようにしました。

私のコードには詳細がすべて含まれています(src/main.rs)。

#![no_main]
#![no_std]

use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};

use microbit::{
    hal::{
        gpio,
        pac::{self, interrupt},
        timer,
    },
    Board,
};

/// Base siren frequency in Hz.
const BASE_FREQ: u32 = 440;
/// Max rise in siren frequency in Hz.
const FREQ_RISE: u32 = 220;
/// Time for one full cycle in µs.
const RISE_TIME: u32 = 500_000;

/// These convenience types make life easier.
type SpeakerPin = gpio::Pin<gpio::Output<gpio::PushPull>>;
type SirenTimer = timer::Timer<pac::TIMER0>;

/// The current state of the siren. Updated by the interrupt
/// handler when running.
struct Siren {
    /// The timer being used by the siren.
    timer: SirenTimer,
    /// The MB2 speaker pin. Needs to be owned
    /// here for the interrupt handler.
    speaker_pin: SpeakerPin,
    /// Is the speaker pin currently high or low?
    pin_high: bool,
    /// Time in µs since the start of the current siren cycle.
    cur_time: u32,
}

impl Siren {
    /// Make a new siren with the given peripherals.
    fn new(speaker_pin: SpeakerPin, timer: SirenTimer) -> Self {
        Self {
            timer,
            speaker_pin,
            pin_high: false,
            cur_time: 0,
        }
    }

    /// Start the siren running.
    fn start(&mut self) {
        self.speaker_pin.set_low().unwrap();
        self.pin_high = false;
        self.cur_time = 0;
        self.timer.enable_interrupt();
        // The timer interval is in ticks.
        // The [nrf52833_hal] timer is hard-wired to 1M ticks/sec.
        self.timer.start(1_000_000 / BASE_FREQ);
    }

    /// Stop the siren.
    fn stop(&mut self) {
        self.timer.disable_interrupt();
    }

    /// Step the siren to the current speaker state change.
    /// This is normally called from the timer interrupt.
    fn step(&mut self) {
        // Flip the speaker pin.
        if self.pin_high {
            self.speaker_pin.set_low().unwrap();
            self.pin_high = false;
        } else {
            self.speaker_pin.set_high().unwrap();
            self.pin_high = true;
        }

        // Figure out the next period. The math is a little
        // special here.

        // First, wrap to the next siren cycle if needed.
        while self.cur_time >= 2 * RISE_TIME {
            self.cur_time -= 2 * RISE_TIME;
        }
        // Next, figure out where we are in the current siren cycle.
        let cycle_time = if self.cur_time < RISE_TIME {
            self.cur_time
        } else {
            2 * RISE_TIME - self.cur_time
        };
        // Finally, calculate the frequency and period.
        let frequency = BASE_FREQ + FREQ_RISE * cycle_time / RISE_TIME;
        let period = 1_000_000 / frequency;

        // Anticipate the time of the next interrupt.
        self.cur_time += period / 2;

        // Make sure to clear the current interrupt before
        // starting the next one, else you might get interrupted
        // again immediately.
        self.timer.reset_event();
        self.timer.start(period / 2);
    }
}

/// The siren. Accessible from both the interrupt handler
/// and the main program.
static SIREN: LockMut<Siren> = LockMut::new();

/// The timer interrupt for the siren. Just steps the siren.
#[interrupt]
fn TIMER0() {
    SIREN.with_lock(|siren| siren.step());
}

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    // It is convenient to use a `degrade()`ed pin
    // to avoid having to deal with the type of the
    // speaker pin, rather than looking it up:
    // the pin is stored globally in `SIREN`, so its
    // size must be known.
    //
    // This does lose type safety, but that is unlikely
    // to matter after this point.
    let speaker_pin = board
        .speaker_pin
        .into_push_pull_output(gpio::Level::Low)
        .degrade();
    let timer0 = timer::Timer::new(board.TIMER0);
    let mut timer1 = timer::Timer::new(board.TIMER1);

    // Set up the NVIC to handle interrupts.
    unsafe { pac::NVIC::unmask(pac::Interrupt::TIMER0) };
    pac::NVIC::unpend(pac::Interrupt::TIMER0);

    // Place the siren struct where the interrupt handler can find it.
    let siren = Siren::new(speaker_pin, timer0);
    SIREN.init(siren);

    // Start the siren and do the countdown.
    SIREN.with_lock(|siren| siren.start());
    for t in (1..=10).rev() {
        rprintln!("{}", t);
        timer1.delay_ms(1_000);
    }
    rprintln!("launch!");
    SIREN.with_lock(|siren| siren.stop());

    loop {
        asm::wfi();
    }
}

補遺: PWM

先へ進む前に、最後に 1 点だけ補足しておきます。

割り込みにはそれなりにコストがかかります。プロセッサは現在実行中の命令を完了するか中断し、その後、実行を再開するのに十分な状態を保存してから、割り込みハンドラを呼び出さなければなりません。これには、貴重な実行時間のうち数 CPU サイクルが費やされます。

前の節の解法の書き方だと、スピーカー出力の 1 周期あたり 2 回の割り込みが発生します。これは 1 秒あたりおよそ 1000 回の割り込みです。nRF52833 のようなプロセッサであれば、これは問題なく動作します。

nRF52833 には、サイレンの割り込み頻度を大幅に下げられるオンボード周辺機能が実際に備わっています。Pulse-Width Modulation (PWM) ユニットは、ほかにもいろいろできますが、PWM レジスタで制御されるレートでスピーカーピン上に周期信号を生成できます。これは、サイレンで使う基本的な矩形波を生成するのに使えます。周波数を変えたいときには引き続き割り込みが必要ですが、その頻度は 1000 回/秒ではなく、1 秒あたり 10 回程度で済むかもしれません。

私の解答では PWM ユニットは使いませんでした。これは一部には、割り込みに焦点を当てたかったからです。ただし、もう 1 つの大きな理由は、nRF52833 の PWM ユニットがかなり複雑で、理解しづらいことでした。制約の厳しいベアメタル環境では、単純な方法で何かを動かせるのはいつでも魅力的です。

挑戦してみる気があるなら、ぜひサイレンに PWM ユニットを使ってみてください。

Snake game

ここからは、MB2 の 5×5 LED マトリクスをディスプレイとして、2 つのボタンを操作入力として使って遊べる、基本的な snake ゲームを実装していきます。これにより、本書の前の章で扱ったいくつかの概念を土台にしつつ、新しい周辺機器や概念についても学んでいきます。

モジュール性

ここでのソースコードは、おそらく必要以上にモジュール化されています。この細かい粒度のモジュール化によって、ソースコードを少しずつ見ていくことができます。コードはボトムアップで構築します。まず gamecontrolsdisplay の 3 つのモジュールを作成し、その後これらを組み合わせて最終的なプログラムを構築します。各モジュールにはトップレベルのソースファイルと、1 つ以上のインクルードされるソースファイルがあります。たとえば、game モジュールは src/game.rssrc/game/coords.rssrc/game/movement.rs などで構成されます。Rust の mod 文は、モジュールのさまざまな構成要素をまとめるために使用されます。The Rust Programming Language には、Rust のモジュールシステムについてのよい description があります。

ゲームロジック

最初に作成するモジュールは、ゲームロジックです。あなたはおそらく snake ゲームをご存じでしょうが、 もし知らなくても、基本的なアイデアは、プレイヤーが 2D グリッド上でヘビを動かすというものです。常に、 グリッド上のどこかランダムな位置に「食べ物」があり、ゲームの目標はヘビにできるだけ多くの食べ物を 「食べさせる」ことです。ヘビは食べ物を食べるたびに長くなります。ヘビが自分の尻尾に衝突すると、 プレイヤーの負けです。

ゲームのバリエーションによっては、ヘビがグリッドの端に衝突してもプレイヤーの負けになりますが、 今回のグリッドは小さいため、「ラップアラウンド」ルールを実装します。つまり、ヘビがグリッドの一方の端から 外に出た場合、反対側の端から続けて現れます。

game モジュール

ゲームの仕組みは game モジュールの中で組み立てていきます。

座標

まず、ゲーム用の座標系を定義します(src/game/coords.rs)。

#![allow(unused)]
fn main() {
use super::Prng;

use heapless::index_set::FnvIndexSet;

/// A single point on the grid.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct Coords {
    // Signed ints to allow negative values (handy when checking if we have gone
    // off the top or left of the grid)
    pub row: i8,
    pub col: i8,
}

impl Coords {
    /// Get random coordinates within a grid. `exclude` is an optional set of
    /// coordinates which should be excluded from the output.
    pub fn random(rng: &mut Prng, exclude: Option<&FnvIndexSet<Coords, 32>>) -> Self {
        let mut coords = Coords {
            row: ((rng.random_u32() as usize) % 5) as i8,
            col: ((rng.random_u32() as usize) % 5) as i8,
        };
        while exclude.is_some_and(|exc| exc.contains(&coords)) {
            coords = Coords {
                row: ((rng.random_u32() as usize) % 5) as i8,
                col: ((rng.random_u32() as usize) % 5) as i8,
            }
        }
        coords
    }

    /// Whether the point is outside the bounds of the grid.
    pub fn is_out_of_bounds(&self) -> bool {
        self.row < 0 || self.row >= 5 || self.col < 0 || self.col >= 5
    }
}
}

グリッド上の位置を表すために Coords 構造体を使います。Coords は 2 つの整数しか含まないため、 所有権を気にせずに Coords 構造体を受け渡しできるよう、コンパイラに Copy トレイトの実装を derive させます。

乱数生成

関連関数 Coords::random を定義します。これにより、グリッド上のランダムな位置を取得できます。 これは後で、ヘビの食べ物をどこに配置するかを決めるために使います。

ランダムな座標を生成するには、乱数の供給源が必要です。nRF52833 にはハードウェア乱数生成器 (HWRNG)ペリフェラルがあり、これは nRF52833 spec の 6.19 節に記載されています。HAL は microbit::hal::rng::Rng 構造体を介して HWRNG へのシンプルなインターフェースを提供してくれます。HWRNG は ゲーム用途には十分な速度でない可能性があります。またテストでは、実行ごとに生成器が出力する乱数列を 再現できると便利ですが、HWRNG では設計上それは不可能です。そこで、pseudo-random 数生成器(PRNG)も 定義します。PRNG は xorshift アルゴリズムを用いて、擬似乱数の u32 値を生成します。このアルゴリズムは 基本的なもので、暗号学的に安全ではありませんが、効率的で実装も簡単であり、ささやかな snake ゲームには 十分です。Prng 構造体には初期シード値が必要ですが、それは RNG ペリフェラルから取得します。

これらすべてをまとめたものが src/game/rng.rs です。

#![allow(unused)]
fn main() {
use crate::Rng;

/// A basic pseudo-random number generator.
pub struct Prng {
    value: u32,
}

impl Prng {
    pub fn seeded(rng: &mut Rng) -> Self {
        Self::new(rng.random_u32())
    }

    pub fn new(seed: u32) -> Self {
        Self { value: seed }
    }

    /// Basic xorshift PRNG function: see <https://en.wikipedia.org/wiki/Xorshift>
    fn xorshift32(mut input: u32) -> u32 {
        input ^= input << 13;
        input ^= input >> 17;
        input ^= input << 5;
        input
    }

    /// Return a pseudo-random u32.
    pub fn random_u32(&mut self) -> u32 {
        self.value = Self::xorshift32(self.value);
        self.value
    }
}
}

移動

ゲームの状態管理に役立つ、いくつかの enum も定義する必要があります。移動方向、曲がる方向、 現在のゲーム状態、そしてゲーム内の特定の「ステップ」(つまり、ヘビの 1 回の移動)の結果です。 これらは src/game/movement.rs に含まれています。

#![allow(unused)]
fn main() {
use super::Coords;

/// Define the directions the snake can move.
pub enum Direction {
    Up,
    Down,
    Left,
    Right,
}

/// What direction the snake should turn.
#[derive(Debug, Copy, Clone)]
pub enum Turn {
    Left,
    Right,
    None,
}

/// The current status of the game.
pub enum GameStatus {
    Won,
    Lost,
    Ongoing,
}

/// The outcome of a single move/step.
pub enum StepOutcome {
    /// Grid full (player wins)
    Full,
    /// Snake has collided with itself (player loses)
    Collision,
    /// Snake has eaten some food
    Eat(Coords),
    /// Snake has moved (and nothing else has happened)
    Move(Coords),
}
}

Snake(A Snaaake!

次に、ヘビが占有している座標と進行方向を追跡する Snake 構造体を定義します。座標の順序を管理するために キュー(heapless::spsc::Queue)を使い、高速に衝突判定を行えるようにハッシュセット (heapless::FnvIndexSet)を使います。Snake には移動を行うためのメソッドがあります。これを src/game/snake.rs に実装します。

#![allow(unused)]
fn main() {
use super::{Coords, Direction, FnvIndexSet, Turn};

use heapless::spsc::Queue;

pub struct Snake {
    /// Coordinates of the snake's head.
    pub head: Coords,
    /// Queue of coordinates of the rest of the snake's body. The end of the tail is
    /// at the front.
    pub tail: Queue<Coords, 32>,
    /// A set containing all coordinates currently occupied by the snake (for fast
    /// collision checking).
    pub coord_set: FnvIndexSet<Coords, 32>,
    /// The direction the snake is currently moving in.
    pub direction: Direction,
}

impl Snake {
    pub fn make_snake() -> Self {
        let head = Coords { row: 2, col: 2 };
        let initial_tail = Coords { row: 2, col: 1 };
        let mut tail = Queue::new();
        tail.enqueue(initial_tail).unwrap();
        let mut coord_set: FnvIndexSet<Coords, 32> = FnvIndexSet::new();
        coord_set.insert(head).unwrap();
        coord_set.insert(initial_tail).unwrap();
        Self {
            head,
            tail,
            coord_set,
            direction: Direction::Right,
        }
    }

    /// Move the snake onto the tile at the given coordinates. If `extend` is false,
    /// the snake's tail vacates the rearmost tile.
    pub fn move_snake(&mut self, coords: Coords, extend: bool) {
        // Location of head becomes front of tail
        self.tail.enqueue(self.head).unwrap();
        // Head moves to new coords
        self.head = coords;
        self.coord_set.insert(coords).unwrap();
        if !extend {
            let back = self.tail.dequeue().unwrap();
            self.coord_set.remove(&back);
        }
    }

    fn turn_right(&mut self) {
        self.direction = match self.direction {
            Direction::Up => Direction::Right,
            Direction::Down => Direction::Left,
            Direction::Left => Direction::Up,
            Direction::Right => Direction::Down,
        }
    }

    fn turn_left(&mut self) {
        self.direction = match self.direction {
            Direction::Up => Direction::Left,
            Direction::Down => Direction::Right,
            Direction::Left => Direction::Down,
            Direction::Right => Direction::Up,
        }
    }

    pub fn turn(&mut self, direction: Turn) {
        match direction {
            Turn::Left => self.turn_left(),
            Turn::Right => self.turn_right(),
            Turn::None => (),
        }
    }
}
}

ゲームモジュールのトップレベル

Game 構造体はゲーム状態を追跡します。これには Snake オブジェクト、現在の食べ物の座標、 ゲームの速度(ヘビの各移動の間に経過する時間を決めるために使われます)、ゲームの状態 (ゲームが進行中か、プレイヤーが勝ったか負けたか)、そしてプレイヤーのスコアが含まれます。

この構造体には、ゲームの各ステップを処理し、ヘビの次の動きを決定して、それに応じてゲーム状態を 更新するメソッドが含まれます。また、game_matrixscore_matrix という 2 つのメソッドもあり、 LED マトリクス上にゲーム状態やプレイヤーのスコアを表示するために使える値の 2 次元配列を出力します (これについては後で見ます)。

Game 構造体は、game モジュールの最上位である src/game.rs に配置します。

#![allow(unused)]
fn main() {
mod coords;
mod movement;
mod rng;
mod snake;

use crate::Rng;

pub use coords::Coords;
pub use movement::{Direction, GameStatus, StepOutcome, Turn};
pub use rng::Prng;
pub use snake::Snake;

use heapless::index_set::FnvIndexSet;

/// Struct to hold game state and associated behaviour
pub struct Game {
    pub status: GameStatus,
    rng: Prng,
    snake: Snake,
    food_coords: Coords,
    speed: u8,
    score: u8,
}

impl Game {
    pub fn new(rng: &mut Rng) -> Self {
        let mut rng = Prng::seeded(rng);
        let snake = Snake::make_snake();
        let food_coords = Coords::random(&mut rng, Some(&snake.coord_set));
        Self {
            rng,
            snake,
            food_coords,
            speed: 1,
            status: GameStatus::Ongoing,
            score: 0,
        }
    }

    /// Reset the game state to start a new game.
    pub fn reset(&mut self) {
        self.snake = Snake::make_snake();
        self.place_food();
        self.speed = 1;
        self.status = GameStatus::Ongoing;
        self.score = 0;
    }

    /// Randomly place food on the grid.
    fn place_food(&mut self) -> Coords {
        let coords = Coords::random(&mut self.rng, Some(&self.snake.coord_set));
        self.food_coords = coords;
        coords
    }

    /// "Wrap around" out of bounds coordinates (eg, coordinates that are off to the
    /// left of the grid will appear in the rightmost column). Assumes that
    /// coordinates are out of bounds in one dimension only.
    fn wraparound(&self, coords: Coords) -> Coords {
        if coords.row < 0 {
            Coords { row: 4, ..coords }
        } else if coords.row >= 5 {
            Coords { row: 0, ..coords }
        } else if coords.col < 0 {
            Coords { col: 4, ..coords }
        } else {
            Coords { col: 0, ..coords }
        }
    }

    /// Determine the next tile that the snake will move on to (without actually
    /// moving the snake).
    fn get_next_move(&self) -> Coords {
        let head = &self.snake.head;
        let next_move = match self.snake.direction {
            Direction::Up => Coords {
                row: head.row - 1,
                col: head.col,
            },
            Direction::Down => Coords {
                row: head.row + 1,
                col: head.col,
            },
            Direction::Left => Coords {
                row: head.row,
                col: head.col - 1,
            },
            Direction::Right => Coords {
                row: head.row,
                col: head.col + 1,
            },
        };
        if next_move.is_out_of_bounds() {
            self.wraparound(next_move)
        } else {
            next_move
        }
    }

    /// Assess the snake's next move and return the outcome. Doesn't actually update
    /// the game state.
    fn get_step_outcome(&self) -> StepOutcome {
        let next_move = self.get_next_move();
        if self.snake.coord_set.contains(&next_move) {
            // We haven't moved the snake yet, so if the next move is at the end of
            // the tail, there won't actually be any collision (as the tail will have
            // moved by the time the head moves onto the tile)
            if next_move != *self.snake.tail.peek().unwrap() {
                StepOutcome::Collision
            } else {
                StepOutcome::Move(next_move)
            }
        } else if next_move == self.food_coords {
            if self.snake.tail.len() == 23 {
                StepOutcome::Full
            } else {
                StepOutcome::Eat(next_move)
            }
        } else {
            StepOutcome::Move(next_move)
        }
    }

    /// Handle the outcome of a step, updating the game's internal state.
    fn handle_step_outcome(&mut self, outcome: StepOutcome) {
        self.status = match outcome {
            StepOutcome::Collision => GameStatus::Lost,
            StepOutcome::Full => GameStatus::Won,
            StepOutcome::Eat(c) => {
                self.snake.move_snake(c, true);
                self.place_food();
                self.score += 1;
                if self.score.is_multiple_of(5) {
                    self.speed += 1
                }
                GameStatus::Ongoing
            }
            StepOutcome::Move(c) => {
                self.snake.move_snake(c, false);
                GameStatus::Ongoing
            }
        }
    }

    pub fn step(&mut self, turn: Turn) {
        self.snake.turn(turn);
        let outcome = self.get_step_outcome();
        self.handle_step_outcome(outcome);
    }

    /// Calculate the length of time to wait between game steps, in milliseconds.
    /// Generally this will get lower as the player's score increases, but need to
    /// be careful it cannot result in a value below zero.
    pub fn step_len_ms(&self) -> u32 {
        let result = 1000 - (200 * ((self.speed as i32) - 1));
        if result < 200 {
            200u32
        } else {
            result as u32
        }
    }

    /// Return an array representing the game state, which can be used to display the
    /// state on the microbit's LED matrix. Each `_brightness` parameter should be a
    /// value between 0 and 9.
    pub fn game_matrix(
        &self,
        head_brightness: u8,
        tail_brightness: u8,
        food_brightness: u8,
    ) -> [[u8; 5]; 5] {
        let mut values = [[0u8; 5]; 5];
        values[self.snake.head.row as usize][self.snake.head.col as usize] = head_brightness;
        for t in &self.snake.tail {
            values[t.row as usize][t.col as usize] = tail_brightness
        }
        values[self.food_coords.row as usize][self.food_coords.col as usize] = food_brightness;
        values
    }

    /// Return an array representing the game score, which can be used to display the
    /// score on the microbit's LED matrix (by illuminating the equivalent number of
    /// LEDs, going left->right and top->bottom).
    pub fn score_matrix(&self) -> [[u8; 5]; 5] {
        let mut values = [[0u8; 5]; 5];
        let full_rows = (self.score as usize) / 5;
        #[allow(clippy::needless_range_loop)]
        for r in 0..full_rows {
            values[r] = [1; 5];
        }
        #[allow(clippy::needless_range_loop)]
        for c in 0..(self.score as usize) % 5 {
            values[full_rows][c] = 1;
        }
        values
    }
}
}

次に、ヘビの動きを操作できるようにします。

操作

主人公は micro:bit の前面にある 2 つのボタンで操作します。ボタン A でヘビは左に曲がり、ボタン B で右に曲がります。

ボタン入力を並行的に処理するために、microbit::pac::interrupt マクロを使用します。割り込みは、MB2 の General Purpose Input/Output Tasks and Events(GPIOTE)ペリフェラルによって生成されます。

controls モジュール

グローバルな可変状態として、2 つの別々の情報を追跡する必要があります。GPIOTE ペリフェラルへの参照と、次に曲がる方向の記録です。

共有データは、内部可変性とロックを可能にするために RefCell でラップされます。RefCell について詳しくは、RefCell documentation と Rust Book] の interior mutability chapter を読んでください。さらに、この RefCell は安全なアクセスを可能にするために cortex_m::interrupt::Mutex でラップされます。cortex_m クレートが提供する Mutex は、critical section の概念を使います。Mutex 内のデータには、cortex_m::interrupt::free に渡された関数またはクロージャの内部からのみアクセスできます(ここでは分かりやすさのため interrupt_free にリネームしています)。これにより、その関数またはクロージャ内のコード自体が割り込まれないことが保証されます。

初期化

まず、ボタンを初期化します(src/controls/init.rs)。

#![allow(unused)]
fn main() {
use super::{Buttons, GPIO};

use cortex_m::interrupt::free as interrupt_free;
use microbit::{
    hal::{
        gpio::{Floating, Input, Pin},
        gpiote::{Gpiote, GpioteChannel},
    },
    pac,
};

/// Initialise the buttons and enable interrupts.
pub fn init_buttons(board_gpiote: pac::GPIOTE, board_buttons: Buttons) {
    let gpiote = Gpiote::new(board_gpiote);

    fn init_channel(channel: &GpioteChannel<'_>, button: &Pin<Input<Floating>>) {
        channel.input_pin(button).hi_to_lo().enable_interrupt();
        channel.reset_events();
    }

    let channel0 = gpiote.channel0();
    init_channel(&channel0, &board_buttons.button_a.degrade());

    let channel1 = gpiote.channel1();
    init_channel(&channel1, &board_buttons.button_b.degrade());

    interrupt_free(move |cs| {
        *GPIO.borrow(cs).borrow_mut() = Some(gpiote);

        unsafe {
            pac::NVIC::unmask(pac::Interrupt::GPIOTE);
        }
        pac::NVIC::unpend(pac::Interrupt::GPIOTE);
    });
}
}

nRF52 上の GPIOTE ペリフェラルには 8 つの「チャネル」があり、それぞれを GPIO ピンに接続して、立ち上がりエッジ(低から高への信号遷移)や立ち下がりエッジ(高から低への信号遷移)を含む特定のイベントに反応するよう設定できます。ボタンは GPIO ピンであり、押されていないときは信号が高く、それ以外では低くなります。したがって、ボタン押下は立ち下がりエッジです。

初期化時に init_channel() 関数をやや不格好な形で使っているのは、ボタン初期化コードのコピー&ペーストを避けるためです。MB2 向けの各種組み込みクレートがこれまで隠していた型は、ときどき少し手ごわく見えます。HAL クレートと PAC クレートの型構造は少し独特で、慣れが必要なので、いずれ調べてみることをお勧めします。特に、microbit 上の各ピンは それぞれ固有の型を持っている ことに注目してください。初期化で使っている degrade() 関数の目的は、それらを共通の型に変換し、その型を init_channel()、ひいては input_pin() の引数として無理なく使えるようにすることです。

channel0button_a に、channel1button_b に接続します。どちらの場合も、立ち下がりエッジ(hi_to_lo)でイベントを生成するようボタンを設定します。GPIOTE ペリフェラルへの参照は GPIO Mutex に保存します。その後、GPIOTE 割り込みを unmask して、ハードウェアがそれらを伝播できるようにし、さらに unpend を呼んで pending 状態の割り込みをすべてクリアします(これは、割り込みが unmask される前に生成されていた可能性があります)。

割り込みハンドラ

次に、割り込みを処理するコードを書きます。nrf52833_hal クレートから再エクスポートされている interrupt マクロを使います。処理したい割り込みと同じ名前の関数を定義し(一覧は here で確認できます)、それに #[interrupt] を付けます(src/controls/interrupt.rs)。

#![allow(unused)]
fn main() {
use super::{Turn, GPIO, TURN};

use cortex_m::interrupt::free as interrupt_free;
use microbit::pac::{self, interrupt};

#[pac::interrupt]
fn GPIOTE() {
    interrupt_free(|cs| {
        if let Some(gpiote) = GPIO.borrow(cs).borrow().as_ref() {
            let a_pressed = gpiote.channel0().is_event_triggered();
            let b_pressed = gpiote.channel1().is_event_triggered();

            let turn = match (a_pressed, b_pressed) {
                (true, false) => Turn::Left,
                (false, true) => Turn::Right,
                _ => Turn::None,
            };

            gpiote.channel0().reset_events();
            gpiote.channel1().reset_events();

            *TURN.borrow(cs).borrow_mut() = turn;
        }
    });
}
}

GPIOTE 割り込みが生成されたら、各ボタンが押されたかどうかを確認します。ボタン A だけが押されていた場合は、ヘビが左に曲がるべきであることを記録します。ボタン B だけが押されていた場合は、ヘビが右に曲がるべきであることを記録します。それ以外の場合は、ヘビは曲がらないことを記録します。(両方のボタンが「同時に」押される可能性は極めて低いです。ボタン押下はほぼ瞬時に検出され、この割り込みハンドラは非常に高速に実行されるため、この状況を起こすには両方のボタンを間に合うように押し下げるのは難しいでしょう。同様に、このコードが見逃して「どちらのボタンも押されていない」と報告してしまうほど短時間だけボタンを押すのも難しいはずです。それでも、Rust はこうした予期しないケースも考慮することを強制します。すべての可能性を確認しない限り、コードはコンパイルされません。)該当する曲がる方向は TURN Mutex に保存されます。これらはすべて interrupt_free ブロック内で行われ、これにより、この割り込みを処理している間に他のイベントで割り込まれないことが保証されます。

最後に、次の曲がる方向を取得する単純な関数を公開します(src/controls.rs)。

#![allow(unused)]
fn main() {
mod init;
mod interrupt;

pub use init::init_buttons;

use crate::game::Turn;
use core::cell::RefCell;
use cortex_m::interrupt::{free as interrupt_free, Mutex};
use microbit::{board::Buttons, hal::gpiote::Gpiote};
pub static GPIO: Mutex<RefCell<Option<Gpiote>>> = Mutex::new(RefCell::new(None));
pub static TURN: Mutex<RefCell<Turn>> = Mutex::new(RefCell::new(Turn::None));

/// Get the next turn (ie, the turn corresponding to the most recently pressed button).
pub fn get_turn(reset: bool) -> Turn {
    interrupt_free(|cs| {
        let turn = *TURN.borrow(cs).borrow();
        if reset {
            *TURN.borrow(cs).borrow_mut() = Turn::None
        }
        turn
    })
}
}

この関数は単に TURN Mutex の現在の値を返します。引数として 1 つの真偽値 reset を取ります。resettrue の場合、TURN の値はリセットされ、つまり Turn::None に設定されます。

次は、高忠実度のゲーム表示をサポートする機能を作成します。

ノンブロッキングディスプレイを使う

次に、MB2 画面の LED にヘビと食べ物を表示します。これまでは、LED を最大輝度で点灯するか消灯するかのどちらかにする ブロッキングインターフェースを使ってきました。これでも、基本的に動作する snake ゲームを作ることは可能です。ですが、ヘビが少し 長くなってくると、ヘビと食べ物を見分けることや、ヘビがどちらの方向に進んでいるのかを判断することが難しくなるかもしれません。 LED の明るさを変えられるようにする方法を考えてみましょう。ヘビの胴体を少し暗くすれば、ごちゃついた表示を整理しやすくなります。

microbit ライブラリでは、LED マトリクスに対する 2 種類の異なるインターフェースが利用できます。1 つは、 これまでの章ですでに見てきたブロッキングインターフェースです。もう 1 つはノンブロッキングインターフェースで、 各 LED の明るさをカスタマイズできます。ハードウェアレベルでは各 LED は「点灯」か「消灯」かのどちらかですが、 microbit::display::nonblocking モジュールは、LED を高速にオン/オフすることで、各 LED について 10 段階の明るさを シミュレートします。

microbit ライブラリクレートの 2 つの表示モードが、別々になっていて別々のコードを使う必要がある大きな理由は ありません。より完全な設計であれば、単一のディスプレイ API について、ユーザーが明るさレベルやリフレッシュレートを指定したうえで、 ノンブロッキングまたはブロッキングのどちらの使い方もできるようにするでしょう。手渡されたものが完成されているとか、 完成に近いとさえ思い込んではいけません。常に、自分なら何を違うやり方でできるかを考えてください。とはいえ今は、 当面の目的には十分な、今あるものを使って進めます。)

ノンブロッキングインターフェースとやり取りするコード(src/display.rs)はかなり単純で、ボタンとやり取りするために使ったコードと 似た構造になります。今回はトップレベルから始めましょう。

ディスプレイモジュール

#![allow(unused)]
fn main() {
pub mod interrupt;
pub mod show;

pub use show::{clear_display, display_image};

use core::cell::RefCell;
use cortex_m::interrupt::{free as interrupt_free, Mutex};
use microbit::display::nonblocking::Display;
use microbit::gpio::DisplayPins;
use microbit::pac;
use microbit::pac::TIMER1;

static DISPLAY: Mutex<RefCell<Option<Display<TIMER1>>>> = Mutex::new(RefCell::new(None));

pub fn init_display(board_timer: TIMER1, board_display: DisplayPins) {
    let display = Display::new(board_timer, board_display);

    interrupt_free(move |cs| {
        *DISPLAY.borrow(cs).borrow_mut() = Some(display);
    });
    unsafe { pac::NVIC::unmask(pac::Interrupt::TIMER1) }
}
}

まず、LED ディスプレイを表す microbit::display::nonblocking::Display 構造体を初期化し、 ボードの TIMER1DisplayPins ペリフェラルを渡します。次に、そのディスプレイを Mutex に格納します。 最後に、TIMER1 割り込みをアンマスクします。

Display API

次に、表示するイメージを簡単に設定(または解除)できるようにするための、いくつかの便利な関数を定義します (src/display/show.rs)。

#![allow(unused)]
fn main() {
use super::DISPLAY;

use cortex_m::interrupt::free as interrupt_free;

use tiny_led_matrix::Render;

/// Display an image.
pub fn display_image(image: &impl Render) {
    interrupt_free(|cs| {
        if let Some(display) = DISPLAY.borrow(cs).borrow_mut().as_mut() {
            display.show(image);
        }
    })
}

/// Clear the display (turn off all LEDs).
pub fn clear_display() {
    interrupt_free(|cs| {
        if let Some(display) = DISPLAY.borrow(cs).borrow_mut().as_mut() {
            display.clear();
        }
    })
}
}

display_image はイメージを受け取り、それを表示するようディスプレイに指示します。これが呼び出す Display::show メソッドと同様に、 この関数は tiny_led_matrix::Render トレイトを実装した構造体を受け取ります。このトレイトは、その構造体が、 Display がそれを LED マトリクス上にレンダリングするために必要なデータとメソッドを備えていることを保証します。 microbit::display::nonblocking モジュールが提供する Render の実装は、BitImageGreyscaleImage の 2 つです。 BitImage では各「ピクセル」(または LED)は点灯しているかしていないかのどちらかですが(ブロッキングインターフェースを使ったときと同様)、 GreyscaleImage では各「ピクセル」がそれぞれ異なる明るさを持てます。

clear_display は、その名前が示すとおりのことを行います。

ディスプレイ割り込み処理

最後に、interrupt マクロを使って TIMER1 割り込みのハンドラを定義します。この割り込みは 1 秒間に何度も発生し、 これによって Display は異なる LED を高速に順番にオン/オフし、明るさが変化しているような錯覚を与えられます。 このハンドラのコードが行うことは、これを処理する Display::handle_display_event メソッドを呼び出すことだけです (src/display/interrupt.rs)。

#![allow(unused)]
fn main() {
use super::DISPLAY;

use cortex_m::interrupt::free as interrupt_free;
use microbit::pac::{self, interrupt};

#[pac::interrupt]
fn TIMER1() {
    interrupt_free(|cs| {
        if let Some(display) = DISPLAY.borrow(cs).borrow_mut().as_mut() {
            display.handle_display_event();
        }
    })
}
}

これで、main 関数がどのように表示を行うのかがわかります。init_display を呼び出し、 新たに定義した関数を使ってこれとやり取りします。

Snake game: 最終的なアセンブリ

src/main.rs ファイル内のコードは、これまで説明してきた仕組みをすべてまとめて、最終的なゲームを作り上げます。

#![no_main]
#![no_std]

mod controls;
mod display;
pub mod game;

use controls::{get_turn, init_buttons};
use display::{clear_display, display_image, init_display};
use game::{Game, GameStatus};

use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use microbit::{
    display::nonblocking::{BitImage, GreyscaleImage},
    hal::{Rng, Timer},
    Board,
};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;

#[entry]
fn main() -> ! {
    rtt_init_print!();
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0).into_periodic();
    let mut rng = Rng::new(board.RNG);
    let mut game = Game::new(&mut rng);

    init_buttons(board.GPIOTE, board.buttons);
    init_display(board.TIMER1, board.display_pins);

    loop {
        loop {
            // Game loop
            let image = GreyscaleImage::new(&game.game_matrix(6, 3, 9));
            display_image(&image);
            timer.delay_ms(game.step_len_ms());
            match game.status {
                GameStatus::Ongoing => game.step(get_turn(true)),
                _ => {
                    for _ in 0..3 {
                        clear_display();
                        timer.delay_ms(200u32);
                        display_image(&image);
                        timer.delay_ms(200u32);
                    }
                    clear_display();
                    display_image(&BitImage::new(&game.score_matrix()));
                    timer.delay_ms(2000u32);
                    break;
                }
            }
        }
        game.reset();
    }
}

ボード、そのタイマー、および RNG ペリフェラルを初期化した後、Game 構造体と microbit::display::blocking モジュールの Display を初期化します。

「ゲームループ」(main 関数内に配置した「メインループ」の中で実行されます)では、次の手順を繰り返し実行します。

  1. グリッドを表す 5×5 のバイト配列を取得します。Game::get_matrix メソッドは 3 つの整数引数(いずれも 0 から 9 までの範囲である必要があります)を受け取り、これらは最終的に頭、尾、食べ物をどの程度明るく表示するかを表します。

  2. Game::step_len_ms メソッドで決まる時間だけ、行列を表示します。現在の実装では、このメソッドは基本的に各ステップの間に 1 秒を確保し、プレイヤーが 5 点獲得するたびに 200ms 短くなります(食べ物 1 個を食べる = 1 点)。ただし、下限は 200ms です。

  3. ゲームの状態を確認します。これが Ongoing(初期値)であれば、ゲームを 1 ステップ進めてゲームの状態(status プロパティを含む)を更新します。そうでなければゲームは終了しているため、現在の画像を 3 回点滅させ、その後プレイヤーのスコア(スコアに対応する数の LED を点灯させたものとして表現されます)を表示して、ゲームループを終了します。

メインループでは、各反復の後にゲームの状態をリセットしながら、ゲームループを繰り返し実行するだけです。

まだ探求すべきこと

私たちはまだほんの表面をなぞったにすぎません! まだ探求すべきことがたくさん あります。

注記: これを読んでいて、以下の項目のいずれか、あるいはその他の関連する 組み込みトピックについて、Discovery book に例や練習問題を追加するのを 手伝いたいと思ってくださるなら、ぜひ力を貸してください!

手伝いたいけれど、これを本にどう貢献すればよいかについて支援やメンタリングが 必要な場合は、open an issue してください。あるいは、その情報を追加する Pull Request を開いてください!

MB2 のさらなるトピック

本書の中で、私たちは MB2 上のハードウェアの大部分に触れてきました。とはいえ、まだ 探求すべき MB2 のトピックがいくつか残っています。

ダイレクトメモリアクセス (DMA).

一部のペリフェラルには DMA があり、これは一種の 非同期な memcpy で、CPU が関与しなくても ペリフェラルがメモリとの間でデータを移動できるようにします。

micro:bit v2 を使っているなら、実はすでに DMA を使っています。HAL が UARTE と TWIM ペリフェラルでこれを行ってくれるからです。DMA ペリフェラルはデータの一括転送に使えます。 たとえば RAM から RAM、UARTE のようなペリフェラルから RAM、あるいは RAM から ペリフェラルへの転送です。DMA 転送をスケジュールして — たとえば「このバッファに UARTE から 256 バイト読み込む」— バックグラウンドで実行したままにできます。あとで何らかの レジスタを確認して転送が完了したかを見ることもできますし、転送完了時に割り込みを 受け取るようにすることもできます。つまり、DMA 転送をスケジュールし、その転送が進行して いる間に別の作業を行えます。

低レベルの DMA の詳細は少し厄介になることがあります。このトピックを扱う章を近いうちに 追加したいと考えています。

embedded-halpwm module には PWM を扱うための抽象化がいくつかあり、nrf52833-hal にはこれらのトレイトの実装があります。

デジタル入力と出力

私たちはマイクロコントローラのピンをデジタル出力として使い、LED を駆動してきました。 スネークゲームを作るときには、これらのピンをデジタル入力として設定する方法も少し 垣間見ました。デジタル入力としては、これらのピンでスイッチの二値状態(オン/オフ)や ボタンの状態(押されている/押されていない)を読み取れます。

デジタル入力と出力は embedded-haldigital module で抽象化されており、 [nrf52833-hal] にはその実装があります。

(ネタバレ スイッチ / ボタンの二値状態を読み取るのは、聞こえるほど単純ではありません ;-) )

アナログ-デジタルコンバータ (ADC)

世の中にはデジタルセンサーがたくさんあります。I2C や SPI のようなプロトコルを使って それらを読み取れます。しかし、アナログセンサーも存在します! これらのセンサーは、 ADC 入力ピンで検知している電圧の読み取り値をそのまま CPU に出力します。

したがって ADC ペリフェラルを使えば、“アナログ” な電圧レベル — たとえば 1.25 ボルト — を、プロセッサが計算に使える “デジタル” な数値 — たとえば 24824 — として測定できます。

embedded-hal には汎用的な ADC トレイトがありましたが、embedded-hal 1.0 では削除されました: issue #377 を参照してください。nrf52833-hal クレートは、nRF52833 に内蔵された固有の ADC に対する使いやすいインターフェースを提供します。

デジタル-アナログコンバータ (DAC)

想像どおり、DAC は ADC とちょうど逆のものです。レジスタにデジタル値を書き込むことで、 あるアナログ出力ピンに特定の電圧を出せます。このアナログ出力ピンを適切な電子回路に接続し、 正しい値を高速にレジスタへ書き込めば、音や音楽を生成するといったことができます。

nRF52833 にも MB2 ボードにも専用の DAC はありません。一般には、PWM を出力し、その出力側に 少し電子回路(RC フィルタ)を追加して PWM 波形を “平滑化” することで、DAC のような効果を 得ます。

リアルタイムクロック

リアルタイムクロックのペリフェラルは、通常は “人間向けの形式”、つまり秒、分、時、日、月、年 で、独自の電源により時刻を保持します。リアルタイムクロックの中には、うるう年や 夏時間を自動で扱えるものもあります。

nRF52833 にも MB2 ボードにもリアルタイムクロックは搭載されていません。nRF52833 には “Real-Time Counter” (RTC) があり、これは nrf52833-hal でサポートされている低周波で刻む クロックです。このカウンタを専用に使って、疑似的なリアルタイムクロックとして機能させる ことができます。もちろん、そのための重要な要件は、MB2 を使っていないときでも RTC ペリフェラルに給電し続けることです。MB2 にはオンボードバッテリーはありませんが、MB2 の バッテリーポートにバッテリーを接続しておけば、RTC は長期間(場合によっては数年)動作 できるはずです(たとえば、micro::bit Go kit に付属するバッテリーパックなど)。

その他の通信プロトコル

  • SPI: 「Serial Peripheral Interface」は、いくつかの点で I2C に似た高速通信 インターフェースです。SPI は embedded-hal spi module で抽象化されており、 [nrf52-hal] によって実装されています。
  • I2S: 「Inter-IC Sound」プロトコルは、音声伝送向けにカスタマイズされた I2C の一種です。 I2S は現在 embedded-hal では抽象化されていませんが、[nrf52-hal] によって 実装されています。
  • Ethernet: smoltcp という小さな TCP/IP スタックが存在し、一部のチップ向けに 実装されています。MB2 には Ethernet ペリフェラルはありません
  • USB: これについてはいくつか実験的な取り組みがあり、たとえば usb-device クレートが あります。MB2 では、USB ポートはホスト MCU ではなくインターフェース MCU によって 管理されているため、独自の USB 機能を扱うのは難しくなります。
  • Bluetooth: Embassy の MB2 ランタイムが提供する nrf-softdevice ラッパーは、おそらく MB2 の Bluetooth への最も簡単な入り口です。Embassy には Rust ネイティブの TrouBLE BLE ホストクレートもあります。
  • CAN、SMBUS、IrDA など: 世界にはあらゆる種類の特殊用途インターフェースが存在し、Rust が それらをサポートしていることもあります。必要なインターフェースの現状は、ぜひ調べてみて ください

アプリケーションによって使う通信プロトコルは異なります。ユーザー向けアプリケーションには通常 USB コネクタがあります。これは、USB が PC やスマートフォンで広く使われているプロトコル だからです。一方、自動車の内部には多くの CAN バスがあります。デジタルセンサーの中には、 SPI、I2C、SMBUS を使うものもあります。

embedded-hal における抽象化や、一般にペリフェラルの実装を開発することに興味があるなら、 気後れせずに HAL リポジトリで issue を開いてください。あるいは Rust Embedded matrix channel に参加して、上で挙げたものを作った人たちの多くと 連絡を取ることもできます。

一般的な組み込み関連トピック

これらのトピックでは、私たちのデバイスやその上のハードウェアに固有ではない項目を扱います。 その代わりに、組み込みシステムで使える有用な技法について説明します。 ここで取り上げるハードウェアの多くは MB2 では利用できませんが、その多くは安価なハードウェアを MB2 のエッジカードコネクタに接続することで簡単に追加でき、それを直接駆動することも、SPI や I2C のようなものを使って制御することもできます。

マルチタスキング

これまでのプログラムのほとんどは、単一のタスクを実行していました。では、OS がなく、したがってスレッドもないシステムで、どうすればマルチタスキングを実現できるでしょうか。マルチタスキングには主に 2 つの方式があります。プリエンプティブ・マルチタスキングと協調的マルチタスキングです。

プリエンプティブ・マルチタスキングでは、現在実行中のタスクは、どの時点でも別のタスクによって プリエンプト(中断)される可能性があります。プリエンプトが発生すると、最初のタスクは一時停止され、代わりにプロセッサが 2 つ目のタスクを実行します。やがて最初のタスクは再開されます。マイクロコントローラは、割り込み という形でプリエンプションをハードウェア的にサポートしています。割り込みについては、第16章 でスネークゲームを作ったときに学びました。

協調的マルチタスキングでは、実行中のタスクは サスペンドポイント に到達するまで実行されます。プロセッサがそのサスペンドポイントに到達すると、現在のタスクの実行を停止し、代わりに別のタスクを実行します。やがて最初のタスクは再開されます。この 2 つのマルチタスク方式の主な違いは、協調的マルチタスキングでは、実行のどの時点でも強制的にプリエンプトされるのではなく、既知の サスペンドポイントで実行制御を 譲る ことです。

ジャイロスコープ

Punch-o-meter の演習の一環として、3 次元の加速度の変化を測定するために加速度計を使いました。しかし、ジャイロスコープのような別のモーションセンサーもあり、これを使うと 3 次元での「回転」の変化を測定できます。

これは、転倒を避けたいロボットのような特定のシステムを構築しようとするときに非常に役立ちます。さらに、ジャイロスコープのようなセンサーから得られるデータは、センサーフュージョンと呼ばれる技法を使って加速度計のデータと組み合わせることもできます(詳細は以下を参照してください)。

サーボモーターとステッピングモーター

一部のモーターは、たとえばラジコンカーを前進または後退させるように、主に一方向または逆方向に回転させるためだけに使われますが、モーターがどのように回転するかをより正確に測定できると便利なこともあります。

マイクロコントローラを使ってサーボモーターやステッピングモーターを駆動できます。これにより、モーターが何回転するかをより正確に制御でき、さらにはモーターを特定の位置に位置決めすることもできます。たとえば、時計の針を特定の方向に動かしたい場合などです。

センサーフュージョン

micro:bit には 2 つのモーションセンサー、加速度計と磁力計が搭載されています。 それぞれ単体では、(固有)加速度と(地球の)磁場を測定します。 しかし、これらの量は「融合」して、より有用なものにできます。つまり、どの単一のセンサーよりも測定誤差が少ない、ボードの姿勢の「ロバストな」測定値です。

異なるソースからより信頼性の高いデータを導き出すこの考え方は、センサーフュージョンと呼ばれます。


では、次はどこへ進めばよいのでしょうか?

まず何よりも、Rust Embedded matrix channel に参加してください。そこには、組み込みソフトウェアに貢献している人や関わっている人がたくさん集まっています。たとえば、microbit BSP、nrf52-hal クレート、embedded-hal クレート群などを書いた人たちもいます。Rust での組み込みプログラミングを始めるときにも、さらに先へ進みたいときにも、私たちは喜んでお手伝いします!

ほかにも多くの選択肢があります:

  • microbit-v2 ボードサポートクレートのサンプルを見てみるのもよいでしょう。そこにあるサンプルはすべて、手元の micro:bit ボードで動作します。
  • 現在 Rust Embedded で何が利用できるかを概観したいなら、Awesome Rust Embedded のリストを見てみてください。
  • Embassy を見てみるのもよいでしょう。これは、Rust の async/await を使った並行実行をサポートする、モダンで効率的な協調的マルチタスキング・フレームワークです。
  • Real-Time Interrupt-driven Concurrency RTIC を見てみるのもよいでしょう。RTIC は、タスクの優先順位付けとデッドロックのない実行をサポートする、非常に効率的なプリエンプティブ・マルチタスキング・フレームワークです。
  • embedded-hal プロジェクトのさらに多くの抽象化を調べてみたり、それをベースに独自のプラットフォーム非依存ドライバを書いてみたりするのもよいでしょう。
  • 別の開発ボードで Rust を動かしてみるのもよいでしょう。ESP-32、Raspberry Pi、Arduino のような人気のボードには、それぞれ活発な Rust 開発者コミュニティがあります。

📥 書籍のePUB版をダウンロード

この書籍のEPUB版はここからダウンロードできます: ダウンロード

一般的なトラブルシューティング

cargo-embed の問題

cargo-embed に関する問題のほとんどは、Linux で udev ルールが正しくインストールされていないことに関連しているため、その点が正しいことを確認してください。

行き詰まった場合は、discovery issue tracker で issue を作成するか、Rust Embedded matrix channel または probe-rs matrix channel を訪れて、そこで助けを求めることができます。

Cargo の問題

core の crate が見つからない」

症状:

   Compiling volatile-register v0.1.2
   Compiling rlibc v1.0.0
   Compiling r0 v0.1.0
error[E0463]: can't find crate for `core`

error: aborting due to previous error

error[E0463]: can't find crate for `core`

error: aborting due to previous error

error[E0463]: can't find crate for `core`

error: aborting due to previous error

Build failed, waiting for other jobs to finish...
Build failed, waiting for other jobs to finish...
error: Could not compile `r0`.

To learn more, run the command again with --verbose.

原因:

マイクロコントローラー用の適切なターゲット thumbv7em-none-eabihf のインストールを忘れています。

修正:

適切なターゲットをインストールしてください。

$ rustup target add thumbv7em-none-eabihf

デバイスに書き込めない: No loadable segments were found in the ELF file

症状:

> cargo embed
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
      Config default
      Target /home/user/embedded/target/thumbv7em-none-eabihf/debug/examples/init
 WARN probe_rs::flashing::loader: No loadable segments were found in the ELF file.
       Error No loadable segments were found in the ELF file.

原因:

Cargo は、ターゲットデバイスの要件に合わせてプログラムをどのようにビルドし、リンクするかを把握する必要があります。 そのため、.cargo/config.toml ファイルに正しいパラメーターを設定する必要があります。

修正:

正しいパラメーターを含む .cargo/config.toml ファイルを追加してください:

[build]
target = "thumbv7em-none-eabihf"

[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip nRF52833_xxAA"
rustflags = [
  "-C", "linker=rust-lld",
]

詳細については、Embedded Setup を参照してください。

GDB の使い方

以下は、プログラムのデバッグに役立つ便利な GDB コマンドです。ここでは、マイクロコントローラにプログラムを書き込み、GDB を cargo-embed セッションに接続していることを前提としています。

一般的なデバッグ

注: 以下に示すコマンドの多くは短縮形で実行できます。たとえば、continue は単に cbreak $locationb $location として使用できます。以下のコマンドに慣れてきたら、GDB が認識しなくなる直前まで、どこまで短くできるか試してみてください!

ブレークポイントの操作

  • break $location: コード内の場所にブレークポイントを設定します。$location の値には次のものを指定できます:
    • break *main - 関数 main の正確なアドレスでブレークします
    • break *0x080012f2 - 正確なメモリアドレス 0x080012f2 でブレークします
    • break 123 - 現在表示されているファイルの 123 行目でブレークします
    • break main.rs:123 - ファイル main.rs の 123 行目でブレークします
  • info break: 現在のブレークポイントを表示します
  • delete: すべてのブレークポイントを削除します
    • delete $n: ブレークポイント $n を削除します(n は数値。例: delete $2
  • clear: 次の命令位置のブレークポイントを削除します
    • clear main.rs:$function: main.rs 内の $function のエントリにあるブレークポイントを削除します
    • clear main.rs:123: main.rs の 123 行目のブレークポイントを削除します
  • enable: 設定済みのすべてのブレークポイントを有効にします
    • enable $n: ブレークポイント $n を有効にします
  • disable: 設定済みのすべてのブレークポイントを無効にします
    • disable $n: ブレークポイント $n を無効にします

実行の制御

  • continue: プログラムの実行を開始または再開します
  • next: プログラムの次の行を実行します
    • next $n: next$n 回繰り返します
  • nexti: next と同じですが、代わりに機械語命令単位で実行します
  • step: 次の行を実行します。次の行に別の関数の呼び出しが含まれている場合は、そのコードの中に入ります
    • step $n: step$n 回繰り返します
  • stepi: step と同じですが、代わりに機械語命令単位で実行します
  • jump $location: 指定した場所から実行を再開します:
    • jump 123: 123 行目から実行を再開します
    • jump 0x080012f2: アドレス 0x080012f2 から実行を再開します

情報の表示

  • print /$f $data - 変数 $data に含まれている値を表示します。必要に応じて $f で出力形式を指定できます。指定できる値は次のとおりです:

    x: hexadecimal
    d: signed decimal
    u: unsigned decimal
    o: octal
    t: binary
    a: address
    c: character
    f: floating point
    
    • print /t 0xA: 16 進数の値 0xA を 2 進数(0b1010)として表示します
  • x /$n$u$f $address: $address にあるメモリを調べます。必要に応じて、$n で表示する単位数、$u で単位サイズ(バイト、ハーフワード、ワードなど)、$f で上記の print 形式を指定できます

    • x /5i 0x080012c4: アドレス 0x080012c4 から始まる 5 個の機械語命令を表示します
    • x/4xb $pc: $pc が現在指している位置から始まる 4 バイトのメモリを表示します
  • disassemble $location

    • disassemble /r main: 関数 main を逆アセンブルします。/r を使うと各命令を構成するバイトも表示されます

シンボルテーブルを見る

  • info functions $regex: $regex に一致する関数の名前とデータ型を表示します。すべての関数を表示するには $regex を省略します
    • info functions main: main という語を含む定義済み関数の名前と型を表示します
  • info address $symbol: $symbol がメモリ上のどこに格納されているかを表示します
    • info address GPIOC: 変数 GPIOC のメモリアドレスを表示します
  • info variables $regex: $regex に一致するグローバル変数の名前と型を表示します。すべてのグローバル変数を表示するには $regex を省略します
  • ptype $data: $data に関するより詳細な情報を表示します
    • ptype cp: 変数 cp の詳細な型情報を表示します

プログラムスタックを調べる

  • backtrace $n: $n 個のフレームのトレースを表示します。すべてのフレームを表示するには $n を省略します
    • backtrace 2: 最初の 2 フレームのトレースを表示します
  • frame $n: 番号またはアドレス $n のフレームを選択します。現在のフレームを表示するには $n を省略します
  • up $n: $n フレーム上のフレームを選択します
  • down $n: $n フレーム下のフレームを選択します
  • info frame $address: $address にあるフレームを説明します。現在選択されているフレームについて表示するには $address を省略します
  • info args: 選択されているフレームの引数を表示します
  • info registers $r: 選択されているフレーム内のレジスタ $r の値を表示します。すべてのレジスタを表示するには $r を省略します
    • info registers $sp: 現在のフレームにおけるスタックポインタレジスタ $sp の値を表示します

cargo-embed をリモートで制御する

  • monitor reset: CPU をリセットし、最初から実行を開始します

磁力計のキャリブレーション

センサーを使ってアプリケーションを開発しようとする前に行うべき非常に重要なことの 1 つは、 その出力が本当に正しいかを確認することです。そうでない場合は、センサーを キャリブレーションする必要があります。あるいは、センサーが壊れている可能性もあります。可能であれば、 使用前および使用中にセンサーの健全性をチェックするのは非常に良い考えです。

私の場合、2 台の異なる MB2 で、LSM303AGR の磁力計はキャリブレーションなしだとかなり ずれています。(z 軸が壊れているように見える個体も 1 台あります。メーカーにはこれを検出するのに役立つ追加の ハードウェアと手順がありますが、ここではその複雑さは扱いません。)

磁力計をキャリブレーションするための、メーカーが規定した手順があります。このキャリブレーションには かなり多くの数学(行列)が関わるため、ここでは詳しく扱いません。詳細に興味がある場合は、この Design Note でその手順が説明されています。

幸いなことに、micro:bit 向けの元の C++ ソフトウェアを作成した CODAL グループは、メーカーの キャリブレーション機構(またはそれに類するもの)を、すでに C++ で here に実装しています。

この C++ のキャリブレーションを Rust に移植したものは src/lib.rs にあります。これは Matlab から C++、そして Rust へと翻訳されたものであり、いくつか興味深い選択がされていることに注意してください。特に、 キャリブレーション済みの値を読むときは 軸が反転されます。これは、USB コネクタが前になるように上から見たとき、キャリブレーション済みの値の X、Y、Z 軸が「標準的な」(右、前、上) 向きになるようにするためです。

このキャリブレーターの使い方は、ここにある src/main.rs で示されています。

ユーザーがどのようにキャリブレーションを行うかは、C++ 版のこの動画で示されています。(冒頭の 表示は無視してください — キャリブレーションはだいたい半分くらいから始まります。)

LED マトリクス上のすべての LED が点灯するまで、micro:bit を傾ける必要があります。点滅するカーソルが 現在の対象 LED を示します。

キャリブレーション行列はデモプログラムによって表示されることに注意してください。この行列は、 たとえば [chapter 12] のコンパスプログラムのようなプログラムにハードコードすることもできますし、 (あるいは何らかの方法で flash のどこかに保存してもよく)、ユーザーがプログラムを実行するたびに 再キャリブレーションする必要をなくせます。

ライセンスと帰属表示

ライセンス

ドキュメントは、次のライセンスの下で提供されています。

  • Creative Commons Attribution 4.0 License (LICENSE-CC-BY または https://creativecommons.org/licenses/by/4.0/legalcode)

また、ソースコードは、利用者の選択により、以下のいずれかのライセンスの下で提供されています。

  • Apache License, Version 2.0 (LICENSE-APACHE または http://www.apache.org/licenses/LICENSE-2.0)

  • MIT License (LICENSE-MIT または https://opensource.org/licenses/MIT)

Wikimedia Commons の画像

この書籍には、Wikimedia Commons の以下の画像が含まれています。

画像作者ライセンス出典
DB-25_male.jpgJacq54CC BY-SA 4.0出典
Serial_port.jpgDuncan Lithgowパブリックドメイン出典
UART_to_USB_adapter.jpgSunmistCC0 1.0出典
LED_circuit.svgDmccreary / StevenBellCC BY-SA 2.5出典
I2C_controller-target.svgTim MathiasCC BY-SA 4.0出典
Atan2_60.svgDmcqCC BY-SA 3.0出典