Over the five previous lessons we have talked about an invisible boundary: the one separating your applications' code from the system's privileged code. It came up when discussing what the kernel is, when classifying architectures and when following an HTTP request. The time has come to look at it closely. In this lesson you are going to understand how the hardware makes it possible for the operating system to defend itself from the programs it runs, what happens exactly — instruction by instruction — when ingestor stores a reading on disk, and why an apparently trivial operation like write() costs hundreds of times more than an ordinary function call. By the end you will be able to read the output of strace, which is probably the most revealing diagnostic tool that exists on Linux, and you will understand why buffering is not an implementation detail but a first-order performance decision.
Contents
- Why a program cannot touch the hardware
- Dual mode: the mode bit and privileged instructions
- Privilege rings
- What a system call is
- The journey step by step, from user to kernel and back
- API, C library and system call: three different things
- Categories of system calls
- Example in C:
write()versusfprintf() - Real observation with
strace - The cost of a system call and why it pays to minimize them
Why a program cannot touch the hardware
Let's start with the problem. Suppose meteo-api, an ordinary program, could execute any CPU instruction and access any memory address. A single bug, or an attacker who had compromised the process, would be enough for it to:
- Read
ingestor's memory and obtain other customers' data. - Write directly to the disk, bypassing the file system and the permissions, and modify
/etc/shadow. - Disable interrupts and monopolize the CPU forever: not even the timer could take it back.
- Reprogram the page table to access all of physical RAM.
Notice a decisive nuance: it is not enough for the operating system to "not let it". If the program can execute the instruction that reprograms the MMU, it will execute it and the system will have no way of preventing it, because by then it has already run. The only possible solution is for the hardware to refuse.
And there lies the central idea of all protection in computing: an operating system's security does not rest on its code, but on a processor mechanism that software cannot circumvent.
Dual mode: the mode bit and privileged instructions
The CPU has a mode bit in its status register indicating which mode it is running in:
| User mode | Kernel mode (supervisor) | |
|---|---|---|
| Who runs here | Applications, libraries, shell | The kernel and its modules |
| Instructions allowed | Only the unprivileged ones | All of them |
| Memory accessible | Only its own address space | All of physical memory |
| Device access | None directly | Full |
| Effect of a fault | The process dies | Kernel panic or corruption |
Certain instructions are privileged: if an attempt is made to execute them in user mode, the CPU does not execute them and raises an exception. On x86-64, some examples:
| Instruction | What it does | Why it must be privileged |
|---|---|---|
hlt |
Stops the CPU until the next interrupt | A program could halt the machine |
cli / sti |
Disables / enables interrupts | With no interrupts, nobody can take the CPU away from it |
mov to cr3 |
Changes the active page table | It would grant access to all of physical memory |
in / out |
Reads and writes I/O ports | Direct access to devices |
lgdt / lidt |
Loads the descriptor and interrupt tables | It would allow redefining the protection mechanism itself |
wrmsr |
Writes model-specific registers | It includes the one defining where a system call jumps to |
You can verify in practice that the protection works:
/* privilege_test.c — compile: gcc -o privtest privilege_test.c */
#include <stdio.h>
int main(void) {
printf("Before the privileged instruction\n");
__asm__ volatile ("cli"); /* disable interrupts */
printf("This will never be printed\n");
return 0;
}What happened, line by line:
__asm__ volatile ("cli")inserts theclimachine instruction directly into the program.volatilestops the compiler from reordering it or removing it as useless.- The CPU, on encountering
cliwith the mode bit set to "user", does not execute it. It raises a general protection exception. - The kernel handles that exception, checks which process it came from and sends it the
SIGSEGVsignal, which by default terminates the process. - The second
printfnever runs.
This is exactly what we wanted to demonstrate: the refusal does not come from the operating system, it comes from the silicon. The operating system only decides what to do afterwards.
And how is the mode bit set?
Here is the elegant part of the design. If a program could set the mode bit to "kernel", all the protection would be useless. That is why the bit cannot be modified directly. It only switches to kernel mode in three circumstances, and in all three control jumps simultaneously to an address the kernel fixed in advance:
- A hardware interrupt (the disk finished, a network packet arrived). It is asynchronous, not caused by the program.
- An exception (division by zero, page fault, privileged instruction). It is synchronous but involuntary.
- A system call, that is, a voluntary request from the program.
In all three cases, the mode change and the jump are a single atomic hardware operation. There is no instant at which the CPU is in kernel mode running code chosen by the program. That atomicity is what makes the system secure.
Privilege rings
The x86 architecture generalizes dual mode into four levels or rings, numbered from 0 (highest privilege) to 3 (lowest). The idea, inherited from Multics, was to allow intermediate levels: for example, drivers in ring 1, with fewer privileges than the kernel but more than applications.
| Ring | Originally intended use | Actual use |
|---|---|---|
| 0 | Kernel | Kernel (Linux, Windows) |
| 1 | Device drivers | Practically none |
| 2 | System services | Practically none |
| 3 | Applications | Applications |
In practice, almost every system uses only 0 and 3, for two reasons: because other architectures (ARM, RISC-V, MIPS) offer only two levels and using four would break portability, and because intermediate levels complicate the design considerably for a debatable benefit. A curious fact: rings 1 and 2 were indeed used in some virtualization techniques before hardware support existed.
Modern virtualization did add a level: the so-called ring -1 or VMX root mode, where the hypervisor runs, below the guest systems' ring 0. You will see it in Virtualization: Hypervisors and Virtual Machines.
What a system call is
A system call (or syscall) is the only legitimate door for a program in user mode to ask the kernel for something. It is a voluntary, controlled request to cross the boundary.
Three characteristics define it:
- The entry point is chosen by the kernel, not by the program. The program cannot jump to an arbitrary kernel address. It can only say "I want call number 1" and the kernel decides which code runs.
- The arguments are validated. The kernel checks that the pointers you pass it point to memory that really is yours, that the file descriptor exists, that the size is reasonable. No check is optional: each one plugs a security hole.
- The return goes back to user mode. When the call finishes, the mode bit goes back to "user" and execution continues right after the instruction that caused the entry.
Linux has about 350 system calls on x86-64. You can see the complete list:
That number on the left is the system call number, and it is what the program puts in a register to indicate what it wants. The numbers are stable forever within an architecture: if they changed, every existing binary would stop working. That is why read has been 0 and write 1 for decades.
The journey step by step, from user to kernel and back
Let's follow what happens when ingestor executes write(fd, &reading, 24).
sequenceDiagram
participant U as ingestor (ring 3)
participant L as glibc (ring 3)
participant H as CPU
participant K as Kernel (ring 0)
U->>L: write(fd, &reading, 24)
Note over L: Places arguments:<br/>rax=1 (syscall number)<br/>rdi=fd, rsi=&reading, rdx=24
L->>H: SYSCALL instruction
Note over H: 1. Saves RIP in RCX and RFLAGS in R11<br/>2. Loads RIP from the LSTAR MSR<br/>3. Switches the mode bit to kernel
H->>K: entry_SYSCALL_64
Note over K: Switches to the kernel stack<br/>Saves the remaining registers
Note over K: Validates: does rax=1 exist?<br/>is fd valid?<br/>does &reading point to the process's memory?
Note over K: Runs sys_write:<br/>copies the 24 bytes into the page cache
Note over K: Restores registers<br/>Puts the result (24) in RAX
K->>H: SYSRET instruction
Note over H: Restores RIP from RCX and RFLAGS from R11<br/>Switches the mode bit to user
H->>L: return with RAX = 24
Note over L: If RAX is negative: errno = -RAX, return -1<br/>Otherwise: return RAX
L->>U: 24 bytes written
Let's detail the phases:
1. Preparing the arguments (user mode). Linux's x86-64 calling convention fixes which register carries what:
| Register | Contents |
|---|---|
rax |
System call number (1 = write) |
rdi |
First argument (the fd descriptor) |
rsi |
Second argument (the pointer to reading) |
rdx |
Third argument (the size, 24) |
r10, r8, r9 |
Fourth, fifth and sixth arguments |
Notice that r10 is used and not rcx for the fourth argument, unlike ordinary function calls. The reason is purely mechanical: the syscall instruction destroys rcx by saving the return address there.
2. The syscall instruction. A single machine instruction that, atomically:
- Saves the program counter (
rip) inrcxand the flags (rflags) inr11. - Loads into
ripthe address the kernel wrote at boot time into the special registerMSR_LSTAR. - Switches the mode bit to kernel.
None of this is controlled by the program: the destination address was fixed by the kernel long before.
3. Stack switch. The kernel cannot use the user process's stack, because its contents could be tampered with or point to invalid memory. Every process has a separate kernel stack (16 KB on Linux x86-64) and the kernel switches to it immediately. This is one of the details most often overlooked and it is essential for security.
4. Dispatch and validation. The kernel checks that rax is within the range of valid calls and consults the system call table to jump to the corresponding function (sys_write). Then it validates each argument. For the pointer, for example, it uses copy_from_user() instead of reading directly: that function checks that the address belongs to the process's space. A kernel that dereferenced a user pointer directly would have a critical vulnerability.
5. Doing the real work. sys_write locates the file from the descriptor, copies the 24 bytes into the page cache and updates the file's size.
6. Return. The result is placed in rax, the registers are restored and the sysret instruction returns the saved rip and flags, and sets the mode bit to "user".
7. Error translation. Here something happens that confuses many people. The kernel does not use errno: it returns the error as a small negative number in rax (for example, -13 for "permission denied"). It is the C library that, on seeing a value between −1 and −4095, does:
if (result < 0 && result > -4096) {
errno = -result; /* errno = 13 (EACCES) */
return -1;
}
return result;That is why errno is a C library variable, not a kernel one, and that is why it must be checked immediately after the failed call: any other library function may overwrite it.
API, C library and system call: three different things
This distinction is constantly confused and clearing it up saves a lot of later confusion.
| What it is | Example | Where it runs | |
|---|---|---|---|
| API | A contract, a specification of functions | POSIX, Win32 | Nowhere: it is a document |
| C library | Real code implementing part of that API | glibc, musl |
User mode, inside your process |
| System call | The specific request to the kernel | write (no. 1) |
Crosses into kernel mode |
The important relationships:
- A library function may make no system call at all.
strlen(),malloc()when there is memory in its internal pool, orprintf()when it only fills its buffer. - A library function may make several.
fopen()doesopen()and oftenfstat().printf()with a full buffer does awrite(). - A system call may have several different wrappers.
open(),open64(),creat()andfopen()all end up in the same family of calls. - You can invoke a system call without a library, with
syscall(1, fd, buf, 24)or directly in assembly. It is almost never worth it.
A particularly interesting case in the opposite direction: some system calls do not cross into the kernel thanks to the vDSO (virtual Dynamic Shared Object), a small library that the kernel maps into every process's space. gettimeofday() and clock_gettime() read the time from a read-only shared page without switching mode. The reason is pure performance: they are such frequent calls that a special shortcut was made for them.
Categories of system calls
| Category | What they do | Examples on Linux | Use at Meteora |
|---|---|---|---|
| Process control | Create, terminate, wait for and replace processes | fork, clone, execve, exit, wait4, kill |
systemd launches ingestor at boot |
| File management | Open, read, write, close, move, delete | open, read, write, close, lseek, unlink, rename |
ingestor writes to 2026-08-31.dat |
| Device management | Request, release and control devices | ioctl, mmap, read/write on /dev/* |
Tuning the network card's parameters |
| System information | Query and set time, identity, limits | time, clock_gettime, getpid, uname, getrlimit |
Stamping the timestamp on each Reading |
| Communication | Sockets, pipes, shared memory, signals | socket, bind, listen, accept, pipe, shmget |
meteo-api accepts connections on port 8080 |
| Protection | Permissions, identity, capabilities | chmod, chown, setuid, umask, capset |
Dropping from root to meteora after starting |
A pattern that repeats across all of them: system calls are deliberately few and low-level. There is no "read a configuration file" call, nor a "make an HTTP request" one. The kernel offers minimal primitives and the libraries build on top. Every system call is an attack surface and a promise of compatibility forever, so adding a new one is a decision taken with extreme care.
Example in C: write() versus fprintf()
We are going to write the same Reading two ways and understand why they are not equivalent.
/* save_reading.c — compile: gcc -O2 -o save_reading save_reading.c */
#include <stdio.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
struct Reading {
uint32_t station_id;
int64_t timestamp;
float temperature;
float humidity;
float pressure;
};
/* Version A: direct system call, binary format */
int save_binary(const char *path, const struct Reading *r) {
int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0640);
if (fd == -1) {
fprintf(stderr, "open failed: %s\n", strerror(errno));
return -1;
}
ssize_t written = write(fd, r, sizeof(*r));
if (written != (ssize_t)sizeof(*r)) {
fprintf(stderr, "incomplete write (%zd of %zu bytes)\n",
written, sizeof(*r));
close(fd);
return -1;
}
close(fd);
return 0;
}
/* Version B: standard library, text format */
int save_text(const char *path, const struct Reading *r) {
FILE *f = fopen(path, "a");
if (f == NULL) {
fprintf(stderr, "fopen failed: %s\n", strerror(errno));
return -1;
}
fprintf(f, "%u;%ld;%.1f;%.1f;%.1f\n",
r->station_id, r->timestamp,
r->temperature, r->humidity, r->pressure);
fclose(f); /* fclose flushes the buffer before closing */
return 0;
}
int main(void) {
struct Reading r = { .station_id = 118, .timestamp = 1756636800,
.temperature = 27.4f, .humidity = 61.0f,
.pressure = 1013.2f };
save_binary("/var/lib/meteora/readings/2026-08-31.dat", &r);
save_text("/var/lib/meteora/readings/2026-08-31.csv", &r);
return 0;
}Analysis of version A (write):
open(...)is a direct system call. It returns anint, the descriptor, or-1on error.- The
if (fd == -1)check is not optional: almost every real failure of a production service comes from unchecked errors.strerror(errno)turns the numeric code into a readable message. write(fd, r, sizeof(*r))hands the 24 bytes to the kernel immediately: one system call per reading.- The
written != sizeof(*r)check covers a case that surprises many people:writecan write fewer bytes than requested and return a smaller number without that being an error. It happens above all with sockets and pipes. Ignoring it produces silent truncation. - The result on disk is 24 binary bytes, unreadable with
cat, but compact and of an exact size.
Analysis of version B (fprintf):
fopen(...)is a library function that wrapsopen()and also allocates a buffer (normally 4096 bytes) and returns aFILE *, a C library structure, not a kernel one.fprintf(...)formats the data as text and copies it into the buffer in user memory. In this specific case, it makes no system call at all: the generated line takes about 35 bytes and fits with room to spare.fclose(f)flushes the buffer, which does cause awrite(), and then closes the descriptor.- The result on disk is
118;1756636800;27.4;61.0;1013.2, readable but of variable and larger size (35 bytes versus 24).
The table summarizing the difference:
write() |
fprintf() |
|
|---|---|---|
| Level | System call | C library |
| Buffer | None in user space | Yes, typically 4 KB |
| System calls per reading | 1 always | 1 every ~117 readings |
| Format | Binary, fixed 24 bytes | Text, ~35 variable bytes |
Readable with cat |
No | Yes |
| Portability of the data | Depends on the architecture (byte order, padding) | Total |
| Data at risk if the process dies | The kernel buffer's | The user buffer's and the kernel's |
| Performance with many writes | Worse | Much better |
The last row is the key one, and we will quantify it shortly. The second-to-last explains a very frequent phenomenon: a program that dies abruptly loses whatever it had in the C library's buffer, and that is why logs cut off right before the error you are looking for. That is what fflush() is for, and it is why stderr is unbuffered by default.
Real observation with strace
strace intercepts and displays every system call a process makes. It is the tool that turns everything above into something you can see.
strace: Process 1099 attached
recvfrom(7, "\x76\x00\x01\x18\x00\x00...", 512, 0, NULL, NULL) = 48 <0.000009>
clock_gettime(CLOCK_REALTIME, {tv_sec=1756636800, tv_nsec=142883917}) = 0 <0.000001>
write(9, "\x76\x00\x00\x00\x00\x8e\xc1\x68...", 24) = 24 <0.000021>
recvfrom(7, 0x7ffd4a2b1c40, 512, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable) <0.000005>
epoll_wait(5, [{EPOLLIN, {u32=7}}], 64, 1000) = 1 <0.031472>
recvfrom(7, "\x77\x00\x01\x18\x00\x00...", 512, 0, NULL, NULL) = 48 <0.000008>
clock_gettime(CLOCK_REALTIME, {tv_sec=1756636800, tv_nsec=174301522}) = 0 <0.000001>
write(9, "\x77\x00\x00\x00\x00\x8e\xc1\x68...", 24) = 24 <0.000019>Let's start with the command's arguments:
-falso follows the process's threads and children. Without this, in a multi-threaded program you would only see a part.-Tshows in<>the time each call took. It is the most useful option for diagnosis.-p 1099attaches to an already running process, in this caseingestor. Alternatively,strace ./programlaunches it from the start.2>&1redirects the error output (wherestracewrites) to standard output, so it can be piped throughhead.
Now the interpretation of the cycle, which is the interesting part:
recvfrom(7, ...) = 48— reads 48 bytes from the socket with descriptor 7: a station's packet. The= 48is the return value. It took 9 microseconds.clock_gettime(CLOCK_REALTIME, ...) = 0— gets the time to stamp the reading. It took 1 microsecond: suspiciously little for a system call, and the reason is that it is resolved through the vDSO without crossing into the kernel.write(9, ..., 24) = 24— writes theReading's 24 bytes to descriptor 9, the day's file. It took 21 µs.recvfrom(...) = -1 EAGAIN— it tries to read from the socket again and there is nothing.EAGAINon a non-blocking socket is not a real error: it means "there is no data right now, come back later".epoll_wait(5, ..., 1000) = 1— the process blocks waiting for activity, with a maximum time of 1000 ms. It took 31,472 µs, that is, 31 ms. During that timeingestoris in stateSand consumes no CPU at all: the correct behavior of a service that waits.
To see the aggregate summary, which is usually more useful than the detail:
% time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 71.42 0.982441 1966 500 epoll_wait 15.31 0.210623 4 52000 write 8.02 0.110336 4 26000 recvfrom 3.15 0.043341 1 26000 clock_gettime 2.10 0.028902 1 26000 26000 recvfrom ------ ----------- ----------- --------- --------- ---------------- 100.00 1.375643 130500 26000 total
How to read this, which is where the tool's real value lies:
epoll_waittakes 71% of the time, but that is good: it is time blocked waiting for work, not CPU consumed. A healthy service spends most of its time here.- The
errorscolumn shows 26,000 errors inrecvfrom. It looks alarming, but they are the expectedEAGAINs from non-blocking polling. Many errors instrace -cdo not mean there is a problem; you have to look at which ones they are. - And here is the important finding: 52,000 calls to
writefor 26,000 readings received. That is exactly two writes per reading. Investigating the code we would discover thatingestorwrites the reading to the data file and also a line to the log, with no buffering at all. That is the optimization target, and it takes us straight to the last section.
The cost of a system call and why it pays to minimize them
Let's put numbers on the cost of crossing the boundary.
| Operation | Typical cost | Comparison |
|---|---|---|
| Ordinary function call | ~1-2 ns | Reference |
Minimal system call (getpid) |
~50-100 ns | 50 times more |
| System call with Spectre/Meltdown mitigations | ~300-800 ns | Up to 400 times more |
write of 24 bytes to the page cache |
~2,000-20,000 ns | Thousands of times more |
Where does that cost come from? From several sources that add up:
- The mode and stack switch itself.
- Saving and restoring registers.
- Argument validation.
- And above all, since 2018, the Meltdown and Spectre mitigations. The main one, KPTI (Kernel Page Table Isolation), separates the kernel's and the user's page tables, which forces part of the TLB to be flushed on every transition. This mitigation multiplied the cost of system calls by a factor of between 2 and 5, and turned buffering from advisable into essential.
The ingestor case with numbers
With 500 stations sending one reading per minute:
Current situation (two write calls per reading, no buffering):
720,000 × 2 = 1,440,000 system calls per day 1,440,000 × 5 µs = 7.2 seconds of CPU per day just crossing the boundary
With a 4 KB buffer for the data file. Since each Reading takes 24 bytes, 170 fit in 4 KB:
720,000 / 170 ≈ 4,235 writes per day for the data plus the buffered log ≈ 4,235 more Total ≈ 8,470 system calls per day 8,470 × 5 µs = 0.04 seconds of CPU per day
Reduction: a factor of 170. From 7.2 seconds to 0.04 seconds of CPU per day.
In absolute terms 7 seconds a day does not look like anything, and on meteo-01 with 500 stations it probably is not. But the reasoning changes completely if Meteora grows to 50,000 stations: we would go from 12 minutes of CPU wasted daily to 4 seconds, and with bursty load the difference shows up as latency, not as average consumption.
The trade-off you have to understand
The buffer is not free. It trades performance for durability:
| Without a buffer | With a 4 KB buffer | |
|---|---|---|
| System calls | 720,000/day | 4,235/day |
| Data at risk if the process dies | 0 readings | Up to 170 readings |
| Latency until the data is visible to other processes | Immediate | Until the buffer is flushed |
And there is a third level worth distinguishing precisely, because almost nobody is clear about it:
- The C library's buffer (user space). It is flushed with
fflush(). If the process dies, it is lost. - The kernel's page cache. The data is already outside the process: if the process dies, it survives. But if the machine powers off, it is lost.
- The physical disk. It is forced with
fsync(). Only here is the data truly durable.
The engineering decision for Meteora would be: use buffering for the reading data (losing 170 readings in a crash is acceptable, they can always be retransmitted) but not for critical alerts or for the audit log, where every entry must reach the disk whatever it costs. This same trade-off, in its most general form, will come back in Space Allocation, Journaling and Integrity.
Common Mistakes and Tips
- Believing that
errnocomes from the kernel. The kernel returns a negative value inrax;errnois a C library variable. Check it immediately after the failed call, before invoking any other function. - Not checking
write's return value. It can write fewer bytes than requested without that being an error. The correct pattern is a loop that retries with what is left. - Confusing a library function with a system call.
printfis not a system call;fopenis notopen.straceis the definitive way to check what really crosses the boundary. - Thinking that
write()means "it is on the disk". It means it is in the kernel's page cache. Onlyfsync()guarantees durability, and only if the hardware is not lying about its own caches. - Optimizing without measuring. Before redesigning anything,
strace -cfor a minute tells you exactly which calls dominate. Iningestor's case, the 52,000 writes showed up on their own. - Overusing
stracein production. It slows the observed process down considerably (it can multiply the cost of each call by 10 or more), because each one causes two stops of the process. For production, lower-impact tools such asperforbpftraceare preferable. - Tip: when a program is slow and you do not know why, run
strace -c -ffor thirty seconds. In most cases, the answer jumps out at you from the first line of the table.
Exercises
Exercise 1
For each of these functions in a C program, state how many system calls it causes approximately and why. Reason in terms of buffers:
strlen("2026-08-31.dat")printf("Reading received\n")with the output redirected to a file.printf("Reading received\n")with the output on an interactive terminal.- A loop that calls
fprintf(f, "%.1f\n", temp)1,000 times on a file opened withfopen. - A loop that calls
write(fd, buf, 8)1,000 times.
Exercise 2
meteo-api responds to every HTTP request by writing a 120-byte line to /var/log/meteora/meteo-api.log with a direct write(), unbuffered. With 200 requests per second and a cost of 5 µs per system call:
- How much CPU per day goes into those calls alone?
- If an 8 KB buffer is added, how many calls would be left and how much CPU?
- What is lost with that change and in which case should it not be made?
Exercise 3
Write a C program that receives 1,000 simulated Reading structures and writes them to a file, minimizing the number of system calls without using the stdio library (that is, with a direct write()). Explain the design and compute how many calls it makes compared with the naive version.
Solutions
Solution 1
1. strlen(...) → 0 system calls.
It walks the process's own memory counting bytes up to the null terminator. It needs nothing from the kernel. It is the canonical example of a library function that does not cross the boundary.
2. printf redirected to a file → 0 calls on that invocation.
When standard output is not a terminal, glibc configures it as fully buffered (4 KB). The 17 bytes are copied into the buffer and stay there. The write() call will happen when the buffer fills up (after about 240 messages), when fflush() is called or when the program terminates in an orderly way.
An important practical consequence: if the program dies abruptly, the output file appears truncated, and frequently the message that explained the failure is exactly the one missing.
3. printf to a terminal → 1 system call.
When the output is a terminal, glibc uses line buffering: it flushes the buffer on encountering a \n. Since the message ends in a newline, an immediate write() occurs.
This explains a behavior that baffles many people: the same program shows its messages instantly on screen but seems to "write nothing" when redirected to a file. It is not a bug, it is a change of buffering policy.
4. 1,000 fprintf calls of ~6 bytes → approximately 2 calls.
6,000 bytes in total, with a 4,096-byte buffer: it is flushed once when it fills up and once on fclose. A reduction from 1,000 to 2.
5. 1,000 write calls of 8 bytes → exactly 1,000 calls.
write() has no buffer: every invocation crosses into kernel mode. At 5 µs each that is 5 ms of CPU to write 8 KB, when with a buffer 2 calls and 10 µs would be enough. It is a difference of 500 times, and it is exactly the problem we detected in ingestor with strace -c.
Solution 2
1. Current situation:
200 requests/s × 86,400 s/day = 17,280,000 writes per day 17,280,000 × 5 µs = 86.4 seconds of CPU per day
A minute and a half of CPU a day devoted exclusively to crossing the boundary to write the log. Put another way: 0.1% of a core continuously, just for logging.
2. With an 8 KB buffer:
8,192 bytes / 120 bytes per line = 68 lines per flush 17,280,000 / 68 ≈ 254,118 system calls per day 254,118 × 5 µs = 1.27 seconds of CPU per day
A reduction by a factor of 68, exactly the number of lines that fit in the buffer. From 86.4 to 1.27 seconds.
3. What is lost and when not to do it:
Two things are lost:
- Durability against a process crash: up to 68 log lines, corresponding to the last 0.34 seconds of activity. And here is the serious problem: they are precisely the lines describing what happened just before the failure, that is, the most valuable ones for diagnosing it.
- Immediacy of observation: an administrator running
tail -fon the log will see the messages in jumps of 68, with variable delay. And monitoring tools that read the file will detect problems later.
It should not be done in three specific cases:
- An audit or security log. If it serves as evidence (who accessed what and when), losing the last entries is unacceptable. Besides, an attacker who caused a crash would erase the most incriminating trail along with it.
- An error log. It is best to apply the same policy as
stderr: no buffering. The volume of errors is low by definition, so the cost is negligible and the diagnostic value is maximal. - When there is an external traceability requirement (regulatory or contractual) demanding a record of every operation.
The correct engineering solution is to separate the streams: the access log, which is voluminous and not very critical, buffered; and the error and audit logs, which are sparse and critical, unbuffered. It is exactly what serious web servers do, and also the reason journald distinguishes priority levels.
Solution 3
/* batch_writer.c — compile: gcc -O2 -o batch_writer batch_writer.c */
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
struct Reading {
uint32_t station_id;
int64_t timestamp;
float temperature;
float humidity;
float pressure;
};
#define CAPACITY 170 /* 170 × 24 = 4,080 bytes, fits in 4 KB */
struct Buffer {
int fd;
struct Reading data[CAPACITY];
size_t used;
};
/* Writes the whole buffer, retrying on partial writes */
static int flush_buffer(struct Buffer *b) {
if (b->used == 0) return 0;
const char *p = (const char *)b->data;
size_t remaining = b->used * sizeof(struct Reading);
while (remaining > 0) {
ssize_t n = write(b->fd, p, remaining);
if (n == -1) {
if (errno == EINTR) continue; /* interrupted: retry */
return -1; /* real error */
}
p += n;
remaining -= (size_t)n;
}
b->used = 0;
return 0;
}
static int append_reading(struct Buffer *b, const struct Reading *r) {
if (b->used == CAPACITY) {
if (flush_buffer(b) == -1) return -1;
}
b->data[b->used++] = *r;
return 0;
}
int main(void) {
struct Buffer b = { .used = 0 };
b.fd = open("/var/lib/meteora/readings/2026-08-31.dat",
O_WRONLY | O_CREAT | O_APPEND, 0640);
if (b.fd == -1) { perror("open"); return 1; }
for (int i = 0; i < 1000; i++) {
struct Reading r = { .station_id = (uint32_t)(100 + i % 50),
.timestamp = 1756636800 + i,
.temperature = 20.0f + (i % 15),
.humidity = 55.0f, .pressure = 1013.0f };
if (append_reading(&b, &r) == -1) { perror("append_reading"); return 1; }
}
if (flush_buffer(&b) == -1) { perror("final flush"); return 1; }
close(b.fd);
return 0;
}Explanation of the design:
CAPACITY= 170 is not arbitrary. 170 × 24 = 4,080 bytes, just under a page's 4,096. Aligning the buffer with the page size makes better use of the kernel's page cache and avoids a write unnecessarily straddling the boundary of two pages.flush_buffer()uses a loop, not a singlewrite. This is what distinguishes correct code from code that works until one day it does not.write()can return fewer bytes than requested, and the loop advances the pointerpand decrementsremaininguntil everything has been written.- The handling of
EINTRcovers another real case: if a signal arrives while the process is blocked inwrite, the call can return-1witherrno == EINTRwithout having written anything. It is not an error: it has to be retried. Omitting this produces sporadic, irreproducible data loss, among the worst kinds to diagnose. append_reading()flushes before adding, not after. That way the buffer never overflows and the order of operations is always correct.- The final
flush_buffer()is essential. Without it the last 150 readings would be lost (1,000 − 5 × 170 = 150), because they would stay in the buffer unwritten. It is the exact equivalent offflush(), and forgetting it is the most frequent mistake when implementing buffering by hand.
Comparison of system calls:
| Version | write calls |
Cost at 5 µs |
|---|---|---|
| Naive (one per reading) | 1,000 | 5,000 µs = 5 ms |
| With a 170-entry buffer | 6 (5 full ones + 1 final) | 30 µs |
A reduction by a factor of 167. And notice a detail that reinforces what we saw in the lesson: the number of system calls does not depend on how much data you write, but on how many times you cross the boundary. Writing 4,080 bytes costs practically the same as writing 24, because the dominant overhead is the mode switch, not copying the data.
An optional addition for robustness: if these readings were critical, after flush_buffer() you would have to call fsync(b.fd) to force the flush to the physical disk. It costs on the order of milliseconds, so it is only justified when data loss in a power cut is unacceptable.
Conclusion
An operating system's protection does not rest on its code but on the hardware: the CPU's mode bit makes privileged instructions simply not execute in user mode, and the switch to kernel mode can only happen through an interrupt, an exception or a system call, always jumping to an address the kernel fixed in advance. Of x86's four privilege rings, in practice only 0 and 3 are used.
A system call is the only legitimate door through that boundary, and we have walked through its complete mechanics: the call number in rax, the arguments in registers, the syscall instruction, the switch to the kernel stack, argument validation, table-based dispatch, the return with sysret and the translation of the negative value into errno performed by the C library. You have also seen why API, C library and system call are three different things: printf is not a system call, fopen is not open, and strace is how you check what really crosses.
And above all you take away an idea with daily practical consequences: crossing the boundary costs, between 50 nanoseconds and several microseconds, and that cost does not depend on how much data you move. Hence buffering is not an ornament but a design decision that trades performance for durability, and in ingestor it would cut system calls by a factor of 170. Knowing where to put it — and where never to put it, as in an audit log — is one of those decisions that separate code that works from code that holds up in production.
With this you close module 1. You know what an operating system is, where it comes from, what types there are, what functions it performs, how its kernel is organized inside and how the boundary that protects it is crossed. From here on we stop looking at the system from the outside and start opening it up. In Module 2: Resource Management we will get into the first and most fundamental of its jobs: Process Management, where you will see what exactly lies inside that abstraction we have used in every lesson without ever opening it, how a process is born with fork and execve, what states it goes through and what happens in a context switch. Meteora's three processes will stop being names in a ps listing and become structures you know how to read.
Operating Systems Fundamentals
Module 1: Introduction to Operating Systems
- Basic Concepts of Operating Systems
- History and Evolution of Operating Systems
- Types of Operating Systems
- Main Functions of an Operating System
- Kernel Architecture: Monolithic, Microkernel and Hybrid
- User Mode, Kernel Mode and System Calls
Module 2: Resource Management
- Process Management
- CPU Scheduling
- Memory Management
- Virtual Memory and Paging
- Storage Management
- Device Management
- Drivers, Interrupts and I/O Operations
Module 3: Concurrency
- Concurrency Concepts
- Threads and Processes
- Inter-Process Communication (IPC)
- Synchronization and Mutual Exclusion
- Classic Concurrency Problems
- Deadlocks: Prevention, Detection and Recovery
Module 4: File Structures
- File Systems
- Directory Structures
- Partitions, Mounting and the Virtual File System
- File Management
- Space Allocation, Journaling and Integrity
- File Security and Permissions
Module 5: System Protection and Security
- Protection Principles and Access Control
- Users, Authentication and Privilege Escalation
- Common Threats and System Hardening
- Auditing, Logging and Incident Response
Module 6: Virtualization and Containers
- Virtualization: Hypervisors and Virtual Machines
- Containers: Namespaces and cgroups
- The Operating System in the Cloud
- Mobile and Real-Time Operating Systems
