RustForge

rust-kernel

Rustbelt: a hobby x86_64 kernel written in Rust

authorEdward <edward@localhost>2026-10-31 20:32:43 -0700
commit42ffc56884a9106dcaec8500c7d4a89bf7dcd04f
parentbef3194286245095a38d6f657808d73436ca6399
parent6bb2b254b3166e0776adecc097ab6292b94393a5
treebrowse files at this commit
Merge master (0.21.1: clean shutdown, K12) into arm64

Conflicts were version strings only (arm64 keeps 0.22.0) and README
(both new chapters and roadmap lines kept, 0.21.1 first).
MREADME.md+49 -0
Mscripts/boot-v2-check.sh+20 -5
Msrc/boot.rs+10 -0
Msrc/fs/fat.rs+32 -9
Msrc/fs/mod.rs+30 -0
Msrc/main.rs+11 -0
Muser/inittest.c+9 -0

7 files changed, 161 insertions, 14 deletions

@@ -5329,6 +5329,54 @@ Doesn't (yet):   mappings to write. - exFAT still has no rename, file times or dirty flag. +## Shutting down cleanly (0.21.1)++A small fix, Clarke's request K12. When a FAT disk is written to, FAT+sets a **"dirty bit"** in its boot sector, meaning "I'm in use, I may be+half-written". Unmounting the disk clears the bit again. If a computer+finds the bit still set, it assumes the disk wasn't unmounted properly+and offers to "repair" it (`fsck.fat` says *"Dirty bit is set. Fs was+not properly unmounted"*). Until 0.21.1, Rustbelt never unmounted+anything: when pid 1 exited, the kernel ended the QEMU run right away,+so after any run that wrote to B: (the FAT32 disk), the bit stayed set.+No data was lost, but every OS complained.++Now there is **one shutdown path**, `boot::shutdown()` in `src/boot.rs`.+It calls `fs::unmount_all()`, which takes every mount out of the mount+table, **newest first** (so `/`, mounted first, goes last), and calls+the file system's `unmount()`. Each file system decides what that means:++| File system | What `unmount()` does |+|-------------|-----------------------|+| FAT32 | writes out the FSInfo sector (fatfs keeps it in memory), then clears the dirty bit (fatfs's own `unmount()`); after that, the volume answers every request with EIO |+| ext2, exFAT, btrfs, ramfs, /dev, /proc | nothing to do: they write everything straight to the disk (or have no disk), and don't keep an "in use" mark |++`FileSystem` (in `src/fs/mod.rs`) got two methods for this, `sync()`+and `unmount()`, both doing nothing unless a file system needs them.++The kernel calls `boot::shutdown()` when pid 1 exits, just before it+ends the QEMU run: with a root partition (boot contract v2), and also in+the headless self-test when the shell exits. It prints one line per+mount:++```+[init] pid 1 (/sbin/init) exited: Exited(7); ending the run+[shutdown] unmounted /mnt/share (exfat)+...+[shutdown] unmounted /mnt/disk0 (fat32)+...+[shutdown] unmounted / (ext2)+```++When the `reboot` system call (169) arrives (Clarke's K7), its POWER_OFF+should call the same `boot::shutdown()`, then stop the machine.++**Testing it.** `/sbin/init` (`user/inittest.c`) now also writes a file+to `/mnt/disk0`, and `scripts/boot-v2-check.sh` boots a writable copy+of B: (`disk.img`) instead of a throwaway snapshot. After the run it+cuts out B:'s FAT32 partition and runs `fsck.fat -n` on it: no errors,+and no dirty bit. The ext2 root and the exFAT share are still checked+with `e2fsck` and `fsck.exfat` as before. ## A second CPU family: arm64 (stage 22)  Everything up to stage 21 runs on one kind of CPU: **x86_64**, the PC@@ -5668,6 +5716,7 @@ timers, user ids), and Unix sockets with `/proc/self` (stage 18). - [x] The vDSO, static PIE and mremap (stage 19): `__vdso_clock_gettime`/`gettimeofday`/`time`/`getcpu` with a seqlocked vvar page and `AT_SYSINFO_EHDR`, a nanosecond kernel clock, ET_DYN programs without PT_INTERP at a random base (`AT_PHDR`/`AT_ENTRY`/`AT_BASE`, `PT_GNU_STACK`/`PT_TLS`/`PT_GNU_RELRO`), musl-gcc C programs in the build, `mremap` with `MREMAP_MAYMOVE`/`MREMAP_FIXED`, the threads flake fixed, Linux device numbers and `ttyname` — [man 7 vdso](https://man7.org/linux/man-pages/man7/vdso.7.html), [man 2 mremap](https://man7.org/linux/man-pages/man2/mremap.2.html) - [x] Plain static programs and timerfd (stage 20): the kernel moved to `0x200_0000_0000` so user space starts at 4 MiB and stock `musl-gcc -static` ET_EXEC binaries run unmodified; `timerfd_create`/`settime`/`gettime` on the existing timer table, readable with `read`/poll/select/epoll - [x] Finding the root and starting init (stage 21): the GPT "Linux root (x86-64)" partition mounted at `/`, `/sbin/init` -> `/bin/init` -> `/bin/sh` as pid 1 with build-time argv/env knobs and its exit status passed to QEMU, an exFAT "Microsoft basic data" partition at `/mnt/share` (read-write), `msync`, `RUSTBELT_DISK0` and `CARGO_TARGET_DIR` everywhere — [Discoverable Partitions Specification](https://uapi-group.org/specifications/specs/discoverable_partitions_specification/), [man 2 msync](https://man7.org/linux/man-pages/man2/msync.2.html)+- [x] Clean shutdown (0.21.1): when pid 1 exits, every file system is synced and unmounted, newest first, before QEMU stops, so B:'s FAT32 dirty bit is cleared (Clarke's K12) — [OSDev: FAT](https://wiki.osdev.org/FAT), [man 8 fsck.fat](https://man7.org/linux/man-pages/man8/fsck.fat.8.html) - [x] A second CPU family, arm64 (stage 22, on the `arm64` branch): `src/arch/{x86_64,aarch64}` with a small interface, a `-kernel` ELF with its own linker script on QEMU `virt` with a Cortex-A53, EL2 -> EL1, exception vectors with an ESR/ELR/FAR report, MMU on (identity + higher half), a device tree reader, GICv2 and the generic timer, semihosting exit, a board layer with QEMU virt and a Raspberry Pi 3 skeleton — [Arm: Learn the architecture (exception model, MMU, GIC, generic timer)](https://developer.arm.com/documentation/102412/latest/), [OSDev: Raspberry Pi Bare Bones](https://wiki.osdev.org/Raspberry_Pi_Bare_Bones), [Devicetree specification](https://www.devicetree.org/specifications/), [QEMU: virt board](https://www.qemu.org/docs/master/system/arm/virt.html) - [ ] arm64 on the Raspberry Pi 3 (see "Hardware roadmap" below) - [ ] The kernel/OS split and a musl port (next; waits for Tyler's go; see the missing-syscall list in "musl prerequisites")
@@ -8,13 +8,15 @@ # inittest exits 7 when all its checks pass, and the kernel turns exit(n) # into QEMU exit code 33 + 2n, so this script wants QEMU to exit with 47. # Afterwards it runs e2fsck and fsck.exfat on the two partitions of the-# copy, which init wrote to.+# copy, which init wrote to. Since 0.21.1, B: (disk.img) is a writable copy+# too: init writes a file there, and fsck.fat must find the FAT32 partition+# clean, with no dirty bit (the kernel unmounts everything when pid 1 exits). # # Usage: ./scripts/boot-v2-check.sh set -euo pipefail cd "$(dirname "$0")/.." T="${CARGO_TARGET_DIR:-target}"-export PATH="$HOME/.cargo/bin:$PATH:/usr/sbin:/sbin" # e2fsck, fsck.exfat+export PATH="$HOME/.cargo/bin:$PATH:/usr/sbin:/sbin" # e2fsck, fsck.exfat, fsck.fat, sfdisk  cargo build --features headless touch $T/x86_64-rust_kernel/debug/rustbelt # (see scripts/runner.sh)@@ -23,11 +25,13 @@ IMG=$T/x86_64-rust_kernel/debug/bootimage-rustbelt.bin  ROOT=$T/boot-v2-root.img cp $T/rootfs-gpt.img "$ROOT"+B=$T/boot-v2-disk0.img # stage 21.1: a writable copy of B:+cp $T/disk.img "$B" LOG=$T/boot-v2-serial.log set +e timeout 60 qemu-system-x86_64 -smp 4 -cpu max \     -drive format=raw,file="$IMG" \-    -drive file=$T/disk.img,format=raw,if=virtio,snapshot=on \+    -drive file="$B",format=raw,if=virtio \     -drive file=$T/ext2.img,format=raw,if=virtio,snapshot=on \     -drive file=$T/exfat.img,format=raw,if=virtio,snapshot=on \     -drive file=$T/btrfs.img,format=raw,if=virtio,snapshot=on \@@ -41,7 +45,8 @@ ok=1 for line in "mounted at / (root partition)" "mounted at /mnt/share" \             "[inittest] started as /sbin/init" "[inittest] ok: I am pid 1" \             'headless: argv[1] is "selftest"' "[inittest] 0 failed" \-            "[init] pid 1 (/sbin/init) exited: Exited(7)"; do+            "[init] pid 1 (/sbin/init) exited: Exited(7)" \+            "[shutdown] unmounted /mnt/disk0 (fat32)" "[shutdown] unmounted / (ext2)"; do     grep -qF -- "$line" "$LOG" || { echo "[boot-v2] MISSING: $line"; ok=0; } done [ "$code" = 47 ] || { echo "[boot-v2] QEMU exited with $code, not 47 (= exit(7))"; ok=0; }@@ -53,8 +58,18 @@ e2fsck -fn $T/boot-v2-p1.img >/dev/null || { echo "[boot-v2] e2fsck found errors fsck.exfat -n $T/boot-v2-p2.img >/dev/null || { echo "[boot-v2] fsck.exfat found errors on the share"; ok=0; } rm -f $T/boot-v2-p1.img $T/boot-v2-p2.img +# B:'s FAT32 partition (the first one on disk.img): no dirty bit (stage 21.1).+read -r BSTART BSIZE < <(sfdisk -d "$B" | awk -F'[=,]' '/start=/ {gsub(/ /,""); print $2, $4; exit}')+dd if="$B" of=$T/boot-v2-b1.img bs=512 skip="$BSTART" count="$BSIZE" status=none+if ! FATOUT=$(fsck.fat -n $T/boot-v2-b1.img 2>&1) || grep -qi dirty <<<"$FATOUT"; then+    echo "[boot-v2] fsck.fat isn't happy with B:"; echo "$FATOUT"; ok=0+fi+grep -q "from-init.txt" < <(mdir -i $T/boot-v2-b1.img -b :: 2>/dev/null || true) \+    || [ -z "$(command -v mdir)" ] || { echo "[boot-v2] init's file isn't on B:"; ok=0; }+rm -f $T/boot-v2-b1.img "$B"+ if [ $ok = 1 ]; then-    echo "[boot-v2] OK: init ran as pid 1, QEMU exited 47 (= exit 7), both partitions fsck-clean"+    echo "[boot-v2] OK: init ran as pid 1, QEMU exited 47 (= exit 7), both partitions fsck-clean, B: has no dirty bit"     exit 0 fi exit 1
@@ -86,6 +86,16 @@ pub fn qemu_exit_value(status: ExitStatus) -> u32 {     } } +/// Shut down cleanly (stage 21.1, Clarke's K12): sync and unmount every+/// file system, newest mount first, so the disks are left the way a host+/// expects (no FAT "dirty bit"). Returns the log lines. This is the one+/// shutdown path: main.rs calls it when pid 1 exits, just before ending the+/// QEMU run, and a future `reboot(169)` POWER_OFF (K7) should call it too.+/// Don't use the file systems after it.+pub fn shutdown() -> Vec<String> {+    fs::unmount_all()+}+ /// End the QEMU run with pid 1's exit status. pub fn exit_qemu_with(status: ExitStatus) {     use x86_64::instructions::port::Port;
@@ -180,8 +180,10 @@ type FatDir<'a> = fatfs::Dir<'a, BlockIo, fatfs::NullTimeProvider, fatfs::LossyO  /// A mounted FAT volume. The fatfs `FileSystem` sits behind one Mutex /// (shared via Arc with every FatInode), so FAT operations are serialized.+/// It's `None` after [`FileSystem::unmount`] (stage 21.1): from then on+/// every operation fails with EIO. pub struct FatFs {-    fs: Arc<Mutex<Fs>>,+    fs: Arc<Mutex<Option<Fs>>>, }  impl FatFs {@@ -191,12 +193,12 @@ impl FatFs {         // (1980-01-01) via NullTimeProvider.         let options = fatfs::FsOptions::new().time_provider(fatfs::NullTimeProvider::new());         let fs = fatfs::FileSystem::new(BlockIo::new(dev), options).map_err(errno)?;-        Ok(Arc::new(FatFs { fs: Arc::new(Mutex::new(fs)) }))+        Ok(Arc::new(FatFs { fs: Arc::new(Mutex::new(Some(fs))) }))     }      /// The volume label (from `mkfs.vfat -n`).     pub fn label(&self) -> String {-        String::from(self.fs.lock().volume_label().trim_end())+        self.fs.lock().as_ref().map(|fs| String::from(fs.volume_label().trim_end())).unwrap_or_default()     } } @@ -210,7 +212,10 @@ impl FileSystem for FatFs {     /// statfs (stage 17): FAT counts clusters, so a "block" is a cluster.     /// There's no inode table, so files = 0 (Linux's vfat does the same).     fn statfs(&self) -> super::StatFs {-        let fs = self.fs.lock();+        let guard = self.fs.lock();+        let Some(fs) = guard.as_ref() else {+            return super::StatFs { magic: super::MSDOS_MAGIC, block_size: 512, name_max: 255, ..Default::default() };+        };         let (bs, total, free) = match fs.stats() {             Ok(st) => (st.cluster_size() as u64, st.total_clusters() as u64, st.free_clusters() as u64),             Err(_) => (fs.cluster_size() as u64, 0, 0),@@ -227,12 +232,30 @@ impl FileSystem for FatFs {             read_only: false,         }     }+    /// fatfs keeps the FSInfo sector (free cluster count, next free+    /// cluster) in memory; write it out.+    fn sync(&self) -> Result<(), Errno> {+        match self.fs.lock().as_ref() {+            Some(fs) => fs.flush().map_err(errno),+            None => Ok(()),+        }+    }+    /// fatfs sets the volume's **dirty bit** (in the boot sector) on the+    /// first write and clears it only in its `unmount()`. Without this,+    /// every run that wrote to a FAT disk left it "not properly unmounted"+    /// for fsck, Windows and macOS (stage 21.1, Clarke's K12).+    fn unmount(&self) -> Result<(), Errno> {+        match self.fs.lock().take() {+            Some(fs) => fs.unmount().map_err(errno),+            None => Ok(()),+        }+    } }  /// A file or directory on a FAT volume, identified by its path from the /// volume root ("" = the root, "bin/hello" = a file). pub struct FatInode {-    fs: Arc<Mutex<Fs>>,+    fs: Arc<Mutex<Option<Fs>>>,     path: String,     kind: InodeKind, }@@ -244,8 +267,8 @@ impl FatInode {      /// Run `f` on this inode's directory (re-opened from the root).     fn with_dir<T>(&self, f: impl FnOnce(&FatDir) -> Result<T, Errno>) -> Result<T, Errno> {-        let fs = self.fs.lock();-        let root = fs.root_dir();+        let guard = self.fs.lock();+        let root = guard.as_ref().ok_or(Errno::EIO)?.root_dir();         if self.path.is_empty() {             f(&root)         } else {@@ -258,8 +281,8 @@ impl FatInode {         if self.kind == InodeKind::Directory {             return Err(Errno::EISDIR);         }-        let fs = self.fs.lock();-        let mut file = fs.root_dir().open_file(&self.path).map_err(errno)?;+        let guard = self.fs.lock();+        let mut file = guard.as_ref().ok_or(Errno::EIO)?.root_dir().open_file(&self.path).map_err(errno)?;         f(&mut file)     } }
@@ -276,6 +276,18 @@ pub trait FileSystem: Send + Sync {     fn statfs(&self) -> StatFs {         StatFs { magic: TMPFS_MAGIC, block_size: 4096, name_max: 255, ..Default::default() }     }+    /// Write out anything still held in memory (stage 21.1). The default is+    /// for file systems that write everything straight to the disk (ramfs,+    /// ext2, exFAT, btrfs): nothing to do.+    fn sync(&self) -> Result<(), Errno> {+        Ok(())+    }+    /// The last call before the machine stops (stage 21.1): sync, and mark+    /// the volume "cleanly unmounted" if the format has such a mark. After+    /// it, the file system may refuse to do anything (EIO).+    fn unmount(&self) -> Result<(), Errno> {+        self.sync()+    } }  struct Mount {@@ -301,6 +313,24 @@ pub fn mount(path: &str, fs: Arc<dyn FileSystem>) -> Result<(), Errno> {     Ok(()) } +/// Shutdown (stage 21.1): sync and unmount every mounted file system,+/// newest mount first (so "/", mounted first, goes last), and empty the+/// mount table. Returns one line per mount for the log. Called by+/// `boot::shutdown` -- the one place the kernel stops the machine from.+pub fn unmount_all() -> Vec<String> {+    let mut log = Vec::new();+    loop {+        // Take the mount out of the table first, so nothing new finds it,+        // and don't hold the table's lock while the disk is written.+        let Some(m) = MOUNTS.lock().pop() else { break };+        match m.fs.unmount() {+            Ok(()) => log.push(alloc::format!("unmounted {} ({})", m.path, m.fs.name())),+            Err(e) => log.push(alloc::format!("unmounting {} ({}) failed: {:?}", m.path, m.fs.name(), e)),+        }+    }+    log+}+ /// (mount path, fs type) of every mount, for printing. pub fn mounts() -> Vec<(String, &'static str)> {     MOUNTS.lock().iter().map(|m| (m.path.clone(), m.fs.name())).collect()
@@ -195,6 +195,7 @@ fn shell_supervisor() {                 frames_before,                 rustbelt::memory::frames_in_use()             );+            shutdown();             rustbelt::exit_qemu(if ok { rustbelt::QemuExitCode::Success } else { rustbelt::QemuExitCode::Failed });         }         kprintln!("[kernel] shell exited ({:?}); starting a new one", status);@@ -216,15 +217,25 @@ fn run_init_v2() {     match result {         Ok((path, status)) => {             kprintln!("[init] pid 1 ({}) exited: {:?}; ending the run", path, status);+            shutdown();             rustbelt::boot::exit_qemu_with(status);         }         Err(e) => {             kprintln!("[init] no init found ({:?}): tried {:?}", e, rustbelt::boot::INIT_PATHS);+            shutdown();             rustbelt::exit_qemu(rustbelt::QemuExitCode::Failed);         }     } } +/// Stage 21.1: sync and unmount everything before QEMU stops (see+/// `boot::shutdown`), logging each mount.+fn shutdown() {+    for line in rustbelt::boot::shutdown() {+        kprintln!("[shutdown] {}", line);+    }+}+ /// `demos` feature: the stage 5/6 user programs, one after the other. #[cfg(feature = "demos")] fn run_demo_programs() {
@@ -75,6 +75,15 @@ static void contract(int argc, char **argv) {     check(read_file("/mnt/share/from-init.txt", buf, sizeof buf) == 16 && strcmp(buf, "written by init\n") == 0,           "...and the new file reads back"); +    /* B: (the FAT32 data disk, /mnt/disk0). Writing to it sets FAT's+     * "dirty bit"; since 0.21.1 the kernel's shutdown clears it again when+     * pid 1 exits (scripts/boot-v2-check.sh runs fsck.fat afterwards). */+    fd = open("/mnt/disk0/from-init.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);+    check(fd >= 0 && write(fd, "written by init\n", 16) == 16, "/mnt/disk0 (B:, FAT32) is writable");+    close(fd);+    check(read_file("/mnt/disk0/from-init.txt", buf, sizeof buf) == 16 && strcmp(buf, "written by init\n") == 0,+          "...and the new file reads back");+     /* msync (26): Rustbelt has nothing to write back, but checks its      * arguments like Linux. */     long pg = 4096;