Writing Portable C Across Different Pointer Sizes

Writing portable software in C across architectures with varying pointer sizes—such as 16-bit microcontrollers, 32-bit embedded systems, and 64-bit servers—requires strict adherence to data abstraction, standardized integer types, and architecture-neutral programming practices. This article explains how developers decouple pointer logic from fixed-width assumptions, select appropriate types from standard headers, manage structure alignment, and format output safely to ensure code compiles and runs reliably regardless of the underlying memory model.

Use Standard Integer Types for Pointer Arithmetic

A common portability pitfall is casting a pointer to an int or long. In modern computing, data models vary significantly:

  • ILP32: int, long, and pointers are 32 bits.
  • LP64 (most Unix/Linux 64-bit systems): int is 32 bits, while long and pointers are 64 bits.
  • LLP64 (64-bit Windows): int and long are 32 bits, while long long and pointers are 64 bits.

Because sizeof(long) does not reliably match pointer width across platforms, C99 introduced <stdint.h>, which provides:

  • intptr_t: A signed integer type guaranteed to hold a pointer value without truncation.
  • uintptr_t: An unsigned integer type guaranteed to hold a pointer value without truncation.

If an address must be stored or manipulated as an integer (for instance, during low-level memory mapping or hashing), developers must use uintptr_t instead of basic integer types.

Standardize Sizes and Offsets with <stddef.h>

Using basic types for memory allocation and buffer management leads to overflow bugs or truncation on architectures with larger memory spaces. C provides dedicated types in <stddef.h>:

  • size_t: The unsigned integer type representing the size of any object in bytes (the result of the sizeof operator). It adapts dynamically to the memory addressing limit of the target architecture.
  • ptrdiff_t: The signed integer type used to represent the result of subtracting one pointer from another within the same array.

Using unsigned int instead of size_t can cause buffer-length limits or overflow vulnerabilities on 64-bit targets.

Avoid Binary Serialization of In-Memory Pointers

Because pointer sizes differ across targets, raw in-memory structures cannot be written directly to disk or transmitted over a network if interoperability between different architectures is required.

To maintain portability:

  1. Never transmit raw pointers: Serialize only semantic identifiers, offsets, or array indices.
  2. Account for struct padding and alignment: A structure containing a pointer will vary in total size and alignment boundaries between 32-bit and 64-bit systems. Use explicit packing (#pragma pack or attributes) cautiously, or preferably, serialize fields explicitly using fixed-width types like uint32_t or uint64_t.

Use Correct Format Specifiers

Printing pointer values or memory sizes using incorrect format specifiers causes undefined behavior due to stack mismatching during variadic function calls.

  • Use %p for printing pointer addresses (casting the argument to (void *)).
  • Use %zu for variables of type size_t.
  • Use %td for variables of type ptrdiff_t.
  • Use macros from <inttypes.h> (such as PRIxPTR or PRIdPTR) when formatting uintptr_t or intptr_t.

Enforce Compiler Checks and Static Analysis

Modern compilers can detect pointer-width incompatibilities during compilation:

  • Enable strict warnings such as -Wall -Wextra -Wconversion -Wpointer-to-int-cast in GCC and Clang.
  • Use static assertions via _Static_assert (or static_assert in C11 and later) to validate critical architectural invariants at compile time rather than relying on runtime checks.
  • Compile and test code against multiple cross-compilation toolchains (e.g., targeting both 32-bit ARM and 64-bit x86_64) within continuous integration pipelines to catch pointer truncation errors early.