summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig246
-rw-r--r--examples/selftest.zig321
-rw-r--r--src/pardes/app.zig19
3 files changed, 581 insertions, 5 deletions
diff --git a/build.zig b/build.zig
index a7e9edb..f85ce1f 100644
--- a/build.zig
+++ b/build.zig
@@ -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) {}
}