The PinePhone Pro’s touchscreen is a Goodix GT917S — a 10-finger multitouch I2C controller hanging off i2c1 at address 0x14, with a reset GPIO and an interrupt GPIO going to GPIO bank 1. Linux drives it from drivers/input/touchscreen/goodix.c. We wrote a minimal port: src/sys/dev/iicbus/goodix.c, ~500 lines, evdev MT Protocol B, the standard 8-byte-per-contact data layout, the standard 0x814E status register that the driver clears after each read.
The driver attached cleanly the day it was written ( 2464a0a goodix: add Goodix GT917S multitouch touchscreen driver ). Touch events even fired — but only in the polling fallback path. The interrupt-driven path returned ENXIO from bus_setup_intr and silently routed itself to polling at 100 Hz. That’s a working stopgap, but polling at 100 Hz costs roughly 5–15 mW (i2c bus active, IRQ-thread wakeups, evdev syncs) and adds up to 10 ms of latency per touch event. We wanted real interrupts. Getting there took seven commits across four days and ended at the bottom of the GPIO driver, not the touchscreen driver.
[WAR STORY]
ENXIO from bus_setup_intr — and why
▸ symptom
At attach: goodix0: Cannot allocate IRQ. Or, after the GPIO fallback path was added: goodix0: Using GPIO interrupt fallback, then goodix0: bus_setup_intr failed: 6 (ENXIO), then goodix0: falling back to 100Hz polling. Touch input works in polling mode. The hardware is fine. The IRQ pin (GPIO1_C5, the one Linux uses) is tied to a known, working interrupt source.
▸ hypothesis 1
The device tree interrupts property isn’t being parsed correctly. Goodix in our DTB has both interrupts = <RK_PC5 IRQ_TYPE_EDGE_RISING> and irq-gpios = <&gpio1 RK_PC5 GPIO_ACTIVE_HIGH> — Linux supports either form. FreeBSD’s bus_alloc_resource_any(SYS_RES_IRQ, ...) only resolves the first form via the FDT layer. Maybe our overlay is missing the interrupt-parent property, or the IRQ allocation is going to the wrong PIC.
Investigated. The interrupt-parent for the goodix node was set, the GIC was being chosen as the PIC, but bus_alloc_resource_any returned NULL. Switched to the GPIO-direct path with gpio_alloc_intr_resource, which uses the GPIO controller as its own PIC (this is what gpio-keys does in upstream FreeBSD). 831f639 goodix: fix GPIO/IRQ — use explicit OFW node, gpio_alloc_intr_resource fallback
▸ hypothesis 2
GIC routing. The RK3399 routes GPIO1_C5 through the per-bank GPIO controller, which then aggregates and forwards to a single shared GIC SPI per bank. If the bank’s parent IRQ isn’t enabled or the GIC SPI isn’t configured for edge sensitivity, the interrupt will route through the GPIO PIC chain but never get unmasked at the GIC. Checked /dev/intrng (well, the equivalent — vmstat -i). The GIC SPI for GPIO bank 1 was registered. The IRQ count was zero.
So the IRQ source existed at every layer down to the GPIO PIC, and bus_setup_intr was returning ENXIO. The gpio_alloc_intr_resource call was succeeding — it returned a non-NULL struct resource * — but bus_setup_intr with that resource bounced.
▸ hypothesis 3
Bug in the goodix driver’s intr handler signature, or some FreeBSD 15-vs-13 API drift in how gpio_alloc_intr_resource packs the intr_irqsrc into the resource. Audited the ISR signature, the rid handling. Found a real bug in 61bd6f1 goodix: pass &rid to gpio_alloc_intr_resource for stable/15 API — the stable/15 API takes &rid, not rid — but fixing it didn’t change the ENXIO. Same return code, same point of failure.
▸ breakthrough
Set a kernel breakpoint on the path. ddb> trace. The ENXIO was bubbling up from intr_isrc_handler → intr_setup_irq → PIC_ENABLE_INTR → kobj_error_method. The kobj_error_method is what the kobj system calls when you ask an interface for a method that the implementing class never registered. rk_gpio.c implemented pic_map_intr, pic_setup_intr, and pic_teardown_intr — but pic_enable_intr was missing. The interrupt was being mapped (good) and “set up” in the GIC routing sense (good), but when INTRNG asked the rk_gpio PIC to actually unmask the interrupt, kobj said “this PIC class does not implement that method” and returned ENXIO.
Then I went looking for what else was missing. FreeBSD’s INTRNG framework expects a PIC implementation to provide eight methods. The contract isn’t documented in any one place — it’s spread across sys/sys/pic_if.m, sys/kern/subr_intr.c, and the comments at the top of the few PICs that get it right. The required set is: pic_map_intr, pic_setup_intr, pic_teardown_intr, pic_enable_intr, pic_disable_intr, pic_pre_ithread, pic_post_ithread, pic_post_filter. Upstream rk_gpio had three. We were missing five.
▸ fix
Add the five missing methods. The core ones, from 5d6a594 rk_gpio: add missing PIC methods for GPIO interrupts (enable/disable/pre/post) :
static void
rk_pic_enable_intr(device_t dev, struct intr_irqsrc *isrc)
{
struct rk_gpio_softc *sc = device_get_softc(dev);
struct rk_pin_irqsrc *irqsrc = (struct rk_pin_irqsrc *)isrc;
RK_GPIO_LOCK(sc);
rk_gpio_write_bit(sc, RK_GPIO_INTMASK, irqsrc->irq, 0);
rk_gpio_write_bit(sc, RK_GPIO_INTEN, irqsrc->irq, 1);
RK_GPIO_UNLOCK(sc);
}
static void
rk_pic_disable_intr(device_t dev, struct intr_irqsrc *isrc)
{
struct rk_gpio_softc *sc = device_get_softc(dev);
struct rk_pin_irqsrc *irqsrc = (struct rk_pin_irqsrc *)isrc;
RK_GPIO_LOCK(sc);
rk_gpio_write_bit(sc, RK_GPIO_INTEN, irqsrc->irq, 0);
RK_GPIO_UNLOCK(sc);
}
static void
rk_pic_pre_ithread(device_t dev, struct intr_irqsrc *isrc)
{
struct rk_gpio_softc *sc = device_get_softc(dev);
struct rk_pin_irqsrc *irqsrc = (struct rk_pin_irqsrc *)isrc;
RK_GPIO_LOCK(sc);
rk_gpio_write_bit(sc, RK_GPIO_INTMASK, irqsrc->irq, 1);
RK_GPIO_UNLOCK(sc);
}
static void
rk_pic_post_ithread(device_t dev, struct intr_irqsrc *isrc)
{
rk_pic_enable_intr(dev, isrc);
}
static void
rk_pic_post_filter(device_t dev, struct intr_irqsrc *isrc)
{
struct rk_gpio_softc *sc = device_get_softc(dev);
struct rk_pin_irqsrc *irqsrc = (struct rk_pin_irqsrc *)isrc;
RK_GPIO_LOCK(sc);
rk_gpio_write_bit(sc, RK_GPIO_PORTA_EOI, irqsrc->irq, 1);
RK_GPIO_UNLOCK(sc);
}The semantics are exactly what the names say: enable clears INTMASK and sets INTEN; disable clears INTEN; pre_ithread masks before the threaded handler runs (so an edge arriving mid-handler doesn’t re-fire); post_ithread unmasks after; post_filter writes the edge-acknowledge PORTA_EOI register. Wire them into the device_method_t array with DEVMETHOD(pic_enable_intr, rk_pic_enable_intr) and so on, and bus_setup_intr returns success on the next boot.
▸ lesson
FreeBSD INTRNG’s PIC contract is implicit: the only enforcement of “you must implement these methods” is that calling a missing method returns kobj_error_method’s ENXIO. There is no compile-time check, no startup-time validation, no warning at PIC registration. A partially-implemented PIC will pass intr_pic_register, attach successfully, even appear in vmstat -i — and then silently fail every bus_setup_intr call against it.
The signal-to-debugger here is “ENXIO from bus_setup_intr after gpio_alloc_intr_resource succeeds.” If you see that combination, do not look at the consumer driver. Look at the PIC. The error is in the controller’s method table, not the device’s IRQ handler.
The other lesson is structural: the rk_gpio.c we inherited from FreeBSD’s tree was never tested as an IRQ-source PIC. It was tested as a GPIO data-pin controller. Lots of upstream drivers ship with PIC methods that have never run — not because they’re broken, but because the consumers nobody upstreamed (touchscreens, accelerometers, anything wake-source on a laptop SoC) didn’t exist. In a port like this, every untested code path is a bug waiting for a customer.
There were two more bugs around the same fix. The polling-mode codepath in goodix_poll was originally calling goodix_intr directly from a callout — which runs in softclock context, where you can’t sleep. Goodix talks to the chip over I2C, and rk_i2c_transfer sleeps on the bus. The phone panicked on the first poll cycle in 4ac5212 goodix: defer I2C work from callout to taskqueue (fix sleep-in-softclock panic) : “sleeping in softclock context.” Fixed by deferring to a taskqueue:
static void
goodix_poll_task(void *arg, int pending)
{
struct goodix_softc *sc = arg;
goodix_intr(sc);
}
static void
goodix_poll(void *arg)
{
struct goodix_softc *sc = arg;
taskqueue_enqueue(taskqueue_thread, &sc->sc_poll_task);
}
The same constraint applies to the IRQ handler when we’re driving from the rk_gpio PIC: goodix_intr can sleep, and the IRQ ithread is the right context for that, but only after pre_ithread has masked the edge. Which is one more reason pic_pre_ithread exists — it keeps the edge from re-firing while the I2C transaction is in flight, otherwise the Goodix’s STATUS register would be cleared (by us at the end of the handler) and re-asserted (by a finger-move during the i2c read) and we’d lose touch events.
The Goodix driver itself is small and boring; the interesting code lives one level down, in the GPIO PIC that any future GPIO-interrupt consumer (the FUSB302 USB-C controller, the headset detect pin, the modem RI line, the BT HOST_WAKE pin from essay 10) will rely on. With the five missing methods in place, all of those are now implementable as proper IRQ-driven drivers instead of callout-based pollers. The Goodix drop from polling to IRQ has shaved ~10 mW off idle and made finger-tracking visibly smoother under sway.
Touch is at ◐ partial because we still don’t handle the proximity sensor or the Goodix’s deep-sleep modes for power management — both are next-quarter problems.