Adopt the confirmed-working reference's pin assignment so the module runs on that proven wiring: DATA=BCM2 (pin 3), CLK=BCM3 (pin 5), ATN=BCM4 (pin 7). RESET is kept and relocated to BCM17 (pin 11), the pin freed by moving CLK. All five GPIOs stay in bank 0, so the direct-register hot path and ATN IRQ are unchanged -- only the IEC_GPIO_* defines, the device-tree overlay, and the docs/self-test move. DATA/CLK now sit on the ARM I2C pins (GPIO2/3) with the SoC's fixed ~1.8k pull-ups; keep dtparam=i2c_arm off. The bare-board self-test expectation changes from 0x15 to 0x0b accordingly. Generated by Clanker
12 KiB
Kernel module notes
Phase-0.5 decisions, resolved in
_research/pi-kernel-module-rt-gpio-2026-06-18.md (read that for the full
evidence and source-level analysis). This file is the short, actionable summary
the code in kernel/ is built on.
Decisions baked into the code
| Question | Decision | Where |
|---|---|---|
| CLK: interrupt vs. busy-poll | ATN falling-edge hardirq enters the state machine; bytes are busy-polled with local_irq_save() held for one byte (ninepin pattern) |
atn_isr, receive_byte |
| IRQ discipline | local_irq_save() around each byte only (~200 µs normal, ≤ 8 ms abort cap), not the whole ATN phase |
receive_byte |
| GPIO access (hot path) | Direct BCM register access via ioremap (~30–40× faster than gpiod) |
iec_lines.h |
| GPIO access (init/exit) | gpiod descriptor API; descriptors resolved by (chip, hwnum) via gpio_device_find_by_label("pinctrl-bcm2835") + gpio_device_get_desc(), not gpio_to_desc() (see "GPIO descriptor lookup" below) |
iec_init/iec_exit |
| Kernel↔userspace | Character device /dev/iec0 + kfifo + wait queue (IEC ≤ 1000 B/s; relayfs not justified) |
iec_read, emit_record |
udelay vs. poll |
Poll-with-timeout for CLK transitions; udelay only for fixed delays (EOI ack 80 µs, EOI detect 250 µs) |
iec_timing.h, wait_clk |
| Sense-line reads | Debounced (glitch-filtered): a level change is believed only after it holds IEC_DEBOUNCE_US (5 µs); shorter pulses are rejected as noise. Mirrors a confirmed-working reference listener. Fast path is a single register read, so tight CLK polls stay cheap. ATN ISR + self-test stay raw |
iec_read_stable, db_*_asserted |
| isolcpus / nohz_full | Not in the Phase-1 baseline. Add isolcpus=3 nohz_full=3 rcu_nocbs=3 irqaffinity=0-2 only if Phase-2 bit-error rate > 1% |
(boot cmdline) |
| PREEMPT_RT | Stock kernel sufficient; RT is a last resort | — |
| Module signing | Not required on stock RPi OS Bookworm | — |
| Peripheral base | 0x3F000000 (BCM2710A1 / Pi Zero 2 W; not ninepin's 0x20000000) |
iec_lines.h |
Level-shifter / DATA-sensing note (deviation from the research doc)
The research doc (§4.3) analysed a 7406 inverting drive + separate resistor divider sense, and flagged a "DATA sensing gap" (a 7406 output pin can't read the bus back). The PLAN instead specifies a non-inverting bidirectional level shifter (BSS138, sd2iec-style single DATA pin, §3.1). That choice:
- makes the logic non-inverting: bus low (asserted) ⇒ Pi reads LOW;
- closes the sensing gap — when the DATA pin is switched to input (Hi-Z),
reading it back through the bidirectional shifter returns the real bus state
the C64 is driving. This is exactly what
receive_byterelies on for bit sampling.
So iec_lines.h is non-inverting; there is no 7406 inversion to track. Confirm
with the IEC_IOC_SELFTEST ioctl (drive low → read low; release → read high)
before connecting the C64 (PLAN.md §10 risk row). See
../kernel/README.md for how to run it
(kernel/selftest.sh), the result bitmask, and the bare-board caveat.
GPIO descriptor lookup (do not use gpio_to_desc())
iec_init resolves the four IEC line descriptors by chip label + hardware
offset, not by the legacy global GPIO number:
iec_gdev = gpio_device_find_by_label("pinctrl-bcm2835"); /* ref held until exit */
gd_atn = gpio_device_get_desc(iec_gdev, IEC_GPIO_ATN); /* hwnum == BCM number */
... /* check with IS_ERR() */
Why — this bit us once (insmod: No such device / -ENODEV). On current
Raspberry Pi OS kernels (6.12 here) the BCM2835 GPIO controller no longer starts
at global number 0 — it's gpiochip512, base 512:
$ cat /sys/class/gpio/gpiochip*/base # -> 512 (pinctrl-bcm2835), 566 (exp-gpio)
The old code used gpio_to_desc(2/3/4/17), i.e. the global numberspace
assuming base 0. Those small numbers now fall outside the chip's range
(512–565), so every gpio_to_desc() returned NULL, the descriptor check
tripped, and init bailed with -ENODEV. The (chip, hwnum) lookup passes the
BCM number as the chip-relative offset (ATN=4, RESET=17, CLK=3, DATA=2),
which is base-independent and survives kernel bumps / gpiochip renumbering.
Notes:
gpio_device_get_desc()returns anERR_PTRon a bad offset (notNULL) — check withIS_ERR(), not!desc.gpio_device_find_by_label()takes a reference; it's released withgpio_device_put(iec_gdev)iniec_exitand on the init error path.- Both symbols are
EXPORT_SYMBOL_GPL(fine — the module is GPL) and need#include <linux/gpio/driver.h>. - If a future kernel renames the controller, update
IEC_GPIO_CHIP_LABEL; confirm the live label withgpioinfo/ the/sys/class/gpio/gpiochip*/labelfiles on the Pi.
Build & deploy (on the Pi)
sudo apt install raspberrypi-kernel-headers
cd kernel
make # iec_listener.ko
make overlay # dts/iec-overlay.dtbo (optional pin reservation)
sudo insmod iec_listener.ko address=4
ls -l /dev/iec0
# ... talk to the C64 ...
sudo rmmod iec_listener # releases DATA on the way out
Optional pin-reservation overlay (Bookworm paths — note the /boot/firmware/
prefix; the legacy /boot/ paths no longer apply on 64-bit Bookworm):
sudo cp dts/iec-overlay.dtbo /boot/firmware/overlays/
echo "dtoverlay=iec-overlay" | sudo tee -a /boot/firmware/config.txt
sudo reboot
# after reboot, verify it loaded:
dtoverlay -l
Pin the kernel before the first apt full-upgrade — any kernel bump
breaks the module via a vermagic mismatch (Invalid module format), so hold the
kernel packages up front rather than after the fact:
sudo apt-mark hold raspberrypi-kernel raspberrypi-kernel-headers raspberrypi-bootloader
# record the pinned version here once known:
# Pinned: raspberrypi-kernel <VERSION> (<DATE>)
Verify the module before loading it (vermagic)
insmod refuses a module whose vermagic doesn't match the running kernel
(Invalid module format / version magic ... should be ... in dmesg). Always
check it after copying a freshly built .ko to the Pi:
modinfo ~/iec_listener.ko | grep vermagic
uname -r # what the running kernel expects
What correct looks like — the kernel-version, SMP, preempt, and arch
tokens all match the running kernel (case differs: modinfo lowercases
preempt, uname prints PREEMPT — that's fine):
vermagic: 6.12.93+rpt-rpi-v8 SMP preempt mod_unload modversions aarch64
^^^^^^^^^^^^^^^^^^^ ^^^ ^^^^^^^ ^^^^^^^^
= uname -r | | = arch (arm64)
SMP PREEMPT
The leading 6.12.93+rpt-rpi-v8 must equal uname -r exactly — that token
is the only thing insmod hard-checks. modversions means symbol CRCs were
built against the matching Module.symvers, so symbol resolution is consistent
too. mod_unload just means rmmod is supported. All good → load it.
What wrong looks like — any difference in the version token, e.g.:
vermagic: 6.6.51+rpt-rpi-v8 SMP preempt mod_unload modversions aarch64
^^^^^^ kernel moved on; uname -r says 6.12.93 → insmod rejects it
or a wrong/blank arch (armv7l vs aarch64 → you built the 32-bit -v7/-v6
flavour by mistake), or a missing modversions (built against the wrong headers
tree). The fix is always the same: rebuild against headers matching the
current uname -r (re-run the one-liner above to get HEADERS_PKG /
KERNEL_VERSION), or keep the Pi pinned so the kernel can't drift out from
under the module.
Building off the Pi (emulated arm64 Docker)
You can compile the module on a non-Pi (x86) host with kernel/build-in-docker.sh
(or make docker-build). It runs an emulated arm64 Raspberry Pi OS container,
installs the raspberrypi kernel headers via apt, and builds natively so the
module's vermagic matches the Pi.
cd kernel
./build-in-docker.sh # -> iec_listener.ko (arm64) in this dir
./build-in-docker.sh clean
Configurable via env vars (kernel version is configurable as requested):
| Var | Default | Purpose |
|---|---|---|
SUITE |
bookworm |
Debian/RPi OS suite; must match the kernel — bookworm ⇒ 6.12.x, trixie ⇒ 6.18.x |
HEADERS_PKG |
linux-headers-rpi-v8 |
headers package; use -v7/-v6 for 32-bit, or raspberrypi-kernel-headers |
KERNEL_VERSION |
(latest) | exact version pin, e.g. 1:6.6.51-1+rpt3 |
IMAGE |
iec-kbuild |
builder image tag |
SUITE=trixie KERNEL_VERSION=1:6.18.34-1+rpt1 ./build-in-docker.sh
SUITE must match KERNEL_VERSION. Each raspberrypi archive suite carries
only its own latest kernel, so a trixie-era version against a bookworm base
fails with Version '…' was not found. Find the Pi's suite with
. /etc/os-release; echo "$VERSION_CODENAME" (or lsb_release -cs).
trixie SHA1 caveat. On trixie, apt verifies signatures with Sequoia (sqv),
whose crypto policy rejects SHA1 since 2026-02-01. The raspberrypi archive key's
binding self-signature is SHA1, so trixie's apt rejects the repo as "not
signed" (Policy rejected … SHA1 is not considered secure …). The Dockerfile
works around this by marking the raspberrypi repo trusted=yes only when
DEBIAN_SUITE=trixie; bookworm (gpgv) keeps full signature verification.
Find the exact values for your Pi — run this on the Pi (e.g. over SSH); it
prints the two lines ready to copy into the build-in-docker.sh invocation:
pkg="linux-headers-$(uname -r | sed 's/.*+rpt-//')"; echo "HEADERS_PKG=$pkg KERNEL_VERSION=$(dpkg-query -W -f='${Version}' "$pkg")"
On the Pi at chris@10.1.0.41 (a Pi 3B+, kernel 6.12.93+rpt-rpi-v8; same
arm64/-v8 headers as the Zero 2 W) this currently prints:
HEADERS_PKG=linux-headers-rpi-v8 KERNEL_VERSION=1:6.12.93-1+rpt1
vermagic caveat: each raspberrypi apt archive suite normally serves only
the latest kernel in its pool (bookworm ⇒ 6.12.x, trixie ⇒ 6.18.x), so the
container's SUITE must match the Pi's release, and pinning KERNEL_VERSION to
an old release within a suite may not be downloadable. The reliable strategy is
to keep the Pi current (sudo apt full-upgrade) and build with the matching
SUITE + default (latest) version — then the Pi and the container agree. If you
must target an older/specific kernel, copy the Pi's /lib/modules/$(uname -r)/build tree into the container instead of using apt.
uname -r is not used inside the container (it reports the host kernel under
emulation); the entrypoint derives KDIR from the installed headers under
/lib/modules/.
The host needs qemu binfmt for arm64; the script registers it once via
tonistiigi/binfmt --install arm64 (a one-time privileged container).
Starting timing constants
See kernel/iec_timing.h. Tune in Phase 2 against a real C64 / logic analyser.
The likely first knobs: IEC_EOI_DETECT_US (EOI false positives/negatives),
IEC_CLK_TIMEOUT_US (frame errors under load), and IEC_DEBOUNCE_US (the
sense-line glitch-filter window — raise it if a noisy bus still yields bit
errors, lower it if it smears fast edges).
Open items to verify on hardware
- GPIO IRQ latency on BCM2710A1 under representative load (target ATN ack ≪ 1 ms).
- Direct-register read latency inside the IRQ-off loop on the A53 (budget vs. the 20 µs C64 bit window).
- Whether
isolcpusis needed once Phase 2 runs under WiFi/SD load. IEC_GPIO_DATAdirection-flip latency (GPFSELwrite) — should be tens of ns.