Closed19

Rustで自作OS - UEFIでブートまで

単純に実装がめんどい系のタスクで手が止まってサボりがちなのでScrapを使うことにする。

0章

  • MikanOSはC標準ライブラリとしてNewlibを使う。名前しか聞いたことがない。いや、そもそもglibcが普段使われててmuslが軽量なコンテナイメージ等でしばしば使われるぐらいしか知らないが...
  • サポートサイト https://zero.osdev.jp/ 。GitHubのIssues。
  • 本書の枠を超えた質問や議論等は自作OSコミュニティ https://osdev.jp/ へ。

自作言語のセルフホスティング時等もこれ書いておけばそれぞれのフェーズでの雑感が残って良かったな...

1章 PCの仕組みとハローワールド

開発環境はいつものWSL2にVSCodeでやってみる。VSCodeのhex editor拡張を使ってみたがまだ貧弱でしんどいかな...

WSL2上でQEMUを使うことにする。VcXsrvも現時点では必要なのでインストールする。Archではpacmanで入る。 mkfs.fat のため dosfstoolsも。 BOOTX64.EFI は勿論わからないがとりあえず打つ。

# ディスクイメージを作成、なお実際に200MB確保するわけではない
qemu-img create -f raw disk.img 200M
mkfs.fat -n 'MIKAN OS' -s 2 -f 2 -R 32 -F 32 disk.img

# FATでフォーマット、各引数の詳細はTODO
mkfs.fat -n 'MIKAN OS' -s 2 -f 2 -R 32 -F 32 disk.img

# ./mnt に disk.img をマウント -o loop はループバック・デバイスのためのオプション
# UEFIの形式に従って /EFI/BOOT/BOOTX64.EFI を配置
mkdir -p mnt
sudo mount -o loop disk.img mnt
sudo mkdir -p mnt/EFI/BOOT
sudo cp BOOTX64.EFI mnt/EFI/BOOT/BOOTX64.EFI
sudo umount mnt

これらステップは devenv/make_image.shに。 ./make_image.sh disk.img mnt BOOTX64.EFI

ループバック・デバイス
一般的なファイルを,あたかもハード・ディスクなどのブロック型デバイスであるかのように扱うための機能です。パソコン上でイメージ・ファイルを直接操作したい場合などに使います。「ループ・デバイス」とも呼ばれます。

Windows上のVcXsrvのための設定もzshrcに加えた

devenv/run_image.sh で、 ./run_image.sh disk.img とするとHello, Worldが出力される!

run_image.sh の中身は、

  • qemu-system-x86_64 QEMUで起動する
  • UEFIモードで起動するためのオプション
    • -drive if=pflash,format=raw,readonly,file=$DEVENV_DIR/OVMF_CODE.fd
    • -drive if=pflash,format=raw,file=$DEVENV_DIR/OVMF_VARS.fd
    • それぞれのfdの中身はTODO
  • ...

make_imagerun_image 合わせた run_qemu <efi>用意されてる

本書で後から出てくるかもしれないが、ちょっと qemu のオプションをいくつか試してみる。

モニタ

run_image.sh にも指定されている -monitor stdio はQEMU モニタを標準入出力で受け付けるというオプションで、QEMU モニタから仮想マシンを操作できる。起動オプション次第でUNIXドメインソケットとかtelnet等で受け付けるようにも出来る。

ディスプレイ

ディスプレイバックエンドを指定できる。 run_image.sh でウィンドウが出てくるのはデフォルトの -display gtk のため。 -display curses とか -display none とかある。

シリアルポート

仮想的なシリアルポートをホストの端末にリダイレクトできる。 -serial stdio で標準入出力にリダイレクトされるが、これはモニタと競合する。 -serial mon:stdio とするとシリアルポートとQEMUのモニタが多重化され、 Ctrl+a h によって出てくるヘルプを参照。

-nographic-serial mon:stdio-display none がどちらも効いたような状態になる。

  • Hello, World! が出力されたのはCPUが /EFI/BOOT/BOOTX64.EFI を実行したため
  • なぜ /EFI/BOOT/BOOTX64.EFI が実行されるかというとBIOSがUEFIの仕様に従ったため
    • このようにUEFI BIOSが実行してくれるプログラムをUEFIアプリケーションと呼ぶ
    • 本書で作成するUEFIアプリケーションはOSをメインメモリに読み込んで起動する ブートローダ を担う
    • UEFIアプリケーションの実体はWindowsで標準的なPortable Executable

ということでCでビルドしてみる。UEFIの仕様に沿ったhello.cを以下のようにビルドできる:

# COFF形式でオブジェクトを出力
clang -target x86_64-pc-win32-coff -mno-red-zone -fno-stack-protector -fshort-wchar -Wall -c hello.c
# UEFI用のPEファイルを生成
lld-link /subsystem:efi_application /entry:EfiMain /out:hello.efi hello.o

2章 EDK II入門とメモリマップ

EDKはEFI Development Kitに由来する と思われる が、公式にはEDKとしか書かれていないとのこと。EDK IIはUEFI BIOS自体の開発にも、UEFIアプリケーションの開発にも使える開発キットである。

EDK2 (edk2-stable202105) のセットアップに手間取る。

既定の手順make -C BaseTools で普通にコンパイルエラーが出る...最近 -Wall に加わった新たな警告が問題っぽい。brotliというライブラリの指定submoduleにこのパッチが当たってないのが問題だったのでソースコードを調整するとビルドが通るようになる。

ちなみにUDK II以外の方法はどうなのか? → EDK II で UEFI アプリケーションを作る

  • edk2/
    • edksetup.sh - 準備用スクリプト、ビルド前に使う
    • Build/ - build コマンドによるビルド成果物が出力されるディレクトリ
    • Conf/target.txt - ビルド設定
      • ACTIVE_PLATFORM - ビルド対象のパッケージの .dsc ファイル
      • TARGET - DEBUG | RELEASE | NOOPT | UserDefined
      • TARGET_ARCH - 対象アーキテクチャ。IA32、X64、ARMなど
      • TOOL_CHAIN_TAG - tools_def.txt にあるツールチェインのどれをビルドに用いるか
    • Conf/tools_def.txt - ツールチェインの設定
      • VS201X とか GCC5 とか CLANG38 とか色々ある
      • 謎言語で詳細は不明
    • ...Pkg/ - この命名のディレクトリでパッケージを構成している模様

解説に挙がってるパッケージ:

  • MdePkg - Module development environment。最も基本的なパッケージ
  • OvmfPkg - Open Virtual Machine Firmware。UEFI BIOSのオープンソース実装。qemuでオプションに与えていた OVMF_CODE.fd OVMF_VARS.fd はこれをビルドしたもの?
  • AppPkgedk2-libcリポジトリに分離された様子

    The edk2-libc repository contains a port of libc to a UEFI environment along with UEFI applications that depend on this port of libc. This repositories contents were exported from the edk2 repository using the script below that is described at http://jimmy.schementi.com/splitting-up-a-git-repo

  • EmulatorPkg - edksetup.sh 叩いた直後の Conf/target.txtACTIVE_PLATFORM に指定されてる
    • UEFI エミュレータで遊ぶ

      ホスト環境上で実行可能なUEFIファームウェアとUEFI Shellのエミュレータが含まれています。このエミュレータ上ではUEFIアプリの実行も可能なようです。

EDK2へのモチベが上がらない。独自定義がしんどい...

ハマったとき解決できないかなあと思っていたが、ここまでの解説からEFI用のバイナリにそこまで特殊な要素はないので、ここもRustでやることを考える。RustはUEFI用バイナリも x86_64-unknown-uefi というターゲットでTier 3 サポートしている。

Rust先駆者のalgon-320/mandarinを参考にuefi-rsでHello, Worldを試みる。uefi-rs/BUILDING.mdに従う。久々にRustのnightlyに戻ってきた。uefi-test-runnerCargo.tomlmain.rs を削ってHello, Worldにする。色々調整して以下のように。

Cargo.toml
...
[dependencies]
# 必須ではないが、uefiがloggerサポートしてるのでついでに使うことにする
log = {version = "0.4", default-features = false}
# featuresはとりあえず全部有効にする
uefi = {version = "0.11", features = ["alloc", "logger", "exts"]}
# Rustがno_stdで要求するpanic_handlerの提供、およびinitユーティリティ
uefi-services = "0.8"
main.rs
#![no_std]
#![no_main]
#![feature(asm)]
#![feature(abi_efiapi)]

// まだ未使用
#[macro_use]
extern crate alloc;

use core::fmt::Write;
use uefi::prelude::*;
use uefi::table::runtime::ResetType;

#[entry]
fn efi_main(_image: Handle, mut st: SystemTable<Boot>) -> Status {
    // logging, memory allocationの初期化
    uefi_services::init(&mut st).unwrap_success();

    st.stdout().reset(false).unwrap_success();

    // log::info!("Hello, World!"); とロギングもできる
    writeln!(st.stdout(), "Hello, World!").unwrap();

    st.boot_services().stall(3_000_000);

    st.stdout().reset(false).unwrap_success();

    st.runtime_services()
        .reset(ResetType::Shutdown, Status::SUCCESS, None);
}
cargo build -Zbuild-std -Zbuild-std-features=compiler-builtins-mem --target x86_64-unknown-uefi
../run_qemu.sh target/x86_64-unknown-uefi/bootloader.efi

ここまで簡単に出来たと言えるので、EDK2はいったん忘れてuefi-rsで進めてみることにする。

色々とメモ。

  • 普段、特に意識せず利用されるRust標準ライブラリstdだが、stdを用いない(no_std)ベアメタル環境でも利用しやすいようにいくつかのクレートに分離されている。
    • core - ポータブルで依存フリーな基盤ライブラリ。ごく一部の関数を提供すれば Option<T>Result<T, E> のようなプラットフォームに依存しない基本的な型の多くを利用できる。
    • compiler_builtins - Rustコンパイラがcompiler intrinsicsとして利用する機能群。 core が要求する memcpy, memset memmove もこれに含まれていて、libcの代わりにこちらの実装を使うことができる。
    • alloc - スマートポインタやコレクションを提供するライブラリ。 #[global_allocator] でグローバルアロケータを与える。
  • 今回の依存では、
    • uefi - UEFI仕様のRustバインディング。特に alloc featureによって、UEFIの関数によるグローバルアロケータ実装を与え、UEFIアプリケーションのベアメタル環境でも alloc クレートのコレクション群を利用できるようになる。
    • uefi-services - uefi::alloc の初期化やロガーの初期化。また #[panic_handler] の実装を提供しており、UEFIアプリケーションのベアメタル環境でも panic できるようになる。
  • ビルドにはいくつかのオプションを要する。
    • -Zbuild-std - 標準ライブラリ関連のクレートもビルドの依存グラフに含めてビルドする
    • -Zbuild-std-features=compiler-builtins-mem - 上述の compiler_builtins のmemcpy等を含める
    • --target x86_64-unknown-uefi - UEFIを対象としたバイナリを吐く
  • ブートサービス - OSを起動するために必要な機能を提供する
  • ランタイムサービス - 起動前/後どちらでも使える機能を提供する

UEFIにおける1ページの大きさは4KiB。UEFIのメモリマップは歯抜けがあり得る。

2章では、まずはこれを取得してファイルに書き出してみる。なんというか、基本は「UEFIにはこういうインターフェースが提供されているのでそれを使ってやると動く」みたいな抽象度になっている。ここまで高級だとは思っていなかった。いや、(EDK2や)uefi-rsがある程度高級にしてくれている。

main.rs
#![no_std]
#![no_main]
#![feature(asm)]
#![feature(abi_efiapi)]

#[macro_use]
extern crate alloc;

use core::mem;
use log::info;
use uefi::prelude::*;
use uefi::proto::media::file::{File, FileAttribute, FileMode, FileType};
use uefi::table::boot::MemoryDescriptor;
use uefi::table::runtime::ResetType;

#[entry]
fn efi_main(image: Handle, mut st: SystemTable<Boot>) -> Status {
    uefi_services::init(&mut st).unwrap_success();
    st.stdout().reset(false).unwrap_success();

    info!("Get memory map");
    // 余分にアロケートするのはmemory_map_sizeの推測が厳密でないため
    let size = st.boot_services().memory_map_size() + 8 * mem::size_of::<MemoryDescriptor>();
    let mut buffer = vec![0; size];
    let (_, descriptors) = st.boot_services().memory_map(&mut buffer).unwrap_success();

    // gBS->OpenProtocol周りのコードはどう対応付いているのか以前にUEFIでどういう枠組みなのかも不明
    // とりあえずSimple File Systemはuefi-rsではAPIとして提供されており、本書のCコードより高抽象である
    info!("Write memory map to /memmap");
    let mut root_dir = {
        let sfs = st
            .boot_services()
            .get_image_file_system(image)
            .unwrap_success();
        unsafe { &mut *sfs.get() }.open_volume().unwrap_success()
    };
    let mut file = match root_dir
        .open("memmap", FileMode::CreateReadWrite, FileAttribute::empty())
        .unwrap_success()
        .into_type()
        .unwrap_success()
    {
        FileType::Regular(file) => file,
        FileType::Dir(_) => panic!(),
    };

    file.write("Index, Type, Type(name), PhysicalStart, NumberOfPages, Attribute\n".as_bytes())
        .unwrap_success();
    // 本書のCコードでやってるプリミティブなイテレート等はuefi-rsでは不要だった
    for (i, d) in descriptors.enumerate() {
        let line = format!(
            "{}, {:x}, {:?}, {:08x}, {:x}, {:x}\n",
            i,
            d.ty.0,
            d.ty,
            d.phys_start,
            d.page_count,
            d.att.bits() & 0xfffff
        );
        file.write(line.as_bytes()).unwrap_success();
    }
    drop(file);

    info!("done.");

    st.boot_services().stall(3_000_000);
    st.stdout().reset(false).unwrap_success();
    st.runtime_services()
        .reset(ResetType::Shutdown, Status::SUCCESS, None);
}

ほとんどIDEの補完とコードジャンプに頼って書いた。今回は format!vec! など alloc クレートの動的アロケートを利用している。

3章 画面表示の練習とブートローダ

QEMU モニタからのデバッグを軽く紹介。

  • (qemu) info registers - 現在の各レジスタの値を表示する
  • x /fmt addr - メモリダンプ
    • fmt[個数][フォーマット][サイズ]
    • フォーマットは 16進数 x, 10進数 d, 機械語命令を逆アセンブル i
    • サイズは何bitを1単位とするか (8 b, 16 h, 32 w, 64 g)

試しにブートローダを終了させないようにし、

loop {
    unsafe {
        asm!("hlt");
    }
}

info registers とすると RIP=000000003e62f326 となったので、 x /i 0x3e62f326 とすると、

0x3e62f326:  eb fd                    jmp      0x3e62f325

となる。 x /2i 0x3e62f325 とすると、

0x3e62f325:  f4                       hlt
0x3e62f326:  eb fd                    jmp      0x3e62f325

hltループしてることがわかる。ところで、このまま単にアドレスの値をさらに引いてダンプしてみるとhltが潰れてしまう (別の命令の一部として意図しない解釈がされる) 場合があるので、

  • x64の命令セットが可変長であること
  • UTF-8のような先頭バイトの判定がおそらくできないこと

がわかる。

パッケージの .cargo/config.tomlcargo build 等に渡すデフォルトの設定を記述できるようだ。bootloaderならこう:

.cargo/config.toml
[build]
target = "x86_64-unknown-uefi"

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

最小のカーネルの実装。 実装側は特に言うことはない UEFIの呼出規約は一般的なLinuxアプリケーションとは異なる。UEFIから呼び出すカーネルのmainは明示的に sysv64 を指定しておく。

main.rs
#![no_std]
#![no_main]
#![feature(asm)]

#[no_mangle]
pub extern "sysv64" fn kernel_main() {
    loop {
        unsafe {
            asm!("hlt");
        }
    }
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {
        unsafe {
            asm!("hlt");
        }
    }
}

UEFI アプリケーションもそうだが、OS本体は通常のアプリケーションとは前提が異なるため、OS本体(カーネル)のビルドは色々設定がある。本書ではclangに、

  • --target=x86_64-elf - 出力ファイルの形式をELFとする
  • -ffreestanding - フリースタンディング環境向けにコンパイルする
    • ホスト環境はOSで動くプログラムのための環境
    • フリースタンディング環境はOSがない環境
    • 具体的に生成結果にどう影響するのかはTODO
  • -mno-red-zone Red Zone機能を無効にする
    • レッドゾーンを利用した関数の実行中に単に割り込みが発生するとデータが壊れる?
    • いつ割り込みが発生しても壊れないようにするための考慮が必要なのでOS開発中は無効にしておく
  • (C++関係のオプション等は除外、例外やRTTIは無効にする)

リンカに、

  • --entry KernelMain
  • -z norelro - リロケーション情報を読み込み専用にする機能を使わない
  • --image-base 0x100000 - 出力されたバイナリのベースアドレスを0x100000番地とする
  • -o kernel.elf
  • --static - 静的リンク

といったオプションを与えている。これ相当のビルドをRustでも行う必要がある。調べると、 x86_64-unknown-uefi と異なりコンパイラ組み込みのターゲットに今回適切なターゲットはないため、 カスタムターゲットを定義する必要がありそうだ。 x86_64-unknown-linux-gnux86_64-unknown-none-linuxkernelalgon-320/mandarinを参考に書いてみる。

x86_64-unknown-none-elf.json
{
  "arch": "x86_64",
  "code-model": "kernel",
  "cpu": "x86-64",
  "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128",
  "disable-redzone": true,
  "executables": true,
  "features": "-mmx,-sse,-sse2,-sse3,-ssse3,-sse4.1,-sse4.2,-3dnow,-3dnowa,-avx,-avx2,+soft-float",
  "linker-flavor": "ld.lld",
  "llvm-target": "x86_64-unknown-none-elf",
  "max-atomic-width": 64,
  "os": "none",
  "panic-strategy": "abort",
  "relro-level": "off",
  "position-independent-executables": false,
  "post-link-args": {
    "ld.lld": [
      "--entry=kernel_main",
      "--image-base=0x100000",
      "-okernel.elf",
      "--static"
    ]
  },
  "target-pointer-width": "64"
}
.cargo/config.toml (kernel側)
[build]
target = "./x86_64-unknown-none-elf.json"

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

VSCodeでrust-analzyerが別のtargetでのコンパイルエラーを通知してくるので無効にする。有効なターゲット一覧みたいなのをプロジェクト側で指定できないかな...

settings.json
    ...
    "rust-analyzer.checkOnSave.allTargets": false,
    ...

ブートローダでのカーネルファイルの読み込み。 fs モジュールはSimpleFileSystemの薄いラッパー。

const KERNEL_BASE_ADDR: usize = 0x100000;
const EFI_PAGE_SIZE: usize = 0x1000;

fn load_kernel(image: Handle, st: &SystemTable<Boot>) {
    let mut root_dir = fs::open_root_dir(image, st.boot_services());
    let mut file = fs::open_file(&mut root_dir, "kernel.elf");
    let size = fs::file_info(&mut file).file_size() as usize;
    st.boot_services()
        .allocate_pages(
            AllocateType::Address(KERNEL_BASE_ADDR),
            MemoryType::LOADER_DATA,
            (size + EFI_PAGE_SIZE - 1) / EFI_PAGE_SIZE,
        )
        .unwrap_success();
    file.read(unsafe { core::slice::from_raw_parts_mut(KERNEL_BASE_ADDR as *mut u8, size) })
        .unwrap_success();
}

リンク側で --image-base 0x100000 と特定のアドレスに配置されて機能するようにリンクしたので、それに対応するように allocate_pages で指定したアドレスにメモリ領域を確保し、そこに読み出す。これで kernel.elf の内容がメインメモリの 0x100000 番地に配置されたことになる。

カーネルの読み込みが完了したので、ブートサービスを停止できる。ブートサービスは裏で色々やってくれているらしいがOSが起動すれば邪魔な存在となる。

fn exit_boot_services(image: Handle, st: SystemTable<Boot>) -> SystemTable<Runtime> {
    let max_mmap_size =
        st.boot_services().memory_map_size() + 8 * mem::size_of::<MemoryDescriptor>();
    let mut mmap_buf = vec![0; max_mmap_size];
    let (st, _) = st
        .exit_boot_services(image, &mut mmap_buf[..])
        .unwrap_success();
    mem::forget(mmap_buf);
    st
}

この実装にはかなりハマった。何にハマったかというと mem::forget(mmap_buf) で、 vec![...] でアロケートされたメモリ領域は ブートサービスから allocate_pool された領域 だが、ブートサービスの終了後UEFIはこの管理を手放すらしいuefi-rs のグローバルアロケータは確保した領域をブートサービスの free_pool から解放しようとするが、ブートサービスは既に終了しているので、 mmap_buf のDrop処理が不正になってしまう。

カーネルのエントリポイントの呼出し。エンディアンを指定した読み込みに byteorder クレートを使用している。

let entry_point_addr = LittleEndian::read_u64(unsafe {
    core::slice::from_raw_parts((KERNEL_BASE_ADDR + 24) as *const u8, 8)
});
let entry_point: extern "sysv64" fn() = unsafe { mem::transmute(entry_point_addr as usize) };
entry_point();

...これは動かない。正直、解説を読んでもこれだけで動く理由がわからず、というのも 0x100000 に展開したデータは kernel.elf そのものであり、ELFファイルをロードしたわけではない。 0x100000 + 24 の指すアドレスはロード後のアドレスでELFデータのオフセットではない。何かしらリンクオプション等を見落としているのか生成された kernel.elf に差異があるのかもしれない...

調べると、4章でいずれにせよローダを改良するとのことなのでELFローダの実装を行ってしまうことにする。

fn load_kernel(image: Handle, st: &SystemTable<Boot>) -> usize {
    let mut root_dir = fs::open_root_dir(image, st.boot_services());
    let mut elf_file = fs::open_file(&mut root_dir, "kernel.elf");
    let elf_buf = fs::read_file(&mut elf_file);
    let elf = elf::Elf::parse(&elf_buf).expect("Failed to parse kernel.elf");

    let mut dest_first = usize::MAX;
    let mut dest_last = 0;
    for ph in elf.program_headers.iter() {
        if ph.p_type != elf::program_header::PT_LOAD {
            continue;
        }
        dest_first = dest_first.min(ph.p_vaddr as usize);
        dest_last = dest_last.max((ph.p_vaddr + ph.p_memsz) as usize);
    }

    st.boot_services()
        .allocate_pages(
            AllocateType::Address(dest_first),
            MemoryType::LOADER_DATA,
            (dest_last - dest_first + EFI_PAGE_SIZE - 1) / EFI_PAGE_SIZE,
        )
        .unwrap_success();

    for ph in elf.program_headers.iter() {
        if ph.p_type != elf::program_header::PT_LOAD {
            continue;
        }
        let ofs = ph.p_offset as usize;
        let fsize = ph.p_filesz as usize;
        let msize = ph.p_memsz as usize;
        let dest = unsafe { core::slice::from_raw_parts_mut(ph.p_vaddr as *mut u8, msize) };
        dest[..fsize].copy_from_slice(&elf_buf[ofs..ofs + fsize]);
        dest[fsize..].fill(0);
    }

    elf.entry as usize
}

ELFのプログラムヘッダを読み、ロード処理を行うようにした load_kernel 。ELFの読み込みにはgoblinを用いた。Rustのクレートはこういうベアメタルで使いたいライブラリは no_std も考慮した作りになっていることが多く、非常に助かる。

何点か気になること:

  • AllocatePoolAllocatePage は重複しない前提の実装になってるが AllocatePool が小さいアドレス値は使わないように考慮している?
  • 実運用されるソフトウェアでもロード先は固定?

リンクとロード周りも色々知らないなあと感じたのでリンカ・ローダ実践開発テクニックを読むと良さそうだ。

4章 ピクセル描画とmake入門

ローダは上で実装していたので4章はすんなりと。
Rustにはないplacement newが使用されており、今回は回避が容易だったが後々困るかもしれない。先に第8章のメモリ管理を覗いてみることを考えている。

ここまでを整理していったんリポジトリに。

https://github.com/yubrot/ors
このスクラップは1ヶ月前にクローズされました
作成者以外のコメントは許可されていません