<< Home

Chapter 16: Cross-Platform Development

Writing code that compiles and runs on Linux, macOS, and Windows requires careful choice of APIs and disciplined use of conditional compilation. This chapter shows practical patterns for selecting the right headers and functions, for performing common file and directory tasks in a portable way, for understanding compiler and library differences, and for linking static or dynamic libraries on each platform without surprises.

💡 Keep platform specific code in small translation units with clear interfaces. Call those interfaces from portable code so that most files remain free of #if blocks.

Conditional compilation for Linux, macOS, and Windows

Compilers define macros that identify the target operating system and toolchain. You can use these macros inside #if blocks to select the correct headers and functions. Prefer feature detection when possible; use platform detection when a feature is not present everywhere.

Recognizing platforms with predefined macros

Common predefined macros include _WIN32 for Windows (both 32 and 64 bit), __linux__ for Linux, and __APPLE__ together with __MACH__ for macOS. Toolchain macros include _MSC_VER for MSVC, __GNUC__ for GCC, and __clang__ for Clang.

#if defined(_WIN32)
  /* Windows specific includes */
  #include <windows.h>
#elif defined(__APPLE__)
  /* macOS specific includes */
  #include <TargetConditionals.h>
  /* use POSIX headers as well */
  #include <unistd.h>
#elif defined(__linux__)
  /* Linux specific includes */
  #include <unistd.h>
#else
  #error "Unsupported platform"
#endif

Selecting headers and functions safely

When a function is only available on POSIX systems you can provide a Windows alternative. Keep the public function name the same and isolate differences behind the scenes.

int sleep_ms(unsigned ms) {
#if defined(_WIN32)
  Sleep((DWORD)ms);
  return 0;
#else
  struct timespec ts = { ms / 1000, (long)(ms % 1000) * 1000000L };
  return nanosleep(&ts, NULL);
#endif
}

Using feature test macros rather than guessing

Some POSIX functions require enabling symbols before including headers. For example you can set _POSIX_C_SOURCE to request specific interfaces. This avoids relying on nonstandard extensions.

#define _POSIX_C_SOURCE 200809L
#include <unistd.h>
#include <time.h>
/* now POSIX interfaces such as clock_gettime may be declared */
⚠️ Windows uses UTF 16 for wide character APIs. If you need full Unicode path support you may need W variants such as CreateFileW and _wfopen, plus conversion between UTF 8 and UTF 16.

Portable file and directory handling

Most file operations are portable with the C standard library. Directory traversal and some metadata differ by platform. The goal is to use standard I O where possible and provide small shims for the parts that vary.

Opening, reading, and writing files portably

Use fopen(), fread(), fwrite(), fclose(), remove(), and rename(). On Windows you should add the "b" flag for binary data to avoid newline translation.

FILE *fp = fopen("data.bin",
#if defined(_WIN32)
         "wb"
#else
         "w"
#endif
);
if (!fp) { perror("fopen"); /* handle error */ }
/* write bytes... */
fclose(fp);

Querying file information with stat

stat() reports file size and type on POSIX systems. Windows has _stat and _wstat. You can wrap these behind a single helper that fills a common structure.

#if defined(_WIN32)
  #include <sys/types.h>
  #include <sys/stat.h>
  #define STAT _stat
#else
  #include <sys/stat.h>
  #define STAT stat
#endif

long long file_size(const char *path) {
  struct STAT st;
  if (STAT(path, &st) != 0) return -1;
  return (long long)st.st_size;
}

Walking directories on POSIX and Windows

POSIX provides opendir(), readdir(), and closedir(). Windows offers FindFirstFile, FindNextFile, and FindClose. A thin adapter lets your code iterate entries with the same callback signature.

/* POSIX version */
#if !defined(_WIN32)
  #include <dirent.h>
  int list_dir(const char *path) {
  DIR *d = opendir(path);
  if (!d) return -1;
  struct dirent *e;
  while ((e = readdir(d)) != NULL) {
    if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue;
    printf("%s\n", e->d_name);
  }
  closedir(d);
  return 0;
  }
#else
/* Windows version */
  #include <windows.h>
  int list_dir(const char *path) {
  char pattern[MAX_PATH];
  snprintf(pattern, sizeof pattern, "%s\\*", path);
  WIN32_FIND_DATAA ffd;
  HANDLE h = FindFirstFileA(pattern, &ffd);
  if (h == INVALID_HANDLE_VALUE) return -1;
  do {
    const char *name = ffd.cFileName;
    if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue;
    printf("%s\n", name);
  } while (FindNextFileA(h, &ffd));
  FindClose(h);
  return 0;
  }
#endif
💡 Normalize path handling at the boundaries. Accept forward slashes in configuration, then convert to native separators when calling platform APIs.

Differences in compilers and standard libraries

The C language is standardized, yet compilers and libraries vary in extensions, warning behavior, and default modes. Understanding these differences helps you choose flags that keep builds clean and reproducible.

Recognizing compiler dialects and choosing warning levels

GCC and Clang accept many of the same flags; MSVC uses different names. Treat warnings as guidance and aim for zero warnings across compilers. Use a strict language mode such as C17 without extensions unless an extension is required.

# GCC or Clang
cc -std=c17 -Wall -Wextra -Wpedantic -O2 -o app app.c

# MSVC (Developer Command Prompt)
cl /std:c17 /W4 /O2 app.c

Library availability and POSIX functions

glibc and musl on Linux, and libSystem on macOS, include POSIX functions such as fork(), poll(), and getline(). MSVCRT on Windows does not implement many POSIX calls. Prefer portable C functions; where you need POSIX, provide Windows alternatives.

TopicGCC/Clang (Linux/macOS)MSVC (Windows)
Language mode-std=c17 or -std=c23/std:c17 or newer
Warnings-Wall -Wextra -Wpedantic/W4 or /Wall
POSIX I OAvailable; needs headersNot available; use Win32 APIs
Threads-pthread links pthreads<threads.h> or Win32 threads
SocketsBSD sockets in libcWinsock; call WSAStartup

Detecting compiler versions and working around bugs

You can branch on compiler version macros when a specific fix is required. Keep such workarounds isolated and remove them once the minimum supported version includes the fix.

#if defined(__GNUC__) && !defined(__clang__)
  #if (__GNUC__ < 10)
  /* apply workaround for GCC < 10 … */
  #endif
#endif
⚠️ Avoid relying on undefined behavior that happens to work with one compiler. Enable sanitizers such as -fsanitize=address,undefined on GCC and Clang during testing to catch issues early.

Static and dynamic linking on each platform

Linking produces an executable by combining object files and libraries. Static libraries are archives that become part of the binary. Dynamic libraries are loaded at run time by the loader. Each platform uses different file extensions and search rules.

Library file types and naming conventions

Linux uses .a for static libraries and .so for shared libraries. macOS uses .a and .dylib (plus frameworks). Windows uses .lib for static libraries and import libraries, and .dll for dynamic libraries.

PlatformStaticDynamicExample link flag
Linuxlibfoo.alibfoo.so-Lpath -lfoo
macOSlibfoo.alibfoo.dylib-Lpath -lfoo or framework flags
Windowsfoo.libfoo.dll + foo.libfoo.lib on the link line

Linking on the command line

Use the compiler driver to link so that the correct runtime libraries are chosen. Order of objects and libraries matters on Unix like linkers; place libraries after the objects that reference them.

# Linux or macOS shared link
cc -o app main.o util.o -Lthird_party/lib -lfoo

# Linux static link (may increase size)
cc -static -o app main.o -Lthird_party/lib -lfoo

# macOS with a framework
cc -o app main.o -framework CoreFoundation

# Windows MSVC link
cl /Fe:app.exe main.obj util.obj foo.lib

Loading dynamic libraries at run time

You can load a plugin at run time. POSIX systems use dlopen() and dlsym(). Windows uses LoadLibrary and GetProcAddress. Always check for errors and unload when finished.

#if defined(_WIN32)
  #include <windows.h>
  HMODULE h = LoadLibraryA("foo.dll");
  if (!h) { /* handle error */ }
  FARPROC sym = GetProcAddress(h, "foo_init");
  /* call through a typed function pointer */
  FreeLibrary(h);
#else
  #include <dlfcn.h>
  void *h = dlopen("libfoo.so", RTLD_NOW);
  if (!h) { /* handle error */ }
  void *sym = dlsym(h, "foo_init");
  dlclose(h);
#endif
💡 Keep runtime search paths explicit during development. On Linux you can use LD_LIBRARY_PATH or link with -Wl,-rpath,<path>; on macOS use DYLD_LIBRARY_PATH or -Wl,-rpath; on Windows ensure the directory containing the .dll is on PATH or next to the executable.