Machine-Level Programming/Arrays, Structs, and Alignment

Lesson 2.61,367 words

Arrays, Structs, and Alignment

How aggregate data lays out in memory. Arrays as base-plus-scaled-index, the row-major ordering of multidimensional arrays, pointer arithmetic in units of the pointed-to type, struct fields at fixed byte offsets, the overlapping storage of unions, and the alignment rules that force padding into a struct.

╌╌╌╌

C's aggregate types — arrays, structs, unions — are conveniences the compiler flattens into addresses and offsets. An array access is an address computation; a struct field is a fixed displacement; alignment rules sometimes insert invisible padding bytes. This lesson shows how each aggregate becomes memory, which is what lets you read the addressing arithmetic compiled code is full of.

Arrays are base plus scaled index

An array T A[N] is N contiguously stored elements of type T, occupying N * sizeof(T) bytes starting at a base address. The address of element A[i] is a single multiply-and-add.

This formula is the scaled-index addressing mode D(Rb,Ri,S) from data movement, with Rb the base, Ri the index i, and the scale S = sizeof(T) — which is why the hardware's scales are precisely {1,2,4,8}, the sizes of the primitive types.

Indexing an int array A[i]. The element address is the base plus i times 4 bytes; the index 2 reaches element A[2] at base + 8. The cells are contiguous, each 4 bytes wide.
arrayidx.cc
int get(int *A, long i) { return A[i]; }
arrayidx.sassembly
get:
        movl    (%rdi,%rsi,4), %eax  # eax = A[i]: base %rdi + i*4
        ret                          # int is 4 bytes, so scale 4

Multidimensional arrays are row-major

C stores a 2-D array T A[R][C] in row-major order: row 0 in full, then row 1, and so on. The element A[i][j] is at row , column , so its address folds the two indices into one offset.

A 3x4 int array in row-major memory. Rows are stored consecutively, so A[i][j] sits at base + (i*4 + j)*4 bytes; the row stride is 4 ints.

For T A[R][C] the address of A[i][j] is : advance whole rows of elements, then within the row. The row stride is the byte distance from one row to the next, and stepping a row pointer by one row is adding that stride.

For example, take int A[3][4] based at address 0x200, and compute &A[2][3]. With columns and , the linear element index is , so the byte offset is and the address is . A compiler emits this as one scaled load once it has the row base: from a pointer to row 2 (at ), the element is 12(%rbase) since .

Address of A[2][3] in int A[3][4] based at 0x200. The two indices fold into the linear index i*C+j = 11; scaling by 4 bytes gives offset 0x2C, so the element sits at 0x22C.

Pointer arithmetic is typed

C scales pointer arithmetic by the pointed-to type automatically. If p has type T *, then p + i is the address p + i * sizeof(T), and *(p + i) is identical to p[i]. The expressions A[i], *(A + i), and &A[0] + i all denote the same element because indexing is scaled pointer arithmetic.

C expressionTypeValue
Aint *
A + iint *
*(A + i)int
&A[i] - Along (difference in elements, not bytes)

The last row is worth naming: subtracting two T * pointers yields the number of elements between them, the byte difference divided by sizeof(T).

A numeric trace makes the scaling visible. Let a long *p (8-byte elements) hold the address 0x2000. Then p + 3 is not 0x2003 but , because C scaled the 3 by sizeof(long) = 8. If a second pointer q holds 0x2028, then q - p is not 0x28 = 40 but : five longs separate them. The compiler emits the scaling silently — p + 3 compiles to a lea of 24(%rdi), and q - p to a subtract followed by a shift right by 3 (divide by 8). Confusing the byte gap with the element gap is the single most common pointer bug, and the machine level is where the factor of sizeof(T) becomes explicit.

Typed pointer arithmetic on long p = 0x2000. p+3 advances by 38 = 24 bytes to 0x2018, not to 0x2003; and q - p for q = 0x2028 is (0x28)/8 = 5 elements, the byte gap divided by sizeof(long).

Structs are fixed field offsets

A struct packs its fields into one block in declaration order, each at a fixed byte offset from the start computed at compile time. Accessing a field is a mov at offset(%base).

rec.cc
struct rec {
    int   i;      // offset 0
    int   j;      // offset 4
    long  v;      // offset 8
    int  *p;      // offset 16
};
rec.sassembly
# set r->v = r->i, with r in %rdi
movl    (%rdi), %eax       # eax = r->i   (offset 0)
movslq  %eax, %rax         # sign-extend int to long
movq    %rax, 8(%rdi)      # r->v = ...   (offset 8)
The struct rec laid out in memory. Each field sits at a fixed byte offset from the start, fixed at compile time; r->v is just the bytes at offset 8, which compiles to the operand 8(%rdi).

The compiler turned r->v into the constant displacement 8(%rdi); field names exist only in the source. Different structs can therefore alias the same field offsets, the basis of how the compiler reads i, j, v, and p.

Alignment forces padding

The hardware prefers a primitive of size to sit at an address that is a multiple of int (4 bytes) at a multiple of 4, long and pointers (8) at a multiple of 8. To honor this the compiler inserts padding bytes between fields, and pads the struct's total size up to a multiple of its largest member's alignment so that arrays of the struct stay aligned.1

The alignment rule for a 4-byte int. Starting at address 4 (a multiple of 4) is aligned; starting at 5 straddles a 4-byte boundary and is misaligned, which the compiler avoids by padding.
Layout of {char c; int i; char d; long v;}. Padding (shaded) follows c to 4-align i, follows d to 8-align v, giving total size 24 with the struct itself 8-aligned.

Reordering fields from widest to narrowest minimizes padding. The struct above spans 24 bytes; declaring long v; int i; char c; char d; packs into 16, because the two chars share the trailing slack instead of each triggering its own gap.

The same four fields reordered widest-first. long v fills bytes 0-7, int i bytes 8-11, and the two chars sit at 12 and 13 with only 2 trailing pad bytes: 16 bytes total against the 24 of the declaration order.

Unions overlap their members

A union lays all its members at the same offset 0, so they share storage and the union's size is its largest member's. Only one member is meaningful at a time; writing one and reading another reinterprets the same bytes, the controlled type-pun behind reading a value's raw representation.

un.cc
union u {
    float  f;     // 4 bytes, offset 0
    int    bits;  // 4 bytes, offset 0 — same storage as f
};                // sizeof(union u) == 4
A union overlaps its members at offset 0. Both f and bits name the very same 4 bytes, so the union is only 4 bytes wide; writing one and reading the other reinterprets the identical bits.

For example, write u.f = 1.0f and then read u.bits. The float 1.0 has the IEEE-754 single-precision encoding 0x3F800000 (sign 0, exponent 01111111, fraction all zero), so u.bits reads back exactly 0x3F800000 — the integer , not 1. The bytes never changed; only the interpretation did. This is precisely how code inspects a floating-point value's raw representation, and how the classic fast inverse-square-root trick reads a float's bits as an int to manipulate its exponent.

Writing u.f = 1.0f and reading u.bits. The 4 bytes hold the IEEE-754 pattern 0x3F800000; read as a float that is 1.0, read as an int it is 1065353216. Same storage, two interpretations.

Where a struct's size is the sum of its (padded) fields, a union's is the max of its members. The two are the additive and the overlapping ways to combine fields into one block.

Packing, false sharing, and flexible arrays

CS:APP explains why the compiler pads; the wider practice is a set of tools and hazards around that padding. The first is the escape hatch: gcc and clang accept __attribute__((packed)) on a struct to suppress all padding, laying fields back to back regardless of alignment. It shrinks the struct — useful for a network packet or on-disk record whose layout is fixed by an external format — but every misaligned field then costs the processor extra work, and on some architectures a misaligned access faults outright. Packing trades space for access cost, the exact inverse of the reordering trick above.2

The subtler hazard is false sharing, which the memory-hierarchy literature treats at length. Caches move data in fixed-size lines (64 bytes on x86-64). If two threads write two different fields that happen to land in the same cache line, the hardware must ferry the line back and forth between the cores' caches on every write, serializing them even though the fields are logically independent. The fix is alignment turned up rather than down: alignas(64) (or __attribute__((aligned(64)))) pads a hot per-thread field out to its own cache line so writes stop colliding. Here alignment is not about correctness but about throughput, and it is a standard concern in the performance-engineering chapters that follow the machine level.3

One last idiom the standard supports is the flexible array member: a struct whose final field is declared T arr[]; with no size, so a single malloc can allocate the struct's header and a run-length array of trailing elements as one contiguous block. The array's base is just the offset past the header — the same fixed-offset arithmetic as any field — which is why the pattern needs no special support beyond the layout rules this lesson already covers.4

Footnotes

  1. Bryant & O'Hallaron, CS:APP, §3.9.3 — Data Alignment: the requirement that a primitive of size start at an address divisible by , satisfied by inter-field and trailing padding.
  2. GNU, Using the GNU Compiler Collection (GCC), §6.35 Type Attributes — the packed attribute suppressing struct padding, and the misaligned-access cost it incurs.
  3. Drepper, What Every Programmer Should Know About Memory (2007), §3.3.4 and §6.4 — cache-line granularity and false sharing between independent fields co-resident in one 64-byte line, mitigated by per-line alignment.
  4. ISO/IEC 9899:1999 (C99), §6.7.2.1 — the flexible array member: a struct whose last member is an incomplete array type, sized at allocation and addressed at the fixed offset past the header.

╌╌ END ╌╌