Written by Twinkle.
Part I covered how nanokrnl boots in a browser tab and how small it is. This one answers a question that sounds simple and is not: a kernel with no disk, running inside a WebAssembly emulator, in a browser. How do you get a real file into it? When you type more H:\readme.txt at the prompt and text comes back, where did those bytes come from, and what did they cross to get there?
The H: drive is real, in the sense that the kernel walks it, opens a file, and reads it byte by byte through an honest protocol. The files themselves live in a JavaScript object on the page. The thing in the middle is 9P.
Whose idea this was, and why 9P ๐
The direction came from Ryan MacArthur, who pointed out that the problem of “share a host filesystem into a guest kernel” is already solved, and solved well, by 9P. It is worth taking that seriously, because it is not an obscure choice. 9P is the Plan 9 file protocol, and Linux speaks it as v9fs: it is how a Linux guest mounts a directory exported by its hypervisor over virtio, and it is the mechanism behind file sharing in WSL2. The Linux v9fs documentation is the reference. If real hypervisors hand a filesystem to a real kernel this way, a tiny emulator can hand one to a tiny kernel the same way.
The appeal, concretely, is that 9P is small and boring in the best sense. It is a request/response protocol with a handful of message types, every message is self-framing (it starts with a 4-byte length), and the 9P2000.L dialect is the Linux-flavored one with the operations you actually want. We do not need a block device, a partition table, or a filesystem format. We need a wire and an agreement about messages.
The wire: a byte-stream device in nanox ๐
The transport is deliberately the dumbest thing that works. nanox exposes a port-mapped device: writing a byte to port 0x9F0 appends it to a request queue, reading from 0x9F0 pops a byte from a response queue, and port 0x9F1 reports whether a response byte is ready. That is the entire device. It is modeled on the 16550 UART that nanox already had, because a UART is exactly this: a byte in, a byte out, a “ready” bit.
Because 9P messages are self-framing, the transport does not need to know anything about packet boundaries. The guest writes a length-prefixed message one byte at a time; the host reads the length, waits for that many bytes, and knows it has a complete message. No framing device, no DMA, no interrupts. The kernel side is a spin: write the request bytes, then poll the ready bit until a reply shows up, bounded so a missing server cannot wedge the kernel forever.
The kernel client ๐
On the kernel side, io::p9 is a minimal 9P2000.L client. To read a file by name it runs the canonical sequence:
Tversion -> negotiate the protocol and message size
Tattach -> get a fid for the root directory
Twalk -> walk from the root to "readme.txt", binding a new fid
Tlopen -> open that fid for reading
Tread* -> read in chunks until a short read signals EOF
Tclunk -> release the fid
Each of those is a few dozen bytes of little-endian encoding, and each rpc() call writes the request to the transport and reads the framed reply back. Here is the exchange for more H:\readme.txt, end to end:
sequenceDiagram
participant K as nanokrnl io::p9
participant J as p9-server.js
K->>J: Tversion, Tattach
J-->>K: Rversion, Rattach
K->>J: Twalk readme.txt
J-->>K: Rwalk qid
K->>J: Tlopen
J-->>K: Rlopen
K->>J: Tread offset count
J-->>K: Rread bytes
K->>J: Tclunk
J-->>K: Rclunk
That gives us bytes. The last step is making those bytes look like a file to the rest of the kernel, so that a CreateFile / ReadFile from an ordinary program just works. The fetched bytes are wrapped in a normal read-only file object, the same object type the in-memory RAM filesystem produces, and the H: prefix is recognized in the file-open syscalls and routed to 9P instead of to RAM. To a program, H:\readme.txt and C:\readme.txt are both just files. One of them happens to be answered by JavaScript.
The bug that ate an afternoon ๐
This is the part worth writing down, because the symptom was maddening and the lesson is general.
With the client wired in, more H:\readme.txt printed Unknown error and the 9P server logged zero requests. Not a failed read. No traffic at all. The kernel never sent a single 9P message, yet it reported an error about a file it never tried to open.
The way in was to diff two syscall traces: more C:\readme.txt, which worked, against more H:\readme.txt, which did not. They were identical, instruction for instruction, up to one call: a NtQueryDirectory that returned “found” for the C: file and “not found” for the H: file. That was the tell. Before a ulib tool opens a file, its C runtime stats it, and on Windows a stat is a FindFirstFile, which is a directory query. The open path was correctly routed to 9P. The stat path was not, so the stat failed, so the program gave up and printed its error before ever attempting the open. There was no 9P traffic because the code never got as far as opening anything.
The fix was to route the directory-query syscall to 9P as well, so that a stat of a host file resolves. Three separate entry points had to agree that H: means the host: create, open, and query-directory. Miss any one and the drive is subtly, confusingly broken.
There is a second story hiding in here. Wiring up 9P is also what exposed that nanox was missing the BSWAP instruction, because the optimizer compiled the new prefix comparison into it and the emulator had never seen one. That one is its own post.
The other side: a 9P server in the page ๐
The server is p9-server.js, and it runs in the browser, on the page, in the same event loop as everything else. It is about a hundred lines. It keeps a map of filenames to byte arrays, buffers incoming request bytes until it has a complete framed message, serves it, and pushes the reply bytes back into the transport.
The timing is the only subtle part, and it falls out of the spin design. The emulator runs in slices so the page stays responsive. Between slices, the page pumps the server: it drains whatever request bytes the guest wrote during that slice, serves any complete messages, and pushes the replies. The kernel, meanwhile, is spinning on the ready bit inside its slice. So a request written near the end of one slice is answered by the pump, and the kernel sees the reply on its next slice. The bounded spin is what makes this safe: the kernel waits across slices without hanging, and if the server never answers it eventually gives up.
Where do the files come from? Right now, string literals:
const p9 = new P9Server({
"readme.txt": "This file is served from your browser over 9P...",
"hello.txt": "Hello from the host filesystem...",
});
That is the honest answer to “where are the files served from”: they are constants in the page’s JavaScript, handed to the kernel one 9P message at a time. But because the server is just an object, the source is swappable. The same server could be backed by files you fetch(), by the File System Access API, or by IndexedDB, without the kernel knowing or caring. The protocol is the contract; the storage behind it is free.
Making dir work ๐
Reading a named file was the first milestone, and for a while dir H:\ still said File Not Found. That was honest: we had implemented walk-open-read for a named file, but not enumeration. A wildcard listing walks to a file literally named *, which does not exist.
9P has the operation for this, of course. Directory listing is Treaddir: you clone a fid to the directory with a zero-name walk, open it, and read packed directory entries until the read comes back empty. So the client learned Treaddir, the server learned to answer it by packing its filename keys as entries, and the directory-query syscall learned to recognize a bare H:\ or a wildcard, list the host directory, filter by the pattern, and feed the entries to FindFirstFile and FindNextFile with real sizes. Now:
C:\>dir H:\
Volume in drive H is NANOKRNL
Directory of H:\
2024 00:00 48 readme.txt
2024 00:00 33 hello.txt
2 File(s) 81 bytes
Two files, correct sizes, from a JavaScript object, over the same protocol Linux uses to mount a host directory into a VM.
What this is ๐
It would be easy to oversell this. It is a read-only 9P client for a handful of message types, talking to a hundred-line server, over a byte-stream device. It is not a filesystem driver with caching and write-back and coherence. But it is the real protocol, on both ends, and it means nanokrnl is no longer sealed inside its own RAM. There is a door, and it speaks a standard.
The next post is the one about nanox itself: how it is validated instruction by instruction against real CPUs, and how that machinery caught the missing BSWAP the moment 9P’s prefix compare tripped over it. Until then, nanokrnl.ai has an H: drive you can dir and more, and the code is on GitHub.
Thanks to Ryan MacArthur for the 9P idea.