Writing portable C
Write C that compiles and runs correctly across platforms — feature-test macros, C standards, compiler extensions, and portability pitfalls.
- Explain the difference between C89, C99, C11, and C17
- Use _POSIX_C_SOURCE to enable POSIX extensions
- Apply feature-test macros to access platform-specific APIs safely
- Identify common portability pitfalls around integer sizes and endianness
C code that works on Linux today may fail on macOS, Windows, or an embedded target — or even on the same platform with a different compiler version. Writing portable C means knowing which features are part of the standard, which are POSIX extensions, and which are compiler-specific.
C language standards
| Standard | Year | Key additions |
|---|---|---|
| C89/C90 | 1989/1990 | The original ANSI/ISO C |
| C99 | 1999 | // comments, VLAs, stdint.h, bool, designated initialisers, restrict |
| C11 | 2011 | _Alignas, _Generic, <threads.h>, _Atomic, _Static_assert |
| C17 | 2018 | Bug fixes to C11; no new features |
| C23 | 2023 | nullptr, _BitInt, #embed, typeof |
Request a specific standard with gcc: -std=c99, -std=c11, -std=c17, or -std=c23. The default in recent gcc is roughly C17 with GNU extensions.
For maximum portability, target C99 (-std=c99). For modern features, target C11 (-std=c11). Most production code targets one of these.
POSIX extensions and feature-test macros
Standard C does not include POSIX APIs: fork, pipe, pthreads, sigaction, openat, etc. To use them, define feature-test macros before any #include (or via -D on the command line):
#define _POSIX_C_SOURCE 200809L /* POSIX.1-2008 */
#include <unistd.h>
#include <pthread.h>Or in the Makefile:
CFLAGS += -D_POSIX_C_SOURCE=200809LCommon feature-test macros:
| Macro | What it enables |
|---|---|
_POSIX_C_SOURCE=200809L | POSIX.1-2008 (most POSIX APIs) |
_XOPEN_SOURCE=700 | X/Open (superset of POSIX) |
_GNU_SOURCE | GNU extensions (Linux-specific, non-portable) |
_DEFAULT_SOURCE | glibc default (POSIX + BSD) |
Do not use _GNU_SOURCE in code that must run on macOS or BSD — use _POSIX_C_SOURCE instead.
Exact-width integer types
int is not 32 bits everywhere. Use <stdint.h> for exact sizes:
#include <stdint.h>
int32_t x = -1000000; /* exactly 32 bits, signed */
uint64_t y = 18000000000ULL; /* exactly 64 bits, unsigned */
uint8_t byte = 0xFF; /* exactly 8 bits */For sizes that just need to be "at least N bits", use the minimum-width types: int_least32_t, uint_least16_t.
For the fastest type with at least N bits: int_fast32_t.
Endianness
Multi-byte integers are stored differently on different architectures:
- Little-endian (x86, x86-64, ARM default): least significant byte first.
- Big-endian (some ARM modes, SPARC, PowerPC): most significant byte first.
This matters when reading binary file formats or network packets:
#include <arpa/inet.h> /* POSIX: network byte order conversion */
uint32_t host_value = 12345678;
uint32_t network_value = htonl(host_value); /* host to network (big-endian) */
uint32_t back = ntohl(network_value); /* network to host */htonl/ntohl (host to network long / network to host long) and htons/ntohs (for uint16_t) convert between host byte order and network byte order (big-endian). Always use them for network data.
Checking at compile time: _Static_assert
#include <stdint.h>
_Static_assert(sizeof(int) == 4, "Expected 32-bit int");
_Static_assert(sizeof(void *) == 8, "Expected 64-bit pointer");_Static_assert (C11) causes a compile error if the condition is false, with the given message. Use it to document and enforce portability assumptions at the point where they matter.
Compiler-specific attributes
When using compiler-specific features, guard them:
#if defined(__GNUC__) || defined(__clang__)
#define LIKELY(x) __builtin_expect(!!(x), 1)
#define UNLIKELY(x) __builtin_expect(!!(x), 0)
#else
#define LIKELY(x) (x)
#define UNLIKELY(x) (x)
#endifThis lets you use __builtin_expect for branch prediction hints while keeping the code compilable on non-GCC compilers.
Compile on at least two platforms. The single most effective portability practice: compile your code with both gcc and clang (-Wall -Wextra -pedantic -std=c11) and fix all warnings. Clang's diagnostic messages are often more precise. Divergence between the two compilers reveals assumptions baked into your code.
Where to go next
Next: the mini shell challenge — you will build a minimal interactive shell that combines fork, exec, waitpid, pipes, and signal handling into a complete systems program.
Pipes and inter-process communication
Connect processes with Unix pipes — creating pipes with pipe(), redirecting stdout/stdin with dup2(), and the design of Unix pipelines.
Challenge: Build a minimal shell
Implement a minimal interactive shell in C that parses commands, forks child processes, handles pipes, and manages signals — integrating the full advanced C curriculum.