Detection without reporting is a flag in an empty room: the status bit that nobody polls while the corrupted words stream on. The fault controller of the extension is the room's alarm panel, collecting detection flags from every structure, prioritising them, and interrupting the CPU. But an alarm that arrives after the damage is history, so the chain owns a latency budget from fault to handler. And an alarm that depends on the CPU cannot report a fault in the CPU. The chain therefore needs a budget and a second path.
Detection flags arrive as held levels, never single-cycle pulses, and are synchronised into the controller clock with the two-flop rule the crossings part of the course teaches. The controller sits in the always-on domain on clk_per, masks per input, prioritises with lower number winning exactly like the interrupt controller it mirrors, and latches one aggregated FAULT. NanoSoC has no fault input today, and interrupt input 0 is irq_pwr, the power-and-PLL priority. The extension ORs the aggregated fault into that input by adding a FAULT bit to PWR_IRQ, so irq_pwr = |PWR_IRQ still holds and the handler tells the two causes apart by reading the register. The spare inputs 6 and 7 are not used: they are the lowest priorities, and a fault report there would wait behind every peripheral.
Software clears by writing the source status, which drops the level, then clears PWR_IRQ.FAULT, and reads back to flush any posted bridge write before returning. The interrupt path cannot report a fault in the CPU or the interrupt controller itself, so the extension adds a second report that uses neither: a dedicated fault output pin driven by the fault controller in the always-on domain, which a supervisor or the bench can see even when the CPU is hung. The budget below recomputes the worst-case reporting latency from the clock plan. It must include the fault controller's own synchroniser and output register, which the existing interrupt path (182.3 ns from a source in clk_sys to the CPU input) does not have, and the interrupt controller's own two stages on top.
# Fault-report latency to the CPU interrupt input (extension, NanoSoC clocks).
T_PER = 1e9 / 24e6 # 41.667 ns
T_CPU = 1e9 / 192e6 # 5.208 ns
stages = {
"fault controller sync (2 clk_per)": 2 * T_PER,
"fault controller register (1 clk_per)": 1 * T_PER,
"interrupt controller input sync (2 clk_per)": 2 * T_PER,
"interrupt controller output register (1 clk_per)": 1 * T_PER,
"CPU sync (2 clk_cpu)": 2 * T_CPU,
"alignment (1 clk_per + 1 clk_cpu)": T_PER + T_CPU,
}
worst_ns = sum(stages.values())
assert abs(worst_ns - 307.29) < 0.01, worst_ns
existing_path_ns = 182.292 # irq_dma to the CPU input in NanoSoC v1
assert worst_ns > existing_path_ns # the controller adds stages; the old number is not reused
DEADLINE_US = 2.0 # illustrative reporting deadline
margin = DEADLINE_US * 1000 / worst_ns
assert margin > 6.0 and margin < 7.0, margin
print(f"PASS: worst case to the CPU interrupt input {worst_ns:.1f} ns; margin x{margin:.1f}")The result is the worst case to the CPU interrupt input, not to the first instruction of the handler: handler entry adds the CPU's own interrupt latency on top.
Every detection flag specified as a held level with its synchroniser, reviewed like any other crossing.
The aggregation into input 0 through the FAULT bit with the budget above recomputed for the shipped clock plan, stage by stage, and the CPU's own interrupt latency added separately.
The second report path (the fault output pin) present and shown to work with the CPU held in reset.
The software clear sequence documented per fault: which status, which order, with the read-back that prevents storms.
Measure the budget, do not just compute it: inject a fault with a timestamp, toggle a GPIO in the handler's first lines, and time the interval on the bench or in simulation. Test the storm case explicitly: a fault whose source is not cleared must re-interrupt rather than wedge, and the handler must distinguish re-interrupt from a new fault by reading the source status first. Hold the CPU in reset and confirm the fault pin still rises.
Because the controller and the CPU run on different clocks from most sources, and a pulse shorter than two destination cycles can be missed entirely. A held level waits for the synchroniser; software clearing drops it only after handling.