// 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\nno 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
writeon 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 inerrno.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.cmust not definemain()โ the test file ownsmain. Where a challenge is fun to drive by hand (raw mode! the editor!) the starter includes a demomainfenced behind#ifdef DEMO; build it locally withcc -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 ptsNot 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 (witherrnoset toENOTTYfor non-terminals).ttyname(fd)โ the pathname of that device (e.g./dev/pts/3), orNULLif 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 ptsTime 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 callingwriteuntil allnbytes are written. Retry onEINTR. Returnnon success, -1 on any real error.read_full(fd, buf, n)โ keep callingreaduntilnbytes have arrived or end-of-file. Retry onEINTR. Return the number of bytes actually read (which is less thannonly at EOF), or -1 on a real error.
The tests are adversarial in exactly the ways the real world is:
- A child process reads your 256 KiB
write_allthrough a pipe in awkward 777-byte sips, so the pipe backs up and your write cannot complete in one call. - The pipe is filled to capacity before
write_allis even called, and the reader stays silent for over a second:write()blocks with zero bytes queued, and a non-restartingSIGALRMfires 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. - A child drips 64 KiB to your
read_fullin tiny bursts, so singlereadcalls return short over and over. - The same zero-progress trick, mirrored for
read_full: an empty pipe with no writer yet, soread()is blocked on nothing at all when the alarm fires โ the one case wherereadinterrupts into-1/EINTRinstead of a short count. - The writer closes early, and
read_fullmust return the short byte count rather than hanging or erroring.
Log in to submit a solution and earn points.