Bitfield vs Packed - MarekBykowski/readme GitHub Wiki
// PACKED — removes padding between fields
struct __attribute__((packed)) pkt {
uint8_t magic; // offset 0
uint32_t seq; // offset 1 — no padding inserted
}; // sizeof = 5
// BITFIELD — controls field size in bits
struct uart_reg {
uint32_t enable : 1; // 1 bit, values 0–1
uint32_t mode : 3; // 3 bits, values 0–7
uint32_t baud : 4; // 4 bits, values 0–15
uint32_t reserved : 24; // 24 bits — 1+3+4+24 = 32 total
}; // sizeof = 4
packed/not packed counts
struct s {
uint8_t a; // 1 B
uint32_t b; // 4 B
uint8_t c; // 1 B
}; // sizeof = 12 (3 B pad after a, 3 B tail pad)
struct __attribute__((packed)) s { ... }; // sizeof = 6
Legitimate use cases for bitfields (non-MMIO)
It's size or layout, two distinct justifications, and it's worth keeping them separate because they push you toward bitfields for opposite reasons.
1. Compact in-memory flags — space matters, exact bit position doesn't
Layout is unspecified but consistent for a given compiler+target, and you never serialize this struct or share it across compilation units with different flags. Only sizeof matters, not which physical bit active lands on.
struct task_flags {
unsigned active : 1;
unsigned suspended : 1;
unsigned rt_prio : 1;
unsigned migrating : 1;
unsigned cpu_bound : 4; // 0-15
}; // 1 byte instead of 5 separate bools
Kernel example: struct task_struct uses bitfields for a handful of boolean-ish flags (sched_reset_on_fork, in_iowait, etc.) — purely to save memory across millions of task structs, never touched by hardware, never serialized.
2. Single hardware descriptor, built once, written out whole
Not a live register — a value assembled entirely in software, then stored as one unit. This is the x86 GDT case:
// arch/x86/include/asm/desc_defs.h
struct desc_struct {
u16 limit0;
u16 base0;
u16 base1 : 8, type : 4, s : 1, dpl : 2, p : 1;
u16 limit1 : 4, avl : 1, l : 1, d : 1, g : 1, base2 : 8;
} __attribute__((packed));
You fill every field in one shot (GDT_ENTRY_INIT(...)), never read-modify-write it against live hardware state afterward. Same "build once, write whole" property applies to constructing an ELF program header, a page-table descriptor built up in a local variable before the single store, etc.
3. Protocol header field-groups, endian-guarded
Only safe because the layout is pinned down explicitly for both byte orders — this is the one wire-format case bitfields survive in:
// include/uapi/linux/tcp.h
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u16 res1:4, doff:4, fin:1, syn:1, rst:1, psh:1,
ack:1, urg:1, ece:1, cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
__u16 doff:4, res1:4, cwr:1, ece:1, urg:1, ack:1,
psh:1, rst:1, syn:1, fin:1;
#endif
4. Decoding a one-shot, already-buffered binary blob (not memory-mapped hardware)
If you read() a fixed-format file/packet into a local buffer and cast a struct over it purely to interpret it — no writes, no live device behind the memory — the RMW hazard doesn't apply because there's no second writer racing you. Still endian-fragile, still non-portable across compilers, but no correctness hazard from concurrent hardware writes. Common in offline parsers: firmware image headers, core-dump formats, on-disk filesystem structures within a single compiler/ABI's own tooling.
5. Compiler-internal / debug-only bit tagging
Anywhere you fully control both the writer and reader (same compiler, same translation unit or tightly coupled ones), and portability/ABI stability across tools was never a requirement — e.g. an internal scratch struct a single subsystem uses to tag state for its own debug logging. Low stakes if the layout shifts between builds, since nothing outside that subsystem interprets the bits.
Common thread across all of these: no live hardware writer, and no requirement that the bit layout matches something external (a datasheet's register map, a wire protocol from a different codebase, another architecture) except in case 3, which is only safe because that mapping is made explicit via #if. The moment either of those holds — a hardware register, or an ABI-critical layout you don't fully control end-to-end — bitfields are the wrong tool.
packed |
bitfield |
|
|---|---|---|
| Controls | padding between fields | field size in bits |
| Min. field | 1 byte | 1 bit |
&field |
works | ✗ compile error |
| Bit order | N/A | unspecified by standard |
| Linux kernel | network protocols | IP header only |
⚠️ Mixing container types (
uint8_t+uint32_t) in bitfields → unexpected padding between groups. Use one type throughout.