This episode covers system programming: interaction with system calls and operating system APIs, creating processes with fork and pipes for inter-process communication, memory-mapped files and low-level I/O, as well as embedded systems basics and bare-metal considerations.

C++ isn't only for high-level applications. When an application must interact with the kernel, manage processes, or run on devices without a full operating system, C++ is the primary choice. This is system programming.
Episode 18 takes you to the lower layers: system calls and POSIX APIs, creating processes with fork and communicating through pipes, inter-process communication, memory-mapped files and low-level I/O, as well as embedded and bare-metal concepts. All examples use Linux with the POSIX API.
A system call is an application's gateway into the kernel: opening files, reading, sending data, and starting processes. The C language provides direct wrappers like open, read, write, and fork. In C++, you can use them directly or wrap them in RAII classes:
cat > syscall.cpp <<'EOF'
#include <iostream>
#include <fcntl.h>
#include <unistd.h>
int main() {
int fd = open("keluaran.txt",
O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
std::cerr << "gagal membuka file\n";
return 1;
}
const char* teks = "tulisan dari system call\n";
write(fd, teks, 24);
close(fd);
std::cout << "selesai\n";
}
EOF
g++ -std=c++20 syscall.cpp -o syscall
./syscallopen("keluaran.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644) creates a file for writing. write(fd, teks, 24) writes bytes to the file, and close(fd) closes it. The <fcntl.h> and <unistd.h> headers provide the POSIX declarations.
The safe pattern: wrap a file descriptor in a class whose destructor calls close. The file is closed automatically when the object goes out of scope — exactly the RAII principle from episode 7. Every open pairs with a close, and RAII guarantees it even when an exception occurs.
fork() duplicates the running process into two: parent and child. waitpid makes the parent wait for the child to finish:
cat > fork.cpp <<'EOF'
#include <iostream>
#include <sys/wait.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
std::cout << "Child: proses anak\n";
return 0;
} else {
int status;
waitpid(pid, &status, 0);
std::cout << "Parent: child selesai\n";
}
}
EOF
g++ -std=c++20 fork.cpp -o fork
./forkfork() returns 0 in the child process and the child's PID in the parent process. waitpid(pid, &status, 0) makes the parent wait. After fork, both processes continue from the same line — the only difference is the return value.
A pipe is a one-way channel between two processes. pipe(fds) creates two descriptors: fds[1] for writing, fds[0] for reading. The common pattern: fork, close the unused end in each process, then send:
cat > pipe.cpp <<'EOF'
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
int main() {
int fds[2];
pipe(fds);
pid_t pid = fork();
if (pid == 0) {
close(fds[0]);
write(fds[1], "pesan dari child", 16);
close(fds[1]);
} else {
close(fds[1]);
char buf[64];
int n = read(fds[0], buf, sizeof(buf));
buf[n] = '\0';
std::cout << "Parent terima: " << buf << "\n";
close(fds[0]);
waitpid(pid, nullptr, 0);
}
}
EOF
g++ -std=c++20 pipe.cpp -o pipe
./pipepipe(fds) creates the pipe, and after fork, the child writes to fds[1] while the parent reads from fds[0]. The unused end is closed in each process — important so read knows when the data ends.
Besides pipes, Unix provides various IPC mechanisms: message queues for structured messages, shared memory for shared data at the highest speed, and Unix domain sockets for network-like communication between processes. The choice depends on the need:
Pipes are enough for simple communication. For richer patterns, consider message queues or sockets — both use concepts from episode 13. Shared memory demands a mutex or semaphore to prevent races, as in episode 12.
Memory-mapped files map file contents directly into a process's address space with mmap. The call mmap(nullptr, len, PROT_READ, MAP_PRIVATE, fd, 0) maps a file read-only, then p[i] reads file bytes as if they were an array. munmap(p, len) releases the mapping. This pattern is efficient for large files and is the foundation of databases and runtime loaders.
Low-level I/O with read and write works on raw bytes, while std::ifstream adds formatting. For large binary data, low-level I/O gives full control. For formatted text, iostream is more convenient — episode 9 covers the iostream side in full.
In bare-metal, there's no operating system, malloc, or full standard library. The program uses the freestanding C++ subset and writes directly to device registers:
#include <cstdint>
volatile std::uint32_t* REG =
reinterpret_cast<std::uint32_t*>(0x40021000);
void tulis() {
*REG = 0x1;
}volatile std::uint32_t* REG points to a hardware register address, and writing to *REG sends a value to the device. The volatile keyword prevents the compiler from removing or reordering accesses.
Info
In embedded, avoid new and exceptions that need a large runtime. Use static allocation, std::array, and status-based error handling — patterns covered in episode 21.
Here's what to take away:
open, write, and close are the gateway to the kernel.fork duplicates a process; waitpid waits for a child to finish.mmap maps a file as memory for fast access.In the next episode, episode 19, we'll discuss modern tooling and build automation — modern CMake workflows, continuous integration with GitHub Actions and GitLab CI, static analysis and code formatting with clang-format, as well as reproducible builds and dependency management.