From dc50ca19594c140fdacfcb37f9cd88b0f4be3358 Mon Sep 17 00:00:00 2001 From: deadprogram Date: Fri, 5 Jun 2026 10:14:34 +0200 Subject: [PATCH 1/2] fix(isr): prevent WiFi blob from clearing unowned INTENABLE bits espradio_ints_off passed the blob's cpu_int mask straight to INTENABLE, which could clear bits TinyGo owns (bit 10 = GPIO, bit 9 = timer alarm). After WiFi init, GPIO SetInterrupt callbacks would silently stop firing. - ints_on/ints_off (esp32s3) now only touch ESPRADIO_WIFI_CPU_INT (bit 12) - add espradio_mark_wifi_isr_slot() to track slots registered via set_intr - espradio_call_wifi_isr() iterates only those tracked slots instead of calling all 32 Should address #40 Signed-off-by: deadprogram --- esp32c3/isr.c | 7 +++++-- esp32s3/isr.c | 34 ++++++++++++++++++++++++---------- espradio.h | 1 + isr.c | 19 +++++++++++++++++-- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/esp32c3/isr.c b/esp32c3/isr.c index 9cedf9d..903c45e 100644 --- a/esp32c3/isr.c +++ b/esp32c3/isr.c @@ -31,16 +31,19 @@ void espradio_prewire_wifi_interrupts(void) { intr_matrix_set(0, ETS_WIFI_PWR_INTR_SOURCE, ESPRADIO_WIFI_CPU_INT); } +extern void espradio_mark_wifi_isr_slot(int32_t n); + /* No-op: the blob calls set_intr to route peripheral sources to CPU * interrupts, but on RISC-V (ESP32-C3) the routing is already configured * by espradio_prewire_wifi_interrupts(). Letting the blob call * intr_matrix_set at arbitrary times interferes with TinyGo's interrupt - * controller state. The Rust esp-wifi does the same (no-op set_intr). */ + * controller state. The Rust esp-wifi does the same (no-op set_intr). + * Record the blob's requested intr_num as a WiFi ISR slot. */ void espradio_set_intr(int32_t cpu_no, uint32_t intr_source, uint32_t intr_num, int32_t intr_prio) { (void)cpu_no; (void)intr_source; - (void)intr_num; (void)intr_prio; + espradio_mark_wifi_isr_slot((int32_t)intr_num); } /* No-op: the Rust esp-wifi also no-ops clear_intr. */ diff --git a/esp32s3/isr.c b/esp32s3/isr.c index 6381c4e..90d68ba 100644 --- a/esp32s3/isr.c +++ b/esp32s3/isr.c @@ -27,11 +27,14 @@ void espradio_prewire_wifi_interrupts(void) { intr_matrix_set(0, ETS_WIFI_BB_INTR_SOURCE, ESPRADIO_WIFI_CPU_INT); /* src 3 */ } -/* No-op: the blob calls set_intr to route peripheral sources to CPU - * interrupts, but routing is already configured by - * espradio_prewire_wifi_interrupts(). */ +extern void espradio_mark_wifi_isr_slot(int32_t n); + +/* Route the blob's requested peripheral source to our fixed WiFi CPU interrupt + * and record the blob's requested intr_num as a WiFi ISR slot so that + * espradio_call_wifi_isr() only calls the relevant handlers. */ void espradio_set_intr(int32_t cpu_no, uint32_t intr_source, uint32_t intr_num, int32_t intr_prio) { intr_matrix_set(0, intr_source, ESPRADIO_WIFI_CPU_INT); + espradio_mark_wifi_isr_slot((int32_t)intr_num); } /* No-op: same as set_intr. */ @@ -40,22 +43,33 @@ void espradio_clear_intr(uint32_t intr_source, uint32_t intr_num) { (void)intr_num; } -/* Enable CPU interrupts using Xtensa INTENABLE special register. - * The blob calls ints_on with its own mask (1<<0 for WiFi MAC). - * We translate: if the blob's mask includes a WiFi-related bit, - * also set our actual WiFi CPU interrupt bit. */ +/* Enable/disable CPU interrupts using Xtensa INTENABLE special register. + * + * IMPORTANT: We deliberately ignore the blob's mask and only operate on + * ESPRADIO_WIFI_CPU_INT (bit 12). The blob passes its own original CPU + * interrupt number (e.g. 0 or 1), which may coincide with CPU interrupts + * that TinyGo has allocated for other purposes (bit 10 = GPIO, bit 9 = + * timer alarm). If we forwarded the blob's mask, espradio_ints_off would + * clear those bits from INTENABLE, permanently disabling user interrupts + * such as GPIO PinFalling callbacks (issue #40). + * + * The blob calls these for its own critical-section protection. Since all + * blob ISR handlers run in goroutine context (never from real ISR context), + * the blob's critical sections are already serialised by TinyGo's + * cooperative scheduler; ignoring the mask is safe. */ void espradio_ints_on(uint32_t mask) { - uint32_t actual_mask = mask | (1u << ESPRADIO_WIFI_CPU_INT); + (void)mask; uint32_t val; __asm__ volatile ("rsr %0, intenable" : "=r"(val)); - val |= actual_mask; + val |= (1u << ESPRADIO_WIFI_CPU_INT); __asm__ volatile ("wsr %0, intenable; rsync" :: "r"(val)); } void espradio_ints_off(uint32_t mask) { + (void)mask; uint32_t val; __asm__ volatile ("rsr %0, intenable" : "=r"(val)); - val &= ~mask; + val &= ~(1u << ESPRADIO_WIFI_CPU_INT); __asm__ volatile ("wsr %0, intenable; rsync" :: "r"(val)); } diff --git a/espradio.h b/espradio.h index 03efc27..5b643f4 100644 --- a/espradio.h +++ b/espradio.h @@ -20,6 +20,7 @@ void espradio_ensure_osi_ptr(void); void espradio_coex_adapter_init(void); void espradio_call_saved_isr(int32_t n); void espradio_call_wifi_isr(void); +void espradio_mark_wifi_isr_slot(int32_t n); uint32_t espradio_get_wifi_isr_count(void); void espradio_prewire_wifi_interrupts(void); void espradio_wifi_int_to_level(void); diff --git a/isr.c b/isr.c index 6b64659..59a2167 100644 --- a/isr.c +++ b/isr.c @@ -79,6 +79,15 @@ void espradio_user_exception(uint32_t cause, uint32_t epc, uint32_t excvaddr, ui static void (*s_isr_fn[32])(void *); static void *s_isr_arg[32]; +/* Bitmask of ISR slots registered via espradio_set_intr (WiFi sources only). */ +static uint32_t s_wifi_isr_slots; + +void espradio_mark_wifi_isr_slot(int32_t n) { + if (n >= 0 && n < 32) { + s_wifi_isr_slots |= (1u << n); + } +} + void espradio_set_isr(int32_t n, void *f, void *arg) { if (n >= 0 && n < 32) { s_isr_fn[n] = (void (*)(void *))f; @@ -98,8 +107,14 @@ void espradio_call_wifi_isr(void) { s_wifi_isr_count++; s_in_isr = 1; ESPRADIO_MEMORY_BARRIER(); - // CALL ALL ISRs from 0 to 31 just in case, to see if they are set! - for (int i = 0; i < 32; i++) { + /* Only call ISR slots that were registered via espradio_set_intr for a + * WiFi peripheral source. Calling all 32 slots risks invoking blob + * handlers at slot numbers that coincide with TinyGo's GPIO or timer + * CPU interrupts, which can corrupt INTENABLE. */ + uint32_t slots = s_wifi_isr_slots; + while (slots) { + int i = __builtin_ctz(slots); + slots &= slots - 1; if (s_isr_fn[i]) { s_isr_fn[i](s_isr_arg[i]); } From a42baff2d2bc914b998f85a33658428111ad17e2 Mon Sep 17 00:00:00 2001 From: deadprogram Date: Fri, 12 Jun 2026 15:58:35 +0200 Subject: [PATCH 2/2] fix(irq): restore GPIO interrupts broken by WiFi blob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sources of GPIO interrupt loss after WiFi init: 1. ROM ets_isr_mask bypass: WiFi blob clears INTENABLE bits via ROM calls that bypass the OS adapter. Fix: snapshot INTENABLE at the start of schedOnce(), OR it back in espradio_wifi_unmask() so no TinyGo-owned bits (GPIO=bit10 on S3, bit6 on C3) are lost. 2. Interrupt matrix corruption: espradio_set_intr (S3) called intr_matrix_set for every blob-requested source, risking overwrite of the GPIO→CPU-int routing. Fix: make set_intr a no-op for routing (same as C3); prewire in espradio_prewire_wifi_interrupts() only. Restore GPIO source routing in espradio_wifi_unmask() each cycle. 3. Missed edge + stuck PS.INTLEVEL: TinyGo GC's tinygo_scanCurrentStack uses rsil/3 to flush Xtensa register windows; a goroutine yield during the recursive spill leaves PS.INTLEVEL=3 permanently, blocking all level-1 interrupts. Additionally, if a button edge arrived while INTENABLE[10]=0, the Xtensa edge latch missed it (GPIO_STATUS set but INTERRUPT[10]=0). Fix: espradio_wifi_unmask() lowers PS.INTLEVEL to 0 and toggles the GPIO interrupt matrix routing (disconnect/reconnect with memw readback fence) to synthesize a new rising edge so any missed GPIO events are replayed. Fixes #40. Signed-off-by: deadprogram --- esp32c3/isr.c | 39 ++++++++++++++++++- esp32s3/isr.c | 105 +++++++++++++++++++++++++++++++++++++++++++++++--- espradio.h | 2 + radio.go | 5 +++ 4 files changed, 145 insertions(+), 6 deletions(-) diff --git a/esp32c3/isr.c b/esp32c3/isr.c index 903c45e..5e80501 100644 --- a/esp32c3/isr.c +++ b/esp32c3/isr.c @@ -21,6 +21,11 @@ * TinyGo registers its handler on this interrupt via interrupt.New(). */ #define ESPRADIO_WIFI_CPU_INT 1u +/* TinyGo routes ETS_GPIO_INTR_SOURCE (16) to this CPU interrupt (cpuInterruptFromPin + * in machine_esp32c3.go). Restored after every schedOnce() so that blob ROM + * calls (e.g. direct intr_matrix_set) cannot permanently steal the GPIO source. */ +#define ESPRADIO_GPIO_CPU_INT 6u + /* Pre-wire WiFi peripheral interrupt sources to the WiFi CPU interrupt. * Must be called before esp_wifi_init so routing is in place before the * blob enables the peripheral-side interrupts. @@ -90,6 +95,20 @@ void espradio_wifi_int_raise_priority(void) { __asm__ volatile ("fence" ::: "memory"); } +/* INTC enable snapshot taken at the start of schedOnce(), before any blob + * code runs. espradio_wifi_unmask() ORs this back so that bits cleared by + * blob OS-adapter ints_off or ROM calls during processing are restored — e.g. + * bit 6 which TinyGo uses for GPIO on ESP32-C3. */ +static volatile uint32_t s_intenable_snapshot; + +void espradio_snapshot_intenable(void) { + s_intenable_snapshot = ESPRADIO_INTC_ENABLE_REG; +} + +/* No-op on RISC-V: PS.INTLEVEL does not exist. */ +void espradio_lower_intlevel(void) { +} + /* Called at the end of espradio_call_wifi_isr(). In level-triggered * mode, mask CPU int 1 via the enable register to prevent re-entry * if the hardware line is still asserted after the blob ISR ran. @@ -101,6 +120,24 @@ void espradio_wifi_isr_post_mask(void) { } void espradio_wifi_unmask(void) { - ESPRADIO_INTC_ENABLE_REG |= (1u << ESPRADIO_WIFI_CPU_INT); + /* Restore any TinyGo-owned INTC enable bits that blob code may have + * cleared, then ensure the WiFi CPU interrupt is enabled. */ + ESPRADIO_INTC_ENABLE_REG |= s_intenable_snapshot | (1u << ESPRADIO_WIFI_CPU_INT); + + /* Re-route GPIO source → TinyGo's CPU interrupt in case blob ROM code + * (direct intr_matrix_set calls inside the binary) corrupted it during + * schedOnce() processing. */ + intr_matrix_set(0, ETS_GPIO_INTR_SOURCE, ESPRADIO_GPIO_CPU_INT); + + /* Re-fire GPIO CPU interrupt if it was registered and its INTC enable bit + * was cleared during schedOnce (blob ets_isr_mask or ints_off). On + * RISC-V, GPIO is level-triggered so toggling the ENABLE bit causes the + * controller to re-sample the level and assert the interrupt if the GPIO + * source is still pending. */ + if (s_intenable_snapshot & (1u << ESPRADIO_GPIO_CPU_INT)) { + ESPRADIO_INTC_ENABLE_REG &= ~(1u << ESPRADIO_GPIO_CPU_INT); + __asm__ volatile ("fence" ::: "memory"); + ESPRADIO_INTC_ENABLE_REG |= (1u << ESPRADIO_GPIO_CPU_INT); + } } diff --git a/esp32s3/isr.c b/esp32s3/isr.c index 90d68ba..761ae4e 100644 --- a/esp32s3/isr.c +++ b/esp32s3/isr.c @@ -16,6 +16,17 @@ * WiFi MAC which holds its interrupt line high until acknowledged). */ #define ESPRADIO_WIFI_CPU_INT 12u +/* TinyGo routes ETS_GPIO_INTR_SOURCE (16) to this CPU interrupt (cpuInterruptFromPin + * in machine_esp32s3.go). Restored after every schedOnce() so that blob ROM + * calls (e.g. direct intr_matrix_set) cannot permanently steal the GPIO source. */ +#define ESPRADIO_GPIO_CPU_INT 10u + +/* Direct volatile pointer to INTERRUPT_CORE0.GPIO_INTERRUPT_PRO_MAP (offset + * 0x40). Writing cpu_int routes ETS_GPIO_INTR_SOURCE to that CPU interrupt. + * Writing 0 disconnects the GPIO source from all CPU interrupts. + * Base 0x600C2000, source 16 → offset 16*4 = 0x40 */ +#define ESPRADIO_GPIO_MAP_REG (*(volatile uint32_t *)(0x600C2040u)) + /* Pre-wire WiFi peripheral interrupt sources to the WiFi CPU interrupt. * Must be called before esp_wifi_init so routing is in place before the * blob enables the peripheral-side interrupts. */ @@ -29,11 +40,20 @@ void espradio_prewire_wifi_interrupts(void) { extern void espradio_mark_wifi_isr_slot(int32_t n); -/* Route the blob's requested peripheral source to our fixed WiFi CPU interrupt - * and record the blob's requested intr_num as a WiFi ISR slot so that - * espradio_call_wifi_isr() only calls the relevant handlers. */ +/* No-op: the blob calls set_intr to route peripheral sources to CPU interrupts, + * but on Xtensa (ESP32-S3) the routing is already configured by + * espradio_prewire_wifi_interrupts(). Letting the blob re-route arbitrary + * sources via intr_matrix_set at arbitrary times is dangerous: if the blob + * passes a source that TinyGo owns (e.g. ETS_GPIO_INTR_SOURCE = 16) we would + * overwrite the GPIO → CPU-int-10 mapping that TinyGo set up, silently routing + * GPIO events into wifiISRHandler and breaking all pin-change interrupts. + * The Rust esp-wifi and the ESP32-C3 path in espradio both use the same no-op + * strategy. Record the blob's requested intr_num as a WiFi ISR slot so that + * espradio_call_wifi_isr() still calls the correct blob handler. */ void espradio_set_intr(int32_t cpu_no, uint32_t intr_source, uint32_t intr_num, int32_t intr_prio) { - intr_matrix_set(0, intr_source, ESPRADIO_WIFI_CPU_INT); + (void)cpu_no; + (void)intr_source; + (void)intr_prio; espradio_mark_wifi_isr_slot((int32_t)intr_num); } @@ -87,6 +107,38 @@ void espradio_wifi_int_raise_priority(void) { /* nothing to do — Xtensa interrupt priorities are fixed */ } +/* INTENABLE snapshot taken at the start of schedOnce(), before any blob code + * runs. espradio_wifi_unmask() ORs this back into INTENABLE so that bits + * cleared by blob ROM calls (e.g. ets_isr_mask) during processing are + * restored — in particular bit 10 (GPIO) and bit 9 (timer alarm) which the + * WiFi blob may clear when it uses those CPU interrupt numbers internally. */ +static volatile uint32_t s_intenable_snapshot; + +void espradio_snapshot_intenable(void) { + uint32_t val; + __asm__ volatile ("rsr %0, intenable" : "=r"(val)); + s_intenable_snapshot = val; +} + +/* Lower PS.INTLEVEL to 0, allowing level-1 (GPIO, WiFi) interrupts to fire. + * + * The TinyGo GC's tinygo_scanCurrentStack does "rsil a4, 3" to flush the + * Xtensa register windows. If a cooperative goroutine yield occurs during + * the recursive window-spill loop, the goroutine is suspended and later + * resumed with PS.INTLEVEL=3 still active — permanently blocking all + * level-1 interrupts (GPIO at CPU int 10, WiFi at CPU int 12) for that + * goroutine until it voluntarily lowers PS.INTLEVEL again. + * + * We call this at the end of espradio_wifi_unmask() (and thus at the end + * of every schedOnce() cycle) to ensure that after blob processing, the + * schedTicker goroutine runs with PS.INTLEVEL=0. */ +void espradio_lower_intlevel(void) { + uint32_t ps; + __asm__ volatile ("rsr %0, ps" : "=r"(ps)); + ps &= ~0x0Fu; /* clear INTLEVEL bits [3:0] */ + __asm__ volatile ("wsr %0, ps; rsync" :: "r"(ps)); +} + /* On Xtensa, level-triggered interrupts auto-clear when the peripheral * de-asserts. We still mask/unmask to prevent re-entry while the * bottom-half runs. */ @@ -95,5 +147,48 @@ void espradio_wifi_isr_post_mask(void) { } void espradio_wifi_unmask(void) { - espradio_ints_on(1u << ESPRADIO_WIFI_CPU_INT); + /* Restore any TinyGo-owned INTENABLE bits that blob code may have cleared + * (e.g. via ROM ets_isr_mask), then ensure the WiFi CPU interrupt is on. */ + uint32_t val; + __asm__ volatile ("rsr %0, intenable" : "=r"(val)); + val |= s_intenable_snapshot | (1u << ESPRADIO_WIFI_CPU_INT); + __asm__ volatile ("wsr %0, intenable; rsync" :: "r"(val)); + + /* Re-route GPIO source → TinyGo's CPU interrupt in case blob ROM code + * (direct intr_matrix_set calls inside the binary) corrupted it during + * schedOnce() processing. intr_matrix_set(cpu_no, source, cpu_int). */ + intr_matrix_set(0, ETS_GPIO_INTR_SOURCE, ESPRADIO_GPIO_CPU_INT); + + /* Force a new rising edge at CPU int 10 by briefly disconnecting the GPIO + * source then reconnecting it. + * + * WHY: CPU interrupt 10 is edge-triggered. On Xtensa LX7 the edge latch + * only captures when INTENABLE[n]=1 at the moment the edge arrives. If + * ets_isr_mask cleared INTENABLE[10] while a button edge was in-flight, + * the edge was missed: GPIO_STATUS is set (peripheral saw the edge) but + * INTERRUPT[10] is 0 (CPU latch did not capture it). + * + * FIX: disconnect GPIO source (matrix output → LOW, edge 1→0 at int 10 + * input), read back the register to flush the write pipeline across the + * APB bus, then reconnect (matrix output → HIGH, edge 0→1 captured by + * the latch because INTENABLE[10]=1 now). The readback + memw ensures + * the LOW has propagated before we write HIGH — without it, back-to-back + * writes coalesce in the write buffer and no real LOW pulse is generated. + * + * A spurious trigger when GPIO_STATUS=0 is harmless: the handler reads 0 + * and calls no callbacks. */ + if (s_intenable_snapshot & (1u << ESPRADIO_GPIO_CPU_INT)) { + ESPRADIO_GPIO_MAP_REG = 0u; /* disconnect → int 10 input LOW */ + (void)ESPRADIO_GPIO_MAP_REG; /* read back: flush write pipeline */ + __asm__ volatile ("memw" ::: "memory"); /* Xtensa memory-wait fence */ + ESPRADIO_GPIO_MAP_REG = ESPRADIO_GPIO_CPU_INT; /* reconnect → rising edge latched */ + __asm__ volatile ("memw" ::: "memory"); + } + + /* Ensure PS.INTLEVEL=0 so pending level-1 interrupts (GPIO, WiFi) can + * actually be taken by the CPU. The TinyGo GC's tinygo_scanCurrentStack + * uses "rsil 3" and a goroutine yield during the window-spill loop can + * leave the schedTicker goroutine permanently at INTLEVEL=3, silencing + * all level-1 interrupts until explicitly lowered here. */ + espradio_lower_intlevel(); } diff --git a/espradio.h b/espradio.h index 5b643f4..800de65 100644 --- a/espradio.h +++ b/espradio.h @@ -26,6 +26,8 @@ void espradio_prewire_wifi_interrupts(void); void espradio_wifi_int_to_level(void); void espradio_wifi_int_raise_priority(void); void espradio_wifi_unmask(void); +void espradio_snapshot_intenable(void); +void espradio_lower_intlevel(void); void espradio_ints_on(uint32_t mask); void espradio_ints_off(uint32_t mask); int32_t espradio_queue_send(void *queue, void *item, uint32_t block_time_tick); diff --git a/radio.go b/radio.go index 0a3da71..cad1daa 100644 --- a/radio.go +++ b/radio.go @@ -116,6 +116,11 @@ func startSchedTicker() { var wifiInitDone uint32 func schedOnce() { + // Snapshot INTENABLE before any blob code runs so that wifi_unmask can + // restore TinyGo-owned bits (e.g. GPIO at bit 10 on ESP32-S3) that the + // blob may clear via ROM calls (ets_isr_mask) bypassing the OS adapter. + C.espradio_snapshot_intenable() + // Mask WiFi CPU interrupt before the ISR softcall. On Xtensa (ESP32-S3) // the WiFi interrupt is level-triggered at level 1. If the MAC asserts // its interrupt while we're already iterating the ISR handlers below,