Adam J. Iwaniec


IllinixOS - a UNIX-like RISC-V kernel

Nov 2024 - Jan 2025 · C, RISC-V assembly, QEMU · University of Illinois, ECE 391 · built with Yixuan Li live demo ·

IllinixOS is a UNIX-like kernel for 64-bit RISC-V, written in C and assembly for QEMU's RISC-V CPU emulator. It has Sv39 virtual memory, preemptive multitasking, fork and exec, a filesystem on a VirtIO disk, a system call interface, and a shell on a serial port. From the shell you can run programs stored on the disk. Each one is a process with its own address space, scheduled preemptively off a timer interrupt, and a process can fork and exec like it would on UNIX. The OS kernel runs an a single hart and with no C library. The string functions, heap allocator, and console are implemented in the kernel.

The kernel is a stack of layers with one I/O interface running through it. A user program calls read via a syscall. The syscall handler triggers an exception, and the trap handler saves its registers and switches to a kernel thread to execute the syscall. It then checks that the pointer passed in really belongs to that process, and the request goes to an io_intf struct, which can be a file on the filesystem or a device such as a UART. All files and devices have an io_intf associated with them that provides four operations: read, write, ctl, and close. The filesystem fetches its blocks through the VirtIO block driver, which posts a descriptor on a virtqueue and sleeps on a condition variable until the disk's interrupt arrives through the PLIC and wakes it. Each layer only knows the interface of the one below it, so we had to design the interfaces so that they agree with each other.

IllinixOS layers from user programs down to the QEMU virt machine
The layers, top to bottom. A file read enters at the top and passes through every one of them.

The virtual memory is Sv39. Virtual addresses are 39-bits walked through three levels of page table. The kernel maps the device registers with 1 GB pages, its own RAM with 2 MB pages, and its image with 4 KB pages so that text is executable but not writable and data is writable but not executable. Each user process gets its own subtree at 0xC000_0000 with the user bit set, so the same virtual address means something different in every process, and fork works by cloning that subtree. A page fault in user space that the kernel cannot resolve kills the process instead of the machine.

The three-level Sv39 page table walk and the physical regions it maps.
(From lecture slides, K. Levchenko, ECE 391 Fall 2024)

The filesystem is made up of 4 KB blocks. A boot block holding the directory of up to 63 entries, one inode block per file listing its length and data blocks, then the data. Up to 32 files can be open at once, each behind the same io_intf as a device. Programs are ELF files on that disk: exec unmaps the old user space, builds a fresh page table, loads each program segment with the right permissions (R/W/X) and begins execution in user mode from the prog entry point.

Two of us built it. I wrote the device drivers (PLIC, UART, VirtIO block), the timer and preemption, threads and processes, fork and exec, the virtual memory system, trap handling, and syscalls. Yixuan wrote the filesystem, the ELF loader and the locking,and I worked on the filesystem side with Yixuan to make communicate with the block device correctly.

After the course I compiled our kernel + QEMU to WebAssembly (using qemu-wasm and emscripten) so it can run in a web browser. There are user programs loaded onto its filesystem, such as Star Trek and Rule30 (provided by the course), one of my fork+thread switching stress test programs, and some other C programs I found on GitHub and compiled to RV64 (slightly modified to work with our kernel ABI). Demo

The kernel source stays private under course policy.