Systems Programming · C and the ABI

Bitfield Allocation and Packed Structures

How bit-fields are packed into storage units on the two major ABIs, why the standard leaves the bit order open, and what packed structs cost at every member access.

A structure of six one-bit flags occupies one byte on one compiler and four on another, and the bit assigned to the first flag may be the least significant or the most significant bit of that byte. Neither choice is a bug. The C standard specifies bit-fields only loosely: the implementation chooses the storage unit, the allocation direction, and whether a field may straddle two units. Code that reads or writes individual bits through a bit-field is therefore relying on a convention that the language does not fix.

The convention is stable within an ABI, which is why bit-fields work in practice for structures that never leave one compilation environment. It becomes fragile the moment the structure is serialised, shared across a language boundary, or compiled for a different target.

What the standard guarantees and what it does not

A bit-field declaration names a member, a width in bits, and optionally a signedness. The width must be a non-negative integer no greater than the width of the declared type. A width of zero is special: it declares no member and forces the next field to start at the beginning of the next storage unit. A bit-field without a name pads rather than stores.

The guarantees are narrow. Consecutive bit-fields are packed into adjacent bits to whatever extent the implementation chooses; a field may not cross a storage unit boundary if that would violate the implementation’s rules; and the address of a bit-field cannot be taken, so &s.flag is not a valid expression. There is no sizeof of a bit-field, and no offsetof.

The choices left open are substantial. Whether a plain int bit-field is signed or unsigned is implementation-defined, which is why declarations should state unsigned int or signed int explicitly. The allocation order within a storage unit is unspecified. The storage unit itself may be the declared type or may be a smaller unit that the implementation finds convenient, and the standard does not require that a structure of bit-fields have any particular size.

The result is that a struct of bit-fields has a size determined by the ABI, not by the source. On the x86-64 System V ABI, bit-fields are allocated from the least significant bit of a storage unit of the declared type, and consecutive fields fill that unit from low to high. A field that does not fit in the remaining bits starts a new unit. On the big-endian variant of the same ABI, the allocation runs from the most significant bit, and the resulting byte order in memory is reversed relative to the little-endian case.

The unit, the straddle, and the zero-width separator

The allocation algorithm on the little-endian System V ABI is small enough to trace. Start with the current storage unit, of the declared type’s size, at the current offset. For each field of width w: if the field does not fit in the remaining bits of the current unit, advance to the next unit. Assign the field the next w bits within the unit, starting from the low end. When all fields are placed, the structure’s size is the offset of the end of the last unit, rounded to the structure’s alignment.

Consider four fields of widths 3, 5, 4, and 4 declared as unsigned int on a target where int is 32 bits. The first two fill 8 bits of the first unit; the third and fourth fill another 8 bits, leaving the unit’s upper 16 bits unused. The structure’s size is 4, not 2, because the storage unit is the full int. Declaring the fields as unsigned char reduces the unit to 8 bits and makes the same four fields span two units, for a size of 2.

The storage unit’s declared type therefore sets the granularity of the waste. A bit-field declared as unsigned int reserves a 32-bit unit even if only a few bits are used, and the unused bits are padding that cannot be recovered by a later field of a different declared type unless the implementation allows it. In practice, mixing declared types in one run of bit-fields is where implementations diverge most, and the safe rule is to use one declared type throughout a structure.

The zero-width field is the portable separator. unsigned int : 0; ends the current unit and forces the next field to begin at the next unit boundary. It does not occupy storage itself, but it may increase the structure’s size by forcing a fresh unit. A named field of width zero is not permitted; the width-zero form must be unnamed.

struct flags {
    unsigned int readable : 1;
    unsigned int writable : 1;
    unsigned int executable : 1;
    unsigned int          : 0;   /* next field starts a new unit */
    unsigned int mode     : 4;
};

Signedness, sign extension, and the read-modify-write

A bit-field declared as int is signed, and reading it sign-extends from the field width to the declared type. A 4-bit signed field holding the pattern 1111 reads as -1, not 15. Writing 15 to that field stores the low 4 bits and reads back -1, which is a silent loss of range that a comparison against the stored value will not catch.

Because bit-fields share storage, every assignment to one is a read-modify-write of the containing unit. The compiler loads the unit, masks in the new field value, and stores the unit back. Two threads writing different fields of the same unit therefore race, even though the fields are logically independent: the two read-modify-write sequences can interleave and one update can be lost. This is a genuine data race in the memory model’s terms, because the two accesses are to the same memory location. The fix is a mutex, an atomic operation on the whole unit, or separate fields that do not share a unit.

The same sharing makes bit-field access slower than a plain member access in code that has not been optimised across statements. A single assignment compiles to a load, an AND, an OR, and a store; a plain field assignment compiles to a store. For a structure held in a register, the compiler can often combine adjacent bit-field writes, but the combining is limited by the aliasing rules and by the possibility that the store is observable.

Packed structures and the alignment they give up

A packed structure removes the padding that alignment would insert. __attribute__((packed)) on a structure declaration sets the alignment of every member to 1 and the alignment of the structure itself to 1, so the members are laid out consecutively with no interior or trailing padding. The size is the sum of the member sizes.

The purpose is to match an external format: a network header, an on-disk record, a memory-mapped register block, or a structure defined by another language’s FFI. Without packing, a structure whose members sum to 13 bytes would occupy 16 or 20, and the mismatch with the format would corrupt every field after the first.

The cost is paid on every access. A member of a packed structure may sit at an address that does not satisfy its type’s alignment. The compiler must generate an access that works regardless: on x86-64 it emits an ordinary load, which the hardware tolerates at the cost of a split access when the load crosses a cache line. On targets that fault on misaligned access, the compiler emits a sequence of byte loads and shifts, which multiplies the instruction count. Neither case is free, and the second is dramatically slower for wide members.

Taking the address of a packed member is the sharper hazard. &s.field yields a pointer whose value may not be a multiple of the field type’s alignment, and dereferencing it through a typed pointer is undefined behaviour. The compiler usually warns when the address of a packed member is taken, and the warning is worth heeding. The safe pattern is to copy the member into a local variable and operate on the copy, which forces the unaligned load to happen once at the copy.

struct __attribute__((packed)) record {
    unsigned char  version;
    unsigned int   length;    /* offset 1: unaligned */
    unsigned short checksum;  /* offset 5: unaligned */
};

unsigned int read_length(const struct record *r)
{
    return r->length;         /* compiler emits a safe unaligned load */
}

A second cost is the loss of the compiler’s ability to assume alignment. Optimisations that vectorise a loop over an array of structures require the elements to be aligned; a packed structure’s elements are aligned to 1, so the vectoriser must either skip the loop or use unaligned accesses, which are slower on many targets and may not be available for every operation.

Why serialised formats should not use either mechanism

The combination of bit-fields and packing is the classic way to describe a wire format in C, and it is also the classic source of portability failures. Three properties make it unreliable.

Bit order within a unit is unspecified by the standard and differs between ABIs. A field declared first is the low bit on little-endian x86-64 and the high bit on big-endian PowerPC, so the same source produces a different wire layout on each. A format that specifies bit 0 of a byte as a particular flag cannot be described by a bit-field without knowing the target.

The storage unit and the straddle rules differ. A field that fits in the remaining bits of a unit on one implementation may be moved to the next unit on another, shifting every subsequent field.

The declared type’s size is not fixed. unsigned int is 32 bits on the common targets but the standard permits 16, and unsigned long is 32 on Windows and 64 on Linux. A bit-field declared as unsigned long therefore has a different unit size on each. The same variability applies to every type whose width the language leaves open, as integer object representation and conversion sets out.

The reliable approach is explicit masking and shifting over an unsigned integer of known width, with the byte order handled by the format’s own rules. The accessor functions are more verbose than a bit-field declaration and they are portable, testable, and immune to the compiler’s layout choices.

/* Bit 0 is readable, bits 1-2 are the mode, independent of target layout. */
#define FLAG_READABLE  (1u << 0)
#define MODE_SHIFT     1
#define MODE_MASK      (3u << MODE_SHIFT)

static inline unsigned int get_mode(unsigned int word)
{
    return (word & MODE_MASK) >> MODE_SHIFT;
}

The masking form also makes the read-modify-write explicit and atomic-friendly. A single |= on the word is one operation on one location, which can be made atomic with the appropriate builtin; the equivalent bit-field assignment is a sequence of three operations that no atomic primitive can cover as a unit.

Bit-fields remain appropriate for a structure that is private to one compilation environment, where the compiler’s layout is the only layout that matters and the convenience of named fields outweighs the loss of control. The boundary between those two cases is the same boundary discussed in struct padding and alignment: anything that crosses an interface needs a layout fixed by a specification rather than by a compiler.

References

  1. ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
  2. GCC Manual — Type Attributes — GNU Project
  3. System V Application Binary Interface, AMD64 Architecture Processor Supplement — x86-64 psABI committee
  4. ARM Compiler Documentation — __attribute__((packed)) — Arm Limited