Writing an OS in Rust

Philipp Oppermann's blog

Rustでつくる最小のカーネル

Translated Content: This is a community translation of the A Minimal Rust Kernel post. It might be incomplete, outdated or contain errors. Please report any issues!

Translation by @woodyZootopia, and @JohnTitor.

この記事では、Rustで最小限の64bitカーネルを作ります。前の記事で作ったフリースタンディングなRustバイナリを下敷きにして、何かを画面に出力する、ブータブルディスクイメージを作ります。

このブログの内容は GitHub 上で公開・開発されています。何か問題や質問などがあれば issue をたててください (訳注: リンクは原文(英語)のものになります)。またこちらにコメントを残すこともできます。この記事の完全なソースコードはpost-02 ブランチにあります。

Table of Contents

🔗起動 (Boot) のプロセス

コンピュータを起動すると、マザーボードの ROM に保存されたファームウェアのコードを実行し始めます。このコードは、起動時の自己テスト (power-on self test) を実行し、使用可能なRAMを検出し、CPUとハードウェアを事前初期化 (pre-initialize) します。その後、ブータブル (bootable) ディスクを探し、オペレーティングシステムのカーネルを起動 (boot) します。

x86には2つのファームウェアの標準規格があります:"Basic Input/Output System" (BIOS) と、より新しい "Unified Extensible Firmware Interface" (UEFI) です。BIOS規格は古く時代遅れですが、シンプルでありすべてのx86のマシンで1980年代からよくサポートされています。対して、UEFIはより現代的でずっと多くの機能を持っていますが、セットアップが複雑です(少なくとも私はそう思います)。

今の所、このブログではBIOSしかサポートしていませんが、UEFIのサポートも計画中です。お手伝いいただける場合は、GitHubのissueをご覧ください。

🔗BIOSの起動

ほぼすべてのx86システムがBIOSによる起動をサポートしています。これは近年のUEFIベースのマシンも例外ではなく、それらはエミュレートされたBIOSを使います。前世紀のすべてのマシンにも同じブートロジックが使えるなんて素晴らしいですね。しかし、この広い互換性は、BIOSによる起動の最大の欠点でもあるのです。というのもこれは、1980年代の化石のようなブートローダーを動かすために、CPUがリアルモード (real mode) と呼ばれる16bit互換モードにされてしまうということを意味しているからです。

まあ順を追って見ていくこととしましょう。

コンピュータは起動時にマザーボードにある特殊なフラッシュメモリからBIOSを読み込みます。BIOSは自己テストとハードウェアの初期化ルーチンを実行し、ブータブルディスクを探します。ディスクが見つかると、 ブートローダー (bootloader) と呼ばれる、その先頭512バイトに保存された実行可能コードへと操作権が移ります。多くのブートローダーのサイズは512バイトより大きいため、通常は512バイトに収まる小さな最初のステージと、その最初のステージによって読み込まれる第2ステージに分けられています。

ブートローダーはディスク内のカーネルイメージの場所を特定し、メモリに読み込まなければなりません。また、CPUを16bitのリアルモードから32bitのプロテクトモード (protected mode) へ、そして64bitのロングモード (long mode) ――64bitレジスタとすべてのメインメモリが利用可能になります――へと変更しなければなりません。3つ目の仕事は、特定の情報(例えばメモリーマップなどです)をBIOSから聞き出し、OSのカーネルに渡すことです。

ブートローダーを書くのにはアセンブリ言語を必要とするうえ、「何も考えずにプロセッサーのこのレジスタにこの値を書き込んでください」のような勉強の役に立たない作業がたくさんあるので、ちょっと面倒くさいです。ですのでこの記事ではブートローダーの制作については飛ばして、代わりにbootimageという、自動でカーネルの前にブートローダを置いてくれるツールを使いましょう。

自前のブートローダーを作ることに興味がある人もご期待下さい、これに関する記事も計画中です!

🔗Multiboot標準規格

すべてのオペレーティングシステムが、自身にのみ対応しているブートローダーを実装するということを避けるために、1995年にフリーソフトウェア財団Multibootというブートローダーの公開標準規格を策定しています。この標準規格では、ブートローダーとオペレーティングシステムのインターフェースが定義されており、Multibootに準拠したブートローダーであれば、同じくそれに準拠したすべてのオペレーティングシステムが読み込めるようになっています。そのリファレンス実装として、Linuxシステムで一番人気のブートローダーであるGNU GRUBがあります。

カーネルをMultibootに準拠させるには、カーネルファイルの先頭にいわゆるMultiboot headerを挿入するだけで済みます。このおかげで、OSをGRUBで起動するのはとても簡単です。しかし、GRUBとMultiboot標準規格にはいくつか問題もあります:

  • これらは32bitプロテクトモードしかサポートしていません。そのため、64bitロングモードに変更するためのCPUの設定は依然行う必要があります。
  • これらは、カーネルではなくブートローダーがシンプルになるように設計されています。例えば、カーネルは通常とは異なるデフォルトページサイズでリンクされる必要があり、そうしないとGRUBはMultiboot headerを見つけることができません。他にも、カーネルに渡されるブート情報 (boot information) は、クリーンな抽象化を与えてくれず、アーキテクチャ依存の構造を多く含んでいます。
  • GRUBもMultiboot標準規格もドキュメントが充実していません。
  • カーネルファイルからブータブルディスクイメージを作るには、ホストシステムにGRUBがインストールされている必要があります。これにより、MacとWindows上での開発は比較的難しくなっています。

これらの欠点を考慮し、私達はGRUBとMultiboot標準規格を使わないことに決めました。しかし、あなたのカーネルをGRUBシステム上で読み込めるように、私達のbootimageツールにMultibootのサポートを追加することも計画しています。Multiboot準拠なカーネルを書きたい場合は、このブログシリーズの第1版をご覧ください。

🔗UEFI

(今の所UEFIのサポートは提供していませんが、ぜひともしたいと思っています!お手伝いいただける場合は、 GitHub issueで教えてください。)

🔗最小のカーネル

どのようにコンピュータが起動するのかについてざっくりと理解できたので、自前で最小のカーネルを書いてみましょう。目標は、起動したら画面に"Hello, World!"と出力するようなディスクイメージを作ることです。というわけで、前の記事の独立した (freestanding) Rustバイナリをもとにして作っていきます。

覚えていますか、この独立したバイナリはcargoを使ってビルドしましたが、オペレーティングシステムに依って異なるエントリポイント名とコンパイルフラグが必要なのでした。これはcargoは標準では ホストシステム(あなたの使っているシステム)向けにビルドするためです。例えばWindows上で走るカーネルというのはあまり意味がなく、私達の望む動作ではありません。代わりに、明確に定義された ターゲットシステム 向けにコンパイルできると理想的です。

🔗RustのNightly版をインストールする

Rustにはstablebetanightlyの3つのリリースチャンネルがあります。Rust Bookはこれらの3つのチャンネルの違いをとても良く説明しているので、一度確認してみてください。オペレーティングシステムをビルドするには、nightlyチャンネルでしか利用できないいくつかの実験的機能を使う必要があるので、Rustのnightly版をインストールすることになります。

Rustの実行環境を管理するのには、rustupを強くおすすめします。nightly、beta、stable版のコンパイラをそれぞれインストールすることができますし、アップデートするのも簡単です。現在のディレクトリにnightlyコンパイラを使うようにするには、rustup override set nightlyと実行してください。もしくは、rust-toolchainというファイルにnightlyと記入してプロジェクトのルートディレクトリに置くことでも指定できます。Nightly版を使っていることは、rustc --versionと実行することで確かめられます。表示されるバージョン名の末尾に-nightlyとあるはずです。

nightlyコンパイラでは、いわゆるfeature flagをファイルの先頭につけることで、いろいろな実験的機能を使うことを選択できます。例えば、#![feature(asm)]main.rsの先頭につけることで、インラインアセンブリのための実験的なasm!マクロを有効化することができます。ただし、これらの実験的機能は全くもって不安定 (unstable) であり、将来のRustバージョンにおいては事前の警告なく変更されたり取り除かれたりする可能性があることに注意してください。このため、絶対に必要なときにのみこれらを使うことにします。

🔗ターゲットの仕様

Cargoは--targetパラメータを使ってさまざまなターゲットをサポートします。ターゲットはいわゆるtarget triple (3つ組) によって表されます。これはCPUアーキテクチャ、製造元、オペレーティングシステム、そしてABIを表します。例えば、x86_64-unknown-linux-gnuというtarget tripleは、x86_64のCPU、製造元不明、GNU ABIのLinuxオペレーティングシステム向けのシステムを表します。Rustは多くのtarget tripleをサポートしており、その中にはAndroidのためのarm-linux-androideabiWebAssemblyのためのwasm32-unknown-unknownなどがあります。

しかしながら、私達のターゲットシステムには、いくつか特殊な設定パラメータが必要になります(例えば、その下ではOSが走っていない、など)。なので、既存のtarget tripleはどれも当てはまりません。ありがたいことに、RustではJSONファイルを使って独自のターゲットを定義できます。例えば、x86_64-unknown-linux-gnuというターゲットを表すJSONファイルはこんな感じです。

{
    "llvm-target": "x86_64-unknown-linux-gnu",
    "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": "64",
    "target-c-int-width": "32",
    "os": "linux",
    "executables": true,
    "linker-flavor": "gcc",
    "pre-link-args": ["-m64"],
    "morestack": false
}

ほとんどのフィールドはLLVMがそのプラットフォーム向けのコードを生成するために必要なものです。例えば、data-layoutフィールドは種々の整数、浮動小数点数、ポインタ型の大きさを定義しています。次に、target-pointer-widthのような、条件付きコンパイルに用いられるフィールドがあります。第3の種類のフィールドはクレートがどのようにビルドされるべきかを定義します。例えば、pre-link-argsフィールドはリンカ (linker) に渡される引数を指定しています。

私達のカーネルもx86_64のシステムをターゲットとするので、私達のターゲット仕様も上のものと非常によく似たものになるでしょう。x86_64-blog_os.jsonというファイル(お好きな名前を選んでください)を作り、共通する要素を埋めるところから始めましょう。

{
    "llvm-target": "x86_64-unknown-none",
    "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": "64",
    "target-c-int-width": "32",
    "os": "none",
    "executables": true
}

ベアメタル (bare metal) 環境で実行するので、llvm-targetのOSを変え、osフィールドをnoneにしたことに注目してください。

以下の、ビルドに関係する項目を追加します。

"linker-flavor": "ld.lld",
"linker": "rust-lld",

私達のカーネルをリンクするのに、プラットフォーム標準の(Linuxターゲットをサポートしていないかもしれない)リンカではなく、Rustに付属しているクロスプラットフォームのLLDリンカを使用します。

"panic-strategy": "abort",

この設定は、ターゲットがパニック時のstack unwindingをサポートしていないので、プログラムは代わりに直接中断 (abort) しなければならないということを指定しています。これは、Cargo.tomlにpanic = "abort"という設定を書くのに等しいですから、後者の設定を消しても構いません(このターゲット設定は、Cargo.tomlの設定と異なり、このあと行うcoreライブラリの再コンパイルにも適用されます。ですので、Cargo.tomlに設定する方が好みだったとしても、この設定を追加するようにしてください)。

"disable-redzone": true,

カーネルを書いている以上、ある時点で割り込み (interrupt) を処理しなければならなくなるでしょう。これを安全に行うために、 "red zone" と呼ばれる、ある種のスタックポインタ最適化を無効化する必要があります。こうしないと、スタックの破損 (corruption) を引き起こしてしまう恐れがあるためです。より詳しくは、red zoneの無効化という別記事をご覧ください。

"features": "-mmx,-sse,+soft-float",

featuresフィールドは、ターゲットの機能 (features) を有効化/無効化します。マイナスを前につけることでmmxsseという機能を無効化し、プラスを前につけることでsoft-floatという機能を有効化しています。それぞれのフラグの間にスペースは入れてはならず、もしそうするとLLVMが機能文字列の解釈に失敗してしまうことに注意してください。

mmxsseという機能は、Single Instruction Multiple Data (SIMD)命令をサポートするかを決定します。この命令は、しばしばプログラムを著しく速くしてくれます。しかし、大きなSIMDレジスタをOSカーネルで使うことは性能上の問題に繋がります。 その理由は、カーネルは、割り込まれたプログラムを再開する前に、すべてのレジスタを元に戻さないといけないためです。これは、カーネルがSIMDの状態のすべてを、システムコールやハードウェア割り込みがあるたびにメインメモリに保存しないといけないということを意味します。SIMDの状態情報はとても巨大(512〜1600 bytes)で、割り込みは非常に頻繁に起こるかもしれないので、保存・復元の操作がこのように追加されるのは性能にかなりの悪影響を及ぼします。これを避けるために、(カーネルの上で走っているアプリケーションではなく!)カーネル上でSIMDを無効化するのです。

SIMDを無効化することによる問題に、x86_64における浮動小数点演算は標準ではSIMDレジスタを必要とするということがあります。この問題を解決するため、soft-float機能を追加します。これは、すべての浮動小数点演算を通常の整数に基づいたソフトウェア上の関数を使ってエミュレートするというものです。

より詳しくは、SIMDを無効化することに関する私達の記事を読んでください。

🔗まとめると

私達のターゲット仕様ファイルは今このようになっているはずです。

{
  "llvm-target": "x86_64-unknown-none",
  "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
  "arch": "x86_64",
  "target-endian": "little",
  "target-pointer-width": "64",
  "target-c-int-width": "32",
  "os": "none",
  "executables": true,
  "linker-flavor": "ld.lld",
  "linker": "rust-lld",
  "panic-strategy": "abort",
  "disable-redzone": true,
  "features": "-mmx,-sse,+soft-float"
}

🔗カーネルをビルドする

私達の新しいターゲットのコンパイルにはLinuxの慣習に倣います(理由は知りません、LLVMのデフォルトであるというだけではないでしょうか)。つまり、前の記事で説明したように_startという名前のエントリポイントが要るということです。

// src/main.rs

#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points

use core::panic::PanicInfo;

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
    // this function is the entry point, since the linker looks for a function
    // named `_start` by default
    loop {}
}

ホストOSが何であるかにかかわらず、エントリポイントは_startという名前でなければならないことに注意してください。

これで、私達の新しいターゲットのためのカーネルを、JSONファイル名を--targetとして渡すことでビルドできるようになりました。

> cargo build --target x86_64-blog_os.json

error[E0463]: can't find crate for `core`

失敗しましたね!エラーはRustコンパイラがcoreライブラリを見つけられなくなったと言っています。このライブラリは、ResultOption、イテレータのような基本的なRustの型を持っており、暗黙のうちにすべてのno_stdなクレートにリンクされています。

問題は、coreライブラリはRustコンパイラと一緒にコンパイル済み (precompiled) ライブラリとして配布されているということです。そのため、これは、私達独自のターゲットではなく、サポートされているhost triple(例えば x86_64-unknown-linux-gnu)でのみ使えるのです。他のターゲットのためにコードをコンパイルしたいときには、coreをそれらのターゲットに向けて再コンパイルする必要があります。

🔗build-stdオプション

ここでcargoのbuild-std機能の出番です。これを使うとcoreやその他の標準ライブラリクレートについて、Rustインストール時に一緒についてくるコンパイル済みバージョンを使う代わりに、必要に応じて再コンパイルすることができます。これはとても新しくまだ完成していないので、不安定 (unstable) 機能とされており、nightly Rustコンパイラでのみ利用可能です。

この機能を使うためには、cargoの設定ファイルを.cargo/config.tomlに作り、次の内容を書きましょう。

# in .cargo/config.toml

[unstable]
build-std = ["core", "compiler_builtins"]

これはcargoにcorecompiler_builtinsライブラリを再コンパイルするよう命令します。後者が必要なのはcoreがこれに依存しているためです。 これらのライブラリを再コンパイルするためには、cargoがRustのソースコードにアクセスできる必要があります。これはrustup component add rust-srcでインストールできます。

注意: unstable.build-std設定キーを使うには、少なくとも2020-07-15以降のRust nightlyが必要です。

unstable.build-std設定キーをセットし、rust-srcコンポーネントをインストールしたら、ビルドコマンドをもう一度実行しましょう。

> cargo build --target x86_64-blog_os.json
   Compiling core v0.0.0 (/…/rust/src/libcore)
   Compiling rustc-std-workspace-core v1.99.0 (/…/rust/src/tools/rustc-std-workspace-core)
   Compiling compiler_builtins v0.1.32
   Compiling blog_os v0.1.0 (/…/blog_os)
    Finished dev [unoptimized + debuginfo] target(s) in 0.29 secs

今回は、cargo buildcorerustc-std-workspace-core (compiler_builtinsの依存です)、そして compiler_builtinsを私達のカスタムターゲット向けに再コンパイルしているということがわかります。

🔗メモリ関係の組み込み関数 (intrinsics)

Rustコンパイラは、すべてのシステムにおいて、特定の組み込み関数が利用可能であるということを前提にしています。それらの関数の多くは、私達がちょうど再コンパイルしたcompiler_builtinsクレートによって提供されています。しかしながら、通常システムのCライブラリによって提供されているので標準では有効化されていない、メモリ関係の関数がいくつかあります。それらの関数には、メモリブロック内のすべてのバイトを与えられた値にセットするmemset、メモリーブロックを他のブロックへとコピーするmemcpy、2つのメモリーブロックを比較するmemcmpなどがあります。これらの関数はどれも、現在の段階で我々のカーネルをコンパイルするのに必要というわけではありませんが、コードを追加していくとすぐに必要になるでしょう(たとえば、構造体をコピーする、など)。

オペレーティングシステムのCライブラリにリンクすることはできませんので、これらの関数をコンパイラに与えてやる別の方法が必要になります。このための方法として考えられるものの一つが、自前でmemsetを実装し、(コンパイル中の自動リネームを防ぐため)#[no_mangle]アトリビュートをこれらに適用することでしょう。しかし、こうすると、これらの関数の実装のちょっとしたミスが未定義動作に繋がりうるため危険です。たとえば、forループを使ってmemcpyを実装すると無限再帰を起こしてしまうかもしれません。なぜなら、forループは暗黙のうちにIntoIterator::into_iterトレイトメソッドを呼び出しており、これがmemcpyを再び呼び出しているかもしれないためです。なので、代わりに既存のよくテストされた実装を再利用するのが良いでしょう。

ありがたいことに、compiler_builtinsクレートにはこれらの必要な関数すべての実装が含まれており、標準ではCライブラリの実装と競合しないように無効化されているだけなのです。これはcargoのbuild-std-featuresフラグを["computer-builtins-mem"]に設定することで有効化できます。build-stdフラグと同じように、このフラグはコマンドラインで-Zフラグとして渡すこともできれば、.cargo/config.tomlファイルのunstableテーブルで設定することもできます。ビルド時は常にこのフラグをセットしたいので、設定ファイルを使う方が良いでしょう:

# in .cargo/config.toml

[unstable]
build-std-features = ["compiler-builtins-mem"]

compiler-builtins-mem機能のサポートが追加されたのはつい最近なので、2019-09-30以降のRust nightlyが必要です。)

このとき、裏でcompiler_builtinsクレートのmem機能が有効化されています。これにより、このクレートのmemcpyなどの実装#[no_mangle]アトリビュートが適用され、リンカがこれらを利用できるようになっています。これらの関数は今のところ最適化されておらず、性能は最高ではないかもしれないものの、少なくとも正しい実装ではあるということは知っておく価値があるでしょう。x86_64については、これらの関数を特殊なアセンブリ命令を使って最適化するプルリクエストが提出されています。

この変更をもって、私達のカーネルはコンパイラに必要とされているすべての関数の有効な実装を手に入れたので、コードがもっと複雑になっても変わらずコンパイルできるでしょう。

🔗標準のターゲットをセットする

cargo buildを呼び出すたびに--targetパラメータを渡すのを避けるために、デフォルトのターゲットを書き換えることができます。これをするには、以下を.cargo/config.tomlcargo設定ファイルに付け加えます:

# in .cargo/config.toml

[build]
target = "x86_64-blog_os.json"

これは、明示的に--target引数が渡されていないときは、x86_64-blog_os.jsonターゲットを使うようにcargoに命令します。つまり、私達はカーネルをシンプルなcargo buildコマンドでビルドできるということです。cargoの設定のオプションについてより詳しく知るには、公式のドキュメントを読んでください。

これにより、シンプルなcargo buildコマンドで、ベアメタルのターゲットに私達のカーネルをビルドできるようになりました。しかし、ブートローダーによって呼び出される私達の_startエントリポイントはまだ空っぽです。そろそろここから何かを画面に出力してみましょう。

🔗画面に出力する

現在の段階で画面に文字を出力する最も簡単な方法はVGAテキストバッファです。これは画面に出力されている内容を保持しているVGAハードウェアにマップされた特殊なメモリです。通常、これは25行からなり、それぞれの行は80文字セルからなります。それぞれの文字セルは、背景色と前景色付きのASCII文字を表示します。画面出力はこのように見えるでしょう:

screen output for common ASCII characters

次の記事では、VGAバッファの正確なレイアウトについて議論し、このためのちょっとしたドライバも書きます。"Hello World!"を出力するためには、バッファがアドレス0xb8000にあり、それぞれの文字セルはASCIIのバイトと色のバイトからなることを知っている必要があります。

実装はこんな感じになります:

static HELLO: &[u8] = b"Hello World!";

#[no_mangle]
pub extern "C" fn _start() -> ! {
    let vga_buffer = 0xb8000 as *mut u8;

    for (i, &byte) in HELLO.iter().enumerate() {
        unsafe {
            *vga_buffer.offset(i as isize * 2) = byte;
            *vga_buffer.offset(i as isize * 2 + 1) = 0xb;
        }
    }

    loop {}
}

まず、0xb8000という整数を生ポインタにキャストします。次に静的 (static) HELLOというバイト列変数の要素に対しイテレートします。enumerateメソッドを使うことで、for ループの実行回数を表す変数 i も取得します。ループの内部では、offsetメソッドを使って文字列のバイトと対応する色のバイト(0xbは明るいシアン色)を書き込んでいます。

すべてのメモリへの書き込み処理のコードを、unsafe (安全でない) ブロックが囲んでいることに注意してください。この理由は、私達の作った生ポインタが正しいものであることをRustコンパイラが証明できないためです。生ポインタはどんな場所でも指しうるので、データの破損につながるかもしれません。これらの操作をunsafeブロックに入れることで、私達はこれが正しいことを確信しているとコンパイラに伝えているのです。ただし、unsafeブロックはRustの安全性チェックを消すわけではなく、追加で5つのことができるようになるだけということに注意してください。

訳注: 翻訳時点(2020-10-20)では、リンク先のThe Rust book日本語版には「追加でできるようになること」は4つしか書かれていません。

強調しておきたいのですが、 このような機能はRustでプログラミングするときに使いたいものではありません! unsafeブロック内で生ポインタを扱うと非常にしくじりやすいです。たとえば、注意不足でバッファの終端のさらに奥に書き込みを行ってしまったりするかもしれません。

ですので、unsafeの使用は最小限にしたいです。これをするために、Rustでは安全なabstraction (抽象化されたもの) を作ることができます。たとえば、VGAバッファ型を作り、この中にすべてのunsafeな操作をカプセル化し、外側からの誤った操作が不可能であることを保証できるでしょう。こうすれば、unsafeの量を最小限にでき、メモリ安全性を侵していないことを確かにできます。そのような安全なVGAバッファの abstraction を次の記事で作ります。

🔗カーネルを実行する

では、目で見て分かる処理を行う実行可能ファイルを手に入れたので、実行してみましょう。まず、コンパイルした私達のカーネルを、ブートローダーとリンクすることによってブータブルディスクイメージにする必要があります。そして、そのディスクイメージを、QEMUバーチャルマシン内や、USBメモリを使って実際のハードウェア上で実行できます。

🔗ブートイメージを作る

コンパイルされた私達のカーネルをブータブルディスクイメージに変えるには、ブートローダーとリンクする必要があります。起動のプロセスのセクションで学んだように、ブートローダーはCPUを初期化しカーネルをロードする役割があります。

自前のブートローダーを書くと、それだけで1つのプロジェクトになってしまうので、代わりにbootloaderクレートを使いましょう。このクレートは、Cに依存せず、Rustとインラインアセンブリだけで基本的なBIOSブートローダーを実装しています。私達のカーネルを起動するためにこれを依存関係に追加する必要があります:

# in Cargo.toml

[dependencies]
bootloader = "0.9.8"

bootloaderを依存として加えることだけでブータブルディスクイメージが実際に作れるわけではなく、私達のカーネルをコンパイル後にブートローダーにリンクする必要があります。問題は、cargoがビルド後 (post-build) にスクリプトを走らせる機能を持っていないことです。

この問題を解決するため、私達はbootimageというツールを作りました。これは、まずカーネルとブートローダーをコンパイルし、そしてこれらをリンクしてブータブルディスクイメージを作ります。このツールをインストールするには、以下のコマンドをターミナルで実行してください:

cargo install bootimage

bootimageを実行しブートローダをビルドするには、llvm-tools-previewというrustupコンポーネントをインストールする必要があります。これはrustup component add llvm-tools-previewと実行することでできます。

bootimageをインストールし、llvm-tools-previewを追加したら、以下のように実行することでブータブルディスクイメージを作れます:

> cargo bootimage

このツールが私達のカーネルをcargo buildを使って再コンパイルしていることがわかります。そのため、あなたの行った変更を自動で検知してくれます。その後、bootloaderをビルドします。これには少し時間がかかるかもしれません。他の依存クレートと同じように、ビルドは一度しか行われず、その都度キャッシュされるので、以降のビルドはもっと早くなります。最終的に、bootimageはbootloaderとあなたのカーネルを合体させ、ブータブルディスクイメージにします。

このコマンドを実行したら、target/x86_64-blog_os/debugディレクトリ内にbootimage-blog_os.binという名前のブータブルディスクイメージがあるはずです。これをバーチャルマシン内で起動してもいいですし、実際のハードウェア上で起動するためにUSBメモリにコピーしてもいいでしょう(ただし、これはCDイメージではありません。CDイメージは異なるフォーマットを持つので、これをCDに焼いてもうまくいきません)。

🔗どういう仕組みなの?

bootimageツールは、裏で以下のステップを行っています:

  • 私達のカーネルをELFファイルにコンパイルする。
  • 依存であるbootloaderをスタンドアロンの実行ファイルとしてコンパイルする。
  • カーネルのELFファイルのバイト列をブートローダーにリンクする。

起動時、ブートローダーは追加されたELFファイルを読み、解釈します。次にプログラム部をページテーブル (page table) 仮想アドレス (virtual address) にマップし、.bss部をゼロにし、スタックをセットアップします。最後に、エントリポイントのアドレス(私達の_start関数)を読み、そこにジャンプします。

🔗QEMUで起動する

これで、ディスクイメージを仮想マシンで起動できます。QEMUを使ってこれを起動するには、以下のコマンドを実行してください:

> qemu-system-x86_64 -drive format=raw,file=target/x86_64-blog_os/debug/bootimage-blog_os.bin
warning: TCG doesn't support requested feature: CPUID.01H:ECX.vmx [bit 5]

これにより、以下のような見た目の別のウィンドウが開きます:

QEMU showing "Hello World!"

私達の書いた"Hello World!"が画面に見えますね。

🔗実際のマシン

USBメモリにこれを書き込んで実際のマシン上で起動することも可能です:

> dd if=target/x86_64-blog_os/debug/bootimage-blog_os.bin of=/dev/sdX && sync

sdXはあなたのUSBメモリのデバイス名です。そのデバイス上のすべてのデータが上書きされてしまうので、 正しいデバイス名を選んでいるのかよく確認してください

イメージをUSBメモリに書き込んだあとは、そこから起動することによって実際のハードウェア上で走らせることができます。特殊なブートメニューを使ったり、BIOS設定で起動時の優先順位を変え、USBメモリから起動することを選択する必要があるでしょう。ただし、bootloaderクレートはUEFIをサポートしていないので、UEFIマシン上ではうまく動作しないということに注意してください。

🔗cargo runを使う

QEMU上でより簡単に私達のカーネルを走らせるために、cargoのrunner設定が使えます。

# in .cargo/config.toml

[target.'cfg(target_os = "none")']
runner = "bootimage runner"

target.'cfg(target_os = "none")'テーブルは、"os"フィールドが"none"であるようなすべてのターゲットに適用されます。私達のx86_64-blog_os.jsonターゲットもその1つです。runnerキーはcargo runのときに呼ばれるコマンドを指定しています。このコマンドは、ビルドが成功した後に、実行可能ファイルのパスを第一引数として実行されます。詳しくは、cargoのドキュメントを読んでください。

bootimage runnerコマンドは、runnerキーとして実行するために設計されています。このコマンドは、与えられた実行ファイルをプロジェクトの依存するbootloaderとリンクして、QEMUを立ち上げます。より詳しく知りたいときや、設定オプションについてはbootimageのReadmeを読んでください。

これで、cargo runを使ってカーネルをコンパイルしQEMU内で起動することができます。

🔗次は?

次の記事では、VGAテキストバッファをより詳しく学び、そのための安全なインターフェースを書きます。また、printlnマクロのサポートも行います。



Comments

Please leave your comments in English if possible.