Volume 08 Beginner 5 sub-modules ~15 min read

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.

You will learn
  • 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
You need

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               */
Remember

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.

Quick check

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
The same three members in two orders, and the padding each layout needs struct badly_ordered - 12 bytes struct well_ordered - 8 bytes 0123 4567 891011 flag padding count mode padding six bytes of data, six bytes wasted 0123 4567 count flag mode padding six bytes of data, two bytes of tail padding Same members, same data, four bytes saved - just by ordering them largest first.
Figure 8.1 - Both structs hold a uint32_t and two uint8_t members - six bytes of data. In the first, the 32-bit count may only start at offset 4, so three bytes are wasted before it and three more at the end. Putting the largest member first leaves one small gap, and the struct is a third smaller.

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
Remember

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.

Quick check

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.

What packing costs
  • Slower access. value now 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.
Common mistake

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];
Quick check

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.

A union of a 32-bit word and four bytes, sharing the same four bytes of memory union word_bytes { uint32_t word; uint8_t byte[4]; }; word = 0x12345678 0x78 0x56 0x34 0x12 byte[0] byte[1] byte[2] byte[3] read as a word read as bytes Not two copies: one block of four bytes, with two names for it. sizeof(union) = 4 - the size of its largest member
Figure 8.2 - One union, four bytes. Writing the word fills all four bytes; reading the byte array reads those same bytes back. Changing byte[0] therefore changes the bottom of the word - on this little-endian machine, 0x12345678 became 0x123456FF.

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.

Common mistake

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.

Quick check

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

Common mistake

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.

Quick check

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

Key words from this volume

Every word below has a plain-English entry in the glossary.

Practice

Practice 1

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.

Practice 2

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 _COUNT member, 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.
Practice 3

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 a is 4 bytes. Two 16-bit members, each already aligned, no padding needed.
  • union b is 8 bytes. The largest member is the 6-byte array, but the union must also be aligned for its uint32_t member, so its size is rounded up to the next multiple of 4.
  • struct c is 6 bytes. The flag takes one byte, one byte of padding follows so that inner starts at an even address, and inner takes 4.

The second one catches people out: a union's size is rounded up to suit its alignment, just as a struct's is.

Practice 4

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

Interview question 1

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."

Interview question 2

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.