From 71b6445b048456bf198d8b622570db95f4665cb9 Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 21 Aug 2026 21:17:44 +0200 Subject: exec: PE32+ loader, versioned ABI, first external binaries (v0.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rsapi: the kernel<->binary ABI crate (no_std, no deps). Versioned Exports table (magic + version header), EntryPoint = extern "C" on x86_64-unknown-uefi (Win64: RCX/RDX/R8), Line/Arg/StreamResult types. Binaries link only this crate. rsnix::load: PE32+ parser — DOS/COFF/optional headers, section copy, BSS zero-fill, .reloc base relocations (DIR64) via rva_to_file mapping (the reloc directory is an RVA, not a file offset). Images are allocated at LOAD_BASE=192MiB inside the page allocator's conventional span and identity-mapped user+writable+nx. rsnix::exec: resolution name -> /disk//bin//.efi per mounted volume; spawn reserves a sched slot, stores a per-task ExecCtx (exports + argv block + image) before launch_at installs the frame — no adopt race. Trampoline calls entry(&Exports, argc, argv), posts TaskMsg::User(exit_code) to the shell. abort_current notifies the shell with u64::MAX when an exec'd binary dies, so a faulting command cannot leave the shell blocked forever. sched: reserve_slot/launch_at split out of spawn; task_exit made pub. shell: exec_external runs before non-frozen built-ins (ADR 0006 resolution rule); frozen core (help/clear/echo/halt/poweroff/exit/ rescan) is exempt. Exports v1 wires put_line to the terminal and get_line to EOF; stream injection lands in Phase 3. ^C while waiting kills the command task. bins/{hello,boom}: gate binaries. hello prints through the exports table; boom faults on 0x400000000000 to prove containment. The image entry point IS the ABI entry (efi_main carries the Exports signature). Version 0.6.0: exec exists end-to-end. Gates verified in QEMU: 'hello' loads from \\bin\\cli\\hello.efi, calls put_line across the ABI, exits 0, shell unblocks; 'boom' faults, task dies gracefully with full report, shell survives, jobs shows the corpse; built-in fallback works when the file is absent; test-disk PASS. --- rsnix/Cargo.toml | 3 + rsnix/src/exec.rs | 137 ++++++++++++++++++++++++++ rsnix/src/lib.rs | 2 + rsnix/src/load.rs | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++++ rsnix/src/sched.rs | 46 ++++++++- 5 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 rsnix/src/exec.rs create mode 100644 rsnix/src/load.rs (limited to 'rsnix') diff --git a/rsnix/Cargo.toml b/rsnix/Cargo.toml index 5225b1a..f0dbad1 100644 --- a/rsnix/Cargo.toml +++ b/rsnix/Cargo.toml @@ -5,3 +5,6 @@ edition.workspace = true [lints] workspace = true + +[dependencies] +rsapi = { path = "../rsapi" } diff --git a/rsnix/src/exec.rs b/rsnix/src/exec.rs new file mode 100644 index 0000000..7ba39aa --- /dev/null +++ b/rsnix/src/exec.rs @@ -0,0 +1,137 @@ +// --------------------------------------------------------------------------- +// exec — run an external binary (ADR 0006 Phase 2). +// +// Resolution: name → \bin\\ on each mounted volume → None. +// The shell calls exec::spawn with a fully constructed Exports table +// (streams point at its pipeline/tty) and clean argv. The binary runs as +// its own task; on exit it posts TaskMsg::User(exit_code) to the spawner. +// +// The Exports table and argv block are heap boxes moved into static slots +// indexed by task id — the trampoline adopts them before jumping to the +// image entry. One slot per MAX_TASKS; spawn fails if the slot is busy. +// --------------------------------------------------------------------------- +use crate::load; +use crate::sched::{self, TaskMsg}; +use alloc::string::String; +use alloc::vec::Vec; +use rsapi::{Arg, EntryPoint, Exports}; + +/// Per-task invocation context adopted by the trampoline. +struct ExecCtx { + exports: &'static Exports, + /// Heap block holding the argv strings (kept alive until task exit — + /// the Arg pointers reference into it). + #[allow(dead_code)] + argv_buf: Vec, + args: Vec, + /// Image pages to free when the task dies. + image: load::Image, +} + +static mut CTX: [Option; sched::MAX_TASKS] = [const { None }; sched::MAX_TASKS]; + +/// Error of exec::resolve / exec::spawn. +pub enum ExecError { + NotFound, + LoadFailed(&'static str), + Busy, +} + +/// Scan every mounted volume for `\bin\\` (LFN exact match). +/// Returns the VFS handle of the first hit. +pub fn resolve(kind: &str, name: &str) -> Option { + for vol in 0..crate::fat::volume_count() { + if !crate::fat::is_mounted(vol) { + continue; + } + let uid = String::from(crate::fat::uid(vol)); + let path = alloc::format!("/disk/{}/bin/{}/{}.efi", uid, kind, name); + if let Some(h) = crate::fs::resolve(&path) { + return Some(h); + } + } + None +} + +/// Read the whole file into a heap buffer (public: the shell uses this +/// to fetch binary bytes before calling load::load_image). +#[allow(dead_code)] +pub fn read_file(h: usize) -> Result, ()> { + let size = crate::fs::size(h) as usize; + let mut buf = Vec::with_capacity(size); + let mut chunk = [0u8; 512]; + let mut pos = 0u64; + loop { + let n = crate::fs::read(h, &mut chunk, pos)?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + pos += n as u64; + } + Ok(buf) +} + +/// Spawn `image` as a new task running entry(exports, argc, argv). +/// +/// The caller keeps `exports` alive (it is constructed per invocation in +/// shell-owned memory that outlives the command — the pipeline/tty live +/// in the blocked spawner). +pub fn spawn( + image: load::Image, + exports: &'static Exports, + argv: &[String], + name: &str, +) -> Result { + // Copy the argv strings into one contiguous block owned by the task. + let mut argv_buf: Vec = Vec::new(); + let mut args: Vec = Vec::with_capacity(argv.len()); + for a in argv { + let off = argv_buf.len(); + argv_buf.extend_from_slice(a.as_bytes()); + args.push(Arg { + ptr: unsafe { argv_buf.as_ptr().add(off) }, + len: a.len(), + }); + } + + // Reserve a task slot first so the context is in place before the + // task can possibly run (spawn may schedule immediately). + let id = sched::reserve_slot().ok_or(ExecError::Busy)?; + unsafe { + CTX[id] = Some(ExecCtx { + exports, + argv_buf, + args, + image, + }); + } + if !sched::launch_at(id, trampoline, name) { + unsafe { + CTX[id] = None; + } + return Err(ExecError::Busy); + } + Ok(id) +} + +/// Trampoline: adopt the exec context, call the entry, report completion. +fn trampoline() { + let id = sched::current(); + let taken = unsafe { + let ctx_ptr = core::ptr::addr_of_mut!(CTX); + (*ctx_ptr)[id].take() + }; + let Some(ctx) = taken else { + sched::abort_current("exec: no context") + }; + // Call the image entry with the table; the return value is the exit code. + let code = unsafe { + let f: EntryPoint = core::mem::transmute(ctx.image.entry); + f(ctx.exports, ctx.args.len(), ctx.args.as_ptr()) + }; + // Report completion to the spawner (the shell), then die. The spawner + // frees the image pages after reaping the message. + let _ = sched::send(sched::SHELL_TID, TaskMsg::User(code as u64)); + drop(ctx); +} diff --git a/rsnix/src/lib.rs b/rsnix/src/lib.rs index a422057..376d3b5 100644 --- a/rsnix/src/lib.rs +++ b/rsnix/src/lib.rs @@ -12,6 +12,8 @@ pub mod gdt; pub mod gop; pub mod interrupts; pub mod klog; +pub mod exec; +pub mod load; pub mod memory; pub mod paging; pub mod pci; diff --git a/rsnix/src/load.rs b/rsnix/src/load.rs new file mode 100644 index 0000000..cebca89 --- /dev/null +++ b/rsnix/src/load.rs @@ -0,0 +1,283 @@ +// --------------------------------------------------------------------------- +// load — PE32+ executable loader (ADR 0006 Phase 2). +// +// Parses a PE32+ image read through the VFS, allocates identity-mapped +// pages for it (the kernel runs identity-mapped, so "user" code lives at +// its link address), applies base relocations, and returns the entry +// point. The spawn path (exec.rs glue in sched) builds an Exports table, +// copies the argv block into the image's memory, and starts a task whose +// trampoline calls entry(&Exports, argc, argv). +// +// Scope: what rustc/lld emits for a no_std UEFI-ish binary — DOS header, +// COFF header, optional header PE32+, section table, .reloc base +// relocations. No imports, no TLS, no exceptions. +// --------------------------------------------------------------------------- +use crate::paging; + +/// Where binaries are loaded: above the heap, inside conventional RAM. +/// The page allocator's span covers the conventional-memory range of the +/// boot memory map; LOAD_BASE must sit inside it (heap ends ~0xbb6c000 +/// with 256 MiB guests; conventional RAM typically ends near 0x0FF00000). +const LOAD_BASE: usize = 0x0C00_0000; // 192 MiB +const LOAD_LIMIT: usize = 0x0F00_0000; // stop before top-of-RAM + +const PAGE_SIZE: usize = 4096; + +/// A loaded image: pages to free on exit + the entry point. +pub struct Image { + /// First allocated page (free this..+page_count on exit). + pub base: usize, + pub page_count: usize, + pub entry: usize, +} + +#[derive(Debug)] +pub enum LoadError { + ReadFailed, + NotPe, + NotPe32Plus, + BadSectionTable, + NoEntryPoint, + AllocFailed, + MapFailed, + BadReloc, + TooBig, +} + +// --------------------------------------------------------------------------- +// PE file-format structures (packed reads via byte slices — no unaligned +// struct pointer derefs). +// --------------------------------------------------------------------------- + +fn u16_at(b: &[u8], off: usize) -> Option { + b.get(off..off + 2).map(|s| u16::from_le_bytes([s[0], s[1]])) +} +fn u32_at(b: &[u8], off: usize) -> Option { + b.get(off..off + 4) + .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} +fn u64_at(b: &[u8], off: usize) -> Option { + b.get(off..off + 8).map(|s| { + let mut t = [0u8; 8]; + t.copy_from_slice(s); + u64::from_le_bytes(t) + }) +} + +const DOS_MAGIC: u16 = 0x5A4D; // "MZ" +const PE_MAGIC: u32 = 0x0000_4550; // "PE\0\0" +const OPT_PE32PLUS: u16 = 0x20B; +const DIR_BASERELOC: usize = 5; + +/// Map an RVA to its raw file offset via the section table. +fn rva_to_file(sections: &[Option
], count: usize, rva: usize) -> Option { + for s in sections.iter().take(count).flatten() { + if rva >= s.virt_addr && rva < s.virt_addr + s.raw_size.max(s.virt_size) { + return Some(s.raw_ptr + (rva - s.virt_addr)); + } + } + None +} + +#[derive(Clone, Copy)] +struct Section { + virt_addr: usize, + virt_size: usize, + raw_ptr: usize, + raw_size: usize, +} + +/// Parse headers, apply relocations into `mem`, return the entry RVA. +fn parse_and_relocate(img: &[u8], mem: usize) -> Result { + // ---- DOS header ---- + if u16_at(img, 0) != Some(DOS_MAGIC) { + return Err(LoadError::NotPe); + } + let e_lfanew = u32_at(img, 0x3C).ok_or(LoadError::NotPe)? as usize; + + // ---- COFF header ---- + if u32_at(img, e_lfanew) != Some(PE_MAGIC) { + return Err(LoadError::NotPe); + } + let coff = e_lfanew + 4; + let num_sections = u16_at(img, coff + 2).ok_or(LoadError::NotPe)? as usize; + let opt_size = u16_at(img, coff + 16).ok_or(LoadError::NotPe)? as usize; + if opt_size == 0 { + return Err(LoadError::NotPe); + } + + // ---- Optional header (PE32+) ---- + let opt = coff + 20; + if u16_at(img, opt) != Some(OPT_PE32PLUS) { + return Err(LoadError::NotPe32Plus); + } + let entry_rva = u32_at(img, opt + 16).ok_or(LoadError::NotPe32Plus)? as usize; + let image_base = u64_at(img, opt + 24).ok_or(LoadError::NotPe32Plus)? as usize; + + let num_dirs = u32_at(img, opt + 108).ok_or(LoadError::NotPe32Plus)? as usize; + let dir_base = opt + 112; + let (reloc_rva, reloc_size) = if num_dirs > DIR_BASERELOC { + ( + u32_at(img, dir_base + DIR_BASERELOC * 8).ok_or(LoadError::BadReloc)? as usize, + u32_at(img, dir_base + DIR_BASERELOC * 8 + 4).ok_or(LoadError::BadReloc)? as usize, + ) + } else { + (0, 0) + }; + + // ---- Section table ---- + let sect_tab = opt + opt_size; + let mut sections: [Option
; 96] = [None; 96]; + if num_sections > sections.len() { + return Err(LoadError::BadSectionTable); + } + for i in 0..num_sections { + let s = sect_tab + i * 40; + let virt_size = u32_at(img, s + 8).ok_or(LoadError::BadSectionTable)? as usize; + let virt_addr = u32_at(img, s + 12).ok_or(LoadError::BadSectionTable)? as usize; + let raw_size = u32_at(img, s + 16).ok_or(LoadError::BadSectionTable)? as usize; + let raw_ptr = u32_at(img, s + 20).ok_or(LoadError::BadSectionTable)? as usize; + sections[i] = Some(Section { + virt_addr, + virt_size, + raw_ptr, + raw_size, + }); + } + + // ---- Copy section contents ---- + for s in sections.iter_mut().take(num_sections).flatten() { + let n = s.raw_size.min(s.virt_size); + if n == 0 { + continue; + } + let src = img.get(s.raw_ptr..s.raw_ptr + n).ok_or(LoadError::BadSectionTable)?; + unsafe { + let dst = (mem + s.virt_addr) as *mut u8; + core::ptr::copy_nonoverlapping(src.as_ptr(), dst, n); + } + } + + // ---- Apply base relocations ---- + if reloc_size > 0 && reloc_rva != 0 { + // The .reloc directory is an RVA; the section table maps it to a + // raw file offset. + let mut off = rva_to_file(§ions, num_sections, reloc_rva).ok_or(LoadError::BadReloc)?; + let end = off + reloc_size; + while off + 8 <= end { + let page_rva = u32_at(img, off).ok_or(LoadError::BadReloc)? as usize; + let block_size = u32_at(img, off + 4).ok_or(LoadError::BadReloc)? as usize; + if block_size < 8 || off + block_size > end { + break; + } + let entries = (block_size - 8) / 2; + for i in 0..entries { + let e = u16_at(img, off + 8 + i * 2).ok_or(LoadError::BadReloc)?; + let r#type = e >> 12; + let pos = e & 0xFFF; + if r#type == 0 { + continue; // IMAGE_REL_BASED_ABSOLUTE — padding + } + let target = mem + page_rva + pos as usize; + match r#type { + 10 => { + // DIR64: 8-bit image base delta + unsafe { + let p = target as *mut u64; + let v = p.read_unaligned(); + p.write_unaligned(v.wrapping_add((mem as u64).wrapping_sub(image_base as u64))); + } + } + _ => return Err(LoadError::BadReloc), + } + } + off += block_size; + } + } + + Ok(entry_rva) +} + +/// Load a PE32+ image from the given bytes. Returns the loaded image +/// (identity-mapped at LOAD_BASE+) with its entry point resolved. +pub fn load_image(bytes: &[u8]) -> Result { + if bytes.len() > 8 * 1024 * 1024 { + return Err(LoadError::TooBig); + } + // Quick header sanity before allocating. + if u16_at(bytes, 0) != Some(DOS_MAGIC) { + return Err(LoadError::NotPe); + } + let e_lfanew = u32_at(bytes, 0x3C).unwrap_or(0) as usize; + if u32_at(bytes, e_lfanew) != Some(PE_MAGIC) { + return Err(LoadError::NotPe); + } + + let page_count = bytes.len().div_ceil(PAGE_SIZE); + // Allocate physical pages at a fixed window (identity mapping makes + // the link address = runtime address after relocation). + let base = crate::memory::alloc_pages_above(page_count, LOAD_BASE).ok_or(LoadError::AllocFailed)?; + if base >= LOAD_LIMIT { + crate::memory::free_pages(base, page_count); + return Err(LoadError::TooBig); + } + // Identity-map them user+writable+nx-default (relocations need write; + // we do not enforce W^X here — single-privilege-level kernel). + if !paging::map_range(base, page_count, paging::PAGE_USER | paging::PAGE_WRITABLE | paging::PAGE_NX) { + crate::memory::free_pages(base, page_count); + return Err(LoadError::MapFailed); + } + + let entry_rva = match parse_and_relocate(bytes, base) { + Ok(e) => e, + Err(e) => { + crate::memory::free_pages(base, page_count); + return Err(e); + } + }; + // Clear BSS tails: sections were copied raw-size bytes each; anything + // between raw_size and virt_size is zero per PE spec. parse_and_relocate + // only copied min(raw,virt); zero-fill the remainder now. + zero_bss_tails(bytes, base); + + Ok(Image { + base, + page_count, + entry: base + entry_rva, + }) +} + +/// Zero the part of each section between raw_size and virt_size (BSS). +fn zero_bss_tails(img: &[u8], mem: usize) { + // Re-walk the section table minimally (headers are small). + let Some(e_lfanew) = u32_at(img, 0x3C) else { return }; + let coff = e_lfanew as usize + 4; + let Some(num_sections) = u16_at(img, coff + 2) else { return }; + let Some(opt_size) = u16_at(img, coff + 16) else { return }; + let sect_tab = coff + 20 + opt_size as usize; + for i in 0..num_sections as usize { + let Some(virt_size) = u32_at(img, sect_tab + i * 40 + 8) else { return }; + let Some(raw_size) = u32_at(img, sect_tab + i * 40 + 16) else { return }; + let Some(virt_addr) = u32_at(img, sect_tab + i * 40 + 12) else { return }; + if virt_size > raw_size { + unsafe { + core::ptr::write_bytes( + (mem + virt_addr as usize + raw_size as usize) as *mut u8, + 0, + virt_size as usize - raw_size as usize, + ); + } + } + } +} + +/// Free a previously loaded image. +/// +/// The task that ran the image must be dead — no code may still execute +/// from or reference these pages. +pub unsafe fn unload_image(img: &Image) { + for i in 0..img.page_count { + let _ = paging::unmap_page(img.base + i * PAGE_SIZE); + } + crate::memory::free_pages(img.base, img.page_count); +} diff --git a/rsnix/src/sched.rs b/rsnix/src/sched.rs index 0c4938e..107dba6 100644 --- a/rsnix/src/sched.rs +++ b/rsnix/src/sched.rs @@ -396,6 +396,31 @@ pub fn spawn(entry: fn(), name: &str) -> Option { if ok { Some(id) } else { None } } +/// Reserve an empty task slot without installing a frame. The slot is +/// marked Ready-with-no-frame is NOT valid to schedule — callers must +/// follow with `launch_at` before yielding (exec does both back-to-back +/// under the preemption lock window of the caller). +pub fn reserve_slot() -> Option { + lock(); + let id = unsafe { + let mut slot = None; + for i in 2..MAX_TASKS { + if TASKS[i].state == TaskState::Done && TASKS[i].stack != 0 { + reap(i); + slot = Some(i); + break; + } + if TASKS[i].state == TaskState::Done && TASKS[i].stack == 0 && TASKS[i].name_len == 0 { + slot = Some(i); + break; + } + } + slot + }; + unlock(); + id +} + unsafe fn reap(id: usize) { unsafe { let t = &mut TASKS[id]; @@ -408,6 +433,19 @@ unsafe fn reap(id: usize) { } } +/// Install the initial frame for a reserved slot and mark it Ready. +/// The slot must have come from `reserve_slot` and must not have been +/// scheduled in between (exec stores its context between the two calls). +pub fn launch_at(id: usize, entry: fn(), name: &str) -> bool { + if id >= MAX_TASKS { + return false; + } + lock(); + let ok = spawn_at(id, entry, name); + unlock(); + ok +} + fn spawn_at(id: usize, entry: fn(), name: &str) -> bool { let Some(base) = memory::alloc_pages(STACK_PAGES) else { return false; @@ -483,7 +521,7 @@ unsafe extern "C" fn trampoline(entry: usize) -> ! { task_exit() } -fn task_exit() -> ! { +pub fn task_exit() -> ! { lock(); unsafe { TASKS[CURRENT].state = TaskState::Done; @@ -648,6 +686,12 @@ pub fn abort_current(msg: &str) -> ! { TASKS[id].stack, TASKS[id].guard ); + // An exec'd binary died: unblock the waiting spawner with a + // completion message (exit code -1 encoded as u64::MAX). The + // shell's queue is the only waiter; best-effort by design. + if id >= 2 { + let _ = push_msg(SHELL_TID, TaskMsg::User(u64::MAX)); + } PREEMPT_DEPTH.store(0, Ordering::Relaxed); } yield_int(); -- cgit v1.2.3