カーネルモジュールと no_std

このページの目次

#![no_std] は、そのクレートが std を自動的にリンクせず、core の prelude を使用することを意味します。これは「OS がない」こと、あるいは「メモリを割り当てられない」ことを意味しません。no_std ライブラリは通常の Linux プログラムで使用することもできますし、適切なランタイムサポートを提供するボトムレス(裸機)環境で使用することもできます。

カーネルモジュールの難しさは、単に std を外すだけではありません。カーネルのリソースライフサイクル、割り当てコンテキスト、ロックルール、ビルド設定に従わなければなりません。ユーザー空間の API に名前を変えただけでは、正しいモジュールにはなりません。

core言語の基礎、イテレータ、Option/Result、利用可能なアトミック型alloc(オプション)Box、Vec、String など;最終的な環境が割り当てサポートを提供する必要があるターゲット実行環境no_std はライブラリ依存関係を変更するだけで、ボトムレスまたはカーネルの実行環境を自動的に構築するものではないエントリーポイント、リンクレイアウト、パニック戦略、デバイスと同期の抽象化std アプリケーションも no_std ライブラリを使用できる;カーネルは独自の API とビルドシステムに依存する。

core、alloc、std はそれぞれ何を提供するか

階層一般的な機能依存条件
coreOption、Result、Iterator、基本型、フォーマットインターフェースヒープに依存しない;一部のアトミック機能はターゲットに制限される
allocBox、Vec、String、Rc、ターゲットに応じた Arc、BTreeMap最終的なプログラムの割り当てサポートが必要
stdファイル、ネットワーク、スレッドなどのプラットフォームインターフェース、および core/alloc の再エクスポートターゲットプラットフォームに対応する実装が必要

HashMap は alloc に含まれていません。ターゲットに応じて追加の実装を選択するか、他のコレクションを使用する必要があります。format_args! はフォーマット引数を構築しますが、String の割り当ては要求しません。alloc::format! だけが文字列を割り当てます。core::fmt::Write を提供することで、フォーマット出力を固定バッファやデバイスに書き込むことができますが、これはヒープ割り当てを最初に実装する必要があることを意味しません。alloc ドキュメント

no_std もまた、「依存関係グラフに std が完全に含まれていないことを保証する」チェッカーではありません。ある依存関係のデフォルトの feature が std を導入する可能性があるため、ターゲットと feature の組み合わせを確認する必要があります。std が根本的に利用できないターゲットでは、関連する依存関係がビルド段階で露出します。Rust クレート規則

no_std はどの依存層を外すか

スライス解析から始めて要件を足します。固定バッファへの整形は core に置けますが、Vec で alloc の責任が生じ、std::fs はプラットフォーム API を必要とします。ヒープ、OS、プログラム入口の有無を no_std だけで一括して決めることはできません。

図を準備しています
no_std はどの依存層を外すか

no_std の核は固定バッファを使えます。Vec は alloc と最終環境の割り当て、ファイルは std または対象向けの実装が必要です。

ランタイムを必要としないライブラリから始める

以下は、ヒープ割り当てを行わない解析関数の例です。呼び出し元が入力スライスを提供し、解析結果または明確なエラーを返します。

#![no_std]

pub fn read_be_u16(input: &[u8]) -> Option<u16> {
    let bytes: [u8; 2] = input.get(..2)?.try_into().ok()?;
    Some(u16::from_be_bytes(bytes))
}

これを nostd.rs として保存し、rustc --edition=2024 --crate-type=rlib nostd.rs でコンパイルします。ここで構築するのは、他のプログラムがリンクできるライブラリであり、_start、panic_handler、またはリンカースクリプトを自分で定義する必要はありません。通常の std テストプログラムから呼び出して、空の入力、短い入力、正しいビッグエンディアンをテストできます。

この分離には実用的な価値があります。プロトコル解析、ステートマシン、データ処理はテスト可能なコアライブラリに、デバイスアクセス、スケジューリング、割り当てポリシーはターゲットアダプタ層に配置します。no_std はこの依存関係の境界を実現する方法の一つです。

std ホスト上で no_std コアを検証する

前述のファイルを parser という名前のライブラリとしてコンパイルし、以下の check.rs を別途作成します。ライブラリは依然として std に依存しませんが、テストのエントリポイントはホストランタイムを使用します。

fn main() {
    assert_eq!(parser::read_be_u16(&[]), None);
    assert_eq!(parser::read_be_u16(&[0x12]), None);
    assert_eq!(parser::read_be_u16(&[0x12, 0x34]), Some(0x1234));
    assert_eq!(parser::read_be_u16(&[0x12, 0x34, 0xff]), Some(0x1234));
}
rustc --edition=2024 --crate-name parser --crate-type=rlib nostd.rs -o libparser.rlib
rustc --edition=2024 check.rs --extern parser=libparser.rlib -o check
./check

最後のアサートは、このインターフェースが最初の 2 バイトのみを読み取り、その後に追加の入力があってもよいことを明確に示しています。プロトコルでパケットが正確に 2 バイトでなければならない場合は、エントリーポイントで長さを別途チェックする必要があります。ホストでの実行は解析契約を検証できますが、ターゲット上でのアラインメント、アトミック機能、リンク、ハードウェア動作のチェックに代わるものではありません。

固定バッファでもフォーマットインターフェースを使用できる

以下を fixed.rs として個別に保存すると、alloc を全く使用しないライブラリになります。バッファは完全な UTF-8 スニペットのみを受け付けます。スペースが不足している場合、現在の write_str 呼び出しは元の値を保持します。フィールドはプライベートであり、呼び出し元が len を偽造したり、既に使用されている領域に不正なエンコーディングを書き込んだりできないようにします。

#![no_std]
use core::fmt::{self, Write};

pub struct FixedText<const N: usize> {
    bytes: [u8; N],
    len: usize,
}
impl<const N: usize> FixedText<N> {
    pub fn new() -> Self { Self { bytes: [0; N], len: 0 } }
    pub fn as_str(&self) -> &str {
        core::str::from_utf8(&self.bytes[..self.len]).expect("UTF-8 invariant")
    }
    pub fn replace_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result {
        let mut next = Self::new();
        next.write_fmt(args)?;
        *self = next;
        Ok(())
    }
}
impl<const N: usize> Write for FixedText<N> {
    fn write_str(&mut self, text: &str) -> fmt::Result {
        let end = self.len.checked_add(text.len()).ok_or(fmt::Error)?;
        let destination = self.bytes.get_mut(self.len..end).ok_or(fmt::Error)?;
        destination.copy_from_slice(text.as_bytes());
        self.len = end;
        Ok(())
    }
}

ホストプログラムがこのライブラリを使用する場合、フォーマット結果を固定領域に書き込むことも、容量不足の結果を検証することもできます。

use core::fmt::{self, Write};
use fixed::FixedText;

struct TwoParts;
impl fmt::Display for TwoParts {
    fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result {
        out.write_str("ok")?;
        out.write_str("toolong")
    }
}
fn main() {
    let mut text = FixedText::<8>::new();
    write!(&mut text, "n={}", 42).unwrap();
    assert_eq!(text.as_str(), "n=42");
    assert!(text.write_str("中中").is_err());
    assert_eq!(text.as_str(), "n=42");

    let mut partial = FixedText::<4>::new();
    assert!(write!(&mut partial, "{TwoParts}").is_err());
    assert_eq!(partial.as_str(), "ok");

    let mut committed = FixedText::<4>::new();
    committed.write_str("old").unwrap();
    assert!(committed.replace_fmt(format_args!("{TwoParts}")).is_err());
    assert_eq!(committed.as_str(), "old");
}

parser と同様に、まず --crate-name fixed --crate-type=rlib でライブラリをコンパイルし、次に --extern fixed=libfixed.rlib でホストプログラムにリンクします。TwoParts は意図的に 2 回に分けて書き出されており、「1 回の write_str の失敗は不変」不等于「全体の write! の失敗は不変」であることを示しています。最初のスニペットは既に書き込まれており、2 番目でオーバーフローします。

replace_fmt はまず一時固定バッファに書き込み、成功してから現在の値を置き換えることで、出力全体のアトミック性を提供します。その代償として、一時的に N バイトのストレージを余分に消費します。ヒープ割り当てがないからといって、メモリ使用量が無視できるわけではありません。大きな N のローカルオブジェクトは依然としてスタックスペースを圧迫します。ここで fmt::Error はフォーマットが続行できないことを示すだけです。デバイスへの書き込みに詳細な I/O エラーを伴わせる必要がある場合は、エラーの保存またはインターフェースを別途設計する必要があります。core::fmt::Write

alloc はオプションだが、最終的な割り当て責任は消えない

割り当てを使用する no_std ライブラリは以下のように書けます。

#![no_std]
extern crate alloc;

use alloc::vec::Vec;

pub fn copy_bytes(input: &[u8])
    -> Result<Vec<u8>, alloc::collections::TryReserveError>
{
    let mut out = Vec::new();
    out.try_reserve_exact(input.len())?;
    out.extend_from_slice(input);
    Ok(out)
}

スペースの予約に成功した後、u8 のコピーにさらに拡張は必要ありません。この関数は、容量不足や割り当て失敗を Result を通じて呼び出し元に渡しますが、プログラム全体のすべての割り当てが回復可能であることを証明するものではありません。最終的な実行環境は、alloc が必要とするグローバル割り当てなどのリンク条件を満たす必要があります。通常の std プログラムは通常、これを提供しています。

ボトムレスのカスタムアロケータは、アラインメント、レイアウトの一致、並行性、割り込みコンテキストなどの規約を満たす必要があります。bump ポインターの例を、任意のマルチスレッドプログラムで利用可能な GlobalAlloc 実装として扱ってはなりません。

no_main、パニック、ハードウェア起動は別の決定事項のグループ

#![no_main] は通常の main エントリポイントの配置を無効にし、no_std から自動的に暗黙的に導出されるものではありません。ボトムレスの実行ファイルには、通常、ターゲットの起動コード、初期スタック、リンクレイアウト、データ初期化、割り込みベクトルが必要です。これらはチップおよび実行フレームワークに依存し、空の loop ですべての起動作業を代表することはできません。

最終的に std がないプログラムでは、どこかで panic handler を提供する必要があります。各ライブラリが独自に定義すると重複します。panic handler はプラットフォームに応じてログ記録、リセット、または停止を行うことができ、必ずしも出力できないわけではありません。展開のサポートは、ターゲット、ランタイム、ビルド戦略によって共同で決定されます。Embedded Rust の no_std

同様に、ビジーウェイトループは低消費電力待機とは異なり、割り込みが開いているかどうか、ウォッチドッグがリセットするかどうかは、実際の動作に影響を与える可能性があります。これらはターゲット実行環境に属し、プラットフォームのチュートリアルで明確にする必要があります。

Rust-for-Linux:ターゲットカーネルソースコードをインターフェースとして

Linux 6.1 で初步的な Rust サポートがマージされましたが、それがすべてのアーキテクチャ、構成、またはディストリビューションで任意の Rust モジュールを直接ビルドできることを意味するものではありません。カーネル Rust API は継続的に進化しており、古いブログ記事にある kernel::file::Operations などのコードは、すべてのバージョンでコンパイル可能なテンプレートとして扱えません。

Linux v6.18 の rust_minimal を読み始めの起点とします。module! はモジュールのメタデータを宣言し、Module::init はモジュールの状態を返し、Drop は終了時のクリーンアップを処理します。例では KVec を使用し、push 時に明示的に GFP_KERNEL を渡して ? で失敗を伝播します。これは、ユーザー空間の Vec、ファイル操作トレイト、架空のシグネチャを貼り合わせたものよりも、実際のインターフェースをよりよく反映しています。v6.18 最小モジュール

学習時は、まずターゲットカーネルソースツリー内で依存関係を確認し、その後、そのバージョンに付属するサンプルをビルドします。

# 準備済みのターゲットカーネルソースツリーで実行
make LLVM=1 rustavailable

その後、そのツリーの構成依存関係に従って Rust と対応するサンプルを有効にし、カーネルビルドシステムを使用してビルドします。rustavailable チェックツールのサポートは、構成や実際のコンパイルに代わるものではありません。Rust、bindgen、LLVM の要件は、ターゲット版本文書を読んで確認すべきであり、「最新バージョンを適当にインストールする」と固定して記述すべきではありません。カーネル Rust クイックスタート

カーネル内での所有権は実行コンテキストによって制約される

ユーザー空間の直感カーネル内でさらに問うべきこと
割り当て成功後に使用する現在の位置でスリープは許可されているか?割り当て失敗をどう返すか?
Mutex をロックすればよい現在、割り込み/アトミックコンテキストにあるか?ロック順序はどのように保証するか?
Drop で自動クリーンアップオブジェクトは依然としてコールバック、ワークキュー、またはデバイスアクセスによって保持されているか?クリーンアップはブロックできるか?
参照カウントが 1 つあれば十分デバイスの取り外し、モジュールのアンロード、非同期実行をどのように調整するか?

カーネルは独自の割り当て、同期、エラー抽象化を提供しており、std::sync::Mutex やユーザー空間のスレッドモデルで直接置き換えることはできません。unsafe によるカプセル化では、これらの追加条件を契約に組み込む必要があり、借用チェッカーはハードウェアが DMA を行っていることや、C のコールバックがまだ終了していないことを自動的に理解しません。

カーネルのパニック動作を「必ず BUG() を呼び出し、カーネル全体を停止する」と一般化することはできません。実装はバージョンと構成によって変化し、例外後の影響は具体的なカーネルパスに依存します。ターゲットバージョンの panic handler と実行戦略を確認する必要があります。結果がどうであれ、パニックはカーネル内で通常予期される失敗を処理するための代替手段ではありません。カーネル Rust 概要

純粋な論理ライブラリ長さ、オーバーフロー、状態不変条件対象でコンパイル・リンクターゲット core / ABI / シンボルターゲット環境での実行起動・デバイス・割り込み・終了各階層は新たな条件を追加するが、前の階層の契約も保持する段階的検証:ホストでの成功はターゲット実行環境を自動的に補完しないカーネルモジュールはターゲットソースツリーのビルドエントリーポイントを使用し、ユーザー空間のグローバルアロケータや起動コードを自分でインストールしない。

初期化と終了をリソース依存関係として捉える

デバイスオブジェクトがまずリソースを取得し、次にコールバックを登録し、最後にワークキューがそれを使用できるように設定すると仮定します。初期化が 2 番目のステップで失敗した場合、1 番目のステップのリソースは確立された所有者によって回収されるべきです。登録に成功した後に失敗した場合は、登録解除を行い、サブシステムの規約に従って進行中の使用が終了するのを待つ必要があります。終了時の鍵は、機械的にいくつかの free を呼び出すことではなく、新しいアクセスを停止し、既存のアクセスを終了させ、最後に参照されている状態を解放することです。

借用と参照カウントはソフトウェアオブジェクトの寿命を表現するのに役立ちますが、DMA、ハードウェア割り込み、C で保存されたコンテキストは自動的に Rust の参照にはなりません。ターゲットサブシステムのシャットダウン、同期、登録解除インターフェースによって「最後のアクセスが終了した」という事実を確立してから、Drop による解放を許可する必要があります。一部の登録解除は待機またはスリープを伴うため、終了はブロックが許可されたコンテキストで発生する必要があります。

検証結果得られた証拠未カバー
no_std rlib のコンパイル成功このライブラリはこのターゲットと feature でコンパイル可能最終エントリーポイント、アロケータ、実行環境
std ホスト境界テスト通過テスト入力に対する論理動作ターゲットハードウェア、割り込み、カーネルコンテキスト
rustavailable 成功ターゲットソースツリーが現在の Rust ツールサポートを認識必要な構成、モジュールリンクとロード
モジュールのビルド完了選択されたソース/構成がモジュール製品を生成可能デバイス相互作用、アンロード競合、負荷動作

推奨される学習の進行順序

まず、純粋なデータ処理をホストでテスト可能な no_std ライブラリとして記述します。次に、ターゲット実行フレームワークでパニック、ログ、割り当てを接続します。最後に、ターゲットカーネルバージョンに既に存在するデバイスまたはサブシステムのサンプルを読み、そのライフサイクルを一つずつ理解します。これにより、言語の問題、ターゲットランタイムの問題、カーネルインターフェースの問題を区別でき、一度に3つの未知の条件を抱え込むことを避けることができます。