はじめに
今年の4月ごろから「ゼロからのOS自作入門」を読みながらRustでOSを開発しています。 その過程で経験した躓きポイント共有します。
OSや低レイヤー詳しいわけではないので間違っている部分等があるかもしれないですが、ご容赦ください。
また半年以上前のことなので必死に記憶を掘り起こして書いています。
作ろうとしたもの
UEFIブートローダーがカーネルをメモリにロードし、起動に必要な情報(メモリマップ、フレームバッファ情報)を渡して、カーネル側で画面描画を行うシステムです。
処理の流れ:
-
ブートローダー:
- UEFIのブートサービスでメモリマップとフレームバッファ情報(解像度、アドレス等)を取得。
- カーネルバイナリをファイルシステムから読み込み、物理メモリ上の特定アドレスに配置。
-
exit_boot_servicesを実行してUEFIの制御を離れる。 - カーネルのエントリーポイントへジャンプ。
-
カーネル:
- 受け取った情報を元に、メモリサイズや解像度をシリアルポートへログ出力。(qemu実行時のデバッグのため)
- フレームバッファを操作して画面にグラデーションを描画。
【問題1】複数関数呼び出し時のPage Faultとスタック破損
起きた現象
フレームバッファの解像度を表示するために print_decimal 関数を実装しました。
fn print_decimal(mut num: u64) {
if num == 0 {
serial_print_str("0");
return;
}
let mut digits = [0u8; 20];
let mut count = 0;
while num > 0 {
let digit = num % 10;
digits[count] = b'0' + digit as u8;
num = num / 10;
count = count + 1;
}
serial_print_num(&digits[0..count]);
}
この関数を1回呼ぶだけなら動くのですが、2回連続で呼ぶとPage Faultが発生してクラッシュするという不可解な挙動を示しました。
// 問題のコード
serial_print_str("Resolution: ");
print_decimal(framebuffer_info.width as u64); // 1回目:正常動作
serial_print_str(" x ");
print_decimal(framebuffer_info.height as u64); // 2回目:ここでPage Fault!
デバッガ(QEMU + GDB)での調査結果:
-
1回目呼び出し時:正常。
-
2回目呼び出し時:
RIP(インストラクションポインタ)がブートローダーのメモリ領域などの不正な場所を指しており、RSP(スタックポインタ)も予期せぬ位置に移動していました。
原因:リンカースクリプトの不在
Rustのデフォルト設定(cargo build)のみでビルドしていたため、カーネルのメモリレイアウトが未定義でした。 これにより、コンパイラが配置したコード領域(.text)と、実行時に使われるスタック領域が衝突していた、もしくはエントリーポイント _start がバイナリの先頭に配置されていなかったことが原因と考えられます。
関数呼び出しからの復帰時(ret)に、スタック上のリターンアドレスが壊れており、適当なアドレス(ブートローダーの跡地など)へジャンプしてしまったようです。
解決策
明示的にメモリレイアウトを定義するリンカースクリプト(kernel.ld)を作成しました。
ENTRY(_start)
SECTIONS {
. = 0x100000; /* カーネルを1MB地点に配置 */
.text : {
KEEP(*(.text._start)) /* _startを確実に先頭へ */
*(.text .text.*)
}
.rodata : {
*(.rodata .rodata.*)
}
.data : {
*(.data .data.*)
}
.bss : {
*(.bss .bss.*)
}
}
これを .cargo/config.toml で指定することで、セクションの順序が固定され、スタックとコードの衝突が解消されました。
【問題2】カーネル関数の実行時に引数が渡せない
起きた現象
ブートローダーからカーネルへ、関数引数として以下のような情報を渡そうとしました(たぶん)。
// ブートローダー側(イメージ)
let kernel_main: extern "C" fn(
memory_map_ptr: *const u8,
memory_map_len: usize,
descriptor_size: usize,
framebuffer_info: FrameBufferInfo
) -> ! = core::mem::transmute(kernel_entry);
kernel_main(
memory_map_ptr as *const u8,
memory_map_len,
descriptor_size,
framebuffer_info
);
しかし、カーネル側で受け取った値がブートローダ側と合わない
-
期待値:
Memory entries: 115,Descriptor size: 48... -
実測値:
Memory entries: 0,Descriptor size: 114...
「ポインタが指す先のメモリが消えた?」と疑い、固定値(リテラル)を渡すテストを行いましたが、それでも値が変化してしまいました(例: 115 を渡したのに 200 になるなど)。
原因:呼び出し規約(ABI)の不一致とメモリ管理の変化
調査の結果、複合的な要因っぽい分かりました。
1. 呼び出し規約(Calling Convention)の不一致 これが最大の原因?
-
UEFI(ブートローダー): Microsoft x64 ABIを使用(引数は
RCX,RDX,R8,R9レジスタの順)。 -
Rustカーネル(ベアメタル): System V AMD64 ABIを使用(引数は
RDI,RSI,RDX,RCXレジスタの順)。
ブートローダーは「第1引数を RCX」に入れて渡しましたが、カーネルは「第1引数は RDI にあるはず」として読み取ります。この会話のズレにより、引数が順不同に見えたり、ゴミ値を読み取ったりしていました。
2. exit_boot_services 後のメモリ不安定化 UEFIの exit_boot_services を実行すると、UEFI管理下のヒープ領域が無効化されます。Rustの Vec などヒープを使う型をそのまま渡そうとすると、そのポインタ先は解放済みメモリとなるため、カーネル側で触った瞬間にクラッシュしたりゴミデータを読んだりします。
解決策:物理メモリの固定アドレスを介して値を渡す
関数引数(レジスタ渡し)に頼るのをやめ、「物理メモリの固定アドレス」で値を読み取る方式に変更しました。
- ブートローダー側で特定のアドレスにカーネルに渡したいデータを書き込み
- カーネル側で同じアドレスを指定してデータを読み出す
ブートローダー側:
// 0x80000 という「決めた場所」にデータを書き込む
let args_address = 0x80000 as *mut u64;
unsafe {
core::ptr::write(args_address.offset(0), memory_data_address as u64); // メモリマップ
core::ptr::write(args_address.offset(1), count as u64); // サイズ
// ...
}
// 引数なしでカーネルへジャンプ
let kernel_main: extern "C" fn() -> ! = core::mem::transmute(kernel_entry);
kernel_main();
カーネル側:
#[no_mangle]
pub extern "C" fn _start() -> ! {
// 同じ「決めた場所」からデータを読み出す
let args_address = 0x80000 as *const u64;
let (mem_ptr, mem_len) = unsafe {
(
core::ptr::read(args_address.offset(0)),
core::ptr::read(args_address.offset(1)),
)
};
// ...
}
このように、ABIに依存しない物理メモリ経由のデータ渡しを行うことで、レジスタの仕様違いやスタックの破損に影響されず、確実に情報を引き継ぐことができるようになりました。
まとめ
ベアメタルOS開発では、普段コンパイラやOSが隠蔽してくれている「当たり前」が存在しません。
-
メモリレイアウト: リンカースクリプトを書かないと、コードもデータもどこに置かれるか分からない。
-
関数呼び出し: 異なる環境(UEFI vs ベアメタル)を跨ぐときは、ABI(レジスタの使い方)の違いを意識しないとデータが渡らない。
「固定アドレス渡し」は原始的ですが、環境移行時には最も確実な通信手段の一つであることを学びました。今後はこの固定アドレス方式をより安全にするために、構造体定義を共有する正式なブートプロトコルへ昇華させていく予定です。