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.
int get(int *A, long i) { return A[i]; }
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.
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
.
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 expression | Type | Value |
|---|---|---|
A | int * | |
A + i | int * | |
*(A + i) | int | |
&A[i] - A | long | (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.
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).
struct rec {
int i; // offset 0
int j; // offset 4
long v; // offset 8
int *p; // offset 16
};
# 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 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
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.
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.
union u {
float f; // 4 bytes, offset 0
int bits; // 4 bytes, offset 0 — same storage as f
}; // sizeof(union u) == 4
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.
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
- 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. ↩
- GNU, Using the GNU Compiler Collection (GCC), §6.35 Type Attributes — the
packedattribute suppressing struct padding, and the misaligned-access cost it incurs. ↩ - 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. ↩
- 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 ╌╌