m³OS docs GitHub

Phase 20

Userspace Init Shell

Replace the kernel-resident `init_task` and `shell_task` ring-0 functions with real ring-3 userspace processes. After this phase PID 1 is a `no_std` Rust binary loaded from the ramdisk; the interactive shell is a userspace ELF that init spawns. The kernel is no longer responsible for parsing commands or managing the interactive session.

Complete

Learning goal

Understand the Unix PID 1 contract: why init must never exit and must reap orphaned children.

Category

userspace

Why this phase matters

Replace the kernel-resident init_task and shell_task ring-0 functions with real ring-3 userspace processes. After this phase PID 1 is a no_std Rust binary loaded from the ramdisk; the interactive shell is a userspace ELF that init spawns. The kernel is no longer responsible for parsing commands or managing the interactive session.

How it works in m3OS

The roadmap entry defines the learning scope for this phase; a deeper implementation doc has not been matched yet.

How it builds on earlier phases

This phase builds on Signal Handlers and turns that foundation into Userspace Init Shell.

Implementation outline

  • Uncomment userspace/init and userspace/shell in the workspace Cargo.toml and verify each crate has #![no_std], a #[panic_handler], and a _start entry point that calls into Rust (or a thin asm stub that sets up rbp and calls main).
  • Write the syscall shim layer shared by both binaries: inline-asm wrappers for read, write, fork, execve, waitpid, exit, pipe, dup2, open, close, chdir, kill, getpid.
  • Implement userspace/init/src/main.rs: open /dev/console as fds 0, 1, 2; fork and exec /bin/sh; enter an infinite waitpid(-1, WNOHANG) + pause loop.
  • Implement the line-reader in userspace/shell: read one byte at a time from fd 0 until \n or \r; handle backspace; cap line length at 256 bytes.
  • Implement the tokenizer: split on whitespace, respect single-quoted strings, expand nothing else (no glob, no variable expansion in this phase).
  • Implement command dispatch: detect cd and exit builtins; otherwise fork + execve; parent calls waitpid for foreground; print exit status on non-zero.
  • Implement pipe support: detect | token; allocate a pipe fd pair; fork left child with dup2(pipe_write, STDOUT_FILENO); fork right child with dup2(pipe_read, STDIN_FILENO); close both pipe ends in the parent; waitpid both.
  • Implement > and < redirection: open the target file before fork; after fork in the child, dup2 onto stdout or stdin; close the extra fd; exec.
  • In kernel_main, after all kernel-task servers have been started, load /sbin/init from the ramdisk via the ELF loader, allocate a new address space, and transfer to ring-3 as PID 1. Remove the init_task() and shell_task() function calls.
  • Add the two ELF binaries to the xtask ramdisk assembly step so they appear at /sbin/init and /bin/sh in the filesystem image.
  • Validate end-to-end: boot in QEMU, observe the shell prompt, run echo hello, run ls | cat, run cat /etc/motd > /tmp/out, run cd /bin && pwd.
  • Confirm that the stdin_feeder_task kernel task (or equivalent) correctly feeds keyboard bytes into the PID 1 process’s stdin fd, so that the shell’s read(0, ...) syscall blocks until a key is pressed rather than spinning.
  • Run cargo xtask check to confirm no new clippy warnings are introduced by the two new workspace members before declaring the phase complete.

Task progress snapshot

73 of 73 tracked tasks are complete.

Documentation targets

  • Documentation deliverables have not been listed yet.

Deferred follow-on work

  • Moving kernel-task servers (console_server, kbd_server, vfs_server, fat_server) to ring-3 userspace (requires the capability-grant IPC phase)
  • PTY / TTY line discipline (/dev/pts, termios, raw mode, character echo)
  • Job control: SIGTSTP, SIGCONT, fg, bg, process groups, sessions
  • Multi-user login and /etc/passwd
  • Shell scripting: loops, conditionals, functions, variable assignment
  • Environment variable export and $VAR expansion
  • Tab completion and readline-style editing
  • exec builtin (replace shell image) and source / .
  • Pipelines longer than two stages
  • Subshell expansion $(...) and backtick substitution
  • Here-documents and here-strings
  • Stderr redirection (2>, 2>&1) and fd duplication beyond 0/1/2

Source materials

Important code

Code spotlight cards

Code spotlight

Add raw syscall wrappers (syscall3–syscall6)

userspace/syscall-lib/src/lib.rs L61-L80

Add raw syscall wrappers (syscall3–syscall6). Why it matters: All higher-arity syscalls (fork, exec, pipe, etc.) need more than two arguments passed through registers. Focus on `syscall3` and `syscall4` in `userspace/syscall-lib/src/lib.rs` while you trace the snippet.

/// Raw three-argument syscall.
#[inline(always)]
pub unsafe fn syscall3(num: u64, a0: u64, a1: u64, a2: u64) -> u64 {
    let mut rax = num;
    asm!(
        "syscall",
        inlateout("rax") rax,
        in("rdi") a0,
        in("rsi") a1,
        in("rdx") a2,
        lateout("rcx") _,
        lateout("r11") _,
        options(nostack),
    );
    rax
}

/// Raw four-argument syscall. Note: arg4 uses r10 (not rcx, which is clobbered).
#[inline(always)]
pub unsafe fn syscall4(num: u64, a0: u64, a1: u64, a2: u64, a3: u64) -> u64 {

Code spotlight

Create init crate

userspace/init/Cargo.toml See file

Create init crate. Why it matters: Init is the first userspace process; without it there is no process tree. Focus on `no_std` and `syscall-lib` in `userspace/init/Cargo.toml` while you trace the snippet.

Code spotlight

Create init entry point

userspace/init/src/main.rs L20-L43

Create init entry point. Why it matters: The kernel hands off control to this function; it must set up fds and never return. Focus on `_start` and `#![no_std]` in `userspace/init/src/main.rs` while you trace the snippet.

#[no_mangle]
pub extern "C" fn _start() -> ! {
    // Fds 0/1/2 are pre-opened by the kernel for PID 1.
    write_str(STDOUT_FILENO, "\nm3OS init (PID 1)\n");

    // Spawn the first shell.
    let mut shell_pid = spawn_shell();
    if shell_pid < 0 {
        write_str(STDOUT_FILENO, "init: failed to spawn shell\n");
        exit(1);
    }

    // Reap loop: wait for any child, re-spawn shell if it exits.
    loop {
        let mut status: i32 = 0;
        let ret = waitpid(-1, &mut status, WNOHANG);

        if ret > 0 {
            // A child exited. If it was the shell, re-spawn it.
            if ret == shell_pid {
                write_str(STDOUT_FILENO, "\ninit: shell exited, respawning...\n");
                shell_pid = spawn_shell();
                if shell_pid < 0 {
                    write_str(STDOUT_FILENO, "init: failed to respawn shell\n");

Key files

  • userspace/init — userspace/init: minimal no_std Rust binary; sets up stdin/stdout/stderr
  • userspace/shell — userspace/shell: interactive no_std Rust shell; reads stdin byte by byte
  • kernel/src/main.rs — Remove shell_task() and init_task() from kernel/src/main.rs; stdin_feeder_task
  • userspace/init/src/main.rs — Implement userspace/init/src/main.rs: open /dev/console as fds 0, 1, 2; fork

How real OSes differ

  • Production init systems (systemd, OpenRC, s6) handle service supervision, dependency ordering, socket activation, cgroups, and logging.
  • Even BusyBox's `init` handles `/etc/inittab` respawn entries, runlevels, and `sysvinit` compatibility.
  • Real shells (dash, bash) implement full POSIX parameter expansion, here-documents, arithmetic, function definitions, and complex job control.
  • This phase targets only the subset needed to run interactive commands and pipelines -- enough to feel like a real shell without the decades of accumulated specification compliance.

Success criteria

  • The OS boots and presents an interactive shell prompt without any ring-0 command parsing code running.
  • `ps` (or `cat /proc/1/status` if available) shows PID 1 is `init` and PID 2 is `sh`.
  • `echo hello world` prints correctly.
  • `ls | cat` produces the directory listing via a two-stage pipe.
  • `cat /sbin/init > /dev/null` exercises I/O redirection without crashing.
  • `cd /bin && pwd` prints `/bin`.
  • `Ctrl-C` during a long-running child kills the child but leaves the shell running.
  • Orphaned grandchildren are reaped by PID 1 (not left as zombies indefinitely).
  • `exit` in the shell causes init to re-spawn it (or the boot stops gracefully).
  • `kernel/src/main.rs` no longer contains `shell_task` or `init_task` functions.
  • A command that does not exist (e.g. `notacommand`) causes `execve` to fail; the shell prints an error and returns to the prompt without crashing.
  • Running `true` exits 0; running `false` exits 1; the shell prints nothing extra for `true` and prints a non-zero exit notice for `false`.

Related phases

Keep exploring the roadmap