Language Runtimes · FFI and Interop
Struct Passing and Register Classification Across ABIs
How a compiler decides whether a struct travels in registers or in memory, what the eightbyte classification rules encode, and where layouts diverge between C and other languages.
A struct of two int fields and a struct of one long occupy the same eight bytes. Under the System V AMD64 convention they are passed identically, in a single general-purpose register. A struct of two float fields also occupies eight bytes and travels in a single SSE register instead. A struct of three int fields occupies twelve bytes and is passed in two registers. A struct of four double fields occupies thirty-two bytes and is passed in memory, through a pointer. None of these distinctions appears in the C type of the argument; all of them are decided by the ABI’s classification algorithm.
This is the layer where naive FFI declarations break. A binding that models a C struct as a language-level record and passes it by value assumes that the language’s own calling convention for records matches the C ABI’s. It often does not, because the language may pass all records by hidden pointer, or split them differently, or insert different padding. The result is a call that satisfies the register count and delivers the wrong bytes.
The eightbyte as the unit of decision
The System V AMD64 algorithm does not classify a struct as a whole. It slices the struct into eight-byte chunks and classifies each chunk independently, then merges the results. A chunk that contains only integer fields or pointers is class INTEGER; one that contains only float or double is class SSE; one that contains both is class INTEGER and the floating-point members are passed in integer registers; one that contains no fields at all is NO_CLASS.
The merging rule is what produces the counterintuitive cases. If any eightbyte of a struct is MEMORY, the whole struct is passed in memory, regardless of the other chunks. If two chunks are INTEGER and SSE respectively, the struct is passed in one general-purpose register and one SSE register. The order of the argument registers consumed follows the order of the chunks, so the first chunk takes the next available register of its class and the second takes the next available register of its own class.
A struct of { double x; double y; } is two SSE chunks and is passed in xmm0 and xmm1. A struct of { double x; int64_t y; } has an SSE first chunk and an INTEGER second, and is passed in xmm0 and rdi, not xmm0 and rsi, because the general-purpose sequence starts at rdi independently of how many SSE registers have been consumed. A struct of { int32_t a; double b; } is more awkward: the first eight bytes contain a four-byte integer followed by four bytes of padding, classified INTEGER, while the second eight bytes are SSE. The struct is passed in rdi and xmm0, with the padding bytes carrying unspecified values.
Anything larger than sixteen bytes is MEMORY under this convention. So is any struct containing an unaligned field, and any struct whose classification would require more registers than remain available. A struct that would have been passed in registers when it was the first argument may be passed in memory when it is the seventh, because the register sequences are exhausted. This is a property of the call site, not of the type, which is why two calls to the same function with the same struct type can differ in how the struct is transmitted.
Alignment decides the padding, not the declaration order
The classification operates on the struct’s in-memory layout, and that layout is determined by alignment rules that C leaves partly implementation-defined. Each member is placed at the next offset that is a multiple of its own alignment, and the struct’s alignment is the maximum of its members’ alignments, with the total size rounded up to a multiple of that alignment.
For struct { char c; int64_t n; } the character occupies offset zero, seven bytes of padding follow, and the integer occupies offsets eight through fifteen. The size is sixteen and the alignment is eight. For struct { int64_t n; char c; } the integer occupies offsets zero through seven, the character occupies eight, and seven bytes of trailing padding bring the size to sixteen. The two structs hold the same data and have the same size, but their first eight bytes are classified differently: the first struct’s first chunk is INTEGER with only one meaningful byte, the second’s is a full integer.
Packing directives change this. A compiler attribute or pragma that sets alignment to one removes the interior padding, producing a struct of size nine for the first example. The ABI then classifies an eightbyte boundary that falls in the middle of a misaligned int64_t, which is a MEMORY case: the struct is passed by pointer. A binding that declares the struct without the packing attribute will compute a different size, place subsequent fields at different offsets, and read the wrong bytes. If the struct is passed by value, the mismatch changes the calling convention as well as the layout.
Where C++ and Rust diverge from C
A C++ class with virtual functions has a vtable pointer as its first member on every mainstream ABI, which shifts every field by eight bytes and changes the first eightbyte’s classification. A class with a non-trivial copy constructor or destructor is passed by invisible reference rather than by value under the Itanium ABI, because the caller must construct a copy the callee can destroy; the parameter becomes a pointer to a temporary, and the register it occupies is an ordinary INTEGER one. A binding that declares such a class as a plain struct will pass it by value and the callee will interpret the first eight bytes of the struct as an address.
Rust’s default repr(Rust) gives the compiler freedom to reorder fields, so a Rust struct with the same field names as a C struct may have a different layout entirely. repr(C) fixes the layout to the C rules for the target, and repr(C, packed) removes padding. Passing a repr(Rust) struct across an FFI boundary is a category error that the compiler will not diagnose, because the type is still a valid Rust type; the bytes are simply in an order the foreign side does not expect. The mitigation is to declare every struct that crosses the boundary with repr(C) and to keep the declaration in one place.
A related divergence is the size of bool and of enum types. C’s _Bool is one byte, but a C enum’s underlying type is implementation-defined and may be int, unsigned int, or something narrower. Rust’s bool is one byte and its fieldless enums default to a size the compiler chooses, which repr(C) pins to the C enum rules. A language whose bool is four bytes will pass a struct of one boolean field in a different register class than the C side expects.
Reading a boundary from both sides
The reliable way to establish a struct’s transmission is to compile a small C translation unit and inspect the generated code, rather than to reason from the declaration. The calling convention determines where the registers come from in the first place, so the disassembly is the only place the two layers meet. A function that takes the struct by value and returns a field will show, in its prologue, whether the argument arrived in a register or was loaded through a pointer.
#include <stdint.h>
typedef struct { int32_t a; double b; } Mixed;
double take_mixed(Mixed m) { return m.b; }
Compiled for x86-64 System V, the body reads the double from xmm0 and ignores edi, which confirms the two-chunk classification. Compiled with the struct declared packed, the body instead loads a pointer from rdi and dereferences offset four, which confirms that packing pushed the type into the MEMORY class. The same source, two conventions, and only the compiler’s output distinguishes them.
For a binding, the durable practice is to generate the declaration from the C header rather than to transcribe it. Tools that parse headers and emit foreign declarations exist for most language ecosystems, and their value is not convenience but correctness: they apply the target’s alignment rules mechanically, so a struct that gains a field in a later library version changes shape in the binding at the same time.
The failure signature
Struct mismatches fail in a small number of recognisable ways. The callee reads a plausible but wrong value, because the field it wanted arrived in a register the caller used for something else. The callee crashes on a dereference, because a struct the caller passed by value was expected by pointer. The caller’s stack is corrupted after the call, because a struct the caller passed by pointer was expected in registers and the callee read eight bytes past the argument area. And, in the mildest case, nothing fails: the struct is small, both sides pass it in one register, and the padding bytes differ without anyone observing them.
The last case is the dangerous one, because it survives testing and breaks when the compiler changes its padding fill. Deterministic initialization of padding, zeroing the whole struct before assigning fields rather than assigning fields individually, removes one source of nondeterminism, though it does not fix a layout mismatch. The stable C ABI article treats this as one instance of a general rule: any type that crosses the boundary must have its representation pinned by a specification the two sides share, and “the compiler’s default” is not such a specification. Passing pointers to opaque types, as described in opaque handles and ownership transfer, sidesteps the classification problem entirely for types whose internals need not be visible.
References
- System V Application Binary Interface, AMD64 Architecture Processor Supplement, Section 3.2.3 — AMD64 psABI maintainers
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64), Section 6 — Arm Limited
- The C Programming Language, 2nd edition — Kernighan & Ritchie, Prentice Hall