summaryrefslogtreecommitdiff
path: root/examples/intrcheck.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/intrcheck.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/intrcheck.zig')
-rw-r--r--examples/intrcheck.zig269
1 files changed, 269 insertions, 0 deletions
diff --git a/examples/intrcheck.zig b/examples/intrcheck.zig
new file mode 100644
index 0000000..278ee8e
--- /dev/null
+++ b/examples/intrcheck.zig
@@ -0,0 +1,269 @@
+//! Does an interrupt actually get taken? The one question the differential harness cannot answer.
+//!
+//! zig build -Dapp=examples/intrcheck.zig run -Dseconds=10
+//!
+//! The register differential proves that `hal.intr`'s writes land in the same registers ESP-IDF's do.
+//! It cannot prove that the CLIC then delivers anything, because delivery leaves no trace in any
+//! register it photographs: mtvec and MTVT are CSRs, the vector table is memory, and whether the
+//! core vectored to the right handler is a fact about control flow.
+//!
+//! So this is the behavioural half, and it is deliberately arranged so each failure mode prints
+//! something different rather than all of them looking like a silent hang:
+//!
+//! * counter 0 and pending 1 - the CLIC latched it and the core never took it: mtvec, MTVT or MIE.
+//! * counter 0 and pending 0 - never latched: the matrix write missed, or the timer never fired
+//! (which the raw status distinguishes).
+//! * counter > 1 - the handler returned without clearing the source, and a level
+//! interrupt re-enters forever. On this chip the CLIC has no
+//! acknowledge for a level source, so clearing at the peripheral is
+//! the only way out, and forgetting it looks exactly like a crash.
+//! * spurious > 0 - an interrupt arrived on a line nobody claimed: a routing write
+//! went somewhere unintended.
+//!
+//! The second phase is the sharper test, and it is the claim the whole CLIC port is least able to
+//! support any other way: raise the threshold *above* the line's priority, confirm the interrupt
+//! latches but is not delivered, then lower it and confirm the pending interrupt arrives. That is
+//! what shows the memory-mapped threshold register at 0x2080_0008 is the one the arbiter reads -
+//! rather than the `mintthresh` CSR, which on this die accepts writes and does nothing.
+//!
+//! TIMG1's interrupt-enable and interrupt-clear registers are reached here through `regs` directly,
+//! because `hal.timg` deliberately does not model interrupts. That is the register layer doing its
+//! job: a peripheral the HAL has not covered yet is still fully addressable.
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK INTR_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+/// TIMG1's timer-0 alarm interrupt. Group index 1.
+const timg1_int_ena = mmio.Reg.atAddress(@intCast(regs.TIMG_INT_ENA_TIMERS_REG(1)));
+const timg1_int_raw = mmio.Reg.atAddress(@intCast(regs.TIMG_INT_RAW_TIMERS_REG(1)));
+const timg1_int_clr = mmio.Reg.atAddress(@intCast(regs.TIMG_INT_CLR_TIMERS_REG(1)));
+const t0_int_ena = mmio.Field.of(regs.TIMG_T0_INT_ENA_S, regs.TIMG_T0_INT_ENA_V);
+const t0_int_raw = mmio.Field.of(regs.TIMG_T0_INT_RAW_S, regs.TIMG_T0_INT_RAW_V);
+const t0_int_clr = mmio.Field.of(regs.TIMG_T0_INT_CLR_S, regs.TIMG_T0_INT_CLR_V);
+
+/// The CLIC line under test. 5 is arbitrary and free; the differential suite uses 5 and 24.
+const line: u5 = 5;
+
+var fired: u32 = 0;
+
+fn onAlarm(l: u5) void {
+ fired += 1;
+ // Two things, and both are needed to return exactly once.
+ //
+ // Clear at the *peripheral*: a level-triggered source stays asserted until the peripheral
+ // deasserts it, and this chip's CLIC offers no acknowledge for one, so a handler that returns
+ // without clearing re-enters immediately and forever with the console silent.
+ //
+ // Then disable the alarm. Clearing the status alone is not enough: with auto-reload off the
+ // counter keeps running past the alarm value, the comparator stays satisfied, and the interrupt
+ // is re-asserted as fast as it is cleared. That is the same silent re-entry by a different
+ // route, and it is what this test hit first.
+ // Mask globally first, before anything else. Any handler that can be re-entered before it has
+ // deasserted its source is one console-silent hang away from being undiagnosable, and this test
+ // exists to distinguish failure modes rather than to demonstrate a tidy handler.
+ hal.intr.globalDisable();
+ timg1_int_clr.write(.{t0_int_clr.is(1)});
+ hal.timg.setAlarmEnabled(.timg1, .t0, false);
+ _ = l;
+}
+
+fn armTimer(alarm_ticks: u64) void {
+ hal.clkrst.setClockEnabled(.timg1, true);
+ hal.clkrst.resetPeripheral(.timg1);
+ // 40 MHz APB with a divider of 400 gives 100 kHz, so the alarm value is in units of 10 us.
+ hal.timg.setDivider(.timg1, .t0, 400);
+ hal.timg.setAutoReload(.timg1, .t0, false);
+ hal.timg.setAlarmValue(.timg1, .t0, alarm_ticks);
+ hal.timg.load(.timg1, .t0);
+ timg1_int_ena.modify(.{t0_int_ena.is(1)});
+ hal.timg.setAlarmEnabled(.timg1, .t0, true);
+ hal.timg.setCounterEnabled(.timg1, .t0, true);
+}
+
+fn disarmTimer() void {
+ hal.timg.setCounterEnabled(.timg1, .t0, false);
+ hal.timg.setAlarmEnabled(.timg1, .t0, false);
+ timg1_int_ena.modify(.{t0_int_ena.is(0)});
+ timg1_int_clr.write(.{t0_int_clr.is(1)});
+}
+
+export fn zig_main() noreturn {
+ // Without this the board resets about ten seconds in, mid-test.
+ _ = hal.rwdt.disable();
+
+ soc.rom.print("\r\nMARK INTR_START clic behavioural test\r\n", .{});
+ soc.rom.print("MARK INTR_CFG mtvt_csr=0x%x mintstatus_csr=0x%x nlbits=%u ext_offset=%u\r\n", .{
+ @as(u32, hal.intr.mtvt_csr),
+ @as(u32, hal.intr.mintstatus_csr),
+ @as(u32, hal.intr.NLBITS),
+ @as(u32, hal.intr.ext_offset),
+ });
+
+ // The bootloader hands over with mstatus.MIE set - `init()` masks it, and this records what it
+ // found, because that fact is what makes the ordering below matter at all.
+ const mie_at_boot = hal.intr.globalEnabled();
+
+ // A parked core is silent, and every mistake in a trap handler parks the core. This hook is the
+ // difference between a diagnosis and a reflash.
+ hal.intr.on_fault = struct {
+ fn f(x: hal.intr.Fault) void {
+ soc.rom.print("MARK INTR_FAULT mcause=0x%08x mepc=0x%08x mtval=0x%08x taken=%u last_id=%u fired=%u\r\n", .{
+ x.mcause, x.mepc, x.mtval, hal.intr.taken, @as(u32, hal.intr.last_clic_id), fired,
+ });
+ }
+ }.f;
+
+ hal.intr.init();
+ // What the ROM left behind, captured before init() cleared it. A non-zero enabled_lines is the
+ // whole explanation for the first version of this test hanging: the ROM hands over with lines
+ // armed and MIE set, so the first globalEnable() delivers someone else's interrupt to a handler
+ // that does not exist, and a level source then re-enters forever.
+ soc.rom.print("MARK INTR_BOOT mie=%u rom_enabled_lines=0x%08x rom_routed_sources=%u mtvec=0x%08x mtvt=0x%08x entry=0x%08x table=0x%08x\r\n", .{
+ @as(u32, @intFromBool(hal.intr.boot_state.mie)),
+ hal.intr.boot_state.enabled_lines,
+ hal.intr.boot_state.routed_sources,
+ hal.intr.readMtvec(),
+ hal.intr.readMtvt(),
+ hal.intr.trapEntryAddress(),
+ hal.intr.vectorTableAddress(),
+ });
+ _ = mie_at_boot;
+
+ // Quiesce the source before its line is enabled. TIMG1's raw interrupt status survives a
+ // reflash, and a level-triggered source that is already asserted fires the instant IE goes up -
+ // which, before init() masked MIE, was an immediate re-entrant trap.
+ timg1_int_clr.writeRaw(0xffff_ffff);
+
+ // What the hardware will actually fetch. With SHV=1 the CLIC loads the handler address from
+ // MTVT[id] and jumps there, so this slot - id 21 for line 5 - is the address the core will run.
+ const tbl = hal.intr.vectorTableAddress();
+ soc.rom.print("MARK INTR_TABLE table=0x%08x slot21=0x%08x slot0=0x%08x expect_entry=0x%08x\r\n", .{
+ tbl,
+ mmio.Reg.atAddress(tbl + 4 * 21).raw(),
+ mmio.Reg.atAddress(tbl).raw(),
+ hal.intr.trapEntryAddress(),
+ });
+
+ hal.intr.setThreshold(0);
+ hal.intr.attach(.tg1_t0, line, .{ .handler = onAlarm, .trigger = .level, .priority = 1 });
+ soc.rom.print("MARK INTR_ROUTE tg1_t0(49) -> line %u, routed_line=%u threshold=%u\r\n", .{
+ @as(u32, line),
+ @as(u32, hal.intr.routedLine(.tg1_t0) orelse 99),
+ @as(u32, hal.intr.getThreshold()),
+ });
+
+ // ------------------------------------------- phase 0: does the source reach the CLIC at all?
+ // No MIE, so nothing can be taken and nothing can hang: this asks only whether the matrix and
+ // the CLIC latch a real peripheral event. If pending stays 0 here, everything after it is moot.
+ timg1_int_clr.writeRaw(0xffff_ffff);
+ armTimer(2_000); // 20 ms
+ soc.rom.ets_delay_us(100_000);
+ const p0_raw = timg1_int_raw.get(t0_int_raw);
+ const p0_pending = hal.intr.isPending(line);
+ disarmTimer();
+ soc.rom.print("MARK INTR_PHASE0 timer_raw=%u expect=1 clic_pending=%u expect=1 (no MIE, cannot hang)\r\n", .{
+ p0_raw, @as(u32, @intFromBool(p0_pending)),
+ });
+
+ // ------------------------------------------------------------------ phase 1: take exactly one
+ fired = 0;
+ hal.intr.spurious = 0;
+ armTimer(5_000); // 50 ms
+ hal.intr.globalEnable();
+ soc.rom.ets_delay_us(200_000);
+ hal.intr.globalDisable();
+
+ const took = fired;
+ const spur = hal.intr.spurious;
+ const raw_after = timg1_int_raw.get(t0_int_raw);
+ const pend_after = hal.intr.isPending(line);
+ disarmTimer();
+
+ // `taken` and `last_clic_id` split "latched but not delivered" in two: taken=0 means the trap
+ // was never entered (mtvec, MTVT or SHV), taken>0 with fired=0 means it was entered and the
+ // handler lookup missed.
+ soc.rom.print("MARK INTR_PHASE1 fired=%u expect=1 taken=%u spurious=%u expect=0 last_clic_id=%u expect=21 timer_raw=%u pending=%u\r\n", .{
+ took, hal.intr.taken, spur, @as(u32, hal.intr.last_clic_id), raw_after, @as(u32, @intFromBool(pend_after)),
+ });
+ if (took == 1 and spur == 0) {
+ soc.rom.print("MARK INTR_PHASE1 PASS an interrupt was taken and vectored to its handler\r\n", .{});
+ } else if (took == 0 and pend_after) {
+ soc.rom.print("MARK INTR_PHASE1 FAIL latched but not taken - mtvec, MTVT or MIE\r\n", .{});
+ } else if (took == 0 and raw_after == 0) {
+ soc.rom.print("MARK INTR_PHASE1 FAIL the timer never fired; this measured nothing\r\n", .{});
+ } else if (took == 0) {
+ soc.rom.print("MARK INTR_PHASE1 FAIL timer fired but never latched - the matrix write missed\r\n", .{});
+ } else {
+ soc.rom.print("MARK INTR_PHASE1 FAIL re-entered %u times - the handler is not clearing the source\r\n", .{took});
+ }
+
+ // ------------------------------------------- phase 2: is the memory-mapped threshold the real one
+ // Priority 1 against a threshold of 7 must not be delivered. If the arbiter were reading the
+ // mintthresh CSR instead - which this die does not implement, and which accepts writes silently -
+ // the threshold would read back correctly and the interrupt would arrive anyway.
+ fired = 0;
+ hal.intr.spurious = 0;
+ hal.intr.setThreshold(7);
+ armTimer(5_000);
+ hal.intr.globalEnable();
+ soc.rom.ets_delay_us(200_000);
+
+ const blocked = fired;
+ const pending_while_blocked = hal.intr.isPending(line);
+
+ // Now drop the threshold with MIE still on: the latched interrupt must be delivered.
+ hal.intr.setThreshold(0);
+ soc.rom.ets_delay_us(50_000);
+ hal.intr.globalDisable();
+ const after_drop = fired;
+ disarmTimer();
+
+ soc.rom.print("MARK INTR_PHASE2 blocked=%u expect=0 pending_while_blocked=%u expect=1 after_drop=%u expect=1\r\n", .{
+ blocked, @as(u32, @intFromBool(pending_while_blocked)), after_drop,
+ });
+ if (blocked == 0 and pending_while_blocked and after_drop >= 1) {
+ soc.rom.print("MARK INTR_PHASE2 PASS the memory-mapped threshold at 0x20800008 is the one the arbiter reads\r\n", .{});
+ } else if (blocked > 0) {
+ soc.rom.print("MARK INTR_PHASE2 FAIL delivered despite threshold 7 - the write is not reaching the arbiter\r\n", .{});
+ } else {
+ soc.rom.print("MARK INTR_PHASE2 FAIL blocked but never delivered after the drop\r\n", .{});
+ }
+
+ soc.rom.print("MARK INTR_DONE\r\n", .{});
+
+ 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
+ );
+}