evidence · 18.7 KiB

ch342-open-garbage.md

Committed at docs/evidence/ch342-open-garbage.md. This is what grounds the requirements that cite it.

Open raw source

The garbage burst when we open /dev/ttyACM0

Investigated 2026-09-21 on solomon (Manjaro, Linux 7.1.9-1-MANJARO, in-tree cdc_acm, pyserial 3.5, Python 3.14.7) against the LilyGO T-Display-K230’s CH342K console bridge (1a86:55d2, two CDC-ACM ports: ttyACM0 = UART0, ttyACM1 = UART3).

The board was never corrupted. Every burst we chased was made on this host.

1. The short version

ModemManager is running on this machine and probes /dev/ttyACM0 and /dev/ttyACM1 every single time the CH342 enumerates — which, because the board resets constantly during debugging, is every single time we go to capture a boot. During the probe it:

That single cause produces both symptoms we saw:

There is a second, smaller contributor that survives even if ModemManager is removed: cdc_acm programs 9600 baud into every CDC device at probe time and does not touch it again until the first open(). So the very first open after each enumeration always has some mis-sampled data behind it.

2. Evidence

2.1 ModemManager is probing this exact device (primary, this host)

journalctl -u ModemManager on solomon, unedited:

Sep 21 11:32:33  <msg> [ttyACM0/probe] probe step: start
Sep 21 11:32:33  <msg> [ttyACM0/probe] probe step: AT open port
Sep 21 11:32:33  <msg> [ttyACM0/probe] probe step: AT
Sep 21 11:32:51  <msg> [ttyACM0/probe] probe step: AT close port
Sep 21 11:32:51  <msg> [ttyACM0/probe] probe step: QCDM
Sep 21 11:32:51  <msg> [ttyACM0/probe] probe step: done
Sep 21 11:32:51  <msg> [device …/usb3/3-1] creating modem with plugin 'generic' and '2' ports (1a86:55d2)
Sep 21 11:32:51  <wrn> [plugin/generic] could not grab port ttyACM0: Cannot add port 'tty/ttyACM0', unhandled port type

The same block repeats at 11:06:22, 11:32:33 and 11:39:58 — once per re-enumeration. Note the vendor/product: (1a86:55d2), our CH342. Note the duration: 18 seconds, 11:32:33 → 11:32:51.

It happens because /usr/lib/udev/rules.d/80-mm-candidate.rules says, with no vendor filtering at all:

SUBSYSTEM=="tty", ENV{ID_MM_CANDIDATE}="1"

and ModemManager.service on this host starts with a bare ExecStart=/usr/bin/ModemManager — no --filter-policy=strict.

2.2 What ModemManager does to the port (primary, MM 1.24.2 source)

Installed version is 1.24.2 (mmcli --version), so these quotes are from modemmanager 1.24.2-2 on sources.debian.org — the matching release.

src/mm-port-serial.c:

g_param_spec_uint (MM_PORT_SERIAL_BAUD, "Baud", "Baud rate",
                   0, G_MAXUINT, 57600, G_PARAM_READWRITE)

→ 57600 is the default baud when nothing sets ID_MM_TTY_BAUDRATE (nothing does here). Corroborated by a string in the shipped binary:

$ strings -a /usr/bin/ModemManager | grep 57600
baudrate invalid: %u; defaulting to 57600

Same file:

if (ioctl (self->priv->fd, TIOCEXCL) < 0)     /* exclusive access */
…
tcflush (self->priv->fd, TCIOFLUSH);          /* on open and on close */
…
stbuf.c_cflag |= (bits | parity | stopbits | CLOCAL | CREAD);

and mm_port_serial_flash() drops the port to B0 for flash_time and then restores the speed. The binary carries the matching help text:

$ strings -a /usr/bin/ModemManager | grep -i flash
Flashing the port (0 baud for a short period) is allowed.

src/mm-port-probe.c:

static const MMPortProbeAtCommand at_probing[] = {
    { "AT",  3, mm_port_probe_response_processor_is_at },
    …
};

six tries by default at a 3 s timeout each = the 18 s the journal shows, then a QCDM version-info frame (0x7E-delimited HDLC). The binary also contains ATE1, +GCAP, +CGMI, +CGMM for the follow-up probes.

TIOCEXCL is why capture-boot.py’s except: time.sleep(0.2) retry loop exists at all, and why the comment there — “udev has not finished with it yet” — is wrong. It is not udev. It is ModemManager holding the port for 18 seconds while writing AT at the wrong baud into U-Boot.

2.3 What cdc_acm does on probe and on open (primary, kernel source)

From drivers/usb/class/cdc-acm.c (mainline):

	/* in acm_probe() */
	acm->line.dwDTERate = cpu_to_le32(9600);
	acm->line.bDataBits = 8;
	acm_set_line(acm, &acm->line);
	acm_tty_driver->init_termios.c_cflag = B9600 | CS8 | CREAD |
							HUPCL | CLOCAL;
static int acm_port_activate(struct tty_port *port, struct tty_struct *tty)
{
	…
	acm_tty_set_termios(tty, NULL);     /* SET_LINE_CODING */
	clear_bit(ACM_THROTTLED, &acm->flags);
	retval = acm_submit_read_urbs(acm, GFP_KERNEL);   /* only now */

So: the chip is programmed to 9600 the moment it enumerates, and stays there until somebody opens the tty. Read URBs are only submitted at open, which means that until then nothing drains the chip’s bulk IN endpoint and whatever the chip decoded piles up in its own FIFO.

Two further details from the same file that matter:

	if (C_BAUD(tty) == B0) {
		newline.dwDTERate = acm->line.dwDTERate;
		newctrl &= ~USB_CDC_CTRL_DTR;
	}

— this is what ModemManager’s “flash” hits: B0 makes cdc_acm drop DTR.

	{ USB_DEVICE(0x1a86, 0x55d3), .driver_info = MISSING_CAP_BRK, },

— WCH’s CH343 (55d3) is already quirked in-tree for lying about BREAK support. Our 55d2 is not in the table. Mentioned only to show that WCH CDC parts are known to need special handling; it is not the cause here.

2.4 What pyserial does on open (primary, installed source)

/usr/lib/python3.14/site-packages/serial/serialposix.py, Serial.open(), in order:

  1. os.open(port, O_RDWR|O_NOCTTY|O_NONBLOCK) — this is where the kernel runs acm_port_activate and starts reading at whatever baud is currently programmed, and where tty_port_block_til_ready() raises DTR/RTS;
  2. self._reconfigure_port(force_update=True) — the tcsetattr that finally sends SET_LINE_CODING for 115200;
  3. _update_dtr_state() / _update_rts_state();
  4. self._reset_input_buffer() → termios.tcflush(fd, TCIFLUSH).

Three consequences, all load-bearing:

The kernel-side DTR/RTS raise is tty_port_block_til_ready() → tty_port_raise_dtr_rts() → acm_port_dtr_rts(port, true) → CDC SET_CONTROL_LINE_STATE with DTR|RTS. It is unconditional; there is no userspace opt-out short of patching the kernel. (https://qsantos.fr/2025/05/03/linux-always-toggles-dtr-rts/)

2.5 DTR/RTS are a dead end on this board (primary, the schematic)

repo/schematic/T-Display K230_V1.0_NEW.pdf, U11, is a CH342K — the 10-pin part. Its entire net list on this board is:

UD+ , UD- , GND , VDD5 , VIO , V3 ,
TXD0_CH342 -> UART0_RXD      RXD0_CH342 <- UART0_TXD
TXD1_CH342 -> UART3_RXD      RXD1_CH342 <- UART3_TXD

There is no DTR, RTS, CTS, DSR, DCD or RI pin on the package, and none on the board. Nothing the host does to modem control lines can physically reach the K230. Every hardware theory that routed through DTR — auto-reset, boot-strapping, a BREAK on the target — is ruled out by the schematic. The garbage is data, and only data.

2.6 A wrong-baud receiver reproduces the shape of what we saw

Written for this investigation (kept out of the repo; it touches no hardware): build the physical 8N1 waveform for a byte stream at one baud, then sample it the way a UART receiver at another baud would — hunt for a start bit, sample eight data bits at the mid-point, resynchronise.

ModemManager writing AT\r at 57600 into a 115200 receiver:

TX=57600 RX=115200
  raw: 06 90 60 66 1e 98 00 06 66 e6 80 06 98 60 66 1e 98 00 06 66 e6 80 …
  the printable subset U-Boot's line editor would keep and echo: b'`ff`ff`ff'

An idle K230# prompt at 115200 sampled by a receiver at 57600:

TX=115200 RX=57600
  raw: 94 48 c8 5a 1a 98 5a 1a 68 5a 1a 98 5a 1a 98 5a 1a 98 5a 1a 98 …

Both reproduce the two things that made the real bursts look like hardware faults: they are short, repeating, low-entropy patterns, and the host→board direction yields a run of ordinary printable ASCII, because U-Boot throws away the non-printables and keeps and echoes the rest.

This is a shape match, not a byte-for-byte reconstruction of kKBBBBkBBBk / 0xF4 0xA4 — we do not know exactly what the board was printing at that instant, nor which of the two wrong-baud epochs (9600 from cdc_acm probe, 57600 from ModemManager) each burst came from. Treat the exact byte mapping as unverified; treat the mechanism as established.

3. What was considered and ruled out

HypothesisVerdict
Opening asserts DTR/RTS and the CH342 answers with data or a BREAKRuled out. CH342K has no DTR/RTS pins (§2.5). The kernel does raise the CDC control-line bits, but they go nowhere.
HUPCL on close causes a transition on the next openNot the cause. cdc_acm’s init_termios does carry HUPCL, and the hangup does drop DTR — but with no DTR pin it cannot reach the board. stty -hupcl is not worth doing here.
The chip is reprogrammed after open, so in-flight bytes decode at the wrong rateConfirmed, and it is the core of it (§2.3, §2.4) — but the window is far wider than “in flight”: it is the whole interval from enumeration to our tcsetattr, which ModemManager stretches to 18+ seconds.
reset_input_buffer() at open fixes itNo. pyserial already does exactly that and it fires too early (§2.4). It works only after a settle delay.
exclusive=True fixes itNo. It is an advisory flock, and ModemManager uses TIOCEXCL (§2.4). Still worth having to stop two copies of our own tooling fighting.
Swap cdc_acm for WCH’s vendor driverHelps a little, does not fix it. WCHSoftGroup/ch343ser_linux covers CH342 and sets ch343->line.dwDTERate = 115200 at probe rather than 9600, so it removes the cdc_acm 9600 epoch. But its device is still a tty, so 80-mm-candidate.rules still tags it and ModemManager still probes it (§2.1). An out-of-tree module to rebuild on every kernel bump, for part of the problem. Not recommended.
A WCH errata / CH342 firmware bugNo evidence found, and none needed — the host-side explanation is complete and each step is observable in the journal.
The board is crashingNo. This is the thing that cost us the most time.

4. The fix

4.1 Prevention: stop ModemManager touching the board (do this first)

tools/99-tdisplay-k230-no-modemmanager.rules, installed to /etc/udev/rules.d/:

SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d2", ENV{ID_MM_DEVICE_IGNORE}="1"
sudo install -m0644 tools/99-tdisplay-k230-no-modemmanager.rules /etc/udev/rules.d/
sudo udevadm control --reload
# then unplug and replug the console cable

ID_MM_DEVICE_IGNORE is honoured under every filter policy including strict, and tagging the usb_device (not the tty) covers both ports at once. (https://modemmanager.org/docs/modemmanager/port-and-device-detection/)

This removes the 18-second TIOCEXCL lockout, the AT bytes going into U-Boot, the B0 flash, and the 57600 epoch. It does not remove the cdc_acm 9600-at-probe epoch, so keep §4.2 as well.

systemctl mask ModemManager would also work and is the right move on a machine with no WWAN hardware, but the udev rule is narrower and survives reinstalls.

4.2 Mitigation in tools/capture-boot.py

Complete prevention is not available: even with ModemManager gone, cdc_acm leaves the chip at 9600 from enumeration until the first open, so the first open always has some mis-sampled data queued behind it. The script therefore has to discard deterministically rather than hope. It now:

--hammer, --kick, --expect/--send, --out, --seconds, --append and --quiet-exit are unchanged. --hammer and --kick now run after the settle+flush, which costs 0.3 s of the autoboot window and in exchange stops the hammer’s own keystrokes from being mixed into the discarded region. With the udev rule installed, --settle 0.05 is enough.

5. Confidence

Established from primary sources:

Inference, consistent with everything above but not directly measured:

The one way to close both inferences — once the board is free — is to install the udev rule, replug, and capture with --settle 0. If the burst disappears entirely, ModemManager was the whole story; if a short burst remains, that residue is the cdc_acm 9600 epoch. Neither test may be run while a capture is live.

Sources