A Struct Is a Block of Bytes

An array stores many values of the same type, one after another, and we reach element i with base + i*scale. A struct stores several values of possibly different types, also laid out one after another in memory. Reaching a member works almost the same way, except the offset of each member is fixed and known at compile time. So member access is even simpler than array indexing: it is just a fixed displacement from the struct's base address.

Here is the struct we will read:

struct Point
{
    int x;
    int y;
    int z;
};

In memory a Point is twelve contiguous bytes: x at offset 0, y at offset 4, z at offset 8 (each int is 4 bytes). The compiler knows these offsets, so accessing a member is a load at a constant displacement.

Member Access Is a Fixed Displacement

Consider two accessor functions:

int getY(const Point* p)
{
    return p->y;
}

int getZ(const Point* p)
{
    return p->z;
}

At -O2:

getY(Point const*):
    mov eax, DWORD PTR 4[rdi]
    ret
getZ(Point const*):
    mov eax, DWORD PTR 8[rdi]
    ret

The pointer p is in rdi, holding the address of the struct. getY reads 4[rdi]: four bytes past the start, which is exactly where y lives. getZ reads 8[rdi]: eight bytes past the start, where z lives. Accessing x would simply be [rdi] with no displacement, since x sits right at offset 0.

This is the whole idea. p->member compiles to a load (or store) at [base + offsetOfMember], where the offset is a constant the compiler computed from the struct layout. There is no index register and no scale, because there is nothing to compute at run time: the member's position is fixed. If you ever want to know a member's offset, you can read it straight off the displacement in the listing. Seeing 8[rdi] tells you the member begins 8 bytes into the struct.

Notice how this resembles the constant-index array case from the previous lesson. arr[3] became 12[rdi], a fixed displacement, for the same reason: the position was known at compile time. A struct member is always in that situation.

Alignment and Padding

The Point example was tidy because every member was the same size and the offsets came out as neat multiples of 4. Real structs are often messier, because the compiler must respect alignment: each type prefers to start at an address that is a multiple of its own size. An int wants to begin on a 4-byte boundary, a double on an 8-byte boundary, and so on. To satisfy this, the compiler sometimes inserts unused bytes, called padding, between members.

Watch what happens when a small member sits before a larger one:

struct Mixed
{
    char flag;
    int count;
};

You might expect flag at offset 0 and count right after it at offset 1. But int must be 4-byte aligned, and offset 1 is not a multiple of 4. So the compiler leaves three padding bytes after flag and places count at offset 4:

getCount(Mixed const*):
    mov eax, DWORD PTR 4[rdi]
    ret

The displacement 4[rdi], not 1[rdi], proves the padding is there. flag occupies byte 0; bytes 1, 2, and 3 are wasted padding; count starts at byte 4. The assembly does not mention the padding directly, but it is visible in the offset the compiler chose.

This also explains a result that surprises many newcomers. The struct contains a 1-byte char and a 4-byte int, so you might guess sizeof(Mixed) is 5. It is actually 8: 1 byte for flag, 3 bytes of padding, and 4 bytes for count. (The trailing size is rounded up so that arrays of the struct stay aligned.) When you reorder members, or mix types of different sizes, the padding and therefore the offsets and the total size can change, and the displacements in the assembly change right along with them.

Reading Layout From a Listing

Put these ideas together and a listing becomes a map of the struct. Suppose you encounter these two accesses for some type T* in rdi:

mov eax, DWORD PTR [rdi]       ; a 4-byte member at offset 0
mov rax, QWORD PTR 8[rdi]      ; an 8-byte member at offset 8

Without seeing the source you can already deduce a lot: there is a 4-byte member at the very start (an int, perhaps), and an 8-byte member at offset 8 (a long, a double, or a pointer). The gap between offset 4 and offset 8 is padding inserted so the 8-byte member could land on an 8-byte boundary. The displacement and the PTR size together reveal both where each member sits and how big it is.

The Unoptimized Version

For completeness, here is getY at -O0:

getY(Point const*):
    push rbp
    mov  rbp, rsp
    mov  QWORD PTR -8[rbp], rdi
    mov  rax, QWORD PTR -8[rbp]
    mov  eax, DWORD PTR 4[rax]
    pop  rbp
    ret

Same plumbing as always: the struct pointer is parked in a stack slot and reloaded into rax, and then the member is read with mov eax, DWORD PTR 4[rax]. The crucial part, the 4 displacement that locates y, is identical to the optimized version. The offset of a member is a property of the type, not of the optimization level, so it never changes.

Key Takeaways

  • A struct is a contiguous block of bytes; each member sits at a fixed offset from the start.
  • p->member compiles to a load/store at [base + offset], a fixed displacement with no index register or scale.
  • The displacement in the listing is the member's byte offset; the PTR size is the member's size.
  • The compiler inserts padding so each member meets its alignment (an int on a 4-byte boundary, a double on an 8-byte boundary, and so on).
  • Padding makes member offsets, and sizeof, larger than the raw sum of member sizes (e.g. {char; int} has size 8, with int at offset 4).
  • You can reconstruct a struct's layout by reading the displacements and sizes of its member accesses straight from the assembly.