Rustで作るx86_64 自作OS入門シリーズ
Part1【環境構築】 | Part2【no_std】 | Part3【Hello World】| Part4【VGA】 | Part5【割り込み】 | Part6【ページング】 | Part7【ヒープ】 | Part8【マルチタスク】| Part9【ファイルシステム】 | Part10【ELFローダー】 | Part11【シェル】 | Part12【完結】
はじめに
Part9でファイルシステムが動きました。ファイルが読み書きできると急にOSっぽい。
今回はELFローダーを実装します。これで外部プログラムが実行できるようになる!
カーネルの中にコードを書くんじゃなくて、別のプログラムを動かす。これができるとマジでOSって感じがする。
目次
ELFフォーマットとは
Executable and Linkable Format。LinuxやBSDで使われる実行ファイル形式。
Windows: .exe (PE形式)
Linux: なし or .elf (ELF形式)
macOS: なし (Mach-O形式)
ELFの構造:
+------------------+
| ELF Header | マジックナンバー、アーキテクチャ、エントリポイント
+------------------+
| Program Headers | ロード情報(メモリにどう配置するか)
+------------------+
| Section Headers | セクション情報(デバッグ用など)
+------------------+
| .text | コード
+------------------+
| .data | 初期化済みデータ
+------------------+
| .bss | 未初期化データ
+------------------+
| ... |
+------------------+
実行に必要なのは主にELF HeaderとProgram Headers。
ELFヘッダーの解析
// src/elf/mod.rs
use alloc::vec::Vec;
pub const ELF_MAGIC: [u8; 4] = [0x7f, b'E', b'L', b'F'];
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct Elf64Header {
pub magic: [u8; 4],
pub class: u8, // 1 = 32bit, 2 = 64bit
pub endian: u8, // 1 = little, 2 = big
pub version: u8,
pub os_abi: u8,
pub _padding: [u8; 8],
pub elf_type: u16, // 1 = relocatable, 2 = executable, 3 = shared
pub machine: u16, // 0x3E = x86_64
pub version2: u32,
pub entry: u64, // エントリポイント!
pub phoff: u64, // Program Header offset
pub shoff: u64, // Section Header offset
pub flags: u32,
pub ehsize: u16,
pub phentsize: u16,
pub phnum: u16, // Program Header数
pub shentsize: u16,
pub shnum: u16,
pub shstrndx: u16,
}
impl Elf64Header {
pub fn parse(data: &[u8]) -> Option<&Self> {
if data.len() < core::mem::size_of::<Self>() {
return None;
}
let header = unsafe { &*(data.as_ptr() as *const Self) };
// マジックナンバーチェック
if header.magic != ELF_MAGIC {
return None;
}
// 64bitチェック
if header.class != 2 {
return None;
}
// x86_64チェック
if header.machine != 0x3E {
return None;
}
Some(header)
}
}
最初マジックナンバーの確認を忘れて、適当なファイルを読ませたら即死しました。
プログラムヘッダー
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct Elf64ProgramHeader {
pub p_type: u32, // セグメントタイプ
pub p_flags: u32, // フラグ (R/W/X)
pub p_offset: u64, // ファイル内オフセット
pub p_vaddr: u64, // 仮想アドレス
pub p_paddr: u64, // 物理アドレス(通常無視)
pub p_filesz: u64, // ファイル内サイズ
pub p_memsz: u64, // メモリ上サイズ
pub p_align: u64, // アライメント
}
// セグメントタイプ
pub const PT_NULL: u32 = 0;
pub const PT_LOAD: u32 = 1; // ← これが重要!
// フラグ
pub const PF_X: u32 = 1; // 実行可能
pub const PF_W: u32 = 2; // 書き込み可能
pub const PF_R: u32 = 4; // 読み込み可能
impl Elf64ProgramHeader {
pub fn parse_all(data: &[u8], header: &Elf64Header) -> Vec<&Self> {
let mut headers = Vec::new();
let offset = header.phoff as usize;
let size = header.phentsize as usize;
for i in 0..header.phnum as usize {
let start = offset + i * size;
if start + size <= data.len() {
let ph = unsafe { &*(data[start..].as_ptr() as *const Self) };
headers.push(ph);
}
}
headers
}
}
ELFローダー
// src/elf/loader.rs
use super::*;
use crate::memory;
use x86_64::{
structures::paging::{
Page, PageTableFlags, Mapper, FrameAllocator, Size4KiB,
},
VirtAddr,
};
use alloc::vec::Vec;
pub struct LoadedElf {
pub entry_point: u64,
pub pages: Vec<Page<Size4KiB>>,
}
pub fn load_elf(
data: &[u8],
mapper: &mut impl Mapper<Size4KiB>,
frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) -> Result<LoadedElf, &'static str> {
// ELFヘッダー解析
let header = Elf64Header::parse(data)
.ok_or("Invalid ELF header")?;
serial_println!("ELF entry point: {:#x}", header.entry);
let mut pages = Vec::new();
// プログラムヘッダーを処理
for ph in Elf64ProgramHeader::parse_all(data, header) {
if ph.p_type != PT_LOAD {
continue; // PT_LOAD以外は無視
}
serial_println!(
"Loading segment: vaddr={:#x}, filesz={:#x}, memsz={:#x}",
ph.p_vaddr, ph.p_filesz, ph.p_memsz
);
// ページフラグを設定
let mut flags = PageTableFlags::PRESENT | PageTableFlags::USER_ACCESSIBLE;
if ph.p_flags & PF_W != 0 {
flags |= PageTableFlags::WRITABLE;
}
// Note: x86_64では実行不可フラグ(NX)を使う
// ページをマップ
let start_page = Page::containing_address(VirtAddr::new(ph.p_vaddr));
let end_page = Page::containing_address(VirtAddr::new(ph.p_vaddr + ph.p_memsz - 1));
for page in Page::range_inclusive(start_page, end_page) {
let frame = frame_allocator
.allocate_frame()
.ok_or("Failed to allocate frame")?;
unsafe {
mapper.map_to(page, frame, flags, frame_allocator)
.map_err(|_| "Failed to map page")?
.flush();
}
pages.push(page);
}
// データをコピー
let dest = ph.p_vaddr as *mut u8;
let src = &data[ph.p_offset as usize..][..ph.p_filesz as usize];
unsafe {
core::ptr::copy_nonoverlapping(src.as_ptr(), dest, src.len());
// .bss領域をゼロ初期化
if ph.p_memsz > ph.p_filesz {
let bss_start = dest.add(ph.p_filesz as usize);
let bss_size = (ph.p_memsz - ph.p_filesz) as usize;
core::ptr::write_bytes(bss_start, 0, bss_size);
}
}
}
Ok(LoadedElf {
entry_point: header.entry,
pages,
})
}
簡単なテストプログラム
カーネルと同じアドレス空間で動くシンプルなプログラムを作ります:
// test_app/src/main.rs
#![no_std]
#![no_main]
#[no_mangle]
pub extern "C" fn _start() -> ! {
// システムコールでカーネルに処理を依頼...
// 今回は単純にループ
loop {}
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
...と思ったけど、これだとカーネルに結果を返せない。
簡易システムコール
// src/syscall.rs
pub const SYSCALL_PRINT: u64 = 1;
pub const SYSCALL_EXIT: u64 = 60;
pub fn handle_syscall(num: u64, arg1: u64, arg2: u64, arg3: u64) -> u64 {
match num {
SYSCALL_PRINT => {
let ptr = arg1 as *const u8;
let len = arg2 as usize;
let slice = unsafe { core::slice::from_raw_parts(ptr, len) };
if let Ok(s) = core::str::from_utf8(slice) {
crate::println!("{}", s);
}
0
}
SYSCALL_EXIT => {
serial_println!("App exited with code: {}", arg1);
0
}
_ => {
serial_println!("Unknown syscall: {}", num);
u64::MAX
}
}
}
関数ポインタで実行
本当のOSではユーザーモードに切り替えて実行しますが、今回は簡易的に関数ポインタでジャンプ:
pub fn execute_elf(loaded: &LoadedElf) -> i32 {
serial_println!("Jumping to entry point: {:#x}", loaded.entry_point);
// エントリポイントを関数ポインタとして呼び出し
let entry: extern "C" fn() -> i32 = unsafe {
core::mem::transmute(loaded.entry_point)
};
entry()
}
注意: これは超危険!本当のOSではRing 3に切り替えてからでないとセキュリティ的にダメ。
テスト用のバイナリを組み込み
ファイルシステムに実行ファイルを入れておく:
// src/fs/simple_fs.rs
impl SimpleFs {
pub fn new() -> Self {
let mut fs = SimpleFs {
files: BTreeMap::new(),
};
// 組み込みテストプログラム(実際にはビルドしたELF)
fs.files.insert("/bin/hello".to_string(), include_bytes!("../../test_app.elf").to_vec());
fs
}
}
実際にハマった話
1. アドレスの問題
ELFファイルが想定するアドレスと実際のメモリ配置が違うと死ぬ:
ELF: "俺は0x400000にロードされたい"
OS: "お前には0x800000をやる"
ELF: *絶対アドレスでジャンプ* → 死
解決策:
- Position Independent Executable (PIE) でビルドする
- または要求通りのアドレスにマップする
今回は要求通りにマップする方式で。
2. メモリ保護の問題
ロードしたコード: "書き込みしたい"
ページテーブル: "WRITABLEフラグないよ"
→ ページフォルト
PF_WフラグをちゃんとチェックしてWRITABLEを設定するようにした。
3. .bssセクション
static mut COUNTER: u32 = 0; // .bss に配置される
これ、ファイルサイズ(p_filesz)は0だけど、メモリサイズ(p_memsz)はある。
コピーするデータはないけど、ゼロ初期化は必要。
最初これを忘れて、.bssに謎のゴミが入って苦しんだ。
動作確認
実際にELFを読み込んで実行:
fn kernel_main(boot_info: &'static BootInfo) -> ! {
// ... 初期化 ...
println!("=== ELF Loader Demo ===");
if let Some(elf_data) = fs::read_file("/bin/hello") {
println!("Loaded /bin/hello ({} bytes)", elf_data.len());
match elf::loader::load_elf(&elf_data, &mut mapper, &mut frame_allocator) {
Ok(loaded) => {
println!("Entry point: {:#x}", loaded.entry_point);
let result = elf::loader::execute_elf(&loaded);
println!("Program returned: {}", result);
}
Err(e) => println!("Failed to load ELF: {}", e),
}
} else {
println!("/bin/hello not found");
}
// ...
}
=== ELF Loader Demo ===
Loaded /bin/hello (4096 bytes)
Loading segment: vaddr=0x400000, filesz=0x1a0, memsz=0x1a0
Entry point: 0x401000
Program returned: 42
プログラムが動いた!!!
これで関数が返した42が取れてる。外部プログラムが実行できるOSになった!
まとめ
Part10では以下を達成しました:
- ELFフォーマットの理解
- ELFヘッダー・プログラムヘッダーの解析
- セグメントのメモリへのロード
- 簡易システムコール
- エントリポイントへのジャンプ
外部プログラムが動いた瞬間、めちゃくちゃ感動した。これでOSと言えるレベルになってきた!
次回(Part11)はシェルを実装します。コマンドを打ってプログラムを実行できるようにする!
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!