<< Home

Chapter 14: System Programming

System programming sits closer to the operating system than typical application development. It deals with process control, files, environment variables, and direct use of system calls. In C, this area is powerful but requires precision and care because you interact directly with kernel-managed resources. Understanding how programs communicate with the OS is key to writing efficient and reliable tools.

💡 System programming bridges the gap between user-level code and the kernel. It is where C shows its original design intent, with tight control over hardware and system resources.

Interacting with the operating system

C provides several layers for interacting with the OS. At the top are the standard library functions defined by ISO C, which offer portable access to files, memory, and processes. Below that are platform-specific APIs such as POSIX for Unix-like systems or the Win32 API on Windows. System calls handle tasks such as creating files, reading input, changing directories, and obtaining system information.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
  /* running an external command */
  int status = system("echo Hello from shell");
  if (status == -1) {
    perror("system");
    return 1;
  }

  /* getting the current working directory */
  char *cwd = getenv("PWD");
  if (cwd)
    printf("Current directory: %s\n", cwd);
  else
    printf("PWD not set\n");

  return 0;
}

The system() function invokes a shell to execute a command string. It is simple but limited and potentially unsafe with untrusted input. For precise control, use platform APIs like fork(), exec(), or CreateProcess() instead of invoking a shell.

⚠️ Always validate data passed to system(). Never concatenate untrusted user input directly into command strings, as this can lead to code execution vulnerabilities.

Accessing system information

The standard library offers simple access to environment details such as user names, temporary paths, and limits through functions like getenv() and constants in limits.h. On POSIX systems, additional details can be retrieved using uname() or sysconf().

#include <stdio.h>
#include <unistd.h>

int main(void) {
  long cpus = sysconf(_SC_NPROCESSORS_ONLN);
  printf("Online CPUs: %ld\n", cpus);
  return 0;
}

Handling environment variables and command-line arguments

Programs can receive information from the outside world in two main ways: through environment variables and through command-line arguments. The main() function can declare parameters int argc and char *argv[], which provide the argument count and list. The environment is accessible via getenv() and setenv() on POSIX systems.

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  printf("Program name: %s\n", argv[0]);

  for (int i = 1; i < argc; ++i)
    printf("Arg %d: %s\n", i, argv[i]);

  const char *home = getenv("HOME");
  if (home)
    printf("HOME = %s\n", home);
  else
    printf("HOME not set\n");

  return 0;
}

Modifying the environment safely

POSIX provides setenv() and unsetenv() for updating environment variables, which affect the current process and any children it spawns. Use them with caution, as environment size and lifetime depend on system implementation.

#include <stdlib.h>

int main(void) {
  setenv("DEBUG", "1", 1);  /* overwrite existing value */
  unsetenv("OLD_PATH");
  return 0;
}
💡 Avoid relying on environment variables for essential program logic unless you document them clearly. Their values can vary between systems, shells, or invocations.

Working with processes and signals (POSIX overview)

In Unix-like environments, processes are fundamental units of execution. C exposes system calls such as fork(), exec(), and wait() to create and manage them. Signals are asynchronous notifications that inform a process of events like interrupts or termination requests.

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
  pid_t pid = fork();

  if (pid < 0) {
    perror("fork");
    return 1;
  } else if (pid == 0) {
    /* child process */
    execlp("echo", "echo", "child process running", NULL);
    perror("execlp");
    return 1;
  } else {
    /* parent process */
    int status = 0;
    waitpid(pid, &status, 0);
    printf("Child exited with status %d\n", status);
  }
  return 0;
}

Catching and handling signals

You can catch signals using the signal() or sigaction() functions. This lets your program respond to events such as a user pressing Ctrl+C (SIGINT), or ensure cleanup before termination.

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void on_signal(int sig) {
  printf("Caught signal %d\n", sig);
}

int main(void) {
  signal(SIGINT, on_signal);
  printf("Press Ctrl+C to trigger SIGINT\n");
  for (;;) sleep(1);
}
⚠️ Signal handlers run asynchronously and must be minimal. Avoid calling functions that are not signal-safe, such as printf(), inside a handler. Use flags or simple writes instead.

Using system calls safely

System calls expose the kernel directly, providing fine control but limited safety nets. Each call must be checked for errors, as nearly all can fail due to resource exhaustion or permission issues. They usually return -1 on failure and set errno to indicate the reason.

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>

int main(void) {
  int fd = open("test.txt", O_RDONLY);
  if (fd == -1) {
    perror("open");
    return 1;
  }

  char buf[64];
  ssize_t n = read(fd, buf, sizeof buf);
  if (n == -1) {
    perror("read");
    close(fd);
    return 1;
  }

  write(STDOUT_FILENO, buf, n);
  close(fd);
  return 0;
}

Distinguishing between library functions and direct system calls

Functions such as fopen() or fprintf() are part of the C standard library and may call multiple system calls under the hood. Lower-level calls like open(), read(), and write() map directly to kernel operations, providing more control over flags, permissions, and file descriptors.

Standard LibrarySystem Call LayerPurpose
fopen()open()Open file stream vs file descriptor
fread()read()Buffered I/O vs raw read
fprintf()write()Formatted vs raw output
💡 Start with high-level functions like fread() and fprintf() for most tasks. Drop to system calls only when you need exact control over file descriptors, performance, or asynchronous I/O.

Practising defensive error handling with system calls

Always check return values and handle interruptions gracefully. Some system calls can return early if interrupted by a signal (EINTR). In those cases, retry the operation. Looping until success or a terminal error ensures robustness.

ssize_t safe_read(int fd, void *buf, size_t count) {
  ssize_t n;
  do {
    n = read(fd, buf, count);
  } while (n == -1 && errno == EINTR);
  return n;
}
⚠️ Working directly with system calls requires a solid understanding of operating system semantics. Proper error checking and cleanup are essential to avoid resource leaks, zombie processes, or undefined behavior.

System programming reveals how C interacts with the OS at its most fundamental level. Mastering these techniques provides insight into how shells, servers, and utilities operate beneath the surface, and builds the foundation for writing performant and reliable software on any platform.