summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-21 21:17:44 +0200
committersalmonik <salmonik@tuta.io>2026-08-21 21:17:44 +0200
commit71b6445b048456bf198d8b622570db95f4665cb9 (patch)
tree808e73fa95709ed9c2d409aecaf275fd70365293
parentinput: total keyboard routing + per-terminal discipline (ADR 0006 ph1) (diff)
downloadrs-dos-71b6445b048456bf198d8b622570db95f4665cb9.tar.gz
rs-dos-71b6445b048456bf198d8b622570db95f4665cb9.zip
exec: PE32+ loader, versioned ABI, first external binaries (v0.6.0)
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/<UID>/bin/<kind>/<name>.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.
-rw-r--r--Cargo.lock28
-rw-r--r--Cargo.toml4
-rw-r--r--bins/boom/Cargo.toml10
-rw-r--r--bins/boom/src/main.rs31
-rw-r--r--bins/hello/Cargo.toml10
-rw-r--r--bins/hello/src/main.rs33
-rw-r--r--rsapi/Cargo.toml7
-rw-r--r--rsapi/src/lib.rs86
-rw-r--r--rsnix/Cargo.toml3
-rw-r--r--rsnix/src/exec.rs137
-rw-r--r--rsnix/src/lib.rs2
-rw-r--r--rsnix/src/load.rs283
-rw-r--r--rsnix/src/sched.rs46
-rw-r--r--shell/Cargo.toml1
-rw-r--r--shell/src/lib.rs111
15 files changed, 786 insertions, 6 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 24b6a35..ed37b5f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3,8 +3,26 @@
3version = 4 3version = 4
4 4
5[[package]] 5[[package]]
6name = "boom"
7version = "0.6.0"
8dependencies = [
9 "rsapi",
10]
11
12[[package]]
13name = "hello"
14version = "0.6.0"
15dependencies = [
16 "rsapi",
17]
18
19[[package]]
20name = "rsapi"
21version = "0.6.0"
22
23[[package]]
6name = "rsdos" 24name = "rsdos"
7version = "0.5.11" 25version = "0.6.0"
8dependencies = [ 26dependencies = [
9 "rsnix", 27 "rsnix",
10 "shell", 28 "shell",
@@ -19,7 +37,10 @@ dependencies = [
19 37
20[[package]] 38[[package]]
21name = "rsnix" 39name = "rsnix"
22version = "0.5.11" 40version = "0.6.0"
41dependencies = [
42 "rsapi",
43]
23 44
24[[package]] 45[[package]]
25name = "rspaint" 46name = "rspaint"
@@ -30,8 +51,9 @@ dependencies = [
30 51
31[[package]] 52[[package]]
32name = "shell" 53name = "shell"
33version = "0.5.11" 54version = "0.6.0"
34dependencies = [ 55dependencies = [
56 "rsapi",
35 "rsdosedit", 57 "rsdosedit",
36 "rsnix", 58 "rsnix",
37 "rspaint", 59 "rspaint",
diff --git a/Cargo.toml b/Cargo.toml
index 26e5874..e8e0827 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,9 +1,9 @@
1[workspace] 1[workspace]
2resolver = "2" 2resolver = "2"
3members = ["rsnix", "shell", "rsdos", "rsdosedit", "rspaint"] 3members = ["rsnix", "shell", "rsdos", "rsdosedit", "rspaint", "rsapi", "bins/hello", "bins/boom"]
4 4
5[workspace.package] 5[workspace.package]
6version = "0.5.11" 6version = "0.6.0"
7edition = "2024" 7edition = "2024"
8 8
9# no_std UEFI kernel: no unwinder available, so panic must abort. 9# no_std UEFI kernel: no unwinder available, so panic must abort.
diff --git a/bins/boom/Cargo.toml b/bins/boom/Cargo.toml
new file mode 100644
index 0000000..2d31886
--- /dev/null
+++ b/bins/boom/Cargo.toml
@@ -0,0 +1,10 @@
1[package]
2name = "boom"
3version.workspace = true
4edition.workspace = true
5
6[lints]
7workspace = true
8
9[dependencies]
10rsapi = { path = "../../rsapi" }
diff --git a/bins/boom/src/main.rs b/bins/boom/src/main.rs
new file mode 100644
index 0000000..0c3201b
--- /dev/null
+++ b/bins/boom/src/main.rs
@@ -0,0 +1,31 @@
1//! boom — crash-containment probe (ADR 0006 Phase 2 gate).
2//!
3//! Deliberately faults on an unmapped address. The kernel must kill only
4//! this task; the shell survives and `jobs` shows the corpse.
5
6#![no_std]
7#![no_main]
8
9#[panic_handler]
10fn panic(_info: &core::panic::PanicInfo) -> ! {
11 loop {
12 core::hint::spin_loop();
13 }
14}
15
16/// The image entry point — the loader jumps here with the ABI arguments.
17#[unsafe(no_mangle)]
18pub extern "C" fn efi_main(
19 _exports: &'static rsapi::Exports,
20 _argc: usize,
21 _argv: *const rsapi::Arg,
22) -> i32 {
23 // Canonical-but-unmapped address (below 2^47, above any RAM/MMIO):
24 // the page walk terminates at a not-present entry → #PF with
25 // CR2 = address. Same trick as the built-in `fault` command.
26 unsafe {
27 let p = 0x4000_0000_0000usize as *mut u64;
28 p.write_volatile(0xDEAD_BEEF);
29 }
30 0
31}
diff --git a/bins/hello/Cargo.toml b/bins/hello/Cargo.toml
new file mode 100644
index 0000000..ac71ded
--- /dev/null
+++ b/bins/hello/Cargo.toml
@@ -0,0 +1,10 @@
1[package]
2name = "hello"
3version.workspace = true
4edition.workspace = true
5
6[lints]
7workspace = true
8
9[dependencies]
10rsapi = { path = "../../rsapi" }
diff --git a/bins/hello/src/main.rs b/bins/hello/src/main.rs
new file mode 100644
index 0000000..d1d58df
--- /dev/null
+++ b/bins/hello/src/main.rs
@@ -0,0 +1,33 @@
1//! hello — the first rs-dos external binary (ADR 0006 Phase 2 gate).
2//!
3//! Prints one line through the injected Exports table and exits 0.
4//! Exercises the whole chain: PE32+ load, relocation, ABI call, task exit.
5
6#![no_std]
7#![no_main]
8
9use rsapi::Exports;
10
11#[panic_handler]
12fn panic(_info: &core::panic::PanicInfo) -> ! {
13 loop {
14 core::hint::spin_loop();
15 }
16}
17
18/// The image entry point — the loader jumps here with the ABI arguments
19/// (exports table, argc, argv). The conventional UEFI name is kept so
20/// the PE looks normal; the signature is the rs-dos ABI.
21#[unsafe(no_mangle)]
22pub extern "C" fn efi_main(exports: &'static Exports, _argc: usize, _argv: *const rsapi::Arg) -> i32 {
23 let msg = b"hello, world - from an external binary";
24 let line = rsapi::Line {
25 ptr: msg.as_ptr(),
26 len: msg.len(),
27 };
28 if (exports.put_line)(line) {
29 0
30 } else {
31 1
32 }
33}
diff --git a/rsapi/Cargo.toml b/rsapi/Cargo.toml
new file mode 100644
index 0000000..fb43004
--- /dev/null
+++ b/rsapi/Cargo.toml
@@ -0,0 +1,7 @@
1[package]
2name = "rsapi"
3version.workspace = true
4edition.workspace = true
5
6[lints]
7workspace = true
diff --git a/rsapi/src/lib.rs b/rsapi/src/lib.rs
new file mode 100644
index 0000000..f71e69f
--- /dev/null
+++ b/rsapi/src/lib.rs
@@ -0,0 +1,86 @@
1//! rsapi — the kernel↔binary ABI (ADR 0006).
2//!
3//! One versioned `Exports` table is constructed per invocation and handed
4//! to the binary's entry point. The header starts with a magic and a
5//! `version: u32` so a future kernel can keep old binaries working
6//! (dispatch on the version, never on struct layout).
7//!
8//! Everything here is `no_std`-clean: plain structs and function pointers,
9//! no allocation, no rsnix dependency — binaries link only this crate.
10
11#![no_std]
12
13/// Table magic: "RSEX".
14pub const EXPORTS_MAGIC: u32 = 0x5245_5853;
15
16/// ABI revision. Bump on any signature change; the kernel refuses to run
17/// binaries whose required major exceeds its own.
18pub const ABI_VERSION: u32 = 1;
19
20/// Entry-point signature. `extern "C"` on the x86_64-unknown-uefi
21/// target is the Win64 convention: the three arguments arrive in
22/// RCX/RDX/R8 on both sides of the table.
23pub type EntryPoint = extern "C" fn(exports: &'static Exports, argc: usize, argv: *const Arg) -> i32;
24
25/// One C-style argv element: pointer + length (rs-dos strings are not
26/// NUL-terminated in memory).
27#[derive(Clone, Copy)]
28pub struct Arg {
29 pub ptr: *const u8,
30 pub len: usize,
31}
32
33/// A line of text handed across the ABI (stream I/O).
34#[derive(Clone, Copy)]
35pub struct Line {
36 pub ptr: *const u8,
37 pub len: usize,
38}
39
40impl Line {
41 /// Borrow as a &str (lossy is impossible: lines are always UTF-8).
42 pub fn as_str(&self) -> &'static str {
43 unsafe { core::str::from_utf8_unchecked(core::slice::from_raw_parts(self.ptr, self.len)) }
44 }
45}
46
47/// Outcome of `get_line`.
48#[derive(Clone, Copy, PartialEq, Eq)]
49pub enum StreamResult {
50 /// A line is available (see `out`).
51 Line,
52 /// Input exhausted.
53 Eof,
54 /// Aborted (^C or upstream killed); stop immediately.
55 Abort,
56}
57
58// ---------------------------------------------------------------------------
59// Exports table — v1
60// ---------------------------------------------------------------------------
61
62/// The function table a binary receives. Layout is fixed by `version`;
63/// new functions are appended, existing ones never move.
64#[repr(C)]
65pub struct Exports {
66 // ---- header ----
67 pub magic: u32,
68 pub version: u32,
69
70 // ---- console / streams (v1) ----
71 /// Print one line to the command's output stream.
72 /// Returns false when the consumer says stop (pager quit / head limit /
73 /// ^C) — the binary should wrap up promptly.
74 pub put_line: fn(line: Line) -> bool,
75 /// Read one line from the command's input stream (only meaningful for
76 /// input consumers like cat/hexdump; otherwise returns Eof).
77 /// `out` receives the line when the result is `StreamResult::Line`.
78 pub get_line: fn(out: &mut Line) -> StreamResult,
79
80 // ---- lifecycle (v1) ----
81 /// Log one line to the kernel log ring (/dev/klog).
82 pub log_line: fn(line: Line),
83 /// Exit code for the completion message to the spawner.
84 /// (Calling this is optional; returning from the entry point exits 0.)
85 pub exit: fn(code: i32) -> !,
86}
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
5 5
6[lints] 6[lints]
7workspace = true 7workspace = true
8
9[dependencies]
10rsapi = { 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 @@
1// ---------------------------------------------------------------------------
2// exec — run an external binary (ADR 0006 Phase 2).
3//
4// Resolution: name → \bin\<kind>\<name> on each mounted volume → None.
5// The shell calls exec::spawn with a fully constructed Exports table
6// (streams point at its pipeline/tty) and clean argv. The binary runs as
7// its own task; on exit it posts TaskMsg::User(exit_code) to the spawner.
8//
9// The Exports table and argv block are heap boxes moved into static slots
10// indexed by task id — the trampoline adopts them before jumping to the
11// image entry. One slot per MAX_TASKS; spawn fails if the slot is busy.
12// ---------------------------------------------------------------------------
13use crate::load;
14use crate::sched::{self, TaskMsg};
15use alloc::string::String;
16use alloc::vec::Vec;
17use rsapi::{Arg, EntryPoint, Exports};
18
19/// Per-task invocation context adopted by the trampoline.
20struct ExecCtx {
21 exports: &'static Exports,
22 /// Heap block holding the argv strings (kept alive until task exit —
23 /// the Arg pointers reference into it).
24 #[allow(dead_code)]
25 argv_buf: Vec<u8>,
26 args: Vec<Arg>,
27 /// Image pages to free when the task dies.
28 image: load::Image,
29}
30
31static mut CTX: [Option<ExecCtx>; sched::MAX_TASKS] = [const { None }; sched::MAX_TASKS];
32
33/// Error of exec::resolve / exec::spawn.
34pub enum ExecError {
35 NotFound,
36 LoadFailed(&'static str),
37 Busy,
38}
39
40/// Scan every mounted volume for `\bin\<kind>\<name>` (LFN exact match).
41/// Returns the VFS handle of the first hit.
42pub fn resolve(kind: &str, name: &str) -> Option<usize> {
43 for vol in 0..crate::fat::volume_count() {
44 if !crate::fat::is_mounted(vol) {
45 continue;
46 }
47 let uid = String::from(crate::fat::uid(vol));
48 let path = alloc::format!("/disk/{}/bin/{}/{}.efi", uid, kind, name);
49 if let Some(h) = crate::fs::resolve(&path) {
50 return Some(h);
51 }
52 }
53 None
54}
55
56/// Read the whole file into a heap buffer (public: the shell uses this
57/// to fetch binary bytes before calling load::load_image).
58#[allow(dead_code)]
59pub fn read_file(h: usize) -> Result<Vec<u8>, ()> {
60 let size = crate::fs::size(h) as usize;
61 let mut buf = Vec::with_capacity(size);
62 let mut chunk = [0u8; 512];
63 let mut pos = 0u64;
64 loop {
65 let n = crate::fs::read(h, &mut chunk, pos)?;
66 if n == 0 {
67 break;
68 }
69 buf.extend_from_slice(&chunk[..n]);
70 pos += n as u64;
71 }
72 Ok(buf)
73}
74
75/// Spawn `image` as a new task running entry(exports, argc, argv).
76///
77/// The caller keeps `exports` alive (it is constructed per invocation in
78/// shell-owned memory that outlives the command — the pipeline/tty live
79/// in the blocked spawner).
80pub fn spawn(
81 image: load::Image,
82 exports: &'static Exports,
83 argv: &[String],
84 name: &str,
85) -> Result<usize, ExecError> {
86 // Copy the argv strings into one contiguous block owned by the task.
87 let mut argv_buf: Vec<u8> = Vec::new();
88 let mut args: Vec<Arg> = Vec::with_capacity(argv.len());
89 for a in argv {
90 let off = argv_buf.len();
91 argv_buf.extend_from_slice(a.as_bytes());
92 args.push(Arg {
93 ptr: unsafe { argv_buf.as_ptr().add(off) },
94 len: a.len(),
95 });
96 }
97
98 // Reserve a task slot first so the context is in place before the
99 // task can possibly run (spawn may schedule immediately).
100 let id = sched::reserve_slot().ok_or(ExecError::Busy)?;
101 unsafe {
102 CTX[id] = Some(ExecCtx {
103 exports,
104 argv_buf,
105 args,
106 image,
107 });
108 }
109 if !sched::launch_at(id, trampoline, name) {
110 unsafe {
111 CTX[id] = None;
112 }
113 return Err(ExecError::Busy);
114 }
115 Ok(id)
116}
117
118/// Trampoline: adopt the exec context, call the entry, report completion.
119fn trampoline() {
120 let id = sched::current();
121 let taken = unsafe {
122 let ctx_ptr = core::ptr::addr_of_mut!(CTX);
123 (*ctx_ptr)[id].take()
124 };
125 let Some(ctx) = taken else {
126 sched::abort_current("exec: no context")
127 };
128 // Call the image entry with the table; the return value is the exit code.
129 let code = unsafe {
130 let f: EntryPoint = core::mem::transmute(ctx.image.entry);
131 f(ctx.exports, ctx.args.len(), ctx.args.as_ptr())
132 };
133 // Report completion to the spawner (the shell), then die. The spawner
134 // frees the image pages after reaping the message.
135 let _ = sched::send(sched::SHELL_TID, TaskMsg::User(code as u64));
136 drop(ctx);
137}
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;
12pub mod gop; 12pub mod gop;
13pub mod interrupts; 13pub mod interrupts;
14pub mod klog; 14pub mod klog;
15pub mod exec;
16pub mod load;
15pub mod memory; 17pub mod memory;
16pub mod paging; 18pub mod paging;
17pub mod pci; 19pub 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 @@
1// ---------------------------------------------------------------------------
2// load — PE32+ executable loader (ADR 0006 Phase 2).
3//
4// Parses a PE32+ image read through the VFS, allocates identity-mapped
5// pages for it (the kernel runs identity-mapped, so "user" code lives at
6// its link address), applies base relocations, and returns the entry
7// point. The spawn path (exec.rs glue in sched) builds an Exports table,
8// copies the argv block into the image's memory, and starts a task whose
9// trampoline calls entry(&Exports, argc, argv).
10//
11// Scope: what rustc/lld emits for a no_std UEFI-ish binary — DOS header,
12// COFF header, optional header PE32+, section table, .reloc base
13// relocations. No imports, no TLS, no exceptions.
14// ---------------------------------------------------------------------------
15use crate::paging;
16
17/// Where binaries are loaded: above the heap, inside conventional RAM.
18/// The page allocator's span covers the conventional-memory range of the
19/// boot memory map; LOAD_BASE must sit inside it (heap ends ~0xbb6c000
20/// with 256 MiB guests; conventional RAM typically ends near 0x0FF00000).
21const LOAD_BASE: usize = 0x0C00_0000; // 192 MiB
22const LOAD_LIMIT: usize = 0x0F00_0000; // stop before top-of-RAM
23
24const PAGE_SIZE: usize = 4096;
25
26/// A loaded image: pages to free on exit + the entry point.
27pub struct Image {
28 /// First allocated page (free this..+page_count on exit).
29 pub base: usize,
30 pub page_count: usize,
31 pub entry: usize,
32}
33
34#[derive(Debug)]
35pub enum LoadError {
36 ReadFailed,
37 NotPe,
38 NotPe32Plus,
39 BadSectionTable,
40 NoEntryPoint,
41 AllocFailed,
42 MapFailed,
43 BadReloc,
44 TooBig,
45}
46
47// ---------------------------------------------------------------------------
48// PE file-format structures (packed reads via byte slices — no unaligned
49// struct pointer derefs).
50// ---------------------------------------------------------------------------
51
52fn u16_at(b: &[u8], off: usize) -> Option<u16> {
53 b.get(off..off + 2).map(|s| u16::from_le_bytes([s[0], s[1]]))
54}
55fn u32_at(b: &[u8], off: usize) -> Option<u32> {
56 b.get(off..off + 4)
57 .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
58}
59fn u64_at(b: &[u8], off: usize) -> Option<u64> {
60 b.get(off..off + 8).map(|s| {
61 let mut t = [0u8; 8];
62 t.copy_from_slice(s);
63 u64::from_le_bytes(t)
64 })
65}
66
67const DOS_MAGIC: u16 = 0x5A4D; // "MZ"
68const PE_MAGIC: u32 = 0x0000_4550; // "PE\0\0"
69const OPT_PE32PLUS: u16 = 0x20B;
70const DIR_BASERELOC: usize = 5;
71
72/// Map an RVA to its raw file offset via the section table.
73fn rva_to_file(sections: &[Option<Section>], count: usize, rva: usize) -> Option<usize> {
74 for s in sections.iter().take(count).flatten() {
75 if rva >= s.virt_addr && rva < s.virt_addr + s.raw_size.max(s.virt_size) {
76 return Some(s.raw_ptr + (rva - s.virt_addr));
77 }
78 }
79 None
80}
81
82#[derive(Clone, Copy)]
83struct Section {
84 virt_addr: usize,
85 virt_size: usize,
86 raw_ptr: usize,
87 raw_size: usize,
88}
89
90/// Parse headers, apply relocations into `mem`, return the entry RVA.
91fn parse_and_relocate(img: &[u8], mem: usize) -> Result<usize, LoadError> {
92 // ---- DOS header ----
93 if u16_at(img, 0) != Some(DOS_MAGIC) {
94 return Err(LoadError::NotPe);
95 }
96 let e_lfanew = u32_at(img, 0x3C).ok_or(LoadError::NotPe)? as usize;
97
98 // ---- COFF header ----
99 if u32_at(img, e_lfanew) != Some(PE_MAGIC) {
100 return Err(LoadError::NotPe);
101 }
102 let coff = e_lfanew + 4;
103 let num_sections = u16_at(img, coff + 2).ok_or(LoadError::NotPe)? as usize;
104 let opt_size = u16_at(img, coff + 16).ok_or(LoadError::NotPe)? as usize;
105 if opt_size == 0 {
106 return Err(LoadError::NotPe);
107 }
108
109 // ---- Optional header (PE32+) ----
110 let opt = coff + 20;
111 if u16_at(img, opt) != Some(OPT_PE32PLUS) {
112 return Err(LoadError::NotPe32Plus);
113 }
114 let entry_rva = u32_at(img, opt + 16).ok_or(LoadError::NotPe32Plus)? as usize;
115 let image_base = u64_at(img, opt + 24).ok_or(LoadError::NotPe32Plus)? as usize;
116
117 let num_dirs = u32_at(img, opt + 108).ok_or(LoadError::NotPe32Plus)? as usize;
118 let dir_base = opt + 112;
119 let (reloc_rva, reloc_size) = if num_dirs > DIR_BASERELOC {
120 (
121 u32_at(img, dir_base + DIR_BASERELOC * 8).ok_or(LoadError::BadReloc)? as usize,
122 u32_at(img, dir_base + DIR_BASERELOC * 8 + 4).ok_or(LoadError::BadReloc)? as usize,
123 )
124 } else {
125 (0, 0)
126 };
127
128 // ---- Section table ----
129 let sect_tab = opt + opt_size;
130 let mut sections: [Option<Section>; 96] = [None; 96];
131 if num_sections > sections.len() {
132 return Err(LoadError::BadSectionTable);
133 }
134 for i in 0..num_sections {
135 let s = sect_tab + i * 40;
136 let virt_size = u32_at(img, s + 8).ok_or(LoadError::BadSectionTable)? as usize;
137 let virt_addr = u32_at(img, s + 12).ok_or(LoadError::BadSectionTable)? as usize;
138 let raw_size = u32_at(img, s + 16).ok_or(LoadError::BadSectionTable)? as usize;
139 let raw_ptr = u32_at(img, s + 20).ok_or(LoadError::BadSectionTable)? as usize;
140 sections[i] = Some(Section {
141 virt_addr,
142 virt_size,
143 raw_ptr,
144 raw_size,
145 });
146 }
147
148 // ---- Copy section contents ----
149 for s in sections.iter_mut().take(num_sections).flatten() {
150 let n = s.raw_size.min(s.virt_size);
151 if n == 0 {
152 continue;
153 }
154 let src = img.get(s.raw_ptr..s.raw_ptr + n).ok_or(LoadError::BadSectionTable)?;
155 unsafe {
156 let dst = (mem + s.virt_addr) as *mut u8;
157 core::ptr::copy_nonoverlapping(src.as_ptr(), dst, n);
158 }
159 }
160
161 // ---- Apply base relocations ----
162 if reloc_size > 0 && reloc_rva != 0 {
163 // The .reloc directory is an RVA; the section table maps it to a
164 // raw file offset.
165 let mut off = rva_to_file(&sections, num_sections, reloc_rva).ok_or(LoadError::BadReloc)?;
166 let end = off + reloc_size;
167 while off + 8 <= end {
168 let page_rva = u32_at(img, off).ok_or(LoadError::BadReloc)? as usize;
169 let block_size = u32_at(img, off + 4).ok_or(LoadError::BadReloc)? as usize;
170 if block_size < 8 || off + block_size > end {
171 break;
172 }
173 let entries = (block_size - 8) / 2;
174 for i in 0..entries {
175 let e = u16_at(img, off + 8 + i * 2).ok_or(LoadError::BadReloc)?;
176 let r#type = e >> 12;
177 let pos = e & 0xFFF;
178 if r#type == 0 {
179 continue; // IMAGE_REL_BASED_ABSOLUTE — padding
180 }
181 let target = mem + page_rva + pos as usize;
182 match r#type {
183 10 => {
184 // DIR64: 8-bit image base delta
185 unsafe {
186 let p = target as *mut u64;
187 let v = p.read_unaligned();
188 p.write_unaligned(v.wrapping_add((mem as u64).wrapping_sub(image_base as u64)));
189 }
190 }
191 _ => return Err(LoadError::BadReloc),
192 }
193 }
194 off += block_size;
195 }
196 }
197
198 Ok(entry_rva)
199}
200
201/// Load a PE32+ image from the given bytes. Returns the loaded image
202/// (identity-mapped at LOAD_BASE+) with its entry point resolved.
203pub fn load_image(bytes: &[u8]) -> Result<Image, LoadError> {
204 if bytes.len() > 8 * 1024 * 1024 {
205 return Err(LoadError::TooBig);
206 }
207 // Quick header sanity before allocating.
208 if u16_at(bytes, 0) != Some(DOS_MAGIC) {
209 return Err(LoadError::NotPe);
210 }
211 let e_lfanew = u32_at(bytes, 0x3C).unwrap_or(0) as usize;
212 if u32_at(bytes, e_lfanew) != Some(PE_MAGIC) {
213 return Err(LoadError::NotPe);
214 }
215
216 let page_count = bytes.len().div_ceil(PAGE_SIZE);
217 // Allocate physical pages at a fixed window (identity mapping makes
218 // the link address = runtime address after relocation).
219 let base = crate::memory::alloc_pages_above(page_count, LOAD_BASE).ok_or(LoadError::AllocFailed)?;
220 if base >= LOAD_LIMIT {
221 crate::memory::free_pages(base, page_count);
222 return Err(LoadError::TooBig);
223 }
224 // Identity-map them user+writable+nx-default (relocations need write;
225 // we do not enforce W^X here — single-privilege-level kernel).
226 if !paging::map_range(base, page_count, paging::PAGE_USER | paging::PAGE_WRITABLE | paging::PAGE_NX) {
227 crate::memory::free_pages(base, page_count);
228 return Err(LoadError::MapFailed);
229 }
230
231 let entry_rva = match parse_and_relocate(bytes, base) {
232 Ok(e) => e,
233 Err(e) => {
234 crate::memory::free_pages(base, page_count);
235 return Err(e);
236 }
237 };
238 // Clear BSS tails: sections were copied raw-size bytes each; anything
239 // between raw_size and virt_size is zero per PE spec. parse_and_relocate
240 // only copied min(raw,virt); zero-fill the remainder now.
241 zero_bss_tails(bytes, base);
242
243 Ok(Image {
244 base,
245 page_count,
246 entry: base + entry_rva,
247 })
248}
249
250/// Zero the part of each section between raw_size and virt_size (BSS).
251fn zero_bss_tails(img: &[u8], mem: usize) {
252 // Re-walk the section table minimally (headers are small).
253 let Some(e_lfanew) = u32_at(img, 0x3C) else { return };
254 let coff = e_lfanew as usize + 4;
255 let Some(num_sections) = u16_at(img, coff + 2) else { return };
256 let Some(opt_size) = u16_at(img, coff + 16) else { return };
257 let sect_tab = coff + 20 + opt_size as usize;
258 for i in 0..num_sections as usize {
259 let Some(virt_size) = u32_at(img, sect_tab + i * 40 + 8) else { return };
260 let Some(raw_size) = u32_at(img, sect_tab + i * 40 + 16) else { return };
261 let Some(virt_addr) = u32_at(img, sect_tab + i * 40 + 12) else { return };
262 if virt_size > raw_size {
263 unsafe {
264 core::ptr::write_bytes(
265 (mem + virt_addr as usize + raw_size as usize) as *mut u8,
266 0,
267 virt_size as usize - raw_size as usize,
268 );
269 }
270 }
271 }
272}
273
274/// Free a previously loaded image.
275///
276/// The task that ran the image must be dead — no code may still execute
277/// from or reference these pages.
278pub unsafe fn unload_image(img: &Image) {
279 for i in 0..img.page_count {
280 let _ = paging::unmap_page(img.base + i * PAGE_SIZE);
281 }
282 crate::memory::free_pages(img.base, img.page_count);
283}
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<usize> {
396 if ok { Some(id) } else { None } 396 if ok { Some(id) } else { None }
397} 397}
398 398
399/// Reserve an empty task slot without installing a frame. The slot is
400/// marked Ready-with-no-frame is NOT valid to schedule — callers must
401/// follow with `launch_at` before yielding (exec does both back-to-back
402/// under the preemption lock window of the caller).
403pub fn reserve_slot() -> Option<usize> {
404 lock();
405 let id = unsafe {
406 let mut slot = None;
407 for i in 2..MAX_TASKS {
408 if TASKS[i].state == TaskState::Done && TASKS[i].stack != 0 {
409 reap(i);
410 slot = Some(i);
411 break;
412 }
413 if TASKS[i].state == TaskState::Done && TASKS[i].stack == 0 && TASKS[i].name_len == 0 {
414 slot = Some(i);
415 break;
416 }
417 }
418 slot
419 };
420 unlock();
421 id
422}
423
399unsafe fn reap(id: usize) { 424unsafe fn reap(id: usize) {
400 unsafe { 425 unsafe {
401 let t = &mut TASKS[id]; 426 let t = &mut TASKS[id];
@@ -408,6 +433,19 @@ unsafe fn reap(id: usize) {
408 } 433 }
409} 434}
410 435
436/// Install the initial frame for a reserved slot and mark it Ready.
437/// The slot must have come from `reserve_slot` and must not have been
438/// scheduled in between (exec stores its context between the two calls).
439pub fn launch_at(id: usize, entry: fn(), name: &str) -> bool {
440 if id >= MAX_TASKS {
441 return false;
442 }
443 lock();
444 let ok = spawn_at(id, entry, name);
445 unlock();
446 ok
447}
448
411fn spawn_at(id: usize, entry: fn(), name: &str) -> bool { 449fn spawn_at(id: usize, entry: fn(), name: &str) -> bool {
412 let Some(base) = memory::alloc_pages(STACK_PAGES) else { 450 let Some(base) = memory::alloc_pages(STACK_PAGES) else {
413 return false; 451 return false;
@@ -483,7 +521,7 @@ unsafe extern "C" fn trampoline(entry: usize) -> ! {
483 task_exit() 521 task_exit()
484} 522}
485 523
486fn task_exit() -> ! { 524pub fn task_exit() -> ! {
487 lock(); 525 lock();
488 unsafe { 526 unsafe {
489 TASKS[CURRENT].state = TaskState::Done; 527 TASKS[CURRENT].state = TaskState::Done;
@@ -648,6 +686,12 @@ pub fn abort_current(msg: &str) -> ! {
648 TASKS[id].stack, 686 TASKS[id].stack,
649 TASKS[id].guard 687 TASKS[id].guard
650 ); 688 );
689 // An exec'd binary died: unblock the waiting spawner with a
690 // completion message (exit code -1 encoded as u64::MAX). The
691 // shell's queue is the only waiter; best-effort by design.
692 if id >= 2 {
693 let _ = push_msg(SHELL_TID, TaskMsg::User(u64::MAX));
694 }
651 PREEMPT_DEPTH.store(0, Ordering::Relaxed); 695 PREEMPT_DEPTH.store(0, Ordering::Relaxed);
652 } 696 }
653 yield_int(); 697 yield_int();
diff --git a/shell/Cargo.toml b/shell/Cargo.toml
index f95b574..b59dfec 100644
--- a/shell/Cargo.toml
+++ b/shell/Cargo.toml
@@ -8,5 +8,6 @@ workspace = true
8 8
9[dependencies] 9[dependencies]
10rsnix = { path = "../rsnix" } 10rsnix = { path = "../rsnix" }
11rsapi = { path = "../rsapi" }
11rsdosedit = { path = "../rsdosedit" } 12rsdosedit = { path = "../rsdosedit" }
12rspaint = { path = "../rspaint" } 13rspaint = { path = "../rspaint" }
diff --git a/shell/src/lib.rs b/shell/src/lib.rs
index d1710cc..9ca999e 100644
--- a/shell/src/lib.rs
+++ b/shell/src/lib.rs
@@ -745,6 +745,15 @@ write `{} <path> TIL <marker>`",
745 } 745 }
746 } 746 }
747 747
748 // Resolution rule (ADR 0006): \bin\<kind>\<name> first, else the
749 // built-in. The frozen core (help/clear/echo/poweroff/exit/halt/
750 // rescan) is exempt — it exists to fix the system and never migrates.
751 if !matches!(cmd, "help" | "clear" | "echo" | "halt" | "poweroff" | "exit" | "rescan")
752 && exec_external(&parsed)
753 {
754 return;
755 }
756
748 match cmd { 757 match cmd {
749 "help" => cmd_help(&parsed), 758 "help" => cmd_help(&parsed),
750 "echo" => cmd_echo(&parsed), 759 "echo" => cmd_echo(&parsed),
@@ -792,6 +801,108 @@ write `{} <path> TIL <marker>`",
792} 801}
793 802
794// --------------------------------------------------------------------------- 803// ---------------------------------------------------------------------------
804// External binaries (ADR 0006 Phase 2)
805//
806// Resolution: \bin\cli\<name> on each mounted volume; else "unknown
807// command". The Exports table is constructed per invocation: v1 wires
808// put_line to the active terminal and get_line to EOF (stream injection
809// through the filter pipeline lands in Phase 3).
810// ---------------------------------------------------------------------------
811
812static mut LAST_EXIT_CODE: i32 = 0;
813
814/// Last exit code of an external command ($? equivalent for scripts).
815pub fn last_exit_code() -> i32 {
816 unsafe { core::ptr::addr_of!(LAST_EXIT_CODE).read() }
817}
818
819/// ABI callback: print one line to the invoking terminal.
820fn abi_put_line(line: rsapi::Line) -> bool {
821 term_println(line.as_str());
822 true
823}
824
825/// ABI callback: read one input line — no stream source wired yet (Phase 3).
826fn abi_get_line(_out: &mut rsapi::Line) -> rsapi::StreamResult {
827 rsapi::StreamResult::Eof
828}
829
830/// ABI callback: kernel log.
831fn abi_log_line(line: rsapi::Line) {
832 rsnix::klog::append(line.as_str(), 0);
833}
834
835/// ABI callback: exit with a code.
836fn abi_exit(code: i32) -> ! {
837 unsafe {
838 LAST_EXIT_CODE = code;
839 }
840 // Unwind the binary by longjmp-style task abort: the trampoline never
841 // returns here. We report the code via the static and kill the task.
842 rsnix::sched::abort_current("exit")
843}
844
845static EXPORTS_V1: rsapi::Exports = rsapi::Exports {
846 magic: rsapi::EXPORTS_MAGIC,
847 version: rsapi::ABI_VERSION,
848 put_line: abi_put_line,
849 get_line: abi_get_line,
850 log_line: abi_log_line,
851 exit: abi_exit,
852};
853
854/// Run `cmd` as an external PE32+ binary from /bin/cli/. Returns false
855/// when no external binary resolved (the caller falls back to built-ins).
856fn exec_external(p: &Parsed) -> bool {
857 let Some(h) = rsnix::exec::resolve("cli", &p.cmd) else {
858 return false;
859 };
860 let bytes = match rsnix::exec::read_file(h) {
861 Ok(b) => b,
862 Err(_) => {
863 term_err(&alloc::format!("{}: read failed", p.cmd));
864 return true;
865 }
866 };
867 let image = match rsnix::load::load_image(&bytes) {
868 Ok(img) => img,
869 Err(e) => {
870 term_err(&alloc::format!("{}: cannot load ({:?})", p.cmd, e));
871 return true;
872 }
873 };
874 let mut argv: Vec<String> = Vec::with_capacity(p.args.len() + 1);
875 argv.push(p.cmd.clone());
876 argv.extend(p.args.iter().cloned());
877 let name = alloc::format!("{}", p.cmd);
878 match rsnix::exec::spawn(image, &EXPORTS_V1, &argv, &name) {
879 Ok(id) => {
880 // Wait for the completion message (TaskMsg::User(exit_code)).
881 loop {
882 match rsnix::sched::recv_blocking() {
883 rsnix::sched::TaskMsg::User(code) => {
884 unsafe {
885 LAST_EXIT_CODE = code as i32;
886 }
887 break;
888 }
889 rsnix::sched::TaskMsg::Key(rsnix::ps2::KeyEvent::Ctrl('c')) => {
890 // ^C while waiting: kill the command task.
891 let _ = rsnix::sched::kill(id);
892 term_err("^C");
893 break;
894 }
895 _ => {}
896 }
897 }
898 }
899 Err(rsnix::exec::ExecError::Busy) => term_err(&alloc::format!("{}: task table full", p.cmd)),
900 Err(_) => term_err(&alloc::format!("{}: cannot start", p.cmd)),
901 }
902 true
903}
904
905// ---------------------------------------------------------------------------
795// Built-in commands 906// Built-in commands
796// --------------------------------------------------------------------------- 907// ---------------------------------------------------------------------------
797 908