// lesson: unix-io

Unix I/O and File Descriptors

A terminal emulator is, at bottom, a program that moves bytes: bytes in from a keyboard, bytes out to a screen, bytes to and from files. Before we touch a single escape code, we need to be precise about how Unix moves bytes โ€” because every bug you will hit later (garbled screens, hung reads, half-written files) is really a misunderstanding of this layer.

File descriptors are just integers

When a process opens a file, the kernel gives it back a small integer โ€” a file descriptor (fd). The integer indexes into a per-process table the kernel keeps; the table entry points at an open file description (offset, flags) which points at the actual thing: a file on disk, a pipe, a socket, or โ€” the case we care about โ€” a terminal device.

Every process starts life with three of them already open:

  • 0 โ€” stdin: where input comes from
  • 1 โ€” stdout: where normal output goes
  • 2 โ€” stderr: where errors go, deliberately separate so they survive when stdout is redirected into a file

unistd.h gives them names: STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO. The two primitive operations are:

#include <unistd.h>

ssize_t nread   = read(fd, buf, count);   /* read up to count bytes  */
ssize_t nwritten = write(fd, buf, count); /* write up to count bytes */

The word up to is doing a lot of work in those comments, and it is the single most important fact in this lesson. We'll come back to it.

The beautiful part of the design is that read and write don't care what's on the other end. The same two calls work whether fd 0 is your keyboard, a pipe from another program, or a file. That's why

$ ./myprog            # stdin is a terminal
$ ./myprog < in.txt   # stdin is a file
$ cat in.txt | ./myprog  # stdin is a pipe

all run the same code. Your terminal emulator will exploit this constantly โ€” and its test suite will too, feeding it pipes and pseudoterminals where a human would be typing.

Syscalls vs. stdio: why we use write(), not printf()

You've used printf and fgets since your first C program. Those are stdio โ€” a user-space buffering library wrapped around read and write. When you printf("hi"), nothing reaches the kernel yet: the bytes go into a buffer inside your process, and stdio flushes that buffer later โ€” when it's full, when you print a newline (if stdout is a terminal), or when the process exits.

That buffering is a performance win for ordinary programs and a correctness hazard for a terminal program:

  • We will disable output processing (OPOST, next lessons), at which point \n no longer flushes and no longer means what stdio thinks it means.
  • We need byte-exact control over what goes to the terminal and when. An escape sequence that gets flushed in two halves at the wrong moment paints garbage.
  • Mixing stdio and raw write on the same fd interleaves unpredictably, because half your output is sitting in a buffer the kernel has never seen.

So the rule for this course: talk to the terminal with read(2) and write(2) directly. We'll still use snprintf โ€” but only to format bytes into our own buffers, which we then hand to write ourselves.

Short reads and short writes

Here is the contract, precisely:

  • read(fd, buf, n) returns the number of bytes actually read, which may be anything from 1 to n when data is available. It returns 0 only at end-of-file (the other end of the pipe closed; the file ran out). It returns -1 on error, with the reason in errno.
  • write(fd, buf, n) returns the number of bytes actually written โ€” again possibly less than n. -1 on error.

A short read is not an error and not rare. Read from a terminal and you get whatever the user has typed so far, not the 512 bytes you asked for. Read from a pipe and you get whatever the writer has written. Even reads from regular files can return short at (say) an EOF boundary. Any code that assumes read(fd, buf, n) fills the buffer is wrong code that happens to pass its first test.

Short writes are rarer โ€” writes to pipes and terminals usually either block until complete or fail โ€” but they happen exactly when you can least afford them: the pipe is nearly full, or a signal arrives mid-write (see below). Production code loops:

/* Keep calling write() until all n bytes are gone. */
size_t off = 0;
while (off < n) {
    ssize_t w = write(fd, buf + off, n - off);
    if (w < 0) { /* error handling here */ }
    off += (size_t)w;
}

EINTR: the signal in the middle

Unix delivers signals (Ctrl+C's SIGINT, window-resize's SIGWINCH, timers' SIGALRM) asynchronously. If a signal arrives while your process is blocked inside read or write, and the handler was installed without the SA_RESTART flag, the syscall gives up and returns -1 with errno == EINTR โ€” "interrupted, nothing wrong, try again."

This matters enormously for a terminal program because we want to be interrupted: when the user resizes the window, SIGWINCH must be able to break us out of a blocked read so we can repaint at the new size. The price is that every read/write in the program must treat EINTR as "retry", not "fail":

ssize_t r;
do {
    r = read(fd, buf, n);
} while (r < 0 && errno == EINTR);

One wrinkle worth knowing before you write that loop for real: a read/write that gets interrupted after it has already transferred some bytes does not return -1 at all โ€” it returns however many bytes it moved before the signal landed, exactly like an ordinary short read/write. -1/EINTR only happens when the call is interrupted with zero bytes transferred so far. In practice this means the short-read/short-write loop you're about to write already absorbs most interruptions for free (off += n just keeps going); the explicit errno == EINTR check only earns its keep at the narrower zero-progress moment โ€” which is also why a test that wants to prove your retry logic works has to go out of its way to catch a syscall stuck at exactly zero bytes, rather than just firing a timer at some arbitrary point mid-transfer.

You will write write_all and read_full exactly once, in the next challenge, and then use them for the rest of the course.

Watching it happen

Two tools make this layer visible, and you should actually run them:

$ strace -e trace=read,write ./yourprog

prints every read/write syscall your program makes โ€” arguments, buffers, return values. Watch a printf-based program make one big write at exit, then watch a raw-write program make exactly the calls you wrote. And:

$ ls -l /proc/self/fd/
lrwx------ 0 -> /dev/pts/3
lrwx------ 1 -> /dev/pts/3
lrwx------ 2 -> /dev/pts/3

shows where a process's fds actually point โ€” here, all three at the same terminal device, /dev/pts/3. That device file is the subject of the next lesson.

How this course works

Each challenge gives you a solution.c starter with TODOs. The grader compiles your file together with a hidden-in-plain-sight test program (shown in each challenge) as:

cc -std=c17 -Wall -O1 -o test_bin solution.c test_solution.c

Two consequences you should internalize now:

  • Your solution.c must not define main() โ€” the test file owns main. Where a challenge is fun to drive by hand (raw mode! the editor!) the starter includes a demo main fenced behind #ifdef DEMO; build it locally with cc -std=c17 -Wall -DDEMO solution.c -o demo && ./demo.
  • The grader has no interactive terminal โ€” tests run headless. So the course is engineered the way real terminal code is engineered: logic lives in pure functions over buffers and structs, and where a real device is genuinely needed, the tests conjure one with posix_openpt(). You'll build that muscle too.

โ€บ Inspect the Terminal

10 pts

Not every fd is a terminal, and programs change behavior based on the difference: ls prints columns to a tty but one-name-per-line into a pipe; grep colors matches only on a tty. Two calls answer the question:

  • isatty(fd) โ†’ 1 if fd refers to a terminal device, 0 otherwise (with errno set to ENOTTY for non-terminals).
  • ttyname(fd) โ†’ the pathname of that device (e.g. /dev/pts/3), or NULL if fd isn't a terminal.

Write describe_fd(), which fills a caller-supplied buffer with a one-line human-readable description of an fd:

  • If the fd is a terminal: tty <name> (e.g. tty /dev/pts/3).
  • If the fd is a terminal but the name can't be determined: tty (name unknown).
  • Otherwise: not a tty.

Return 1 if the fd was a terminal, 0 if not. Use snprintf to build the string โ€” never assume the caller's buffer is big enough.

The tests exercise your function against a pipe (not a tty) and against a real pseudoterminal device the test creates itself โ€” proof that "is this a terminal?" is a property of the device behind the fd, not of how the program was launched.

Log in to submit a solution and earn points.

โ€บ Reliable Reads and Writes

15 pts

Time to bake the short-read/short-write/EINTR rules into two helpers you'll use for the rest of the course:

  • write_all(fd, buf, n) โ€” keep calling write until all n bytes are written. Retry on EINTR. Return n on success, -1 on any real error.
  • read_full(fd, buf, n) โ€” keep calling read until n bytes have arrived or end-of-file. Retry on EINTR. Return the number of bytes actually read (which is less than n only at EOF), or -1 on a real error.

The tests are adversarial in exactly the ways the real world is:

  1. A child process reads your 256 KiB write_all through a pipe in awkward 777-byte sips, so the pipe backs up and your write cannot complete in one call.
  2. The pipe is filled to capacity before write_all is even called, and the reader stays silent for over a second: write() blocks with zero bytes queued, and a non-restarting SIGALRM fires right there. That's the case that actually produces -1/EINTR (see the previous lesson's wrinkle) โ€” the test has to engineer a truly-stuck-at-zero write on purpose, because an interrupt after any partial progress would just look like an ordinary short write.
  3. A child drips 64 KiB to your read_full in tiny bursts, so single read calls return short over and over.
  4. The same zero-progress trick, mirrored for read_full: an empty pipe with no writer yet, so read() is blocked on nothing at all when the alarm fires โ€” the one case where read interrupts into -1/EINTR instead of a short count.
  5. The writer closes early, and read_full must return the short byte count rather than hanging or erroring.

Log in to submit a solution and earn points.