Structs, unions and bit-fields
Describing register blocks and communication packets with structs, and why alignment and padding appear.
Structs
A struct groups related values. MCU header files declare a peripheral's registers as struct members in address order and point a pointer to that struct at the peripheral's base address. TIM2->CNT works exactly this way.
Alignment and padding
A 32-bit CPU wants a 4-byte value at an address that is a multiple of 4. So the compiler inserts empty bytes between members.
struct { uint8_t a; uint32_t b; }; // sizeof is 8, not 5
Send a struct as a packet as-is and those empty bytes go out too, and the other side misreads it. Serialise packets byte by byte, or use the packed attribute knowing the cost of unaligned access.
Unions
A union views the same memory as several types. It is often used to split a float into a 4-byte array for sending.
Bit-fields
uint32_t en : 1; divides members at the bit level. It reads well, but bit order differs between compilers, so masks and shifts are the safer way to define registers and communication formats.
A struct is bigger than you think
struct a { uint8_t flag; uint32_t value; uint8_t id; }; // 12 bytes
struct b { uint32_t value; uint8_t flag; uint8_t id; }; // 8 bytes
The compiler inserts gaps (padding) so that each member lands on an address that is a multiple of its size.
Declaring the largest members first reduces padding. For a struct you make hundreds of in an array, the difference is RAM.
Using a struct directly as a communication packet
typedef struct __attribute__((packed)) {
uint8_t header;
uint16_t length;
uint32_t timestamp;
} packet_t; // exactly 7 bytes, no padding
With packed the padding disappears and the struct can be overlaid on the receive buffer. Two things need care.
- Unaligned access: you end up reading a
uint32_tat an odd address. On cores that do not support it, such as Cortex-M0, this faults. - Byte order: if sender and receiver differ in endianness, the value is reversed. State the byte order in the protocol document and, to be safe, assemble values one byte at a time.
uint16_t len = (uint16_t)buf[1] | ((uint16_t)buf[2] << 8); // assembled as little-endian
Unions: two views of the same memory
typedef union {
uint32_t word;
uint8_t byte[4];
} u32_t;
u32_t v; v.word = 0x12345678;
// little-endian MCU: byte[0] = 0x78, byte[3] = 0x12
Convenient for splitting a 32-bit value into bytes to send. The result depends on endianness, though, so for data exchanged with a different kind of MCU it is safer to split it yourself with shifts.
Bit-fields: convenient but not portable
typedef struct {
uint32_t enable : 1;
uint32_t mode : 2;
uint32_t : 5; // reserved
uint32_t prescaler : 8;
} ctrl_t;
The code is easier to read, but the compiler decides which end the bits are filled from. The same declaration can mean different bit positions on another compiler. The usual rule is therefore masks and shifts where bit positions are fixed, such as hardware registers and packets, and bit-fields only for groups of flags inside the program.
Passing a struct to a function
Passed by value, the whole struct is copied onto the stack. Passed by pointer, only 4 bytes travel. If the function only reads, add const to show the intent.
void motor_update(const motor_cfg_t *cfg, motor_state_t *st);
Check yourself
- How many bytes is
struct { uint8_t a; uint16_t b; uint8_t c; }? How small can it get if you reorder it? - Code checks the received length against
sizeof(packet_t). What happens ifpackedis removed?