feat(userspace): shell runs programs (ls, cat) via spawn w/ terminal+storage+args page; ext4 holes read as zeros; storage re-attach same buffer

This commit is contained in:
2026-10-27 17:22:08 +01:00
parent 0fd568cdad
commit c603a578c0
22 changed files with 396 additions and 66 deletions

View File

@@ -0,0 +1,13 @@
[package]
name = "cat"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "cat"
test = false
bench = false
[dependencies]
types = { workspace = true }
libkos = { workspace = true }

View File

@@ -0,0 +1,45 @@
//! Prints files: `cat <file>...`.
#![no_std]
#![no_main]
extern crate libkos;
use libkos::fs::{FsError, Storage};
use libkos::println;
/// Where storage's buffer is mapped.
const STORAGE_BUF_VA: usize = 0x0180_0000;
fn cat(fs: &mut Storage, path: &str) -> Result<(), FsError> {
let h = fs.open(path.as_bytes())?;
let mut buf = [0u8; 4096];
loop {
let n = fs.read(h, &mut buf)?;
libkos::io::write_bytes(&buf[..n]);
if n < buf.len() {
break;
}
}
fs.close(h)
}
#[unsafe(no_mangle)]
extern "C" fn main() {
let Some(mut fs) = libkos::start::storage().and_then(|ep| Storage::attach(ep, STORAGE_BUF_VA).ok()) else {
println!("cat: no storage");
return;
};
let mut any = false;
for path in libkos::start::args().skip(1) {
any = true;
match cat(&mut fs, path) {
Ok(()) => {}
Err(FsError::NotFound) => println!("cat: {}: no such file", path),
Err(_) => println!("cat: {}: cannot read", path),
}
}
if !any {
println!("usage: cat <file>...");
}
}

13
crates/apps/ls/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "ls"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "ls"
test = false
bench = false
[dependencies]
types = { workspace = true }
libkos = { workspace = true }

View File

@@ -0,0 +1,35 @@
//! Lists a directory: `ls [dir]`, the root by default.
#![no_std]
#![no_main]
extern crate libkos;
use libkos::fs::Storage;
use libkos::println;
/// Where storage's buffer is mapped.
const STORAGE_BUF_VA: usize = 0x0180_0000;
#[unsafe(no_mangle)]
extern "C" fn main() {
let Some(mut fs) = libkos::start::storage().and_then(|ep| Storage::attach(ep, STORAGE_BUF_VA).ok()) else {
println!("ls: no storage");
return;
};
let dir = libkos::start::args().nth(1).unwrap_or("/");
let listed = fs.list_dir(dir.as_bytes(), |e| {
let name = core::str::from_utf8(e.name()).unwrap_or("?");
if name == "." || name == ".." {
return;
}
if e.is_dir {
println!("{}/", name);
} else {
println!("{:<24} {:>8}", name, e.size);
}
});
if listed.is_err() {
println!("ls: {}: no such directory", dir);
}
}

View File

@@ -1,33 +1,95 @@
//! The shell, for now built-ins only; running programs comes next.
//! The shell: a few built-ins, and anything else is a program on disk.
//! `name args...` runs `name.elf` through init, handing it the terminal as
//! its output and the storage server, and nothing else; then waits for it.
#![no_std]
#![no_main]
extern crate libkos;
use libkos::{print, println};
use libkos::spawn::{SpawnError, spawn};
use libkos::{print, println, syscall};
use types::bounded::CapSlot;
use types::budget::Limits;
use types::permission::{Permission, PermissionSet};
use types::start_arg::StartArg;
/// Init's manifest gives it the terminal first.
/// Where init's manifest put them: `uses`, then the spawn endpoint.
const TERMINAL: CapSlot = CapSlot::const_new(0);
const STORAGE: CapSlot = CapSlot::const_new(1);
const INIT: CapSlot = CapSlot::const_new(2);
fn run(line: &str) {
let mut words = line.split_ascii_whitespace();
match words.next() {
/// What each program gets, out of the shell's allowance.
const PROGRAM: Limits = Limits { pages: 128, endpoints: 1, notifications: 1, regions: 1 };
const MAX_ARGS: usize = 16;
/// Runs `args[0].elf` with `args` and waits for it to finish.
fn run_program(args: &[&str], done: CapSlot) {
let mut file = [0u8; types::spawn::MAX_NAME];
let name = args[0].as_bytes();
let Some(file) = file.get_mut(..name.len() + 4).map(|f| {
f[..name.len()].copy_from_slice(name);
f[name.len()..].copy_from_slice(b".elf");
&*f
}) else {
println!("shell: {}: name too long", args[0]);
return;
};
let child = match spawn(INIT, file, PROGRAM) {
Ok(c) => c,
Err(SpawnError::NotFound) => return println!("shell: {}: not found", args[0]),
Err(SpawnError::OverAllowance) => return println!("shell: no room to run {}", args[0]),
Err(SpawnError::Failed) => return println!("shell: {}: cannot run", args[0]),
};
let call = PermissionSet::empty().with(Permission::Execute);
let arg = StartArg {
log: child.give(TERMINAL, call),
storage: child.give(STORAGE, call),
args: child.set_args(args),
console: None,
};
let watched = syscall::watch_death(child.process, done).is_ok();
if child.start_with(arg) && watched {
let _ = syscall::wait(done);
} else {
let _ = syscall::kill(child.process);
println!("shell: {}: cannot start", args[0]);
}
syscall::drop_cap(child.process);
}
fn run(line: &str, done: CapSlot) {
let mut args = [""; MAX_ARGS];
let mut n = 0;
for w in line.split_ascii_whitespace().take(MAX_ARGS) {
args[n] = w;
n += 1;
}
let args = &args[..n];
match args.first().copied() {
None => {}
Some("help") => {
println!("built-ins: help, echo <text>, clear");
println!("anything else runs a program from the disk: try ls, cat readme.txt");
}
Some("echo") => {
let mut first = true;
for w in words {
print!("{}{}", if first { "" } else { " " }, w);
first = false;
}
println!();
}
Some("echo") => println!("{}", Joined(&args[1..])),
Some("clear") => libkos::terminal::clear(TERMINAL),
Some(other) => println!("shell: {}: not found", other),
Some(_) => run_program(args, done),
}
}
/// Words with single spaces between them.
struct Joined<'a>(&'a [&'a str]);
impl core::fmt::Display for Joined<'_> {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
for (i, w) in self.0.iter().enumerate() {
if i > 0 {
f.write_str(" ")?;
}
f.write_str(w)?;
}
Ok(())
}
}
@@ -35,13 +97,17 @@ fn run(line: &str) {
extern "C" fn main() {
// Everything this shell prints goes to the terminal.
libkos::start::set_log(Some(TERMINAL));
let Ok(done) = syscall::create_notification() else {
println!("shell: no notification to wait on");
return;
};
println!("k-os shell. Type 'help'.");
let mut buf = [0u8; 256];
loop {
print!("$ ");
let n = libkos::terminal::read_line(TERMINAL, &mut buf);
match core::str::from_utf8(&buf[..n]) {
Ok(line) => run(line.trim_end_matches('\n')),
Ok(line) => run(line.trim_end_matches('\n'), done),
Err(_) => println!("shell: not text"),
}
}

View File

@@ -153,6 +153,6 @@ fn hand_over_console(log: CapSlot) {
pub(crate) fn start(program: &LoadedProcess, console: Option<CapSlot>) {
let log = libkos::start::log()
.and_then(|l| syscall::delegate(l, program.process_cap, perms(&[Permission::Execute])));
let arg = types::start_arg::encode(console, log);
let arg = types::start_arg::StartArg { console, log, ..Default::default() }.encode();
syscall::spawn_thread_in(program.process_cap, program.entry_point, USER_STACK_TOP, arg);
}

View File

@@ -79,6 +79,8 @@ pub const TESTS: &[Program] = &[Program {
/// The desktop; the test image runs the SMP self-test instead.
pub const APPS: &[Program] = &[Program {
serves: false,
uses: &["terminal"],
uses: &["terminal", "storage"],
// Room for one program at a time, the way the shell runs them.
spawns: Some(Limits { pages: 256, endpoints: 2, notifications: 2, regions: 2 }),
..server("shell", b"shell.elf", SMALL)
}];

View File

@@ -4,21 +4,26 @@ struct Writer;
impl fmt::Write for Writer {
fn write_str(&mut self, s: &str) -> fmt::Result {
// The log server first; a process without either prints nothing.
// A call, not a send: the text is out before the caller goes on.
if let Some(log) = crate::start::log() {
for chunk in s.as_bytes().chunks(types::log::TEXT_BYTES) {
let _ = crate::syscall::call(log, types::log::text_words(chunk), None);
}
} else if let Some(console) = crate::start::console() {
for &b in s.as_bytes() {
crate::syscall::debug_putchar(console, b);
}
}
write_bytes(s.as_bytes());
Ok(())
}
}
/// Bytes as they are to where `print!` goes: the log endpoint (the log
/// server, or a terminal), else the console; nowhere without either.
pub fn write_bytes(bytes: &[u8]) {
// A call, not a send: the text is out before the caller goes on.
if let Some(log) = crate::start::log() {
for chunk in bytes.chunks(types::log::TEXT_BYTES) {
let _ = crate::syscall::call(log, types::log::text_words(chunk), None);
}
} else if let Some(console) = crate::start::console() {
for &b in bytes {
crate::syscall::debug_putchar(console, b);
}
}
}
pub fn _print(args: fmt::Arguments) {
use fmt::Write;
Writer.write_fmt(args).ok();

View File

@@ -6,7 +6,9 @@ use crate::syscall;
use types::address::{EntryPoint, StackPointer};
use types::bounded::CapSlot;
use types::budget::Limits;
use types::permission::{Permission, PermissionSet};
use types::spawn::{STACK_TOP, reply, request};
use types::start_arg::{ARGS_SIZE, ARGS_VA, StartArg};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnError {
@@ -44,10 +46,46 @@ pub fn spawn(init: CapSlot, name: &[u8], limits: Limits) -> Result<Spawned, Spaw
}
impl Spawned {
/// Runs the program, telling it where its log endpoint is.
pub fn start(&self) -> bool {
let arg = types::start_arg::encode(None, self.log);
/// The log endpoint init gave the program.
pub fn log(&self) -> Option<CapSlot> {
self.log
}
/// A copy of `cap` for the program, with at most `perms` (and never
/// Delegate: it is the program's, not to pass on); its slot there.
pub fn give(&self, cap: CapSlot, perms: PermissionSet) -> Option<CapSlot> {
syscall::delegate(cap, self.process, perms.without(Permission::Delegate))
}
/// Puts `args` (its name first) where the program finds them: a page
/// this process fills and then moves into the program, which owns it.
pub fn set_args(&self, args: &[&str]) -> bool {
let need: usize = args.iter().map(|a| a.len() + 1).sum::<usize>() + 1;
if need > ARGS_SIZE || args.iter().any(|a| a.contains('\0')) {
return false;
}
let Some(page) = types::bounded::PageCount::new(1).and_then(syscall::request_pages) else {
return false;
};
// SAFETY: the page was just mapped read-write here, ARGS_SIZE bytes.
let buf = unsafe { core::slice::from_raw_parts_mut(page.raw() as *mut u8, ARGS_SIZE) };
buf.fill(0);
let mut at = 0;
for a in args {
buf[at..at + a.len()].copy_from_slice(a.as_bytes());
at += a.len() + 1;
}
syscall::map_page(self.process, page, types::address::VirtualAddress::from_raw(ARGS_VA), false)
}
/// Runs the program with `arg` in its `a0` (see `types::start_arg`).
pub fn start_with(&self, arg: StartArg) -> bool {
let stack = StackPointer::from_raw(STACK_TOP);
syscall::spawn_thread_in(self.process, self.entry, stack, arg) != usize::MAX
syscall::spawn_thread_in(self.process, self.entry, stack, arg.encode()) != usize::MAX
}
/// Runs the program, printing to the log init gave it.
pub fn start(&self) -> bool {
self.start_with(StartArg { log: self.log, ..StartArg::default() })
}
}

View File

@@ -2,7 +2,7 @@
use core::sync::atomic::{AtomicUsize, Ordering};
use types::bounded::CapSlot;
use types::start_arg;
use types::start_arg::{ARGS_SIZE, ARGS_VA, StartArg};
/// Set by `_start` from `a0` before `main` runs.
#[unsafe(no_mangle)]
@@ -20,7 +20,7 @@ fn arg() -> usize {
/// The console capability this process prints through, if any.
pub fn console() -> Option<CapSlot> {
read(&CONSOLE, start_arg::console)
read(&CONSOLE, |a| StartArg::decode(a).console)
}
/// For a process that gets its console some other way (init, from BootInfo).
@@ -30,7 +30,25 @@ pub fn set_console(slot: Option<CapSlot>) {
/// The log server endpoint this process prints to, if any.
pub fn log() -> Option<CapSlot> {
read(&LOG, start_arg::log)
read(&LOG, |a| StartArg::decode(a).log)
}
/// The storage server endpoint this process was given, if any.
pub fn storage() -> Option<CapSlot> {
StartArg::decode(arg()).storage
}
/// This program's arguments, its name first; none without an args page.
pub fn args() -> impl Iterator<Item = &'static str> {
let page: &'static [u8] = if StartArg::decode(arg()).args {
// SAFETY: the args flag says the parent mapped this page here
// before starting the program; nothing writes it afterwards.
unsafe { core::slice::from_raw_parts(ARGS_VA as *const u8, ARGS_SIZE) }
} else {
&[]
};
let end = page.windows(2).position(|w| w == [0, 0]).map_or(0, |i| i + 1);
page[..end].split(|b| *b == 0).filter(|a| !a.is_empty()).filter_map(|a| core::str::from_utf8(a).ok())
}
/// For a process given its log endpoint after it started.

View File

@@ -106,9 +106,15 @@ impl<B: BlockDevice> FileSystem for Ext4<B> {
let mut block_buf = [0u8; 4096];
while done < to_read {
let offset = file.offset + done;
let phys_block = self.map_logical_block(&inode, (offset / bs) as u32)?;
if !read_block(&mut self.device, phys_block, self.sb.sectors_per_block(), &mut block_buf) {
return None;
// A block no extent maps is a hole (mkfs leaves zero blocks
// out): it reads as zeros.
match self.map_logical_block(&inode, (offset / bs) as u32) {
Some(phys_block) => {
if !read_block(&mut self.device, phys_block, self.sb.sectors_per_block(), &mut block_buf) {
return None;
}
}
None => block_buf.fill(0),
}
let byte_in_block = offset % bs;
let chunk = (to_read - done).min(bs - byte_in_block);

View File

@@ -23,6 +23,8 @@ pub trait Buffers {
struct Client {
badge: usize,
buf: &'static mut [u8],
/// What maps `buf`, sent again when the same client attaches again.
cap: Option<CapSlot>,
}
pub struct StorageServer<F: FileSystem, B: Buffers> {
@@ -77,9 +79,11 @@ impl<F: FileSystem, B: Buffers> StorageServer<F, B> {
self.clients.iter_mut().flatten().find(|c| c.badge == badge)
}
/// A client attaching again gets its buffer again: the programs a shell
/// runs one after another share its badge.
fn attach(&mut self, badge: usize) -> (StorageResponse, Option<CapSlot>) {
if self.client(badge).is_some() {
return (StorageResponse::Error, None);
if let Some(c) = self.client(badge) {
return (StorageResponse::Attached { size: c.buf.len() }, c.cap);
}
let Some(index) = self.clients.iter().position(Option::is_none) else {
return (StorageResponse::Error, None);
@@ -89,7 +93,7 @@ impl<F: FileSystem, B: Buffers> StorageServer<F, B> {
};
let size = buf.len();
if let Some(slot) = self.clients.get_mut(index) {
*slot = Some(Client { badge, buf });
*slot = Some(Client { badge, buf, cap });
}
(StorageResponse::Attached { size }, cap)
}

View File

@@ -170,3 +170,16 @@ fn one_read_fills_the_buffer_across_blocks() {
assert_eq!(fs.read(h, &mut buf), Some(total - 10_000));
assert_eq!(fs.read(h, &mut buf), Some(0));
}
#[test]
fn holes_read_as_zeros() {
let dir = scratch("sparse");
// Data, then a zero block mkfs stores as a hole, then data again.
let mut data = std::vec![b'x'; 4096];
data.extend(std::vec![0u8; 4096]);
data.extend(b"end");
std::fs::write(dir.join("root/sparse.bin"), &data).unwrap();
let Some(img) = mkfs(&dir, MKFS_FEATURES) else { return };
let mut fs = Ext4::mount(FileDisk(img)).expect("mount");
assert_eq!(read_all(&mut fs, b"sparse.bin"), data);
}

View File

@@ -67,10 +67,14 @@ fn nothing_works_before_attaching() {
}
#[test]
fn a_client_attaches_once() {
fn attaching_again_hands_back_the_same_buffer() {
let mut s = server();
Client::attach(&mut s, 0);
assert_eq!(call(&mut s, 0, StorageRequest::Attach).0, StorageResponse::Error);
let (first, cap) = call(&mut s, 0, StorageRequest::Attach);
assert_eq!(call(&mut s, 0, StorageRequest::Attach), (first, cap));
// Not a new client: the other seven places are still free.
for badge in 1..8 {
assert!(matches!(call(&mut s, badge, StorageRequest::Attach).0, StorageResponse::Attached { .. }));
}
}
#[test]

View File

@@ -1,11 +1,27 @@
//! The word a process's first thread gets in `a0`: the slots of the
//! capabilities any process may be handed at start (console, log endpoint).
//! Each field is slot + 1 in one byte; 0 means none.
//! capabilities any process may be handed at start, and whether it has an
//! arguments page. Each slot field is slot + 1 in one byte; 0 means none.
use crate::bounded::CapSlot;
const CONSOLE_SHIFT: u32 = 0;
const LOG_SHIFT: u32 = 8;
const STORAGE_SHIFT: u32 = 16;
const ARGS_BIT: usize = 1 << 24;
/// Where the arguments page sits: the program's arguments, NUL separated,
/// the first its name. Below the stack, which ends at the image.
pub const ARGS_VA: usize = 0x8000;
pub const ARGS_SIZE: usize = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct StartArg {
pub console: Option<CapSlot>,
/// Where `println!` goes: the log server, or a terminal.
pub log: Option<CapSlot>,
pub storage: Option<CapSlot>,
pub args: bool,
}
fn field(arg: usize, shift: u32) -> Option<CapSlot> {
let v = (arg >> shift) & 0xFF;
@@ -16,16 +32,22 @@ fn put(slot: Option<CapSlot>, shift: u32) -> usize {
slot.map_or(0, |s| (s.value() as usize + 1) << shift)
}
pub fn encode(console: Option<CapSlot>, log: Option<CapSlot>) -> usize {
put(console, CONSOLE_SHIFT) | put(log, LOG_SHIFT)
}
impl StartArg {
pub fn encode(&self) -> usize {
put(self.console, CONSOLE_SHIFT)
| put(self.log, LOG_SHIFT)
| put(self.storage, STORAGE_SHIFT)
| if self.args { ARGS_BIT } else { 0 }
}
pub fn console(arg: usize) -> Option<CapSlot> {
field(arg, CONSOLE_SHIFT)
}
pub fn log(arg: usize) -> Option<CapSlot> {
field(arg, LOG_SHIFT)
pub fn decode(arg: usize) -> Self {
Self {
console: field(arg, CONSOLE_SHIFT),
log: field(arg, LOG_SHIFT),
storage: field(arg, STORAGE_SHIFT),
args: arg & ARGS_BIT != 0,
}
}
}
#[cfg(test)]
@@ -33,11 +55,14 @@ mod tests {
use super::*;
#[test]
fn slots_round_trip_and_zero_is_none() {
let (c, l) = (CapSlot::new(0), CapSlot::new(63));
let arg = encode(c, l);
assert_eq!((console(arg), log(arg)), (c, l));
assert_eq!((console(0), log(0)), (None, None));
assert_eq!(log(encode(c, None)), None);
fn every_field_round_trips_and_zero_is_none() {
let a = StartArg {
console: CapSlot::new(0),
log: CapSlot::new(63),
storage: CapSlot::new(5),
args: true,
};
assert_eq!(StartArg::decode(a.encode()), a);
assert_eq!(StartArg::decode(0), StartArg::default());
}
}