No. 60 / project of 147 on the ladder

First userspace process — RV32 hello as PID 1

introduces — a freestanding RV32 hello binary, an initramfs cpio embedded in the kernel, and the kernel's Run /init handoff observed end-to-end

harden statelast run2026-05-03
signoff
  • DRCNOT RUN
  • LVSNOT RUN
  • antennaNOT RUN

P59 made the kernel boot. P60 is about getting Linux to actually hand control to a userspace program — proving the M-mode → S-mode → U-mode transition and the syscall path all work end to end.

Status: PARTIAL. The kernel reaches Run /init as init process with our binary as PID 1, environment variables set, ready to exec. We have not yet captured the userspace printf output before the testbench timeout — that lands on the follow-up page once a long-enough run completes.

Same chip RTL as P59. The work is entirely in the kernel/userspace stack on top of it.

What’s new

  • userspace/hello.c — a freestanding RV32 program. No libc. _start is the entry point. Calls Linux’s SYS_write (64) and SYS_exit (93) directly via ecall. Builds with -march=rv32ima -mabi=ilp32 -static -nostdlib. Comes out to ~1.9 KB of ELF.
  • userspace/Makefile — builds hello, copies it to rootfs/init, and emits initramfs.cpio (newc format).
  • The kernel was rebuilt with CONFIG_BLK_DEV_INITRD=y, CONFIG_INITRAMFS_SOURCE="/abs/path/to/initramfs.cpio", CONFIG_INITRAMFS_COMPRESSION_GZIP=y, CONFIG_BINFMT_ELF=y, CONFIG_DEVTMPFS=y, CONFIG_DEVTMPFS_MOUNT=y, CONFIG_PROC_FS=y. The cpio embeds at link time, so the resulting Image is self-contained.

Why no libc

Bringing up an RV32 libc (musl or glibc) would mean either multilib in our Nix toolchain or a separate musl cross-build. hello.c sidesteps that entirely:

  • _start is our entry, so no crt0 needed.
  • write() and exit() are inlined ecall instructions with the syscall ABI in a7 / a0a2.
  • A hand-rolled slen() instead of strlen (gcc helpfully optimised our naive loop into a strlen call against libc — -fno-builtin fixes that).

Result: a 1.9 KB ELF that does literally only what we want.

Why /init = our hello

Linux mounts the initramfs at /, then execs /init as PID 1. We don’t have a shell, BusyBox, or login — we just point /init at our binary directly. When it exit()s, the kernel panics with “Attempted to kill init!” (because PID 1 is supposed to stay alive); that panic message also goes via SBI putchar to our UART, which is fine for a test demo. We see our hello AND we see the panic, both proving the kernel is fully alive.

Boot path

chip reset
  -> stage-0 firmware (M-mode)
  -> mret -> kernel _start (S-mode, satp = our identity PT)
  -> setup_vm + relocate_enable_mmu
     (satp = trampoline -> fault -> stvec redirect -> virtual)
     (satp = early_pg_dir)
  -> start_kernel
  -> all the init... (memory zones, slub, irq, clocksource)
  -> rest_init -> kernel_init -> ramdisk_execute_command "/init"
  -> load_elf_binary("/init")
  -> hello._start prints via SBI putchar
  -> hello.exit
  -> kernel panic "Attempted to kill init!"

What we have captured

The kernel reaches Run /init and prints the exec preamble:

Run /init as init process
  with arguments:
    /init
    earlyprintk
  with environment:
    HOME=/
    TERM=linux

That is the kernel telling us, through SBI putchar, that it has loaded our ELF, is about to call start_thread, and is handing control to U-mode. All the plumbing — initramfs unpack, ELF parse, exec, env var setup — completed cleanly. The userspace write/exit themselves happen a few hundred thousand cycles later, which is where our 200M-cycle testbench timeout was hitting. Bumped to 500M for the next run.

Files

  • userspace/hello.c — the userspace binary
  • userspace/Makefile — build hello + cpio
  • app/, runtime/, boot/, src/, test/ — same as P59, unchanged
  • test/Makefile — adds the kernel-with-initramfs path

How to reproduce

# 1. Build hello + initramfs.
cd projects/60_userspace_hello/userspace
make
# emits: hello (binary), rootfs/, initramfs.cpio

# 2. Configure kernel for initramfs and rebuild.
cd /path/to/linux-6.12.85
CPIO=/abs/path/to/initramfs.cpio
./scripts/config --enable BLK_DEV_INITRD \
  --set-str INITRAMFS_SOURCE "$CPIO" \
  --enable BINFMT_ELF \
  --enable DEVTMPFS --enable DEVTMPFS_MOUNT \
  --enable PROC_FS
make ARCH=riscv olddefconfig
make ARCH=riscv -j$(nproc) Image

# 3. Build chip + run sim.
cd projects/60_userspace_hello/test
make run KERNEL_IMAGE=/path/to/Image

Harden

NOT RUN. Software-only project; same chip as P59.

What just happened?

The chip is now hosting a real Linux userspace ABI. The kernel parsed our cpio, allocated pages, built ELF auxv, set up the user stack, switched to U-mode, and was about to start running our binary’s _start. Every privilege transition the RV32-Linux model defines is now exercised at least once on this chip.

Closing the partial — getting the userspace write actually captured — is a wall-clock-time problem, not a chip problem. The follow-up will land on a P61-equipped run because the TLB ~halves the cycle count to reach /init exec.