Embedded C Idioms
Embedded C is the same language as any other C. What differs is which of its underspecified corners you are standing on. On a desktop, int is 32 bits, structs are laid out the way you expect, unaligned access works, and the byte order matches whatever produced the file. In firmware you are parsing a protocol written by someone else's compiler, laying a struct over a hardware register, and running on a part where int might be 16 bits and an unaligned load might be a fault.
The mental model: an embedded idiom is a way of writing something so that the thing the standard leaves open cannot hurt you. Every entry below is a small piece of syntax that trades a little verbosity for the removal of an entire failure mode โ usually one that is invisible until the code is ported, the compiler is upgraded, or the optimiser gets smarter.
Register-Level Programming covers memory-mapped I/O and the field-clear-then-set idiom, which the bitfield section here argues against replacing with language features. Integers and Two's Complement owns integer representation, promotion and overflow โ the mechanics under several of the traps below. What volatile Does and Does Not Do is a prerequisite for anything touching hardware.
The idioms and what each one preventsโ
| Idiom | Instead of | The mistake it prevents |
|---|---|---|
uint32_t, int16_t from <stdint.h> | int, long, unsigned | A type whose width changes with the target. int is 16 bits on many compilers for 8- and 16-bit parts; long is 32 on ARM and 64 on x86-64 |
static on every non-exported function and file-scope variable | External linkage by default | Name collisions at link time, and an optimiser that cannot prove a function is only called from one place |
const after the thing it protects: uint32_t * const p | Guessing which side | Making the pointer read-only when you meant the target, or vice versa |
sizeof x / sizeof arr / sizeof arr[0] | A hardcoded length | The buffer overrun that appears the day someone changes the array size and misses one of the two places |
Designated initialisers: .baud = 115200 | Positional initialisation | Silently shifting every field when a member is inserted into the struct |
_Static_assert on every layout assumption | A comment | Discovering at runtime, on hardware, that the struct is not the size the protocol says |
memcpy into a local, or byte-wise assembly, for wire data | Casting a pointer to a packed struct | An unaligned or wrong-endian read that works on one target and faults or corrupts on another |
| Explicit shift-and-mask for hardware registers | Bitfield structs | The compiler choosing a byte-wide access to a 32-bit register, or ordering fields the other way round |
U suffix on unsigned constants: 1U << 31 | 1 << 31 | Undefined behaviour โ shifting into the sign bit of a signed int |
Braces on every if/while, even one-liners | Bare statements | The second statement someone adds later that is not actually in the branch |
enum for state, switch with no default that falls off | Magic numbers | An unhandled state that the compiler could have warned about under -Wswitch |
The rest of this page is the four that are genuinely subtle.
Packed structs and alignmentโ
The temptation with a wire protocol is irresistible: describe the packet as a struct, cast the receive buffer to it, read the fields.
typedef struct __attribute__((packed)) {
uint8_t id;
uint32_t value; /* at offset 1 โ not 4-byte aligned */
uint16_t crc;
} msg_t;
uint32_t read_packed(const msg_t *m) { return m->value; }
Whether this works depends entirely on the target, and here is what the same source produces on two Cortex-M parts with the same compiler:
- Cortex-M4 (Armv7-M)
- Cortex-M0 (Armv6-M)
read_packed:
ldr.w r0, [r0, #1] @ one unaligned word load. Works.
bx lr
Armv7-M supports unaligned LDR/STR in hardware, so the compiler emits the obvious instruction and it is fast.
read_packed:
ldrb r2, [r0, #2]
ldrb r3, [r0, #1]
lsls r2, r2, #8
orrs r2, r3
ldrb r3, [r0, #3]
ldrb r0, [r0, #4]
lsls r3, r3, #16
orrs r3, r2
lsls r0, r0, #24
orrs r0, r3
bx lr
Armv6-M has no unaligned access at all, so the compiler synthesises the load from four byte reads and six ALU operations. Eleven instructions instead of one โ correct, but a factor of ten, in a parser that may run per byte.
Both: GCC 14.2.Rel1, -Os -mthumb, -mcpu=cortex-m4 and -mcpu=cortex-m0.
So __attribute__((packed)) is safe โ the compiler knows the member is unaligned and does whatever the target requires. What is not safe is the version people actually write:
uint32_t value = *(uint32_t *)(buf + 1); /* โ undefined behaviour */
Here the compiler has no idea the pointer is unaligned. It emits a plain aligned load, which:
- works on Cortex-M4 by luck, until someone sets
SCB->CCR.UNALIGN_TRPto catch exactly this, at which point it is a UsageFault; - HardFaults immediately on Cortex-M0/M0+, where unaligned
LDRis not merely slow but architecturally invalid; - can be miscompiled even on M4, because the compiler is entitled to assume the pointer is aligned and may transform the surrounding code accordingly โ for example by using
LDMorLDRD, which do not support unaligned access even on Armv7-M.
The portable idiom is memcpy, which every compiler recognises and lowers to whatever the target can actually do:
uint32_t value;
memcpy(&value, buf + 1, sizeof value); /* correct everywhere, free at -O1+ */
And the assumption you made about the layout gets written down where it can fail loudly:
_Static_assert(sizeof(msg_t) == 7, "protocol says 7 bytes on the wire");
_Static_assert(offsetof(msg_t, crc) == 5, "crc must follow value immediately");
Without packed the same struct is 12 bytes on ARM, not 7 โ the compiler inserts three padding bytes before value (to 4-align it at offset 4) and two after crc (to round the struct up to a multiple of its 4-byte alignment). Sending it over a wire, writing it to flash, or comparing it with memcmp are all then wrong in a way no test on a single target will reveal. The _Static_assert costs nothing and fails at compile time on the day the layout changes.
Bitfields are not for hardware registersโ
A struct of bitfields looks like the perfect description of a peripheral register:
typedef struct { uint32_t a : 3; uint32_t b : 5; uint32_t c : 24; } bits_t;
void set_b(bits_t *p) { p->b = 5; }
Here is what GCC 14.2 actually generates for set_b on Cortex-M4 at -Os:
set_b:
ldrb r3, [r0, #0] @ โ an 8-BIT read of a 32-bit register
movs r2, #5
bfi r3, r2, #3, #5
strb r3, [r0, #0] @ โ an 8-BIT write
bx lr
The compiler correctly noticed that field b lives entirely within the first byte, so it used a byte access. That is a perfectly legal optimisation on memory. On a peripheral it can be a disaster: a great many hardware registers are documented as 32-bit access only, and a byte write to them is ignored, or writes the wrong thing, or generates a bus fault. Nothing in the C source says "this must be one 32-bit store".
volatile, and that matters hereA real register would be, and on ARM that changes this particular listing. The Arm EABI mandates that a volatile bitfield is accessed using the width of its declared container type, which GCC implements as -fstrict-volatile-bitfields โ on by default for ARM targets. Declaring the same three fields volatile uint32_t and recompiling gives:
set_vb:
ldr r3, [r0, #0] @ full 32-bit read โ the ARM EABI rule
movs r2, #5
bfi r3, r2, #3, #5
str r3, [r0, #0] @ full 32-bit write
bx lr
GCC 14.2.Rel1, -Os -mcpu=cortex-m4 -mthumb, with and without -fno-strict-volatile-bitfields โ identical output in this case.
So on ARM GCC with volatile, the access-width objection does not bite. It bites on targets or toolchains without that ABI rule, on compilers that do not implement it, and if the flag is ever turned off โ and it depends on an ABI guarantee rather than on anything the C standard promises, which is a thin thing to build a register map on.
The width objection is therefore the least portable of the five, not the strongest. Each of the four below is independently sufficient, and all four apply even in the volatile ARM case above โ note that the listing is still a ldrโฆstr read-modify-write, not one atomic operation.
The remaining objections:
- Bit order within a unit is implementation-defined. C17 ยง6.7.2.1ยถ11: whether the first declared field occupies the low-order or high-order bits is up to the implementation. GCC on ARM puts it low; another compiler may not. Your register map is then silently mirrored.
- The allocation unit and padding are implementation-defined too (ยง6.7.2.1ยถ11). Whether a field that would straddle a boundary is split or moved is not specified.
- A field is not atomic.
p->b = 5above is a read-modify-write, with all the consequences from Critical Sections and Atomicity. - Read-sensitive registers break. A read-modify-write on a status register clears flags you never intended to touch โ see Register-Level Programming.
The idiom that has none of these problems is the one CMSIS device headers use throughout: a volatile uint32_t member and named shift/mask macros.
#define USART_CR1_OVER8_Pos (15U)
#define USART_CR1_OVER8_Msk (0x1UL << USART_CR1_OVER8_Pos)
USART1->CR1 = (USART1->CR1 & ~USART_CR1_OVER8_Msk)
| (1UL << USART_CR1_OVER8_Pos); /* exactly one 32-bit store */
Verbose, explicit, one access of the documented width, and portable. Bitfields remain a perfectly good tool for internal data structures where you are packing your own state to save RAM and no hardware is watching.
Endianness, and the only way to parse a protocolโ
The STM32 is little-endian. So is every Cortex-M in practice. That is precisely why endianness bugs in firmware survive so long: the code works on your target and fails against the peer, the file format, or the network โ all of which are frequently big-endian.
The rule is that the wire has an endianness and your CPU has an endianness, and the only place they may meet is in explicit code. Not in a cast, not in a union, not in a memcpy of a multi-byte field.
/* Correct on every machine, because it never depends on host layout. */
static uint32_t be32(const uint8_t *p)
{
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16)
| ((uint32_t)p[2] << 8) | (uint32_t)p[3];
}
static void put_be32(uint8_t *p, uint32_t v)
{
p[0] = (uint8_t)(v >> 24); p[1] = (uint8_t)(v >> 16);
p[2] = (uint8_t)(v >> 8); p[3] = (uint8_t)v;
}
Note the casts on the way in. Without (uint32_t), p[0] is a uint8_t that gets promoted to int, and p[0] << 24 shifts a value into the sign bit of a 32-bit signed int โ undefined behaviour, and on a 16-bit int target it is a guaranteed zero. This is the integer-promotion trap and it turns up everywhere in byte-fiddling code; Integers and Two's Complement has the general rules.
Do not reach for a union to do this. Type-punning through a union is defined in C but is reading the host's byte order, which is exactly the dependency you are trying to remove. And do not reach for htonl unless you know where it came from โ it is a POSIX networking function, not a C one, and the version in your vendor stack may or may not be a no-op.
At -O2 GCC recognises the shift-and-or pattern above and compiles it to a single REV instruction on Armv7-M, so the portable version costs nothing. Write the portable one.
Designated initialisers for configuration tablesโ
Firmware is full of tables: pin maps, baud-rate tables, task lists, register initialisation sequences. Positional initialisation of these is a latent bug that detonates on an unrelated edit.
/* Positional: correct today. */
static const pin_cfg_t pins[] = {
{ GPIOA, 5, MODE_OUTPUT, PULL_NONE, SPEED_LOW },
{ GPIOC, 13, MODE_INPUT, PULL_UP, SPEED_LOW },
};
Insert a drive_type member into the middle of pin_cfg_t and every row silently shifts: MODE_OUTPUT becomes the drive type, PULL_NONE becomes the mode. The types are all small integers or enums, so nothing warns, and the failure is a pin configured as an input when it should drive a MOSFET.
/* Designated: survives the edit, and reads as documentation. */
static const pin_cfg_t pins[] = {
{ .port = GPIOA, .pin = 5, .mode = MODE_OUTPUT, .pull = PULL_NONE },
{ .port = GPIOC, .pin = 13, .mode = MODE_INPUT, .pull = PULL_UP },
};
Members not mentioned are zero-initialised, which is why the enums in such a table should be defined with the safe default as zero โ MODE_INPUT = 0, PULL_NONE = 0 โ so that an omitted field fails safe rather than into an output driving a bus.
The same technique makes vector tables and command dispatch tables robust:
static void (* const commands[CMD_COUNT])(const char *) = {
[CMD_STATUS] = cmd_status,
[CMD_RESET] = cmd_reset,
[CMD_DUMP] = cmd_dump,
};
Adding a command to the enum can no longer renumber the others, and any slot you forget is NULL โ which you can check for โ rather than pointing at the wrong function.
And const on the table is not decoration: it puts the array in .rodata, which the linker places in flash. On a 128 KB-RAM part, moving a few kilobytes of configuration tables out of .data is often the single largest RAM saving available. Memory Sections covers where each qualifier sends your data.
The most expensive idiom failure is the one that spans two devices, because neither one is wrong on its own.
A sensor node and a gateway share a header file defining the message struct. The node is a Cortex-M0+ built with one vendor's compiler; the gateway is a Cortex-M4 built with another. The struct has a uint8_t followed by a uint32_t. Nobody wrote packed, so both compilers insert padding โ but they are entitled to make different choices about how much and where, and even with identical padding, the sender writes the struct's bytes including its uninitialised padding bytes. Those bytes are whatever was on the stack. The CRC computed over sizeof(msg_t) therefore covers three bytes of garbage and fails intermittently, at a rate that depends on what the sender was doing before it built the message.
The team spends a week on the radio link. The radio is fine.
The same family of bug, with a different trigger each time:
- A compiler upgrade changes padding or bitfield allocation, and a firmware image can no longer read the configuration blob written to flash by its predecessor. Every device that takes the update loses its calibration.
sizeofis used as the wire length. It is the in-memory size, and it includes padding. The wire length is a constant from the protocol specification, and the two agreeing is an assertion, not an assumption.- A field is added to the end of a persisted struct, and old records are now short. Without a version byte as the first member, there is no way to tell.
Three habits, each about ten seconds of typing:
_Static_assert(sizeof(msg_t) == 7, "wire size")next to every struct that crosses a boundary โ a wire, a flash sector, a shared-memory region, an API to code built separately. The check runs at compile time on every build, on both sides.- Serialise field by field. A
msg_to_bytes()function usingput_be32and friends is twenty lines that make layout, order and endianness explicit and immune to every compiler decision above. It is the boring answer, and it is the one that does not fail. - Version the first byte of anything persisted. Then the reader can refuse, migrate, or default โ instead of interpreting the new layout with the old code.
See alsoโ
- Register-Level Programming โ the shift-and-mask register idiom that this page argues against replacing with bitfields, and the read-modify-write rules for status registers.
- Critical Sections and Atomicity โ why a bitfield assignment being a read-modify-write matters when an ISR touches the same register.
- Memory Sections โ where
const,staticand initialised data actually land, and the flash-versus-RAM consequence of each. - Integers and Two's Complement โ integer promotion, signedness and overflow: the mechanics behind the
1U << 31andp[0] << 24traps above. - Stack Usage and Overflow โ why "pass a pointer, not a struct by value" is a stack-budget idiom as well as a performance one.
Referencesโ
- ISO/IEC โ 9899:2018 (C17). ยง6.7.2.1ยถ11 for bitfields: the implementation-defined allocation order within a storage unit and the implementation-defined addressable allocation unit โ the two clauses that make bitfield register maps non-portable; ยง6.3.1.1 for the integer promotions behind the
p[0] << 24trap; ยง6.5.7 for shift behaviour, including the undefined result of shifting into the sign bit; ยง6.7.9 for designated initialisers and the zero-initialisation of omitted members. The freely available N2310 working draft tracks the published text closely. - Carnegie Mellon University SEI โ CERT C Coding Standard. EXP36-C (do not cast pointers to more strictly aligned types) is the rule the
*(uint32_t *)(buf + 1)example violates; INT13-C on bitwise operators and unsigned operands; INT34-C on shifts; EXP11-C on type-punning; and the "Bit Manipulation" recommendations covering theUsuffix idiom. - MISRA โ MISRA C:2012, third edition, first revision. Directive 4.6 (use typedefs indicating size and signedness), Rule 6.1 and 6.2 on permitted bitfield types, Rule 8.7 and 8.8 on internal linkage and
static, Rule 10.x on the essential type model, and Rule 15.6 on compound statements โ the codified forms of most of the table above. - Free Software Foundation โ GCC manual, "Common Type Attributes" and "Structures, Unions, Enumerations, and Bit-Fields Implementation".
packedandaligned, and GCC's documented answers to every question C17 ยง6.7.2.1 leaves implementation-defined โ which is the only reason the bitfield layout on your target is predictable at all. See also-fstrict-volatile-bitfieldsin "Options for Code Generation Conventions": the flag that makes avolatilebitfield be accessed at the width of its declared type, enabled by default where the target ABI requires it, as the Arm EABI does. - Arm โ Armv7-M Architecture Reference Manual (DDI 0403), ยงA3.2 "Alignment support". Which instructions support unaligned access (
LDR,LDRHand their store forms) and which never do (LDM,STM,LDRD,STRD, and all exclusive accesses);CCR.UNALIGN_TRPfor turning permitted unaligned accesses into UsageFaults. The Armv6-M manual's corresponding section states that unaligned access is not supported at all, which is what the Cortex-M0 listing above reflects.
Instruction listings on this page were produced with Arm GNU Toolchain 14.2.Rel1 (GCC 14.2.1) at -Os -mthumb, targeting -mcpu=cortex-m4 and -mcpu=cortex-m0.