diff options
Diffstat (limited to 'src/esp32p4_9p.zig')
| -rw-r--r-- | src/esp32p4_9p.zig | 295 |
1 files changed, 13 insertions, 282 deletions
diff --git a/src/esp32p4_9p.zig b/src/esp32p4_9p.zig index dd0346ef..4aa559a1 100644 --- a/src/esp32p4_9p.zig +++ b/src/esp32p4_9p.zig @@ -1,149 +1,13 @@ -//! THE BOARD AS A 9P SERVER, and nothing else: the reset entry, one UART, and a pump. -//! -//! This is the SECOND ESP32-P4 image and it is not a second role for the first one. `app.zig` is -//! the editor — a real `pardes.Pardes` core with vaxis on top, emitting ANSI down UART0 to a -//! terminal emulator on the far end. This image links none of that. Same board, same UART, same -//! flash partition, one at a time, because the editor owns UART0 bidirectionally and JP1 exposes no -//! second P4 UART (`docs/registry.typ` `9P-11`: "The board is either an editor or a filesystem at -//! any one time. Say that plainly rather than implying both"). -//! -//! THE UART CARRIES ONLY 9P. That is the whole difference from the other image and it is the point. -//! No ANSI, no vaxis, no escape sequences, no `MARK` boot markers, no `soc.rom.print` — not even -//! the heap report `app.zig:320-329` prints on every boot, which would be the single most useful -//! line here and is still not allowed, because a byte on this wire that is not part of a 9P message -//! is a byte that desynchronises whatever is parsing it. The proof that this image booted is that it -//! answers `Tversion`. -//! -//! The one thing that had to be said in some other language is a PANIC and a TRAP, and they are said -//! in 9P too: an `Rerror` carrying the message, tagged `NOTAG`. No client is waiting for that tag, -//! so `9p` reports it as an unexpected reply and prints the string — which is exactly the diagnosis -//! wanted ("the board died, here is why") delivered without putting one non-protocol byte on the -//! wire. See `panicImpl` and `trapReport`. -//! -//! ## What it serves -//! -//! `src/board9p.zig`, which is the board's own capabilities as a tree: `gpio/pinout` is the JP1 -//! drawing the editor's `Gpio` word prints, and `gpio/<n>/value` is one pad's driven level, readable -//! and writable. Both come out of a comptime table, and adding a capability to that table adds files -//! here with no code in this file changing at all. -//! -//! Deliberately NOT `src/acmefs.zig`, and the reason is the same one that makes this a second image. -//! That file is the EDITOR's control filesystem: every operation in it is about a pane, and a pane -//! only exists because a `pardes.Pardes` exists. Serving it would mean linking the editor object -//! (809,536 B of image) and instantiating the core, at which point this is `app.zig` with a -//! different output encoding rather than a 9P server. It compiles for this target — `llvm-nm` finds -//! 21,548 B of `acmefs.*` in `zig-out/pardes-esp32p4.o` — and that fact is what made this image -//! worth building, because it is what proved the filesystem layer has no host dependency. The ABI is -//! what got reused, not the tree: `src/9p.zig`'s `Server` is a generic over the filesystem, and -//! `board9p` implements `acmefs`'s `Op`/`Status`/`Req`/`Reply` verbatim, so the same server serves -//! either one and neither knows about the other. -//! -//! ## The loop -//! -//! Four lines, and every one of them is a `Server` method doing what its doc comment says: -//! -//! read bytes off the UART -> srv.push(bytes) -//! pump -> srv.retry() / srv.next() -> fsys.handle(req) -> srv.reply(...) -//! write what is queued -> srv.wrote(uart.writeSome(srv.output())) -//! -//! NOTHING BLOCKS. `uart.read` is non-blocking, `uart.writeSome` hands over what the transmit FIFO -//! has room for and answers how much, and `Server.wrote(n)` takes a partial write as an ordinary -//! answer rather than an error (`src/9p.zig:2248-2257`). So a client that stops reading cannot stall -//! this loop, and a reply larger than the 128-byte FIFO leaves over several trips round it. That is -//! the same sans-io contract `src/fs9_service.zig` gives the desktop's unix socket; the difference -//! is that there is no `poll` here and no need for one, because there is exactly one connection and -//! it is the wire. -//! -//! ## The numbers, measured rather than costed -//! -//! `.bss` IS THE WHOLE RAM BILL, because this image has no allocator: not a heap, not an arena, and -//! the 384 KiB span the editor's image hands `heapmod` is not even mapped by anything here. So the -//! board's ≈336 KB of free heap (`docs/registry.typ` `FIX-2`) is untouched at 100%, and what this -//! program spends is the 240 KiB of low L2MEM that `9P-11`'s built note names as the real binding -//! constraint. `llvm-size` on the ELF says `.bss` is 16,656 B, and every byte of it is accounted -//! for: -//! -//! 9,192 `srv` — `Server(Tree(Pads))` on riscv32. `9P-11` measured 9,488 on the -//! host; a 32-bit target's slices are half the width, and the park -//! table has thirty-two of them. -//! 1,024 `in_buf` — one msize -//! 2,048 `out_buf` — two, so no reply can fail to be queued -//! 4,108 the rescue ring — `input_rescue.Ring` inside `uart.zig`, which comes with the UART -//! 148 `fsys` — the whole tree: one 143-byte answer buffer and a counter -//! 128 `stage` -//! ------ -//! 16,648 + 8 of alignment and `uart.dropped` = 16,656 -//! -//! Add the 32,768-byte `.stack` the shared linker script gives every image built through -//! `firmware()` and the low-L2MEM total is 49,424 B, 20% of the 240 KiB — against the editor's -//! 75,236 B (20,408 `.data` + 22,060 `.bss` + the same stack). The stack is the largest single item -//! and it is inherited rather than chosen: 32 KiB is sized for the CORE's recursive layout pass -//! (`build.zig:1088-1090`), and nothing in this image recurses at all. -//! -//! FLASH: the image is 88,080 B of the 1,536,000 B partition — 5.7%, against the editor image's -//! 812,688 B (52.9%). Only 28,066 B of that is content (22,504 `.flash.text`, 5,562 B of real -//! `.flash.rodata`, 80 B of image header and checksum); the rest is the gap between the end of the -//! rodata segment and the 64 KiB-aligned origin the code segment must start on, because the ESP32 -//! flash MMU maps in 64 KiB pages and the two segments cannot share one. A tiny image pays up to -//! 64 KiB for that and there is nothing to be done about it here — it is the generated linker -//! script's arithmetic (`05-zig-p4/build.zig`), and it is why the estimate of "≈39 KiB" in `9P-11` -//! was closer to the CONTENT than to the image. -//! -//! ## Build it, flash it, talk to it -//! -//! zig build -Dplatform=esp32p4 -Desp32p4-firmware -Desp32p4-9p esp32p4-9p-flash -//! zig build -Dplatform=esp32p4 -Desp32p4-firmware -Desp32p4-9p esp32p4-9p-size # no board needed -//! -//! There is no `esp32p4-9p-attach`, and that absence is the design: what belongs on the far end of -//! this wire is a 9P client opened at `baud`, not a terminal. `9p` and `9pfuse` speak to a SOCKET, -//! so reaching this board with either means a program that copies bytes between the tty and a unix -//! socket in both directions — which is nine lines of anything and is not this file's business. -//! pardes's own client (`src/fs9_client.zig`) needs no such bridge, because a tty is already a -//! bidirectional byte stream and that is all 9P has ever asked for (`docs/registry.typ` `9P-19`). -//! -//! FLASHING THIS REPLACES THE EDITOR. Both images are written to `img.opts`'s one offset, on -//! purpose: there is one partition and the board is one thing at a time. `zig build esp32p4-flash` -//! puts the editor back. - +//! Standalone GPIO 9P firmware over UART0; the editor is not linked. +//! UART0 carries protocol bytes only, including fatal diagnostics. const std = @import("std"); const soc = @import("soc"); const hal = @import("hal"); const config = @import("config"); -// PATH imports, not named modules, and that is what lets any builder root an -// image here: the toolchain repository links this file with the four platform -// modules it owns (`soc`, `hal`, `config`, `heap`) and nothing else, so a -// `@import("ninep")` here was a module only pardes's own build.zig knew to -// inject — and the image stopped building the moment that build.zig stopped -// linking it. See `src/board9p.zig`'s note on the same change. const ninep = @import("9p.zig"); -const board9p = @import("board9p.zig"); +const gpio = @import("esp32p4_gpio.zig"); const uart = @import("esp32p4/uart.zig"); -/// THE PADS, and this is the whole seam between the tree and the silicon. -/// -/// The same four `hal.gpio` calls `src/esp32p4/app.zig:200-209` makes for the editor's `Gpio` word, -/// for the reason that file gives at length: a toggle is not a write to GPIO_OUT. `configureOutput` -/// points the pad's IO MUX at the GPIO function, routes the GPIO matrix's output to it, sets the -/// drive strength and input buffer, clears the pulls and only then enables the driver — four register -/// files indexed by a per-pin table, which live in the toolchain package where `zig build diff` -/// checks their numbers against ESP-IDF's own headers. A second copy would be a second copy under no -/// test. This is a second CALLER, which is the opposite thing. -/// -/// `getDrivenLevel` and not `getLevel`: the answer is the level this board is DRIVING, which is -/// defined for every pin including one with nothing attached, where the pad's own level is whatever -/// the air says. `readback = true` enables the input buffer anyway, so a client that wants the pad -/// rather than the register has something to compare against. -/// -/// SPLIT INTO `level` AND `drive` rather than the editor's single `toggle`, because a file can say -/// which level it wants and a keystroke cannot. `Gpio 20` has one argument and has to mean "the -/// other one"; `echo 1 > gpio/20/value` says 1, which is what makes it idempotent and therefore -/// scriptable. Writing the level a pad is already at still calls `configureOutput`, and that is not -/// a wasted write: on a freshly booted board it is the call that makes the pad an output at all. -/// -/// BOTH ARE `pub` AND HAVE TO BE, for the same reason `src/esp32p4/selftest.zig:44-46` says its -/// `FakePort`'s methods are: `board9p` is a MODULE here, and duck typing across a module boundary -/// still needs the declaration to be visible from outside the file it is in. Nothing else in this -/// image is `pub`. const Pads = struct { pub fn level(pin: u8) u1 { return hal.gpio.getDrivenLevel(pin); @@ -156,132 +20,58 @@ const Pads = struct { }; comptime { - // Every pin the tree generates has to be a pad this chip package has, and the check belongs here - // rather than in `board9p.zig`: `max_pin` is 56 on this package and lives in the toolchain - // repository, which a host-testable tree cannot import. A JP1 row edited to name GPIO 60 is a - // compile error in this image instead of an out-of-bounds register index on the die. - for (board9p.pins) |pin| { + for (gpio.Header.gpio_pins) |pin| { if (pin > hal.gpio.max_pin) @compileError("JP1 names a pad this chip package does not have"); } } -/// The board's tree, over the real pads. -const Fs = board9p.Tree(Pads); -const Server = ninep.Server(Fs); +const Fs = gpio.Fs(Pads); +const Server = ninep.Server(Fs, ninep.board_fids); + +comptime { + std.debug.assert(@typeInfo(@FieldType(Server, "fids")).array.len == 32); + std.debug.assert(@sizeOf(Server) <= 10 * 1024); +} -/// THE msize, and it is 1,024 rather than the 4,096 everything else in this tree assumes. -/// -/// The 4,096 floor is the LINUX KERNEL's and nobody else's: `linux/net/9p/client.c:840-843` refuses -/// to mount below it, which is why `9p.min_msize` is 4,096 and why the desktop daemon serves that. -/// Plan 9's devmnt, plan9port's `9p` and pardes's own client all accept 512 -/// (`docs/registry.typ` `9P-11`), and no Linux kernel is ever going to mount this image: the far end -/// of this wire is a serial port, and a `mount -t 9p` needs a socket or a virtio channel, neither of -/// which a CH340 is. So the floor that applies here is `9p.msize_min` — 217 bytes, DERIVED from the -/// largest reply whose size the client does not choose (`src/9p.zig:1873-1881`). -/// -/// 1,024 and not 217, because the number to size against is the widest DIRECTORY READ. `gpio/` has -/// twelve entries, a `stat` record in a directory read is 49 bytes of fixed fields plus the name plus -/// three copies of the client's `uname` (`src/9p.zig:3251-3260`), so a `goblin` reading `ls gpio/` -/// wants 12 × ~73 = ~880 bytes to get the listing in ONE round trip. At 217 it would take five, and -/// each one costs a `Tread` and an `Rread` on a wire. Everything else here is tiny: the largest file -/// in the tree is the 468-byte JP1 drawing and the largest write is two bytes. -/// -/// What it costs: `in` is one msize and `out` is two — one message going out and one being built, -/// which is what makes every reply in the server infallible — so 3,072 B for the buffers against -/// 12,288 B at a 4,096 msize. Nine kilobytes of the board's low L2MEM for a round trip nobody needs. const msize: u32 = 1024; -/// One whole T-message, and the ceiling on the msize this connection will agree to. var in_buf: [msize]u8 = undefined; -/// Two, for the reason above. `Server.hasRoom` reserves one msize before it hands any request to the -/// filesystem, which is what makes back-pressure land on `next()` returning null instead of on a -/// half-written reply. var out_buf: [2 * msize]u8 = undefined; -/// Bytes off the receiver on their way into the server, and the ONE buffer in this file. -/// -/// 128 is the transmit and receive FIFO depth (the toolchain package's `src/hal/uart.zig:52`), so one -/// `uart.read` can never leave more behind than one FIFO's worth, and the tail that `push` would not -/// take is re-offered next time round the loop. It is not a reassembly buffer — `Server.in` is that, -/// and it holds a whole message — it is the handover between a driver that fills a slice and a server -/// that takes what it has room for. var stage: [128]u8 = undefined; -/// The wire's rate, and the host must be opened to match or nothing works and nothing says so. -/// -/// 921600 rather than the 115200 the bootloader leaves behind: `docs/registry.typ` `BOARD-1`. One -/// `UART_CLKDIV_SYNC` write on the existing 40 MHz XTAL, int 43 frag 6, +0.064% error, and it takes -/// a byte from 86.8 µs to 10.85 µs — which on this loop is a warm `cat gpio/20/value` going from -/// 10.8 ms to 1.35 ms and a 1 KiB `Tread` from 89 ms to 11 ms. 2 Mbaud is representable and this -/// CH340 is unreliable there, corroborated by the flasher's own choice at `build.zig:1136-1138`. -/// -/// It is programmed before the first reply and after the input drain, which is the one moment when -/// there can be nothing in either FIFO to be corrupted by the change. const baud: u32 = 921600; -/// The server and the tree, both in `.bss` and both fixed for the life of the image. No allocator -/// exists in this program at all — not a heap, not an arena, not the `heapmod` the editor's image -/// hands over 384 KiB to — so `zig build esp32p4-9p-size` reporting `.bss` is reporting the whole -/// of what this server costs in RAM. var srv: Server = undefined; var fsys: Fs = .{}; export fn zig_main() noreturn { - // FIRST, before anything reads `.rodata`, exactly as `app.zig:275` does it and for the same - // reason: the JP1 drawing this image serves is 468 bytes of `.rodata` in flash, and a read of it - // through a stale cache returns whatever was there at reset. soc.flushFlashCache(); - // The same clock the editor's image runs at, so a latency measured on one is a latency on the - // other. A divider change that disturbs neither UART0 (XTAL) nor the flash interface (SPLL). if (config.cpu_mhz != 90) hal.clkrst.setCpuFreq(switch (config.cpu_mhz) { 180 => .mhz180, 360 => .mhz360, else => .mhz90, }); - // The RTC watchdog is armed at reset and this loop never feeds anything. Without this the board - // resets a few seconds in, which over a wire that carries only 9P looks exactly like a client - // that cannot reach it. _ = hal.rwdt.disable(); - // WHAT THE BOOTLOADER LEFT ON THE WIRE, discarded before the divider changes: its own chatter - // has already been echoed at the host, and the host bridge injects a synthetic window-size - // report before this program exists. Neither is 9P, and either would be the first bytes of a - // message that never was. _ = uart.drainInput(); - // The rate, then. A refusal is not fatal and must not be: an unreachable divider leaves 115200 - // in place, which is a slow board rather than a silent one, and a client opened at the wrong rate - // finds out immediately because `Tversion` gets no answer it can parse. _ = uart.setBaud(baud); - srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = board9p.root }); + srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = gpio.root }); - // THE PUMP. `stage_len` is the only state outside the server. var stage_len: usize = 0; while (true) { - // IN. Non-blocking, rescued bytes first (`uart.read`), and never more than the staging - // buffer's room, so a burst larger than one FIFO simply arrives over two iterations. if (stage_len < stage.len) stage_len += uart.read(stage[stage_len..]); if (stage_len != 0) { - // A SHORT PUSH IS NORMAL AND IS NOT A LOSS: it is the only back-pressure a sans-io - // server has (`src/9p.zig:2229-2233`). What it would not take stays here and is offered - // again after the pump has made room by finishing a message. const took = srv.push(stage[0..stage_len]); if (took != stage_len) std.mem.copyForwards(u8, stage[0 .. stage_len - took], stage[took..stage_len]); stage_len -= took; } - // PUMP, in the order `src/fs_service.zig:209-222` requires: every parked request offered - // once, then everything the wire has, both loops to null. - // - // NOTHING ON THIS BOARD PARKS — the answer to "what level is this pad" is a register read, - // and there is no `event` file and no reader to block — so `retry()` answers null on the - // first call, every time. It is here because the contract is the contract, and because the - // first capability that does block (an interrupt-driven `gpio/<n>/edge`) needs this line to - // already exist rather than to be remembered. while (srv.retry()) |req| { const a = fsys.handle(req); srv.reply(&a.reply, a.bytes); @@ -291,18 +81,9 @@ export fn zig_main() noreturn { srv.reply(&a.reply, a.bytes); } - // OUT. Whatever fits in the transmitter right now, and the server keeps the rest. const queued = srv.output(); if (queued.len != 0) srv.wrote(uart.writeSome(queued)); - // THE STREAM WAS NOT 9P, and there is no resynchronising from that: a `size` no encoder - // could have produced, an R-message from something that thought it was the server, a - // message larger than the negotiated msize. On a socket the answer is to close the - // connection and let the client notice; on a wire that cannot be closed, the answer is to - // reset it — pay the filesystem whatever `release`s the dead fids owe it, throw away every - // byte in flight in both directions, and start a fresh connection in the same silence a - // reboot would have. A client resynchronises by sending `Tversion`, which is what a client - // does after any failure anyway. if (srv.dead) { srv.hangup(); while (srv.next()) |req| { @@ -311,25 +92,11 @@ export fn zig_main() noreturn { } _ = uart.drainInput(); stage_len = 0; - srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = board9p.root }); + srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = gpio.root }); } } } -// --------------------------------------------------------------------------- dying in protocol - -/// A message this image is about to die with, as an `Rerror` on `NOTAG`. -/// -/// THE ONE PLACE A NON-REPLY IS SENT, and it is still a legal 9P message, which is the whole trick. -/// `NOTAG` is the tag of the `Tversion` exchange and no client has a request outstanding under it, so -/// `9p` and pardes's own client both report an unexpected reply AND PRINT THE STRING — "the board -/// panicked at 0x4000a1b8", delivered through a parser rather than past it. The alternative is what -/// the editor's image does, `MARK PARDES_PANIC` in plain text, which on this wire would be a frame -/// header of 0x4b52414d followed by garbage: an unrecoverable stream instead of a diagnosis. -/// -/// Blocking `uart.write` and not `writeSome`, because there is no loop left to come back round: this -/// is the last thing the image does, and a bounded spin that gets the whole message out is worth -/// more here than one that returns. fn die(msg: []const u8) noreturn { var buf: [ninep.errmax + ninep.header_len + 2]u8 = undefined; const bytes = ninep.encode( @@ -341,9 +108,6 @@ fn die(msg: []const u8) noreturn { while (true) {} } -/// Eight hex digits into `buf`, computed arithmetically. Hand-rolled rather than `std.fmt`, for the -/// reason `uart.dumpWord` gives: this runs in a trap handler, where the less of the image it depends -/// on the more likely it is to run at all. fn hex8(buf: *[8]u8, v: u32) void { var shift: u5 = 28; for (buf) |*slot| { @@ -353,11 +117,6 @@ fn hex8(buf: *[8]u8, v: u32) void { } } -/// `mtvec` is set in DIRECT mode by `_start`, so every trap and every interrupt lands here. -/// -/// A trap handler exists for the reason `app.zig:432-441` gives — the mask ROM's "Guru Meditation" -/// only prints while ITS handler is installed, and a silent fault over a serial line is -/// indistinguishable from an infinite loop — and it reports through 9P for the reason `die` gives. export fn trapEntry() linksection(".text.entry") callconv(.naked) noreturn { asm volatile ("j trapReport"); } @@ -372,8 +131,6 @@ export fn trapReport() noreturn { const mtval = asm volatile ("csrr %[o], mtval" : [o] "=r" (-> u32), ); - // The three registers that name a RISC-V fault, in the order a reader wants them: what happened, - // where, and to which address. var msg = "trap mcause=00000000 mepc=00000000 mtval=00000000".*; hex8(msg[12..20], mcause); hex8(msg[26..34], mepc); @@ -381,21 +138,6 @@ export fn trapReport() noreturn { die(&msg); } -// --------------------------------------------------------------- the root's own duties -// -// This is a ROOT, so it owns std's configuration for this compilation unit. The editor's image has -// two of these (`app.zig` and `src/esp32p4.zig`, one per object); this image is one object and has -// one. - -/// `page_size_min`/`max`: no MMU and no pages here, but std derives alignment from them, and 4 KiB -/// is this chip's cache and DMA granularity. -/// -/// `logFn` is not cosmetic and it is not optional. std's default log implementation reaches -/// `std.debug_io`, which instantiates `std.Io.Threaded` — a thread pool, `getrandom`, `IOV_MAX`, -/// `mremap` — and one `log.warn` anywhere in the graph drags all of it into the image. This one -/// DISCARDS, which is the only honest thing it can do: there is nowhere for a log line to go on a -/// wire that carries only 9P, and a log line that went out anyway would break the connection it was -/// trying to explain. Nothing in this image's graph logs; this is the wall that keeps it that way. pub const std_options: std.Options = .{ .page_size_min = 4096, .page_size_max = 4096, @@ -412,9 +154,6 @@ fn logFn( pub const panic = std.debug.FullPanic(panicImpl); fn panicImpl(msg: []const u8, first_trace_addr: ?usize) noreturn { - // The address is what makes it actionable — `addr2line` against the ELF in zig-out turns it into - // a source line — so it goes in front of the message, where `errmax`'s 128-byte truncation - // cannot reach it. A panic message names a KIND of failure; the address names which one. var buf: [ninep.errmax]u8 = undefined; @memcpy(buf[0..7], "panic 0"); buf[7] = 'x'; @@ -425,14 +164,6 @@ fn panicImpl(msg: []const u8, first_trace_addr: ?usize) noreturn { die(buf[0 .. 17 + n]); } -/// Reset entry, identical in shape to `app.zig:528-546` and for the identical reasons: the bootloader -/// hands over with an unspecified stack pointer and the FPU off, so enable the F extension -/// (`mstatus.FS`), establish a stack, install the trap vector, clear `.bss`, and jump into Zig. -/// -/// `.bss` MATTERS MORE HERE THAN ANYWHERE. Everything this image owns is in it — the server, its two -/// buffers, the tree, the staging buffer — so this loop is what makes the fid table empty and the -/// msize zero, and skipping it would start the server mid-connection with a client that does not -/// exist. export fn _start() linksection(".text.entry") callconv(.naked) noreturn { asm volatile ( \\ li t0, 1 << 13 |
