summaryrefslogtreecommitdiff
path: root/build.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 09:56:28 -0300
committerGabriel Schneider <[email protected]>2026-08-26 09:56:28 -0300
commit55743cc5d564f6ef6d6f8a0eb1a614a74b021d3a (patch)
tree2d98cba30a8620605babb791e95dc0a381d4384c /build.zig
parented8c5632e0228b1b821c87b511beb474c6a41f0c (diff)
downloadesp32p4-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.
Diffstat (limited to 'build.zig')
-rw-r--r--build.zig246
1 files changed, 245 insertions, 1 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,