Structs, Unions and Enums
A struct groups values that belong together - a reading, a message, a device. It is the closest C gets to an object, and on a chip the way you lay one out decides how much RAM it takes. This volume covers structs, the padding hidden inside them, packed layouts for data on a wire, unions and enums.
- How to declare, initialise and pass a struct without copying it
- Why the compiler leaves holes between members, and how ordering removes them
- When a packed struct is the right answer, and what it costs
- How a union lets one block of memory be read two ways
- How enums and typedef make states and modes readable
8.1 Structs
A struct groups values that belong together under one name. A reading, a message, a device: one thing, several parts.
struct reading {
uint16_t millivolts;
int16_t celsius_x100;
uint8_t channel;
uint8_t valid;
};
struct reading a = { 1650u, 2345, 0u, 1u }; /* in order */
struct reading b = { .millivolts = 800u, /* by name: clearer, and safer */
.celsius_x100 = -250,
.channel = 1u,
.valid = 1u };
Reach a member with a dot, or with an arrow when you have a pointer:
a.channel = 2u; /* through the struct itself */
p->channel = 2u; /* through a pointer to it */
(*p).channel = 2u; /* the same thing, written out */
two readings:
channel 0: 1650 mV, 23.45 C, valid
channel 1: 800 mV, -2.50 C, valid
a.channel is 0, and through a pointer it is p->channel
after copying and changing the copy: original 1650, copy 999
the table holds 3 readings and takes 18 bytes
nested: set.first.channel = 0, taken at 12345 ms
Pass by address, not by value
Assigning a struct copies every member, which is why the copy changed while the original did not. That is occasionally what you want, and usually not - a 60-byte struct copied into a function is 60 bytes of stack and 60 bytes of copying.
void print_reading(const struct reading *r); /* reads it, will not change it */
void update(struct reading *r); /* will change it */
Named initialisers - .channel = 1u - are worth the extra typing. They survive someone adding a
member in the middle, they read like the datasheet, and anything you leave out is set to zero.
You have struct reading *p. How do you read its channel member?
Show the answer
Answer: B. The arrow follows the pointer and then takes the member. (*p).channel is the same thing written
out; *p.channel does not work, because the dot binds tighter than the star.
8.2 Padding and alignment
A struct is usually bigger than its members added up. The compiler inserts padding so that every member starts at an address its type allows.
struct badly_ordered takes 12 bytes
flag at offset 0
count at offset 4 <- three bytes were skipped to reach it
mode at offset 8
struct well_ordered takes 8 bytes
count at offset 0
flag at offset 4
mode at offset 5
the members add up to 6 bytes in both cases
ordering the members largest first saved 4 bytes
Why the holes exist
Most processors require a uint32_t to start at an address divisible by 4. That is
alignment. On Arm, reading a misaligned word is either slow or a fault,
so the compiler lines things up for you:
a uint32_t must start at an address divisible by 4
a uint16_t must start at an address divisible by 2
a uint8_t may start anywhere: 1
Declare members largest first. It costs nothing and needs no special keywords. On a chip with a few kilobytes of RAM, a table of 200 structs at 12 bytes instead of 8 wastes 800 bytes you did not have to spend.
Going deeper: offsetof and the tail
offsetof(struct x, member) from <stddef.h> tells you exactly where a member sits, which is the
quickest way to see padding. Note the padding at the *end* too: a struct's size is rounded up so
that an array of them keeps every element aligned. That is why well_ordered is 8 bytes and not 6.
struct { uint8_t a; uint32_t b; uint8_t c; }; takes 12 bytes. How would you make it smaller without removing anything?
Show the answer
Answer: C. Putting the 4-byte member first lets the two single bytes follow it with no gap, giving 8 bytes including the tail padding. It is the same data in a better order.
8.3 Packed structs
A packed struct has no padding at all. It matches a wire format exactly, and that is the only reason to use one.
struct __attribute__((packed)) message {
uint8_t type;
uint32_t value;
uint8_t checksum;
};
packed: 6 bytes
padded: 12 bytes
from the wire: type 0x01, value 0x12345678, checksum 0xA5
the value came out little-endian, because this machine is
built by hand from the same bytes: 0x12345678
Six bytes instead of twelve - exactly the six that arrive over the link.
- Slower access.
valuenow starts at offset 1, so the processor cannot load it in one go. The compiler emits byte-by-byte loads instead. - A fault, on some cores. Older Arm cores and many other chips fault on a misaligned word access rather than working around it.
- It is not standard C.
__attribute__((packed))is a gcc and clang extension; other toolchains use#pragma pack. - Endianness is still yours to handle. The struct maps the bytes in memory order, so a message from a big-endian sender comes out reversed.
Taking the address of a member of a packed struct. &m.value is a pointer that may be misaligned.
Passing it to something that expects a normal uint32_t * is undefined behaviour. Copy the member
into a local variable first.
The safer alternative
For anything crossing a wire, taking the bytes apart yourself is portable, endian-safe, and needs no extensions:
uint32_t value = ((uint32_t)wire[4] << 24) | ((uint32_t)wire[3] << 16) |
((uint32_t)wire[2] << 8) | (uint32_t)wire[1];
When is a packed struct the right tool?
Show the answer
Answer: A. Packing exists to match a layout defined outside your program. It makes access slower, not faster, and it can fault on cores that cannot do unaligned reads.
8.4 Unions
A union gives all its members the same memory. Only one of them holds a meaningful value at a time, and the union is as big as its largest member.
one union, 4 bytes: the word and the four bytes share them
word 0x12345678 reads as bytes 78 56 34 12
after setting byte[0] to 0xFF, the word is 0x123456FF
three messages through one struct of 12 bytes:
temperature 23.45 C
switch is on
text "ready"
The pattern firmware actually uses
A union with a tag beside it - one variable saying which member is in use:
enum msg_kind { MSG_TEMPERATURE, MSG_SWITCH, MSG_TEXT };
struct message {
enum msg_kind kind;
union {
int16_t temperature_x100;
uint8_t switch_state;
char text[8];
} body;
};
One struct carries any of the three messages, and takes only as much room as the largest. The
switch on kind decides which member to read - and reading the wrong one gives you the bytes of
whatever was written last.
Writing one member and reading another to convert types - a float written, then read as a
uint32_t. It is a very common trick, and it is formally undefined behaviour in
C, although most compilers do what you expect. Using memcpy between the two variables says the
same thing, is always defined, and compiles to the same instructions with optimisation on.
union { uint32_t w; uint8_t b[4]; uint16_t h[2]; } u; How big is it?
Show the answer
Answer: C. A union is the size of its largest member, because they all share the same memory. Every member here is four bytes or less, so the union is 4 bytes.
8.5 Enums and typedef
An enum gives names to a short list of numbers. typedef gives a type a shorter name. Together they make firmware read like the thing it controls.
enum state { STATE_IDLE, STATE_RUNNING, STATE_ERROR, STATE_COUNT }; /* 0, 1, 2, 3 */
enum reg_addr { REG_CTRL = 0x00, REG_STATUS = 0x04, REG_DATA = 0x08 }; /* match the datasheet */
typedef enum { LED_OFF, LED_ON, LED_BLINK } led_mode_t; /* a short type name */
STATE_IDLE=0 RUNNING=1 ERROR=2, and STATE_COUNT=3 says how many
state 0 is called idle
state 1 is called running
state 2 is called error
register offsets: CTRL 0x00, STATUS 0x04, DATA 0x08
led on pin 13, mode 2
sizeof(led_mode_t) = 4 bytes on this compiler
sizeof(led_t) = 8 bytes
Three habits worth copying
- A
_COUNTmember at the end. It is automatically one more than the last real value, so array sizes and loop limits follow the enum around. - Set the values when they mean something. Register offsets, protocol codes and command bytes should be written down, not left to counting.
switchover an enum, with nodefaultfor the real cases. Then the compiler warns when a new value is added and some switch has not been updated - one of the most useful warnings there is.
Assuming an enum is one byte. This compiler made it 4 bytes, because it uses int unless told
otherwise. In a struct held in RAM in quantity, or a struct that has to match a wire format, use an
explicit uint8_t field and keep the enum for the names.
Going deeper: enum or #define?
Both give a name to a number. An enum is visible to the debugger, is grouped as a type, gets
switch-coverage warnings, and obeys scope. A #define is a blunt text replacement that the
compiler never sees, but it works for anything - strings, expressions, bit masks wider than an int.
Firmware uses enums for states and modes, and #define for masks and hardware addresses. Volume 15
covers the preprocessor properly.
Why do many enums end with an extra member such as STATE_COUNT?
Show the answer
Answer: B. Values count up from 0, so a final member lands on the number of real values above it. Add a new
state in the middle and every [STATE_COUNT] array and i < STATE_COUNT loop updates itself.
What you learned
- A struct groups values; a dot reaches a member, an arrow reaches one through a pointer.
- Assigning a struct copies every member, so functions take a pointer - const when reading.
- The compiler inserts padding so each member starts at an address its type allows.
- Ordering members largest first often shrinks a struct with no other change.
- A packed struct removes the padding to match a wire format, at the cost of slower access.
- A union gives its members one shared block, sized by the largest, and needs a tag beside it.
- Enums name a list of numbers; a _COUNT member keeps loops and arrays in step.
- An enum is usually four bytes, not one.
Key words from this volume
Every word below has a plain-English entry in the glossary.
Practice
Shrink the struct
How many bytes does this take on a 32-bit chip, and how would you make it smaller?
struct device {
uint8_t id;
uint32_t serial;
uint8_t state;
uint16_t voltage_mv;
};
Show the solution
As written it takes 12 bytes. The compiler puts id at offset 0, then three bytes of padding so that serial can start at 4. The state member lands at 8, one byte of padding follows, and voltage sits at 10. That reaches 12, which is already a multiple of four, so no tail padding is needed.
Order the members largest first:
struct device {
uint32_t serial; /* offset 0 */
uint16_t voltage_mv; /* offset 4 */
uint8_t id; /* offset 6 */
uint8_t state; /* offset 7 */
};
Now everything lands on an address it likes with no gaps at all: 8 bytes, half the size. For a table of 100 devices that is 800 bytes of RAM saved by moving four lines.
Choose the tool
Say whether you would use a struct, a packed struct, a union or an enum for each of these. The current mode of a state machine. A sensor reading with a value and a timestamp. A 6-byte message arriving over a radio link. One buffer that holds either a temperature or a text message.
Show the solution
- Current mode: an enum. Named states, a
_COUNTmember, and switch-coverage warnings. - Reading with value and timestamp: a plain struct. Nothing outside sees the layout, so let the compiler order and pad it. Declare the timestamp first to avoid a gap.
- 6-byte radio message: neither, ideally. A packed struct works, but taking the bytes apart with shifts is portable and endian-safe. If you do pack it, write down which endianness the sender uses.
- Either a temperature or text: a union with a tag, exactly like the message struct in this volume.
Predict the sizes
On a machine where int is 4 bytes, what is sizeof each of these?
struct a { uint16_t x; uint16_t y; };
union b { uint8_t bytes[6]; uint32_t word; };
struct c { uint8_t flag; struct a inner; };
Show the solution
struct ais 4 bytes. Two 16-bit members, each already aligned, no padding needed.union bis 8 bytes. The largest member is the 6-byte array, but the union must also be aligned for itsuint32_tmember, so its size is rounded up to the next multiple of 4.struct cis 6 bytes. The flag takes one byte, one byte of padding follows so thatinnerstarts at an even address, andinnertakes 4.
The second one catches people out: a union's size is rounded up to suit its alignment, just as a struct's is.
Write the tagged union
Design a struct that can carry either a 32-bit counter value or a 4-character name, and a function that prints whichever it holds.
Show the solution
enum item_kind { ITEM_COUNT, ITEM_NAME };
struct item {
enum item_kind kind;
union {
uint32_t count;
char name[5]; /* four letters and a terminator */
} body;
};
void print_item(const struct item *it)
{
switch (it->kind) {
case ITEM_COUNT: printf("count %u\n", it->body.count); break;
case ITEM_NAME: printf("name \"%s\"\n", it->body.name); break;
default: printf("unknown\n"); break;
}
}
The tag is what makes it safe: never read a member other than the one the tag names. And note the 5-byte name array - four characters plus the terminator, as Volume 06 insisted.
Interview corner
Why is my struct bigger than I expected?
"A struct with a uint8_t, a uint32_t and a uint8_t comes out at 12 bytes. Explain."
Show the solution
"Padding. The uint32_t has to start at an address divisible by four, so the compiler inserts three bytes after the first uint8_t. The last uint8_t then sits at offset 8, and the struct is rounded up to 12 so that an array of them keeps every element aligned. Reordering the members largest first brings it down to 8 bytes with no other change. If the layout has to match something external I would pack it instead, and accept the slower access."
Struct or union?
"When would you use a union in firmware?"
Show the solution
"When one block of memory has to be read in more than one shape, and only one at a time. The usual case is a tagged union: a message type carrying any one of several payloads, with an enum saying which. It saves RAM because the struct is only as big as the largest payload. The other common use is reinterpreting bytes, such as a word as four bytes. I would rather use memcpy there, because reading a member other than the one last written is formally undefined."
Next, Volume 09 leaves the language for a moment and looks at the chip itself. Flash and RAM, the sections your program is cut into, what runs before main, and how to read a map file.