rust-kernel
Rustbelt: a hobby x86_64 kernel written in Rust
| author | Edward <edward@localhost> | 2026-10-31 20:32:43 -0700 |
|---|---|---|
| commit | 42ffc56884a9106dcaec8500c7d4a89bf7dcd04f | |
| parent | bef3194286245095a38d6f657808d73436ca6399 | |
| parent | 6bb2b254b3166e0776adecc097ab6292b94393a5 | |
| tree | browse 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).
| M | README.md | +49 -0 |
| M | scripts/boot-v2-check.sh | +20 -5 |
| M | src/boot.rs | +10 -0 |
| M | src/fs/fat.rs | +32 -9 |
| M | src/fs/mod.rs | +30 -0 |
| M | src/main.rs | +11 -0 |
| M | user/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;