summaryrefslogtreecommitdiff
path: root/examples/differ.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 12:40:53 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:46:51 -0300
commitf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch)
tree2731a3ed4e51cae09e184e25778eded5fc37d1f5 /examples/differ.zig
downloadesp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz
esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker. src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers; src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip; src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'examples/differ.zig')
-rw-r--r--examples/differ.zig203
1 files changed, 203 insertions, 0 deletions
diff --git a/examples/differ.zig b/examples/differ.zig
new file mode 100644
index 0000000..865af3e
--- /dev/null
+++ b/examples/differ.zig
@@ -0,0 +1,203 @@
+//! Differential test: this project's Zig HAL against ESP-IDF's own LL, in one image, on the die.
+//!
+//! zig build diff -Doracle -Dapp=examples/differ.zig
+//!
+//! Both implementations are compiled into the same binary - IDF's `*_ll.h` by Zig's clang, ours by
+//! Zig - so they run on the same boot, the same clocks and the same silicon. For each operation the
+//! harness brings the peripheral to a known state, runs ESP-IDF's version, photographs the register
+//! block, restores, runs ours, photographs again, and compares. A pass means: for this operation and
+//! these arguments, our sequence leaves the hardware in the state ESP-IDF's does.
+//!
+//! Four rules this harness follows because adversarial review measured what happens without them:
+//!
+//! 1. **A snapshot can have side effects.** `UART_FIFO_REG` is at offset 0x000 of every UART block -
+//! the first word a "read the whole block" loop touches - and reading it *pops the RX FIFO*. The
+//! header annotates it `RO`. So each peripheral declares offsets that must not be read.
+//! 2. **A block cannot be restored by writing its snapshot back.** About 10% of this chip's fields
+//! perform an action when written; writing one saved word back to a UART's offset 0 transmits a
+//! character, and restoring GPIO's saved `ENABLE_W1TC` would clear the enables just set. Restore
+//! is either the peripheral's reset bit or a deliberate configure function - never a write-back.
+//! 3. **A clock-gated block reads stale data, silently.** Not zeros: the last value latched. Two
+//! snapshots of a gated peripheral can compare *equal* while describing nothing, so the bus
+//! clock is checked before every comparison.
+//! 4. **Equal registers do not prove equal sequences.** Ordering is invisible in the final state,
+//! and ordering is where the interesting bugs are - LEDC's shadow registers commit on a
+//! self-clearing bit that leaves no trace. Where a peripheral's correctness is an order rather
+//! than a state, its case list says so.
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const oracle = @import("oracle");
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK DIFF_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+/// Widest register block any suite compares. 400 words covers GPIO through its matrix
+/// configuration; two snapshots at that size are 3.2 KB of L2MEM, which this image has to spare.
+const max_words = 512;
+var snap_a: [max_words]u32 = @splat(0);
+var snap_b: [max_words]u32 = @splat(0);
+
+var cases_run: u32 = 0;
+var failures: u32 = 0;
+
+fn contains(haystack: []const u32, needle: u32) bool {
+ for (haystack) |h| if (h == needle) return true;
+ return false;
+}
+
+fn snapshot(p: oracle.types.Peripheral, out: []u32) void {
+ for (0..p.words) |i| {
+ const w: u32 = @intCast(i);
+ if (contains(p.no_read, w)) {
+ // A value hardware cannot produce, so a diff involving it is obviously a harness bug
+ // rather than a peripheral difference.
+ out[i] = 0xdead_0000 | w;
+ continue;
+ }
+ out[i] = mmio.Reg.atAddress(p.base + w * 4).raw();
+ }
+}
+
+fn restore(p: oracle.types.Peripheral) void {
+ switch (p.restore) {
+ .configure => |f| f(),
+ .reset_bit => |b| {
+ // Assert then deassert, with interrupts masked: these bits share a register with every
+ // other peripheral's reset.
+ const guard = hal.clkrst.maskInterrupts();
+ defer guard.release();
+ const r = mmio.Reg.atAddress(b.reg);
+ r.writeRaw(r.raw() | (@as(u32, 1) << b.bit));
+ r.writeRaw(r.raw() & ~(@as(u32, 1) << b.bit));
+ },
+ }
+}
+
+fn runSuite(suite: oracle.types.Suite) void {
+ const p = suite.descriptor;
+ if (p.words > max_words) {
+ soc.rom.print("MARK DIFF_SKIP %s wants %u words, harness holds %u\r\n", .{ p.name, p.words, @as(u32, max_words) });
+ failures += 1;
+ return;
+ }
+ if (suite.setup) |s| s();
+
+ for (suite.cases) |c| {
+ cases_run += 1;
+
+ // Rule 3: a gated block returns the last latched value, so two snapshots of it can agree
+ // and mean nothing.
+ if (p.clock) |clk| {
+ if (mmio.Reg.atAddress(clk.reg).raw() & (@as(u32, 1) << clk.bit) == 0) {
+ soc.rom.print("MARK DIFF SKIP %s.%s bus clock is off; a snapshot would be stale\r\n", .{ p.name, c.name });
+ failures += 1;
+ continue;
+ }
+ }
+
+ restore(p);
+ c.idf();
+ snapshot(p, &snap_a);
+
+ restore(p);
+ c.ours();
+ snapshot(p, &snap_b);
+
+ var diffs: u32 = 0;
+ for (0..p.words) |i| {
+ const w: u32 = @intCast(i);
+ if (contains(p.volatile_words, w)) continue;
+ if (snap_a[i] == snap_b[i]) continue;
+ diffs += 1;
+ if (diffs <= 4) {
+ soc.rom.print(" DIFF %s+0x%03x idf=0x%08x ours=0x%08x xor=0x%08x\r\n", .{
+ p.name, w * 4, snap_a[i], snap_b[i], snap_a[i] ^ snap_b[i],
+ });
+ }
+ }
+ if (diffs == 0) {
+ soc.rom.print("MARK DIFF ok %s.%s(%u) %u words identical\r\n", .{ p.name, c.name, c.arg, p.words });
+ } else {
+ failures += 1;
+ soc.rom.print("MARK DIFF FAIL %s.%s(%u) %u of %u words differ\r\n", .{ p.name, c.name, c.arg, diffs, p.words });
+ }
+ }
+}
+
+export fn zig_main() noreturn {
+ // First, before anything long-running: take the RTC watchdog off the board.
+ //
+ // The bootloader arms it to cover the handover and expects the application to take it over.
+ // Nothing in this repo ever did, and nothing noticed, because no run had exceeded eight seconds.
+ // This harness passed 26 cases, then 64, and then started resetting mid-run - which looked
+ // exactly like "the newest suite crashes the board" and was in fact a ten-second fuse that had
+ // been burning since the first image.
+ const wdt_was_armed = hal.rwdt.armed();
+ const wdt_off = hal.rwdt.disable();
+ soc.rom.print("\r\nMARK DIFF_START esp-idf LL vs zig HAL, one image, on the die\r\n", .{});
+ soc.rom.print("MARK DIFF_WDT armed_at_entry=%u disabled=%u (bootloader leaves the RTC watchdog running)\r\n", .{
+ @as(u32, @intFromBool(wdt_was_armed)),
+ @as(u32, @intFromBool(wdt_off)),
+ });
+ // If IDF's LL was compiled to call the mask ROM, the comparison would be against
+ // `rom_gpio_set_output_level` rather than against IDF's register sequence. src/oracle/
+ // oracle_sdkconfig.h exists to keep this at 0.
+ soc.rom.print("MARK DIFF_CFG gpio_ll_uses_rom_api=%u expect=0\r\n", .{
+ @as(u32, @intFromBool(oracle.gpio.usesRomApi())),
+ });
+
+ // GPIO's two suites run once per pin, because the bank split at 32 is where its arithmetic
+ // differs - and because the pad registers live in a different register file from the GPIO block,
+ // far enough away that one window cannot cover both.
+ for (oracle.gpio.pins) |p| {
+ oracle.gpio.pin = p;
+ soc.rom.print("MARK DIFF_PIN %u\r\n", .{@as(u32, p)});
+ runSuite(oracle.gpio.suite);
+ runSuite(oracle.gpio.iomux_suite);
+ }
+
+ // Every other registered peripheral. The two GPIO suites are skipped here because the loop above
+ // already ran them once per pin.
+ inline for (oracle.suites) |suite| {
+ const n = comptime std.mem.span(suite.descriptor.name);
+ if (comptime !std.mem.eql(u8, n, "gpio") and !std.mem.eql(u8, n, "iomux")) runSuite(suite);
+ }
+
+ soc.rom.print("MARK DIFF_TOTAL cases=%u failures=%u\r\n", .{ cases_run, failures });
+ soc.rom.print("MARK DIFF_DONE\r\n", .{});
+
+ // Leave the board as the rest of the project expects it: LED pin an output, blinking.
+ hal.gpio.configureOutput(20, .{ .readback = true });
+ while (true) {
+ hal.gpio.setHigh(20);
+ soc.rom.ets_delay_us(500_000);
+ hal.gpio.setLow(20);
+ soc.rom.ets_delay_us(500_000);
+ }
+}
+
+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
+ );
+}