組込みRust考察①~効率良く安全な組込み開発をしたい~

Rustの本質は、プログラムを理解する苦労を、未来から現在に移すことにある。

プログラミングRust p.120より

はじめに

www.rust-lang.org

Rustは、とても良い言語です。 Rustを使う理由は、性能、信頼性、生産性を高めたいから、に尽きると考えています。

Rustを学ぶうちに、組込み開発でRustを使うと、多くの恩恵を受けられるのではないか、と考えるようになりました。

また、Rustを学ぶことは、プログラマとしてのスキルを向上させてくれます。 プログラミングRustで一番お気に入りの文章を引用します。

Rustを使い始めてわかったのは、C/C++では長い間かけてゆっくり学んでいたような「良い書き方」を学ばないことには、 Rustではプログラムをコンパイルすることすらできない、ということだ。 Rustは2,3日でまずは手早く覚えて、後から難しい点、技術的な点、良い書き方などを学べるような言語ではないことを強調したい。 Rustでは厳密な安全性についてただちに学ばなければならず、最初のうちはあまり快適には感じないだろう。 しかし、このことは、プログラムをコンパイルするということが本来どういうことだったかを思い出させてくれたように思う。

Mitchell Nordine

原文

p.69 4章 所有権より

残念ながら、組込みプログラマには(にも?)プログラミングや設計スキルが高くない方が多く見られます。 組込みRust普及のため、組込みプログラマ全体のレベルアップのため、Rustが組込みでどう良いのか、を言語化してみます。

長くなりすぎて疲れたので、分割して投稿します。

この記事が目指すもの

一般的なRustの利点と、The Embedded Rust Bookを合わせた要約を作りたいです。 興味のある方はぜひ、The Embedded Rust Bookにも目を通して欲しいのですが、端的に言って長いです。 この記事も長いです。

組込み開発でRustを使いたい人が、周りを説得する材料として、使える部品を作り上げていく意図があります。 1発でうまく作れる気がしないので、フィードバックをもらいながら改版していこう、と考えています。

最終的な成果物としては、GitHubにまとまった1つの資料ができれば良いかな、と思っています。 分かるように説明しておじさん用にプレゼンテーション資料も用意できれば、と考えていますが、確約はできません。

注意事項

比較対象の言語について

Rustの比較対象となる言語は、CおよびC++です(2つの言語を明確に区別します)。 特に今回は組込み開発を対象とするため、特にC言語との比較について言及します。 残念なことに、観測範囲内では、modernなC++を最低限使える組込みプログラマは少ないです。 ということで、C++との比較はこの記事内ではあまり重要視しません(modernなC++を使っている人なら、そのうちRustに興味を持つでしょう)。

この記事内で、CおよびC++に言及する箇所があります。 私としては、この両言語を貶める意図は一切ありません。 (ただ、Cは危ない、C++は難しすぎる、というのが個人の見解です)

また、Rustが両言語を完全に置き換える、ということも現実的でないです。 既存アプリケーションの修正では、依然として、両言語を使う必要があるでしょう。 当面のRustの利用方法としては、新規アプリケーション、または、CおよびC++をラッピングできる状態での使用、となるでしょう。

著者のバックボーン

組込み、というのは曖昧な言葉で、非常に幅広い範囲をカバーしています。 そこで、私自身のバックボーンを明確にしておきます。

私は業務では、主に組込みLinuxを取り扱っています。ハードウェアも比較的、リッチな環境を用いることが多いです。ARMであればAシリーズです。 リソースが制限されているボード上で開発経験は、ごく一部の業務と趣味でのプログラミングとに限定されます。 上記のバックボーンのため、マイコンボードガチ勢の方や、プロプライエタリRTOS上で開発しているプログラマとは、少し見解が違う可能性があります。

対象とする組込み開発

プロプライエタリなツールチェインが必須な環境での開発は、対象外です。 「Rustが良いって言われても、使えないんだけど!」と言われても、ごめんなさい、としか言えません。ごめんなさい。

RTOSでのRust利用方法も、現時点では、確立されていません。 コミュニティでは議題になっています。

Develop resources for Rust integration with RTOSs

組込みLinuxについても少し触れますが、基本は、新規のユーザランドアプリケーションならRust、Linux device driverはC言語で書いて下さい、が主な主張です。 GUI作るなら、Qtとか使った方が良いです。

トピック

細かな話に移る前に、大まかなトピックをまとめます。

今、ざっと思いつく限りで、下の3つが大まかなトピックとして浮かびました。

  • コンパイル時検証による安全性向上
  • 言語仕様、エコシステムによる生産性向上
  • 設計、プログラム品質の向上

本記事では、1つ目のコンパイル時検証による安全性向上と組込みLinuxでの状況について書きます。

コンパイル時検証による安全性向上

コンパイル時、すなわち、実行時のオーバーヘッドなしに、安全なプログラミングをすることができます。 これは、Rustの次の言語仕様によってもたらされます。

  • 型システム
  • 所有権、借用、ライフタイム

言語仕様、エコシステムによる生産性向上

Rustは習得が難しい言語と言われており、その通りだと思います。 C言語GCCの組み合わせで開発を続けて来たプログラマにとって、効率的にRustを書けるようになるまでのコストは大きいでしょう。 ただ、一度学習を終えると、Rustの言語仕様+エコシステムによってもたらされるものの恩恵がいかに大きいか実感できると思います。 下のトピックを扱います。

設計、プログラム品質の向上

Rustでの開発を続けていて感じることは、より優れた設計考えることであったり、ライブラリを作る思考になったり、ドキュメントを書くことが増えた、ということです。 なぜそうなるのでしょうか?Rustでは、その方がうまく行くからです。 これは、次のようなRustの言語仕様やエコシステムに由来するものです。

  • 所有権、借用、ライフタイム
  • Result型によるエラー処理
  • パッケージマネジメント
  • テストフレームワーク
  • rustdoc

問題点と障害

組込みRustの問題点や普及の障害について書きます。 あくまでも、私の観測範囲ですが、下記の要因が複合して、うーんどうしよう状態です。下に行くほど深刻です。抗う仲間募集中です!

  • 単純にRustが難しいという問題
  • ドキュメントと実績不足
  • プログラミングがめっちゃ好き、という人が少ない。新しい言語に自ら手を出す人は希少種。関数型?聞いたことない。
  • 大手メーカが顧客で、プログラミング音痴だが、こちらが技術選定できる立場にない
  • 分かるように説明しておじさん

細かく書くと憂鬱な気分になるので、この話題はここまでにします。

Why Rust?

Rust公式のRust for Embedded devicesに、組込みデバイスでRustを使う理由として、次の6点が挙げられていますので、紹介しておきます。

  • 強力な静的解析 (Powerful static analysis)
  • 柔軟なメモリ管理 (Flexible memory management)
  • 恐れる必要のない並行性 (Fearless concurrency)
  • 相互運用性 (Interoperability)
  • 移植性 (Portability)
  • コミュニティ駆動 (Community driven)

コンパイル時検証による安全性向上

コンパイル時、すなわち、実行時のオーバーヘッドなしに、安全なプログラミングをすることができます。

型安全

まず、Rustは型安全なプログラミング言語です。コンパイル可能なRustのコードは、未定義動作を引き起こしません。 ただし、unsafeブロックという抜け道があり、unsafeブロックの中では容易に未定義動作を起こすことができます。例えば、生ポインタを扱うことはunsafeブロックの中でしかできません。 組込みでは、メモリマップドレジスタを扱うために生ポインタを使う必要があり、unsafeを使わずにプログラミングすることは難しいでしょう。 プログラマは、このunsafeブロックの中で未定義動作を引き起こさないことを保証する必要があります。

逆に言えば、unsafeブロックを安全に保つ、というコストだけで、プログラムが完全に定義されている状態になる、とも言えます。 unsafeブロックの影響を局所化するプラクティスが多く考えられており、ベアメタルな組込みプログラミングであっても、既存のcrateを使うことで、安全にプログラミングすることができます。

品質の良いcrateでは、unsafeブロックが実際には安全である理由がコメントとして書かれていることが多いです。 Rustを使った仕事でのプログラミングでは、当然unsafeブロックを使う理由の説明が求められるでしょう。 しかし、C言語でプログラミングしていて、生ポインタを操作しているからと言って、いちいち槍玉に挙げることはしないでしょう(しないよね?でも危ないんだよ?)。

このことは、unsafeブロックを減らし、未定義動作を引き起こす危険な場所を局所化するインセンティブになります。 例えば、安全なAPIを用意したにも関わらず、そのAPIを使わない人は、unsafeブロックを追加せざるを得ないでしょう。そのことをレビューで指摘することは、従来よりずっと簡単なはずです。 Rustでコードを書くことは、より安全な方向へプログラマを誘導します。

型状態プログラミング

例えば、GPIOを制御することを考えます。あるレジスタの特定ビットを操作することで、GPIOの有効/無効、入力/出力、入力時のプルアップ/プルダウン/ハイインピーダンス、出力時のハイ/ローを制御できるとします。 GPIOの出力をハイレベルにする場合、C言語では次のように書くと思います。

    uint8_t temp = gpio0.read();
    temp |= GPIO0_OUTPUT_HIGH;
    gpio0.write(temp);

このコードでは、gpio0が有効になっているかおよび出力モードになっているかどうか、はチェックしていません。そのため、この操作が本当に意図通り作用するかわかりません。入力モードになっている場合は不正な操作と言えるでしょう。 実行時チェックを追加すると、有効かつ出力モードの時だけ、意図した書き込みを行うようにできます。もちろん、実行時にコストがかかります。

    uint8_t temp = gpio0.read();
    // bit演算を最適化しても本質的なコストは同じです
    if (enable_bit_is_set(temp) && output_bit_is_set(temp)) {
        temp |= GPIO_OUTPUT_HIGH;
        gpio0.write(temp);
    }

Rustでは型状態を使って、不正な操作をコンパイル不可能にすることができます。次の例のような実装を考えます。

struct GpioConfig<ENABLED, DIRECTION, MODE> {
    periph: GPIO_CONFIG,
    enabled: ENABLED,
    direction: DIRECTION,
    mode: MODE,
}

// GpioConfigのMODEのための型状態
struct Disabled;
struct Enabled;
struct Output;
struct Input;
...

/// これらの関数はどのGPIOピンにも使えます。型パラメータは任意の型を取れます。
impl<EN, DIR, IN_MODE> GpioConfig<EN, DIR, IN_MODE> {
...
    // GpioConfigは、`Enabled`かつ`Output`な型状態になります。
    pub fn into_enabled_output(self) -> GpioConfig<Enabled, Output, DontCare> {
        self.periph.modify(|_r, w| {
            w.enable.enabled()
             .direction.output()
             .input_mode.set_high()
        });
        GpioConfig {
            periph: self.periph,
            enabled: Enabled,
            direction: Output,
            mode: DontCare,
        }
    }
}

/// この関数は`Enabled`かつ`Output`な型状態のピンにのみ使用できます。
impl GpioConfig<Enabled, Output, DontCare> {
    pub fn set_bit(&mut self, set_high: bool) {
        self.periph.modify(|_r, w| w.output_mode.set_bit(set_high));
    }
}

これを使うアプリケーションは以下のようになります。

// これで未初期化のGPIOピンが取得できるとします。
let pin: GpioConfig<Disabled, _, _> = get_gpio();

// これはコンパイルエラーになります。`Enabled`かつ`Output`な型状態を持つピンしか出力モードは設定できません。
// pin.set_bit(true);

// 今度は大丈夫です。まず`Enable`かつ`Output`な型状態にします。
let output_pin = pin.into_enabled_output();
output_pin.set_bit(true);

struct Disabled;のような型状態を定義していますが、サイズが0のstructはコンパイラの最適化によって消えます。 上記のアプリケーションコードは、実行時には、メモリマップドレジスタに値を書くだけの機械語になります。

Rustでは、静的に安全性を保証するAPI設計が可能です。

@nodamushi さんが分かりやすい図を作ってくれました。

f:id:tomo-wait-for-it-yuki:20190209054345j:plain
型状態プログラミングによるGPIO制御

所有権、借用、ライフタイム

プログラムの複数の部分が、共有リソース(グローバル変数、ハードウェアレジスタなど)にアクセスすると、プログラムやハードウェアが意図せぬ状態に陥る可能性があります。 そのため、共有リソースの扱いは慎重に検討すべきです。 Rustでは、共有リソースの扱いを慎重に検討しないと、至るところにunsafeブロックが出現したり、コンパイルすることすらできません。

まず、Rustでは、ミュータブルなグローバル変数への読み書きは、常にunsafeです。unsafeブロックを使うのであれば、安全である理由の説明が必要です!

static mut COUNTER: u32 = 0;

fn main() -> ! {
    // 危険!なぜ安全と言えるのか説明して下さい!
    unsafe { COUNTER += 1 };
    loop {}
}

上記の例では、COUNTERの操作中に、COUNTERの値を変更するような割り込みがあると、データ競合が発生し、実行結果はどうなるかわかりません。 例えば、アトミックなアクセスが保証されていれば良いのであれば、そのように書くことができます。

use core::sync::atomic::{AtomicUsize, Ordering};

static COUNTER: AtomicUsize = AtomicUsize::new(0);

fn main() -> ! {
    // アトミック操作命令の`fetch_add()`を使います
    COUNTER.fetch_add(1, Ordering::Relaxed);
    loop {}
}

Rustでは、ガベージコレクションなしにメモリ安全性を保証するため、プログラマに所有権、借用、ライフタイム、というルールを課します。 このルールは、多くのメモリ管理バグや並行性バグを排除できるため、プログラマがこのルールに従うことは、理にかなったものだと思います。

Rustでは、全ての値には唯一の所有者が存在します。これは、その所有者がライフタイムを終えたとき、所有していた値も解放(ドロップ)されることを意味します。 C++をご存知の方はスマートポインタを想像して下さい。

fn main() {
    // Rustでは、`Box<T>型`を使うことで、ヒープ領域を割り当てます。
    let x = Box::new(5);
}  // `x`のライフタイムはここで終了し、所有しているヒープ領域もここで解放(ドロップ)される。

ムーブすることで、所有権を譲渡することが可能です。

fn main() {
    let x = Box::new(5);
    let y = x;  // 所有権を`y`に移動します。

    println!("{}", y);
    // println!("{}", x);  // コンパイルエラー!所有権は`y`に移っています。
}

このように、値は唯一の所有者を持ちますが、例外があります。それが参照の借用です。

fn main() {
    let x = Box::new(5);
    let y = &x;  // `参照`を`借用`する。

    println!("{}", y);
    println!("{}", x);  // 今度はOK!`y`に参照を借用しただけです。
}

参照の借用には、次の2つのルールがあります。

  • ミュータブル参照は同時に1つだけ存在します
  • イミュータブル参照は同時に複数存在することができます

例え、所有権の所有者であっても、ミュータブル参照を誰かに借用すると、その値にはアクセスできません。 このルールによって、Rustの借用チェッカは、データ競合が発生しないことをコンパイル時に検出します。

せっかくの仕組みも、台無しにするのは簡単です。 同一ハードウェアにアクセスするインタンスを複数作ってしまえば、借用チェッカの目を逃れることができます。

0xE000_E010番地にマッピングされているSystemTimerを考えます。

pub struct SystemTimer {
    p: &'static mut RegisterBlock
}

#[repr(C)]
struct RegisterBlock {
    pub csr: RW<u32>,
    pub rvr: RW<u32>,
    pub cvr: RW<u32>,
    pub calib: RO<u32>,
}

impl SystemTimer {
    pub fn new() -> SystemTimer {
        SystemTimer {
            p: unsafe { &mut *(0xE000_E010 as *mut RegisterBlock) }
        }
    }

    pub fn set_reload(&mut self, reload_value: u32) {
        unsafe { self.p.rvr.write(reload_value) }
    }
}

残念ながら、次のコードはコンパイルが通ってしまいます。借用チェッカは、同一ハードウェアにアクセスするインタンスが2つあって安全が脅かされることを検知できません。

fn thread1() {
    let mut st = SystemTimer::new();
    st.set_reload(2000);
}

fn thread2() {
    let mut st = SystemTimer::new();
    st.set_reload(1000);
}

シングルトンは、唯一のインスタンスが存在するように保証するパターンです。乱用すべきでないパターンではありますが、今回の使い方は適切です。

下の例でもグローバル変数を使いますが、単純なグローバル変数ではありません。ペリフェラルアクセス用の唯一のインスタンスを取得するためだけのグローバル変数です。

struct Peripherals {
    serial: Option<SerialPort>,
}

impl Peripherals {
    fn take_serial(&mut self) -> SerialPort {
        let p = replace(&mut self.serial, None);
        p.unwrap()
    }
}

static mut PERIPHERALS: Peripherals = Peripherals {
    serial: Some(SerialPort),
};

使い方は、次のようになります。

fn main() {
    let serial_1 = unsafe { PERIPHERALS.take_serial() };
    // This panics!
    // let serial_2 = unsafe { PERIPHERALS.take_serial() };
}

2回以上take_serial()を呼び出すと、Noneをunwrap()しようとするため、パニックが発生します。 ペリフェラルへのアクセスインスタンスが唯一であることが保証できるので、後はRustの借用チェッカが正しいプログラムであることを保証してくれます。

組込みLinux

組込みLinuxでRustを使うことについて簡単に書きます。

user space application

特に理由がなければ、新規アプリケーションはRustでの開発をお勧めします、と言いたいところですが、欠点もあります。

  • (未来永劫変わらないかもしれませんが)少なくとも現時点では、GUIアプリケーションを作ると苦労するでしょう。
  • Yoctoでフルビルドする場合、LLVMがフルビルドされます。

YoctoとRustでの組込み開発を紹介するブログがあります。

Embedded development with Yocto and Rust

YoctoでSDKを作成してあげると、sysrootもターゲットの環境を使えます。 Runnerをユーザモードエミュレーションのqemuに設定すると、ホスト上でユニットテストが動きます。 ということで、Cargoや言語組込みのユニットテスト機能など、普通のRustの開発サイクルを回すことが可能です。 この環境を一度構築すると、めちゃくちゃ快適に開発できます。

また、CargoのプロジェクトからYoctoのレシピを生成するcargo bitbakeも存在します。 ただ、meta-rustのレイヤを追加すると、LLVMがフルビルドされ、ビルド時間が大幅に増加します。また、人権のないマシンでビルドしている場合、LLVMのリンク時にメモリ不足でビルドが失敗します。 運用上、フルビルドの機会が少ない、夜間ビルドで回している、という場合は影響が少ないと思います。

C言語のライブラリを利用するのは、かなり簡単です。 C言語のヘッダファイルからバインディングを自動生成するbindgenがあります。 C言語のヘッダファイルからバインディングを自動生成して、クレートをビルドする、というプロセスも自動化することができます。

Linux device driver

Rustで書く方法がないわけではないですが、素直にC言語で書くのをお勧めします。

参考ですが、Rustでkernel moduleを書くプロジェクトとして、kernel-rouletteがあります。 Linux kernel moduleからRustを呼ぶkernel-rouletteを解析に解説記事も書いているため、興味がある方は覗いてみて下さい。

kmallocをラッピングして、グローバルアロケータとして登録することで、Vecのようなコレクションを使えるようにしている点が、非常におもしろいです。

続きはこちら。

tomo-wait-for-it-yuki.hatenablog.com