Skip to content
C++
Language
since C++98
Intermediate

Bit Fields

"C++ bit fields: allocation rules, signed semantics, implementation-defined behavior, volatile MMIO patterns, and portable alternatives for systems programming."

Bit Fieldsince C++98

A bit field is a struct or union member declared with an explicit bit-width, allowing the compiler to pack multiple logical values into a single integer allocation unit without manual masking.

Overview

Bit fields let you specify, at the declaration site, exactly how many bits a member occupies. The compiler generates the read-modify-write sequences for you. They are a C feature that C++ inherited from day one and every standard since has retained.

The appeal is concrete: eight 1-bit flags occupy one byte instead of eight. Hardware register definitions become self-documenting. The cost is equally concrete: the standard leaves layout—bit order within an allocation unit, how fields split across unit boundaries, whether a plain int field is signed—entirely to the implementation. Code that matches a register map on GCC/x86 can silently misread it on an ARM Keil toolchain.

What the standard guarantees (C++98 and later):

  • Members are laid out within their allocation unit in some implementation-defined order.
  • A zero-width unnamed field forces the next member onto a fresh allocation unit.
  • Storing a value that does not fit in the declared width is undefined behavior.
  • Bit fields cannot be static, cannot have their address taken, and cannot be bound to lvalue references.

What the standard leaves implementation-defined:

  • Whether bits fill from LSB or MSB within the allocation unit.
  • Whether a field that would overflow one unit is split across units or starts in the next.
  • Whether a plain int bit field is signed or unsigned (use signed int or unsigned int explicitly).

Since C++20, all signed integers are guaranteed to use two's complement. Before C++20, the representation itself was implementation-defined; in practice every mainstream compiler used two's complement, but the guarantee was absent.

Syntax

cpp
struct StatusWord {
    unsigned int ready   : 1;   // 1 bit, values [0, 1]
    unsigned int mode    : 2;   // 2 bits, values [0, 3]
    unsigned int         : 4;   // unnamed padding — inaccessible
    unsigned int         : 0;   // zero-width: force next field to new allocation unit
    unsigned int fault   : 1;
};

The declared type determines the allocation unit size. Use unsigned int, uint8_t, uint16_t, uint32_t, or (since C++11) any integral or unscoped enumeration type. The bit-width must be a constant expression no greater than sizeof(type) * CHAR_BIT.

bool bit fields are well-formed:

cpp
struct Flags {
    bool valid   : 1;
    bool pending : 1;
    bool flushed : 1;
};
static_assert(sizeof(Flags) == 1);  // typically true; implementation-defined

Signed bit fields

cpp
struct Delta {
    signed int offset : 4;  // two's complement: range [-8, 7]
                             // representation guaranteed since C++20
};

Delta d{};
d.offset = -3;  // stored as 0b1101 in two's complement
d.offset = 8;   // UB — 8 does not fit in a 4-bit signed field

Prefer signed or unsigned explicitly. A bare int has implementation-defined signedness in a bit field context, even though GCC, Clang, and MSVC all treat it as signed.

Examples

Memory-mapped hardware registers

cpp
// UART control register at a fixed physical address
struct alignas(4) UartStatus {
    uint32_t tx_ready  : 1;   // bit 0: transmit buffer empty
    uint32_t rx_ready  : 1;   // bit 1: receive data available
    uint32_t overrun   : 1;   // bit 2: receive overrun error
    uint32_t frame_err : 1;   // bit 3: framing error
    uint32_t           : 28;  // bits 4–31: reserved
};
static_assert(sizeof(UartStatus) == 4);

inline volatile UartStatus& uart_status() {
    // volatile on the pointer — every access goes to memory
    return *reinterpret_cast<volatile UartStatus*>(0x40011000U);
}

void uart_send_byte(uint8_t byte) {
    while (!uart_status().tx_ready) {}
    // write byte to TX data register...
}

Apply volatile to the pointer or reference, not to individual bit field members. Both approaches compile, but a volatile pointer makes the intent clear and avoids compiler warnings on some toolchains.

Dense flag storage for large datasets

Eight bytes of graph node flags instead of eight separate bytes — eight times the cache density:

cpp
struct NodeFlags {
    uint8_t visited   : 1;
    uint8_t on_stack  : 1;
    uint8_t permanent : 1;
    uint8_t           : 5;  // reserved
};
static_assert(sizeof(NodeFlags) == 1);

struct GraphNode {
    uint32_t  id;
    uint32_t  edge_offset;
    NodeFlags flags;
};

Lambda capture limitation and the C++14 workaround

Bit fields have no address, so they cannot be captured by reference. Since C++14, generalized init-capture provides an escape:

cpp
struct Packet {
    uint16_t src_port : 16;
    uint16_t dst_port : 16;
};

Packet pkt{8080, 443};

// auto log = [&pkt.src_port]() { ... };  // ERROR — no address

auto log = [p = pkt.src_port]() { return p; };  // OK since C++14 — copies value

Zero-width field forcing a new allocation unit

cpp
struct Split {
    uint8_t a : 5;
    uint8_t   : 0;  // force 'b' to start in a fresh uint8_t
    uint8_t b : 3;
};
static_assert(sizeof(Split) == 2);  // a and b in separate bytes

Best Practices

Use fixed-width types as allocation units. uint32_t over unsigned int — the latter has implementation-defined size. This makes static_assert(sizeof(RegMap) == 4) meaningful.

Add static_assert for every register struct. Silent padding breaks register maps catastrophically. The assertion costs nothing and catches toolchain surprises immediately.

Isolate hardware layout structs behind an abstraction boundary. Keep bit-field register maps in target-specific headers. Do not let them leak into portable business logic.

Document the hardware spec in comments. Note which bit numbers each field covers and which datasheet defines them. The field declaration order rarely matches the bit-number order in a manual.

Use unsigned for all raw-bit-pattern fields. Signed arithmetic on narrow fields has surprising wrap semantics; unsigned truncation is at least well-defined.

Common Pitfalls

Assuming layout matches the hardware spec

The most consequential mistake. GCC/x86 typically fills bits from LSB to MSB. MSVC and some big-endian toolchains do the opposite. A struct that correctly maps a network header on one platform silently inverts every field on another.

cpp
// Appears to work on little-endian GCC — breaks on MSVC or big-endian:
struct FragileHeader {
    uint8_t version : 4;  // might be bits [3:0] or [7:4] — impl-defined
    uint8_t ihl     : 4;
};

// Portable: explicit byte manipulation, defined behavior everywhere:
struct RobustHeader {
    uint8_t raw;
    uint8_t version() const { return (raw >> 4) & 0x0F; }
    uint8_t ihl()     const { return raw & 0x0F; }
    void set_version(uint8_t v) { raw = (raw & 0x0F) | uint8_t(v << 4); }
    void set_ihl(uint8_t v)     { raw = (raw & 0xF0) | (v & 0x0F); }
};

Width overflow is undefined behavior

cpp
struct S { unsigned int x : 3; };  // valid range: [0, 7]
S s{};
s.x = 8;   // UB — truncation in practice, but not guaranteed
s.x = -1;  // UB for unsigned field

Clang and GCC truncate silently. MSVC may warn. None are required to. Validate external values before storing.

Omitting volatile on MMIO structs

cpp
struct Reg { uint32_t ready : 1; };

// BAD: compiler may hoist the load out of the loop entirely
auto* r = reinterpret_cast<Reg*>(0x40001000U);
while (!r->ready) {}

// GOOD: volatile prevents the optimization
auto* r = reinterpret_cast<volatile Reg*>(0x40001000U);
while (!r->ready) {}

alignas cannot appear on bit field members

cpp
struct Bad {
    alignas(4) uint8_t x : 4;  // ill-formed
};

Control sub-field alignment with unnamed zero-width fields or explicit padding instead.

[[no_unique_address]] is inapplicable

Since C++20, [[no_unique_address]] can optimize empty member storage — but it cannot be applied to bit fields. The compiler will reject it.

Portable Alternative: Manual Bit Manipulation

For network protocols, serialization formats, or any cross-platform ABI requirement:

cpp
#include <cstdint>

class TcpFlags {
    uint8_t raw_{};
public:
    bool fin() const { return raw_ & 0x01; }
    bool syn() const { return raw_ & 0x02; }
    bool ack() const { return raw_ & 0x10; }

    void set_syn(bool v) { raw_ = v ? (raw_ | 0x02) : (raw_ & uint8_t(~0x02)); }
    void set_ack(bool v) { raw_ = v ? (raw_ | 0x10) : (raw_ & uint8_t(~0x10)); }

    uint8_t raw() const { return raw_; }
};

For large or runtime-sized flag sets, std::bitset (C++98) is portable and type-safe, though it lacks the named-accessor ergonomics:

cpp
#include <bitset>

enum Permission : size_t { Read = 0, Write = 1, Execute = 2 };

std::bitset<8> perms;
perms.set(Read);
perms.set(Write);

bool can_execute = perms.test(Execute);  // false

Struct Layout with Bit Fields

Packing depends on the underlying type's allocation unit. Fields sharing the same underlying type are packed together; switching to a different type typically starts a new allocation unit, introducing padding between groups.

cpp
#include <cstdint>
#include <cstddef>

// All three fields share one uint8_t — fits in exactly 1 byte
struct Flags {
    uint8_t a : 3;   // bits [2:0]
    uint8_t b : 3;   // bits [5:3]
    uint8_t c : 2;   // bits [7:6]
};
static_assert(sizeof(Flags) == 1);  // C++11

// Mixing underlying types: compiler starts a new allocation unit at the boundary
struct Mixed {
    uint8_t  x : 4;   // 1-byte unit
    uint32_t y : 4;   // new 4-byte unit — 3 bytes of padding between x and y
};
// sizeof(Mixed) is typically 8 on most ABIs

The layout is entirely implementation-defined. Two conforming compilers may produce structs of different sizes from the same declaration. Always static_assert the size when matching a hardware spec or wire format.

cpp
// Equivalent manual approach — portable, explicit, no surprises:
struct FlagsManual {
    uint8_t raw;
    uint8_t a() const { return (raw >> 0) & 0x07; }
    uint8_t b() const { return (raw >> 3) & 0x07; }
    uint8_t c() const { return (raw >> 6) & 0x03; }
    void set_a(uint8_t v) { raw = (raw & ~0x07u) | (v & 0x07u); }
    void set_b(uint8_t v) { raw = (raw & ~0x38u) | ((v & 0x07u) << 3); }
    void set_c(uint8_t v) { raw = (raw & ~0xC0u) | ((v & 0x03u) << 6); }
};
static_assert(sizeof(FlagsManual) == 1);

The manual version and the bit-field version produce identical machine code on any mainstream optimizing compiler, but the manual version's layout is defined by the C++ standard rather than the toolchain.

Bit Fields in Hardware Register Mapping

Bit fields are attractive for register definitions but carry atomicity risks. Accessing a register through a bit-field struct generates a read-modify-write sequence on the whole allocation unit — which is not atomic at the hardware level.

cpp
#include <cstdint>

// C++11
struct UART_CR {
    volatile uint32_t enable     : 1;   // bit 0: peripheral clock gate
    volatile uint32_t parity     : 2;   // bits [2:1]: 00=none 01=odd 10=even
    volatile uint32_t stopbits   : 2;   // bits [4:3]: 00=1 01=1.5 10=2
    volatile uint32_t baud_select: 5;   // bits [9:5]: baud-rate divisor index
    volatile uint32_t reserved   : 22;  // bits [31:10]: must write 0
};
static_assert(sizeof(UART_CR) == 4);

// Map to a physical address
inline volatile UART_CR& uart_cr() {
    return *reinterpret_cast<volatile UART_CR*>(0x40013800U);
}

void uart_enable() {
    uart_cr().enable = 1;   // compiler emits load → OR → store on the full 32-bit register
}

The shift-and-mask idiom makes the read-modify-write explicit. It is no more or less atomic, but the code is portable across ABIs:

cpp
// C++11
#include <cstdint>

inline volatile uint32_t& UART_CR_REG() {
    return *reinterpret_cast<volatile uint32_t*>(0x40013800U);
}

constexpr uint32_t UART_CR_ENABLE_MASK  = 0x00000001u;
constexpr uint32_t UART_CR_PARITY_SHIFT = 1u;
constexpr uint32_t UART_CR_PARITY_MASK  = 0x00000006u;

void uart_set_parity(uint32_t mode) {
    uint32_t reg = UART_CR_REG();
    reg = (reg & ~UART_CR_PARITY_MASK) | ((mode & 0x3u) << UART_CR_PARITY_SHIFT);
    UART_CR_REG() = reg;
}

For truly atomic register access shared between an ISR and a thread, use std::atomic<uint32_t> with explicit shift/mask — std::atomic cannot be a bit field, so the struct-based approach is unavailable in that context.

Anonymous Bit Fields for Alignment Padding

Unnamed bit fields occupy space but are inaccessible. They are the standard-blessed way to insert padding between fields to match a hardware register layout without adding named members that callers could accidentally use.

cpp
#include <cstdint>

// C++11
struct Aligned {
    uint8_t a : 3;   // bits [2:0]
    uint8_t   : 2;   // bits [4:3]: unnamed padding — no way to read or write
    uint8_t b : 3;   // bits [7:5]
};
static_assert(sizeof(Aligned) == 1);

A zero-width unnamed field forces the next field into a fresh allocation unit. This is the portable way to guarantee two fields do not share a word, which matters when a hardware spec assigns fields to different register offsets within the same struct:

cpp
// C++11
struct SplitRegs {
    uint32_t ctrl : 8;   // occupies bits [7:0] of first 32-bit word
    uint32_t      : 0;   // jump to the next uint32_t boundary
    uint32_t stat : 8;   // occupies bits [7:0] of the second 32-bit word
};
static_assert(sizeof(SplitRegs) == 8);

The C++ standard guarantees: unnamed bit fields have no name, cannot be initialized with a designated initializer, and produce no named member in the class's member list. They do contribute to the struct's size and to the layout of subsequent members. Use them to mirror a register map precisely without polluting the public interface.

Bit Field Limitations and Alternatives

Bit fields impose several hard constraints that are easy to hit in generic or systems code. Knowing the alternatives avoids painful refactors.

cpp
#include <cstdint>
#include <atomic>
#include <bitset>

struct S { uint32_t x : 4; };

S s{};
// &s.x;               // ERROR — cannot take address of bit field
// int& r = s.x;       // ERROR — no reference to bit field
// std::atomic<S> as;  // atomic cannot wrap a bit-field-containing struct portably
                       // std::atomic<T> requires T to be trivially copyable and lock-free

The four main alternatives and when to reach for each:

cpp
// 1. std::bitset<N> — fixed-size, portable, supports named constants via enum
#include <bitset>
enum Bit : size_t { READY = 0, ERROR = 1, BUSY = 2 };
std::bitset<8> flags;
flags.set(READY);
bool is_ready = flags.test(READY);

// 2. uint32_t + shift/mask — portable, ABI-stable, readable with named constants
constexpr uint32_t FLAG_READY = 1u << 0;
constexpr uint32_t FLAG_ERROR = 1u << 1;
uint32_t reg = 0;
reg |= FLAG_READY;
bool err = reg & FLAG_ERROR;

// 3. std::atomic<uint32_t> + shift/mask — for lock-free multi-threaded flag word  // C++11
#include <atomic>
std::atomic<uint32_t> atomic_flags{0};
atomic_flags.fetch_or(FLAG_READY, std::memory_order_relaxed);

// 4. std::byte — for byte-level access where semantic meaning is deliberately absent  // C++17
#include <cstddef>
std::byte b{0b10110010};
b |= std::byte{0x01};

The volatile qualifier on individual bit field members is implementation-defined in its effect — the standard does not guarantee that a volatile bit-field access prevents the compiler from merging adjacent field accesses into one read-modify-write. Apply volatile to the pointer to the whole struct instead.

Bit Manipulation Algorithms with std::bit (C++20)

C++20's <bit> header replaces compiler-specific builtins with portable, constexpr functions. Before C++20, counting bits or finding the highest set bit required __builtin_popcount (GCC/Clang) or _BitScanReverse (MSVC) — neither portable nor constexpr.

cpp
#include <bit>      // C++20
#include <cstdint>
#include <cassert>

uint32_t x = 0b0110'1100u;

// Population count — number of set bits
assert(std::popcount(x) == 4);
// Replaces: __builtin_popcount(x) on GCC/Clang

// Leading/trailing zeros
assert(std::countl_zero(x) == 25);   // 32 - position of MSB - 1
assert(std::countr_zero(x) == 2);    // position of LSB
// Replaces: __builtin_clz / __builtin_ctz

// Power-of-two utilities
assert(std::has_single_bit(8u));            // true — exactly one bit set
assert(!std::has_single_bit(6u));           // false

assert(std::bit_floor(13u) == 8u);          // largest power of two <= 13
assert(std::bit_ceil(13u)  == 16u);         // smallest power of two >= 13

A concrete use case: sizing a hash table's bucket array to the next power of two avoids expensive modulo operations:

cpp
#include <bit>      // C++20
#include <cstdint>
#include <vector>

size_t next_power_of_two(size_t n) {
    return std::bit_ceil(n);   // e.g. 13 → 16, 16 → 16, 17 → 32
}

template <typename K, typename V>
struct HashMap {
    explicit HashMap(size_t capacity)
        : buckets_(next_power_of_two(capacity)) {}

    size_t bucket(size_t hash) const {
        // Fast modulo for power-of-two sizes: hash & (size - 1)
        return hash & (buckets_.size() - 1);
    }

    std::vector<std::pair<K,V>> buckets_;
};

All <bit> functions are constexpr and defined only for unsigned integer types. Passing a signed type is ill-formed.

Serializing and Deserializing Bit Fields

Never memcpy a bit-field struct across a network boundary or between platforms. Three independent sources of non-portability combine: byte order (endianness), compiler padding between fields, and bit order within an allocation unit — all three are implementation-defined.

cpp
#include <cstdint>
#include <bit>        // C++20

struct Flags {
    uint8_t a : 3;
    uint8_t b : 3;
    uint8_t c : 2;
};

// WRONG — layout is not portable; on a different toolchain this may read
//          garbled values even for the same data:
// uint8_t raw;
// memcpy(&raw, &f, 1);

// CORRECT — serialize field by field with explicit shifts:
constexpr uint8_t serialize_flags(Flags f) noexcept {
    return static_cast<uint8_t>(
        ((f.a & 0x07u) << 0) |
        ((f.b & 0x07u) << 3) |
        ((f.c & 0x03u) << 6)
    );
}

constexpr Flags deserialize_flags(uint8_t raw) noexcept {
    Flags f{};
    f.a = (raw >> 0) & 0x07u;
    f.b = (raw >> 3) & 0x07u;
    f.c = (raw >> 6) & 0x03u;
    return f;
}

For multi-byte fields, also account for byte order. C++20 provides std::endian to detect the platform's native byte order at compile time:

cpp
// C++20
#include <bit>
#include <cstdint>
#include <array>

// Write a uint32_t as big-endian bytes for a network protocol:
constexpr std::array<uint8_t, 4> to_big_endian(uint32_t v) noexcept {
    if constexpr (std::endian::native == std::endian::big) {
        return { uint8_t(v >> 24), uint8_t(v >> 16), uint8_t(v >> 8), uint8_t(v) };
    } else {
        // Little-endian host — byte-swap manually
        return { uint8_t(v >> 24), uint8_t(v >> 16), uint8_t(v >> 8), uint8_t(v) };
    }
}

// Or use std::byteswap (C++23) / manual swap for C++20:
constexpr uint32_t byteswap32(uint32_t v) noexcept {
    return ((v & 0xFF000000u) >> 24) | ((v & 0x00FF0000u) >> 8)
         | ((v & 0x0000FF00u) << 8)  | ((v & 0x000000FFu) << 24);
}

The rule of thumb: treat the wire format as an explicit unsigned integer and use shift/mask to move between logical fields and that integer. Keep bit-field structs as an internal convenience for reading and writing, never as the serialized representation.

See Also

  • std::bitset — portable, dynamically-accessible bit array (C++98)
  • volatile — required qualifier for memory-mapped I/O access
  • reinterpret_cast — used to overlay structs on hardware addresses
  • [[no_unique_address]] — empty-base optimization for data members; inapplicable to bit fields (C++20)
  • std::endian — detect platform byte order at compile time (C++20)