There's a phrase everyone who has ever ported a C project from Linux to Windows has heard, or said themselves: "it compiled, so it must be fine." It isn't. And that's exactly where the real problem with writing portable C lives — it's not about clean code or following some best-practices checklist, it's about knowing precisely where Linux, macOS and Windows actually disagree, and sealing that off before the difference leaks silently into the rest of the program.
## Type sizes are where it actually hurts
The nastiest bug of all, and one that catches even experienced people, is the size of `long`. On Linux and macOS 64-bit it's 8 bytes, the LP64 model. On Windows, even compiling in 64-bit, `long` stays at 4 bytes — only `long long` and pointers reach 8 bytes there, the LLP64 model. This doesn't throw a compile error. It usually doesn't warn either, under default settings. It just breaks at runtime, when someone casts a pointer to `long` (still fairly common in older code ported from earlier C) or assumes `sizeof(long) == sizeof(void*)` somewhere in an offset calculation. There was a case, if I remember right, of a compression library doing exactly that, running fine in Linux production for years, and nobody had tested the Windows build until a client who only ran Windows Server needed it. The fix is more or less obvious once you know the problem exists: use `<stdint.h>` for anything where the width actually matters.
```c
#include <stdint.h>
int32_t counter; // always 4 bytes, on any platform
int64_t total_size; // always 8 bytes
uintptr_t address; // wide enough to hold a pointer
size_t buffer_size; // already portable by definition
```
`long` becomes a type to avoid, except where the standard itself forces it (return value of `strtol`, say), and even then treat it as "at least 32 bits" and nothing more.
To detect the platform at compile time, three macros cover almost everything: `_WIN32` (which, despite the name, is also defined in 64-bit builds — this trips people up constantly), `__APPLE__` combined with `TargetConditionals.h` when you actually need to tell macOS apart from iOS, and `__linux__`. The problem usually isn't using the wrong macro. It's scattering `#ifdef _WIN32` across forty different files, which turns any behavior change into a manhunt. Better to centralize it in one header, something like a `platform.h` defining `PLATFORM_WINDOWS`, `PLATFORM_MACOS`, `PLATFORM_LINUX`, and let the rest of the code call wrapper functions of its own — `platform_thread_create()`, that sort of thing — never seeing a platform `#ifdef` outside that one isolated file.
```c
// platform.h
#if defined(_WIN32)
#define PLATFORM_WINDOWS 1
#elif defined(__APPLE__)
#define PLATFORM_MACOS 1
#elif defined(__linux__)
#define PLATFORM_LINUX 1
#endif
```
## POSIX versus the Windows API
The widest divergence, and the one that demands an architectural decision rather than a syntax swap, sits between POSIX (Linux and macOS follow this: `unistd.h`, `sys/stat.h`, `pthread.h`, `fork()`, `dlopen()`) and Windows' native API, which is a different world entirely — `windows.h`, `CreateThread`, `LoadLibrary`. There are basically two paths here. One is writing your own thin abstraction layer, functions like `platform_file_open()` hiding the `#ifdef` behind them, which makes sense when the project has few system dependencies and you want full control. The other is using something that already solved this — libuv (the same base Node runs on, incidentally) for async I/O and threads, SDL if it's game or multimedia work, or C11's `<threads.h>` when the compiler supports it (careful here, MSVC support for this is still fairly recent and not always complete, worth checking the version before assuming). And there's one thing on this list that isn't negotiable: you cannot reimplement `fork()` on Windows. It simply doesn't exist there, only `CreateProcess`, which spins up a process from scratch without copying the parent's memory. If your architecture depends on fork — a server that forks per connection, say — porting to Windows isn't swapping a function call, it's redesigning the architecture, usually toward a threading model.
File paths have two problems people tend to conflate. The directory separator bothers people far less than its reputation suggests, because most of the Windows CRT functions accept `/` just fine. The real issue is hardcoded paths like `"C:\\data\\file.txt"` scattered through the code. Prefer relative paths when you can. What actually bites — and bites often, especially teams developing on Mac and deploying to a Linux server — is case sensitivity. Linux is case-sensitive on the filesystem, full stop. macOS by default isn't (though it preserves the case you typed), so a `#include "Utils.h"` pointing at the real file `utils.h` compiles cleanly on your Mac and blows up on the Linux CI. This never shows up in local testing, only when someone builds on the right environment — which is usually a Friday afternoon, in my experience.
The compiler is where MSVC decides not to be GCC with a different name. It doesn't support several GNU extensions common in code written Linux-first, and by default it complains — sometimes treated as an error depending on project settings — asking you to swap `strcpy`, `sprintf`, `scanf` for Microsoft's "secure" versions like `strcpy_s`. Those functions aren't standard C, they don't exist on Linux or macOS. Two ways out here, with different weight. You can throw `_CRT_SECURE_NO_WARNINGS` before the includes and get it to compile — but that only silences the warning, it doesn't fix the safety issue it was flagging. Or you can abstract those calls behind your own wrapper that uses the secure version where available and the standard one where not, more work but it actually solves it.
```c
#if defined(_WIN32)
#define _CRT_SECURE_NO_WARNINGS
#endif
```
C99 and C11 support on MSVC used to be pretty rough and improved a lot from Visual Studio 2015 onward, except "improved a lot" isn't a guarantee — if the code uses VLAs or `_Generic`, check the target compiler version rather than assume it'll behave like GCC.
Keeping a Makefile for Linux and macOS and a separate Visual Studio project for Windows is the decision that accrues the most interest over time, on any project that grows past a single `.c` file. CMake became the de facto standard not because it's perfect, far from it, but because it solves exactly that pain: it detects whether it's compiling with MSVC, GCC or Clang and generates each platform's native build system from a single `CMakeLists.txt`. For smaller projects, Meson is a leaner alternative with simpler syntax covering the same ground. Migrating to this after the project already grew three hand-maintained builds is a lot more expensive than deciding it upfront.
Dynamic library naming everyone already knows — `.so`, `.dylib`, `.dll` — what actually catches people is symbol export. Linux and macOS export non-static functions by default from a shared library. Windows is the complete opposite: nothing gets out unless you mark it with `__declspec(dllexport)` when compiling the DLL and `__declspec(dllimport)` on the consuming side.
```c
#if defined(_WIN32)
#ifdef BUILDING_MYLIB
#define MYLIB_API __declspec(dllexport)
#else
#define MYLIB_API __declspec(dllimport)
#endif
#else
#define MYLIB_API
#endif
MYLIB_API void my_function(void);
```
Forgetting this is probably the single most common cause of "links wrong only on Windows" in existence.
Endianness today is almost a non-issue. x86, x86-64, practically every Mac, PC and Linux server in use is little-endian, so it only really matters if you're serializing binary to be read on a different architecture (rare outside specific embedded work) or dealing with a network protocol, which solved this decades ago with `htonl`/`ntohl`.
Two things to watch before wrapping up. Line endings: `\n` versus `\r\n`, and if the file content needs to be byte-identical across platforms, opening in binary mode instead of text mode stops the system from silently converting it underneath you. And the most common mistake of all, honestly: testing only on your own platform and assuming it'll work on the others. Type bugs and missing headers never show up in code review, only in the actual build — which means "works on my machine" doesn't rigorously prove anything beyond that.
In the end, what separates a C project that genuinely compiles across all three platforms from one held together with scattered `#ifdef`s isn't how many operating systems it supports. It's how many points concentrate that complexity — one platform header, one thin abstraction layer, one CMakeLists.txt, one export macro. The rest of the code shouldn't even know which OS it's running on. And still, every time I think I've solved this for good on some project, some new detail shows up. Usually on Windows. Usually on a Friday.