Module 04·Systems Programming

zyterm. A serial terminal.

ReleasedAdvancedUpdated 2026-06-26

A serial terminal for people who work with hardware every day. Stay reading when the board disconnects. Type while it spams. Find any line in seconds. Render the device's own colour without trusting its bytes. One C binary, libc only, Linux.

zyterm

A serial terminal built for long sessions with hardware. One C binary, nothing to configure, libc the only runtime dependency.

zyterm in a live session: HUD, streaming output, anchored input bar
A live session. HUD on top, output in the middle, your prompt anchored at the bottom.

Try it in two minutes

There's nothing to configure. Grab the .deb, point it at your adapter, and you're in.

Install

Download the latest .deb (amd64 or arm64) from Releases and sudo dpkg -i zyterm_*.deb. Or make && sudo make install from source. A C compiler and make are the whole toolchain.

Connect

zyterm /dev/ttyUSB0 -b 115200. Swap the node for whatever ls /dev/tty* shows.

Quit

Ctrl + A then q. You can pull the USB out at any time, more on that below.

You land in a live HUD with scrollback and search out of the box. No Python, no Node, no daemon.


Reading survives a disconnect

Pull the USB out of your dev board halfway through a boot log. The lines you were reading don't go anywhere. Scroll up. Search. Copy what you need. Plug the board back in. The view stays where you left it.

A bright ◆ DISCONNECTED pill in the HUD keeps the state visible. The popup auto-hides the moment you scroll, so the history is yours to use. When the device comes back, a small ✓ reconnected flash, and the live tail resumes without disturbing what you were reading.

The same patience covers a cold start: launch zyterm before the board is even plugged in and it waits for the device instead of aborting, then connects the moment the port appears.

On by default. Pass --no-reconnect to opt out. With --port-glob "/dev/ttyUSB*" or --match-vid-pid 0403:6001 it re-discovers the port on every replug, so a board that re-enumerates on a different node still comes back.


Type while the board talks

A UART at speed moves a lot of bytes per second. Most terminals share one screen between output and input, so a wall of bytes scrolls right over the line you are entering. zyterm splits the screen into three zones with ANSI scroll regions: HUD at the top, output streaming in the middle, your prompt and cursor anchored at the bottom.

You type. The board talks. Both happen at once.

When the rates climb high enough to matter, --threaded adds an opt-in worker that drains the device into a lock-free ring buffer, so a flood of boot log can't out-run your keystrokes.


Find any line

Built for reading, not just watching.

🔍

Search the scrollback

Ctrl + A then / opens a substring search over the scrollback. n and N step matches.

🎯

Watch patterns

--watch ERROR --watch panic highlights matching lines in colour as the bytes arrive.

🔖

Bookmarks & macros

After Ctrl + A, + drops a bookmark and [ lists them. F-keys fire bound macros.

Scrollback, command history, and bookmarks live in memory and are cleared on exit. There's no dotfile written for them yet. It's a deliberate, documented choice, not a silent gap.


Reads in colour, without trusting the device

Device-emitted colour renders by default. Your firmware's <wrn> yellow and <err> red show up as colour, not as ^[[1;33m caret litter, and a shell's Tab-completion columns line up the way they would in a normal terminal.

The catch every other terminal ignores: the bytes a device sends are untrusted. Your terminal is a trust sink. Bytes written to it can move the cursor, set the window title, or drive an OSC 52 clipboard write, and a device that prints the right escape gets all of that for free. No exotic firmware required: a cat > /dev/ttyUSB0 from the other side is enough. So zyterm runs device output through a bounded SGR-only filter: colour passes, everything else (clipboard, title, cursor, erase, private modes) is neutralized to inert caret text.

The first version denied every device escape outright. The one that ships allows exactly one class back, because SGR is the only escape that can't drive the terminal: it sets colour and weight, nothing more. The risk was never the colour, it was the parser. So the answer was to make the parser small enough to trust, a three-state machine that accepts a sequence only when every parameter byte is a digit, a semicolon, or a colon, backed by a fixed buffer that aborts to inert text rather than grow. A sequence like CSI ? 1 m looks like colour but carries a private marker, so it never passes.

One escape class, SGR colour, is allowed through. Everything else is defanged.

--no-sgr selects strict deny-all if you want it, and Ctrl + A then G flips to full raw passthrough for the times you need every byte verbatim, like driving KGDB. The same posture covers the browser bridge: it binds loopback only, validates Host/Origin on both its write routes and its read streams (/ws, /stream), and --http-token adds a bearer gate before you ever tunnel the port.


Drag to select, release to copy

Drag with the mouse to select text from anywhere on screen. Release. The bytes land on the system clipboard. Wayland, X11, SSH, tmux: all covered by a cascade of fallbacks, transparent to you.

Selections stay anchored to the data, not the rows. Drag, scroll down to verify the end, then copy. The selection doesn't drift.


Under the hood

One C binary. Single-threaded by default: one poll(2) loop, one context struct, no shared state. The path from a byte on the wire to a pixel on screen is short, and the input bar gets to share it.

The source is split into a stack of modules with a compiler-enforced dependency chain. Each layer may only include the one beneath it, so a render change can't reach into the serial layer by accident.

core ← serial ← log ← proto ← render ← tui ← net ← ext ← loop. Layering the compiler enforces.

The hard parts have been thought about. Mouse selection in a scrollback buffer means mapping screen columns to byte offsets while stepping over invisible ANSI escapes and multi-byte UTF-8. The implementations are direct and small.

c
/* Visible column to byte offset. The buffer holds raw bytes
 * including ANSI SGR colour codes; the mapping has to step
 * over invisible escape runs to land on a real character. */
size_t col_to_byte(const char *s, size_t len, int col_target) {
    size_t i = 0;
    int    col = 0;
    while (i < len && col < col_target) {
        unsigned char b = (unsigned char)s[i];
        if (b == 0x1b) { i = skip_esc(s, len, i); continue; }
        /* advance by the UTF-8 character width carried in the lead byte */
        size_t step = (b & 0x80) == 0   ? 1
                    : (b & 0xE0) == 0xC0 ? 2
                    : (b & 0xF0) == 0xE0 ? 3
                    : 4;
        if (i + step > len) step = len - i;
        i += step;
        col++;
    }
    return i;
}

The optional --threaded reader holds a private dup(2) of the serial fd and drains it into a lock-free ring, so a close-and-reopen on the main thread (autobaud, manual reconnect, hot-unplug) can't race the read. Shutdown is the same trick in reverse: closing the worker's private copy makes its blocked read return at once, with nothing to wait on.


The audit I ran on my own code

A serial terminal sits in a more hostile spot than it looks. Two surfaces can quietly widen its trust boundary: a web page in your browser the moment --http is on, and the device on the far end of the cable, always. So at one point I stopped adding features and ran a source-review audit of my own code, reading the risky subsystems by hand and sweeping the rest for the usual classes: races, leaks, memory safety, integer overflow, protocol logic. Every suspected bug was re-checked against the source before it counted.

The sharpest finding was the browser bridge. The live HTTP view wrote its POST body straight to the serial line with no check on who sent it. A text/plain POST is a CORS "simple request", so any site you visited while the bridge was running could fire one at 127.0.0.1 and push commands onto your board: a reset, a bootloader write, a shell on the target, with nothing on screen to show for it. DNS rebinding reached it even without permissive CORS. The fix pins Host and Origin to a loopback literal by default and answers anything else with a 403, so the built-in UI still works with no config while a cross-site request is turned away. --http-token layers a bearer check on top for when you deliberately tunnel the port, and the bridge only ever offers read methods to other origins.

The local sockets got the same scrutiny. The --detach and --metrics sockets had been sitting in /tmp under the inherited umask, reachable by any other user on the machine. They moved to a per-user runtime directory, are created owner-only, and check the peer's user id on every connection. Permissions and peer-credentials rather than a token, since nothing in a browser can reach them.

What stayed with me isn't the list of fixes. It's the discipline around them: each defect class became a written invariant tied to the exact bug that taught it, so the same mistake can't quietly return. Fix the class, not the instance.


And the rest of the toolbox

Everything above is the daily-driver surface. The long tail, all verified working against the source tree:

AreaWhat you get
Framing & CRCraw / COBS / SLIP / HDLC / length-prefixed decoders, with none / CCITT / IBM / CRC-32 checks, cycled live with Ctrl + A F / K.
File transferXMODEM (CRC and checksum) and YMODEM send in-tree; YMODEM receive and ZMODEM shell out to lrzsz.
TransportsLocal serial, tcp://, and telnet:// (with IAC handling).
Logging & replaytext / JSONL / raw logs with size rotation, asciinema --rec casts, and --replay at any speed.
Browser bridge--http serves a live view over HTTP + SSE + WebSocket, with --metrics exposing Prometheus text on a UNIX socket.
Automation--on-match /RE/=CMD, --on-connect, --on-disconnect event hooks (a send: action injects TX); --filter pipes RX through any command.
Sessions--detach / --attach over a Unix socket; --profile config files with inotify hot-reload.
ExtrasHex view, autobaud probe, line-ending translation, a live settings dialog (Ctrl + A o: serial, screen, keyboard, logging), --diff of two captures, and a small embedding API (zyterm_embed.a).

The in-app controls all sit behind one Ctrl + A command menu; the network and automation features are launch-time flags. Ctrl + A then ? shows the full keybinding reference, dismissed by any key.


How it compares

A rough snapshot against the usual suspects on Linux. None of these are bad tools. Pick whatever fits.

Featureminicompicocomscreentiozyterm
Scrollback + in-stream search··
Live HUD (baud, throughput, sparkline)
USB hot-plug rediscovery
Network transports (tcp:// / telnet://)·
Timestamped + JSONL logging··
HTTP / SSE bridge for browsers
Single C binary, no runtime deps
✓ first-class · partial / via add-on — not supported

How it's built

One C binary. libc is the only runtime dependency, and clipboard support dlopens libxcb at runtime rather than linking it, so it works headless too. Linux is the supported target on purpose: termios2 custom baud rates, inotify for config reload, /sys/class/tty for USB discovery. It may build on other Unixes, but no binaries ship for them. MIT licensed.

Every push builds under gcc and clang, each with and without the address and undefined-behaviour sanitizers, and runs the test suite on all of them. The amd64 and arm64 .deb packages are built from the same source tree, the same way, every release, and a tag is refused unless it matches the version baked into the binary.


Decisions, on the record

A lot of the work here was deciding what not to build, and writing down why. The repo keeps three layers apart on purpose: the decisions (why a thing is the way it is), the reference (how it works today), and a set of invariants where each rule is tied to the exact bug that taught it. A safety property you have written down is one you can test and review, instead of one you hope holds.

A few of the calls:

  • Reconnect is on by default, because bench hardware is unreliable on purpose. You reset the board, reflash it, unplug the adapter to move it, and it comes back on a different /dev/ttyUSB*. A terminal that exits on hang-up throws away your session for an event that is routine and self-healing, so zyterm waits and re-resolves the port instead.
  • History stays in memory. A serial console is not a shell. Its "commands" are device-specific byte sequences for one board on one bench, and a password typed at a login prompt has no business landing in a world-readable dotfile. Connection profiles earn a file on disk; scratch history does not.
  • The unfinished fast path is not advertised. An experimental epoll-and-splice reader sits in the tree but is not wired into the loop, so it is not offered as a flag. The bar is plain: wired and tested, or removed. Until then it is not a feature.

Why it's here

For my portfolio, zyterm is a focused systems artifact: a tight poll(2) event loop, careful ownership across an optional reader thread, terminal UX that survives a device unplug, and a security posture (untrusted device RX, a loopback-pinned bridge, owner-only local control sockets) designed in rather than bolted on. Small, readable, and honest about what it doesn't do.

bash
sudo dpkg -i zyterm_*.deb
zyterm /dev/ttyUSB0 -b 115200

Quit with Ctrl + A then x. Pull the USB out whenever you like.