C POSIX Systems Programming on Linux: File Descriptors, Syscalls & Daemons
Created: 2026-11-07 Last updated: 2026-11-07
Executive Summary: High-level applications interact with the filesystem through language runtimes or standard buffered abstractions (
stdio.h). In Linux systems engineering, high-performance services interact directly with the operating system through POSIX System Calls (open,read,write,close,lseek). Bypassing user-space buffers unlocks direct control over kernel file descriptor tables, atomic non-blocking file locks (flock), and operating system daemons. This guide forms Part 2.2 (Intermediate) of our C curriculum: deconstructing the User Space to Kernel Space boundary, managing file descriptors, preventing multi-process race conditions with atomic locks, and implementing the classical 5-step Unix daemonization protocol.
-
Buffered I/O vs. Kernel Syscalls
Understand user-space
glibcbuffers (FILE*) versus raw unbuffered system calls (unistd.h) and the cost of CPU ring context switches. -
File Descriptor Tables & VFS
Explore how the Linux kernel maps integer descriptors (
0,1,2) to global open file descriptions, offsets, and inode tables viadup2(). -
Atomic File Locking (flock)
Prevent data corruption across concurrent processes with advisory shared (
LOCK_SH) and exclusive (LOCK_EX) file locks. -
The Unix Daemon Protocol
Transform C binaries into detached background background daemons using the canonical double-
fork(),setsid(), and/dev/nullredirection.
1. Buffered I/O (stdio.h) vs. Raw Unbuffered System Calls (unistd.h)
Every I/O operation in C belongs to one of two distinct architectural tiers:
%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
subgraph UserSpace ["User Space (Ring 3)"]
APP["C Application Code"]
LIBC["glibc Buffered Stream (stdio.h)<br/>FILE* pointer, internal 4KB buffer<br/>(fread, fwrite, fgetc)"]
end
subgraph KernelSpace ["Kernel Space (Ring 0)"]
VFS["Linux VFS & System Call ABI<br/>(open, read, write, lseek)<br/>Syscall transition cost!"]
PAGE["Kernel Page Cache & Buffer Cache"]
DRV["Block Device Driver (NVMe / SSD)"]
end
APP -->|"Formatted calls"| LIBC
LIBC -->|"Flushed batch (4KB chunks)"| VFS
APP ==>|"Direct unbuffered syscall"| VFS
VFS --> PAGE
PAGE --> DRV
The Key Differences
| Feature | Standard I/O (stdio.h) |
POSIX System Calls (unistd.h, fcntl.h) |
|---|---|---|
| Identifier | Stream pointer: FILE * |
Integer File Descriptor: int fd |
| Functions | fopen(), fread(), fwrite(), fclose() |
open(), read(), write(), close() |
| Buffering | User-space buffer (typically 4 KB or 8 KB in libc) |
Unbuffered (transfers bytes straight to kernel) |
| Syscall Overhead | Amortized (collects many small writes into one syscall) | Every invocation triggers a CPU context switch to Ring 0 |
| Portability | ANSI C Standard (Linux, Windows, macOS, Embedded) | POSIX / Unix-specific |
| Best For | Text processing, formatted parsing (fprintf) |
Network sockets, device drivers, IPC pipes, daemons |
Standard File Descriptors in Linux
When any Linux process launches, the kernel automatically assigns three integer file descriptors by default:
0(STDIN_FILENO): Standard Input (/dev/stdin)1(STDOUT_FILENO): Standard Output (/dev/stdout)2(STDERR_FILENO): Standard Error (/dev/stderr)
2. Managing File Descriptors: open, read, write & lseek
POSIX system calls operate on raw integers. Let us create a low-level file copy utility:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#define BUFFER_SIZE 4096
int main(int argc, char *argv[]) {
if (argc != 3) {
dprintf(STDERR_FILENO, "Usage: %s <source_file> <dest_file>\n", argv[0]);
return EXIT_FAILURE;
}
// 1. Open source file (Read-Only)
int src_fd = open(argv[1], O_RDONLY);
if (src_fd < 0) {
dprintf(STDERR_FILENO, "Error opening %s: %s\n", argv[1], strerror(errno));
return EXIT_FAILURE;
}
// 2. Open destination file (Write-Only, Create if missing, Truncate existing)
// Mode 0644: Owner Read/Write (rw-), Group Read (r--), Others Read (r--)
int dest_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (dest_fd < 0) {
dprintf(STDERR_FILENO, "Error opening %s: %s\n", argv[2], strerror(errno));
close(src_fd);
return EXIT_FAILURE;
}
uint8_t buffer[BUFFER_SIZE];
ssize_t bytes_read = 0;
ssize_t total_copied = 0;
// 3. Unbuffered byte loop
while ((bytes_read = read(src_fd, buffer, sizeof(buffer))) > 0) {
ssize_t bytes_written = 0;
// Ensure all read bytes are physically written out (handle partial writes)
while (bytes_written < bytes_read) {
ssize_t res = write(dest_fd, buffer + bytes_written, (size_t)(bytes_read - bytes_written));
if (res < 0) {
if (errno == EINTR) continue; // Retry if interrupted by signal
perror("write failed");
close(src_fd);
close(dest_fd);
return EXIT_FAILURE;
}
bytes_written += res;
}
total_copied += bytes_written;
}
if (bytes_read < 0) {
perror("read error");
}
// 4. Close descriptors to release kernel table entries
close(src_fd);
close(dest_fd);
printf("Successfully copied %zd bytes using raw POSIX syscalls.\n", total_copied);
return EXIT_SUCCESS;
}
gcc -std=c17 -Wall -Wextra raw_copy.c -o raw_copy
./raw_copy /etc/os-release my_os_release.txt
cat my_os_release.txt
3. Atomic File Locking (flock): Preventing Race Conditions
When multiple autonomous processes attempt to append to the same log file or database journal simultaneously, writes can interleave, corrupting entries.
Linux provides Advisory File Locking via flock() (<sys/file.h>):
LOCK_SH(Shared Lock): Multiple readers can hold shared locks concurrently. All writers are blocked.LOCK_EX(Exclusive Lock): Only one process can hold an exclusive lock. All other readers and writers are blocked.LOCK_NB(Non-Blocking): Fails immediately withEWOULDBLOCKinstead of hanging if the file is already locked.LOCK_UN(Unlock): Releases the acquired lock.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/file.h>
#include <time.h>
#include <string.h>
void log_entry(const char *log_file, const char *message) {
int fd = open(log_file, O_WRONLY | O_CREAT | O_APPEND, 0644);
if (fd < 0) {
perror("open failed");
return;
}
// 1. Acquire EXCLUSIVE file lock (blocks until other processes finish)
if (flock(fd, LOCK_EX) < 0) {
perror("flock LOCK_EX failed");
close(fd);
return;
}
// 2. Critical Section: Write timestamped log entry atomically
time_t now = time(NULL);
char entry[256];
int len = snprintf(entry, sizeof(entry), "[%ld] PID %d: %s\n", now, getpid(), message);
write(fd, entry, (size_t)len);
// Simulate work inside critical section
usleep(10000);
// 3. Release lock and close descriptor
flock(fd, LOCK_UN);
close(fd);
}
int main(void) {
for (int i = 0; i < 5; i++) {
log_entry("app.log", "Processing transaction batch");
}
printf("Log entries written with atomic flock synchronization.\n");
return 0;
}
4. File Metadata & Directory Traversal (stat & dirent)
To inspect file permissions, size, and inode information without opening the file, use the stat() system call:
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <time.h>
void inspect_path(const char *path) {
struct stat st;
// Use lstat to inspect symbolic links themselves rather than target
if (lstat(path, &st) < 0) {
perror("stat failed");
return;
}
printf("\n=== METADATA: %s ===\n", path);
printf("Inode Number: %lu\n", (unsigned long)st.st_ino);
printf("File Size: %ld bytes\n", (long)st.st_size);
printf("Hard Link Count: %lu\n", (unsigned long)st.st_nlink);
printf("Access Mode: %o (octal permissions)\n", st.st_mode & 0777);
// Determine file type using POSIX macros
if (S_ISREG(st.st_mode)) printf("Type: Regular File\n");
if (S_ISDIR(st.st_mode)) printf("Type: Directory\n");
if (S_ISLNK(st.st_mode)) printf("Type: Symbolic Link\n");
if (S_ISFIFO(st.st_mode)) printf("Type: Named Pipe (FIFO)\n");
if (S_ISSOCK(st.st_mode)) printf("Type: UNIX Domain Socket\n");
printf("Last Modified: %s", ctime(&st.st_mtime));
}
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("Usage: %s <filepath>\n", argv[0]);
return 1;
}
inspect_path(argv[1]);
return 0;
}
5. The Classical Unix Daemonization Protocol (System V / POSIX Standard)
A daemon is a long-running background process that has no controlling terminal and operates independently of user login sessions (such as sshd, cron, or nginx).
Creating a true Unix daemon requires a strict 5-step detachment sequence:
%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
STEP1["1. First fork()<br/>Parent exits immediately.<br/>Child is orphaned and adopted by PID 1 (systemd)"]
STEP2["2. setsid()<br/>Detaches child from controlling TTY.<br/>Creates a new Session and Process Group"]
STEP3["3. Second fork()<br/>Ensures daemon is NOT a session leader,<br/>preventing it from ever re-acquiring a TTY!"]
STEP4["4. umask(0) & chdir('/')<br/>Unrestricts file modes and avoids<br/>holding open unmountable disk partitions"]
STEP5["5. Close & Redirect FDs<br/>Close 0, 1, 2 and redirect to /dev/null<br/>(Prevents broken pipe crashes)"]
STEP1 --> STEP2 --> STEP3 --> STEP4 --> STEP5
The Production daemonize() Function
Create ~/c_posix_lab/daemon_example.c:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <syslog.h>
#include <sys/stat.h>
#include <sys/types.h>
void daemonize(const char *daemon_name) {
// 1. First fork: Parent terminates, child continues in background
pid_t pid = fork();
if (pid < 0) exit(EXIT_FAILURE);
if (pid > 0) exit(EXIT_SUCCESS); // Parent exits
// 2. Create new Session; detach from controlling terminal
if (setsid() < 0) exit(EXIT_FAILURE);
// 3. Second fork: Prevent daemon from ever re-acquiring a controlling TTY
pid = fork();
if (pid < 0) exit(EXIT_FAILURE);
if (pid > 0) exit(EXIT_SUCCESS);
// 4. Set unmask to 0 for full control over file creation permissions
umask(0);
// 5. Change working directory to root to prevent locking mounts
if (chdir("/") < 0) exit(EXIT_FAILURE);
// 6. Close standard file descriptors and redirect to /dev/null
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);
int null_fd = open("/dev/null", O_RDWR);
if (null_fd != -1) {
dup2(null_fd, STDIN_FILENO);
dup2(null_fd, STDOUT_FILENO);
dup2(null_fd, STDERR_FILENO);
if (null_fd > 2) close(null_fd);
}
// 7. Initialize syslog for background diagnostics
openlog(daemon_name, LOG_PID | LOG_CONS, LOG_DAEMON);
syslog(LOG_INFO, "Daemon started successfully in background (PID: %d)", getpid());
}
int main(void) {
daemonize("my_sensor_daemon");
// Daemon Main Loop
for (int i = 0; i < 3; i++) {
syslog(LOG_INFO, "Daemon heartbeat tick %d...", i + 1);
sleep(5);
}
syslog(LOG_INFO, "Daemon shutting down cleanly.");
closelog();
return EXIT_SUCCESS;
}
gcc -std=c17 -Wall -Wextra daemon_example.c -o daemon_example
./daemon_example
# Notice your shell prompt returns immediately!
# Verify daemon executed via journalctl / syslog:
sudo journalctl -t my_sensor_daemon -n 5
6. Hands-On Lab: Engineering a Low-Level Unix Log Tail Utility (mini_tail_f)
Let us synthesize these principles by authoring a real-world CLI utility: mini_tail_f.
Like tail -f, this tool opens a log file, skips directly to the end via lseek(), and polls for newly appended bytes using unbuffered POSIX syscalls:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdbool.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <errno.h>
static volatile sig_atomic_t g_keep_running = 1;
void handle_sigint(int sig) {
(void)sig;
g_keep_running = 0; // Signal-safe flag mutation
}
int main(int argc, char *argv[]) {
if (argc < 2) {
dprintf(STDERR_FILENO, "Usage: %s <target_log_file>\n", argv[0]);
return EXIT_FAILURE;
}
// 1. Install clean signal handler for Ctrl+C (SIGINT)
struct sigaction sa = { .sa_handler = handle_sigint };
sigemptyset(&sa.sa_mask);
sigaction(SIGINT, &sa, NULL);
// 2. Open file with read-only descriptor
int fd = open(argv[1], O_RDONLY);
if (fd < 0) {
perror("Failed to open file");
return EXIT_FAILURE;
}
// 3. Jump directly to the current end of the file using lseek
off_t current_pos = lseek(fd, 0, SEEK_END);
if (current_pos == (off_t)-1) {
perror("lseek failed");
close(fd);
return EXIT_FAILURE;
}
dprintf(STDOUT_FILENO, "=== Following %s (Press Ctrl+C to stop) ===\n", argv[1]);
char buffer[1024];
// 4. Live tail polling loop
while (g_keep_running) {
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
if (bytes_read > 0) {
// Write out newly arrived bytes directly to stdout
write(STDOUT_FILENO, buffer, (size_t)bytes_read);
} else if (bytes_read == 0) {
// End of File reached: sleep briefly to prevent 100% CPU busy-loop
usleep(100000); // 100 milliseconds
} else {
if (errno == EINTR) continue; // Loop again if interrupted by signal
perror("read error");
break;
}
}
dprintf(STDOUT_FILENO, "\nDetached cleanly from log stream.\n");
close(fd);
return EXIT_SUCCESS;
}
# 1. Compile utility
gcc -std=c17 -Wall -Wextra mini_tail_f.c -o mini_tail_f
# 2. In Terminal 1: Run mini_tail_f on a test log
touch /tmp/test.log
./mini_tail_f /tmp/test.log
# 3. In Terminal 2: Append lines to the log
echo "First live log message!" >> /tmp/test.log
echo "Second live alert from kernel!" >> /tmp/test.log
Where to Go Next
Continue your journey through the C Systems Programming curriculum:
-
Part 2.3: Debugging, Sanitizers & Profiling
Master advanced GDB core dumps, AddressSanitizer (ASan), Valgrind Massif, and compiler hardening flags.
-
Part 1.2: Build Toolchain & Makefiles
Review the 4 GCC compilation stages,
#pragma once, static vs. shared libraries, and automatic-MMDMakefiles. -
Linux Process Management & System Calls
Inspect running ELF processes, signals (
SIGTERM/SIGKILL),stracesyscall traces, and/procinternals.