自作 OS「FerriOS」の開発日記、第3回です。前回はユーザプロセスを実装して機械語コードでループさせてみて、なんとなく動いていそうだなぁというところまでできました。

image-20260322220019960

しかし、これじゃユーザプロセスからはなんの文字出力もできないですし、使い物になりませんよね。

なので今回は簡単なシステムコールを実装して、ユーザアプリケーションからカーネルに対して文字出力を実行させることで、ユーザプロセスが動いている様を確認できるようにしたいと思います。今回実装するシステムコールは下記の4つ。

  • fork()
  • exec()
  • getpid()
  • print_num():数値表示用
  • print_str():文字列表示用

print_num()print_str() は、write() システムコールを実装するまでの暫定的なシステムコールとして実装しています。実際のところは Linux などでは write() システムコールを通して VGA なりデバイスファイルに文字を書き込むことで文字列を表示しているのですが、FerriOS にはまだファイルシステムを全く実装しておりませんので、文字表示専用のシステムコールを用意しています。

また、今回はユーザアプリケーションを ELF アプリケーションとしてビルドし、exec() で ELF からユーザ空間にコードをロードして実行できるようにします。お楽しみに!

image-20260505224735070

RustでOS開発シリーズ

今回のゴール

  • システムコールを実装する
  • ELF アプリケーションのビルドと実行を可能にする

最終的なゴールとしては、親プロセスで fork() して子プロセスで exec() で ELF アプリケーションをロードし、それぞれの PID を getpid() で表示させるところまで行きたいと思います。

今回の実装内容はこちら ↓ で公開しています。

abi 定数の定義

システムコールを実装する前に、システムコール番号や戻り値などを定数で定義しておきましょう。カーネルからもユーザからも参照できるよう、独立した abi クレートとして実装しています。

cargo new abi --lib

abi/src/lib.rs

#![no_std]

/// type: syscall number
pub type SyscallNum = i64;

/// type: return value
pub type SysRet = i64;

/// syscall numbers
pub const SYS_PRINT_NUM: SyscallNum = 1;
pub const SYS_PRINT_STR: SyscallNum = 2;
pub const SYS_FORK: SyscallNum = 3;
pub const SYS_EXEC: SyscallNum = 4;
pub const SYS_GETPID: SyscallNum = 5;

/// return values
pub const RET_SUCCESS: SysRet = 0;
pub const RET_ERROR: SysRet = -1;

型が一意になるように SyscallNumSysRet 型をそれぞれ定義しています。とりあえず今は i64 にしました。

続いてシステムコール番号。これらの番号を SYSCALL 命令実行時にシステムコールエントリに渡します。

最後に return value。システムコールからユーザへの戻り値として、RET_SUCCESSRET_ERROR を定義しておきました。

カーネルから abi クレートを参照できるよう、kernel/Cargo.toml も更新しておきます。

...
[dependencies]
bootloader_api = "0.11"
volatile = "0.2.6"
spin = "0.5.2"
x86_64 = "0.14.2"
uart_16550 = "0.2.0"
pic8259 = "0.10.1"
pc-keyboard = "0.7.0"
linked_list_allocator = "0.9.0"
noto-sans-mono-bitmap = { version = "0.3.2", features = ["bold"] }
abi = { path = "../abi" }
...

システムコールの実装

エントリポイントの実装

CPU で SYSCALL 命令が実行されたときに飛ぶ先としてのシステムコールエントリポイントを作成しておきます。

まずは kernel/src/syscall/mod.rs を用意。

kernel/src/syscall/mod.rs

use x86_64::registers::model_specific::{Efer, EferFlags, LStar, Star, SFMask};
use x86_64::VirtAddr;
use core::arch::naked_asm;
use core::cmp;
use core::mem::offset_of;
use alloc::vec::Vec;

use crate::gdt;
use crate::cpu::Cpu;

mod ksyscall;

const OFFSET_SAVED_USER_RSP: usize = offset_of!(Cpu, saved_user_rsp);
const OFFSET_KERNEL_SYSCALL_RSP: usize = offset_of!(Cpu, kernel_syscall_rsp);
const OFFSET_SAVED_RAX: usize = 72;

#[unsafe(naked)]
unsafe extern "C" fn syscall_entry() {
    naked_asm!(
        // カーネル用 GS に切り替え
        "swapgs",

        // ユーザ RSP を退避し、カーネルスタックに切り替え
        "mov gs:[{saved_user_rsp}], rsp",
        "mov rsp, gs:[{kernel_syscall_rsp}]",

        // レジスタを退避
        "push rcx",   // sysretq 用 RIP
        "push r11",   // sysretq 用 RFLAGS
        "push rax",   // syscall 番号
        "push rdi",
        "push rsi",
        "push rdx",
        "push rbx",
        "push rbp",
        "push r12",
        "push r13",
        "push r14",
        "push r15",

        // syscall_dispatch(number=rax, arg0=rdi, arg1=rsi, arg2=rdx)
        // 引数は rdi, rsi, rdx に入っている
        "mov r10, rax",
        "mov r8,  rsp",
        "mov rcx, rdx",
        "mov rdx, rsi",
        "mov rsi, rdi",
        "mov rdi, r10",
        // rsi, rdx はユーザが設定した値がそのまま残っている
        "call {syscall_dispatch}",
        // syscall_dispatch の戻り値を保存済み rax スロットへ反映する
        "mov [rsp + {saved_rax_offset}], rax",

        // レジスタを復元
        "pop r15",
        "pop r14",
        "pop r13",
        "pop r12",
        "pop rbp",
        "pop rbx",
        "pop rdx",
        "pop rsi",
        "pop rdi",
        "pop rax",
        "pop r11",
        "pop rcx",

        // ユーザ RSP を復元
        "mov rsp, gs:[{saved_user_rsp}]",

        // ユーザ用 GS に戻す
        "swapgs",

        // ユーザモードに戻る
        "sysretq",

        saved_user_rsp     = const OFFSET_SAVED_USER_RSP,
        kernel_syscall_rsp = const OFFSET_KERNEL_SYSCALL_RSP,
        saved_rax_offset   = const OFFSET_SAVED_RAX,
        syscall_dispatch   = sym ksyscall::syscall_dispatch,
    )
}

ここでは、カーネルスタックに切り替えてレジスタをコンテキストに退避させ、後に実装する syscall_dispatch() という関数にジャンプさせます。このとき、呼び出し先のシステムコール番号は RAX レジスタに、第1引数~第3引数はそれぞれ RDIRSIRDX レジスタに格納されていますので、それらを引数で渡します。また、R8 には実行中のスレッドの Trap Frame を渡します。これは、Thread 構造体に「今この syscall を実行しているスレッドの、カーネルスタック上の保存レジスタ領域」を記録させるためです。代入処理は syscall_dispatch() で行っています。

システムコールの処理が完了したら、コンテキストからレジスタの値を復元し、ユーザスタックに戻した後にユーザモードに戻ります。

要するに syscall_entry() は、ユーザ空間から見れば「関数呼び出しの入口」ですが、カーネル側から見ると「特権レベルを切り替えつつ、ユーザの実行状態を丸ごと保存するための薄いラッパー」です。ここでレジスタをきちんと退避しておかないと、fork() のように「後でその時点のユーザ状態をコピーしたい」処理や、exec() のように「復帰先の RIP を差し替えたい」処理がうまく実装できません。

また、SYSCALL 命令が実行されたときに、上記の syscall_entry() が呼ばれるよう、初期設定もしておきます。

kernel/src/syscall/mod.rs

pub fn init() -> Result<(), &'static str> {
    unsafe {
        Efer::update(|flags| *flags |= EferFlags::SYSTEM_CALL_EXTENSIONS);
    }

    // syscall handler のアドレスを LSTAR に登録
    LStar::write(VirtAddr::new(syscall_entry as u64));

    // CC/SS セグメントを STAR に設定
    Star::write(
        gdt::GDT.1.user_code_selector,
        gdt::GDT.1.user_data_selector,
        gdt::GDT.1.kernel_code_selector,
        gdt::GDT.1.kernel_data_selector,
    )?;

    // syscall 呼び出し時に IF をクリアさせる
    SFMask::write(x86_64::registers::rflags::RFlags::INTERRUPT_FLAG);

    Ok(())
}

こちらの syscall::init()kernel_main() から呼び出される init::init() から呼び出されます。

kernel/src/libbackend/init.rs

/// init
/// IDT の初期化
pub fn init() {
    gdt::init();
    cpu::init();
    syscall::init().expect("syscall init error");      // これ
    interrupts::init_idt();
    ...
}

システムコールの実装

まずは syscall_entry() から呼び出され、各システムコールのエントリポイント関数を呼び出す syscall_dispatch() の実装から。

src/syscall/ksyscall.rs

use crate::println;
use crate::thread;
use crate::exec;

use abi::*;

/// Rustから呼ばれるディスパッチャ
/// 戻り値はRAXに入る
#[unsafe(no_mangle)]
pub extern "C" fn syscall_dispatch(syscall_num: SyscallNum, arg1: i64, arg2: i64, arg3: i64, tf: *mut thread::trapframe::TrapFrame) -> SysRet {
    set_trapframe(tf);
    
    match syscall_num {
        abi::SYS_PRINT_NUM => sys_print_num(arg1),
        abi::SYS_PRINT_STR => sys_print_str(arg1 as u64, arg2),
        abi::SYS_FORK => sys_fork(),
        abi::SYS_EXEC => sys_exec(arg1 as u64, arg2 as u64),
        abi::SYS_GETPID => sys_getpid(),
        _ => {
            crate::println!("[syscall] unknown syscall: {}", syscall_num);
            SysRet::MAX  // エラー
        }
    }
}

/// TrapFrame をセットする
#[unsafe(no_mangle)]
pub extern "C" fn set_trapframe(tf_ptr: *mut thread::trapframe::TrapFrame) {
    let tid = {
        let cpu = crate::cpu::CPU.lock();
        cpu.current_tid
    }.expect("no current thread");
    
    let mut table = crate::thread::THREAD_TABLE.lock();
    table[tid].tf = Some(tf_ptr);
}

ここでは先に、set_trapframe() を呼び出して実行中のスレッドの Thread Table 上のメンバ変数 tf に Trap Frame を記録しておき、後にユーザに戻る際にコンテキストを復元できるようにします。

その後、 syscall_num に従って各システムコールのエントリポイント関数を呼び出します。実行結果は RAX レジスタに保存されます。

ここで tf を Thread 構造体に保存しているのは、後で fork()exec() を実装するうえで効いてきます。たとえば fork() では「親が syscall から戻る直前のレジスタ状態」をそのまま子にコピーし、子だけ RAX = 0 に書き換えたいですし、exec() ではユーザに戻る直前の RIPRSP を新しいプログラム向けに差し替えたいです。そのため、今まさに syscall を実行中のスレッドがどの Trap Frame を使っているかを、Thread Table から辿れるようにしておく必要があります。

sys_print_num()

sys_print_num() は数値を表示するだけのシステムコールです。write() システムコールの代わりとして暫定的に実装しています(ファイルシステムを実装したら削除予定)。

実装はこんな感じです。

kernel/src/syscall/ksyscall.rs

/// 数値を表示する
fn sys_print_num(n: i64) -> SysRet {
    crate::println!("[syscall] print_num: {}", n);
    abi::RET_SUCCESS
}

sys_print_str()

sys_print_str() は文字列を表示するシステムコールです。こちらも write() システムコールの代わりとして暫定的に実装しています。

sys_print_num() のように引数で数値を渡すだけならレジスタを介して渡せばいいので楽なんですが、文字列となるとそうは行きません。レジスタで渡せるのは数値やポインタだけなので、ユーザ→カーネルに文字列をコピーするなり、ユーザ空間上のアドレスから文字列を取得するなりの処理が必要です。

というわけで、先にユーザ→カーネルに文字列をコピーするための処理を実装します。

kernel/src/syscall/mod.rs

/// user から kernel に string をコピー
fn copy_cstr_from_user(ptr: u64, max_len: usize) -> Result<Vec<u8>, &'static str> {
    if ptr == 0 {
        return Err("exec: null argument pointer");
    }

    let mut bytes = Vec::new();
    for i in 0..max_len {
        let byte = unsafe { *((ptr as *const u8).add(i)) };
        if byte == 0 {
            return Ok(bytes);
        }
        bytes.push(byte);
    }

    Err("exec: argument is too long")
}

この関数はカーネルで実行されるので、カーネル空間上に Vec が作成され、そこにユーザ空間上のアドレス(ptr)から文字列をコピーしています。

もちろん実際には「ユーザが渡してきたポインタをそのまま信用してはいけない」ので、もう少し厳密にはユーザページテーブルを辿って、そのアドレスが本当にマップされているか、ユーザアクセス可能か、といった検証も必要です。が、現時点では割愛。

これで下準備は整いました。sys_print_str() を実装していきましょう。

kernel/src/syscall/ksyscall.rs

/// 文字列を表示する(ポインタ + 長さ)
fn sys_print_str(ptr: u64, len: i64) -> SysRet {
    // ユーザポインタの検証(今は簡易版)
    if len > 256 {
        return SysRet::MAX;
    }
    let bytes = match super::copy_bytes_from_user(ptr, len as usize) {
        Ok(bytes) => bytes,
        Err(e) => {
            crate::println!("[syscall] print_str copy error: {}", e);
            return SysRet::MAX;
        }
    };
    if let Ok(s) = core::str::from_utf8(&bytes) {
        crate::println!("[syscall] print_str: {}", s);
        abi::RET_SUCCESS
    } else {
        SysRet::MAX
    }
}

sys_getpid()

Linux でもおなじみの getpid() システムコールです。こちらはプロセスから PID を取得して数値で返すだけなので比較的簡単です。

まずはエントリポイント関数から。

kernel/src/syscall/ksyscall.rs

/// getpid
fn sys_getpid() -> SysRet {
    let pid = thread::uprocess::syscalls::getpid();
    if let Ok(pid) = pid {
        return pid as SysRet;
    }
    else {
        return abi::RET_ERROR;
    }
}

つづいて本体となる実装関数。

kernel/src/thread/uprocess/syscalls.rs

pub fn getpid() -> Result<usize, &'static str> {
    let cpu = cpu::CPU.lock();
    let pid = cpu.current_pid();

    if let Some(pid) = pid {
        return Ok(pid);
    }
    else {
        return Err("no process")?;
    }
}

もともと cpu 構造体に current_pid() を実装しているので、そこから数値を取得して返すだけです。

sys_fork()

ここからが若干ややこしいです。fork() もおなじみのプロセス生成のシステムコールで、親プロセスをコピーして子プロセスを生成します。

実装は xv6 を参考に作成しています。まずはエントリポイント関数から。

kernel/src/syscall/ksyscall.rs

/// fork
fn sys_fork() -> SysRet {
    match thread::uprocess::syscalls::fork() {
        Ok(child_pid) => child_pid as SysRet,
        Err(e) => {
            println!("{}", e);
            abi::RET_ERROR
        }
    }
}

続いて本体。

kernel/src/thread/uprocess/syscalls.rs

pub fn fork() -> Result<usize, &'static str> {
    // プロセス割り当て
    let mut process = super::alloc_proc()?;

    // frame allocator を取得
    let mut guard = memory::FRAME_ALLOCATOR.lock();
    let frame_allocator = guard.as_mut().expect("FRAME_ALLOCATOR not initialized");

    // 現在のプロセスの PML4 page table を取得
    let mut current_process_pml4: &mut PageTable = {
        let cpu = cpu::CPU.lock();
        let phys_frame = cpu.current_process().expect("process not found").page_table.expect("no page table");

        let physical_memory_offset = memory::PHYSICAL_MEMORY_OFFSET.lock().expect("physical memory offset not initialized");

        // PhysFrame → 仮想アドレス → &mut PageTable
        let virt = unsafe {
            memory::va::phys_to_virt(phys_frame.start_address(), physical_memory_offset)
        };
        unsafe { &mut *virt.as_mut_ptr::<PageTable>() }
    };

    // proces state (page table) をコピー
    let (_, page_table) = memory::umem::copy_uvm(frame_allocator, &mut current_process_pml4)?;

    // ページテーブル設定
    process.page_table = Some(page_table);

    // 親プロセスの trapframe とユーザ復帰情報をコピー
    let (parent_tf, parent_user_rsp): (thread::trapframe::TrapFrame, u64) = {
        let cpu = crate::cpu::CPU.lock();
        let tid = cpu.current_tid.expect("no current thread");
        let saved_user_rsp = cpu.saved_user_rsp;
        drop(cpu);
        let thread_table = crate::thread::THREAD_TABLE.lock();
        (unsafe { *thread_table[tid].tf.expect("no trapframe") }, saved_user_rsp)
    };
    {
        let mut table = crate::thread::THREAD_TABLE.lock();
        let child_tid = process.threads[0].expect("no child thread");
        let child = &mut table[child_tid];

        // xv6: *np->tf = *proc->tf
        unsafe { *child.tf.expect("no trapframe") = parent_tf; }

        // xv6: np->tf->eax = 0 (子の fork 戻り値を 0 に)
        unsafe { (*child.tf.expect("no trapframe")).rax = 0; }

        // 子は fork から復帰する形で最初にユーザ空間へ入る
        child.context.rsp3 = parent_user_rsp;
        child.context.user_rip = parent_tf.rcx;
        child.context.user_rdi = parent_tf.rdi;
        child.context.user_rsi = parent_tf.rsi;
        
        crate::println!(
            "[fork] child pid={}, tid={}, user_rip={:#x}, rsp3={:#x}, rax={:#x}",
            process.pid,
            child_tid,
            child.context.user_rip,
            child.context.rsp3,
            unsafe { (*child.tf.expect("no trapframe")).rax },
        );
    }

    // ステータスの設定

    // process_table に追加
    let mut process_table = super::PROCESS_TABLE.lock();
    process_table[process.pid] = Some(process);
    
    // runnable に設定
    super::mark_threads_as_runnable(process)?;

    Ok(process.pid)
}

基本的には

  1. Process 構造体を作成し
  2. 現在のプロセス = 親プロセスのページテーブルをコピーして
  3. 子プロセスにコピーしたページテーブルを設定
  4. 親プロセスの Trap Frame と user rsp もコピー(fork 完了後の復帰先を設定)
  5. 子プロセスの戻り値を 0 になるようにする(親プロセスの場合は子プロセスの PID)
  6. 子プロセスを Process Table に追加

といった形です。

fork() が難しいのは、単に Process 構造体を複製すればよいわけではない点です。親と子で「見えているメモリ空間」と「ユーザ空間に戻る瞬間のレジスタ状態」の両方を、それぞれ整合が取れるように複製しなければなりません。

これに際して copy_uvm() といった一連のページテーブル操作関数も実装しました。

kernel/src/memory/umem.rs

/// 親プロセスのユーザ空間 [0]..[255] を子プロセスにコピー
pub fn copy_uvm(frame_allocator: &mut impl FrameAllocator<Size4KiB>, parent_pml4: &mut PageTable) -> Result<(OffsetPageTable<'static>, PhysFrame), &'static str> {
    // physical_memory_offset
    let physical_memory_offset = PHYSICAL_MEMORY_OFFSET.lock().expect("physical memory offset not initialized");

    // 子の PML4 を作成
    let (child_offset_table, child_pml4_frame) = new_uvm(frame_allocator)?;

    // 子の PML4 への生ポインタを取得
    let child_pml4_virt = physical_memory_offset + child_pml4_frame.start_address().as_u64();
    let child_pml4: &mut PageTable = unsafe { &mut *child_pml4_virt.as_mut_ptr() };

    // ユーザ空間 PML4 エントリ (index 0..255) を走査
    for pml4_idx in PAGETABLE_USER_SPACE_START..PAGETABLE_USER_SPACE_END { // Iterate 0 to 255 (exclusive of 256)
        if !parent_pml4[pml4_idx].flags().contains(PageTableFlags::PRESENT) {
            continue;
        }

        // 子の PDPT を新規割り当て
        let child_pdpt_frame = frame_allocator.allocate_frame().ok_or("copy_uvm: failed to allocate PDPT frame")?;
        va::init_page_table(child_pdpt_frame, physical_memory_offset);

        // 子の PML4 エントリに書き込む
        let parent_pdpt_flags = parent_pml4[pml4_idx].flags();
        child_pml4[pml4_idx].set_frame(child_pdpt_frame, parent_pdpt_flags);

        // 親の PDPT を取得
        let parent_pdpt = unsafe {
            va::table_from_entry(&parent_pml4[pml4_idx], physical_memory_offset)
        };
        let child_pdpt = unsafe {
            va::table_from_frame(child_pdpt_frame, physical_memory_offset)
        };

        // PDPT エントリを走査
        for pdpt_idx in 0..512usize {
            if !parent_pdpt[pdpt_idx].flags().contains(PageTableFlags::PRESENT) {
                continue;
            }

            // 子の PD を新規割り当て
            let child_pd_frame = frame_allocator.allocate_frame().ok_or("copy_uvm: failed to allocate PD frame")?;
            va::init_page_table(child_pd_frame, physical_memory_offset);

            let parent_pd_flags = parent_pdpt[pdpt_idx].flags();
            child_pdpt[pdpt_idx].set_frame(child_pd_frame, parent_pd_flags);

            let parent_pd = unsafe {
                va::table_from_entry(&parent_pdpt[pdpt_idx], physical_memory_offset)
            };
            let child_pd = unsafe {
                va::table_from_frame(child_pd_frame, physical_memory_offset)
            };

            // PD エントリを走査
            for pd_idx in 0..512usize {
                if !parent_pd[pd_idx].flags().contains(PageTableFlags::PRESENT) {
                    continue;
                }

                // 子の PT を新規割り当て
                let child_pt_frame = frame_allocator.allocate_frame().ok_or("copy_uvm: failed to allocate PT frame")?;
                va::init_page_table(child_pt_frame, physical_memory_offset);

                let parent_pt_flags = parent_pd[pd_idx].flags();
                child_pd[pd_idx].set_frame(child_pt_frame, parent_pt_flags);

                let parent_pt = unsafe {
                    va::table_from_entry(&parent_pd[pd_idx], physical_memory_offset)
                };
                let child_pt = unsafe {
                    va::table_from_frame(child_pt_frame, physical_memory_offset)
                };

                // PT エントリを走査
                for pt_idx in 0..512usize {
                    let parent_pte = &parent_pt[pt_idx];
                    if !parent_pte.flags().contains(PageTableFlags::PRESENT) {
                        continue;
                    }

                    // 新しい物理フレームを確保
                    let new_frame = frame_allocator
                        .allocate_frame()
                        .ok_or("copy_uvm: failed to allocate data frame")?;

                    let src_virt = physical_memory_offset + parent_pte.addr().as_u64();
                    let dst_virt = physical_memory_offset + new_frame.start_address().as_u64();

                    // ページをコピー
                    unsafe {
                        core::ptr::copy_nonoverlapping(
                            src_virt.as_ptr::<u8>(),
                            dst_virt.as_mut_ptr::<u8>(),
                            super::PAGE_SIZE,
                        );
                    }

                    // 子の PT エントリに新フレームを書き込む
                    child_pt[pt_idx].set_frame(new_frame, parent_pte.flags());
                }
            }
        }
    }

    Ok((child_offset_table, child_pml4_frame))
}

pub fn new_uvm(frame_allocator: &mut impl FrameAllocator<Size4KiB>) -> Result<(OffsetPageTable<'static>, PhysFrame), &'static str> {
    // physical_memory_offset
    let physical_memory_offset = PHYSICAL_MEMORY_OFFSET.lock().expect("physical memory offset not initialized");

    // 新しい level-4 フレームを allocate
    let (new_frame, new_table_ptr) = unsafe {
        kmem::setup_kvm(frame_allocator, physical_memory_offset)
    }?;

    let new_table = unsafe {
        &mut *new_table_ptr
    };

    // ユーザ空間のエントリのみクリア
    let user_code_l4_index = (crate::thread::uprocess::USER_CODE_START >> 39) as usize & 0x1FF;   // 32
    let user_stack_l4_index = (crate::thread::uprocess::USER_STACK_TOP >> 39) as usize & 0x1FF;   // 64
    new_table[user_code_l4_index].set_unused();
    new_table[user_stack_l4_index].set_unused();

    let new_page_table = unsafe {
        OffsetPageTable::new(&mut *new_table_ptr, physical_memory_offset)
    };

    Ok((new_page_table, new_frame))
}

が、このへんは説明がむずい…。基本的には xv6 と同じような実装で、親プロセスを PML4 から順に走査していって、エントリがあればページをコピーする、とった実装になっています。なんでこんな多重入れ子ループになっているかというと、前回の記事でも掲載した通り x86_64 のページテーブルが 4-level paging を採用しているからです。

image-20260322212543703

ELF64 アプリケーションの実装

まだ sys_exec() システムコールが残っていますが、先に ELF アプリケーションの実装から紹介したほうが良さそうです。

FerriOS でもユーザアプリケーションをポータブルにできるよう、ELF64 形式でアプリケーションをビルドし、それを exec() でロードして実行できるようにしています。

ELF のフォーマットについてはこちらの記事が参考になります。

カーネル側:ELF64 ローダの実装

まずは ELF64 フォーマットに従いヘッダを実装していきます。

kernel/src/exec/mod.rs

#[derive(Clone, Copy)]
#[repr(C)]
pub struct Elf64Header {
    ident: [u8; 16],
    elf_type: u16,
    machine: u16,
    version: u32,
    entry: u64,
    phoff: u64,
    shoff: u64,
    flags: u32,
    ehsize: u16,
    phentsize: u16,
    phnum: u16,
    shentsize: u16,
    shnum: u16,
    shstrndx: u16,
}

impl Elf64Header {
    /// ELF 識別子先頭4バイトを magic 値として返す
    fn magic(&self) -> u32 {
        u32::from_le_bytes([self.ident[0], self.ident[1], self.ident[2], self.ident[3]])
    }
}

#[derive(Clone, Copy)]
#[repr(C)]
pub struct Elf64ProgramHeader {
    prog_type: u32,
    flags: u32,
    offset: u64,
    vaddr: u64,
    paddr: u64,
    filesz: u64,
    memsz: u64,
    align: u64,
}

続いてマジックナンバーなど定数も定義しておきます。

kernel/src/exec/mod.rs

const ELF_MAGIC_NUM: u32 = 0x464C457F;
const ELF_CLASS_64: u8 = 2;
const ELF_DATA_LE: u8 = 1;
const ELF_TYPE_EXEC: u16 = 2;
const ELF_MACHINE_X86_64: u16 = 0x3E;
const ELF_PROG_LOAD: u32 = 1;

ELF ヘッダを読み取る処理がこちら。

kernel/src/exec/mod.rs

/// ELF ヘッダを読み取り、対象アーキテクチャなどを検証する
fn read_elf_header(image: &[u8]) -> Result<Elf64Header, &'static str> {
    if image.len() < size_of::<Elf64Header>() {
        return Err("exec: ELF header is truncated");
    }

    let elf = unsafe {
        ptr::read_unaligned(image.as_ptr() as *const Elf64Header)
    };

    if elf.ident[0..4] != [0x7F, b'E', b'L', b'F'] {
        return Err("exec: bad ELF magic");
    }
    if elf.ident[4] != ELF_CLASS_64 || elf.ident[5] != ELF_DATA_LE {
        return Err("exec: unsupported ELF class");
    }
    if elf.elf_type != ELF_TYPE_EXEC || elf.machine != ELF_MACHINE_X86_64 || elf.version != 1 {
        return Err("exec: unsupported ELF target");
    }
    if elf.magic() != ELF_MAGIC_NUM {
        return Err("exec: bad ELF magic");
    }

    Ok(elf)
}

ELF ファイルのうち、LOAD セグメントを読み取ってユーザページテーブルにマップする処理がこちら。

kernel/src/exec/mod.rs

/// LOAD セグメントをユーザページテーブルへマップして内容を配置する
fn load_elf_segments(image: &[u8], elf: Elf64Header, pml4: &mut PageTable, user_mapper: &mut OffsetPageTable<'static>, frame_allocator: &mut impl FrameAllocator<Size4KiB>) -> Result<(), &'static str> {
    let pa_offset = usize::try_from(elf.phoff).map_err(|_| "exec: invalid phoff")?;
    let pa_entry_size = usize::from(elf.phentsize);
    if pa_entry_size != size_of::<Elf64ProgramHeader>() {
        return Err("exec: unexpected program header size");
    }

    for i in 0..usize::from(elf.phnum) {
        let start = pa_offset.checked_add(i.checked_mul(pa_entry_size).ok_or("exec: program header overflow")?).ok_or("exec: program header overflow")?;
        let end = start.checked_add(pa_entry_size).ok_or("exec: program header overflow")?;
        if end > image.len() {
            return Err("exec: truncated program header");
        }

        let program_header = unsafe {
            ptr::read_unaligned(image[start..end].as_ptr() as *const Elf64ProgramHeader)
        };
        if program_header.prog_type != ELF_PROG_LOAD {
            continue;
        }
        crate::println!(
            "[exec] LOAD vaddr={:#x}, filesz={:#x}, memsz={:#x}, flags={:#x}",
            program_header.vaddr,
            program_header.filesz,
            program_header.memsz,
            program_header.flags,
        );
        if program_header.memsz < program_header.filesz {
            return Err("exec: invalid LOAD segment sizes");
        }
        if program_header.memsz == 0 {
            continue;
        }

        let file_start = usize::try_from(program_header.offset).map_err(|_| "exec: invalid segment offset")?;
        let file_size = usize::try_from(program_header.filesz).map_err(|_| "exec: invalid segment size")?;
        let file_end = file_start.checked_add(file_size).ok_or("exec: segment overflow")?;
        if file_end > image.len() {
            return Err("exec: truncated LOAD segment");
        }

        let segment_start = VirtAddr::new(program_header.vaddr);
        let segment_end = VirtAddr::new(program_header.vaddr.checked_add(program_header.memsz).ok_or("exec: invalid segment address")?);

        let start_page = Page::containing_address(segment_start);
        let end_page = Page::containing_address(segment_end - 1u64);
        let mut flags = PageTableFlags::PRESENT | PageTableFlags::USER_ACCESSIBLE;
        if (program_header.flags & 0x2) != 0 {
            flags |= PageTableFlags::WRITABLE;
        }

        for page in Page::range_inclusive(start_page, end_page) {
            ensure_user_page_mapping(pml4, user_mapper, frame_allocator, page, flags)?;
        }

        zero_user_range(pml4, frame_allocator, program_header.vaddr, program_header.memsz)?;
        copy_to_user_pagetable(pml4, frame_allocator, program_header.vaddr, &image[file_start..file_end])?;
    }

    Ok(())
}

fn ensure_user_page_mapping(
    pml4: &mut PageTable,
    user_mapper: &mut OffsetPageTable<'static>,
    frame_allocator: &mut impl FrameAllocator<Size4KiB>,
    page: Page,
    flags: PageTableFlags,
) -> Result<(), &'static str> {
    let physical_memory_offset = memory::PHYSICAL_MEMORY_OFFSET
        .lock()
        .expect("PHYSICAL_MEMORY_OFFSET not initialized");
    let pte = unsafe {
        memory::va::walk_pagetable(
            pml4,
            page.start_address(),
            false,
            physical_memory_offset,
            frame_allocator,
        )
    };

    if let Some(entry) = pte {
        if entry.flags().contains(PageTableFlags::PRESENT) {
            entry.set_flags(entry.flags() | flags);
            return Ok(());
        }
    }

    memory::va::map_page(user_mapper, frame_allocator, page, flags)
}

また、これらの ELF イメージのロードを経て、ページテーブルやユーザスタックポインタなどを保持ししておくための構造体 Exec を用意する処理がこちら。

kernel/src/exec/mod.rs

pub struct Exec {
    pub page_table: PhysFrame,
    pub entry: u64,
    pub user_sp: u64,
    pub argc: usize,
    pub argv_user_ptr: u64,
}

/// ELF と argv から新しい実行イメージを準備する
pub fn prepare_exec_image(elf_image: &[u8], argv: &[Vec<u8>]) -> Result<Exec, &'static str> {
    if elf_image.len() < size_of::<Elf64Header>() {
        return Err("exec: invalid ELF image");
    }

    let elf = read_elf_header(elf_image)?;

    let mut guard = memory::FRAME_ALLOCATOR.lock();
    let frame_allocator = guard.as_mut().expect("FRAME_ALLOCATOR not initialized");
    let (mut user_mapper, page_table) = memory::umem::new_uvm(frame_allocator)?;

    let physical_memory_offset = memory::PHYSICAL_MEMORY_OFFSET.lock().expect("PHYSICAL_MEMORY_OFFSET not initialized");
    let pml4 = unsafe {
        &mut *(memory::va::phys_to_virt(page_table.start_address(), physical_memory_offset).as_mut_ptr::<PageTable>())
    };
    load_elf_segments(elf_image, elf, pml4, &mut user_mapper, frame_allocator)?;

    // ユーザスタック範囲を計算し、先頭側にガードページを置く
    let stack_top = USER_STACK_TOP;
    let stack_pages = memory::STACK_PAGES as u64;
    let stack_bytes = stack_pages
        .checked_mul(memory::PAGE_SIZE as u64)
        .ok_or("exec: stack size overflow")?;
    let stack_start = stack_top.checked_sub(stack_bytes).ok_or("exec: stack overflow")?;
    let guard_page_start = stack_start
        .checked_sub(memory::PAGE_SIZE as u64)
        .ok_or("exec: guard page overflow")?;

    let guard_page = Page::containing_address(VirtAddr::new(guard_page_start));
    let stack_start_page = Page::containing_address(VirtAddr::new(stack_start));
    // ガードページ(U=0)を1枚確保
    memory::va::map_page(
        &mut user_mapper,
        frame_allocator,
        guard_page,
        PageTableFlags::PRESENT | PageTableFlags::WRITABLE,
    )?;
    // 実際のユーザスタックを複数ページで確保
    memory::va::map_pages(
        &mut user_mapper,
        frame_allocator,
        stack_start_page,
        stack_pages,
        PageTableFlags::PRESENT | PageTableFlags::WRITABLE | PageTableFlags::USER_ACCESSIBLE,
    )?;

    let (user_sp, argc, argv_user_ptr) = setup_user_stack(pml4, frame_allocator, argv, stack_top)?;

    Ok(Exec {
        page_table,
        entry: elf.entry,
        user_sp,
        argc,
        argv_user_ptr
    })
}

ここでやっていることは、ELF ローダの最小実装です。ELF にはセクションヘッダもありますが、実行に本当に必要なのは LOAD セグメントのほうです。つまり「このファイルの何バイト目から何バイト目までを、ユーザ空間のどの仮想アドレスへ、どの権限で配置するか」という情報だけを取り出して、ページテーブルと実メモリに反映しています。

また、実際に実装してみると、複数の LOAD セグメントが同じページを共有するケースが普通に出てきます。たとえば .rodata.got が同じ 4KiB ページ内に並ぶことがあり、その場合は単純に map_to() を2回呼ぶと失敗します。そのため、すでにマップ済みのページならフラグだけをマージし、未マップのページだけを新しくマップする、という処理も必要です。

最後に、実際に ELF アプリケーションを実行する処理がこちら。exec() システムコールが呼び出されたときは、ここで実装している exec() が実行されます。 commit_exec() で ELF イメージを実行させるんですが、スレッドの Trap Frame に反映させていく過程で失敗した場合にロールバックできる形にしています。

kernel/src/exec/mod.rs

/// パスからユーザプログラムを解決して exec を完了する
pub fn exec(path: &str, argv: &[Vec<u8>]) -> Result<(), &'static str> {
    let elf_image = user_programs::lookup(path).ok_or("exec: program not found")?;
    let prepared = prepare_exec_image(elf_image, &argv)?;
    commit_exec(prepared)?;
    Ok(())
}

/// 準備済み実行イメージを現在プロセス/スレッドへ反映する
fn commit_exec(prepared: Exec) -> Result<(), &'static str> {
    let (current_tid, current_pid) = {
        let cpu = cpu::CPU.lock();
        (
            cpu.current_tid().ok_or("exec: no current thread id")?,
            cpu.current_pid().ok_or("exec: no current process id")?,
        )
    };

    // ロールバック用のスナップショットを作成しておく
    let old_page_table = {
        let process_table = thread::uprocess::PROCESS_TABLE.lock();
        let process = process_table
            .get(current_pid)
            .and_then(|p| p.as_ref())
            .ok_or("exec: process table entry missing")?;
        process.page_table
    };

    let (old_context, old_trap_frame, old_saved_user_rsp) = {
        let thread_table = thread::THREAD_TABLE.lock();
        let thread = thread_table
            .get(current_tid)
            .ok_or("exec: thread table entry missing")?;
        let trap_frame = thread.tf.ok_or("exec: no trapframe")?;
        let old_tf = unsafe { *trap_frame };
        let old_context = thread.context;
        drop(thread_table);

        let cpu = cpu::CPU.lock();
        (old_context, old_tf, cpu.saved_user_rsp)
    };

    let (old_cr3, old_cr3_flags) = control::Cr3::read();

    // 反映本体(途中で失敗したら下でロールバックする)
    let commit_result = (|| -> Result<(), &'static str> {
        {
            let mut process_table = thread::uprocess::PROCESS_TABLE.lock();
            let process = process_table
                .get_mut(current_pid)
                .and_then(|p| p.as_mut())
                .ok_or("exec: process table entry missing")?;
            process.page_table = Some(prepared.page_table);
        }

        {
            let mut thread_table = thread::THREAD_TABLE.lock();
            let thread = thread_table
                .get_mut(current_tid)
                .ok_or("exec: thread table entry missing")?;
            thread.context.rsp3 = prepared.user_sp;
            thread.context.user_rip = prepared.entry;
            thread.context.user_rdi = prepared.argc as u64;
            thread.context.user_rsi = prepared.argv_user_ptr;

            let trap_frame = thread.tf.ok_or("exec: no trapframe")?;
            unsafe {
                (*trap_frame).rax = 0;
                (*trap_frame).rdi = prepared.argc as u64;
                (*trap_frame).rsi = prepared.argv_user_ptr;
                (*trap_frame).rcx = prepared.entry;
            }
        }

        {
            let mut cpu = cpu::CPU.lock();
            cpu.saved_user_rsp = prepared.user_sp;
        }

        unsafe {
            control::Cr3::write(prepared.page_table, control::Cr3Flags::empty());
        }
        Ok(())
    })();

    if commit_result.is_err() {
        // 途中失敗時はロールバック
        if let Some(old_pt) = old_page_table {
            let mut process_table = thread::uprocess::PROCESS_TABLE.lock();
            if let Some(process) = process_table.get_mut(current_pid).and_then(|p| p.as_mut()) {
                process.page_table = Some(old_pt);
            }
        }

        {
            let mut thread_table = thread::THREAD_TABLE.lock();
            if let Some(thread) = thread_table.get_mut(current_tid) {
                thread.context = old_context;
                if let Some(trap_frame) = thread.tf {
                    unsafe {
                        *trap_frame = old_trap_frame;
                    }
                }
            }
        }

        {
            let mut cpu = cpu::CPU.lock();
            cpu.saved_user_rsp = old_saved_user_rsp;
        }

        unsafe {
            control::Cr3::write(old_cr3, old_cr3_flags);
        }
    }

    commit_result
}

exec() は「新しいプロセスを作る」システムコールではなく、「今動いているプロセスの実行イメージを丸ごと差し替える」システムコールです。なので、fork() で子を増やした後に、その子側で exec() を呼ぶことで「親はそのまま、子だけ別プログラムに入れ替える」という Unix らしい流れができます。

今回の実装でも、commit_exec() では現在の process.page_tablethread.context、Trap Frame の RIP/RSP 相当をまとめて新しいプログラムのものへ差し替えています。途中で失敗したらロールバックするのは、これらの情報が中途半端に書き換わると、その後のコンテキストスイッチや sysretq で簡単にカーネルごと落ちるからです。

ほかにもユーザスタックの構築、ページテーブルへの書き込み、ユーザ空間のゼロクリア、…といった処理がありますが、長くなるのでここでの掲載は割愛します。

ユーザライブラリの用意

ユーザから簡単にシステムコールを呼び出せるよう、ラップ関数をライブラリとして実装しておきます。Linux でいうところの linux/syscalls.h みたいな感じです。

cargo new userlib --lib

userlib/src/lib.rs

#![no_std]

use core::arch::asm;
use core::fmt::{self, Write};
pub use abi::*;

const EXEC_MAX_ARGC: usize = 16;
const EXEC_MAX_ARG_LEN: usize = 256;
const PRINT_FMT_BUF_LEN: usize = 256;

#[unsafe(no_mangle)]
pub unsafe extern "C" fn syscall(num: SyscallNum, arg1: i64, arg2: i64, arg3: i64) -> SysRet {
    let ret: SysRet;
    unsafe {
        asm!(
            "mov rax, rdi",
            "mov rdi, rsi",
            "mov rsi, rdx",
            "mov rdx, rcx",
            "syscall",
            inlateout("rdi") num => _,
            inlateout("rsi") arg1 => _,
            inlateout("rdx") arg2 => _,
            inlateout("rcx") arg3 => _,
            lateout("rax") ret,
            lateout("r11") _,
            options(nostack),
        );
    }
    ret
}

pub fn print_num(num: i64) -> SysRet {
    unsafe {
        syscall(SYS_PRINT_NUM, num, 0, 0)
    }
}

pub fn print_str(s: &str) -> SysRet {
    unsafe {
        syscall(SYS_PRINT_STR, s.as_ptr() as i64, s.len() as i64, 0)
    }
}

pub fn fork() -> SysRet {
    unsafe {
        syscall(SYS_FORK, 0, 0, 0)
    }
}

pub fn exec(path: &str, argv: &[&str]) -> SysRet {
    if argv.len() > EXEC_MAX_ARGC {
        return RET_ERROR;
    }

    let mut path_buf = [0u8; EXEC_MAX_ARG_LEN + 1];
    if copy_c_string(path, &mut path_buf).is_err() {
        return RET_ERROR;
    }

    let mut arg_bufs = [[0u8; EXEC_MAX_ARG_LEN + 1]; EXEC_MAX_ARGC];
    let mut argv_ptrs = [0u64; EXEC_MAX_ARGC + 1];

    for (i, arg) in argv.iter().enumerate() {
        if copy_c_string(arg, &mut arg_bufs[i]).is_err() {
            return RET_ERROR;
        }
        argv_ptrs[i] = arg_bufs[i].as_ptr() as u64;
    }

    unsafe {
        syscall(SYS_EXEC, path_buf.as_ptr() as i64, argv_ptrs.as_ptr() as i64, 0)
    }
}

pub fn getpid() -> SysRet {
    unsafe {
        syscall(SYS_GETPID, 0, 0, 0)
    }
}

fn copy_c_string(src: &str, dst: &mut [u8; EXEC_MAX_ARG_LEN + 1]) -> Result<(), ()> {
    let bytes = src.as_bytes();
    if bytes.len() > EXEC_MAX_ARG_LEN {
        return Err(());
    }

    dst[..bytes.len()].copy_from_slice(bytes);
    dst[bytes.len()] = 0;
    Ok(())
}

基本的には各システムコールに対応する形でラップ関数を用意し、内部で syscall() を呼び出して指定したシステムコールを呼び出します。システムコール呼び出し処理は syscall() にアセンブリで実装しています。

この userlib を用意した理由は、ユーザアプリケーション側に毎回 asm!("syscall") を直書きしたくなかったからです。print_num(123)getpid() のような普通の関数呼び出しに見せておけば、アプリケーション側は syscall 番号やレジスタ割り当てを意識しなくて済みます。

また、println!() みたいな使い勝手で文字列を表示できるよう、print_fmt!() マクロもユーザライブラリに実装しています。内部的には print_str() システムコールを利用しています。

userlib/src/lib.rs

pub fn print_fmt(args: fmt::Arguments<'_>) -> SysRet {
    let mut buf = StackBuf::new();
    if buf.write_fmt(args).is_err() {
        return RET_ERROR;
    }
    print_str(buf.as_str())
}

struct StackBuf {
    bytes: [u8; PRINT_FMT_BUF_LEN],
    len: usize,
}

impl StackBuf {
    const fn new() -> Self {
        Self {
            bytes: [0; PRINT_FMT_BUF_LEN],
            len: 0,
        }
    }

    fn as_str(&self) -> &str {
        core::str::from_utf8(&self.bytes[..self.len]).unwrap_or("")
    }
}

impl Write for StackBuf {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        let bytes = s.as_bytes();
        let new_len = self.len.checked_add(bytes.len()).ok_or(fmt::Error)?;
        if new_len > self.bytes.len() {
            return Err(fmt::Error);
        }

        self.bytes[self.len..new_len].copy_from_slice(bytes);
        self.len = new_len;
        Ok(())
    }
}

#[macro_export]
macro_rules! print_fmt {
    ($($arg:tt)*) => ;
}

ユーザ側:ELF アプリケーションのビルド環境の準備

ユーザアプリケーションたるもの、カーネルからは独立して別個にビルドできるようにすることが望ましいです。というわけで、こんな構成にしています。

.
├── Cargo.lock
├── Cargo.toml
├── README.md
├── abi
│   ├── Cargo.toml
│   └── src
├── user
│   ├── child
│   └── init
├── build.rs
├── build.sh
├── kernel
│   ├── Cargo.toml
│   ├── build.rs
│   ├── src
│   └── tests
...

重要なのは user ディレクトリです。ここにそれぞれ childinit という名のユーザアプリケーションを配置しています。

./user
├── child
│   ├── Cargo.toml
│   ├── build.rs
│   ├── linker.ld
│   └── src
│       └── main.rs
└── init
    ├── Cargo.toml
    ├── build.rs
    ├── linker.ld
    └── src
        └── main.rs

各ユーザクレートにはリンカースクリプト linker.ld を用意し、ELF アプリケーションに対応する形でセクションを定義しています。

ENTRY(main)

SECTIONS
{
  . = 0x0000100000000000;

  .text : ALIGN(4K) {
    *(.text .text.*)
  }

  .rodata : ALIGN(4K) {
    *(.rodata .rodata.*)
  }

  .data : ALIGN(4K) {
    *(.data .data.*)
  }

  .bss : ALIGN(4K) {
    *(.bss .bss.*)
    *(COMMON)
  }
}

また、このリンカースクリプトを cargo が参照するよう、各ユーザクレートの build.rsrustc-link-arg オプションを指定しています。

use std::path::PathBuf;

fn main() {
    let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
    let linker_script = manifest_dir.join("linker.ld");

    println!("cargo:rustc-link-arg=-T{}", linker_script.display());
    println!("cargo:rerun-if-changed={}", linker_script.display());
}

あとは通常通り、ユーザアプリケーションを Rust コードで実装します。

init アプリケーションはこんな感じ。fork() で子プロセスを生成し、子プロセス側では child アプリケーションの ELF ファイルを読ませて実行させています。

#![no_std]
#![no_main]

use core::panic::PanicInfo;
use userlib::*;

#[unsafe(no_mangle)]
pub extern "C" fn main() -> ! {
    let ret = fork();
    if ret == RET_ERROR {
        panic!("failed to call fork()");
    }
    if ret == 0 {
        // on the child process
        let ret = exec("/child", &[]);
        if ret == RET_ERROR {
            panic!("failed to call exec()");
        }
    }

    // on the parent process
    let pid = getpid();
    loop {
        print_fmt!("[parent] pid = {}", pid);
    }
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop { core::hint::spin_loop(); }
}

こちらは child アプリケーションで、親プロセス(init アプリケーション)から fork された子プロセスで getpid() で自身の PID を取得し、永久ループを回して PID を表示させています。

#![no_std]
#![no_main]

use core::panic::PanicInfo;
use userlib::*;

#[unsafe(no_mangle)]
pub extern "C" fn main() -> ! {
    let pid = getpid();
    loop {
        print_fmt!("[child] pid = {}", pid);
    }
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop { core::hint::spin_loop(); }
}

また、これらのユーザアプリケーションがビルドされるように、ルートディレクトリの build.rs も更新したんですが、長くなるのでざっくり何をしているかだけ書いておきます。ルートの build.rs では、まず user ディレクトリ以下の各アプリを custom target 向けに ELF としてビルドします。その後、「実行時のパス」と「生成された ELF のファイルパス」を manifest にまとめて kernel 側 build script に渡し、kernel 側でそれらを include_bytes!() できる形にコード生成しています。

つまり cargo build を一回叩くと、内部的には

  1. user アプリ群のビルド
  2. manifest の生成
  3. kernel 側で ELF を取り込み
  4. 最後に kernel 本体をビルド

という順番で処理が進んでいます。ファイルシステムが未実装なうちは少し泥臭いですが、「いまは静的に埋め込んでおき、あとで本物のファイルシステムに置き換える」という方針にしておくと、exec("/child") のようなインターフェース自体は先に固められるのが良いところです。

ユーザアプリケーションをカーネルに埋め込ませる

上記の通り、現時点では FerriOS にはまだファイルシステムを実装していないので、ファイルとして ELF ファイルを読み込ませることができません。なので、ユーザアプリケーションとしてビルドした ELF バイナリを、カーネル側にハードコーディングという形でビルド時に埋め込ませます。つまり、今の構成は「user app を事前に ELF 化して、kernel に include_bytes!() で埋め込む」方式になっています。

具体的な手順としては下記の通りです。

  • root の build.rs が user app を見つけて、先に ELF としてビルドする
let apps = discover_apps(&apps_root);

for app in &apps {
    let app_status = Command::new("cargo")
        .args(&app_args)
        .status()
        .expect("failed to build app");
    assert!(app_status.success(), "app build failed: {}", app.dir_name);
}
  • 各 app の「実行時パス」と「生成された ELF の場所」を manifest にまとめる
manifest_lines.push(format!(
    "{}\t{}\t{}",
    runtime_path,
    app.package_name,
    elf_path.display()
));
  • その manifest のパスを USER_APPS_MANIFEST 環境変数で kernel 側 build script に渡す
let status = Command::new("cargo")
    .env("USER_APPS_MANIFEST", &apps_manifest)
    .args(&args)
    .status()
    .expect("failed to build kernel");
  • kernel の build.rs は manifest を読んで、各 ELF を OUT_DIR にコピーする
let dst = out_dir.join(format!("{file_stem}.elf"));
fs::copy(&elf_src, &dst).expect("failed to copy app elf");
  • include_bytes! 用の Rust コードを自動生成する

  • kernel 本体はその生成コードを include! して、パスから ELF を引けるようにする

include!(concat!(env!("OUT_DIR"), "/user_programs.rs"));

pub fn lookup(path: &str) -> Option<&'static [u8]> {
    PROGRAMS.iter().find(|program| program.path == path).map(|program| program.elf)
}

この方式のおかげで、exec() の実装は「まずパスから ELF バイト列を取ってくる」「それを ELF ローダに渡す」という素直な形で書けています。将来的にファイルシステムを実装したら、lookup(path) の中身をファイル読み出しに置き換えるだけで、exec() の大部分はそのまま流用できるはずです。

実行結果

kernel_main() からユーザプロセスで init アプリケーションを実行させるよう実装しています。

kernel/src/main.rs

/// エントリポイント
fn kernel_main(boot_info: &'static mut BootInfo) -> ! {
    ...
    // ユーザプロセス作成
    thread::uprocess::create_user_process_from_path("/init").expect("failed to create user process");
    ...
}

実行結果がこちら。

image-20260505224735070

うん。うまく動いていますね。親プロセス(pid 0)と子プロセス(pid 1)が交互に実行されていて、それぞれの PID を表示できている様子が確認できます。

Ring 3 confirmed! と書かれているのはコンテキストスイッチ時に表示させているデバッグ出力です。コンテキストスイッチに応じて親子プロセスがそれぞれ交互に実行されています。fork() できてますし、exec() で ELF バイナリを読み込めているし、getpid() で PID を取得できているし、print_str() で文字列表示もできています。ようやく自作 OS らしくなってきましたね。

ELF バイナリの中身も見てみました。きちんと ELF64 フォーマットになっていますね。

ytani@ytani-Virtual-Machine:~/git/ferrios$ readelf -h target/apps-build/x86_64-ferrios/release/init
ELF ヘッダ:
  マジック:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  クラス:                            ELF64
  データ:                            2 の補数、リトルエンディアン
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI バージョン:                    0
  型:                                EXEC (実行可能ファイル)
  マシン:                            Advanced Micro Devices X86-64
  バージョン:                        0x1
  エントリポイントアドレス:               0x100000000010
  プログラムヘッダ始点:          64 (バイト)
  セクションヘッダ始点:          20488 (バイト)
  フラグ:                            0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         7
  Size of section headers:           64 (bytes)
  Number of section headers:         11
  Section header string table index: 9
ytani@ytani-Virtual-Machine:~/git/ferrios$ readelf -l target/apps-build/x86_64-ferrios/release/init

Elf ファイルタイプは EXEC (実行可能ファイル) です
エントリポイント 0x100000000010
There are 7 program headers, starting at offset 64

プログラムヘッダ:
  タイプ        オフセット          仮想Addr           物理Addr
                 ファイルサイズ        メモリサイズ         フラグ 整列
  LOAD           0x0000000000001000 0x0000100000000000 0x0000100000000000
                 0x00000000000016e5 0x00000000000016e5  R E    0x1000
  LOAD           0x0000000000003000 0x0000100000002000 0x0000100000002000
                 0x0000000000000304 0x0000000000000304  R      0x1000
  LOAD           0x0000000000003308 0x0000100000002308 0x0000100000002308
                 0x0000000000000010 0x0000000000000010  RW     0x1000
  LOAD           0x0000000000004000 0x0000100000003000 0x0000100000003000
                 0x0000000000000078 0x0000000000000078  RW     0x1000
  GNU_RELRO      0x0000000000003308 0x0000100000002308 0x0000100000002308
                 0x0000000000000010 0x0000000000000010  R      0x1
  GNU_EH_FRAME   0x00000000000032dc 0x00001000000022dc 0x00001000000022dc
                 0x000000000000000c 0x000000000000000c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x0

 セグメントマッピングへのセクション:
  セグメントセクション...
   00     .text
   01     .rodata .eh_frame_hdr .eh_frame
   02     .got
   03     .data
   04     .got
   05     .eh_frame_hdr
   06

おわりに

今回はシステムコール実装と ELF バイナリのロード処理の実装を行いました。任意のユーザアプリケーションが動かせるようになったことで、結構 xv6 に近づいてきたんじゃないかなと思っています。でもまだファイルシステムがありませんね。ELF 実装もなかなかの沼でしたが、次の沼はファイルシステムかな。

FerriOS はこちら ↓ の GitHub リポジトリで開発中のものを公開しております。よかったらご覧ください。