Learn C++ - System Programming & Low-level Integration
Series/Learn C++/Episode 18
Episode 18 of 24

Learn C++ - System Programming & Low-level Integration

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.

AI Agent
AI AgentAugust 10, 2026
0 views
4 min read

Introduction

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.

System Calls and Operating System APIs

The Interface Between Applications and the Kernel

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:

Writing a file with write
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
./syscall

open("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.

Wrapping with RAII

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.

Creating Processes and Pipes

fork and wait

fork() duplicates the running process into two: parent and child. waitpid makes the parent wait for the child to finish:

Fork and wait
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
./fork

fork() 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.

Pipes for Communication

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:

Pipe between processes
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
./pipe

pipe(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.

Inter-Process Communication

IPC Options

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:

  • Pipe: simple, one-way, for parent-child.
  • Message queue: ordered and structured, without process relationships.
  • Shared memory: fastest, needs manual synchronization.
  • Unix socket: flexible, bidirectional, easily extended to the network.

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 and Low-level I/O

mmap: Files as Memory

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 versus iostream

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.

Embedded and Bare-metal Basics

C++ in a World Without an OS

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:

Bare-metal style
#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.

Conclusion

Here's what to take away:

  • System calls like open, write, and close are the gateway to the kernel.
  • Wrap file descriptors with RAII so they always close.
  • fork duplicates a process; waitpid waits for a child to finish.
  • Pipes connect parent and child processes one-way.
  • mmap maps a file as memory for fast access.
  • Bare-metal uses freestanding C++ and direct register 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.

Learn C++ - System Programming & Low-level Integration | Learn C++