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.
#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 */
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
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.
| Topic | GCC/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 O | Available; needs headers | Not available; use Win32 APIs |
| Threads | -pthread links pthreads | <threads.h> or Win32 threads |
| Sockets | BSD sockets in libc | Winsock; 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
-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.
| Platform | Static | Dynamic | Example link flag |
| Linux | libfoo.a | libfoo.so | -Lpath -lfoo |
| macOS | libfoo.a | libfoo.dylib | -Lpath -lfoo or framework flags |
| Windows | foo.lib | foo.dll + foo.lib | foo.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
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.