Portability and the Machine Model
Week 36 covered behavior with no requirements. This week covers behavior that is perfectly defined and simply different somewhere else — type widths, byte order, alignment, character encoding. It is the difference between code that runs here and code that runs everywhere.
- Use
<stdint.h>and say when a fixed width is required rather than preferred. - Detect and convert byte order, and explain why network protocols specify it.
- Reason about alignment and use
_Alignasand_Alignof. - Handle UTF-8 correctly without a library.
- Write a binary format that survives moving between machines.
1What the standard does not fix
| Property | Guaranteed | Varies |
|---|---|---|
sizeof(char) | Exactly 1 | Never |
CHAR_BIT | At least 8 | 8 everywhere you will meet |
sizeof(int) | At least 2 | 2 on small embedded, 4 elsewhere |
sizeof(long) | At least 4 | 4 on Windows, 8 on Linux and macOS |
sizeof(void *) | Nothing | 4 or 8; unrelated to sizeof(int) |
Signedness of plain char | Nothing | Signed on x86, unsigned on ARM |
| Byte order | Nothing | Little-endian on x86 and ARM; big-endian in network protocols |
| Structure padding | Nothing | Depends on compiler and target |
The long row is the one that catches working programmers. The three mainstream 64-bit data models disagree:
| Model | int | long | pointer | Used by |
|---|---|---|---|---|
| LP64 | 4 | 8 | 8 | Linux, macOS, the BSDs |
| LLP64 | 4 | 4 | 8 | 64-bit Windows |
| ILP32 | 4 | 4 | 4 | 32-bit systems |
Code that assumes long holds a pointer or 64 bits works on Linux and breaks on Windows. Use int64_t when you mean 64 bits, and intptr_t when you mean "wide enough for a pointer".
2Fixed-width types
#include <stdint.h>
#include <inttypes.h>
int8_t int16_t int32_t int64_t /* exactly this many bits */
uint8_t uint16_t uint32_t uint64_t
int_least16_t /* at least 16, smallest available */
int_fast32_t /* at least 32, fastest available */
intptr_t uintptr_t /* can hold a pointer */
size_t ptrdiff_t /* sizes and differences */Printing them needs the macros from <inttypes.h>, because the underlying type differs by platform:
printf("%" PRId64 "\n", value); /* int64_t */
printf("%" PRIu32 "\n", count); /* uint32_t */
printf("%zu\n", size); /* size_t has its own specifier */The syntax is string-literal concatenation: PRId64 expands to "ld" or "lld" as appropriate, and the adjacent literals join at compile time.
When to use which
| Use | When |
|---|---|
int | Ordinary arithmetic, loop counters, small values. Still the default. |
size_t | Sizes, counts, array indices |
int32_t, uint8_t | File formats, protocols, hardware registers — anywhere the width is part of the contract |
int_fast32_t | Hot loops where you want at least 32 bits and speed |
intptr_t | Storing a pointer in an integer (rarely needed) |
Do not replace every int with int32_t. Fixed widths are for data whose layout is specified; for ordinary arithmetic, int is what the machine prefers and what everyone expects.
3Byte order
A multi-byte integer can be stored with its least significant byte first (little-endian) or last (big-endian). The value 0x12345678 in memory:
Same value, same four bytes, opposite order. Week 26's raw-struct file format depends on which machine wrote it.
This matters the moment bytes leave your process — a file, a socket, shared memory between different architectures.
#include <arpa/inet.h> /* POSIX */
uint32_t on_wire = htonl(host_value); /* host TO network long */
uint32_t local = ntohl(on_wire); /* network TO host long */
uint16_t port = htons(1234); /* short version */"Network byte order" is big-endian, fixed by convention since the earliest internet protocols. On a big-endian machine these functions do nothing; on a little-endian one they swap. Calling them unconditionally is correct everywhere, which is the point.
Without POSIX, do it explicitly — this version does not care what the host order is:
static void store_be32(uint8_t *out, uint32_t v)
{
out[0] = (uint8_t)(v >> 24);
out[1] = (uint8_t)(v >> 16);
out[2] = (uint8_t)(v >> 8);
out[3] = (uint8_t)(v);
}
static uint32_t load_be32(const uint8_t *in)
{
return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16)
| ((uint32_t)in[2] << 8) | (uint32_t)in[3];
}Shifting works on values, not on the bytes in memory, so the result is identical on either architecture. This is the technique to use for a file format.
4Alignment
Most architectures require an object's address to be a multiple of its size — an int at a multiple of 4, a double at a multiple of 8. This is where week 22's structure padding came from.
printf("%zu\n", _Alignof(double)); /* usually 8 */
_Alignas(64) char cache_line[64]; /* aligned to a cache line */An unaligned access is a crash on some architectures, and merely slow on x86. Reading a uint32_t straight out of a byte buffer is the usual way to cause one:
uint32_t bad = *(uint32_t *)(buffer + 1); /* alignment + aliasing UB */
uint32_t good;
memcpy(&good, buffer + 1, sizeof good); /* correct everywhere */memcpy solves the alignment problem and the strict-aliasing problem from week 36 at the same time, and compiles to the same instruction on a platform that permits the unaligned load. It is the standard answer to "how do I read an integer out of a buffer".
5Text beyond ASCII
Week 4 covered ASCII's 128 codes. Turkish needs ş, ğ, ı, ö, ü, ç; other languages need far more. Two approaches exist and one of them has won.
Wide characters — wchar_t and <wchar.h> — use a fixed-width type per character. The trouble is that wchar_t is 32 bits on Linux and 16 on Windows, so it is not portable, and 16 bits is not enough for all of Unicode anyway.
UTF-8 encodes each code point in one to four bytes. It has three properties that made it universal:
- ASCII is unchanged — a byte below 0x80 means exactly what it always did.
- No byte of a multi-byte sequence can be mistaken for ASCII, so
strchr(s, '/')still works correctly. - It is byte-order independent, so no conversion is needed when it travels.
The consequence for C is that char * holds UTF-8 perfectly well, and most string code needs no change. What changes is counting:
const char *s = "türkçe";
strlen(s) /* 8 BYTES, not 6 characters */To count characters, count the bytes that are not continuation bytes. A continuation byte has its top two bits set to 10:
static size_t utf8_length(const char *s)
{
size_t chars = 0;
for (; *s != '\0'; s++) {
if (((unsigned char)*s & 0xC0) != 0x80) {
chars++; /* not a continuation byte */
}
}
return chars;
}The practical rule: treat text as bytes wherever you can — copying, comparing, searching, and writing all work unchanged. Decode only when you must count characters, truncate for display, or change case. And never truncate a UTF-8 string at an arbitrary byte, or you will split a character in half.
6Worked example: a portable record format
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdbool.h>
#include <stddef.h>
/* ---------- what this machine looks like ---------- */
static bool is_little_endian(void)
{
uint32_t probe = 1;
unsigned char first;
memcpy(&first, &probe, 1);
return first == 1;
}
/* ---------- explicit big-endian accessors ---------- */
static void store_be16(uint8_t *out, uint16_t v)
{
out[0] = (uint8_t)(v >> 8);
out[1] = (uint8_t)(v);
}
static void store_be32(uint8_t *out, uint32_t v)
{
out[0] = (uint8_t)(v >> 24); out[1] = (uint8_t)(v >> 16);
out[2] = (uint8_t)(v >> 8); out[3] = (uint8_t)(v);
}
static void store_be64(uint8_t *out, uint64_t v)
{
for (int i = 0; i < 8; i++) {
out[i] = (uint8_t)(v >> (56 - 8 * i));
}
}
static uint16_t load_be16(const uint8_t *in)
{
return (uint16_t)(((uint16_t)in[0] << 8) | in[1]);
}
static uint32_t load_be32(const uint8_t *in)
{
return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16)
| ((uint32_t)in[2] << 8) | (uint32_t)in[3];
}
static uint64_t load_be64(const uint8_t *in)
{
uint64_t v = 0;
for (int i = 0; i < 8; i++) v = (v << 8) | in[i];
return v;
}
/* ---------- the in-memory record ---------- */
#define NAME_BYTES 32
typedef struct {
uint32_t id;
uint16_t score;
uint64_t timestamp;
char name[NAME_BYTES]; /* UTF-8 bytes, NUL padded */
} Record;
/* ---------- the wire format: fixed offsets, fixed widths, big-endian ----------
* offset size field
* 0 4 magic "CREC"
* 4 2 version
* 6 4 id
* 10 2 score
* 12 8 timestamp
* 20 32 name (UTF-8, NUL padded)
* total 52 bytes, identical on every machine
*/
#define WIRE_SIZE 52
#define WIRE_MAGIC "CREC"
#define WIRE_VERSION 1
static void record_encode(const Record *r, uint8_t out[WIRE_SIZE])
{
memcpy(out, WIRE_MAGIC, 4);
store_be16(out + 4, WIRE_VERSION);
store_be32(out + 6, r->id);
store_be16(out + 10, r->score);
store_be64(out + 12, r->timestamp);
memset(out + 20, 0, NAME_BYTES);
memcpy(out + 20, r->name, strnlen(r->name, NAME_BYTES));
}
static bool record_decode(const uint8_t in[WIRE_SIZE], Record *out)
{
if (memcmp(in, WIRE_MAGIC, 4) != 0) {
return false; /* not our format */
}
uint16_t version = load_be16(in + 4);
if (version != WIRE_VERSION) {
return false; /* version check, not a guess */
}
out->id = load_be32(in + 6);
out->score = load_be16(in + 10);
out->timestamp = load_be64(in + 12);
memcpy(out->name, in + 20, NAME_BYTES);
out->name[NAME_BYTES - 1] = '\0'; /* guarantee termination */
return true;
}
/* ---------- UTF-8 ---------- */
static size_t utf8_length(const char *s)
{
size_t chars = 0;
for (; *s != '\0'; s++) {
if (((unsigned char)*s & 0xC0) != 0x80) chars++;
}
return chars;
}
/* Truncate to at most max_bytes without splitting a character. */
static size_t utf8_safe_truncate(const char *s, size_t max_bytes)
{
if (strlen(s) <= max_bytes) return strlen(s);
size_t cut = max_bytes;
while (cut > 0 && ((unsigned char)s[cut] & 0xC0) == 0x80) {
cut--; /* back off to a lead byte */
}
return cut;
}
int main(void)
{
puts("== this machine ==");
printf(" CHAR_BIT assumed 8; sizeof: int %zu, long %zu, void * %zu\n",
sizeof(int), sizeof(long), sizeof(void *));
printf(" data model : %s\n",
sizeof(long) == 8 ? "LP64 (Linux/macOS)" :
sizeof(void *) == 8 ? "LLP64 (Windows)" : "ILP32");
printf(" byte order : %s\n",
is_little_endian() ? "little-endian" : "big-endian");
printf(" alignment : int %zu, double %zu, max %zu\n",
_Alignof(int), _Alignof(double), _Alignof(max_align_t));
printf(" plain char is %s\n", (char)-1 < 0 ? "signed" : "unsigned");
puts("\n== why a raw struct is not a format ==");
printf(" sizeof(Record) = %zu, but the fields total %zu\n",
sizeof(Record), (size_t)(4 + 2 + 8 + NAME_BYTES));
printf(" offsets: id %zu, score %zu, timestamp %zu, name %zu\n",
offsetof(Record, id), offsetof(Record, score),
offsetof(Record, timestamp), offsetof(Record, name));
printf(" the wire format is a fixed %d bytes on every machine\n",
WIRE_SIZE);
puts("\n== encode and decode ==");
Record original = {
.id = 0x12345678u,
.score = 950,
.timestamp = UINT64_C(1735689600),
.name = "Ada Lovelace"
};
uint8_t wire[WIRE_SIZE];
record_encode(&original, wire);
printf(" first 20 bytes: ");
for (int i = 0; i < 20; i++) printf("%02X ", wire[i]);
putchar('\n');
puts(" note bytes 6-9 are 12 34 56 78 — most significant first,");
puts(" regardless of how this machine stores integers internally");
Record restored;
if (record_decode(wire, &restored)) {
printf(" id : 0x%08" PRIX32 " %s\n", restored.id,
restored.id == original.id ? "ok" : "MISMATCH");
printf(" score : %" PRIu16 " %s\n", restored.score,
restored.score == original.score ? "ok" : "MISMATCH");
printf(" timestamp : %" PRIu64 " %s\n", restored.timestamp,
restored.timestamp == original.timestamp ? "ok" : "MISMATCH");
printf(" name : \"%s\"\n", restored.name);
}
puts("\n== rejecting foreign and future data ==");
uint8_t alien[WIRE_SIZE] = { 'X','X','X','X' };
printf(" wrong magic : %s\n",
record_decode(alien, &restored) ? "accepted (bad)" : "rejected");
memcpy(alien, WIRE_MAGIC, 4);
store_be16(alien + 4, 99);
printf(" future version: %s\n",
record_decode(alien, &restored) ? "accepted (bad)" : "rejected");
puts("\n== unaligned access ==");
uint8_t buffer[16] = { 0, 0x12, 0x34, 0x56, 0x78 };
uint32_t value;
memcpy(&value, buffer + 1, sizeof value); /* the correct way */
printf(" memcpy from an odd offset: 0x%08" PRIX32 "\n", value);
puts(" *(uint32_t *)(buffer + 1) would be an alignment and");
puts(" aliasing violation; it crashes on some architectures");
puts("\n== UTF-8 ==");
const char *turkish = "türkçe karakterler";
printf(" \"%s\"\n", turkish);
printf(" strlen = %zu bytes\n", strlen(turkish));
printf(" utf8_length = %zu characters\n", utf8_length(turkish));
for (size_t limit = 1; limit <= 4; limit++) {
size_t cut = utf8_safe_truncate(turkish, limit);
printf(" truncate to %zu bytes -> %zu bytes: \"%.*s\"\n",
limit, cut, (int)cut, turkish);
}
puts(" cutting at an arbitrary byte would split a character in half");
return EXIT_SUCCESS;
}gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined -o portable portable.c
./portableWhy the wire format is built this way
A magic number. Four bytes that identify the format, so a wrong file is rejected instead of misread. Every real binary format has one.
A version field. Checked, not assumed. Without it, a version 2 file read by a version 1 program silently produces wrong values. With it, the program refuses and says why.
Fixed widths and fixed offsets. Nothing depends on sizeof or on the compiler's padding. The record is 52 bytes on a 32-bit ARM board and on a 64-bit x86 server.
Explicit byte order. The shift-based accessors operate on values, so they produce the same bytes regardless of host order. Note that the program prints 12 34 56 78 for 0x12345678 even though the machine stores it as 78 56 34 12 internally.
Prove the endianness handling
./portable | grep -A1 'first 20 bytes'
xxd -l 20 <<< "" # for comparison with a raw struct dumpIf you have access to a big-endian environment — QEMU can emulate one, which weeks 49 onward use anyway — run the same program there. The debug output for byte order changes; the twenty bytes of wire format do not. That is the whole objective.
qemu-s390x -L /usr/s390x-linux-gnu ./portable_s390x # big-endian targetBreak it on purpose
Replace record_encode with a raw structure write:
fwrite(&original, sizeof original, 1, f);Now the file contains this compiler's padding, this machine's byte order, and this platform's type sizes. It is unreadable on any other configuration, and — worse — readable but wrong on a machine with the same sizes and different byte order. Week 26 used exactly this shortcut; here is the reason it was flagged as unsuitable for exchange.
The UTF-8 truncation
The output shows that truncating "türkçe" to 2 bytes yields 1 byte, because the ü occupies bytes 1 and 2 and cutting between them would produce invalid UTF-8. Naive truncation is how a database field or a display label ends in a replacement character — and in a protocol it can be a parsing failure rather than a cosmetic one.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Assuming long is 64 bits | Works on Linux, breaks on Windows | int64_t. |
| Writing a raw struct to a file | Padding and byte order baked in | Explicit field-by-field encoding. |
Forgetting htonl on a socket | Works between identical machines only | Convert at every boundary. |
*(uint32_t *)(buf + 1) | Unaligned access and aliasing violation | memcpy. |
%d for an int64_t | Format mismatch on some platforms | PRId64 from <inttypes.h>. |
Storing a byte value in plain char | Signed on x86, unsigned on ARM | uint8_t. |
strlen as a character count | Wrong for any non-ASCII text | Count non-continuation bytes. |
| Truncating UTF-8 at a byte boundary | Invalid sequence; broken display | Back off to a lead byte. |
| No magic or version in a binary format | Wrong files silently misread | Both, and check both. |
8Check yourself
Why is long a poor choice for a 64-bit value?
Because the standard guarantees only 32 bits, and the mainstream 64-bit platforms disagree: it is 8 bytes under LP64 on Linux and macOS and 4 bytes under LLP64 on Windows. Code assuming the wider size compiles on both and produces wrong results on one. int64_t states the requirement exactly.
Why do the shift-based encoders work regardless of the host's byte order?
Because shifting operates on the numeric value, not on its representation in memory. v >> 24 extracts the most significant byte of the number on any machine, so writing it first always produces big-endian output. A cast-and-copy approach would instead expose whatever the host happens to store.
Why use memcpy to read an integer out of a byte buffer?
Because it solves two problems at once: the buffer offset may not satisfy the integer's alignment requirement, which crashes on some architectures, and casting the pointer would violate strict aliasing. memcpy is defined in both respects and compiles to the same single instruction where the hardware permits an unaligned load.
What three properties made UTF-8 the universal encoding?
ASCII passes through unchanged, so existing text and existing code keep working; no byte of a multi-byte sequence can be confused with an ASCII character, so byte-oriented searching and splitting stay correct; and it is byte-order independent, so it needs no conversion when it moves between machines.
Why must a binary format carry a magic number and a version?
The magic identifies the format so a wrong file is rejected rather than interpreted as garbage. The version lets a reader refuse data written by a newer writer instead of misreading it — the alternative is silently wrong values, which is far worse than an error. Both must be checked, not merely written.
9Where this leads
Week 38 completes the language: the qualifiers volatile, restrict, and register, inline functions, compound literals, anonymous structures, variable-length arrays, and non-local jumps. volatile in particular is the bridge to the embedded weeks, where it stops being an optimization detail and becomes the difference between a working driver and a hanging one.