Working with Files
Lesson 2 of 2
2Working with Files (C - File I/O)
Moving from working only with memory to interacting directly with the operating system to read and write files, through low-level system calls instead of the usual standard-library functions like fopen.
What the picture shows: open returns a descriptor such as 3 (0, 1 and 2 are input, output and errors), every read and write uses it, and close frees it.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
So far you asked the C library to read files for you, like asking a clerk to fetch a file from the archive. Now you walk to the archive window yourself and deal with the system directly: open, read a set number of bytes, write, and close.
The open, read, write and close system calls work with a numeric file descriptor, flags like O_RDONLY and O_CREAT, and permissions like 0644. read may return less than you asked, so you read in a loop and check the return value and errno at every step.
Before You Start
🐚System calls
Review the idea of a system call as a direct interface with the OS kernel, the same idea used by fork and execve in the Integration Projects guide. The key difference: a system call transfers execution from user mode to kernel mode, and this transition is slower than calling an ordinary library function.
🧵Pointers and buffers
read() and write() work with a buffer that is effectively a char array and a pointer to it, you need a basic understanding of how arrays are passed as pointers in C and how their size is determined with sizeof.
🔢Bitwise operations
Open flags like O_WRONLY | O_CREAT | O_TRUNC are combined with bitwise OR, review how | and & work at the bit level to understand how these flags are read and assembled together.
The Core Idea: Four Calls That Build Any File Operation
Unlike fopen/fread (standard-library functions that add a buffering layer), the following four system calls interact directly with the OS kernel:
Figure 1, every file operation goes through this triple loop: open, then read/write (possibly several times), then close.
1File Descriptor
A small integer returned by open() that represents a "handle" to the open file, every subsequent read, write, and close call uses this number to refer to the same file. In practice, every process owns its own file descriptor table, a small array of integers, with 0, 1, and 2 already reserved for stdin, stdout, and stderr. Each slot in that table points to an entry in the kernel's open-file table, and that entry in turn holds the current read/write offset and a pointer to the file's actual data on disk. That's why opening the same file twice with two separate open() calls gives you two different descriptors with completely independent offsets, even though both ultimately point to the same file.
2Open flags
O_RDONLY for read-only, O_WRONLY for write-only, O_CREAT to create the file if it doesn't exist, O_TRUNC to truncate its contents, these flags are combined with a bitwise OR. That works because each flag is defined as a distinct bit within the same integer (powers of two like 1, 2, 4...), so combining them with | turns on every requested bit at once without any of them overwriting another, and the kernel checks each bit separately to decide how to open the file. Note that O_RDONLY is usually just 0, so it isn't really a bit at all and can't be combined with O_WRONLY (the access mode itself can't be both read and write at once without O_RDWR), unlike O_CREAT and O_TRUNC, which behave like add-on modifiers layered on top of either access mode.
3The return value
read() and write() return the number of bytes actually read/written, it can be less than requested, and -1 means the operation failed. The reason is that neither call guarantees transferring the full requested count in one go: a read can stop early at end-of-file and return fewer bytes than the buffer size asked for, both calls can be interrupted by a signal before finishing, and pipes or sockets may simply have less data available at that exact moment. That's why any code that needs a specific number of bytes transferred reliably has to call read()/write() inside a loop that keeps going until the full count is reached or the end of the file is hit.
📍Seeking (lseek)
lseek(fd, offset, whence) moves the read/write position inside the file without reading or writing a single byte. whence sets the reference point: SEEK_SET from the file's start, SEEK_CUR from the current position, SEEK_END from the file's end. Calling lseek(fd, 0, SEEK_END) returns the file's size in bytes, a common technique for finding out the size before allocating memory to read it.
Comparison: System Calls vs. Standard Library Functions
| Criterion | System Calls (open/read/write/close) | Library Functions (fopen/fread/fwrite/fclose) |
|---|---|---|
| Buffering | No buffering, every read()/write() goes straight to the kernel | Automatic user-space buffering, dramatically reduces the number of actual system calls |
| Performance | Slower for small, frequent reads (each byte = a full system call) | Faster for small, frequent reads thanks to buffering, but slower for direct transfer of large blocks |
| Control | Full control: file descriptors, precise flags, manual seeking with lseek | Simpler to use but less control, can't access the file descriptor directly without fileno() |
How It Works, Step by Step: Copying a File's Contents
As a direct application of this four-call pattern, here is a function that copies a file's entire contents from a source to a destination using the four calls directly instead of fopen. The function follows exactly this sequence: open the source for reading only, open the destination (creating it if it doesn't exist and truncating it if it does) for writing only, then a repeated read/write loop, and finally close both descriptors. Notice that the two open() calls here are completely independent of each other: each one creates a brand-new entry in the kernel's open file table, with its own offset starting at zero, so even if the source and destination happened to be the same file on disk (a rare but possible case), fd_src and fd_dst would still advance through two completely separate offsets with no effect on each other, exactly as explained in the File Descriptor card above.
#include <fcntl.h>
#include <unistd.h>
int copy_file(const char *src, const char *dst)
{
int fd_src, fd_dst;
char buf[1024];
ssize_t n;
fd_src = open(src, O_RDONLY);
if (fd_src == -1)
return (-1);
fd_dst = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd_dst == -1)
{
close(fd_src);
return (-1);
}
while ((n = read(fd_src, buf, sizeof(buf))) > 0)
write(fd_dst, buf, n);
close(fd_src);
close(fd_dst);
return (0);
}
Figure 2, Notice the while loop: reading happens in chunks the size of the buffer instead of reading the whole file at once, because the file's size might exceed the memory available.
Figure 3, the copy loop repeats one buffer-sized chunk at a time; it stops automatically once read() returns 0, meaning it has reached the end of the source file.
The value 0644 passed as open()'s third argument only matters at all when O_CREAT is actually among the flags, it's the permission mode for the new file in case open() has to create one, expressed in octal: the first digit (6) for the owner (read and write), the second and third (4 and 4) for the group and everyone else (read only), and even this value isn't applied literally as written; the kernel first ANDs it with the complement of the calling process's umask before it becomes the actual permission that lands on disk. The while loop, meanwhile, depends entirely on what read() returns: a value greater than zero is the number of bytes actually read in that particular chunk (which can be less than 1024 once the source gets close to its end), zero specifically, not any negative value, is the official end-of-file (EOF) signal, and -1 means the read genuinely failed. Note too that, for simplicity, the example above doesn't check write()'s return value the same way; production-ready code actually needs to check both calls inside a complete loop, exactly as flagged in the "Return Value" card above.
Figure 4, every open() call, even from the same process and on the same file, creates an independent entry with its own offset in the kernel's open file table; only that entry holds a pointer to the actual inode that carries the file's real data on disk.
Worked Example: Reading a Text File Line by Line
Another practical example of the same pattern: a function that reads a text file and prints each line with its number. The challenge here is that read() has no concept of "line", it just transfers bytes, so we have to search manually for the newline character '\n' inside the buffer:
#include <fcntl.h>
#include <unistd.h>
void print_lines(const char *path)
{
int fd = open(path, O_RDONLY);
if (fd == -1)
return;
char buf[4096];
ssize_t n;
int line_num = 1;
int col = 0;
/* print first line number */
char header[16];
int hlen = 0;
int tmp = line_num;
header[hlen++] = '0' + tmp;
header[hlen++] = ':';
header[hlen++] = ' ';
write(1, header, hlen);
while ((n = read(fd, buf, sizeof(buf))) > 0)
{
ssize_t i;
for (i = 0; i < n; i++)
{
write(1, &buf[i], 1);
if (buf[i] == '\n')
{
line_num++;
hlen = 0;
tmp = line_num;
if (tmp >= 10) header[hlen++] = '0' + tmp / 10;
header[hlen++] = '0' + tmp % 10;
header[hlen++] = ':';
header[hlen++] = ' ';
write(1, header, hlen);
}
}
}
write(1, "\n", 1);
close(fd);
}
Notice that the function reads the file in large chunks (4096 bytes) to minimize system calls, but processes each byte individually to search for '\n'. This pattern (read in chunks + process byte by byte) is extremely common in system tools, cat -n, grep, and wc all work on roughly the same principle.
write()'s return value as well.
Myth vs. Reality
| Myth | Reality |
|---|---|
| System calls are always better than library functions | Not necessarily, fread()/fwrite() add buffering that dramatically reduces the number of actual trips to the kernel. Reading a file byte by byte with read() makes a full system call per byte (very slow), while fgetc() reads a full block once and returns bytes one at a time from user-space memory. |
| Closing a file is optional, the program cleans everything up on exit | The OS does close file descriptors when a process terminates, but relying on that is a bad habit: the program might open files in a loop (exhausting descriptors before it finishes), and buffered data in FILE* may not actually get written to disk if not explicitly closed. |
| File descriptors 0, 1, or 2 can't be closed or reopened | They can be closed just like any other descriptor, and that's exactly the mechanism used for redirection: close stdout (descriptor 1), then open a new file, the system assigns it the first available number, which is the 1 that was just closed, so everything written to stdout now goes to that file. |
Practical Applications
📝Log Files
Any web server or backend application writes events to log files, opening the file with O_WRONLY | O_APPEND | O_CREAT ensures that every new write is appended at the end without erasing what came before, even if multiple processes write to the same file.
⚙️Configuration Files
Reading a .conf, .ini, or .json file and parsing it line by line, exactly the open → read in a loop → close pattern. Programs like nginx and ssh rely on this pattern at startup.
🔄File Copying and System Tools
Commands like cp, dd, and cat are all built on the same read → write loop we saw in the copy_file example, they read a chunk, write it, and repeat until EOF.
🗄️Database Engines
Engines like SQLite read and write directly to a single file on disk, they use lseek to jump to specific pages, read/write to transfer data, and manage their own caching instead of relying on the stdio layer.
Deep Dive: I/O Layers, Safe Checking and Byte Order
Beyond what was covered about descriptors and flags, we add three things: the I/O layers, the safe pattern of checking every call, and how byte order matters when writing binary data (marshaling).
The I/O layers
An ordinary function runs entirely inside your program. A system call is a request to the kernel, because only the kernel may touch the disk. read, write, open and close are system calls. printf is a library function: it gathers text in a buffer and calls write behind the scenes. That is why the project bans library functions: to show you what lies underneath.
Figure 5, I/O layers: library functions on top of system calls on top of the kernel.
Reading octal permissions
Permissions have three groups: owner, group, others. Each group has read 4, write 2, execute 1 summed into one digit. So 0644 means: owner 6 (read and write), group 4, others 4. 0600 is for the owner alone.
| Octal digit | Symbols | Meaning |
|---|---|---|
| 7 | rwx | everything |
| 6 | rw- | read and write |
| 5 | r-x | read and execute |
| 4 | r-- | read only |
| 0 | --- | nothing |
The safe-checking pattern
Figure 6, The safe pattern for a file: check every call and always close at the end.
Byte order and marshaling
A number wider than one byte is stored as several bytes, and there are two orderings. In little endian (common x86 processors) the least significant byte is stored first at the lowest address. In big endian the most significant byte comes first. The number 0x12345678 appears in memory as follows:
| Address | +0 | +1 | +2 | +3 |
|---|---|---|---|---|
| Little Endian | 78 | 56 | 34 | 12 |
| Big Endian | 12 | 34 | 56 | 78 |
If you write a raw int to a file or send it over the network to a machine with a different order, it will be read wrongly. The fix is to agree on one order: network byte order, which is big endian. Functions such as htonl and htons convert from host order to network order before sending, and ntohl and ntohs do the reverse on receipt. This process is called marshaling. Text files avoid the problem because each character is a single byte.
Figure 7, Marshaling: both sides agree on one order in the middle whatever each one’s own order is.
Try it yourself
What does the file descriptor returned by open() represent?
All later operations (read, write, close) use this number. 0, 1 and 2 are reserved for stdin, stdout and stderr.
You open the same file twice with two separate open calls. What do you get?
Each open creates a new entry in the kernel's open-file table with its own offset starting at zero.
When does a copy loop built on read() stop?
read returns the number of bytes read: 0 means end of file, negative means error, anything else is data to write.
Quick Review Flashcards
Check Your Understanding
If you forgot O_CREAT when opening the destination file in the copy_file example, what happens if that file doesn't exist?
open() would fail and return -1, because O_WRONLY alone requests opening a file that already exists for writing, it doesn't create it if it's missing.
Why do we check read()'s return value instead of using feof()?
Because feof() is a standard library function that only works with FILE* (the result of fopen()), while read() is a system call that returns a numeric file descriptor. The only way to detect end-of-file with read() is when it returns zero.
If you open the same file twice with open(), does reading from one affect the read position of the other?
No, each open() call creates a completely independent entry in the kernel's open file table, with its own offset. Reading from one descriptor never moves the other descriptor's offset, even though they point to the same physical file on disk.
You write binary data on a little-endian machine to read on a big-endian one; what do you do?
Agree on network order: convert with htonl when writing and ntohl when reading.
You asked to write 100 bytes and write returned 60; what is wrong?
A partial write; your program must check the count and retry or report failure.
Resources for this part
Sorted by kind: start with the official docs when you want the precise reference, or with an article or video when you want the explanation first.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs3
Done with this lesson?
Sign in to save your progress.