UM-NATOS-023 — The Interrupt Matrix, and Four Ways to Deliver an Edge to Nobody
UM-NATOS-023 — The Interrupt Matrix, and Four Ways to Deliver an Edge to Nobody
Used Medias LLC — Embedded Systems Division Revision 1.0 · 2026-08-16 · Status: Infrastructure verified; the first consumer is not
1. Abstract
Until this work, every peripheral in this kernel was polled, and nothing said so. The Level-3 vector served exactly one source — CCOMPARE1, which is internal to the Xtensa core and reaches the CPU without passing through any routing at all. No peripheral interrupt had ever been routed, and the absence was invisible because the only peripherals were a display written on demand and a touch panel sampled at 100 Hz.
That is a hard wall for anything serviced on the hardware's schedule rather than ours — which is everything the sensor and audio work in §8 needs.
This report covers the matrix (kernel/intr.c), which works and is verified,
and its first intended consumer, PENIRQ, which does not and is switched off.
The second half is the more useful one: four distinct ways an interrupt was
delivered correctly to something that could not act on it, each of which read
back from every register as a complete success.
2. Sources are not lines
On the ESP32 a peripheral cannot reach the CPU by itself:
| | range | fixed by | meaning | |---|---|---|---| | source | 0–69 | silicon | what raised it — GPIO, I2S0, UART1 | | line | 0–31 | silicon | what the CPU sees; its priority level and edge/level type |
A DPORT map register per source holds the line number. These are different
numbering spaces, and confusing them is the classic failure: writing a source
number into INTENABLE enables an unrelated line while the peripheral stays
silent, and every register involved reads back exactly as intended.
Line 23 is used here because it is level-triggered at level 3 — so it shares the existing Level-3 vector and its proven context save rather than needing a second one. A line's level is a property of the silicon, not a choice.
3. The vector could only ever have been right by accident
_handler_level3 called timer_isr unconditionally. Correct while CCOMPARE1 was
the only source that could reach level 3, and a clock corruption the moment a
second one is routed: timer_isr() advances the tick deadline by a whole
interval every time it runs, so any GPIO interrupt would silently push the clock
into the future.
That is the same defect as the task_yield() bug in UM-NATOS-014, arrived at
from the opposite direction, and it would have been just as quiet — sleeps
running long, timeouts running loose, and every frame rate measured against a
clock that drifts.
The fix is one line of assembly: dispatch on the INTERRUPT register rather than
on an assumption about who could have fired.
4. The defence against a hang
An enabled, pending, unhandled level-triggered line is not a glitch — it is a permanent hang. Nothing clears the condition, so returning re-enters the handler immediately and forever. There is no output from that state and no watchdog reach: the watchdog is fed by a task, and tasks have stopped running.
intr_dispatch() therefore masks such a line and counts it. The kernel loses
that interrupt and keeps running, which is recoverable and inspectable rather
than a silent brick.
5. Four ways to deliver an edge to nobody
The theme: at every stage, an edge was generated correctly, routed correctly, and consumed by something that could not act on it. No register ever read back wrong. Only counters distinguished these.
5.1 Delivered to a halted CPU
GPIO_PINn_INT_ENA is a 5-bit field at 17:13 selecting which CPU and which kind
of interrupt receives the pin. Bit 13 was chosen as the low bit of the field,
matching the vendor header's PRO_CPU_INTR_ENA being BIT(0).
Real taps latched GPIO_STATUS1 bit 4. The map register read 23. INTENABLE
had bit 23. The CPU's INTERRUPT register never showed it.
The pin was being delivered to the APP CPU, which this kernel never starts — arriving perfectly at a core that was halted.
The measured answer is bit 15.
intr_selftest()settles it by injecting an edge throughGPIO_STATUS1_W1TSonce per candidate bit and reporting which the handler actually saw. Runningirqtestre-derives the constant on any board rather than trusting the comment that records it.
Injecting the edge in software mattered for a second reason: it isolates the status→CPU path from the pad→status path. The alternative was asking someone to tap a panel five times while watching a serial log.
5.2 The driver interrupting itself
touch.c already knew that "a conversion in progress drives PENIRQ regardless of
whether anything is touching". That is as true of the interrupt as of the
level, and the driver had not been read that way.
Every touch_read() manufactured its own edges — 3,917 across a handful of
taps, none caused by a finger. Each one set the "edge arrived with nobody
waiting" latch, so the idle wait returned immediately, forever. The driver had
interrupted itself into a busy loop.
Fixed by masking the pin for the duration of the burst. An edge genuinely missed there costs nothing: the burst only runs while the task is awake and sampling.
5.3 Re-arming against a pin a finger is holding down
The mask was then released at the end of touch_read(), which looked right and
was the same bug moved. The pad is still LOW at that point if a finger is on
it, and arming falling-edge detection against an already-low pin latches an edge
immediately.
Result: 100 edges, 0 wakes — every one self-inflicted, every one while the task was awake with no waiter registered.
The interrupt is only ever useful while the task is idle and the pen is up.
Arming it anywhere else can only manufacture events, so arming now happens in
touch_irq_wait() and nowhere else.
5.4 A diagnostic that was itself wrong
The first register dump read 0x3FF44078 for the PRO CPU's masked status and
reported a confident zero. The per-CPU status registers sit between STATUS1 and
PIN0 in an order taken from memory, and that address is most likely the other
CPU's copy.
Printing the whole range and letting the value identify the register found bit 4
set at 0x3FF44074 — which is what pointed at §5.1.
A diagnostic derived from the same faulty recollection as the code it is checking will agree with the code and both will be wrong. Dump the range, not the address you believe in.
6. What is verified
irqtest -> bit 15 SERVICED injected edge reaches the handler
intr -> tick line 15 serviced N dispatch does not disturb the clock
spurious 0 nothing masked defensively
armed rb 0x00008100 INT_TYPE=falling + enable, read back
from inside the armed window
The last line matters as method: sampling GPIO_PIN36 from the shell returned
zero three times running against a window covering 93% of the cycle. That is not
chance — the shell's sampling is correlated with the touch task being awake. Only
the armed task could answer without the measurement being correlated with its own
subject.
7. What is NOT verified, and why it is switched off
A finger has never once been observed to wake the touch task. With everything above confirmed, real taps produced 24 touch events and 30 low PENIRQ readings while the armed edge detector latched nothing. That gap is unexplained.
The touch task is therefore back to polling — byte for byte its previous
behaviour — and touch_irq_wait() is intact, one line from reinstatement.
Shipping touch input on an interrupt whose end-to-end path has never been observed to work would trade a mechanism that demonstrably works for one that only should.
The most likely remaining suspects, untested:
- the pad→status edge path may need
FUN_IEor a GPIO-matrix input selection that plain reads do not require —gpio_read()working does not prove the edge detector is fed - the arm/disarm boundary may be clearing a finger's edge:
gpio_int_disable()clears status, and it runs the instant the sleep expires - GPIO 34–39 are input-only RTC pads and may route their edges differently from the ordinary bank
8. Why this was built first
The alternative was ADC — smaller, and with an onboard LDR to prove it. The matrix was chosen because it cannot be bolted on later without revisiting every driver, and because PENIRQ offered a real, low-rate, forgiving consumer already on the board.
That reasoning half survives. The infrastructure is real and the next peripheral inherits it. But the "forgiving consumer" turned out to be the hardest part, and an ADC would have proved the same matrix against something with no edge semantics to get wrong.
9. Metrics
| Quantity | Value | |---|---| | Peripheral interrupts routed before this | 0 | | CPU line used | 23, level 3, level-triggered | | GPIO source | 22 | | PRO CPU enable bit | 15 (measured, not read) | | Self-inflicted edges before masking | 3,917 | | Assembly changed | 1 call | | Distinct delivery failures found | 4 | | Finger-driven wakes observed | 0 |
10. What this does not establish
- No peripheral interrupt has yet driven anything. §7. The matrix is proven by injection only.
task_wake()has never woken a task. It exists, it is correct by inspection, and no interrupt has ever called it successfully.- One line, one handler. There is no sharing, no priority among sources, and no way to route two peripherals to one line.
- Nothing is re-entrant. Handlers run at level 3 with interrupts masked, so a slow one delays the tick, and nothing measures how long any of them take.
- The APP CPU is still never started, so half of every
INT_ENAfield is addressing a core that does not exist as far as this kernel is concerned.
11. References
- UM-NATOS-014 — the tick deadline defect §3 would have recreated
- UM-NATOS-017 §4.1 — PENIRQ, and why the level is trusted over the Z channels
- UM-NATOS-017 §7.4 — the calibration whose pair check is the same idea as §5.4
kernel/intr.c— routing, dispatch, the hang defence,irqtestkernel/gpio.h— the interrupt registers, and the measured bit 15kernel/touch.c— the masking of §5.2/§5.3 and the disabled wait of §7
Written by: Hare
../nat-os-test/docs/UM-NATOS-023*.md · Rendered from markdown at build time.