Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The embedonomicon

The embedonomicon は、#![no_std] アプリケーションをゼロから作成するプロセスと、 Cortex-M マイクロコントローラ向けのアーキテクチャ固有の機能を反復的に構築していく プロセスを順を追って説明します。

目的

この本を読むことで、以下を学べます

  • #![no_std] アプリケーションを構築する方法。これは #![no_std] ライブラリを構築するよりもはるかに複雑です。というのも、対象システムでは OS が 動作していない可能性があり(あるいは OS 自体を構築しようとしているかもしれません)、 またそのプログラムが対象環境で動作する唯一のプロセス(あるいは最初のプロセス)である 可能性があるためです。 その場合、プログラムを対象システム向けにカスタマイズする必要があるかもしれません。

  • Rust プログラムのメモリレイアウトを細かく制御するためのテクニック。リンカ、 リンカスクリプト、および Rust プログラムの ABI をある程度制御できる Rust の機能に ついて学びます。

  • 静的にオーバーライドできるデフォルト機能を実装するテクニック(ランタイムコストなし)。

対象読者

この本は主に次の 2 つの読者層を対象としています:

  • エコシステムがまだサポートしていないアーキテクチャ(たとえば Rust 1.28 時点の Cortex-R)や、Rust がサポートを獲得したばかりのアーキテクチャ(たとえば将来的には Xtensa かもしれません)に対して、ベアメタルサポートを立ち上げたい人。

  • cortex-m-rtmsp430-rtriscv-rt のような ランタイム クレートの 一風変わった実装に興味がある人。

翻訳

この本は、有志のボランティアによって翻訳されています。ここにあなたの 翻訳を掲載したい場合は、それを追加する PR を作成してください。

要件

この本は自己完結しています。読者は Cortex-M アーキテクチャに精通している 必要はなく、Cortex-M マイクロコントローラにアクセスできる必要もありません – この 本に含まれる例はすべて QEMU でテストできます。ただし、この本の例を実行して確認するには、 以下のツールをインストールする必要があります:

  • この本のすべてのコードは 2024 エディションを使用しています。2024 の機能や イディオムに慣れていない場合は、edition guide を確認してください。

  • ARM Cortex-M コンパイルをサポートする Rust 1.89 以降のツールチェーン。

  • cargo-binutils。v0.1.4 以降。

  • cargo-edit

  • ARM エミュレーションをサポートする QEMU。qemu-system-arm プログラムが コンピュータにインストールされている必要があります。

  • ARM サポート付きの GDB。

セットアップ例

すべての OS に共通の手順

$ # Rust ツールチェーン
$ # ゼロから始める場合は、https://rustup.rs/ から rustup を入手してください
$ rustup default stable

$ # ツールチェーンはこれより新しい必要があります
$ rustc -V
rustc 1.89.0 (29483883e 2025-08-04)

$ rustup target add thumbv7m-none-eabi

$ # cargo-binutils
$ cargo install cargo-binutils

$ rustup component add llvm-tools

macOS

$ # arm-none-eabi-gdb
$ # 最初に `brew tap Caskroom/tap` を実行する必要があるかもしれません
$ brew install --cask gcc-arm-embedded

$ # QEMU
$ brew install qemu

Ubuntu 16.04

$ # arm-none-eabi-gdb
$ sudo apt install gdb-arm-none-eabi

$ # QEMU
$ sudo apt install qemu-system-arm

Ubuntu 18.04 (またはそれ以降) または Debian

$ # gdb-multiarch -- gdb を起動したいときは `gdb-multiarch` を使用します
$ sudo apt install gdb-multiarch

$ # QEMU
$ sudo apt install qemu-system-arm

Windows

ARM からツールチェーンバンドルをインストールする (任意の手順) (Ubuntu 18.04 でテスト済み)

  • 2018 年後半に Cortex-M マイクロコントローラ向けで GCC’s linker to LLD への切り替えが行われたため、gcc-arm-none-eabi はもはや 不要です。 それでもこのツールチェーンを使用したい場合は、 こちら からインストールし、以下の手順に従ってください:
$ tar xvjf gcc-arm-none-eabi-8-2018-q4-major-linux.tar.bz2
$ mv gcc-arm-none-eabi-<version_downloaded> <your_desired_path> # 任意
$ export PATH=${PATH}:<path_to_arm_none_eabi_folder>/bin # 永続化するには、この行を .bashrc に追加します

最小の #![no_std] プログラム

この節では、コンパイルできる 最小の #![no_std] プログラムを書きます。

#![no_std] とは何か?

#![no_std] はクレートレベル属性であり、そのクレートが std クレートではなく core クレートにリンクすることを示します。では、これはアプリケーションにとって何を意味するのでしょうか。

std クレートは Rust の標準ライブラリです。これには、プログラムが directly on the metal ではなくオペレーティングシステム上で動作することを前提とした機能が含まれています。std はまた、そのオペレーティングシステムがサーバーやデスクトップで見られるような汎用 OS であることも前提としています。そのため、std は、そのような OS に通常備わっている機能、すなわちスレッド、ファイル、ソケット、ファイルシステム、プロセスなどに対する標準 API を提供します。

一方、core クレートは std クレートのサブセットであり、プログラムが実行されるシステムについて一切前提を置きません。そのため、浮動小数点数、文字列、スライスといった言語プリミティブの API に加え、アトミック操作や SIMD 命令のようなプロセッサ機能を扱う API も提供します。ただし、ヒープメモリ割り当てや I/O を伴うものの API はありません。

アプリケーションにとって、std は OS 抽象へのアクセス手段を提供する以上のことを行います。たとえば、スタックオーバーフロー保護の設定、コマンドライン引数の処理、プログラムの main 関数が呼び出される前のメインスレッドの生成などを担います。#![no_std] アプリケーションには、そうした標準ランタイムがありません。そのため、必要であれば自前のランタイムを初期化しなければなりません。

こうした性質のため、#![no_std] アプリケーションは、システム上で最初に、そして / または唯一実行されるコードになりえます。たとえば、標準的な Rust アプリケーションでは決してなれない次のようなものになれます。

  • OS のカーネル。
  • ファームウェア。
  • ブートローダー。

コード

前置きはこのくらいにして、コンパイルできる最小の #![no_std] プログラムに進みましょう。

$ cargo new --edition 2024 --bin app

$ cd app
$ # main.rs を次の内容になるように変更する
$ cat src/main.rs
#![allow(unused)]
#![no_main]
#![no_std]

fn main() {
use core::panic::PanicInfo;

#[panic_handler]
#[inline(never)]
fn panic(_panic: &PanicInfo<'_>) -> ! {
    loop {}
}
}

このプログラムには、標準的な Rust プログラムでは見かけないものがいくつか含まれています。

すでに詳しく扱った #![no_std] 属性です。

#![no_main] 属性は、このプログラムが標準の main 関数をエントリポイントとして使わないことを意味します。執筆時点では、Rust の main インターフェースはプログラムが実行される環境についていくつかの前提を置いています。たとえば、コマンドライン引数の存在を前提としているため、一般には #![no_std] プログラムには適していません。

#[panic_handler] 属性です。この属性が付いた関数は、ライブラリレベルの panic (core::panic!) と、言語レベルの panic(範囲外インデックスアクセス)の両方について、その動作を定義します。

このプログラムは有用なものを何も生成しません。実際、生成されるのは空のバイナリです。

$ # `size target/thumbv7m-none-eabi/debug/app` と同等
$ cargo size --target thumbv7m-none-eabi --bin app
   text	   data	    bss	    dec	    hex	filename
      0	      0	      0	      0	      0	app

リンク前の時点では、このクレートには panic 処理のシンボルが含まれています。

$ cargo rustc --target thumbv7m-none-eabi -- --emit=obj

$ cargo nm -- -C $(pwd)/target/thumbv7m-none-eabi/debug/deps/app-*.o | grep '[0-9]* [^N] '
00000000 T __rustc::rust_begin_unwind

しかし、これは出発点です。次の節では、何か役に立つものを作ります。ただしその前に、Cargo を呼び出すたびに --target フラグを渡さなくて済むよう、デフォルトのビルドターゲットを設定しましょう。

$ mkdir .cargo

$ # .cargo/config.toml を次の内容になるように変更する
$ cat .cargo/config.toml
[build]
target = "thumbv7m-none-eabi"

eh_personality

設定が panic 時に無条件で abort しない場合、これは完全なオペレーティングシステム向けの大半のターゲットでそうなっていません(あるいは custom target"panic-strategy": "abort" が含まれていない場合)、Cargo にそのように指示するか、eh_personality 関数を追加しなければなりません。後者には nightly コンパイラが必要です。これに関する Rust のドキュメントはこちらです。また、これに関する議論はこちらです

Cargo.toml に次を追加します。

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

あるいは、eh_personality 関数を宣言します。アンワインド時に特別なことを何もしない単純な実装は次のとおりです。

#![allow(unused)]
#![feature(lang_items)]

fn main() {
#[lang = "eh_personality"]
extern "C" fn eh_personality() {}
}

これを含めない場合、language item required, but not found: 'eh_personality' というエラーが発生します。

メモリレイアウト

次のステップは、ターゲットシステムがプログラムを実行できるように、プログラムが適切なメモリレイアウトを 持つことを確認することです。この例では、仮想的な Cortex-M3 マイクロコントローラである LM3S6965 を扱います。プログラムはこのデバイス上で動作する唯一のプロセスになるため、 デバイスの初期化も行わなければなりません。

背景情報

Cortex-M デバイスでは、code memory region の先頭に vector table が存在している必要があります。 ベクタテーブルはポインタの配列です。最初の 2 つのポインタはデバイスの起動に必要で、 残りのポインタは例外に関連しています。ここではひとまずそれらを無視します。

リンカはプログラムの最終的なメモリレイアウトを決定しますが、linker scripts を使うことで ある程度これを制御できます。リンカスクリプトがレイアウトに対して提供する制御の粒度 は、セクション のレベルです。セクションとは、連続したメモリ上に配置された シンボル の集合です。 シンボルは、データ(静的変数)であることもあれば、命令(Rust 関数)であることもあります。

すべてのシンボルには、コンパイラによって名前が割り当てられます。Rust 1.28 の時点では、Rust コンパイラが シンボルに割り当てる名前は _ZN5krate6module8function17he1dfc17c86fe16daE のような形式で、 これをデマングルすると krate::module::function::he1dfc17c86fe16da になります。ここで krate::module::function は関数または変数のパスであり、he1dfc17c86fe16da は何らかの ハッシュです。Rust コンパイラは各シンボルをそれぞれ固有のセクションに配置します。たとえば、先ほどの シンボルは .text._ZN5krate6module8function17he1dfc17c86fe16daE という名前のセクションに 配置されます。

コンパイラが生成するこれらのシンボル名とセクション名は、Rust コンパイラの異なるリリース間で 一定に保たれる保証はありません。しかし、言語仕様により、以下の属性を通じてシンボル名と セクション配置を制御できます。

  • #[unsafe(export_name = "foo")] はシンボル名を foo に設定します。
  • #[unsafe(no_mangle)] は、関数または変数名(完全なパスではなく)をそのシンボル名として使うことを意味します。 #[unsafe(no_mangle)] fn bar()bar という名前のシンボルを生成します。
  • #[unsafe(link_section = ".bar")] は、そのシンボルを .bar という名前のセクションに配置します。

これらの属性により、プログラムの安定した ABI を公開し、それをリンカスクリプトで利用できます。 これらの属性にはそれぞれ、プログラム内で守らなければならない安全性上の保証があります。具体的には次のとおりです。

  • #[unsafe(no_mangle)] は、crate、module、変数または 関数名や引数に関係なく、シンボル名を固定します。この属性を使うと、同じ名前のシンボルを 複数作成できてしまい、未定義動作につながる可能性があります。ユーザーは、そのシンボルが ほかのものと衝突しないことを保証しなければなりません。
  • #[unsafe(export_name = "foo")] もシンボル名を固定します。ユーザーは、 選択したシンボル名(この場合は foo)がプログラム内のほかのどのシンボルとも衝突しないことを保証しなければなりません。
  • #[unsafe(link_section = ".bar")] は、データやコードを、それらを想定していないメモリセクションに 配置してしまう可能性があります。たとえば、可変データを読み取り専用領域に置いたり、ターゲットデバイス上に存在しない メモリ領域に置いたりすることがあります。

Rust 側

前述のとおり、Cortex-M デバイスでは、ベクタテーブルの最初の 2 つのエントリを 埋める必要があります。1 つ目であるスタックポインタの初期値は、リンカスクリプトだけで 設定できます。2 つ目であるリセットベクタは、Rust コードで作成し、 リンカスクリプトを使って正しい位置に配置する必要があります。

リセットベクタは、リセットハンドラを指すポインタです。リセットハンドラは、システムリセット後、 または最初の電源投入後にデバイスが実行する関数です。リセットハンドラは常にハードウェアの コールスタックにおける最初のスタックフレームです。そのため、そこから return する先の スタックフレームがほかに存在せず、そこからの return は未定義動作です。リセットハンドラが 決して return しないことは、それを発散関数にすることで強制できます。発散関数とは、 シグネチャが fn(/* .. */) -> ! の関数です。

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub unsafe extern "C" fn Reset() -> ! {
    let _x = 42;

    // can't return so we go into an infinite loop here
    loop {}
}

// The reset vector, a pointer into the reset handler
#[unsafe(link_section = ".vector_table.reset_vector")]
#[unsafe(no_mangle)]
pub static RESET_VECTOR: unsafe extern "C" fn() -> ! = Reset;

}

ここではハードウェアが特定の形式を期待しているため、extern "C" を使って、この関数を 不安定な Rust ABI ではなく C ABI でコンパイラに生成させることで、それに従っています。

リンカスクリプトからリセットハンドラとリセットベクタを参照するには、それらが安定した シンボル名を持つ必要があるため、#[unsafe(no_mangle)] を使います。RESET_VECTOR の位置は 細かく制御する必要があるので、既知のセクション .vector_table.reset_vector に配置します。 リセットハンドラ自身である Reset の正確な位置は重要ではありません。こちらは単に、 コンパイラがデフォルトで生成するセクションのままにしておきます。

リンカは、入力オブジェクトファイルの一覧をたどる際に、内部リンケージを持つシンボル (内部シンボルとも呼ばれます)を無視します。そのため、これら 2 つのシンボルには外部リンケージが 必要です。Rust でシンボルを外部にする唯一の方法は、対応するアイテムを public(pub)にし、 かつ 到達可能 にすることです(アイテムと crate のルートの間に private な module があってはいけません)。

リンカスクリプト側

ベクタテーブルを正しい位置に配置する最小限のリンカスクリプトを以下に示します。 順に見ていきましょう。

$ cat link.x
/* Memory layout of the LM3S6965 microcontroller */
/* 1K = 1 KiBi = 1024 bytes */
MEMORY
{
  FLASH : ORIGIN = 0x00000000, LENGTH = 256K
  RAM : ORIGIN = 0x20000000, LENGTH = 64K
}

/* The entry point is the reset handler */
ENTRY(Reset);

EXTERN(RESET_VECTOR);

SECTIONS
{
  .vector_table ORIGIN(FLASH) :
  {
    /* First entry: initial Stack Pointer value */
    LONG(ORIGIN(RAM) + LENGTH(RAM));

    /* Second entry: reset vector */
    KEEP(*(.vector_table.reset_vector));
  } > FLASH

  .text :
  {
    *(.text .text.*);
  } > FLASH

  /DISCARD/ :
  {
    *(.ARM.exidx .ARM.exidx.*);
  }
}

MEMORY

リンカスクリプトのこのセクションでは、ターゲット内のメモリブロックの位置とサイズを記述します。 FLASHRAM という 2 つのメモリブロックが定義されています。これらはターゲットで利用可能な 物理メモリに対応しています。ここで使われている値は、LM3S6965 マイクロコントローラに対応しています。

ENTRY

ここでは、シンボル名が Reset であるリセットハンドラが、プログラムの エントリポイント であることをリンカに示します。リンカは未使用のセクションを積極的に破棄します。 リンカは、エントリポイントと、そこから呼び出される関数を 使用済み とみなすため、それらは破棄されません。 この行がないと、リンカは Reset 関数と、そこから後続で呼び出されるすべての関数を破棄してしまいます。

EXTERN

リンカは遅延的に動作します。エントリポイントから再帰的に参照されるすべてのシンボルを見つけると、 入力オブジェクトファイルの探索をやめてしまいます。EXTERN は、ほかの参照シンボルをすべて見つけたあとでも、 EXTERN の引数を探すようリンカに強制します。経験則として、エントリポイントから呼び出されない シンボルを出力バイナリ内に常に存在させたい場合は、KEEP と併用して EXTERN を使うべきです。

SECTIONS

この部分では、入力オブジェクトファイル内のセクション(入力セクション とも呼ばれます)を、 出力オブジェクトファイルのセクション(出力セクションとも呼ばれます)内でどのように配置するか、 あるいは破棄するかを記述します。ここでは 2 つの出力セクションを定義します。

  .vector_table ORIGIN(FLASH) : { /* .. */ } > FLASH

.vector_table にはベクタテーブルが含まれ、FLASH メモリの先頭に配置されます。

  .text : { /* .. */ } > FLASH

.text にはプログラムのサブルーチンが含まれており、FLASH 内のどこかに配置されます。その開始 アドレスは指定されていませんが、リンカがこれを直前の出力セクションである .vector_table の後に配置します。

出力 .vector_table セクションには次のものが含まれます:

    /* First entry: initial Stack Pointer value */
    LONG(ORIGIN(RAM) + LENGTH(RAM));

(コール)スタックは RAM の末尾に配置します(スタックは フルディセンディング であり、小さい アドレスの方向へ成長します)。そのため、RAM の末尾アドレスが初期スタックポインタ(SP)値として使用されます。 そのアドレスは、RAM メモリブロックに対して入力した情報を使って、リンカスクリプト自身の中で計算されます。

    /* Second entry: reset vector */
    KEEP(*(.vector_table.reset_vector));

次に、KEEP を使って、.vector_table.reset_vector という名前のすべての入力セクションを 初期 SP 値の直後にリンカが挿入するよう強制します。そのセクションに配置されているシンボルは RESET_VECTOR だけなので、これにより実質的に RESET_VECTOR がベクターテーブルの 2 番目に配置されます。

出力 .text セクションには次のものが含まれます:

    *(.text .text.*);

これには、.text および .text.* という名前のすべての入力セクションが含まれます。未使用の セクションをリンカに破棄させるため、ここでは KEEP を使っていないことに注意してください。

最後に、特別な /DISCARD/ セクションを使って、

    *(.ARM.exidx .ARM.exidx.*);

という名前の入力セクションを破棄します。これらのセクションは例外処理に関連していますが、panic 時に スタックアンワインドは行わず、Flash メモリの容量も消費するため、単に破棄します。

すべてをまとめる

これでアプリケーションをリンクできます。参考までに、完全な Rust プログラムは次のとおりです:

#![allow(unused)]
#![no_main]
#![no_std]

fn main() {
use core::panic::PanicInfo;

// The reset handler
#[unsafe(no_mangle)]
pub unsafe extern "C" fn Reset() -> ! {
    let _x = 42;

    // can't return so we go into an infinite loop here
    loop {}
}

// The reset vector, a pointer into the reset handler
#[unsafe(link_section = ".vector_table.reset_vector")]
#[unsafe(no_mangle)]
pub static RESET_VECTOR: unsafe extern "C" fn() -> ! = Reset;

#[panic_handler]
fn panic(_panic: &PanicInfo<'_>) -> ! {
    loop {}
}
}

リンカに自分たちのリンカスクリプトを使わせるために、リンクプロセスを少し調整する必要があります。これは rustc-C link-arg フラグを渡すことで行います。これは cargo-rustc または cargo-build で行えます。

重要: このコマンドを実行する前に、前のセクションの最後で追加した .cargo/config.toml ファイルがあることを 確認してください。

cargo-rustc サブコマンドを使う場合:

$ cargo rustc -- -C link-arg=-Tlink.x

あるいは、.cargo/config.toml で rustflags を設定して、引き続き cargo-build サブコマンドを使うこともできます。ここでは後者を使います。こちらの方が cargo-binutils との統合がよりうまく行えるためです。

# .cargo/config.toml を次の内容になるように変更する
$ cat .cargo/config.toml
[target.thumbv7m-none-eabi]
rustflags = ["-C", "link-arg=-Tlink.x"]

[build]
target = "thumbv7m-none-eabi"

[target.thumbv7m-none-eabi] の部分は、これらのフラグがそのターゲットへクロスコンパイルするときにのみ使用されることを示しています。

確認する

では、出力バイナリを調べて、メモリレイアウトが意図したとおりになっていることを確認しましょう (これには cargo-binutils が必要です):

$ cargo objdump --bin app -- -d --no-show-raw-insn --print-imm-hex

app:	file format elf32-littlearm

Disassembly of section .text:

00000008 <Reset>:
       8:      	push	{r7, lr}
       a:      	mov	r7, sp
       c:      	sub	sp, #0x4
       e:      	movs	r0, #0x2a
      10:      	str	r0, [sp]
      12:      	b	0x14 <Reset+0xc>        @ imm = #-0x2
      14:      	b	0x14 <Reset+0xc>        @ imm = #-0x4

これは .text セクションの逆アセンブル結果です。Reset という名前のリセットハンドラが、 アドレス 0x8 に配置されていることがわかります。

$ cargo objdump --bin app -- -s --section .vector_table

app:	file format elf32-littlearm
Contents of section .vector_table:
 0000 00000120 09000000                    ... ....

これは .vector_table セクションの内容を示しています。セクションがアドレス 0x0 から始まり、 セクションの最初のワードが 0x2001_0000 であることがわかります(objdump の出力は リトルエンディアン形式です)。これは初期 SP 値であり、RAM の末尾アドレスと一致します。2 番目の ワードは 0x9 です。これはリセットハンドラの thumb モード アドレスです。関数を thumb モードで実行する場合、そのアドレスの最下位ビットは 1 に設定されます。

テストする

このプログラムは有効な LM3S6965 プログラムです。仮想マイクロコントローラ(QEMU)上で実行して 動作をテストできます。

$ # このプログラムはブロックします
$ qemu-system-arm \
      -cpu cortex-m3 \
      -machine lm3s6965evb \
      -gdb tcp::3333 \
      -S \
      -nographic \
      -kernel target/thumbv7m-none-eabi/debug/app
$ # 別のターミナルで
$ arm-none-eabi-gdb -q target/thumbv7m-none-eabi/debug/app
Reading symbols from target/thumbv7m-none-eabi/debug/app...done.

(gdb) target remote :3333
Remote debugging using :3333
Reset () at src/main.rs:8
8       pub unsafe extern "C" fn Reset() -> ! {

(gdb) # SP は、ベクターテーブルにプログラムした初期値になっている
(gdb) print/x $sp
$1 = 0x20010000

(gdb) step
9           let _x = 42;

(gdb) step
12          loop {}

(gdb) # 次に、スタック変数 `_x` を調べる
(gdb) print _x
$2 = 42

(gdb) print &_x
$3 = (i32 *) 0x2000fff4

(gdb) quit

main インターフェース

これで最小限に動作するプログラムはできましたが、エンドユーザーがその上に安全なプログラムを構築できる形でこれをパッケージ化する必要があります。この節では、標準的な Rust プログラムが使うものと同様の main インターフェースを実装します。

まず、バイナリクレートをライブラリクレートに変換します:

$ mv src/main.rs src/lib.rs

そして、これを “runtime” の略である rt にリネームします。

$ sed -i s/app/rt/ Cargo.toml

$ head -n4 Cargo.toml
[package]
edition = "2024"
name = "rt" # <-
version = "0.1.0"

最初の変更は、リセットハンドラが外部の main 関数を呼び出すようにすることです:

$ head -n13 src/lib.rs
#![no_std]

use core::panic::PanicInfo;

// CHANGED!
#[unsafe(no_mangle)]
pub unsafe extern "C" fn Reset() -> ! {
    unsafe extern "Rust" {
        safe fn main() -> !;
    }

    main()
}

また、#![no_main] 属性も削除します。これはライブラリクレートでは効果がないためです。

この段階では別の観点の問いも生じます。rt ライブラリは標準的な panic 時の挙動を提供すべきでしょうか。それとも、 #[panic_handler] 関数を 提供しない ことで、panic 時の挙動をエンドユーザーが 選べるようにしておくべきでしょうか。この文書ではその問いには立ち入りません。単純化のため、 ダミーの #[panic_handler] 関数は rt クレートに残しておきます。ただし、 他の選択肢もあることは読者に伝えておきたいと思います。

2 つ目の変更は、先ほど書いたリンカスクリプトをアプリケーションクレートに提供することです。リンカーは、ライブラリ検索パス (-L) と、リンカーが起動されるディレクトリでリンカスクリプトを検索します。アプリケーションクレートが link.x のコピーを同梱しておく必要はないので、build script を使って rt クレートがリンカスクリプトをライブラリ検索パスに配置するようにします。

$ # `rt` のルートに次の内容で build.rs ファイルを作成する
$ cat build.rs
use std::{env, error::Error, fs::File, io::Write, path::PathBuf};

fn main() -> Result<(), Box<dyn Error>> {
    // build directory for this crate
    let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());

    // extend the library search path
    println!("cargo:rustc-link-search={}", out_dir.display());

    // put `link.x` in the build directory
    File::create(out_dir.join("link.x"))?.write_all(include_bytes!("link.x"))?;

    Ok(())
}

これでユーザーは main シンボルを公開するアプリケーションを書き、それを rt クレートにリンクできるようになります。 rt は、プログラムに適切なメモリレイアウトを与える役目を担います。

$ cd ..

$ cargo new --edition 2024 --bin app

$ cd app

$ # `rt` クレートを依存関係として含めるように Cargo.toml を変更する
$ tail -n2 Cargo.toml
[dependencies]
rt = { path = "../rt" }
$ # デフォルトターゲットを設定し、リンカー呼び出しを調整する設定ファイルをコピーする
$ cp -r ../rt/.cargo .

$ # `main.rs` の内容を次のように変更する
$ cat src/main.rs
#![no_std]
#![no_main]

extern crate rt;

#[unsafe(no_mangle)]
pub fn main() -> ! {
    let _x = 42;

    loop {}
}

逆アセンブル結果は似たものになりますが、今度はユーザーの main 関数が含まれます。

$ cargo objdump --bin app -- -d --no-show-raw-insn --print-imm-hex

app:	file format elf32-littlearm

Disassembly of section .text:

00000008 <main>:
       8:      	push	{r7, lr}
       a:      	mov	r7, sp
       c:      	sub	sp, #0x4
       e:      	movs	r0, #0x2a
      10:      	str	r0, [sp]
      12:      	b	0x14 <main+0xc>         @ imm = #-0x2
      14:      	b	0x14 <main+0xc>         @ imm = #-0x4

00000016 <Reset>:
      16:      	push	{r7, lr}
      18:      	mov	r7, sp
      1a:      	bl	0x8 <main>              @ imm = #-0x16

型安全にする

main インターフェースは機能しますが、間違えやすいものです。たとえば、ユーザーが main を発散しない関数として書いても、コンパイル時エラーは発生せず、未定義動作になります(コンパイラがプログラムを誤って最適化します)。

シンボルインターフェースの代わりにマクロをユーザーに公開することで、型安全性を追加できます。 rt クレートでは、このマクロを次のように書けます:

$ tail -n12 ../rt/src/lib.rs
#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! entry {
    ($path:path) => {
        #[unsafe(export_name = "main")]
        pub unsafe fn __main() -> ! {
            // type check the given path
            let f: fn() -> ! = $path;

            f()
        }
    };
}
}

すると、アプリケーションの作者は次のようにこれを呼び出せます:

$ cat src/main.rs
#![no_std]
#![no_main]

use rt::entry;

entry!(main);

fn main() -> ! {
    let _x = 42;

    loop {}
}

これで作者は、main のシグネチャを 発散しない関数、たとえば fn() に変更するとエラーを受け取るようになります。

main より前

rt はかなり良くなってきましたが、まだ機能的に完全ではありません。これを前提に書かれたアプリケーションでは、rt のリンカスクリプトが標準の .bss.data.rodata セクションを定義していないため、static 変数や文字列リテラルを使えません。これを直しましょう!

最初のステップは、リンカスクリプトでこれらのセクションを定義することです:

$ # ファイルの一部だけを表示
$ sed -n 25,46p ../rt/link.x
  .text :
  {
    *(.text .text.*);
  } > FLASH

  /* NEW! */
  .rodata :
  {
    *(.rodata .rodata.*);
  } > FLASH

  .bss :
  {
    *(.bss .bss.*);
  } > RAM

  .data :
  {
    *(.data .data.*);
  } > RAM

  /DISCARD/ :

これらは単に入力セクションを再エクスポートし、各出力セクションをどのメモリ領域に配置するかを指定しているだけです。

これらの変更により、次のプログラムはコンパイルできるようになります:

#![no_std]
#![no_main]

use rt::entry;

entry!(main);

static RODATA: &[u8] = b"Hello, world!";
static mut BSS: u8 = 0;
static mut DATA: u16 = 1;

#[allow(static_mut_refs)]
fn main() -> ! {
    let _x = RODATA;
    let _y = unsafe { &BSS };
    let _z = unsafe { &DATA };

    loop {}
}

しかし、このプログラムを実機で実行してデバッグすると、main に到達した時点で static 変数 BSSDATA01 になっていないことが分かります。 その代わり、これらの変数にはゴミ値が入っています。問題は、デバイスの電源投入後の RAM の内容が ランダムであることです。プログラムを QEMU で実行しても、この現象は観測できません。

現状では、プログラムが static 変数に書き込む前にそれを読み取ると、 未定義動作になります。main を呼び出す前にすべての static 変数を初期化して、これを修正しましょう。

RAM の初期化を行うには、リンカスクリプトをもう少し調整する必要があります:

$ # ファイルの一部だけを表示
$ sed -n 25,52p ../rt/link.x
  .text :
  {
    *(.text .text.*);
  } > FLASH

  /* CHANGED! */
  .rodata :
  {
    *(.rodata .rodata.*);
  } > FLASH

  .bss :
  {
    _sbss = .;
    *(.bss .bss.*);
    _ebss = .;
  } > RAM

  .data : AT(ADDR(.rodata) + SIZEOF(.rodata))
  {
    _sdata = .;
    *(.data .data.*);
    _edata = .;
  } > RAM

  _sidata = LOADADDR(.data);

  /DISCARD/ :

これらの変更の詳細を見ていきましょう:

    _sbss = .;
    _ebss = .;
    _sdata = .;
    _edata = .;

.bss.data セクションの開始アドレスと終了アドレスにシンボルを対応付けます。これらは後で初期化に使います。

  .data : AT(ADDR(.rodata) + SIZEOF(.rodata))

.data セクションの Load Memory Address (LMA) を .rodata セクションの末尾に設定します。.data には初期値が 0 ではない static 変数が入ります。.data セクションの Virtual Memory Address (VMA) は RAM 上のどこかにあり、そこが static 変数の 配置場所です。しかし、それらの static 変数の初期値は不揮発性メモリ(Flash)に 配置されていなければなりません。LMA は、それらの初期値が Flash のどこに格納されるかを示します。

  _sidata = LOADADDR(.data);

最後に、.data の LMA にシンボルを対応付けます。

初期化コードを使って、.bss セクションをゼロクリアし、.data セクションを初期化します。リンカスクリプトで作成したシンボルは、コードから参照できます。これらのシンボルの アドレス1.bss.data セクションの境界になります。

純粋な Rust コードで .bss.data セクションの初期化コードを書くこともできます。実際、 この本の以前の版ではそうしていました。しかし、時間の経過とともに健全性に関するいくつかの疑問が提起されており、 いまでは Rust コードでこれらを初期化することは良い実践とは見なされなくなっています。詳しくは、この本の なぜ .data と .bss を Rust で初期化しないのか の節を参照してください。 global_asm! マクロを使って初期化コードを書き、リセットハンドラを定義します。

更新されたリセットハンドラは、今度は Thumb-2 アセンブリで書かれており、以下のとおりです:

$ head -n53 ../rt/src/lib.rs
#![allow(unused)]
#![no_std]

fn main() {
use core::panic::PanicInfo;

use core::arch::global_asm;

global_asm!(
    ".text

     .syntax unified
     .global _sbss
     .global _ebss

     .global _sdata
     .global _edata
     .global _sidata

     .global main
     .global Reset

     .type Reset,%function
     .thumb_func
     Reset:

     _init_bss:
         movs r2, #0
         ldr r0, =_sbss
         ldr r1, =_ebss

     1:
         cmp r1, r0
         beq _init_data
         strb r2, [r0]
         add r0, #1
         b 1b

     _init_data:
         ldr r0, =_sdata
         ldr r1, =_edata
         ldr r2, =_sidata

     1:
         cmp r0, r1
         beq _main_trampoline
         ldrb r3, [r2]
         strb r3, [r0]
         add r0, #1
         add r2, #1
         b 1b
     _main_trampoline:
         ldr r0, =main
         bx r0"
);
}

これで、エンドユーザーは未定義動作に陥ることなく、static 変数を直接的にも間接的にも利用できます!

上のコードでは、メモリ初期化を 1 バイトずつ行いました。.bss.data セクションを、たとえば 4 バイト境界にそろえるよう強制することも可能です。この事実はその後 Rust コードで利用でき、アラインメントチェックを省略しつつワード単位で初期化を行えます。これを どう実現するかに興味があるなら、cortex-m-rt クレートを確認してください。


  1. ここではリンカスクリプトのシンボルのアドレスを使わなければならないという点が、分かりにくく直感に反するかもしれません。この奇妙な点についての詳しい説明は こちら にあります。

なぜ .data.bss を Rust で初期化しないのか

この本の以前のバージョンでは、.data セクションと .bss セクションを Rust コードで初期化していました。 しかし、これは健全性に疑義があることが判明しており、今日では、これらのセクションの初期化を行う推奨される方法はアセンブリに依存しています。

この章では、cortex-m-rtriscv-rt のような さまざまな crate が、これらのセクションの初期化をアセンブリで行う方式へ移行することを決定した理由について説明します。こうしたコードの健全性については、これまでに a decent number of threads で疑問が呈されてきました。これらをこの章で要約します。

この本で Rust によるグローバルデータ初期化に使われていた元のコードを以下に示します。

#![no_std]

use core::panic::PanicInfo;
use core::ptr;

#[unsafe(no_mangle)]
#[allow(static_mut_refs)]
pub unsafe extern "C" fn Reset() -> ! {
    // NEW!
    // Initialize RAM
    unsafe extern "C" {
        static mut _sbss: u8;
        static mut _ebss: u8;

        static mut _sdata: u8;
        static mut _edata: u8;
        static _sidata: u8;
    }

    let count = unsafe { &_ebss as *const u8 as usize - &_sbss as *const u8 as usize };
    unsafe { ptr::write_bytes(&mut _sbss as *mut u8, 0, count) };

    let count = unsafe { &_edata as *const u8 as usize - &_sdata as *const u8 as usize };
    unsafe { ptr::copy_nonoverlapping(&_sidata as *const u8, &mut _sdata as *mut u8, count) };

    // Call user entry point
    unsafe extern "Rust" {
        safe fn main() -> !;
    }

    main()
}

特定のメモリ位置を参照するために、5 つの extern "C" 変数が宣言されています。 リンカスクリプトが各シンボルを定義しているため、それらの正確な配置を心配する必要はありません。

ポインタ provenance

.bss セクションを初期化するために、.bss セクションの先頭を指す _sbss u8 変数のアドレスを取得します。 そして、その場所に任意の量のデータを書き込みます。_sbssu8 変数として宣言されており、 ポインタ provenance の規則では、_sbss 変数の allocation に収まる量のデータしか書き込めません。 それにもかかわらず、私たちはその 1 バイトを超えて(少なくとも Rust から見れば、このアドレスには 1 バイトしか割り当てられていません)、 _ebss の位置に達するまで書き込んでいます。

別の問題として、.bss セクションの 1 バイト外を指す _ebss 変数が実際に存在しています。 特定の実装では、.bss セクションが使用可能なメモリを使い切っている場合、このバイトにはアクセスすらできない可能性があります。 理想的には、_ebss は ZST として宣言される必要があります。そしてその延長として、.bss セクションは空である可能性があるため、 _sbss も ZST であるべきです。なぜなら、この場合 _sbss.bss 用に予約された領域の外側に位置することになるからです。

エイリアシング

上のコードにおけるもう 1 つの潜在的な問題はエイリアシングです。リンカスクリプトを考えてみましょう。

  .bss :
  {
    _sbss = .;
    *(.bss .bss.*);
    _ebss = .;
  } > RAM

  .data : AT(ADDR(.rodata) + SIZEOF(.rodata))
  {
    _sdata = .;
    *(.data .data.*);
    _edata = .;
  } > RAM

次のような状況が起こりえます。

  • .bss セクションが空でない場合、_sbss.bss セクション内の最初の変数と同じアドレスに配置される可能性があります。
  • _ebss_sdata と同じアドレスに配置され、さらに言えば、 .data セクション内の最初の変数とも同じアドレスに配置されます。
  • .bss セクションが空の場合、_sbss_ebss は互いにエイリアスします。
  • .data セクションが空の場合、_sdata_edata は互いにエイリアスします。

Rust では、同じアドレスに複数の変数を配置することは許されていません (ZST は重要な例外です)。しかし、たとえ許されていたとしても、私たちはこれらの変数を使って グローバルメモリ領域全体に書き込んでおり、これは実質的にプログラム内で定義された すべてのグローバルデータを可変にエイリアスしていることになります。

抽象マシンの初期化

もう 1 つの疑問は、Rust の抽象マシンが完全に初期化される前に Rust コードへ入っても安全かどうかです。 グローバルメモリがまだ初期化されていない間、Rust がそれを一切使わないと期待してよいのでしょうか。 この問いに対する答えは明確ではありません(少なくとも、この節の執筆時点で著者には明確ではないようです)。

provenance に関するさらなる潜在的な問題

気の利く読者なら、_ebss_sbss の間のオフセットをどのように計算しているかを見て、 代わりにポインタの offset_from メソッドを使えないだろうか、と思ったかもしれません。

しかしこの方法の問題は、前述したように、_ebss_sbss は異なる allocation に属しているため、同じポインタ provenance を共有していないことです。これは、それらが互いにエイリアスしていて、 たまたま同じアドレスに配置されている場合(つまり .bss セクションが空の場合)でも同じです。

この Rust Playground のスニペット で Miri を実行すると、未定義動作が示されます。

なるほど、でも動いてはいるんですよね?

はい。この章の冒頭で示したコードは、Rust 1.89 時点では正しい 挙動を示します。しかし問題は、この挙動が将来のリリースでも維持されることに頼れない ということです。さらには、将来オプティマイザが何か妙なことをしないとも限りません。

そのため、全体として、この本の推奨は、この目的のための初期化を Rust コードで行わないことです。

例外処理

「メモリレイアウト」セクションでは、まずは単純に始めるため、例外の処理は扱わないことにしました。このセクションでは、それらを処理するためのサポートを追加します。これは、stable Rust でコンパイル時にオーバーライド可能な動作を実現する方法の一例にもなります(つまり、シンボルを弱くする不安定な #[linkage = "weak"] 属性に依存しません)。

背景情報

要するに、例外 とは、Cortex-M などのアーキテクチャがアプリケーションに対して非同期の、通常は外部からのイベントに応答させるために提供している仕組みです。多くの人になじみのある最も代表的な例外の種類は、古典的な(ハードウェア)割り込みです。

Cortex-M の例外機構は次のように動作します。 プロセッサがある種類の例外に関連付けられた信号やイベントを受け取ると、現在のサブルーチンの実行を中断し(状態をコールスタックに退避して)、その後、新しいスタックフレーム上で対応する例外ハンドラー、つまり別のサブルーチンを実行します。例外ハンドラーの実行が終了すると(つまり、そこからリターンすると)、プロセッサは中断されていたサブルーチンの実行を再開します。

プロセッサは、どのハンドラーを実行するかを決めるためにベクターテーブルを使います。テーブル内の各エントリにはハンドラーへのポインターが含まれており、各エントリはそれぞれ異なる種類の例外に対応しています。たとえば、2 番目のエントリはリセットハンドラー、3 番目のエントリは NMI(非マスカブル割り込み)ハンドラー、といった具合です。

前に述べたとおり、プロセッサはベクターテーブルがメモリ上の特定の場所にあることを前提としており、その中の各エントリは実行時にプロセッサによって使用される可能性があります。したがって、エントリには常に有効な値が入っていなければなりません。さらに、rt クレートは柔軟であってほしいので、エンドユーザーが各例外ハンドラーの動作をカスタマイズできるようにしたいところです。最後に、ベクターテーブルは読み出し専用メモリ、あるいはより正確には簡単には変更できないメモリ上に置かれるため、ユーザーはハンドラーを実行時ではなく静的に登録しなければなりません。

これらすべての制約を満たすために、rt クレートではベクターテーブルのすべてのエントリに デフォルト 値を割り当てます。ただし、その値を一種の weak なものにして、エンドユーザーがコンパイル時にそれらをオーバーライドできるようにします。

Rust側

これをどのように実装できるのか見ていきましょう。簡単のため、ベクターテーブルの最初の 16 個のエントリだけを扱います。これらのエントリはデバイス固有ではないため、どの Cortex-M マイクロコントローラーでも同じ役割を持ちます。

まず最初に、rt クレートのコードにベクター(例外ハンドラーへのポインター)の配列を作成します。

$ sed -n 56,91p ../rt/src/lib.rs
#![allow(unused)]
fn main() {

pub union Vector {
    reserved: u32,
    handler: unsafe extern "C" fn(),
}

unsafe extern "C" {
    fn NMI();
    fn HardFault();
    fn MemManage();
    fn BusFault();
    fn UsageFault();
    fn SVCall();
    fn PendSV();
    fn SysTick();
}

#[unsafe(link_section = ".vector_table.exceptions")]
#[unsafe(no_mangle)]
pub static EXCEPTIONS: [Vector; 14] = [
    Vector { handler: NMI },
    Vector { handler: HardFault },
    Vector { handler: MemManage },
    Vector { handler: BusFault },
    Vector {
        handler: UsageFault,
    },
    Vector { reserved: 0 },
    Vector { reserved: 0 },
    Vector { reserved: 0 },
    Vector { reserved: 0 },
    Vector { handler: SVCall },
    Vector { reserved: 0 },
    Vector { reserved: 0 },
    Vector { handler: PendSV },
    Vector { handler: SysTick },
}

ベクターテーブル内の一部のエントリは 予約済み です。ARM のドキュメントでは、それらには値 0 を割り当てるべきとされているので、そのために union を使います。ハンドラーを指さなければならないエントリでは、外部 関数を使用します。これは、エンドユーザーが実際の関数定義を 提供 できるようにするために重要です。

次に、Rust コード内でデフォルトの例外ハンドラーを定義します。エンドユーザーによってハンドラーが割り当てられていない例外は、このデフォルトハンドラーを使います。

$ tail -n4 ../rt/src/lib.rs
#![allow(unused)]
fn main() {

#[unsafe(no_mangle)]
pub extern "C" fn DefaultExceptionHandler() {
    loop {}
}
}

リンカスクリプト側

リンカスクリプト側では、これらの新しい例外ベクターをリセットベクターの直後に配置します。

$ sed -n 12,25p ../rt/link.x
EXTERN(RESET_VECTOR);
EXTERN(EXCEPTIONS); /* <- NEW */

SECTIONS
{
  .vector_table ORIGIN(FLASH) :
  {
    /* First entry: initial Stack Pointer value */
    LONG(ORIGIN(RAM) + LENGTH(RAM));

    /* Second entry: reset vector */
    KEEP(*(.vector_table.reset_vector));

    /* The next 14 entries are exception vectors */
    KEEP(*(.vector_table.exceptions)); /* <- NEW */
  } > FLASH

また、rt 内で未定義のままにしておいたハンドラー(NMI と上にあるその他のもの)にデフォルト値を与えるために PROVIDE を使います。

$ tail -n8 ../rt/link.x
PROVIDE(NMI = DefaultExceptionHandler);
PROVIDE(HardFault = DefaultExceptionHandler);
PROVIDE(MemManage = DefaultExceptionHandler);
PROVIDE(BusFault = DefaultExceptionHandler);
PROVIDE(UsageFault = DefaultExceptionHandler);
PROVIDE(SVCall = DefaultExceptionHandler);
PROVIDE(PendSV = DefaultExceptionHandler);
PROVIDE(SysTick = DefaultExceptionHandler);

PROVIDE は、すべての入力オブジェクトファイルを調べ終えたあとでも、等号の左側にあるシンボルがまだ未定義である場合にのみ効果を持ちます。これは、ユーザーがその例外に対応するハンドラーを実装しなかった場合に該当します。

試してみる

これで完了です! rt クレートは、例外ハンドラーをサポートするようになりました。次のアプリケーションで試してみましょう。

: QEMU で例外を発生させるのは、実は難しいことがわかりました。実機 では、無効なメモリアドレス(つまり Flash 領域と RAM 領域の外側)を読み取れば十分ですが、QEMU はその操作を何事もなく受け入れて 0 を返してしまいます。トラップ命令は QEMU と実機の両方で動作しますが、 残念ながら純粋な Rust コードとしては利用できません。ここではインラインアセンブリを 使ってトラップ命令を生成します。後の章ではアセンブリの使い方を より詳しく説明します。

#![no_main]
#![no_std]

use core::arch::asm;
use rt::entry;

entry!(main);

fn main() -> ! {
    // this executes the undefined instruction (UDF) and causes a HardFault exception
    unsafe { asm!("udf #0", options(noreturn)) };
}
(gdb) target remote :3333
Remote debugging using :3333
Reset () at ../rt/src/lib.rs:7
7       pub unsafe extern "C" fn Reset() -> ! {

(gdb) b DefaultExceptionHandler
Breakpoint 1 at 0xec: file ../rt/src/lib.rs, line 95.

(gdb) continue
Continuing.

Breakpoint 1, DefaultExceptionHandler ()
    at ../rt/src/lib.rs:95
95          loop {}

(gdb) list
90          Vector { handler: SysTick },
91      ];
92
93      #[no_mangle]
94      pub extern "C" fn DefaultExceptionHandler() {
95          loop {}
96      }

参考までに、最適化版プログラムの逆アセンブルも示しておきます。

$ cargo objdump --bin app --release -- -d --no-show-raw-insn --print-imm-hex

app:	file format elf32-littlearm

Disassembly of section .text:

00000040 <main>:
      40:      	push	{r7, lr}
      42:      	mov	r7, sp
      44:      	udf	#0x0
      46:      	trap

00000048 <Reset>:
      48:      	push	{r7, lr}
      4a:      	mov	r7, sp
      4c:      	movw	r1, #0x0
      50:      	movw	r0, #0x0
      54:      	movt	r1, #0x2000
      58:      	movt	r0, #0x2000
      5c:      	subs	r1, r1, r0
      5e:      	bl	0x8a <__aeabi_memclr>   @ imm = #0x28
      62:      	movw	r1, #0x0
      66:      	movw	r0, #0x0
      6a:      	movt	r1, #0x2000
      6e:      	movt	r0, #0x2000
      72:      	subs	r2, r1, r0
      74:      	movw	r1, #0x44e
      78:      	movt	r1, #0x0
      7c:      	bl	0x15a <__aeabi_memcpy>  @ imm = #0xda
      80:      	bl	0x40 <main>             @ imm = #-0x44

00000084 <UsageFault>:
      84:      	push	{r7, lr}
      86:      	mov	r7, sp
      88:      	b	0x88 <UsageFault+0x4>   @ imm = #-0x4
$ cargo objdump --bin app --release -- -s -j .vector_table

app:	file format elf32-littlearm
Contents of section .vector_table:
 0000 00000120 49000000 85000000 85000000  ... I...........
 0010 85000000 85000000 85000000 00000000  ................
 0020 00000000 00000000 00000000 85000000  ................
 0030 00000000 00000000 85000000 85000000  ................

ベクタテーブルは、ここまで本書で扱ってきたすべてのコードスニペットの結果と一致するものになりました。 要約すると:

  • 以前のメモリの章の Inspecting it セクションでは、次のことを学びました:
    • ベクタテーブルの最初のエントリには、スタックポインタの初期値が含まれます。
    • Objdump は little endian 形式で表示するため、スタックは 0x2001_0000 から始まります。
    • 2 番目のエントリは、Reset ハンドラであるアドレス 0x0000_0049 を指します。
      • Reset ハンドラのアドレスは上の逆アセンブルで確認でき、 0x48 です。
      • 最下位ビットが 1 に設定されていても、アラインメント要件のため アドレス自体は変わりません。代わりに、その関数が Thumb モード で実行されるようになります。
  • その後には、0x850x00 が交互に現れるアドレスのパターンが見て取れます。
    • 上の逆アセンブルを見ると、0x85UsageFault0x84 を Thumb モードで実行したもの)を指していることは明らかです。
    • このパターンを、この章の前半で設定したベクタテーブル(pub static EXCEPTIONS の定義を参照)と the vector table layout for the Cortex-M に 照らし合わせると、テーブル内に対応するハンドラエントリが存在するたびに、 UsageFault のアドレスが存在していることが分かります。
    • しかし、各エントリに UsageFault を明示的に挿入したわけではありません。 代わりに、各ハンドラを DefaultExceptionHandler のエイリアスにしました。 逆アセンブラは同じ場所に複数のシンボルを見つけ、そのうちの 1 つを 関数名として選択しました。
    • また、Rust コード内のベクタテーブルのデータ構造のレイアウトが、 Cortex-M ベクタテーブル内のすべての予約スロットに対応していることも 分かります。したがって、すべての予約スロットは正しく値 0 に設定 されています。

ハンドラのオーバーライド

例外ハンドラをオーバーライドするには、ユーザーは、シンボル名が正確に EXCEPTIONS で使用した名前と一致する関数を提供しなければなりません。

#![no_main]
#![no_std]

use core::arch::asm;

use rt::entry;

entry!(main);

fn main() -> ! {
    unsafe { asm!("udf #0", options(noreturn)) };
}

#[unsafe(no_mangle)]
pub extern "C" fn HardFault() -> ! {
    // do something interesting here
    loop {}
}

QEMU でテストできます:

(gdb) target remote :3333
Remote debugging using :3333
Reset () at /home/japaric/rust/embedonomicon/ci/exceptions/rt/src/lib.rs:7
7       pub unsafe extern "C" fn Reset() -> ! {

(gdb) b HardFault
Breakpoint 1 at 0x44: file src/main.rs, line 18.

(gdb) continue
Continuing.

Breakpoint 1, HardFault () at src/main.rs:18
18          loop {}

(gdb) list
13      }
14
15      #[no_mangle]
16      pub extern "C" fn HardFault() -> ! {
17          // ここで何か面白いことをする
18          loop {}
19      }

このプログラムは、rt クレート内の DefaultExceptionHandler ではなく、 ユーザー定義の HardFault 関数を実行するようになりました。

main インターフェースに対する最初の試みと同様に、この最初の実装には型安全性がないという問題があります。さらに、例外名をタイプミスするのも簡単ですが、その場合でもエラーや警告は生成されません。代わりに、ユーザー定義のハンドラは単に無視されます。これらの問題は、cortex-m-rt v0.5.x で定義されている exception! マクロのようなマクロや、cortex-m-rt v0.6.x の exception 属性を使うことで解決できます。

安定版でのアセンブリ

注: Rust 1.59 以降、インラインアセンブリ (asm!) と 自由形式アセンブリ (global_asm!) はどちらも安定化されています。しかし、既存のクレートがこの 変更に追いつくにはしばらく時間がかかること、また、私たちが歴史的にアセンブリを 扱ってきた別の方法を知っておくのも有益であることから、この章は残しておきます。

これまでのところ、アセンブリを 1 行も書かずにデバイスをブートし、割り込みを処理 してきました。これはかなりの偉業です! しかし、対象としているアーキテクチャに よっては、ここまで到達するためにいくらかのアセンブリが必要になるかもしれません。 また、たとえばコンテキストスイッチのように、アセンブリを必要とする操作もあります。

インラインアセンブリ (asm!) と 自由形式アセンブリ (global_asm!) は どちらも Rust 1.59 より前は不安定でした。通常、クレートでは global_asm!asm! を使うことになるでしょう。ただし、この章では別のアプローチも紹介します。

この節の導入として、例外を発生させたスタックフレームに関する情報を提供するように HardFault ハンドラを少し変更してみます。

やりたいことは次のとおりです:

ユーザーが自分の HardFault ハンドラを直接ベクタテーブルに置くのではなく、 rt クレートがユーザー定義の HardFault ハンドラへのトランポリンを ベクタテーブルに配置するようにします。

$ tail -n36 ../rt/src/lib.rs
#![allow(unused)]
fn main() {
unsafe extern "C" {
    fn NMI();
    fn HardFaultTrampoline(); // <- CHANGED!
    fn MemManage();
    fn BusFault();
    fn UsageFault();
    fn SVCall();
    fn PendSV();
    fn SysTick();
}

#[unsafe(link_section = ".vector_table.exceptions")]
#[unsafe(no_mangle)]
pub static EXCEPTIONS: [Vector; 14] = [
    Vector { handler: NMI },
    Vector { handler: HardFaultTrampoline }, // <- CHANGED!
    Vector { handler: MemManage },
    Vector { handler: BusFault },
    Vector { handler: UsageFault },
    Vector { reserved: 0 },
    Vector { reserved: 0 },
    Vector { reserved: 0 },
    Vector { reserved: 0 },
    Vector { handler: SVCall },
    Vector { reserved: 0 },
    Vector { reserved: 0 },
    Vector { handler: PendSV },
    Vector { handler: SysTick },
];

#[unsafe(no_mangle)]
pub extern "C" fn DefaultExceptionHandler() {
    loop {}
}
}

このトランポリンはスタックポインタを読み取り、その後でユーザーの HardFault ハンドラを呼び出します。このトランポリンはアセンブリで記述する必要があります:

  mrs r0, MSP
  b HardFault

ARM ABI の仕組みにより、これによって Main Stack Pointer (MSP) が HardFault 関数 / ルーチンの第 1 引数として設定されます。この MSP の値は、例外によって スタックにプッシュされたレジスタを指すポインタにもなっています。これらの変更に より、ユーザーの HardFault ハンドラは fn(&StackedRegisters) -> ! というシグネチャでなければなりません。

.s ファイル

安定版でアセンブリを扱う 1 つの方法は、アセンブリを外部ファイルに書くことです:

$ cat ../rt/asm.s
  .section .text.HardFaultTrampoline
  .global HardFaultTrampoline
  .thumb_func
HardFaultTrampoline:
  mrs r0, MSP
  b HardFault

そして、rt クレートのビルドスクリプトで cc クレートを使い、そのファイルを オブジェクトファイル (.o) にアセンブルし、その後アーカイブ (.a) にします。

$ cat ../rt/build.rs
use std::{env, error::Error, fs::File, io::Write, path::PathBuf};

use cc::Build;

fn main() -> Result<(), Box<dyn Error>> {
    // build directory for this crate
    let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());

    // extend the library search path
    println!("cargo:rustc-link-search={}", out_dir.display());

    // put `link.x` in the build directory
    File::create(out_dir.join("link.x"))?.write_all(include_bytes!("link.x"))?;

    // assemble the `asm.s` file
    Build::new().file("asm.s").compile("asm"); // <- NEW!

    // rebuild if `asm.s` changed
    println!("cargo:rerun-if-changed=asm.s"); // <- NEW!

    Ok(())
}
$ tail -n2 ../rt/Cargo.toml
[build-dependencies]
cc = "1.0.25"

これで完了です!

非常にシンプルなプログラムを書くことで、ベクタテーブルに HardFaultTrampoline へのポインタが含まれていることを確認できます。

#![no_main]
#![no_std]

use rt::entry;

entry!(main);

fn main() -> ! {
    loop {}
}

#[allow(non_snake_case)]
#[unsafe(no_mangle)]
pub fn HardFault(_ef: *const u32) -> ! {
    loop {}
}

以下が逆アセンブル結果です。HardFaultTrampoline のアドレスを見てください。

$ cargo objdump --bin app --release -- -d --no-show-raw-insn --print-imm-hex

app:	file format elf32-littlearm

Disassembly of section .text:

00000040 <HardFault>:
      40:      	push	{r7, lr}
      42:      	mov	r7, sp
      44:      	b	0x44 <HardFault+0x4>    @ imm = #-0x4

00000046 <main>:
      46:      	push	{r7, lr}
      48:      	mov	r7, sp
      4a:      	b	0x4a <main+0x4>         @ imm = #-0x4

0000004c <Reset>:
      4c:      	push	{r7, lr}
      4e:      	mov	r7, sp
      50:      	bl	0x46 <main>             @ imm = #-0xe

00000054 <UsageFault>:
      54:      	push	{r7, lr}
      56:      	mov	r7, sp
      58:      	b	0x58 <UsageFault+0x4>   @ imm = #-0x4

0000005a <HardFaultTrampoline>:
      5a:      	mrs	r0, msp
      5e:      	b	0x40 <HardFault>        @ imm = #-0x22

注: この逆アセンブル結果を短くするため、RAM の初期化はコメントアウト しています。

次にベクタテーブルを見てください。4 番目のエントリは HardFaultTrampoline のアドレスに 1 を足した値になっているはずです。

$ cargo objdump --bin app --release -- -s -j .vector_table

app:	file format elf32-littlearm
Contents of section .vector_table:
 0000 00000120 4d000000 55000000 5b000000  ... M...U...[...
 0010 55000000 55000000 55000000 00000000  U...U...U.......
 0020 00000000 00000000 00000000 55000000  ............U...
 0030 00000000 00000000 55000000 55000000  ........U...U...

.o / .a ファイル

cc クレートを使う欠点は、ビルドマシン上で何らかのアセンブラプログラムが必要に なることです。たとえば ARM Cortex-M をターゲットにする場合、cc クレートは アセンブラとして arm-none-eabi-gcc を使用します。

ビルドマシン上でそのファイルをアセンブルする代わりに、事前にアセンブルした ファイルを rt クレートとともに配布することもできます。そうすれば、ビルド マシン上ではアセンブラプログラムは不要になります。ただし、クレートを パッケージ化して公開するマシンでは、やはりアセンブラが必要です。

アセンブリ (.s) ファイルと、その コンパイルされた 版であるオブジェクト (.o) ファイルとの違いはほとんどありません。アセンブラは最適化を行わず、 単にターゲットアーキテクチャに適したオブジェクトファイル形式を選ぶだけです。

Cargo は、クレートにアーカイブ (.a) を同梱するためのサポートを提供してい ます。ar コマンドを使ってオブジェクトファイルをアーカイブにまとめ、その アーカイブをクレートに同梱できます。実際、cc クレートが行っているのもこれで す。target ディレクトリで output という名前のファイルを探せば、実行された コマンドを確認できます。

$ grep running $(find target -name output)
running: "arm-none-eabi-gcc" "-O0" "-ffunction-sections" "-fdata-sections" "-fPIC" "-g" "-fno-omit-frame-pointer" "-mthumb" "-march=armv7-m" "-Wall" "-Wextra" "-o" "/tmp/app/target/thumbv7m-none-eabi/debug/build/rt-6ee84e54724f2044/out/asm.o" "-c" "asm.s"
running: "ar" "crs" "/tmp/app/target/thumbv7m-none-eabi/debug/build/rt-6ee84e54724f2044/out/libasm.a" "/home/japaric/rust-embedded/embedonomicon/ci/asm/app/target/thumbv7m-none-eabi/debug/build/rt-6ee84e54724f2044/out/asm.o"
$ grep cargo $(find target -name output)
cargo:rustc-link-search=/tmp/app/target/thumbv7m-none-eabi/debug/build/rt-6ee84e54724f2044/out
cargo:rustc-link-lib=static=asm
cargo:rustc-link-search=native=/tmp/app/target/thumbv7m-none-eabi/debug/build/rt-6ee84e54724f2044/out

アーカイブを生成するために、これと似たことを行います。

$ # `cc` が使用するフラグのほとんどはアセンブル時には効果がないので省きます
$ arm-none-eabi-as -march=armv7-m asm.s -o asm.o

$ ar crs librt.a asm.o

$ arm-none-eabi-objdump -Cd librt.a
In archive librt.a:

asm.o:     file format elf32-littlearm


Disassembly of section .text.HardFaultTrampoline:

00000000 <HardFaultTrampoline>:
   0:	f3ef 8008 	mrs	r0, MSP
   4:	e7fe      	b.n	0 <HardFault>

次に、ビルドスクリプトを変更して、このアーカイブを rtrlib に同梱し ます。

$ cat ../rt/build.rs
use std::{
    env,
    error::Error,
    fs::{self, File},
    io::Write,
    path::PathBuf,
};

fn main() -> Result<(), Box<dyn Error>> {
    // build directory for this crate
    let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());

    // extend the library search path
    println!("cargo:rustc-link-search={}", out_dir.display());

    // put `link.x` in the build directory
    File::create(out_dir.join("link.x"))?.write_all(include_bytes!("link.x"))?;

    // link to `librt.a`
    fs::copy("librt.a", out_dir.join("librt.a"))?; // <- NEW!
    println!("cargo:rustc-link-lib=static=rt"); // <- NEW!

    // rebuild if `librt.a` changed
    println!("cargo:rerun-if-changed=librt.a"); // <- NEW!

    Ok(())
}

これで、この新しい版を先ほどのシンプルなプログラムでテストでき、同じ出力が 得られます。

$ cargo objdump --bin app --release -- -d --no-show-raw-insn --print-imm-hex
../app/release.objdump

: 前と同様に、逆アセンブル結果を短くするため、RAM の初期化を コメントアウトしています。

$ cargo objdump --bin app --release -- -s -j .vector_table
../app/release.vector_table

事前にアセンブルしたアーカイブを同梱する欠点は、最悪の場合、ライブラリが サポートする各コンパイルターゲットごとに 1 つのビルド成果物を同梱する必要が あることです。

シンボルを使ったロギング

このセクションでは、シンボルと ELF フォーマットを活用して 非常に低コストなロギングを実現する方法を紹介します。

任意のシンボル

クレート間で安定したシンボルインターフェースが必要になるたびに、主に no_mangle 属性を使い、ときには export_name 属性も使ってきました。この export_name 属性はシンボル名になる文字列を受け取り、一方 #[unsafe(no_mangle)] は基本的に #[unsafe(export_name = <item-name>)] の 糖衣構文です。

実は、名前は 1 語だけに限定されません。export_name 属性の引数として、 文のような任意の文字列を使うことができます。少なくとも出力フォーマットが ELF であれば、ヌルバイトを含まないものであれば何でも構いません。 これは GNU リンカーを使う場合には一般に成り立ちます。ただし、macOS と Windows のリンカーにはいくつかの既知の制限事項 があります。

確認してみましょう。

$ cargo new --lib foo

$ cat foo/src/lib.rs
#![allow(unused)]
fn main() {
#[unsafe(export_name = "Hello, world!")]
#[used]
static A: u8 = 0;

#[unsafe(export_name = "こんにちは")]
#[used]
static B: u8 = 0;
}
$ ( cd foo && cargo nm --lib )
foo-d26a39c34b4e80ce.3lnzqy0jbpxj4pld.rcgu.o:
0000000000000000 r Hello, world!
0000000000000000 V __rustc_debug_gdb_scripts_section__
0000000000000000 r こんにちは

これで何をしようとしているかわかりますか?

エンコーディング

ここで行うことは次のとおりです。ログメッセージごとに 1 つの static 変数を作りますが、メッセージを変数の中に格納する代わりに、変数の シンボル名に格納します。そうしてログに出力するのは static 変数の内容 ではなく、そのアドレスです。

static 変数がゼロサイズでない限り、それぞれ異なるアドレスを持ちます。 ここでやっていることは、事実上、各メッセージを一意な識別子に エンコードすることです。その識別子が、たまたま変数のアドレスになって います。ログシステムのどこかの部分で、この ID をメッセージにデコードし 戻す必要があります。

この考え方を示すコードを書いてみましょう。

この例では I/O を行う方法が必要なので、そのために cortex-m-semihosting クレートを使います。セミホスティングとは、 ターゲットデバイスがホストの I/O 機能を借りるための技法です。ここでいう ホストとは、通常はターゲットデバイスをデバッグしているマシンを指します。 今回の場合、QEMU はセミホスティングをそのままサポートしているため、 デバッガーは不要です。実機では、シリアルポートのような別の I/O 手段を 使うことになります。この場合にセミホスティングを使うのは、QEMU 上で I/O を行う最も簡単な方法だからです。

コードは次のとおりです。

#![no_main]
#![no_std]

use core::fmt::Write;
use cortex_m_semihosting::{debug, hio};

use rt::entry;

entry!(main);

fn main() -> ! {
    let mut hstdout = hio::hstdout().unwrap();

    #[unsafe(export_name = "Hello, world!")]
    static A: u8 = 0;

    let _ = writeln!(hstdout, "{:#x}", &A as *const u8 as usize);

    #[unsafe(export_name = "Goodbye")]
    static B: u8 = 0;

    let _ = writeln!(hstdout, "{:#x}", &B as *const u8 as usize);

    debug::exit(debug::EXIT_SUCCESS);

    loop {}
}

また、プログラムが QEMU プロセスを終了できるように debug::exit API も 利用します。これは、QEMU プロセスを手動で終了しなくて済むようにするための 便宜です。

そして、Cargo.toml の dependencies セクションは次のとおりです。

[dependencies]
cortex-m-semihosting = "0.3.1"
rt = { path = "../rt" }

これでプログラムをビルドできます。

$ cargo build

実行するには、QEMU の起動コマンドに --semihosting-config フラグを 追加する必要があります。

$ qemu-system-arm \
      -cpu cortex-m3 \
      -machine lm3s6965evb \
      -nographic \
      -semihosting-config enable=on,target=native \
      -kernel target/thumbv7m-none-eabi/debug/app
0x1fe0
0x1fe1

: これらのアドレスは、ローカルで得られるものとは異なる場合が あります。static 変数のアドレスは、ツールチェーンが変わったときにも 同じままであることが保証されていないためです(たとえば最適化が改善される ことがあります)。

これでコンソールに 2 つのアドレスが出力されました。

デコーディング

これらのアドレスをどうやって文字列に変換するのでしょうか? 答えは ELF ファイルのシンボルテーブルにあります。

$ cargo objdump --bin app -- -t | grep '\.rodata\s*0*1\b'
00001fe1 g       .rodata		 00000001 Goodbye
00001fe0 g       .rodata		 00000001 Hello, world!
$ # 1 列目はシンボルのアドレス、最後の列はシンボル名

objdump -t はシンボルテーブルを表示します。このテーブルには すべて の シンボルが含まれますが、ここで見ているのは .rodata セクション内にあり、 サイズが 1 バイトのものだけです(変数の型は u8 です)。

プログラムを最適化すると、シンボルのアドレスはおそらく変わるという点に 注意することが重要です。確認してみましょう。

豆知識 Cargo の設定ファイル(.cargo/config.toml)で target.thumbv7m-none-eabi.runner に、先ほどの長い QEMU コマンド (qemu-system-arm -cpu ... -kernel ...)を設定しておくと、 cargo run がその ランナー を使って出力バイナリを実行するように なります。

$ head -n2 .cargo/config.toml
[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
$ cargo run --release
     Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target/thumbv7m-none-eabi/release/app`
0xb9c
0xb9d
$ cargo objdump --bin app --release -- -t | grep '\.rodata\s*0*1\b'
00000b9d g     O .rodata	00000001 Goodbye
00000b9c g     O .rodata	00000001 Hello, world!

したがって、必ず実行した ELF ファイルの中で文字列を探すようにしてください。

もちろん、ELF ファイル内の文字列を調べる処理は、ELF ファイルに含まれる シンボルテーブル(.symtab セクション)をパースするツールを使えば 自動化できます。そのようなツールの実装はこの本の範囲外なので、 読者への演習問題として残しておきます。

ゼロコストにする

もっと良くできるでしょうか? はい、できます!

現在の実装では static 変数は .rodata に配置されるため、その内容を 一切使わないにもかかわらずフラッシュ上の領域を消費します。リンカースクリプトの ちょっとした細工を使えば、これらがフラッシュ上で ゼロ の領域しか 占有しないようにできます。

$ cat log.x
SECTIONS
{
  .log 0 (INFO) : {
    *(.log);
  }
}

static 変数を、この新しい出力 .log セクションに配置します。この リンカースクリプトは、入力オブジェクトファイルの .log セクションにある すべてのシンボルを集めて、出力 .log セクションに配置します。この パターンは Memory layout の章で見ました。

ここで新しいのは (INFO) の部分です。これは、このセクションが割り当て不能な セクションであることをリンカーに伝えます。割り当て不能セクションは、 メタデータとして ELF バイナリ内には残されますが、ターゲットデバイスには ロードされません。

また、この出力セクションの開始アドレスも指定しています。.log 0 (INFO)0 がそれです。

もう 1 つ改善できる点は、整形済み I/O(fmt::Write)からバイナリ I/O に 切り替え、アドレスを文字列ではなくバイト列としてホストに送ることです。

バイナリシリアライズは難しくなりがちですが、ここでは各アドレスを 1 バイトで シリアライズすることで、非常に単純に保ちます。この方法であれば、 エンディアンやフレーミングを気にする必要はありません。この形式の欠点は、 1 バイトでは最大でも 256 個までの異なるアドレスしか表現できないことです。

では、これらの変更を加えてみましょう。

#![no_main]
#![no_std]

use cortex_m_semihosting::{debug, hio};

use rt::entry;

entry!(main);

fn main() -> ! {
    let mut hstdout = hio::hstdout().unwrap();

    #[unsafe(export_name = "Hello, world!")]
    #[unsafe(link_section = ".log")] // <- NEW!
    static A: u8 = 0;

    let address = &A as *const u8 as usize as u8;
    hstdout.write_all(&[address]).unwrap(); // <- CHANGED!

    #[unsafe(export_name = "Goodbye")]
    #[unsafe(link_section = ".log")] // <- NEW!
    static B: u8 = 0;

    let address = &B as *const u8 as usize as u8;
    hstdout.write_all(&[address]).unwrap(); // <- CHANGED!

    debug::exit(debug::EXIT_SUCCESS);

    loop {}
}

これを実行する前に、リンカーに渡される引数へ -Tlog.x を追加する必要が あります。これは Cargo の設定ファイルで行えます。

$ cat .cargo/config.toml
[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
rustflags = [
  "-C", "link-arg=-Tlink.x",
  "-C", "link-arg=-Tlog.x", # <- NEW!
]

[build]
target = "thumbv7m-none-eabi"

これで実行できます! 出力はバイナリ形式になったので、xxd コマンドにパイプして16進文字列として再整形します。

$ cargo run | xxd -p
0001

アドレスは 0x000x01 です。では、シンボルテーブルを見てみましょう。

$ cargo objdump --bin app -- -t | grep '\.log'
00000001 g     O .log	00000001 Goodbye
00000000 g     O .log	00000001 Hello, world!

これがその文字列です。アドレスが 0 から始まっていることに気づくでしょう。これは、出力 .log セクションの開始アドレスを設定したためです。

各変数のサイズは 1 バイトです。型として u8 を使っているためです。u16 のようなものを使うと、すべてのアドレスは偶数になり、アドレス空間全体(0...255)を効率的に使えなくなります。

パッケージ化する

文字列をログに記録する手順は常に同じであることに気づいたでしょう。そこで、これらを専用の crate に置かれたマクロへとリファクタリングできます。また、I/O の部分をトレイトの背後に抽象化することで、ロギングライブラリの再利用性を高めることもできます。

$ cargo new --lib log

$ cat log/src/lib.rs
#![allow(unused)]
#![no_std]

fn main() {
pub trait Log {
    type Error;

    fn log(&mut self, address: u8) -> Result<(), Self::Error>;
}

#[macro_export]
macro_rules! log {
    ($logger:expr, $string:expr) => {{
        #[unsafe(export_name = $string)]
        #[unsafe(link_section = ".log")]
        static SYMBOL: u8 = 0;

        $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8)
    }};
}
}

このライブラリは .log セクションに依存しているので、log.x リンカスクリプトを提供する責任もこのライブラリが持つべきです。そうなるようにしてみましょう。

$ mv log.x ../log/
$ cat ../log/build.rs
use std::{env, error::Error, fs::File, io::Write, path::PathBuf};

fn main() -> Result<(), Box<dyn Error>> {
    // Put the linker script somewhere the linker can find it
    let out = PathBuf::from(env::var("OUT_DIR")?);

    File::create(out.join("log.x"))?.write_all(include_bytes!("log.x"))?;

    println!("cargo:rustc-link-search={}", out.display());

    Ok(())
}

これで、アプリケーションを log! マクロを使うようにリファクタリングできます。

$ cat src/main.rs
#![no_main]
#![no_std]

use cortex_m_semihosting::{
    debug,
    hio::{self, HStdout},
};

use log::{log, Log};
use rt::entry;

struct Logger {
    hstdout: HStdout,
}

impl Log for Logger {
    type Error = ();

    fn log(&mut self, address: u8) -> Result<(), ()> {
        self.hstdout.write_all(&[address])
    }
}

entry!(main);

fn main() -> ! {
    let hstdout = hio::hstdout().unwrap();
    let mut logger = Logger { hstdout };

    let _ = log!(logger, "Hello, world!");

    let _ = log!(logger, "Goodbye");

    debug::exit(debug::EXIT_SUCCESS);

    loop {}
}

新しい log crate に依存するように、Cargo.toml ファイルを更新するのを忘れないでください。

$ tail -n4 Cargo.toml
[dependencies]
cortex-m-semihosting = "0.3.1"
log = { path = "../log" }
rt = { path = "../rt" }
$ cargo run | xxd -p
0001
$ cargo objdump --bin app -- -t | grep '\.log'
00000001 g     O .log	00000001 Goodbye
00000000 g     O .log	00000001 Hello, world!

出力は前と同じです!

おまけ: 複数のログレベル

多くのロギングフレームワークは、異なる ログレベル でメッセージを記録する手段を提供しています。これらのログレベルは、メッセージの重大度、つまり「これはエラーです」「これは単なる警告です」などを表します。こうしたログレベルは、たとえばエラーメッセージを探すときに、重要でないメッセージを除外するために使えます。

フットプリントを増やすことなく、ロギングライブラリを拡張してログレベルをサポートできます。方法は次のとおりです。

メッセージ用のアドレス空間は、0 から 255 まで(両端を含む)のフラットなものです。簡単にするため、ここではエラーメッセージと警告メッセージだけを区別したいことにしましょう。すべてのエラーメッセージをアドレス空間の先頭に配置し、すべての警告メッセージをエラーメッセージの に配置できます。デコーダーが最初の警告メッセージのアドレスを知っていれば、メッセージを分類できます。この考え方は、2 つを超えるログレベルのサポートにも拡張できます。

この考え方を試すために、log マクロを 2 つの新しいマクロ error!warn! に置き換えてみましょう。

$ cat ../log/src/lib.rs
#![allow(unused)]
#![no_std]

fn main() {
pub trait Log {
    type Error;

    fn log(&mut self, address: u8) -> Result<(), Self::Error>;
}

/// Logs messages at the ERROR log level
#[macro_export]
macro_rules! error {
    ($logger:expr, $string:expr) => {{
        #[unsafe(export_name = $string)]
        #[unsafe(link_section = ".log.error")] // <- CHANGED!
        static SYMBOL: u8 = 0;

        $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8)
    }};
}

/// Logs messages at the WARNING log level
#[macro_export]
macro_rules! warn {
    ($logger:expr, $string:expr) => {{
        #[unsafe(export_name = $string)]
        #[unsafe(link_section = ".log.warning")] // <- CHANGED!
        static SYMBOL: u8 = 0;

        $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8)
    }};
}
}

メッセージを異なるリンクセクションに配置することで、エラーと警告を区別します。

次に行う必要があるのは、エラーメッセージを警告メッセージより前に配置するよう、リンカスクリプトを更新することです。

$ cat ../log/log.x
SECTIONS
{
  .log 0 (INFO) : {
    *(.log.error);
    __log_warning_start__ = .;
    *(.log.warning);
  }
}

さらに、エラーと警告の境界に __log_warning_start__ という名前を付けます。このシンボルのアドレスが、最初の警告メッセージのアドレスになります。

これで、アプリケーションを更新してこれらの新しいマクロを利用できます。

$ cat src/main.rs
#![no_main]
#![no_std]

use cortex_m_semihosting::{
    debug,
    hio::{self, HStdout},
};

use log::{error, warn, Log};
use rt::entry;

entry!(main);

fn main() -> ! {
    let hstdout = hio::hstdout().unwrap();
    let mut logger = Logger { hstdout };

    let _ = warn!(logger, "Hello, world!"); // <- CHANGED!

    let _ = error!(logger, "Goodbye"); // <- CHANGED!

    debug::exit(debug::EXIT_SUCCESS);

    loop {}
}

struct Logger {
    hstdout: HStdout,
}

impl Log for Logger {
    type Error = ();

    fn log(&mut self, address: u8) -> Result<(), ()> {
        self.hstdout.write_all(&[address])
    }
}

出力はほとんど変わりません。

$ cargo run | xxd -p
0100

出力には引き続き 2 バイトが含まれますが、警告のほうが先にログに記録されていても、エラーにはアドレス 0 が、警告にはアドレス 1 が割り当てられます。

では、シンボルテーブルを見てみましょう。

$ cargo objdump --bin app -- -t | grep '\.log'
00000000 g     O .log	00000001 Goodbye
00000001 g     O .log	00000001 Hello, world!
00000001 g       .log	00000000 __log_warning_start__

.log セクションには、__log_warning_start__ という追加のシンボルが存在するようになりました。このシンボルのアドレスは、最初の警告メッセージのアドレスです。この値より小さいアドレスを持つシンボルはエラーで、それ以外のシンボルは警告です。

適切なデコーダーがあれば、これらすべての情報から次のような人間可読な出力を得られます。

WARNING Hello, world!
ERROR Goodbye

このセクションが気に入ったら、このアイデアを完全に実装したロギングフレームワーク stlog をチェックしてみてください。

グローバルシングルトン

この節では、グローバルで共有されるシングルトンをどのように実装するかを 扱います。embedded Rust book では、ほぼ Rust 特有と言ってよい、ローカルで 所有されるシングルトンを扱いました。グローバルシングルトンは本質的には C や C++ で見られるシングルトンパターンそのものです。これは組み込み開発に 固有のものではありませんが、シンボルが関わるので Embedonomicon によく合う 題材だと思われました。embedded Rust book には singletons の章もあります。

この節を説明するために、前の節で開発したロガーを拡張して、グローバル ロギングをサポートするようにします。結果は、embedded Rust book の collections 章で扱った #[global_allocator] 機能によく似たものに なります。

ここでやりたいことを要約すると次のとおりです。

前の節では、特定のロガー、つまり Log トレイトを実装する値を通して メッセージを記録する log! マクロを作成しました。log! マクロの構文は log!(logger, "String") です。これを拡張して、log!("String") も動作 するようにしたいのです。logger を指定しない版を使った場合、その メッセージはグローバルロガーを通して記録されるようにします。これは std::println! の動作と同じです。また、どれをグローバルロガーとするかを 宣言する仕組みも必要です。ここが #[global_allocator] と似ている部分です。

グローバルロガーが最上位クレートで宣言されることもあれば、グローバル ロガーの型自体が最上位クレートで定義されることもあります。このシナリオ では、依存クレートはグローバルロガーの正確な型を 知ることができません。 このシナリオをサポートするには、何らかの間接化が必要になります。

そこで、log クレートの中にグローバルロガーの型をハードコードする代わり に、そのクレートではグローバルロガーの インターフェース だけを宣言 します。つまり、log クレートに新しいトレイト GlobalLog を追加します。 log! マクロもそのトレイトを利用しなければなりません。

$ cat ../log/src/lib.rs
#![allow(unused)]
#![no_std]

fn main() {
// NEW!
pub trait GlobalLog: Sync {
    fn log(&self, address: u8);
}

pub trait Log {
    type Error;

    fn log(&mut self, address: u8) -> Result<(), Self::Error>;
}

#[macro_export]
macro_rules! log {
    // NEW!
    ($string:expr) => {
        unsafe {
            unsafe extern "Rust" {
                static LOGGER: &'static dyn $crate::GlobalLog;
            }

            #[unsafe(export_name = $string)]
            #[unsafe(link_section = ".log")]
            static SYMBOL: u8 = 0;

            $crate::GlobalLog::log(LOGGER, &SYMBOL as *const u8 as usize as u8)
        }
    };

    ($logger:expr, $string:expr) => {{
        #[unsafe(export_name = $string)]
        #[unsafe(link_section = ".log")]
        static SYMBOL: u8 = 0;

        $crate::Log::log(&mut $logger, &SYMBOL as *const u8 as usize as u8)
    }};
}

// NEW!
#[macro_export]
macro_rules! global_logger {
    ($logger:expr) => {
        #[unsafe(no_mangle)]
        pub static LOGGER: &dyn $crate::GlobalLog = &$logger;
    };
}
}

ここには説明すべきことがかなりあります。

まずはトレイトから始めましょう。

#![allow(unused)]
fn main() {
pub trait GlobalLog: Sync {
    fn log(&self, address: u8);
}
}

GlobalLogLog はどちらも log メソッドを持っています。違いは、 GlobalLog.log がレシーバへの共有参照 (&self) を取ることです。これは、 グローバルロガーが static 変数になるために必要です。これについては後で 詳しく説明します。

もう 1 つの違いは、GlobalLog.logResult を返さないことです。つまり、 呼び出し元にエラーを報告することは できません。これは、グローバル シングルトンを実装するために使うトレイトに対する厳密な要件ではありません。 グローバルシングルトンでエラーハンドリングを行うこと自体は問題ありません が、その場合は log! マクロのグローバル版を使うすべての利用者がエラー型 について合意しなければなりません。ここでは、GlobalLog の実装者にエラー 処理を任せることで、インターフェースを少し単純化しています。

さらに別の違いとして、GlobalLog は実装者が Sync であること、つまり スレッド間で共有できることを要求します。これは static 変数に置かれる 値に対する要件です。その型は Sync トレイトを実装していなければなりません。

この時点では、なぜインターフェースがこの形でなければならないのか、まだ 完全には明らかでないかもしれません。クレートの他の部分を見ると、これが よりはっきりするので、そのまま読み進めてください。

次は log! マクロです。

#![allow(unused)]
fn main() {
    ($string:expr) => {
        unsafe {
            unsafe extern "Rust" {
                static LOGGER: &'static dyn $crate::GlobalLog;
            }

            #[unsafe(export_name = $string)]
            #[unsafe(link_section = ".log")]
            static SYMBOL: u8 = 0;

            $crate::GlobalLog::log(LOGGER, &SYMBOL as *const u8 as usize as u8)
        }
    };
}

特定の $logger を指定せずに呼び出されると、このマクロは LOGGER という 名前の extern static 変数を使ってメッセージを記録します。この変数 こそが 別の場所で定義されているグローバルロガーであり、そのために extern ブロックを使います。このパターンは main interface 章で見ました。

LOGGER の型を宣言する必要があります。そうしないとコードが型チェックを 通りません。この時点では LOGGER の具体的な型は分かりませんが、 GlobalLog トレイトを実装していることは分かっている、というより要求して いるので、ここではトレイトオブジェクトを使えます。

マクロ展開の残りの部分は log! マクロのローカル版の展開と非常によく似て いるので、previous 章で説明していることもあり、ここでは説明しません。

LOGGER がトレイトオブジェクトでなければならないと分かった今、なぜ GlobalLog で関連型 Error を省いたのかがより明確になります。もし 省かなければ、LOGGER の型シグネチャにおいて Error の型を選ぶ必要が あったでしょう。これが先ほど「log! のすべての利用者がエラー型について 合意する必要がある」と述べた意味です。

さて、最後のピースは global_logger! マクロです。proc macro 属性にする こともできましたが、macro_rules! マクロとして書くほうが簡単です。

#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! global_logger {
    ($logger:expr) => {
        #[unsafe(no_mangle)]
        pub static LOGGER: &dyn $crate::GlobalLog = &$logger;
    };
}
}

このマクロは log! が使う LOGGER 変数を作成します。安定した ABI インターフェースが必要なので、no_mangle 属性を使います。こうすることで LOGGER のシンボル名は “LOGGER” となり、これは log! マクロが期待する ものです。

もう 1 つ重要なのは、この static 変数の型が log! マクロの展開で使われる 型と完全に一致していなければならないことです。一致しないと、ABI の不一致 によって深刻な問題が発生します。

この新しいグローバルロガー機能を使う例を書いてみましょう。

$ cat src/main.rs
#![no_main]
#![no_std]

use core::cell::RefCell;
use cortex_m::interrupt;
use cortex_m::interrupt::Mutex;
use cortex_m_semihosting::{
    debug,
    hio::{self, HostStream},
};

use log::{GlobalLog, global_logger, log};
use rt::entry;

struct Logger;

global_logger!(Logger);

entry!(main);

fn main() -> ! {
    log!("Hello, world!");

    log!("Goodbye");

    debug::exit(debug::EXIT_SUCCESS);

    loop {}
}

impl GlobalLog for Logger {
    fn log(&self, address: u8) {
        // we use a critical section (`interrupt::free`) to make the access to the
        // `HSTDOUT` variable interrupt-safe which is required for memory safety
        interrupt::free(|cs| {
            static HSTDOUT: Mutex<RefCell<Option<HostStream>>> = Mutex::new(RefCell::new(None));
            let mut hstdout = HSTDOUT.borrow(cs).borrow_mut();

            // lazy initialization
            if hstdout.is_none() {
                hstdout.replace(hio::hstdout()?);
            }

            let hstdout = hstdout.as_mut().unwrap();

            hstdout.write_all(&[address])
        })
        .ok(); // `.ok()` = ignore errors
    }
}

依存関係に cortex-m を追加する必要がありました。

$ tail -n5 Cargo.toml
[dependencies]
cortex-m = "0.7.7"
cortex-m-semihosting = "0.5.0"
log = { path = "../log" }
rt = { path = "../rt" }

これは previous 節で書いた例の 1 つを移植したものです。出力はそこで 得たものと同じです。

$ cargo run | xxd -p
0001
$ cargo objdump --bin app -- -t | grep '\.log'
00000001 g     O .log	00000001 Goodbye
00000000 g     O .log	00000001 Hello, world!

このグローバルシングルトンの実装は、動的ディスパッチ、つまり vtable 参照によるメソッド呼び出しを伴うトレイトオブジェクトを使っているため、 ゼロコストではないのではないかと懸念する読者もいるかもしれません。

しかし、LLVM は最適化 / LTO 付きでコンパイルすると、この動的ディスパッチ を取り除けるほど賢いようです。これはシンボルテーブルで LOGGER を検索 することで確認できます。

$ cargo objdump --bin app --release -- -t | grep LOGGER

この static が見つからない場合、それは vtable が存在せず、LLVM が すべての LOGGER.log 呼び出しを Logger.log 呼び出しに変換できたことを 意味します。

ダイレクトメモリアクセス (DMA)

このセクションでは、DMA 転送を扱うメモリ安全な API を構築するための 中核的な要件を説明します。

DMA ペリフェラルは、プロセッサの処理(メインプログラムの実行)と並行して メモリ転送を行うために使われます。DMA 転送は、おおむね memcpy を行う スレッドを生成すること(thread::spawn を参照)に相当します。 ここでは、メモリ安全な API の要件を説明するために、フォーク・ジョインモデルを 用います。

次の DMA プリミティブを考えてみましょう:

#![allow(unused)]
fn main() {
/// A singleton that represents a single DMA channel (channel 1 in this case)
///
/// This singleton has exclusive access to the registers of the DMA channel 1
pub struct Dma1Channel1 {
    // ..
}

impl Dma1Channel1 {
    /// Data will be written to this `address`
    ///
    /// `inc` indicates whether the address will be incremented after every byte
    /// transfer
    ///
    /// NOTE this performs a volatile write
    pub fn set_destination_address(&mut self, address: usize, inc: bool) {
        // ..
    }

    /// Data will be read from this `address`
    ///
    /// `inc` indicates whether the address will be incremented after every byte
    /// transfer
    ///
    /// NOTE this performs a volatile write
    pub fn set_source_address(&mut self, address: usize, inc: bool) {
        // ..
    }

    /// Number of bytes to transfer
    ///
    /// NOTE this performs a volatile write
    pub fn set_transfer_length(&mut self, len: usize) {
        // ..
    }

    /// Starts the DMA transfer
    ///
    /// NOTE this performs a volatile write
    pub fn start(&mut self) {
        // ..
    }

    /// Stops the DMA transfer
    ///
    /// NOTE this performs a volatile write
    pub fn stop(&mut self) {
        // ..
    }

    /// Returns `true` if there's a transfer in progress
    ///
    /// NOTE this performs a volatile read
    pub fn in_progress() -> bool {
        // ..
        false
    }
}
}

Dma1Channel1 は、シリアルポート(別名 UART または USART)#1 である Serial1 と、ワンショットモード(つまり循環モードではない)で動作するよう 静的に設定されているとします。Serial1 は次の ブロッキング API を提供します:

#![allow(unused)]
fn main() {
/// A singleton that represents serial port #1
pub struct Serial1 {
    // ..
}

impl Serial1 {
    /// Reads out a single byte
    ///
    /// NOTE: blocks if no byte is available to be read
    pub fn read(&mut self) -> Result<u8, Error> {
        // ..
        Ok(0)
    }

    /// Sends out a single byte
    ///
    /// NOTE: blocks if the output FIFO buffer is full
    pub fn write(&mut self, byte: u8) -> Result<(), Error> {
        // ..
        Ok(())
    }
}
}

Serial1 の API を拡張して、(a) バッファを非同期に送信し、(b) バッファを 非同期に埋めるようにしたいとします。

まずはメモリ安全でない API から始めて、完全にメモリ安全になるまで段階的に 改善していきます。各段階で、その API がどのように破られるかを示し、 非同期メモリ操作を扱う際に対処しなければならない問題を明らかにします。

最初の試み

手始めに、Write::write_all API を参考にしてみましょう。単純化のため、 エラーハンドリングはすべて無視します。

#![allow(unused)]
fn main() {
/// A singleton that represents serial port #1
pub struct Serial1 {
    // NOTE: we extend this struct by adding the DMA channel singleton
    dma: Dma1Channel1,
    // ..
}

impl Serial1 {
    /// Sends out the given `buffer`
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn write_all<'a>(mut self, buffer: &'a [u8]) -> Transfer<&'a [u8]> {
        self.dma.set_destination_address(USART1_TX, false);
        self.dma.set_source_address(buffer.as_ptr() as usize, true);
        self.dma.set_transfer_length(buffer.len());

        self.dma.start();

        Transfer { buffer }
    }
}

/// A DMA transfer
pub struct Transfer<B> {
    buffer: B,
}

impl<B> Transfer<B> {
    /// Returns `true` if the DMA transfer has finished
    pub fn is_done(&self) -> bool {
        !Dma1Channel1::in_progress()
    }

    /// Blocks until the transfer is done and returns the buffer
    pub fn wait(self) -> B {
        // Busy wait until the transfer is done
        while !self.is_done() {}

        self.buffer
    }
}
}

NOTE: Transfer は、上に示した API の代わりに、future ベースまたは generator ベースの API を公開してもかまいません。これは API 設計の問題であり、 API 全体のメモリ安全性にはほとんど影響しないため、この文書では立ち入りません。

Read::read_exact の非同期版も実装できます。

#![allow(unused)]
fn main() {
impl Serial1 {
    /// Receives data into the given `buffer` until it's filled
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn read_exact<'a>(&mut self, buffer: &'a mut [u8]) -> Transfer<&'a mut [u8]> {
        self.dma.set_source_address(USART1_RX, false);
        self.dma
            .set_destination_address(buffer.as_mut_ptr() as usize, true);
        self.dma.set_transfer_length(buffer.len());

        self.dma.start();

        Transfer { buffer }
    }
}
}

write_all API の使い方は次のとおりです:

#![allow(unused)]
fn main() {
fn write(serial: Serial1) {
    // fire and forget
    serial.write_all(b"Hello, world!\n");

    // do other stuff
}
}

こちらは read_exact API の使用例です:

#![allow(unused)]
fn main() {
fn read(mut serial: Serial1) {
    let mut buf = [0; 16];
    let t = serial.read_exact(&mut buf);

    // do other stuff

    t.wait();

    match buf.split(|b| *b == b'\n').next() {
        Some(b"some-command") => { /* do something */ }
        _ => { /* do something else */ }
    }
}
}

mem::forget

mem::forget は safe な API です。私たちの API が本当に安全であれば、 この 2 つを組み合わせて使っても未定義動作に陥らないはずです。しかし実際には そうではありません。次の例を見てください:

#![allow(unused)]
fn main() {
fn unsound(mut serial: Serial1) {
    start(&mut serial);
    bar();
}

#[inline(never)]
fn start(serial: &mut Serial1) {
    let mut buf = [0; 16];

    // start a DMA transfer and forget the returned `Transfer` value
    mem::forget(serial.read_exact(&mut buf));
}

#[inline(never)]
fn bar() {
    // stack variables
    let mut x = 0;
    let mut y = 0;

    // use `x` and `y`
}
}

ここでは start 内で DMA 転送を開始し、スタック上に割り当てられた配列を 埋めようとした後、返された Transfer 値に対して mem::forget を呼んでいます。 その後 start からリターンし、関数 bar を実行します。

この一連の操作は未定義動作を引き起こします。DMA 転送はスタックメモリへ 書き込みますが、そのメモリは start がリターンすると解放され、その後 barxy のような変数を割り当てるために再利用されます。実行時には、このために 変数 xy の値がランダムなタイミングで変化する可能性があります。DMA 転送は、 関数 bar のプロローグによってスタックに積まれた状態(たとえばリンクレジスタ)を 上書きしてしまう可能性もあります。

mem::forget の代わりに mem::drop を使っていれば、Transfer の デストラクタで DMA 転送を停止させることができ、その場合プログラムは安全に できたはずです。しかし、メモリ安全性を保証するためにデストラクタが実行される ことへ 頼ることはできません。なぜなら mem::forget とメモリリーク (Rc の循環参照を参照)は Rust では safe だからです。 (mem::forget safety を参照してください。)

この特定の問題は、両方の API でバッファのライフタイムを 'a から 'static に変更することで解決できます。

#![allow(unused)]
fn main() {
impl Serial1 {
    /// Receives data into the given `buffer` until it's filled
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn read_exact(&mut self, buffer: &'static mut [u8]) -> Transfer<&'static mut [u8]> {
        // .. same as before ..
    }

    /// Sends out the given `buffer`
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn write_all(mut self, buffer: &'static [u8]) -> Transfer<&'static [u8]> {
        // .. same as before ..
    }
}
}

前の問題を再現しようとしても、mem::forget がもはや問題を引き起こさない ことがわかります。

#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn sound(mut serial: Serial1, buf: &'static mut [u8; 16]) {
    // NOTE `buf` is moved into `foo`
    foo(&mut serial, buf);
    bar();
}

#[inline(never)]
fn foo(serial: &mut Serial1, buf: &'static mut [u8]) {
    // start a DMA transfer and forget the returned `Transfer` value
    mem::forget(serial.read_exact(buf));
}

#[inline(never)]
fn bar() {
    // stack variables
    let mut x = 0;
    let mut y = 0;

    // use `x` and `y`
}
}

これまでと同様に、Transfer 値を mem::forget したあとも DMA 転送は 継続します。今回は buf がスタック上ではなく静的に割り当てられている (たとえば static mut 変数)ため、問題にはなりません。

重複利用

この API では、DMA 転送の進行中にユーザーが Serial インターフェースを 使うことを防げません。これにより、転送が失敗したりデータが失われたりする 可能性があります。

重複利用を防ぐ方法はいくつかあります。1 つの方法は、TransferSerial1 の所有権を受け取り、wait が呼ばれたときにそれを返すようにすること です。

#![allow(unused)]
fn main() {
/// A DMA transfer
pub struct Transfer<B> {
    buffer: B,
    // NOTE: added
    serial: Serial1,
}

impl<B> Transfer<B> {
    /// Blocks until the transfer is done and returns the buffer
    // NOTE: the return value has changed
    pub fn wait(self) -> (B, Serial1) {
        // Busy wait until the transfer is done
        while !self.is_done() {}

        (self.buffer, self.serial)
    }

    // ..
}

impl Serial1 {
    /// Receives data into the given `buffer` until it's filled
    ///
    /// Returns a value that represents the in-progress DMA transfer
    // NOTE we now take `self` by value
    pub fn read_exact(mut self, buffer: &'static mut [u8]) -> Transfer<&'static mut [u8]> {
        // .. same as before ..

        Transfer {
            buffer,
            // NOTE: added
            serial: self,
        }
    }

    /// Sends out the given `buffer`
    ///
    /// Returns a value that represents the in-progress DMA transfer
    // NOTE we now take `self` by value
    pub fn write_all(mut self, buffer: &'static [u8]) -> Transfer<&'static [u8]> {
        // .. same as before ..

        Transfer {
            buffer,
            // NOTE: added
            serial: self,
        }
    }
}
}

ムーブセマンティクスにより、転送の進行中は Serial1 へアクセスできないことが 静的に保証されます。

#![allow(unused)]
fn main() {
fn read(serial: Serial1, buf: &'static mut [u8; 16]) {
    let t = serial.read_exact(buf);

    // let byte = serial.read(); //~ ERROR: `serial` has been moved

    // .. do stuff ..

    let (serial, buf) = t.wait();

    // .. do more stuff ..
}
}

重複利用を防ぐ方法はほかにもあります。たとえば、DMA 転送が進行中かどうかを 示す(Cell の)フラグを Serial1 に追加できます。フラグが設定されている場合、 readwriteread_exactwrite_all はすべて実行時にエラー (たとえば Error::InUse)を返すようにできます。このフラグは write_all / read_exact の使用時に設定され、Transfer.wait でクリアされます。

コンパイラの(誤った)最適化

コンパイラは、プログラムをよりよく最適化するために、非 volatile なメモリ操作を 並べ替えたり統合したりすることが自由にできます。現在の API では、この自由が 未定義動作につながる可能性があります。次の例を考えてみましょう:

#![allow(unused)]
fn main() {
fn reorder(serial: Serial1, buf: &'static mut [u8]) {
    // zero the buffer (for no particular reason)
    buf.iter_mut().for_each(|byte| *byte = 0);

    let t = serial.read_exact(buf);

    // ... do other stuff ..

    let (buf, serial) = t.wait();

    buf.reverse();

    // .. do stuff with `buf` ..
}
}

ここでコンパイラは、buf.reverse()t.wait() より前に移動させることが 自由にできます。そうなるとデータ競合が発生します。つまり、プロセッサと DMA の 両方が同じ時点で buf を変更することになるためです。同様に、コンパイラは ゼロ埋めの操作を read_exact の後へ移動させることもでき、それもデータ競合を 引き起こします。

このような問題のある並べ替えを防ぐために、compiler_fence を使えます。

#![allow(unused)]
fn main() {
impl Serial1 {
    /// Receives data into the given `buffer` until it's filled
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn read_exact(mut self, buffer: &'static mut [u8]) -> Transfer<&'static mut [u8]> {
        self.dma.set_source_address(USART1_RX, false);
        self.dma
            .set_destination_address(buffer.as_mut_ptr() as usize, true);
        self.dma.set_transfer_length(buffer.len());

        // NOTE: added
        atomic::compiler_fence(Ordering::Release);

        // NOTE: this is a volatile *write*
        self.dma.start();

        Transfer {
            buffer,
            serial: self,
        }
    }

    /// Sends out the given `buffer`
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn write_all(mut self, buffer: &'static [u8]) -> Transfer<&'static [u8]> {
        self.dma.set_destination_address(USART1_TX, false);
        self.dma.set_source_address(buffer.as_ptr() as usize, true);
        self.dma.set_transfer_length(buffer.len());

        // NOTE: added
        atomic::compiler_fence(Ordering::Release);

        // NOTE: this is a volatile *write*
        self.dma.start();

        Transfer {
            buffer,
            serial: self,
        }
    }
}

impl<B> Transfer<B> {
    /// Blocks until the transfer is done and returns the buffer
    pub fn wait(self) -> (B, Serial1) {
        // NOTE: this is a volatile *read*
        while !self.is_done() {}

        // NOTE: added
        atomic::compiler_fence(Ordering::Acquire);

        (self.buffer, self.serial)
    }

    // ..
}
}

read_exactwrite_all では Ordering::Release を使い、それに先行する すべてのメモリ操作が、volatile write を行う self.dma.start() に 移動されないようにします。

同様に、Transfer.wait では Ordering::Acquire を使い、それに後続する すべてのメモリ操作が、volatile read を行う self.is_done() に 移動されないようにします。

フェンスの効果をよりわかりやすくするために、前のセクションの例を少し調整した 版を以下に示します。コメントにはフェンスとそのオーダリングを追加してあります。

#![allow(unused)]
fn main() {
fn reorder(serial: Serial1, buf: &'static mut [u8], x: &mut u32) {
    // zero the buffer (for no particular reason)
    buf.iter_mut().for_each(|byte| *byte = 0);

    *x += 1;

    let t = serial.read_exact(buf); // compiler_fence(Ordering::Release) ▲

    // NOTE: the processor can't access `buf` between the fences
    // ... do other stuff ..
    *x += 2;

    let (buf, serial) = t.wait(); // compiler_fence(Ordering::Acquire) ▼

    *x += 3;

    buf.reverse();

    // .. do stuff with `buf` ..
}
}

ゼロ化操作は、Release フェンスがあるため、read_exact の後に 移動することは できません。同様に、reverse 操作は、Acquire フェンスがあるため、wait の前に 移動することは できません。両方のフェンスの にあるメモリ操作は、フェンスをまたいで自由に並べ替えることが できます が、それらの操作はいずれも buf に関与しないため、そのような並べ替えによって未定義動作が生じることは ありません

なお、compiler_fence は必要とされるものより少し強すぎます。たとえば、bufx とオーバーラップしないことがわかっていても(Rust のエイリアシング規則による)、これらのフェンスは x に対する操作がマージされるのを防ぎます。しかし、compiler_fence より粒度の細かい intrinsic は存在しません。

メモリバリアは必要ないのでしょうか?

それはターゲットアーキテクチャに依存します。Cortex M0 から M4F までのコアの場合、AN321 には次のようにあります。

3.2 典型的な使用法

(..)

Cortex-M プロセッサでは、メモリトランザクションの並べ替えを行わないため、DMB の使用が必要になることはまれです。ただし、ソフトウェアを他の ARM プロセッサ、特にマルチマスターシステムで再利用する場合には必要です。たとえば:

  • DMA コントローラの設定。CPU のメモリアクセスと DMA 操作の間にはバリアが必要です。

(..)

4.18 マルチマスターシステム

(..)

47 ページの Figure 41 および Figure 42 の例において DMB または DSB 命令を省略しても、Cortex-M プロセッサではエラーは発生しません。なぜなら:

  • メモリ転送を並べ替えない
  • 2 つの書き込み転送が重なることを許可しない

からです。

Figure 41 では、DMA トランザクションを開始する前に DMB(メモリバリア)命令が使われていることが示されています。

Cortex-M7 コアの場合、データキャッシュ(DCache)を使用しているなら、DMA が使用するバッファを手動で無効化しない限り、メモリバリア(DMB/DSB)が必要になります。データキャッシュを無効にしていても、ストアバッファでの並べ替えを避けるために、依然としてメモリバリアが必要になる場合があります。

ターゲットがマルチコアシステムであれば、メモリバリアが必要になる可能性は非常に高いです。

実際にメモリバリアが必要であれば、compiler_fence ではなく atomic::fence を使う必要があります。Cortex-M デバイスでは、これにより DMB 命令が生成されるはずです。

アトミックは必要ないのでしょうか?

フェンスのドキュメントには、フェンスはアトミックと組み合わせた場合にのみ機能すると書かれています。

(少なくとも)Release 順序付けセマンティクスを持つフェンス ‘A’ は、(少なくとも)Acquire セマンティクスを持つフェンス ‘B’ と同期します。ただしそれは、あるアトミックオブジェクト ‘m’ に対して動作する操作 X と Y が存在し、A が X の前に順序付けられ、Y が B の前に順序付けられ、かつ Y が m への変更を観測する場合に限られます。

同じことは compiler_fence にも当てはまります。

fence と同様に、同期には依然として両方のスレッドでアトミック操作を使う必要があることに注意してください。フェンスと非アトミック操作だけで完全に同期を行うことはできません。

では、別のスレッドではなく DMA エンジンのようなハードウェアとやり取りしている場合、これはどのように機能するのでしょうか?答えは、現行実装では、volatile 操作がたまたま relaxed atomic 操作と同じように動作するということです。将来の Rust のバージョンでこの振る舞いを実際に保証するための作業が進められています。

ジェネリックなバッファ

この API は必要以上に制約が厳しくなっています。たとえば、次のプログラムは有効であるにもかかわらず受け入れられません。

#![allow(unused)]
fn main() {
fn reuse(serial: Serial1, msg: &'static mut [u8]) {
    // send a message
    let t1 = serial.write_all(msg);

    // ..

    let (msg, serial) = t1.wait(); // `msg` is now `&'static [u8]`

    msg.reverse();

    // now send it in reverse
    let t2 = serial.write_all(msg);

    // ..

    let (buf, serial) = t2.wait();

    // ..
}
}

このようなプログラムを受け入れるには、バッファ引数をジェネリックにできます。

#![allow(unused)]
fn main() {
// as-slice = "0.1.0"
use as_slice::{AsMutSlice, AsSlice};

impl Serial1 {
    /// Receives data into the given `buffer` until it's filled
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn read_exact<B>(mut self, mut buffer: B) -> Transfer<B>
    where
        B: AsMutSlice<Element = u8>,
    {
        // NOTE: added
        let slice = buffer.as_mut_slice();
        let (ptr, len) = (slice.as_mut_ptr(), slice.len());

        self.dma.set_source_address(USART1_RX, false);

        // NOTE: tweaked
        self.dma.set_destination_address(ptr as usize, true);
        self.dma.set_transfer_length(len);

        atomic::compiler_fence(Ordering::Release);
        self.dma.start();

        Transfer {
            buffer,
            serial: self,
        }
    }

    /// Sends out the given `buffer`
    ///
    /// Returns a value that represents the in-progress DMA transfer
    fn write_all<B>(mut self, buffer: B) -> Transfer<B>
    where
        B: AsSlice<Element = u8>,
    {
        // NOTE: added
        let slice = buffer.as_slice();
        let (ptr, len) = (slice.as_ptr(), slice.len());

        self.dma.set_destination_address(USART1_TX, false);

        // NOTE: tweaked
        self.dma.set_source_address(ptr as usize, true);
        self.dma.set_transfer_length(len);

        atomic::compiler_fence(Ordering::Release);
        self.dma.start();

        Transfer {
            buffer,
            serial: self,
        }
    }
}

}

NOTE: AsRef<[u8]> (AsMut<[u8]>) を AsSlice<Element = u8> (AsMutSlice<Element = u8) の代わりに使うこともできました。

これで reuse プログラムは受け入れられるようになります。

移動不能なバッファ

この修正により、API は配列を値として(たとえば [u8; 16])受け入れるようにもなります。しかし、配列を使うとポインタが無効化される可能性があります。次のプログラムを考えてみましょう。

#![allow(unused)]
fn main() {
fn invalidate(serial: Serial1) {
    let t = start(serial);

    bar();

    let (buf, serial) = t.wait();
}

#[inline(never)]
fn start(serial: Serial1) -> Transfer<[u8; 16]> {
    // array allocated in this frame
    let buffer = [0; 16];

    serial.read_exact(buffer)
}

#[inline(never)]
fn bar() {
    // stack variables
    let mut x = 0;
    let mut y = 0;

    // use `x` and `y`
}
}

read_exact 操作は、start 関数のローカル変数 buffer のアドレスを使います。そのローカル変数 bufferstart が返ると解放され、read_exact で使われるポインタは無効になります。結果として、unsound の例に似た状況になります。

この問題を避けるため、私たちの API で使うバッファには、移動されてもメモリ上の位置を保持することを要求します。Pin newtype はそのような保証を提供します。そこで API を更新して、すべてのバッファがまず「ピン留め」されていることを要求できます。

#![allow(unused)]
fn main() {
/// A DMA transfer
pub struct Transfer<B> {
    // NOTE: changed
    buffer: Pin<B>,
    serial: Serial1,
}

impl Serial1 {
    /// Receives data into the given `buffer` until it's filled
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn read_exact<B>(mut self, mut buffer: Pin<B>) -> Transfer<B>
    where
        // NOTE: bounds changed
        B: DerefMut,
        B::Target: AsMutSlice<Element = u8> + Unpin,
    {
        // .. same as before ..
    }

    /// Sends out the given `buffer`
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn write_all<B>(mut self, buffer: Pin<B>) -> Transfer<B>
    where
        // NOTE: bounds changed
        B: Deref,
        B::Target: AsSlice<Element = u8>,
    {
        // .. same as before ..
    }
}
}

NOTE: Pin newtype の代わりに StableDeref トレイトを使うこともできましたが、標準ライブラリで提供されているため Pin を選びました。

この新しい API では、&'static mut 参照、Box 化されたスライス、Rc 化されたスライスなどを使えます。

#![allow(unused)]
fn main() {
fn static_mut(serial: Serial1, buf: &'static mut [u8]) {
    let buf = Pin::new(buf);

    let t = serial.read_exact(buf);

    // ..

    let (buf, serial) = t.wait();

    // ..
}

fn boxed(serial: Serial1, buf: Box<[u8]>) {
    let buf = Pin::new(buf);

    let t = serial.read_exact(buf);

    // ..

    let (buf, serial) = t.wait();

    // ..
}
}

'static 境界

ピン留めすれば、スタックに確保された配列を安全に使えるのでしょうか?答えは いいえ です。次の例を考えてみましょう。

#![allow(unused)]
fn main() {
fn unsound(serial: Serial1) {
    start(serial);

    bar();
}

// pin-utils = "0.1.0-alpha.4"
use pin_utils::pin_mut;

#[inline(never)]
fn start(serial: Serial1) {
    let buffer = [0; 16];

    // pin the `buffer` to this stack frame
    // `buffer` now has type `Pin<&mut [u8; 16]>`
    pin_mut!(buffer);

    mem::forget(serial.read_exact(buffer));
}

#[inline(never)]
fn bar() {
    // stack variables
    let mut x = 0;
    let mut y = 0;

    // use `x` and `y`
}
}

これまで何度も見てきたように、上のプログラムはスタックフレームの破壊により未定義動作に陥ります。

この API は、'anot 'static である Pin<&'a mut [u8]> 型のバッファに対しては健全ではありません。この問題を防ぐには、いくつかの箇所に 'static 境界を追加する必要があります。

#![allow(unused)]
fn main() {
impl Serial1 {
    /// Receives data into the given `buffer` until it's filled
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn read_exact<B>(mut self, mut buffer: Pin<B>) -> Transfer<B>
    where
        // NOTE: added 'static bound
        B: DerefMut + 'static,
        B::Target: AsMutSlice<Element = u8> + Unpin,
    {
        // .. same as before ..
    }

    /// Sends out the given `buffer`
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn write_all<B>(mut self, buffer: Pin<B>) -> Transfer<B>
    where
        // NOTE: added 'static bound
        B: Deref + 'static,
        B::Target: AsSlice<Element = u8>,
    {
        // .. same as before ..
    }
}
}

これで問題のあるプログラムは拒否されます。

デストラクタ

API が Box などのデストラクタを持つ型を受け入れるようになったので、Transfer が早期に drop されたときにどうするかを決める必要があります。

通常、Transfer 値は wait メソッドを使って消費されますが、転送が終わる前に暗黙的または明示的にその値を drop することも可能です。たとえば、Transfer<Box<[u8]>> 値を drop すると、バッファは解放されます。転送がまだ進行中であれば、DMA が解放済みメモリに書き込むことになってしまうため、これは未定義動作につながる可能性があります。

このような状況では、選択肢の 1 つは Transfer.drop に DMA 転送を停止させることです。もう 1 つの選択肢は、Transfer.drop に転送の完了を待たせることです。ここでは、コストが低いため前者を選びます。

#![allow(unused)]
fn main() {
/// A DMA transfer
pub struct Transfer<B> {
    // NOTE: always `Some` variant
    inner: Option<Inner<B>>,
}

// NOTE: previously named `Transfer<B>`
struct Inner<B> {
    buffer: Pin<B>,
    serial: Serial1,
}

impl<B> Transfer<B> {
    /// Blocks until the transfer is done and returns the buffer
    pub fn wait(mut self) -> (Pin<B>, Serial1) {
        while !self.is_done() {}

        atomic::compiler_fence(Ordering::Acquire);

        let inner = self
            .inner
            .take()
            .unwrap_or_else(|| unsafe { hint::unreachable_unchecked() });
        (inner.buffer, inner.serial)
    }
}

impl<B> Drop for Transfer<B> {
    fn drop(&mut self) {
        if let Some(inner) = self.inner.as_mut() {
            // NOTE: this is a volatile write
            inner.serial.dma.stop();

            // we need a read here to make the Acquire fence effective
            // we do *not* need this if `dma.stop` does a RMW operation
            unsafe {
                ptr::read_volatile(&0);
            }

            // we need a fence here for the same reason we need one in `Transfer.wait`
            atomic::compiler_fence(Ordering::Acquire);
        }
    }
}

impl Serial1 {
    /// Receives data into the given `buffer` until it's filled
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn read_exact<B>(mut self, mut buffer: Pin<B>) -> Transfer<B>
    where
        B: DerefMut + 'static,
        B::Target: AsMutSlice<Element = u8> + Unpin,
    {
        // .. same as before ..

        Transfer {
            inner: Some(Inner {
                buffer,
                serial: self,
            }),
        }
    }

    /// Sends out the given `buffer`
    ///
    /// Returns a value that represents the in-progress DMA transfer
    pub fn write_all<B>(mut self, buffer: Pin<B>) -> Transfer<B>
    where
        B: Deref + 'static,
        B::Target: AsSlice<Element = u8>,
    {
        // .. same as before ..

        Transfer {
            inner: Some(Inner {
                buffer,
                serial: self,
            }),
        }
    }
}
}

これで、バッファが解放される前に DMA 転送が停止されます。

#![allow(unused)]
fn main() {
fn reuse(serial: Serial1) {
    let buf = Pin::new(Box::new([0; 16]));

    let t = serial.read_exact(buf); // compiler_fence(Ordering::Release) ▲

    // ..

    // this stops the DMA transfer and frees memory
    mem::drop(t); // compiler_fence(Ordering::Acquire) ▼

    // this likely reuses the previous memory allocation
    let mut buf = Box::new([0; 16]);

    // .. do stuff with `buf` ..
}
}

まとめ

要するに、メモリ安全な DMA 転送を実現するには、次の点をすべて考慮する必要があります:

  • 移動不能なバッファと間接参照を使用する: Pin<B>。あるいは、StableDeref トレイトを使用することもできます。

  • バッファの所有権は DMA に渡さなければなりません: B: 'static

  • メモリ安全性について、デストラクタが実行されることに依存してはいけませんmem::forget があなたの API で使われた場合に何が起こるかを考えてください。

  • DMA 転送を停止するか、その完了を待機するカスタムデストラクタを必ず追加してください。mem::drop があなたの API で使われた場合に何が起こるかを考えてください。


このテキストでは、本番品質の DMA 抽象化を構築するために必要ないくつかの詳細を省いています。たとえば、DMA チャネルの設定(例: ストリーム、循環モードかワンショットモードか、など)、バッファのアラインメント、エラーハンドリング、抽象化をデバイス非依存にする方法などです。これらの側面はすべて、読者 / コミュニティへの演習課題として残されています(:P)。

コンパイラサポートに関する注記

この本では、組み込みのコンパイラターゲットである thumbv7m-none-eabi を利用しています。このターゲット向けには、Rust チームが rust-std コンポーネントを配布しており、これは corestd のようなクレートを事前コンパイルしたコレクションです。

この本の内容を別のターゲットアーキテクチャ向けに再現してみたい場合は、Rust が(コンパイル) ターゲットに対して提供しているサポートのレベルの違いを考慮する必要があります。

LLVM サポート

Rust 1.28 時点では、公式の Rust コンパイラである rustc は、(機械語)コード生成に LLVM を使用しています。Rust がアーキテクチャに対して提供する最小限のサポートレベルは、 rustc でその LLVM バックエンドが有効化されていることです。LLVM を通じて rustc がサポートしているすべてのアーキテクチャは、次のコマンドを実行することで確認できます。

$ # このコマンドを実行するには `cargo-binutils` がインストールされている必要があります
$ cargo objdump -- --version
LLVM (http://llvm.org/):
  LLVM version 7.0.0svn
  Optimized build.
  Default target: x86_64-unknown-linux-gnu
  Host CPU: skylake

  Registered Targets:
    aarch64    - AArch64 (little endian)
    aarch64_be - AArch64 (big endian)
    arm        - ARM
    arm64      - ARM64 (little endian)
    armeb      - ARM (big endian)
    hexagon    - Hexagon
    mips       - Mips
    mips64     - Mips64 [experimental]
    mips64el   - Mips64el [experimental]
    mipsel     - Mipsel
    msp430     - MSP430 [experimental]
    nvptx      - NVIDIA PTX 32-bit
    nvptx64    - NVIDIA PTX 64-bit
    ppc32      - PowerPC 32
    ppc64      - PowerPC 64
    ppc64le    - PowerPC 64 LE
    sparc      - Sparc
    sparcel    - Sparc LE
    sparcv9    - Sparc V9
    systemz    - SystemZ
    thumb      - Thumb
    thumbeb    - Thumb (big endian)
    wasm32     - WebAssembly 32-bit
    wasm64     - WebAssembly 64-bit
    x86        - 32-bit X86: Pentium-Pro and above
    x86-64     - 64-bit X86: EM64T and AMD64

LLVM が目的のアーキテクチャをサポートしていても、rustc がそのバックエンドを無効にした状態でビルドされている場合(Rust 1.28 時点での AVR がこれに該当します)は、それを有効にするよう Rust のソースコードを変更する必要があります。PR rust-lang/rust#52787 の最初の 2 つのコミットを見ると、必要な変更のイメージがつかめます。

一方で、LLVM 自体はそのアーキテクチャをサポートしていないものの、LLVM のフォークがサポートしている場合は、rustc をビルドする前に元の LLVM をそのフォークに置き換える必要があります。Rust のビルドシステムはこれを許容しており、原則として llvm サブモジュールの参照先をそのフォークに変更するだけで済むはずです。

ターゲットアーキテクチャがベンダー提供の GCC でしかサポートされていない場合は、非公式の Rust コンパイラである mrustc を使って Rust プログラムを C コードに変換し、その後 GCC でコンパイルするという選択肢があります。

組み込みターゲット

コンパイルターゲットは、単なるアーキテクチャ以上のものです。各ターゲットには、それに対応する specification があり、そこには特に、そのアーキテクチャ、オペレーティングシステム、デフォルトのリンカーが記述されています。

Rust コンパイラは複数のターゲットを認識しています。これらはコンパイラに 組み込まれて おり、次のコマンドを実行すると一覧表示できます。

``` console
$ rustc --print target-list | column
aarch64-fuchsia                   mipsisa32r6el-unknown-linux-gnu
aarch64-linux-android             mipsisa64r6-unknown-linux-gnuabi64
aarch64-pc-windows-msvc           mipsisa64r6el-unknown-linux-gnuabi64
aarch64-unknown-cloudabi          msp430-none-elf
aarch64-unknown-freebsd           nvptx64-nvidia-cuda
aarch64-unknown-hermit            powerpc-unknown-linux-gnu
aarch64-unknown-linux-gnu         powerpc-unknown-linux-gnuspe
aarch64-unknown-linux-musl        powerpc-unknown-linux-musl
aarch64-unknown-netbsd            powerpc-unknown-netbsd
aarch64-unknown-none              powerpc-wrs-vxworks
aarch64-unknown-none-softfloat    powerpc-wrs-vxworks-spe
aarch64-unknown-openbsd           powerpc64-unknown-freebsd
aarch64-unknown-redox             powerpc64-unknown-linux-gnu
aarch64-uwp-windows-msvc          powerpc64-unknown-linux-musl
aarch64-wrs-vxworks               powerpc64-wrs-vxworks
arm-linux-androideabi             powerpc64le-unknown-linux-gnu
arm-unknown-linux-gnueabi         powerpc64le-unknown-linux-musl
arm-unknown-linux-gnueabihf       riscv32i-unknown-none-elf
arm-unknown-linux-musleabi        riscv32imac-unknown-none-elf
arm-unknown-linux-musleabihf      riscv32imc-unknown-none-elf
armebv7r-none-eabi                riscv64gc-unknown-linux-gnu
armebv7r-none-eabihf              riscv64gc-unknown-none-elf
armv4t-unknown-linux-gnueabi      riscv64imac-unknown-none-elf
armv5te-unknown-linux-gnueabi     s390x-unknown-linux-gnu
armv5te-unknown-linux-musleabi    sparc-unknown-linux-gnu
armv6-unknown-freebsd             sparc64-unknown-linux-gnu
armv6-unknown-netbsd-eabihf       sparc64-unknown-netbsd
armv7-linux-androideabi           sparc64-unknown-openbsd
armv7-unknown-cloudabi-eabihf     sparcv9-sun-solaris
armv7-unknown-freebsd             thumbv6m-none-eabi
armv7-unknown-linux-gnueabi       thumbv7a-pc-windows-msvc
armv7-unknown-linux-gnueabihf     thumbv7em-none-eabi
armv7-unknown-linux-musleabi      thumbv7em-none-eabihf
armv7-unknown-linux-musleabihf    thumbv7m-none-eabi
armv7-unknown-netbsd-eabihf       thumbv7neon-linux-androideabi
armv7-wrs-vxworks-eabihf          thumbv7neon-unknown-linux-gnueabihf
armv7a-none-eabi                  thumbv7neon-unknown-linux-musleabihf
armv7a-none-eabihf                thumbv8m.base-none-eabi
armv7r-none-eabi                  thumbv8m.main-none-eabi
armv7r-none-eabihf                thumbv8m.main-none-eabihf
asmjs-unknown-emscripten          wasm32-unknown-emscripten
hexagon-unknown-linux-musl        wasm32-unknown-unknown
i586-pc-windows-msvc              wasm32-wasi
i586-unknown-linux-gnu            x86_64-apple-darwin
i586-unknown-linux-musl           x86_64-fortanix-unknown-sgx
i686-apple-darwin                 x86_64-fuchsia
i686-linux-android                x86_64-linux-android
i686-pc-windows-gnu               x86_64-linux-kernel
i686-pc-windows-msvc              x86_64-pc-solaris
i686-unknown-cloudabi             x86_64-pc-windows-gnu
i686-unknown-freebsd              x86_64-pc-windows-msvc
i686-unknown-haiku                x86_64-rumprun-netbsd
i686-unknown-linux-gnu            x86_64-sun-solaris
i686-unknown-linux-musl           x86_64-unknown-cloudabi
i686-unknown-netbsd               x86_64-unknown-dragonfly
i686-unknown-openbsd              x86_64-unknown-freebsd
i686-unknown-uefi                 x86_64-unknown-haiku
i686-uwp-windows-gnu              x86_64-unknown-hermit
i686-uwp-windows-msvc             x86_64-unknown-hermit-kernel
i686-wrs-vxworks                  x86_64-unknown-illumos
mips-unknown-linux-gnu            x86_64-unknown-l4re-uclibc
mips-unknown-linux-musl           x86_64-unknown-linux-gnu
mips-unknown-linux-uclibc         x86_64-unknown-linux-gnux32
mips64-unknown-linux-gnuabi64     x86_64-unknown-linux-musl
mips64-unknown-linux-muslabi64    x86_64-unknown-netbsd
mips64el-unknown-linux-gnuabi64   x86_64-unknown-openbsd
mips64el-unknown-linux-muslabi64  x86_64-unknown-redox
mipsel-unknown-linux-gnu          x86_64-unknown-uefi
mipsel-unknown-linux-musl         x86_64-uwp-windows-gnu
mipsel-unknown-linux-uclibc       x86_64-uwp-windows-msvc
mipsisa32r6-unknown-linux-gnu     x86_64-wrs-vxworks

次のコマンドを使うと、これらのターゲットのいずれか 1 つの仕様を表示できます:

$ rustc +nightly -Z unstable-options --print target-spec-json --target thumbv7m-none-eabi
{
  "abi-blacklist": [
    "stdcall",
    "fastcall",
    "vectorcall",
    "thiscall",
    "win64",
    "sysv64"
  ],
  "arch": "arm",
  "data-layout": "e-m:e-p:32:32-i64:64-v128:64:128-a:0:32-n32-S64",
  "emit-debug-gdb-scripts": false,
  "env": "",
  "executables": true,
  "is-builtin": true,
  "linker": "arm-none-eabi-gcc",
  "linker-flavor": "gcc",
  "llvm-target": "thumbv7m-none-eabi",
  "max-atomic-width": 32,
  "os": "none",
  "panic-strategy": "abort",
  "relocation-model": "static",
  "target-c-int-width": "32",
  "target-endian": "little",
  "target-pointer-width": "32",
  "vendor": ""
}

これらの組み込みターゲットのどれも自分のターゲットシステムに適していないようであれば、次のセクション で説明する JSON 形式の独自のターゲット仕様ファイルを作成して、カスタムターゲットを作る必要があります。

rust-std コンポーネント

一部の組み込みターゲットについては、Rust チームが rustup を通じて rust-std コンポーネントを配布しています。このコンポーネントは corestd のような事前コンパイル済みクレートの集合であり、クロスコンパイルに必要です。

次のコマンドを実行すると、rustup 経由で利用可能な rust-std コンポーネントを持つターゲットの一覧を確認できます:

``` console
$ rustup target list | column
aarch64-apple-ios                       mipsel-unknown-linux-musl
aarch64-fuchsia                         nvptx64-nvidia-cuda
aarch64-linux-android                   powerpc-unknown-linux-gnu
aarch64-pc-windows-msvc                 powerpc64-unknown-linux-gnu
aarch64-unknown-linux-gnu               powerpc64le-unknown-linux-gnu
aarch64-unknown-linux-musl              riscv32i-unknown-none-elf
aarch64-unknown-none                    riscv32imac-unknown-none-elf
aarch64-unknown-none-softfloat          riscv32imc-unknown-none-elf
arm-linux-androideabi                   riscv64gc-unknown-linux-gnu
arm-unknown-linux-gnueabi               riscv64gc-unknown-none-elf
arm-unknown-linux-gnueabihf             riscv64imac-unknown-none-elf
arm-unknown-linux-musleabi              s390x-unknown-linux-gnu
arm-unknown-linux-musleabihf            sparc64-unknown-linux-gnu
armebv7r-none-eabi                      sparcv9-sun-solaris
armebv7r-none-eabihf                    thumbv6m-none-eabi
armv5te-unknown-linux-gnueabi           thumbv7em-none-eabi
armv5te-unknown-linux-musleabi          thumbv7em-none-eabihf
armv7-linux-androideabi                 thumbv7m-none-eabi
armv7-unknown-linux-gnueabi             thumbv7neon-linux-androideabi
armv7-unknown-linux-gnueabihf           thumbv7neon-unknown-linux-gnueabihf
armv7-unknown-linux-musleabi            thumbv8m.base-none-eabi
armv7-unknown-linux-musleabihf          thumbv8m.main-none-eabi
armv7a-none-eabi                        thumbv8m.main-none-eabihf
armv7r-none-eabi                        wasm32-unknown-emscripten
armv7r-none-eabihf                      wasm32-unknown-unknown
asmjs-unknown-emscripten                wasm32-wasi
i586-pc-windows-msvc                    x86_64-apple-darwin
i586-unknown-linux-gnu                  x86_64-apple-ios
i586-unknown-linux-musl                 x86_64-fortanix-unknown-sgx
i686-linux-android                      x86_64-fuchsia
i686-pc-windows-gnu                     x86_64-linux-android
i686-pc-windows-msvc                    x86_64-pc-windows-gnu
i686-unknown-freebsd                    x86_64-pc-windows-msvc
i686-unknown-linux-gnu                  x86_64-rumprun-netbsd
i686-unknown-linux-musl                 x86_64-sun-solaris
mips-unknown-linux-gnu                  x86_64-unknown-cloudabi
mips-unknown-linux-musl                 x86_64-unknown-freebsd
mips64-unknown-linux-gnuabi64           x86_64-unknown-linux-gnu (default)
mips64-unknown-linux-muslabi64          x86_64-unknown-linux-gnux32
mips64el-unknown-linux-gnuabi64         x86_64-unknown-linux-musl
mips64el-unknown-linux-muslabi64        x86_64-unknown-netbsd
mipsel-unknown-linux-gnu                x86_64-unknown-redox

ターゲット向けの rust-std コンポーネントがない場合、またはカスタムターゲットを使用している場合は、 標準ライブラリをビルドするために nightly ツールチェーンを使用する必要があります。カスタムターゲット向けのビルド については次のページを参照してください。

カスタムターゲットを作成する

あなたのプラットフォームでカスタムのターゲットトリプルが利用できない場合は、rustc に対して ターゲットを記述するカスタムターゲットファイルを作成しなければなりません。

rustc に未知のターゲット向けには core ライブラリをビルドする必要があり、そのためには nightly コンパイラを使用しなければならないことに注意してください。

ターゲットトリプルを決める

多くのターゲットには、それらを記述するために使われる既知のトリプルがすでにあり、通常は ARCH-VENDOR-SYS-ABI の形式です。LLVM が使用するものと同じトリプルを使うことを目指す べきですが、LLVM が知らない追加情報を Rust に指定する必要がある場合は、それと異なることもあります。 トリプルは技術的には人間が使うためのものにすぎませんが、特に将来的にそのターゲットがアップストリームに 取り込まれる場合は、一意で説明的であることが重要です。

ARCH 部分は、32 ビット ARM の場合を除いて、通常は単にアーキテクチャ名です。たとえば、 そのようなプロセッサにはおそらく x86_64 を使うことになるでしょうが、ARM では正確なアーキテクチャ バージョンを指定します。典型的な値としては armv7armv5tethumbv7neon などがあります。参考として 組み込みターゲットの名前を見てみてください。

VENDOR 部分は省略可能で、製造元を表します。このフィールドを省略することは unknown を使うのと同じです。

SYS 部分は、使用される OS を表します。デスクトッププラットフォームでは、典型的な値として win32linuxdarwin があります。ベアメタルでの使用には none を使います。

ABI 部分は、プロセスがどのように起動するかを表します。ベアメタルには eabi が使われ、 glibc には gnu、musl には musl などが使われます。

ターゲットトリプルが決まったら、そのトリプル名に .json 拡張子を付けたファイルを作成します。 たとえば、armv7a-none-eabi を記述するファイルのファイル名は armv7a-none-eabi.json になります。

ターゲットファイルの内容を埋める

ターゲットファイルは有効な JSON でなければなりません。その内容は 2 か所で説明されています: Target ではすべてのフィールドが必須で、TargetOptions ではすべてのフィールドが省略可能です。 すべてのアンダースコアはハイフンに置き換えられます

推奨される方法は、あなたのターゲットシステムに似た組み込みターゲットの仕様を土台として ターゲットファイルを作成し、その後でターゲットシステムの特性に合わせて調整することです。そのためには、 rustc +nightly -Z unstable-options --print target-spec-json --target $SOME_SIMILAR_TARGET コマンドを使い、 コンパイラにすでに組み込まれているターゲットを指定します。

その出力は、ほぼそのままファイルにコピーできます。まずは次のような修正から始めます:

  • "is-builtin": true を削除する
  • llvm-targetLLVM が想定するトリプル で埋める
  • パニック戦略を決める。ベアメタル実装ではおそらく "panic-strategy": "abort" を使います。panic 発生時に abort しないと決めた場合は、プロジェクトごとに Cargo に そう指示しない限り、eh_personality 関数を定義しなければなりません。
  • アトミック操作を設定する。あなたのターゲットを表す最初の選択肢を選んでください:
    • シングルコアのプロセッサで、スレッドがなく、割り込みがなく、または複数のことが 並行して起こる手段が一切ない: WASM(現時点)などのようにそれが事実だと 確信 できるなら、 "singlethread": true を設定できます。これにより、LLVM はすべてのアトミック 操作を単一スレッド版に変換するよう設定されます。このオプションを誤って使うと、 スレッドや割り込みを使用している場合に UB を引き起こす可能性があります。
    • ネイティブのアトミック操作がある: max-atomic-width を、ターゲットがアトミックに操作できる 最大の型のビット幅に設定します。たとえば、多くの ARM コアは 32 ビットのアトミック操作を持ちます。そうした場合は "max-atomic-width": 32 を設定できます。
    • ネイティブのアトミック操作はないが、自分でエミュレートできる: max-atomic-width を、 128 までの範囲でエミュレートできる最大ビット数に設定し、そのうえで LLVM が期待するすべての atomic および sync 関数を #[no_mangle] unsafe extern "C" として実装します。これらの関数は GCC によって標準化されているため、GCC のドキュメントに補足があるかもしれません。関数が欠けているとリンカーエラーになります。一方、 実装が正しくない関数は UB を引き起こす可能性があります。たとえば、シングルコア・シングルスレッドだが割り込みのある プロセッサなら、これらの関数を、割り込みを無効化し、通常の操作を行い、その後で再度有効化するように 実装できます。
    • ネイティブのアトミック操作がない: コード内で同期を手動で保証するために、unsafe な作業をいくらか 行う必要があります。"max-atomic-width": 0 を設定しなければなりません。
  • 既存のツールチェーンと統合する場合はリンカーを変更する。たとえば、カスタムビルドの GCC を使う ツールチェーンを使っているなら、"linker-flavor": "gcc" を設定し、linker には リンカーのコマンド名を設定します。追加のリンカー引数が必要な場合は、pre-link-argspost-link-args を次のように使います:
    "pre-link-args": {
        "gcc": [
            "-Wl,--as-needed",
            "-Wl,-z,noexecstack",
            "-m64"
        ]
    },
    "post-link-args": {
        "gcc": [
            "-Wl,--allow-multiple-definition",
            "-Wl,--start-group,-lc,-lm,-lgcc,-lstdc++,-lsupc++,--end-group"
        ]
    }
    
    リンカーの種類が link-args 内のキーになっていることを確認してください。
  • LLVM の機能を設定する。利用可能な機能とその説明を一覧するには、ARCH をベースアーキテクチャ (ARM の場合はバージョンを含まない)として llc -march=ARCH -mattr=help を実行します。ターゲットで 厳格なメモリアラインメントアクセスが必要な場合(例: armv5te)は、必ず strict-align を 有効にしてください。機能を有効にするには、その前にプラスを付けます。同様に、 機能を無効にするには、その前にマイナスを付けます。機能は次のようにカンマ区切りにします: "features": "+soft-float,+neon"。ただし、指定したトリプルと CPU に基づいて LLVM が ターゲットについて十分に理解している場合は、これが不要なこともあります。
  • わかっているなら、LLVM が使う CPU を設定する。これにより CPU 固有の最適化と 機能が有効になります。前の手順のコマンド出力の先頭には、既知の CPU の一覧があります。 特定の CPU をターゲットにすることがわかっているなら、JSON ターゲットファイルの cpu フィールドに それを設定できます。

ターゲットファイルを使用する

ターゲット仕様ファイルを用意したら、そのパス、または名前(つまり .json を除いたもの)で参照できます。現在のディレクトリまたは $RUST_TARGET_PATH 内にある場合は、その名前で参照できます。

rustc で読み取れることを確認してください:

❱ rustc --print cfg --target foo.json # または、現在のディレクトリにある場合は foo だけでも可
debug_assertions
target_arch="arm"
target_endian="little"
target_env=""
target_feature="mclass"
target_feature="v7"
target_has_atomic="16"
target_has_atomic="32"
target_has_atomic="8"
target_has_atomic="cas"
target_has_atomic="ptr"
target_os="none"
target_pointer_width="32"
target_vendor=""

これで、ついにこれを使えるようになります! 多くの資料では xargocargo-xbuild が推奨されてきました。 しかし、その後継である cargo の build-std 機能は、最近かなり多くの改善が加えられており、 他の選択肢とすぐに同等の機能を備えるようになりました。そのため、このガイドではこの 選択肢のみを扱います。

まずは最小限の no_std プログラム から始めましょう。次に、 cargo build -Z build-std=core --target foo.json を実行します。パスの参照方法については、 先ほどのルールに従ってください。うまくいけば、ターゲットディレクトリ内にバイナリが 生成されているはずです。

必要に応じて、常にそのターゲットを使うよう cargo を設定することもできます。最小の no_std プログラム に関するページの末尾にある推奨事項を参照してください。ただし、 そのオプションは不安定であるため、現時点では -Z build-std=core フラグを使用する必要があります。

追加の組み込みクレートをビルドする

cargo の build-std 機能を使う場合、どのクレートをコンパイル対象に含めるかを選べます。デフォルトでは、 -Z build-std のみを渡した場合、stdcorealloc がコンパイルされます。ただし、ベアメタル向けに コンパイルする際には std を除外したいこともあるでしょう。その場合は、build-std の後に必要なクレートを 指定してください。たとえば、corealloc を含めるには、-Z build-std=core,alloc を渡します。

トラブルシューティング

language item required, but not found: eh_personality

ターゲットファイルに "panic-strategy": "abort" を追加するか、eh_personality 関数を定義してください。 あるいは、Cargo にこれを無視するよう指示することもできます。

undefined reference to __sync_val_compare_and_swap_#

Rust はターゲットにアトミック命令があると考えていますが、LLVM はそう考えていません。 アトミックの設定 に関する手順に戻ってください。max-atomic-width の数値を 小さくする必要があります。詳細は #58500 を参照してください。

could not find sync in alloc

上のケースと同様に、Rust はアトミックがあると認識していません。自分で実装するか、 アトミック命令があることを Rust に伝える必要があります。

multiple definition of __(something)

Rust プログラムを別の言語でビルドされたコードとリンクしており、その別の言語側に Rust も生成する コンパイラ組み込みが含まれている可能性があります。これを修正するには、複数定義を許可するよう リンカに指示する必要があります。gcc を使用している場合は、次のように追加できます:

"post-link-args": {
    "gcc": [
        "-Wl,--allow-multiple-definition"
    ]
}

error adding symbols: file format not recognized

cargo の build-std 機能に切り替え、コンパイラを更新してください。これは、内部 Rust オブジェクトを 外部リンカに渡そうとした一部のコンパイラビルドで入り込んだバグでした。

SoC の Rust サポートを有効にするためのシリコンベンダー向けガイド

はじめに

Rust は、強力で安全性を重視したプログラミング言語として台頭し、 組み込み開発者の間で支持を広げています。自社の System-on-Chip(SoC)製品で Rust サポートを有効にしたいシリコンベンダーは、この流れを活用して、 拡大する Rust 開発者コミュニティを惹きつけることができます。

このガイドは、シリコンベンダーが Rust サポートを実現できるよう支援することを目的とし、 自ら対応する場合でも、サードパーティ開発者を支援する場合でも役立ちます。 ここでは、System-on-Chip(SoC)を中心とした堅牢な Rust エコシステムを育成するために必要な重要なリソース、タスク、優先事項を概説します。

注: コミュニティとの関わり方に関する戦略について支援が必要な場合は、 Rust Embedded Working Group(REWG)のリードに連絡することを お勧めします。プロセスを効果的に進められるよう、有益な知見と支援を 提供してくれます。

必須のリソース

ドキュメント

詳細なドキュメントは、効果的な開発とデバッグに不可欠です。これは、 メモリマップ、ペリフェラル、割り込み処理、低消費電力モードなどを含む System-on-Chip(SoC)を開発者が理解できるようにするためです。 ドキュメントが、レジスタレベルの詳細からシステムレベルの相互作用まで、 あらゆるハードウェア面を包括的に網羅していることを確認してください。 ドキュメントは公開されているべきです。公開が難しい場合でも、 秘密保持契約(NDA)は、そこから派生したオープンソースコードの公開を 許可しなければなりません。

レジスタ記述ファイル

レジスタ記述ファイルは、ペリフェラルアクセスクレート (PAC)を生成するために使用されます。これらのファイルで最も一般的な形式は SVD (System View Description)です。Rust 開発者は SVD ファイルで問題に遭遇することが少なくないため、不一致や問題を 報告するための明確な連絡先情報を提供することが重要です。最新の SVD ファイルがあれば、コミュニティは効果的に協力して問題を解決し、 PAC の品質を向上させることができます。

Flash Algorithms

Flash Algorithms は、probe-rs のようなデバッグツールと 統合されます。これにより、ファームウェアの書き込みとデバッグが容易かつ高速になり、 開発ワークフローが効率化されます。十分にサポートされた FlashAlgos を提供すると、 これらのツールとの統合が強化され、開発者体験全体が向上します。 フラッシュアルゴリズムは Rust で作成できます(作成用のテンプレートについては flash-algorithm-template を参照してください)。

ベンダーツール

一部の System-on-Chip(SoC)デバイスでは、イメージ生成や デバイスへの書き込みのためにカスタムツールが必要です。これらのツールは、 オープンソースとして提供することが有益であり、コミュニティの貢献を促し、 エコシステムの成長を加速させます。ベンダーツールをオープンソース化すると、 サードパーティ開発者がツールチェーンを拡張および改善できるようになり、 より広範な Embedded Rust エコシステムとの互換性向上にもつながります。

連絡先情報

連絡先情報を提供することは、メンテナーからの問い合わせや、 レジスタ記述ファイルまたはその他のリソースに関連する問題に対応するうえで重要です。 問題の報告と追跡には、公開 issue トラッキングシステム(GitHub Issues など)の 利用が役立つ場合があります。フォーラム、ディスカッション、更新情報を通じて コミュニティと積極的に関わり、信頼と協力関係を築いてください。

PAC と HAL クレートのメンテナンス

ペリフェラルアクセスクレート(PAC)とハードウェア抽象化レイヤー(HAL)クレートは、 Rust サポートを実現するうえで中核となるものです。

PAC の生成とメンテナンス

複数のツール、たとえば svd2rustchiptoolraltool、および svd2pac は、PAC の生成を レジスタ記述ファイルから自動化します。各ツールにはそれぞれ強みがあり、 適切なものの選択は要件とハードウェアの複雑さによって決まります。

HAL クレートの開発とメンテナンス

HAL クレートでは、embedded-halembedded-hal-async、および embedded-io のトレイトを実装してください。 これらのトレイトに準拠することで、Embedded Rust エコシステム全体との互換性が 確保され、相互運用性が向上します。HAL が既存の C コードをラップするのではなく Rust コードを使用することは重要な目標です。すべての中核機能を Rust で実装しつつ、 複雑なドライバーでは Rust バインディング付きの C を使用する段階的移植戦略も 許容され、徐々な採用とコミュニティの貢献を可能にします。

まずは主要なペリフェラル(clock、timer、GPIO)から始め、 コミュニティからのフィードバックに基づいて(I2C、SPI、UART など)へと段階的に拡張してください。早い段階から高頻度で リリースし、コミュニティと関わって、今後の開発に向けた貴重な知見を集めてください。

一般的な推奨事項

  • クレートが no_std 環境と互換性を持つようにしてください。これは、 オペレーティングシステムを持たない組み込みシステムで一般的です。allocstd を必要とする機能は、Cargo の features でゲートしたうえで含めることができます。
  • クレートを crates.io で公開し、 開発者にとっての可視性と使いやすさを最大化してください。
  • リリースの一貫性と予測可能性を維持するために、 semantic versioning を使用してください。
  • Apache 2.0 や MIT のような許容的なライセンスを優先してください。 これにより、より広い採用とコラボレーションが促進されます。

Issue トラッキング

健全で協調的なエコシステムを維持するには、効果的な issue トラッキングが不可欠です。 issue 解決におけるトリアージ、ラベル付け、コミュニティ参加について検討し、 次のための透明性の高いプロセスを整備してください:

  • 深刻度と影響度に基づいて issue をトリアージし、優先順位を付ける。
  • ラベルを使って issue を分類する(例: bugs、feature requests)。
  • フィードバックの提供や pull requests(PRs)の送信を通じて、 コミュニティメンバーが issue 解決に貢献するよう促す。

デバッグとテストを容易にする

Embedded Rust エコシステムには、デバッグ やテストに使われるさまざまなツールがあり、その中でも probe-rs は 最も広く使われているものの 1 つです。probe-rs は幅広い ターゲットアーキテクチャ、デバッグインターフェイス、およびデバッグプローブ プロトコルをサポートしています。組み込みシステム向けのロギング機能を提供する defmt-rtt のようなデバッグベースの仕組みと 組み合わせることで、これらのツールは開発のための堅牢な基盤を形成します。

徹底したテストはハードウェアとソフトウェアの信頼性を確保し、こうした ツールを活用することで開発ワークフローを大幅に強化できます。

エコシステムサポートを強化するための、あると望ましい機能

サンプル

HAL の一部としていくつかの基本的なサンプルを含めることは、 開発者が使い始めるうえで不可欠です。これらのサンプルでは、ペリフェラルの初期化や 割り込み処理などの主要な機能を示すべきです。これらは、 実践的な出発点であり、学習支援にもなります。

BSP(Board Support Package)クレート

BSP クレートは、ボード固有の設定と初期化を提供する必要がある場合に重要です。ハードウェア抽象化に重点を置く HAL とは異なり、BSP は特定のボード向けに複数のコンポーネントを統合する役割を担います。BSP クレートと HAL クレートを分離することで階層的なアプローチが可能になり、開発者は特定のハードウェアボードを対象とするアプリケーションをより容易に構築できます。

プロジェクトテンプレート

プロジェクトテンプレートは、新しいプロジェクトの出発点となる定型的なコード構造です。一般的な設定、依存関係、セットアップ手順が含まれており、開発者の時間を節約し、学習コストを下げます。プロジェクトテンプレートの例には、ベアメタル(フレームワークを使わずに HAL を使用)、Embassy、RTIC などがあります。

一般的な IDE とツールとの統合

次のような一般的なツールを使用して、Embedded Rust プロジェクトの開発環境をセットアップするためのガイドを提供します。

  • rust-analyzer: Rust の構文ハイライトとエラーチェック用。
  • probe-rs: ファームウェアの書き込みとデバッグ用。
  • defmt: 組み込みシステム向けに最適化されたロギングフレームワークで、defmt-test と呼ばれるテストハーネスを含みます。

これらのツールのセットアップ手順を提供することで、開発者がそれらをワークフローに統合しやすくなり、生産性とコラボレーションが向上します。

SoC サポート追加の推奨フロー

  • このフローの事前要件として、Rust ツールチェーンに System-on-Chip (SoC) に一致する target が含まれている必要があります。そうでない場合、解決策は custom target を追加するだけで済むこともあれば、基盤となるアーキテクチャへのサポートを LLVM に追加する必要があるほど難しいこともあります。
  • ゼロから始める前に、既存のコミュニティによる取り組みがすでに存在しないか確認してください(たとえば、awesome-embedded-rust を確認したり、Rust Embedded Matrix room に参加したりします)。これにより、かなりの開発時間を節約できる可能性があります。
  • 対象のターゲットが probe-rs でサポートされていることを確認してください。SWD または JTAG を使ってデバッグできる能力は非常に有益です。書き込みのサポートは Flash Algorithm を使って追加できます(たとえば CMSIS-Pack から取得するか、Rust で作成する ことができます)。
  • レジスタ記述ファイルから Peripheral Access Crates (PACs) を生成します。最も一般的で推奨される形式は SVD (System View Description) です。代替手段として、PDF データシートや C ヘッダーファイルからレジスタ記述を抽出する方法もありますが、こちらははるかに手間がかかる場合があります。
  • PAC および/または空の Hardware Abstraction Layer (HAL) を含む最小限のプロジェクトを作成します。目標は、PAC クレートのみ、または最小限の HAL を用いて、LED を点滅させるか defmt-rtt を通じてメッセージを送信する、最小限で動作するバイナリを得ることです。これにはリンカスクリプトが必要であり、プログラムの書き込みとデバッグが利用可能であることの確認にもなります。コアレジスタや周辺機能用の追加クレート、あるいはスタートアップコードや割り込み処理も必要になります(Cortex-M または RISC-V を参照)。
  • HAL にクロック、タイマー、割り込みといった中核機能を追加します。ロジックアナライザやオシロスコープなどの外部ツールを使って、タイマーと割り込みの正確性を検証してください。
  • 他の周辺機能(GPIO、I2C、SPI、UART など)のドライバを段階的に追加し、標準的な Rust Embedded trait(embedded-halembedded-hal-asyncembedded-io)を実装します。
  • 初期段階では早めに、そして頻繁にリリースし、コミュニティと関わってフィードバックを得てください。

結論

SoC に対する Rust サポートを実現することで、安全性、性能、信頼性を重視する活気ある開発者コミュニティへの扉が開かれます。必要なリソースを提供し、高品質な PAC および HAL クレートを維持し、支援的なエコシステムを育てることで、社内チームとサードパーティ開発者の双方がハードウェアの可能性を最大限に引き出せるようになります。

Rust の組み込みエコシステムが成長を続ける中、これらの実践を取り入れることは、貴社をこのムーブメントの最前線に位置づけ、堅牢で革新的なシステムの構築に情熱を持つ開発者を惹きつけます。Rust コミュニティとの継続的な関わりを促し、ベストプラクティスやツールの最新情報を把握することで、貴社の System-on-Chip (SoC) が Rust 開発者にとって魅力的な選択肢であり続けるようにしてください。

このガイドに従うことで、Rust サポートを実現するだけでなく、製品の周囲に活気ある開発者エコシステムを育む、包括的で支援的な環境を構築できます。