diff options
| -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) {} } |
