The cache was NBUF buffers, 1 MB, on every machine: enough for one with tens of megabytes and nothing on one with gigabytes, which re-read the same directory and binary blocks off the disk for every command. It still starts as those 256 in the kernel image, and a miss with every buffer busy now makes a new one rather than evicting, up to 1/32 of RAM or 256 MB. Growing on demand means nothing is spent that is not used, and binit() needs nothing from the page allocator. Lookups go through a hash on (dev, blockno) instead of walking the LRU list, and bflush() walks a creation-order list with an unlocked filter first, so neither costs more at 65536 buffers than it did at 256. struct buf's data is a pointer to its own page now; the drivers copy through their own DMA buffers, so where it sits does not matter. On a 2 GB QEMU guest: a 40 MB file read back in 0.89 s instead of 1.83, the big binaries in 0.17 s instead of 0.26. /proc/fsstat shows the cache as buf_cached now / most. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
403 lines
20 KiB
Markdown
403 lines
20 KiB
Markdown
# The VFS
|
|
|
|
The layer between `open(2)` and a filesystem. SchFS is described separately in
|
|
[schfs.md](schfs.md); this is the machinery it plugs into, plus the buffer cache,
|
|
mounts, and procfs.
|
|
|
|
---
|
|
|
|
## `struct fs_ops`
|
|
|
|
A filesystem is a vtable, chosen by `iget()` from the device number and never
|
|
null once an inode is held.
|
|
|
|
```c
|
|
struct fs_ops {
|
|
const char *name;
|
|
|
|
void (*iload) (struct inode *); // fill type/size/nlink from disk
|
|
void (*istore) (struct inode *); // write them back
|
|
void (*ifree) (struct inode *); // last link gone, release it
|
|
|
|
int (*read) (struct inode *, int user_dst, uint64 dst, uint64 off, uint n);
|
|
int (*write) (struct inode *, int user_src, uint64 src, uint64 off, uint n);
|
|
void (*truncate)(struct inode *, uint64 newsize);
|
|
|
|
struct inode *(*dirlookup)(struct inode *dp, char *name, uint *poff);
|
|
int (*dirlink) (struct inode *dp, char *name, uint64 inum);
|
|
int (*dirunlink)(struct inode *dp, char *name, uint off);
|
|
int (*dirempty) (struct inode *dp);
|
|
|
|
struct inode *(*ialloc)(uint dev, short type);
|
|
int (*getdents)(struct inode *dp, char *out, int cap, uint64 *off);
|
|
|
|
uint max_write; // largest write in one call; 0 for no limit
|
|
uint refuse_types; // 1 << T_* the filesystem cannot store: EPERM
|
|
int no_hardlinks; // link() fails with EPERM
|
|
};
|
|
```
|
|
|
|
The implementations: `schfs_ops` (`kernel/fs/schfs/vfs.c`), `fat_ops`
|
|
(`kernel/fs/fat/fat.c`, see [fat.md](fat.md)), `procfs_ops`
|
|
(`kernel/fs/procfs.c`) and `devfs_ops` (`kernel/fs/devfs.c`).
|
|
|
|
**Creation can fail, and is undone when it does.** `ialloc` and `dirlink` used
|
|
to panic on failure, because on v1 and SchFS they cannot fail for a reason the
|
|
caller could have avoided. FAT can: the volume fills, the fixed root directory
|
|
of FAT16 holds 512 names, a name holds a character FAT does not allow. So
|
|
`createat_x()` gives the object back and reports `ENOSPC`, `EINVAL`, `EPERM` or
|
|
`EROFS`, and `open`, `mkdir`, `mknod` and `symlink` return that errno instead of
|
|
`ENOENT`.
|
|
|
|
**A short write is a full disk.** `filewrite()` panicked on one, which held as
|
|
long as no filesystem ever wrote short. Now `write(2)` returns what went in, and
|
|
the next call gets the error.
|
|
|
|
`poff` is deliberately **an opaque cookie** rather than a byte offset —
|
|
meaningful only to the same filesystem's `dirunlink`. A directory does not have
|
|
to be an array of fixed-size records for this interface to work.
|
|
|
|
`ialloc` creates an object **with no name**. The caller sets `nlink` and calls
|
|
`dirlink`; until it does, nothing can reach the object. If the caller dies in
|
|
between, the result is an unreferenced record that is harmless and, in SchFS,
|
|
never reaches the disk anyway — nothing is durable until the transaction commits.
|
|
|
|
### The one place the models disagree
|
|
|
|
The VFS inherited xv6's shape: allocate an inode, fill it in, link a name to it.
|
|
SchFS has no free-standing inode — an object exists because a record with its id
|
|
exists.
|
|
|
|
`nlink` is the sharper disagreement. **The VFS adjusts it itself** and then calls
|
|
`iupdate`, so SchFS's own link accounting is deliberately bypassed in the VFS
|
|
path: `schfs_dir_add`/`schfs_dir_del` are used rather than `schfs_link`/
|
|
`schfs_unlink`, and `iput`'s `ifree` hook releases the object once the VFS has
|
|
decided there are no names left.
|
|
|
|
> Two pieces of code counting links independently is precisely how a filesystem
|
|
> loses a file that is still referenced.
|
|
|
|
`nlink` is an `int`, not a `short`. SchFS stores a `uint32` and the narrower type
|
|
truncated it, so a file past 32767 names came back with a *negative* count — and
|
|
`iput` frees an object whose count reaches zero. Signed on purpose: `nlink--` on
|
|
a count that is somehow already zero should land somewhere a check can see it,
|
|
not wrap to four billion.
|
|
|
|
## Path resolution
|
|
|
|
`namex()` walks a path one component at a time. It was refactored into
|
|
`namex_from(struct inode *start, ...)`, parameterising what used to be a
|
|
hardcoded `idup(proc->cwd)`.
|
|
|
|
That refactor is what made the `*at()` family work. Every one of them — `openat`,
|
|
`fstatat`, `unlinkat`, `renameat`, `linkat`, `symlinkat`, `mkdirat`, `mknodat`,
|
|
`readlinkat`, `faccessat` — previously handled only two cases: `AT_FDCWD`, or an
|
|
absolute path with `dirfd` ignored. A **real** dirfd with a **relative** path is
|
|
the standard, efficient way any directory-tree walker avoids rebuilding full
|
|
path strings, and it was either rejected outright or — worse, in `openat` —
|
|
silently resolved against `proc->cwd`, which can open the wrong file entirely.
|
|
|
|
`sys_fs.c` now has `at_dir(dirfd)`, `at_namei()` and `at_nameiparent()`, and
|
|
every `*at()` call uses them.
|
|
|
|
## The buffer cache
|
|
|
|
`kernel/dev/bio.c`, xv6's interface: `bread()` returns a locked buffer,
|
|
`bwrite()` writes it, `bdirty()` marks it for later, `brelse()` releases. One
|
|
sleeplock per buffer and an LRU list for eviction.
|
|
|
|
**It grows with the machine.** It starts as the 256 buffers in the kernel
|
|
image (1 MB), and a miss with every buffer busy makes a new one instead of
|
|
evicting, until the cache is 1/32 of RAM, at most 256 MB: 32 MB on 1 GB, 256 MB
|
|
from 8 GB up. It never shrinks, so the fraction is kept small. Lookups go
|
|
through a hash on `(dev, blockno)`, not the LRU walk, so a 64k-buffer cache
|
|
costs no more per `bread()` than a 256-buffer one. `/proc/fsstat` shows
|
|
`buf_cached` as now / most.
|
|
|
|
On a 2 GB machine under QEMU, reading a 40 MB file back went from 1.83 s to
|
|
0.89 s, and `ls -R /` and the big binaries are each served from the cache on the
|
|
second run instead of from the disk.
|
|
|
|
**Sleeping is the point.** A buffer lock is held across a disk read, which is
|
|
why every path that touches a filesystem must be able to sleep — and why the
|
|
root mount happens in `forkret()` rather than in `main()`.
|
|
|
|
## Mounts
|
|
|
|
`mount()` and `umount2()` at their Linux numbers, with a mount table consulted
|
|
during path resolution. `/proc` is mounted by init:
|
|
|
|
```c
|
|
mount("proc", "/proc", "proc", 0, 0);
|
|
```
|
|
|
|
The filesystem types are `proc`, `devtmpfs`, `sysfs`, `schfs` and `vfat` (also
|
|
accepted as `msdos` and `fat`). Unmounting a FAT volume while anything on it is open, or
|
|
is some process's working directory, fails with `EBUSY`.
|
|
|
|
## procfs
|
|
|
|
`kernel/fs/procfs.c`. A real filesystem behind the same vtable, not a set of
|
|
character devices.
|
|
|
|
**The reason it had to become one is `ps`.** /proc used to be `mknod`'d nodes —
|
|
init ran `mknod("/proc/uptime", 6, 2)` and friends, so every file was a real
|
|
inode on the root filesystem with a major/minor behind it. That is fine for a
|
|
fixed list and cannot work at all for the thing /proc is mostly for: **one
|
|
directory per process**. You cannot `mknod` a directory for a process that does
|
|
not exist yet and will be gone in a minute. `ps` listed `/proc`, got no numbers,
|
|
and reported nothing. It was never a `ps` problem.
|
|
|
|
Nothing is stored. Directory listings are generated from the process table when
|
|
asked; file contents when read.
|
|
|
|
Three things worth knowing:
|
|
|
|
- **The split is deliberate.** `kernel/fs/procfs.c` owns the *namespace* — what
|
|
exists. `kernel/dev/procfs.c` keeps the *generators* — what the global files
|
|
say. They change for different reasons, and `proc_gen_global()` is the only
|
|
thing that crosses between them.
|
|
- **procfs never walks the process table.** `proc_snapshot()` copies what is
|
|
needed out from under `ptable.lock`, so a process that exits between the
|
|
readdir that listed it and the open that reads it produces a stale line rather
|
|
than a use-after-free.
|
|
- **Sizes are reported as zero**, exactly as Linux does. A synthetic file has no
|
|
length until it is generated, and generating every one on every `stat()` to
|
|
fill in a number almost nothing reads would be absurd.
|
|
|
|
`/proc/mounts`, `/proc/filesystems` and `/proc/partitions` are in Linux's
|
|
formats to the column, since what reads them (`df`, `mount`, `fdisk -l`,
|
|
installer scripts) parses them. Every disk and partition in `/proc/partitions`
|
|
has major 8 and its device number as the minor, matching `/dev`.
|
|
|
|
## devtmpfs
|
|
|
|
`/dev` is generated, not stored. `kernel/fs/devfs.c`, behind the same `fs_ops`
|
|
vtable, mounted by init with `mount("dev", "/dev", "devtmpfs", 0, 0)`.
|
|
|
|
It used to be twenty `mknod()` calls in `cmd/schinit`, which was wrong twice
|
|
over:
|
|
|
|
**The cost.** Every node was a real inode on the root filesystem, and SchFS
|
|
commits each mutation as its own transaction group — a copy-on-write rewrite of
|
|
the path to the root, then an uberblock behind a cache flush. Measured at
|
|
**2600 ms of a first boot**, spent before init printed its first line. On later
|
|
boots the nodes already existed and the same code took 0 ms, so the cost was
|
|
invisible to anyone not building fresh images — which is to say, invisible to
|
|
everyone except whoever works on the system.
|
|
|
|
**The duplication, which matters more.** Major and minor numbers are the
|
|
kernel's: they are what `devsw[]` in `kernel/dev/cdev.c` dispatches on. init
|
|
held a second copy of that mapping and nothing checked the two agreed. Adding a
|
|
character device meant editing a userspace program, and a wrong minor there
|
|
produced a node that opened successfully and did nothing.
|
|
|
|
The table now sits next to the thing it describes. The namespace is all devfs
|
|
provides — reads and writes never reach it, because `fileread`/`filewrite` see
|
|
`T_DEV` and dispatch through `devsw` themselves.
|
|
|
|
It is read-only: `dirlink` and `ialloc` answer `EROFS` rather than pretending.
|
|
A node exists because the kernel has the device, or it does not exist.
|
|
|
|
### What is there
|
|
|
|
`console`, `null`, `keyboard`, `zero`, `full`, `random`, `urandom`, `tty`,
|
|
`stdin`, `stdout`, `stderr`, `fb0`, `mouse`, `hda`, `hdb`, and `input/event0`
|
|
and `input/event1` in one subdirectory.
|
|
|
|
Every disk and partition the block layer registered is there too, under the
|
|
name it was given (`sata0`, `sata0p2`, `nvme0p1`, `usb0p1`): major 8, minor its
|
|
block device number, listed only while it is present. Reads and writes go
|
|
through `blkdev_xfer` in whole sectors, end at the end of the device, and do
|
|
not pass through the buffer cache. A partition need not be a whole number of
|
|
4 KB blocks, and the buffer cache's last block of one would run into the next. Minors under major 1 follow Linux's
|
|
mem-major layout where it costs nothing.
|
|
|
|
The raw disks are created whether or not a drive is present: opening one with
|
|
nothing behind it fails with `ENXIO`, which is a better answer than a missing
|
|
path — and with no node at all, `echo x > /dev/hdb` silently creates an ordinary
|
|
file of that name and appears to work.
|
|
|
|
### What is there
|
|
|
|
Per-process directories, plus the global files: `meminfo`, `uptime`, `cpuinfo`,
|
|
`version`, `stat`, `loadavg`, `mounts`, `filesystems`, `schfs`, `pci`,
|
|
`interrupts`, `devices`, `netif`, and `sysrq`.
|
|
|
|
**/proc is read-only except `sysrq`**, which is the one deliberate write target,
|
|
and the code says so in one place. Marking every procfs inode read-only once
|
|
broke `echo s > /proc/sysrq` and took out three USB checks within a minute —
|
|
they drive the disk through sysrq.
|
|
|
|
`sysrq` keys: `c` crash on purpose, `u` / `s` storage checks, `d` display
|
|
selftest, `t` text specimen, `n` the netdev ARP selftest.
|
|
|
|
---
|
|
|
|
|
|
## sysfs
|
|
|
|
`/sys`, in Linux's layout for the parts that exist here. `kernel/fs/sysfs.c`,
|
|
mounted by init with `mount("sysfs", "/sys", "sysfs", 0, 0)`.
|
|
|
|
| path | contents |
|
|
|------|----------|
|
|
| `/sys/bus/pci/devices/0000:BB:DD.F/` | `vendor device class revision subsystem_vendor subsystem_device irq resource config uevent` |
|
|
| `/sys/bus/usb/devices/B-P[.P...]/` | `idVendor idProduct bcdDevice busnum devnum speed bDeviceClass bDeviceSubClass bDeviceProtocol bNumConfigurations uevent` |
|
|
| `/sys/firmware/acpi/tables/` | each ACPI table, raw; a repeated signature is numbered from 1 (`SSDT1`, `SSDT2`) |
|
|
| `/sys/firmware/dmi/tables/` | `smbios_entry_point` and `DMI`, raw |
|
|
| `/sys/firmware/memmap/N/` | `start end type`, the firmware's map as the loader handed it over |
|
|
| `/sys/firmware/efi/` | present when UEFI booted the kernel; empty |
|
|
| `/sys/class/dmi/id/` | `sys_vendor product_name product_version board_name bios_version`, and the rest Linux has, except serials and the UUID |
|
|
|
|
**Linux's layout because everything that reads hardware reads it from there.**
|
|
toybox's `lspci` and `lsusb` walk `/sys/bus/*/devices` and parse each `uevent`
|
|
(`DRIVER=`, `PCI_ID=`, `PRODUCT=`, `BUSNUM=`), so with these files they work
|
|
unmodified, with names from `/usr/share/hwdata/pci.ids` and `usb.ids`. Formats
|
|
follow Linux to the character: `0x8086` in `vendor`, `%04x` in `idVendor`,
|
|
`PCI_CLASS` in upper case.
|
|
|
|
Like `/proc`, nothing is stored: a listing comes from the live PCI list, the
|
|
USB core's table of attached devices, the ACPI root table and the SMBIOS table
|
|
at the moment it is read, so a stick shows up in `/sys/bus/usb/devices` as soon
|
|
as it is attached. Text files report size 0, as `/proc`'s do; binary ones (the
|
|
tables, `config`) report their real length, since tools copy them by it.
|
|
|
|
What is missing, compared with Linux: `/sys/devices` and its symlinks (the
|
|
`bus/*/devices` entries are directories rather than links into it),
|
|
`/sys/class` beyond `dmi`, `/sys/block`, and anything writable. PCI `config` is
|
|
256 bytes, because config space is read through port `0xCF8`, which reaches
|
|
no further.
|
|
|
|
The machine's identity comes from SMBIOS (`kernel/dev/dmi.c`): the UEFI loader
|
|
hands over the entry point from the firmware's configuration table, and the
|
|
kernel falls back to scanning `0xF0000`-`0xFFFFF`. The boot log carries Linux's
|
|
line for it, `DMI: <vendor> <product>/<board>, BIOS <version> <date>`.
|
|
|
|
## Errno, and why `return -1` is a bug
|
|
|
|
A syscall that fails returns a negative errno. `-1` is therefore **`-EPERM`**,
|
|
"operation not permitted" — and about ninety error paths in `kernel/sys/sys_fs.c`
|
|
still say `return -1` because that is what xv6 did, where the convention was
|
|
just "negative means no".
|
|
|
|
It is not a cosmetic difference. Callers branch on `errno`, and the standard
|
|
shape for removing a file you are not sure about is:
|
|
|
|
```c
|
|
if (unlink(p) < 0 && errno != ENOENT)
|
|
complain();
|
|
```
|
|
|
|
Against `-1` every such test takes the complaining branch. That is how it was
|
|
found — zsh, saving its history, reported failures for things that had
|
|
succeeded:
|
|
|
|
```
|
|
zsh: locking failed for /root/.zsh_history: operation not permitted
|
|
zsh: failed to write history file /root/.zsh_history.new: operation not permitted
|
|
```
|
|
|
|
`link`, `unlink`, `rename` and `renameat` return real values now — `ENOENT`,
|
|
`EEXIST`, `EXDEV`, `EISDIR`, `ENOTEMPTY`, `EFAULT`. **The rest of the file does
|
|
not**, and that is the honest state: these four were corrected because these
|
|
four were the ones a program tripped over.
|
|
|
|
`symlink()` joined them later, and for the same reason: `symlink()` is the
|
|
classic atomic lock — create the link, and if it exists someone else holds it —
|
|
so zsh distinguishes `EEXIST` ("contended, retry") from anything else ("this
|
|
cannot work, give up"). A bare `-1` is `-EPERM`, which put every shell on the
|
|
machine in the second bucket on a filesystem that supports symlinks perfectly
|
|
well, and printed the first line above on every boot.
|
|
|
|
### rename() replaces its target, and why it did not
|
|
|
|
`rename()` used to refuse an existing destination, which broke
|
|
write-to-a-temporary-then-rename — how every careful program saves a file, and
|
|
how zsh saves history. The second save of anything failed with `EEXIST` and left
|
|
a `.zsh_history.new` behind for ever.
|
|
|
|
The first attempt to fix it was reverted after it lost both files, and the note
|
|
left behind blamed HTree slot reuse. That was the wrong suspect. **`sys_rename`
|
|
was removing the old name by hand** — `memset` a `struct dirent` to zero and
|
|
`writei()` it into the parent directory at the offset `dirlookup` returned. That
|
|
is the v1 filesystem's on-disk layout, written directly, bypassing `dp->ops`.
|
|
On SchFS a directory is a B-tree with an index over it: "the record at that
|
|
offset" is not a thing, and writing zeroes there corrupted the directory while
|
|
leaving the name in the index. Adding a second removal on top of that is what
|
|
lost both files.
|
|
|
|
Every other caller in the file already used `dirunlink()`. This one had been
|
|
open-coding a filesystem it no longer ran on, and the bug was invisible while
|
|
nothing asked it to remove two names instead of one.
|
|
|
|
The lock discipline is the delicate part and is inherited rather than invented:
|
|
`dp_old` and `dp_new` are frequently the *same* directory reached through two
|
|
`nameiparent()` calls, which is two references to one inode. No two directory
|
|
locks are ever held at once, and the source inode stays unlocked throughout,
|
|
held alive by an `nlink` bump.
|
|
|
|
Renaming a *directory* is still refused with `EISDIR`, and so is replacing one:
|
|
both need the `..` fixup and the ancestor-loop check that are not written.
|
|
|
|
## Names and paths
|
|
|
|
A name is at most 255 bytes and a path 4096 including its NUL, Linux's
|
|
`NAME_MAX` and `PATH_MAX` (`VFS_NAME_MAX` and `VFS_PATH_MAX` in
|
|
`include/file.h`). Longer is `ENAMETOOLONG`. A filesystem may hold less: the v1
|
|
format stores `DIRSIZ` bytes and refuses longer names rather than matching on
|
|
their first 30.
|
|
|
|
Both limits used to be the copy buffers: every component went through a
|
|
`DIRSIZ` array and every path through a `MAXPATH` one, so a name was cut at 29
|
|
bytes and a path at 127, silently. Two files sharing their first 29 bytes were
|
|
one file. The path buffers are a page each now, from `kalloc()`, since two of
|
|
them would be most of a kernel stack.
|
|
|
|
## Symlinks
|
|
|
|
Followed the way Linux follows them, in `namex_from()`. A link in the middle of
|
|
a path (`/a/link/b`) always is; the last component only for the calls that
|
|
follow (`open`, `stat`, `chdir`, `execve`, `access`, `truncate`, `statfs`,
|
|
`utimensat`), not for `lstat`, `readlink`, `unlink`, `rename`, `link` and the
|
|
create paths. A relative target resolves from the directory holding the link,
|
|
not from the caller's cwd, and a 41st link in one lookup is `ELOOP`.
|
|
|
|
The walk never holds a directory lock while it reads a link, only a reference
|
|
to the directory it is in, so following one is restarting the walk from there
|
|
with the target spliced in front of what is left of the path.
|
|
|
|
`open(O_CREAT)` through a link to an existing file opens the file, so
|
|
`echo hi > link` writes where the link points. A dangling link is not created
|
|
through.
|
|
|
|
## What it does not do
|
|
|
|
- **No page cache.** `mmap` of a file reads the whole thing in at map time, and
|
|
writable `MAP_SHARED` of a file is refused rather than faked. This is the
|
|
single largest gap in the layer.
|
|
- **No symlinks in procfs**, which is why `/proc/<pid>/exe` is a regular file
|
|
and `ps -o comm` prints `[init]`.
|
|
- **`rename()` does not move directories**, and will not replace one. `EISDIR`
|
|
either way.
|
|
- **No named pipes.** `mkfifo()` fails with `EPERM`, Linux's answer for a
|
|
filesystem that cannot hold one. It used to *succeed* and make a character
|
|
device, because `mknod()` read a Linux program's `(mode, dev)` as the native
|
|
`(major, minor)`: `ls -l` showed `crw-rw-rw- 438, 0` where a `p` belongs. The
|
|
zsh port was configured with `zsh_cv_sys_fifo=yes` on the strength of that.
|
|
- **No `/dev/fd`, and no `/proc/self`.** A process cannot reach its own open
|
|
descriptors by path. `<(cmd)` needs one or the other — zsh now knows it has
|
|
neither and says so.
|
|
- **No `AF_UNIX` socket inodes.** Socket names live in a table in
|
|
`kernel/net/unix.c`, so `bind()` creates nothing that can be `stat()`ed or
|
|
`unlink()`ed.
|
|
- **No unified inode cache across filesystems** beyond the single `itable`.
|
|
- **A known, un-root-caused hang:** heavy `ls -lR` use across many commands in
|
|
one long shell session can eventually make a *later*, unrelated directory
|
|
hang; the identical command succeeds on a fresh boot. It is not inode or file
|
|
table exhaustion — both fail cleanly and neither fired. It smells like a lock
|
|
or refcount leak in the `*at()` paths or in toybox's own fd handling. The next
|
|
step is instrumenting `itable`/`ftable` occupancy across a long session to see
|
|
what is climbing.
|
|
- **`sys_delete`/`sys_show`/`sys_showstat`** are orphaned entries from the old
|
|
v1 filesystem's recycle bin. Nothing reaches them and they die with `fs.c`.
|