組込みC開発者へのヒント

この章では、組込みC開発の経験者が、Rustを書き始める時に役に立つ様々なヒントをまとめます。 特に、既にC言語で慣れ親しんでいることが、Rustではどう違うのかを強調します。

プリプロセッサ

組込みCでは、次のような様々な目的でプリプロセッサを使うことが一般的です。

  • #ifdefを使ったコンパイル時のコードブロック選択
  • コンパイル時の配列サイズやコンパイル時計算
  • (関数呼び出しのオーバーヘッドを避けるための)共通パターンを簡単化するマクロ

Rustにはプリプロセッサはありません。上記のユースケースは異なる方法で解決されます。 セクションの残りの部分では、プリプロセッサの様々な代替手段について説明します。

コンパイル時コード選択

#ifdef ... #endifに最も近いRustの機能は、Cargoフィーチャです。 Cargoフィーチャは、Cプリプロセッサよりももう少し秩序だったものです。 フィーチャの候補は、クレートごとに明示的にリスト化されており、オンまたはオフのいずれかになります。 依存関係としてクレートを記載すると、フィーチャが有効になります。またこのフィーチャは追加式です。 依存ツリー内の何らかのクレートが、別クレートのフィーチャを有効化した場合、そのフィーチャは、そのクレートを使う全てのユーザに対して有効化されます。

例えば、信号処理プリミティブを提供するライブラリがあるとします。 それぞれが、大きな定数テーブルをコンパイルまたは宣言するのに余分な時間がかかるとすると、その時間を回避したいと思うでしょう。 Cargo.toml内で各コンポーネントのフィーチャを宣言することができます。

[features]
FIR = []
IIR = []

それから、コード内で、何をインクルードするか制御するために#[cfg(feature="FIR")]を使います。


#![allow(unused)]
fn main() {
/// In your top-level lib.rs
/// トップレベルのlib.rs内

#[cfg(feature="FIR")]
pub mod fir;

#[cfg(feature="IIR")]
pub mod iir;
}

同様に、フィーチャが有効になって いない 場合にだけコードブロックをインクルードすることができます。 また、フィーチャの組み合わせや、フィーチャが有効か無効かに関わらず、コードブロックをインクルードすることもできます。

さらに、Rustは、自動的に設定される数々の条件を提供します。例えば、アーキテクチャに基づいて異なるコードを選択するtarget_archです。 条件コンパイルがサポートしている全ての詳細については、Rustリファレンスの条件コンパイルの章を参照して下さい。

条件コンパイルは、次のステートメントまたはブロックにのみ適用されます。 現在のスコープ内でブロックが使えない場合、cfgアトリビュートは複数回必要になります。 ほとんどの場合、単純に全てのコードをインクルードして、コンパイラが最適化時にデッドコードを削除できるようにするほうが良いことに、注意すべきです。 これは、あなたにも、あなたのユーザにとってもより簡単です。そして、通常、コンパイラは使用されていないコードをうまく削除します。

コンパイル時サイズとコンパイル時計算

Rustはconst fnを提供しています。この関数はコンパイル時に評価されることが保証されているため、配列のサイズなど、定数が求められる場所で使用できます。 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()];
}

これらは、Rust 1.31以降の新しい機能であるため、ドキュメントはまだわずかしかありません。 執筆時点では、const fnで利用可能な機能は、非常に限られています。 将来のRustでは、const fn内で許可されることが拡張されていくでしょう。

マクロ

Rustは、極めて強力なマクロシステムを提供しています。 Cプリプロセッサがソースコードのテキストにほぼ直接作用するのに対して、Rustのマクロシステムはより上位レベルで作用します。 Rustのマクロは2種類あります。例によるマクロ手続きマクロ です。 前者はより単純で最も一般的なものです。関数呼び出しのように見えて、完全な式やステートメント、アイテム、パターンに展開できます。 手続きマクロは、より複雑ですが、Rust言語に非常に強力な拡張を許可します。任意のRust構文を、新しいRust構文に変換することができます。

通常、Cプリプロセッサマクロを使っていた場所に、例によるマクロで同じことができるかどうか、確認したいと思います。 マクロは、クレート内に定義でき、自身のクレート内で簡単に使ったり、他のユーザにエクスポートしたりできます。 マクロは、完全な式や、ステートメント、アイテム、パターンに展開されなければならないため、Cプリプロセッサマクロのいくつかのユースケースではうまく機能しません。 例えば、変数名や、リスト内の不完全なアイテムの一部に展開するようなマクロです。

Cargoフィーチャと同様、本当にマクロが必要かどうか、は検討する価値があります。 多くの場合、通常の関数は理解しやすく、マクロと同様にインライン化されます。 #[inline]および#[inline(always)]アトリビュートを使用すると、このプロセスをさらに細かく制御できます。 ここでも注意が必要です。コンパイラは、適切な場合、関数を自動的にインライン化します。 そのため、不適切なインライン化を強制すると、パフォーマンスが低下する可能性があります。

Rustのマクロシステムの全体を説明することは、このヒントページのスコープ範囲外です。 詳細については、Rustのドキュメントの参照をお勧めします。

ビルドシステム

(必須ではありませんが)ほとんどのRustのクレートは、Cargoを使ってビルドされます。 Cargoは、従来のビルドシステムに関する多くの難しい問題の面倒を見ています。 しかし、ビルドプロセスをカスタマイズしたいと思うかもしれません。このため、Cargoはbuild.rsスクリプトを提供しています。 build.rsスクリプトはRustで書かれたスクリプトで、必要に応じてCargoのビルドシステムとやり取りします。

ビルドスクリプトの一般的なユースケースを示します。

  • ビルド時の情報を提供します。例えば、実行ファイルにビルド日時やGitのコミットハッシュを静的に埋め込みます。
  • 選択されたフィーチャやその他のロジックに応じて、リンカスクリプトをビルド時に生成します。
  • Cargoのビルド設定を変更します。
  • リンクする静的ライブラリを追加します。

現状、ビルド後に実行するスクリプトは提供されていません。 そのようなスクリプトは、従来では、ビルドしたオブジェクトからバイナリを自動的に生成したり、ビルド情報を表示したりするタスクに使われています。

クロスコンパイル

Cargoをビルドシステムに使用するとクロスコンパイルも簡単になります。 多くの場合、Cargoに--target thumbv6m-none-eabiを伝えるだけで十分です。 そうすると、適切な実行ファイルがtarget/thumbv6m-none-eabi/debug/myappに見つかります。

Rustが本来サポートしていないプラットフォームの場合、ターゲットのlibcoreを自分自身でビルドする必要があります。 そのようなプラットフォームでは、XargoをCargoの代わりに使うことができ、自動的にlibcoreをビルドしてくれます。

イテレータ vs 配列アクセス

Cでは、おそらくインデックスによって直接配列にアクセスしているでしょう。

int16_t arr[16];
int i;
for(i=0; i<sizeof(arr)/sizeof(arr[0]); i++) {
    process(arr[i]);
}

Rustでは、これはアンチパターンです。インデックスによるアクセスは、低速(境界チェックが必要なため)で様々なコンパイラの最適化を妨げます。 これは重要な違いであり、繰り返す価値があります。 Rustは、メモリ安全性を保証するために、手動で配列のインデックスを指定する際、境界を越えたアクセスをチェックします。 一方、Cでは配列外のインデックスにアクセスできてしまいます。

代わりに、イテレータを使います。


#![allow(unused)]
fn main() {
let arr = [0u16; 16];
for element in arr.iter() {
    process(*element);
}
}

イテレータは、chaining、zipping、enumerating、最小値や最大値の検索、合計の算出など、Cでは手動で実装する必要がある強力な配列の機能を提供します。 イテレータのメソッドは、連鎖することができ、非常に読みやすいデータ処理のコードになります。

詳細はthe Bookのイテレータイテレータのドキュメントを参照して下さい。

参照 vs ポインタ

Rustでも、ポインタ([生ポインタ と呼びます])は存在しますが、限られた状況でしか使いません。 ポインタの参照外しは、常にunsafeと考えられるからです。 Rustは、ポインタの背後にあるかもしれないものについて、通常の保証を提供できません。

代わりに、ほとんどの場合、&のシンボルで表現される 参照 もしくは &mutで表現される ミュータブルな参照 を使います。 参照は、ポインタと似た働きをします。つまり、裏にある値にアクセスするために参照外しができます。 しかし、参照は、Rustの所有権システムの重要な一部です。 Rustは、どんな時でも同じ値に対して、唯一のミュータブル参照を持つか、あるいは、複数のイミュータブル参照を持つか、を厳密に強制します。

実際のところ、データへのミュータブルアクセスが必要かどうか、をより慎重に検討する必要があることを意味します。 Cではデフォルトがミュータブルであり、明示的にconstをつける必要があります。Rustではその反対です。

生ポインタを使う可能性のある状況の1つは、直接ハードウェアとやり取りする時です(DMAペリフェラルのレジスタにバッファのポインタを書き込むなど)。 また、生ポインタは、メモリマップドレジスタの読み書きを可能にするために、ペリフェラルアクセスクレートの内部で使われています。

Volatileアクセス

Cでは、個別の変数にvolatileを付けることができます。 これは、変数の値がアクセスごとに変わるかもしれない、ということをコンパイラに伝えます。 組込みでは、Volatile変数はメモリマップドレジスタに広く使用されています。

Rustでは、変数にvolatileを付けるのではなく、volatileアクセスをするための特定のメソッドを使います。 core::ptr::read_volatilecore::ptr::write_volatileです。 これらのメソッドは、*const T*mut T(上述の通り 生ポインタ です)を受け取り、volatileな読み書きを行います。

例えば、Cでは次のように書きます。

volatile bool signalled = false;

void ISR() {
    // 割り込みが発生したというシグナル
    signalled = true;
}

void driver() {
    while(true) {
        // シグナルがあるまでスリープします
        while(!signalled) { WFI(); }
        // シグナルをリセットします
        signalled = false;
        // 割り込みを待っていた何らかのタスクを実行します
        run_task();
    }
}

Rustで同じことをするには、各アクセスにvolatileメソッドを使用します。


#![allow(unused)]
fn main() {
static mut SIGNALLED: bool = false;

#[interrupt]
fn ISR() {
    // Signal that the interrupt has occurred
    // (In real code, you should consider a higher level primitive,
    //  such as an atomic type).
    // 割り込みが発生したというシグナル
    // (実際のコードでは、アトミック型のような、より上位レベルのプリミティブを検討して下さい)
    unsafe { core::ptr::write_volatile(&mut SIGNALLED, true) };
}

fn driver() {
    loop {
        // Sleep until signalled
        // シグナルがあるまでスリープします
        while unsafe { !core::ptr::read_volatile(&SIGNALLED) } {}
        // Reset signalled indicator
        // シグナルをリセットします
        unsafe { core::ptr::write_volatile(&mut SIGNALLED, false) };
        // Perform some task that was waiting for the interrupt
        // 割り込みを待っていた何らかのタスクを実行します
        run_task();
    }
}
}

このコードサンプルには、いくつかの注目すべき点があります。

  • *mut Tを要求する関数に、&mut SIGNALLEDを渡すことができます。 これは、&mut T*mut Tに自動的に変換されるためです(*const Tについても同じです)。
  • read_volatile/write_volatileメソッドにunsafeブロックが必要です。 これらの関数はunsafeだからです。安全な使用を保証することはプログラマの責任です。 詳細は、メソッドのドキュメントを参照して下さい。

これらの関数をコードに直接書くことは稀です。通常、より上位レベルのライブラリで面倒を見てくれます。 メモリマップドペリフェラルについては、ペリフェラルアクセスクレートがvolatileアクセスを自動的に実装します。 並行性プリミティブの場合、より優れた抽象化が利用できます(並行性の章を参照して下さい)。

パック型と整列型

組込みCでは、通常、特定のハードウェアやプロトコルの要件を満たすために、変数に特定のアライメントが必要なことや、 構造体が整列されているだけでなくパックされている必要があることを、コンパイラに指示することが一般的です。

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
// Note ordering has been changed to x, z, y to improve packing.
// データの詰め方を改善するために、x, y, zの順序が入れ替わっていることに注目して下さい。

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
// Ordering is preserved and the layout will not change over time.
// `z` is two-byte aligned so a byte of padding exists between `y` and `z`.
// 順序は維持され、レイアウトは時間が経っても変化しません。
// `z`は2バイトで整列されており、`y`と`z`の間には、1バイトのパディングが存在します。

パックされた表現を保証する場合、repr(packed)を使います。

#[repr(packed)]
struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    // Unsafe is required to borrow a field of a packed struct.
    // パックされた構造体のフィールドを借用するには、アンセーフが必要です。
    unsafe { println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z) };
}

// 0x7ffd33598490 0x7ffd33598492 0x7ffd33598493
// No padding has been inserted between `y` and `z`, so now `z` is unaligned.
// `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
// The two instances `u` and `v` have been placed on 4096-byte alignments,
// evidenced by the `000` at the end of their addresses.
// 2つのインスタンス`u`と`v`は4096バイトのアライメントで配置されます。
// インスタンスのアドレスの最後は`000`になっています。

整列されていてCと互換性のあるレイアウトを取得するため、repr(C)repr(align(n))とを組み合わせることができます。 repr(align(n))repr(packed)とを組み合わせることはできません。repr(packed)はアライメントを1に設定するからです。 repr(packed)の型をrepr(align(n))の型に含めることもできません。

型レイアウトに関するさらなる詳細は、Rustリファレンスの型レイアウトの章を参照して下さい。

その他のリソース