Part 1 · The C Language31 min read
Structs, Unions, Enums, and Memory Layout
A struct is how C groups related data into one type: a driver’s state, a configuration record, a message, a peripheral’s register block. Firmware cares about structs more than most code does, because their memory layout matters. The layout decides how much RAM an array of them costs, whether they can be sent over a wire or written to flash, and whether a struct overlay lines up with a peripheral’s registers. This lesson covers how to define and initialize structs, exactly how the compiler lays them out, when and why to pack them, the offsetof and container_of tools, unions for type punning and tagged messages, enums, bitfields and why most firmware teams avoid them for registers, and the traps in comparing and copying structs.
Defining and initializing structs
Section titled “Defining and initializing structs”A struct declaration lists members, each with its own type:
#include <stdbool.h>#include <stdint.h>
struct uart_cfg { uint32_t baud; uint8_t data_bits; uint8_t stop_bits; bool parity_even;};
struct uart_cfg console = { 115200u, 8u, 1u, false }; /* positional */struct uart_cfg is the type’s full name. The word after struct is a tag, and tags live in their own namespace, which is why you must write struct uart_cfg each time. A typedef gives the type a one-word name:
typedef struct uart_cfg uart_cfg_t; /* separate typedef */
typedef struct { /* anonymous struct + typedef */ uint16_t x; uint16_t y;} point_t;Both styles are common. Keep the tag when the struct must refer to itself (a linked-list node has a struct node *next) or when other headers need to forward-declare it; that is the basis of opaque handles in Organizing a real C codebase. (Strictly, POSIX reserves names ending in _t; firmware code uses the suffix anyway, and it is harmless on a bare-metal toolchain.)
Designated initializers
Section titled “Designated initializers”Positional initializers break silently when someone reorders or inserts a member. Designated initializers (C99) name each member:
struct uart_cfg console = { .baud = 115200u, .data_bits = 8u, .stop_bits = 1u, /* .parity_even not mentioned: zero (false) */};Members you do not mention are zero-initialized, exactly as with arrays. Order no longer matters, and a reader sees what each value means. Use designated initializers for every struct with more than two members.
Compound literals
Section titled “Compound literals”A compound literal (C99) creates an unnamed object of a struct type inline, which is handy for passing a configuration without declaring a variable:
#include <stdint.h>
struct uart_cfg { uint32_t baud; uint8_t data_bits; uint8_t stop_bits;};
void uart_configure(const struct uart_cfg *cfg);
void board_init(void){ uart_configure(&(struct uart_cfg){ .baud = 115200u, .data_bits = 8u, .stop_bits = 1u });}The syntax is a parenthesized type name followed by an initializer list. The object has automatic storage when it appears inside a function: it lives until the end of the enclosing block. Passing its address to a function that uses it during the call is fine. Passing it to a driver that keeps the pointer is the dangling-pointer bug from Pointers II.
Nested structs and arrays of structs
Section titled “Nested structs and arrays of structs”Structs can contain other structs and arrays, and arrays can hold structs. Together they describe things like a board’s LEDs:
#include <stdbool.h>#include <stdint.h>
struct pin { uint8_t port; /* 0 = GPIOA, 1 = GPIOB, ... 3 = GPIOD */ uint8_t num; /* 0..15 */};
struct led_cfg { const char *name; struct pin pin; bool active_low;};
const struct led_cfg board_leds[] = { { .name = "green", .pin = { .port = 3u, .num = 12u }, .active_low = false }, { .name = "orange", .pin = { .port = 3u, .num = 13u }, .active_low = false }, { .name = "red", .pin = { .port = 3u, .num = 14u }, .active_low = false }, { .name = "blue", .pin = { .port = 3u, .num = 15u }, .active_low = false },};These are the four Discovery LEDs on PD12 to PD15. Member access chains left to right: board_leds[2].pin.num is 14. Through a pointer, cfg->pin.num. The whole table is const with static storage duration, so it sits in flash (Arrays, strings, and tables explains why that matters).
Passing structs: by value or by pointer
Section titled “Passing structs: by value or by pointer”Structs can be passed and returned by value; the callee gets a copy. Under the Arm calling convention (Functions and the calling convention), a struct argument is passed in r0 to r3 as if it were a sequence of words, spilling to the stack beyond 16 bytes, and a returned struct larger than 4 bytes is written through a hidden pointer the caller passes in r0. Small structs by value are cheap. Large ones mean copying.
The firmware convention:
- Pass by
const struct x *when the function only reads the struct. It costs one register regardless of size and says “read-only”. - Pass by
struct x *when the function modifies it. - Pass or return by value for small value types (a point, a 2-member result) where copy semantics are what you want.
Alignment and padding
Section titled “Alignment and padding”Every type has an alignment requirement (Pointers I introduced it). The compiler lays out a struct’s members in declaration order and inserts padding bytes so each member lands on a multiple of its alignment. It also adds tail padding so the struct’s total size is a multiple of its largest member’s alignment; that keeps every element of an array of structs aligned.
The rules for Cortex-M under the AAPCS, which also match x86-64 for these types:
| Type | Size | Alignment |
|---|---|---|
uint8_t, int8_t, char, bool | 1 | 1 |
uint16_t, int16_t | 2 | 2 |
uint32_t, int32_t, float | 4 | 4 |
uint64_t, int64_t, double | 8 | 8 |
| Pointer | 4 (8 on a 64-bit host) | 4 (8 on a 64-bit host) |
| A struct | sum of members plus padding | largest member alignment |
Here is a struct declared in the order someone thought of the fields:
#include <stdint.h>
struct event_bad { uint8_t type; /* offset 0 */ uint64_t timestamp; /* offset 8: 7 bytes of padding before it */ uint8_t source; /* offset 16 */ uint32_t value; /* offset 20: 3 bytes of padding before it */}; /* sizeof = 24 */ struct event_bad (24 bytes, alignment 8)
offset 0 1 2 3 4 5 6 7 +----+----+----+----+----+----+----+----+ 0 |type| pad pad pad pad pad pad pad| +----+----+----+----+----+----+----+----+ 8 | timestamp | +----+----+----+----+----+----+----+----+ 16 |src | pad pad pad| value | +----+----+----+----+----+----+----+----+ 10 bytes of padding, 14 bytes of dataReorder the members from largest alignment to smallest:
#include <stdint.h>
struct event_good { uint64_t timestamp; /* offset 0 */ uint32_t value; /* offset 8 */ uint8_t type; /* offset 12 */ uint8_t source; /* offset 13 */}; /* sizeof = 16: 2 bytes of tail padding */ struct event_good (16 bytes, alignment 8)
offset 0 1 2 3 4 5 6 7 +----+----+----+----+----+----+----+----+ 0 | timestamp | +----+----+----+----+----+----+----+----+ 8 | value |type|src | pad pad| +----+----+----+----+----+----+----+----+Same data, 8 bytes smaller. An event queue of 64 entries just saved 512 bytes of RAM. The tail padding is needed so that in an array, the next element’s timestamp starts on an 8-byte boundary.
Verify layouts yourself with sizeof and offsetof (from stddef.h, explained below):
#include <stddef.h>#include <stdint.h>#include <stdio.h>
struct event_bad { uint8_t type; uint64_t timestamp; uint8_t source; uint32_t value; };struct event_good { uint64_t timestamp; uint32_t value; uint8_t type; uint8_t source; };
int main(void){ printf("bad: size %zu, value at %zu\n", sizeof(struct event_bad), offsetof(struct event_bad, value)); printf("good: size %zu, value at %zu\n", sizeof(struct event_good), offsetof(struct event_good, value)); return 0;}bad: size 24, value at 20good: size 16, value at 8These numbers are the same on a 64-bit host and on Cortex-M because neither struct contains pointers. Add a pointer member and the host numbers grow, which is one more reason not to treat a struct’s in-memory layout as a file or wire format.
The rule of thumb: order members by decreasing alignment (8-byte types, then 4, then 2, then 1), and group small fields together. Compilers never reorder struct members for you; C requires declaration order. GCC’s -Wpadded warns about every padding byte if you want to audit a struct, though it is too noisy to leave on.
Packed structs
Section titled “Packed structs”Sometimes you need a struct with no padding at all, because its layout must match bytes defined by someone else: a protocol header, a file format, a USB descriptor. GCC and Clang provide an attribute for that:
#include <stdint.h>
struct __attribute__((packed)) wire_hdr { uint8_t type; /* offset 0 */ uint32_t length; /* offset 1: no padding */ uint16_t crc; /* offset 5 */}; /* sizeof = 7, alignment 1 */ offset 0 1 2 3 4 5 6 +------+------+------+------+------+------+------+ | type | length | crc | +------+------+------+------+------+------+------+Packing has costs you need to know:
- Misaligned members.
lengthsits at offset 1. The compiler knows the struct is packed, so it generates code that is safe for any alignment. On a Cortex-M4 that is an unalignedldr, which the hardware handles with extra bus cycles. On a Cortex-M0, or with-mno-unaligned-access, every access tolengthbecomes four byte loads, three shifts and three ORs, roughly ten instructions instead of one. - Pointers to members are dangerous.
uint32_t *p = &hdr->length;produces an ordinaryuint32_t *that the compiler believes is aligned. Use it in a function that does not know about the packing and you are back to the misaligned-access faults from Pointers I. GCC 9 and later warn with-Waddress-of-packed-member, on by default. - The whole struct’s alignment drops to 1, which changes the layout of any struct that contains it and of arrays of it.
- It is not portable. The syntax is a GNU extension (other compilers use
#pragma pack), and the byte order oflengthis still the CPU’s, while most protocols are big-endian.
When is packing justified? When you are overlaying a buffer that really has that layout, the code that touches it is small and contained, and you have checked the endianness. Many teams avoid it entirely and serialize fields explicitly with shifts, which handles both alignment and byte order and is shown in Bit manipulation. Never pack a struct just to save RAM; reorder the members instead.
Whatever you choose, pin the layout down with a compile-time check so a later edit cannot change it silently:
_Static_assert(sizeof(struct wire_hdr) == 7, "wire_hdr must match the protocol");offsetof and container_of
Section titled “offsetof and container_of”offsetof(type, member), from stddef.h, gives the byte offset of a member from the start of its struct, as a compile-time constant of type size_t. You saw it in the layout experiment. Its main uses are checking layouts against a specification and computing addresses in generic code.
The most important computed address is the reverse direction: given a pointer to a member, find the struct that contains it. That is container_of, a macro the Linux kernel made famous:
#include <stddef.h>
#define container_of(ptr, type, member) \ ((type *)(void *)((char *)(ptr) - offsetof(type, member)))Convert the member pointer to char * so the arithmetic is in bytes, step back by the member’s offset, and convert to a pointer to the outer type. The void * step silences cast-alignment warnings.
Why you need it: a generic module (a timer service, a linked list) works with its own small struct, and you embed that struct inside your bigger one. When the generic module calls you back, it hands you a pointer to its small struct, and container_of gets you back to yours without any void *ctx:
#include <stddef.h>#include <stdint.h>#include <stdio.h>
#define container_of(ptr, type, member) \ ((type *)(void *)((char *)(ptr) - offsetof(type, member)))
struct timer { uint32_t period; uint32_t elapsed; void (*expired)(struct timer *t);};
struct blinker { const char *name; uint32_t toggles; struct timer tmr; /* embedded timer, not the first member */};
static void timer_tick(struct timer *t){ if (++t->elapsed >= t->period) { t->elapsed = 0u; t->expired(t); }}
static void blinker_expired(struct timer *t){ struct blinker *b = container_of(t, struct blinker, tmr);
b->toggles++; printf("%s: toggle %u\n", b->name, (unsigned)b->toggles);}
int main(void){ struct blinker green = { .name = "green", .toggles = 0u, .tmr = { .period = 3u, .elapsed = 0u, .expired = blinker_expired }, };
for (int i = 0; i < 7; i++) { timer_tick(&green.tmr); } return 0;}green: toggle 1green: toggle 2 struct blinker green at 0x20000100 (Cortex-M offsets)
+-------------------+0x20000100 | name | offset 00x20000104 | toggles | offset 4 +-------------------+0x20000108 | tmr.period | offset 8 <- t points here (0x20000108)0x2000010C | tmr.elapsed | offset 120x20000110 | tmr.expired | offset 16 +-------------------+
container_of(t, struct blinker, tmr) = 0x20000108 - offsetof(struct blinker, tmr) = 0x20000108 - 8 = 0x20000100The timer module knows nothing about blinkers. The offset is a compile-time constant, so container_of compiles to a single subtraction (or folds into the member offsets of the following loads). The macro is only correct if t really points to a tmr inside a struct blinker; pass it any other timer and you get a wild pointer. This “embed the generic part” pattern is the backbone of object-oriented C, developed in Design patterns for embedded C.
Unions
Section titled “Unions”A union looks like a struct, but all members start at offset 0 and share the same storage. Its size is the size of its largest member, rounded up to its alignment. Only one member holds a meaningful value at a time, the one most recently written.
#include <stdint.h>
union sample { uint8_t u8; uint16_t u16; uint32_t u32;}; /* sizeof = 4 */ union sample (4 bytes): every member starts at offset 0
offset 0 1 2 3 +------+------+------+------+ u8 | xx | +------+------+ u16 | xx | xx | +------+------+------+------+ u32 | xx | xx | xx | xx | +------+------+------+------+Type punning through a union
Section titled “Type punning through a union”Writing one member and reading another reinterprets the bytes. C explicitly allows this (C11 6.5.2.3, footnote 95): the bytes are reinterpreted as the new type. C++ does not allow it, which matters if your code is ever compiled as C++. It is the clean way to look at the bit pattern of a float, where a pointer cast would break strict aliasing:
#include <inttypes.h>#include <stdio.h>
union f32_bits { float f; uint32_t u;};
int main(void){ union f32_bits x = { .f = 1.0f }; printf("1.0f = 0x%08" PRIX32 "\n", x.u);
x.f = -2.5f; printf("-2.5f = 0x%08" PRIX32 "\n", x.u); return 0;}1.0f = 0x3F800000-2.5f = 0xC0200000memcpy(&u, &f, sizeof u) is the other legal way, and it works in both C and C++. The compiler turns either into a register move (vmov r0, s0 on the M4F). The strict aliasing background is in Undefined behavior, volatile, and what the optimizer assumes.
A union of a word and a byte array, union { uint32_t word; uint8_t bytes[4]; }, is also common for splitting values into bytes. Remember that which byte is bytes[0] depends on endianness: on Cortex-M it is the least significant byte. Code that sends bytes[] over a wire is not portable to a big-endian peer without care.
Tagged unions for messages
Section titled “Tagged unions for messages”The most useful union in application code is the tagged union: a struct holding a tag that says which member of a union is valid. It is how one queue carries several kinds of messages:
#include <stdbool.h>#include <stdint.h>#include <stdio.h>
typedef enum { MSG_ADC, MSG_BUTTON, MSG_TEXT } msg_type_t;
typedef struct { msg_type_t type; /* the tag: which member is valid */ union { /* anonymous union (C11) */ struct { uint16_t mv; uint8_t channel; } adc; struct { uint8_t id; bool pressed; } button; char text[16]; };} msg_t;
static void handle(const msg_t *m){ switch (m->type) { case MSG_ADC: printf("adc ch%u: %u mV\n", (unsigned)m->adc.channel, (unsigned)m->adc.mv); break; case MSG_BUTTON: printf("button %u %s\n", (unsigned)m->button.id, m->button.pressed ? "down" : "up"); break; case MSG_TEXT: printf("text: %s\n", m->text); break; }}
int main(void){ const msg_t queue[] = { { .type = MSG_ADC, .adc = { .mv = 1650u, .channel = 3u } }, { .type = MSG_BUTTON, .button = { .id = 0u, .pressed = true } }, { .type = MSG_TEXT, .text = "hello" }, };
for (size_t i = 0; i < sizeof queue / sizeof queue[0]; i++) { handle(&queue[i]); } return 0;}adc ch3: 1650 mVbutton 0 downtext: helloEvery message is the same size (the tag plus the largest payload, here the 16-byte text), so they fit in a fixed array or a ring buffer. The switch on the tag has a case for every enumerator and no default, so if someone adds MSG_ERROR to the enum and forgets the handler, -Wswitch (part of -Wall) points to this switch.
The union is anonymous (C11): it has no member name, so you write m->adc.mv rather than m->u.adc.mv. Anonymous structs work the same way. Event queues built from tagged unions are the core of State machines and event-driven firmware.
An enum defines a set of named integer constants:
typedef enum { LED_OFF, /* 0 */ LED_ON, /* 1 */ LED_BLINK = 5, /* explicit value */ LED_FADE, /* 6: previous + 1 */} led_mode_t;Values start at 0 and count up unless you assign them. Enums beat #define for related constants: they have a type the debugger can display by name, they are scoped by the compiler rather than the preprocessor, and they enable switch checking. Use them for states, modes, error codes and message types.
What enums do not give you:
- Type safety. In C an enum is just an integer.
led_mode_t m = 42;compiles without a warning. Validate enum values that arrive from outside before using them as table indices. - A predictable size. Enumeration constants have type
int, but the enum type itself may be any integer type able to hold all the values; the choice is implementation-defined. On x86 hosts it is 4 bytes. On Arm it depends on the ABI variant and on flags such as-fshort-enums, which uses the smallest type that fits. Mixing objects compiled with different settings is a real bug source, and the linker warns about it on Arm.
So never put an enum-typed member in a struct that defines a wire format, a flash record or an interface between separately built components. Store the value in a fixed-width integer instead, and pin the assumption with a static assertion where it matters:
#include <stdint.h>
typedef enum { STATE_IDLE, STATE_RUN, STATE_FAULT } state_t;
struct flash_record { uint32_t magic; uint8_t state; /* holds a state_t value; fixed 1-byte size */ uint8_t reserved[3];};
_Static_assert(sizeof(struct flash_record) == 8, "record layout is part of the flash format");C23 adds enums with a fixed underlying type (enum state : uint8_t { ... }), which solves the size problem once your toolchain supports it.
For switch statements over enums, -Wswitch (in -Wall) warns when a switch without default misses an enumerator, and -Wswitch-enum warns even when there is a default. Control flow that holds up in production covers when to use each.
Bitfields
Section titled “Bitfields”A bitfield member occupies a specified number of bits:
struct ctrl_bits { unsigned int enable : 1; /* 1 bit */ unsigned int mode : 2; /* 2 bits */ unsigned int : 5; /* unnamed: 5 bits of padding */ unsigned int prescale : 8; /* 8 bits */};The compiler packs these into an unsigned int and generates the masking and shifting for you. Writing c.mode = 2; becomes a read-modify-write of the containing word. It looks like the perfect way to describe a hardware register. It is not, because almost everything about the layout is implementation-defined:
- Whether the first field occupies the least or the most significant bits. GCC on little-endian Arm and x86 allocates from bit 0 upward; other compilers and big-endian targets differ.
- Whether a field that does not fit in the remaining bits of a unit straddles into the next one or starts a new unit.
- Which types are allowed. Standard C guarantees only
_Bool,signed intandunsigned int; other types are an extension. This bites in a subtle way: onarm-none-eabi,uint32_tisunsigned long, souint32_t x : 1;makes-Wpedanticwarn there (“type of bit-field is a GCC extension”) but not on x86-64 Linux, whereuint32_tisunsigned int. - Whether a plain
intbitfield is signed or unsigned. - What width of memory access the compiler uses. A one-bit field write might be compiled as a byte store, which many peripherals ignore or mishandle. (The Arm AAPCS specifies that volatile bitfields are accessed with their declared container width, and GCC follows it on Arm by default, but you are relying on an ABI rule, not on C.)
With GCC on Cortex-M, the struct above lays out like this:
bit 31 16 15 8 7 3 2 1 0 +-----------------------+-------------+-------+----+--+ | (unused) | prescale | (pad) |mode|en| +-----------------------+-------------+-------+----+--+Another compiler could legally produce the mirror image. There is also a behavioral trap: setting three fields of a volatile register bitfield is three separate read-modify-write cycles on the hardware, not one, and each may have side effects.
Firmware still meets bitfields constantly, because some vendor headers describe registers as a union of a bitfield struct and a whole word:
#include <stdint.h>
typedef union { struct { unsigned int enable : 1; unsigned int mode : 2; unsigned int : 5; unsigned int prescale : 8; unsigned int : 16; } bits; uint32_t word;} ctrl_reg_t;Such headers are written for one specific compiler and ABI, and the vendor guarantees they work there. In your own code, the portable approach is masks and shifts on a plain uint32_t, which also lets you change several fields in one write. For a hypothetical control register with the layout above:
#include <stdint.h>
#define CTRL_ENABLE (1u << 0)#define CTRL_MODE_POS 1u#define CTRL_MODE_MASK (0x3u << CTRL_MODE_POS)#define CTRL_PRESCALE_POS 8u#define CTRL_PRESCALE_MASK (0xFFu << CTRL_PRESCALE_POS)
void ctrl_configure(volatile uint32_t *reg, uint32_t mode, uint32_t prescale){ uint32_t v = *reg; /* one read */ v &= ~(CTRL_MODE_MASK | CTRL_PRESCALE_MASK); v |= ((mode << CTRL_MODE_POS) & CTRL_MODE_MASK) | ((prescale << CTRL_PRESCALE_POS) & CTRL_PRESCALE_MASK) | CTRL_ENABLE; *reg = v; /* one write */}The layout is explicit, identical on every compiler, and the register sees exactly one read and one 32-bit write. Bit manipulation develops these field operations fully, and Memory-mapped registers in C applies them to the STM32’s real registers.
Bitfields are fine, and sometimes clearer, for packing flags in RAM-only data structures that never cross a wire or touch hardware, where layout does not matter.
Comparing structs
Section titled “Comparing structs”C has no == for structs. a == b on two struct values is a compile error. The tempting workaround is memcmp:
#include <stdbool.h>#include <stdint.h>#include <string.h>
struct reading { uint8_t channel; /* offset 0, then 3 padding bytes */ uint32_t value; /* offset 4 */};
bool same_bad(const struct reading *a, const struct reading *b){ return memcmp(a, b, sizeof *a) == 0; /* also compares padding */}
bool same_good(const struct reading *a, const struct reading *b){ return a->channel == b->channel && a->value == b->value;}memcmp compares all 8 bytes, including the 3 padding bytes, and the content of padding is unspecified. For a local struct whose members were assigned one by one, the padding holds whatever was on the stack:
a (local, members assigned) b (local, members assigned) +----+----+----+----+---------+ +----+----+----+----+---------+ | 03 | 7F | 20 | 00 | 0x00064 | | 03 | 00 | 00 | 00 | 0x00064 | +----+----+----+----+---------+ +----+----+----+----+---------+ chan stack garbage value chan zero padding value
same data, memcmp says "different"Two structs with equal members compare unequal, randomly, depending on what ran before. Compare member by member, or, if you must use memcmp (for example on a large record), memset both structs to zero before filling them so the padding is known.
The same padding causes an information leak: if you send a struct’s raw bytes over a radio or write it to external flash, the padding carries stale stack contents, which may include keys or other secrets. Zero structs before filling them when their bytes leave the device.
Copying structs
Section titled “Copying structs”Struct assignment copies every byte, arrays included:
#include <stdint.h>
struct config { uint32_t baud; /* offset 0 */ uint8_t parity; /* offset 4 */ uint8_t stop_bits; /* offset 5 */ char name[8]; /* offset 6 */}; /* sizeof = 16 (2 bytes tail padding) */
void copy_example(void){ struct config a = { .baud = 9600u, .parity = 0u, .stop_bits = 1u, .name = "dbg" }; struct config b = a; /* copies all 16 bytes, including name[] */ (void)b;}This is the one way to copy an array with =: wrap it in a struct. For a small struct the compiler emits a few ldr/str pairs or an ldm/stm sequence; for a large one it calls memcpy. b = a and memcpy(&b, &a, sizeof b) produce the same result; prefer assignment because it is type-checked.
A struct copy is shallow. If the struct holds a pointer, the copy holds the same pointer, so both copies refer to the same buffer. Modifying the buffer through one is visible through the other, and if the buffer is freed through one, the other dangles.
Copies also cost time and stack. A 200-byte struct passed by value is a 200-byte memcpy onto the stack on every call. Watch for that in ISRs and deep call chains, and pass by const pointer instead.
Common mistakes
Section titled “Common mistakes”- Positional initializers on structs with many members. Silently wrong after a reorder. Use designated initializers.
- Declaring members in random order. Wastes RAM in padding, multiplied by every array element. Order by decreasing alignment and check with
sizeof. - Packing structs to save RAM. Costs code size and speed and invites misaligned pointer faults. Reorder instead; pack only for external layouts.
- Overlaying a packed struct on a big-endian protocol buffer. Multi-byte fields come out byte-swapped. Serialize with shifts or convert explicitly.
- Using an enum type inside a wire or flash format. Its size is implementation-defined. Store a fixed-width integer and assert the struct size.
- Using bitfields for hardware registers in your own code. Layout and access width are compiler-dependent. Use masks and shifts.
- Comparing structs with
memcmp. Padding makes the result random. Compare members, or zero both structs first. - Sending raw struct bytes off the device. Padding leaks stack data. Zero the struct before filling it, or serialize field by field.
Exercises
Section titled “Exercises”- Predict, then measure. For each struct, write down its size and every member offset on Cortex-M before compiling:
struct { uint8_t a; uint16_t b; uint8_t c; },struct { uint16_t a; uint8_t b[3]; uint32_t c; },struct { uint8_t a; double b; uint8_t c; }. Printsizeofandoffsetofon your host to check (the answers are the same as on the target: 6, 12, 24). Then reorder each for minimum size and verify. - Layout check that breaks the build. Write
struct sensor_framewith auint16_tid, auint8_tstatus, auint32_ttimestamp and anint16_treading, laid out sosizeofis 12 withtimestampat offset 4. Add_Static_asserts on the size and onoffsetof(struct sensor_frame, timestamp). Then movetimestampto be the first member and confirm compilation fails with your message. - Packed cost in assembly. Compile a function returning
h->lengthfrom the packedwire_hdrwitharm-none-eabi-gcc -O2 -mthumb -S, once with-mcpu=cortex-m4and once with-mcpu=cortex-m0. Count the instructions in each. You should see a singleldrfrom offset 1 for the M4 and a sequence ofldrb, shifts andorrinstructions for the M0. - Tagged union queue. Extend the message example with a
MSG_ERRORtype carrying auint16_tcode. First add only the enumerator and compile with-Wallto see the-Wswitchwarning, then add the handler. Printsizeof(msg_t)and explain the value from the member sizes. - container_of list. Write an intrusive singly linked list:
struct list_node { struct list_node *next; };,void list_push(struct list_node **head, struct list_node *n). Embed alist_nodein astruct task { const char *name; uint32_t prio; struct list_node node; }, push three tasks, and walk the list printing each task’s name usingcontainer_of. The list code must never mentionstruct task.
Check yourself
Section titled “Check yourself”Question 1:On Cortex-M, what is sizeof(struct { uint8_t a; uint32_t b; uint8_t c; })?
b needs 4-byte alignment, so 3 padding bytes follow a; c is at offset 8; tail padding rounds the size up to a multiple of 4, giving 12. Reordering to b, a, c gives 8.
Question 2:Why does memcmp(&x, &y, sizeof x) sometimes report two structs with identical members as different?
Padding inserted for alignment is not set by member assignments. For local structs it holds old stack data, so byte-wise comparison includes garbage. Compare member by member instead.
Question 3:struct blinker contains struct timer tmr at offset 8. A callback receives struct timer *t equal to 0x20000108. What does container_of(t, struct blinker, tmr) return?
container_of subtracts the member's offset from the member pointer: 0x20000108 - 8 = 0x20000100, the start of the enclosing struct blinker.
Question 4:Which of these is the strongest reason not to describe a hardware register with a bitfield struct in your own code?
The C standard leaves bitfield allocation order, unit straddling and allowed types to the implementation. Masks and shifts on a uint32_t give an explicit layout and a single read and write.
Question 5:A protocol header is overlaid with a packed struct whose uint32_t length member is at offset 1. What happens when code does uint32_t *p = &hdr->length; and passes p to a function that does *p?
Inside the struct the compiler knows length is misaligned, but a plain uint32_t * carries no such information. The callee may use instructions like LDRD or LDM that fault on misaligned addresses. GCC warns with -Waddress-of-packed-member.
Question 6:Why should a flash record store a state as uint8_t state; instead of state_t state; where state_t is an enum?
Enumeration constants are int, but the enum type's size may be 1, 2 or 4 bytes depending on the compiler and options like -fshort-enums. A fixed-width integer makes the record layout stable.
Key takeaways
Section titled “Key takeaways”- Use designated initializers for structs; unmentioned members are zero.
- Members are laid out in declaration order with padding for alignment, plus tail padding to a multiple of the struct’s alignment. Order members by decreasing alignment to minimize size, and verify with
sizeof,offsetofand_Static_assert. packedremoves padding at the cost of misaligned accesses, slower code on cores without unaligned support, and dangerous member pointers. Reserve it for external layouts.container_ofturns a pointer to an embedded member back into a pointer to the enclosing struct by subtractingoffsetof.- Unions overlay members at offset 0. Punning through a union is legal C (not C++); tagged unions carry several message types in one fixed-size object.
- Enum constants are
int, but the enum type’s size is implementation-defined: keep enums out of wire and flash formats. - Bitfield layout is implementation-defined. Use masks and shifts for registers and protocols, and never compare structs with
memcmpunless the padding has been zeroed.
Next: Bit manipulation turns the masks and shifts used here into a complete toolkit: field extraction and insertion, atomic set and reset registers, endianness, and portable serialization.