Familiar situation: you designed a binary protocol, everything’s beautiful, the struct matches the spec one-to-one. You send it over UART or CAN, and the receiver gets garbage. Or: it works on a Cortex-M4, but on an M0+ it dies with a HardFault and a “but I didn’t do anything weird”.
Spoiler: a struct in C - is not “a group of variables”, it’s a specific picture of bytes, and that picture is drawn not by us but by the compiler. The outputs below come from GCC 13.3 (x86-64), arm-none-eabi-gcc 13.2.1, pahole 1.25 and rustc 1.84; with other versions the asm details may differ.
TL;DR
- The compiler inserts invisible bytes (padding) between fields so that every field sits at an address that is a multiple of its
alignof.- Field order affects size. Sort fields from largest to smallest and check with
pahole.packedremoves padding, but drops the alignment of the whole struct to 1: on Cortex-M0 even fields at offset 0 are read byte by byte.packed, aligned(4)partially fixes this.- A HardFault on M0 is caused not by
packeditself, but by code that “forgets” about the misalignment: a castuint8_t *=>uint32_t *or taking the address of a packed field.packed≠ “ready-made wire format”: there’s also byte order and garbage in the padding. For protocols, serialize explicitly.- Alignment - is an ABI matter:
doubleon i386 is aligned to 4, on ARM and x86-64 to 8. Even arguments in registers get aligned.- On a Cortex-M7 with D-cache, a DMA buffer is aligned to the cache line (32 bytes) both by address and by size, otherwise cache maintenance will hit neighboring variables.
- C++ adds hidden bytes (vptr), Rust reorders fields by itself by default. The difference between the languages is not in “alignment”, but in what each of them guarantees.
Hardware: why alignment exists at all
Memory is not an endless tape of bytes, but a set of cells accessed over a bus of a particular width.
- One bank, 8-bit cells. To read a 32-bit number you need 4 cycles: byte => byte => byte => byte, and then stitch them together.
- Several parallel banks (say, 4 one-byte banks). If a 32-bit value sits at an address divisible by 4, all four banks return their byte at once: 1 cycle. If the value is “cut” across two neighboring words, you need two accesses plus a shift and a merge.
Hence natural alignment: a value of type T must sit at an address for which addr % alignof(T) == 0. For scalar types alignof usually equals sizeof, but not always: the platform’s ABI has the final say (below we’ll see how double on i386 is aligned only to 4). A struct has to lay out its fields so that this condition holds for each of them.
On a 32-bit core an aligned word is read in a single bus access, while a double takes two, since it’s two words. An unaligned access costs more: on some cores through hidden extra cycles, on others (Cortex-M0/M0+) - a HardFault.
Experiment #1: guess the sizeof
Four structs. Try to guess the sizes on paper before reading on:
| |
| |
The naive sum of fields vs. reality:
| struct | sum of fields | actual (x86-64, ARM EABI) | alignof |
|---|---|---|---|
a_t | 3 | 4 | 2 |
b_t | 7 | 8 | 4 |
c_t | 13 | 24 | 8 |
d_t | 13 | 16 | 8 |
The alignof column - is the key to everything: a struct’s alignment equals the largest alignment among its fields.
a_t and b_t are obvious: one byte of padding between u8 and u16, and likewise between u8 and u32.
But c_t usually breaks people’s brains. Let’s count: a (1 byte), 7 bytes of padding, b (8 bytes, offset 8), c (4 bytes, offset 16). We end at 20. So why 24?
Because the size of a struct must be a multiple of its alignment (here 8, thanks to double). Otherwise in an array c_t arr[2] the second element would land at offset 20, and its double would become misaligned. So another 4 bytes of tail padding get added at the end. The same rule works recursively for structs inside structs.
d_t says the same thing from the other side: same types, but the big double comes first, so there are no internal holes, and the tail padding is only 3 bytes (13 => 16).
Field order = memory savings, and for free. A practical rule: sort fields by descending size (more precisely, by descending alignof).
| |
Ask the compiler to tell you itself
| |
We see both the internal padding and the tail padding (4 bytes for c_t, 3 for d_t). For a real project -Wpadded is too chatty to keep on all the time, but for a one-off review of your structs - it’s exactly what you need.
Experiment #2: looking through pahole
-Wpadded is useful, but the holes are shown most clearly by pahole (the dwarves package). It reads the DWARF info from an object file and draws the struct the way it lies in memory. It works with ARM object files too.
| |
| |
| |
Everything we worked out by hand is stated right here: a 7-byte hole, 4 bytes of tail padding, 13 useful bytes out of 24. And now d_t:
| |
No XXX holes, just 3 bytes at the tail.
Three pitfalls you can step on
1. A bare pahole file.o may stay silent. No error at all, exit code 0. The reason: the structs in the example are anonymous (typedef struct { ... } c_t;), and a bare invocation prints only structs that have a tag. The DWARF format has nothing to do with it: with -gdwarf-4 the behavior is the same. There are two ways to fix it:
| |
2. A type that isn’t used anywhere doesn’t make it into DWARF. By default GCC drops unused types from the debug info. If a struct shows up only in sizeof, pahole will say type 'X' not found. Either create at least one variable of that type (like gc and gd above), or ask GCC not to drop anything:
| |
3. You need -g. Without debug info pahole has nothing to read from.
Experiment #3: packed and a firmware header
Now a realistic case: a firmware image header that sits in Flash and whose format is fixed byte by byte. We’ll use this file in the next two experiments, so here it is in full:
| |
In the natural variant the compiler inserts 1 byte of padding between flags (offset 6) and crc, crc lands at offset 8, and the size is 12. In packed there’s no padding: crc is at offset 7, and the size is 11.
The second diagram shows exactly the case from the memory banks section: crc starts in one word and ends in another.
Note the _Static_asserts. They compile to zero bytes, but if someone a year from now adds a field or changes a compiler flag, the build will fail with a human-readable message instead of the firmware failing in the field.
packed looks like the perfect solution. Now for the price.
Experiment #4: what packed costs
The read_crc function from fw.c reads an unaligned field:
| |
We compile for three configurations. There are no C field names in the .s file, only offsets like [r0, #7], so -fverbose-asm adds comments, and sed cuts out the function we need:
| |
| |
| |
| |
Cortex-M4 (ARMv7-M, unaligned access is supported in hardware):
| |
One instruction. GCC only leaves an @ unaligned comment. The cost is hidden: inside, the core makes several bus accesses instead of one, so it’s slower, but you can’t see that in the assembly.
Cortex-M0 (ARMv6-M, no hardware support):
| |
Now you can see what’s going on: four ldrb (a byte each), three shifts, three merges. Ten instructions instead of one.
Cortex-M4 with -mno-unaligned-access: same thing, but with the barrel shifter in orr
| |
Thumb-2 can shift the second operand right inside orr, so separate lsls aren’t needed: 7 instructions instead of 10.
The key takeaway: the price of packed is determined by the core. The compiler knows the field is unaligned (because you wrote packed yourself) and picks a safe sequence for the specific architecture. So in this case there won’t be a HardFault, it will just be slower and longer.
Surprise: even magic is read byte by byte
packed removes not only the padding between fields: it drops the alignof of the whole struct to 1. The compiler can no longer assume that the pointer h itself is aligned, so on M0 even magic at offset 0 is assembled from bytes:
| |
The cure - give the struct its alignment back without bringing back the padding between fields: __attribute__((packed, aligned(4))) (the fw_header_packed_a4_t type in fw.c). Now the compiler knows that the base is 4-aligned, and fields at “honest” offsets are read with a single instruction even on M0 (the comments in the asm - are mine):
| |
Even the unaligned crc got cheaper: the compiler reads the aligned word at offset 8 and glues on one more byte. This is legal precisely because byte 11 belongs to the struct (tail padding).
| function | M4 | M4 -mno-unaligned-access | M0 |
|---|---|---|---|
read_crc (packed) | 1 | 7 | 10 |
read_magic (packed) | 1 | 7 | 10 |
read_magic_a4 (packed + aligned(4)) | 1 | 1 | 1 |
read_crc_a4 (packed + aligned(4)) | 1 | 3 | 4 |
Instruction count, not including bx lr.
Important
aligned(4)brings tail padding back:sizeof(fw_header_packed_a4_t) == 12, not 11. For a header in Flash that you lay out yourself, that’s usually acceptable. For a format where the payload follows right after the 11th byte, - it’s not. That’s why there’s a_Static_asserton the size infw.cfor this type too.
Where M0 really crashes
A HardFault on M0 happens when the compiler doesn’t know the address is unaligned. There are two typical ways of “not telling” it: casting a byte buffer and taking the address of a packed field.
Pointer cast
| |
| |
A plain ldr. No @ unaligned, no byte-by-byte assembly: for the compiler, a cast uint8_t * => uint32_t * means a promise of “this is definitely 4-aligned” (and per the C standard it also violates strict aliasing). If buf + 1 isn’t a multiple of 4, then on M0/M0+ it’s an instant HardFault, and on M3/M4/M7 - a silent slow access (until we enable the trap, see below).
Why does M0 have a separate adds?
In Thumb-1, LDR with an immediate offset encodes an offset that’s a multiple of 4 (#0..#124), so [r0, #1] simply doesn’t exist as an instruction. The M0 instruction set itself reminds you that a word must be aligned.
Address of a packed field
The second way - passing a field’s address to somewhere that expects a regular pointer:
| |
Inside crc_of there’s no information about packed at all: it’s a plain ldr r0, [r0], and what arrives in it is h + 7. On M0 - HardFault. GCC warns about this by default:
| |
CautionThis is the most common real-world scenario: the struct is
packed, and the field is passed by pointer into a CRC or parse function. It’s worth keeping the-Waddress-of-packed-memberwarning as an error (-Werror=address-of-packed-member).
How to read correctly
cast.c has two more variants. Here’s what the compiler makes of them:
| |
memcpy is correct everywhere, and on M4 it collapses into a single ldr. But on M0 GCC 13 left a real call to the library memcpy. get_u32le with shifts is correct everywhere; on M4 GCC recognizes the pattern itself and also collapses it into a single ldr, while on M0 it gives the same byte-by-byte assembly with no call. And most importantly: it pins down the byte order, which memcpy knows nothing about. For reading from protocol buffers it’s the best choice.
The compiler and the sanitizer as watchmen
-Wcast-alignon ARM warns about a castuint8_t *=>uint32_t *(cast increases required alignment of target type). On x86 the same flag stays silent, because the architecture tolerates unaligned accesses. There you need-Wcast-align=strict.- If your business logic is tested on the host,
-fsanitize=alignmentcatches unaligned access at runtime, even on x86:
| |
| |
get_u32le and memcpy pass, while read_cast stops the test with the exact line.
How to find unaligned accesses on M3/M4/M7
On these cores an unaligned access silently works, which means the bugs hide. But you can enable a trap:
| |
Now every unaligned LDR/STR will raise a UsageFault (with the UNALIGNED flag in UFSR, and if the UsageFault handler isn’t enabled, it escalates to a HardFault). This isn’t about “slow spots”, it’s about every place where you rely on hardware tolerance. Useful for catching, on an M4, code that will “accidentally” break when migrating to M0.
One nuance: with the trap on, the code from read_crc above (ldr r0, [r0, #7] @ unaligned) will start to fail, because by default GCC considers an unaligned ldr acceptable. So along with the trap, add -mno-unaligned-access: then for packed fields the compiler will switch to byte-by-byte reads on its own. The upshot: compiler flag + trap = an honest check that the code is portable to M0.
Small types: LDRB, LDRSB, LDRH
A byte can be read from any offset with a single instruction, and it lands right in the low bits of the register: no need to shift or mask anything by hand.
| |
| |
| |
| |
| |
ldrbtakes a byte from an odd offset and zero-extends it to 32 bits - one instruction on both cores.- For a signed byte, M4 has
ldrsbwith an immediate offset, while M0 has no such form (in Thumb-1LDRSBonly supports a register offset), henceldrb+sxtb. - A
uint16_tat offset 3 is already unaligned: on M4 it’sldrhwith@ unaligned, on M0 - assembly from two bytes.
Compare with architectures that have no byte loads: there you’d have to read a whole word, shift, and mask with & 0xFF. On ARM the hardware takes care of that.
Padding - isn’t everything: don’t send a struct over the network
You might think: “Aha, packed, 11 bytes, I’ll send the struct as is.” No. Let’s look at what actually sits in memory in a regular (non-packed) struct and what should go into the protocol:
| |
| |
memset(..., 0xCC, ...) simulates the stack: the value of a padding byte is not defined by the standard, so in real code it will be whatever was lying on the stack before. Which means a raw struct on the wire - is also a memory content leak. Plus its byte order is dictated by the sender’s architecture. Cortex-M (in practice) and x86 - are little-endian, but the first big-endian network partner will break everything.
So the rule is: packed - is for formats that sit in Flash and are read by the same code (image headers, config blocks). Anything that goes outside, serialize explicitly: with shifts, like put_u32le/get_u32le, in Rust - to_le_bytes()/from_le_bytes(). That way you solve padding, endianness, and alignment all at once.
Warning
packedis not for MMIO registers. Peripheral registers must be naturally aligned and read at their own width, whilepackedcan turn a 32-bit access into four byte accesses, which the register either doesn’t support or reacts to differently (for example, read-to-clear).
Alignment - is an ABI matter, not a language one
Why is a double in a struct aligned to 8 even though the bus is 32-bit? Because the ABI said so: fewer bus accesses at the cost of more padding. Let’s check: the same sizes.c, but for 32-bit x86:
| |
On i386 System V a double inside a struct is aligned only to 4, so c_t shrinks from 24 to 16 (b at offset 4, not 8). On ARM EABI (AAPCS) and x86-64 the requirement is 8. The takeaway: a struct’s size is not portable across platforms, and that’s one more reason to put in a _Static_assert.
And what about double on Cortex-M4?
| |
| |
| |
An aligned double is loaded with a single vldr.64. For an unaligned one the compiler can’t use vldr (on an unaligned address it always raises a UsageFault, see above), and can’t even use ldrd. So it takes two single ldrs and moves them into the FPU register via vmov.
pahole shows the cost in bytes:
| |
| |
| |
The M4 FPU - is single-precision
fpv4-sp-d16handles onlyfloat. Theaddfunction fromdbl.c(a + bfordouble) compiles intobl __aeabi_dadd, i.e. software emulation, whileaddfforfloat- into a singlevadd.f32. Against that backdrop, padding around adoubleis small potatoes. If the precision allows, go withfloat.
Registers and the stack get aligned too
Alignment rules apply not only in structs. AAPCS passes the first arguments in r0 - r3, and a 64-bit value must occupy an even register pair:
| |
| |
The compiler writes constants in decimal, the hex comments and explanations - are mine:
| |
a goes into r0, r1 is skipped, b goes into r2:r3. Argument order matters just like field order: sink(uint64_t b, uint32_t a) would fit into r0 - r2 and leave r3 free for one more argument. If the arguments ran out of registers, on the stack a 64-bit value would likewise land at an address that’s a multiple of 8.
The stack itself is aligned too: AAPCS requires SP to be a multiple of 8 at public function boundaries, and on exception entry Cortex-M aligns the frame to 8 (CCR.STKALIGN; on M0 always). But the placement of local variables inside a frame - is up to the compiler: several chars may well sit next to each other at odd addresses, so there’s nothing to rely on here.
C++: the same, plus surprises
C++ fully inherits the C rules, but adds its own sources of hidden size. All the examples are in one file:
| |
| |
| |
| |
What we see here:
- vptr. One
virtualfunction, and the object got fat: on a 32-bit target +4 bytes (4 => 8), on x86-64 all the way to 16 because of alignment.nmshows that onlyWithVirthas a vtable (the other one - is a service one, from RTTI). In arrays of polymorphic objects in RAM this is noticeable, which is why embedded code often prefersswitchor function tables. - EBO. An empty base class takes no space (4), while an empty data member does (8, because every object must have its own address).
[[no_unique_address]](C++20) gives the same effect for data members. alignas(32)- the standard way to do DMA buffers. In C for a long time there were only compiler extensions (__attribute__((aligned)),__alignof__),_Alignas/_Alignofappeared in C11, and in C23alignas/alignofbecame full-fledged keywords, like in C++.- Not standard-layout. The
static_assertin the file checks thatWithVirtis not standard-layout: you can’t rely on field order,offsetof, or C compatibility for such types. For anything that goes to Flash or into a protocol, keep structs simple and addstatic_assert(std::is_standard_layout_v<T>).
Rust: a different philosophy
In C/C++ the field order in memory = the order in code. In Rust by default (repr(Rust)) the compiler is allowed to reorder fields to save space.
| |
| |
The same u8, u32, u16: in C that’s 12 bytes, while in repr(Rust) the compiler reordered the fields (b at 0, c at 4, a at 6) and got 8, exactly like our “sorted” struct good in C. Relief from the human factor, but with a caveat: the repr(Rust) layout is not guaranteed and can change between compiler versions. So for everything that crosses the boundary of your Rust code (FFI, registers, Flash, network) you need an explicit #[repr(C)] or #[repr(C, packed)].
const _: () = assert!(...) in the file - is the Rust analog of _Static_assert: a failed check stops compilation.
The layout of every type from the compiler: -Z print-type-sizes (nightly)
| |
This is the closest analog of pahole for Rust: you can see both the reordered fields and the padding.
Besides that:
- Niche optimization.
Option<&u32>takes as much as&u32(8 on a 64-bit host): a null pointer is forbidden for&T, soNoneis encoded with it, and no separate tag is needed. - For aligning DMA buffers:
#[repr(C, align(32))]on a wrapper around an array (DmaBufin the file). - A reference to a field of a
packedstruct. Rust won’t let you into UB:
| |
| |
This is the same trap as read_via_ptr in C, but in Rust it’s a hard error, not a warning. Read the field by copy (let crc = h.crc;), as in the example above.
Summary table
| C | C++ | Rust (repr(Rust)) | Rust (repr(C)) | |
|---|---|---|---|---|
| Field order | as in the code | as in the code (base => derived) | not guaranteed, the compiler optimizes | as in the code |
| Padding | natural alignment | same | minimized automatically | as in C |
| Packed | __attribute__((packed)) / #pragma pack | same | #[repr(packed)] | #[repr(C, packed)] |
| Alignment for DMA | aligned(N) / _Alignas (C11), alignas (C23) | alignas(N) | #[repr(align(N))] | #[repr(C, align(N))] |
| Hidden size | none | vptr, bases | none | none |
| Compile-time check | _Static_assert | static_assert | const _: () = assert!(...) + offset_of! | same |
| Tools | pahole, -Wpadded, -Wcast-align | same + nm -C for vtables | -Z print-type-sizes | same |
| Suitable for FFI/Flash | yes | standard-layout only | no | yes |
Golden rules for developers
- Sort fields from largest to smallest and verify the result with
pahole -C <type>(or-Wpadded). Don’t count in your head. - Pin down the size and offsets with
_Static_assert/static_assert/const assert!. It’s insurance against surprises when the platform, compiler, or flags change. packedonly for formats that sit in Flash. Not for MMIO, not “to make it fit” in every struct. If the format allows, addaligned(4)so you don’t pay with byte-by-byte reads for aligned fields.- Serialize everything that goes to the network explicitly.
packedcures neither endianness nor garbage in padding. - Don’t take the address of a field of a
packedstruct, and don’t cast a byte buffer touint32_t *. For reading from a buffer there areget_u32le-style functions ormemcpy. Keep-Wcast-alignand-Waddress-of-packed-memberon, and on the host --fsanitize=alignment. - Remember the core. On M0/M0+ a dishonest unaligned
ldr= HardFault. On M3/M4/M7 it’s silently slow (and forldrd/ldm/vldr- a fault too), and it’s caught viaUNALIGN_TRPtogether with-mno-unaligned-access. - In Rust, everything that crosses the boundary of Rust code gets
#[repr(C)]. Leaverepr(Rust)for internal structs.
