comodore-iec-emu/docs/kernel-notes.md
Christian Werner b2e4f9710d
All checks were successful
Build kernel module / build (1:6.12.93-1+rpt1, bookworm) (pull_request) Successful in 1m1s
Build kernel module / build (1:6.18.34-1+rpt1, trixie) (pull_request) Successful in 1m8s
Build kernel module / package (pull_request) Successful in 29s
Build kernel module / release (pull_request) Has been skipped
refactor(kernel): align GPIO pin layout with the reference listener
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
2026-06-20 16:09:10 +02:00

12 KiB
Raw Blame History

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 (~3040× 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_byte relies 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 (512565), 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 an ERR_PTR on a bad offset (not NULL) — check with IS_ERR(), not !desc.
  • gpio_device_find_by_label() takes a reference; it's released with gpio_device_put(iec_gdev) in iec_exit and 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 with gpioinfo / the /sys/class/gpio/gpiochip*/label files 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 kernelbookworm ⇒ 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 isolcpus is needed once Phase 2 runs under WiFi/SD load.
  • IEC_GPIO_DATA direction-flip latency (GPFSEL write) — should be tens of ns.