From 939e3a7288d6782139cac36f091ccd1d41cdfc0a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 25 Aug 2026 13:01:13 -0300 Subject: A fourth platform: pardes as ESP32-P4 firmware, bytes in and bytes out `-Dplatform=p4 -Dtarget=riscv32-freestanding` emits a single freestanding OBJECT exporting a seven-function C ABI, not an executable. The board's toolchain (../05-zig-p4) owns `_start`, the linker script and the UART driver and links this in. The seam is bytes rather than types, so neither side can accidentally depend on the other's internals, and a signature that drifts fails at link time. The serial line is the whole of the I/O. `src/p4.zig` drives vaxis unchanged over it: the renderer is a byte writer and `queryTerminalSend` is a byte writer, so the terminal emulator on the host answers the capability handshake and the firmware sees a real terminal. Measured going out over the wire on attach: alt screen, in-band resize, cursor report, kitty keyboard, kitty graphics, DA1. THREE WORDS EXIST ONLY HERE. `src/board_memory.zig` implements `Peek`, `Poke` and `Hexdump`, gated on `builtin.os.tag == .freestanding and !isWasm()` - derived from the TARGET, because they are a property of running with no OS under you rather than a product option, and because wasm is freestanding too and is exactly what must be excluded: in a browser an address is an offset into the linear memory this editor's own heap lives in. Every access goes through `*allowzero volatile`: a peripheral register is not memory, and address 0 is an ordinary unmapped address on this bus. One 4 KiB cap per command, set by the console rather than the memory - an unbounded dump would wedge the only console the board has for eleven hours. Measured on ESP32-P4 rev v1.3 silicon, driven from a host terminal: Peek 0x501101a4 0x0e63ce71, then 0xaeaa6919 on a second read - the RNG register, so the volatile loads are not folded Poke 0x5011002c 0xdeadbeef LP_STORE0; a later Peek returned 0xdeadbeef Hexdump 0x5011002c 32 16 bytes a row, hex columns and an ASCII gutter Peek 0x50110001 `peek: MisalignedAddress` on the message row That last line is the one that matters. A misaligned 32-bit access traps, and a trap in firmware is a watchdog reset that takes the session with it, so the check that turns it into a message is the reason the file is hand-written rather than a generic reader. BARE METAL BOOTS AN EMPTY OUTPUT BUFFER. Every other boot layout in `init` makes a shell, and on this platform that is not a preference but an impossibility: nothing to fork, no pty to give a terminal pane. Booting one anyway produced precisely what that describes - a pane whose tag ends in `Filter`, no gutter, no buffer, and every keystroke vanishing into the Fallback's silent pty. An output buffer is also what the platform's own words want, since Peek, Poke and Hexdump each fill one. Sized for the board rather than for a desktop: * `allocators.zig` gains a p4 tier that is ALL fallback - every capacity is zero, so each arena spills immediately to the 384 KiB heap the firmware hands over, and no megabyte-shaped static reservation lands in `.bss`. * `source_manifest.zig`'s allowlist is EMPTY on p4. The table is ~0.95 MiB of rodata against a 1.5 MiB flash partition; the firmware's filesystem is the serial host's, through the Host vtable. * The grid is clamped and the clamp is measured, not guessed: every cell is paid for four times (vaxis Screen + InternalScreen, pardes Surface + previous_cells), so 40x12 fits and 80x24 exhausts the heap during `Pardes.init`. * `Vaxis.resize` deinits both screens before allocating replacements, so a failed resize leaves vaxis rendering nothing. The p4 shell keeps the previous geometry on failure instead of leaving a half-applied one. Also here: `output_pane_integration_test.zig` had an exhaustive switch over `Platform` that adding `.p4` left unhandled, which broke `zig build unit-test` outright - the native test binary is the one consumer no platform build compiles. 346 tests pass again. --- src/board_memory.zig | 323 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 src/board_memory.zig (limited to 'src/board_memory.zig') diff --git a/src/board_memory.zig b/src/board_memory.zig new file mode 100644 index 00000000..853ae5da --- /dev/null +++ b/src/board_memory.zig @@ -0,0 +1,323 @@ +//! The board's own address space, as text: the Peek, Poke and Hexdump +//! 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. +//! +//! Everything here goes through `*allowzero volatile` pointers. A peripheral +//! register is not memory: reading UART_STATUS twice is two reads and must not +//! be folded into one, a write to a write-only command register has no +//! observable value for the optimizer to keep, and address 0 is an ordinary +//! (unmapped) address on this bus rather than the null Zig assumes it is. +//! +//! The formatting side is a plain renderer over `Pardes.gpa`, so it lands in +//! an output buffer the same way Jumplist and Config do: an output buffer is a +//! file pane, so every motion, chord and Look works on a dump for free — you +//! can right-click an address in a hexdump row and Peek it. +const std = @import("std"); +const builtin = @import("builtin"); +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. +/// +/// 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. +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"); +} + +/// How much of the address space ONE command may render. +/// +/// The number is set by the console, not by the memory: UART0 runs at 115200 +/// baud and measures ~11.9 KB/s on the wire, and a hexdump row is 76 bytes of +/// text per 16 bytes of memory. 4 KiB is therefore 256 rows and ~19.5 KiB of +/// text — under two seconds to paint the whole buffer, and ~4% of the 512 KiB +/// heap the firmware hands over. `Hexdump 0x0 0xffffffff` would otherwise wedge +/// the only console the board has for eleven hours, with no way to interrupt +/// it, which makes an unbounded dump not a slow command but a lost session. +/// +/// Peek's cap is the same 4 KiB window expressed in words, so `Peek a 1024` +/// and `Hexdump a 4096` cover exactly the same bytes. +pub const max_bytes: u32 = 4096; +pub const max_words: u32 = max_bytes / 4; + +/// One address past the last: the reads below are bounded by this rather than +/// wrapping, because `Hexdump 0xfffffff0 256` wrapping to 0 would silently +/// show you the bottom of the space labelled with top-of-space addresses. +const space: u64 = 1 << 32; + +pub const Error = error{ + MissingAddress, + BadAddress, + BadCount, + MissingValue, + BadValue, + /// the ONE fault this file exists to prevent by hand: the RISC-V core + /// traps an unaligned 32-bit access, and a trap in firmware with no + /// handler is a watchdog reset that takes the session with it. Reported on + /// the message row instead. + MisalignedAddress, + ExtraArgument, +}; + +/// hex (`0x4ff40000`), decimal (`1341718528`), and — for free, from base 0 — +/// binary and octal. A bare `4ff40000` is deliberately NOT hex: it is a +/// legal-looking decimal number, so guessing the base would make one typo +/// silently address somewhere else entirely. +fn parseAddr(tok: []const u8) Error!u32 { + return std.fmt.parseInt(u32, tok, 0) catch return Error.BadAddress; +} + +fn parseCount(tok: []const u8) Error!u64 { + return std.fmt.parseInt(u64, tok, 0) catch return Error.BadCount; +} + +fn parseValue(tok: []const u8) Error!u32 { + return std.fmt.parseInt(u32, tok, 0) catch return Error.BadValue; +} + +/// A 32-bit peripheral or RAM read that the compiler may neither elide, +/// duplicate, reorder past another access, nor narrow. +fn readWord(addr: u32) u32 { + const cell: *allowzero const volatile u32 = @ptrFromInt(@as(usize, addr)); + return cell.*; +} + +fn writeWord(addr: u32, value: u32) void { + const cell: *allowzero volatile u32 = @ptrFromInt(@as(usize, addr)); + cell.* = value; +} + +fn readByte(addr: u32) u8 { + const cell: *allowzero const volatile u8 = @ptrFromInt(@as(usize, addr)); + return cell.*; +} + +const Limit = enum { + /// the 4 KiB console cap above + console, + /// the end of the 32-bit address space + space, +}; + +/// How many units this command will actually show, and WHY that is fewer than +/// you asked for when it is. Never silent: the note below becomes the buffer's +/// FIRST line, which is the one place a clamp cannot be missed — a trailing +/// note on a 256-row dump is a note you scroll past. +const Extent = struct { + count: u32, + /// the tighter of the two bounds, or null when neither applied + limit: ?Limit, +}; + +fn extent(addr: u32, requested: u64, unit: u32, cap: u32) Extent { + var count = requested; + var limit: ?Limit = null; + if (count > cap) { + count = cap; + limit = .console; + } + const fits = (space - addr) / unit; + if (count > fits) { + count = fits; + limit = .space; + } + return .{ .count = @intCast(count), .limit = limit }; +} + +fn writeNote(w: *std.Io.Writer, e: Extent, requested: u64, unit_name: []const u8) !void { + switch (e.limit orelse return) { + .console => try w.print( + "clamped: {d} {s} requested, {d} shown ({d}-byte cap, one 115200-baud console)\n", + .{ requested, unit_name, e.count, max_bytes }, + ), + .space => try w.print( + "clamped: {d} {s} requested, {d} shown (the 32-bit address space ends at 0x100000000)\n", + .{ requested, unit_name, e.count }, + ), + } +} + +// The two bounds and their reporting, on the one part of this file that is +// pure arithmetic and therefore testable on any target — the accesses +// themselves are only meaningful on the board. +test "the clamp reports the tighter bound and never wraps the address space" { + const eq = std.testing.expectEqual; + // neither bound applied: what you asked for, and nothing to report + try eq(Extent{ .count = 3, .limit = null }, extent(0x4ff40000, 3, 4, max_words)); + // the console cap, in words and in bytes + try eq(Extent{ .count = max_words, .limit = .console }, extent(0x4ff40000, 99_999, 4, max_words)); + try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0, 100_000, 1, max_bytes)); + // sixteen bytes left above 0xfffffff0 — the whole point, because wrapping + // would show the BOTTOM of the space under top-of-space addresses + try eq(Extent{ .count = 16, .limit = .space }, extent(0xfffffff0, 64, 1, max_bytes)); + try eq(Extent{ .count = 4, .limit = .space }, extent(0xfffffff0, 64, 4, max_words)); + // ...including the row that has no whole word left in it + try eq(Extent{ .count = 0, .limit = .space }, extent(0xffffffff, 1, 4, max_words)); + // both bounds at once: the tighter one is the one reported + try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0xffff0000, 1 << 20, 1, max_bytes)); +} + +test "a clamp note is written exactly when something was clamped" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + + try writeNote(&w, extent(0x4ff40000, 3, 4, max_words), 3, "words"); + try std.testing.expectEqualStrings("", w.buffered()); + + try writeNote(&w, extent(0x4ff40000, 99_999, 4, max_words), 99_999, "words"); + try std.testing.expectEqualStrings( + "clamped: 99999 words requested, 1024 shown (4096-byte cap, one 115200-baud console)\n", + w.buffered(), + ); + + w = .fixed(&buf); + try writeNote(&w, extent(0xfffffff0, 64, 1, max_bytes), 64, "bytes"); + try std.testing.expectEqualStrings( + "clamped: 64 bytes requested, 16 shown (the 32-bit address space ends at 0x100000000)\n", + w.buffered(), + ); +} + +test "an address is hex or decimal, and a bare hex-looking token is decimal" { + try std.testing.expectEqual(0x4ff40000, parseAddr("0x4ff40000")); + try std.testing.expectEqual(0x4ff40000, parseAddr("1341390848")); + // a bare hex-looking token is a decimal number, never a guess + try std.testing.expectError(Error.BadAddress, parseAddr("4ff40000")); + try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000")); + try std.testing.expectError(Error.BadCount, parseCount("-1")); + try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000")); +} + +/// `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` +/// chorded onto one re-reads or writes exactly that word. +pub fn peek(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const requested = if (it.next()) |tok| try parseCount(tok) else 1; + if (it.next() != null) return Error.ExtraArgument; + if (addr % 4 != 0) return Error.MisalignedAddress; + + const e = extent(addr, requested, 4, max_words); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try writeNote(&out.writer, e, requested, "words"); + for (0..e.count) |i| { + const at = addr + @as(u32, @intCast(i * 4)); + try out.writer.print("0x{x:0>8}: 0x{x:0>8}\n", .{ at, readWord(at) }); + } + const content = try out.toOwnedSlice(); + try fill(p, id, .{ .cmd = .Peek }, content); +} + +/// `Poke ` — one 32-bit store, then one load back, both reported +/// on the message row. +/// +/// The READ-BACK is the whole point of the word and not a confirmation: on RAM +/// it always equals what you wrote and tells you nothing, and on MMIO it +/// almost never does — a write-only command register reads as 0, a W1C status +/// bit reads back cleared, a reserved field reads back masked, and a register +/// behind a gated clock reads back whatever the bus returns for nothing at +/// all. Printing only the value written would show you your own argument. +pub fn poke(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const value = try parseValue(it.next() orelse return Error.MissingValue); + if (it.next() != null) return Error.ExtraArgument; + if (addr % 4 != 0) return Error.MisalignedAddress; + + writeWord(addr, value); + const back = readWord(addr); + var buf: [96]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint( + &buf, + "0x{x:0>8}: wrote 0x{x:0>8}, reads 0x{x:0>8}", + .{ addr, value, back }, + ) catch unreachable); +} + +/// `Hexdump [len]` — len bytes, 16 to a row, hex columns and an ASCII +/// gutter, in `hexdump -C`'s layout because that is the one everyone can +/// already read. BYTE reads, so a partial row at the end of the space is a +/// short row rather than a refusal, and no alignment is required: this is the +/// word you reach for when you do not yet know what is there. +pub fn hexdump(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const requested = if (it.next()) |tok| try parseCount(tok) else 256; + if (it.next() != null) return Error.ExtraArgument; + + const e = extent(addr, requested, 1, max_bytes); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try writeNote(&out.writer, e, requested, "bytes"); + var row: u32 = 0; + while (row < e.count) : (row += 16) { + const n = @min(@as(u32, 16), e.count - row); + var bytes: [16]u8 = undefined; + for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i))); + try out.writer.print("0x{x:0>8} ", .{addr + row}); + for (0..16) |i| { + // hexdump -C's gap after the eighth column: the eye counts to + // eight, not to sixteen + if (i == 8) try out.writer.writeByte(' '); + if (i < n) + try out.writer.print(" {x:0>2}", .{bytes[i]}) + else + try out.writer.writeAll(" "); + } + try out.writer.writeAll(" |"); + for (0..n) |i| try out.writer.writeByte( + if (bytes[i] >= 0x20 and bytes[i] < 0x7f) bytes[i] else '.', + ); + try out.writer.writeAll("|\n"); + } + const content = try out.toOwnedSlice(); + try fill(p, id, .{ .cmd = .Hexdump }, content); +} + +/// The shared tail. `fillResults` is the one public entry that REFILLS the +/// buffer a command already opened instead of stacking a twin beside it, which +/// is what a dump wants: peeking twenty addresses in a row is twenty renders +/// of one window on memory, not twenty panes. The empty argument is what makes +/// it one window — a dump is identified by the command, never by the address, +/// so a second Peek replaces the first rather than opening a buffer per +/// address and exhausting the pane slots. +/// +/// Neither buffer `steps`, so nothing is armed on n/N and focus stays in the +/// pane you typed the command in. `content` is gpa-owned and adopted there. +fn fill(p: *Pardes, id: usize, from: output_pane.Origin, content: []u8) !void { + const pane = p.panes[id] orelse { + p.gpa.free(content); + return error.MissingPane; + }; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + try output_pane.fillResults(p, id, dir, from, "", content, null); +} -- cgit v1.3