Structs, Unions, and Bitfields

A struct is not just a bundle of fields — it’s a memory layout the compiler chooses to satisfy each field’s alignment requirement. If you don’t understand padding and alignment, you’ll be confused why sizeof(struct { char; int; }) is 8 instead of 5, and one day you’ll wonder why your struct-of-arrays for SIMD blows up on aligned load. This file makes struct layout concrete, then covers unions (the tagged-union pattern you’ll use for AST nodes and VM values), flexible array members, and the one time bitfields are worth it.

Alignment: the Rule the Compiler Follows

Each primitive type has an alignment requirement — typically equal to its size on a 64-bit machine. The compiler pads structs so every field lands at an address divisible by its alignment.

Type

Size (64-bit)

Alignment

char

1

1

short

2

2

int

4

4

long (LP64)

8

8

long long

8

8

float

4

4

double

8

8

void *

8

8

And the struct itself has an alignment equal to the maximum alignment of its members, and its size is rounded up to a multiple of that alignment (so arrays of the struct are also aligned).

The Padding Walk-Through

Consider:

struct A { char a; int b; char c; };

The compiler places a at offset 0 (1 byte). b needs offset 4-aligned, so 3 bytes of padding at offset 1..3, b at 4..7. c at 8 (1 byte). Struct alignment = 4 (max of members), so total size padded to 12. sizeof(struct A) == 12, not 6.

Compare:

struct B { int b; char a; char c; };

b at 0..3, a at 4, c at 5. Struct alignment = 4, so padded to 8. sizeof(struct B) == 8.

Reorder large-to-small to save memory. Not always worth it, but for hot structs (per-request state, per-connection, per-particle in a physics sim) the savings compound.

The Nasty One

struct C { char a; double d; char b; };
// a at 0, then 7 bytes padding, d at 8, b at 16, then 7 bytes padding to satisfy 8-alignment.
// sizeof(struct C) == 24. Only 10 bytes of data.

struct D { double d; char a; char b; };
// d at 0, a at 8, b at 9, then 6 bytes trailing padding.
// sizeof(struct D) == 16.

Dump layouts with offsetof:

#include <stddef.h>
#include <stdio.h>
printf("a: %zu, b: %zu, sizeof: %zu\n", offsetof(struct A, a), offsetof(struct A, b), sizeof(struct A));

clang -Xclang -fdump-record-layouts prints the layout compiler-side — useful when investigating a puzzling sizeof.

Anonymous Struct/Union Members (C11)

Since C11, an unnamed inner struct/union is transparent:

struct Point {
    struct { int x, y; };   // anonymous struct member
};

struct Point p;
p.x = 3;   // no p.<something>.x needed.

Useful for one-off nested groupings; also enables the tagged-union pattern below with cleaner syntax.

Unions and the Tagged-Union Pattern

A union is a memory region large enough to hold any one of its members. Only one is valid at a time; the language does not track which. So you pair it with a tag — the tagged union pattern, which you’ll write dozens of times in your bytecode VM and any AST.

typedef enum { VAL_INT, VAL_FLOAT, VAL_STR } val_tag;

typedef struct {
    val_tag tag;
    union {
        int64_t i;
        double  f;
        char   *s;   // ownership is your responsibility
    } as;
} value;

void print_value(const value *v) {
    switch (v->tag) {
        case VAL_INT:   printf("%" PRId64 "\n", v->as.i); break;
        case VAL_FLOAT: printf("%g\n", v->as.f); break;
        case VAL_STR:   puts(v->as.s); break;
    }
}

The compiler cannot warn you if you access v->as.f after storing to v->as.i — that’s your discipline. Wrap creation in constructor-like functions (value_new_int, value_new_str) and never touch the union directly outside those.

Type punning via union (writing one field, reading another) has been legal in C since C99 (it’s UB in C++). But memcpy is always safer and equally fast under optimization:

// Bit-pattern of a float as uint32:
float f = 3.14f;
uint32_t bits;
memcpy(&bits, &f, sizeof(bits));   // portable, no aliasing worries

Flexible Array Members (C99)

A struct’s last member may be T name[] — an incomplete array. You allocate a single block big enough for the struct + N elements:

typedef struct {
    size_t len;
    char   data[];   // FAM. Must be the last member.
} string;

string *str_new(size_t n) {
    string *s = malloc(sizeof(*s) + n + 1);   // +1 for null
    if (!s) return NULL;
    s->len = n;
    s->data[n] = '\0';
    return s;
}

string *s = str_new(100);
memcpy(s->data, "hello", 5);
// One allocation, cache-friendly, no separate pointer to chase.

Rules:

  • FAM must be the last member.

  • The containing struct must have at least one other member (sizeof semantics).

  • sizeof(struct string) gives the size without the flex part.

  • You cannot have a FAM in an array, or embed a FAM struct in another struct as anything other than the last field.

This pattern shows up all over: msghdr in networking, struct inotify_event, and in every pool allocator worth reading. Learn it.

Before C99, people used the hack char data[1]; and over-allocated. That’s technically UB (write past the declared size) but compilers tolerated it. Use [], not [0] (a GCC extension) and not [1] (the old hack).

Bitfields — When They Bite

struct Packed {
    unsigned int flag_a : 1;
    unsigned int flag_b : 1;
    unsigned int count  : 6;
};

Bitfields let you pack sub-byte fields, useful when interfacing with hardware registers or wire protocols. The catch: bit-order and packing across bytes is implementation-defined. GCC packs LSB-first on little-endian, MSB-first on big-endian, but the standard does not require this. Two problems:

  1. Not portable to binary wire formats. Do not use bitfields to lay out a struct that must match a network packet or a file format. Use explicit shifts and masks on a uint32_t.

  2. Cannot take &. You can’t have a pointer to a bitfield member.

  3. Can silently promote. Bitfields participate in integer promotion; a 1-bit field being negated is subtle.

Rule of thumb: for wire protocols, use uint32_t + shifts/masks; for compact in-memory flags where portability doesn’t matter (single-process, single-compiler), bitfields are convenient and fine.

offsetof and the container_of Trick

offsetof(type, member) from <stddef.h> gives you the byte offset of a member. Combined with pointer arithmetic, this is the trick behind Linux’s intrusive linked lists:

#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

typedef struct list_node { struct list_node *next; } list_node;
typedef struct request { int id; list_node link; } request;

request *req = container_of(some_node_ptr, request, link);

This is how you get from a linked-list node pointer back to the enclosing struct. Used everywhere in the Linux kernel, worth understanding by M13.

What Most People Get Wrong About Structs

They assume sizeof(struct T) equals the sum of member sizes. It doesn’t — padding adds up, sometimes to 30% or more of the struct for pathological field orderings. This matters when you allocate millions of them (cache misses) or serialize them (padding bytes are indeterminate content that must not be fwrite’d as-is or you leak stack garbage across a network).

The second common mistake: struct A a1; struct A a2 = a1; copies the padding including whatever uninitialized garbage was there. memcmp of two “equal” structs can fail because their padding differs. If you need to hash or compare structs, either compare field-by-field or memset the whole struct to 0 first (which C guarantees works: all-bits-zero is a valid null pointer and a valid 0 for every integer/float type).

Exercises

  1. Write a struct with 5 mixed-size fields. Predict sizeof and every offsetof. Verify with a program. Then reorder to minimize size, re-verify.

  2. Implement the value tagged union above and a print function. Add a fourth variant (VAL_LIST — pointer to another value array with a length). Free correctly.

  3. Implement a length-prefixed string using a flexible array member. Write str_new, str_free, str_append (which reallocates). Run under ASan.

  4. Read <linux-kernel>/include/linux/list.h (or a mirror online). Understand container_of and the doubly-linked list macros. This is one of the most educational ~200 lines of C in existence.


Return to README.md · Next: 04_preprocessor_and_macros.md