//! The board's own address space and its pins, as text: the Peek, Poke, Hexdump and Gpio builtins' //! whole implementation. //! //! 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 `esp32p4`. //! //! 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"); const limits = @import("limits.zig"); /// THE ONE GATE, and it names the esp32p4 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. /// /// 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 `esp32p4` excludes it by construction rather than by a term /// somebody has to keep remembering. pub const enabled = pardes.platform == .esp32p4; // The target is now the WITNESS rather than the gate: whatever else `esp32p4` 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 (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. /// /// 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, /// 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 /// write a decimal one. /// /// This replaces base-0 parsing, which accepted `0x4ff40000` and `1341390848` and refused a bare /// `4ff40000` on the grounds that guessing between hex and decimal would make one typo address /// somewhere else entirely. That reasoning was sound and the conclusion was still wrong: the /// ambiguity it protected against is not a real one. Every address anybody has ever typed at these /// three words is hex - it came off a datasheet, a linker map, or a previous dump's own output, all /// of which print hex - so the base was never in doubt, and demanding `0x` on every one of them was /// a toll on the common case to guard a case that does not arise. /// /// The COUNTS go with them, and that is the part worth stating out loud rather than leaving as a /// surprise: `Hexdump 4ff40000 100` shows 0x100 bytes, which is 256, not one hundred. One rule for /// every literal in the word is worth more than two rules that each fit their argument better, /// because the second kind is the sort of thing you have to remember at the moment you are already /// concentrating on something else. Everything these words PRINT is hex too, including the clamp /// notes, so a number can go back in where it came out. fn parseHex(comptime T: type, tok: []const u8, bad: Error) Error!T { // `parseInt` only honours an `0x` when its base is 0, so with base 16 the prefix has to come off // here. A bare `0x` leaves nothing behind and `parseInt` rejects the empty string, which is the // answer that wants giving. const body = if (tok.len > 2 and tok[0] == '0' and (tok[1] | 0x20) == 'x') tok[2..] else tok; return std.fmt.parseInt(T, body, 16) catch bad; } fn parseAddr(tok: []const u8) Error!u32 { return parseHex(u32, tok, Error.BadAddress); } fn parseCount(tok: []const u8) Error!u64 { return parseHex(u64, tok, Error.BadCount); } fn parseValue(tok: []const u8) Error!u32 { return parseHex(u32, tok, 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) { // Hex, like everything else these words read and print, so the number in a clamp note can go // straight back into the command that produced it. .console => try w.print( "clamped: 0x{x} {s} requested, 0x{x} shown (0x{x}-byte cap, one 115200-baud console)\n", .{ requested, unit_name, e.count, max_bytes }, ), .space => try w.print( "clamped: 0x{x} {s} requested, 0x{x} 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: 0x1869f words requested, 0x400 shown (0x1000-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: 0x40 bytes requested, 0x10 shown (the 32-bit address space ends at 0x100000000)\n", w.buffered(), ); } test "every literal is hex, with or without the prefix" { const eq = std.testing.expectEqual; // the prefix is optional, never required, and never changes the answer try eq(0x4ff40000, parseAddr("0x4ff40000")); try eq(0x4ff40000, parseAddr("4ff40000")); try eq(0x4ff40000, parseAddr("0X4FF40000")); try eq(0x4ff40000, parseAddr("4FF40000")); // a token that looks decimal is hex too - the whole point, and the thing to remember try eq(0x100, parseCount("100")); try eq(0x256, parseCount("256")); try eq(0xdeadbeef, parseValue("deadbeef")); // and the refusals still refuse try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000")); try std.testing.expectError(Error.BadAddress, parseAddr("0x")); try std.testing.expectError(Error.BadAddress, parseAddr("nope")); try std.testing.expectError(Error.BadAddress, parseAddr("12g4")); try std.testing.expectError(Error.BadCount, parseCount("-1")); 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").esp32p4_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` /// 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 0x1; 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("{x:0>8}: {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, "{x:0>8}: wrote {x:0>8}, reads {x:0>8}", .{ addr, value, back }, ) 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 — see /// `limits.hexdump_row_bytes`, which is where that number and its reasoning /// live now. const row_bytes: u32 = limits.hexdump_row_bytes; /// `Hexdump [len]` — len bytes, `row_bytes` 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 0x100; 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 += row_bytes) { const n = @min(row_bytes, e.count - row); var bytes: [row_bytes]u8 = undefined; for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i))); try out.writer.print("{x:0>8} ", .{addr + row}); for (0..row_bytes) |i| { // The gap at the halfway mark: the eye counts to four or eight, not to sixteen. if (i == row_bytes / 2) 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); }