06 · touch

Goodix and the PIC methods that didn't exist

GPIO interrupts that silently fall back to polling, and the five INTRNG methods nobody told you were required.

● working

The PinePhone Pro’s touchscreen is a Goodix GT917S — a 10-finger multitouch I2C controller hanging off DTS &i2c3 at 7-bit address 0x14, with reset on GPIO3_B4 and interrupt on GPIO3_B5. FreeBSD dmesg prints that as shifted addr 0x28 on iicbus1. 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

goodix / rk_gpio

▸ 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_PB5 IRQ_TYPE_EDGE_RISING> and irq-gpios = <&gpio3 RK_PB5 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_handlerintr_setup_irqPIC_ENABLE_INTRkobj_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 ● working : 10-finger multitouch in X11 and Wayland, IRQ-driven, no missed events under sustained input. Goodix deep-sleep modes for power management are still untouched — that’s a power-policy refinement, not a functional gap.

Postscript: the same bug, one layer up

The PIC fix above made gpio_alloc_intr_resource work. That was sufficient for two weeks. Then the rt5640 audio bringup forced a return to first principles on iicbus, and goodix’s interrupt path stopped working again — for an entirely different reason that had been camouflaged the whole time.

[WAR STORY]

The iicbus IRQ blackhole

rk_i2c / iicbus

▸ symptom

After reverting goodix to the canonical bus_alloc_resource_any(SYS_RES_IRQ, …) form (the one any iicbus child should be able to use), every attempt at IRQ allocation returned NULL. No log line. No error code higher in the chain. bus_alloc_resource_any just gave back NULL and the driver fell back to polling, the way it had been doing since day one. The GPIO PIC fix didn’t seem to have done anything.

▸ hypothesis 1

FDT IRQ resolution is broken for iicbus children. Maybe simplebus → iicbus isn’t propagating the FDT interrupts property down to the child, so by the time goodix asks for SYS_RES_IRQ rid 0 there’s nothing in its resource list. Added a config_intrhook deferral to test the hypothesis that goodix was attaching before the PIC was ready. 9fa5edf goodix: defer IRQ setup via config_intrhook to test attach-order hypothesis Same NULL return after the deferral fired.

▸ hypothesis 2

Attach order. The rk_gpio PIC registers from a SI_SUB_INTR / SI_ORDER_MIDDLE SYSINIT; goodix attaches from the iicbus probe, which runs much later. If for some reason rid-0 was getting reserved before the PIC was registered, the rman entry would point at a vanished interrupt source. Split rman_reserve from activation to inspect intermediate state. d2e5654 goodix: split rman_reserve from activation in IRQ debug The reservation succeeded with a non-NULL struct resource *; activation failed with the same silent NULL.

▸ hypothesis 3

Bug in iicbus’s bus_generic_rl_alloc_resource itself. iicbus uses the resource-list helpers; maybe the helper was looking up rid 0 against an empty list and returning NULL without complaining. Loaded heavy debug instrumentation: every step of the alloc chain printed its arguments and return value. 7188cd6 goodix: heavy debug instrumentation for IRQ alloc failure The trail went down through iicbus into … nothing. iicbus’s parent’s BUS_ALLOC_RESOURCE returned NULL with no further calls.

▸ breakthrough

At line 502 of src/sys/dev/iicbus/goodix.c (in the debug build, 2b9f381 goodix: bypass iicbus's bus_generic_rl path to find which layer drops IRQ alloc ), bypass the iicbus path entirely and call BUS_ALLOC_RESOURCE directly on the I2C controller — device_get_parent(device_get_parent(dev)). Then call it again on simplebus, the controller’s parent. Print both results.

goodix0: IRQ debug: parent chain dev=goodix0 parent=iicbus0 pp=rk_i2c0 ppp=simplebus0
goodix0: IRQ debug: direct BUS_ALLOC_RESOURCE(rk_i2c0, ...) -> 0
goodix0: IRQ debug: direct BUS_ALLOC_RESOURCE(simplebus0, ...) -> 0xffff…

simplebus returned a resource. rk_i2c returned NULL. The blackhole was inside rk_i2c.c. Cross-reference against sys/dev/iicbus/controller/nvidia/tegra_i2c.c, which works. Tegra declares a passthrough block of seven bus_generic_* methods (alloc_resource, release_resource, setup_intr, teardown_intr, activate_resource, deactivate_resource, adjust_resource). rk_i2c declared device_*, ofw_bus_*, and iicbus_* methods — and zero bus_* methods. iicbus children walking up through bus_generic_rl_alloc_resource hit rk_i2c, found the kobj null_alloc_resource default, and got NULL with no error. The PIC contract from the previous war story applies one bus layer up: kobj’s silent default is the same trap.

▸ fix

A seven-line patch against upstream rk_i2c.c, modeled on tegra. Added as patches/sys/dev/iicbus/controller/rockchip/rk_i2c.c.patch rather than a vendored overlay because the file otherwise lives unmodified in the FreeBSD tree:

DEVMETHOD(bus_setup_intr,        bus_generic_setup_intr),
DEVMETHOD(bus_teardown_intr,     bus_generic_teardown_intr),
DEVMETHOD(bus_alloc_resource,    bus_generic_alloc_resource),
DEVMETHOD(bus_release_resource,  bus_generic_release_resource),
DEVMETHOD(bus_activate_resource, bus_generic_activate_resource),
DEVMETHOD(bus_deactivate_resource, bus_generic_deactivate_resource),
DEVMETHOD(bus_adjust_resource,   bus_generic_adjust_resource),

Goodix reverts to canonical bus_alloc_resource_any(SYS_RES_IRQ, &rid, RF_ACTIVE) ( 2972362 rk_i2c + goodix: fix iicbus child IRQ alloc ), strips ~150 lines of debug scaffolding, and IRQ allocation works on the next boot. RT5640 inherits the same fix for free — its interrupt allocation path was almost certainly broken too, but we’d been bringing it up without IRQs and hadn’t noticed.

▸ lesson

The same anti-pattern appears at two layers in this essay: a kobj interface with optional _method slots, where “optional” means “the framework returns a silent kobj-default instead of telling you the implementor forgot.” First it was the PIC contract on rk_gpio. Then it was the bus-method passthrough on rk_i2c. Both controllers shipped in upstream FreeBSD with exactly the methods their existing consumers needed and nothing more, and “exactly enough” is indistinguishable from “broken for the next consumer” until you write that consumer. When porting to a new SoC, every controller-class method table is suspect. Diff against a known-working analog (tegra for I2C, the few worked-out PICs for INTRNG) before chasing the consumer driver.