diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-26 09:56:28 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-26 09:56:28 -0300 |
| commit | 55743cc5d564f6ef6d6f8a0eb1a614a74b021d3a (patch) | |
| tree | 2d98cba30a8620605babb791e95dc0a381d4384c | |
| parent | ed8c5632e0228b1b821c87b511beb474c6a41f0c (diff) | |
| download | esp32p4-55743cc5d564f6ef6d6f8a0eb1a614a74b021d3a.tar.gz esp32p4-55743cc5d564f6ef6d6f8a0eb1a614a74b021d3a.zip | |
A test suite that runs on the die, and two build steps for reading an image
## zig build selftest
Seventeen checks, on the board, chosen by one rule: a check belongs there only if the die
can answer it and a host cannot. Every serious bug this port has produced was invisible to
a host test. `std.mem.eql` compares a byte at a time on this target, 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 sequence and wrong when a 115200 line hands over one byte every 87 us. A full
transmit FIFO stopped anything draining the receiver, and the FIFO depth is a hardware
number.
So: the volatile promise (two reads of a live counter are two reads, which is what `Peek`
rests on), word-wise equality checked against `std.mem.eql` itself at every difference
position and both alignments, the input rescue against a fake port with a FIFO that loses
what arrives into a full one, the allocator on real L2MEM, and the cycle counter against
the systimer - which is clocked from the crystal and therefore cannot be flattered by a
wrong CPU divider.
Anything that is pure logic stays in `zig build test`, which is faster and needs no
hardware. Duplicating those here would make the suite longer and no stronger.
Not a `zig test` binary, deliberately: Zig's 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. The harness is thirty lines.
`selftest` has its own application, image and flash chain so it is one command with no
flags to remember, and it makes the board's verdict the build's - a suite whose result a
human has to read out of a scrolling log is a suite that gets ignored on the first busy
afternoon. Proven both ways: 17/17 with exit 0, and exit 1 naming the check when one is
deliberately inverted.
The clock check earned its place immediately. The first version read `config.cpu_mhz` and
compared the die against it without ever performing the raise, so `-Dcpu-mhz=360` failed
with `khz=90001 want=360000`. The check was right and the expectation was wrong; it now
calls `setCpuFreq` itself, which makes it a test of the raise rather than a tautology.
90001 kHz at 90, 360004 at 360.
## zig build layout, and a loader error that explains itself
Both of these exist because of an hour I spent that they would have saved.
`NotTwoMappedSegments` said the count was wrong and nothing about what the segments were,
which is the only thing that says which section grew, shrank or stopped being emitted. I
diagnosed one by hand with readelf on an artifact that turned out to be a stale install,
then guessing at the linker script. The image step now prints the segment table with the
error - it has to happen there, because a rejected image is never written, so no later step
can show it - and `zig build layout` prints the same table for an ELF plus the loader's
verdict as TEXT, which `size` cannot do because it reads a finished image and the moment you
need the table is when there isn't one.
They immediately paid for themselves. The reason the suite would not build was that it had
no `_start`: `-fentry=_start` found no such symbol, `--gc-sections` discarded every
function as unreachable, and `.flash.text` was empty. `layout` prints `entry 0x0` for
exactly that, in one line. The second failure - a silent board - was a missing app
descriptor, which is the same class of thing and now has a comment where it happened.
## The panic handler already existed, and was printing past the end of its message
`msg` is a Zig slice and `%s` reads until a NUL, so handing `msg.ptr` to the ROM's printf
printed the message and then whatever followed it in memory. String literals get away with
it; std's own panics do not, because they are formatted into a buffer - "index out of
bounds: index 5, len 3" - and carry no terminator. It now goes out through `uart.write`,
which takes a length, with the fault address after it so addr2line can find the line.
| -rw-r--r-- | build.zig | 246 | ||||
| -rw-r--r-- | examples/selftest.zig | 321 | ||||
| -rw-r--r-- | src/pardes/app.zig | 19 |
3 files changed, 581 insertions, 5 deletions
@@ -257,6 +257,17 @@ pub fn build(b: *std.Build) void { }); app.root_module.addImport("heap", heap_mod); + // The input-rescue policy as a module, so `examples/selftest.zig` can run its checks ON THE DIE + // and not only on the host. Same file the firmware's UART uses. Added unconditionally, like + // `heap` above: an application that never imports it costs nothing, because an unreferenced + // module emits no code. + app.root_module.addImport("input_rescue", b.createModule(.{ + .root_source_file = b.path("src/pardes/input_rescue.zig"), + .target = target, + .optimize = optimize, + .single_threaded = true, + })); + if (pardes_app) { // The editor arrives as a linked OBJECT, not as a package dependency, and that is a // measurement rather than a preference. @@ -438,6 +449,58 @@ pub fn build(b: *std.Build) void { bench.addArgs(&.{ "--port", port_path }); bench.stdio = .inherit; bench.step.dependOn(&bench_install.step); + + // `zig build selftest` - its OWN application, image and flash chain, so it is one command with no + // flags to remember. Sharing the `-Dapp` pipeline would have meant `zig build selftest + // -Dapp=examples/selftest.zig`, which is the kind of incantation that turns a suite into + // something nobody runs. The modules are the ones its checks need and no more. + const selftest_exe = b.addExecutable(.{ + .name = "selftest", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/selftest.zig"), + .target = target, + .optimize = optimize, + .strip = true, + .single_threaded = true, + .unwind_tables = .none, + .omit_frame_pointer = true, + .error_tracing = false, + .imports = &.{ + .{ .name = "config", .module = config_mod }, + .{ .name = "soc", .module = soc_mod }, + .{ .name = "hal", .module = hal_mod }, + .{ .name = "mmio", .module = mmio_mod }, + .{ .name = "regs", .module = regs_mod }, + .{ .name = "heap", .module = heap_mod }, + .{ .name = "input_rescue", .module = b.createModule(.{ + .root_source_file = b.path("src/pardes/input_rescue.zig"), + .target = target, + .optimize = optimize, + .single_threaded = true, + }) }, + }, + }), + }); + selftest_exe.setLinkerScript(app.linker_script.?); + selftest_exe.link_function_sections = true; + selftest_exe.link_data_sections = true; + selftest_exe.entry = .{ .symbol_name = "_start" }; + // The app descriptor, without which the image has nothing at offset 0x20 for the bootloader to + // read and the board boots into silence - which is exactly how the first run of this step failed, + // and it looks identical to a suite that hung. + selftest_exe.root_module.addObject(appdesc_obj); + selftest_exe.step.dependOn(registers.census); + const selftest_img = ImageStep.create(b, selftest_exe, img.opts); + const selftest_flash = FlashStep.create(b, selftest_img, .{ + .port = flash.port, + .baud = flash.baud, + .verify = flash.verify, + .opts = img.opts, + }); + const selftest_run = SelftestStep.create(b, port_path, 20); + selftest_run.step.dependOn(&selftest_flash.step); + b.step("selftest", "flash the on-die test suite, run it, and fail the build if any check fails") + .dependOn(&selftest_run.step); b.step("bench", "measure the serial link: verified throughput each way, and latency") .dependOn(&bench.step); @@ -446,6 +509,9 @@ pub fn build(b: *std.Build) void { const size = SizeStep.create(b, img); b.step("size", "print the image layout byte by byte").dependOn(&size.step); + const layout_step = LayoutStep.create(b, app, img.opts); + b.step("layout", "print the ELF's image segments and the loader's verdict, without building an image") + .dependOn(&layout_step.step); // `zig build diff` - the hardware oracle. Builds the differential harness with ESP-IDF's own LL // functions linked in beside ours, flashes it, and prints the comparison. This is the project's @@ -1454,8 +1520,20 @@ const ImageStep = struct { // Validate before anything is written: a rejected image must never exist on disk under a // name the flash step - or a human with esptool - would pick up. - layout.validate(self.opts) catch |err| + // + // The TABLE goes out with the error, and that is not decoration. `NotTwoMappedSegments` says + // the count is wrong and says nothing about what the segments were, which is the only thing + // that tells you which section grew, shrank, or stopped being emitted at all. Diagnosing one + // of these by hand - readelf on an artifact that turned out to be a stale install, then + // guessing at the linker script - took an hour that this print makes unnecessary. It has to + // happen here because a rejected image is never written, so no later step can show it. + layout.validate(self.opts) catch |err| { + var buf: [4096]u8 = undefined; + var stderr = std.Io.File.stderr().writer(io, &buf); + describeLayout(&stderr.interface, layout, self.opts) catch {}; + stderr.interface.flush() catch {}; return step.fail("image violates a loader rule: {s}", .{@errorName(err)}); + }; const out_path = try b.cache_root.join(b.allocator, &.{ cache_dir, self.basename }); std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = layout.bytes }) catch |err| @@ -1466,6 +1544,95 @@ const ImageStep = struct { } }; +/// The image's segment table, in one place because three callers want exactly this and disagreeing +/// about it would be its own bug: `size` on an image that built, the failure path above on one the +/// loader rejected, and `layout` on an ELF that never got as far as an image. +/// +/// The mapped count is spelled out because it is the subject of the rule that fails most often - the +/// bootloader asserts on exactly two - and counting rows by eye is how you misread it. +fn describeLayout(w: *std.Io.Writer, l: image.Layout, opts: image.Options) !void { + var mapped: usize = 0; + var loaded: usize = 0; + for (l.segments) |s| switch (s.kind) { + .mapped => mapped += 1, + else => loaded += 1, + }; + try w.print("image {d} B = {d} B segments + {d} B overhead, entry 0x{x}\n", .{ + l.bytes.len, l.payload, l.overhead, l.entry, + }); + var off: usize = 24; + for (l.segments) |s| { + const flash = opts.flash_offset + off + 8; + try w.print(" {s:<6} vaddr=0x{x:0>8} len={d:>6} flash=0x{x:0>6}{s}\n", .{ + @tagName(s.kind), s.addr, s.len, flash, + if (s.kind == .mapped and flash % opts.mmu_page == s.addr % opts.mmu_page) + " congruent" + else + "", + }); + off += 8 + s.len; + } + try w.print(" {d} mapped, {d} loaded", .{ mapped, loaded }); + if (mapped != 2) { + // The one that bites, and the two ways it happens, because the error name says neither. + try w.print(" <-- the bootloader asserts on exactly 2 mapped segments.\n", .{}); + try w.print(" .flash.rodata and .flash.text are what produce them; one of them is\n", .{}); + try w.print(" empty or merged. `zig build layout` on the ELF shows which.\n", .{}); + } else try w.print("\n", .{}); + try w.print(" 24 B header + {d} B segment headers + checksum pad + 32 B sha256\n", .{l.segments.len * 8}); +} + +/// `zig build layout` - the segment table for an ELF, WITHOUT validating it. +/// +/// `size` cannot do this job: it reads the finished image, so it only runs when the image built, and +/// the moment you actually need the table is when it did not. This one starts from the ELF and +/// reports the loader verdict as text instead of as a failed build, so an image the bootloader would +/// refuse can still be inspected. +const LayoutStep = struct { + step: std.Build.Step, + elf: std.Build.LazyPath, + opts: image.Options, + + fn create(b: *std.Build, app: *std.Build.Step.Compile, opts: image.Options) *LayoutStep { + const self = b.allocator.create(LayoutStep) catch @panic("OOM"); + self.* = .{ + .step = std.Build.Step.init(.{ .id = .custom, .name = "layout", .owner = b, .makeFn = make }), + .elf = app.getEmittedBin(), + .opts = opts, + }; + self.elf.addStepDependencies(&self.step); + return self; + } + + fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void { + const self: *LayoutStep = @fieldParentPtr("step", step); + const b = step.owner; + const io = b.graph.io; + const gpa = options.gpa; + + const elf_path = self.elf.getPath2(b, step); + const elf_bytes = std.Io.Dir.cwd().readFileAlloc(io, elf_path, gpa, .limited(8 << 20)) catch |err| + return step.fail("unable to read {s}: {s}", .{ elf_path, @errorName(err) }); + defer gpa.free(elf_bytes); + + var layout = image.fromElf(gpa, elf_bytes, self.opts) catch |err| + return step.fail("cannot derive segments from {s}: {s}", .{ elf_path, @errorName(err) }); + defer layout.deinit(gpa); + + var buf: [4096]u8 = undefined; + var stdout = std.Io.File.stdout().writer(io, &buf); + const w = &stdout.interface; + try w.print("{s}\n", .{elf_path}); + try describeLayout(w, layout, self.opts); + if (layout.validate(self.opts)) |_| { + try w.print(" verdict: the loader would accept this image\n", .{}); + } else |err| { + try w.print(" verdict: the loader would REFUSE this image: {s}\n", .{@errorName(err)}); + } + try w.flush(); + } +}; + /// Read a built image back off disk. Both the flash and size steps do exactly this, which is what /// lets them work on a cache hit, in any order, or on an image from a previous build. fn readImage(step: *std.Build.Step, gpa: std.mem.Allocator, path: std.Build.LazyPath, opts: image.Options) !image.Layout { @@ -1652,6 +1819,83 @@ const ResetStep = struct { } }; +/// `zig build selftest` - run the die's own test suite and make its verdict the build's. +/// +/// `monitor` already prints what the board says, so this exists for one reason: an exit code. A +/// suite whose result a human has to read out of a scrolling log is a suite that gets ignored the +/// first busy afternoon, and the whole point of putting tests on the board was that the interesting +/// failures are the ones a host cannot see. +/// +/// Reads until `MARK SELFTEST DONE pass=N fail=M`, echoing as it goes so a failing check is visible +/// in place rather than only as a count. Absent marker within the window is itself a failure: it +/// means the board never got there, which is worse than a failed check and must not read as a pass. +const SelftestStep = struct { + step: std.Build.Step, + port: []const u8, + seconds: u32, + + fn create(b: *std.Build, port: []const u8, seconds: u32) *SelftestStep { + const self = b.allocator.create(SelftestStep) catch @panic("OOM"); + self.* = .{ + .step = std.Build.Step.init(.{ .id = .custom, .name = "selftest", .owner = b, .makeFn = make }), + .port = port, + .seconds = seconds, + }; + return self; + } + + fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void { + const self: *SelftestStep = @fieldParentPtr("step", step); + const b = step.owner; + + port_lock.lockUncancelable(b.graph.io); + defer port_lock.unlock(b.graph.io); + + var port = serial.Port.open(self.port, .b115200) catch |err| + return failPort(step, self.port, err); + defer port.close(); + port.resetToRun(.{}) catch {}; + + var out_buf: [4096]u8 = undefined; + var stdout = std.Io.File.stdout().writer(b.graph.io, &out_buf); + var buf: [1024]u8 = undefined; + // The summary can straddle a read, so the tail of every read is kept. 128 is far more than + // the marker needs and costs nothing. + var tail: [128]u8 = undefined; + var tail_len: usize = 0; + const deadline = port.nowMs() + @as(i64, self.seconds) * 1000; + + while (port.nowMs() < deadline) { + const n = port.readTimeout(&buf, 200) catch break; + if (n == 0) continue; + try stdout.interface.writeAll(buf[0..n]); + try stdout.interface.flush(); + + const keep = @min(tail_len, tail.len - @min(n, tail.len)); + if (keep > 0) std.mem.copyForwards(u8, tail[0..keep], tail[tail_len - keep ..][0..keep]); + const take = @min(n, tail.len - keep); + @memcpy(tail[keep..][0..take], buf[n - take ..][0..take]); + tail_len = keep + take; + + if (std.mem.indexOf(u8, tail[0..tail_len], "SELFTEST DONE")) |at| { + const rest = tail[at..tail_len]; + const fail_at = std.mem.indexOf(u8, rest, "fail=") orelse continue; + var digits = rest[fail_at + 5 ..]; + var end: usize = 0; + while (end < digits.len and digits[end] >= '0' and digits[end] <= '9') end += 1; + if (end == 0) continue; + const failures = std.fmt.parseInt(u32, digits[0..end], 10) catch continue; + if (failures != 0) return step.fail("{d} on-board check(s) failed", .{failures}); + return; + } + } + return step.fail( + "the board never reported a result within {d}s - it did not reach the end of the suite", + .{self.seconds}, + ); + } +}; + const SizeStep = struct { step: std.Build.Step, bin: std.Build.LazyPath, diff --git a/examples/selftest.zig b/examples/selftest.zig new file mode 100644 index 0000000..e1bd19a --- /dev/null +++ b/examples/selftest.zig @@ -0,0 +1,321 @@ +//! The test suite that 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 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. +//! +//! 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 selftest +//! or: zig build -Dapp=examples/selftest.zig run + +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/pardes/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) {} +} diff --git a/src/pardes/app.zig b/src/pardes/app.zig index 1302556..8b43791 100644 --- a/src/pardes/app.zig +++ b/src/pardes/app.zig @@ -405,10 +405,21 @@ fn logFn( pub const panic = std.debug.FullPanic(panicImpl); -fn panicImpl(msg: []const u8, _: ?usize) noreturn { - // The ROM path deliberately: a panic may BE the console writer failing, and `ets_printf` shares - // nothing with `uart.write` except the FIFO itself. - soc.rom.print("\r\nMARK PARDES_PANIC %s\r\n", .{msg.ptr}); +fn panicImpl(msg: []const u8, first_trace_addr: ?usize) noreturn { + // The fixed text goes out through the ROM deliberately: a panic may BE the console writer + // failing, and `ets_printf` shares nothing with `uart.write` except the FIFO itself. + // + // The MESSAGE does not, and that is a correction rather than a preference. `msg` is a Zig SLICE + // and `%s` reads until a NUL, so handing `msg.ptr` to printf prints the message and then + // whatever happens to sit after it in memory until a zero byte turns up. Literals get away with + // it; std's own panics do not, because they are formatted into a buffer - "index out of bounds: + // index 5, len 3" - and carry no terminator. `uart.write` takes a length. + soc.rom.print("\r\nMARK PARDES_PANIC ", .{}); + uart.write(msg); + // The address is what makes it actionable: addr2line against the ELF in zig-out turns it into a + // source line, and without it a panic message names a KIND of failure with no way to find which + // one of them happened. Zero when the caller had no return address to give. + soc.rom.print("\r\nMARK PARDES_PANIC_AT 0x%08x\r\n", .{@as(u32, @truncate(first_trace_addr orelse 0))}); while (true) {} } |
