💬

月刊「Rustは低レイヤーで使ってもunsafeだらけになるから意味がない」廃刊のお知らせ

2025/01/05に公開

組込みRustや自作OSなどにおいて、従来言語より高い安全性があるとされているRustは注目される一方、Rustでも低レイヤー分野で頻発するメモリアクセスやインラインアセンブラなどでunsafeブロックが存在が多くなりがちです。
このunsafeブロックが多くなるので、結局Rustでこのような低レイヤー分野を触っても意味がないのでは?安全ではないのでは?という疑問が投げかけられることがしばしばあります。

筆者はTwitterや過去のブログで、組込みRustにおいてのunsafeとの付き合い方について触れていきましたが、内容としては分散していたので今一度この疑問に対するアンサーをまとめることにし、この論争に終止符を打とうと思います。

Rustにおけるunsafeとは

そもそもunsafeとは何かというのはThe Rust Programming Languageのドキュメントにも詳しく書かれています。

https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html

Rustがコンパイル時に保証するメモリ安全性は厳しく、本来メモリ安全であるようなコードがそのまま書けないということがあります。また、低レイヤープログラミングで頻繁に登場する直接のメモリアクセスやインラインアセンブラをコンパイラがその中身を精査することは難しいです。
そのため、抜け道としてunsafeブロックというものが用意されており、この中ではunsafeとマークされている操作が行えるようになります。すべてのコンパイラのチェックがなくなるわけではありません。
具体的にできるようになるものとしては

  • 生ポインタの参照外し
  • グローバル変数の変更
  • インラインアセンブラの呼び出し
  • C言語などで書かれた外部関数の呼び出し(FFIの使用)
  • unsafe関数の呼び出し

などがあります。一方で、例えばmutableな参照を複数つくったりライフタイムを無視したりといった、もともとのRustの安全性のためのルールを破ることは直接はできません(一度生ポインタをつくるなどで実質的に同じようなことをすることはできる)。

このようなunsafeブロックを含んだ関数は必ずしもunsafeな関数にする必要はありません。人間がこれは安全なコードだと判断できるのであればunsafeブロックを含むコードをsafeな関数として公開できてしまいます。
ただし、コンパイラが安全性を保証できないため、このようなunsafeな操作を挟んでも最終的にRustが本来保証している安全性を保っている関数のみをsafeな関数として公開するのが望ましいです。

実はsafeな標準関数のライブラリの実装を見てみると実際は多くのunsafeブロックを含んでいます。よって実は低レイヤープログラミングでなくてもほとんどのRustのプログラムはunsafeな操作と共存していると言えます。

unsafeブロックとの共存

じゃあ、ほとんどのRustプログラムがunsafeブロックを含んでいるのだから、Rustがメモリ安全というのは嘘なのか、というとそうではありません。
例えばstdに含まれるVecinsertメソッドを見てみましょう。この実装はunsafeブロックを含みます。

    pub fn insert(&mut self, index: usize, element: T) {
        #[cold]
        #[cfg_attr(not(feature = "panic_immediate_abort"), inline(never))]
        #[track_caller]
        #[optimize(size)]
        fn assert_failed(index: usize, len: usize) -> ! {
            panic!("insertion index (is {index}) should be <= len (is {len})");
        }

        let len = self.len();
        if index > len {
            assert_failed(index, len);
        }

        // space for the new element
        if len == self.buf.capacity() {
            self.buf.grow_one();
        }

        unsafe {
            // infallible
            // The spot to put the new value
            {
                let p = self.as_mut_ptr().add(index);
                if index < len {
                    // Shift everything over to make space. (Duplicating the
                    // `index`th element into two consecutive places.)
                    ptr::copy(p, p.add(1), len - index);
                }
                // Write it in, overwriting the first copy of the `index`th
                // element.
                ptr::write(p, element);
            }
            self.set_len(len + 1);
        }
    }

内部に持っている生配列をunsafeブロック内で操作することで要素を挿入しています。では、このinsertメソッドを使っているコードはunsafeかと言われるとそんなことはないです。
insertメソッドが正しく実装されている限りは配列の挿入操作によってメモリモデルが破壊されることはないためです。
C++とか他の言語の同様のデータ構造の同様な操作をするときに、ここにメモリ関連のバグがあるかもしれないとか怯えながら書きませんよね?

つまり、unsafeなブロックを使っても人の手できちんとメモリの整合性を保てているのであれば、safeとして扱ってそれを隠蔽してしまいそれを使う上位のコードは気にせずに書けばよいということになります。
低レイヤーのコードでも同じです。unsafeな操作をしなければならない箇所というのを限定して適切な抽象化を行うことで、その上位のコードは安全に書けるようになります。

組込みRustの抽象化の具体例

とはいっても、いざ適切に抽象化せよと言われても難しいです。特にC言語とかで経験を積んできた人はC言語前提でデータ構造を考えてしまいがちで、それを愚直にRustに移植してしまうとunsafeブロックがそこら中に発生してしまいます。
ではいくつか自分が美しいと思った抽象化の例を上げてみます

embedded-hal

Rust embeddedチームでメンテナンスされているembedded-halというクレートは各種組込みボードのペリフェラルのハードウェア抽象用のトレイトを提供しています。

https://github.com/rust-embedded/embedded-hal

ペリフェラルドライバに相当するのでMMIOのようなメモリを直接触る操作をたくさん実装する必要があるのですが、最終的にはすべてがsafeな関数として提供されています。
例えばRaspberrpy PicoのUARTドライバで1バイトを読み込む実装はこのようになっています。

https://github.com/rp-rs/rp-hal/blob/2fb63064c98edcf688694bc63dc05f57b89a71b2/rp2040-hal/src/uart/reader.rs#L115

UARTドライバを実装したことがある人はなんとなく何をしているコードか解ると思いますが、レジスタを触っていると思われる箇所向けにsafeな関数が提供されていることがわかります。
これはembedded-halを使ったクレートではよくある手法なのですが、SVDファイルというハードウェアのレジスタ情報を記述したファイルを使って自動生成されたコードを使って、レジスタ周りのsafeな関数を提供しています。

https://github.com/rp-rs/rp2040-pac

MMIOを触るコードを書こうとすると生ポインタへのアクセスが必要でその部分はどうしてもunsafeとしてマークされてしまいます。しかし、MMIOとしてマッピングされている領域にメモリアクセスをするのであれば他のメモリに悪影響を与えないはずなのでメモリモデルが壊れることはないです。
このような抽象レイヤーを提供することで、上位のコードは任意のアドレスの生ポインタを触るといったリスクがなくなるため、上位レイヤーでunsafeなブロックを排除することに成功しています。

さらに上位のレイヤーとしてイーサネットドライバの実装を読んだことがあり、そこのDMAから受信パケットを読み出す構造体は設計がうまいと思いました。

https://garasubo.com/hexo/2020/12/20/ether.html

ポーリング受信用のメソッドのrecv_nextRxPacketという構造体が受け取れ、ここからDMAのバッファの参照をもらうことができるのですが、DMA内のバッファはリングバッファになっており読み込みが終わったら開放のための処理をしないといけません。
この構造体はDropトレイトを実装していて、この構造体がスコープを抜けるときに自動的にバッファを開放するようになっています。

OSの例

RustでOSをつくるという論文がいくつかOSのトップ会議で発表されています。
これらは単にLinuxのようなOSをRustで再実装しましたよという話ではなく、Rustの型システムを上手につかってあげることで今までにないOSの設計手法を提案するというものです。
私が簡単に紹介したものを以前プレゼンしたのがlogmiの記事として公開されています。

https://logmi.jp/main/technology/324499

ここで紹介したTheseusというOSについてはこちらのブログで深堀しています。

https://garasubo.com/hexo/2021/04/04/theseus.html

unsafeブロックに絞ったバグ検出手法

unsafeブロックを適切に抽象化してしまえば上位レイヤーは安全に書けるという話をしましたが、それでもunsafeブロックを使っている箇所というのはコンパイラでは安全性が保証できない部分になります。
unsafeな箇所が限定されることで、たしかに他の言語よりはデバッグの際の目星がつきやすいという利点はありそうです。
さらに踏み込んでこのunsafeコードに絞って機械的に解析することでバグを発見するという研究もあります。

https://dl.acm.org/doi/10.1145/3477132.3483570

このRudraという研究ではunsafeブロックを用いているstdを含む著名なクレートを解析して76個のCVEと112個のRustSec Advisoryを発見しました。

Rustの魅力は安全性だけではない

Rustは安全性ばかりが注目されがちにですが、関連するツールが洗練されている点も魅力だと筆者は考えています。
cargoによる標準のビルドシステムによるパッケージ管理はC言語にはないものです。ユニットテストフレームワークやドキュメンテーション生成ツールも標準で提供されているため、これらも開発体験を向上するのに役に立ちます。
また、coreライブラリも意外と充実していて、stdが使えない組込み環境でも十分な機能を提供してくれます。

たとえコードがunsafeだらけになったとしても、C言語や他の言語ではそもそも保証がなかった部分なので、他の言語と同等の水準の安全性になるというだけの話です。
単にこれらのエコシステムを使いたいという理由だけでもRustを使う価値はあると筆者は考えます。

Rustは銀の弾丸ではない

Rustは最近になり人気・知名度も上がり、実際に使っている企業・プロジェクトも増えています。一方で、過度な期待から誤解を生むことも多々あるのかなと感じます。
自分でメモリ管理やデバイス抽象化をしなければならない低レイヤー分野でRustが提供するのは完全なメモリ安全性というような幻想ではなく、あくまで安全性を担保しやすい構造をつくるための環境だけです。
また、Rustが保証するメモリ安全性はすべてのバグを排除するというものではありません。例えばメモリリークはRustではsafeな操作として許容されています。シンプルなロジックのミスやアルゴリズムの誤りも当然Rustでは防げません。

しかしながら、Rustを活用することで従来では保証が難しかった性質をコンパイル時に保証することができるというのは安全性を高める上で大きなメリットです。
モダンな言語機能をフルに活用して今まではできなかった抽象化を行うことで、より安全かつ効率的なプログラムを書く可能性を切り開いてくれます。
組込みRustはまだまだ発展途上の分野なので、みなさんもこの可能性を探求してみませんか?

Discussion