From fbc194068687e49a8490c85c9f1257a2f2bb9079 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 12:40:03 -0300 Subject: A Gpio word that flips one pin, JP1 drawn in ASCII, and these words only on the P4 ## Gpio `Gpio 33` flips one pad and answers on the message row with what it did: GPIO 33: 0->1 GPIO 33: 1->0 Bare `Gpio` draws the header instead, because the first question about a header is which pins it has. The pin number is DECIMAL and it is the only literal in board_memory.zig that is - every other one is an address, and addresses come off datasheets and linker maps that print hex, which is why that file made everything hex two commits ago. A GPIO number is not an address, it is part of a NAME: the schematic says GPIO47, the datasheet's pin table says 47, and `Gpio 20` meaning pin 32 would be a trap laid for the one argument anybody types from memory. ## The toggle is the host's, not the editor's New `Host.VTable.pull_gpio_toggle`, and a `GpioFn` in the p4 ABI (hence version 2), rather than board_memory reaching for GPIO_OUT the way `Poke` two functions above it would happily do. Writing that register is not the job. A pad has to be pointed at the GPIO function in the IO MUX, routed in the GPIO matrix, given drive strength and an input buffer with its pulls cleared, and only then driven - four register files behind a per-pin table. That code already exists in `05-zig-p4/src/hal/gpio.zig`, it is the same `configureOutput` the blink demo has always used, and its register numbers are checked against ESP-IDF's own headers on the die by `zig build diff`. A second copy inside the editor object would be a second copy under no test, and getting it wrong on a pin that boots as something else is how you lose the console you are typing on. Reported levels are the OUTPUT bits, before and after, because that is what a toggle means: the level this board is driving. A pad's input buffer on an unconnected header pin reads the air. ## JP1, read off the schematic rather than remembered The diagram is the vendor's own wiring, from sheet 2 "Expand IO" of `01-esp32p4-m3/docs/JC-ESP32P4-M3_schematic.pdf` - the only document that carries this mapping. The specification PDF's "Interface Description" page turned out to be a marketing render, and there is no board user guide; the chip datasheet has a package pinout, which is not a header. That sheet is a 872x1168 raster (`pdfimages -list` - the PDF embeds no vectors, so rendering it larger adds nothing), and at that size the rows around pin 14 are genuinely ambiguous by eye. So the mapping came from the drawing's geometry instead: thirteen wires leave each side of the symbol, a net wire runs ~100 px to its label and a power stub ~21 px. Pin 8's wire is 21 px, which is what identifies it as unconnected rather than as the first of the GPIO4x labels - the reading that had GPIO47 one row higher and shorted GPIO45 to the ground bracket. Cross-checked against a second source that has been in the tree all along: `05-zig-p4/build.zig` documents `-Dled=20` as "JP1 pin 17", and GPIO20 lands on pin 17 here. Both facts are asserted in the test, so the diagram cannot drift from either. ## Peek, Poke, Hexdump and Gpio are now the P4 build's alone `board_memory.enabled` was `os.tag == .freestanding and !isWasm()`, on the argument that these words are a property of having no operating system rather than a product configuration, and that a predicate spelled out of `builtin` cannot drift the way a hand-maintained enum can. Tidy, and it answered the wrong question. A word only exists if some shell offers it, and the shells are the platforms. `Gpio` settles it beyond argument: its whole content is one board's header, and a second freestanding port would need its own pinout rather than inheriting this one. "Bare metal" was never the requirement, "this board" was, and the two only looked identical because there is currently one of them. The old predicate's real work was excluding wasm - `freestanding` too, where an address is an offset into a linear memory the engine owns - and naming `p4` excludes it by construction instead of by a term somebody has to keep remembering. The target is now the witness rather than the gate. Absent means not compiled: the tty binary contains no `+Gpio`, no `+Hexdump`, no `ES_I2C_SDA` and no `MisalignedAddress`. ## The boot buffer's lines are checked, not eyeballed Three times now a line in that tour has been one or two characters too long for a 56-column grid, and every time it was found by reading the die's screen - the expensive way to measure a string literal. The text is a named `boot_buffer` with a test over it, six lines came down to fit with margin, and the tour gained `Gpio`. Tests: the pinout's width, its thirteen aligned pin rows, GPIO20-on-17 and pin-8-unconnected; the decimal-versus-hex distinction; every boot-buffer line. Full suite green - unit-test, snap 95/95, hxdiff 481/0, hxparity 561/0, image-harness, pdf-harness, mupdf-check - and tty, p4, gui, p4 at 80x24, p4 with the fade forced on. On the die `p4-bench --check` is 5/5, the fifth being a new one: three `Gpio 33` runs must report 0->1, 1->0, 0->1, because the alternation is the only oracle a hardcoded string could not fake. --- src/board_memory.zig | 193 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 162 insertions(+), 31 deletions(-) (limited to 'src/board_memory.zig') diff --git a/src/board_memory.zig b/src/board_memory.zig index d2878d2d..79a9a2cf 100644 --- a/src/board_memory.zig +++ b/src/board_memory.zig @@ -1,13 +1,13 @@ -//! The board's own address space, as text: the Peek, Poke and Hexdump -//! builtins' whole implementation. +//! The board's own address space and its pins, as text: the Peek, Poke, Hexdump and Gpio builtins' +//! whole implementation. //! -//! BARE METAL ONLY (`enabled` below), and the reason is not caution but -//! honesty: with no OS there is no MMU, no supervisor and no process — the -//! editor IS the system software — so every one of the 2^32 addresses is -//! legitimately this program's to read and write, and a word that could name -//! only some of them would be lying about where it is running. Under an OS the -//! same three words would be either a segfault or a syscall stub, so they are -//! absent from those builds entirely rather than present and refusing. +//! THE P4 BUILD ONLY (`enabled` below), and the reason is not caution but honesty: with no OS there +//! is no MMU, no supervisor and no process - the editor IS the system software - so every one of the +//! 2^32 addresses is legitimately this program's to read and write, and a word that could name only +//! some of them would be lying about where it is running. Under an OS the same words would be either +//! a segfault or a syscall stub, so they are absent from those builds entirely rather than present +//! and refusing. Absent means not compiled, not hidden: nothing below is analysed for a build whose +//! platform is not `p4`. //! //! Everything here goes through `*allowzero volatile` pointers. A peripheral //! register is not memory: reading UART_STATUS twice is two reads and must not @@ -25,30 +25,33 @@ const pardes = @import("pardes.zig"); const Pardes = pardes.Pardes; const output_pane = @import("output_pane.zig"); -/// The one gate, and it is derived from the TARGET rather than from -/// `pardes.platform`: these three words are not a product configuration, they -/// are a property of running with no operating system under you, and a -/// predicate spelled out of `builtin` cannot drift from that the way a -/// hand-maintained platform enum can. Same idiom as allocators.zig's tiers. +/// THE ONE GATE, and it names the p4 build, so `Peek`, `Poke`, `Hexdump` and `Gpio` are analysed +/// and emitted for that build and for no other. Nothing in this file reaches any other target's +/// binary: not the volatile accessors, not the JP1 pinout, not the parsers. /// -/// Wasm is `freestanding` too — that is the `web` platform — and it is exactly -/// what this must exclude: inside the browser's sandbox an address is an offset -/// into a linear memory the engine owns, so a "peek" there would read a number -/// that means nothing about any machine and a "poke" would corrupt the heap -/// this same editor is running out of. Bare metal is the freestanding target -/// whose addresses are the bus's. -pub const enabled = builtin.os.tag == .freestanding and !builtin.target.cpu.arch.isWasm(); - -// `pardes.platform` is not the gate, but it IS an independent witness, so each -// of the three interesting builds proves its own half of the predicate rather -// than leaving "wasm is freestanding" as a comment nobody re-checks. The one -// that matters is the middle line: without the `isWasm` term above, the web -// build would silently hand a browser tab a Poke that writes into the linear -// memory this editor's own heap lives in. +/// This used to be derived from the target - `os.tag == .freestanding and !isWasm()` - on the +/// argument that these words are a property of having no operating system rather than a product +/// configuration, and that a predicate spelled out of `builtin` cannot drift the way a +/// hand-maintained enum can. The argument was tidy and it answered the wrong question. A word +/// only exists if some SHELL offers it, and the shells are the platforms; `Gpio` settles it beyond +/// argument, because its whole content is one board's header, and a second freestanding port would +/// need its own pinout rather than inheriting this one. "Bare metal" was never the requirement, +/// "this board" was, and the two only looked identical because there is currently one of them. +/// +/// The old predicate's real work was excluding wasm, which is `freestanding` too - inside the +/// browser's sandbox an address is an offset into a linear memory the engine owns, so a `Peek` +/// would read a number that means nothing about any machine and a `Poke` would corrupt the heap +/// this same editor runs out of. Naming `p4` excludes it by construction rather than by a term +/// somebody has to keep remembering. +pub const enabled = pardes.platform == .p4; + +// The target is now the WITNESS rather than the gate: whatever else `p4` means, it has to still be +// a machine whose addresses are the bus's, and a hosted or wasm build reaching this line means the +// platform and the target disagree about what the firmware is. comptime { - if (pardes.hosted and enabled) @compileError("an OS is not bare metal"); - if (pardes.platform == .web and enabled) @compileError("wasm is not bare metal"); - if (pardes.platform == .p4 and !enabled) @compileError("the P4 firmware is bare metal"); + if (enabled and pardes.hosted) @compileError("an OS is not bare metal"); + if (enabled and builtin.os.tag != .freestanding) @compileError("the P4 firmware is freestanding"); + if (enabled and builtin.target.cpu.arch.isWasm()) @compileError("wasm addresses are not a bus"); } /// How much of the address space ONE command may render. @@ -83,6 +86,11 @@ pub const Error = error{ /// the message row instead. MisalignedAddress, ExtraArgument, + /// not a number, or a number the part does not have a pad for + BadPin, + /// the host brought no pads: every build but the firmware, where the word + /// is not registered at all, and a firmware too old to pass the hook + NoPads, }; /// EVERY literal these three words take is HEX, with or without an `0x`, and there is no way to @@ -247,6 +255,49 @@ test "every literal is hex, with or without the prefix" { try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000")); } +// The pinout is the one thing here whose CORRECTNESS IS ITS SHAPE: a header drawn in two columns +// stops being a header the moment a row wraps, and it wraps on the board rather than on a +// developer's terminal, which is the worst place to find out. So the width is asserted against the +// grid the board is actually built with, and the alignment is asserted against the column the pin +// numbers are supposed to share. +test "the pinout fits the board's own grid, in two aligned columns" { + const cols: usize = @import("pardes_config").p4_cols; + // Seven columns of the shell's grid go to the line-number gutter before a pane's text starts. + const usable = cols - 7; + + var rows: usize = 0; + var pins: usize = 0; + var first_bar: ?usize = null; + var it = std.mem.splitScalar(u8, pinout, '\n'); + while (it.next()) |line| { + try std.testing.expect(line.len <= usable); + rows += 1; + // A pin row is one with two numbers in it; every one must put its bars in the same place, + // which is what "aligned in two columns" means when the check is mechanical. + const bar = std.mem.indexOfScalar(u8, line, '|') orelse continue; + if (line[line.len - 1] == '+') continue; + pins += 1; + if (first_bar) |b| try std.testing.expectEqual(b, bar) else first_bar = bar; + } + try std.testing.expectEqual(@as(usize, 13), pins); + try std.testing.expect(rows > 15); + + // Two independent facts about the board, each with a witness outside this file: GPIO20 is + // `05-zig-p4/build.zig`'s documented `-Dled` default ("JP1 pin 17"), and pin 8 is the one + // header pin the vendor schematic leaves unconnected. + try std.testing.expect(std.mem.indexOf(u8, pinout, "GPIO 20 | 17 |") != null); + try std.testing.expect(std.mem.indexOf(u8, pinout, "| 8 | --") != null); +} + +// The exception to the file's own rule, so it is written down as a test rather than only as a +// comment: a pin number is part of a name and is read as decimal, while every address beside it is +// hex. `Gpio 20` must mean the pin the schematic calls GPIO20, not 0x20. +test "a pin number is decimal, unlike every address in this file" { + try std.testing.expectEqual(@as(u16, 20), try std.fmt.parseInt(u16, "20", 10)); + try std.testing.expectEqual(@as(u32, 0x20), try parseAddr("20")); + try std.testing.expect(20 != 0x20); +} + /// `Peek [count]` — count 32-bit words at addr, one `addr: value` row /// each. One word per row rather than four so that every row carries its own /// address: the rows are then ordinary Look targets, and `Peek` or `Poke` @@ -296,6 +347,86 @@ pub fn poke(p: *Pardes, id: usize, argument: []const u8) !void { ) catch unreachable); } +/// JP1, the 26-pin header down the left edge of the JC-ESP32P4-M3-DEV, as the board wears it: two +/// columns, odd pins on the left, even on the right, pin 1 at the top. +/// +/// READ OFF THE VENDOR SCHEMATIC, sheet 2 "Expand IO" +/// (`01-esp32p4-m3/docs/schematics/2_EXPAND_IO&BAT.png`), which is the only document that carries +/// this mapping - the specification PDF's "Interface Description" page is a marketing render, and +/// there is no board user guide. The sheet is a 872x1168 raster, so the assignment was taken from +/// the drawing's own geometry rather than by eye: thirteen wires leave each side of the symbol, a +/// net wire runs ~100 px to its label and a power stub ~21 px, which is what identifies pin 8 as +/// unconnected rather than as the first of the GPIO4x labels. Cross-checked against a second, +/// independent source: `05-zig-p4/build.zig` has always documented `-Dled=20` as "JP1 pin 17", and +/// GPIO20 lands on pin 17 here. +/// +/// `--` is a pin the header brings out with nothing behind it. `C6_*` are the ESP32-C6 companion's +/// pads, not the P4's, and toggling a P4 GPIO cannot reach them. `ES_I2C_*` is the audio codec's +/// bus, shared - driving either one by hand while the codec is live is a collision, which is a +/// reason to know the pin is there rather than a reason to hide it. +const pinout = + \\JP1 header - 26 pins, pin 1 top left. + \\Every number here is DECIMAL. + \\ + \\ +---------+ + \\ 3V3 | 1 | 2 | 5V + \\ 3V3 | 3 | 4 | 5V + \\ GND | 5 | 6 | GND + \\ GPIO 1 | 7 | 8 | -- + \\ GPIO 2 | 9 | 10 | GPIO 47 + \\ GPIO 3 | 11 | 12 | GPIO 46 + \\ GPIO 4 | 13 | 14 | GPIO 45 + \\ GPIO 5 | 15 | 16 | GND + \\ GPIO 20 | 17 | 18 | 3V3 + \\ GPIO 32 | 19 | 20 | C6_U0RXD + \\ GPIO 33 | 21 | 22 | C6_U0TXD + \\ES_I2C_SDA | 23 | 24 | C6_IO9 + \\ES_I2C_SCL | 25 | 26 | C6_CHIP_PU + \\ +---------+ + \\ + \\Gpio flips one: 0->1 or 1->0. + \\ +; + +/// `Gpio ` flips one pad and says what it did; `Gpio` alone draws JP1. +/// +/// THE PIN NUMBER IS DECIMAL, and it is the one literal in this file that is. Every other one is +/// hex because every other one is an address, and addresses come off datasheets and linker maps +/// that print hex. A GPIO number is not an address - it is part of a NAME. The schematic says +/// `GPIO47`, the silkscreen says 47, the datasheet's pin table says 47, and `Gpio 20` meaning pin +/// 32 would be a trap laid for the one argument a person types from memory. One rule per KIND of +/// literal beats one rule per file when the kinds are this different. +/// +/// The toggle is the host's to perform (`Host.VTable.pull_gpio_toggle`) even though `Poke` two +/// functions up would happily write GPIO_OUT_REG directly. Writing that register is not the job: +/// a pad has to be pointed at the GPIO peripheral in the IO MUX, routed in the GPIO matrix, have +/// its driver and input buffer enabled, and only then be driven - and getting that wrong on a pin +/// that boots as something else is how you lose the console you are typing on. +/// +/// Reported levels are the OUTPUT bits, before and after, because that is what a toggle means: the +/// level this board is DRIVING. A pad's input buffer on an unconnected header pin reads whatever +/// the air says. +pub fn gpio(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const tok = it.next() orelse { + // No argument is not an error and not inert: it is the question "which pins are there", + // and the answer is a picture of the header. + const content = try p.gpa.dupe(u8, pinout); + errdefer p.gpa.free(content); + return fill(p, id, .{ .cmd = .Gpio }, content); + }; + if (it.next() != null) return Error.ExtraArgument; + const pin = std.fmt.parseInt(u16, tok, 10) catch return Error.BadPin; + + const toggle = p.host.vtable.pull_gpio_toggle orelse return Error.NoPads; + var was: u8 = 0; + var now: u8 = 0; + if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin; + + var buf: [48]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable); +} + /// Bytes per dumped row, and it is a different number on the board. /// /// `hexdump -C`'s sixteen is the layout everyone can already read, and it needs 79 columns: ten for -- cgit v1.3