Rustで自作OS - UEFIでブートまで
自作言語のセルフホスティングが終わったので、積んでた
をやる。C++は非常に書きたくないのと no_std
を試したいので、やれる範囲でRustでやることを試みる。Cは普通に書く。先駆者がいるので何とかなってくれると良いが、わからなかったらその時考える。
単純に実装がめんどい系のタスクで手が止まってサボりがちなので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_image
と run_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
はこれをビルドしたもの? -
AppPkg
はedk2-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.txt
のACTIVE_PLATFORM
に指定されてる-
UEFI エミュレータで遊ぶ
ホスト環境上で実行可能なUEFIファームウェアとUEFI Shellのエミュレータが含まれています。このエミュレータ上ではUEFIアプリの実行も可能なようです。
-
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-runnerの Cargo.toml
や main.rs
を削ってHello, Worldにする。色々調整して以下のように。
...
[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"
#![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がある程度高級にしてくれている。
#![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
, 16h
, 32w
, 64g
)
-
試しにブートローダを終了させないようにし、
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.toml
に cargo build
等に渡すデフォルトの設定を記述できるようだ。bootloaderならこう:
[build]
target = "x86_64-unknown-uefi"
[unstable]
build-std = ["core", "alloc"]
build-std-features = ["compiler-builtins-mem"]
最小のカーネルの実装。 実装側は特に言うことはない UEFIの呼出規約は一般的なLinuxアプリケーションとは異なる。UEFIから呼び出すカーネルのmainは明示的に sysv64
を指定しておく。
#![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-gnu
や x86_64-unknown-none-linuxkernel
、algon-320/mandarinを参考に書いてみる。
{
"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"
}
[build]
target = "./x86_64-unknown-none-elf.json"
[unstable]
build-std = ["core", "alloc"]
build-std-features = ["compiler-builtins-mem"]
VSCodeでrust-analzyerが別のtargetでのコンパイルエラーを通知してくるので無効にする。有効なターゲット一覧みたいなのをプロジェクト側で指定できないかな...
...
"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
も考慮した作りになっていることが多く、非常に助かる。
何点か気になること:
-
AllocatePool
とAllocatePage
は重複しない前提の実装になってるがAllocatePool
が小さいアドレス値は使わないように考慮している? - 実運用されるソフトウェアでもロード先は固定?
リンクとロード周りも色々知らないなあと感じたのでリンカ・ローダ実践開発テクニックを読むと良さそうだ。
4章 ピクセル描画とmake入門
ローダは上で実装していたので4章はすんなりと。
Rustにはないplacement newが使用されており、今回は回避が容易だったが後々困るかもしれない。先に第8章のメモリ管理を覗いてみることを考えている。
ここまでを整理していったんリポジトリに。
ここまではいったん記事に。続きは別のスクラップを作るか...