04 · Writing Portable C¶
C runs on more platforms than any other language, and that is exactly why portable C is hard. The standard deliberately leaves things unspecified so that implementers can map C onto everything from an 8-bit microcontroller to a 64-bit server, and every one of those gaps is a place your program can behave differently somewhere else.
The useful mental model is three distinct categories, which programmers routinely blur together:
| Category | Meaning | Example |
|---|---|---|
| Implementation-defined | The compiler picks and must document it | Is plain char signed? |
| Unspecified | The compiler picks and need not tell you | Order of evaluation of function arguments |
| Undefined | Anything at all may happen; no requirements | Signed overflow, out-of-bounds access |
Implementation-defined behaviour is a portability problem: your program is still valid, it just does something else elsewhere. Undefined behaviour is a correctness problem: your program is invalid, and the optimiser is entitled to assume it cannot happen.
What is actually guaranteed about integer types¶
Almost nothing. Here is what the standard promises versus what one 64-bit machine reports:
// sizes.c -- what the standard guarantees vs what this machine does
#include <stdio.h>
#include <limits.h>
#include <stdint.h>
#include <inttypes.h>
__STDC_VERSION__ = 201112L
type size min standard minimum
char 1 8 CHAR_BIT >= 8
short 2 >= 16 bits
int 4 >= 16 bits (!)
long 8 >= 32 bits
long long 8 >= 64 bits
void * 8 no guarantee at all
size_t 8 unsigned, big enough for any object
fixed width (portable):
int32_t 4 bytes, INT32_MAX = 2147483647
int64_t 8 bytes, INT64_MAX = 9223372036854775807
intptr_t 8 bytes (an integer that can hold a pointer)
is plain char signed here? yes
sizeof(char) is 1 by definition — but a "byte" is CHAR_BIT bits, and
CHAR_BIT is only guaranteed to be at least 8. Some DSPs use 16 or 32.
The one that catches real code: int is only required to hold ±32767, and
long only ±2147483647. The three common data models disagree:
| Model | int |
long |
void * |
Where |
|---|---|---|---|---|
| ILP32 | 32 | 32 | 32 | 32-bit Linux, older embedded |
| LP64 | 32 | 64 | 64 | 64-bit Linux, macOS, BSD |
| LLP64 | 32 | 32 | 64 | 64-bit Windows |
So long is 64-bit on macOS and 32-bit on Windows, both 64-bit platforms.
Code that stores a pointer or a file offset in a long is broken on
Windows and nowhere else — the single most common portability bug in C.
The fix is <stdint.h>: use int32_t/uint64_t when the width matters,
size_t for sizes and indices, intptr_t when an integer must hold a
pointer, and plain int only for small values where you genuinely do not
care.
Format specifiers must match exactly¶
printf is variadic, so the compiler cannot coerce arguments for you — a
mismatched specifier reads the wrong number of bytes off the argument list.
// badfmt.c -- format specifiers that "work" on one platform and break on another
size_t n = 42;
int64_t big = 9000000000;
printf("%d\n", n); /* WRONG: size_t is not int */
printf("%ld\n", big); /* WRONG on LLP64 (Windows): long is 32-bit there */
printf("%zu %" PRId64 " %ld\n", n, big, l); /* right */
badfmt.c:11:20: warning: format specifies type 'int' but the argument has type 'size_t' (aka 'unsigned long') [-Wformat]
11 | printf("%d\n", n);
| ~~ ^
| %zu
badfmt.c:12:21: warning: format specifies type 'long' but the argument has type 'int64_t' (aka 'long long') [-Wformat]
| %lld
2 warnings generated.
Both bugs printed the right answer on this machine. That is the danger: the ABI happened to pass a 64-bit value where the format expected 32 bits, and little-endian ordering meant the low half was in the right place. Recompile on a big-endian or LLP64 target and you get garbage.
%zu for size_t, %td for ptrdiff_t, and the <inttypes.h> macros
(PRId64, PRIu32, PRIxPTR) are the portable answers. The macros expand
to whatever string literal is correct on the target, and adjacent string
literals concatenate — which is why the syntax looks odd.
Treat -Wformat as an error. Both bugs above were caught at compile
time by warnings that most projects have on and ignore.
Struct layout and byte order¶
Never write a struct's raw bytes to a file or socket. Padding, alignment and endianness are all implementation-defined:
struct Bad { char a; int b; char c; }; /* padded */
struct Good { int b; char a; char c; }; /* reordered */
struct Bad : size 12 (a@0 b@4 c@8)
struct Good: size 8 (b@0 a@4 c@5)
0x01020304 in memory: 04 03 02 01 -> little-endian
portable big-endian encoding: 01 02 03 04
decoded back: 0x01020304 ok
The same three members, in a different order, cost 12 bytes or 8. The
compiler inserted three bytes of padding after a so that b lands on a
4-byte boundary, and one more at the end so arrays stay aligned. Ordering
members from largest to smallest usually shrinks a struct for free — a real
win when you have millions of them.
For anything that leaves the process, serialise a byte at a time:
wire[0] = (unsigned char)(v >> 24);
wire[1] = (unsigned char)(v >> 16);
wire[2] = (unsigned char)(v >> 8);
wire[3] = (unsigned char)(v );
This code produces big-endian output on every platform, because shifting
is defined in terms of values, not storage. It needs no #ifdef, no
endianness detection, and no htonl. Shift-and-mask serialisation is one of
the few places where the portable version is also the simplest.
Implementation-defined behaviour, and where it bites¶
// impldef.c -- undefined, unspecified, and implementation-defined, side by side
printf("plain char is %s\n", (char)-1 < 0 ? "signed" : "unsigned");
printf("-8 >> 1 = %d\n", -8 >> 1);
int big = 300;
printf("(char)300 = %d\n", (char)big);
char c = (char)0xE9; /* 'é' in Latin-1 */
printf("index from plain char : %d\n", c);
printf("index from unsigned : %d\n", (unsigned char)c);
int m = INT_MAX;
printf("INT_MAX + 1 = %d\n", m + 1);
plain char is signed
-8 >> 1 = -4 (arithmetic shift here; logical is also legal)
(char)300 = 44
index from plain char : -23 <-- negative! out-of-bounds index
index from unsigned : 233
about to overflow INT_MAX...
impldef.c:24:36: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior impldef.c:24:36
INT_MAX + 1 = -2147483648
Four separate lessons in eight lines:
- Plain
charsignedness is implementation-defined. Signed on x86 and Apple silicon, unsigned on ARM Linux and PowerPC. The byte0xE9read as -23 here and would read as 233 there. - That difference is a live bug the moment you write
table[c]. A negative index is an out-of-bounds read that ASan catches and a code review does not. Always useunsigned charfor byte data, and cast before passing to<ctype.h>functions likeisalpha, which are defined only forunsigned charvalues andEOF. -8 >> 1giving-4is arithmetic shift, which every mainstream compiler does — but right-shifting a negative value is implementation-defined, so it is not guaranteed. Shift unsigned types; divide signed ones.- Signed overflow is undefined, not "wraps around." UBSan named the line.
It printed
-2147483648this time; at a different optimisation level the compiler may delete the check that guards it, because it is allowed to assume overflow never happens. Useunsignedarithmetic when you want defined wrapping, or check before the operation (if (a > INT_MAX - b) ...).
Feature test macros and platform detection¶
Anything beyond ISO C — POSIX calls, GNU extensions — needs the right feature macro, before any include:
For genuinely divergent platforms, detect at compile time and keep the
#ifdef blocks as small as possible — ideally one wrapper function, not
sprinkled through your logic:
#if defined(_WIN32)
#define PATH_SEP '\\'
#elif defined(__APPLE__)
#define PATH_SEP '/'
#elif defined(__linux__)
#define PATH_SEP '/'
#else
#error "unsupported platform"
#endif
The #error is deliberate. A silent fallback that "probably works" on an
unknown platform is worse than a build failure that tells someone to think.
Cheat sheet¶
| Need | Portable answer |
|---|---|
| Exact-width integer | int32_t, uint64_t (<stdint.h>) |
| Size of an object / an index | size_t |
| Difference of two pointers | ptrdiff_t |
| Integer holding a pointer | intptr_t / uintptr_t |
| Print them | %zu, %td, PRId64, PRIxPTR (<inttypes.h>) |
| Byte data | unsigned char, never plain char |
| Type limits | <limits.h>, <float.h> |
| Alignment of a type | _Alignof(T) (C11) |
| Offset of a member | offsetof(T, m) (<stddef.h>) |
| Serialise a number | Shift and mask into unsigned char[] |
| Detect C version | __STDC_VERSION__ (201112L = C11, 201710L = C17) |
| Detect platform | _WIN32, __linux__, __APPLE__, __unix__ |
| Enable POSIX APIs | #define _POSIX_C_SOURCE 200809L first |
Portability flags worth turning on: -std=c11 -pedantic -Wall -Wextra
rejects extensions you did not mean to use, and
-Wconversion -Wsign-conversion finds the implicit narrowing that silently
changes behaviour when a type's width changes underneath you.
How It Actually Works¶
Struct layout differences trace back to two independent compiler decisions: alignment and byte order, and it helps to see both as mechanical rules rather than platform mysteries.
Alignment: every type has a required alignment (_Alignof), and the
compiler inserts padding so each member starts at an address that is a
multiple of its own alignment, and so the whole struct's size is a multiple
of its largest member's alignment (so arrays of the struct keep every
element aligned too). Given struct { char a; int b; char c; } on a
platform where int needs 4-byte alignment: a sits at offset 0, three
padding bytes fill 1–3 so b can start at offset 4, b occupies 4–7, c
sits at offset 8, and three more padding bytes bring the struct's total
size to 12 (a multiple of 4) so a second struct in an array starts aligned
too. Reordering the same three members as {int b; char a; char c;} needs
only 8 bytes — the compiler cannot reorder them for you because C guarantees
declaration order for members, so you control padding by member ordering
(widest members first is the standard heuristic).
Byte order is a property of how a multi-byte integer's bytes map to
ascending memory addresses, decided by the CPU, not the compiler: on a
little-endian machine (x86-64, most ARM configurations) the least
significant byte of a 32-bit value sits at the lowest address; on a
big-endian machine it's the most significant byte. *(uint32_t *)&wire[0]
= v writes v's bytes in whatever order the local CPU uses internally, so
that same buffer read back on a machine with the other byte order produces
a different number — not a crash, a silently wrong value. The shift-and-mask
version in this module sidesteps the CPU's internal representation entirely:
wire[0] = (v >> 24) & 0xFF is defined purely in terms of v's numeric
value (shifting is an arithmetic operation on the integer, not a
reinterpretation of its storage bytes), so it produces the identical four
bytes in wire[] regardless of which byte order the host CPU uses for its
own registers.
Plain-char signedness is implementation-defined for a genuinely
historical reason: some CPU instruction sets (ARM, PowerPC, IBM Z) sign-
extend a loaded byte for free as part of the load instruction when treating
it as signed is cheaper, and others (older x86 conventions, though x86
itself defaults char to signed under most ABIs) zero-extend more
naturally — the C standard left it open so the ABI could pick whichever the
underlying load instruction does cheaply rather than force everyone to pay
for an explicit sign/zero-extension. That single unspecified bit is why
table[(char)byte] compiles to movsx (sign-extend, giving you a negative
index for bytes ≥ 0x80) on some ABIs and movzx (zero-extend, giving 0–255)
on others — identical source, genuinely different generated instruction and
runtime behavior.
Exercise¶
Write portable_io.c providing write_u32(FILE *, uint32_t) and
read_u32(FILE *, uint32_t *) that use shift-and-mask serialisation, plus a
write_struct/read_struct pair for a record containing a uint16_t, a
uint64_t, and a fixed 12-byte name. Write a file with one program and read
it back with another; verify the on-disk size is exactly what you computed
by hand, not sizeof(struct).
Then hunt for portability bugs in your own earlier code. Rebuild any program
from Level 3 with -std=c11 -pedantic -Wall -Wextra -Wconversion and fix
every warning. Pay particular attention to: int used where size_t
belongs, %d printing a size_t, plain char holding data above 127, and
any sizeof(long) assumption. For each fix, write one sentence naming the
platform where the original would actually have broken — if you cannot name
one, you may not have found a real bug.