Serial ports for Zig: rate, framing, flow control and modem lines on termios2 for Linux and the DCB for Windows.
  • Zig 93%
  • Nix 6.4%
  • Shell 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 1a06e42ab4
Some checks are pending
test / docs (push) Blocked by required conditions
test / test (push) Has started running
Test on macOS and Windows through a GitHub mirror
GitHub has native macOS and Windows runners, which Forgejo does not, so
a workflow there runs the tests on arm64 and Intel macOS and on Windows.
Forgejo reads .forgejo/workflows in preference to .github/workflows, so
it does not run this one.

The Windows backend had no tests at all. Its conversion to and from the
DCB is pulled out into encode and decode, as on the other platforms, so
that it can be tested without a port, and the refusal of NUL and of a
missing COM port is tested too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fcrFCsAeNziaz3Y5QR8E2
2026-10-10 18:38:15 -05:00
.forgejo/workflows Run the tests on FreeBSD in a virtual machine 2026-10-10 16:28:20 -05:00
.github/workflows Test on macOS and Windows through a GitHub mirror 2026-10-10 18:38:15 -05:00
LICENSES Serial ports for Zig 2026-10-10 12:08:54 -05:00
src Test on macOS and Windows through a GitHub mirror 2026-10-10 18:38:15 -05:00
tests/freebsd Run the tests on FreeBSD in a virtual machine 2026-10-10 16:28:20 -05:00
tools Serial ports for Zig 2026-10-10 12:08:54 -05:00
.gitignore Serial ports for Zig 2026-10-10 12:08:54 -05:00
build.zig Run the tests on FreeBSD in a virtual machine 2026-10-10 16:28:20 -05:00
build.zig.zon Serial ports for Zig 2026-10-10 12:08:54 -05:00
flake.lock Serial ports for Zig 2026-10-10 12:08:54 -05:00
flake.nix Run the tests on FreeBSD in a virtual machine 2026-10-10 16:28:20 -05:00
package.nix Add a backend for macOS and the BSDs 2026-10-10 16:19:50 -05:00
README.md Test on macOS and Windows through a GitHub mirror 2026-10-10 18:38:15 -05:00
REUSE.toml Serial ports for Zig 2026-10-10 12:08:54 -05:00

zig-serial

Serial ports for Zig 0.17: open one, set its rate and framing, read and write it, and drive its modem lines.

const serial = @import("serial");

var port = try serial.Port.open("/dev/ttyUSB0", .{ .baud = 9600 }, .{});
defer port.close();

_ = try port.write("AT\r");
var buf: [256]u8 = undefined;
const n = try port.read(&buf);

The API documentation is generated from the doc comments and published at https://jeff.jcollie.page/zig-serial/.

What a Port does

  • Raw mode. Opening a port puts it into raw mode, so that what is written is what goes on the wire and what arrives is what is read: no echo, no line editing, no newline translation, no signals. Closing it puts back the settings the device had before.
  • Settings. Settings holds the rate, data bits, parity (including mark and space), stop bits, flow control (RTS/CTS or XON/XOFF), hang-up-on-close and CLOCAL. configure applies all of them in one call, so the line never runs at a mix of the old configuration and the new.
  • What the driver actually chose. current reads the settings back from the driver, which may differ from what was asked: a driver can round a rate it cannot divide down to.
  • Modem lines and line control. modem reads the modem lines and setLine raises or lowers DTR and RTS. sendBreak, drain and flush do what their names say.
  • Exclusive by default. On Linux, macOS and the BSDs an advisory flock and TIOCEXCL keep a second program off the port; on Windows a port is always exclusive.
  • Non-blocking by default. read and write return error.WouldBlock rather than waiting, which is what an event loop polling Port.handle wants. Pass .nonblocking = false for a thread that does nothing but read.

Platforms

  • Linux. Built on raw system calls with no libc. It uses termios2 (TCGETS2/TCSETS2) with BOTHER, so every rate is asked for by number and a rate like 31250 needs no special case. The asm-generic termios2 layout is the only one written out: MIPS, PowerPC, SPARC and Alpha lay the structure out differently and are refused at compile time.
  • Windows. Uses the DCB and COMMTIMEOUTS through kernel32, declared in the source rather than taken from a bindings package. Every rate, every parity, and one and a half stop bits are ordinary values. DTR and RTS cannot be read back on Windows, so modem reports them as this port last set them. Its tests run on GitHub's Windows runners, which have no serial port, so they cover the conversion to and from the DCB and the refusal of what is not a port; the backend has not been run against hardware.
  • macOS, FreeBSD, NetBSD, OpenBSD and DragonFly. One backend, on the libc termios all five inherit from 4.4BSD, so a program for these links libc; the module asks for it on its own. A speed_t there is the rate itself, so any rate is asked for by number. A macOS driver that refuses a rate through tcsetattr is asked again through IOSSIOSPEED, which takes two calls rather than one. There is no CMSPAR, so mark and space parity are refused. On macOS, open the /dev/cu.* node rather than the dial-in /dev/tty.*. This backend is compiled by zig build check for all five. Its tests run on FreeBSD in a virtual machine and on GitHub's macOS runners, both arm64 and Intel (see below); on NetBSD, OpenBSD and DragonFly they have not yet been run.

Testing

$ nix develop -c zig build test --summary all
$ nix develop -c zig build check   # every other platform, compiled only
$ nix build .#checks.x86_64-linux.freebsd --print-build-logs

The last of these runs the tests on FreeBSD. It cross-compiles them for x86_64-freebsd, boots FreeBSD's own BASIC-CI image under QEMU and KVM, and hands the machine a NoCloud cidata drive holding the test binary and a user-data script. nuageinit, FreeBSD's cloud-init, runs that script on the first boot; it runs the tests and powers the machine off, and the check passes if the serial console says they passed. It needs no network and nothing logs in. The CI runs it on every push.

macOS and Windows are tested on GitHub, whose runners are native machines of both: every push to the GitHub mirror runs zig build test on arm64 and Intel macOS and on Windows, from .github/workflows/test.yaml.

zig build test-exe -Dtarget=... installs the test binary for any target as zig-out/bin/serial-test, to be carried to a machine that can run it — a Mac, say.

The tests use pseudo-terminals in place of a cable, on Linux and on macOS and the BSDs alike. A pty cannot test everything, because it always reports eight data bits and no parity whatever it is told. So the conversion between Settings and each platform's termios is tested on its own, in memory, and the pty tests cover what a pty does keep: the rate, the stop bits, flow control, raw data in both directions, exclusivity, and the restoring of the original settings on close.

Where this came from

zettacom has a serial layer of its own, but most of it is ported from picocom and is GPL. zig-serial is a fresh MIT implementation, written from the interfaces in the references below, so that MIT programs — the harrier terminal among them — can use it.

Repository

The canonical repository is on my Forgejo instance, and Tangled and GitHub carry mirrors of it:

git clone https://git.jcollie.dev/jeff/zig-serial.git

CI for Linux and FreeBSD, and the published documentation, come from the Forgejo repository; the GitHub mirror is there for its macOS and Windows runners.

License

MIT; see LICENSES/MIT.txt. The project follows the REUSE specification, and reuse lint passes.

References cited