Structs in C are not just bags of fields. The compiler silently inserts empty
bytes — padding — between and after members so that every field sits at an
address the CPU can access efficiently. This is invisible in your source, but it
shows up the moment you ask sizeof, serialize a struct, or run multithreaded
code. In this article I measure all of it on x86-64 with real programs: sizes,
offsets, alignment requirements, a packed struct that traps you, serialization
garbage, and a false-sharing demo with real timings.
If you want a broader first pass at reading unfamiliar code, see Reading a Codebase; the ESP32 C node post covers embedded C in practice.
Why padding exists
A CPU reads memory in fixed-width chunks. On x86-64, an 8-byte double is
fastest when its address is a multiple of 8; a 4-byte int wants a multiple of
4. If a char (1 byte) is immediately followed by a double, the double would
land at an address that is not a multiple of 8. The compiler inserts 7 bytes of
padding to push it to the next 8-byte boundary. The struct grows, but every
access stays fast and — on architectures that require it — aligned.
The rule is simple: each member is placed at the next address that is a multiple of its own alignment, and the whole struct’s size is rounded up to a multiple of the largest member’s alignment. The largest alignment in the struct becomes the struct’s alignment.
Seeing it with sizeof and offsetof
Here is the same three fields in two orders:
struct good {
double d; // 8
int i; // 4
char c; // 1
}; // natural: 8 + 4 + 1, round up to 8 => 16
struct bad {
char c; // 1
double d; // 8 (needs 7 bytes of padding after c)
int i; // 4
}; // 1 + 7pad + 8 + 4, round up to 8 => 24
Compiled and run (gcc 16.2.1, x86-64):
sizeof(char)=1 sizeof(int)=4 sizeof(double)=8
offsetof(struct good, d)=0 i=8 c=12 size=16
offsetof(struct bad, c)=0 d=8 i=16 size=24
Same fields, same program: 16 bytes vs 24 bytes — a 50% difference purely
from declaration order. offsetof (from <stddef.h>) tells you exactly where
each member lands, and sizeof tells you the padded total.
_Alignof: the alignment of each type
_Alignof (from <stdalign.h>) gives the alignment requirement of a type, and
a struct’s alignment is the maximum alignment of its members:
struct mixed {
char a;
double b;
char c;
int d;
};
_Alignof(char)=1 _Alignof(int)=4 _Alignof(double)=8
_Alignof(struct mixed)=8 sizeof(struct mixed)=24
struct mixed is aligned to 8 (because of the double), so its size is a
multiple of 8. Even though the members sum to 14 bytes, the struct occupies 24.
pahole: the layout tool
sizeof and offsetof are manual. The tool pahole reads debug info and draws
the layout with the holes marked. On the bad struct above:
struct bad {
char c; /* 0 1 */
/* XXX 7 bytes hole, try to pack */
double d; /* 8 8 */
int i; /* 16 4 */
/* size: 24, cachelines: 1, members: 3 */
/* sum members: 13, holes: 1, sum holes: 7 */
/* padding: 4 */
/* last cacheline: 24 bytes */
};
The XXX 7 bytes hole, try to pack comment is exactly the padding after c.
The reordered good struct has no hole line at all:
struct good {
double d; /* 0 8 */
int i; /* 8 4 */
char c; /* 12 1 */
/* size: 16, cachelines: 1, members: 3 */
/* padding: 3 */
/* last cacheline: 16 bytes */
};
pahole is invaluable when you inherit a struct and need to know where the
waste is before you reorder anything.
The rule: order members largest-first
The practical heuristic is to declare members in descending order of size.
This is not a guarantee — alignment requirements matter, not just size — but it
eliminates most interior padding. In the example above, good (largest-first)
is 16 bytes; bad (smallest-first) is 24. For structs with many fields, the
savings compound: a handful of reordered members can shrink a struct by 30–50%,
which matters when you have millions of them in an array.
The trailing padding is unavoidable in general: the struct’s size must be a multiple of its alignment, so that arrays of the struct keep every element aligned. But interior holes between members are yours to eliminate.
packed: right for the wire, wrong for performance
__attribute__((packed)) tells the compiler to insert no padding at all:
struct normal {
char c;
short s;
int i;
}; // 1 + 1pad + 2 + 4 = 8
struct __attribute__((packed)) packed_p {
char c;
short s;
int i;
}; // 1 + 2 + 4 = 7, no padding
sizeof(struct normal)=8 offsetof(s)=2 offsetof(i)=4
sizeof(struct packed_p)=7 offsetof(s)=1 offsetof(i)=3
The packed struct is 7 bytes — perfect for a wire format where you need an exact byte layout. But the same packed struct is a trap for performance and portability:
- Misaligned access. On x86-64 the CPU tolerates misaligned loads (with a
small penalty), but on ARM, RISC-V, or SPARC a misaligned load can crash with
SIGBUS. Packed structs invite exactly this. - Undefined behavior. Reading a misaligned member through a pointer is UB in
C, even on x86. Compile with
-fsanitize=undefinedand a real misaligned load is reported:
packed.c:33:5: runtime error: load of misaligned address 0x7ffe1a365145 for type 'unsigned int', which requires 4 byte alignment
0x7ffe1a365145: note: pointer points here
00 41 34 12 ef be ad de 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
^
- No compiler fix-up. The compiler cannot reorder a packed struct for you; it takes the layout literally, holes and all.
Use packed when you must match an external byte layout (a protocol header, a
hardware register map), and even then, prefer to serialize field-by-field with
explicit shifts rather than casting a packed struct to a byte buffer.
The serialization lesson: never memcpy a struct to the wire
This is the mistake that bites people: memcpy a struct into a send buffer and
assume the bytes are meaningful. They are not — the padding bytes are
uninitialized garbage, and the layout is not portable.
struct sensor {
char tag; // 1 byte
double value; // 8 bytes (7 pad bytes after tag)
short seq; // 2 bytes
}; // total 24
I poison every byte with 0xAA, then assign only the three real fields. The
padding stays 0xAA:
sizeof(struct sensor)=24
wire bytes: 54 aa aa aa aa aa aa aa 6e 86 1b f0 f9 21 09 40 07 00 aa aa aa aa aa aa
offsetof(tag)=0 value=8 seq=16
padding bytes at offsets 1..7 (uninitialized 0xAA): aa aa aa aa aa aa aa
Those 7 aa bytes are not data — they are whatever was on the stack. Send that
to another machine and you are leaking memory contents (a security problem) and
sending bytes that a different compiler, architecture, or packing setting will
interpret differently (a correctness problem). The layout of a struct is an
implementation detail, not a contract.
The fix is explicit serialization: write each field to the buffer in a defined
order with defined widths, or use a library that does it for you. Never cast a
struct to unsigned char * and ship it.
False sharing: the cache-line tax
Padding is not just about saving bytes — it is about cache lines. A CPU caches memory in 64-byte lines (on x86-64). When two threads write to two variables that happen to sit on the same cache line, the cache coherency protocol forces the line to bounce between cores: each write invalidates the other core’s copy, and the line ping-pongs. The two threads are not sharing data, but they are sharing a cache line — hence false sharing.
Here is a real benchmark: two threads each increment a counter 20 million times. First the counters are adjacent (same cache line), then padded apart so each lives on its own 64-byte line:
N iterations per thread: 20000000, cache line: 64 bytes
false sharing (adjacent): 0.048 s
padded apart (own line): 0.035 s
speedup from padding: 1.37x
Across three runs the speedup was 1.37x, 1.40x, and 1.41x — a consistent ~40% improvement from inserting 56 bytes of padding. The increments are identical; the only difference is cache-line placement. On a real workload with more contention or on a many-core machine, the gap widens further.
The pattern is to pad hot per-thread variables to a full cache line:
typedef struct {
volatile unsigned long a; // hot
char pad[64 - sizeof(unsigned long)];
volatile unsigned long b; // hot, on its own cache line
} padded_t;
This is the same idea as struct bad in reverse: there you remove padding to
save space; here you add padding to buy speed. Both are tools; the difference is
whether the struct is memory-bound or contention-bound.
Practical advice
- Measure first. Use
sizeof,offsetof, andpaholeto see the real layout before you change anything. Do not guess. - Order members largest-first to eliminate interior padding. It is free, portable, and shrinks arrays of structs.
- Use
packedsparingly — for wire formats and register maps only. Be aware of misaligned access, UB, and portability. - Never serialize a struct by
memcpy. Write fields explicitly or use a serialization library. The padding is garbage and the layout is not portable. - Pad hot per-thread data to a cache line when two threads write nearby variables. The 1.4x speedup above is real and reproducible.
Padding is one of those C details that is invisible until it costs you — bytes on the wire, a crash on ARM, or a 40% throughput loss to cache-line bouncing. Now you have the tools to see it, shrink it, and spend it deliberately.