diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-26 13:27:46 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 09:47:39 -0300 |
| commit | 11f380f6d7222f2cad93c2cdf13701ea1f903d47 (patch) | |
| tree | 803194ee5853a6b4cda93f90a95e28d1f02e69ae /src/esp32p4/selftest.zig | |
| parent | fbc194068687e49a8490c85c9f1257a2f2bb9079 (diff) | |
| download | pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.tar.gz pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.zip | |
One core behind N frontends, the board's own runner moved in, and every board cap on one screen
## The wire is the effect stream, not a new protocol
`pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns
a terminal and a socket and nothing else. N frontends on one core all look at the same screen —
`screen -x`, not N sessions.
The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per
message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the
core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is
a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin
command line, and a command line cannot carry a frame.
ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be
riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit
little-endian fixed width and no message is a blit of a native struct. A protocol that only works
between two builds of the same compiler would have thrown away the one frontend that motivated it.
## The board comes in; its toolchain stays out
`src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over-
serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves
into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so
`zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the
board from this repo's `build.zig`.
The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes
core over a serial line. What stayed is everything a second project would also want — the HAL, the
register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its
own and its `build()` early-returns when it is not the root package, so this costs the package
graph exactly zero packages and the editor's own builds nothing at all.
## limits.zig: nine forgettable places become one budget
Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions —
they are ONE decision, how much memory this build may spend, taken nine times where no reader could
see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against
what it is measured against, derived from two booleans.
The payoff is testability on a machine that is not the board: the caps are ordinary comptime values,
so a host build can be compiled against the board's numbers and the parking, eviction and clamping
paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART.
## A bare `zig build`
`zig build` with no arguments now builds the tty and GUI binaries and installs them into
`~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built
one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and
"use it" two different commands for no reason.
Diffstat (limited to 'src/esp32p4/selftest.zig')
| -rw-r--r-- | src/esp32p4/selftest.zig | 351 |
1 files changed, 351 insertions, 0 deletions
diff --git a/src/esp32p4/selftest.zig b/src/esp32p4/selftest.zig new file mode 100644 index 00000000..0d692d98 --- /dev/null +++ b/src/esp32p4/selftest.zig @@ -0,0 +1,351 @@ +//! pardes's own test suite for the ESP32-P4, which runs ON THE DIE. +//! +//! WHY THIS EXISTS. Every serious bug this port has produced was invisible to a host test, and two +//! of them were invisible for months. `std.mem.eql` compares a byte at a time on this target and +//! several times faster on the host, so the firmware's largest read was three times slower than it +//! needed to be and nothing on a laptop could tell. A lone ESC resolves to the Escape key, which is +//! right when a kernel hands over a whole escape sequence and wrong when a 115200 line hands over +//! one byte every 87 microseconds. A full transmit FIFO stopped anything draining the receiver, and +//! the FIFO depth is a hardware number. None of those is a logic error you can reason your way to +//! from a host: they are properties of THIS chip, THIS clock and THIS wire. +//! +//! So the checks below are chosen by one rule: a check belongs here only if the die can answer it +//! and a host cannot. Anything that is pure logic - the ring's wrap-around, the mouse coalescer's +//! ordering - already has a deterministic host test in `zig build unit-test`, which is faster, needs +//! no hardware, and is where such a thing belongs. Duplicating those here would make this suite +//! longer and no stronger. +//! +//! ## Why the suite is in the editor's repository +//! +//! It was written in the `05-zig-p4` toolchain repository as `examples/selftest.zig`, and it was +//! never an example of anything. Re-read the paragraph above: every claim it makes is a claim about +//! THIS program on this board. The word-at-a-time `std.mem.eql` it measures against is the reason +//! the editor's frame diff compares rows a `u32` at a time; the lone-ESC decode and the +//! transmit-FIFO backpressure are `input_rescue.zig`, a sibling file in this directory; the heap +//! span and the allocator churn are what decide how large a grid this board can drive; and the +//! clock check is the divisor under every `-Dprof` cycle count the editor reports. A suite that +//! specific to one application belongs beside it, so that changing a source and changing the check +//! which defends it are one diff in one repository rather than two commits in two trees. +//! +//! What stayed behind in the toolchain is the half that is about the CHIP: the image builder's rules, +//! the register layer's field arithmetic, the console bridge's escape matcher. Those still run under +//! `zig build test` over there and need no board. +//! +//! ## What it expects from the toolchain package +//! +//! Four of its modules and no more: `soc` (mask-ROM printf, the cycle counter), `hal` (UART0, the +//! systimer, clock/reset), `config` (this build's `cpu_mhz`) and `heap` (the coalescing allocator). +//! They arrive as the `zig_p4` dependency, which also supplies what makes this an image at all - +//! the generated linker script, `ENTRY(_start)` and the app descriptor via `firmware(...).attach`, +//! then `ImageStep`, `FlashStep` and `SelftestStep`. +//! +//! `input_rescue` is the fifth import and is NOT that package's: it is the sibling file in this +//! directory, handed over as a named MODULE rather than imported as a path. Deliberately so - the +//! `pub` on `FakePort`'s methods below is what lets that module reach them by duck typing across the +//! boundary, and both builds, this repository's `esp32p4-test` and the toolchain's `selftest`, wire +//! the identical root the identical way. One file, one arrangement, nothing to drift. +//! +//! Not a `zig test` binary, deliberately. Zig's test runner wants an OS, and `std.testing.allocator` +//! is a debug allocator over the page allocator, which on freestanding is either a compile error or +//! a lie. A hand-rolled harness is thirty lines and answers to nobody. +//! +//! Run with: zig build esp32p4-test -Dplatform=esp32p4 -Desp32p4-firmware (from here) +//! or: zig build selftest (from ../05-zig-p4) + +const std = @import("std"); +const soc = @import("soc"); +const hal = @import("hal"); +const config = @import("config"); +const heapmod = @import("heap"); +const input_rescue = @import("input_rescue"); + +/// Reset entry. Identical in shape to `src/main.zig`'s and for the same reasons: the bootloader hands +/// over with an unspecified stack pointer and the FPU off, so set `mstatus.FS`, establish a stack, +/// clear `.bss`, and jump. +/// +/// Leaving this out is what made the first draft of this file unbuildable, and the failure said +/// nothing useful: `-fentry=_start` found no such symbol, `--gc-sections` then discarded every +/// function as unreachable, and the image builder reported `NotTwoMappedSegments` because +/// `.flash.text` had nothing left in it. `zig build layout` now prints `entry 0x0` for exactly that, +/// which is the same diagnosis in one line. +export fn _start() linksection(".text.entry") callconv(.naked) noreturn { + asm volatile ( + \\ li t0, 1 << 13 + \\ csrs mstatus, t0 + \\ la sp, __stack_top + \\ mv fp, sp + \\ la t0, __bss_start + \\ la t1, __bss_end + \\ bgeu t0, t1, 2f + \\1: + \\ sw zero, 0(t0) + \\ addi t0, t0, 4 + \\ bltu t0, t1, 1b + \\2: + \\ j zig_main + ); +} + +pub const panic = std.debug.FullPanic(struct { + fn call(msg: []const u8, first_trace_addr: ?usize) noreturn { + // `msg` is a slice and carries no terminator - std's own panics are formatted into a buffer - + // so it goes out with a length rather than through a `%s` that would read past the end of it. + soc.rom.print("\r\nMARK SELFTEST_PANIC at 0x%08x: ", .{@as(u32, @truncate(first_trace_addr orelse 0))}); + hal.uart.Uart.init(0).write(msg); + // A panic is a FAILED RUN, and the host is watching for the summary line. Without this the + // run looks like a board that never answered, which is a different diagnosis entirely. + soc.rom.print("\r\nMARK SELFTEST DONE pass=%u fail=%u\r\n", .{ passed, failed + 1 }); + while (true) {} + } +}.call); + +var passed: u32 = 0; +var failed: u32 = 0; + +/// One claim about the silicon. Printed either way: a suite that only speaks up when it fails gives +/// no way to tell "all good" from "never ran", and on a board that difference matters. +fn check(name: [*:0]const u8, ok: bool) void { + if (ok) { + passed += 1; + soc.rom.print("MARK SELFTEST ok %s\r\n", .{name}); + } else { + failed += 1; + soc.rom.print("MARK SELFTEST FAIL %s\r\n", .{name}); + } +} + +/// Word-at-a-time equality, the same shape the editor's frame diff uses. +fn sameBytesWordwise(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + if ((@intFromPtr(a.ptr) | @intFromPtr(b.ptr)) & 3 == 0) { + const n = a.len / 4; + const wa: [*]align(4) const u32 = @ptrCast(@alignCast(a.ptr)); + const wb: [*]align(4) const u32 = @ptrCast(@alignCast(b.ptr)); + for (wa[0..n], wb[0..n]) |x, y| { + if (x != y) return false; + } + return std.mem.eql(u8, a[n * 4 ..], b[n * 4 ..]); + } + return std.mem.eql(u8, a, b); +} + +/// A UART with a receive FIFO that loses whatever arrives into a full one, as the hardware does. +/// `pub` on the methods because `input_rescue` is a separate module here and reaches them by duck +/// typing across it. +const FakePort = struct { + tx_cap: u32, + tx_used: u32 = 0, + ticks: u32 = 0, + sent: u32 = 0, + incoming: []const u8, + delivered: usize = 0, + rx: [8]u8 = undefined, + rx_head: usize = 0, + rx_len: usize = 0, + lost: u32 = 0, + + fn tick(p: *FakePort) void { + p.ticks += 1; + if (p.ticks % 4 == 0 and p.tx_used > 0) p.tx_used -= 1; + if (p.delivered < p.incoming.len) { + const byte = p.incoming[p.delivered]; + p.delivered += 1; + if (p.rx_len == p.rx.len) { + p.lost += 1; + } else { + p.rx[(p.rx_head + p.rx_len) % p.rx.len] = byte; + p.rx_len += 1; + } + } + } + pub fn txFree(p: *FakePort) u32 { + p.tick(); + return p.tx_cap - p.tx_used; + } + pub fn pushByte(p: *FakePort, _: u8) void { + p.sent += 1; + p.tx_used += 1; + } + pub fn rxCount(p: *FakePort) u32 { + return @intCast(p.rx_len); + } + pub fn popByte(p: *FakePort) u8 { + const byte = p.rx[p.rx_head]; + p.rx_head = (p.rx_head + 1) % p.rx.len; + p.rx_len -= 1; + return byte; + } +}; + +/// Backing store for the heap checks. Static, because the point is to exercise the allocator on real +/// L2MEM rather than to find out where a stack array happens to land. +var heap_area: [64 * 1024]u8 align(16) = undefined; + +export fn zig_main() noreturn { + // THE CLOCK RAISE, performed here rather than assumed, which is what turns the frequency check + // at the end into a test of `setCpuFreq` instead of a tautology. The first version of this file + // read `config.cpu_mhz` and compared the die against it without ever setting it, so + // `-Dcpu-mhz=360` failed with `khz=90001 want=360000` - the check was right and the expectation + // was wrong. Same call, and the same order, as `src/esp32p4/app.zig`. + if (config.cpu_mhz != 90) hal.clkrst.setCpuFreq(switch (config.cpu_mhz) { + 360 => .mhz360, + else => .mhz90, + }); + hal.systimer.init(); + + soc.rom.print("\r\nMARK SELFTEST_START cpu_mhz=%u\r\n", .{@as(u32, config.cpu_mhz)}); + + // ------------------------------------------------ 1. the memory model the memory words assume + // + // `Peek`, `Poke` and `Hexdump` reach the bus through `*allowzero volatile` pointers and refuse an + // unaligned word. Both halves are claims about this core, and neither is checkable on a host. + { + const cell: *volatile u32 = @ptrCast(@alignCast(&heap_area[0])); + cell.* = 0xdeadbeef; + check("an aligned word round-trips through a volatile pointer", cell.* == 0xdeadbeef); + + // Every byte offset in a word, readable and writable: this is what `Hexdump` does, and it is + // why `Hexdump` needs no alignment while `Peek` does. + var all_offsets_ok = true; + for (0..4) |i| { + const at: *volatile u8 = @ptrCast(&heap_area[16 + i]); + at.* = @intCast(0xa0 + i); + if (at.* != 0xa0 + @as(u8, @intCast(i))) all_offsets_ok = false; + } + check("a byte at every offset in a word round-trips", all_offsets_ok); + + // THE VOLATILE PROMISE. Two reads of a running counter must be two reads. Were the optimiser + // allowed to fold them, `Peek` would print one value twice for a register that had changed, + // which is the one thing a memory word must never do. + const first = hal.systimer.micros(.unit0) orelse 0; + var spin: u32 = 0; + while (spin < 4000) : (spin += 1) asm volatile ("" ::: .{ .memory = true }); + const second = hal.systimer.micros(.unit0) orelse 0; + check("two reads of a live counter are two reads", second != first); + check("and that counter runs forwards", second > first); + } + + // --------------------------------------- 2. word-wise equality, on THIS instruction set + // + // The editor's frame diff compares rows a `u32` at a time because `std.mem.eql` compares a byte + // at a time here: 223 us against 66 for the same answer. "The same answer" is the part that has + // to hold on the target rather than on the host, so it is checked against `std.mem.eql` itself, + // at every difference position, at both alignments, and at lengths that are not multiples of 4. + { + var a: [64]u8 align(4) = undefined; + var b: [64]u8 align(4) = undefined; + for (&a, 0..) |*slot, i| slot.* = @intCast(i); + @memcpy(&b, &a); + + var agree = true; + for (0..a.len) |len| { + if (sameBytesWordwise(a[0..len], b[0..len]) != std.mem.eql(u8, a[0..len], b[0..len])) agree = false; + } + check("aligned equality agrees with std.mem.eql at every length", agree); + + agree = true; + for (0..a.len) |i| { + b[i] ^= 0xff; + if (sameBytesWordwise(&a, &b) != std.mem.eql(u8, &a, &b)) agree = false; + if (sameBytesWordwise(a[0..33], b[0..33]) != std.mem.eql(u8, a[0..33], b[0..33])) agree = false; + b[i] ^= 0xff; + } + check("a difference at any position is found, as std.mem.eql finds it", agree); + + agree = true; + // UNALIGNED, which is why `sameBytes` tests alignment at RUNTIME: `Cell` is all u8 fields, so + // whether a row starts on a word boundary belongs to the allocator and not to the type. + for (1..4) |off| { + const ua = a[off..]; + const ub = b[off..]; + if (sameBytesWordwise(ua, ub) != std.mem.eql(u8, ua, ub)) agree = false; + b[off + 5] ^= 0xff; + if (sameBytesWordwise(ua, ub) != std.mem.eql(u8, ua, ub)) agree = false; + b[off + 5] ^= 0xff; + } + check("unaligned spans fall back and still agree", agree); + } + + // ------------------------------------------------- 3. the rescue, on the real codegen + // + // The logic has a host test. What that cannot say is whether it behaves the same compiled for + // this core at this optimisation level, which is a question only a board answers. + { + const typed = "the quick brown fox jumps over the lazy dog, and then some more besides"; + var port: FakePort = .{ .tx_cap = 2, .incoming = typed }; + var ring: input_rescue.Ring = .{}; + const frame = "\x1b[1;1H" ++ "x" ** 300; + const abandoned = input_rescue.pump(&port, &ring, frame, 1_000_000); + + check("the frame went out whole", abandoned == 0 and port.sent == frame.len); + check("the receive FIFO never overflowed", port.lost == 0); + check("the ring dropped nothing", ring.dropped == 0); + + var got: [128]u8 = undefined; + var n = ring.pop(&got); + while (port.rxCount() > 0 and n < got.len) : (n += 1) got[n] = port.popByte(); + check("every rescued byte, in order", n == typed.len and std.mem.eql(u8, got[0..n], typed)); + } + + // --------------------------------------------------- 4. the allocator on real L2MEM + // + // The editor's whole geometry ceiling is an allocator question, and this is the allocator, on the + // memory it actually runs in rather than on a host's malloc. + { + var h = heapmod.Heap.init(heap_area[0..]); + const gpa = h.allocator(); + + const one = gpa.alloc(u32, 256) catch null; + check("a modest allocation succeeds", one != null); + if (one) |slice| { + check("and is aligned for its element", @intFromPtr(slice.ptr) % @alignOf(u32) == 0); + for (slice, 0..) |*slot, i| slot.* = @intCast(i * 7); + var intact = true; + for (slice, 0..) |slot, i| { + if (slot != i * 7) intact = false; + } + check("and holds what was written to it", intact); + gpa.free(slice); + } + + // FREE THEN REUSE. A heap that cannot hand the same bytes back is a heap that runs out, which + // on this board is the difference between a 40x12 grid and an 80x24 one. + var churn_ok = true; + for (0..64) |_| { + const block = gpa.alloc(u8, 1024) catch { + churn_ok = false; + break; + }; + gpa.free(block); + } + check("a kilobyte can be taken and returned repeatedly", churn_ok); + + // AND IT REFUSES CLEANLY. An allocator that returns garbage instead of an error when it is + // out is the failure mode that cost an afternoon during bring-up. + const absurd = gpa.alloc(u8, heap_area.len * 4); + check("an impossible allocation returns an error", absurd == error.OutOfMemory); + } + + // ------------------------------------------ 5. the clock, which everything divides by + // + // Every cycle count this firmware reports is divided by the configured frequency somewhere, and + // the systimer is clocked from the crystal rather than from the CPU - which is exactly what makes + // it a reference the CPU cannot flatter. The 90-to-360 MHz raise rested on this comparison. + { + const t0 = hal.systimer.micros(.unit0) orelse 0; + const c0 = soc.cycles(); + while ((hal.systimer.micros(.unit0) orelse 0) -% t0 < 20_000) {} + const us = (hal.systimer.micros(.unit0) orelse 0) -% t0; + const cy = soc.cycles() - c0; + const khz: u32 = if (us > 0) @intCast(cy * 1000 / us) else 0; + const want: u32 = @as(u32, config.cpu_mhz) * 1000; + // Two percent: far wider than either clock's error, far narrower than the 4x a wrong divider + // would produce. + const slack = want / 50; + soc.rom.print("MARK SELFTEST_CLOCK khz=%u want=%u\r\n", .{ khz, want }); + check("the cycle counter and the systimer agree on the CPU frequency", khz > want - slack and khz < want + slack); + } + + soc.rom.print("MARK SELFTEST DONE pass=%u fail=%u\r\n", .{ passed, failed }); + while (true) {} +} |
