Skip to content
FreeBSDDeep Dive July 11, 2026 5 min read

FreeBSD's VFS Layer: How Multiple Filesystems Share One Interface

How the Virtual File System abstraction lets FreeBSD run UFS, ZFS, tmpfs, and NFS behind the exact same open()/read()/write() calls, and what that abstraction actually costs.

Every FreeBSD filesystem — UFS, ZFS, tmpfs, msdosfs, an NFS mount, even procfs — answers to the exact same set of syscalls. open() doesn’t know or care whether the file it’s opening lives on a spinning disk formatted as UFS or in a ZFS dataset backed by three mirrored drives. That indifference isn’t an accident; it’s the entire point of the Virtual File System (VFS) layer, and it’s worth understanding as a mechanism rather than just accepting it as background plumbing.

Two abstractions, not one

VFS rests on two core data structures: the vnode and the mount structure. A mount represents one mounted filesystem instance — /, /usr, an NFS share, whatever — and holds a pointer to a table of filesystem-specific operations (vfsops): how to mount, unmount, sync, and look up the root vnode. A vnode represents one open file or directory within that filesystem, and holds its own operations table (vop, vnode operations) — how to read, write, look up a name inside it, get its attributes, and so on.

The kernel’s generic file-handling code — the code that backs open(), read(), write(), stat() — never touches UFS or ZFS internals directly. It calls through the vnode’s vop table: VOP_READ(vp, uio, ioflag, cred). What that call actually does is determined entirely by which filesystem the vnode belongs to, because each filesystem installs its own implementation of every VOP_* operation at mount time. UFS’s VOP_READ walks inode block pointers and reads from the buffer cache. ZFS’s VOP_READ walks a completely different object model — DMU objects, dnodes, indirect blocks — that has nothing structurally in common with UFS. The syscall layer above neither knows nor needs to know which one it’s talking to.

Why this is harder than “just define an interface”

The tricky part isn’t defining a common set of operations — it’s making genuinely different on-disk (or no-disk, for tmpfs) representations all satisfy semantics that userspace code assumes are universal. POSIX assumes files have inode numbers, that hard links exist, that rename() is atomic, that permissions follow a specific model. A filesystem that doesn’t naturally have one of those concepts still has to fake it convincingly enough that generic code — and the applications built on top of it — can’t tell the difference.

tmpfs is the cleanest illustration: it has no backing store at all, everything lives in memory-backed vnodes, yet it presents identical semantics to a disk-backed filesystem — permissions, timestamps, hard link counts, readdir() behavior — because its vop table implements every operation VFS expects, just against in-memory structures instead of disk blocks. From the syscall layer’s perspective, tmpfs and UFS are indistinguishable; the difference is entirely hidden below the vop boundary.

Name lookup: the operation that ties it together

The single most load-bearing VOP is VOP_LOOKUP — given a directory vnode and a filename component, return the vnode for that name (or ENOENT). Path resolution for something like /usr/local/bin/python3 isn’t one operation; it’s a sequence of VOP_LOOKUP calls, one per path component, each one potentially crossing into a different vnode’s filesystem-specific implementation, and — crucially — potentially crossing a mount point.

That mount-point crossing is where VFS’s two abstractions meet. When lookup resolves a component that turns out to be a mount point (a directory with something mounted on top of it), the generic pathname-resolution code checks the vnode’s mount flags, and if it’s covered, redirects the lookup to the root vnode of the mounted filesystem instead of continuing to resolve within the original one. This is how /usr/home can be UFS while /usr/home/user/.cache is tmpfs, and a program walking that path never notices the seam — the seam is VFS’s job to hide, not the application’s problem to handle.

The namecache: making lookup fast enough to matter

Calling into filesystem-specific VOP_LOOKUP for every single path component on every single open() would be brutally slow if it always meant touching a filesystem’s actual on-disk (or on-network, for NFS) structures. FreeBSD’s namecache sits in front of this: a hash table mapping (directory vnode, name) pairs to the vnode they resolved to last time, entirely independent of which filesystem is involved. A cache hit skips the filesystem-specific lookup path entirely and returns a vnode reference directly from the cache.

This is also why namecache correctness is subtle: the cache has to be invalidated precisely when the underlying filesystem’s namespace actually changes (a rename(), an unlink(), a directory being removed), and it has to cope with negative caching (remembering that a name definitely does not exist, so repeated failed lookups — extremely common, e.g. $PATH searches — don’t repeatedly hit the filesystem either) without ever serving a stale negative result after the name starts existing.

What this abstraction costs

VFS indirection isn’t free. Every VOP_* call is a pointer dereference plus an indirect function call rather than a direct call to, say, ufs_read() — the compiler can’t inline across that boundary because the actual target is decided at mount time, not at compile time. For most workloads this overhead is genuinely negligible next to actual I/O latency, but it’s real, and it’s part of why filesystems that need to bypass generic semantics entirely (certain specialized ZFS operations, for instance) sometimes expose ioctls rather than routing everything through the standard VOP path — the abstraction is general-purpose, not zero-cost, and FreeBSD’s own filesystem implementations occasionally step outside it when the generic interface can’t express what they need efficiently.

The tradeoff is straightforwardly worth it: a kernel that would otherwise need filesystem-aware code sprinkled through every syscall handler instead has exactly one such handler, calling through a stable interface that any new filesystem — including one nobody has written yet — can implement without touching a single line of the code that already handles open(), read(), and write() correctly for everything else.