JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

libc, POSIX, and the Standard Streams

You have already watched a single syscall trap into the kernel and seen how a failed call reports itself through errno. This capstone widens the lens to the whole interface you actually program against: the libc wrapper that makes a kernel crossing look like an ordinary call, the POSIX standard that makes that menu portable, and the three streams the kernel opens for you before main() ever runs — including the one buffering surprise that bites almost everyone.

One name, two very different jobs

By now you have followed a system call all the way down: the trap instruction, the mode switch into the kernel, the work done on the other side, and the return value carried back. But there is a quiet sleight of hand you have been relying on the whole time. You never wrote a trap instruction. You wrote `read(fd, buf, 100)`, which looks for all the world like calling a function you could have written yourself. That illusion is the work of libc, the C standard library, and pulling it apart is the first job of this capstone.

Here is the key distinction to hold, sharper than before. A library call is an ordinary function that lives in user-mode code you linked into your program; calling it is just a jump, and it may never bother the kernel at all. A system call is the kernel crossing itself — the trap, the privilege switch, the guarded entry point. The trap that confuses people is that for I/O functions the two share a name. The libc function read() is a library call. Inside it, after arranging your arguments, it performs the actual read system call. So one source line is really two layers: a cheap user-mode wrapper on the outside, and a genuine, expensive border crossing buried in its belly. Keeping library calls and system calls distinct is the habit this whole rung has been building toward.

A quick test settles which is which for any function: does it ever need the kernel to get its job done? strlen(), memcpy(), and a malloc() that already had spare memory stay entirely in user mode — pure library calls. open(), fork(), and write() must cross — each wraps a real system call. printf() is the tricky middle case: it formats text in user-mode memory (library work) and then sometimes calls write() underneath (a syscall). When you want certainty rather than a guess, run the program under `strace ./a.out`, which prints one line per system call and so reads off exactly which calls crossed the line.

What the wrapper actually does

It is worth seeing the wrapper's job concretely, because once you do, the libc-versus-kernel boundary stops being a slogan and becomes a small, mechanical thing. The kernel does not take arguments the way a C function does; it expects them in specific registers and identifies which call you want by a number. So the wrapper is the translator between the C calling convention you know and the raw syscall numbering the kernel speaks. For each call, libc moves your arguments into the agreed registers, puts the syscall number in its register, executes the trap, and then — the part you most often forget exists — it reads the kernel's raw result and turns it into the tidy return-value-and-errno convention your C code expects.

/* roughly what the libc read() wrapper does on x86-64 Linux */
read(fd, buf, count):
        mov  rax, 0        ; rax = syscall number for read
        mov  rdi, fd       ; 1st arg
        mov  rsi, buf      ; 2nd arg
        mov  rdx, count    ; 3rd arg
        syscall            ; trap: cross into the kernel
        ; kernel returns in rax:
        ;   >= 0  -> a real byte count, hand it straight back
        ;   < 0   -> -errno; libc sets errno = -rax and returns -1
The wrapper in miniature: arrange registers, trap, then translate the kernel's raw result. The kernel signals failure as a small negative value; libc is what converts that into the return -1 plus errno you actually see.

Two honest caveats keep this from being a fairy tale. First, libc is not the only path: a program can issue the trap instruction itself and skip libc entirely, which is exactly what languages with their own runtimes (and the occasional hand-written assembly) sometimes do. The wrapper is a convenience, not a law. Second, the registers and the syscall number above are specific to x86-64 Linux; on a different CPU or a different kernel the numbers and registers differ, which is precisely the messy, per-platform detail that the next idea — POSIX — exists to paper over so you rarely have to care.

POSIX: the contract that makes it portable

If every system spoke its own syscall numbers and its own register layout, code written for Linux would be useless on macOS, and you would relearn file handling for each machine. The fix is a standard that stands one level above the raw numbers and fixes the shapes everyone agrees to: the function names, their argument types, and what each one promises to do. That standard is the POSIX API — Portable Operating System Interface — first published by the IEEE in 1988 and maintained ever since. POSIX does not specify the trap instruction or the syscall numbers; it specifies that there is an open() taking a path and flags, a read() taking a file descriptor, a buffer, and a count, and so on. Each system then implements those shapes however its kernel prefers underneath.

This is what makes the file-and-process vocabulary of this rung genuinely transferable rather than one operating system's quirk. The exact same C source — open() the file, check for -1, read() in a loop, close() it — compiles and runs on Linux, macOS, the BSDs, and other Unix-like systems, because each ships a libc whose wrappers honor the POSIX shapes. It is the same spirit as a language standard: agree on the interface in writing, and many independent implementations can interoperate. Note one boundary, though: POSIX is a Unix-family contract. Windows is not POSIX-native, so the same source needs a compatibility layer there; portability is a real promise, but it is a promise within a family, not a guarantee everywhere.

Three doors that are already open

Before your main() runs its first line, the kernel has already handed your process three open file descriptors. These are the standard streams, and you have used all three without naming them. Descriptor 0 is standard input, where your program reads from; descriptor 1 is standard output, where ordinary results go; descriptor 2 is standard error, where complaints and diagnostics go. When you call printf() it writes to descriptor 1; when perror() or an error message goes out, it writes to descriptor 2. The numbers are not arbitrary trivia — they are the literal file descriptors you would pass to read() and write().

The quiet genius of the standard streams is that your program does not know or care what is on the other end. You write to descriptor 1; whether that lands on a terminal, gets saved to a file, or is fed straight into another program is the shell's decision, not yours. That is exactly the machinery behind redirection and a pipeline: `./a.out > out.txt` quietly points descriptor 1 at a file before your program starts, and `./producer | ./consumer` wires the producer's descriptor 1 to the consumer's descriptor 0. Splitting normal output (1) from errors (2) is what lets you save results to a file while still seeing errors on screen — they travel on two separate descriptors, so the shell can route them independently.

The buffering surprise nearly everyone hits

There is one more layer between printf() and the kernel, and skipping it produces a bug that has confused generations of beginners. Calling write() per character would be brutally slow, because each call is a full kernel crossing. So libc's higher-level I/O — the stdio stream family: printf(), fputs(), putchar() — does not call write() every time. It collects your output in a user-mode buffer and only calls write() once the buffer fills or some trigger fires. This is pure library work sitting on top of the syscall: many printf() calls, far fewer actual crossings into the kernel. It is a real performance win, and it is also where the surprise lives.

The trigger depends on where output is going, and that is the trap. When standard output is a terminal, stdio is line-buffered: it flushes at every newline, so you see your prints immediately and all feels well. But when standard output is redirected to a file or a pipe, stdio switches to fully buffered — it waits until a few kilobytes pile up. So a program that prints fine on screen can appear to produce its output late, out of order relative to standard error, or — if it crashes before the buffer flushes — to lose its last lines entirely. Nothing was wrong with your logic; the bytes were sitting in a user-mode buffer that never got flushed. Understanding buffering modes and flushing is what turns this from a baffling ghost into an expected, controllable behavior.

The whole interface, in one frame

Stack the layers you now hold and the OS interface stops being a wall of jargon and becomes a clean ladder. At the bottom is the kernel, reachable only through the trap. One step up is the raw system-call interface — numbers and registers, the thing you saw in the trap-and-mode-switch guide. Above that sits libc, whose wrappers dress each crossing as an ordinary C call and translate failures into errno. Above that sits stdio, which buffers your output so a thousand printf() calls become a handful of write() crossings. And spanning the middle is POSIX, the written contract that fixes the names and shapes so the same source runs across the whole Unix family.

That ladder is the lasting picture from this entire rung. You began by drawing the line between user mode and the kernel; you watched a single call trap across it; you learned to read what the kernel hands back when a crossing fails; and now you can place every familiar I/O function on its correct rung and say, of any line of C, what it really costs and what can go wrong. From here the ladder keeps climbing — file descriptors and processes are next — but the shape never changes: your code on one side, the kernel on the other, and a small, standardized, checkable set of doorways in between, with libc and stdio making them comfortable to use.