組み込み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 章を 参照してください。