Two layers, not one
In guide 2 you wrote bytes straight to the kernel: `write(fd, buf, n)` is one system call, one crossing of the user/kernel line, every single time. That is raw I/O — direct, predictable, and exactly as expensive as the previous guide warned, because each call pays the full cost of entering the kernel and coming back. Most programs, though, do not call `write()` per line of output. They call friendly functions like `printf()`, `fputs()`, `fgetc()`, and `fread()`. Those belong to a second layer that sits on top of the raw calls, and understanding that this layer exists is the whole point of this guide.
That second layer is stdio, the standard I/O part of the C library, and its job is to hold a stream — a buffered wrapper around a raw file descriptor. When you `printf("hello\n")`, the bytes do not rush off to the kernel one at a time. They are copied into a chunk of memory inside libc called a buffer, and they sit there. Only when the buffer fills up, or some rule fires, does libc make one big `write()` to flush the whole buffer across the line at once. So the picture is a chain: your program, then the stdio buffer in user memory, then a single raw syscall, then the kernel. The difference between unbuffered and buffered I/O is simply whether that middle box is there.
RAW I/O BUFFERED stdio
------- --------------
putc-style, but each one printf("hi\n"); -> buffer in libc
is a real syscall: "hi\n" (still in user memory)
write(1,"h",1) -> kernel printf("there\n"); -> buffer grows
write(1,"i",1) -> kernel "hi\nthere\n" (still not sent)
write(1,"\n",1) -> kernel ...buffer fills OR program flushes...
write(1,"hi\nthere\n...",N) -> kernel
3 crossings, 3 bytes 1 crossing, N bytesWhy the buffer earns its keep
Why bother with this middle box at all? Because crossing into the kernel is genuinely, measurably expensive — recall that a system call is the priciest kind of "function call" you can make. Imagine printing a 10 KiB log file one character at a time. Done raw, that is over ten thousand separate `write()` syscalls, ten thousand crossings of the line. Done through stdio, those ten thousand characters quietly pile up in a buffer (commonly a few KiB), and libc issues just a handful of `write()` calls to drain it. Same bytes arrive at the kernel; a thousand-fold fewer crossings. The buffer trades a little user-mode memory and copying for an enormous cut in syscall count.
The same trick works in reverse for input. When you call `fgetc()` to read one character, stdio does not perform a one-byte `read()` syscall. It performs one larger `read()` — pulling, say, 4 KiB from the kernel into its input buffer — and then hands you characters out of that buffer, one at a time, with no kernel crossing at all, until the buffer empties and it quietly refills. So a loop that reads a file character by character through stdio is not as slow as it looks: nearly every `fgetc()` is just a cheap memory fetch, and only one in a few thousand actually touches the kernel. This is the buffer paying you back on both sides of the door.
Three buffering modes, and how the rule is chosen
If the buffer only ever flushed when full, an interactive prompt would be maddening — you'd type a question and the program's "What is your name? " would sit invisibly in a buffer, never reaching the screen until much later. So stdio has three buffering modes, and it picks one automatically based on what the stream is connected to. Fully buffered: flush only when the buffer fills (used for ordinary files — speed matters, immediacy doesn't). Line buffered: also flush whenever a newline `\n` is written (used when the stream is a terminal — you want each line to appear as it's completed). Unbuffered: flush instantly, every write goes straight out (this is standard error's default, so error messages are never trapped in a buffer when a program is about to die).
Here is the subtle, important consequence. The mode is usually chosen by asking "is this stream a terminal?" That means the same program behaves differently depending on where its output goes. Run `./a.out` in a terminal and standard output is line buffered, so each `printf` line appears promptly. Run `./a.out > file.txt` or pipe it into another program, and standard output is now connected to a file or pipe, not a terminal — so it becomes fully buffered, and your lines may not appear in `file.txt` until the buffer fills or the program ends. Nothing is broken; the rule simply changed under your feet. This single fact explains a remarkable number of "my output vanished" mysteries.
The flush, and the trap of "written"
Because buffering means "written" no longer means "gone to the kernel," stdio gives you a way to force the issue: `fflush(stream)`. It tells stdio to push everything currently in that stream's buffer out through a `write()` right now, regardless of mode. Calling `fflush(stdout)` after a prompt is the honest fix for the interactive case above — it guarantees "What is your name? " reaches the screen before you call `fgets()` to read the answer. You don't usually need it in line-buffered terminal output (the newline already flushes), but the moment your output is redirected to a file or pipe, an explicit flush is often the difference between seeing your progress and staring at an empty file.
And now an honest line you must not blur, because it is the deepest point in this guide: even a successful `fflush()` does not mean your bytes are safely on the disk. `fflush()` moves data from the libc buffer to the kernel — it crosses one layer. But the kernel has buffers of its own (the page cache), and your data may sit there, unwritten to the physical drive, for a while longer. Getting bytes all the way down to durable storage is a separate, stronger guarantee that needs a separate syscall — `fsync()` on the descriptor. The full story of flushing and durability is its own topic; the rule to carry now is that there are two buffers between your `printf` and the spinning platter, and `fflush()` only empties the first.
Which layer should you reach for?
So which do you use, raw or buffered? A good default: reach for buffered stdio for ordinary text — reading a config file, printing human-readable output, processing lines — because it is convenient, portable, and fast for the small, frequent reads and writes that buffering was built for. Drop down to raw `read()`/`write()` when you need precise control: large block transfers where you manage your own buffer anyway, talking to a network socket or pipe where message timing matters, low-latency or real-time code where a hidden buffer would be a liability, or any moment you genuinely need a byte to cross the kernel line now. Neither layer is "better"; they are different tools for the buffered-versus-unbuffered tradeoff.
One steady piece of ground under all of this: the standard streams you met as descriptors 0, 1, and 2 are the same streams stdio wraps as `stdin`, `stdout`, and `stderr`. `printf()` is just `fprintf(stdout, ...)`, and `stdout` is a buffered `FILE *` whose underlying descriptor is 1. That is why everything connects: the raw layer from guide 2 and the buffered layer here are not two separate worlds but two heights on the same ladder, and the next guides — on the filesystem beneath the descriptor, and on redirection and pipes — keep climbing the same one. Choose your layer on purpose, remember that "written" can mean "buffered," and stdio will serve you well.