2021年06月14日
RustからRTOS APIを使う
最近Rustという新しいプログラミング言語が多くの分野で注目を浴びています。RustはC++のような低レベルなコンパイル型言語であり高い効率で動作する一方、強力な型システムやメモリ安全性を保証するための仕組みを備えており、バグの少ないコードを書くことができます。本記事では、TOPPERSカーネルや弊社のSOLID-OS上でRustで書かれたプログラムを動かし、カーネルAPIを使用する方法を紹介します。
ビルド環境を整える
Rustコンパイラのインストールは簡単で、 https://rustup.rs/ からダウンロードしたインストーラを実行することでホームディレクトリにインストールできます。rustupはインストールされているコンパイラ、ドキュメント、および標準ライブラリのバージョンを管理するコマンドラインツールです。rustupをインストールするとユーザのPATH
変数が自動的に設定されるはずですので、次のコマンドを実行することでAArch64フリースタンディング環境向けの標準ライブラリをインストールすることができます。
> rustup target add aarch64-unknown-none
標準ライブラリのビルドが提供されているターゲットの一覧は rustup target list
で見ることができます。
Node.jsやRubyのように、RustではCargoというパッケージマネージャを使用してプログラムを構成します (直接コンパイラを呼ぶのはおすすめしません)。Cargoを使用してアプリケーションパッケージを作るには次のようにします。
> cargo new --bin hello
Created binary (application) `hello` package
> cd hello
> cargo run
Compiling hello v0.1.0 (...\hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.33s
Running `target\debug\hello.exe`
Hello, world!
簡単ですね! ただ今回はAArch64向けにコンパイルしてRTOSアプリケーションに結合したいので、次のようにして静的ライブラリパッケージを作ります。
> cd ..
> del -recurse hello
> cargo new --lib hello
Created library `hello` package
> cd hello
hello/Cargo.toml
を編集:
[package]
name = "hello"
version = "0.1.0"
edition = "2018"
# 以下を追加
[lib]
crate-type = ["staticlib"]
hello/src/lib.rs
を編集:
// 標準ライブラリはcore, alloc, stdという部分に分かれている。
// デフォルトではすべてにリンクするが、フリースタンディングターゲットでは
// stdが存在しないので、stdへの依存を明示的に取り除く。
#![no_std]
// `hello` というシンボル名でそのまま出す (名前をマングリングしない)
#[no_mangle]
// Cと互換な呼出し規約を使用する
extern "C" fn hello(x: usize) -> usize {
// 最後の式の値が返り値として返される
x + 42
}
// 配列の境界外アクセスなどの深刻な実行時エラーが検出されると呼び出されます。
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
この状態でビルドするとライブラリが出来上がります。--target
でターゲットを指定するようにしてください。
> cargo build --release --target aarch64-unknown-none
Compiling hello v0.1.0 (...\hello)
Finished release [optimized] target(s) in 0.11s
> dir .\target\aarch64-unknown-none\release\libhello.a
Directory: ...\hello\target\aarch64-unknown-none\release
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 6/10/2021 5:15 PM 6101052 libhello.a
ファイルサイズが大きいように見えますが、未使用のコードも多く含まれているので、-Wl,--gc-sections
を使用している限りは実際には hello
関数の8バイトのコードしか残りません。
> aarch64-kmc-elf-objdump -d target\aarch64-unknown-none\release\libhello.a
In archive .\target\aarch64-unknown-none\release\libhello.a:
hello-d3e497bdb33a28f9.hello.9l5nt8y9-cgu.0.rcgu.o: file format elf64-littleaarch64
Disassembly of section .text.hello:
0000000000000000 <hello>:
0: 9100a800 add x0, x0, #0x2a
4: d65f03c0 ret
⋮
この関数に対応するC関数シグニチャは size_t hello(size_t)
です。libhello.a
を適当なアプリケーションにリンクして呼び出してみてください。
TOPPERS/ASP3カーネルをRustから使う
RustプログラムからTOPPERSカーネルのAPIを呼び出してみたいと思います。
バインディングを手書きする
素朴なアプローチはバインディングを手書きすることです。外部で定義された関数を使用するために extern
ブロックを使用します。
use core::mem::MaybeUninit;
// データ型
type int_t = i32;
type uint_t = u32;
type ATR = uint_t;
type TASK = Option<unsafe extern "C" fn(EXINF)>;
type EXINF = MaybeUninit<isize>;
type PRI = int_t;
type ER_ID = int_t;
#[derive(Debug, Clone, Copy)]
#[repr(C)]
struct T_CTSK {
/// タスク属性
tskatr: ATR,
/// タスクの拡張情報
exinf: EXINF,
/// タスクのメインルーチンの先頭番地
task: TASK,
/// タスクの起動時優先度
itskpri: PRI,
/// タスクのスタック領域のサイズ
stksz: usize,
/// タスクのスタック領域の先頭番地
stk: *mut u8,
}
/// タスクを起動された状態で生成
const TA_ACT: ATR = 0x01;
extern "C" {
fn acre_tsk(pk_ctsk: *const T_CTSK) -> ER_ID;
}
これを呼び出すには次のようにします。
extern "C" fn hello(x: usize) -> usize {
// 関数の中で関数を定義できる
// (効果はトップレベルで定義するのと同じだが、外からはこの関数は
// アクセスできない)
extern "C" fn task_body(_: EXINF) {}
let er = unsafe {
acre_tsk(&T_CTSK {
tskatr: TA_ACT,
exinf: MaybeUninit::uninit(),
task: Some(task_body),
itskpri: 4,
stksz: 2048,
stk: core::ptr::null_mut(),
})
};
er as usize
}
外部の関数は unsafe
ブロック内でしか呼び出せません。Rustはメモリ安全性を脅かす操作をコンパイル時に静的に防ぐ仕組みがありますが、外部の関数には適用できません。外部の関数を呼び出す際には unsafe
ブロックを必要とすることで、メモリ安全性の保証を unsafe
ブロックを書く人に委ねている訳です。
unsafe
というキーワードは大きく分けて2つの対になる使用方法があります。
unsafe fn
やunsafe trait
の中のunsafe
は、そこで定義される項目を使用するにあたって、言語機能で保証できない何らかの性質をプログラマが自ら保証しなければならないことを意味します。RFC 2585の言葉を借りると、これは「証明責任を定義する」と言います。
extern "C" { ... }
やunion member accessなど、unsafe
と書かれていなくても暗黙のうちにこちらに属するものがあります。
unsafe { ... }
やunsafe impl
はこうして定義された項目が正しく使用されているということを宣言します。RFC 2585の言葉を借りると、「証明責任を果たす」と言います。
RFC 2585で導入された
unsafe_op_in_unsafe_fn
lintが無効な現状のデフォルト状態では、unsafe fn
は関数本体が暗黙のうちにunsafe { ... }
で囲まれたように振舞うため、unsafe fn
もこちらにも同時に属します。これは将来的には変わるかもしれません。
#![forbid(unsafe_code)]
属性を使用することで、これらの使用を禁止することができます (例)。
バインディングを自動生成する
bindgen
というツールを使用することでCヘッダーファイルからバインディングを自動的に生成できます。bindgen
はcrates.io (パッケージリポジトリ) に登録されているので、次のCargoコマンドでインストールできます。
> cargo install bindgen
bindgen
を使用するためには libclang
(Clangコンパイラのライブラリ) が必要です。公式ドキュメントで取得方法が説明されています。bindgen
でバインディングを生成するには次のコマンドを実行します。
> cd asp3
> bindgen include\kernel.h -- -Iinclude -Itarget\gr_peach_gcc -Iarch\arm_gcc\common\ -Iarch\arm_gcc\rza1 -Iarch\gcc
こうして出力されるコードには __va_start
など余計なものが多く含まれます。また std
から型を持ってこようとするため、フリースタンディングターゲットではそのまま使えません。こうした問題を解決するために、出力を手作業や sed
で後処理したり、パラメータを調節する必要があります。
⋮
pub type va_list = *mut ::std::os::raw::c_char;
extern "C" {
pub fn __va_start(arg1: *mut *mut ::std::os::raw::c_char, ...);
}
⋮
pub type T_EXCINF = t_excinf;
pub type FLGPTN = uint_t;
pub type INTNO = uint_t;
pub type INHNO = uint_t;
pub type EXCNO = uint_t;
pub type TASK = ::std::option::Option<unsafe extern "C" fn(exinf: EXINF)>;
pub type TMEHDR = ::std::option::Option<unsafe extern "C" fn(exinf: EXINF)>;
pub type ISR = ::std::option::Option<unsafe extern "C" fn(exinf: EXINF)>;
⋮
手間は掛かるので小規模なバインディングを作成するのには非効率的ですが、大規模なバインディングを作成するには重宝します。
このほかに、Autocxxというbindgenをベースにしたツールがあるようです。
Rustの標準ライブラリは大きく分けて3つの部分に分けられます。
std
は標準ライブラリの全ての機能が含まれますが、OSとグローバルメモリアロケータの存在に依存します。alloc
はstd
のうちグローバルメモリアロケータのみに依存する部分です。フリースタンディングターゲットでもグローバルメモリアロケータを定義すれば使用できます。core
はどちらにも依存しない部分です。上の生成コードの中の
::std::option::Option
はcore
に同じものが定義されており (::core::option::Option
,core::option::Option
, または単にOption
)、単純に置き換えられます。しかし、::std::os::raw::c_char
はstd
にしか存在しません。
バインディングをパッケージ化する
crates.ioにはこうして作成されたバインディングをパッケージ化したものが多数公開されています。例としてはwinapi
, libz-sys
, libusb-sys
, llvm-sys
などがあります。
定義した項目をパッケージの消費者がアクセスできるようにするためには pub
を項目の前に付けます。
/// タスクを起動された状態で生成
pub const TA_ACT: ATR = 0x01;
extern "C" {
/// タスクの生成 (ID自動割当て)
pub fn acre_tsk(pk_ctsk: *const T_CTSK) -> ER_ID;
}
cargo doc --open
コマンドを実行するとパッケージのHTMLドキュメンテーションが自動的に生成されます。pub
を追加した後に実行してみてください。
ちなみに、TOPPERS/ASP3向けのバインディングをitron
として実験的にリリースしました。次のようにして使用できるので、お試しください。
Cargo.toml
:
[dependencies]
itron = { version = "0.1.0", features = ["asp3", "dcre"] }
lib.rs
:
use core::mem::MaybeUninit;
use itron::abi::{acre_tsk, TA_ACT, T_CTSK, EXINF};
extern "C" fn hello(x: usize) -> usize {
extern "C" fn task_body(_: EXINF) {}
let er = unsafe {
acre_tsk(&T_CTSK {
tskatr: TA_ACT,
exinf: MaybeUninit::uninit(),
task: Some(task_body),
itskpri: 4,
stksz: 2048,
stk: core::ptr::null_mut(),
})
};
er as usize
}
安全なラッパーを作る
メモリ安全性が脅かされるのは unsafe { ... }
を使用される場所に限られます。したがって、unsafe
な操作をラップすることで、unsafetyを局所化し、コードの検証を容易にすることができます。
例えば acre_tsk
は次のようにラップすることができます。
enum Stack {
Auto { size: usize },
// タスクは `new_task` が返った後も生き残り、またいつ削除されるか分からないので、
// 渡されるスタック領域は `&'static ...` でなければならない。`& ...` としてしまうと、
// `new_task` が返った後に、作成したタスクのスタック領域を呼出し元が破壊できてしまう。
Manual(&'static mut [MaybeUninit<u8>]),
}
#[inline]
fn new_task(
// `TASK` とは異なり、ここには `unsafe` は付けない。
// もしも `unsafe` を付けてしまうと、呼び出し側は `unsafe fn` を
// 渡すことができ、`unsafe { ... }` ブロックを使わずにそうした関数を
// 実行させることができてしまう。
body: extern "C" fn(EXINF),
exinf: EXINF,
itskpri: PRI,
stk: Stack,
) -> ER_ID {
let (stk, stksz) = match stk {
Stack::Auto { size } => (core::ptr::null_mut(), size),
Stack::Manual(slice) => (slice.as_mut_ptr() as _, slice.len()),
};
// `unsafe` ブロックの前でなぜメモリ安全性が保証されるかを説明するのが慣習です。
// Safety: ここで渡す `*const T_CTSK` の参照先は呼出しの期間中は有効。
// `stk` が non-null である場合、`stk[0..stksz]` の所有権は
// 呼出し側が持っていたもので、これをカーネルに渡す。呼出し後に所有
// 権は返ってこないが、`stk` は `&'static mut _` なのでこれでOK。
// `body` は `unsafe fn` ではないので、`body` の呼出しに関して
// メモリ安全性のために満たされるべき前提条件はない。
// Assumptions: `acre_tsk` はTOPPERSカーネル仕様にしたがって実装されており、
// カーネル動作状態であり、カーネル管理外割り込みハンドラから
// 呼び出されていないと仮定する。また、`body` は実行中にタスクの
// スタックをオーバフローしないと仮定する。
unsafe {
acre_tsk(&T_CTSK {
tskatr: TA_ACT,
exinf,
task: Some(body),
itskpri,
stksz,
stk,
})
}
}
これを呼び出す際には unsafe { ... }
は要りませんが、直接 acre_tsk
を呼ぶのと同程度に効率的なコードが生成されます。
extern "C" fn hello(x: usize) -> usize {
extern "C" fn task_body(_: EXINF) {}
let er = new_task(
task_body,
MaybeUninit::uninit(),
4,
Stack::Auto { size: 2048 },
);
er as usize
}
安全なラッパーが本当に安全か確認してみる
前節で作成したラッパーに様々な入力を与えることで、どのようにしてメモリ安全性が保証されるかを確認してみたいと思います。
まずこの関数はタスクの本体を unsafe extern "C" fn
ではなく extern "C" fn
として受け取っています。このため、unsafe fn
(メモリ安全性の前提条件をコンパイラが検証できない関数) を渡すことはできません。
new_task(unreachable, MaybeUninit::uninit(), 4, Stack::Auto { size: 2048 });
/// Safety: This function is never safe to call.
unsafe extern "C" fn unreachable(_: EXINF) {
core::hint::unreachable_unchecked()
}
error[E0308]: mismatched types
--> <source>:40:9
|
40 | unreachable,
| ^^^^^^^^^^^ expected normal fn, found unsafe fn
|
= note: expected fn pointer `extern "C" fn(MaybeUninit<_>)`
found fn item `unsafe extern "C" fn(MaybeUninit<_>) {unreachable}`
この関数はスタック領域を &'static mut [MaybeUninit<u8>]
として受け取っています。単に &[u8]
としない理由は複数あります。これらの理由とそれによって弾ける危険な使用法を構成要素ごとに順を追って見ていきます。
mut
: これはこの参照の寿命にわたって、同じ領域を指す他の参照がないことを表します。タスクが起動した後はタスクの本体がスタックの内容を操作します。書き換えられている最中のスタックの内容を他のタスクが他の参照を通じて同時に読んだらデータ競合 (data races) が起きてしまいます。また同じスタック領域を複数のタスクが使用したら、動作はまったく予想できません。
Data racesはC++の用語で、これが発生した場合にはプログラムの動作は保証されません。
fn create_tasks_with_stack(stack: &'static mut [MaybeUninit<u8>]) {
new_task(hoge, MaybeUninit::uninit(), 4, Stack::Manual(stack));
new_task(hoge, MaybeUninit::uninit(), 4, Stack::Manual(stack));
}
error[E0499]: cannot borrow `*stack` as mutable more than once at a time
--> <source>:40:60
|
39 | new_task(hoge, MaybeUninit::uninit(), 4, Stack::Manual(stack));
| -----
| |
| first mutable borrow occurs here
| requires that `*stack` is borrowed for `'static`
40 | new_task(hoge, MaybeUninit::uninit(), 4, Stack::Manual(stack));
| ^^^^^ second mutable borrow occurs here
'static
: これは渡された参照がプログラムの期間にわたって有効であることを要求します。この関数にはタスクの終了を待機する仕組みはなく、いつまでスタック領域が使用されるか分かりません。このため、この参照はずっと有効である必要があります。
let mut stack = [MaybeUninit::uninit(); 512];
new_task(hoge, MaybeUninit::uninit(), 4, Stack::Manual(&mut stack));
// `stack` はスタック上に確保されているので、ここで寿命が尽きてしまう
error[E0597]: `stack` does not live long enough
--> <source>:40:60
|
40 | new_task(hoge, MaybeUninit::uninit(), 4, Stack::Manual(&mut stack));
| ^^^^^^^^^^
| |
| borrowed value does not live long enough
| cast requires that `stack` is borrowed for `'static`
41 | }
| - `stack` dropped here while still borrowed
'static
でないstack: &mut _
を'static
にするには次のようにします。unsafe { &mut *(stack as *mut _) }
これは安全な操作ではないのでunsafe { ... }
が要ります。この領域が作成したタスクより長生き (outlive) するようにすることは、unsafe { ... }
を使ったコード、すなわち呼び出し側の責任になります。
MaybeUninit
: これは最初の二つとは異なり、呼び出し側に課せられる要求を緩和し、関数本体に制限を課すものです。Rustでは適切に初期化されていない値を読むのは未定義動作を生じるので、基本的には値はすべてその型に応じた形で初期化されている必要があります。例えば &mut [u8]
の値を持っているとき、これの参照先は完全に初期化されている必要があります。しかし、この関数の場合に渡されるスタック領域は初期化されている必要はありません。MaybeUninit<u8>
はこうした「u8
として初期化されているかもしれないが、一方で初期化されていない可能性もあるメモリ領域」を表すための型です。
例えば、上の new_task
の中で Stack::Manual
が指すスライスに含まれる MaybeUninit<u8>
型の要素を読むのは、与えられたメモリ領域が初期化されていなくても安全です。しかし、ここから u8
を取り出すのは unsafe { ... }
無しには行えません。
match stk {
Stack::Manual(slice) => {
// "core::mem::maybe_uninit::MaybeUninit<u8>"
dbg!(slice[0]);
// 初期化されていれば値を出力、初期化されていなければ未定義動作
dbg!(unsafe { slice[0].assume_init() });
}
_ => {}
}
MaybeUninit<T>
はT
として正しく初期化されていないかもしれないので、T
として解釈してアクセスするにはunsafe { ... }
が要ります (e.g.,unsafe { maybe_uninit.assume_init() }
)。逆に、MaybeUninit<T>
の値を作成する際にはT
の値を与える必要はありません (e.g.,MaybeUninit::<T>::uninit()
)。ローカル変数,Box
,Vec
等でメモリを割り当てる際には値を入れる必要がありますが、MaybeUninit
を使用することで内容を初期化せずメモリ割り当てをすることができます。上の例のように、
T
がu8
などの「任意のバイト表現が有効な値」である場合に関しては、実は未初期化の値を扱うコードがMaybeUninit
の導入以前からあったこともあり、厳密な扱いはまだ決まっていません。現時点では、新しく書かれるコードはMaybeUninit
を必ず使用することが推奨されています。
MaybeUninit
(初期化されていないメモリ) と値の欠如 (e.g., 未代入のローカル変数) はまったく異なる概念です。
&mut u8
の参照先は「必ず初期化されていなければならない」というルールに従いますが、*mut u8
(raw pointers) はこれに縛られません。型付けされていないサイズ未指定のメモリ領域は*mut u8
で指すのが一般的で、これはCのvoid *
と対応します。