Linux System Programming · intermediate · ~10 min
## What you will learn - Open a file with `open()`, choosing the correct **flags** (`O_RDONLY`, `O_WRONLY`, `O_CREAT`, `O_TRUNC`, `O_APPEND`) and supplying a `mode` only when you create a file. - Move bytes in and out of a file descriptor with `read()` and `write()`, and interpret their return values correctly (`>0`, `0`, and `-1`). - Recognize and correctly handle **short reads and short writes** by looping until the work is done. - Retry a syscall safely when it is interrupted by a signal (`EINTR`). - Release every descriptor with `close()` and check the result, so you never leak descriptors or silently lose buffered data. - Write small, reusable helpers (`read_all` / `write_all`) that turn the raw syscalls into something safe to use everywhere.
In the previous lesson, File descriptors, you learned that the kernel hands your program a small non-negative integer — a file descriptor (fd) — to represent something it has opened on your behalf. By convention 0 is standard input, 1 is standard output, and 2 is standard error. This lesson is about the four system calls that let you create and use those descriptors yourself: open, read, write, and close.
These four calls are the bedrock of file I/O on Linux and other POSIX systems. The friendly C standard-library functions you may already know — fopen, fread, fwrite, fclose — are built on top of these syscalls. The stdio layer adds buffering and a FILE * wrapper for convenience. When you go one level down to open/read/write/close, you trade that convenience for direct, predictable control over exactly what the kernel does. That control is what device drivers, network servers, shells, and database engines need.
The single most important idea in this lesson is that read and write are requests, not guarantees. You ask to move up to N bytes; the kernel tells you how many it actually moved. A correct program never assumes the number it asked for is the number it got. Once that idea clicks, raw I/O stops being scary and becomes a small, dependable toolkit.
Terminology to keep straight as we go: a system call (syscall) is a request from your program into the kernel; a flag is a bit you OR together to describe how to open a file; a mode is the permission bits a newly created file should get; EINTR is the error code meaning "a signal interrupted me before I finished — try again."
Every byte that leaves or enters a running Linux program passes through these calls or something layered on them. A web server reading a request, a logger appending a line, cp copying a file, a backup tool streaming gigabytes — all of it is read and write under the hood.
Understanding the raw layer matters for three concrete reasons:
write always writes everything you gave it will silently corrupt data the first time it talks to a slow disk, a full pipe, or a network socket. The bug is invisible in testing and catastrophic in production.-1 with errno == EINTR. Programs that do not handle this crash or behave erratically when, for example, the terminal is resized or a timer fires.These are exactly the failure modes that distinguish toy code from software people can depend on.
open — turning a path into a file descriptoropen asks the kernel to set up access to a file named by a path and return a fresh file descriptor pointing at it.
int fd = open(path, flags, mode);
path — the filename (relative to the current directory, or absolute).flags — an integer built by OR-ing together one access mode and any number of behavior flags.mode — the permission bits for a file you are creating; only used when O_CREAT is in the flags, ignored otherwise.Exactly one access mode is required: O_RDONLY (read only), O_WRONLY (write only), or O_RDWR (both). Common behavior flags you OR on top:
| Flag | Meaning |
|---|---|
O_CREAT |
Create the file if it does not exist (then mode applies) |
O_TRUNC |
Truncate an existing file to length 0 |
O_APPEND |
Every write goes to the end of the file |
O_EXCL |
With O_CREAT, fail if the file already exists |
Internally, the kernel walks the path, checks permissions, finds the smallest unused descriptor number in your process, links it to an open file description (which holds the current offset and the flags), and returns that number. On failure it returns -1 and sets errno.
When to use which mode: A new file you create gets mode like 0644 (owner read/write, others read). The kernel then masks it with the process umask, so the file may end up with fewer bits than you asked for — that is expected.
Pitfall: passing a mode but forgetting O_CREAT does nothing useful (mode is ignored), and passing O_CREAT without a mode leaves the new file's permissions as garbage. Always pair them.
open("data.txt", O_RDONLY)
|
v
kernel: resolve path, check perms
|
v smallest free fd = 3
+-----------------------------+
| process fd table |
| 0 -> stdin |
| 1 -> stdout |
| 2 -> stderr |
| 3 -> open file description -> [offset=0, flags] -> inode(data.txt)
+-----------------------------+
|
v
returns 3
Knowledge check (concept): You call
open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644). What are two distinct behaviors this combination of flags produces?
read — pulling bytes inssize_t n = read(fd, buf, count);
read copies up to count bytes from the file (starting at the current offset) into buf, advances the offset, and returns how many bytes it actually copied. The three outcomes:
> 0 — that many bytes were placed in buf (may be fewer than count).0 — end of file. There is nothing more to read.-1 — an error occurred; check errno.A return smaller than count is a short read and is completely normal — especially from pipes, sockets, and terminals, but possible from regular files near EOF too. It is not an error.
When NOT to use a single read: never assume one read gives you a whole "message" or the whole file. If you need an exact number of bytes, loop.
Pitfall: read does not add a '\0' terminator. If you plan to treat buf as a C string, you must place the terminator yourself at offset n, and your buffer must be at least count + 1 bytes.
Knowledge check (find-the-bug):
char buf[64]; ssize_t n = read(fd, buf, 64); printf("%s\n", buf);— what is wrong, and what is the smallest fix?
write — pushing bytes outssize_t n = write(fd, buf, count);
write copies up to count bytes from buf to the file and returns how many it actually wrote. A return less than count is a short write. This is the failure mode the existing quiz asks about: a pipe can be full, a signal can interrupt mid-write, the disk can fill, or the kernel may simply choose to accept fewer bytes. The remaining bytes are your responsibility — you must call write again for the rest.
This is why every robust codebase has a write_all helper that loops until all bytes are sent or a real error occurs.
write_all(fd, buf, 1000)
buf: [################################] 1000 bytes
write -> returns 600 (short write)
p += 600; left = 400
buf: [############] 400 left
write -> returns 400
left = 0 -> done
When NOT to use a single write: any time correctness depends on all the bytes landing — which is almost always.
Pitfall: treating the short-write return as an error and aborting. It is not an error; it is a request to continue.
Knowledge check (predict-the-output): A program does
write(STDOUT_FILENO, "hi", 2)and ignores the return value. On a regular terminal this usually printshi. Name one situation where ignoring the return value would silently lose part of the output.
close — releasing the descriptorint rc = close(fd);
close tells the kernel you are done with the descriptor. The number becomes free for reuse, and when the last descriptor referring to a file is closed, kernel resources for it are released. close returns 0 on success and -1 on error.
Why check close's return value? On some filesystems the final flush of data happens at close time, so a deferred I/O error (like a full disk) is reported here, not at write. Ignoring it can mean believing data was saved when it was not.
Pitfall: "double close." Closing the same fd twice is a bug; in a threaded program the number may have been reused by another thread in between, and your second close then closes someone else's file. Set the variable to -1 after closing if there is any chance of reuse.
Lifetime of a descriptor:
open ----> [ fd in use: read / write / read ... ] ----> close
3 3 freed
Knowledge check (explain-in-your-own-words): Why might a disk-full error show up at
close()rather than at thewrite()that produced the data?
All four calls live in standard headers. You need <fcntl.h> for open and the O_* flags, <unistd.h> for read/write/close, and <errno.h> to read errno. The return types ssize_t (signed size, can be -1) and size_t come from <sys/types.h> (pulled in by the others).
#include <fcntl.h> // open, O_RDONLY, O_WRONLY, O_CREAT, O_TRUNC, ...
#include <unistd.h> // read, write, close, ssize_t
#include <errno.h> // errno, EINTR
#include <string.h> // strerror (for messages)
// Access mode is REQUIRED; behavior flags are OR-ed on:
int in = open("in.txt", O_RDONLY); // no mode: not creating
int out = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, // creating -> mode needed
0644); // rw-r--r-- before umask
char buf[4096];
ssize_t n = read(in, buf, sizeof buf); // up to 4096 bytes; n may be < 4096
if (n < 0) { /* error: inspect errno */ }
ssize_t w = write(out, buf, (size_t)n); // returns bytes actually written
if (close(in) == -1) { /* report */ }
if (close(out) == -1) { /* report */ } // close can surface a deferred error
Key rules encoded above: pick exactly one of O_RDONLY/O_WRONLY/O_RDWR; add mode only with O_CREAT; store results in a signed type because -1 is a valid return; and always pass a count, never assume the call honors it fully.
These four functions are the foundation of file I/O on Linux. Each works with a file descriptor (fd) — a small integer the kernel uses to identify an open file.
open(path, flags, [mode]) — opens a file. Returns a file descriptor on success, or -1 on failure.read(fd, buf, n) — reads up to n bytes into buf. Returns the number of bytes actually read.write(fd, buf, n) — writes up to n bytes from buf. Returns the number of bytes actually written.close(fd) — releases the file descriptor when you are done.read and write may transfer fewer bytes than you asked for. This is called a short read or short write, and it is normal — not an error.
For example, write(fd, buf, 1000) might return 600, meaning only 600 bytes were written. The remaining 400 still need to be sent.
write until every byte is sent.-1 signals an error.EINTR. This error code means a signal interrupted the syscall before it finished. The correct response is simply to retry the call.#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* Write exactly n bytes, looping over short writes and retrying on EINTR.
Returns 0 on success, -1 on a real error (errno set). */
static int write_all(int fd, const void *buf, size_t n) {
const char *p = buf;
size_t left = n;
while (left > 0) {
ssize_t w = write(fd, p, left);
if (w < 0) {
if (errno == EINTR) continue; /* interrupted by a signal: retry */
return -1; /* a genuine error */
}
p += w; /* advance past what was written */
left -= (size_t)w;
}
return 0;
}
/* Copy src -> dst using only raw syscalls. Returns 0 on success, -1 on error. */
static int copy_file(const char *src, const char *dst) {
int in = open(src, O_RDONLY);
if (in < 0) { perror("open src"); return -1; }
/* Create dst (or truncate it), owner rw, group/other read. */
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out < 0) { perror("open dst"); close(in); return -1; }
char buf[4096];
ssize_t r;
int status = 0;
/* read returns 0 at EOF, which ends the loop cleanly. */
while ((r = read(in, buf, sizeof buf)) != 0) {
if (r < 0) {
if (errno == EINTR) continue; /* retry the read */
perror("read");
status = -1;
break;
}
if (write_all(out, buf, (size_t)r) < 0) {
perror("write");
status = -1;
break;
}
}
/* Always close both. close can report a deferred write error on dst. */
if (close(in) < 0) { perror("close src"); status = -1; }
if (close(out) < 0) { perror("close dst"); status = -1; }
return status;
}
int main(int argc, char **argv) {
if (argc != 3) {
fprintf(stderr, "usage: %s <src> <dst>\n", argv[0]);
return 1;
}
if (copy_file(argv[1], argv[2]) < 0) return 1;
printf("copied %s -> %s\n", argv[1], argv[2]);
return 0;
}
What it does: This is a complete, minimal cp built only from open, read, write, and close. It opens the source read-only, creates/truncates the destination, then repeatedly reads a 4 KB block and writes it out with write_all. The read loop ends naturally when read returns 0 (EOF). Both descriptors are closed at the end, and close errors are reported.
Expected output (after building and running on a file that exists):
$ ./mycp notes.txt copy.txt
copied notes.txt -> copy.txt
If the source does not exist, you get a diagnostic from perror such as open src: No such file or directory and exit status 1.
Key edge cases: an empty source file produces an empty destination (the loop body never runs because read returns 0 immediately); a read interrupted by a signal is retried via EINTR; a short write inside write_all is handled by looping; and a disk-full condition may surface either at write or at close(out) — both are checked.
We trace copy_file("notes.txt", "copy.txt") where notes.txt contains the 5 bytes Hello.
open(src, O_RDONLY) resolves notes.txt, checks read permission, and returns the smallest free descriptor — say 3. in == 3.open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644) creates copy.txt (length 0) with permission bits 0644 masked by your umask, and returns the next free descriptor — say 4. out == 4.read(3, buf, 4096) copies all 5 available bytes into buf and returns 5. The file offset for fd 3 is now 5.r == 5, which is > 0, so we call write_all(4, buf, 5).write_all: left = 5. write(4, p, 5) returns, say, 5. p += 5, left = 0, loop ends, return 0.read(3, buf, 4096) is called again. The offset is already at end of file, so it returns 0 (EOF). The while condition r != 0 is false, so the loop exits.close(3) releases the source descriptor and returns 0. close(4) flushes and releases the destination, returns 0. status stays 0.copy_file returns 0; main prints copied notes.txt -> copy.txt and exits 0.| Step | Call | Returns | r |
left (in write_all) |
Offset of fd 3 |
|---|---|---|---|---|---|
| 3 | read(3,buf,4096) |
5 | 5 | — | 5 |
| 5 | write(4,buf,5) |
5 | — | 0 | 5 |
| 6 | read(3,buf,4096) |
0 | 0 | — | 5 (EOF) |
The crucial detail: the program never assumes read returned the file size or that write wrote everything. It reacts to the actual returned counts. Swap in a 10 MB file and the same code runs the read/write block thousands of times with no changes — that is the payoff of looping correctly.
write writes everything// WRONG: ignores the return value
write(fd, buf, n); // what if only half went out?
Why it is wrong: write may perform a short write (full pipe, signal, disk pressure). The unwritten bytes are silently dropped from the file. The bug rarely shows on small writes to a regular file, so it survives testing and corrupts data later.
// CORRECT: loop until all bytes are written
if (write_all(fd, buf, n) < 0) { perror("write"); /* handle */ }
Recognize it: any bare write(...) whose return value is unused is suspect. Prevent it by always routing writes through a write_all helper.
// WRONG: aborts on a perfectly normal partial read
ssize_t r = read(fd, buf, count);
if (r != count) { fprintf(stderr, "read failed\n"); exit(1); }
Why it is wrong: r < count is normal, and r == 0 means clean EOF, not failure. This code rejects valid data.
// CORRECT: only -1 is an error; 0 is EOF; >0 is data
ssize_t r = read(fd, buf, count);
if (r < 0) { /* real error */ }
else if (r == 0) { /* EOF: stop */ }
else { /* use r bytes */ }
mode with O_CREAT// WRONG: O_CREAT but no mode argument -> file gets unpredictable permissions
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC);
Why it is wrong: when O_CREAT is present the kernel reads a third argument for the permission bits; omitting it leaves whatever garbage was on the stack/register as the mode. The file may end up unreadable or world-writable.
// CORRECT: supply mode whenever O_CREAT is used
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// WRONG: returns without closing 'in' when the second open fails
int in = open(src, O_RDONLY);
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out < 0) return -1; // 'in' is now leaked
Why it is wrong: each leaked descriptor consumes a slot; a long-running program eventually hits its per-process limit and every later open fails with EMFILE.
// CORRECT: clean up what you already acquired
int in = open(src, O_RDONLY);
if (in < 0) return -1;
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out < 0) { close(in); return -1; }
read result as a C string without terminating// WRONG: buf is not NUL-terminated
ssize_t n = read(fd, buf, sizeof buf);
printf("%s\n", buf); // reads past the data
// CORRECT: leave room for '\0' and terminate at n
char buf[4097];
ssize_t n = read(fd, buf, 4096);
if (n >= 0) { buf[n] = '\0'; printf("%s\n", buf); }
implicit declaration of function 'open' / 'read': you forgot #include <fcntl.h> (for open) or #include <unistd.h> (for read/write/close). Add the headers.'O_RDONLY' undeclared: same cause — <fcntl.h> defines the O_* flags.comparison is always true/false or sign warnings: you stored the result of read/write in an int or unsigned type. Use ssize_t so -1 is representable and comparisons work.errno)Always print the reason with perror("context") or strerror(errno). Common values:
errno |
Typical cause | Fix |
|---|---|---|
ENOENT |
Path does not exist (and no O_CREAT) |
Check the path; add O_CREAT if creating |
EACCES |
No permission for the requested mode | Check file permissions / ownership |
EMFILE |
Too many open descriptors | You are leaking — close what you open |
EINTR |
A signal interrupted the call | Retry the call in a loop |
ENOSPC |
Disk full | Free space; surfaces at write or close |
EBADF |
The fd is not open (e.g. double close) | Do not reuse a closed fd |
sizeof buf) instead of just the r bytes that read returned. Always write exactly r bytes.read waits; that is expected for interactive input.read (see Mistake 5).-1?read returned, or am I assuming a count?mode with O_CREAT?close every descriptor on every path, including error paths?errno/perror actually say? (Read the message before guessing.)Tools: strace ./prog shows every syscall with its arguments and return value — invaluable for seeing the exact open/read/write/close sequence and where it failed.
These syscalls move raw bytes, so the safety burden is on you:
count to read/write larger than the buffer. Use sizeof buf for stack arrays (it counts bytes for char arrays) and track the allocated size for heap buffers. A count bigger than the buffer is a classic overflow.read fills only data bytes; it does not append '\0'. Treating the buffer as a C string without terminating it at the returned length reads out of bounds. Size the buffer as N + 1 and set buf[n] = '\0' when you need a string.n bytes of buf are valid after read returns n. Reading or printing beyond index n-1 accesses uninitialized memory — undefined behavior.read/write return ssize_t (signed). Storing the result in size_t (unsigned) turns the -1 error into a huge positive number, and your loop arithmetic (left -= w) can underflow into a giant value. Keep the return in ssize_t and only cast to size_t after confirming it is >= 0.close (use-after-close) or closing it twice (double-close) is undefined and, in threaded code, can corrupt unrelated files because the number may have been reused. Set fd = -1 after closing if reuse is possible.close so a deferred ENOSPC is not mistaken for success.Where these calls actually run:
cp, cat, dd, and tee are essentially the read/write loop you built above, plus options.read/write calls — and short reads/writes are the norm on sockets, making read_all/write_all mandatory.O_APPEND) to control exactly when bytes are durable./dev are read and written with these same four calls; talking to a sensor or serial port is open("/dev/...") then read/write.Professional best practices
Beginner rules:
-1 means stop and inspect errno.O_CREAT with an explicit mode.close everything you open, on every code path, and check the result.Advanced habits:
read_all/write_all helpers so the looping and EINTR logic lives in exactly one place.fsync(fd) before close so data reaches the physical device, and handle the error it can report.O_RDONLY if you only read), and use O_EXCL with O_CREAT when you must not clobber an existing file — both reduce the blast radius of bugs.Objective: Re-implement a tiny cat. Requirements: Open the file given as argv[1] read-only; in a loop, read into a 1024-byte buffer and write exactly the returned number of bytes to STDOUT_FILENO; stop at EOF; close the file. I/O example: ./mycat hello.txt prints the file's contents. Constraints: raw syscalls only — no fopen/printf for the file data. Hints: the read loop ends when read returns 0; write r bytes, not sizeof buf. Concepts: open, read, write, EOF handling.
Objective: Append a timestamped-style message to a log file. Requirements: open the path with O_WRONLY | O_CREAT | O_APPEND and mode 0644; build a line in a buffer ending with '\n'; write all of it; close and check the result. Constraints: must not truncate the file on each run (no O_TRUNC). Hints: verify by running twice and confirming both lines are present. Concepts: flags (O_APPEND vs O_TRUNC), mode with O_CREAT, checking close.
write_allObjective: Implement int write_all(int fd, const void *buf, size_t n) returning 0 on success, -1 on error. Requirements: loop until all n bytes are written; on -1 with errno == EINTR, retry; on any other -1, return -1. Constraints: must work even if write returns a small count each time. Hints: advance a const char * pointer and decrement a size_t left; never lose track of how many bytes remain. Concepts: short writes, EINTR, pointer arithmetic.
read_exactlyObjective: Implement ssize_t read_exactly(int fd, void *buf, size_t n) that reads exactly n bytes, returning n, or fewer than n only if EOF is hit first, or -1 on error. Requirements: loop calling read; on 0 (EOF) stop and return how many you got; retry EINTR. I/O example: asking for 100 bytes from a 40-byte file returns 40. Constraints: no over-read past the buffer. Hints: distinguish the three read outcomes carefully. Concepts: short reads, EOF, loop termination.
Objective: Write int safe_replace(const char *path, const void *data, size_t n) that writes data to path without leaving a half-written file if something fails midway. Requirements: open a temporary file in the same directory with O_WRONLY | O_CREAT | O_EXCL; write_all the data; fsync; close; then rename(tmp, path) to swap it into place atomically. On any error, close and unlink the temp file and return -1. Constraints: handle every error path with full cleanup; no descriptor or temp-file leaks. Hints: rename within the same filesystem is atomic, which is why the temp file must be in the same directory; O_EXCL prevents clobbering an unexpected leftover. Concepts: O_CREAT|O_EXCL, fsync, error-path cleanup, atomic replace pattern.
open, read, write, and close are the four core POSIX I/O system calls. open turns a path into a file descriptor; read/write move bytes using that descriptor; close releases it.open(path, flags, mode): pick exactly one access mode (O_RDONLY/O_WRONLY/O_RDWR), OR on behavior flags (O_CREAT, O_TRUNC, O_APPEND, O_EXCL), and supply mode (e.g. 0644) only when O_CREAT is present.read and write are requests, not guarantees. read returns >0 (bytes read), 0 (EOF), or -1 (error); write may write fewer bytes than asked (short write). Always loop until the work is done.EINTR by retrying the call. Store results in ssize_t so -1 is representable. read does not NUL-terminate.write's return value, treating a short read or EOF as an error, forgetting mode with O_CREAT, leaking descriptors on error paths, and printing an unterminated buffer.EINTR, and close (and check close) everything you open. Centralize it in read_all/write_all helpers and the rest of your I/O code stays simple and correct.