//! THE BOARD AS A FILESYSTEM, generated from a comptime table of what the board can do. //! //! The ESP32-P4 already exposes its pads and its address space — by TYPING A WORD into a tag. //! `Gpio 20` flips a pin and prints `GPIO 20: 0->1`, `Gpio` alone draws JP1, `Peek`, `Poke` and //! `Hexdump` reach all 2³² addresses (`src/board_memory.zig`), and every one of the four caps at //! 4,096 bytes because the answer has to fit down a 115200-baud console. Nothing about that is //! machine-readable and nothing about it is remote: the answer lands in an output pane, for a person //! to read (`docs/registry.typ` `9P-11`, review note). //! //! This file is that same capability WITH NAMES INSTEAD OF VERBS. `cat gpio/pinout` is `Gpio`; //! `echo 1 > gpio/20/value` is `Gpio 20`, except that it says which level it wants instead of asking //! for whichever one it is not. A shell pipeline can do it, a script on a laptop can do it over the //! UART, and neither needs a terminal emulator or a pane. //! //! WHY A TABLE, which is the whole design and not a flourish. A hand-written tree is a `Node` //! packing, a `lookup`, a `getattr`, a `readdir`, a `read` and a `write` — six places that have to //! agree about what exists — and the cost of adding `uptime` to it is an edit to all six plus a new //! node id nobody else is using. The board's capabilities are a LIST, they will grow, and the entry //! that describes one should be the only place it is described. So `caps` below is the tree: the //! directories, the files, the per-pin fan-out, the permissions, the handlers and even the size of //! the answer buffer are all derived from it at comptime, and the six functions at the bottom read //! the derived table and know nothing about GPIO at all. //! //! WHAT A SECOND CAPABILITY COSTS, entry by entry, because "extensible" is a claim and this is the //! evidence for it. Not implemented here — none of them is needed to serve a pin — but each is one //! `Cap` and its handlers, and NO tree code: //! //! * `mem/` — `peek` and `poke` over `board_memory.readWord`/`writeWord` //! (`src/board_memory.zig:136-144`), which are four lines of `*allowzero volatile` and already //! compile for this target. `poke` is a WRITE handler that parses ` `, so //! it needs `Fault.Malformed` and nothing else; `peek` needs an address to read, which a //! stateless file cannot carry, so it is either a write-then-read pair (`echo 4ff40000 > addr; //! cat word`, one more file and one `u32` of state) or a fan over a comptime list of interesting //! registers. The second is free: `fan` below already generates a directory per key. //! * `hexdump` — the same, with `scratch = 4096`: the one field that makes the shared answer //! buffer grow, and the reason that field is in the table rather than a constant at the top. //! * `prof` — three cycle counts from `pardes_esp32p4_frame_prof`, which the editor object already //! exports (`src/esp32p4.zig:985`). It is the one capability that is NOT available in this //! image: that symbol lives in the pardes object and the 9P image links none, so serving it //! would mean either linking the editor or moving the counters. Worth saying out loud rather //! than listing it as cheap. //! * `uptime` and `heap` — `hal.systimer` and the heap's own free count, both of which the //! runtime (`src/esp32p4_9p.zig`) can reach today. Two read handlers, `scratch = 24`, one //! `Cap` each. These are the cheapest of the four and the reason the table's `board` parameter //! is a TYPE rather than a pair of function pointers: adding `board.uptimeMs()` to the seam //! adds a capability without changing anything here but the table. //! //! THE ABI IS `acmefs`'s, VERBATIM — `Op`, `Status`, `Req`, `Reply`, `Reply.Attr` with the same //! fields and the same meanings — so `src/9p.zig`'s `Server` serves this tree with no translation //! layer, exactly as it serves the editor's. That is the point of `Server` being a generic over the //! filesystem rather than an importer of one (`src/9p.zig:1994-2010`), and it is what makes a board //! image possible at all: `acmefs.zig` reaches `pardes.zig` and the whole core, and this file //! reaches `std` and one leaf table. //! //! NO ALLOCATOR, NO OS, ONE REQUEST AT A TIME. Same rules as `acmefs`: `handle(req) -> Answer` is a //! pure transaction, the answer's bytes are either `.rodata` or the one shared buffer, and they are //! borrowed until the next call. Nothing here blocks, so `Status.again` never appears — the board //! has no `event` file and no reader to park. const std = @import("std"); const board_pins = @import("board_pins.zig"); /// The errno values this tree returns. `acmefs.E`'s subset — the four a tree with no panes, no /// blocking and no allocation can produce — with the same numbers, because they are Linux's and a /// second spelling would be a second thing to check against `9p.errString`. pub const E = struct { pub const NOENT: u16 = 2; pub const IO: u16 = 5; pub const NOTDIR: u16 = 20; pub const INVAL: u16 = 22; }; /// How a HANDLER refuses, as against how the tree refuses. The tree answers ENOENT and ENOTDIR /// itself, out of the table, before any handler runs; this is the set of things only the handler can /// know. /// /// One variant today, and it is the honest count: a pad takes `0` or `1` and nothing else. A /// capability that can refuse for a second reason adds a variant here and a prong to `errnoOf`, /// which is the whole of what "another kind of no" costs. pub const Fault = error{ /// the bytes offered are not a value this file takes Malformed, }; /// The one place a `Fault` becomes a number. fn errnoOf(f: Fault) u16 { return switch (f) { error.Malformed => E.INVAL, }; } /// A file's two halves, as POINTERS rather than function types: the derived table below is an /// ordinary runtime array, and a struct holding a bare `fn` is comptime-only. /// /// `key` says which pad, address or counter the call is about, and `out` is the slice of the shared /// answer buffer this file's table entry declared — exactly `scratch` bytes, so a handler cannot /// write past its own budget. A read may also ignore `out` entirely and answer out of `.rodata`, /// which is what the JP1 drawing does. const ReadFn = *const fn (key: u16, out: []u8) Fault![]const u8; const WriteFn = *const fn (key: u16, bytes: []const u8) Fault!u32; /// One FILE in the table. `key` is not here: it comes from the directory the file is generated /// into, which is what makes one entry serve eleven pins. /// /// The MODE is derived, never declared: a file with both handlers is 0o600, a read handler alone is /// 0o400, a write handler alone is 0o200, and neither is a compile error. A declared mode is a /// fourth thing that can disagree with the three that decide it. pub const FileSpec = struct { name: []const u8, read: ?ReadFn = null, write: ?WriteFn = null, /// Bytes of the shared answer buffer this file's read needs. ZERO when the read answers out of /// `.rodata` and copies nothing, which is what `gpio/pinout` does — the JP1 drawing is 468 /// bytes of static text and there is no reason to stage it. The largest `scratch` in the table /// is one of the two numbers that size `Tree.out`. scratch: u32 = 0, }; /// One generated subdirectory of a capability, and its KEY: the pad, address or counter every file /// inside it is about. `gpio/20/value` is `key = 20`. pub const FanDir = struct { key: u16, name: []const u8 }; /// A capability's fan-out: one directory per key, each holding the same files. THE REASON the tree /// has exactly the pins this board has — the dirs are collected from `board_pins.gpio_pins`, which /// is collected from the JP1 rows, which are the schematic. pub const Fan = struct { dirs: []const FanDir, files: []const FileSpec }; /// ONE CAPABILITY = ONE DIRECTORY under the root. Always a directory, even for a capability with a /// single file: a flat root would put every capability's names in one u4 (see `block` below) and /// would make `ls /` a list of files whose grouping a reader has to infer. `ls /` here is the list /// of things this board can do. pub const Cap = struct { name: []const u8, files: []const FileSpec = &.{}, fan: ?Fan = null, }; /// One node of the derived tree. Flat, because a table of fifteen entries scanned linearly is /// faster than any structure with pointers in it and is the same shape `src/9p.zig`'s own test stub /// uses — and because a scan cannot disagree with itself about what the tree contains. const Entry = struct { node: u64, /// Where `..` goes. See `block`: this is also the value `src/9p.zig`'s `parentOf` derives from /// the node id, for every entry but a fan leaf, and the test at the bottom asserts it. parent: u64, name: []const u8, dir: bool, mode: u16, /// the pad this file is about, or zero key: u16 = 0, read: ?ReadFn = null, write: ?WriteFn = null, scratch: u32 = 0, }; /// THE NODE ID PACKING, and it is not ours: it is `acmefs.Node`'s, `{ file: u4, serial: u60 }`, /// because `src/9p.zig:1955` `parentOf` READS node ids to answer `..` and has that packing built in. /// A tree that numbered its nodes freely would get a wrong answer to `cd ..` and no diagnostic. /// /// The rule, restated as arithmetic: a node's parent is the node rounded down to a multiple of 16, /// except that a node already at a multiple of 16 — or below 16 — is a child of the root. /// /// * the root is 1: serial 0, so `..` is itself, which is POSIX's rule and `intro(5)`'s. /// * a capability directory is its own BLOCK BASE, `(index + 1) * 16`, so its `..` is the root. /// * everything inside a capability — its files AND its fan directories — is a member of that /// block, `base + 1 .. base + 15`, so their `..` is the capability directory. Correct, which is /// what matters for the one `..` a client actually performs: `cd /gpio/20; cd ..`. /// * a fan LEAF (`gpio/20/value`) cannot be expressed. Its parent is a block member, and /// `parentOf` can only produce block bases. So leaves get blocks of their own, above every /// capability's, and `..` from one lands on an unallocated block base, which this tree answers /// ENOENT. That is the honest failure: a walk that cannot be expressed is refused rather than /// silently landing on a different file. No client does it — `..` from a file requires having /// walked INTO a file, and a file is not a directory — and the fix, if one is ever wanted, is a /// `parent` hook on `Server` so a filesystem deeper than two levels answers `..` itself. That /// is exactly the wall `parentOf`'s own doc comment says it is (`src/9p.zig:1950-1954`), and /// `acmefs`'s `pty/` subtree stands on the same side of it today. const block: u64 = 16; /// The root, and the value the runtime hands `Server.init` as `Options.root`. One, for the same /// reason `acmefs.TopFile.root` is one: node 0 is `{ file: 0, serial: 0 }` and cannot be a root /// (`src/9p.zig:2190-2192`). pub const root: u64 = 1; /// Every key the GPIO fan generates, re-exported for the RUNTIME's benefit: `src/esp32p4_9p.zig` /// checks at comptime that each one is a pad `hal.gpio` will accept, which is the one thing this /// file cannot check for itself — `max_pin` is a property of the chip package and lives in the /// toolchain repository, and importing it here would make the tree unbuildable on a host. pub const pins = board_pins.gpio_pins; /// The board's own tree, over a `board` seam the runtime supplies. /// /// GENERIC over the board for exactly the reason `Server` is generic over the filesystem: the pads /// are four register files behind `hal.gpio` in the toolchain package, which exists only for /// riscv32, and a tree that imported it could not be tested on a host at all. The seam is two /// functions, both about the level the board is DRIVING: /// /// * `board.level(pin: u8) u1` /// * `board.drive(pin: u8, level: u1) void` /// /// `src/esp32p4_9p.zig` implements them over `hal.gpio`, in the same four calls /// `src/esp32p4/app.zig:200-209` uses for the `Gpio` word — the same seam, a second caller, not a /// second copy of the register sequence. The tests below implement them over a recording stub, the /// way `src/9p.zig`'s server tests implement a filesystem. pub fn Tree(comptime board: type) type { return struct { const Self = @This(); // -- the ABI, which is `acmefs`'s ------------------------------------ // // A MIRROR, not a redefinition: `Server(acmefs)` is the instantiation that proves the // shape, and a field that drifts from it is a compile error the moment `Server(Tree(...))` // is built — which the tests at the bottom do. pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir, statfs }; /// `again` is here because the ABI has it, and it never occurs: nothing on this board /// blocks. The board's answer to "what is this pin at" is a register read. pub const Status = enum(u8) { ok, again, err }; pub const Req = struct { tag: u64, op: Op, node: u64, handle: u32 = 0, off: u64 = 0, size: u32 = 0, data: []const u8 = &.{}, truncate: bool = false, }; pub const Reply = struct { tag: u64, status: Status = .ok, errno: u16 = 0, attr: Attr = .{}, handle: u32 = 0, written: u32 = 0, pub const Attr = struct { node: u64 = 0, dir: bool = false, size: u64 = 0, mode: u16 = 0o600, }; /// `acmefs.Reply.fail`'s twin, so a refusal is one expression here as it is there. pub fn fail(tag: u64, e: u16) Reply { return .{ .tag = tag, .status = .err, .errno = e }; } }; /// A reply and the bytes it points at, borrowed until the next `handle`. `Server.reply` /// takes exactly this pair. pub const Answer = struct { reply: Reply, bytes: []const u8 = "" }; // -- the handlers ---------------------------------------------------- /// `gpio/pinout` — JP1, as the `Gpio` word draws it, TO THE BYTE. The same /// `board_pins.jp1_text` the word prints (`src/board_memory.zig:364`), returned out of /// `.rodata` rather than staged, so this read costs no buffer and no copy. fn readPinout(_: u16, _: []u8) Fault![]const u8 { return board_pins.jp1_text; } /// `gpio//value` — the level this board is DRIVING on pad `n`, as `0` or `1` and a /// newline. /// /// THE DRIVEN LEVEL and not the pad's, for the reason `src/esp32p4/app.zig:196-199` gives: /// the pad's own level is what the outside world says, and on an unconnected header pin that /// is noise. The driven level is defined for every pin, which is what a file that a script /// reads in a loop needs. /// /// The trailing newline is not decoration: `cat gpio/20/value` in a terminal and `$(cat /// ...)` in a script both want it, and the write side accepts it back, so `cp` of one pin's /// value onto another's is a legal round trip. fn readValue(key: u16, out: []u8) Fault![]const u8 { out[0] = '0' + @as(u8, board.level(@intCast(key))); out[1] = '\n'; return out[0..2]; } /// `gpio//value` — drive pad `n` to `0` or `1`. /// /// WRITING THE OPPOSITE OF THE CURRENT LEVEL IS THE `Gpio` WORD'S TOGGLE, through the same /// seam; writing the level it is already at is not a no-op, because the FIRST write to a pad /// is what makes it an output at all (`hal.gpio.configureOutput`, four register files). So /// this always drives, and `echo 0 > value` on a fresh boot is a meaningful command: it /// takes the pad off whatever the IO MUX had it pointed at and holds it low. /// /// `0`, `1`, `0\n` and `1\n` are the whole language. Anything else is EINVAL, including /// `true`, `high`, `01` and the empty write — a file whose only two values are one character /// each has no room for a spelling debate, and guessing at `on` would be the beginning of /// one. fn writeValue(key: u16, bytes: []const u8) Fault!u32 { const want = try oneBit(bytes); board.drive(@intCast(key), want); // The whole write is consumed, trailing newline included: a short count would make // `echo` retry the tail and drive the pin a second time. return @intCast(bytes.len); } /// `0` or `1`, with at most one trailing newline (and the `\r` a Windows-ish client may put /// in front of it). Nothing else. fn oneBit(bytes: []const u8) Fault!u1 { var end = bytes.len; while (end > 0 and (bytes[end - 1] == '\n' or bytes[end - 1] == '\r')) end -= 1; if (end != 1) return error.Malformed; return switch (bytes[0]) { '0' => 0, '1' => 1, else => error.Malformed, }; } // -- THE TABLE ------------------------------------------------------- /// The pin directories, one per P4 GPIO the header brings out, named by the pin number in /// DECIMAL — the number the schematic, the silkscreen and the datasheet all use, and the one /// literal in `board_memory.zig` that is not hex (`:394-399`). Generated from /// `board_pins.gpio_pins`, so this list cannot contain a pin JP1 does not have. const gpio_dirs = dirs: { var out: [board_pins.gpio_pins.len]FanDir = undefined; for (board_pins.gpio_pins, 0..) |pin, i| out[i] = .{ .key = pin, .name = std.fmt.comptimePrint("{d}", .{pin}), }; break :dirs out; }; /// EVERYTHING THIS BOARD OFFERS, and the only place any of it is described. The tree, the /// permissions, the handlers, the node ids and the answer buffer all come out of here. const caps = [_]Cap{ .{ .name = "gpio", .files = &.{ .{ .name = "pinout", .read = readPinout }, }, .fan = .{ .dirs = &gpio_dirs, .files = &.{ .{ .name = "value", .read = readValue, .write = writeValue, .scratch = 2 }, }, }, }, }; /// How many nodes the table generates, counted separately because it is an array length. const node_count = count: { var n: usize = 1; // the root for (caps) |c| { n += 1 + c.files.len; if (c.fan) |f| n += f.dirs.len * (1 + f.files.len); } break :count n; }; /// THE DERIVED TREE. Built once at comptime and `const`, so it lands in `.rodata` and costs /// the image its bytes and the board's RAM nothing. const table: [node_count]Entry = build: { var out: [node_count]Entry = undefined; out[0] = .{ .node = root, .parent = root, .name = "/", .dir = true, .mode = 0o500 }; var at: usize = 1; // Blocks 1..caps.len are the capability directories; fan leaves take the ones above, // which is what keeps a leaf's unexpressible parent from landing on a real node. var next_block: u64 = caps.len + 1; for (caps, 0..) |c, ci| { const dir_node = (ci + 1) * block; out[at] = .{ .node = dir_node, .parent = root, .name = c.name, .dir = true, .mode = 0o500 }; at += 1; // The u4 in the node id, spent one per name inside this capability. Directories and // files come out of the same fifteen, which is the wall `acmefs.PaneFile`'s doc // comment describes from the other side. var slot: u64 = 1; for (c.files) |f| { out[at] = fileEntry(dir_node + slot, dir_node, f, 0); at += 1; slot += 1; } if (c.fan) |fan| for (fan.dirs) |d| { const fan_node = dir_node + slot; slot += 1; out[at] = .{ .node = fan_node, .parent = dir_node, .name = d.name, .dir = true, .mode = 0o500 }; at += 1; const leaf_base = next_block * block; next_block += 1; for (fan.files, 0..) |f, l| { out[at] = fileEntry(leaf_base + 1 + l, fan_node, f, d.key); at += 1; } }; if (slot >= block) @compileError( "capability '" ++ c.name ++ "' has more than 15 names in it, and a node id has four bits for them:" ++ " `acmefs.Node.file` is a u4 and `9p.parentOf` reads it. Split it into two" ++ " capabilities, or widen the packing in acmefs.zig, 9p.zig and here at once.", ); } break :build out; }; /// One file's entry, with the mode derived from which handlers it has. fn fileEntry(node: u64, parent: u64, f: FileSpec, key: u16) Entry { const mode: u16 = if (f.read != null and f.write != null) 0o600 else if (f.read != null) 0o400 else if (f.write != null) 0o200 else @compileError("file '" ++ f.name ++ "' has no read and no write, so it is a name and not a file"); return .{ .node = node, .parent = parent, .name = f.name, .dir = false, .mode = mode, .key = key, .read = f.read, .write = f.write, .scratch = f.scratch, }; } /// THE ONE BUFFER, and both numbers that size it come out of the table: the largest /// `scratch` any read declares, and the widest directory's worth of staged entries. Never /// both at once — one request is in flight at a time — so one buffer serves both, and the /// board pays for the larger. const out_max = size: { var most: usize = 0; for (table) |e| most = @max(most, e.scratch); for (table) |d| { if (!d.dir) continue; var n: usize = 0; for (table) |e| if (e.parent == d.node and e.node != d.node) { n += dirent_fixed + e.name.len; }; most = @max(most, n); } break :size most; }; /// `node[8] dir[1] namelen[1]` — `acmefs`'s staging format for a readdir /// (`acmefs.zig:942-957`), which is what `Server` decodes. Ten bytes and then the name. const dirent_fixed = 8 + 1 + 1; /// Formatted answers and staged directory entries. Valid until the next `handle`, which is /// the borrow window `Server.reply` documents. out: [out_max]u8 = undefined, /// Every request the board has been asked, for the runtime's own diagnostics. Not a /// protocol counter — `Server` keeps those — and not a statistic anybody has to read: it is /// the one number that distinguishes "nothing is arriving" from "everything is being /// refused" on a board with no second console to ask. calls: u32 = 0, fn find(node: u64) ?*const Entry { for (&table) |*e| if (e.node == node) return e; return null; } fn attrOf(t: *Self, e: *const Entry) Reply.Attr { return .{ .node = e.node, .dir = e.dir, .mode = e.mode, .size = t.sizeOf(e) }; } /// A file's size is WHAT ITS READ ANSWERS, asked rather than declared. That means a /// `getattr` of `gpio/20/value` reads the pad's output register, which is a load from a /// peripheral and nothing more; the alternative is a second declaration in the table that /// can disagree with the handler, on a tree whose whole claim is that there is one place per /// fact. A write-only file has no size and reports zero, which is what `acmefs` reports for /// every file it cannot cheaply measure. fn sizeOf(t: *Self, e: *const Entry) u64 { const read = e.read orelse return 0; const bytes = read(e.key, t.out[0..e.scratch]) catch return 0; return bytes.len; } /// ONE OPERATION, and the whole of what this filesystem is. Pure: no allocation, no /// blocking, no state but `out` and the counter. pub fn handle(t: *Self, req: Req) Answer { t.calls += 1; const e = find(req.node) orelse return .{ .reply = .fail(req.tag, E.NOENT) }; switch (req.op) { .lookup => { if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) }; for (&table) |*c| { if (c.parent != req.node or c.node == req.node) continue; if (!std.mem.eql(u8, c.name, req.data)) continue; return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(c) } }; } return .{ .reply = .fail(req.tag, E.NOENT) }; }, .getattr => return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } }, // The only `setattr` that reaches here is a truncate, from `Topen` with `OTRUNC` // (`src/9p.zig:2816-2822`) — which is what `echo 1 > gpio/20/value` opens with. // Every file here is a fixed-length register view, so there is nothing to truncate // and nothing to refuse either: answering EINVAL would make the shell's own // redirection fail on a pin that is perfectly writable. .setattr => { if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } }; }, // No per-open state, so one handle for every open. `Server` checks the mode against // the fid's cached permissions before it gets here (`src/9p.zig:2075-2079`). .open => return .{ .reply = .{ .tag = req.tag, .handle = 1 } }, .release => return .{ .reply = .{ .tag = req.tag } }, .read => { if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; const read = e.read orelse return .{ .reply = .fail(req.tag, E.INVAL) }; const all = read(e.key, t.out[0..e.scratch]) catch |f| { return .{ .reply = .fail(req.tag, errnoOf(f)) }; }; // Past the end is the empty read every client uses to stop, not an error. if (req.off >= all.len) return .{ .reply = .{ .tag = req.tag } }; const from = all[@intCast(req.off)..]; return .{ .reply = .{ .tag = req.tag }, .bytes = from[0..@min(from.len, req.size)] }; }, .write => { if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; const write = e.write orelse return .{ .reply = .fail(req.tag, E.INVAL) }; // A REGISTER IS NOT A STREAM. Every file here is one value, so the only offset // that means anything is zero; a client that seeks and writes is describing an // edit to a byte range this file does not have. `echo`, `9p write` and // `cat > file` all write at zero. if (req.off != 0) return .{ .reply = .fail(req.tag, E.INVAL) }; const n = write(e.key, req.data) catch |f| { return .{ .reply = .fail(req.tag, errnoOf(f)) }; }; return .{ .reply = .{ .tag = req.tag, .written = n } }; }, .readdir => { if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) }; return .{ .reply = .{ .tag = req.tag }, .bytes = t.stage(req.node, req.off) }; }, // 9P2000 has no `Tstatfs` — that is a `.L` message (`src/9p.zig:24-28`) — so // nothing reaches this. It is answered rather than `unreachable` because the ABI // names it and a panic in a server is worse than an empty answer. .statfs => return .{ .reply = .{ .tag = req.tag } }, } } /// A directory's children in `acmefs`'s staging format, from an ENTRY INDEX rather than a /// byte offset — `Server` does that coordinate change and advances both cursors /// (`src/9p.zig:2836-2849`). The whole of the widest directory fits `out` by construction, /// so this never stages a short list for want of room; `Server` still takes only what one /// reply holds and asks again. fn stage(t: *Self, node: u64, skip: u64) []const u8 { var n: usize = 0; var seen: u64 = 0; for (&table) |*e| { if (e.parent != node or e.node == node) continue; if (seen < skip) { seen += 1; continue; } std.mem.writeInt(u64, t.out[n..][0..8], e.node, .little); t.out[n + 8] = @intFromBool(e.dir); t.out[n + 9] = @intCast(e.name.len); @memcpy(t.out[n + dirent_fixed ..][0..e.name.len], e.name); n += dirent_fixed + e.name.len; } return t.out[0..n]; } }; } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- // // A RECORDING STUB FOR THE PADS, exactly as `src/9p.zig`'s server tests use a stub filesystem: the // seam is two functions, so the test can hold the pads still and check what was asked of them. Every // claim below is one a host can answer — the tree's shape, the bytes of an answer, which pin the // seam was called with — and the one claim it cannot is stated as such: whether `hal.gpio` drives // the pad, which only the die knows. const testing = std.testing; /// The pads, faked. `driven` is the board's output register. const StubPads = struct { var driven: [64]u1 = @splat(0); var log: [16]Call = undefined; var log_len: usize = 0; const Call = struct { pin: u8, level: u1 }; fn reset() void { driven = @splat(0); log_len = 0; } fn level(pin: u8) u1 { return driven[pin]; } fn drive(pin: u8, want: u1) void { driven[pin] = want; log[log_len] = .{ .pin = pin, .level = want }; log_len += 1; } }; const Board = Tree(StubPads); /// The tree, walked by name the way a client walks it: `lookup` after `lookup` from the root, which /// is the only way to find out what the generated table actually offers. fn walk(t: *Board, path: []const []const u8) !Board.Reply.Attr { var at: u64 = root; var attr: Board.Reply.Attr = .{ .node = root, .dir = true, .mode = 0o500 }; for (path) |name| { const a = t.handle(.{ .tag = 1, .op = .lookup, .node = at, .data = name }); if (a.reply.status == .err) return switch (a.reply.errno) { E.NOENT => error.NoEntry, E.NOTDIR => error.NotDirectory, else => error.Refused, }; attr = a.reply.attr; at = attr.node; } return attr; } fn readAll(t: *Board, node: u64) !Board.Answer { const open = t.handle(.{ .tag = 1, .op = .open, .node = node }); try testing.expectEqual(Board.Status.ok, open.reply.status); return t.handle(.{ .tag = 2, .op = .read, .node = node, .handle = open.reply.handle, .size = 65535 }); } test "board9p: the generated tree has exactly the header's pins, and nothing else" { var t: Board = .{}; // The capability directory, and its one hand-written file. try testing.expect((try walk(&t, &.{"gpio"})).dir); try testing.expect(!(try walk(&t, &.{ "gpio", "pinout" })).dir); // Every pin JP1 brings out is a directory with a `value` in it. Eleven of them, generated. for (board_pins.gpio_pins) |pin| { var name: [4]u8 = undefined; const dir = try std.fmt.bufPrint(&name, "{d}", .{pin}); try testing.expect((try walk(&t, &.{ "gpio", dir })).dir); const value = try walk(&t, &.{ "gpio", dir, "value" }); try testing.expect(!value.dir); try testing.expectEqual(@as(u16, 0o600), value.mode); } // And a pin the board does not bring out is not there. 6 and 21 are real ESP32-P4 GPIOs that // JP1 simply does not route, which is the distinction the table exists to keep: the tree has // the pins the BOARD has, not the pins the CHIP has. try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "6" })); try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "21" })); try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "20", "level" })); try testing.expectError(error.NoEntry, walk(&t, &.{"mem"})); } test "board9p: a read of gpio/pinout is the bytes the Gpio word draws" { var t: Board = .{}; const at = try walk(&t, &.{ "gpio", "pinout" }); // `board_memory.zig:364`'s `pinout` IS this declaration, so this is the word's own output and // not a copy of it. The bytes themselves are pinned by `board_pins.zig`'s golden test. const a = try readAll(&t, at.node); try testing.expectEqualStrings(board_pins.jp1_text, a.bytes); // The size a client is told matches what it gets, which is what makes `cat` stop in one read. try testing.expectEqual(board_pins.jp1_text.len, at.size); // Read-only: the drawing is the header's, not the client's. try testing.expectEqual(@as(u16, 0o400), at.mode); const w = t.handle(.{ .tag = 3, .op = .write, .node = at.node, .data = "x" }); try testing.expectEqual(E.INVAL, w.reply.errno); } test "board9p: writing 1 then 0 drives the pad twice, through the seam" { StubPads.reset(); var t: Board = .{}; const at = try walk(&t, &.{ "gpio", "20", "value" }); // A fresh pad reads 0 — the level the board is DRIVING, which is defined before anybody has // written anything. const before = try readAll(&t, at.node); try testing.expectEqualStrings("0\n", before.bytes); const one = t.handle(.{ .tag = 4, .op = .write, .node = at.node, .data = "1" }); try testing.expectEqual(Board.Status.ok, one.reply.status); try testing.expectEqual(@as(u32, 1), one.reply.written); try testing.expectEqualStrings("1\n", (try readAll(&t, at.node)).bytes); // `echo 0 > value`, newline and all: the whole write is consumed, so the shell does not retry // the tail and drive the pin a second time. const zero = t.handle(.{ .tag = 5, .op = .write, .node = at.node, .data = "0\n" }); try testing.expectEqual(@as(u32, 2), zero.reply.written); try testing.expectEqualStrings("0\n", (try readAll(&t, at.node)).bytes); // TWO CALLS, the right pin, the right levels, in order. This is the whole of what the host can // check about the seam; that `hal.gpio` then moves the pad is the die's to answer. try testing.expectEqual(@as(usize, 2), StubPads.log_len); try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 1 }, StubPads.log[0]); try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 0 }, StubPads.log[1]); } test "board9p: a pad takes 0 and 1 and refuses everything else, without touching the pads" { StubPads.reset(); var t: Board = .{}; const at = try walk(&t, &.{ "gpio", "45", "value" }); for ([_][]const u8{ "2", "", "01", "x", "true", "high", "\n", "1 ", " 1", "10" }) |bad| { const a = t.handle(.{ .tag = 6, .op = .write, .node = at.node, .data = bad }); try testing.expectEqual(Board.Status.err, a.reply.status); try testing.expectEqual(E.INVAL, a.reply.errno); } // A refused write is a pad that was never driven, which is the part that matters: a half-parsed // command must not leave the board in a state nobody asked for. try testing.expectEqual(@as(usize, 0), StubPads.log_len); // A register is one value, so a write at an offset is refused too, and refused before the pads. const off = t.handle(.{ .tag = 7, .op = .write, .node = at.node, .off = 1, .data = "1" }); try testing.expectEqual(E.INVAL, off.reply.errno); try testing.expectEqual(@as(usize, 0), StubPads.log_len); } test "board9p: every node's parent is the one 9p.parentOf derives, or an unallocated block" { // THE ENCODING'S OWN TEST, and it defends the one thing this file cannot see: `src/9p.zig` // answers `..` from the node id alone, by the rule restated at `block` above. A node numbered // outside that rule would make `cd ..` land somewhere else with no diagnostic, so the rule is // applied here to every generated node and compared against the table's own `parent`. for (&Board.table) |*e| { const serial = e.node >> 4; const file = e.node & 0xF; const derived: u64 = if (e.node == root or serial == 0 or file == 0) root else serial << 4; if (derived == e.parent) continue; // The one exception, and it must be exactly the one documented: a fan leaf, whose parent is // a block MEMBER and therefore unexpressible. Its derived parent has to be a node that does // not exist, so the walk is refused rather than landing on the wrong file. try testing.expectEqualStrings("value", e.name); var t: Board = .{}; const a = t.handle(.{ .tag = 8, .op = .getattr, .node = derived }); try testing.expectEqual(E.NOENT, a.reply.errno); } } test "board9p: a directory read lists what the table generated, in table order" { var t: Board = .{}; // The root is the capability list, and today that is one name. try testing.expectEqualStrings("gpio", (try names(&t, root, 0))[0]); try testing.expectEqual(@as(usize, 1), (try names(&t, root, 0)).len); const gpio = (try walk(&t, &.{"gpio"})).node; const listing = try names(&t, gpio, 0); try testing.expectEqual(board_pins.gpio_pins.len + 1, listing.len); try testing.expectEqualStrings("pinout", listing[0]); for (board_pins.gpio_pins, 0..) |pin, i| { var buf: [4]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "{d}", .{pin}), listing[i + 1]); } // The cursor is an ENTRY INDEX, which is what `Server` advances between reads of a directory // bigger than one reply. const rest = try names(&t, gpio, 5); try testing.expectEqual(board_pins.gpio_pins.len + 1 - 5, rest.len); try testing.expectEqualStrings("5", rest[0]); } /// The names in one staged directory read, decoded out of `acmefs`'s `node[8] dir[1] namelen[1] /// name[]` records — the same decode `Server` does. var name_slots: [32][]const u8 = undefined; fn names(t: *Board, node: u64, skip: u64) ![][]const u8 { const a = t.handle(.{ .tag = 9, .op = .readdir, .node = node, .off = skip, .size = 65535 }); try testing.expectEqual(Board.Status.ok, a.reply.status); var n: usize = 0; var i: usize = 0; while (i < a.bytes.len) { const len = a.bytes[i + 9]; name_slots[n] = a.bytes[i + 10 ..][0..len]; n += 1; i += 10 + len; } return name_slots[0..n]; } test "board9p: the whole tree costs one buffer, and the table says how big" { // The two numbers the board's RAM budget is quoted from. `out` is the ONLY buffer this // filesystem has, and both of its bounds come out of the table: the widest directory's staged // entries (gpio's twelve) and the largest read scratch (a pin's two bytes). try testing.expectEqual(@as(usize, 143), Board.out_max); try testing.expect(@sizeOf(Board) <= 160); // The JP1 drawing is not in it, and that is the point of `scratch = 0`: 468 bytes of static // text are served straight out of `.rodata`. try testing.expect(board_pins.jp1_text.len > Board.out_max); } // The proof that the ABI claim in this file's header is true, and the only place the two halves meet // on the host: `Server` is a generic over exactly `Op`, `Status`, `Req`, `Reply` and `Reply.Attr`, // so a field that drifts from `acmefs`'s is a compile error HERE, and a real client's bytes are what // comes out. // // A PATH IMPORT, and it took two goes to get here. The first was // `@import("9p.zig")`, which did not compile while `src/9p.zig` was also the // ROOT of a named `ninep` module in the same link — a file belongs to exactly // one module, and it was both. The second was a named module, declared twice in // `build.zig`; that compiled and made this file unbuildable by anyone but // `build.zig`, which is what broke the board image the moment the firmware // link moved to the toolchain repository and stopped injecting modules. // // The path form works now because nothing declares `src/9p.zig` as a module // root any more: `fs9_service.zig` and `fs9_client.zig` reach it by path too, // so every link that contains it contains it once. The gain is that this file // and `src/esp32p4_9p.zig` are self-contained — `zig test src/board9p.zig` // works with no flags, and any builder can root an image at `nine.zig` without // being told what modules to inject. const ninep = @import("9p.zig"); test "board9p: a real 9P client reads a pin's value off this tree" { StubPads.reset(); const Server = ninep.Server(Board); // The board's own buffers, at the board's own msize. See `src/esp32p4_9p.zig` for why 1024. var in: [1024]u8 = undefined; var out: [2048]u8 = undefined; var fsys: Board = .{}; var srv = Server.init(.{ .in = &in, .out = &out, .root = root }); var scratch: [256]u8 = undefined; const send = struct { fn call(s: *Server, f: *Board, buf: []u8, tag: u16, msg: ninep.Msg) !void { const bytes = try ninep.encode(msg, tag, buf); try testing.expectEqual(bytes.len, s.push(bytes)); while (s.retry()) |req| { const a = f.handle(req); s.reply(&a.reply, a.bytes); } while (s.next()) |req| { const a = f.handle(req); s.reply(&a.reply, a.bytes); } } }.call; const reap = struct { fn call(s: *Server) !ninep.Decoded { const queued = s.output(); const len = ninep.frameLen(queued) orelse return error.NoReply; const got = try ninep.decode(queued[0..len]); s.wrote(len); return got; } }.call; try send(&srv, &fsys, &scratch, ninep.notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }); const v = try reap(&srv); // Clamped to what the board's buffers hold, which is the number the RAM budget was chosen for. try testing.expectEqual(@as(u32, 1024), v.msg.rversion.msize); try send(&srv, &fsys, &scratch, 1, .{ .tattach = .{ .fid = 0, .afid = ninep.nofid, .uname = "goblin", .aname = "" } }); try testing.expectEqual(root, (try reap(&srv)).msg.rattach.qid.path); var wname: [ninep.max_welem][]const u8 = @splat(""); wname[0] = "gpio"; wname[1] = "20"; wname[2] = "value"; try send(&srv, &fsys, &scratch, 2, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 3, .wname = wname } }); try testing.expectEqual(@as(u16, 3), (try reap(&srv)).msg.rwalk.nwqid); try send(&srv, &fsys, &scratch, 3, .{ .topen = .{ .fid = 1, .mode = ninep.ordwr } }); _ = try reap(&srv); // `echo 1 > /mnt/board/gpio/20/value`, as bytes on a wire. try send(&srv, &fsys, &scratch, 4, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "1\n" } }); try testing.expectEqual(@as(u32, 2), (try reap(&srv)).msg.rwrite.count); try testing.expectEqual(@as(u1, 1), StubPads.driven[20]); // ...and `cat` of the same file. try send(&srv, &fsys, &scratch, 5, .{ .tread = .{ .fid = 1, .offset = 0, .count = 512 } }); try testing.expectEqualStrings("1\n", (try reap(&srv)).msg.rread.data); // The refusal reaches the client as an error STRING, which is 9P's only channel for "no": EINVAL // becomes the wording `9p.errString` gives it, and the pad is not touched. try send(&srv, &fsys, &scratch, 6, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "on" } }); try testing.expectEqualStrings(ninep.errString(E.INVAL), (try reap(&srv)).msg.rerror.ename); try testing.expectEqual(@as(usize, 1), StubPads.log_len); }