はじめに
The Embedded Rust Book へようこそ。これは、マイクロコントローラのような「ベアメタル」の組み込みシステムで Rust Programming Language を使うための入門書です。
Embedded Rust はどのような人向けか
Embedded Rust は、Rust 言語が提供するより高水準な概念と安全性の保証を活用しながら、組み込みプログラミングを行いたいすべての人のためのものです。 (Who Rust Is For も参照してください)
範囲
この本の目標は次のとおりです。
-
開発者が組み込み Rust 開発をすぐに始められるようにすること。つまり、開発環境をどのように セットアップするかです。
-
Rust を組み込み開発に使うための 現在の ベストプラクティスを共有すること。つまり、 より正しい組み込みソフトウェアを書くために Rust 言語の機能をどのように最適に 使うかです。
-
場合によってはクックブックとして機能すること。たとえば、単一の プロジェクトで C と Rust をどのように混在させるのか、ということです。
この本はできる限り汎用的であろうとしていますが、読者と執筆者の両方にとって物事を簡単にするために、 すべての例で ARM Cortex-M アーキテクチャを使用しています。ただし、この本は読者がこの 特定のアーキテクチャに精通していることを前提としておらず、必要に応じてこのアーキテクチャに特有の 詳細を説明します。
本書の対象読者
本書は、組み込みの経験がある人、または Rust の経験がある人のいずれにも向けて書かれていますが、 私たちは、組み込み Rust プログラミングに興味がある人なら誰でもこの本から何かを得られると考えています。事前知識がまったくない人には、 「前提と事前条件」セクションを読み、不足している知識を補うことで、本書からより多くを得て 読書体験を向上させることを勧めます。「その他のリソース」セクションでは、 補いたいと思うトピックに関するリソースを見つけることができます。
前提と事前条件
- Rust Programming Language を問題なく使いこなせ、デスクトップ環境で Rust アプリケーションを 書き、実行し、デバッグした経験があること。また、本書は Rust 2018 を対象としているため、2018 edition の慣用表現にも精通している必要が あります。
- C、C++、Ada などの別の言語で組み込みシステムを開発・デバッグすることに慣れており、
次のような概念に精通していること:
- クロスコンパイル
- メモリマップドペリフェラル
- 割り込み
- I2C、SPI、シリアルなどの一般的なインターフェース
その他のリソース
上で述べた内容に不慣れな場合や、この本で触れられている特定のトピックについてさらに詳しい情報が欲しい場合は、これらのリソースのいくつかが役立つかもしれません。
| トピック | リソース | 説明 |
|---|---|---|
| Rust | Rust Book | まだ Rust に十分慣れていない場合は、この本を読むことを強く勧めます。 |
| Rust, 組み込み | Discovery Book | 組み込みプログラミングを一度も行ったことがない場合は、この本のほうがよりよい出発点かもしれません |
| Rust, 組み込み | Embedded Rust Bookshelf | ここでは、Rust の Embedded Working Group が提供するいくつかの他のリソースを見つけることができます。 |
| Rust, 組み込み | Embedonomicon | Rust で組み込みプログラミングを行う際の細かな実践的詳細。 |
| Rust, 組み込み | embedded FAQ | 組み込みコンテキストにおける Rust に関するよくある質問。 |
| Rust, 組み込み | Comprehensive Rust 🦀: Bare Metal | ベアメタル Rust 開発に関する 4 日間の講義用教材 |
| 割り込み | Interrupt | - |
| メモリマップド IO/ペリフェラル | Memory-mapped I/O | - |
| SPI, UART, RS232, USB, I2C, TTL | Stack Exchange about SPI, UART, and other interfaces | - |
翻訳
この本は、寛大なボランティアによって翻訳されています。ここにあなたの 翻訳を掲載したい場合は、追加する PR を送ってください。
この本の使い方
この本は一般に、最初から最後まで順に読むことを前提としています。後の 章は前の章の概念の上に成り立っており、前の章ではあるトピックの詳細まで 掘り下げず、後の章でそのトピックを再び扱うことがあります。
この本では、掲載されている例の大半で STMicroelectronics の STM32F3DISCOVERY 開発ボードを 使用します。このボードは ARM Cortex-M アーキテクチャに基づいており、このアーキテクチャに 基づくほとんどの CPU では基本機能は同じですが、ペリフェラルやその他の マイクロコントローラの実装の詳細はベンダーごとに異なり、同じ ベンダーのマイクロコントローラファミリ間でさえしばしば異なります。
このため、本書の例を追いながら学ぶ目的で STM32F3DISCOVERY 開発ボードを購入することを勧めます。
本書への貢献
この本の作業は this repository で調整されており、主に resources team によって開発されています。
この本の手順に従うのが難しい、あるいは本のどこかの セクションが十分に明確でない、または追いにくいと感じた場合、それはバグであり、 本書の the issue tracker に報告すべきです。
誤字脱字の修正や新しい内容の追加を行う Pull Request は大歓迎です!
この資料の再利用
この本は、次のライセンスの下で配布されています。
- この本に含まれるコードサンプルおよび独立した Cargo プロジェクトは、MIT License と Apache License v2.0 の両方の条件の下でライセンスされています。
- この本に含まれる本文、画像、および図は、Creative Commons の CC-BY-SA v4.0 ライセンスの条件の下でライセンスされています。
要するに: この本のテキストや画像をあなたの作品で使いたい場合、次のことが必要です。
- 適切なクレジットを表示すること(つまり、スライドでこの本に言及し、関連するページへのリンクを提供すること)
- CC-BY-SA v4.0 ライセンスへのリンクを提供すること
- 何らかの形で資料を変更した場合はそれを明示し、その変更した資料を同じライセンスの下で利用可能にすること
また、本書が役に立ったと感じたら、ぜひお知らせください!
使用するハードウェア
これから扱うハードウェアに慣れていきましょう。
STM32F3DISCOVERY(「F3」)
このボードには何が搭載されているのでしょうか。
-
STM32F303VCT6 マイクロコントローラー。このマイクロコントローラーには、次のものがあります
-
単精度浮動小数点演算をハードウェアでサポートし、最大クロック周波数が 72 MHz のシングルコア ARM Cortex-M4F プロセッサ。
-
256 KiB の「フラッシュ」メモリ。(1 KiB = 1024 バイト)
-
48 KiB の RAM。
-
タイマー、I2C、SPI、USART などのさまざまな内蔵ペリフェラル。
-
ボードの両側に沿って並ぶ 2 列のヘッダーからアクセスできる、汎用入出力(GPIO)やその他の種類のピン。
-
「USB USER」と表示された USB ポートから利用できる Mini-USB インターフェース。
-
-
LSM303DLHC チップの一部である 加速度計。
-
LSM303DLHC チップの一部である 磁力計。
-
コンパスの形に配置された 8 個のユーザー LED。
-
2 つ目のマイクロコントローラー: STM32F103。このマイクロコントローラーは実際にはオンボードのプログラマー / デバッガーの一部であり、「USB ST-LINK」という名前の Mini-USB ポートに接続されています。
機能のより詳細な一覧やボードの追加仕様については、STMicroelectronics の Web サイトを参照してください。
注意: ボードに外部信号を加えたい場合は注意してください。STM32F303VCT6 マイクロコントローラーのピンの公称電圧は 3.3 ボルトです。詳しくは、マニュアルの 6.2「Absolute maximum ratings」セクションを参照してください。
no_std の Rust 環境
組み込みプログラミングという用語は、非常に幅広い異なる種類のプログラミングを指すために使われます。 数 KB の RAM と ROM しかない 8 ビット MCU(ST72325xx など) 向けのプログラミングから、Raspberry Pi のようなシステム (Model B 3+)までを含み、こちらは 32/64 ビットの 4 コア Cortex-A53 @ 1.4 GHz と 1GB の RAM を備えています。コードを書く際に適用される制約や制限は、 どのようなターゲットやユースケースを持つかによって異なります。
組み込みプログラミングには、一般的に 2 つの分類があります:
ホステッド環境
この種の環境は、通常の PC 環境に近いものです。 これは、ファイルシステム、ネットワーキング、メモリ管理、スレッドなど、 さまざまなシステムとやり取りするためのプリミティブを提供するシステムインターフェイス 例: POSIX が提供されることを意味します。 標準ライブラリは通常、その機能を実装するために、さらにこれらのプリミティブに依存します。 また、何らかの sysroot や RAM/ROM 使用量の制約、そして場合によっては 特殊なハードウェアや I/O を持つこともあります。全体として、特殊用途の PC 環境でコーディングしているような感覚です。
ベアメタル環境
ベアメタル環境では、あなたのプログラムより前にコードは何もロードされていません。
OS が提供するソフトウェアがなければ、標準ライブラリをロードすることはできません。
その代わり、プログラムとそれが使用するクレートは、実行のためにハードウェア(ベアメタル)のみを利用できます。
Rust が標準ライブラリをロードしないようにするには no_std を使用します。
標準ライブラリのプラットフォーム非依存な部分は libcore を通じて利用できます。
libcore は、組み込み環境では常に望ましいとは限らないものも除外しています。
その 1 つが、動的メモリ割り当てのためのメモリアロケータです。
これやその他の機能が必要な場合、それらを提供するクレートが用意されていることがよくあります。
libstd ランタイム
前述のとおり、libstd を使用するには何らかのシステムとの統合が必要ですが、これは単に
libstd が OS 抽象化にアクセスするための共通の方法を提供しているからだけではなく、
ランタイムも提供しているためです。
このランタイムは、とりわけ、スタックオーバーフロー保護の設定、コマンドライン引数の処理、
そしてプログラムの main 関数が呼び出される前にメインスレッドを生成することを担当します。このランタイムも no_std 環境では利用できません。
まとめ
#![no_std] は、クレートが std クレートではなく core クレートにリンクすることを示すクレートレベル属性です。
一方、libcore クレートは、プログラムが実行されるシステムについて何も仮定しない
std クレートのプラットフォーム非依存なサブセットです。
そのため、浮動小数点数、文字列、スライスといった言語プリミティブ向けの API に加えて、
アトミック操作や SIMD 命令のようなプロセッサ機能を公開する API も提供します。ただし、プラットフォーム統合を伴うもののための API は欠いています。
これらの特性により、no_std と libcore のコードは、ブートローダー、ファームウェア、カーネルのような
あらゆる種類のブートストラップ(stage 0)コードに使用できます。
概要
| 機能 | no_std | std |
|---|---|---|
| ヒープ(動的メモリ) | * | ✓ |
| コレクション(Vec、BTreeMap など) | ** | ✓ |
| スタックオーバーフロー保護 | ✘ | ✓ |
| main の前に初期化コードを実行 | ✘ | ✓ |
| libstd が利用可能 | ✘ | ✓ |
| libcore が利用可能 | ✓ | ✓ |
| ファームウェア、カーネル、またはブートローダーのコードを書くこと | ✓ | ✘ |
* alloc クレートを使用し、alloc-cortex-m のような適切なアロケータを使う場合に限ります。
** collections クレートを使用し、グローバルなデフォルトアロケータを設定する場合に限ります。
** HashMap と HashSet は、安全な乱数生成器がないため利用できません。
関連項目
ツール
マイクロコントローラーを扱うには、いくつかの異なるツールを使用する必要があります。これは、 ラップトップとは異なるアーキテクチャを扱うことになり、さらに リモート デバイス上で プログラムを実行し、デバッグしなければならないためです。
以下に挙げるツールをすべて使用します。最小バージョンが指定されていない場合は、 最近のバージョンであればどれでも動作するはずですが、ここではテスト済みの バージョンを記載しています。
- Rust 1.31、1.31-beta、またはそれ以降のツールチェーンに加え、ARM Cortex-M のコンパイル サポート
cargo-binutils~0.1.4qemu-system-arm。テスト済みバージョン: 3.0.0- OpenOCD >=0.8。テスト済みバージョン: v0.9.0 および v0.10.0
- ARM サポート付き GDB。バージョン 7.12 以降を強く推奨。テスト済み バージョン: 7.10、7.11、7.12、8.1
cargo-generateまたはgit。 これらのツールは任意ですが、本書を読み進めるのが容易になります。
以下の本文では、これらのツールを使用する理由を説明します。インストール手順は 次のページにあります。
cargo-generate または git
ベアメタルプログラムは、プログラムのメモリレイアウトを正しくするために、
リンク処理にいくつかの調整を必要とする非標準の (no_std) Rust プログラムです。
そのため、追加のファイル(リンカスクリプトなど)や
設定(リンカフラグなど)が必要になります。私たちはそれらをテンプレートとして
パッケージ化しているため、足りない情報(プロジェクト名や
対象ハードウェアの特性など)を埋めるだけで済みます。
このテンプレートは cargo-generate と互換性があります。これは、テンプレートから
新しい Cargo プロジェクトを作成するための Cargo サブコマンドです。git、curl、
wget、または Web ブラウザを使用してテンプレートをダウンロードすることもできます。
cargo-binutils
cargo-binutils は、Rust ツールチェーンに同梱されている LLVM ツールを簡単に使えるようにする
Cargo サブコマンドのコレクションです。これらのツールには、LLVM 版の
objdump、nm、size が含まれており、バイナリの調査に使用されます。
GNU binutils ではなくこれらのツールを使う利点は、(a) LLVM ツールのインストールが、
OS に関係なく同じ 1 コマンド(rustup component add llvm-tools)で行えること、そして (b) objdump のようなツールが、
ARM から x86_64 まで、rustc がサポートするすべてのアーキテクチャをサポートしていることです。これは
両者が同じ LLVM バックエンドを共有しているためです。
qemu-system-arm
QEMU はエミュレーターです。この場合は、ARM システムを完全にエミュレートできるバリアントを使用します。QEMU を使って、ホスト上で 組み込みプログラムを実行します。これにより、手元にハードウェアがまったくなくても、 本書の一部を進めることができます。
Embedded Rust デバッグのためのツール
概要
Rust における組み込みシステムのデバッグには、デバッグプロセスを管理するソフトウェア、プログラムの実行を調査および制御するためのデバッガー、そしてホストと組み込みデバイスとの相互作用を可能にするハードウェアプローブなど、専用のツールが必要です。この文書では、Probe-rs や OpenOCD のような、デバッグプロセスを簡素化して支援する重要なソフトウェアツールと、GDB や Probe-rs Visual Studio Code 拡張機能のような代表的なデバッガーについて説明します。さらに、組み込みデバイスの効果的なデバッグとプログラミングに不可欠な、Rusty-probe、ST-Link、J-Link、MCU-Link などの主要なハードウェアプローブについても扱います。
デバッグツールを駆動するソフトウェア
Probe-rs
Probe-rs は、組み込みシステムにおいてデバッガーと連携するよう設計された、Rust を重視したモダンなソフトウェアです。OpenOCD とは異なり、Probe-rs はシンプルさを念頭に構築されており、他のデバッグソリューションでしばしば見られる設定負荷を軽減することを目指しています。さまざまなプローブとターゲットをサポートし、組み込みハードウェアとやり取りするための高水準インターフェースを提供します。Probe-rs は Rust のツール群と直接統合されており、さらにその拡張機能を通じて Visual Studio Code とも統合されるため、開発者はデバッグワークフローを効率化できます。
OpenOCD (Open On-Chip Debugger)
OpenOCD は、組み込みシステムのデバッグ、テスト、プログラミングに使用されるオープンソースのソフトウェアツールです。ホストシステムと組み込みハードウェアの間のインターフェースを提供し、JTAG や SWD (Serial Wire Debug) のようなさまざまなトランスポート層をサポートします。OpenOCD はデバッガーである GDB と統合されます。OpenOCD は広くサポートされており、豊富なドキュメントと大規模なコミュニティがありますが、特にカスタムの組み込み構成では複雑な設定が必要になる場合があります。
デバッガー
デバッガーを使用すると、開発者はエラーやバグを特定して修正するために、プログラムの実行を調査および制御できます。デバッガーは、ブレークポイントの設定、コードを 1 行ずつ実行するステップ実行、変数の値やメモリ状態の確認といった機能を提供します。デバッガーは、徹底したソフトウェア開発と保守に不可欠であり、さまざまな条件下でコードが意図どおりに動作することを開発者が確認できるようにします。
デバッガーは次のことを行う方法を認識しています:
- メモリマップトレジスタを操作する。
- ブレークポイント / ウォッチポイントを設定する。
- メモリマップトレジスタの読み取りと書き込みを行う。
- デバッグイベントによって MCU が停止したことを検出する。
- デバッグイベントの発生後に MCU の実行を再開する。
- マイクロコントローラーの FLASH を消去して書き込む。
Probe-rs Visual Studio Code 拡張機能
Probe-rs には Visual Studio Code 拡張機能があり、大がかりなセットアップなしでシームレスなデバッグ体験を提供します。この連携により、開発者は pretty printing や詳細なエラーメッセージといった Rust 固有の機能を利用でき、デバッグプロセスを Rust エコシステムに沿ったものにできます。
TRACE32
TRACE32 は、Lauterbach によって開発された、組み込みシステム向けのプロフェッショナルなデバッグおよびトレースソリューションです。ARM や RISC-V を含む幅広いプロセッサアーキテクチャをサポートし、JTAG、SWD、および各種トレースインターフェースを介してターゲットハードウェアに接続します。TRACE32 は、マルチコアデバッグ、複雑なブレークポイント、リアルタイムのトレース解析などの高度なデバッグ機能を提供します。標準的な ELF/DWARF デバッグ情報を利用するため、従来のツールチェーンでビルドされた Rust バイナリと互換性があります。
GDB (GNU Debugger)
GDB は、プログラムの実行中またはクラッシュ後にその状態を調べることができる、汎用的なデバッグツールです。Embedded Rust では、GDB は OpenOCD やその他のデバッグサーバーを介してターゲットシステムに接続し、組み込みコードと対話します。GDB は高度に構成可能で、リモートデバッグ、変数の調査、条件付きブレークポイントなどの機能をサポートします。さまざまなプラットフォームで使用でき、pretty printing や IDE との統合など、Rust 固有のデバッグ要件も幅広くサポートしています。
プローブ
ハードウェアプローブは、ホストコンピューターとターゲットの組み込みデバイスの間の通信を可能にするために、組み込みシステムの開発およびデバッグで使用されるデバイスです。通常は JTAG や SWD のようなプロトコルをサポートしており、組み込みシステム上のマイクロコントローラーやマイクロプロセッサーのプログラミング、デバッグ、解析を可能にします。ハードウェアプローブは、ブレークポイントの設定、コードのステップ実行、メモリやプロセッサレジスタの確認を開発者が行ううえで重要であり、問題をリアルタイムで診断して修正できるようにします。
Rusty-probe
Rusty-probe は、probe-rs と連携するように設計された、オープンソースの USB ベースのハードウェアデバッグプローブです。Rusty-Probe と probe-rs を組み合わせることで、組み込み Rust アプリケーションを扱う開発者にとって、使いやすく費用対効果の高いソリューションが提供されます。
ST-Link
ST-Link は、STMicroelectronics が主に STM32 および STM8 マイクロコントローラーシリーズ向けに開発した、広く利用されているデバッグおよびプログラミングプローブです。JTAG または SWD(Serial Wire Debug)インターフェイスを介したデバッグとプログラミングの両方をサポートしています。STMicroelectronics の幅広い開発ボード製品群から直接サポートされ、主要な IDE に統合されていることから、ST-Link は広く利用されており、STM マイクロコントローラーを扱う開発者にとって便利な選択肢となっています。
J-Link
SEGGER Microcontroller が開発した J-Link は、ARM に限らず RISC-V などを含む幅広い CPU コアおよびデバイスをサポートする、堅牢で汎用性の高いデバッガです。高い性能と信頼性で知られる J-Link は、JTAG、SWD、fine-pitch JTAG インターフェイスなど、さまざまな通信インターフェイスをサポートしています。フラッシュメモリ上での無制限のブレークポイントなどの高度な機能や、多数の開発環境との互換性が評価され、広く支持されています。
MCU-Link
MCU-Link は、NXP Semiconductors が提供する、プログラマとしても機能するデバッグプローブです。さまざまな ARM Cortex マイクロコントローラーをサポートし、MCUXpresso IDE などの開発ツールとシームレスに連携します。MCU-Link は、その汎用性と手頃な価格が特に注目されており、愛好家、教育関係者、プロフェッショナルな開発者のいずれにとっても利用しやすい選択肢となっています。
ツールのインストール
このページには、いくつかのツールについて、OS に依存しないインストール手順を記載しています。
Rust ツールチェーン
https://rustup.rs にある手順に従って rustup をインストールしてください。
NOTE コンパイラのバージョンが 1.31 以上であることを確認してください。rustc -V は、以下に示すものより新しい日付を返すはずです。
$ rustc -V
rustc 1.31.1 (b6c32da9b 2018-12-18)
帯域幅とディスク使用量の観点から、デフォルトのインストールでは
ネイティブコンパイルのみがサポートされています。ARM Cortex-M
アーキテクチャ向けのクロスコンパイルサポートを追加するには、次のいずれかのコンパイルターゲットを選択してください。本書のサンプルで使用する STM32F3DISCOVERY
ボードには、thumbv7em-none-eabihf ターゲットを使用してください。
自分に最適な Cortex-M を見つけてください。
Cortex-M0、M0+、M1(ARMv6-M アーキテクチャ):
rustup target add thumbv6m-none-eabi
Cortex-M3(ARMv7-M アーキテクチャ):
rustup target add thumbv7m-none-eabi
ハードウェア浮動小数点なしの Cortex-M4 および M7(ARMv7E-M アーキテクチャ):
rustup target add thumbv7em-none-eabi
ハードウェア浮動小数点ありの Cortex-M4F および M7F(ARMv7E-M アーキテクチャ):
rustup target add thumbv7em-none-eabihf
Cortex-M23(ARMv8-M アーキテクチャ):
rustup target add thumbv8m.base-none-eabi
Cortex-M33 および M35P(ARMv8-M アーキテクチャ):
rustup target add thumbv8m.main-none-eabi
ハードウェア浮動小数点ありの Cortex-M33F および M35PF(ARMv8-M アーキテクチャ):
rustup target add thumbv8m.main-none-eabihf
cargo-binutils
cargo install cargo-binutils
rustup component add llvm-tools
WINDOWS: 前提条件として、Visual Studio 2019 用の C++ Build Tools がインストールされていること。 https://visualstudio.microsoft.com/thank-you-downloading-visual-studio/?sku=BuildTools&rel=16
cargo-generate
これは後で、テンプレートからプロジェクトを生成するために使用します。
cargo install cargo-generate
注: 一部の Linux ディストリビューション(例: Ubuntu)では、cargo-generate をインストールする前に、libssl-dev と pkg-config パッケージをインストールする必要がある場合があります。
OS 固有の手順
次に、使用している OS に対応する手順に従ってください:
Linux
以下に、いくつかの Linux ディストリビューション向けのインストールコマンドを示します。
パッケージ
- Ubuntu 18.04 以降 / Debian stretch 以降
NOTE
gdb-multiarchは、ARM Cortex-M プログラムをデバッグする際に使用する GDB コマンドです
sudo apt install gdb-multiarch openocd qemu-system-arm
- Ubuntu 14.04 および 16.04
NOTE
arm-none-eabi-gdbは、ARM Cortex-M プログラムをデバッグする際に使用する GDB コマンドです
sudo apt install gdb-arm-none-eabi openocd qemu-system-arm
- Fedora 27 以降
sudo dnf install gdb openocd qemu-system-arm
- Arch Linux
NOTE
arm-none-eabi-gdbは、ARM Cortex-M プログラムをデバッグする際に使用する GDB コマンドです
sudo pacman -S arm-none-eabi-gdb qemu-system-arm openocd
udev ルール
このルールにより、root 権限なしで Discovery ボードと OpenOCD を使用できます。
以下に示す内容で、ファイル /etc/udev/rules.d/70-st-link.rules を作成します。
# STM32F3DISCOVERY rev A/B - ST-LINK/V2
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="3748", TAG+="uaccess"
# STM32F3DISCOVERY rev C+ - ST-LINK/V2-1
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374b", TAG+="uaccess"
次に、以下を実行してすべての udev ルールを再読み込みします。
sudo udevadm control --reload-rules
ボードをノート PC に接続していた場合は、一度取り外してから再度接続してください。
次のコマンドを実行して、権限を確認できます。
lsusb
次のような出力が表示されるはずです
(..)
Bus 001 Device 018: ID 0483:374b STMicroelectronics ST-LINK/V2.1
(..)
バス番号とデバイス番号を控えてください。これらの番号を使って
/dev/bus/usb/<bus>/<device> のようなパスを作成します。次に、このパスを次のように使用します。
ls -l /dev/bus/usb/001/018
crw-------+ 1 root root 189, 17 Sep 13 12:34 /dev/bus/usb/001/018
getfacl /dev/bus/usb/001/018 | grep user
user::rw-
user:you:rw-
権限に付加されている + は、拡張権限が存在することを示しています。
getfacl コマンドは、ユーザー you がこのデバイスを使用できることを示します。
では、next section に進んでください。
macOS
すべてのツールは、Homebrew または MacPorts を使用してインストールできます。
Homebrew を使用してツールをインストールする
$ # GDB
$ brew install arm-none-eabi-gdb
$ # OpenOCD
$ brew install openocd
$ # QEMU
$ brew install qemu
注記 OpenOCD がクラッシュする場合は、以下を使用して最新バージョンをインストールする必要があるかもしれません。
$ brew install --HEAD openocd
MacPorts を使用してツールをインストールする
$ # GDB
$ sudo port install arm-none-eabi-gcc
$ # OpenOCD
$ sudo port install openocd
$ # QEMU
$ sudo port install qemu
以上です!next section に進んでください。
Windows
arm-none-eabi-gdb
ARM は Windows 向けに .exe インストーラーを提供しています。こちら から入手し、指示に従ってください。
インストール処理が完了する直前に、“Add path to environment variable”
オプションにチェックを入れるか選択してください。次に、ツールが %PATH% に含まれていることを確認してください:
$ arm-none-eabi-gdb -v
GNU gdb (GNU Tools for Arm Embedded Processors 7-2018-q2-update) 8.1.0.20180315-git
(..)
OpenOCD
Windows 向けの OpenOCD の公式バイナリリリースはありませんが、自分でコンパイルする気がない場合は、
xPack プロジェクトがバイナリ配布版を こちら で提供しています。提供されている
インストール手順に従ってください。次に、%PATH% 環境変数を更新して
バイナリがインストールされたパスを含めてください。(簡単インストールを使っている場合は、
C:\Users\USERNAME\AppData\Roaming\xPacks\@xpack-dev-tools\openocd\0.10.0-13.1\.content\bin\ です)
OpenOCD が %PATH% に含まれていることを次のように確認してください:
$ openocd -v
Open On-Chip Debugger 0.10.0
(..)
QEMU
QEMU は 公式ウェブサイト から入手してください。
ST-LINK USBドライバー
さらに、this USB driver もインストールする必要があります。そうしないと OpenOCD は動作しません。インストーラーの 指示に従い、ドライバーの正しいバージョン(32 ビットまたは 64 ビット)をインストールしてください。
以上です!next section に進んでください。
インストールの検証
このセクションでは、必要なツール / ドライバーの一部が正しくインストールおよび設定 されていることを確認します。
Mini-USB USB ケーブルを使用して、ノート PC / PC を discovery board に接続して ください。discovery board には 2 つの USB コネクターがあります。ボードの縁の中央 にある “USB ST-LINK” と表示された方を使用してください。
また、ST-LINK ヘッダーが実装されていることも確認してください。下の画像を参照して ください。ST-LINK ヘッダーが強調表示されています。
次に、以下のコマンドを実行してください。
openocd -f interface/stlink.cfg -f target/stm32f3x.cfg
注記: openocd の古いバージョンには、2017 年の 0.10.0 リリースを含め、 新しい(そして望ましい)
interface/stlink.cfgファイルが含まれていません。 その代わりに、interface/stlink-v2.cfgまたはinterface/stlink-v2-1.cfgを 使用する必要がある場合があります。
以下の出力が表示され、プログラムはコンソールを占有したままになるはずです。
Open On-Chip Debugger 0.10.0
Licensed under GNU GPL v2
For bug reports, read
http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
adapter speed: 1000 kHz
adapter_nsrst_delay: 100
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
none separate
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : clock speed 950 kHz
Info : STLINK v2 JTAG v27 API v2 SWIM v15 VID 0x0483 PID 0x374B
Info : using stlink api v2
Info : Target voltage: 2.919881
Info : stm32f3x.cpu: hardware has 6 breakpoints, 4 watchpoints
内容は完全には一致しないかもしれませんが、breakpoints と watchpoints に関する 最後の行は表示されるはずです。表示された場合は、OpenOCD プロセスを終了して next section に進んでください。
“breakpoints” の行が表示されなかった場合は、以下のいずれかのコマンドを試して ください。
openocd -f interface/stlink-v2.cfg -f target/stm32f3x.cfg
openocd -f interface/stlink-v2-1.cfg -f target/stm32f3x.cfg
これらのコマンドのいずれかが動作する場合、それは古いハードウェアリビジョンの discovery board を入手したことを意味します。これは問題にはなりませんが、 後で少し異なる設定を行う必要があるため、その事実を覚えておいてください。 next section に進むことができます。
通常ユーザーとしてこれらのコマンドがどれも動作しない場合は、root 権限
(例: sudo openocd ..)で実行してみてください。root 権限ではコマンドが
動作する場合は、udev rules が正しく設定されていることを確認してください。
ここまで来ても OpenOCD が動作しない場合は、an issue を作成してください。 サポートします。
はじめに
このセクションでは、組み込みプログラムの作成、ビルド、 フラッシュ、およびデバッグの手順を順を追って説明します。一般的な オープンソースのハードウェアエミュレータである QEMU を使って基本を 説明するため、特別なハードウェアがなくてもほとんどの例を試すことが できます。ハードウェアが必要なのは、当然ながら、 ハードウェア セクションだけで、そこでは OpenOCD を 使って STM32F3DISCOVERY にプログラムを書き込みます。
QEMU
まず、Cortex-M3 マイクロコントローラーである LM3S6965 向けのプログラムを 書き始めます。 これを最初のターゲットに選んだのは、QEMU を使ってエミュレートできるためで、 このセクションではハードウェアをいじる必要がなく、ツール類と 開発プロセスに集中できるからです。
重要 このチュートリアルでは、プロジェクト名として “app” という名前を使います。 “app” という単語を見かけたら、その箇所をあなたが選んだ プロジェクト名に置き換えてください。あるいは、プロジェクト名を “app” にして、 置き換えを不要にすることもできます。
標準的ではない Rust プログラムを作成する
新しいプロジェクトを生成するために、cortex-m-quickstart のプロジェクトテンプレートを
使います。作成されるプロジェクトには、最小限のアプリケーションが含まれます。これは
新しい組み込み Rust アプリケーションのよい出発点になります。さらに、プロジェクトには
examples ディレクトリも含まれており、いくつかの独立したアプリケーションを通して、
組み込み Rust の主要な機能の一部を示しています。
cargo-generate を使う
まず cargo-generate をインストールします
cargo install cargo-generate
次に新しいプロジェクトを生成します
cargo generate --git https://github.com/knurling-rs/app-template
Project Name: app
Creating project called `app`...
Done! New project created /tmp/app
cd app
git を使う
リポジトリをクローンします
git clone https://github.com/rust-embedded/cortex-m-quickstart app
cd app
その後、Cargo.toml ファイル内のプレースホルダーを埋めます
[package]
authors = ["{{authors}}"] # "{{authors}}" -> "John Smith"
edition = "2018"
name = "{{project-name}}" # "{{project-name}}" -> "app"
version = "0.1.0"
# ..
[[bin]]
name = "{{project-name}}" # "{{project-name}}" -> "app"
test = false
bench = false
どちらも使わない場合
cortex-m-quickstart テンプレートの最新スナップショットを取得して展開します。
curl -LO https://github.com/rust-embedded/cortex-m-quickstart/archive/master.zip
unzip master.zip
mv cortex-m-quickstart-master app
cd app
あるいは、cortex-m-quickstart をブラウザーで開き、緑色の “Clone or
download” ボタンをクリックし、その後 “Download ZIP” をクリックしてもかまいません。
その後、「git を使う」版の後半で行ったのと同じように、
Cargo.toml ファイル内のプレースホルダーを埋めます。
プログラムの概要
便宜上、src/main.rs のソースコードの中で最も重要な部分を以下に示します:
#![no_std]
#![no_main]
use panic_halt as _;
use cortex_m_rt::entry;
#[entry]
fn main() -> ! {
loop {
// ここにコードを書きます
}
}
このプログラムは標準的な Rust プログラムとは少し異なるので、 詳しく見ていきましょう。
#![no_std] は、このプログラムが標準クレートである
std に リンクしない ことを示します。代わりに、そのサブセットである
core クレートにリンクします。
#![no_main] は、このプログラムが大半の Rust プログラムで使われる標準の main
インターフェースを使わないことを示します。no_main を使う主な(しゃれではなく)理由は、
no_std コンテキストで main インターフェースを使うには
nightly が必要だからです。
use panic_halt as _;。このクレートは、プログラムの panic 時の挙動を定義する
panic_handler を提供します。これについては、本書の
パニックの章でさらに詳しく説明します。
#[entry] は、cortex-m-rt クレートが提供する属性で、
プログラムのエントリポイントを示すために使われます。標準の main
インターフェースを使っていないため、プログラムのエントリポイントを示す
別の方法が必要であり、それが #[entry] です。
fn main() -> !。このプログラムはターゲット
ハードウェア上で動作する 唯一 のプロセスなので、終了してほしくありません!
そのことをコンパイル時に保証するために、発散関数(関数シグネチャ中の -> !
の部分)を使っています。
クロスコンパイル
まず最初に、ターゲットマイクロコントローラー、この場合は
LM3S6965 のメモリーレイアウトが必要です。そうでないと、ビルド時にイメージのリンクに失敗します。プロジェクトの
ルートに memory.x という名前のファイルを作成し、以下の内容を貼り付けてください:
MEMORY
{
/* 注 1 K = 1 KiBi = 1024 バイト */
/* TODO これらのメモリー領域を、使用するデバイスのメモリーレイアウトに合わせて調整してください */
/* これらの値は、QEMU がエミュレートできる数少ないデバイスの 1 つである LM3S6965 に対応しています */
FLASH : ORIGIN = 0x00000000, LENGTH = 256K
RAM : ORIGIN = 0x20000000, LENGTH = 64K
}
/* ここにコールスタックが割り当てられます。 */
/* スタックはフルディセンディング型です。 */
/* この変数を使って、コールスタックと静的
変数を異なるメモリー領域に配置したい場合があります。以下にデフォルト値を示します */
/* _stack_start = ORIGIN(RAM) + LENGTH(RAM); */
/* このシンボルを使うと .text セクションの配置位置をカスタマイズできます */
/* 省略した場合、.text セクションは .vector_table
セクションの直後に配置されます */
/* これは、ベクターテーブルの直後に何らかの設定を格納する
マイクロコントローラーでのみ必要です */
/* _stext = ORIGIN(FLASH) + 0x400; */
/* 非初期化変数をカスタム RAM 領域に配置する例。 */
/* これは、上で RAM2 領域を定義しており、Rust
ソースで、そこに配置したいデータに `#[link_section = ".ram2bss"]` 属性を追加している
ことを前提としています。 */
/* このセクションはランタイムによってゼロ初期化されない点に注意してください! */
/* SECTIONS {
.ram2bss (NOLOAD) : ALIGN(4) {
*(.ram2bss);
. = ALIGN(4);
} > RAM2
} INSERT AFTER .bss;
*/
次のステップは、Cortex-M3 アーキテクチャ向けにプログラムを
クロスコンパイルすることです。コンパイルターゲット($TRIPLE)が
何であるべきかわかっていれば、cargo build --target $TRIPLE を実行するだけで済みます。
幸いなことに、テンプレート内の .cargo/config.toml にその答えがあります:
tail -n6 .cargo/config.toml
[build]
# これらのコンパイルターゲットのうち 1 つを選んでください
# target = "thumbv6m-none-eabi" # Cortex-M0 と Cortex-M0+
target = "thumbv7m-none-eabi" # Cortex-M3
# target = "thumbv7em-none-eabi" # Cortex-M4 と Cortex-M7(FPU なし)
# target = "thumbv7em-none-eabihf" # Cortex-M4F と Cortex-M7F(FPU あり)
Cortex-M3 アーキテクチャ向けにクロスコンパイルするには
thumbv7m-none-eabi を使う必要があります。このターゲットは、Rust
ツールチェーンをインストールしただけでは自動的には追加されないので、まだであれば、
このタイミングでツールチェーンに追加しておくとよいでしょう:
rustup target add thumbv7m-none-eabi
thumbv7m-none-eabi コンパイルターゲットはすでに
.cargo/config.toml ファイルでデフォルトに設定されているため、
以下の 2 つのコマンドは同じ意味になります:
cargo build --target thumbv7m-none-eabi
cargo build
確認
これで、target/thumbv7m-none-eabi/debug/app にネイティブではない ELF バイナリができました。これを
cargo-binutils を使って調べることができます。
cargo-readobj を使うと ELF ヘッダーを表示でき、これが ARM
バイナリであることを確認できます。
cargo readobj --bin app -- --file-headers
以下に注意してください。
--bin appはtarget/$TRIPLE/debug/appにあるバイナリを調べるための糖衣構文です--bin appは必要に応じてバイナリを(再)コンパイルします
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: 0x0
Type: EXEC (Executable file)
Machine: ARM
Version: 0x1
Entry point address: 0x405
Start of program headers: 52 (bytes into file)
Start of section headers: 153204 (bytes into file)
Flags: 0x5000200
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 2
Size of section headers: 40 (bytes)
Number of section headers: 19
Section header string table index: 18
cargo-size は、バイナリのリンカーセクションのサイズを表示できます。
cargo size --bin app --release -- -A
最適化されたバージョンを調べるために --release を使います
app :
section size addr
.vector_table 1024 0x0
.text 92 0x400
.rodata 0 0x45c
.data 0 0x20000000
.bss 0 0x20000000
.debug_str 2958 0x0
.debug_loc 19 0x0
.debug_abbrev 567 0x0
.debug_info 4929 0x0
.debug_ranges 40 0x0
.debug_macinfo 1 0x0
.debug_pubnames 2035 0x0
.debug_pubtypes 1892 0x0
.ARM.attributes 46 0x0
.debug_frame 100 0x0
.debug_line 867 0x0
Total 14570
ELF リンカーセクションのおさらい
.textにはプログラム命令が含まれます.rodataには文字列のような定数値が含まれます.dataには、初期値がゼロではない静的に確保された変数が含まれます.bssにも、初期値がゼロである静的に確保された変数が含まれます.vector_tableは、ベクターテーブル(割り込みテーブル)を格納するために使用する、標準外のセクションです.ARM.attributesと.debug_*セクションにはメタデータが含まれており、バイナリを書き込む際にターゲットへロードされることはありません。
重要: ELF ファイルにはデバッグ情報のようなメタデータが含まれているため、ディスク上のサイズは、そのプログラムをデバイスに書き込んだときに占有する容量を正確には反映しません。バイナリの実際の大きさを確認するには、必ず cargo-size を使ってください。
cargo-objdump を使うと、バイナリを逆アセンブルできます。
cargo objdump --bin app --release -- --disassemble --no-show-raw-insn --print-imm-hex
注記 上記のコマンドで
Unknown command line argumentというエラーが出る場合は、次のバグレポートを参照してください: https://github.com/rust-embedded/book/issues/269
注記 この出力はあなたのシステムでは異なる場合があります。新しいバージョンの rustc、LLVM、およびライブラリは、異なるアセンブリを生成することがあります。スニペットを小さく保つために、一部の命令は省略しています。
app: file format ELF32-arm-little
Disassembly of section .text:
main:
400: bl #0x256
404: b #-0x4 <main+0x4>
Reset:
406: bl #0x24e
40a: movw r0, #0x0
< .. truncated any more instructions .. >
DefaultHandler_:
656: b #-0x4 <DefaultHandler_>
UsageFault:
657: strb r7, [r4, #0x3]
DefaultPreInit:
658: bx lr
__pre_init:
659: strb r7, [r0, #0x1]
__nop:
65a: bx lr
HardFaultTrampoline:
65c: mrs r0, msp
660: b #-0x2 <HardFault_>
HardFault_:
662: b #-0x4 <HardFault_>
HardFault:
663: <unknown>
実行
次に、QEMU 上で組み込みプログラムを実行する方法を見ていきましょう! 今回は、実際に何かを行う hello の例を使います。デフォルトでは、この例は [defmt] と RTT を使ってテキストを出力します。
注記
defmtは、Embedded Rust エコシステムで広く使われているサードパーティ依存関係(つまりコア以外)です。
ホスト側で defmt が生成したメッセージを読み取ってデコードするには、RTT のトランスポート出力を semihosting に切り替える必要があります。実機を使う場合、これにはデバッグセッションが必要ですが、QEMU を使う場合はそのままで動作します。
依存関係を切り替えましょう。
cargo remove defmt-rtt
cargo add defmt-semihosting
src/lib.rs を開き、use defmt_rtt as _; を use defmt_semihosting as _; に置き換えてください。
これで、このサンプルをビルドできます。
cargo build --bin hello
出力されるバイナリは
target/thumbv7m-none-eabi/debug/hello
に配置されます。
このバイナリを QEMU 上で実行するには、通常は次のコマンドで十分です。
qemu-system-arm \
-cpu cortex-m3 \
-machine lm3s6965evb \
-nographic \
-semihosting-config enable=on,target=native \
-kernel target/thumbv7m-none-eabi/debug/hello
ただし今回のケースでは defmt を使っているため、ホストは出力をデコードできません。代わりに、Ferrous Systems が提供する qemu-run というツールが必要になります。
git clone git@github.com:knurling-rs/defmt.git
cd defmt/qemu-run/
cargo run -- --machine lm3s6965evb ../qemu-rs/target/thumbv7m-none-eabi/debug/hello
Hello, world!
このコマンドは、テキストを出力したあと正常に終了するはずです(終了コード = 0)。*nix では、次のコマンドでそれを確認できます。
echo $?
0
その QEMU コマンドを分解して見ていきましょう。
-
qemu-system-arm。これは QEMU エミュレータです。QEMU のバイナリにはいくつかの種類があり、これは ARM マシン全体の システム エミュレーションを行うものなので、この名前になっています。 -
-cpu cortex-m3。これは QEMU に Cortex-M3 CPU をエミュレートするよう指示します。CPU モデルを明示することで、いくつかの誤コンパイルを検出できます。たとえば、ハードウェア FPU を持つ Cortex-M4F 向けにコンパイルされたプログラムを実行すると、QEMU は実行中にエラーになります。 -
-machine lm3s6965evb。これは QEMU に LM3S6965EVB をエミュレートするよう指示します。これは LM3S6965 マイクロコントローラを搭載した評価ボードです。 -
-nographic。これは QEMU に GUI を起動しないよう指示します。 -
-semihosting-config (..)。これは QEMU に semihosting を有効にするよう指示します。semihosting を使うと、エミュレートされたデバイスは、とりわけ、ホストの stdout、stderr、stdin を使用したり、ホスト上にファイルを作成したりできます。 -
-kernel $file。これは QEMU に、どのバイナリをロードしてエミュレートされたマシン上で実行するかを指示します。
この長い QEMU コマンドを毎回入力するのは大変です! この手順を簡単にするために、カスタム runner を設定できます。.cargo/config.toml には、QEMU を呼び出す runner がコメントアウトされた状態で入っています。これをコメント解除しましょう。
head -n3 .cargo/config.toml
[target.thumbv7m-none-eabi]
# `cargo run` が QEMU 上でプログラムを実行するようにするには、これのコメントを解除します
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
このランナーは、デフォルトのコンパイルターゲットである thumbv7m-none-eabi
ターゲットにのみ適用されます。これで cargo run はプログラムをコンパイルし、
QEMU 上で実行します:
cargo run --example hello --release
Compiling app v0.1.0 (file:///tmp/app)
Finished release [optimized + debuginfo] target(s) in 0.26s
Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target/thumbv7m-none-eabi/release/examples/hello`
Hello, world!
デバッグ
デバッグは組み込み開発において極めて重要です。どのように行うのか見ていきましょう。
組み込みデバイスのデバッグでは リモート デバッグを行います。これは、デバッグ したいプログラムが、デバッガプログラム(GDB または LLDB)を実行している マシン上では動作しないためです。
リモートデバッグにはクライアントとサーバーが関わります。QEMU を使う構成では、 クライアントは GDB(または LLDB)のプロセスであり、サーバーは組み込み プログラムも実行している QEMU プロセスです。
このセクションでは、すでにコンパイルした hello のサンプルを使用します。
デバッグの最初の手順は、QEMU をデバッグモードで起動することです:
qemu-system-arm \
-cpu cortex-m3 \
-machine lm3s6965evb \
-nographic \
-semihosting-config enable=on,target=native \
-gdb tcp::3333 \
-S \
-kernel target/thumbv7m-none-eabi/debug/examples/hello
このコマンドはコンソールに何も出力せず、端末を占有したままになります。今回は 追加で 2 つのフラグを指定しています:
-
-gdb tcp::3333。これは、TCP ポート 3333 での GDB 接続を待つよう QEMU に 指示します。 -
-S。これは、起動時にマシンを停止したままにするよう QEMU に指示します。 これがないと、デバッガを起動する前にプログラムが main の終わりまで到達して しまいます!
次に、別の端末で GDB を起動し、このサンプルのデバッグシンボルを読み込むよう 指示します:
gdb-multiarch -q target/thumbv7m-none-eabi/debug/examples/hello
注記: インストールの章でどれをインストールしたかによっては、gdb-multiarch ではなく別のバージョンの gdb が必要になるかもしれません。たとえば
arm-none-eabi-gdb や単に gdb の場合もあります。
その後、GDB シェル内で、TCP ポート 3333 で接続を待っている QEMU に接続します。
target remote :3333
Remote debugging using :3333
Reset () at $REGISTRY/cortex-m-rt-0.6.1/src/lib.rs:473
473 pub unsafe extern "C" fn Reset() -> ! {
プロセスが停止しており、プログラムカウンタが Reset という名前の関数を指して
いることが分かります。これがリセットハンドラです。Cortex-M コアはブート時に
これを実行します。
環境によっては、上に示したように
Reset () at $REGISTRY/cortex-m-rt-0.6.1/src/lib.rs:473の行が表示される代わりに、gdb が次のような警告を表示することがあります:
core::num::bignum::Big32x40::mul_small () at src/libcore/num/bignum.rs:254src/libcore/num/bignum.rs: No such file or directory.これは既知の不具合です。これらの警告はそのまま無視して構いません。おそらく Reset() にいます。
このリセットハンドラは最終的に私たちの main 関数を呼び出します。ブレーク
ポイントと continue コマンドを使って、そこまで一気に進みましょう。
ブレークポイントを設定するために、まず list コマンドでコードのどこで停止
したいか確認しましょう。
list main
これで、examples/hello.rs ファイルのソースコードが表示されます。
6 use panic_halt as _;
7
8 use cortex_m_rt::entry;
9 use cortex_m_semihosting::{debug, hprintln};
10
11 #[entry]
12 fn main() -> ! {
13 hprintln!("Hello, world!").unwrap();
14
15 // QEMU を終了
“Hello, world!” の直前、つまり 13 行目にブレークポイントを追加したいので、
break コマンドを使います:
break 13
これで continue コマンドを使って、main 関数まで実行するよう gdb に指示
できます:
continue
Continuing.
Breakpoint 1, hello::__cortex_m_rt_main () at examples\hello.rs:13
13 hprintln!("Hello, world!").unwrap();
これで “Hello, world!” を出力するコードの直前まで来ました。next
コマンドで先に進みましょう。
next
16 debug::exit(debug::EXIT_SUCCESS);
この時点で、qemu-system-arm を実行している端末に “Hello, world!” が表示
されるはずです。
$ qemu-system-arm (..)
Hello, world!
もう一度 next を実行すると、QEMU プロセスが終了します。
next
[Inferior 1 (Remote target) exited normally]
これで GDB セッションを終了できます。
quit
ハードウェア
ここまでで、ツール群と開発プロセスにはある程度慣れてきたはずです。 このセクションでは実際のハードウェアに切り替えます。プロセス自体は ほぼ同じです。では、始めましょう。
ハードウェアを把握する
始める前に、ターゲットデバイスのいくつかの特性を特定する必要があります。 これらはプロジェクトの設定に使用します。
-
ARM コア。例: Cortex-M3。
-
ARM コアに FPU は含まれていますか。Cortex-M4F および Cortex-M7F コアには含まれています。
-
ターゲットデバイスにはどれだけのフラッシュメモリと RAM がありますか。例: フラッシュ 256 KiB、RAM 32 KiB。
-
フラッシュメモリと RAM はアドレス空間のどこにマップされていますか。例: RAM は一般にアドレス
0x2000_0000に配置されています。
この情報は、デバイスのデータシートまたはリファレンスマニュアルで確認できます。
このセクションでは、リファレンスハードウェアである STM32F3DISCOVERY を 使用します。このボードには STM32F303VCT6 マイクロコントローラーが搭載 されています。このマイクロコントローラーの仕様は次のとおりです。
-
単精度 FPU を含む Cortex-M4F コア
-
アドレス 0x0800_0000 に配置された 256 KiB のフラッシュ。
-
アドレス 0x2000_0000 に配置された 40 KiB の RAM。(別の RAM 領域も ありますが、簡単のためここでは無視します)。
設定
新しいテンプレートインスタンスを使って、最初から始めます。
cargo-generate を使わずにこれを行う方法を思い出したい場合は、
previous section on QEMU を参照してください。
$ cargo generate --git https://github.com/rust-embedded/cortex-m-quickstart
Project Name: app
Creating project called `app`...
Done! New project created /tmp/app
$ cd app
最初の手順は、.cargo/config.toml でデフォルトのコンパイルターゲットを
設定することです。
tail -n5 .cargo/config.toml
# これらのコンパイルターゲットのうち 1 つを選択
# target = "thumbv6m-none-eabi" # Cortex-M0 および Cortex-M0+
# target = "thumbv7m-none-eabi" # Cortex-M3
# target = "thumbv7em-none-eabi" # Cortex-M4 および Cortex-M7(FPU なし)
target = "thumbv7em-none-eabihf" # Cortex-M4F および Cortex-M7F(FPU あり)
Cortex-M4F コアに対応しているため、thumbv7em-none-eabihf を使用します。
注記: 前の章で見たとおり、すべてのターゲットをインストールする必要が あり、これは新しいターゲットです。したがって、このターゲット向けに
rustup target add thumbv7em-none-eabihfというインストール処理を 実行するのを忘れないでください。
2 つ目の手順は、メモリ領域の情報を memory.x ファイルに入力することです。
$ cat memory.x
/* STM32F303VCT6 用リンカスクリプト */
MEMORY
{
/* 注 1 K = 1 KiBi = 1024 バイト */
FLASH : ORIGIN = 0x08000000, LENGTH = 256K
RAM : ORIGIN = 0x20000000, LENGTH = 40K
}
注記: 何らかの理由で、特定のビルドターゲットを最初にビルドした後に
memory.xファイルを変更した場合は、cargo buildの前にcargo cleanを実行してください。cargo buildではmemory.xの 更新が追跡されないことがあるためです。
再び hello の例から始めますが、まず小さな変更が必要です。
examples/hello.rs では、debug::exit() の呼び出しがコメントアウト
されているか、削除されていることを確認してください。これは QEMU で
実行するときにのみ使用します。
#[entry]
fn main() -> ! {
hprintln!("Hello, world!").unwrap();
// QEMU を終了する
// 注記: これはハードウェア上で実行しないでください。OpenOCD の状態を破損する可能性があります
// debug::exit(debug::EXIT_SUCCESS);
loop {}
}
これで cargo build を使ってプログラムをクロスコンパイルし、
以前行ったように cargo-binutils を使ってバイナリを調べられます。
役に立つことに、ほとんどすべての Cortex-M CPU は同じ方法でブートするため、
チップを動作させるために必要なあらゆる処理は cortex-m-rt クレートが
引き受けてくれます。
cargo build --example hello
デバッグ
デバッグの見た目は少し異なります。実際、最初の手順はターゲットデバイスに よって異なる場合があります。このセクションでは、STM32F3DISCOVERY 上で 動作するプログラムをデバッグするのに必要な手順を示します。これは参照用の 情報です。デバイス固有のデバッグ情報については the Debugonomicon を 参照してください。
前と同様にリモートデバッグを行い、クライアントは GDB プロセスになります。 ただし今回は、サーバーが OpenOCD です。
verify セクションで行ったように、discovery ボードをノート PC / PC に 接続し、ST-LINK ヘッダーが実装されていることを確認してください。
端末で openocd を実行して、discovery ボード上の ST-LINK に接続します。
このコマンドはテンプレートのルートから実行してください。openocd は、
使用するインターフェイスファイルとターゲットファイルを指定する
openocd.cfg ファイルを読み取ります。
cat openocd.cfg
# STM32F3DISCOVERY 開発ボード向けの OpenOCD 設定例
# 入手したハードウェアのリビジョンに応じて、これらのうち 1 つを選択する必要があります
# インターフェイス。どの時点でも、コメントアウトされているインターフェイスは 1 つだけにしてください
# リビジョン C(新しいリビジョン)
source [find interface/stlink.cfg]
# リビジョン A および B(古いリビジョン)
# source [find interface/stlink-v2.cfg]
source [find target/stm32f3x.cfg]
注記 verify セクションで discovery ボードが古いリビジョンであると 分かった場合は、この時点で
openocd.cfgファイルを変更してinterface/stlink-v2.cfgを使用する必要があります。
$ openocd
Open On-Chip Debugger 0.10.0
Licensed under GNU GPL v2
For bug reports, read
http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
adapter speed: 1000 kHz
adapter_nsrst_delay: 100
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
none separate
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : clock speed 950 kHz
Info : STLINK v2 JTAG v27 API v2 SWIM v15 VID 0x0483 PID 0x374B
Info : using stlink api v2
Info : Target voltage: 2.913879
Info : stm32f3x.cpu: hardware has 6 breakpoints, 4 watchpoints
別の端末で、これもテンプレートのルートから GDB を実行します。
gdb-multiarch -q target/thumbv7em-none-eabihf/debug/examples/hello
注記: 前と同様に、インストールの章でどれをインストールしたかによっては、
gdb-multiarch ではなく別のバージョンの gdb が必要になる場合があります。
これは arm-none-eabi-gdb や単に gdb である可能性もあります。
次に、ポート 3333 で TCP 接続を待っている OpenOCD に GDB を接続します。
(gdb) target remote :3333
Remote debugging using :3333
0x00000000 in ?? ()
次に、load コマンドを使ってプログラムをマイクロコントローラーに
フラッシュ(ロード)します。
(gdb) load
Loading section .vector_table, size 0x400 lma 0x8000000
Loading section .text, size 0x1518 lma 0x8000400
Loading section .rodata, size 0x414 lma 0x8001918
Start address 0x08000400, load size 7468
Transfer rate: 13 KB/sec, 2489 bytes/write.
これでプログラムはロードされました。このプログラムは semihosting を
使用するため、semihosting の呼び出しを行う前に、OpenOCD に
semihosting を有効にするよう伝える必要があります。monitor コマンドを
使って OpenOCD にコマンドを送信できます。
(gdb) monitor arm semihosting enable
semihosting is enabled
monitor helpコマンドを実行すると、OpenOCD のすべてのコマンドを確認できます。
前と同じように、ブレークポイントと
continue コマンドを使って main まで一気にスキップできます。
(gdb) break main
Breakpoint 1 at 0x8000490: file examples/hello.rs, line 11.
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) continue
Continuing.
Breakpoint 1, hello::__cortex_m_rt_main_trampoline () at examples/hello.rs:11
11 #[entry]
注記 上の
continueコマンドを実行したあと、ブレークポイントで停止する代わりに GDB が端末をブロックしてしまう場合は、memory.xファイル内のメモリ領域情報が お使いのデバイス向けに正しく設定されているか(開始アドレス と 長さの 両方)を再確認するとよいでしょう。
step で main 関数の中に入ります。
(gdb) step
halted: PC: 0x08000496
hello::__cortex_m_rt_main () at examples/hello.rs:13
13 hprintln!("Hello, world!").unwrap();
next でプログラムを進めると、ほかにもいくつか出力がありますが、OpenOCD のコンソールに
“Hello, world!” が表示されるはずです。
$ openocd
(..)
Info : halted: PC: 0x08000502
Hello, world!
Info : halted: PC: 0x080004ac
Info : halted: PC: 0x080004ae
Info : halted: PC: 0x080004b0
Info : halted: PC: 0x080004b4
Info : halted: PC: 0x080004b8
Info : halted: PC: 0x080004bc
このメッセージが表示されるのは、プログラムが 19 行目で定義された無限ループ loop {} に入ろうとする直前の 1 回だけです
これで quit コマンドを使って GDB を終了できます。
(gdb) quit
A debugging session is active.
Inferior 1 [Remote target] will be detached.
Quit anyway? (y or n)
ここからのデバッグにはもう少し手順が必要になるので、それらの手順を
openocd.gdb という 1 つの GDB スクリプトにまとめてあります。このファイルは cargo generate の段階で作成されており、変更を加えなくても動作するはずです。中を見てみましょう:
cat openocd.gdb
target extended-remote :3333
# デマングルされたシンボルを表示
set print asm-demangle on
# 未処理の例外、ハードフォルト、パニックを検出
break DefaultHandler
break HardFault
break rust_begin_unwind
monitor arm semihosting enable
load
# プロセスを開始するが、ただちにプロセッサを停止する
stepi
<gdb> -x openocd.gdb target/thumbv7em-none-eabihf/debug/examples/hello を実行すると、GDB がただちに
OpenOCD に接続し、semihosting を有効にして、プログラムをロードし、プロセスを開始します。
あるいは、<gdb> -x openocd.gdb をカスタムランナーにすると、
cargo run でプログラムをビルドし、さらに GDB セッションも開始できるようになります。このランナーは
.cargo/config.toml に含まれていますが、コメントアウトされています。
head -n10 .cargo/config.toml
[target.thumbv7m-none-eabi]
# これをアンコメントすると、`cargo run` が QEMU 上でプログラムを実行するようになります
# runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
# これら 3 つのオプションのうち 1 つをアンコメントすると、`cargo run` が GDB セッションを開始するようになります
# どのオプションを選ぶかはシステムによって異なります
runner = "arm-none-eabi-gdb -x openocd.gdb"
# runner = "gdb-multiarch -x openocd.gdb"
# runner = "gdb -x openocd.gdb"
$ cargo run --example hello
(..)
Loading section .vector_table, size 0x400 lma 0x8000000
Loading section .text, size 0x1e70 lma 0x8000400
Loading section .rodata, size 0x61c lma 0x8002270
Start address 0x800144e, load size 10380
Transfer rate: 17 KB/sec, 3460 bytes/write.
(gdb)
メモリマップドレジスタ
組み込みシステムは、通常の Rust コードを実行して RAM 内でデータをやり取りするだけでは、できることに限界があります。システムに情報を入れたり、システムから情報を取り出したりしたい場合(LED を点滅させる、ボタン押下を検出する、ある種のバスでチップ外のペリフェラルと通信する、といったことです)、ペリフェラルとその「メモリマップドレジスタ」の世界に足を踏み入れなければなりません。
マイクロコントローラ内のペリフェラルにアクセスするために必要なコードは、次のいずれかのレベルですでに書かれていることがよくあります。
- マイクロアーキテクチャクレート - この種のクレートは、マイクロコントローラが使用しているプロセッサコアに共通する有用なルーチンと、その特定の種類のプロセッサコアを使うすべてのマイクロコントローラに共通するペリフェラルを扱います。たとえば cortex-m クレートは、すべての Cortex-M ベースのマイクロコントローラで共通の、割り込みの有効化と無効化を行う関数を提供します。また、すべての Cortex-M ベースのマイクロコントローラに含まれている
SysTickペリフェラルにもアクセスできます。 - Peripheral Access Crate (PAC) - この種のクレートは、使用している特定の型番のマイクロコントローラ向けに定義された、さまざまなメモリマップドレジスタに対する薄いラッパーです。たとえば、Texas Instruments Tiva-C TM4C123 シリーズ向けの tm4c123x や、ST-Micro STM32F30x シリーズ向けの stm32f30x です。ここでは、マイクロコントローラの Technical Reference Manual に記載された各ペリフェラルの操作手順に従って、レジスタを直接操作することになります。
- HAL クレート - これらのクレートは、embedded-hal で定義されているいくつかの共通トレイトを実装することで、多くの場合、特定のプロセッサ向けにより使いやすい API を提供します。たとえば、この種のクレートは
Serial構造体を提供し、そのコンストラクタは適切な GPIO ピンの組とボーレートを受け取り、データ送信のための何らかのwrite_byte関数を提供するかもしれません。embedded-hal の詳細については Portability の章を参照してください。 - ボードクレート - これらのクレートは、使用している特定の開発キットやボードに合うように各種ペリフェラルや GPIO ピンを事前設定することで、HAL クレートよりさらに一歩進んだものです。たとえば、STM32F3DISCOVERY ボード向けの stm32f3-discovery があります。
ボードクレート
組み込み Rust が初めてなら、ボードクレートは最適な出発点です。学び始めたばかりの段階では圧倒されかねないハードウェアの詳細をうまく抽象化してくれますし、LED をオンまたはオフにするといった標準的な作業も簡単にしてくれます。公開される機能はボードによって大きく異なります。この本はハードウェア非依存であることを目指しているため、ボードクレートはこの本では扱いません。
STM32F3DISCOVERY ボードで試してみたい場合は、ボード上の LED を点滅させたり、コンパスや Bluetooth にアクセスしたりする機能などを提供する stm32f3-discovery ボードクレートを見ることを強くおすすめします。Discovery 本では、ボードクレートの使い方について優れた入門が提供されています。
しかし、まだ専用のボードクレートがないシステムで作業している場合や、既存のクレートでは提供されていない機能が必要な場合は、マイクロアーキテクチャクレートから始めて、下の層から見ていきましょう。
マイクロアーキテクチャクレート
すべての Cortex-M ベースのマイクロコントローラに共通する SysTick ペリフェラルを見てみましょう。cortex-m クレートにはかなり低レベルな API があり、次のように使えます。
#![no_std]
#![no_main]
use cortex_m::peripheral::{syst, Peripherals};
use cortex_m_rt::entry;
use panic_halt as _;
#[entry]
fn main() -> ! {
let peripherals = Peripherals::take().unwrap();
let mut systick = peripherals.SYST;
systick.set_clock_source(syst::SystClkSource::Core);
systick.set_reload(1_000);
systick.clear_current();
systick.enable_counter();
while !systick.has_wrapped() {
// ループ
}
loop {}
}
SYST 構造体上の関数は、このペリフェラルについて ARM Technical Reference Manual で定義されている機能にかなり近く対応しています。この API には「X ミリ秒待つ」のようなものはなく、while ループを使って自分たちで素朴に実装しなければなりません。Peripherals::take() を呼び出すまでは SYST 構造体にアクセスできない点に注意してください。これは、プログラム全体に SYST 構造体が 1 つしか存在しないことを保証する特別なルーチンです。これについて詳しくは Peripherals セクションを参照してください。
Peripheral Access Crate (PAC) を使う
すべての Cortex-M に含まれる基本的なペリフェラルだけに限定していては、組み込みソフトウェア開発はあまり先へ進みません。いずれ、使っている特定のマイクロコントローラに固有のコードを書く必要が出てきます。この例では、Texas Instruments の TM4C123、つまり 80MHz 動作で 256 KiB の Flash を備えた中程度の Cortex-M4 があると仮定しましょう。このチップを利用するために tm4c123x クレートを取り込みます。
#![no_std]
#![no_main]
use panic_halt as _; // panic ハンドラ
use cortex_m_rt::entry;
use tm4c123x;
#[entry]
pub fn init() -> (Delay, Leds) {
let cp = cortex_m::Peripherals::take().unwrap();
let p = tm4c123x::Peripherals::take().unwrap();
let pwm = p.PWM0;
pwm.ctl.write(|w| w.globalsync0().clear_bit());
// モード = 1 => アップ/ダウンカウントモード
pwm._2_ctl.write(|w| w.enable().set_bit().mode().set_bit());
pwm._2_gena.write(|w| w.actcmpau().zero().actcmpad().one());
// 528 サイクル (アップとダウンがそれぞれ 264) = ビデオラインごとに 4 ループ (2112 サイクル)
pwm._2_load.write(|w| unsafe { w.load().bits(263) });
pwm._2_cmpa.write(|w| unsafe { w.compa().bits(64) });
pwm.enable.write(|w| w.pwm4en().set_bit());
}
PWM0 ペリフェラルには、先ほど SYST ペリフェラルにアクセスしたときとまったく同じ方法でアクセスしていますが、tm4c123x::Peripherals::take() を呼び出している点だけが異なります。このクレートは svd2rust を使って自動生成されているため、レジスタフィールドのアクセサ関数は数値引数ではなくクロージャを受け取ります。コード量が多く見えるかもしれませんが、Rust コンパイラはこれを使って多くのチェックを行い、そのうえで手書きのアセンブラにかなり近いマシンコードを生成できます。自動生成されたコードが、特定のアクセサ関数に渡され得るすべての引数が有効であると判断できない場合(たとえば、SVD ではレジスタが 32 ビットと定義されているものの、その 32 ビット値の一部に特別な意味があるかどうかが示されていない場合)、その関数は unsafe としてマークされます。上の例では、bits() 関数を使って load および compa サブフィールドを設定している箇所で、これを確認できます。
読み取り
read() 関数は、このチップ向けのメーカーの SVD ファイルで定義されている、このレジスタ内のさまざまなサブフィールドへの読み取り専用アクセスを提供するオブジェクトを返します。この特定のチップ上のこの特定のペリフェラルにあるこの特定のレジスタについて、特別な R 戻り値型で利用可能なすべての関数は、tm4c123xドキュメント にあります。
if pwm.ctl.read().globalsync0().is_set() {
// 何かを行う
}
書き込み
write() 関数は、1 つの引数を持つクロージャを受け取ります。通常、これを w と呼びます。この引数は、このチップ向けのメーカーの SVD ファイルで定義されている、このレジスタ内のさまざまなサブフィールドへの読み書きアクセスを提供します。繰り返しになりますが、この特定のチップ上のこの特定のペリフェラルにあるこの特定のレジスタについて、‘w’ で利用可能なすべての関数は、tm4c123xドキュメント にあります。設定しなかったすべてのサブフィールドにはデフォルト値が設定されるため、レジスタ内の既存の内容はすべて失われる点に注意してください。
pwm.ctl.write(|w| w.globalsync0().clear_bit());
変更
このレジスタ内のある特定のサブフィールドだけを変更し、他のサブフィールドは変更しないままにしたい場合は、modify 関数を使用できます。この関数は 2 つの引数を持つクロージャを受け取ります。1 つは読み取り用、もう 1 つは書き込み用です。通常、これらをそれぞれ r と w と呼びます。r 引数はレジスタの現在の内容を調べるために使用でき、w 引数はレジスタの内容を変更するために使用できます。
pwm.ctl.modify(|r, w| w.globalsync0().clear_bit());
ここでは、modify 関数がクロージャの威力をよく示しています。C では、いったん一時的な値に読み込み、正しいビットを変更してから、その値を書き戻さなければなりません。つまり、かなりのミスの入り込む余地があります。
uint32_t temp = pwm0.ctl.read();
temp |= PWM0_CTL_GLOBALSYNC0;
pwm0.ctl.write(temp);
uint32_t temp2 = pwm0.enable.read();
temp2 |= PWM0_ENABLE_PWM4EN;
pwm0.enable.write(temp); // おっと! 変数を間違えた!
HALクレートを使う
チップ向けの HAL クレートは通常、PAC が公開する生の構造体に対してカスタムトレイトを実装することで動作します。このトレイトは、多くの場合、単一のペリフェラル向けには constrain()、複数のピンを持つ GPIO ポートのようなもの向けには split() という関数を定義します。この関数は、基になる生のペリフェラル構造体を消費し、より高水準の API を持つ新しいオブジェクトを返します。この API では、たとえば Serial ポートの new 関数が、Clock 構造体への借用を要求することもあります。この Clock 構造体は、PLLs を構成してすべてのクロック周波数を設定する関数を呼び出した場合にのみ生成できます。このようにして、先にクロックレートを設定せずに Serial ポートオブジェクトを作成することや、Serial ポートオブジェクトがボーレートをクロックティックに誤変換することは、静的に不可能になります。クレートによっては、各 GPIO ピンが取り得る状態ごとに特別なトレイトを定義し、ユーザーに対して、そのピンをペリフェラルに渡す前に正しい状態にしておくこと(たとえば適切な Alternate Function Mode を選択すること)を要求するものさえあります。しかも、これらはすべて実行時コストなしで実現されます!
例を見てみましょう。
#![no_std]
#![no_main]
use panic_halt as _; // panicハンドラ
use cortex_m_rt::entry;
use tm4c123x_hal as hal;
use tm4c123x_hal::prelude::*;
use tm4c123x_hal::serial::{NewlineMode, Serial};
use tm4c123x_hal::sysctl;
#[entry]
fn main() -> ! {
let p = hal::Peripherals::take().unwrap();
let cp = hal::CorePeripherals::take().unwrap();
// SYSCTL 構造体を、より高水準の API を持つオブジェクトにまとめる
let mut sc = p.SYSCTL.constrain();
// 発振設定を選ぶ
sc.clock_setup.oscillator = sysctl::Oscillator::Main(
sysctl::CrystalFrequency::_16mhz,
sysctl::SystemClock::UsePll(sysctl::PllOutputFrequency::_80_00mhz),
);
// その設定で PLL を構成する
let clocks = sc.clock_setup.freeze();
// GPIO_PORTA 構造体を、より高水準の API を持つオブジェクトにまとめる。
// GPIO ペリフェラルに自動的に電源投入できるように、
// `sc.power_control` を借用する必要があることに注意。
let mut porta = p.GPIO_PORTA.split(&sc.power_control);
// UART を有効化する。
let uart = Serial::uart0(
p.UART0,
// 送信ピン
porta
.pa1
.into_af_push_pull::<hal::gpio::AF1>(&mut porta.control),
// 受信ピン
porta
.pa0
.into_af_push_pull::<hal::gpio::AF1>(&mut porta.control),
// RTS と CTS は不要
(),
(),
// ボーレート
115200_u32.bps(),
// 出力の扱い
NewlineMode::SwapLFtoCRLF,
// ボーレート分周値を計算するにはクロックレートが必要
&clocks,
// UART ペリフェラルに電源投入するにはこれが必要
&sc.power_control,
);
loop {
writeln!(uart, "Hello, World!\r\n").unwrap();
}
}
セミホスティング
セミホスティングは、組み込みデバイスがホスト上で I/O を行えるようにする 仕組みで、主にホストのコンソールにメッセージをログ出力するために使われます。 セミホスティングに必要なのはデバッグセッションと、ほかにはほとんど何もありません (追加の配線も不要です!)ので、とても手軽に使えます。欠点は非常に遅いことです。 書き込み操作のたびに、使用するハードウェアデバッガ(例: ST-Link)によっては 数ミリ秒かかることがあります。
cortex-m-semihosting クレートは、Cortex-M デバイス上でセミホスティング操作を
行うための API を提供します。以下のプログラムは、「Hello, world!」の
セミホスティング版です。
#![no_main]
#![no_std]
use panic_halt as _;
use cortex_m_rt::entry;
use cortex_m_semihosting::hprintln;
#[entry]
fn main() -> ! {
hprintln!("Hello, world!").unwrap();
loop {}
}
このプログラムを実機で実行すると、OpenOCD のログ内に “Hello, world!” メッセージが表示されます。
$ openocd
(..)
Hello, world!
(..)
ただし、最初に GDB から OpenOCD でセミホスティングを有効にする必要があります:
(gdb) monitor arm semihosting enable
semihosting is enabled
QEMU はセミホスティング操作を理解するので、上のプログラムは
デバッグセッションを開始しなくても qemu-system-arm でも動作します。なお、
セミホスティングのサポートを有効にするには、QEMU に
-semihosting-config フラグを渡す必要があります。これらのフラグは、テンプレートの
.cargo/config.toml ファイルにすでに含まれています。
$ # このプログラムはターミナルをブロックします
$ cargo run
Running `qemu-system-arm (..)
Hello, world!
QEMU プロセスを終了するために使用できる exit セミホスティング操作もあります。
重要: 実機では debug::exit を 決して 使用しないでください。この関数は
OpenOCD セッションを壊す可能性があり、再起動するまでそれ以上プログラムを
デバッグできなくなります。
#![no_main]
#![no_std]
use panic_halt as _;
use cortex_m_rt::entry;
use cortex_m_semihosting::debug;
#[entry]
fn main() -> ! {
let roses = "blue";
if roses == "red" {
debug::exit(debug::EXIT_SUCCESS);
} else {
debug::exit(debug::EXIT_FAILURE);
}
loop {}
}
$ cargo run
Running `qemu-system-arm (..)
$ echo $?
1
最後にもう 1 つヒントです: panic 時の動作を exit(EXIT_FAILURE) に設定できます。これに
より、QEMU 上で実行できる no_std の run-pass テストを書けるようになります。
利便性のために、panic-semihosting クレートには “exit” feature があり、これを
有効にすると panic メッセージをホストの stderr に記録したあとで
exit(EXIT_FAILURE) を呼び出します。
#![no_main]
#![no_std]
use panic_semihosting as _; // features = ["exit"]
use cortex_m_rt::entry;
use cortex_m_semihosting::debug;
#[entry]
fn main() -> ! {
let roses = "blue";
assert_eq!(roses, "red");
loop {}
}
$ cargo run
Running `qemu-system-arm (..)
panicked at 'assertion failed: `(left == right)`
left: `"blue"`,
right: `"red"`', examples/hello.rs:15:5
$ echo $?
1
注: panic-semihosting でこの feature を有効にするには、
panic-semihosting を指定している Cargo.toml の依存関係セクションを次のように
編集してください:
panic-semihosting = { version = "VERSION", features = ["exit"] }
ここで VERSION は必要なバージョンです。依存関係の feature について詳しくは、
Cargo book の specifying dependencies セクションを参照してください。
パニック
パニックは Rust 言語の中核的な要素です。インデックスアクセスのような組み込み 演算は、メモリ安全性のために実行時チェックされます。範囲外のインデックスアクセスを試みると、 パニックが発生します。
標準ライブラリでは、パニックには定義された振る舞いがあります。パニックした スレッドのスタックをアンワインドします。ただし、ユーザーがパニック時にプログラムを アボートすることを選択している場合は除きます。
しかし、標準ライブラリのないプログラムでは、パニック時の振る舞いは未定義のままです。
#[panic_handler] 関数を宣言することで、振る舞いを選択できます。
この関数は、プログラムの依存グラフ内に必ず 一度だけ 現れなければならず、
シグネチャは次のとおりでなければなりません: fn(&PanicInfo) -> !。ここで PanicInfo
は、パニックが発生した場所に関する情報を含む構造体です。
組み込みシステムは、ユーザー向けのものから安全性が重要なもの(クラッシュできない)
まで幅広いため、すべてに当てはまる単一のパニック時の振る舞いはありませんが、
一般的によく使われる振る舞いは数多くあります。これらの一般的な振る舞いは、
#[panic_handler] 関数を定義するクレートとしてパッケージ化されています。例として、
次のようなものがあります:
panic-abort. パニックが発生すると、アボート命令が実行されます。panic-halt. パニックが発生すると、プログラム、または現在のスレッドは、 無限ループに入ることで停止します。panic-itm. パニックメッセージは、ARM Cortex-M 固有の周辺機能である ITM を 使ってログに記録されます。panic-semihosting. パニックメッセージは、セミホスティングを使ってホストに ログ出力されます。
crates.io で panic-handler キーワードを検索すると、さらに多くのクレートが
見つかるかもしれません。
プログラムは、対応するクレートをリンクするだけで、これらの振る舞いのうち 1 つを選択できます。パニック時の振る舞いがアプリケーションのソース中で 1 行のコードとして表現されるという事実は、ドキュメントとして有用なだけでなく、 コンパイルプロファイルに応じてパニック時の振る舞いを変更するためにも利用できます。 たとえば、次のようになります:
#![no_main]
#![no_std]
// dev プロファイル: パニックをデバッグしやすい; `rust_begin_unwind` にブレークポイントを設定できる
#[cfg(debug_assertions)]
use panic_halt as _;
// release プロファイル: アプリケーションのバイナリサイズを最小限に抑える
#[cfg(not(debug_assertions))]
use panic_abort as _;
// ..
この例では、dev プロファイル(cargo build)でビルドした場合は
panic-halt クレートにリンクしますが、release プロファイル
(cargo build --release)でビルドした場合は panic-abort クレートにリンクします。
use文のuse panic_abort as _;という形式は、panic_abortのパニックハンドラが 最終的な実行可能ファイルに含まれるようにしつつ、そのクレートから何かを明示的に使うことはないと コンパイラに明確に伝えるために使われます。as _によるリネームがないと、 コンパイラは未使用のインポートがあると警告します。 代わりにextern crate panic_abortを見かけることもありますが、これは Rust の 2018 edition より前に使われていた古い書き方であり、現在ではproc_macro、alloc、std、testのような「sysroot」クレート(Rust 自体とともに配布されるもの)に対してのみ 使うべきです。
例
以下は、配列の長さを超えてインデックスアクセスしようとする例です。この操作は パニックを引き起こします。
#![no_main]
#![no_std]
use panic_semihosting as _;
use cortex_m_rt::entry;
#[entry]
fn main() -> ! {
let xs = [0, 1, 2];
let i = xs.len();
let _y = xs[i]; // 範囲外アクセス
loop {}
}
この例では panic-semihosting の振る舞いを選択しており、これはパニック
メッセージをセミホスティングを使ってホストコンソールに表示します。
$ cargo run
Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb (..)
panicked at 'index out of bounds: the len is 3 but the index is 4', src/main.rs:12:13
振る舞いを panic-halt に変更して、その場合はメッセージが
表示されないことを確認してみてください。
例外
例外と割り込みは、プロセッサが非同期イベントや致命的なエラー (たとえば無効な命令の実行)を処理するためのハードウェア機構です。例外は プリエンプションを伴い、イベントを引き起こしたシグナルに応答して実行される サブルーチンである例外ハンドラが関与します。
cortex-m-rt クレートは、例外ハンドラを宣言するための exception 属性を
提供します。
// SysTick(システムタイマー)例外の例外ハンドラ
#[exception]
fn SysTick() {
// ..
}
exception 属性を除けば、例外ハンドラは普通の関数のように見えますが、
もう 1 つ違いがあります。exception ハンドラはソフトウェアから
呼び出せません。先ほどの例でいえば、SysTick(); という文は
コンパイルエラーになります。
この挙動は意図されたものであり、次の機能を提供するために必要です:
exception ハンドラの 内部 で宣言された static mut 変数は、使用しても
安全 です。
#[exception]
fn SysTick() {
static mut COUNT: u32 = 0;
// `COUNT` は型 `&mut u32` に変換されており、安全に使用できます
*COUNT += 1;
}
ご存じのとおり、関数内で static mut 変数を使うと、その関数は
非リエントラント になります。非リエントラントな関数を、
複数の例外 / 割り込みハンドラから直接または間接に、あるいは main と
1 つ以上の例外 / 割り込みハンドラから呼び出すことは未定義動作です。
Safe Rust は決して未定義動作を引き起こしてはならないため、非リエントラントな
関数には unsafe を付けなければなりません。ところが、先ほど私は
exception ハンドラは static mut 変数を安全に使えると述べました。
なぜこれが可能なのでしょうか。これは、exception ハンドラは
ソフトウェアから 呼び出せない ため、リエントランシーが起こりえないからです。
これらのハンドラはハードウェア自体によって呼び出され、ハードウェアは物理的に
同時実行されないものとみなされます。
その結果、組み込みシステムの例外ハンドラという文脈では、同じハンドラが同時に呼び出されないため、ハンドラが静的な可変変数を使用していてもリエントランシーの問題は生じません。
マルチコアシステムでは、複数のプロセッサコアが同時にコードを実行するため、
例外ハンドラ内であってもリエントランシーの問題が再び重要になります。各コアが独自の例外ハンドラ群を持つ場合でも、複数のコアが同じ例外ハンドラを同時に実行しようとする状況は起こりえます。
マルチコア環境でこの懸念に対処するには、共有リソースへのアクセスがコア間で
適切に調整されるよう、例外ハンドラ内で適切な同期機構を用いる必要があります。
これには通常、データ競合を防ぎ、データ整合性を維持するためのロック、
セマフォ、アトミック操作などの手法が含まれます
exception属性は、関数内の静的変数の定義をunsafeブロックで 包み、同じ名前の&mut型の適切な新しい変数を私たちに提供することで 変換することに注意してください。 そのため、参照を*でデリファレンスすることで、変数の値にアクセスする際に それらをunsafeブロックで囲む必要はありません。
完全な例
これは、システムタイマーを使って約 1 秒ごとに SysTick 例外を発生させる
例です。SysTick 例外ハンドラは、呼び出された回数を COUNT 変数で
追跡し、その後 COUNT の値をセミホスティングを使ってホストコンソールに
出力します。
注記: この例はどの Cortex-M デバイス上でも実行できます。また、 QEMU 上でも実行できます
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use panic_halt as _;
use core::fmt::Write;
use cortex_m::peripheral::syst::SystClkSource;
use cortex_m_rt::{entry, exception};
use cortex_m_semihosting::{
debug,
hio::{self, HostStream},
};
#[entry]
fn main() -> ! {
let p = cortex_m::Peripherals::take().unwrap();
let mut syst = p.SYST;
// システムタイマーを設定し、毎秒 SysTick 例外を発生させる
syst.set_clock_source(SystClkSource::Core);
// これは、デフォルトの CPU クロックが 12 MHz の LM3S6965 向けの設定です
syst.set_reload(12_000_000);
syst.clear_current();
syst.enable_counter();
syst.enable_interrupt();
loop {}
}
#[exception]
fn SysTick() {
static mut COUNT: u32 = 0;
static mut STDOUT: Option<HostStream> = None;
*COUNT += 1;
// 遅延初期化
if STDOUT.is_none() {
*STDOUT = hio::hstdout().ok();
}
if let Some(hstdout) = STDOUT.as_mut() {
write!(hstdout, "{}", *COUNT).ok();
}
// 重要: 実機で実行する場合は、この `if` ブロックを省略してください
// そうしないとデバッガーが不整合な状態になります
if *COUNT == 9 {
// これにより QEMU プロセスは終了します
debug::exit(debug::EXIT_SUCCESS);
}
}
tail -n5 Cargo.toml
[dependencies]
cortex-m = "0.5.7"
cortex-m-rt = "0.6.3"
panic-halt = "0.2.0"
cortex-m-semihosting = "0.3.1"
$ cargo run --release
Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb (..)
123456789
これを Discovery ボードで実行すると、OpenOCD コンソールに出力が表示されます。 また、カウントが 9 に達してもプログラムは 停止しません。
デフォルトの例外ハンドラ
exception 属性が実際に行うのは、特定の例外に対するデフォルトの例外
ハンドラを オーバーライドする ことです。特定の例外のハンドラを
オーバーライドしない場合、その例外は DefaultHandler 関数によって処理され、
そのデフォルト実装は次のとおりです:
fn DefaultHandler() {
loop {}
}
この関数は cortex-m-rt クレートによって提供されており、
#[no_mangle] が付いているため、“DefaultHandler” にブレークポイントを置いて
未処理の 例外を捕捉できます。
exception 属性を使って、この DefaultHandler をオーバーライドすることも
できます:
#[exception]
fn DefaultHandler(irqn: i16) {
// カスタムデフォルトハンドラ
}
irqn 引数は、現在どの例外が処理されているかを示します。負の値は
Cortex-M の例外が処理されていることを示し、0 または正の値は
デバイス固有の例外、すなわち割り込みが処理されていることを示します。
ハードフォルトハンドラ
HardFault 例外は少し特別です。この例外はプログラムが無効な状態に
入ったときに発生するため、そのハンドラは 復帰できません。復帰すると
未定義動作になる可能性があるからです。また、ランタイムクレートは、
ユーザー定義の HardFault ハンドラが呼び出される前に、デバッグしやすさを
向上させるための少しの処理を行います。
その結果、HardFault ハンドラは次のシグネチャでなければなりません:
fn(&ExceptionFrame) -> !。ハンドラの引数は、例外によってスタックに
プッシュされたレジスタへのポインタです。これらのレジスタは、例外が発生した
瞬間のプロセッサ状態のスナップショットであり、ハードフォルトの診断に
役立ちます。
これは、不正な操作、つまり存在しないメモリアドレスの読み取りを行う 例です。
注意: このプログラムは QEMU では動作しません。つまりクラッシュしません。これは、
qemu-system-arm -machine lm3s6965evbがメモリロードをチェックせず、 無効なメモリの読み取りに対して平然と0を返すためです。
#![no_main]
#![no_std]
use panic_halt as _;
use core::fmt::Write;
use core::ptr;
use cortex_m_rt::{entry, exception, ExceptionFrame};
use cortex_m_semihosting::hio;
#[entry]
fn main() -> ! {
// 存在しないメモリアドレスを読み取る
unsafe {
ptr::read_volatile(0x3FFF_0000 as *const u32);
}
loop {}
}
#[exception]
fn HardFault(ef: &ExceptionFrame) -> ! {
if let Ok(mut hstdout) = hio::hstdout() {
writeln!(hstdout, "{:#?}", ef).ok();
}
loop {}
}
HardFault ハンドラは ExceptionFrame の値を出力します。これを実行すると、
OpenOCD コンソールに次のようなものが表示されます。
$ openocd
(..)
ExceptionFrame {
r0: 0x3fff0000,
r1: 0x00000003,
r2: 0x080032e8,
r3: 0x00000000,
r12: 0x00000000,
lr: 0x080016df,
pc: 0x080016e2,
xpsr: 0x61000000,
}
pc の値は、例外発生時のプログラムカウンタの値であり、
例外を引き起こした命令を指しています。
プログラムの逆アセンブリを見ると、
$ cargo objdump --bin app --release -- -d --no-show-raw-insn --print-imm-hex
(..)
ResetTrampoline:
8000942: movw r0, #0xfffe
8000946: movt r0, #0x3fff
800094a: ldr r0, [r0]
800094c: b #-0x4 <ResetTrampoline+0xa>
逆アセンブリの中でプログラムカウンタの値 0x0800094a を調べることができます。
そうすると、ロード命令 (ldr r0, [r0] ) が例外を引き起こしたことがわかります。
ExceptionFrame の r0 フィールドを見ると、その時点でレジスタ r0
の値が 0x3fff_fffe だったことがわかります。
割り込み
割り込みはさまざまな点で例外とは異なりますが、その動作と使用方法はおおむね似ており、同じ割り込みコントローラによって処理されます。例外が Cortex-M アーキテクチャによって定義されるのに対し、割り込みは名称と機能の両面で、常にベンダー(多くの場合はチップでさえも)固有の実装です。
割り込みは大きな柔軟性を持つため、高度な方法で使用しようとする場合にはその点を考慮する必要があります。本書ではそうした使い方は扱いませんが、次の点を覚えておくとよいでしょう。
- 割り込みにはプログラム可能な優先度があり、それによって各ハンドラの実行順序が決まります
- 割り込みはネストおよびプリエンプトできるため、ある割り込みハンドラの実行中に、より高い優先度の別の割り込みによって中断されることがあります
- 一般に、割り込みを発生させた原因はクリアする必要があり、そうしないと割り込みハンドラに際限なく再突入してしまいます
実行時における一般的な初期化手順は、常に次のとおりです。
- 望んだタイミングで割り込み要求を生成するようにペリフェラルを設定する
- 割り込みコントローラで割り込みハンドラの望ましい優先度を設定する
- 割り込みコントローラで割り込みハンドラを有効にする
例外と同様に、cortex-m-rt クレートは割り込みハンドラを宣言するための interrupt 属性を公開しています。ただし、この属性は device フィーチャが有効な場合にのみ利用できます。とはいえ、この属性を直接使用することは意図されておらず、そのようにするとコンパイルエラーになります。
代わりに、device クレート(通常は svd2rust を使って生成されます)が提供する interrupt 属性の再エクスポート版を使用するべきです。これにより、コンパイラはその割り込みが対象デバイス上に実際に存在することを検証できます。利用可能な割り込みの一覧と、それらが割り込みベクタテーブル内で占める位置は、通常、svd2rust によって SVD ファイルから自動生成されます。
use lm3s6965::interrupt; // device クレートから再エクスポートされた属性
// Timer2 割り込みのハンドラ
#[interrupt]
fn TIMER2A() {
// ..
// 生成された割り込み要求の原因をクリアする
}
割り込みハンドラは、例外ハンドラと同様、見た目は通常の関数に似ています(引数がない点を除いて)。ただし、特別な呼び出し規約があるため、ファームウェアのほかの部分から直接呼び出すことはできません。ただし、ソフトウェアで割り込み要求を生成し、制御を割り込みハンドラへ移すことは可能です。
例外ハンドラと同様に、割り込みハンドラの内部で static mut 変数を宣言して、安全に 状態を保持することもできます。
#[interrupt]
fn TIMER2A() {
static mut COUNT: u32 = 0;
// `COUNT` は `&mut u32` 型であり、安全に使用できる
*COUNT += 1;
}
ここで示した仕組みについてより詳しい説明は、exceptions section を参照してください。
ペリフェラル
ペリフェラルとは何か?
ほとんどのマイクロコントローラーは、CPU、RAM、フラッシュメモリだけで構成されているわけではありません。マイクロコントローラーの外部にあるシステムとやり取りしたり、センサー、モーターコントローラー、ディスプレイやキーボードのようなヒューマンインターフェイスを介して、外界と直接的または間接的にやり取りしたりするためのシリコン領域も含まれています。これらのコンポーネントは総称してペリフェラルと呼ばれます。
これらのペリフェラルが有用なのは、開発者が処理の一部をそれらにオフロードできるためであり、すべてをソフトウェアで処理する必要がなくなるからです。デスクトップ開発者がグラフィックス処理をビデオカードにオフロードするのと同じように、組み込み開発者も一部のタスクをペリフェラルにオフロードできます。これにより、CPU は別の重要な処理に時間を使ったり、省電力のために何もしなかったりできるようになります。
1970 年代や 1980 年代の昔ながらの家庭用コンピューターのメイン回路基板を見ると(そして実際、ひと昔前のデスクトップ PC は今日の組み込みシステムとそれほどかけ離れてはいません)、次のようなものが載っているはずです。
- プロセッサ
- RAMチップ
- ROMチップ
- I/Oコントローラー
RAM チップ、ROM チップ、I/O コントローラー(このシステムにおけるペリフェラル)は、「バス」と呼ばれる一連の並列配線を介してプロセッサに接続されます。このバスは、プロセッサがバス上のどのデバイスと通信したいかを選択するためのアドレス情報と、実際のデータを運ぶデータバスを担います。組み込みマイクロコントローラーでも同じ原理が当てはまります。違うのは、すべてが 1 枚のシリコン上に詰め込まれているという点だけです。
しかし、通常は Vulkan、Metal、OpenGL のような Software API を持つグラフィックスカードとは異なり、ペリフェラルはハードウェアインターフェイスとしてマイクロコントローラーに公開されており、それがメモリの一部にマッピングされています。
線形メモリ空間と実メモリ空間
マイクロコントローラーでは、0x4000_0000 や 0x0000_0000 のような別の任意のアドレスに何らかのデータを書き込むことも、完全に正当な操作である場合があります。
デスクトップシステムでは、メモリへのアクセスは MMU、すなわち Memory Management Unit によって厳密に制御されています。このコンポーネントには大きく 2 つの役割があります。1 つはメモリの各領域に対するアクセス権限を強制すること(あるプロセスが別のプロセスのメモリを読み取ったり変更したりするのを防ぐこと)であり、もう 1 つは物理メモリのセグメントをソフトウェアで使用する仮想メモリ範囲に再マッピングすることです。マイクロコントローラーには通常 MMU はなく、代わりにソフトウェアでは実際の物理アドレスだけを使用します。
32 ビットのマイクロコントローラーは 0x0000_0000 から 0xFFFF_FFFF までの実際かつ線形なアドレス空間を持っていますが、一般にはその範囲のうち実際のメモリに使われるのは数百キロバイト程度にすぎません。その結果、かなりの量のアドレス空間が残ります。前の章では、RAM がアドレス 0x2000_0000 に配置されていると説明しました。RAM のサイズが 64 KiB(つまり最大アドレスが 0xFFFF)であれば、0x2000_0000 から 0x2000_FFFF までのアドレスがその RAM に対応します。アドレス 0x2000_1234 に存在する変数に書き込むと、内部では何らかのロジックがアドレスの上位部分(この例では 0x2000)を検出し、続いて RAM を有効化して、アドレスの下位部分(この場合は 0x1234)を処理できるようにします。Cortex-M では、たとえば 512 KiB の Flash ROM がある場合、0x0000_0000 から 0x0007_FFFF までのアドレスにも Flash ROM がマッピングされています。マイクロコントローラーの設計者は、これら 2 つの領域の間に残る空間をすべて無視するのではなく、代わりに特定のメモリ位置にペリフェラル用のインターフェイスをマッピングしました。その結果、概ね次のような構成になります。

Nordic nRF52832 Datasheet (pdf)
メモリマップドペリフェラル
一見すると、これらのペリフェラルとのやり取りは単純です。正しいアドレスに適切なデータを書き込むだけです。たとえば、シリアルポート経由で 32 ビットのワードを送信する場合、その 32 ビットのワードを特定のメモリアドレスに書き込むだけで済むことがあります。するとシリアルポートのペリフェラルが処理を引き継ぎ、データを自動的に送信します。
これらのペリフェラルの設定も、同様の仕組みで行います。ペリフェラルを設定するために関数を呼び出す代わりに、ハードウェア API として機能するメモリ領域が公開されています。SPI の周波数設定レジスタに 0x8000_0000 を書き込むと、SPI ポートは毎秒 8 メガビットでデータを送信します。同じアドレスに 0x0200_0000 を書き込むと、SPI ポートは毎秒 125 キロビットでデータを送信します。これらの設定レジスタは、おおよそ次のようになっています。

Nordic nRF52832 Datasheet (pdf)
このインターフェイスこそが、Assembly、C、Rust のいずれを使う場合でも、ハードウェアとやり取りする方法です。
最初の試み
レジスタ
「SysTick」ペリフェラルを見てみましょう。これは、すべての Cortex-M プロセッサコアに備わっているシンプルなタイマーです。通常、これらはチップメーカーのデータシートや テクニカルリファレンスマニュアル で調べることになりますが、この例はすべての ARM Cortex-M コアに共通なので、ARM reference manual を見てみましょう。4 つのレジスタがあることが分かります。
| オフセット | 名前 | 説明 | 幅 |
|---|---|---|---|
| 0x00 | SYST_CSR | 制御および状態レジスタ | 32 ビット |
| 0x04 | SYST_RVR | リロード値レジスタ | 32 ビット |
| 0x08 | SYST_CVR | 現在値レジスタ | 32 ビット |
| 0x0C | SYST_CALIB | キャリブレーション値レジスタ | 32 ビット |
C によるアプローチ
Rust では、レジスタの集まりを C とまったく同じ方法、つまり struct で表現できます。
#[repr(C)]
struct SysTick {
pub csr: u32,
pub rvr: u32,
pub cvr: u32,
pub calib: u32,
}
属性 #[repr(C)] は、この構造体を C コンパイラと同じように配置するよう Rust コンパイラに指示します。これは非常に重要です。C ではできませんが、Rust では構造体のフィールドが並べ替えられる可能性があるからです。もしこれらのフィールドがコンパイラによって黙って並べ替えられていたら、どれだけデバッグすることになるか想像できますよね! この属性を付ければ、上の表に対応する 4 つの 32 ビットフィールドが得られます。しかし当然ながら、この struct だけでは役に立ちません。変数が必要です。
let systick = 0xE000_E010 as *mut SysTick;
let time = unsafe { (*systick).cvr };
volatile アクセス
さて、上のアプローチにはいくつか問題があります。
- ペリフェラルにアクセスしたいたびに
unsafeを使わなければなりません。 - どのレジスタが読み取り専用で、どれが読み書き可能かを指定する方法がありません。
- プログラム中のどのコード片からでも、この構造体を通じてハードウェアにアクセスできてしまいます。
- そして最も重要なのは、実際には動作しないということです…
問題は、コンパイラが賢いことです。同じ RAM 領域に対して続けて 2 回書き込みを行うと、コンパイラはそれに気付き、最初の書き込みを完全に省いてしまうことがあります。C では、すべての読み書きが意図どおりに行われるよう、変数を volatile としてマークできます。Rust では代わりに、変数ではなく アクセス を volatile として扱います。
let systick = unsafe { &mut *(0xE000_E010 as *mut SysTick) };
let time = unsafe { core::ptr::read_volatile(&mut systick.cvr) };
これで 4 つの問題のうち 1 つは解決しましたが、今度は unsafe コードがさらに増えてしまいました! 幸い、これを助けてくれるサードパーティのクレートがあります。volatile_register です。
use volatile_register::{RW, RO};
#[repr(C)]
struct SysTick {
pub csr: RW<u32>,
pub rvr: RW<u32>,
pub cvr: RW<u32>,
pub calib: RO<u32>,
}
fn get_systick() -> &'static mut SysTick {
unsafe { &mut *(0xE000_E010 as *mut SysTick) }
}
fn get_time() -> u32 {
let systick = get_systick();
systick.cvr.read()
}
これで、volatile アクセスは read メソッドと write メソッドを通じて自動的に行われます。書き込みを行うのは依然として unsafe ですが、公平に言えば、ハードウェアは可変状態の塊であり、こうした書き込みが実際に安全かどうかをコンパイラが知る術はないので、これは妥当なデフォルトです。
Rust らしいラッパー
この struct を、ユーザーが安全に呼び出せる、より高レベルな API に包み込む必要があります。ドライバ作者として、私たちは unsafe コードが正しいことを手作業で検証し、そのうえでユーザーに安全な API を提供します。そうすれば、ユーザーはそのことを気にしなくて済みます(もちろん、私たちが正しく実装していると信頼してもらえるならですが!)。
たとえば、次のようになります。
use volatile_register::{RW, RO};
pub struct SystemTimer {
p: &'static mut RegisterBlock
}
#[repr(C)]
struct RegisterBlock {
pub csr: RW<u32>,
pub rvr: RW<u32>,
pub cvr: RW<u32>,
pub calib: RO<u32>,
}
impl SystemTimer {
pub fn new() -> SystemTimer {
SystemTimer {
p: unsafe { &mut *(0xE000_E010 as *mut RegisterBlock) }
}
}
pub fn get_time(&self) -> u32 {
self.p.cvr.read()
}
pub fn set_reload(&mut self, reload_value: u32) {
unsafe { self.p.rvr.write(reload_value) }
}
}
pub fn example_usage() -> String {
let mut st = SystemTimer::new();
st.set_reload(0x00FF_FFFF);
format!("Time is now 0x{:08x}", st.get_time())
}
ただし、このアプローチの問題は、次のコードもコンパイラにとってはまったく問題なく受け入れられてしまうことです。
fn thread1() {
let mut st = SystemTimer::new();
st.set_reload(2000);
}
fn thread2() {
let mut st = SystemTimer::new();
st.set_reload(1000);
}
set_reload 関数の &mut self 引数は、その特定の SystemTimer 構造体に対してほかの参照が存在しないことは保証しますが、ユーザーがまったく同じペリフェラルを指す 2 つ目の SystemTimer を作成することまでは防げません! この書き方のコードでも、作者がこうした「重複した」ドライバインスタンスをすべて見つけ出せるほど注意深ければ動作します。しかし、コードが複数のモジュール、ドライバ、開発者、そして日数にまたがって広がると、この種のミスはますます起こしやすくなります。
借用チェッカー
可変なグローバル状態
残念ながら、ハードウェアは基本的に可変なグローバル状態そのものであり、Rust 開発者にとっては非常に恐ろしく感じられるかもしれません。ハードウェアは、私たちが書くコードの構造とは独立して存在し、現実世界によっていつでも変更される可能性があります。
私たちのルールはどうあるべきでしょうか?
これらのペリフェラルと、どうすれば信頼性高くやり取りできるのでしょうか?
- ペリフェラルのメモリはいつでも変化しうるため、読み書きには常に
volatileメソッドを使用する - ソフトウェアでは、これらのペリフェラルに対する読み取り専用アクセスは、いくつでも共有できるべきである
- あるソフトウェアがペリフェラルへの読み書きアクセスを持つべき場合、そのソフトウェアがそのペリフェラルへの唯一の参照を保持するべきである
借用チェッカー
この最後の 2 つのルールは、すでに借用チェッカーが行っていることと、どこか不気味なほど似ています!
これらのペリフェラルの所有権を受け渡したり、それらへの不変参照や可変参照を提供したりできるとしたらどうでしょうか?
実際、それは可能です。しかし借用チェッカーのためには、Rust がこれを正しく扱えるよう、各ペリフェラルについてインスタンスがちょうど 1 つだけ存在している必要があります。幸い、ハードウェア上では任意のペリフェラルのインスタンスは 1 つしかありません。では、そのことをコードの構造の中でどのように表現すればよいのでしょうか?
シングルトン
ソフトウェアエンジニアリングにおいて、singleton パターンは、クラスのインスタンス化を 1 つのオブジェクトに制限するソフトウェア設計パターンです。
Wikipedia: Singleton Pattern
しかし、なぜ単にグローバル変数を使えないのでしょうか?
次のように、すべてを public static にすることもできます
static mut THE_SERIAL_PORT: SerialPort = SerialPort;
fn main() {
let _ = unsafe {
THE_SERIAL_PORT.read_speed();
};
}
しかし、これにはいくつか問題があります。これは可変なグローバル変数であり、Rust では、これらを扱うことは常に unsafe です。さらに、これらの変数はプログラム全体から見えるため、borrow checker はこれらの変数への参照や所有権の追跡を支援できません。
Rust ではどのように行うのでしょうか?
ペリフェラルを単なるグローバル変数にする代わりに、PERIPHERALS という構造体を作成し、その中に各ペリフェラル用の Option<T> を含めることを考えるかもしれません。
struct Peripherals {
serial: Option<SerialPort>,
}
impl Peripherals {
fn take_serial(&mut self) -> SerialPort {
let p = replace(&mut self.serial, None);
p.unwrap()
}
}
static mut PERIPHERALS: Peripherals = Peripherals {
serial: Some(SerialPort),
};
この構造体により、ペリフェラルの単一インスタンスを取得できます。take_serial() を複数回呼び出そうとすると、コードは panic します!
fn main() {
let serial_1 = unsafe { PERIPHERALS.take_serial() };
// これは panic します!
// let serial_2 = unsafe { PERIPHERALS.take_serial() };
}
この構造体とのやり取りは unsafe ですが、いったんその中に含まれていた SerialPort を取得してしまえば、もはや unsafe も PERIPHERALS 構造体自体も使う必要はありません。
これには小さな実行時オーバーヘッドがあります。というのも、SerialPort 構造体を option でラップし、さらに一度 take_serial() を呼び出す必要があるからです。しかし、この小さな初期コストによって、プログラムの残り全体で borrow checker を活用できるようになります。
既存ライブラリのサポート
上では独自の Peripherals 構造体を作成しましたが、あなたのコードでこれを行う必要はありません。cortex_m クレートには singleton!() というマクロがあり、これを代わりに実行してくれます。
use cortex_m::singleton;
fn main() {
// `main` が一度だけ実行される場合は OK
let x: &'static mut bool =
singleton!(: bool = false).unwrap();
}
さらに、cortex-m-rtic を使用している場合、これらのペリフェラルを定義して取得する一連の処理全体は抽象化されており、代わりに、定義したすべての項目について非 Option<T> 版を含む Peripherals 構造体が渡されます。
// cortex-m-rtic v0.5.x
#[rtic::app(device = lm3s6965, peripherals = true)]
const APP: () = {
#[init]
fn init(cx: init::Context) {
static mut X: u32 = 0;
// Cortex-M ペリフェラル
let core: cortex_m::Peripherals = cx.core;
// デバイス固有のペリフェラル
let device: lm3s6965::Peripherals = cx.device;
}
}
しかし、なぜでしょうか?
では、これらのシングルトンは、Rust コードの動作にどのような明確な違いをもたらすのでしょうか?
impl SerialPort {
const SER_PORT_SPEED_REG: *mut u32 = 0x4000_1000 as _;
fn read_speed(
&self // <------ これは本当に、本当に重要です
) -> u32 {
unsafe {
ptr::read_volatile(Self::SER_PORT_SPEED_REG)
}
}
}
ここでは 2 つの重要な要素が関係しています。
- シングルトンを使用しているため、
SerialPort構造体を取得する方法や場所は 1 つしかありません read_speed()メソッドを呼び出すには、SerialPort構造体の所有権または参照を持っていなければなりません
これら 2 つの要素を組み合わせると、borrow checker の要件を適切に満たしている場合にのみハードウェアへアクセスできることになり、つまり、同じハードウェアに対する複数の可変参照が同時に存在することはありません!
fn main() {
// `self` への参照がありません!これは動作しません。
// SerialPort::read_speed();
let serial_1 = unsafe { PERIPHERALS.take_serial() };
// アクセスできるものだけを読み取れます
let _ = serial_1.read_speed();
}
ハードウェアをデータのように扱う
さらに、参照には可変なものと不変なものがあるため、ある関数やメソッドがハードウェアの状態を変更し得るかどうかを見分けられるようになります。たとえば、
これはハードウェア設定の変更が許可されています。
fn setup_spi_port(
spi: &mut SpiPort,
cs_pin: &mut GpioPin
) -> Result<()> {
// ...
}
これは許可されていません。
fn read_button(gpio: &GpioPin) -> bool {
// ...
}
これにより、コードがハードウェアに変更を加えるべきかどうかを、実行時ではなく コンパイル時 に強制できます。補足すると、これは通常 1 つのアプリケーション内でのみ機能しますが、ベアメタルシステムではソフトウェアは単一のアプリケーションにコンパイルされるため、通常これは制約にはなりません。
静的保証
Rust の型システムは、コンパイル時にデータ競合を防ぎます(Send
および Sync トレイトを参照)。型システムは、コンパイル時にそのほかの性質を
検査するためにも利用でき、その結果、場合によっては実行時チェックの必要性を
減らせます。
これらの 静的チェック を組み込みプログラムに適用すると、たとえば I/O インターフェイスの設定が適切に行われることを強制できます。たとえば、 まずそのインターフェイスで使用するピンを設定しなければ、シリアル インターフェイスを初期化できないような API を設計できます。
また、ピンを low に設定するといった操作が、正しく設定された ペリフェラルに対してのみ実行できることも、静的に検査できます。たとえば、 フローティング入力モードに設定されたピンの出力状態を変更しようとすると、 コンパイルエラーになります。
また、前章で見たように、所有権の概念をペリフェラルに適用することで、 プログラムの特定の部分だけがペリフェラルを変更できるようにできます。 この アクセス制御 により、ペリフェラルをグローバルな可変状態として扱う 代替案と比べて、ソフトウェアについて推論しやすくなります。
Typestateプログラミング
typestates の概念では、オブジェクトの現在の状態に関する情報を、そのオブジェクトの型にエンコードします。これは少し難解に聞こえるかもしれませんが、Rust で Builder Pattern を使ったことがあるなら、すでに Typestateプログラミングを使い始めています!
pub mod foo_module {
#[derive(Debug)]
pub struct Foo {
inner: u32,
}
pub struct FooBuilder {
a: u32,
b: u32,
}
impl FooBuilder {
pub fn new(starter: u32) -> Self {
Self {
a: starter,
b: starter,
}
}
pub fn double_a(self) -> Self {
Self {
a: self.a * 2,
b: self.b,
}
}
pub fn into_foo(self) -> Foo {
Foo {
inner: self.a + self.b,
}
}
}
}
fn main() {
let x = foo_module::FooBuilder::new(10)
.double_a()
.into_foo();
println!("{:#?}", x);
}
この例では、Foo オブジェクトを直接作成する方法はありません。必要な Foo オブジェクトを得るには、まず FooBuilder を作成し、それを適切に初期化する必要があります。
この最小限の例では、2 つの状態がエンコードされています。
FooBuilderは、「未設定」または「設定中」の状態を表しますFooは、「設定済み」または「使用可能」な状態を表します
強い型
Rust には Strong Type System があるため、Foo のインスタンスを魔法のように簡単に作成したり、into_foo() メソッドを呼ばずに FooBuilder を Foo に変えたりする簡単な方法はありません。さらに、into_foo() メソッドを呼び出すと元の FooBuilder 構造体は消費されるため、新しいインスタンスを作成しない限り再利用できません。
これにより、システムの状態を型として表現し、ある型を別の型に交換するメソッドに、状態遷移に必要な操作を組み込めます。FooBuilder を作成し、それを Foo オブジェクトに交換することで、基本的な状態機械のステップをたどったことになります。
状態機械としてのペリフェラル
マイクロコントローラのペリフェラルは、一連の状態機械の集合と考えることができます。たとえば、単純化した GPIOピン の構成は、次のような状態の木として表現できます:
- 無効
- 有効
- 出力として設定
- 出力: High
- 出力: Low
- 入力として設定
- 入力: ハイインピーダンス
- 入力: プルダウン
- 入力: プルアップ
- 出力として設定
ペリフェラルが Disabled モードから開始する場合、Input: High Resistance モードへ移るには、次の手順を実行しなければなりません:
- 無効
- 有効
- 入力として設定
- 入力: ハイインピーダンス
Input: High Resistance から Input: Pulled Low に移りたい場合、次の手順を実行しなければなりません:
- 入力: ハイインピーダンス
- 入力: プルダウン
同様に、GPIOピンを Input: Pulled Low として構成された状態から Output: High へ移行したい場合、次の手順を実行しなければなりません:
- 入力: プルダウン
- 入力として設定
- 出力として設定
- 出力: High
ハードウェア表現
通常、上に挙げた状態は、GPIOペリフェラルにマップされた所定のレジスタに値を書き込むことで設定されます。これを説明するために、架空の GPIO 構成レジスタを定義してみましょう:
| 名前 | ビット番号 | 値 | 意味 | 注記 |
|---|---|---|---|---|
| enable | 0 | 0 | 無効 | GPIO を無効にする |
| 1 | 有効 | GPIO を有効にする | ||
| direction | 1 | 0 | 入力 | 方向を入力に設定する |
| 1 | 出力 | 方向を出力に設定する | ||
| input_mode | 2..3 | 00 | hi-z | 入力をハイインピーダンスに設定する |
| 01 | pull-low | 入力ピンは Low にプルされる | ||
| 10 | pull-high | 入力ピンは High にプルされる | ||
| 11 | 該当なし | 無効な状態。設定しないこと | ||
| output_mode | 4 | 0 | Low に設定 | 出力ピンを Low に駆動する |
| 1 | High に設定 | 出力ピンを High に駆動する | ||
| input_status | 5 | x | 入力値 | 入力が < 1.5v の場合は 0、入力が >= 1.5v の場合は 1 |
この GPIO を制御するために、Rust では次のような構造体を公開すること もできます:
/// GPIO インターフェース
struct GpioConfig {
/// svd2rust によって生成された GPIO 構成構造体
periph: GPIO_CONFIG,
}
impl GpioConfig {
pub fn set_enable(&mut self, is_enabled: bool) {
self.periph.modify(|_r, w| {
w.enable().set_bit(is_enabled)
});
}
pub fn set_direction(&mut self, is_output: bool) {
self.periph.modify(|_r, w| {
w.direction().set_bit(is_output)
});
}
pub fn set_input_mode(&mut self, variant: InputMode) {
self.periph.modify(|_r, w| {
w.input_mode().variant(variant)
});
}
pub fn set_output_mode(&mut self, is_high: bool) {
self.periph.modify(|_r, w| {
w.output_mode.set_bit(is_high)
});
}
pub fn get_input_status(&self) -> bool {
self.periph.read().input_status().bit_is_set()
}
}
しかし、これでは意味をなさない特定のレジスタを変更できてしまいます。たとえば、GPIO が入力として構成されているときに output_mode フィールドを設定すると、どうなるでしょうか?
一般に、この構造体を使うと、上の状態機械で定義されていない状態、たとえばプルダウンされた出力や High に設定された入力のような状態に到達できてしまいます。ハードウェアによっては、これは問題にならないかもしれません。別のハードウェアでは、予期しない動作や未定義動作を引き起こす可能性があります!
このインターフェースは記述しやすいものの、ハードウェア実装で定められた設計上の契約を強制しません。
設計契約
前の章では、設計契約を強制しないインターフェイスを書きました。では、架空の GPIO 設定レジスタをもう一度見てみましょう。
| 名前 | ビット番号 | 値 | 意味 | 備考 |
|---|---|---|---|---|
| enable | 0 | 0 | 無効 | GPIO を無効にする |
| 1 | 有効 | GPIO を有効にする | ||
| direction | 1 | 0 | 入力 | 方向を入力に設定する |
| 1 | 出力 | 方向を出力に設定する | ||
| input_mode | 2..3 | 00 | ハイインピーダンス | 入力を高抵抗に設定する |
| 01 | プルロー | 入力ピンはローにプルされる | ||
| 10 | プルハイ | 入力ピンはハイにプルされる | ||
| 11 | 該当なし | 無効な状態。設定しないこと | ||
| output_mode | 4 | 0 | ローに設定 | 出力ピンはローで駆動される |
| 1 | ハイに設定 | 出力ピンはハイで駆動される | ||
| input_status | 5 | x | 入力値 | 入力が < 1.5v の場合は 0、入力が >= 1.5v の場合は 1 |
代わりに、基盤となるハードウェアを利用する前に状態をチェックし、実行時に設計契約を強制するとしたら、次のようなコードを書くことになるでしょう。
/// GPIO インターフェイス
struct GpioConfig {
/// svd2rust によって生成された GPIO 設定構造体
periph: GPIO_CONFIG,
}
impl GpioConfig {
pub fn set_enable(&mut self, is_enabled: bool) {
self.periph.modify(|_r, w| {
w.enable().set_bit(is_enabled)
});
}
pub fn set_direction(&mut self, is_output: bool) -> Result<(), ()> {
if self.periph.read().enable().bit_is_clear() {
// 方向を設定するには有効でなければならない
return Err(());
}
self.periph.modify(|r, w| {
w.direction().set_bit(is_output)
});
Ok(())
}
pub fn set_input_mode(&mut self, variant: InputMode) -> Result<(), ()> {
if self.periph.read().enable().bit_is_clear() {
// 入力モードを設定するには有効でなければならない
return Err(());
}
if self.periph.read().direction().bit_is_set() {
// 方向は入力でなければならない
return Err(());
}
self.periph.modify(|_r, w| {
w.input_mode().variant(variant)
});
Ok(())
}
pub fn set_output_status(&mut self, is_high: bool) -> Result<(), ()> {
if self.periph.read().enable().bit_is_clear() {
// 出力状態を設定するには有効でなければならない
return Err(());
}
if self.periph.read().direction().bit_is_clear() {
// 方向は出力でなければならない
return Err(());
}
self.periph.modify(|_r, w| {
w.output_mode.set_bit(is_high)
});
Ok(())
}
pub fn get_input_status(&self) -> Result<bool, ()> {
if self.periph.read().enable().bit_is_clear() {
// 状態を取得するには有効でなければならない
return Err(());
}
if self.periph.read().direction().bit_is_set() {
// 方向は入力でなければならない
return Err(());
}
Ok(self.periph.read().input_status().bit_is_set())
}
}
ハードウェア上の制約を強制する必要があるため、実行時のチェックを大量に行うことになり、時間とリソースを浪費します。また、このコードは開発者にとってもかなり使いにくいものになります。
型状態
では、代わりに Rust の型システムを使って状態遷移のルールを強制するとしたらどうでしょうか。次の例を見てください。
/// GPIO インターフェイス
struct GpioConfig<ENABLED, DIRECTION, MODE> {
/// svd2rust によって生成された GPIO 設定構造体
periph: GPIO_CONFIG,
enabled: ENABLED,
direction: DIRECTION,
mode: MODE,
}
// GpioConfig における MODE の型状態
struct Disabled;
struct Enabled;
struct Output;
struct Input;
struct PulledLow;
struct PulledHigh;
struct HighZ;
struct DontCare;
/// これらの関数は任意の GPIO ピンで使用できる
impl<EN, DIR, IN_MODE> GpioConfig<EN, DIR, IN_MODE> {
pub fn into_disabled(self) -> GpioConfig<Disabled, DontCare, DontCare> {
self.periph.modify(|_r, w| w.enable.disabled());
GpioConfig {
periph: self.periph,
enabled: Disabled,
direction: DontCare,
mode: DontCare,
}
}
pub fn into_enabled_input(self) -> GpioConfig<Enabled, Input, HighZ> {
self.periph.modify(|_r, w| {
w.enable.enabled()
.direction.input()
.input_mode.high_z()
});
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: HighZ,
}
}
pub fn into_enabled_output(self) -> GpioConfig<Enabled, Output, DontCare> {
self.periph.modify(|_r, w| {
w.enable.enabled()
.direction.output()
.input_mode.set_high()
});
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Output,
mode: DontCare,
}
}
}
/// この関数は出力ピンで使用できる
impl GpioConfig<Enabled, Output, DontCare> {
pub fn set_bit(&mut self, set_high: bool) {
self.periph.modify(|_r, w| w.output_mode.set_bit(set_high));
}
}
/// これらのメソッドは有効化された任意の入力 GPIO で使用できる
impl<IN_MODE> GpioConfig<Enabled, Input, IN_MODE> {
pub fn bit_is_set(&self) -> bool {
self.periph.read().input_status.bit_is_set()
}
pub fn into_input_high_z(self) -> GpioConfig<Enabled, Input, HighZ> {
self.periph.modify(|_r, w| w.input_mode().high_z());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: HighZ,
}
}
pub fn into_input_pull_down(self) -> GpioConfig<Enabled, Input, PulledLow> {
self.periph.modify(|_r, w| w.input_mode().pull_low());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: PulledLow,
}
}
pub fn into_input_pull_up(self) -> GpioConfig<Enabled, Input, PulledHigh> {
self.periph.modify(|_r, w| w.input_mode().pull_high());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: PulledHigh,
}
}
}
では、これを使うコードがどのようになるか見てみましょう。
/*
* 例 1: 未設定から High-Z 入力へ
*/
let pin: GpioConfig<Disabled, _, _> = get_gpio();
// これはできません。pin が有効化されていないためです!
// pin.into_input_pull_down();
// 次に、ピンを未設定状態から High-Z 入力へ切り替えます
let input_pin = pin.into_enabled_input();
// ピンから読み取ります
let pin_state = input_pin.bit_is_set();
// これはできません。入力ピンにはこのインターフェースがないためです!
// input_pin.set_bit(true);
/*
* 例 2: High-Z 入力からプルダウン入力へ
*/
let pulled_low = input_pin.into_input_pull_down();
let pin_state = pulled_low.bit_is_set();
/*
* 例 3: プルダウン入力から出力へ切り替え、High を設定
*/
let output_pin = pulled_low.into_enabled_output();
output_pin.set_bit(true);
// これはできません。出力ピンにはこのインターフェースがないためです!
// output_pin.into_input_pull_down();
これは確かにピンの状態を保持する便利な方法ですが、なぜこのやり方にするのでしょうか? なぜ GpioConfig 構造体の内部に状態を enum として保持するより優れているのでしょうか?
コンパイル時の機能的安全性
設計上の制約を完全にコンパイル時に強制しているため、実行時コストは発生しません。ピンが入力モードのときに出力モードを設定することは不可能です。代わりに、出力ピンへ変換して状態を順にたどり、その後で出力モードを設定しなければなりません。これにより、関数を実行する前に現在の状態を確認することによる実行時ペナルティはありません。
さらに、これらの状態は型システムによって強制されるため、このインターフェースの利用者が誤る余地はなくなります。不正な状態遷移を行おうとすると、コードはコンパイルされません!
ゼロコスト抽象化
型状態もまた、ゼロコスト抽象化の優れた例です。これは、特定の振る舞いをコンパイル時の実行や解析へ移せる能力を指します。これらの型状態は実際のデータを一切持たず、代わりにマーカーとして使用されます。データを持たないため、実行時のメモリ上には実際の表現を持ちません。
use core::mem::size_of;
let _ = size_of::<Enabled>(); // == 0
let _ = size_of::<Input>(); // == 0
let _ = size_of::<PulledHigh>(); // == 0
let _ = size_of::<GpioConfig<Enabled, Input, PulledHigh>>(); // == 0
ゼロサイズ型
struct Enabled;
このように定義された構造体は、実際のデータを含まないため、ゼロサイズ型と呼ばれます。これらの型はコンパイル時には「実在する」かのように振る舞い、コピーしたり、ムーブしたり、参照を取ったりできますが、オプティマイザによって完全に取り除かれます。
このコードスニペットでは:
pub fn into_input_high_z(self) -> GpioConfig<Enabled, Input, HighZ> {
self.periph.modify(|_r, w| w.input_mode().high_z());
GpioConfig {
periph: self.periph,
enabled: Enabled,
direction: Input,
mode: HighZ,
}
}
返される GpioConfig は実行時には決して存在しません。この関数の呼び出しは、一般に単一のアセンブリ命令、つまり定数のレジスタ値をレジスタ位置に格納する処理にまで落とし込まれます。これは、私たちが開発した型状態インターフェースがゼロコスト抽象化であることを意味します。GpioConfig の状態を追跡するために追加の CPU、RAM、コード領域を一切消費せず、直接的なレジスタアクセスと同じマシンコードに変換されます。
ネスト
一般に、これらの抽象化は好きなだけ深くネストできます。使用されるすべての構成要素がゼロサイズ型である限り、構造全体は実行時には存在しません。
複雑な構造や深くネストした構造では、可能な状態の組み合わせをすべて定義するのは面倒になることがあります。そのような場合には、すべての実装を生成するためにマクロを使用できます。
移植性
組み込み環境では、移植性は非常に重要なトピックです。すべてのベンダー、さらには同じメーカーの各ファミリであっても、提供されるペリフェラルや機能は異なり、同様に、それらのペリフェラルとやり取りする方法も異なります。
このような差異を均一化する一般的な方法として、Hardware Abstraction Layer、すなわち HAL と呼ばれるレイヤーを用いるものがあります。
ハードウェア抽象化とは、ソフトウェア内の一連のルーチンであり、プラットフォーム固有の詳細の一部をエミュレートすることで、プログラムにハードウェアリソースへの直接アクセスを提供するものです。
これにより、プログラマは多くの場合、ハードウェアに対する標準的なオペレーティングシステム(OS)呼び出しを提供することで、デバイス非依存で高性能なアプリケーションを記述できます。
Wikipedia: Hardware Abstraction Layer
組み込みシステムはこの点で少し特殊です。というのも、通常はオペレーティングシステムやユーザーがインストール可能なソフトウェアがあるのではなく、全体としてコンパイルされるファームウェアイメージがあり、さらに他にもさまざまな制約があるためです。そのため、Wikipedia で定義されている従来のアプローチは潜在的には機能するかもしれませんが、移植性を確保するための最も生産的なアプローチである可能性は高くありません。
では、Rust ではこれをどのように実現するのでしょうか? embedded-hal の出番です……
embedded-hal とは何でしょうか?
ひとことで言えば、これは HAL implementation、drivers、applications (or firmwares) の間における実装契約を定義する trait の集合です。これらの契約には、機能とメソッドの両方が含まれます(つまり、ある型に対して特定の trait が実装されていれば、その HAL implementation は特定の機能を提供します。また、ある trait を実装する型を構築できるのであれば、その trait で定義されたメソッドが利用可能であることが保証されます)。
典型的なレイヤー構成は次のようになります。
embedded-hal で定義されている trait の一部は次のとおりです。
- GPIO(入力ピンと出力ピン)
- シリアル通信
- I2C
- SPI
- タイマー/カウントダウン
- アナログ-デジタル変換
embedded-hal trait と、それらを実装・利用する crate が存在する主な理由は、複雑さを抑えることにあります。アプリケーションでは、ハードウェア内のペリフェラルの利用に加えて、アプリケーション自体、さらに追加のハードウェアコンポーネント向けのドライバまで実装しなければならない場合があることを考えると、再利用性が非常に限定的になることは容易に理解できるでしょう。これを数式で表すと、M をペリフェラル HAL 実装の数、N をドライバの数とした場合、各アプリケーションごとに車輪の再発明をすると、最終的に M*N の実装が必要になります。一方、embedded-hal trait が提供する API を利用すれば、実装の複雑さは M+N に近づきます。もちろん、明確に定義され、すぐに使える API によって試行錯誤が減るなど、追加の利点もあります。
embedded-hal の利用者
前述のとおり、HAL の主な利用者は 3 種類あります。
HAL implementation
HAL implementation は、ハードウェアと HAL trait の利用者との間のインターフェイスを提供します。典型的な実装は、次の 3 つの部分で構成されます。
- 1 つ以上のハードウェア固有の型
- そのような型を生成および初期化する関数。多くの場合、さまざまな設定オプション(速度、動作モード、使用するピンなど)を提供する
- その型に対する embedded-hal trait の 1 つ以上の
traitimpl
このような HAL implementation には、さまざまな形態があります。
- 低レベルのハードウェアアクセス経由。たとえばレジスタを介するもの
- オペレーティングシステム経由。たとえば Linux 上で
sysfsを使うもの - アダプタ経由。たとえばユニットテスト用の型のモック
- ハードウェアアダプタ用ドライバ経由。たとえば I2C マルチプレクサや GPIO エキスパンダ
Driver
Driver は、embedded-hal trait を実装するペリフェラルに接続された、内部または外部コンポーネント向けのカスタム機能群を実装します。このような driver の典型例としては、各種センサー(温度、磁力計、加速度計、光)、表示デバイス(LED アレイ、LCD ディスプレイ)、アクチュエータ(モーター、送信機)などがあります。
driver は、embedded-hal の特定の trait を実装する型のインスタンスで初期化される必要があり、これは trait bound によって保証されます。そして、対象デバイスとやり取りするための独自のメソッド群を持つ独自の型インスタンスを提供します。
Application
Application は、さまざまな部分を結び付け、目的とする機能が実現されるようにします。異なるシステム間で移植する際に最も適応の手間がかかるのはこの部分です。というのも、application は HAL implementation を通じて実際のハードウェアを正しく初期化する必要があり、しかもハードウェアごとに初期化方法が異なり、ときには大きく異なるからです。また、ユーザーの選択も大きな役割を果たします。コンポーネントは物理的に異なる端子に接続できる場合があり、ハードウェアバスは構成に合わせるために外部ハードウェアを必要とすることがあり、あるいは内部ペリフェラルの利用において異なるトレードオフが存在するためです(たとえば、能力の異なる複数のタイマーが利用可能であったり、あるペリフェラルが別のものと競合したりします)。
並行性
並行性は、プログラムの異なる部分が異なる時点で実行されたり、 順不同で実行されたりし得るときに発生します。組み込みの文脈では、 これには次のものが含まれます:
- 関連する割り込みが発生するたびに実行される、割り込みハンドラ
- マイクロプロセッサがプログラムの各部分を定期的に切り替えて実行する、 さまざまな形式のマルチスレッド
- また、一部のシステムでは、各コアが同時にプログラムの別々の部分を 独立して実行できる、マルチコアのマイクロプロセッサ
多くの組み込みプログラムは割り込みを扱う必要があるため、並行性の 問題にはたいてい遅かれ早かれ直面します。また、そこでは見つけにくく 厄介なバグも数多く発生し得ます。幸いなことに、Rust は正しいコードを 書く助けとなるさまざまな抽象化と安全性保証を提供しています。
並行性なし
組み込みプログラムで最も単純なのは、並行性がまったくない場合です。 ソフトウェアは実行し続ける単一のメインループから成り、割り込みは まったくありません。問題によっては、これが完全に適していることも あります! 通常、ループは何らかの入力を読み取り、処理を行い、 何らかの出力を書き出します。
#[entry]
fn main() {
let peripherals = setup_peripherals();
loop {
let inputs = read_inputs(&peripherals);
let outputs = process(inputs);
write_outputs(&peripherals, outputs);
}
}
並行性がないので、プログラムの各部分の間でのデータ共有や、 周辺機器へのアクセスの同期を心配する必要はありません。このような 単純なアプローチで済むのであれば、非常に優れた解決策になり得ます。
グローバルな可変データ
組み込み以外の Rust とは異なり、通常はヒープ割り当てを作成し、 新たに作成したスレッドへそのデータへの参照を渡せるという 贅沢はありません。代わりに、割り込みハンドラはいつ呼ばれるか わからず、使用している共有メモリにどうアクセスするかを 把握していなければなりません。最も低いレベルでは、これは 割り込みハンドラとメインコードの両方から参照できる、 静的に確保された 可変メモリが必要であることを意味します。
Rust では、このような static mut 変数の読み書きは常に unsafe
です。特別な注意を払わないと、同じ変数にアクセスする割り込みに
よってその変数へのアクセスが途中で中断されるような、
レースコンディションを引き起こす可能性があるためです。
この挙動がどのようにコードに微妙なエラーを引き起こし得るかの例と して、各 1 秒区間ごとに、ある入力信号の立ち上がりエッジを数える 組み込みプログラム(周波数カウンタ)を考えてみましょう:
static mut COUNTER: u32 = 0;
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// 危険 - 実際には安全ではありません!データ競合を引き起こす可能性があります。
unsafe { COUNTER += 1 };
}
last_state = state;
}
}
#[interrupt]
fn timer() {
unsafe { COUNTER = 0; }
}
毎秒、タイマー割り込みはカウンタを 0 に戻します。その一方で、
メインループは絶えず信号を測定し、low から high への変化を検出
するとカウンタをインクリメントします。COUNTER は static mut
なのでアクセスには unsafe を使わなければならず、これはつまり、
未定義動作を起こさないことをコンパイラに約束しているという
ことです。レースコンディションがわかるでしょうか? COUNTER の
インクリメントはアトミックであることが 保証されていません。実際、
ほとんどの組み込みプラットフォームでは、ロード、次に
インクリメント、最後にストアへと分割されます。ロードの後で
ストアの前に割り込みが発生すると、割り込みから復帰した後に
0 へのリセットは無視され、その期間の遷移を 2 倍数えてしまいます。
クリティカルセクション
では、データ競合に対して何ができるでしょうか? 単純なアプローチ
は、割り込みが無効化される文脈である クリティカルセクション を
使うことです。main での COUNTER へのアクセスをクリティカル
セクションで囲めば、COUNTER のインクリメントが終わるまで
タイマー割り込みが発生しないと確信できます:
static mut COUNTER: u32 = 0;
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// 新しいクリティカルセクションにより、COUNTER へのアクセスの同期が保証される
cortex_m::interrupt::free(|_| {
unsafe { COUNTER += 1 };
});
}
last_state = state;
}
}
#[interrupt]
fn timer() {
unsafe { COUNTER = 0; }
}
この例では cortex_m::interrupt::free を使っていますが、他の
プラットフォームにも、クリティカルセクション内でコードを実行する
ための同様の仕組みがあります。これは、割り込みを無効化して
コードを実行し、その後で再び割り込みを有効化するのと同じです。
なお、次の 2 つの理由により、タイマー割り込みの内部に クリティカルセクションを置く必要はありませんでした:
COUNTERに 0 を書き込む処理は、それを読み取らないのでレースの影響を受けません- いずれにせよ、それが
mainスレッドに割り込まれることはありません
もし COUNTER が、互いを プリエンプト し得る複数の割り込み
ハンドラで共有されているなら、それぞれにクリティカルセクションが
必要になる可能性があります。
これで当面の問題は解決しますが、依然として注意深く妥当性を検討 しなければならない unsafe なコードを多く書くことになりますし、 クリティカルセクションを不必要に使っているかもしれません。各 クリティカルセクションは一時的に割り込み処理を停止するため、 コードサイズの増加と、より大きな割り込みレイテンシおよび ジッタ(割り込みの処理により長くかかる可能性があり、処理される までの時間のばらつきも大きくなる)というコストが伴います。 これが問題になるかどうかはシステム次第ですが、一般には 避けたいところです。
注意すべき点として、クリティカルセクションは割り込みが発生しない ことは保証しますが、マルチコアシステムでの排他性までは保証し ません! 他方のコアは、割り込みがなくても、あなたのコアと同じ メモリにアクセスしている可能性があります。複数コアを使用する場合 は、より強力な同期プリミティブが必要になります。
アトミックアクセス
一部のプラットフォームでは、読み取り・変更・書き込み操作に対する
保証を提供する、特別なアトミック命令が利用できます。Cortex-M に
ついて具体的に言うと、thumbv6 (Cortex-M0, Cortex-M0+) が提供する
のはアトミックなロード命令とストア命令だけである一方、thumbv7
(Cortex-M3 and above) は完全な Compare and Swap (CAS) 命令を提供します。
これらの CAS 命令は、すべての割り込みを大がかりに無効化する方法に
代わる選択肢となります。インクリメントを試みると、ほとんどの場合
は成功しますが、途中で割り込まれた場合はインクリメント操作全体を
自動的に再試行します。これらのアトミック操作は、複数コア間でも
安全です。
```rust,ignore
use core::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// `fetch_add` を使って COUNTER に 1 をアトミックに加算する
COUNTER.fetch_add(1, Ordering::Relaxed);
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// `store` を使って 0 を直接 COUNTER に書き込む
COUNTER.store(0, Ordering::Relaxed)
}
今回は COUNTER は安全な static 変数です。AtomicUsize
型のおかげで、COUNTER は割り込みハンドラと
メインスレッドの両方から、割り込みを無効化することなく安全に変更できます。
可能であれば、こちらのほうがより良い解決策です — ただし、プラットフォームで
サポートされていない場合があります。
Ordering について補足すると、これはコンパイラやハードウェアが命令を
どのように並べ替えうるかに影響し、キャッシュの可視性にも影響します。
ターゲットが単一コアプラットフォームであると仮定すると、この特定のケースでは
Relaxed で十分であり、最も効率的な選択です。より厳格な順序付けを指定すると、
コンパイラはアトミック操作の前後にメモリバリアを挿入します。アトミックを
何のために使っているかによって、これが必要な場合もあれば不要な場合もあります!
アトミックモデルの正確な詳細は複雑であり、ここ以外の場所で説明するのが適切です。
アトミックと順序付けの詳細については、nomicon を参照してください。
抽象化、Send、そして Sync
上記の解決策はどれも、特に満足のいくものではありません。これらは
非常に注意深く確認しなければならない unsafe
ブロックを必要とし、扱いやすくもありません。Rust なら、きっともっと良い方法があります!
このカウンタを、安全なインターフェースとして抽象化し、コード内の他のどこからでも安全に使えるようにできます。この例では、クリティカルセクションを使った カウンタを用いますが、アトミックでも非常によく似たことができます。
use core::cell::UnsafeCell;
use cortex_m::interrupt;
// このカウンタは単なる UnsafeCell<u32> のラッパーであり、これは Rust における
// 内部可変性の中核です。内部可変性を使うことで、COUNTER を `static mut` ではなく
// `static` にできますが、それでも
// そのカウンタ値を変更できます。
struct CSCounter(UnsafeCell<u32>);
const CS_COUNTER_INIT: CSCounter = CSCounter(UnsafeCell::new(0));
impl CSCounter {
pub fn reset(&self, _cs: &interrupt::CriticalSection) {
// CriticalSection を受け取ることを要求することで、必ず
// CriticalSection 内で動作していると分かるため、
// この unsafe ブロック(UnsafeCell::get の呼び出しに必要)を安心して使えます。
unsafe { *self.0.get() = 0 };
}
pub fn increment(&self, _cs: &interrupt::CriticalSection) {
unsafe { *self.0.get() += 1 };
}
}
// static CSCounter を許可するために必要です。説明は以下を参照してください。
unsafe impl Sync for CSCounter {}
// COUNTER は内部可変性を使っているため、もはや `mut` ではありません。
// したがって、アクセス時にもはや unsafe ブロックは不要です。
static COUNTER: CSCounter = CS_COUNTER_INIT;
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
// ここに unsafe はありません!
interrupt::free(|cs| COUNTER.increment(cs));
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// ここでは、有効な cs トークンを得るためだけに、クリティカルセクションへ
// 入る必要があります。他のどの割り込みも
// これをプリエンプトできないと分かっているにもかかわらずです。
interrupt::free(|cs| COUNTER.reset(cs));
// 本当にそうしたければ、オーバーヘッドを避けるために unsafe コードで
// 偽の CriticalSection を生成することもできます:
// let cs = unsafe { interrupt::CriticalSection::new() };
}
unsafe コードを慎重に設計した抽象化の内部へ移し、
これでアプリケーションコードには unsafe ブロックが一切含まれなくなりました。
この設計では、アプリケーションが CriticalSection トークンを渡す必要があります。
これらのトークンは interrupt::free によってのみ安全に生成されるため、それを
引数として要求することで、実際に自分でロック処理を行わなくても、
クリティカルセクション内で動作していることを保証できます。この保証は
コンパイラによって静的に提供されるため、cs に伴う実行時オーバーヘッドはありません。
複数のカウンタがある場合でも、複数のネストしたクリティカルセクションを必要とせず、
すべてに同じ cs を渡せます。
これは、Rust の並行性における重要なトピック、すなわち
Send and Sync トレイトにもつながります。Rust book を要約すると、型が Send
であるとは別のスレッドへ安全に移動できることを意味し、一方で Sync であるとは
複数のスレッド間で安全に共有できることを意味します。組み込みの文脈では、
割り込みはアプリケーションコードとは別のスレッドで実行されていると考えるため、
割り込みとメインコードの両方からアクセスされる変数は
Sync でなければなりません。
Rust のほとんどの型では、これらのトレイトは両方ともコンパイラによって自動的に導出されます。
しかし、CSCounter は UnsafeCell を含んでいるため
Sync ではなく、その結果 static CSCounter にはできません。static
変数は複数のスレッドからアクセスされうるため、必ず Sync でなければなりません。
実際には CSCounter がスレッド間で安全に共有できるよう配慮していることを
コンパイラに伝えるため、Sync トレイトを明示的に実装します。先ほどの
クリティカルセクションの利用と同様に、これは単一コアプラットフォームでのみ安全です。
複数コアでは、安全性を確保するためにさらに踏み込んだ対策が必要になります。
ミューテックス
ここではカウンタの問題に特化した便利な抽象化を作成しましたが、 並行性で使われる一般的な抽象化は他にも数多くあります。
そのような 同期プリミティブ の 1 つがミューテックスで、mutual exclusion の略です。
この仕組みは、今回のカウンタのような変数への排他的アクセスを保証します。スレッドは
ミューテックスの ロック(または 取得)を試みることができ、その結果は
ただちに成功するか、ロックを取得できるまで待機してブロックするか、あるいは
ミューテックスをロックできなかったというエラーを返すかのいずれかです。その
スレッドがロックを保持している間は、保護されたデータへのアクセスが与えられます。
スレッドでの処理が終わると、ミューテックスを アンロック(または 解放)し、
別のスレッドがそれをロックできるようにします。Rust では、通常
Drop トレイトを使ってアンロックを実装し、ミューテックスがスコープを
外れるときに常に解放されるようにします。
ミューテックスを割り込みハンドラで使うのは難しい場合があります。通常、 割り込みハンドラがブロックすることは許容されませんし、メインスレッドが ロックを解放するのを待ってブロックするのは特に致命的です。そうなると デッドロック してしまうからです(割り込みハンドラ内に実行が留まり続けるため、 メインスレッドは決してロックを解放しません)。デッドロックは unsafe とは見なされません。safe Rust でも起こりえます。
この挙動を完全に避けるには、カウンターの例と同じように、ロックするためにクリティカルセクションを必要とする mutex を実装できます。クリティカルセクションがロックと同じ長さだけ継続しなければならない限り、mutex の lock/unlock 状態を追跡する必要すらなく、ラップされた変数への排他的アクセスがあることを確信できます。
実際、これは `cortex_m` crate が私たちのためにやってくれています! カウンターはこれを使って次のように書けました。
```rust,ignore
use core::cell::Cell;
use cortex_m::interrupt::Mutex;
static COUNTER: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));
#[entry]
fn main() -> ! {
set_timer_1hz();
let mut last_state = false;
loop {
let state = read_signal_level();
if state && !last_state {
interrupt::free(|cs|
COUNTER.borrow(cs).set(COUNTER.borrow(cs).get() + 1));
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// ここでも Mutex の要件を満たすためにクリティカルセクションに入る必要があります。
interrupt::free(|cs| COUNTER.borrow(cs).set(0));
}
現在は Cell を使っています。これは兄弟である RefCell とともに、安全な内部可変性を提供するために使われます。Rust における内部可変性の最下層である UnsafeCell はすでに見ました。これは、その値への複数の可変参照を取得できるようにしますが、それは unsafe code を使う場合に限られます。Cell は UnsafeCell に似ていますが、安全なインターフェースを提供します。現在の値のコピーを取得するか置き換えることだけを許可し、参照の取得は許可しません。また、Sync ではないため、スレッド間で共有することはできません。これらの制約により安全に使えますが、static は Sync でなければならないため、static 変数の中で直接使うことはできません。
では、なぜ上の例は動くのでしょうか? Mutex<T> は、Cell のように Send である任意の T に対して Sync を実装しています。これは、その内容へのアクセスをクリティカルセクション中にしか許可しないため、安全に実現できます。したがって、unsafe code をまったく使わずに安全なカウンターを得られるのです!
これは、カウンターの u32 のような単純な型には素晴らしい方法ですが、Copy ではないもっと複雑な型ではどうでしょうか? 組み込みの文脈で非常によくある例はペリフェラル構造体で、一般に Copy ではありません。
そのためには、RefCell を使います。
ペリフェラルの共有
svd2rust や同様の抽象化を使って生成されたデバイス crate は、ペリフェラル構造体のインスタンスが一度に 1 つしか存在できないことを強制することで、ペリフェラルへの安全なアクセスを提供します。これにより安全性は確保されますが、メインスレッドと割り込みハンドラの両方からペリフェラルへアクセスするのが難しくなります。
ペリフェラルへのアクセスを安全に共有するには、先ほど見た Mutex を使えます。さらに、RefCell も使う必要があります。これは、一度に 1 つの参照しかペリフェラルに対して渡されないことを実行時チェックで保証します。これは単純な Cell よりオーバーヘッドが大きいですが、コピーではなく参照を渡すため、一度に 1 つしか存在しないことを保証しなければなりません。
最後に、メインコードで初期化したあと、そのペリフェラルを共有変数に移動する方法も考慮しなければなりません。そのために、None で初期化し、あとでペリフェラルのインスタンスに設定する Option 型を使えます。
use core::cell::RefCell;
use cortex_m::interrupt::{self, Mutex};
use stm32f4::stm32f405;
static MY_GPIO: Mutex<RefCell<Option<stm32f405::GPIOA>>> =
Mutex::new(RefCell::new(None));
#[entry]
fn main() -> ! {
// ペリフェラルのシングルトンを取得し、それを設定します。
// この例は svd2rust で生成された crate のものですが、
// ほとんどの組み込みデバイス crate もこれと似ています。
let dp = stm32f405::Peripherals::take().unwrap();
let gpioa = &dp.GPIOA;
// 何らかの設定関数です。
// これが PA0 を入力に、PA1 を出力に設定すると仮定します。
configure_gpio(gpioa);
// GPIOA を mutex に保存し、ムーブします。
interrupt::free(|cs| MY_GPIO.borrow(cs).replace(Some(dp.GPIOA)));
// これ以降は `gpioa` や `dp.GPIOA` は使えなくなり、
// 代わりに mutex 経由でアクセスしなければなりません。
// 割り込みは MY_GPIO を設定したあとでのみ有効化するよう注意してください。
// そうしないと、まだ None を含んでいる間に割り込みが発生する可能性があり、
// このままのコードでは(`unwrap()` を使っているため)panic します。
set_timer_1hz();
let mut last_state = false;
loop {
// これ以降は mutex 経由で state をデジタル入力として読み取ります
let state = interrupt::free(|cs| {
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().idr.read().idr0().bit_is_set()
});
if state && !last_state {
// PA0 で立ち上がりエッジを検出したら PA1 を High にします。
interrupt::free(|cs| {
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().set_bit());
});
}
last_state = state;
}
}
#[interrupt]
fn timer() {
// 今回は割り込みの中で PA0 をクリアするだけにします。
interrupt::free(|cs| {
// 割り込みは MY_GPIO が設定されたあとにしか有効化していないので
// `unwrap()` を使えます。そうでなければ None になる可能性を
// 処理する必要があります。
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().clear_bit());
});
}
かなり多くの内容があるので、重要な行を分解して見ていきましょう。
static MY_GPIO: Mutex<RefCell<Option<stm32f405::GPIOA>>> =
Mutex::new(RefCell::new(None));
共有変数は、Option を含む RefCell を囲んだ Mutex になっています。Mutex は、クリティカルセクション中にしかアクセスできないことを保証するため、通常の RefCell は Sync ではないにもかかわらず、この変数を Sync にします。RefCell は参照を使った内部可変性を提供し、GPIOA を使うためにこれが必要になります。Option によって、この変数を空の値で初期化し、あとで実際の変数をムーブできます。ペリフェラルのシングルトンには静的にはアクセスできず、実行時にしかアクセスできないため、これが必要です。
interrupt::free(|cs| MY_GPIO.borrow(cs).replace(Some(dp.GPIOA)));
クリティカルセクションの中では、mutex に対して borrow() を呼び出せ、それによって RefCell への参照が得られます。続いて replace() を呼び出して、新しい値を RefCell にムーブします。
interrupt::free(|cs| {
let gpioa = MY_GPIO.borrow(cs).borrow();
gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().set_bit());
});
最後に、安全かつ並行的な方法で MY_GPIO を使います。クリティカルセクションは通常どおり割り込みの発生を防ぎ、mutex を借用できるようにします。次に RefCell は &Option<GPIOA> を与え、それがどれだけの間借用されたままであるかを追跡します。その参照がスコープを抜けると、RefCell はもはや借用されていないことを示すよう更新されます。
GPIOA を &Option からムーブすることはできないため、それを as_ref() によって &Option<&GPIOA> に変換する必要があります。最後にこれを unwrap() すると、ペリフェラルを変更できる &GPIOA が得られます。
共有リソースへの可変参照が必要な場合は、代わりに borrow_mut と deref_mut
を使用する必要があります。以下のコードは、TIM2 タイマーを使用した例を示しています。
use core::cell::RefCell;
use core::ops::DerefMut;
use cortex_m::interrupt::{self, Mutex};
use cortex_m::asm::wfi;
use stm32f4::stm32f405;
static G_TIM: Mutex<RefCell<Option<Timer<stm32::TIM2>>>> =
Mutex::new(RefCell::new(None));
#[entry]
fn main() -> ! {
let mut cp = cm::Peripherals::take().unwrap();
let dp = stm32f405::Peripherals::take().unwrap();
// 何らかのタイマー設定関数。
// これが TIM2 タイマーとその NVIC 割り込みを設定し、
// 最後にタイマーを開始すると仮定する。
let tim = configure_timer_interrupt(&mut cp, dp);
interrupt::free(|cs| {
G_TIM.borrow(cs).replace(Some(tim));
});
loop {
wfi();
}
}
#[interrupt]
fn timer() {
interrupt::free(|cs| {
if let Some(ref mut tim)) = G_TIM.borrow(cs).borrow_mut().deref_mut() {
tim.start(1.hz());
}
});
}
ふう!これは安全ですが、少し扱いにくくもあります。ほかにできることは あるでしょうか?
RTIC
代替案の 1 つは RTIC framework です。これは Real Time Interrupt-driven Concurrency の略です。これにより静的優先度が強制され、static mut 変数
(「resources」)へのアクセスが追跡されることで、常にクリティカルセクションに入ったり
(RefCell のように)参照カウントを使用したりするオーバーヘッドを必要とせずに、
共有リソースへのアクセスが常に安全であることを静的に保証します。これには、デッドロックが
起こらないことを保証できることや、時間およびメモリのオーバーヘッドが非常に低いことなど、
多くの利点があります。
RTIC には非同期エグゼキュータも含まれているため、ソフトウェアタスクは async 関数となり、
通常の同期 API に加えて async API を使用できます。
このフレームワークには、明示的な共有状態の必要性を減らすメッセージパッシングや、 指定した時刻に実行されるようタスクをスケジュールする機能など、ほかの機能も含まれています。 これらは周期的タスクの実装に使用できます。 詳細については the documentation を確認してください。
Embassy
Embassy は、Rust に含まれている async / await 構文を並行性に活用することに焦点を当てたライブラリ群のエコシステムです。embassy の中核は
非同期エグゼキュータ
であり、一般的な MCU アーキテクチャの大半をサポートしています。
embassy は全部入りのアプローチも採っており、たとえば次のような多くのコンポーネントも提供しています。
- Time library
- time library のサポートも提供するさまざまな HAL ライブラリ
- 同期プリミティブのための embassy-sync
詳細については、website と book を参照してください。
リアルタイムオペレーティングシステム
組み込みの並行性におけるもう 1 つの一般的なモデルは、リアルタイムオペレーティングシステム (RTOS)です。現在のところ Rust ではまだあまり深く探究されていませんが、 従来の組み込み開発では広く使われています。オープンソースの例には FreeRTOS と ChibiOS があります。これらの RTOS は複数のアプリケーションスレッドの実行をサポートし、 スレッドが制御を明け渡したとき(協調的マルチタスクと呼ばれます)、 または定期タイマーや割り込みに基づいて(プリエンプティブマルチタスク)、 CPU がそれらの間を切り替えます。RTOS は通常、ミューテックスやそのほかの同期 プリミティブを提供し、しばしば DMA エンジンのようなハードウェア機能とも連携します。
本書の執筆時点では、参照できる Rust の RTOS の例はあまり多くありませんが、 興味深い分野なので今後に注目してください!
複数コア
組み込みプロセッサで 2 つ以上のコアを持つことが一般的になりつつあり、
これにより並行性にはさらに複雑さが加わります。クリティカルセクションを使用する
すべての例(cortex_m::interrupt::Mutex を含む)は、唯一の別実行スレッドが
割り込みスレッドであることを前提としていますが、マルチコアシステムでは
もはやそうではありません。代わりに、複数コア向けに設計された同期プリミティブ
(対称型マルチプロセッシングを表す SMP とも呼ばれます)が必要になります。
これらは通常、先ほど見たアトミック命令を使用します。処理システムが、 すべてのコアにわたってアトミック性が維持されることを保証するためです。
これらのトピックを詳細に扱うことは、現時点では本書の範囲を超えていますが、 一般的なパターンはシングルコアの場合と同じです。
コレクション
いずれ、プログラム内で動的データ構造(別名コレクション)を使いたくなるでしょう。std は、Vec、String、HashMap などの一般的なコレクションを一式提供しています。std に実装されているすべてのコレクションは、グローバルな動的メモリアロケータ(別名ヒープ)を使用します。
定義上、core はメモリ割り当てを含まないため、これらの実装はそこでは利用できませんが、コンパイラに同梱されている alloc クレートで利用できます。
コレクションが必要な場合でも、ヒープ割り当てされた実装だけが唯一の選択肢ではありません。固定容量 のコレクションを使うこともできます。そのような実装の 1 つは heapless クレートにあります。
このセクションでは、これら 2 つの実装を調べて比較します。
alloc を使う
alloc クレートは、標準の Rust ディストリビューションに同梱されています。このクレートをインポートするには、Cargo.toml ファイルで依存関係として宣言しなくても、直接 use できます。
#![feature(alloc)]
extern crate alloc;
use alloc::vec::Vec;
任意のコレクションを使えるようにするには、まず global_allocator 属性を使って、プログラムが使用するグローバルアロケータを宣言する必要があります。選択したアロケータは GlobalAlloc トレイトを実装している必要があります。
完全性のため、またこのセクションをできるだけ自己完結させるために、ここでは単純なバンプポインタアロケータを実装し、それをグローバルアロケータとして使います。ただし、このアロケータの代わりに、crates.io にある実運用で十分に検証されたアロケータをプログラムで使うことを 強く 推奨します。
// バンプポインタアロケータの実装
use core::alloc::{GlobalAlloc, Layout};
use core::cell::UnsafeCell;
use core::ptr;
use cortex_m::interrupt;
// *シングル* コアシステム向けのバンプポインタアロケータ
struct BumpPointerAlloc {
head: UnsafeCell<usize>,
end: usize,
}
unsafe impl Sync for BumpPointerAlloc {}
unsafe impl GlobalAlloc for BumpPointerAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
// `interrupt::free` は、アロケータを割り込み内から使用しても
// 安全になるようにするクリティカルセクション
interrupt::free(|_| {
let head = self.head.get();
let size = layout.size();
let align = layout.align();
let align_mask = !(align - 1);
// 開始位置を次のアラインメント境界まで進める
let start = (*head + align - 1) & align_mask;
if start + size > self.end {
// null ポインタは Out Of Memory 状態を示す
ptr::null_mut()
} else {
*head = start + size;
start as *mut u8
}
})
}
unsafe fn dealloc(&self, _: *mut u8, _: Layout) {
// このアロケータはメモリを解放しない
}
}
// グローバルメモリアロケータの宣言
// NOTE ユーザーは、メモリ領域 `[0x2000_0100, 0x2000_0200]` が
// プログラムのほかの部分で使われないことを保証しなければならない
#[global_allocator]
static HEAP: BumpPointerAlloc = BumpPointerAlloc {
head: UnsafeCell::new(0x2000_0100),
end: 0x2000_0200,
};
グローバルアロケータの選択に加えて、ユーザーは unstable な alloc_error_handler 属性を使って、Out Of Memory(OOM)エラーをどのように処理するかも定義しなければなりません。
#![feature(alloc_error_handler)]
use cortex_m::asm;
#[alloc_error_handler]
fn on_oom(_layout: Layout) -> ! {
asm::bkpt();
loop {}
}
これらすべてを設定すると、ようやく alloc 内のコレクションを使えるようになります。
#[entry]
fn main() -> ! {
let mut xs = Vec::new();
xs.push(42);
assert!(xs.pop(), Some(42));
loop {
// ..
}
}
std クレートのコレクションを使ったことがあるなら、これらは見覚えがあるはずです。というのも、実装はまったく同じだからです。
heapless を使う
heapless は、そのコレクションがグローバルメモリアロケータに依存しないため、設定を必要としません。単にそのコレクションを use して、インスタンス化すればよいだけです。
// heapless バージョン: v0.4.x
use heapless::Vec;
use heapless::consts::*;
#[entry]
fn main() -> ! {
let mut xs: Vec<_, U8> = Vec::new();
xs.push(42).unwrap();
assert_eq!(xs.pop(), Some(42));
loop {}
}
これらのコレクションと alloc のコレクションの間には、2 つの違いがあることに気づくでしょう。
1 つ目は、コレクションの容量をあらかじめ宣言しなければならないことです。heapless のコレクションは再割り当てを行わず、固定容量を持ちます。この容量はコレクションの型シグネチャの一部です。この例では、xs が 8 要素の容量を持つ、つまりこのベクタは最大で 8 要素まで保持できることを宣言しています。これは型シグネチャ内の U8(typenum を参照)で示されています。
2 つ目は、push メソッドやそのほか多くのメソッドが Result を返すことです。heapless のコレクションは固定容量であるため、要素をコレクションに挿入するすべての操作は失敗する可能性があります。API は、この問題を反映して、操作が成功したかどうかを示す Result を返します。これに対して、alloc のコレクションは容量を増やすためにヒープ上で自分自身を再割り当てします。
v0.4.x の時点では、すべての heapless コレクションはすべての要素をインラインで保持します。これは、let x = heapless::Vec::new(); のような操作ではコレクションがスタック上に割り当てられることを意味しますが、static 変数上、あるいはヒープ上(Box<Vec<_, _>>)にコレクションを割り当てることも可能です。
トレードオフ
ヒープ割り当てされ、再配置可能なコレクションと、固定容量のコレクションのどちらを選ぶかを考える際には、以下を念頭に置いてください。
Out Of Memory とエラーハンドリング
ヒープ割り当てでは、Out Of Memory は常に起こり得るものであり、コレクションが成長する必要があるあらゆる場所で発生する可能性があります。たとえば、すべての alloc::Vec.push 呼び出しは OOM 状態を引き起こす可能性があります。したがって、一部の操作は 暗黙的に 失敗する可能性があります。一部の alloc コレクションは try_reserve メソッドを公開しており、コレクションを拡張する際の潜在的な OOM 状態を確認できますが、それらを積極的に使う必要があります。
heapless コレクションだけを使い、それ以外の用途でメモリアロケータを使用しないのであれば、OOM 状態は不可能です。その代わりに、コレクションの容量不足をケースごとに処理する必要があります。つまり、Vec.push のようなメソッドが返すすべての Result を処理しなければなりません。
OOM 障害は、たとえば heapless::Vec.push が返すすべての Result に対して unwrap する場合よりもデバッグが難しいことがあります。なぜなら、観測された障害発生箇所が、問題の原因となった箇所と 一致しない 可能性があるからです。たとえば、vec.reserve(1) ですら、アロケータがほぼ使い尽くされている場合には OOM を引き起こすことがあります。その原因が、ほかのコレクションによるメモリリークであることもあり得ます(メモリリークは safe Rust でも起こり得ます)。
メモリ使用量
ヒープに割り当てられたコレクションのメモリ使用量を見積もるのは難しいです。というのも、長寿命のコレクションの容量は実行時に変化する可能性があるためです。いくつかの操作は暗黙的にコレクションを再割り当てしてメモリ使用量を増やすことがあり、また一部のコレクションは shrink_to_fit のような、コレクションが使用するメモリを減らせる可能性のあるメソッドを公開しています – ただし最終的に、メモリ割り当てを実際に縮小するかどうかを決めるのはアロケータです。さらに、アロケータはメモリ断片化にも対処しなければならない場合があり、それによって 見かけ上の メモリ使用量が増えることがあります。
一方、固定容量コレクションだけを使い、その大半を static 変数に格納し、さらにコールスタックの最大サイズを設定しておけば、物理的に利用可能な量を超えるメモリを使おうとしたときにリンカがそれを検出します。
さらに、スタック上に割り当てられた固定容量コレクションは -Z emit-stack-sizes フラグの出力に含まれます。つまり、スタック使用量を解析するツール(stack-sizes など)は、それらを解析対象に含めます。
ただし、固定容量コレクションを 縮小することはできない ため、再配置可能なコレクションで達成できるものよりも低い負荷率(コレクションのサイズと容量の比率)になる可能性があります。
最悪実行時間(WCET)
時間制約の厳しいアプリケーションやハードリアルタイムアプリケーションを構築しているなら、プログラムのさまざまな部分の最悪実行時間を、おそらく非常に強く気にすることになります。
alloc のコレクションは再割り当てを行う可能性があるため、コレクションを拡張しうる操作の WCET には、コレクションを再割り当てするのにかかる時間も含まれます。そしてその時間自体が、コレクションの 実行時 の容量に依存します。このため、たとえば alloc::Vec.push 操作の WCET を求めるのは難しくなります。というのも、それは使用しているアロケータとコレクションの実行時容量の両方に依存するからです。
一方、固定容量コレクションは決して再割り当てを行わないため、すべての操作の実行時間は予測可能です。たとえば、heapless::Vec.push は定数時間で実行されます。
使いやすさ
alloc ではグローバルアロケータを設定する必要がありますが、heapless にはその必要がありません。しかし、heapless ではインスタンス化する各コレクションの容量を選ぶ必要があります。
alloc の API は、ほぼすべての Rust 開発者にとって馴染みのあるものでしょう。heapless の API は alloc の API をできるだけ忠実に模倣しようとしていますが、明示的なエラーハンドリングがあるため、完全に同じになることはありません – この明示的なエラーハンドリングを過剰、あるいは煩雑すぎると感じる開発者もいるかもしれません。
デザインパターン
この章の目的は、組み込みRust向けのさまざまな有用なデザインパターンをまとめることです。
HAL 設計パターン
これは、Rust でマイクロコントローラ向けのハードウェア抽象化レイヤー(HAL)を作成するための、一般的で推奨されるパターン集です。これらのパターンは、マイクロコントローラ向けの HAL を作成する際に、既存の Rust API ガイドライン に加えて使用することを意図しています。
HAL 設計パターン チェックリスト
- 命名 (クレートが Rust の命名規則に準拠している)
- クレートの名前が適切である (C-CRATE-NAME)
- 相互運用性 (クレートが他のライブラリ機能とうまく連携する)
- ラッパー型がデストラクタメソッドを提供する (C-FREE)
- HAL がレジスタアクセス用クレートを再エクスポートする (C-REEXPORT-PAC)
- 型が
embedded-halトレイトを実装する (C-HAL-TRAITS)
- 予測可能性 (クレートにより、見た目どおりに動作する読みやすいコードを書ける)
- 拡張トレイトの代わりにコンストラクタを使用する (C-CTOR)
- GPIO インターフェイス (GPIO インターフェイスが共通のパターンに従う)
- ピン型はデフォルトでゼロサイズである (C-ZST-PIN)
- ピン型はピンとポートの情報を消去するメソッドを提供する (C-ERASED-PIN)
- ピンの状態は型パラメータとしてエンコードすべきである (C-PIN-STATE)
命名
クレートに適切な名前が付けられている (C-CRATE-NAME)
HAL クレートは、サポート対象とするチップまたはチップファミリにちなんで
命名するべきです。レジスタアクセスクレートと区別できるように、名前の末尾は -hal
で終わるべきです。名前にはアンダースコアを含めてはいけません(代わりにダッシュを使用します)。
相互運用性
ラッパー型はデストラクタメソッドを提供する (C-FREE)
HAL が提供する non-Copy のラッパー型は、それを消費し、作成元となった生のペリフェラル(および場合によってはほかのオブジェクト)を返す free メソッドを提供するべきです。
このメソッドは、必要に応じてペリフェラルを停止してリセットするべきです。free が返した生のペリフェラルで new を呼び出しても、ペリフェラルの予期しない状態が原因で失敗してはなりません。
HAL 型の構築にほかの non-Copy オブジェクト(たとえば I/O ピン)が必要な場合、そのようなオブジェクトも free によって解放され、返されるべきです。その場合、free はタプルを返すべきです。
たとえば次のようになります。
#![allow(unused)]
fn main() {
pub struct TIMER0;
pub struct Timer(TIMER0);
impl Timer {
pub fn new(periph: TIMER0) -> Self {
Self(periph)
}
pub fn free(self) -> TIMER0 {
self.0
}
}
}
HAL はレジスタアクセスクレートを再エクスポートする (C-REEXPORT-PAC)
HAL は、svd2rust が生成した PAC の上に書くことも、あるいは生のレジスタアクセスを提供するほかのクレートの上に書くこともできます。HAL は、それがベースとしているレジスタアクセスクレートを、常にクレートルートで再エクスポートするべきです。
PAC は、クレートの実際の名前にかかわらず、pac という名前で再エクスポートするべきです。というのも、HAL の名前によって、どの PAC にアクセスしているのかはすでに明確になっているはずだからです。
型は embedded-hal のトレイトを実装する (C-HAL-TRAITS)
HAL が提供する型は、embedded-hal クレートが提供する適用可能なすべてのトレイトを実装するべきです。
同じ型に対して複数のトレイトが実装される場合があります。
予測可能性
拡張トレイトではなくコンストラクタを使用する (C-CTOR)
HAL が機能を追加するすべてのペリフェラルは、その機能に追加のフィールドが不要であっても、新しい型でラップすべきです。
生のペリフェラルに対して実装された拡張トレイトは避けるべきです。
適切な箇所でメソッドに #[inline] を付ける (C-INLINE)
Rust コンパイラは、デフォルトでは crate 境界をまたぐ完全なインライン化を行いません。組み込みアプリケーションは予期しないコードサイズの増加に敏感であるため、#[inline] を使用して次のようにコンパイラを誘導すべきです。
- すべての「小さい」関数には
#[inline]を付けるべきです。「小さい」と見なされるかどうかは主観的ですが、一般には、コンパイル後に一桁個の命令列になると見込まれる関数は、いずれも小さいと見なされます。 - パラメータとして定数値を受け取る可能性が非常に高い関数には、
#[inline]を付けるべきです。これにより、関数への入力が既知である限り、複雑な初期化ロジックであってもコンパイラがコンパイル時に計算できるようになります。
GPIOインターフェイスに関する推奨事項
ピン型はデフォルトでゼロサイズにする (C-ZST-PIN)
HAL によって公開される GPIO インターフェイスは、各インターフェイスまたはポート上の各ピンに対して専用のゼロサイズ型を提供するべきです。これにより、すべてのピン割り当てが静的に既知である場合に、ゼロコストの GPIO 抽象化が実現されます。
各 GPIO インターフェイスまたはポートは、すべてのピンを含む構造体を返す split メソッドを実装するべきです。
例:
#![allow(unused)]
fn main() {
pub struct PA0;
pub struct PA1;
// ...
pub struct PortA;
impl PortA {
pub fn split(self) -> PortAPins {
PortAPins {
pa0: PA0,
pa1: PA1,
// ...
}
}
}
pub struct PortAPins {
pub pa0: PA0,
pub pa1: PA1,
// ...
}
}
ピン型はピンとポートを消去するメソッドを提供する (C-ERASED-PIN)
ピンは、自身の特性をコンパイル時から実行時へ移す型消去メソッドを提供するべきであり、アプリケーションでより高い柔軟性を可能にするべきです。
例:
#![allow(unused)]
fn main() {
/// ポートAのピン0。
pub struct PA0;
impl PA0 {
pub fn erase_pin(self) -> PA {
PA { pin: 0 }
}
}
/// ポートA上のピン。
pub struct PA {
/// ピン番号。
pin: u8,
}
impl PA {
pub fn erase_port(self) -> Pin {
Pin {
port: Port::A,
pin: self.pin,
}
}
}
pub struct Pin {
port: Port,
pin: u8,
// (これらのフィールドはメモリフットプリントを削減するためにパックできる)
}
enum Port {
A,
B,
C,
D,
}
}
ピンの状態は型パラメータとしてエンコードするべきである (C-PIN-STATE)
ピンは、チップやファミリに応じて異なる特性を持つ入力または出力として設定される場合があります。この状態は型システムでエンコードするべきであり、誤った状態のピンが使われることを防ぎます。
追加のチップ固有の状態(たとえばドライブ強度)も、追加の型パラメータを使用することで、同様にエンコードして構いません。
ピン状態を変更するメソッドは、into_input および into_output メソッドとして提供するべきです。
さらに、ピンをムーブせずに一時的に別の状態へ再設定する with_{input,output}_state メソッドも提供するべきです。
以下のメソッドをすべてのピン型に対して提供するべきです(つまり、消去されたピン型と消去されていないピン型の両方が同じ API を提供するべきです):
pub fn into_input<N: InputState>(self, input: N) -> Pin<N>pub fn into_output<N: OutputState>(self, output: N) -> Pin<N>-
pub fn with_input_state<N: InputState, R>( &mut self, input: N, f: impl FnOnce(&mut PA1<N>) -> R, ) -> R -
pub fn with_output_state<N: OutputState, R>( &mut self, output: N, f: impl FnOnce(&mut PA1<N>) -> R, ) -> R
ピン状態は sealed トレイトによって境界付けするべきです。HAL の利用者が独自の状態を追加する必要はないはずです。これらのトレイトは、ピン状態 API の実装に必要な HAL 固有のメソッドを提供できます。
例:
#![allow(unused)]
fn main() {
use std::marker::PhantomData;
mod sealed {
pub trait Sealed {}
}
pub trait PinState: sealed::Sealed {}
pub trait OutputState: sealed::Sealed {}
pub trait InputState: sealed::Sealed {
// ...
}
pub struct Output<S: OutputState> {
_p: PhantomData<S>,
}
impl<S: OutputState> PinState for Output<S> {}
impl<S: OutputState> sealed::Sealed for Output<S> {}
pub struct PushPull;
pub struct OpenDrain;
impl OutputState for PushPull {}
impl OutputState for OpenDrain {}
impl sealed::Sealed for PushPull {}
impl sealed::Sealed for OpenDrain {}
pub struct Input<S: InputState> {
_p: PhantomData<S>,
}
impl<S: InputState> PinState for Input<S> {}
impl<S: InputState> sealed::Sealed for Input<S> {}
pub struct Floating;
pub struct PullUp;
pub struct PullDown;
impl InputState for Floating {}
impl InputState for PullUp {}
impl InputState for PullDown {}
impl sealed::Sealed for Floating {}
impl sealed::Sealed for PullUp {}
impl sealed::Sealed for PullDown {}
pub struct PA1<S: PinState> {
_p: PhantomData<S>,
}
impl<S: PinState> PA1<S> {
pub fn into_input<N: InputState>(self, input: N) -> PA1<Input<N>> {
todo!()
}
pub fn into_output<N: OutputState>(self, output: N) -> PA1<Output<N>> {
todo!()
}
pub fn with_input_state<N: InputState, R>(
&mut self,
input: N,
f: impl FnOnce(&mut PA1<N>) -> R,
) -> R {
todo!()
}
pub fn with_output_state<N: OutputState, R>(
&mut self,
output: N,
f: impl FnOnce(&mut PA1<N>) -> R,
) -> R {
todo!()
}
}
// `PA`、`Pin`、および他のピン型についても同様。
}
組み込みC開発者向けのヒント
この章では、Rust を書き始めようとしている経験豊富な 組み込みC開発者に役立つ可能性のある、さまざまなヒントをまとめます。特に、 C ですでに慣れ親しんでいることが Rust ではどのように異なるかを強調して説明します。
プリプロセッサ
組み込みCでは、プリプロセッサをさまざまな目的で使うのが非常に一般的です。たとえば、
#ifdefを使ったコンパイル時のコードブロック選択- コンパイル時の配列サイズや計算
- 共通パターンを簡略化するためのマクロ(関数呼び出しのオーバーヘッドを避けるため)
Rust にはプリプロセッサがないため、こうしたユースケースの多くには別の方法で対処します。 この節の残りでは、プリプロセッサを使う代わりとなるさまざまな方法を取り上げます。
コンパイル時のコード選択
Rust において #ifdef ... #endif に最も近いのは Cargo features です。これらは
C のプリプロセッサよりも少し形式的です。考え得るすべてのフィーチャは
クレートごとに明示的に列挙され、オンかオフのどちらかにしかなりません。フィーチャは
クレートを依存関係として列挙したときに有効化され、かつ加算的です。つまり、依存関係ツリー内のいずれかのクレート
が別のクレートに対してあるフィーチャを有効にすると、そのフィーチャは
そのクレートのすべての利用者に対して有効になります。
たとえば、信号処理プリミティブのライブラリを提供するクレートがあるとします。
それぞれのコンポーネントはコンパイルに余分な時間がかかったり、
避けたい大きな定数テーブルを宣言したりするかもしれません。Cargo.toml では、
各コンポーネントに対して Cargo のフィーチャを宣言できます。
[features]
FIR = []
IIR = []
そしてコード内では、何を含めるかを制御するために #[cfg(feature="FIR")] を使います。
#![allow(unused)]
fn main() {
/// トップレベルの lib.rs 内
#[cfg(feature="FIR")]
pub mod fir;
#[cfg(feature="IIR")]
pub mod iir;
}
同様に、あるフィーチャが 有効でない 場合にのみコードブロックを含めたり、ある フィーチャの任意の組み合わせが有効または無効である場合に含めたりすることもできます。
さらに、Rust は自動設定される条件も数多く提供しています。たとえば、
target_arch を使うと、アーキテクチャに応じて別のコードを選択できます。条件付きコンパイルの
完全な詳細については、Rust リファレンスの
conditional compilation の章を参照してください。
条件付きコンパイルが適用されるのは、次の文またはブロックだけです。ある
ブロックを現在のスコープで使えない場合は、cfg 属性を
複数回使う必要があります。なお、ほとんどの場合は、
すべてのコードを単純に含めておき、最適化時にコンパイラにデッド
コードを取り除かせるほうがよい点にも注意してください。そのほうが、あなたにとってもその利用者にとっても
単純であり、一般にコンパイラは未使用コードの除去をうまく行ってくれます。
コンパイル時のサイズと計算
Rust は const fn をサポートしています。これはコンパイル時に
評価可能であることが保証された関数であり、そのため
配列のサイズのように定数が必要な場所で使用できます。これは、前述のフィーチャと
組み合わせて使うことができます。たとえば、
#![allow(unused)]
fn main() {
const fn array_size() -> usize {
#[cfg(feature="use_more_ram")]
{ 1024 }
#[cfg(not(feature="use_more_ram"))]
{ 128 }
}
static BUF: [u32; array_size()] = [0u32; array_size()];
}
const fn は 1.31 時点で stable Rust に新たに加わったものなので、ドキュメントはまだ少ないです。
執筆時点では const fn で利用できる機能も非常に限られています。将来の
Rust リリースでは、const fn で許可されることがさらに拡充されると見込まれています。
マクロ
Rust は非常に強力な macro system を提供しています。C のプリプロセッサが ほぼソースコードのテキストに直接作用するのに対して、Rust のマクロシステムは より高いレベルで動作します。Rust のマクロには 2 種類あります。例示による マクロ と 手続きマクロ です。前者はより単純で、最も一般的です。これらは 関数呼び出しのように見え、完全な式、文、 アイテム、またはパターンに展開できます。手続きマクロはより複雑ですが、 Rust 言語に対して非常に強力な拡張を可能にします。つまり、任意の Rust 構文を 新しい Rust 構文へ変換できます。
一般に、C のプリプロセッサマクロを使っていた場面では、代わりに 例示によるマクロで目的を果たせないか検討するとよいでしょう。これらは 自分のクレート内で定義でき、自分のクレートで簡単に利用したり、 他の利用者向けにエクスポートしたりできます。ただし、これらは完全な式、 文、アイテム、またはパターンに展開されなければならないため、C のプリプロセッサマクロのユースケースの一部は機能しません。たとえば、 変数名の一部に展開されるマクロや、リスト内の不完全なアイテム集合 に展開されるマクロです。
Cargo のフィーチャと同様に、そもそもそのマクロが本当に必要かどうかを検討する価値があります。多くの
場合、通常の関数のほうが理解しやすく、マクロと同じコードへ
インライン化されます。#[inline] と #[inline(always)] の attributes
を使うとこの過程をさらに制御できますが、ここでも注意が必要です
— コンパイラは同じクレート内の関数を適切な場合に自動で
インライン化するため、不適切にそれを強制すると、かえって
性能が低下することがあります。
Rust のマクロシステム全体を説明することは、このヒントページの範囲外です。そのため、 完全な詳細については Rust のドキュメントを参照することをおすすめします。
ビルドシステム
ほとんどの Rust クレートは Cargo を使ってビルドされます(必須ではありませんが)。これにより、
従来のビルドシステムに伴う多くの難しい問題に対処できます。しかし、
ビルドプロセスをカスタマイズしたいこともあるでしょう。そのために、Cargo は build.rs
scripts を提供しています。これらは、必要に応じて
Cargo のビルドシステムとやり取りできる Rust スクリプトです。
ビルドスクリプトの一般的なユースケースには、次のようなものがあります。
- ビルド時の情報を提供する。たとえば、ビルド 日や Git のコミットハッシュを実行ファイルに静的に埋め込む
- 選択されたフィーチャやその他の ロジックに応じて、ビルド時にリンカスクリプトを生成する
- Cargo のビルド設定を変更する
- リンク対象となる追加の静的ライブラリを追加する
現時点では、ビルド後スクリプトはサポートされていません。従来であれば、 これはビルドオブジェクトからのバイナリ自動生成や ビルド情報の表示といったタスクに使っていたかもしれません。
クロスコンパイル
ビルドシステムとして Cargo を使うと、クロスコンパイルも簡単になります。ほとんどの
場合、Cargo に --target thumbv6m-none-eabi を指定し、
target/thumbv6m-none-eabi/debug/myapp にある適切な実行ファイルを見つければ十分です。
Rust にネイティブにサポートされていないプラットフォームでは、そのターゲット向けに libcore
を自分でビルドする必要があります。そのようなプラットフォームでは、Xargo を Cargo
の代替として使用でき、libcore を自動的にビルドしてくれます。
イテレータと配列アクセス
C では、おそらく配列にインデックスで直接アクセスすることに慣れているでしょう。
int16_t arr[16];
int i;
for(i=0; i<sizeof(arr)/sizeof(arr[0]); i++) {
process(arr[i]);
}
Rust では、これはアンチパターンです。添字によるアクセスは遅くなる可能性があり(境界チェックが必要なため)、またさまざまなコンパイラ最適化を妨げることがあります。これは重要な違いなので、繰り返す価値があります。Rust はメモリ安全性を保証するために、手動で配列にインデックスアクセスする際の範囲外アクセスをチェックしますが、C は平然と配列の外側をインデックスアクセスします。
代わりに、イテレータを使ってください:
let arr = [0u16; 16];
for element in arr.iter() {
process(*element);
}
イテレータは、C では手動で実装しなければならないような、連結、zip、列挙、最小値や最大値の検索、合計など、多くの強力な機能を提供します。イテレータメソッドは連結することもできるため、非常に読みやすいデータ処理コードを書けます。
詳しくは Iterators in the Book と Iterator documentation を参照してください。
参照とポインタ
Rust では、ポインタ(raw pointers と呼ばれるもの)は存在しますが、使われるのは特定の状況に限られます。というのも、それらをデリファレンスすることは常に unsafe と見なされるからです – Rust は、ポインタの先に何があるかについて通常どおりの保証を提供できません。
ほとんどの場合、代わりに & 記号で示される 参照、または &mut で示される 可変参照 を使います。参照は、デリファレンスして基になる値にアクセスできるという点でポインタと似ていますが、Rust の所有権システムの重要な一部です。Rust は、任意の時点で同じ値に対して持てるのは、1 つの可変参照 または 複数の不変参照だけであることを厳密に強制します。
実際にはこれは、データへの可変アクセスが本当に必要かどうかを、より注意深く考えなければならないということです。C では可変がデフォルトで、const を明示する必要がありますが、Rust ではその逆です。
それでも raw ポインタを使う可能性がある状況の 1 つは、ハードウェアと直接やり取りする場合です(たとえば、バッファへのポインタを DMA ペリフェラルレジスタに書き込む場合)。また、メモリマップドレジスタを読み書きできるようにするため、すべてのペリフェラルアクセスクレートでも内部的に使われています。
Volatile アクセス
C では、個々の変数に volatile を付けることができ、これはその変数の値がアクセスの合間に変化し得ることをコンパイラに示します。volatile 変数は、組み込みの文脈ではメモリマップドレジスタに対してよく使われます。
Rust では、変数を volatile としてマークする代わりに、volatile アクセスを行うための専用メソッドを使います: core::ptr::read_volatile と core::ptr::write_volatile です。これらのメソッドは *const T または *mut T(前述の raw pointers)を受け取り、volatile 読み取りまたは書き込みを行います。
たとえば、C では次のように書くかもしれません:
volatile bool signalled = false;
void ISR() {
// 割り込みが発生したことを通知する
signalled = true;
}
void driver() {
while(true) {
// 通知されるまでスリープする
while(!signalled) { WFI(); }
// 通知フラグをリセットする
signalled = false;
// 割り込みを待っていた何らかのタスクを実行する
run_task();
}
}
Rust での等価なコードでは、各アクセスで volatile メソッドを使います:
static mut SIGNALLED: bool = false;
#[interrupt]
fn ISR() {
// 割り込みが発生したことを通知する
// (実際のコードでは、アトミック型のような
// より高水準のプリミティブを検討すべきです。)
unsafe { core::ptr::write_volatile(&mut SIGNALLED, true) };
}
fn driver() {
loop {
// 通知されるまでスリープする
while unsafe { !core::ptr::read_volatile(&SIGNALLED) } {}
// 通知フラグをリセットする
unsafe { core::ptr::write_volatile(&mut SIGNALLED, false) };
// 割り込みを待っていた何らかのタスクを実行する
run_task();
}
}
このコード例で注目すべき点がいくつかあります:
*mut Tを要求する関数に&mut SIGNALLEDを渡せます。これは、&mut Tが自動的に*mut Tに変換されるためです(*const Tについても同様です)read_volatile/write_volatileメソッドにはunsafeブロックが必要です。これらはunsafe関数だからです。安全に使用されることを保証するのはプログラマの責任です。詳しくは各メソッドのドキュメントを参照してください。
これらの関数をコード内で直接必要とすることはまれで、通常はより高水準のライブラリが代わりに処理してくれます。メモリマップドペリフェラルについては、ペリフェラルアクセスクレートが volatile アクセスを自動的に実装します。一方、並行性プリミティブについては、より良い抽象化が利用できます(Concurrency chapter を参照)。
Packed 型とアライン指定された型
組み込み C では、特定のハードウェア要件やプロトコル要件を満たすために、変数が特定のアラインメントを持たなければならない、あるいは構造体がアラインされるのではなく packed でなければならないことをコンパイラに伝えるのは一般的です。
Rust では、これは構造体または共用体の repr 属性で制御します。デフォルトの表現はレイアウトについて何の保証も提供しないため、ハードウェアや C と相互運用するコードには使うべきではありません。コンパイラは構造体メンバーを並べ替えたりパディングを挿入したりする可能性があり、その挙動は将来の Rust のバージョンで変わることがあります。
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}
// 0x7ffecb3511d0 0x7ffecb3511d4 0x7ffecb3511d2
// packed しやすくするために、順序が x, z, y に変更されていることに注意してください。
C と相互運用可能なレイアウトを保証するには、repr(C) を使います:
#[repr(C)]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}
// 0x7fffd0d84c60 0x7fffd0d84c62 0x7fffd0d84c64
// 順序は保持され、レイアウトも将来にわたって変わりません。
// `z` は 2 バイト境界にアラインされるため、`y` と `z` の間には 1 バイトのパディングが存在します。
packed な表現を保証するには、repr(packed) を使います:
#[repr(packed)]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
// 参照は常にアラインされている必要があるため、
// 構造体のフィールドのアドレスを確認するには、単に `&v.x` を表示する
// のではなく、`std::ptr::addr_of!()` を使って raw ポインタを取得します。
let px = std::ptr::addr_of!(v.x);
let py = std::ptr::addr_of!(v.y);
let pz = std::ptr::addr_of!(v.z);
println!("{:p} {:p} {:p}", px, py, pz);
}
// 0x7ffd33598490 0x7ffd33598492 0x7ffd33598493
// `y` と `z` の間にはパディングが挿入されていないため、`z` は非アライン状態になります。
repr(packed) を使うと、その型のアラインメントも 1 に設定されることに注意してください。
最後に、特定のアラインメントを指定するには repr(align(n)) を使います。ここで n はアラインするバイト数であり(2 の累乗でなければなりません):
#[repr(C)]
#[repr(align(4096))]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
let u = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
println!("{:p} {:p} {:p}", &u.x, &u.y, &u.z);
}
// 0x7ffec909a000 0x7ffec909a002 0x7ffec909a004
// 0x7ffec909b000 0x7ffec909b002 0x7ffec909b004
// 2 つのインスタンス `u` と `v` は 4096 バイト境界に配置されており、
// これはそれぞれのアドレス末尾が `000` であることから分かります。
なお、repr(C) と repr(align(n)) を組み合わせることで、アラインされ、
C 互換のレイアウトを得ることができます。repr(align(n)) と
repr(packed) を組み合わせることはできません。repr(packed) は
アラインメントを 1 に設定するためです。また、repr(packed) 型の中に
repr(align(n)) 型を含めることもできません。
型レイアウトの詳細については、Rust Reference の type layout 章を 参照してください。
その他のリソース
相互運用性
Rust と C のコードの相互運用性は、常に
2 つの言語の間でデータを変換することに依存します。
この目的のために、stdlib には
専用のモジュール
std::ffi
があります。
std::ffi は、char、int、long などの
C のプリミティブ型の型定義を提供します。
また、文字列のような、より複雑な型を変換するための
いくつかのユーティリティも提供し、
&str と String の両方を、
より簡単かつ安全に扱える C の型に対応付けます。
Rust 1.30 以降では、
std::ffi の機能は、
メモリ割り当てが関係するかどうかに応じて
core::ffi または alloc::ffi
のいずれかで利用できます。
cty クレートおよび cstr_core クレートも
同様の機能を提供します。
| Rust の型 | 中間型 | C の型 |
|---|---|---|
String | CString | char * |
&str | CStr | const char * |
() | c_void | void |
u32 または u64 | c_uint | unsigned int |
| など | … | … |
C のプリミティブ型の値は、
対応する Rust の型の 1 つとして使用でき、
その逆も同様です。
これは、前者が単に後者の型エイリアスだからです。
たとえば、次のコードは、
unsigned int が 32 ビット長であるプラットフォーム上で
コンパイルできます。
fn foo(num: u32) {
let c_num: c_uint = num;
let r_num: u32 = c_num;
}
他のビルドシステムとの相互運用性
組み込みプロジェクトに Rust を含める際の一般的な要件は、 Cargo を、make や cmake などの既存のビルドシステムと 組み合わせることです。
この件に関する例やユースケースは、issue tracker の issue #61 で収集しています。
RTOS との相互運用性
FreeRTOS や ChibiOS のような RTOS と Rust を統合する作業は、 依然として進行中です。特に、Rust から RTOS の関数を呼び出すのは 難しい場合があります。
現在、次のプロジェクトが Rust<->RTOS の相互運用性を 公開サポートしています。
この件に関する例やユースケースは、issue tracker の issue #62 で収集しています。
Rust に少し C を加える
Rust プロジェクトの中で C または C++ を使うことは、大きく 2 つの部分から成ります。
- Rust から利用できるように公開された C API をラップすること
- Rust コードと統合できるように C または C++ コードをビルドすること
C++ には Rust コンパイラが対象にできる安定した ABI がないため、Rust を C または C++ と組み合わせる際には C ABI を使用することが推奨されます。
インターフェースを定義する
Rust から C または C++ のコードを利用する前に、リンクされるコード内にどのようなデータ型と関数シグネチャが存在するのかを(Rust 側で)定義する必要があります。C または C++ では、この情報を定義したヘッダーファイル(.h または .hpp)を include します。Rust では、これらの定義を手作業で Rust に移し替えるか、ツールを使ってこれらの定義を生成する必要があります。
まずは、これらの定義を C/C++ から Rust に手作業で変換する方法を見ていきます。
C 関数とデータ型をラップする
通常、C または C++ で書かれたライブラリは、公開インターフェースで使われるすべての型と関数を定義したヘッダーファイルを提供します。たとえば、次のようなファイルです。
/* ファイル: cool.h */
typedef struct CoolStruct {
int x;
int y;
} CoolStruct;
void cool_function(int i, char c, CoolStruct* cs);
これを Rust に変換すると、インターフェースは次のようになります。
/* ファイル: cool_bindings.rs */
#[repr(C)]
pub struct CoolStruct {
pub x: cty::c_int,
pub y: cty::c_int,
}
extern "C" {
pub fn cool_function(
i: cty::c_int,
c: cty::c_char,
cs: *mut CoolStruct
);
}
各要素を説明するために、この定義を少しずつ見ていきましょう。
#[repr(C)]
pub struct CoolStruct { ... }
デフォルトでは、Rust は struct に含まれるデータの順序、パディング、サイズを保証しません。C コードとの互換性を保証するために、#[repr(C)] 属性を付けます。これにより Rust コンパイラに対して、構造体内のデータ配置に常に C と同じ規則を使うよう指示します。
pub x: cty::c_int,
pub y: cty::c_int,
C または C++ における int や char の定義には柔軟性があるため、cty で定義されているプリミティブデータ型を使うことが推奨されます。これにより、C の型が Rust の型に対応付けられます。
extern "C" { pub fn cool_function( ... ); }
この文は、C ABI を使う cool_function という関数のシグネチャを定義しています。関数本体を定義せずにシグネチャだけを定義しているため、この関数の定義は別の場所で提供されるか、静的ライブラリから最終的なライブラリまたはバイナリへリンクされる必要があります。
i: cty::c_int,
c: cty::c_char,
cs: *mut CoolStruct
上で見たデータ型と同様に、関数引数のデータ型も C 互換の定義を使って定義しています。わかりやすさのために、引数名も同じものを保持しています。
ここでは 1 つ新しい型、*mut CoolStruct が出てきます。C には Rust の参照、すなわち &mut CoolStruct のような概念がないため、代わりに生ポインターを使います。このポインターのデリファレンスは unsafe であり、実際には null ポインターである可能性もあるため、C または C++ コードとやり取りする際には、Rust で通常期待される保証を満たすよう注意する必要があります。
インターフェースを自動生成する
これらのインターフェースは手作業で生成することもできますが、面倒でミスも起こりやすいため、bindgen というツールを使って自動的に変換できます。bindgen の使い方については bindgen user’s manual を参照してください。一般的な流れは次のとおりです。
- Rust と一緒に使いたいインターフェースまたはデータ型を定義している、すべての C または C++ ヘッダーを集めます。
bindings.hファイルを作成し、ステップ 1 で集めた各ファイルを#include "..."します。- この
bindings.hファイルと、コードのコンパイルに使用するコンパイルフラグをbindgenに渡します。ヒント: 生成されるコードを#![no_std]互換にするには、Builder.ctypes_prefix("cty")/--ctypes-prefix=ctyとBuilder.use_core()/--use-coreを使用してください。 bindgenは生成した Rust コードをターミナルの出力に表示します。この出力は、bindings.rsのようなプロジェクト内のファイルにパイプできます。このファイルを Rust プロジェクトで使えば、外部ライブラリとしてコンパイル・リンクされた C/C++ コードとやり取りできます。ヒント: 生成されたバインディング内の型にcty接頭辞が付いている場合は、ctycrate を使うことを忘れないでください。
C/C++ コードをビルドする
Rust コンパイラは C または C++ のコード(あるいは C インターフェースを提供する他言語のコード)を直接コンパイルする方法を知らないため、Rust 以外のコードは事前にコンパイルしておく必要があります。
組み込みプロジェクトでは、これは多くの場合、C/C++ コードを静的アーカイブ(cool-library.a など)にコンパイルすることを意味します。そうすることで、最後のリンクステップで Rust コードと結合できます。
使いたいライブラリがすでに静的アーカイブとして配布されている場合は、コードを再ビルドする必要はありません。上で説明したように提供されたインターフェースヘッダーファイルを変換し、コンパイル/リンク時にその静的アーカイブを含めるだけで済みます。
コードがソースプロジェクトとして存在する場合は、C/C++ コードを静的ライブラリにコンパイルする必要があります。これは、既存のビルドシステム(make、CMake など)を起動するか、必要なコンパイル手順を cc crate を使う形に移植することで行えます。どちらの場合にも、build.rs スクリプトを使う必要があります。
Rust の build.rs ビルドスクリプト
build.rs スクリプトは Rust 構文で書かれたファイルであり、プロジェクトの依存関係がビルドされた後で、かつプロジェクト自体がビルドされる前に、コンパイルを実行しているマシン上で実行されます。
完全なリファレンスは こちら にあります。build.rs スクリプトは、コード生成(たとえば bindgen の利用)、Make のような外部ビルドシステムの呼び出し、あるいは cc crate を使った C/C++ の直接コンパイルに役立ちます。
外部ビルドシステムを起動する
複雑な外部プロジェクトやビルドシステムを持つプロジェクトでは、相対パスをたどり、固定コマンド(make library など)を呼び出し、その後に生成された静的ライブラリを target ビルドディレクトリ内の適切な場所へコピーすることで、std::process::Command を使って他のビルドシステムを「shell out」して呼び出すのが最も簡単な場合があります。
あなたの crate が no_std の組み込みプラットフォームを対象としている場合でも、build.rs が実行されるのはその crate をコンパイルしているマシン上だけです。つまり、コンパイルホスト上で動作する Rust crate はどれでも使えます。
cc crate で C/C++ コードをビルドする
依存関係や複雑さが限られているプロジェクト、あるいは最終的なバイナリや実行ファイルではなく静的ライブラリを生成するようにビルドシステムを変更するのが難しいプロジェクトでは、代わりに cc crate を使うほうが簡単な場合があります。これは、ホストが提供するコンパイラに対する Rust らしいインターフェースを提供します。
単一の C ファイルを静的ライブラリの依存物としてコンパイルする最も単純なケースでは、cc crate を使った build.rs スクリプトの例は次のようになります。
fn main() {
cc::Build::new()
.file("src/foo.c")
.compile("foo");
}
build.rs はパッケージのルートに配置します。すると cargo build は、パッケージのビルド前にそれをコンパイルして実行します。libfoo.a という名前の静的アーカイブが生成され、target ディレクトリに配置されます。
C に少し Rust を混ぜる
C または C++ のプロジェクト内で Rust コードを使う作業は、主に 2 つの部分から成ります。
- Rust で C フレンドリーな API を作成する
- Rust プロジェクトを外部のビルドシステムに組み込む
cargo と meson を除けば、ほとんどのビルドシステムには Rust のネイティブサポートがありません。
そのため、crate とその依存関係のコンパイルには、
cargo をそのまま使うのがたいてい最善です。
プロジェクトのセットアップ
通常どおり、新しい cargo プロジェクトを作成します。
cargo に通常の Rust ターゲットではなく、
システムライブラリを出力させるためのフラグがあります。
これにより、必要であれば、ライブラリの出力名を
crate の他の部分とは異なる名前に設定することもできます。
[lib]
name = "your_crate"
crate-type = ["cdylib"] # 動的ライブラリを作成
# crate-type = ["staticlib"] # 静的ライブラリを作成
C API の構築
C++ には Rust コンパイラがターゲットにできる安定した ABI がないため、
異なる言語間の相互運用には C を使います。これは、Rust を
C や C++ のコード内で使う場合も例外ではありません。
#[no_mangle]
Rust コンパイラは、シンボル名をネイティブコードのリンカが期待するものとは 異なる形でマングルします。 そのため、Rust が Rust の外部で使うために公開する関数には、 コンパイラによってマングルされないよう指定する必要があります。
extern "C"
デフォルトでは、Rust で書いた関数はすべて Rust ABI を使用します(これも安定化されていません)。 その代わり、外部向けの FFI API を構築する際には、 システム ABI を使うようコンパイラに指示する必要があります。
プラットフォームによっては特定の ABI バージョンをターゲットにしたい場合があります。それらは こちら に文書化されています。
これらを組み合わせると、おおよそ次のような関数になります。
#[no_mangle]
pub extern "C" fn rust_function() {
}
Rust プロジェクト内で C コードを使うときと同じように、今度は
アプリケーションの残りの部分が理解できる形にデータを変換したり、
その形から変換したりする必要があります。
リンクとプロジェクト全体の文脈
これで、問題の半分は解決しました。 では、これをどう使うのでしょうか。
これはプロジェクトやビルドシステムに大きく依存します
cargo は、プラットフォームと設定に応じて、
my_lib.so/my_lib.dll または my_lib.a ファイルを作成します。
このライブラリは、ビルドシステムから単純にリンクできます。
ただし、C から Rust 関数を呼び出すには、 関数シグネチャを宣言するためのヘッダーファイルが必要です。
Rust-ffi API 内のすべての関数には、対応するヘッダー関数が必要です。
#[no_mangle]
pub extern "C" fn rust_function() {}
これは次のようになります。
void rust_function();
などです。
このプロセスを自動化するためのツールとして、 cbindgen があります。これは Rust コードを解析し、 そこから C および C++ プロジェクト向けのヘッダーを生成します。
この時点では、C から Rust 関数を使うのは、 ヘッダーをインクルードして呼び出すだけです。
#include "my-rust-project.h"
rust_function();
未分類のトピック
最適化: 速度とサイズのトレードオフ
誰もが自分のプログラムを非常に高速かつ非常に小さくしたいと思いますが、通常は
その両方の特性を同時に満たすことはできません。この節では、
rustc が提供するさまざまな最適化レベルと、それらがプログラムの
実行時間およびバイナリサイズにどのような影響を与えるかを説明します。
最適化なし
これはデフォルトです。cargo build を呼び出すと、開発(別名
dev)プロファイルを使用します。このプロファイルはデバッグ向けに
最適化されているため、デバッグ情報を有効にし、最適化は有効にしません。
つまり、-C opt-level = 0 を使用します。
少なくともベアメタル開発においては、デバッグ情報は Flash / ROM の領域を 消費しないという意味でコストがゼロなので、実際には release プロファイルでも デバッグ情報を有効にすることを推奨します – これはデフォルトでは無効です。 そうすることで、release ビルドをデバッグするときにもブレークポイントを 使えるようになります。
[profile.release]
# シンボルがあると便利で、Flash 上のサイズも増えない
debug = true
最適化なしはデバッグに最適です。コードのステップ実行が、文を 1 つずつ
実行しているように感じられるうえ、GDB でスタック変数や関数引数を print
できます。コードが最適化されていると、変数を表示しようとしても
$0 = <value optimized out> と表示される結果になります。
dev プロファイルの最大の欠点は、生成されるバイナリが非常に大きく、
しかも遅いことです。通常はサイズのほうがより大きな問題です。というのも、
最適化されていないバイナリは Flash を数十 KiB 占有することがあり、
ターゲットデバイスにはその容量がないかもしれないからです – 結果として、
最適化されていないバイナリがデバイスに収まりません!
より小さく、デバッガで扱いやすいバイナリは作れるのでしょうか。はい、 ちょっとしたコツがあります。
依存関係の最適化
Cargo には profile-overrides という機能があり、これを使うと
依存関係の最適化レベルを上書きできます。この機能を使えば、
トップクレートは最適化せずデバッガで扱いやすいままにして、
すべての依存関係をサイズ優先で最適化できます。
ただし、ジェネリックコードは、定義されたクレートではなく、 インスタンス化されたクレート側で最適化されることがあります。 アプリケーション内でジェネリック構造体のインスタンスを作成し、 それが大きなフットプリントを持つコードを引き込んでいることに気付いた場合、 関係する依存関係の最適化レベルを上げても効果がない可能性があります。
以下に例を示します:
# Cargo.toml
[package]
name = "app"
# ..
[profile.dev.package."*"] # +
opt-level = "z" # +
オーバーライドなしの場合:
$ cargo size --bin app -- -A
app :
section size addr
.vector_table 1024 0x8000000
.text 9060 0x8000400
.rodata 1708 0x8002780
.data 0 0x20000000
.bss 4 0x20000000
オーバーライドありの場合:
$ cargo size --bin app -- -A
app :
section size addr
.vector_table 1024 0x8000000
.text 3490 0x8000400
.rodata 1100 0x80011c0
.data 0 0x20000000
.bss 4 0x20000000
これにより、トップクレートのデバッグしやすさを損なうことなく、Flash 使用量を
6 KiB 削減できます。依存関係の中にステップインすると、再び
<value optimized out> メッセージが表示されるようになりますが、通常は
依存関係ではなくトップクレートをデバッグしたいことがほとんどです。そして、
依存関係を 本当に デバッグする必要があるなら、profile-overrides
機能を使って特定の依存関係を最適化対象から外せます。以下に例を示します:
# ..
# `cortex-m-rt` クレートは最適化しない
[profile.dev.package.cortex-m-rt] # +
opt-level = 0 # +
# ただし、ほかのすべての依存関係は最適化する
[profile.dev.package."*"]
codegen-units = 1 # より良い最適化
opt-level = "z"
これでトップクレートと cortex-m-rt はデバッガで扱いやすくなります!
速度を優先した最適化
2018-09-18 時点で、rustc は 3 つの「速度優先」最適化レベル、
opt-level = 1、2、3 をサポートしています。cargo build --release
を実行すると、デフォルトで opt-level = 3 の release プロファイルを
使用することになります。
opt-level = 2 と 3 はどちらも、バイナリサイズを犠牲にして速度を
最適化しますが、レベル 3 はレベル 2 よりも多くのベクトル化と
インライン化を行います。特に、opt-level が 2 以上になると LLVM が
ループを展開するようになることがわかるでしょう。ループ展開は Flash / ROM
の観点ではかなり高コストです(たとえば、配列をゼロクリアするループでは
26 バイトが 194 バイトになります)が、条件が適切であれば(たとえば反復回数が
十分に大きければ)実行時間を半分にできることもあります。
現在のところ、opt-level = 2 と 3 でループ展開を無効にする方法はないため、
そのコストを負担できない場合はプログラムをサイズ優先で最適化するべきです。
サイズを優先した最適化
2018-09-18 時点で、rustc は 2 つの「サイズ優先」最適化レベル、
opt-level = "s" と "z" をサポートしています。これらの名前は
clang / LLVM から受け継いだもので、あまり説明的ではありませんが、
"z" は "s" よりも小さいバイナリを生成することを示す意図があります。
release バイナリをサイズ優先で最適化したい場合は、以下に示すように
Cargo.toml の profile.release.opt-level 設定を変更してください。
[profile.release]
# または "z"
opt-level = "s"
これら 2 つの最適化レベルでは、関数をインライン化するかどうかを決めるために
使われる指標である LLVM の inline threshold が大きく引き下げられます。
Rust の原則の 1 つにゼロコスト抽象化があります。これらの抽象化は、不変条件を
保つために多くの newtype や小さな関数(たとえば deref や as_ref のように
内部の値を借用する関数)を使う傾向があるため、inline threshold が低いと LLVM が
最適化の機会(たとえば不要な分岐の除去や、クロージャ呼び出しのインライン化)を
逃す可能性があります。
サイズ優先で最適化する場合は、inline threshold を上げてみて、それが
バイナリサイズに何らかの影響を与えるかを確認したくなるかもしれません。
inline threshold を変更する推奨方法は、.cargo/config.toml 内のほかの
rustflags に -C inline-threshold フラグを追加することです。
# .cargo/config.toml
# これは cortex-m-quickstart テンプレートを使用していることを前提としています
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
rustflags = [
# ..
"-C", "inline-threshold=123", # +
]
どの値を使うべきでしょうか。1.29.0 時点では、各最適化レベルが使用する inline threshold は次のとおりです:
opt-level = 3は 275 を使用しますopt-level = 2は 225 を使用しますopt-level = "s"は 75 を使用しますopt-level = "z"は 25 を使用します
サイズ優先で最適化する際は、225 と 275 を試すべきです。
#[no_std] で数学関連の機能を利用する
平方根の計算や数値の指数関数の計算といった数学関連の機能を使いたく、完全な標準ライブラリが利用できる場合、コードは次のようになります。
//! 標準サポートが利用可能な、いくつかの数学関数
fn main() {
let float: f32 = 4.82832;
let floored_float = float.floor();
let sqrt_of_four = floored_float.sqrt();
let sinus_of_four = floored_float.sin();
let exponential_of_four = floored_float.exp();
println!("テスト用の浮動小数点数 {} を {} に切り捨てました", float, floored_float);
println!("{} の平方根は {} です", floored_float, sqrt_of_four);
println!("4 の正弦は {} です", sinus_of_four);
println!(
"4 の e を底とする指数関数の値は {} です",
exponential_of_four
)
}
標準ライブラリのサポートがない場合、これらの関数は利用できません。
代わりに libm のような外部クレートを使用できます。その場合、コード例は次のようになります。
#![no_main]
#![no_std]
use panic_halt as _;
use cortex_m_rt::entry;
use cortex_m_semihosting::{debug, hprintln};
use libm::{exp, floorf, sin, sqrtf};
#[entry]
fn main() -> ! {
let float = 4.82832;
let floored_float = floorf(float);
let sqrt_of_four = sqrtf(floored_float);
let sinus_of_four = sin(floored_float.into());
let exponential_of_four = exp(floored_float.into());
hprintln!("テスト用の浮動小数点数 {} を {} に切り捨てました", float, floored_float).unwrap();
hprintln!("{} の平方根は {} です", floored_float, sqrt_of_four).unwrap();
hprintln!("4 の正弦は {} です", sinus_of_four).unwrap();
hprintln!(
"4 の e を底とする指数関数の値は {} です",
exponential_of_four
)
.unwrap();
// QEMU を終了する
// 注: これはハードウェア上で実行しないでください。OpenOCD の状態を破損する可能性があります
// debug::exit(debug::EXIT_SUCCESS);
loop {}
}
MCU 上で DSP 信号処理や高度な線形代数のような、より複雑な演算を行う必要がある場合は、次のクレートが役立つかもしれません
付録A: 用語集
組み込みエコシステムには、それぞれ独自の用語や略語を使う、さまざまなプロトコル、ハードウェアコンポーネント、ベンダー固有の要素が存在します。この用語集では、それらをよりよく理解するための参照先とともに一覧化することを試みます。
BSP
Board Support Crate は、特定のボード向けに構成された高レベルインターフェースを提供します。通常は HAL クレートに依存します。 より詳しい説明は メモリマップドレジスタのページ にあり、より広い概要については この動画 を参照してください。
FPU
Floating-point Unit。浮動小数点数に対する演算のみを実行する「数値演算プロセッサ」です。
HAL
Hardware Abstraction Layer クレートは、マイクロコントローラの機能やペリフェラルに対して、開発者にとって扱いやすいインターフェースを提供します。通常は Peripheral Access Crate (PAC) の上に実装されます。
また、embedded-hal クレートのトレイトを実装している場合もあります。
より詳しい説明は メモリマップドレジスタのページ にあり、より広い概要については この動画 を参照してください。
I2C
I²C または Inter-IC と呼ばれることもあります。これは、単一の集積回路内でのハードウェア通信を目的としたプロトコルです。詳細については こちら を参照してください
PAC
Peripheral Access Crate は、マイクロコントローラのペリフェラルへのアクセスを提供します。これは低レベルなクレートの1つで、通常は提供された SVD から直接生成され、多くの場合 svd2rust が使用されます。Hardware Abstraction Layer は通常このクレートに依存します。 より詳しい説明は メモリマップドレジスタのページ にあり、より広い概要については この動画 を参照してください。
SPI
Serial Peripheral Interface。詳しくは こちら を参照してください。
SVD
System View Description は、マイクロコントローラデバイスのプログラマから見た構成を記述するために使用される XML ファイル形式です。詳細は ARM CMSIS ドキュメントサイト で確認できます。
UART
Universal asynchronous receiver-transmitter。詳しくは こちら を参照してください。
USART
Universal synchronous and asynchronous receiver-transmitter。詳しくは こちら を参照してください。