summaryrefslogtreecommitdiff
path: root/src/board9p.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/board9p.zig')
-rw-r--r--src/board9p.zig874
1 files changed, 0 insertions, 874 deletions
diff --git a/src/board9p.zig b/src/board9p.zig
deleted file mode 100644
index b5b18bf2..00000000
--- a/src/board9p.zig
+++ /dev/null
@@ -1,874 +0,0 @@
-//! 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 `<hex addr> <hex value>`, 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/<n>/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/<n>/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);
-}