This episode touches the operating system layer: system calls and libc wrappers, process creation with fork, exec, and wait, signals, pipes, and inter-process communication, as well as file descriptors with select and poll for event-driven I/O.

A C program doesn't run alone on bare hardware; it runs on top of an operating system that provides services through system calls. Episode 18 takes you to the systems programming level: creating processes, communicating between processes, and handling lots of I/O at once.
We start with system calls and libc wrappers, then fork, exec, and wait for process management, then signals and pipes as inter-process communication tools. Finally, select and poll let one program handle many file descriptors without blocking.
This is the material that connects C to the kernel — the same language that wrote Unix and Linux is used again to interact with it.
Applications don't call the kernel directly; they call libc functions that act as wrappers. For example, open, read, write, and close call system calls with the same names, plus handle errno and parameters. These wrappers standardize the interface and handle architecture details.
All Linux I/O flows through file descriptors, numbers representing an open file, socket, or pipe:
#include <fcntl.h>
#include <unistd.h>
int main(void) {
int fd = open("catatan.txt", O_WRONLY | O_CREAT, 0644);
if (fd < 0) {
return 1;
}
write(fd, "data dari system call\n", 22);
close(fd);
return 0;
}
EOF
gcc -Wall -Wextra syscall.c -o syscall && ./syscallThe call open("catatan.txt", O_WRONLY | O_CREAT, 0644) opens a file and returns a descriptor, write writes bytes, and close releases it. O_CREAT creates the file if it doesn't exist, and 0644 is the default permission. This is the foundation of all Linux I/O, including the sockets in episode 12.
fork copies the current process into two: parent and child, both continuing from the same point but receiving different return values:
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
printf("proses anak\n");
return 0;
}
waitpid(pid, NULL, 0);
printf("proses induk selesai\n");
return 0;
}pid_t pid = fork() returns 0 in the child and the child's PID in the parent. waitpid(pid, NULL, 0) makes the parent wait for the child to finish, preventing zombie processes. Without wait, a finished child process leaves a trace in the process table.
exec replaces the current process image with another program. The classic combination: fork creates a child, then the child calls exec to run a new program:
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
execlp("ls", "ls", "-l", NULL);
return 1;
}
waitpid(pid, NULL, 0);
return 0;
}execlp("ls", "ls", "-l", NULL) runs ls -l inside the child process. The first argument is the program name, the second is the name that appears in argv[0], followed by the remaining arguments, and NULL as the terminator. If exec fails, the function returns control — that's why there's a return 1 after it.
Signals are asynchronous notifications to a process: SIGINT on Ctrl+C, SIGTERM when asked to stop. A program can catch a signal and run a handler:
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
void handler(int sig) {
write(2, "menerima sinyal\n", 16);
}
int main(void) {
signal(SIGTERM, handler);
pause();
return 0;
}signal(SIGTERM, handler) registers handler for the termination signal, and pause holds the process until a signal arrives. A handler should only call async-signal-safe functions like write, not printf.
A pipe connects one process's output to another's input, like the cat file | grep pola command in the shell:
#include <stdio.h>
#include <unistd.h>
int main(void) {
int fd[2];
pipe(fd);
pid_t pid = fork();
if (pid == 0) {
close(fd[0]);
write(fd[1], "halo dari anak\n", 15);
return 0;
}
close(fd[1]);
char buf[64];
read(fd[0], buf, sizeof(buf));
printf("%s", buf);
return 0;
}pipe(fd) provides two descriptors: fd[1] for writing and fd[0] for reading. The child writes, the parent reads. Closing the unused side is an important rule to prevent deadlocks.
A server that must serve many connections can't block on a single read. select and poll monitor many descriptors at once and tell you which are ready to read:
#include <poll.h>
#include <stdio.h>
int main(void) {
struct pollfd fds[1];
fds[0].fd = 0;
fds[0].events = POLLIN;
int siap = poll(fds, 1, 5000);
if (siap > 0 && (fds[0].revents & POLLIN)) {
printf("stdin siap dibaca\n");
}
return 0;
}struct pollfd fds[1] declares an array of monitored descriptors, and poll(fds, 1, 5000) waits until one is ready or a 5-second timeout. This is the basic pattern of event-driven I/O used by large-scale servers — waiting without spending one thread per connection.
Tip
Note the difference in patterns: one thread per connection handles concurrency simply but is expensive in resources. select, poll, or epoll let a single thread handle thousands of connections — an important choice for production servers.
Key takeaways:
In the next episode 19 we will discuss modern tooling and build automation — the GCC, Clang, and MSVC toolchains, static analysis, sanitizer, and linting in a workflow, build automation with make, CMake, and CI pipelines, as well as binary packaging and the basics of cross-compilation.