feat(kernel): add debounced (glitch-filtered) sense-line reads #5

Open
chris wants to merge 14 commits from debounced-reads into master
14 changed files with 343 additions and 70 deletions

View File

@ -142,6 +142,7 @@ jobs:
cp -r dist/modules "$root"/ # modules/iec_listener_<kernel_version>.ko cp -r dist/modules "$root"/ # modules/iec_listener_<kernel_version>.ko
cp kernel/selftest.sh "$root"/ # one-shot hardware self-test cp kernel/selftest.sh "$root"/ # one-shot hardware self-test
cp launch.sh "$root"/ # load module -> run frontend -> unload cp launch.sh "$root"/ # load module -> run frontend -> unload
chmod +x "$root/launch.sh" "$root/selftest.sh"
# iecpoc Python frontend + its packaging metadata. launch.sh pip-installs # iecpoc Python frontend + its packaging metadata. launch.sh pip-installs
# this on the Pi; the build reads pyproject.toml's readme = "README.md", # this on the Pi; the build reads pyproject.toml's readme = "README.md",
# so README.md must travel with it. __pycache__ is stripped to stay clean. # so README.md must travel with it. __pycache__ is stripped to stay clean.
@ -177,6 +178,7 @@ jobs:
- name: Zip release asset - name: Zip release asset
run: | run: |
repo="${{ github.event.repository.name }}" repo="${{ github.event.repository.name }}"
chmod +x "dist/$repo/launch.sh" "dist/$repo/selftest.sh"
(cd dist && zip -r "../${repo}-${{ github.ref_name }}.zip" "$repo") (cd dist && zip -r "../${repo}-${{ github.ref_name }}.zip" "$repo")
- name: Publish to Gitea release - name: Publish to Gitea release

View File

@ -94,10 +94,10 @@ the 1 ms ATN ack and the per-byte ack — both forgiving. The tight constraint i
| IEC signal | Pi GPIO (BCM) | Header pin | Direction | Logic note | | IEC signal | Pi GPIO (BCM) | Header pin | Direction | Logic note |
|------------|---------------|------------|-----------|------------| |------------|---------------|------------|-----------|------------|
| ATN | GPIO 2 | 3 | input (divider) | bus low = Pi low = asserted | | ATN | GPIO 4 | 7 | input (divider) | bus low = Pi low = asserted |
| CLK | GPIO 17 | 11 | input (divider) | bus low = Pi low = asserted | | CLK | GPIO 3 | 5 | input (divider) | bus low = Pi low = asserted |
| DATA (in) | GPIO 18 | 12 | output → 7406 → bus | Pi HIGH = bus asserted (inverted) | | DATA (in) | GPIO 2 | 3 | output → 7406 → bus | Pi HIGH = bus asserted (inverted) |
| RESET | GPIO 3 | 5 | input (divider) | bus low = Pi low = asserted | | RESET | GPIO 17 | 11 | input (divider) | bus low = Pi low = asserted |
| GND | — | 6 (or any) | — | tie to IEC pin 2 | | GND | — | 6 (or any) | — | tie to IEC pin 2 |
> Convention used in code: a helper layer converts electrical reads/writes into > Convention used in code: a helper layer converts electrical reads/writes into

View File

@ -15,6 +15,8 @@ the code in `kernel/` is built on.
| 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` | | 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` | | 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` | | `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` |
| DATA line control | All DATA assert/release in the worker + ATN ISR go through `iec_data_*_sync()` under `iec_data_lock`. The pin direction-flip is a read-modify-write of the shared `GPFSEL0`; without the lock the worker (one CPU) and the ATN ISR (another CPU on the quad-core Pi) could clobber each other's RMW. The release wrapper also **refuses to release while ATN is asserted**, so a ready-for-data release can never erase the ISR's presence acknowledge — the bug that caused intermittently missed LISTEN/UNLISTEN commands. init/exit/self-test keep the **raw** unconditional helpers | `iec_data_assert_sync`, `iec_data_release_sync`, `atn_isr` |
| 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) | | 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 | — | | PREEMPT_RT | Stock kernel sufficient; RT is a last resort | — |
| Module signing | Not required on stock RPi OS Bookworm | — | | Module signing | Not required on stock RPi OS Bookworm | — |
@ -58,11 +60,11 @@ at global number 0 — it's `gpiochip512`, **base 512**:
$ cat /sys/class/gpio/gpiochip*/base # -> 512 (pinctrl-bcm2835), 566 (exp-gpio) $ cat /sys/class/gpio/gpiochip*/base # -> 512 (pinctrl-bcm2835), 566 (exp-gpio)
``` ```
The old code used `gpio_to_desc(2/3/17/18)`, i.e. the *global* numberspace 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 assuming base 0. Those small numbers now fall outside the chip's range
(512565), so every `gpio_to_desc()` returned `NULL`, the descriptor check (512565), so every `gpio_to_desc()` returned `NULL`, the descriptor check
tripped, and init bailed with `-ENODEV`. The `(chip, hwnum)` lookup passes the tripped, and init bailed with `-ENODEV`. The `(chip, hwnum)` lookup passes the
**BCM number as the chip-relative offset** (ATN=2, RESET=3, CLK=17, DATA=18), **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. which is base-independent and survives kernel bumps / gpiochip renumbering.
Notes: Notes:
@ -222,8 +224,10 @@ The host needs qemu binfmt for arm64; the script registers it once via
## Starting timing constants ## Starting timing constants
See `kernel/iec_timing.h`. Tune in Phase 2 against a real C64 / logic analyser. 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) and The likely first knobs: `IEC_EOI_DETECT_US` (EOI false positives/negatives),
`IEC_CLK_TIMEOUT_US` (frame errors under load). `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 ## Open items to verify on hardware

View File

@ -25,20 +25,26 @@ The shifter is **non-inverting**: bus low (asserted) ⇒ Pi reads LOW.
| IEC signal | IEC DIN pin | Pi GPIO (BCM) | Header pin | Direction | | IEC signal | IEC DIN pin | Pi GPIO (BCM) | Header pin | Direction |
|------------|-------------|---------------|------------|-----------| |------------|-------------|---------------|------------|-----------|
| ATN | 3 | GPIO 2 | 3 | input (via shifter) | | ATN | 3 | GPIO 4 | 7 | input (via shifter) |
| CLK | 4 | GPIO 17 | 11 | input (via shifter) | | CLK | 4 | GPIO 3 | 5 | input (via shifter) |
| DATA | 5 | GPIO 18 | 12 | **bidirectional** (via shifter) | | DATA | 5 | GPIO 2 | 3 | **bidirectional** (via shifter) |
| RESET | 6 | GPIO 3 | 5 | input (via shifter) | | RESET | 6 | GPIO 17 | 11 | input (via shifter) |
| GND | 2 | GND | 6 (or any) | — | | GND | 2 | GND | 6 (or any) | — |
| SRQ | 1 | — | — | not connected | | SRQ | 1 | — | — | not connected |
> **DATA (GPIO2) and CLK (GPIO3) are the Pi's ARM I²C pins** and carry the
> SoC's fixed ~1.8 kΩ pull-ups (benign — DATA is open-drain). Keep
> `dtparam=i2c_arm` **off** in `/boot/firmware/config.txt` (the default) so I²C
> does not claim these pins. This layout matches a confirmed-working reference
> listener.
## DATA open-drain emulation ## DATA open-drain emulation
GPIO 18 is never a push-pull driver of the bus. The module flips its direction: GPIO 2 is never a push-pull driver of the bus. The module flips its direction:
- **assert** (pull bus low): GPIO 18 = output **LOW** → shifter pulls bus to 0 V. - **assert** (pull bus low): GPIO 2 = output **LOW** → shifter pulls bus to 0 V.
- **release / read**: GPIO 18 = **input (Hi-Z)** → bus pull-ups float it to 5 V, - **release / read**: GPIO 2 = **input (Hi-Z)** → bus pull-ups float it to 5 V,
and GPIO 18 reads the level the *talker* (C64) is driving — this is how bits and GPIO 2 reads the level the *talker* (C64) is driving — this is how bits
are sampled during receive. are sampled during receive.
Logical mapping (handled in `kernel/iec_lines.h`): asserted ⇔ Pi reads LOW. Logical mapping (handled in `kernel/iec_lines.h`): asserted ⇔ Pi reads LOW.

View File

@ -10,7 +10,7 @@ from __future__ import annotations
from typing import Iterable, Iterator from typing import Iterable, Iterator
from . import decode, device from . import decode, device, petscii
# bracketed state tag, padded so the content columns line up # bracketed state tag, padded so the content columns line up
_TAG_WIDTH = 8 _TAG_WIDTH = 8
@ -32,12 +32,19 @@ class TraceFormatter:
the symbolic annotations and state events. the symbolic annotations and state events.
""" """
def __init__(self, my_address: int = 4, raw: bool = False): def __init__(self, my_address: int = 4, raw: bool = False, debug: bool = False):
self.my_address = my_address self.my_address = my_address
self.raw = raw self.raw = raw
self.debug = debug
@property
def plain(self) -> bool:
return not self.debug and not self.raw
def format(self, rec: device.IecRecord) -> str | None: def format(self, rec: device.IecRecord) -> str | None:
"""Format one record. Returns ``None`` for records to be skipped.""" """Format one record. Returns ``None`` for records to be skipped."""
if self.plain:
return self._format_plain(rec)
if self.raw: if self.raw:
# raw mode: just the byte value of command/data records # raw mode: just the byte value of command/data records
if rec.is_command or rec.is_data: if rec.is_command or rec.is_data:
@ -60,6 +67,23 @@ class TraceFormatter:
yield line yield line
# -- per-kind formatters ------------------------------------------------- # -- per-kind formatters -------------------------------------------------
def _format_plain(self, rec: device.IecRecord) -> str | None:
if rec.is_event:
return None
if rec.is_command:
cmd = decode.decode_command(rec.value)
if cmd.mnemonic == decode.LISTEN:
return f"\n--- LISTEN {cmd.primary} ---\n"
if cmd.mnemonic == decode.UNLISTEN:
return "\n--- UNLISTEN ---\n"
return None
if rec.is_data:
if rec.value in (0x0D, 0x0A, 0x8D): # CR, LF, SHIFT-CR
return "\n"
g = petscii.to_glyph(rec.value)
return g.text if g.printable else None
return None
def _format_event(self, rec: device.IecRecord) -> str: def _format_event(self, rec: device.IecRecord) -> str:
text = decode.describe_event(rec) text = decode.describe_event(rec)
if rec.value == device.EV_IDLE: if rec.value == device.EV_IDLE:

View File

@ -33,6 +33,10 @@ def build_parser() -> argparse.ArgumentParser:
"--replay", metavar="FILE", "--replay", metavar="FILE",
help="replay a captured record file instead of opening the device", help="replay a captured record file instead of opening the device",
) )
p.add_argument(
"--debug", action="store_true",
help="print the full annotated trace instead of plain received text",
)
p.add_argument( p.add_argument(
"--raw", action="store_true", "--raw", action="store_true",
help="dump a bare hex stream instead of the annotated trace", help="dump a bare hex stream instead of the annotated trace",
@ -47,11 +51,12 @@ def build_parser() -> argparse.ArgumentParser:
def run(records: Iterable[device.IecRecord], fmt: TraceFormatter, def run(records: Iterable[device.IecRecord], fmt: TraceFormatter,
out: TextIO, logfile: TextIO | None = None) -> None: out: TextIO, logfile: TextIO | None = None) -> None:
"""Format ``records`` through ``fmt`` and write lines to ``out`` (+ logfile).""" """Format ``records`` through ``fmt`` and write lines to ``out`` (+ logfile)."""
line_end = "" if fmt.plain else "\n"
try: try:
for line in fmt.format_stream(records): for line in fmt.format_stream(records):
print(line, file=out, flush=True) print(line, end=line_end, file=out, flush=True)
if logfile is not None: if logfile is not None:
print(line, file=logfile, flush=True) print(line, end=line_end, file=logfile, flush=True)
except BrokenPipeError: except BrokenPipeError:
# downstream (e.g. `head`) closed the pipe; redirect stdout to devnull so # downstream (e.g. `head`) closed the pipe; redirect stdout to devnull so
# the interpreter's shutdown flush doesn't re-raise, then exit quietly. # the interpreter's shutdown flush doesn't re-raise, then exit quietly.
@ -61,7 +66,7 @@ def run(records: Iterable[device.IecRecord], fmt: TraceFormatter,
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
fmt = TraceFormatter(my_address=args.address, raw=args.raw) fmt = TraceFormatter(my_address=args.address, raw=args.raw, debug=args.debug)
logfile = open(args.logfile, "a") if args.logfile else None logfile = open(args.logfile, "a") if args.logfile else None
try: try:

View File

@ -43,6 +43,24 @@ actually capture and decode C64 traffic, use **`launch.sh`**, which installs the
frontend, loads the matching module, runs `iecpoc`, and unloads on exit — see the frontend, loads the matching module, runs `iecpoc`, and unloads on exit — see the
[top-level README](../README.md#running-the-frontend-on-the-pi). [top-level README](../README.md#running-the-frontend-on-the-pi).
### Frontend output modes
`iecpoc` has three output modes, selected by flags passed after `--` to `launch.sh`
(or directly on the command line when running `iecpoc` standalone):
| Mode | Flag | Output |
|---|---|---|
| Plain text (default) | *(none)* | Received characters only; LISTEN/UNLISTEN print a divider; CR/LF become real newlines |
| Debug / annotated trace | `--debug` | Full `[CMD]` / `[DATA]` / `[ATN]` / `[IDLE]` tag-per-record trace |
| Raw hex | `--raw` | Bare uppercase hex bytes of command and data records, no annotations |
```bash
sudo ./launch.sh # plain text — what the C64 is printing
sudo ./launch.sh -- --debug # full annotated trace
sudo ./launch.sh -- --raw # bare hex stream
sudo ./launch.sh -- --logfile trace.txt # also write output to a file
```
You don't pick the file yourself: the script reads `uname -r`, then scans You don't pick the file yourself: the script reads `uname -r`, then scans
`modules/` (and a few fallback locations) and selects the `.ko` whose **vermagic** `modules/` (and a few fallback locations) and selects the `.ko` whose **vermagic**
matches the running kernel. Matching is by vermagic rather than filename because matches the running kernel. Matching is by vermagic rather than filename because
@ -92,18 +110,21 @@ Constants are defined in [`iec_listener.h`](iec_listener.h); the ioctl number is
### ⚠️ Bare-board caveat ### ⚠️ Bare-board caveat
With **nothing connected**, the expected result is **`0x15`**, *not* a fault: With **nothing connected**, the expected result is **`0x0B`**, *not* a fault:
- `DATA_ASSERT_OK` (`0x01`) passes — it's internal to the Pi. - `DATA_ASSERT_OK` (`0x01`) passes — it's internal to the Pi.
- `ATN_RELEASED` (`0x04`) and `RESET_RELEASED` (`0x10`) pass **only because - `DATA_FLOAT_OK` (`0x02`) and `CLK_RELEASED` (`0x08`) pass **only because
GPIO2/GPIO3 have fixed ~1.8 kΩ pull-ups built into the BCM2710 SoC** (they are DATA=GPIO2 and CLK=GPIO3 have fixed ~1.8 kΩ pull-ups built into the BCM2710
the I²C0 pins; the pull-ups can't be disabled). They read high even with SoC** (they are the I²C pins; the pull-ups can't be disabled). They read high
nothing wired, so on a bare board these two bits prove **nothing** about your even with nothing wired, so on a bare board these two bits prove **nothing**
wiring. about your wiring. In particular `DATA_FLOAT_OK` now floats high via the SoC
- `DATA_FLOAT_OK` (`0x02`) and `CLK_RELEASED` (`0x08`) read low because GPIO17/18 pull-up even without the shifter, so it no longer confirms the shifter's DATA
have no such built-in pull-up and nothing is attached. pull-up — it's only meaningful once the shifter is wired.
- `ATN_RELEASED` (`0x04`) and `RESET_RELEASED` (`0x10`) read low because
ATN=GPIO4 and RESET=GPIO17 have no such built-in pull-up and nothing is
attached.
So on a bare board the only meaningful signal is `DATA_ASSERT_OK`. A full `0x1F` So on a bare board the only fully internal signal is `DATA_ASSERT_OK`. A full
is only reachable once the level shifter (with its CLK/DATA pull-ups) is wired `0x1F` is only reachable once the level shifter (with its ATN/RESET pull-ups) is
and powered. To make ATN/RESET meaningful, briefly ground each at the connector wired and powered. To make ATN/RESET meaningful, briefly ground each at the
and confirm the corresponding bit *drops*. connector and confirm the corresponding bit *drops*.

View File

@ -1,10 +1,15 @@
/* /*
* iec-overlay.dts - pin reservation + pull configuration for the IEC listener. * iec-overlay.dts - pin reservation + pull configuration for the IEC listener.
* *
* GPIO 2 = ATN (input, pull-up: bus idle = 5 V = high) * GPIO 4 = ATN (input, pull-up: bus idle = 5 V = high)
* GPIO 17 = CLK (input, pull-up) * GPIO 3 = CLK (input, pull-up)
* GPIO 3 = RESET (input, pull-up) * GPIO 17 = RESET (input, pull-up)
* GPIO 18 = DATA (bidirectional; starts as input/Hi-Z, the module flips it) * GPIO 2 = DATA (bidirectional; starts as input/Hi-Z, the module flips it)
*
* NOTE: DATA (GPIO2) and CLK (GPIO3) are the ARM I2C pins and carry the SoC's
* fixed ~1.8 kOhm pull-ups, which the overlay's pull settings cannot fully
* override. Benign here (DATA is open-drain). Keep dtparam=i2c_arm OFF so I2C
* does not claim these pins.
* *
* The module drives GPIO via direct registers, so this overlay's main job is to * The module drives GPIO via direct registers, so this overlay's main job is to
* reserve the pins and set sane pull defaults. Build & install: * reserve the pins and set sane pull defaults. Build & install:
@ -25,12 +30,12 @@
target = <&gpio>; target = <&gpio>;
__overlay__ { __overlay__ {
iec_input_pins: iec_input_pins { iec_input_pins: iec_input_pins {
brcm,pins = <2 17 3>; /* ATN, CLK, RESET */ brcm,pins = <4 3 17>; /* ATN, CLK, RESET */
brcm,function = <0>; /* 0 = input */ brcm,function = <0>; /* 0 = input */
brcm,pull = <2>; /* 2 = pull-up */ brcm,pull = <2>; /* 2 = pull-up */
}; };
iec_data_pin: iec_data_pin { iec_data_pin: iec_data_pin {
brcm,pins = <18>; /* DATA */ brcm,pins = <2>; /* DATA */
brcm,function = <0>; /* start as input (Hi-Z) */ brcm,function = <0>; /* start as input (Hi-Z) */
brcm,pull = <0>; /* 0 = none (shifter drives) */ brcm,pull = <0>; /* 0 = none (shifter drives) */
}; };

View File

@ -10,8 +10,8 @@
* bus released (5 V) <=> Pi GPIO reads HIGH * bus released (5 V) <=> Pi GPIO reads HIGH
* *
* DATA is open-drain emulated on a single bidirectional pin: * DATA is open-drain emulated on a single bidirectional pin:
* assert : drive GPIO18 OUTPUT LOW -> shifter pulls bus to 0 V * assert : drive GPIO2 OUTPUT LOW -> shifter pulls bus to 0 V
* release : set GPIO18 INPUT (Hi-Z) -> bus pull-ups float it to 5 V; * release : set GPIO2 INPUT (Hi-Z) -> bus pull-ups float it to 5 V;
* the talker's bits can now be read back through the shifter. * the talker's bits can now be read back through the shifter.
* *
* The hot path uses direct BCM register access (ioremap'd), which the kernel * The hot path uses direct BCM register access (ioremap'd), which the kernel
@ -30,10 +30,10 @@
#include <linux/io.h> #include <linux/io.h>
/* --- GPIO assignment (BCM numbering, PLAN.md §3.2) ----------------------- */ /* --- GPIO assignment (BCM numbering, PLAN.md §3.2) ----------------------- */
#define IEC_GPIO_ATN 2 /* header pin 3 - input */ #define IEC_GPIO_ATN 4 /* header pin 7 - input */
#define IEC_GPIO_RESET 3 /* header pin 5 - input */ #define IEC_GPIO_RESET 17 /* header pin 11 - input */
#define IEC_GPIO_CLK 17 /* header pin 11 - input */ #define IEC_GPIO_CLK 3 /* header pin 5 - input */
#define IEC_GPIO_DATA 18 /* header pin 12 - bidirectional (open-drain) */ #define IEC_GPIO_DATA 2 /* header pin 3 - bidirectional (open-drain) */
/* --- BCM2710A1 / BCM2837 (Pi Zero 2 W) peripheral map ------------------- */ /* --- BCM2710A1 / BCM2837 (Pi Zero 2 W) peripheral map ------------------- */
/* Same peripheral base as RPi 3. RPi 1 = 0x20000000, RPi 4 = 0xFE000000. */ /* Same peripheral base as RPi 3. RPi 1 = 0x20000000, RPi 4 = 0xFE000000. */

View File

@ -81,6 +81,97 @@ static DECLARE_WAIT_QUEUE_HEAD(iec_work_wq);
/* listener addressing state (decoded in-kernel, just enough to participate) */ /* listener addressing state (decoded in-kernel, just enough to participate) */
static bool addressed_listener; static bool addressed_listener;
/*
* --- debounced sense lines (glitch filter) -------------------------------
*
* Mirrors the 5 us glitch filter from a confirmed-working reference listener.
* The raw iec_*_asserted() helpers in iec_lines.h read
* the GPIO register once; on a level-shifted 5 V<->3.3 V bus a single sample
* can land on noise and yield a wrong bit, with no chance to retry since the
* bit loop runs with IRQs off. These wrappers only believe a level change once
* it has held for IEC_DEBOUNCE_US; a shorter pulse is rejected as a glitch and
* the previous stable level is kept.
*
* Fast path (no change since last read) is a single register read, so the tight
* CLK polls in wait_clk() stay cheap; the debounce cost is paid only when an
* edge actually appears. udelay() is safe with IRQs disabled.
*
* Concurrency: the entire IEC receive path runs single-threaded in the worker
* kthread, so the per-line stable state needs no locking. The ATN ISR must NOT
* use these (it needs an instantaneous read and must not touch this state) --
* it keeps using the raw iec_atn_asserted() from iec_lines.h.
*/
static int db_clk = 1; /* last stable level, 1 = released (high) = idle bus */
static int db_data = 1;
static int db_atn = 1;
static int db_reset = 1;
static int iec_read_stable(unsigned int pin, int *last_stable)
{
int raw = iec_gpio_read(pin);
unsigned int held;
if (raw == *last_stable)
return *last_stable; /* fast path: no change */
/* a transition appeared: require it to hold IEC_DEBOUNCE_US */
for (held = 0; held < IEC_DEBOUNCE_US; held++) {
udelay(1);
if (iec_gpio_read(pin) != raw)
return *last_stable; /* bounced back -> glitch, ignore */
}
*last_stable = raw; /* held steady -> accept */
return raw;
}
static inline bool db_clk_asserted(void) { return iec_read_stable(IEC_GPIO_CLK, &db_clk) == 0; }
static inline bool db_atn_asserted(void) { return iec_read_stable(IEC_GPIO_ATN, &db_atn) == 0; }
static inline bool db_reset_asserted(void) { return iec_read_stable(IEC_GPIO_RESET, &db_reset) == 0; }
static inline bool db_data_asserted(void) { return iec_read_stable(IEC_GPIO_DATA, &db_data) == 0; }
/*
* --- synchronized DATA line control --------------------------------------
*
* DATA is open-drain-emulated by flipping GPIO2's direction (iec_lines.h), and
* that flip is a read-modify-write of the shared GPFSEL0 register. Two contexts
* change DATA: the worker kthread (handshake) and the ATN falling-edge ISR
* (presence acknowledge). On the quad-core Pi these can run on different CPUs at
* the same time, so their RMWs can clobber each other -- e.g. the worker's RFD
* release erasing the ISR's ATN ack, which makes the C64 see "device not
* present" and drop the LISTEN/UNLISTEN command (the missed-command bug).
*
* iec_data_lock serializes every DATA change so the RMW is never split. In
* addition, the release wrapper refuses to release while ATN is asserted: when
* ATN is low the device must keep DATA low as the acknowledge, so a release the
* worker was about to do for ready-for-data must not undo a just-arrived ack.
* iec_atn_asserted() is the raw instantaneous read (matching the ISR's view);
* the debounced db_atn_asserted() must NOT be used here.
*
* Only the worker/ISR hot path uses these. init/exit/selftest keep the raw
* helpers: exit() must release the bus unconditionally, and selftest needs the
* unconditional behaviour to probe the line.
*/
static DEFINE_SPINLOCK(iec_data_lock);
static void iec_data_assert_sync(void)
{
unsigned long f;
spin_lock_irqsave(&iec_data_lock, f);
iec_data_assert();
spin_unlock_irqrestore(&iec_data_lock, f);
}
static void iec_data_release_sync(void)
{
unsigned long f;
spin_lock_irqsave(&iec_data_lock, f);
if (!iec_atn_asserted())
iec_data_release();
spin_unlock_irqrestore(&iec_data_lock, f);
}
/* receive_byte() outcomes */ /* receive_byte() outcomes */
enum iec_rx { enum iec_rx {
IEC_RX_OK = 0, /* byte received */ IEC_RX_OK = 0, /* byte received */
@ -126,10 +217,10 @@ static enum iec_rx wait_clk(bool want_asserted, unsigned int timeout_us,
{ {
unsigned int waited = 0; unsigned int waited = 0;
while (iec_clk_asserted() != want_asserted) { while (db_clk_asserted() != want_asserted) {
if (iec_atn_asserted() == data_phase) if (db_atn_asserted() == data_phase)
return IEC_RX_ATN; return IEC_RX_ATN;
if (iec_reset_asserted()) if (db_reset_asserted())
return IEC_RX_RESET; return IEC_RX_RESET;
if (waited++ >= timeout_us) if (waited++ >= timeout_us)
return IEC_RX_TIMEOUT; return IEC_RX_TIMEOUT;
@ -156,7 +247,7 @@ static enum iec_rx receive_byte(u8 *out, bool *eoi, bool data_phase)
rc = wait_clk(false /* released */, IEC_CLK_TIMEOUT_US, data_phase); rc = wait_clk(false /* released */, IEC_CLK_TIMEOUT_US, data_phase);
if (rc != IEC_RX_OK) if (rc != IEC_RX_OK)
return rc; return rc;
iec_data_release(); iec_data_release_sync();
local_irq_save(flags); /* IRQs off for the duration of the byte */ local_irq_save(flags); /* IRQs off for the duration of the byte */
@ -164,14 +255,14 @@ static enum iec_rx receive_byte(u8 *out, bool *eoi, bool data_phase)
if (data_phase) { if (data_phase) {
unsigned int waited = 0; unsigned int waited = 0;
while (!iec_clk_asserted()) { while (!db_clk_asserted()) {
if (iec_atn_asserted()) { rc = IEC_RX_ATN; goto out; } if (db_atn_asserted()) { rc = IEC_RX_ATN; goto out; }
if (iec_reset_asserted()) { rc = IEC_RX_RESET; goto out; } if (db_reset_asserted()) { rc = IEC_RX_RESET; goto out; }
if (waited++ >= IEC_EOI_DETECT_US) { if (waited++ >= IEC_EOI_DETECT_US) {
/* ack EOI: pull DATA low for Tei, then release */ /* ack EOI: pull DATA low for Tei, then release */
iec_data_assert(); iec_data_assert_sync();
udelay(IEC_EOI_ACK_HOLD_US); udelay(IEC_EOI_ACK_HOLD_US);
iec_data_release(); iec_data_release_sync();
*eoi = true; *eoi = true;
break; break;
} }
@ -190,12 +281,21 @@ static enum iec_rx receive_byte(u8 *out, bool *eoi, bool data_phase)
if (rc != IEC_RX_OK) if (rc != IEC_RX_OK)
goto out; goto out;
/* released(high) = bit 1, asserted(low) = bit 0 */ /* released(high) = bit 1, asserted(low) = bit 0 */
if (!iec_data_asserted()) if (!db_data_asserted())
value |= (1u << i); value |= (1u << i);
} }
/* 4. BYTE ACKNOWLEDGE: pull DATA low (Tf), hold for the next RFD */ /* 4. BYTE ACKNOWLEDGE: wait for the talker to pull CLK low at end of
iec_data_assert(); * byte (mirrors the reference listener), THEN pull DATA low (Tf).
* Without this wait we would assert the ack while CLK is still high
* from bit 7, racing the talker's end-of-byte edge and desyncing the
* handshake. The next receive_byte() releases DATA after CLK goes high
* again (RFD), completing the per-byte handshake. */
rc = wait_clk(true /* asserted: talker finished the byte */,
IEC_CLK_TIMEOUT_US, data_phase);
if (rc != IEC_RX_OK)
goto out;
iec_data_assert_sync();
*out = value; *out = value;
rc = IEC_RX_OK; rc = IEC_RX_OK;
@ -229,10 +329,14 @@ static void run_state_machine(void)
bool eoi; bool eoi;
enum iec_rx rc; enum iec_rx rc;
if (iec_reset_asserted()) if (kthread_should_stop()) {
iec_data_release_sync();
return;
}
if (db_reset_asserted())
goto reset; goto reset;
if (iec_atn_asserted()) { if (db_atn_asserted()) {
/* RECEIVE_COMMAND: ATN held low */ /* RECEIVE_COMMAND: ATN held low */
rc = receive_byte(&b, &eoi, false); rc = receive_byte(&b, &eoi, false);
if (rc == IEC_RX_RESET) if (rc == IEC_RX_RESET)
@ -249,16 +353,20 @@ static void run_state_machine(void)
/* ATN released */ /* ATN released */
if (!addressed_listener) { if (!addressed_listener) {
emit_record(IEC_KIND_EVENT, IEC_EV_ATN_RELEASED, 0); emit_record(IEC_KIND_EVENT, IEC_EV_ATN_RELEASED, 0);
iec_data_release(); iec_data_release_sync();
return; /* not our business -> IDLE */ return; /* not our business -> IDLE */
} }
/* LISTENER: data phase */ /* LISTENER: data phase */
emit_record(IEC_KIND_EVENT, IEC_EV_ATN_RELEASED, IEC_FLAG_ADDRESSED); emit_record(IEC_KIND_EVENT, IEC_EV_ATN_RELEASED, IEC_FLAG_ADDRESSED);
for (;;) { for (;;) {
if (iec_reset_asserted()) if (kthread_should_stop()) {
iec_data_release_sync();
return;
}
if (db_reset_asserted())
goto reset; goto reset;
if (iec_atn_asserted()) { if (db_atn_asserted()) {
/* C64 interrupts with a new command */ /* C64 interrupts with a new command */
emit_record(IEC_KIND_EVENT, IEC_EV_ATN_COMMAND, 0); emit_record(IEC_KIND_EVENT, IEC_EV_ATN_COMMAND, 0);
break; break;
@ -279,7 +387,7 @@ static void run_state_machine(void)
reset: reset:
addressed_listener = false; addressed_listener = false;
iec_data_release(); iec_data_release_sync();
emit_record(IEC_KIND_EVENT, IEC_EV_RESET, 0); emit_record(IEC_KIND_EVENT, IEC_EV_RESET, 0);
} }
@ -304,7 +412,7 @@ static int iec_worker(void *unused)
static irqreturn_t atn_isr(int irq, void *dev) static irqreturn_t atn_isr(int irq, void *dev)
{ {
if (iec_atn_asserted()) { if (iec_atn_asserted()) {
iec_data_assert(); /* pull DATA low immediately (ack) */ iec_data_assert_sync(); /* pull DATA low immediately (ack) */
atomic_set(&iec_atn_pending, 1); atomic_set(&iec_atn_pending, 1);
wake_up_interruptible(&iec_work_wq); wake_up_interruptible(&iec_work_wq);
} }

View File

@ -25,8 +25,12 @@
* 80 us gives margin. */ * 80 us gives margin. */
#define IEC_EOI_ACK_HOLD_US 80 #define IEC_EOI_ACK_HOLD_US 80
/* Debounce between consecutive bus reads (mirrors sd2iec / 1571 ROM). */ /* Glitch filter window for sense-line reads. A level change is only believed
#define IEC_DEBOUNCE_US 2 * once it has held this long; a shorter pulse is treated as noise. Matches a
* confirmed-working reference listener (5 us). sd2iec / the 1571 ROM
* use a comparable few-microsecond debounce. Far shorter than a real IEC bit
* (tens of us), so it never smears valid data. */
#define IEC_DEBOUNCE_US 5
/* Between-bytes minimum hold before the talker releases CLK for the next byte /* Between-bytes minimum hold before the talker releases CLK for the next byte
* (Tbb). We simply wait for the next CLK transition; this is documentary. */ * (Tbb). We simply wait for the next CLK transition; this is documentary. */

View File

@ -189,8 +189,9 @@ if [ "$ST" -eq 0 ]; then
ok "self-test PASSED (0x1F) - all four lines wired correctly" ok "self-test PASSED (0x1F) - all four lines wired correctly"
else else
info "self-test did not fully pass." info "self-test did not fully pass."
info "On a BARE board 0x15 is expected and fine: only DATA_ASSERT_OK is" info "On a BARE board 0x0b is expected and fine: DATA_FLOAT_OK + CLK_RELEASED"
info "meaningful; ATN/RESET pass via the SoC's fixed GPIO2/3 pull-ups." info "pass via the SoC's fixed GPIO2/3 (DATA/CLK) pull-ups, and DATA_ASSERT_OK"
info "is internal; ATN/RESET read low (GPIO4/17 have no built-in pull-up)."
info "Re-run once the level shifter is wired + powered; you want 0x1F." info "Re-run once the level shifter is wired + powered; you want 0x1F."
fi fi
exit "$ST" exit "$ST"

View File

@ -19,11 +19,13 @@
# sudo ./launch.sh [--address N] [--device PATH] [--ko FILE] [-- <iecpoc args>] # sudo ./launch.sh [--address N] [--device PATH] [--ko FILE] [-- <iecpoc args>]
# #
# Examples: # Examples:
# sudo ./launch.sh # address 4, /dev/iec0, annotated trace # sudo ./launch.sh # address 4, /dev/iec0, plain text (default)
# sudo ./launch.sh --address 8 # listen as device 8 # sudo ./launch.sh --address 8 # listen as device 8
# sudo ./launch.sh -- --debug # full annotated trace (verbose)
# sudo ./launch.sh -- --raw # forward --raw to iecpoc # sudo ./launch.sh -- --raw # forward --raw to iecpoc
# sudo ./launch.sh --ko modules/iec_listener_1-6.12.93-1+rpt1.ko # sudo ./launch.sh --ko modules/iec_listener_1-6.12.93-1+rpt1.ko
# #
# Any flag after -- is forwarded verbatim to iecpoc (--debug, --raw, --logfile FILE, …).
# Defaults: address=4 (printer), device=/dev/iec0. # Defaults: address=4 (printer), device=/dev/iec0.
set -euo pipefail set -euo pipefail

View File

@ -16,7 +16,11 @@ from . import fixtures
def _trace(records, my_address=4, raw=False): def _trace(records, my_address=4, raw=False):
return list(TraceFormatter(my_address=my_address, raw=raw).format_stream(records)) return list(TraceFormatter(my_address=my_address, raw=raw, debug=True).format_stream(records))
def _plain(records, my_address=4):
return list(TraceFormatter(my_address=my_address).format_stream(records))
# --- wire-format round trip ------------------------------------------------- # --- wire-format round trip -------------------------------------------------
@ -101,3 +105,90 @@ def test_raw_mode_is_hex_only():
assert trace[0] == "24" assert trace[0] == "24"
# data bytes present as hex # data bytes present as hex
assert "48" in trace # 'H' assert "48" in trace # 'H'
# --- plain mode -------------------------------------------------------------
def _make_record(kind, value, flags=0):
from iecpoc.device import IecRecord
return IecRecord(kind, value, flags, 0)
def test_plain_property():
assert TraceFormatter(debug=False).plain is True
assert TraceFormatter(debug=True).plain is False
assert TraceFormatter(raw=True).plain is False
def test_plain_listen_divider():
rec = _make_record(device.KIND_COMMAND, 0x24, device.FLAG_ADDRESSED) # LISTEN 4
out = _plain([rec])
assert len(out) == 1
assert "LISTEN 4" in out[0]
def test_plain_unlisten_divider():
rec = _make_record(device.KIND_COMMAND, 0x3F) # UNLISTEN
out = _plain([rec])
assert len(out) == 1
assert "UNLISTEN" in out[0]
def test_plain_data_printable():
rec = _make_record(device.KIND_DATA, 0x48, device.FLAG_ADDRESSED) # 'H'
assert _plain([rec]) == ["H"]
def test_plain_data_cr_yields_newline():
rec = _make_record(device.KIND_DATA, 0x0D, device.FLAG_ADDRESSED)
assert _plain([rec]) == ["\n"]
def test_plain_data_lf_yields_newline():
rec = _make_record(device.KIND_DATA, 0x0A, device.FLAG_ADDRESSED)
assert _plain([rec]) == ["\n"]
def test_plain_data_shift_cr_yields_newline():
rec = _make_record(device.KIND_DATA, 0x8D, device.FLAG_ADDRESSED)
assert _plain([rec]) == ["\n"]
def test_plain_events_are_silent():
recs = [
_make_record(device.KIND_EVENT, device.EV_IDLE),
_make_record(device.KIND_EVENT, device.EV_ATN_ASSERTED),
_make_record(device.KIND_EVENT, device.EV_ATN_RELEASED),
]
assert _plain(recs) == []
def test_plain_non_listen_commands_silent():
recs = [
_make_record(device.KIND_COMMAND, 0xF0), # OPEN SA=0
_make_record(device.KIND_COMMAND, 0xE0), # CLOSE SA=0
_make_record(device.KIND_COMMAND, 0x60), # SECOND/DATA SA=0
_make_record(device.KIND_COMMAND, 0x48), # TALK 8
_make_record(device.KIND_COMMAND, 0x5F), # UNTALK
]
assert _plain(recs) == []
def test_plain_graphic_bytes_silent():
rec = _make_record(device.KIND_DATA, 0x70, device.FLAG_ADDRESSED) # graphic range
assert _plain([rec]) == []
def test_plain_control_bytes_silent():
rec = _make_record(device.KIND_DATA, 0x12, device.FLAG_ADDRESSED) # RVS-ON
assert _plain([rec]) == []
def test_plain_full_session():
out = _plain(fixtures.build_session())
joined = "".join(out)
assert "HELLO WORLD" in joined
assert "LINE TWO" in joined
assert "LISTEN 4" in joined
assert "UNLISTEN" in joined
# each PRINT# record ends with CR → two newlines from data, plus divider newlines
assert joined.count("\n") >= 2