You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Linux embedded-development tools (esptool, avrdude, picocom, stm32flash, PlatformIO,
pyserial-based scripts, ...) talk to boards through /dev/ttyUSB<n> / /dev/ttyACM<n>
plus a small, well-defined set of termios and modem-control ioctls. Everything in that
set is "ioctl on a character device", the same shape as the existing /dev/pts, /dev/ptmx, and /dev/fuse emulation, and the host side already exists on macOS as /dev/cu.usbserial-* / /dev/cu.usbmodem*.
This issue proposes a narrow mapping of those device nodes plus the missing serial
ioctls, so that unmodified Linux serial tooling runs under elfuse. It does not propose
libusb / usbdevfs passthrough, hidraw, udev, or hotplug notification (see "Non-goals").
I am willing to implement this; I'd like to agree on scope and naming first.
Motivation
I ran esptool from a python:3.12-slim arm64 sysroot (Debian 13, glibc 2.41) with pip install esptool (esptool 5.3.1, pyserial 3.5), as elfuse --timeout 0 --sysroot sysroot-esptool /usr/local/bin/python3 -m esptool ....
The sysroot sat on case-insensitive APFS, so every run also prints the sysroot.c:409
warning, omitted below:
$ ... -m esptool --port /dev/ttyUSB0 flash_id
A fatal error occurred: Could not open /dev/ttyUSB0, the port is busy or doesn't exist.
([Errno 2] could not open port /dev/ttyUSB0: [Errno 2] No such file or directory: '/dev/ttyUSB0')
$ ... -m serial.tools.list_ports -v
no ports found # pyserial globs /dev/ttyUSB*,/dev/ttyACM*,... and /sys/class/tty
$ ... -m esptool --port /dev/ttys002 flash_id # a host pty, to get past the name
File ".../serial/serialposix.py", line 344, in open
self._reset_input_buffer()
File ".../serial/serialposix.py", line 677, in _reset_input_buffer
termios.tcflush(self.fd, termios.TCIFLUSH)
termios.error: (25, 'Inappropriate ioctl for device')
So today a pyserial-based tool cannot find the port under its Linux name, and when
pointed at the host node it dies inside Serial.open() on TCFLSH. Had it gotten
further, its tcsetattr() would have left the line at the host's previous speed (see
below). The ioctl sequence pyserial issued on the port fd, from elfuse -v: TCGETS
x3, TCSETS (returns 0, but the baud is dropped), TCGETS, TIOCMBIS (ENOTTY,
tolerated), TCFLSH (ENOTTY, fatal).
Tools in this class are what README's Positioning section describes ("runs single
Linux binaries ... with minimal overhead"). x86_64 builds of the same tools take the
same path under Rosetta, since these ioctl numbers and struct layouts come from asm-generic on both architectures.
Current behavior
Verified against bffd6bd (2026-08-19).
Device nodes
Only the Linux names are missing; the host nodes are already reachable. Every /dev/*
open goes through the intercept (path_might_use_open_intercept, src/syscall/path.c:48). The intercept in src/runtime/procemu.c:2276-2380
synthesizes ptmx, null, zero, (u)random, tty, shm, std{in,out,err}, fd/, pts/ (/dev/fuse is short-circuited one step earlier, fs.c:625) and returns PROC_NOT_INTERCEPTED for anything else, after which src/syscall/fs.c:668 falls
through to the real openat on the host. So from a guest today:
open("/dev/cu.usbmodem101") -> ok (host USB CDC-ACM node, passthrough)
open("/dev/cu.Bluetooth-Incoming-Port") -> ok (host node, passthrough)
open("/dev/ttys002") -> ok (host pty, passthrough)
open("/dev/ttyUSB0") -> ENOENT
open("/dev/ttyACM0") -> ENOENT
stat("/dev/serial/by-id") -> ENOENT
readdir("/dev") -> host listing (ttyp0.., tty.wlan-debug, ttysNNN ...)
A Linux tool can already be pointed at --port /dev/cu.usbmodemXXXX. What it cannot
do is find the port under the name it expects, or drive it once open (next two
sections).
Baud rate is dropped on plain TCSETS, and TCGETS reports B0
src/syscall/io.c:759-765 documents that "termios translation drops CBAUD from
c_cflag and always uses the speed accessors", and the TCSETS/TCSETSW/TCSETSF arm
(io.c:2289-2316) never calls cfsetispeed/cfsetospeed; only the TCSETS2 arm
(io.c:2342-2389) resolves CBAUD/BOTHER into a speed. TCGETS likewise never
encodes the host speed back into CBAUD. With a static glibc 2.41 guest against a
macOS pty whose host side was preset to 9600:
A native macOS build of the same program on the same pty gives cfgetospeed -> 115200 and a host side at 115200.
Which libcs hit this: musl and glibc < 2.42 issue TCSETS with the rate in CBAUD.
glibc >= 2.42 (commit 5cf101a85a, "linux: implement arbitrary and split speeds in
termios") always issues TCGETS2/TCSETS2 on every architecture except old-kernel
Alpha, so only very recent sysroots take the working arm. Debian 13 ships 2.41 and is
affected; Fedora 43 and Ubuntu 25.10 ship 2.42.
Modem-control and line ioctls return ENOTTY
The sys_ioctl switch handles TCGETS/TCSETS*, TCGETS2/TCSETS2*, TIOCGWINSZ/TIOCSWINSZ, TIOCGPGRP/TIOCSPGRP, TIOCSCTTY/TIOCNOTTY, TIOCGSID, TIOCPKT, TIOCGPTN/TIOCSPTLCK/TIOCGPTPEER, FIONREAD, FIONBIO, FIOASYNC, FIOCLEX/FIONCLEX; everything else falls to default: return -LINUX_ENOTTY
(io.c:2573). The ones serial tools need and that are missing:
ioctl
who uses it
macOS equivalent
TIOCMGET / TIOCMSET / TIOCMBIS / TIOCMBIC
esptool/avrdude/stm32flash toggle DTR/RTS to reset the MCU and enter the bootloader; pyserial setDTR/setRTS
same names and same TIOCM_* bit values on Darwin
TCSBRK / TCSBRKP
tcsendbreak(), tcdrain() (glibc implements tcdrain as TCSBRK 1)
tcsendbreak() / tcdrain()
TCFLSH
tcflush(); pyserial calls reset_input_buffer() before every handshake
tcflush()
TCXONC
tcflow()
tcflow()
TIOCOUTQ
pyserial out_waiting
TIOCOUTQ exists on Darwin
TIOCEXCL / TIOCNXCL
C serial tools that lock the line at the tty level (picocom, minicom, setserial-style code). pyserial's exclusive=True (which esptool sets, loader.py:433-434) uses flock() instead, so this row is for completeness
TIOCEXCL / TIOCNXCL exist on Darwin
TIOCSBRK / TIOCCBRK
pyserial break_condition (serialposix.py:300-301)
same names on Darwin
TIOCGSERIAL / TIOCSSERIAL
only pyserial's opt-in set_low_latency_mode() (serialposix.py:130-146), which esptool never calls; it raises on failure
none; keep returning ENOTTY
Measured on the same macOS pty (guest = static glibc 2.41 aarch64; the native column
is the same source built with clang on the host, so it isolates what elfuse adds):
ioctl
under elfuse
native macOS
TCFLSH (tcflush)
ENOTTY
0
TCSBRK (tcdrain)
ENOTTY
0
TIOCOUTQ
ENOTTY
0
TIOCEXCL
ENOTTY
0
TIOCMGET/MBIS/MBIC
ENOTTY
ENOTTY (Darwin ptys have no modem lines; needs a real port)
Proposal
Three pieces that can land independently, smallest first.
1. Honor CBAUD in the plain TCSETS arm (bug fix, no new surface)
Reuse linux_cbaud_to_speed() (io.c:790) in the TCSETS/TCSETSW/TCSETSF arm the
same way the TCSETS2 arm already does: output rate from CBAUD, input rate from CIBAUD with B0 meaning "same as output", per drivers/tty/tty_baudrate.c. Encode the
host rates back into CBAUD/CIBAUD in TCGETS, and in TCGETS2 (which today always
reports BOTHER where the kernel reports the B* index), the way tty_termios_encode_baud_rate() does, so cfgetospeed()/cfgetispeed() round-trip
for both glibc and musl. This is a correctness fix that stands on its own and is a
prerequisite for any real serial device.
2. Add the missing line/modem ioctls
Add the rows in the table above to sys_ioctl. All of them map 1:1 to a Darwin ioctl/tc*() call on the already-resolved host_fd; for a pty host fd Darwin
returns the same errors Linux does, so the change is also safe for the existing pty
path. Two kernel details to match: TCSBRK and TCSBRKP both drain pending output
before breaking (drivers/tty/tty_io.c, tty_wait_until_sent ahead of send_break), and TCFLSH/TCXONC selectors are 0-based on Linux but 1-based on
Darwin. Extend tests/test-pty.c with a slave-side block that exercises all of this
against a pty pair (no hardware needed), so the QEMU side of the matrix cross-checks
it.
I have a working draft of pieces 1 and 2 (io.c + linux-wire.h + a slave-side block
in tests/test-pty.c, +422/-8; make check rc=0, 77/77 in tests/driver.sh, with
the fixture-dependent musl/dyn-glibc cases skipped as on a stock checkout). With it
the same glibc-2.41 and musl guests on a host pty report cfgetospeed -> 4098, a host
line at 115200, and TCFLSH/TCSBRK/TIOCOUTQ/TIOCEXCL = 0, and Linux esptool
gets past Serial.open() (see the ESP32-S3 run below). One detail to get right: on a
plain TCSETS a bare BOTHER in CBAUD must leave the host speed unchanged, because set_termios starts from tty->termios and tty_termios_baud_rate() returns the
stored c_ospeed for BOTHER (drivers/tty/tty_baudrate.c). Otherwise a tcgetattr/tcsetattr pair on a port set to e.g. 74880 via termios2 collapses the
line to B0. I'll send it as a PR once the scope here is agreed.
3. Map the device nodes
/dev/ttyUSB<n> -> the n-th host node produced by a USB-UART bridge, typically /dev/cu.usbserial-* (Apple's built-in CP210x/CH34x/FTDI drivers), /dev/cu.wchusbserial* (WCH vendor driver), /dev/cu.SLAB_USBtoUART*
(Silicon Labs vendor driver)
/dev/ttyACM<n> -> the n-th /dev/cu.usbmodem* on the host (CDC-ACM, e.g. the
native USB-Serial/JTAG port on ESP32-S3/C3, Arduino, Pico)
/sys/class/tty/tty{USB,ACM}<n>/device -> a minimal synthetic USB-interface
directory whose subsystem link resolves to .../bus/usb (for ttyACM) or .../bus/usb-serial one level deeper (for ttyUSB), with idVendor, idProduct, serial, manufacturer, product, bNumInterfaces in its parent USB-device
directory, populated from IOKit. This is the walk pyserial's list_ports_linux.py:30-53 does, and esptool's reset-strategy selection depends on
it (see the ESP32-S3 run below). Same lazy, one-shot shape as the /sys/devices/system/cpu tree.
/dev/serial/by-id/<id> -> directory listing derived from the host node name
(the macOS suffix is already the USB serial number / location ID), so tools
that prefer stable names keep working.
Make stat/lstat/readlink/getdents64 on these paths consistent with the open
intercept, following the same "one source of truth" pattern the sysfs CPU tree uses
(path.c:39-52, procemu.c:1641-1680).
Ordering: enumerate host /dev/cu.* sorted by name at first use and memoize for the
process lifetime, like the lazy CPU tree. That gives a deterministic index within a
run. Linux itself hands out the lowest free minor at probe time
(drivers/usb/serial/usb-serial.c, idr_alloc) and promises nothing across a
replug, which is why /dev/serial/by-id exists.
Host side: use cu.* (call-out) rather than tty.* (call-in) so that open() does
not block on DCD. O_NONBLOCK/O_CLOEXEC handling can reuse the character-device
open path at procemu.c:2310-2323.
Non-goals (out of scope for this issue)
libusb / /dev/bus/usb / usbdevfs URB ioctls, /sys/bus/usb attribute trees
/dev/hidraw*
udev / netlink hotplug events, inotify on /dev
Intel Macs (project is Apple Silicon only)
Questions for maintainers
Opt-in or default? Unknown /dev/* names already pass through to the host, so the
guest can open /dev/cu.* today; the Linux aliases add names rather than reach. I
lean toward on by default, but a --no-serial flag or an allow-list
(--serial=/dev/cu.usbserial-0001) is easy if you prefer explicit.
Naming split. Is usbserial-* -> ttyUSB, usbmodem* -> ttyACM acceptable, or
should everything be ttyUSB<n>? The split matches what the corresponding Linux
drivers (ftdi_sio/cp210x/ch341 vs cdc-acm) would produce for the same hardware.
Should piece 1 (CBAUD on TCSETS) be a separate PR? It is a standalone bug fix
with its own test; I'd rather land it first.
Testing. Pieces 1 and 2 are fully testable on the existing pty path and in the QEMU
matrix. Piece 3 needs a host serial device; I would gate that test on an env var
(ELFUSE_TEST_SERIAL=/dev/cu.usbserial-XXXX) and have the driver report it as
skipped with a reason rather than silently passing, per the policy from Harden test suite against silent-skip patterns #38.
Reproducer
repro-serial.c (static aarch64; glibc and musl builds behave identically) opens the
given port, round-trips B115200, then issues TIOCMGET/MBIS/MBIC, TCFLSH, TCSBRK, TIOCOUTQ, TIOCEXCL. A small harness opens a macOS pty pair, presets the host side
to 9600, and passes the slave path to the guest. Output lightly condensed (headers
shortened, columns realigned):
$ python3 run-on-host-pty.py build/elfuse ./repro-serial
host pty slave = /dev/ttys002, host speed preset to 9600
stat(/dev/ttyUSB0) = -1 No such file or directory
stat(/dev/ttyACM0) = -1 No such file or directory
== 2. open(/dev/ttys002) ==
open = 3 ok
== 3. baud rate round-trip ==
tcsetattr(B115200) = 0
cfgetospeed -> 0 (want 4098)
== 4. line / modem-control ioctls ==
TIOCMGET = -1 Inappropriate ioctl for device
TIOCMBIS(DTR|RTS)= -1 Inappropriate ioctl for device
TIOCMBIC(DTR|RTS)= -1 Inappropriate ioctl for device
tcflush/TCFLSH = -1 Inappropriate ioctl for device
tcdrain/TCSBRK = -1 Inappropriate ioctl for device
TIOCOUTQ = -1 Inappropriate ioctl for device
TIOCEXCL = -1 Inappropriate ioctl for device
host-side speed after guest tcsetattr(B115200): ispeed=9600 ospeed=9600
The same program built natively with clang on the host, on the same pty, gives cfgetospeed -> 115200, TCFLSH/TCSBRK/TIOCOUTQ/TIOCEXCL = 0, and a host side at
115200.
Real hardware: ESP32-S3 (native USB-Serial/JTAG, VID 0x303A PID 0x1001, macOS node /dev/cu.usbmodem101)
Control group: native macOS esptool 5.3.1 on the same port reports Connected to ESP32-S3 ... USB mode: USB-Serial/JTAG and reads the flash ID.
Static glibc-2.41 reproducer under elfuse, opening the host node by name (which works
today through the /dev passthrough), unpatched vs. with the pieces-1+2 draft:
unpatched (bffd6bd) with draft patch
open(/dev/cu.usbmodem101) 3 ok 3 ok
tcsetattr(B115200) 0 0
cfgetospeed 0 (want 4098) 4098
TIOCMGET ENOTTY 0 bits=0x6 (DTR|RTS)
TIOCMBIS/TIOCMBIC(DTR|RTS) ENOTTY 0
TCFLSH / TCSBRK ENOTTY 0
TIOCOUTQ / TIOCEXCL ENOTTY 0
open(/dev/ttyACM0) ENOENT ENOENT (piece 3 not in the draft)
Linux esptool (arm64 python:3.12-slim sysroot) under elfuse against the same node.
Condensed: esptool 5.x prints a flash_id -> flash-id deprecation notice and repeats
the VID/PID line several times:
unpatched: termios.error: (25, 'Inappropriate ioctl for device') # serialposix.py:677 tcflush, inside Serial.open()
patched: esptool --port /dev/cu.usbmodem101 flash-id
Connecting...
Failed to get VID/PID of a device on /dev/cu.usbmodem101, using standard reset sequence.
A fatal error occurred: Failed to connect to Espressif device: Wrong boot mode detected (0x8)!
patched: esptool --port /dev/cu.usbmodem101 --before usb-reset flash-id
Connected to ESP32-S3 on /dev/cu.usbmodem101:
Chip type: ESP32-S3 (QFN56) (revision v0.1)
...
Uploading stub flasher... Running stub flasher... Stub flasher running.
Manufacturer: c8 Device: 4017 Detected flash size: 8MB
Hard resetting via RTS pin...
With pieces 1 and 2 the data path and DTR/RTS control work: the Linux esptool talks
to the chip, runs the stub, and reads flash. The remaining gap is piece 3. esptool
picks its reset strategy from the port's VID/PID (esptool/loader.py:846-847: USBJTAGSerialReset when pid == 0x1001 or --before usb-reset), which pyserial on
Linux reads from /sys/class/tty/<name>/device/.../{idVendor,idProduct}
(serial/tools/list_ports_linux.py:30-53). Without a ttyACM0 name and that sysfs
view it falls back to the classic DTR/RTS reset, which the USB-Serial/JTAG peripheral
does not honor, so I had to pass --before usb-reset by hand. That is why piece 3
should include the minimal /sys/class/tty/ttyACM<n>/device walk described above,
and not only the /dev alias.
Full sources (reproducer, harness, esptool sysroot recipe) are available on request or
with the PR.
Summary
Linux embedded-development tools (esptool, avrdude, picocom, stm32flash, PlatformIO,
pyserial-based scripts, ...) talk to boards through
/dev/ttyUSB<n>//dev/ttyACM<n>plus a small, well-defined set of termios and modem-control ioctls. Everything in that
set is "ioctl on a character device", the same shape as the existing
/dev/pts,/dev/ptmx, and/dev/fuseemulation, and the host side already exists on macOS as/dev/cu.usbserial-*//dev/cu.usbmodem*.This issue proposes a narrow mapping of those device nodes plus the missing serial
ioctls, so that unmodified Linux serial tooling runs under elfuse. It does not propose
libusb / usbdevfs passthrough, hidraw, udev, or hotplug notification (see "Non-goals").
I am willing to implement this; I'd like to agree on scope and naming first.
Motivation
I ran esptool from a
python:3.12-slimarm64 sysroot (Debian 13, glibc 2.41) withpip install esptool(esptool 5.3.1, pyserial 3.5), aselfuse --timeout 0 --sysroot sysroot-esptool /usr/local/bin/python3 -m esptool ....The sysroot sat on case-insensitive APFS, so every run also prints the
sysroot.c:409warning, omitted below:
So today a pyserial-based tool cannot find the port under its Linux name, and when
pointed at the host node it dies inside
Serial.open()onTCFLSH. Had it gottenfurther, its
tcsetattr()would have left the line at the host's previous speed (seebelow). The ioctl sequence pyserial issued on the port fd, from
elfuse -v:TCGETSx3,
TCSETS(returns 0, but the baud is dropped),TCGETS,TIOCMBIS(ENOTTY,tolerated),
TCFLSH(ENOTTY, fatal).Tools in this class are what README's Positioning section describes ("runs single
Linux binaries ... with minimal overhead"). x86_64 builds of the same tools take the
same path under Rosetta, since these ioctl numbers and struct layouts come from
asm-genericon both architectures.Current behavior
Verified against
bffd6bd(2026-08-19).Device nodes
Only the Linux names are missing; the host nodes are already reachable. Every
/dev/*open goes through the intercept (
path_might_use_open_intercept,src/syscall/path.c:48). The intercept insrc/runtime/procemu.c:2276-2380synthesizes
ptmx,null,zero,(u)random,tty,shm,std{in,out,err},fd/,pts/(/dev/fuseis short-circuited one step earlier,fs.c:625) and returnsPROC_NOT_INTERCEPTEDfor anything else, after whichsrc/syscall/fs.c:668fallsthrough to the real
openaton the host. So from a guest today:A Linux tool can already be pointed at
--port /dev/cu.usbmodemXXXX. What it cannotdo is find the port under the name it expects, or drive it once open (next two
sections).
Baud rate is dropped on plain
TCSETS, andTCGETSreports B0src/syscall/io.c:759-765documents that "termios translation drops CBAUD fromc_cflag and always uses the speed accessors", and the
TCSETS/TCSETSW/TCSETSFarm(
io.c:2289-2316) never callscfsetispeed/cfsetospeed; only theTCSETS2arm(
io.c:2342-2389) resolvesCBAUD/BOTHERinto a speed.TCGETSlikewise neverencodes the host speed back into
CBAUD. With a static glibc 2.41 guest against amacOS pty whose host side was preset to 9600:
A native macOS build of the same program on the same pty gives
cfgetospeed -> 115200and a host side at 115200.Which libcs hit this: musl and glibc < 2.42 issue
TCSETSwith the rate inCBAUD.glibc >= 2.42 (commit 5cf101a85a, "linux: implement arbitrary and split speeds in
termios") always issues
TCGETS2/TCSETS2on every architecture except old-kernelAlpha, so only very recent sysroots take the working arm. Debian 13 ships 2.41 and is
affected; Fedora 43 and Ubuntu 25.10 ship 2.42.
Modem-control and line ioctls return
ENOTTYThe
sys_ioctlswitch handlesTCGETS/TCSETS*,TCGETS2/TCSETS2*,TIOCGWINSZ/TIOCSWINSZ,TIOCGPGRP/TIOCSPGRP,TIOCSCTTY/TIOCNOTTY,TIOCGSID,TIOCPKT,TIOCGPTN/TIOCSPTLCK/TIOCGPTPEER,FIONREAD,FIONBIO,FIOASYNC,FIOCLEX/FIONCLEX; everything else falls todefault: return -LINUX_ENOTTY(
io.c:2573). The ones serial tools need and that are missing:TIOCMGET/TIOCMSET/TIOCMBIS/TIOCMBICsetDTR/setRTSTIOCM_*bit values on DarwinTCSBRK/TCSBRKPtcsendbreak(),tcdrain()(glibc implementstcdrainasTCSBRK 1)tcsendbreak()/tcdrain()TCFLSHtcflush(); pyserial callsreset_input_buffer()before every handshaketcflush()TCXONCtcflow()tcflow()TIOCOUTQout_waitingTIOCOUTQexists on DarwinTIOCEXCL/TIOCNXCLsetserial-style code). pyserial'sexclusive=True(which esptool sets,loader.py:433-434) usesflock()instead, so this row is for completenessTIOCEXCL/TIOCNXCLexist on DarwinTIOCSBRK/TIOCCBRKbreak_condition(serialposix.py:300-301)TIOCGSERIAL/TIOCSSERIALset_low_latency_mode()(serialposix.py:130-146), which esptool never calls; it raises on failureENOTTYMeasured on the same macOS pty (guest = static glibc 2.41 aarch64; the native column
is the same source built with clang on the host, so it isolates what elfuse adds):
TCFLSH(tcflush)ENOTTYTCSBRK(tcdrain)ENOTTYTIOCOUTQENOTTYTIOCEXCLENOTTYTIOCMGET/MBIS/MBICENOTTYENOTTY(Darwin ptys have no modem lines; needs a real port)Proposal
Three pieces that can land independently, smallest first.
1. Honor
CBAUDin the plainTCSETSarm (bug fix, no new surface)Reuse
linux_cbaud_to_speed()(io.c:790) in theTCSETS/TCSETSW/TCSETSFarm thesame way the
TCSETS2arm already does: output rate fromCBAUD, input rate fromCIBAUDwith B0 meaning "same as output", perdrivers/tty/tty_baudrate.c. Encode thehost rates back into
CBAUD/CIBAUDinTCGETS, and inTCGETS2(which today alwaysreports
BOTHERwhere the kernel reports the B* index), the waytty_termios_encode_baud_rate()does, socfgetospeed()/cfgetispeed()round-tripfor both glibc and musl. This is a correctness fix that stands on its own and is a
prerequisite for any real serial device.
2. Add the missing line/modem ioctls
Add the rows in the table above to
sys_ioctl. All of them map 1:1 to a Darwinioctl/tc*()call on the already-resolvedhost_fd; for a pty host fd Darwinreturns the same errors Linux does, so the change is also safe for the existing pty
path. Two kernel details to match:
TCSBRKandTCSBRKPboth drain pending outputbefore breaking (
drivers/tty/tty_io.c,tty_wait_until_sentahead ofsend_break), andTCFLSH/TCXONCselectors are 0-based on Linux but 1-based onDarwin. Extend
tests/test-pty.cwith a slave-side block that exercises all of thisagainst a pty pair (no hardware needed), so the QEMU side of the matrix cross-checks
it.
I have a working draft of pieces 1 and 2 (io.c + linux-wire.h + a slave-side block
in
tests/test-pty.c, +422/-8;make checkrc=0, 77/77 intests/driver.sh, withthe fixture-dependent musl/dyn-glibc cases skipped as on a stock checkout). With it
the same glibc-2.41 and musl guests on a host pty report
cfgetospeed -> 4098, a hostline at 115200, and
TCFLSH/TCSBRK/TIOCOUTQ/TIOCEXCL= 0, and Linux esptoolgets past
Serial.open()(see the ESP32-S3 run below). One detail to get right: on aplain
TCSETSa bareBOTHERinCBAUDmust leave the host speed unchanged, becauseset_termiosstarts fromtty->termiosandtty_termios_baud_rate()returns thestored
c_ospeedforBOTHER(drivers/tty/tty_baudrate.c). Otherwise atcgetattr/tcsetattrpair on a port set to e.g. 74880 via termios2 collapses theline to B0. I'll send it as a PR once the scope here is agreed.
3. Map the device nodes
/dev/ttyUSB<n>-> the n-th host node produced by a USB-UART bridge, typically/dev/cu.usbserial-*(Apple's built-in CP210x/CH34x/FTDI drivers),/dev/cu.wchusbserial*(WCH vendor driver),/dev/cu.SLAB_USBtoUART*(Silicon Labs vendor driver)
/dev/ttyACM<n>-> the n-th/dev/cu.usbmodem*on the host (CDC-ACM, e.g. thenative USB-Serial/JTAG port on ESP32-S3/C3, Arduino, Pico)
/sys/class/tty/tty{USB,ACM}<n>/device-> a minimal synthetic USB-interfacedirectory whose
subsystemlink resolves to.../bus/usb(for ttyACM) or.../bus/usb-serialone level deeper (for ttyUSB), withidVendor,idProduct,serial,manufacturer,product,bNumInterfacesin its parent USB-devicedirectory, populated from IOKit. This is the walk pyserial's
list_ports_linux.py:30-53does, and esptool's reset-strategy selection depends onit (see the ESP32-S3 run below). Same lazy, one-shot shape as the
/sys/devices/system/cputree./dev/serial/by-id/<id>-> directory listing derived from the host node name(the macOS suffix is already the USB serial number / location ID), so tools
that prefer stable names keep working.
Make
stat/lstat/readlink/getdents64on these paths consistent with the openintercept, following the same "one source of truth" pattern the sysfs CPU tree uses
(
path.c:39-52,procemu.c:1641-1680).Ordering: enumerate host
/dev/cu.*sorted by name at first use and memoize for theprocess lifetime, like the lazy CPU tree. That gives a deterministic index within a
run. Linux itself hands out the lowest free minor at probe time
(
drivers/usb/serial/usb-serial.c,idr_alloc) and promises nothing across areplug, which is why
/dev/serial/by-idexists.Host side: use
cu.*(call-out) rather thantty.*(call-in) so thatopen()doesnot block on DCD.
O_NONBLOCK/O_CLOEXEChandling can reuse the character-deviceopen path at
procemu.c:2310-2323.Non-goals (out of scope for this issue)
/dev/bus/usb/ usbdevfs URB ioctls,/sys/bus/usbattribute trees/dev/hidraw*inotifyon/devQuestions for maintainers
/dev/*names already pass through to the host, so theguest can open
/dev/cu.*today; the Linux aliases add names rather than reach. Ilean toward on by default, but a
--no-serialflag or an allow-list(
--serial=/dev/cu.usbserial-0001) is easy if you prefer explicit.usbserial-* -> ttyUSB,usbmodem* -> ttyACMacceptable, orshould everything be
ttyUSB<n>? The split matches what the corresponding Linuxdrivers (ftdi_sio/cp210x/ch341 vs cdc-acm) would produce for the same hardware.
TCSETS) be a separate PR? It is a standalone bug fixwith its own test; I'd rather land it first.
matrix. Piece 3 needs a host serial device; I would gate that test on an env var
(
ELFUSE_TEST_SERIAL=/dev/cu.usbserial-XXXX) and have the driver report it asskipped with a reason rather than silently passing, per the policy from Harden test suite against silent-skip patterns #38.
Reproducer
repro-serial.c(static aarch64; glibc and musl builds behave identically) opens thegiven port, round-trips B115200, then issues
TIOCMGET/MBIS/MBIC,TCFLSH,TCSBRK,TIOCOUTQ,TIOCEXCL. A small harness opens a macOS pty pair, presets the host sideto 9600, and passes the slave path to the guest. Output lightly condensed (headers
shortened, columns realigned):
The same program built natively with clang on the host, on the same pty, gives
cfgetospeed -> 115200,TCFLSH/TCSBRK/TIOCOUTQ/TIOCEXCL = 0, and a host side at115200.
Real hardware: ESP32-S3 (native USB-Serial/JTAG, VID 0x303A PID 0x1001, macOS node
/dev/cu.usbmodem101)Control group: native macOS esptool 5.3.1 on the same port reports
Connected to ESP32-S3 ... USB mode: USB-Serial/JTAGand reads the flash ID.Static glibc-2.41 reproducer under elfuse, opening the host node by name (which works
today through the
/devpassthrough), unpatched vs. with the pieces-1+2 draft:Linux esptool (arm64
python:3.12-slimsysroot) under elfuse against the same node.Condensed: esptool 5.x prints a
flash_id->flash-iddeprecation notice and repeatsthe VID/PID line several times:
With pieces 1 and 2 the data path and DTR/RTS control work: the Linux esptool talks
to the chip, runs the stub, and reads flash. The remaining gap is piece 3. esptool
picks its reset strategy from the port's VID/PID (
esptool/loader.py:846-847:USBJTAGSerialResetwhenpid == 0x1001or--before usb-reset), which pyserial onLinux reads from
/sys/class/tty/<name>/device/.../{idVendor,idProduct}(
serial/tools/list_ports_linux.py:30-53). Without attyACM0name and that sysfsview it falls back to the classic DTR/RTS reset, which the USB-Serial/JTAG peripheral
does not honor, so I had to pass
--before usb-resetby hand. That is why piece 3should include the minimal
/sys/class/tty/ttyACM<n>/devicewalk described above,and not only the
/devalias.Full sources (reproducer, harness, esptool sysroot recipe) are available on request or
with the PR.