Embedded C
Structs, Unions & Bit-Fields
C's Structs & Unions chapter covers the general concept — bundling fields, or sharing memory between interpretations. On real hardware, both get used for something more specific: making a C type's memory layout match something outside the compiler's control exactly — a hardware register map, a wire protocol's byte layout, or a single bit inside a status word — which is where bit-fields come in, a C feature that fundamentals-level coverage skips entirely.
- What it is
- Structs and unions used to mirror an externally-defined memory layout exactly, plus bit-fields, a struct member syntax for describing individual bits.
- Mental model
- A normal struct describes memory your program owns; a hardware-mapped struct describes memory your program has to match, byte for byte, to something it doesn't control at all.
- Key numbers
- A bit-field's width is stated in bits, not bytes (
unsigned int flag : 1;is exactly 1 bit); compilers commonly pack several bit-fields into one storage unit. - Reach for it when
- Describing a hardware register's individual bits, a network/file format's exact byte layout, or any case where memory layout is dictated by something other than your own code.
Structs as register maps
A microcontroller's peripheral registers often sit at consecutive addresses — a struct whose fields match that layout exactly lets you address them by name instead of raw offsets, building directly on the memory-mapped-I/O idea from Registers & volatile:
typedef struct {
volatile uint32_t CTRL; // offset 0x00 — control register
volatile uint32_t STATUS; // offset 0x04 — status register
volatile uint32_t DATA; // offset 0x08 — data register
} UART_TypeDef;
#define UART1 ((UART_TypeDef *)0x40011000)
UART1->CTRL = 0x01; // write to the real control register
uint32_t s = UART1->STATUS; // read the real status register
This only works because the struct's field order and each field's size exactly match the hardware's real memory layout — get either wrong and every field after the mistake reads or writes the wrong address, a bug that compiles cleanly and fails silently.
Packed structs for wire protocols
Alignment & Padding covers why the compiler normally inserts invisible gaps between struct fields for performance. When a struct describes an exact byte layout defined by something external — a network packet, a file format — that padding breaks the match. A compiler extension forces the layout to be exactly as written, with no gaps:
#pragma pack(push, 1)
typedef struct {
uint8_t version;
uint16_t length;
uint8_t flags;
} PacketHeader; // exactly 4 bytes, no padding — matches the wire format exactly
#pragma pack(pop)
Bit-fields
A bit-field declares a struct member with an explicit width in bits, not bytes — the compiler packs several of them together into shared storage:
typedef struct {
unsigned int power_on : 1; // 1 bit
unsigned int error_flag : 1; // 1 bit
unsigned int mode : 2; // 2 bits, values 0-3
unsigned int reserved : 4; // unused padding bits
} StatusRegister; // all 4 fields fit in a single byte
StatusRegister status = {0};
status.power_on = 1;
status.mode = 2;
if (status.error_flag) {
handle_error();
}
Bit-fields vs. manual masking
Everything a bit-field does can also be written by hand with bitwise operators (see C's Variables, Data Types & Operators for the bitwise basics):
// Manual masking — explicit, portable, and predictable
#define POWER_ON_MASK 0x01
status_byte |= POWER_ON_MASK; // set the bit
int is_on = (status_byte & POWER_ON_MASK) != 0; // test the bit
// Bit-field — more readable, but with layout caveats (see Gotchas)
status.power_on = 1;
int is_on = status.power_on;
Bit-fields read better, but manual masking gives full, guaranteed control over exactly which physical bit gets set — many embedded codebases deliberately prefer manual masking for anything that has to match an external, fixed specification exactly, for reasons covered next.
Real-world applications
- Peripheral register maps — the
UART_TypeDef-style struct above is exactly how vendor-supplied hardware abstraction layers (CMSIS, HAL headers) expose every microcontroller peripheral. - Network/file format headers — a packed struct matching a protocol's exact byte layout, used throughout Networking-adjacent embedded code (custom framing over UART, simple binary file formats).
- Status and configuration registers — bit-fields (or manual masks) representing a device's individual on/off flags in one compact word.
Gotchas
- Bit-field layout (which bit is "first") is compiler- and platform-defined, not guaranteed by the C standard — code relying on a specific bit-field layout to match an external hardware or protocol specification can silently break when recompiled for a different target. Manual masking with explicit, documented bit numbers is the safer choice whenever exact bit position genuinely matters.
#pragma packis a widely-supported compiler extension, not standard C — always verify your specific toolchain's exact packing syntax and behavior rather than assuming it matches another compiler's.- A register-map struct without
volatileon every field reintroduces exactly the compiler-caching bug covered in Registers & volatile — every field mapped to real hardware needs it, not just the struct as a whole. - Taking the address of a bit-field member (
&status.power_on) is not allowed in standard C — a bit-field doesn't necessarily start at a byte boundary, so it has no well-defined address to take.