Procedural Programming with C · Advanced · Week 37

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.

By the end of this week you can
  • 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 _Alignas and _Alignof.
  • Handle UTF-8 correctly without a library.
  • Write a binary format that survives moving between machines.

1What the standard does not fix

PropertyGuaranteedVaries
sizeof(char)Exactly 1Never
CHAR_BITAt least 88 everywhere you will meet
sizeof(int)At least 22 on small embedded, 4 elsewhere
sizeof(long)At least 44 on Windows, 8 on Linux and macOS
sizeof(void *)Nothing4 or 8; unrelated to sizeof(int)
Signedness of plain charNothingSigned on x86, unsigned on ARM
Byte orderNothingLittle-endian on x86 and ARM; big-endian in network protocols
Structure paddingNothingDepends on compiler and target

The long row is the one that catches working programmers. The three mainstream 64-bit data models disagree:

ModelintlongpointerUsed by
LP64488Linux, macOS, the BSDs
LLP6444864-bit Windows
ILP3244432-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

UseWhen
intOrdinary arithmetic, loop counters, small values. Still the default.
size_tSizes, counts, array indices
int32_t, uint8_tFile formats, protocols, hardware registers — anywhere the width is part of the contract
int_fast32_tHot loops where you want at least 32 bits and speed
intptr_tStoring 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:

78563412 12345678 little-endianbig-endian x86, ARMnetwork order addr+0+1+2+3

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 characterswchar_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
./portable

Why 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 dump

If 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 target

Break 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

MistakeWhat happensFix
Assuming long is 64 bitsWorks on Linux, breaks on Windowsint64_t.
Writing a raw struct to a filePadding and byte order baked inExplicit field-by-field encoding.
Forgetting htonl on a socketWorks between identical machines onlyConvert at every boundary.
*(uint32_t *)(buf + 1)Unaligned access and aliasing violationmemcpy.
%d for an int64_tFormat mismatch on some platformsPRId64 from <inttypes.h>.
Storing a byte value in plain charSigned on x86, unsigned on ARMuint8_t.
strlen as a character countWrong for any non-ASCII textCount non-continuation bytes.
Truncating UTF-8 at a byte boundaryInvalid sequence; broken displayBack off to a lead byte.
No magic or version in a binary formatWrong files silently misreadBoth, 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.