Featured image of post Memory layout: why sizeof(struct) ≠ the sum of its fields, and what HardFault has to do with it

Memory layout: why sizeof(struct) ≠ the sum of its fields, and what HardFault has to do with it

Padding, packed, and unaligned access on Cortex-M0/M4/M7, DMA and cache, differences between C, C++, and Rust

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.
  • packed removes 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 packed itself, but by code that “forgets” about the misalignment: a cast uint8_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: double on 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.

  1. One bank, 8-bit cells. To read a 32-bit number you need 4 cycles: byte => byte => byte => byte, and then stitch them together.
  2. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>

typedef struct { uint8_t  a; uint16_t b; }              a_t;
typedef struct { uint16_t a; uint8_t  b; uint32_t c; }  b_t;
typedef struct { uint8_t  a; double   b; uint32_t c; }  c_t;
typedef struct { double   a; uint32_t b; uint8_t  c; }  d_t;

#define SHOW(T) printf("%-4s sizeof=%2zu alignof=%zu\n", #T, sizeof(T), _Alignof(T))

int main(void) {
    SHOW(a_t); SHOW(b_t); SHOW(c_t); SHOW(d_t);
    printf("c_t offsets: a=%zu b=%zu c=%zu\n",
           offsetof(c_t, a), offsetof(c_t, b), offsetof(c_t, c));
    printf("d_t offsets: a=%zu b=%zu c=%zu\n",
           offsetof(d_t, a), offsetof(d_t, b), offsetof(d_t, c));
    return 0;
}
1
2
3
4
5
6
7
gcc -std=c11 -Wall sizes.c -o sizes && ./sizes
a_t  sizeof= 4 alignof=2
b_t  sizeof= 8 alignof=4
c_t  sizeof=24 alignof=8
d_t  sizeof=16 alignof=8
c_t offsets: a=0 b=8 c=16
d_t offsets: a=0 b=8 c=12

The naive sum of fields vs. reality:

structsum of fieldsactual (x86-64, ARM EABI)alignof
a_t342
b_t784
c_t13248
d_t13168

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).

1
2
struct bad  { uint8_t a; uint32_t b; uint16_t c; };  // 1 + 3 pad + 4 + 2 + 2 pad = 12
struct good { uint32_t b; uint16_t c; uint8_t a; };  // 4 + 2 + 1 + 1 pad         = 8

Ask the compiler to tell you itself

1
2
3
4
5
6
gcc -Wpadded -c sizes.c -o /dev/null
sizes.c:6:39: warning: padding struct to align 'b' [-Wpadded]
sizes.c:7:51: warning: padding struct to align 'c' [-Wpadded]
sizes.c:8:39: warning: padding struct to align 'b' [-Wpadded]
sizes.c:8:16: warning: padding struct size to alignment boundary with 4 bytes [-Wpadded]
sizes.c:9:16: warning: padding struct size to alignment boundary with 3 bytes [-Wpadded]

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.

1
2
3
4
5
6
#include <stdint.h>

typedef struct { uint8_t  a; double   b; uint32_t c; }  c_t;
typedef struct { double   a; uint32_t b; uint8_t  c; }  d_t;

c_t gc; d_t gd;   // at least one variable of each type, see pitfall #2
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -g -c sizes_arm.c -o sizes_arm.o
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pahole -C c_t sizes_arm.o
typedef struct {
	uint8_t                    a;                    /*     0     1 */

	/* XXX 7 bytes hole, try to pack */

	double                     b;                    /*     8     8 */
	uint32_t                   c;                    /*    16     4 */

	/* size: 24, cachelines: 1, members: 3 */
	/* sum members: 13, holes: 1, sum holes: 7 */
	/* padding: 4 */
	/* last cacheline: 24 bytes */
} c_t;

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pahole -C d_t sizes_arm.o
typedef struct {
	double                     a;                    /*     0     8 */
	uint32_t                   b;                    /*     8     4 */
	uint8_t                    c;                    /*    12     1 */

	/* size: 16, cachelines: 1, members: 3 */
	/* padding: 3 */
	/* last cacheline: 16 bytes */
} 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:

1
pahole -C c_t file.o  # look up by typedef name, or give the struct a tag: typedef struct c_s { ... } c_t; and then bare pahole works too

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:

1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -g -fno-eliminate-unused-debug-types -c file.c -o file.o

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <stdint.h>
#include <stddef.h>

typedef struct {
    uint32_t magic;
    uint16_t version;
    uint8_t  flags;
    uint32_t crc;
} fw_header_natural_t;

typedef struct __attribute__((packed)) {
    uint32_t magic;
    uint16_t version;
    uint8_t  flags;
    uint32_t crc;
} fw_header_packed_t;

typedef struct __attribute__((packed, aligned(4))) {
    uint32_t magic;
    uint16_t version;
    uint8_t  flags;
    uint32_t crc;
} fw_header_packed_a4_t;

_Static_assert(sizeof(fw_header_natural_t) == 12, "natural: 12");
_Static_assert(offsetof(fw_header_natural_t, crc) == 8, "natural crc @ 8");
_Static_assert(sizeof(fw_header_packed_t)  == 11, "packed: 11");
_Static_assert(offsetof(fw_header_packed_t, crc) == 7, "packed crc @ 7");
_Static_assert(sizeof(fw_header_packed_a4_t) == 12, "packed+aligned(4): 12");

// Reading an unaligned field: the compiler KNOWS it's packed
uint32_t read_crc(const fw_header_packed_t *h)      { return h->crc; }

// An aligned field (offset 0) in a packed struct with alignof == 1
uint32_t read_magic(const fw_header_packed_t *h)    { return h->magic; }

// Same thing, but the struct is packed + aligned(4)
uint32_t read_magic_a4(const fw_header_packed_a4_t *h) { return h->magic; }
uint32_t read_crc_a4(const fw_header_packed_a4_t *h)   { return h->crc; }

// Taking the address of a packed field: the packed-ness gets lost
uint32_t crc_of(const uint32_t *p);   // somewhere in another file: return *p;
uint32_t read_via_ptr(const fw_header_packed_t *h) { return crc_of(&h->crc); }

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:

1
uint32_t read_crc(const fw_header_packed_t *h) { return h->crc; }

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:

1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -S -fverbose-asm fw.c -o fw_m4.s
1
arm-none-eabi-gcc -mcpu=cortex-m0 -mthumb -O2 -S -fverbose-asm fw.c -o fw_m0.s
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -S -fverbose-asm -mno-unaligned-access fw.c -o fw_m4_nounal.s
1
sed -n '/^read_crc:/,/^\s*bx\s*lr/p' fw_m4.s     # and likewise for fw_m0.s, fw_m4_nounal.s

Cortex-M4 (ARMv7-M, unaligned access is supported in hardware):

1
2
3
read_crc:
        ldr     r0, [r0, #7]    @ unaligned
        bx      lr

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
read_crc:
        ldrb    r2, [r0, #8]
        ldrb    r3, [r0, #7]
        lsls    r2, r2, #8
        orrs    r2, r3
        ldrb    r3, [r0, #9]
        ldrb    r0, [r0, #10]
        lsls    r3, r3, #16
        orrs    r3, r2
        lsls    r0, r0, #24
        orrs    r0, r3
        bx      lr

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
1
2
3
4
5
6
7
8
9
read_crc:
        ldrb    r3, [r0, #7]
        ldrb    r1, [r0, #8]
        ldrb    r2, [r0, #9]
        ldrb    r0, [r0, #10]
        orr     r3, r3, r1, lsl #8
        orr     r3, r3, r2, lsl #16
        orr     r0, r3, r0, lsl #24
        bx      lr

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
read_magic:                     @ Cortex-M0, fw_header_packed_t
        ldrb    r2, [r0, #1]
        ldrb    r3, [r0]
        lsls    r2, r2, #8
        orrs    r2, r3
        ldrb    r3, [r0, #2]
        ldrb    r0, [r0, #3]
        lsls    r3, r3, #16
        orrs    r3, r2
        lsls    r0, r0, #24
        orrs    r0, r3
        bx      lr

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
read_magic_a4:                  @ Cortex-M0
        ldr     r0, [r0]
        bx      lr

read_crc_a4:                    @ Cortex-M0
        ldrb    r3, [r0, #7]    @ byte 7 separately
        ldr     r0, [r0, #8]    @ word 8..11 is aligned
        lsls    r0, r0, #8      @ byte 11 (tail padding) gets shifted out
        orrs    r0, r3
        bx      lr

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).

functionM4M4 -mno-unaligned-accessM0
read_crc (packed)1710
read_magic (packed)1710
read_magic_a4 (packed + aligned(4))111
read_crc_a4 (packed + aligned(4))134

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_assert on the size in fw.c for 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <stdint.h>
#include <string.h>

// The compiler does NOT know the address is unaligned: a cast  -  is a promise
uint32_t read_cast(const uint8_t *buf) {
    return *(const uint32_t *)(buf + 1);
}

// Correct: memcpy
uint32_t read_memcpy(const uint8_t *buf) {
    uint32_t v;
    memcpy(&v, buf + 1, sizeof v);
    return v;
}

// Correct, and with explicit byte order: little-endian from any address
uint32_t get_u32le(const uint8_t *p) {
    return (uint32_t)p[0]       | (uint32_t)p[1] << 8 |
           (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@ Cortex-M0
read_cast:
        adds    r0, r0, #1
        ldr     r0, [r0]
        bx      lr

@ Cortex-M4 (any flags, even -mno-unaligned-access)
read_cast:
        ldr     r0, [r0, #1]
        bx      lr

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:

1
2
uint32_t crc_of(const uint32_t *p);   // somewhere in another file: return *p;
uint32_t read_via_ptr(const fw_header_packed_t *h) { return crc_of(&h->crc); }

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:

1
fw.c:44:68: warning: taking address of packed member of 'struct <anonymous>' may result in an unaligned pointer value [-Waddress-of-packed-member]
🚨 Caution

This 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-member warning 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@ Cortex-M4
read_memcpy:
        ldr     r0, [r0, #1]    @ unaligned
        bx      lr
get_u32le:
        ldr     r0, [r0]        @ unaligned
        bx      lr

@ Cortex-M0, -O2
read_memcpy:
        push    {lr}
        sub     sp, sp, #12
        adds    r1, r0, #1
        movs    r2, #4
        add     r0, sp, #4
        bl      memcpy
        ldr     r0, [sp, #4]
        add     sp, sp, #12
        pop     {pc}
get_u32le:                      @ 10 instructions, same as read_crc
        ...

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-align on ARM warns about a cast uint8_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=alignment catches unaligned access at runtime, even on x86:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <assert.h>
#include <stdint.h>
#include "cast.c"  // yes, this is a bit hack-ish

int main(void) {
    _Alignas(4) const uint8_t buf[8] = { 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0, 0, 0 };
    assert(get_u32le(buf + 1) == 0xDEADBEEF);
    assert(read_memcpy(buf)   == 0xDEADBEEF);   // on a little-endian host
    assert(read_cast(buf)     == 0xDEADBEEF);   // UBSan will stop here
    return 0;
}
1
2
gcc -std=c11 -O1 -g -fsanitize=alignment -fno-sanitize-recover=alignment cast_test.c -o cast_test && ./cast_test
cast.c:6:49: runtime error: load of misaligned address 0x7ffe38e3a431 for type 'const uint32_t', which requires 4 byte alignment

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:

1
SCB->CCR |= SCB_CCR_UNALIGN_TRP_Msk;   // CMSIS

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdint.h>

typedef struct __attribute__((packed)) {
    uint8_t  pad;   // offset 0: only there to shift the following fields
    uint8_t  u8;    // offset 1
    int8_t   s8;    // offset 2
    uint16_t u16;   // offset 3: odd, not a multiple of 2
} small_t;

uint32_t read_u8 (const small_t *s) { return s->u8;  }
int32_t  read_s8 (const small_t *s) { return s->s8;  }
uint32_t read_u16(const small_t *s) { return s->u16; }
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -S small_int.c -o small_int_m4.s
1
arm-none-eabi-gcc -mcpu=cortex-m0 -mthumb -O2 -S small_int.c -o small_int_m0.s
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@ Cortex-M4
read_u8:
        ldrb    r0, [r0, #1]
        bx      lr
read_s8:
        ldrsb   r0, [r0, #2]
        bx      lr
read_u16:
        ldrh    r0, [r0, #3]    @ unaligned
        bx      lr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@ Cortex-M0
read_u8:
        ldrb    r0, [r0, #1]
        bx      lr
read_s8:
        ldrb    r0, [r0, #2]
        sxtb    r0, r0
        bx      lr
read_u16:
        ldrb    r3, [r0, #3]
        ldrb    r0, [r0, #4]
        lsls    r0, r0, #8
        orrs    r0, r3
        bx      lr
  • ldrb takes a byte from an odd offset and zero-extends it to 32 bits - one instruction on both cores.
  • For a signed byte, M4 has ldrsb with an immediate offset, while M0 has no such form (in Thumb-1 LDRSB only supports a register offset), hence ldrb + sxtb.
  • A uint16_t at offset 3 is already unaligned: on M4 it’s ldrh with @ 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>

typedef struct {
    uint32_t magic;
    uint16_t version;
    uint8_t  flags;
    uint32_t crc;
} fw_header_natural_t;

static void put_u32le(uint8_t *p, uint32_t v) { p[0] = v; p[1] = v >> 8; p[2] = v >> 16; p[3] = v >> 24; }
static void put_u16le(uint8_t *p, uint16_t v) { p[0] = v; p[1] = v >> 8; }

#define HDR_NET_SIZE 11u  // size from the protocol spec, not sizeof()

static size_t hdr_serialize(uint8_t *out, const fw_header_natural_t *h) {
    put_u32le(out + 0, h->magic);
    put_u16le(out + 4, h->version);
    out[6] = h->flags;
    put_u32le(out + 7, h->crc);
    return HDR_NET_SIZE;
}

static void dump(const char *name, const void *p, size_t n) {
    printf("%s", name);
    for (size_t i = 0; i < n; i++)
        printf("%02x ", ((const uint8_t *)p)[i]);
    printf("\n");
}

int main(void) {
    fw_header_natural_t h;
    memset(&h, 0xCC, sizeof h);  // simulating "garbage" on the stack
    h.magic   = 0xA5A50001;
    h.version = 3;
    h.flags   = 0x80;
    h.crc     = 0xDEADBEEF;

    uint8_t wire[HDR_NET_SIZE];
    hdr_serialize(wire, &h);

    dump("on the wire:  ", wire, sizeof wire);
    dump("raw struct:   ", &h, sizeof h);
    return 0;
}
1
2
3
4
gcc -std=c11 -Wall -O2 ser.c -o ser && ./ser
on the wire:  01 00 a5 a5 03 00 80 ef be ad de
raw struct:   01 00 a5 a5 03 00 80 cc ef be ad de
#                                  ^^ padding

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

packed is not for MMIO registers. Peripheral registers must be naturally aligned and read at their own width, while packed can 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:

1
2
3
4
5
6
7
gcc -m32 -std=c11 sizes.c -o sizes32 && ./sizes32
a_t  sizeof= 4 alignof=2
b_t  sizeof= 8 alignof=4
c_t  sizeof=16 alignof=4  # <-- was 24 and 8
d_t  sizeof=16 alignof=4
c_t offsets: a=0 b=4 c=12
d_t offsets: a=0 b=8 c=12

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?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
typedef struct { unsigned char a; double b; } natural_t;
typedef struct __attribute__((packed)) { unsigned char a; double b; } packed_t;

natural_t gn; packed_t gp;   // so the types make it into DWARF for pahole

double read_natural(const natural_t *p) { return p->b; }
double read_packed (const packed_t  *p) { return p->b; }

double add (double a, double b) { return a + b; }
float  addf(float  a, float  b) { return a + b; }
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard -O2 -S dbl.c -o dbl.s
1
2
3
4
5
6
7
8
9
read_natural:
        vldr.64 d0, [r0, #8]
        bx      lr

read_packed:
        ldr     r2, [r0, #1]    @ unaligned
        ldr     r3, [r0, #5]    @ unaligned
        vmov    d0, r2, r3
        bx      lr

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:

1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard -g -c dbl.c -o dbl.o
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pahole -C natural_t dbl.o
typedef struct {
	unsigned char              a;                    /*     0     1 */

	/* XXX 7 bytes hole, try to pack */

	double                     b;                    /*     8     8 */

	/* size: 16, cachelines: 1, members: 2 */
	/* sum members: 9, holes: 1, sum holes: 7 */
	/* last cacheline: 16 bytes */
} natural_t;
1
2
3
4
5
6
7
8
pahole -C packed_t dbl.o
typedef struct {
	unsigned char              a;                    /*     0     1 */
	double                     b;                    /*     1     8 */

	/* size: 9, cachelines: 1, members: 2 */
	/* last cacheline: 9 bytes */
} __attribute__((__packed__)) packed_t;
💡 The M4 FPU - is single-precision

fpv4-sp-d16 handles only float. The add function from dbl.c (a + b for double) compiles into bl __aeabi_dadd, i.e. software emulation, while addf for float - into a single vadd.f32. Against that backdrop, padding around a double is small potatoes. If the precision allows, go with float.

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:

1
2
3
4
5
6
7
#include <stdint.h>

uint64_t sink(uint32_t a, uint64_t b);  // definition  -  in another file

uint64_t call(void) {
    return sink(0x11111111u, 0x2222222233333333ull);
}
1
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -S args.c -o args.s

The compiler writes constants in decimal, the hex comments and explanations - are mine:

1
2
3
4
5
call:
        mov     r2, #858993459  @ 0x33333333  -  low half of b
        mov     r3, #572662306  @ 0x22222222  -  high half of b
        mov     r0, #286331153  @ 0x11111111  -  a
        b       sink            @ r1 unused: this is padding in registers

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <cstdio>
#include <cstdint>
#include <type_traits>

struct NoVirt   { uint32_t a; };
struct WithVirt { uint32_t a; virtual void f() {} };

struct Empty {};
struct DerivedEmpty { Empty e; uint32_t x; };  // empty data member
struct DerivedEBO : Empty { uint32_t x; };     // empty base class

struct Logger {};
struct Widget { [[no_unique_address]] Logger log; uint32_t value; };

struct alignas(32) Dma { uint8_t b[64]; };

static_assert(std::is_standard_layout_v<NoVirt>);
static_assert(!std::is_standard_layout_v<WithVirt>);

int main() {
    WithVirt w;  // so the vtable definitely ends up in the binary
    w.f();
    std::printf("NoVirt=%zu WithVirt=%zu\n", sizeof(NoVirt), sizeof(WithVirt));
    std::printf("member Empty=%zu  base Empty (EBO)=%zu  [[no_unique_address]]=%zu\n",
                sizeof(DerivedEmpty), sizeof(DerivedEBO), sizeof(Widget));
    std::printf("Dma align=%zu\n", alignof(Dma));
}
1
2
3
4
g++ -std=c++20 lay.cpp -o lay && ./lay  # x86-64
NoVirt=4 WithVirt=16
member Empty=8  base Empty (EBO)=4  [[no_unique_address]]=4
Dma align=32
1
2
3
4
g++ -m32 -std=c++20 lay.cpp -o lay32 && ./lay32  # 32-bit mode
NoVirt=4 WithVirt=8
member Empty=8  base Empty (EBO)=4  [[no_unique_address]]=4
Dma align=32
1
2
3
nm -C lay | grep vtable  # whose vtable ended up in the binary
0000000000003d88 V vtable for WithVirt
                 U vtable for __cxxabiv1::__class_type_info@CXXABI_1.3

What we see here:

  • vptr. One virtual function, 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. nm shows that only WithVirt has 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 prefers switch or 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/_Alignof appeared in C11, and in C23 alignas/alignof became full-fledged keywords, like in C++.
  • Not standard-layout. The static_assert in the file checks that WithVirt is 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 add static_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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use std::mem::{align_of, offset_of, size_of};

struct AutoOrder { a: u8, b: u32, c: u16 }

#[repr(C)]
struct CLayout { a: u8, b: u32, c: u16 }

#[repr(C, packed)]
struct FwHeader { magic: u32, version: u16, flags: u8, crc: u32 }

#[repr(C, align(32))]
struct DmaBuf([u8; 64]);

// compile-time layout check, the analog of _Static_assert
const _: () = assert!(size_of::<FwHeader>() == 11);
const _: () = assert!(offset_of!(FwHeader, crc) == 7);

fn main() {
    println!("repr(Rust): size={} align={}",
             size_of::<AutoOrder>(), align_of::<AutoOrder>());
    println!("  offsets: a={} b={} c={}",
             offset_of!(AutoOrder, a), offset_of!(AutoOrder, b),
             offset_of!(AutoOrder, c));
    println!("repr(C):    size={} align={}",
             size_of::<CLayout>(), align_of::<CLayout>());
    println!("packed:     size={} align={}",
             size_of::<FwHeader>(), align_of::<FwHeader>());
    println!("DmaBuf:     size={} align={}",
             size_of::<DmaBuf>(), align_of::<DmaBuf>());
    println!("&u32={} Option<&u32>={}",
             size_of::<&u32>(), size_of::<Option<&u32>>());

    let h = FwHeader {
        magic: 0xA5A5_0001,
        version: 3,
        flags: 0,
        crc: 0xDEAD_BEEF
    };
    let crc = h.crc;  // copy of the value: OK
    println!("crc = {:#x}, bytes LE = {:02x?}", crc, crc.to_le_bytes());
}
1
2
3
4
5
6
7
8
rustc -O lay.rs -o lay && ./lay  # offset_of! needs Rust 1.77+
repr(Rust): size=8 align=4
  offsets: a=6 b=0 c=4
repr(C):    size=12 align=4
packed:     size=11 align=1
DmaBuf:     size=64 align=32
&u32=8 Option<&u32>=8
crc = 0xdeadbeef, bytes LE = [ef, be, ad, de]

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)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
rustc +nightly -Z print-type-sizes -O lay.rs  # with cargo: cargo +nightly rustc -- -Z print-type-sizes
print-type-size type: `CLayout`: 12 bytes, alignment: 4 bytes
print-type-size     field `.a`: 1 bytes
print-type-size     padding: 3 bytes
print-type-size     field `.b`: 4 bytes, alignment: 4 bytes
print-type-size     field `.c`: 2 bytes
print-type-size     end padding: 2 bytes
print-type-size type: `AutoOrder`: 8 bytes, alignment: 4 bytes
print-type-size     field `.b`: 4 bytes
print-type-size     field `.c`: 2 bytes
print-type-size     field `.a`: 1 bytes
print-type-size     end padding: 1 bytes

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, so None is encoded with it, and no separate tag is needed.
  • For aligning DMA buffers: #[repr(C, align(32))] on a wrapper around an array (DmaBuf in the file).
  • A reference to a field of a packed struct. Rust won’t let you into UB:
1
let r = &h.crc;  // reference to an unaligned field
1
2
3
4
error[E0793]: reference to packed field is unaligned
  = note: packed structs are only aligned by one byte, and many modern architectures penalize unaligned field accesses
  = note: creating a misaligned reference is undefined behavior (even if that reference is never dereferenced)
  = help: copy the field contents to a local variable, or replace the reference with a raw pointer and use `read_unaligned`/`write_unaligned` (loads and stores via `*p` must be properly aligned even when using raw pointers)

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

CC++Rust (repr(Rust))Rust (repr(C))
Field orderas in the codeas in the code (base => derived)not guaranteed, the compiler optimizesas in the code
Paddingnatural alignmentsameminimized automaticallyas in C
Packed__attribute__((packed)) / #pragma packsame#[repr(packed)]#[repr(C, packed)]
Alignment for DMAaligned(N) / _Alignas (C11), alignas (C23)alignas(N)#[repr(align(N))]#[repr(C, align(N))]
Hidden sizenonevptr, basesnonenone
Compile-time check_Static_assertstatic_assertconst _: () = assert!(...) + offset_of!same
Toolspahole, -Wpadded, -Wcast-alignsame + nm -C for vtables-Z print-type-sizessame
Suitable for FFI/Flashyesstandard-layout onlynoyes

Golden rules for developers

  1. Sort fields from largest to smallest and verify the result with pahole -C <type> (or -Wpadded). Don’t count in your head.
  2. 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.
  3. packed only for formats that sit in Flash. Not for MMIO, not “to make it fit” in every struct. If the format allows, add aligned(4) so you don’t pay with byte-by-byte reads for aligned fields.
  4. Serialize everything that goes to the network explicitly. packed cures neither endianness nor garbage in padding.
  5. Don’t take the address of a field of a packed struct, and don’t cast a byte buffer to uint32_t *. For reading from a buffer there are get_u32le-style functions or memcpy. Keep -Wcast-align and -Waddress-of-packed-member on, and on the host - -fsanitize=alignment.
  6. Remember the core. On M0/M0+ a dishonest unaligned ldr = HardFault. On M3/M4/M7 it’s silently slow (and for ldrd/ldm/vldr - a fault too), and it’s caught via UNALIGN_TRP together with -mno-unaligned-access.
  7. In Rust, everything that crosses the boundary of Rust code gets #[repr(C)]. Leave repr(Rust) for internal structs.
System Status: Running (0 HardFaults in last 24h) | Compiled with -Os
Built with Hugo
Theme Stack designed by Jimmy