comodore-iec-emu/_research/pi-kernel-module-rt-gpio-2026-06-18.md

54 KiB
Raw Permalink Blame History

Linux Kernel Module Real-Time GPIO Techniques for Commodore IEC Emulator on Raspberry Pi Zero 2 W

Research tier: deep dive · 2026-06-18


Question

How to build a Linux kernel module that meets the real-time timing demands of the Commodore IEC bus listener handshake on a Raspberry Pi Zero 2 W (BCM2710A1 Cortex-A53)? Specifically: interrupt vs. polling strategy, IRQ discipline, udelay accuracy, CPU isolation, PREEMPT_RT necessity, GPIO access speed, concrete timing constants, precedent code analysis (ninepin, raspbiec, sd2iec, IECDevice), kernel↔userspace interface, device-tree overlay, build/deploy workflow.

Internal grounding documents read: _plans/poc-listener-printer-PLAN.md and _research/commodore-iec-serial-bus-2026-06-18.md.


Summary

A plain stock Raspberry Pi OS kernel (6.x) with a properly structured kernel module is sufficient for the listener-only IEC PoC. The winning strategy is a CLK-transition-driven ISR for the bit loop (interrupt on both edges of CLK), with local_irq_save() applied at the top of iec_readByte() for the ~160 µs duration of one byte only. Both ninepin (FozzTexx) and raspbiec (Flogistoni) confirm this architecture in production. Direct BCM GPIO register access via ioremap() is 3040× faster than the kernel gpiod descriptor API for repeated reads and should be used inside the bit loop. PREEMPT_RT reduces worst-case latency from ~300 µs to ~90 µs but is not needed when IRQs are locally disabled for a byte. CPU isolation (isolcpus=3 nohz_full=3) on one of the four A53 cores is the highest-value optional tuning. Kernel module signing is not required on stock Raspberry Pi OS Bookworm (secure boot is opt-in and off by default). The character device /dev/iec0 with a kfifo and wait-queue is the right kernel↔userspace interface for our low-rate tagged-record use case.


Priority 1 — Kernel Real-Time Timing

1.1 Interrupt-driven vs. busy-polled CLK for the bit loop

Recommendation: CLK-interrupt-driven byte reception, not a pure busy-poll.

The ninepin kernel module (iec/iec.c) uses two interrupt handlers:

  • iec_handleATN() — falls edge on ATN; immediately drives DATA low and queues work.
  • iec_handleCLK() — rising edge on CLK; calls iec_readByte() to collect bits.

Inside iec_readByte(), the 8 bits are collected by busy-polling CLK transitions with a short timeout per bit (150 µs), after IRQs are disabled with local_irq_save(). This is the classic hybrid: edge interrupt to enter the byte context, then spin inside that context.

raspbiec's raspbiecdrv.c also uses IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING on all three lines (ATN, CLK, DATA), with a 19-state state machine driven by GPIO interrupts and hrtimer timeouts. The bit loop (iec_wait_clk_busy) busy-polls with 1000 µs per-transition timeouts:

// raspbiecdrv.c bit receive loop
while (iec_bit > 0) {
    iec_biterror |= iec_wait_clk_busy(IEC_HI, 1000);
    iec_byte >>= 1;
    iec_byte |= iec_get_data() << 7;
    --iec_bit;
    iec_biterror |= iec_wait_clk_busy(IEC_LO, 1000);
}

Trade-offs:

Approach Pro Con
Pure hardirq per-CLK-edge Minimum latency to each bit; no poll loop 16 IRQ context switches per byte; complex state machine; each context switch ~510 µs
Enter byte in IRQ, then busy-poll (ninepin pattern) Simple loop; no per-bit interrupt overhead; IRQs-off guarantees Local IRQs disabled for ~160 µs per byte — acceptable
Threaded IRQ Can sleep; simplifies locking 1050 µs extra latency per wakeup; unacceptable for 20 µs bit windows
Pure busy-poll from module init Simplest code Burns entire core; no entry point for ATN

Verdict for our PoC: Use ATN falling-edge hardirq to pull DATA and enter state machine. For the byte receive loop use the ninepin pattern: enter via CLK interrupt (or enter directly from ATN ISR in command-phase), then local_irq_save() and busy-poll CLK for the 8 bits. This keeps the critical path entirely in hardirq context without per-bit context switches.

Threaded IRQ (IRQF_ONESHOT or request_threaded_irq) is appropriate for the bookkeeping that happens after a complete byte is decoded — e.g., pushing the record to the kfifo. On PREEMPT_RT all IRQs are force-threaded, so on a stock kernel we can use a threaded IRQ for the kfifo push safely.

1.2 Preemption / IRQ discipline around a byte

Recommendation: local_irq_save() / local_irq_restore() around each byte receive, NOT the entire ATN phase.

ninepin explicitly uses local_irq_save() inside iec_readByte() (confirmed in source analysis — "there are only 2 places where interrupts are disabled during processing"). The IRQF_ONESHOT flag in raspbiec ensures the IRQ handler itself runs with the line masked, providing similar protection.

Time budget for one byte with IRQs off:

The C64-as-talker uses 20 µs CLK half-periods (spec). One byte = 8 bits × (CLK-low 20 µs + CLK-high 20 µs) = 8 × 40 µs = 320 µs maximum. With per-bit polling timeouts of ~1 ms each (as in raspbiec), the IRQ-off window is bounded by:

  • 8 bits × 150 µs timeout (ninepin) = 1200 µs absolute worst case if bus stalls
  • In the normal case (bus running): ~160 µs for one byte at 20 µs CLK

For safety, add a hard IRQ-off limit: exit the loop and report an error if CLK does not transition within 1 ms per bit (configurable). This caps the worst-case IRQ-disabled window at 8 ms (abnormal/stall) or ~200 µs (normal). The 200 µs normal case is benign on a 1 GHz A53; USB frame IRQs fire at 1 ms intervals and will miss at most one frame during a byte transfer, which is harmless.

Rule of thumb: Measured data from Cortex-A53 real-time benchmarks show preemption-disabled windows of 100500 µs are common even on busy systems without special tuning [HowTech 2025]. A kernel module keeping IRQs disabled for ~200 µs per byte is well within the range that existing Pi kernel modules tolerate.

Do NOT use local_irq_disable() for the entire ATN phase (potentially many bytes × many ms). Re-enable between bytes: the between-bytes interval (Tbb ≥ 100 µs) provides a safe window to process the previous record and re-arm the IRQ.

1.3 udelay() / ndelay() accuracy and busy-wait in kernel context

Recommendation: Use busy-poll loops (poll CLK with timeout counter) rather than fixed udelay() for the bit loop. Use udelay() only for fixed protocol delays (EOI ack hold, talker delays) where the value is small (≤ 100 µs).

From kernel documentation (Documentation/timers/delay_sleep_functions.rst):

  • udelay() busy-waits based on loops_per_jiffy calibration. It may return early if the computed loops_per_jiffy is too low (due to IRQ execution time, cache effects, or CPU frequency scaling).
  • ndelay() shares the same constraints; "ndelay-level precision may not actually exist on many non-PC devices."
  • Neither function should be used for delays exceeding MAX_UDELAY_MS (typically 1 ms) without using mdelay(), due to overflow in the loop counter calculation.
  • Both are valid in atomic/IRQ context (they do not sleep).
  • In kernel module context on a 1 GHz A53: udelay(20) corresponds to ~20000 busy-wait loop iterations, which is accurate to ±5 µs in practice.

Practical pattern from precedents:

sd2iec (src/iec.c) uses a hardware timer (start_timeout(256)) for EOI detection (the 200+ µs window), not a udelay — because udelay would block the CPU without being interruptible by ATN. Then delay_us(73) for the EOI acknowledge hold (Tei, spec says ≥ 60 µs). The "73" is stated explicitly as "calculated from all instructions between IO accesses" — it accounts for the instruction stream latency on the AVR, not just the raw timer.

ninepin uses a polling loop with timeout counter for waiting on CLK transitions, not udelay:

// ninepin iec_waitForSignals()
iec_waitForSignals(IEC_CLK, 1, 0, 0, 150)   // wait up to 150 µs for CLK high

For our module:

  • Inside receive_byte(), use busy-poll loops with timeout for CLK transitions (not udelay). This correctly handles the case where CLK transitions faster than expected (C64 slightly faster than 20 µs) and avoids a fixed delay overshoot.
  • Use udelay(80) for the EOI acknowledge hold (Tei spec = 60 µs; buffer to 80 µs for safety on a cached Cortex-A53).
  • Use udelay(1000) for the ATN ack (1 ms), but in ISR context this should be done via GPIO write + workqueue defer instead.

Important: On the Pi Zero 2 W (BCM2710A1 @ 1 GHz), one udelay(1) iteration takes approximately 1 µs ± 20% depending on cache state. When IRQs are disabled and the code is hot in L1, accuracy improves significantly.

1.4 CPU isolation: isolcpus, nohz_full, IRQ affinity steering

Recommendation: For Phase 1 and Phase 2, try WITHOUT isolation first. Add isolcpus=3 nohz_full=3 if Phase 2 shows bit errors under load.

The raspbiec README notes timing sensitivity requiring a kernel module; the ninepin README confirms interrupt-driven approach is needed. Neither project documents a mandatory isolcpus requirement — they rely on the IRQ-off-per-byte approach.

How to do it if needed:

  1. Add to /boot/cmdline.txt (single line):

    isolcpus=3 nohz_full=3 rcu_nocbs=3 irqaffinity=0-2
    

    This excludes CPU 3 from the scheduler, disables the periodic tick on it, offloads RCU callbacks, and routes all other IRQs to CPUs 02.

  2. In module init, pin the work to CPU 3:

    kthread = kthread_create(iec_worker_thread, NULL, "iec_worker");
    kthread_bind(kthread, 3);    // bind to isolated core
    wake_up_process(kthread);
    
  3. Verify IRQ affinity at runtime:

    # Move a specific IRQ away from CPU 3 (replace NN with GPIO IRQ number)
    echo 7 > /proc/irq/NN/smp_affinity   # CPUs 0,1,2 = bitmask 0x7
    

Cost/benefit on a 4-core A53:

  • Pi Zero 2 W runs 4 × Cortex-A53 @ 1 GHz. Sacrificing one core costs 25% total throughput, but for a listener-only PoC this is negligible.
  • The IRQ-off-per-byte approach already handles the critical window without core isolation. Isolation is insurance against pathological interrupt storm scenarios (USB, WiFi activity).
  • nohz_full incurs overhead on every kernel entry/exit from the isolated core — only worthwhile if a kthread on that core does the busy-polling.

Practical recommendation: Reserve isolcpus=3 as a Phase 2 fallback if bit error rate exceeds 1%. Do not add it to Phase 1 baseline.

1.5 PREEMPT_RT vs. stock Raspberry Pi OS kernel

**Recommendation: Use stock kernel for the PoC. Revisit PREEMPT_RT only if stock

  • IRQ-off-per-byte fails to meet the 20 µs window reliably.**

Why stock is sufficient for our use case:

The critical window is not interrupt latency (ATN: 1 ms budget, easy) but sampling CLK while it is HIGH (data valid) during the bit loop. That sampling happens inside local_irq_save(), so interrupt latency does not matter during the loop — we are already in a non-preemptible, IRQ-off context.

PREEMPT_RT would matter if we needed to react to CLK edges from an interrupt handler without disabling IRQs — i.e., if a threaded IRQ approach were used. Since our bit loop busy-polls with IRQs off, the stock kernel provides equivalent determinism for the bit loop itself.

Measured latency numbers:

  • Stock Raspberry Pi 4B (similar A53-class): cyclictest max ~301 µs [LeMaRiva 2019]
  • PREEMPT_RT RPi 4B: cyclictest max ~93 µs (3.2× improvement) [LeMaRiva 2019]
  • PREEMPT_RT RPi 4B (6.6.59-rt45, tuned): max 3040 µs [community builds 2024]
  • Stock IRQ-off window for one byte (~200 µs): zero additional scheduler jitter because we are IRQ-disabled

PREEMPT_RT availability on Bookworm 64-bit:

As of kernel 6.12, the RT patch is merged into the mainline Linux kernel — no separate patch required, just CONFIG_PREEMPT_RT=y in menuconfig [RPi forums, Jan 2025]. For the RPi-specific 6.6.x trees, community builds exist (6.6.59-rt45, 6.6.77-rt50). Build procedure:

git clone --depth=1 https://github.com/raspberrypi/linux
wget https://www.kernel.org/pub/linux/kernel/projects/rt/6.6/patch-6.6.58-rt45.patch.gz
gunzip patch-6.6.58-rt45.patch.gz && patch -p1 < patch-6.6.58-rt45.patch
KERNEL=kernel8 make bcm2711_defconfig    # use bcm2837_defconfig for Zero 2 W
make menuconfig  # General setup → Preemption Model → Fully Preemptible
make -j4 Image.gz modules dtbs

Build time on Pi 4B is ~2 hours natively. Cross-compiling on an x86 host is faster.

Decision: Use stock kernel for all phases of the PoC. Document PREEMPT_RT as a last-resort fallback if bit errors persist after CPU isolation.

1.6 Measured GPIO toggle/read latency from kernel context

Recommendation: Use direct BCM register access (ioremap + ioread32/iowrite32) inside the bit loop. The gpiod descriptor API is 3040× slower for repeated GPIO operations.

Benchmark data:

From vovkos/rpi-gpio-test (Raspberry Pi 2 Model B, similar BCM283x peripheral):

Operation Kernel GPIO API Direct Register (BCM2836)
Write-only toggle 1.3 MHz 41 MHz
Read-write polling 370 kHz 2.7 MHz
Read-write IRQ-based 110 kHz 140 kHz
  • Direct register write-only: ~24 ns per operation
  • Direct register read-write polling: ~370 ns per operation
  • Kernel GPIO API write: ~770 ns per operation

For our use case (read CLK, read DATA — polling pattern inside IRQ-off loop):

  • Direct register: ~370 ns per read → for 8 bits × 2 reads = 16 reads ≈ 6 µs overhead
  • GPIO API: ~2.7 µs per read → 16 reads ≈ 43 µs overhead — eating 2× the bit window

From codeembedded.com kernel driver example on BCM2835: ioremap-based GPIO achieves 6.25 MHz without artificial delays (vs. ~8 kHz from userspace). With udelay(1) inserted, still achieves 458 kHz.

Conclusion: The gpiod descriptor API is not fast enough inside a tight CLK-polling loop. Use direct BCM register access.

BCM2710A1 (Pi Zero 2 W) peripheral base address:

/* BCM2837 / BCM2710A1 — same peripheral map as RPi 3 */
#define BCM2837_PERI_BASE   0x3F000000UL
#define GPIO_BASE           (BCM2837_PERI_BASE + 0x200000UL)
#define GPIO_BLOCK_SIZE     0x1000

/* Register offsets (word offsets, multiply by 4 for byte offset) */
#define GPFSEL0   0x00  /* Function select 0 (GPIO 0-9) */
#define GPSET0    0x07  /* Pin output set 0 (GPIO 0-31) */
#define GPCLR0    0x0A  /* Pin output clear 0 (GPIO 0-31) */
#define GPLEV0    0x0D  /* Pin level 0 (GPIO 0-31) */

ninepin's gpio.h implements exactly this with direct macros:

#define BCM2708_PERI_BASE   0x20000000   // RPi 1; ninepin targets older Pi
#define GPIO_BASE           (BCM2708_PERI_BASE + 0x200000)

#define digitalRead(pin) \
    ({int _p = (pin) & 31; (*(gpio + 13) & (1 << _p)) >> _p;})
#define digitalWrite(pin, val) \
    ({int _p = (pin) & 31, _v = !!(val); *(gpio + 7 + ((_v) ? 0 : 3)) = 1 << _p;})

The gpio pointer is the ioremap()-mapped virtual address of GPIO_BASE.

Modern alternative for non-timing-critical paths: The gpiod descriptor API with devm_gpiod_get() is correct for module init/exit (GPIO claim, direction set, IRQ number lookup). Only the hot path (inside receive_byte()) needs direct register access.

Important note: ioremap_nocache() is deprecated in kernels 5.6+. Use ioremap() — on ARM, device memory is implicitly non-cacheable when marked as DEVICE_nGnRnE in the DT, which the BCM GPIO region is.

1.7 Concrete udelay/timeout constants

Cross-referencing: IEC spec (from _research/commodore-iec-serial-bus-2026-06-18.md), sd2iec empirical values (src/iec.c), and ninepin/raspbiec observed values.

Parameter Spec (µs) sd2iec empirical ninepin (µs) Our starting value Notes
ATN response (Tat) ≤ 1000 immediate ISR immediate ISR ISR pulls DATA within ~5 µs of IRQ delivery Not a udelay — it is the ISR itself
Debounce read-read 2 2 (explicit) n/a udelay(2) Before each GPIO read in a loop
EOI detection timeout (Tye) ≥ 200 start_timeout(256) 200 µs window Poll CLK for 250 µs; if no assertion → EOI +25% margin over spec
EOI ack hold (Tei) ≥ 60 delay_us(73) ~60 udelay(80) sd2iec adds ~13 µs for instruction overhead; we add ~20 µs margin
Between-bit poll timeout n/a n/a 150 µs 1000 µs max per CLK transition Generous; return error if exceeded
Byte acknowledge (Tf) ≤ 1000 fast fast Assert DATA within 10 µs of 8th bit Already in IRQ-off context; GPIO write is ~24 ns
Between-bytes (Tbb) ≥ 100 n/a n/a Min 100 µs hold before release Talker must hold CLK ≥ 100 µs; we just wait for next CLK-release
ATN ISR→ DATA pull latency ≤ 1000 ISR immediate ISR immediate Target < 20 µs (hardirq latency on A53) Hardware interrupt latency ~520 µs [HowTech 2025]
CLK bit-valid window to sample (Tv) ≥ 20 n/a n/a Sample on CLK rising edge immediately Edge-triggered ISR or busy-poll rising edge

sd2iec timing notes (from direct source read):

  • delay_us(2): debounce between consecutive bus reads (mirrors 1571 ROM instruction timing)
  • start_timeout(256): EOI detection window (256 µs, slightly above spec's 200 µs)
  • delay_us(73): EOI acknowledge hold — explicitly annotated "calculated from all instructions between IO accesses" (includes AVR instruction overhead; our Pi A53 runs faster, use 80 µs)
  • delay_us(50) + delay_us(70): talker handshake delays (not relevant for listener-only PoC)
  • start_timeout(218): JiffyDOS detection window on last bit

Priority 2 — Precedent Kernel Modules: Source-Level Analysis

2.1 FozzTexx/ninepin (iec/iec.c, iec/gpio.h)

What to copy:

  • GPIO claim via gpio_request() + gpio_to_irq() + direct register macros (gpio.h)
  • ATN ISR pattern: immediate DATA assert on falling edge
  • local_irq_save() scope: just inside iec_readByte(), not the whole command phase
  • iec_data header struct (command, channel, len, eoi, serial) — use as starting point for iec_record
  • Character device read() returning fixed-size records with header + payload bytes
  • /dev/iec0 through /dev/iec31 device numbering

Key facts extracted (confirmed from source):

GPIO API: Direct BCM register access via gpio.h macros — NOT gpiod descriptor API. Base address: BCM2708_PERI_BASE = 0x20000000 (Pi 1). For Pi Zero 2 W, change to 0x3F000000. The gpio volatile pointer is obtained via ioremap(GPIO_BASE, ...).

IRQ setup:

request_irq(irq_atn, iec_handleATN,
            IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING, "iec_atn", NULL);
request_irq(irq_clk, iec_handleCLK,
            IRQF_TRIGGER_RISING, "iec_clk", NULL);

Timing:

static int c64slowdown = 10;   // base unit microseconds, auto-calibrated
udelay(c64slowdown)            // ~10 µs single delay
udelay(c64slowdown*2)          // ~20 µs double (EOI ack)
iec_waitForSignals(IEC_CLK, 1, 0, 0, 150)   // 150 µs per-CLK timeout
iec_waitForSignals(IEC_CLK, 0, 0, 0, 150)
iec_waitForSignals(..., 200)   // 200 µs EOI window
iec_waitForSignals(..., 100000) // 100 ms for protocol phase timeouts

The c64slowdown = 10 variable is described as auto-calibrating based on actual measured timing during initial communication.

ISR structure: iec_handleCLK() is a rising-edge IRQ handler that calls iec_readByte(). Inside iec_readByte(), local_irq_save() is called to protect the 8-bit collection loop. Only 2 places in the entire driver disable IRQs.

Kernel→userspace interface: Character device /dev/iec0..iec31. read() returns a header (iec_data: command, channel, len, eoi, serial) followed by payload bytes. poll() returns POLLIN when data is available. Workqueue (iec_readQ) handles the deferred processing after byte collection.

Bit receive loop:

for (len = 0, bits = eoi; !abort && len < 8; len++) {
    if ((abort = iec_waitForSignals(IEC_CLK, 1, 0, 0, 150))) break;
    if (digitalRead(IEC_DATA)) bits |= 1 << len;
    if (iec_waitForSignals(IEC_CLK, 0, 0, 0, 150)) {
        if (len < 7) abort = 1;
    }
}

Collects 8 bits LSB-first. Waits for CLK high (data valid), samples DATA, waits for CLK low (next bit setup). Timeout 150 µs per transition.

What doesn't apply to our listener-only PoC:

  • Write/talker path (iec_writeByte()): skip entirely for Phase 1 and 2
  • Device numbering iec0iec31: we need only iec0
  • c64slowdown auto-calibration: useful but not essential; start with fixed 10 µs

Gotchas:

  • BCM base address 0x20000000 is RPi 1 only; Pi Zero 2 W uses 0x3F000000
  • ninepin was written for 32-bit Linux; for 64-bit aarch64, verify pointer sizes in GPIO macros (the int _p = (pin) & 31 is fine; pointer arithmetic is long)
  • gpio_request() is the legacy API; prefer devm_gpio_request() or gpiod descriptor API for initial claim, then use direct registers for hot path

2.2 Flogistoni/raspbiec (raspbiecdrv.c)

What to copy:

  • Full IRQ edge detection: IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING | IRQF_ONESHOT — the IRQF_ONESHOT flag ensures the handler runs with the IRQ line masked, providing isolation without local_irq_disable().
  • Timing constants for the bus protocol (see table below).
  • KFIFO-based kernel↔userspace interface pattern:
    static DECLARE_KFIFO(raspbiec_in_fifo, int16_t, RASPBIEC_IN_FIFO_SIZE);
    DECLARE_WAIT_QUEUE_HEAD(inq);
    
    This is the idiomatic blocking-read character device pattern.
  • INVERTED_OUTPUT preprocessor flag for the 7406 open-collector inversion logic — clean separation of hardware inversion from protocol logic.

Key facts extracted:

GPIO setup:

static const struct gpio gpios[] = {
    { IEC_ATN_IN,   GPIOF_IN, "RASPBIEC ATN in"  },
    { IEC_CLK_IN,   GPIOF_IN, "RASPBIEC CLOCK in" },
    { IEC_DATA_IN,  GPIOF_IN, "RASPBIEC DATA in"  },
    { IEC_ATN_OUT,  GPIOF_OUT_INIT_LOW, ... },
    { IEC_CLK_OUT,  GPIOF_OUT_INIT_LOW, ... },
    { IEC_DATA_OUT, GPIOF_OUT_INIT_LOW, ... },
    { IEC_DEBUG1,   GPIOF_OUT_INIT_LOW, ... },
    { IEC_DEBUG2,   GPIOF_OUT_INIT_LOW, ... },
};

Separate IN and OUT GPIOs — matches the hardware design with unidirectional level-shift buffers (input: resistor divider; output: 7406).

Timing constants (bit_timings array, C64 mode):

data_hi:     50 µs  (hold time before valid bit — Ts for device as talker)
data_settle: 25 µs
data_valid:  25 µs  (Tv)

For 1541 mode: data_hi=90 µs, data_valid=75 µs (the 60 µs spec with margin).

Protocol-level timing:

udelay(3)    // GPIO line stabilization after output change
udelay(20)   // Frame-to-ATN release (Tr)
udelay(40)   // Frame handshake (Tf response)
udelay(60)   // EOI response / acknowledge (Tei, Tfr)
udelay(80)   // Talk-attention ack hold (Tda)
udelay(150)  // Talk-attention release (Ttk)

State machine: 19 states, fully event-driven via IRQ + hrtimer. More complete than ninepin but also more complex. The IEC_RECEIVE_BYTEIEC_LISTENER_READY_FOR_DATAIEC_PROCESS_USER_DATA sequence mirrors our §6 pseudocode directly.

Bit receive loop: MSB-first shift:

iec_byte >>= 1;
iec_byte |= iec_get_data() << 7;

Note: IEC protocol is LSB-first, but raspbiec shifts MSB-in and shifts right — this is correct because after 8 iterations bit 0 ends up in position 0.

What doesn't apply:

  • Talker (SEND_BYTE) path — we are listener-only
  • The separate ATN/CLK/DATA output GPIOs — we only drive DATA
  • Debug GPIO pins (IEC_DEBUG1/2) — useful but out of scope for PoC

Gotchas:

  • GPIOF_* flags are legacy API (deprecated in favor of gpiod). Still works in 6.x kernels but compiler may warn.
  • register_chrdev() is also legacy; prefer alloc_chrdev_region() + cdev_add() for new code in 6.x kernels.
  • No isolcpus usage found in raspbiec source — the module relies on IRQ-off-per-operation, not core isolation. This confirms that core isolation is not mandatory.

2.3 sd2iec src/iec.c (AVR baseline)

What to mine: Empirical timing constants and the clean handshake structure. This is bare-metal AVR (not Linux), so none of the Linux-specific patterns apply.

Key timing values confirmed from direct source read:

iec_debounced():           delay_us(2)     // debounce between reads
EOI detect timeout:        start_timeout(256)  // 256 µs window
EOI ack hold (delay_us):   73              // "calculated from all instructions between IO accesses"
JiffyDOS detect timeout:   start_timeout(218)  // on 7th bit
Talker delay (ATNPROCESS): delay_us(50) + delay_us(70)

ATN ISR structure:

IEC_ATN_HANDLER {
    if (!IEC_ATN) {
        set_data(0);   // immediate DATA assert
    }
}

This mirrors our plan exactly — immediate DATA assert in ISR, no delay.

Bus state machine states confirmed: IDLE → FOUNDATN → ATNACTIVE → ATNPROCESS → ATNFINISH → CLEANUP → IDLE

The _iec_getc() function returns -1 if iec_check_atn() detects ATN change — showing the correct pattern of checking ATN on every iteration of wait loops.

What to copy:

  • The iec_check_atn() guard on every wait loop inside _iec_getc() — essential for handling ATN assertion mid-byte (C64 can interrupt a transfer)
  • 256 µs EOI detection window (generous over 200 µs spec; safe for slow C64 Kernals)
  • 73 µs EOI ack hold → translate to 80 µs for Pi (slightly faster ISA, add margin)
  • 2 µs debounce read pattern

What doesn't apply: All hardware-specific AVR macros, timer hardware, UART output.

2.4 dhansel/IECDevice (C++)

What to mine: ATN handling for non-interrupt hardware, byte receive loop structure.

Key facts extracted:

  • 1 ms ATN response deadline confirmed as the hard constraint
  • For hardware that cannot guarantee software timing, a hardware ATN assist circuit (74LS125 buffer with CTRL pin) is used: the hardware automatically pulls DATA low on ATN assert, software re-arms it afterwards. This is relevant if software IRQ latency is measured to exceed 1 ms.
  • "Disable all interrupts during fast-load transfers for up to 20 ms" — confirms that even 20 ms IRQ-off is done in practice on Arduino/Pico for fast-loaders (JiffyDOS etc.). Our ~200 µs per byte is entirely safe by comparison.
  • The library supports both interrupt-driven (preferred) and polling-based ATN detection via a task() function called at ≥ 1 kHz.

What to copy: The ATN-assist circuit idea is worth noting for Phase 3 hardening (a 74LS125 + CTRL pin could guarantee the 1 ms ATN response even if the Pi is briefly in a long IRQ-off section from another driver).

What doesn't apply: C++ class hierarchy, Arduino HAL, EEPROM config.


Priority 3 — Kernel→Userspace Interface

Recommendation: Use a character device with kfifo + wait_queue. This is the right choice for our low-rate tagged-record IEC use case.

Idiomatic skeleton (6.x kernel style):

/* iec_listener.h - shared between kernel module and userspace */
struct iec_record {
    __u8  kind;       /* 0=command, 1=data, 2=event */
    __u8  value;      /* the byte, or event code */
    __u8  flags;      /* bit0=EOI, bit1=addressed-to-us */
    __u8  _pad;
    __u64 ts_ns;      /* ktime_get_ns() at receive time */
} __packed;

/* iec_listener.c */
#include <linux/cdev.h>
#include <linux/kfifo.h>
#include <linux/wait.h>

#define IEC_FIFO_SIZE  256   /* number of records (power of 2) */

static DECLARE_KFIFO_STATIC(iec_fifo, struct iec_record, IEC_FIFO_SIZE);
static DECLARE_WAIT_QUEUE_HEAD(iec_read_wq);
static dev_t        iec_devno;
static struct cdev  iec_cdev;
static struct class *iec_class;

/* Call from ISR (or threaded handler) after receiving a byte: */
static inline void emit_record(u8 kind, u8 value, u8 flags)
{
    struct iec_record rec = {
        .kind  = kind,
        .value = value,
        .flags = flags,
        .ts_ns = ktime_get_ns(),
    };
    kfifo_put(&iec_fifo, rec);      /* non-blocking; ISR context safe */
    wake_up_interruptible(&iec_read_wq);
}

static ssize_t iec_read(struct file *f, char __user *buf,
                        size_t count, loff_t *ppos)
{
    struct iec_record rec;
    int ret;

    if (kfifo_is_empty(&iec_fifo)) {
        if (f->f_flags & O_NONBLOCK) return -EAGAIN;
        ret = wait_event_interruptible(iec_read_wq,
                    !kfifo_is_empty(&iec_fifo));
        if (ret) return ret;   /* -ERESTARTSYS on signal */
    }
    if (!kfifo_get(&iec_fifo, &rec)) return 0;
    if (copy_to_user(buf, &rec, sizeof(rec))) return -EFAULT;
    return sizeof(rec);
}

static const struct file_operations iec_fops = {
    .owner   = THIS_MODULE,
    .read    = iec_read,
    .open    = nonseekable_open,
    .llseek  = no_llseek,
};

static int __init iec_init(void)
{
    INIT_KFIFO(iec_fifo);
    alloc_chrdev_region(&iec_devno, 0, 1, "iec");
    cdev_init(&iec_cdev, &iec_fops);
    cdev_add(&iec_cdev, iec_devno, 1);
    iec_class = class_create("iec");
    device_create(iec_class, NULL, iec_devno, NULL, "iec0");
    /* ... GPIO claim, IRQ request, etc. */
    return 0;
}

Python userspace side:

import struct, os, fcntl

RECORD_FMT = "=BBBxQ"   # kind, value, flags, pad, ts_ns
RECORD_SIZE = struct.calcsize(RECORD_FMT)   # 12 bytes

with open("/dev/iec0", "rb", buffering=0) as f:
    while True:
        raw = f.read(RECORD_SIZE)
        kind, value, flags, ts_ns = struct.unpack(RECORD_FMT, raw)
        # decode and log...

3.2 relayfs / debugfs ring buffer

relayfs (Documentation/filesystems/relay.rst) is designed for high-volume, sustained kernel→userspace data logging (e.g., ftrace, SystemTap). Key properties:

  • Per-CPU ring buffers with mmap access — very low overhead for kernel writes
  • Userspace access via files in debugfs (or relayfs mount)
  • No wake_up overhead per record; userspace polls or reads in bulk

When relayfs is worth it:

  • Logging rates > ~100,000 records/sec
  • Per-record interrupt overhead of wake_up_interruptible() would dominate

For our use case: IEC bus speed is at most ~1000 bytes/sec (standard IEC at 20 µs bit clock, 8 bits + handshake ≈ 1 ms/byte). This is far too slow for relayfs to provide any benefit. The kfifo char device adds ~200 ns per record for kfifo_put() + wake_up() — negligible at 1000 records/sec.

Decision: Use the character device. The complexity of relayfs (debugfs mount, per-CPU buffer management, sub-buffer scheme) is not justified for a ≤ 1000 byte/sec data rate.


Priority 4 — GPIO Device-Tree Overlay and Electrical Validation

4.1 Writing and applying a dtoverlay

IEC Overlay DTS template:

/dts-v1/;
/plugin/;

/ {
    compatible = "brcm,bcm2835";

    fragment@0 {
        target = <&gpio>;
        __overlay__ {
            iec_pins: iec_pins {
                /*
                 * GPIO 2  = ATN  (input, pull-up: bus idle = 5V = high)
                 * GPIO 17 = CLK  (input, pull-up)
                 * GPIO 3  = RESET (input, pull-up)
                 * GPIO 18 = DATA_OUT (output, no pull initially)
                 */
                brcm,pins = <2 17 3>;          /* ATN, CLK, RESET */
                brcm,function = <0>;           /* 0 = INPUT */
                brcm,pull = <2>;               /* 2 = PULL-UP */
            };
            iec_data_pin: iec_data_pin {
                brcm,pins = <18>;              /* DATA output via 7406 */
                brcm,function = <1>;           /* 1 = OUTPUT */
                brcm,pull = <0>;               /* 0 = NO PULL (7406 drives) */
            };
        };
    };
};

Pull resistor choice: With the resistor divider (3.3 kΩ + 2.2 kΩ) on the input lines, the Pi's internal pull-up (~50 kΩ) is effectively in parallel with the 2.2 kΩ bottom resistor and is negligible. Setting brcm,pull = <2> (pull-up) is safe but the divider dominates. The pull-up prevents the input from floating if the IEC cable is disconnected.

Build and deploy (Bookworm):

# On the Pi (or cross-compiled with dtc):
dtc -I dts -O dtb -o iec-overlay.dtbo iec-overlay.dts
sudo cp iec-overlay.dtbo /boot/overlays/
# Add to /boot/config.txt:
echo "dtoverlay=iec" | sudo tee -a /boot/config.txt
# Verify after reboot:
dtoverlay -l

Note on Bookworm compatibility: Some custom overlays have reported issues in Bookworm with the 6.6.x kernel if they use deprecated DTS syntax. The template above uses the current brcm,bcm2835-compatible syntax which works on Bookworm.

4.2 Modern kernel module GPIO claim (gpiod descriptor API)

For module init/exit (non-timing-critical), use the descriptor API to claim GPIOs and obtain IRQ numbers. For the hot path (inside receive_byte()), switch to direct register reads via the ioremap-mapped GPIO block.

#include <linux/gpio/consumer.h>

static struct gpio_desc *gd_atn, *gd_clk, *gd_reset, *gd_data_out;

static int __init iec_init(void)
{
    /* Claim GPIOs — these must match what the DT overlay exports */
    gd_atn      = gpio_to_desc(IEC_GPIO_ATN);    /* GPIO 2  */
    gd_clk      = gpio_to_desc(IEC_GPIO_CLK);    /* GPIO 17 */
    gd_reset    = gpio_to_desc(IEC_GPIO_RESET);  /* GPIO 3  */
    gd_data_out = gpio_to_desc(IEC_GPIO_DATA);   /* GPIO 18 */

    gpiod_direction_input(gd_atn);
    gpiod_direction_input(gd_clk);
    gpiod_direction_input(gd_reset);
    gpiod_direction_output(gd_data_out, 0);   /* DATA: start released */

    /* Get IRQ numbers */
    int irq_atn = gpiod_to_irq(gd_atn);
    int irq_clk = gpiod_to_irq(gd_clk);
    request_irq(irq_atn, atn_isr, IRQF_TRIGGER_FALLING, "iec_atn", NULL);
    request_irq(irq_clk, clk_isr, IRQF_TRIGGER_RISING,  "iec_clk", NULL);

    /* Map BCM GPIO block for direct register access (hot path) */
    gpio_regs = ioremap(GPIO_BASE, GPIO_BLOCK_SIZE);
    ...
}

static void __exit iec_exit(void)
{
    free_irq(irq_atn, NULL);
    free_irq(irq_clk, NULL);
    /* Release DATA line — CRITICAL: float the bus before unloading */
    gpiod_set_value(gd_data_out, 0);   /* DATA released (7406: Pi LOW → bus float) */
    iounmap(gpio_regs);
    /* gpiod_put() for each descriptor */
}

Note on kernel 6.3+: Global GPIO numbers have been deprecated since 6.3 and warnings are issued if drivers use gpio_request(number, ...) directly. The gpiod descriptor API (via gpio_to_desc() in-module, or devm_gpiod_get() with platform device) is the correct modern approach.

4.3 Electrical validation of §3 plan

Plan design (from _plans/poc-listener-printer-PLAN.md §3):

  • ATN, CLK, RESET input: 3.3 kΩ top / 2.2 kΩ to GND voltage divider (5V → ~3.06 V)
  • DATA output: 7406 open-collector driver; Pi GPIO HIGH → 7406 output LOW → bus asserted
  • Pi GPIO 18 drives 7406 input; 7406 output pulls IEC DATA line

Validation against Pi Zero 2 W GPIO electrical limits:

Check Value Status
Input voltage from divider at idle (bus 5V) 5V × 2.2/(3.3+2.2) = 2.0V... wait
Correct divider: 5V × 2.2/(3.3+2.2) = 5 × 2.2/5.5 = 2.0V RISK: below 2.5V logic high threshold
Pi GPIO logic HIGH threshold (Vih) 1.8V min per BCM spec OK — 2.0V is above 1.8V
Pi absolute max input voltage 3.3V OK — 2.0V is under limit
Bus asserted (0V) at Pi GPIO 0V OK — clear LOW

Wait — recheck the divider math from the plan: Plan says "3.3 kΩ top / 2.2 kΩ to GND" → at 5V bus: Vout = 5V × 2200/(3300+2200) = 5V × 0.4 = 2.0V.

This is below the commonly cited ~2.5V "safe" threshold for Pi GPIO high. However:

  • BCM2835/2837 GPIO Vih (input HIGH threshold) is documented as ~1.8V (from various datasheet excerpts; Broadcom does not publish a full GPIO datasheet).
  • 2.0V is above 1.8V, so the signal should be read as HIGH reliably.
  • There is no hysteresis spec published for BCM2710 GPIO; this is a risk.

Recommendation: Adjust the divider to give a higher voltage at idle. Use 1.5 kΩ top / 2.2 kΩ bottom → Vout = 5 × 2.2/3.7 = 2.97V at idle. This is closer to 3.3V (below the 3.3V absolute max) and gives better noise margin.

Alternatively, use the divider from the research doc (3.3 kΩ / 1.8 kΩ): Vout = 5 × 1.8/5.1 = 1.76V — this is actually below the 1.8V Vih minimum! The 3.3kΩ/1.8kΩ divider from the research doc is potentially marginal.

Safer divider options:

  • 1kΩ top / 2kΩ bottom: Vout = 5 × 2/3 = 3.33V — slightly over 3.3V absolute max! Don't use.
  • 1.5kΩ top / 2.2kΩ bottom: Vout = 2.97V — safe and good margin.
  • 2.2kΩ top / 3.3kΩ bottom: Vout = 5 × 3.3/5.5 = 3.0V — very safe.

Recommended divider: 2.2 kΩ top / 3.3 kΩ bottom → 3.0 V at bus idle.

7406 output to Pi DATA (GPIO 18) path — no concern: The 7406 output is not connected back to any Pi input pin. GPIO 18 is an output only; it drives the 7406 input (3.3V logic compatible). No voltage risk.

DATA line: Pi GPIO 18 reading during bit sampling: During bit transfer the talker (C64) drives DATA; as the sole listener we have released DATA (Pi GPIO 18 HIGH = 7406 output LOW = bus floating). But we need to read the DATA line state that the C64's 7406 is setting.

The plan's pin map shows only GPIO 18 for DATA, which is the output to the 7406. There is no separate DATA-IN pin listed. This means the plan relies on GPIO 18 also sensing the bus state (reading back through the 7406).

Concern: When Pi drives GPIO 18 HIGH (7406 output LOW = bus asserted), reading GPIO 18 reads the Pi's own output — correct. When Pi drives GPIO 18 LOW (7406 output Hi-Z = bus floating), reading GPIO 18 reads 0 (the Pi's own LOW output), not the bus state being driven by the C64.

This is a wiring design gap. Either: a) Add a separate resistor-divider input on a different GPIO to read the DATA bus line (like ninepin's 5-wire mode: separate DATA_IN and DATA_OUT pins) b) Use a bidirectional scheme where we read the output back, accepting the 7406 inversion correctly — only valid if GPIO 18 is set to INPUT during bit sampling and OUTPUT during DATA driving.

Looking at the plan again: The plan comment says "GPIO 18 = DATA (in)" and "output → 7406 → bus". The 7406 has the property that when the Pi sets GPIO 18 LOW, the 7406 output is Hi-Z (floating, bus pulled to 5V). At that point, if the C64 then pulls DATA low via its own 7406, the bus goes to 0V. The Pi GPIO 18 (which is LOW) will see 0V on the bus through the 7406 input pin — but the Pi's OUTPUT register holds the pin state, not a bus readback.

Safest solution: Use a separate GPIO for DATA sensing (e.g., GPIO 27) connected via a 2.2 kΩ/3.3 kΩ divider directly to the IEC DATA bus line. Use GPIO 18 exclusively for DATA output (7406 drive). This matches raspbiec's hardware design (separate IEC_DATA_IN and IEC_DATA_OUT GPIOs).

Summary of electrical concerns:

  1. Divider values in plan (3.3kΩ/2.2kΩ or 3.3kΩ/1.8kΩ) give marginal voltages; use 2.2kΩ/3.3kΩ for a clean 3.0V at bus idle.
  2. DATA sensing needs a dedicated input GPIO separate from the DATA output GPIO to correctly read the C64-driven DATA state during bit sampling.
  3. Everything else (7406 drive, bypass cap, common GND) is correct.

Priority 5 — Build / Deploy / Signing Workflow

5.1 Building an out-of-tree module on 64-bit RPi OS Bookworm

Standard Makefile pattern:

MODULE_NAME := iec_listener
obj-m       += $(MODULE_NAME).o

KDIR := /lib/modules/$(shell uname -r)/build
PWD  := $(shell pwd)

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

install:
	$(MAKE) -C $(KDIR) M=$(PWD) modules_install
	depmod -a

Getting kernel headers on Bookworm 64-bit:

Option A — APT package (preferred):

sudo apt update
sudo apt install raspberrypi-kernel-headers
# Headers land in /usr/src/linux-headers-$(uname -r)/
# Symlinked from /lib/modules/$(uname -r)/build → /usr/src/linux-headers-...

Note: Install raspberrypi-kernel-headers, not linux-headers-aarch64 (the Debian-generic package, which installs a different kernel's headers and will not match the RPi-specific kernel you're running).

Option B — rpi-source (when APT headers are mismatched or unavailable):

sudo apt install git bc bison flex libssl-dev libelf-dev
wget https://raw.githubusercontent.com/RPi-Distro/rpi-source/master/rpi-source
sudo install -m 755 rpi-source /usr/local/bin/
rpi-source   # downloads and prepares headers matching running kernel

Known Bookworm issue: DKMS modules should be installed with sudo apt install --no-install-recommends dkms to avoid pulling in the generic Debian kernel/headers that conflict with the RPi-specific ones.

5.2 Kernel version pinning

To avoid ABI churn during apt upgrade:

# Hold the kernel and headers at a specific version:
sudo apt-mark hold raspberrypi-kernel raspberrypi-kernel-headers raspberrypi-bootloader

Document the pinned version in kernel-notes.md:

# Pinned: raspberrypi-kernel 1:6.6.31-1+rpt2 (2024-xx-xx)
# Rebuild required if unhold and upgrade.

For DKMS (automated rebuild on kernel update):

# Create DKMS config dkms.conf in the module source directory:
PACKAGE_NAME="iec_listener"
PACKAGE_VERSION="0.1"
CLEAN="make clean"
MAKE[0]="make"
BUILT_MODULE_NAME[0]="iec_listener"
DEST_MODULE_LOCATION[0]="/kernel/drivers/misc"
AUTOINSTALL="yes"

sudo dkms add .
sudo dkms build iec_listener/0.1
sudo dkms install iec_listener/0.1

5.3 Module signing and Secure Boot

Raspberry Pi OS (Bookworm, stock) does NOT require module signing.

Key facts:

  • Raspberry Pi does not use UEFI Secure Boot by default. Secure boot on RPi requires the RPi Secure Boot Provisioner and explicit OTP key burning — it is opt-in, not the default.
  • Stock RPi OS Bookworm kernel does not set CONFIG_MODULE_SIG_FORCE=y.
  • insmod ./iec_listener.ko works without signing on a default installation.
  • If secure boot were enabled (e.g., for production), kernel modules require signing with a MOK (Machine Owner Key) and kmodsign. This is out of scope for the PoC.

Module lockdown: The lockdown=integrity or lockdown=confidentiality cmdline options, or CONFIG_SECURITY_LOCKDOWN_LSM=y, would prevent unsigned module loading. These are not set in stock RPi OS.

Verification:

# Check if module signature enforcement is active:
cat /proc/sys/kernel/modules_disabled   # 0 = no lockdown
# Or:
dmesg | grep -i lockdown   # should be empty

5.4 Clean load/unload lifecycle

Critical requirements for rmmod:

static void __exit iec_exit(void)
{
    /* 1. Disable ATN and CLK interrupts first */
    free_irq(irq_atn, NULL);
    disable_irq(irq_clk);
    free_irq(irq_clk, NULL);

    /* 2. RELEASE DATA LINE — MOST IMPORTANT */
    /* Pi GPIO LOW → 7406 Hi-Z → IEC DATA floats (released) */
    gpiod_set_value(gd_data_out, 0);   /* or: direct register write to CLR */
    /* Do NOT leave DATA asserted — it would hang any C64 try to send */

    /* 3. Unmap direct GPIO registers */
    iounmap(gpio_regs);

    /* 4. Release GPIO descriptors */
    gpiod_put(gd_atn);
    gpiod_put(gd_clk);
    gpiod_put(gd_reset);
    gpiod_put(gd_data_out);

    /* 5. Destroy character device */
    device_destroy(iec_class, iec_devno);
    class_destroy(iec_class);
    cdev_del(&iec_cdev);
    unregister_chrdev_region(iec_devno, 1);
}

The single most important action on unload is releasing DATA (step 2). If DATA is left asserted (bus held low), the C64 will see the bus stuck and will hang on the next IEC operation.


Open Questions / Gaps

  1. Direct register GPIO read vs. gpiod_get_value() in hardirq context on 6.6.x kernel: The benchmark data (vovkos/rpi-gpio-test) is from Pi 2 (BCM2836), not Pi Zero 2 W (BCM2710A1). The ratio should be similar but should be verified on the target hardware with a kernel-module toggle test.

  2. Pi Zero 2 W BCM2710A1 GPIO Vih spec: Broadcom does not publish the full BCM2710 GPIO electrical spec. The 1.8V threshold is cited in community resources but not confirmed against a Broadcom datasheet. The corrected divider (2.2kΩ/3.3kΩ → 3.0V) makes this moot by providing ample margin.

  3. ninepin's c64slowdown auto-calibration mechanism: The source analysis confirms this exists but the exact calibration algorithm was not fully extracted. The starting value of 10 µs is safe to use without calibration for Phase 1 and 2.

  4. raspbiec isolation behavior: The raspbiec README explicitly states it does not document isolcpus/IRQ affinity. The driver relies entirely on the IRQF_ONESHOT + IRQ-off approach. No evidence found that isolcpus is required for standard IEC timing — only that it is an option for further hardening.

  5. DATA sensing pin gap in the plan: The plan's §3 hardware table needs to add a separate GPIO input for reading the IEC DATA bus state. This is a physical wiring design decision that must be made before Phase 1 hardware bring-up.

  6. Pi Zero 2 W vs. Pi 1 BCM peripheral base address in ninepin: ninepin's gpio.h hardcodes 0x20000000 (Pi 1 / BCM2708). The Pi Zero 2 W uses 0x3F000000. This change is mandatory when adapting ninepin's GPIO macros.

  7. devm_gpiod_get() availability without platform device: The devm_ variant requires a struct device *. For a simple char-driver LKM without a platform device, gpio_to_desc() is used instead. Alternatively, register a minimal platform device. This is a code-level decision for Phase 1.


Decisions Table (Phase-0.5 Deferred Items from §4 of the Plan)

Deferred Item Decision Evidence/Rationale
Interrupt-driven vs. fully busy-polled CLK CLK-edge ISR → busy-poll inside ISR with local_irq_save() (ninepin pattern) Both ninepin and raspbiec use this hybrid; pure polling burns CPU and misses ATN
Threaded IRQ vs. hardirq vs. spin-with-IRQs-off Hardirq for ATN + CLK; local_irq_save() inside receive_byte(); threaded handler for kfifo push Threaded IRQ adds 1050 µs latency, unacceptable for 20 µs bit window
Exact udelay/timeout constants Poll-with-timeout for CLK transitions (150 µs per-transition timeout); udelay(80) for EOI ack; udelay(250) for EOI detect window sd2iec empirical + protocol spec cross-referenced; see §1.7 table
How long safe to keep IRQs off on BCM2710A1 ~200 µs per byte normal; max 8 ms (abort case). Safe on stock kernel. Measured benchmarks show 100500 µs preemption-disabled windows are common [HowTech]
isolcpus / IRQ steering — mandatory? No, optional. Add isolcpus=3 nohz_full=3 irqaffinity=0-2 only if bit error rate > 1% in Phase 2 Neither ninepin nor raspbiec documents this as mandatory
PREEMPT_RT vs. stock kernel Stock kernel sufficient. PREEMPT_RT is last-resort fallback. IRQ-off per-byte provides the determinism we need; RT reduces ATN response worst-case from 300 to 90 µs but ATN has 1 ms budget
GPIO toggle latency — gpiod vs. direct register Direct BCM register (ioremap) for hot path; gpiod for init/exit. 3040× faster (41 MHz vs 1.3 MHz write-only); gpiod would consume half the 20 µs bit window
Char device vs. relayfs Char device /dev/iec0 with kfifo + wait_queue. IEC rate ≤ 1000 bytes/sec; relayfs complexity not justified
Build against kernel headers apt install raspberrypi-kernel-headers; use standard out-of-tree Makefile Works on Bookworm 64-bit; rpi-source as fallback if APT headers mismatch
Module signing / secure boot Not required. Stock RPi OS does not enforce module signing. Secure boot is opt-in, off by default; no lockdown setting
dtoverlay for GPIO pins Write iec-overlay.dts with brcm,pull=2 for inputs; compile with dtc; load via /boot/config.txt Bootlin blog procedure; Bookworm-compatible DTS syntax

Starting Constants Table (for iec_listener.c first pass)

/* iec_timing.h — starting constants for iec_listener.c Phase 1/2 */

/* Per-CLK-transition busy-poll timeout */
#define IEC_CLK_TIMEOUT_US   1000   /* 1 ms per CLK hi/lo transition */

/* EOI detection: poll CLK after DATA released; if no CLK assert within this,
 * it's EOI. Spec = 200 µs; sd2iec uses 256 µs. We use 250 µs. */
#define IEC_EOI_DETECT_US    250

/* EOI acknowledge hold (pull DATA low for this long). Spec >= 60 µs;
 * sd2iec uses 73 µs (instruction-calibrated for AVR). Pi A53 runs faster;
 * use 80 µs as margin. */
#define IEC_EOI_ACK_HOLD_US  80

/* ATN response: this is implemented in the ATN ISR itself (GPIO write);
 * target < 20 µs from IRQ delivery. No udelay needed — it's just a
 * GPIO register write. */
/* #define IEC_ATN_RESP_US   <ISR latency, typically 5-20 µs> */

/* Byte acknowledge (Tf): pull DATA within 1 ms after 8th bit.
 * We do this immediately after the 8th bit in the ISR-off loop. */
#define IEC_BYTE_ACK_MAX_US  1000

/* Debounce between bus reads (mirrors sd2iec / 1571 ROM): */
#define IEC_DEBOUNCE_US      2

/* c64slowdown base (ninepin-derived): used as udelay unit in manual
 * timing loops. 10 µs on Pi, subject to empirical tuning. */
#define IEC_SLOWDOWN_BASE    10

Annotated Reference List

[A] FozzTexx/ninepiniec/iec.c, iec/gpio.h https://github.com/FozzTexx/ninepin Source files read: iec/iec.c (ISR structure, bit loop, local_irq_save(), timing), iec/gpio.h (BCM register macros), iec/iec.h (iec_data struct). Most directly applicable: ISR pattern, timing constants, GPIO register approach. Primary source — read source code.

[B] Flogistoni/raspbiecraspbiecdrv.c https://github.com/Flogistoni/raspbiec Source files read: raspbiecdrv.c (full kernel driver ~1200 lines: GPIO array, IRQ setup, kfifo interface, 19-state machine, bit loop, udelay constants). README.md (architecture overview, no isolcpus detail). Primary source — read source code.

[C] rkrajnc/sd2iecsrc/iec.c https://github.com/rkrajnc/sd2iec/blob/master/src/iec.c Source file read via browser: confirmed delay_us(73) for EOI ack, delay_us(2) debounce, start_timeout(256) for EOI detection, IEC_ATN_HANDLER ISR pattern. Primary source — read source code.

[D] dhansel/IECDevice https://github.com/dhansel/IECDevice Architecture overview read. ATN hardware-assist circuit noted. IRQ-off for 20 ms confirmed for fast-loaders (scope context). Not read at source level.

[E] Raspberry Pi kernel docs — GPIO driver API https://docs.kernel.org/driver-api/gpio/driver.html https://docs.kernel.org/driver-api/gpio/index.html (accessed 2026-06-18)

[F] Linux kernel delay/sleep functions https://docs.kernel.org/timers/delay_sleep_functions.html udelay() accuracy, early-return conditions, atomic context usage. (accessed 2026-06-18)

[G] Linux kernel CPU isolation https://docs.kernel.org/admin-guide/cpu-isolation.html isolcpus, nohz_full, irqaffinity parameters and interactions. (accessed 2026-06-18)

[H] Linux kernel lock types https://docs.kernel.org/locking/locktypes.html local_irq_disable() behavior on PREEMPT_RT vs stock kernel. (accessed 2026-06-18)

[I] Linux kernel relay interface https://docs.kernel.org/filesystems/relay.html When to use vs character device. (accessed 2026-06-18)

[J] LeMaRiva — RPi 4B PREEMPT_RT cyclictest results https://lemariva.com/blog/2019/09/raspberry-pi-4b-preempt-rt-kernel-419y-performance-test Stock max 301 µs; RT max 93 µs (3.23× improvement on RPi 4B with 4.19.x kernel). (accessed 2026-06-18)

[K] dev.to — RPi 4B PREEMPT_RT 6.6.59 build procedure https://dev.to/behainguyen/raspberry-pi-4b-natively-build-a-64-bit-fully-preemptible-kernel-real-time-with-desktop-1afj Confirmed working build with kernel 6.6.59-rt45-v8. No cyclictest data. (accessed 2026-06-18)

[L] RPi Forums — PREEMPT_RT 6.1 64-bit https://forums.raspberrypi.com/viewtopic.php?t=344994 Confirmed that from kernel 6.12, RT is in mainline (no patch needed). (accessed 2026-06-18)

[M] HowTech substack — Analyzing Real-Time Latency on ARM Cortex-A53 https://howtech.substack.com/p/analyzing-real-time-latency-interrupt 5G base station (Cortex-A53): avg 2 µs, spikes to 150 µs; after tuning 12 µs. RPi 4 audio DSP: initial 800 µs, after GIC tuning 35 µs. RPi 4 tuned: 2050 µs; PREEMPT_RT: 1025 µs. 100500 µs preemption-disabled windows common on busy systems. (accessed 2026-06-18)

[N] vovkos/rpi-gpio-test — GPIO benchmark frequencies https://github.com/vovkos/rpi-gpio-test RPi 2 (BCM2836): write-only: kernel API 1.3 MHz, direct register 41 MHz (31×). Polling read-write: kernel API 370 kHz, direct register 2.7 MHz (7.3×). (accessed 2026-06-18)

[O] codeembedded.com — RPi GPIO kernel module with ioremap https://www.codeembedded.com/blog/raspberry_pi_gpio_driver/ ioremap-based GPIO: 6.25 MHz without delay, 458 kHz with udelay(1). BCM2835 peripheral base: 0xFE000000 (RPi 4); 0x3F000000 for RPi 3/Zero 2W. (accessed 2026-06-18)

[P] Bootlin — RPi Device Tree Overlays https://bootlin.com/blog/enabling-new-hardware-on-raspberry-pi-with-device-tree-overlays/ DTS syntax for GPIO pin reservation and pull config; dtc compile + deploy procedure. (accessed 2026-06-18)

[Q] RPi Forums — DKMS on Bookworm https://forums.raspberrypi.com/viewtopic.php?t=357549 apt install --no-install-recommends dkms to avoid generic Debian kernel. Headers: sudo apt install raspberrypi-kernel-headers. (accessed 2026-06-18)

[R] RPi Forums — Solved: Building a kernel module https://forums.raspberrypi.com/viewtopic.php?t=342312 Confirmed unsigned modules load on stock RPi OS; install raspberrypi-kernel-headers not linux-headers-aarch64. (accessed 2026-06-18)

[S] LWN — Moving interrupts to threads https://lwn.net/Articles/302043/ Threaded IRQ origins (PREEMPT_RT tree); appropriate use cases. (accessed 2026-06-18)

[T] gkaindl/linux-gpio-irq-latency-test https://github.com/gkaindl/linux-gpio-irq-latency-test Kernel module for measuring GPIO IRQ latency on embedded systems. (accessed 2026-06-18) — not read at source level; noted as measurement tool.

[U] embetronicx — GPIO Linux Device Driver (GPIO Interrupt) https://embetronicx.com/tutorials/linux/device-drivers/gpio-linux-device-driver-using-raspberry-pi/ Practical gpiod_to_irq() + request_irq() example for 6.x kernel. (accessed 2026-06-18)

[V] Embedded.com — gpiod descriptor API https://www.embedded.com/linux-device-driver-development-the-descriptor-based-gpio-interface/ devm_gpiod_get() pattern; note on 6.3+ deprecation of global GPIO numbers. (accessed 2026-06-18)