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

17
Cargo.lock generated
View File

@@ -12,7 +12,6 @@ version = "0.1.0"
dependencies = [
"domain",
"elf",
"hw-virtio",
"kernel",
"platform",
"types",
@@ -42,6 +41,14 @@ version = "0.1.0"
name = "camera-server"
version = "0.1.0"
[[package]]
name = "cat"
version = "0.1.0"
dependencies = [
"libkos",
"types",
]
[[package]]
name = "clipboard-server"
version = "0.1.0"
@@ -224,6 +231,14 @@ dependencies = [
"types",
]
[[package]]
name = "ls"
version = "0.1.0"
dependencies = [
"libkos",
"types",
]
[[package]]
name = "media-index-server"
version = "0.1.0"

View File

@@ -3,6 +3,8 @@ members = [
"crates/adapter-riscv",
"crates/apps/hello",
"crates/apps/shell",
"crates/apps/cat",
"crates/apps/ls",
"crates/apps/echo",
"crates/apps/smptest",
"crates/apps/spawntest",

View File

@@ -55,7 +55,7 @@ build:
-p init -p echo -p snake \
-p display-qemu -p input-qemu -p storage-qemu \
-p log-server -p permission-manager -p session-server -p smptest -p spawntest -p hello \
-p terminal-server -p shell
-p terminal-server -p shell -p cat -p ls
cargo build --target $(RISCV_TARGET) --release -p adapter-riscv
image: build

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,19 +4,24 @@ 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.
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 s.as_bytes().chunks(types::log::TEXT_BYTES) {
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 s.as_bytes() {
for &b in bytes {
crate::syscall::debug_putchar(console, b);
}
}
Ok(())
}
}
pub fn _print(args: fmt::Arguments) {

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,10 +106,16 @@ 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)?;
// 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);
buf[done..done + chunk].copy_from_slice(&block_buf[byte_in_block..byte_in_block + chunk]);

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());
}
}

7
disk/docs/shell.txt Normal file
View File

@@ -0,0 +1,7 @@
The shell runs a program by asking init to load it (init alone may
create processes), then gives the program the terminal as its output
and the storage server, writes its arguments onto a page it moves
into the program, starts it and waits for it to die.
Built-ins: help, echo, clear. Anything else is a program on disk:
`name args...` runs name.elf.

7
disk/readme.txt Normal file
View File

@@ -0,0 +1,7 @@
Welcome to k-os.
Every program here runs with only the capabilities it was given:
this file reached your screen because the shell handed `cat` the
terminal and the storage server, and nothing else.
Try: ls ls docs cat docs/shell.txt

View File

@@ -74,7 +74,10 @@ calling it (`types::log`) if init gave it a log endpoint.
A thread made by `SpawnThread`/`SpawnThreadIn` starts with the caller's
`arg` in `a0`. A process's first thread reads it as `types::start_arg`:
the slots of the console and log capabilities it was handed.
the slots of the console, log and storage capabilities it was handed, and
whether its parent put an arguments page at `ARGS_VA` (NUL-separated, the
program's name first; the parent fills a page and `MapPage`s it over, so
the program owns it and it goes when the program does).
Boot loads only init and storage, both packed into the kernel image (they
are what reads the disk, so they cannot come from it). The disk is one ext4
@@ -102,7 +105,12 @@ font. Its output side is the log protocol (`types::log`), so a program whose
log endpoint is the terminal prints there with `println!`: standard output
is whatever text endpoint a program was given. `READ` (`types::terminal`)
blocks until a line is typed and returns it in chunks, the last ending in
`\n`. The desktop is the shell, given the terminal.
`\n`. The desktop is the shell, given the terminal, storage and a spawn
allowance. A command that is not a built-in runs `name.elf`: the shell
spawns it, gives it call-only copies of the terminal (as its log, so its
output) and of storage, writes its arguments page, starts it and waits for
its death. The programs share the shell's storage badge; storage hands a
client that attaches again the same buffer, so they run one at a time.
Only init holds `ProcessCreate`. Everyone else runs programs through init's
spawn service (`types::spawn`, client `libkos::spawn`), which init serves

View File

@@ -30,6 +30,10 @@ copy permission-manager "permis.elf"
copy session-server "session.elf"
copy terminal-server "terminal.elf"
copy shell "shell.elf"
copy cat "cat.elf"
copy ls "ls.elf"
# Files that ship on the disk.
cp -r disk/. "${ROOT}/"
# Only the test image: its presence makes init run the self-test.
if [ -n "${SMPTEST:-}" ]; then
copy smptest "smptest.elf"