diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 12:40:53 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 12:46:51 -0300 |
| commit | f5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch) | |
| tree | 2731a3ed4e51cae09e184e25778eded5fc37d1f5 /examples/intrcheck.zig | |
| download | esp32p4-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.zig | 269 |
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 + ); +} |
