RayBench EmbeddedInteractive engineering labs
EMBEDDED C

User-Defined Types

Declare your own types in embedded C: struct, union, enum, typedef, bitfields, designated initialisers, flexible array members and opaque handles.

Reviewed 2026-08-225,114 words

Declaring Your Own Types: struct, union, enum and typedef

C gives you four tools for building types of your own, and using them well is most of what separates readable firmware from a wall of integers. A struct groups values that belong together, so a function takes one parameter instead of six and cannot get their order wrong. A union overlays several interpretations on the same storage, which is how a register is viewed as both a word and a set of fields. An enum names a small set of related constants, so a state machine's states are self-describing rather than magic numbers. A typedef gives an existing type a new name, which is either a considerable clarification or a way of hiding exactly the information a reader needs. All four are declarations rather than code - they allocate nothing and generate no instructions - and the mistakes people make with them are almost all about that distinction.

How it is built

  • A struct declaration describes a shape; it does not create anything. Writing struct point { int x, y; }; introduces a type and no storage. Storage appears only when you declare an object of that type. Confusing the two is why a header that defines a struct is fine to include everywhere, while a header that defines a variable is not.
  • Struct tags live in a separate namespace from ordinary identifiers, which is why plain C requires you to write struct point p rather than point p. That separate namespace is also why struct point and a variable called point can coexist without conflict, and it is the single most common source of early confusion.
  • A typedef removes the need for the tag keyword by giving the type an ordinary-namespace name. The common idiom declares both at once, keeping the tag so the type can refer to itself - which a linked list node must do, because the typedef name does not exist yet at the point the member is declared.
  • Members are laid out in declaration order with padding inserted so each meets its alignment requirement, and the struct's total size is rounded up to a multiple of its strictest member's alignment. Ordering members from largest alignment to smallest frequently removes the padding entirely, which on a part with kilobytes of RAM is worth doing deliberately.
  • Designated initialisers let you initialise by member name rather than position. This matters more than it looks: a positional initialiser silently becomes wrong when someone adds a member in the middle, whereas a designated one keeps working, and any member you do not mention is zero-initialised.
  • A union gives every member the same starting address, so writing one and reading another reinterprets the bytes. The size is that of the largest member. This is the natural way to overlay a register as a word and as named fields, and the natural way to hold one of several message payloads.
  • Reading a union member other than the one last written is implementation-defined in C rather than undefined, and every mainstream compiler documents it as reinterpretation - which is why it is the accepted idiom for type punning while a pointer cast is not. The bytes still depend on endianness and padding, so it is portable only where you have pinned those.
  • An enum declares named integer constants and a type. The constants are ordinary identifiers, so they collide with everything else in scope, which is why firmware conventionally prefixes them. Values ascend from zero unless assigned, and assigning some but not others continues counting from the last assigned value.
  • An enum's underlying type is implementation-defined and it is not a bounded type: a variable of enum type may legally hold a value that no enumerator names, so a switch over an enum still needs a default. Assuming otherwise is how an out-of-range state silently does nothing.
  • Bitfields declare members in bits rather than bytes, which reads beautifully and portably specifies almost nothing. Allocation order within a unit, straddling behaviour, and the signedness of a plain int field are all implementation-defined, which is why a bitfield struct is a poor choice for a hardware register or a wire format despite being the obvious one.
  • An anonymous union or struct as a member merges its members into the enclosing type's namespace, so you write msg.length rather than msg.header.length. It is the standard way to make a tagged union read well.
  • A flexible array member - an unsized array as the final member - lets one allocation hold a header and a variable-length payload contiguously. It is the correct expression of a packet with a variable body, and it must be last and cannot be the only member.
  • An opaque type is a struct declared but not defined in a header, so callers may hold a pointer to it and cannot see or depend on its contents. This is how C expresses encapsulation, and it is the difference between a driver you can change and one whose internals are baked into every caller.

Design procedure

  1. Declare types in headers and objects in exactly one translation unit. A type declaration is safe to include everywhere; a definition of a variable is not.
  2. Keep the struct tag when writing a typedef, so the type can refer to itself and so the tag is available for a forward declaration.
  3. Order members from largest alignment to smallest, then verify the result with sizeof and a static assertion rather than assuming the compiler did what you expected.
  4. Use designated initialisers everywhere, so adding a member in the middle of a struct cannot silently corrupt existing initialisers.
  5. Give every enum a prefix, because enumerators are ordinary identifiers in the enclosing scope and will collide with anything else that shares a name.
  6. Write a default case in every switch over an enum, since the type does not constrain the value and an out-of-range state must be handled rather than ignored.
  7. Do not use bitfields for hardware registers or wire formats. Use explicit masks and shifts, which specify exactly what you mean on every compiler.
  8. Use an opaque struct pointer in any header that crosses a module boundary, so callers cannot come to depend on a layout you want to change.
  9. Static-assert the size of any struct that must match hardware or a protocol, so a padding change becomes a build failure rather than a field fault.

Key terms

Tag namespace
Struct tags live apart from ordinary identifiers. Why plain C needs the struct keyword.
typedef
An ordinary-namespace alias for a type. Declares nothing new and allocates nothing.
Designated initialiser
Initialise by name, not position. Survives someone adding a member in the middle.
Union
Every member at the same address, sized by the largest. The register-overlay idiom.
Enum is not bounded
A variable may hold a value no enumerator names. Always write the default case.
Bitfield
Reads beautifully, specifies almost nothing. Wrong tool for registers and wire formats.
Anonymous member
Merges its members into the enclosing namespace. Makes a tagged union read well.
Flexible array member
Unsized final member: header plus variable payload in one allocation.
Opaque type
Declared, not defined, in the header. How C expresses encapsulation.

Worked example

/* ---- the tag namespace, which confuses everyone exactly once ---- */

struct point { int x, y; };      /* declares a TYPE. No storage. */
struct point p;                  /* now there is an object       */
point q;                         /* ERROR in C: tags need the keyword */

/* The idiom that fixes it - and note the tag is KEPT: */
typedef struct node {
    int value;
    struct node *next;           /* `Node` does not exist yet here */
} Node;                          /* ...it exists from this line on */

/* ---- padding is not optional, and ordering is free ---- */

struct bad  { uint8_t a; uint32_t b; uint8_t c; };  /* 12 bytes */
/*             a  PPP   bbbb        c  PPP            4 wasted  */
struct good { uint32_t b; uint8_t a; uint8_t c; };  /*  8 bytes */
/*             bbbb      a  c  PP                                */

_Static_assert(sizeof(struct good) == 8, "layout changed");

/* ---- designated initialisers survive maintenance ---- */

struct cfg { uint32_t baud; uint8_t bits; uint8_t stop; };

struct cfg a = { 115200, 8, 1 };              /* positional  */
struct cfg b = { .baud = 115200, .bits = 8, .stop = 1 };

/*  Someone inserts `uint8_t parity;` after baud:
      a  -> bits=8 lands in PARITY. Silent, and wrong.
      b  -> still correct; parity is zero-initialised.        */

/* ---- a union is how a register is two things at once ---- */

typedef union {
    uint32_t word;
    struct { uint32_t enable:1, mode:3, reserved:28; } bits;
} ctrl_t;                /* readable - and see the pitfall below */

/* ---- enums are named constants, not a bounded type ---- */

typedef enum {
    LED_OFF,             /* 0 */
    LED_ON,              /* 1 */
    LED_BLINK = 10,      /* 10 */
    LED_FADE             /* 11 - counting resumes from the last */
} led_state_t;

led_state_t s = (led_state_t)42;   /* legal. Nothing prevents it. */
switch (s) {
    case LED_OFF: break;
    default: fault();              /* REQUIRED, not defensive */
}

/* ---- a tagged union, made readable by an anonymous member ---- */

typedef struct {
    uint8_t kind;
    union {                        /* anonymous: no member name */
        struct { uint16_t mv; } voltage;
        struct { int16_t  dc; } temperature;
    };
} sample_t;

sample_t s2 = { .kind = 0, .voltage = { .mv = 3300 } };
s2.voltage.mv;                     /* not s2.payload.voltage.mv */

/* ---- header and payload in one allocation ---- */

typedef struct {
    uint16_t length;
    uint8_t  payload[];            /* flexible: must be LAST */
} packet_t;

/* ---- encapsulation: the caller cannot see inside ---- */

/* uart.h */   typedef struct uart uart_t;   /* declared, not defined */
               uart_t *uart_open(int id);
               int     uart_write(uart_t *, const void *, size_t);

/* uart.c */   struct uart { volatile uint32_t *base; uint16_t head; };
/*  Callers hold a pointer and cannot depend on the layout,
    so the layout can change without touching them.          */

Common pitfalls

What a struct is: one name for several values

A structure is a type you define, made of other types laid out one after another in memory. Where an array holds many values of one type reached by index, a struct holds several values of possibly different types reached by name. That is the whole idea. Everything else - pointers to structs, arrays of structs, nesting, passing them to functions - follows from the fact that a struct is an ordinary object type: it has a size, an address, and a lifetime like any other.

How it is built

  • struct Point { int x; int y; }; declares a type. It creates no object and occupies no memory until you declare a variable of it.
  • Members are laid out in declaration order, at increasing addresses, with padding inserted where alignment requires it.
  • Access a member of an object with a dot, and through a pointer with an arrow: p.x and ptr->x. ptr->x is exactly (*ptr).x.
  • A struct is assignable and copyable as a whole; assignment copies every member, including any array member, which a bare array cannot do.
  • The tag name lives in a separate namespace from ordinary identifiers, which is why you must write struct Point unless you add a typedef.

Design procedure

  1. Declare the type once, usually in a header, and define objects of it wherever they are needed.
  2. Pass large structs by pointer rather than by value; passing by value copies every byte at every call.
  3. Add const to a pointer parameter the function does not modify, which documents the contract and lets the compiler optimise.
  4. Initialise with designated initialisers so the code does not depend on declaration order.
  5. Group members by alignment when size matters, since that is what removes internal padding.

Key terms

Member
One named component of a struct. Also called a field.
Tag
The name after the struct keyword. It lives in its own namespace, separate from variables and typedefs.
-> operator
Member access through a pointer. ptr->x is defined as (*ptr).x.
Aggregate
An array or a struct: a type that can be initialised with a braced list.
Complete type
One whose size is known. A struct is incomplete between its declaration and its closing brace.

Worked example

Declaring the type, then objects of it:

  struct Point { int x; int y; };   // a TYPE. No storage yet.

  struct Point a;                   // an object: 8 bytes
  struct Point b = { 3, 4 };        // positional initialisation
  struct Point c = { .y = 4, .x = 3 };  // designated: order-independent
  struct Point d = a;               // whole-struct copy, both members

  struct Point *p = &a;
  p->x = 10;                        // same as (*p).x = 10

And the one thing a struct can do that an array cannot:

  int  arr1[4], arr2[4];
  arr1 = arr2;                      // ERROR: arrays are not assignable

  struct Wrap { int v[4]; } w1, w2;
  w1 = w2;                          // fine - copies all four ints

That is a real technique: wrapping an array in a struct to make it copyable
and returnable.

Common pitfalls

What an enum is: named integer constants with a type

An enumeration defines a set of named integer constants and a type to hold them. It is not a distinct value space the way an enum is in Rust or Java - the enumerators are plain integer constants, and an enum variable is an integer type the compiler chooses. What the construct buys you is names that appear in a debugger, a place to hang a switch that the compiler can check for completeness, and a single place to change a numbering scheme.

How it is built

  • enum Mode { IDLE, RUN, FAULT }; defines three constants with values 0, 1 and 2.
  • Values increment by one from the previous, and any enumerator may be given an explicit value, after which counting resumes from there.
  • The enumerators are of type int in C. The enum TYPE is an implementation-chosen integer type able to hold every enumerator.
  • Values do not have to be unique; two names may share a value, which is how aliases are written.
  • Anonymous enums are a common way to define integer constants without the preprocessor, because they are typed and visible to the debugger.

Design procedure

  1. Use an enum for any fixed set of states, modes or codes, in preference to bare numbers or #define constants.
  2. Give the first enumerator an explicit value when the numbering is externally meaningful, such as a protocol code.
  3. Switch on an enum without a default clause and enable -Wswitch, so adding a state produces a warning at every switch that ignores it.
  4. Do not assume the enum type is int when it crosses an interface; use an explicit fixed-width type in a struct whose layout matters.
  5. Add a trailing COUNT enumerator when you need the number of states, so it updates itself.

Key terms

Enumerator
One named constant in an enumeration. Its type is int.
Enumerated type
The type of an enum variable. Compatible with an implementation-chosen integer type.
-Wswitch
The warning for a switch over an enum that does not handle every enumerator.
Anonymous enum
An enum with no tag, used purely to introduce constants.
COUNT idiom
A final enumerator whose value is the number of preceding ones, used to size arrays.

Worked example

Counting, explicit values, and the COUNT idiom:

  enum Mode { IDLE, RUN, FAULT };            // 0, 1, 2

  enum Cmd {
      CMD_READ  = 0x10,
      CMD_WRITE,                             // 0x11 - counts on
      CMD_ERASE = 0x20,
      CMD_RESET                              // 0x21
  };

  enum { LED_RED, LED_GREEN, LED_BLUE, LED_COUNT };
  static uint8_t brightness[LED_COUNT];      // resizes itself

Why an enum beats a #define for constants:

  #define TIMEOUT_MS 500     no type, invisible to the debugger,
                             text-substituted before compilation
  enum { TIMEOUT_MS = 500 }; typed, scoped, visible in the debugger,
                             and it obeys the language's scope rules

And the completeness check that makes enums worth using:

  switch (mode) {            // no default clause
      case IDLE:  ...; break;
      case RUN:   ...; break;
  }                          // -Wswitch: enumeration value FAULT not handled

Common pitfalls

What a typedef is: a second name for an existing type

A typedef creates an alias. It introduces no new type, no new storage, and no new checking - the alias and the original are the same type, mutually assignable, and indistinguishable to the compiler. What it buys is readability: a name that says what the value is for, and a single place to change a representation. What it costs is that the concrete type is hidden, which is helpful for an opaque handle and harmful for a plain integer.

How it is built

  • typedef declares a name in the ordinary identifier namespace, so it does not need the struct keyword at each use.
  • The syntax follows a declaration: write the declaration you want, then put typedef in front, and the identifier becomes the type name.
  • Because it is an alias and not a new type, a typedef of int accepts any int, with no additional type safety at all.
  • Wrapping a single-member struct DOES create a new type, and is the standard way to get real type safety for a unit or a handle.
  • A typedef of a pointer hides the asterisk, which is why const applied to such a name qualifies the pointer, not the pointee.

Design procedure

  1. Typedef a struct when the tag adds nothing at the point of use, particularly for a handle passed between modules.
  2. Keep the tag as well as the alias, so the type can be forward-declared: typedef struct Node Node; then define struct Node later.
  3. Do not typedef away a pointer unless the pointer nature is genuinely an implementation detail; readers rely on seeing the asterisk.
  4. Use a one-member struct where mixing up two integer quantities would be a real bug, such as millivolts and milliamps.
  5. Follow one naming convention for aliases so a reader can tell a type name from a variable at a glance.

Key terms

Alias
Another name for the same type. Not a distinct type and not checked separately.
Opaque type
A typedef of a struct whose definition callers cannot see, so they can only use the API.
Forward declaration
Naming a struct before defining it, which is enough to declare pointers to it.
Strong typedef
A one-member struct used to get real type checking, since a plain typedef gives none.
Self-referential struct
A struct with a pointer to its own type, the basis of every linked structure.

Worked example

The alias gives no type safety; the wrapper does:

  typedef int Millivolts;
  typedef int Milliamps;
  Millivolts v = 3300;
  Milliamps  i = v;              // compiles clean. Same type.

  typedef struct { int v; } Millivolts;
  typedef struct { int v; } Milliamps;
  Milliamps i = v;               // ERROR - and it should be

The self-referential form, which needs the tag:

  typedef struct Node Node;      // alias for a type not yet defined
  struct Node { int value; Node *next; };

  // The tag is required: inside its own definition the alias does
  // not exist yet, so `Node *next` only works because of line one.

And the pointer-hiding trap:

  typedef char *String;
  const String s;                // char * const - CONST POINTER
                                 // not const char * as it reads

Common pitfalls

Unions: one storage, several interpretations

A union is a struct whose members all start at offset zero, so they overlap: it is as large as its largest member and holds exactly one of them at a time. This makes it the tool for two very different jobs - saving memory when several alternatives are mutually exclusive, and reinterpreting the same bytes under two types. The second use is where the language rules get subtle, and where C and C++ genuinely differ.

How it is built

  • Every member has offset zero. sizeof is the largest member, rounded up to the strictest alignment.
  • Writing one member and reading another is type punning. In C this is explicitly permitted, and the value read is the bytes reinterpreted.
  • In C++ the same read is undefined behaviour, which is why code shared between the two languages should use memcpy instead.
  • A tagged union pairs a union with an enum saying which member is currently valid; the language does not track this for you.
  • Anonymous unions inside a struct, from C11, let the members be named directly without an intermediate name.

Design procedure

  1. Pair every union with a tag unless the active member is obvious from context, and set the tag in the same place you write the member.
  2. Prefer memcpy for reinterpreting bytes in code that must also compile as C++; compilers optimise it to nothing.
  3. Do not use a union to convert between float and int representations in shared headers - use memcpy, which is defined everywhere.
  4. Remember that writing a narrow member leaves the other bytes of a wider member unspecified, not zero.
  5. Use a union of a register struct and a plain integer to get both bit-field access and whole-word access to the same MMIO word.

Key terms

Type punning
Reading bytes written as one type through a different type. Defined in C via a union; not in C++.
Tagged union
A union plus a discriminator saying which member is live. Also called a variant or sum type.
Active member
The member most recently written. The language does not record it.
Anonymous union
A union without a name inside a struct; its members are accessed as if they were the struct's own.
Common initial sequence
The rule letting you read shared leading members of two structs in the same union.

Worked example

Saving memory, and reinterpreting bytes:

  struct Message {
      enum { MSG_TEMP, MSG_TEXT } kind;   // the tag
      union {
          int32_t temperature;
          char    text[16];
      } body;                              // 16 bytes, not 20
  };

  if (m.kind == MSG_TEMP) use(m.body.temperature);

The register idiom, both views of one word:

  typedef union {
      uint32_t word;
      struct { uint32_t enable:1, mode:2, :5, divisor:8; } bits;
  } CtrlReg;

  CtrlReg r = { .word = 0 };     // clear everything
  r.bits.mode = 2;               // then set one field
  REG_CTRL = r.word;             // one 32-bit write to hardware

And the portable reinterpretation:

  float f = 1.5f;
  uint32_t bits;
  memcpy(&bits, &f, sizeof bits);   // defined in C AND C++,
                                    // and compiles to zero instructions

Common pitfalls

Bit-fields: named bits, and why hardware code should be careful

A bit-field is a struct member declared with an explicit width in bits, letting several small values share one storage unit. They are the most readable way to express a packed layout and the least portable, because almost everything about how they are laid out is implementation-defined: which end they start from, whether they cross storage-unit boundaries, and how the compiler reads and writes them. That combination makes them excellent for internal state and risky for hardware registers and wire formats.

How it is built

  • A member declared uint32_t mode : 2; occupies two bits within a storage unit the compiler chooses.
  • Allocation order within a unit is implementation-defined: most compilers fill from the least significant bit, but the standard does not require it.
  • Whether a field may straddle a storage-unit boundary is implementation-defined, so identical source can pack differently on two compilers.
  • An unnamed field of width zero forces alignment to the next storage unit; an unnamed field of nonzero width is reserved padding.
  • Writing one bit-field may compile to a read-modify-write of the whole storage unit, which is a correctness problem for volatile hardware registers.

Design procedure

  1. Use bit-fields freely for internal, in-process state where only your compiler ever sees the layout.
  2. For hardware registers, prefer explicit masks and shifts, or check your compiler's documented layout and pin it with a static assertion.
  3. Never send a bit-field struct over a wire or write it to flash without serialising it field by field.
  4. Declare the underlying type explicitly rather than using plain int, whose signedness for bit-fields is implementation-defined.
  5. Add _Static_assert on the total size, so a layout change fails the build rather than the product.

Key terms

Storage unit
The addressable unit the compiler allocates bit-fields within; usually the declared type.
Zero-width field
An unnamed field of width 0, forcing the next field to start a new storage unit.
Read-modify-write
Reading a whole unit, changing some bits, writing it back. What a bit-field write usually becomes.
Implementation-defined
The compiler chooses and documents. Different compilers may choose differently and both be conforming.
Bit-field signedness
A plain int bit-field may be signed or unsigned; a 1-bit signed field holds 0 and -1.

Worked example

Readable, and dangerous on hardware:

  typedef struct {
      uint32_t enable   : 1;
      uint32_t mode     : 2;
      uint32_t          : 5;   // reserved, unnamed
      uint32_t divisor  : 8;
      uint32_t          : 0;   // force to next storage unit
      uint32_t status   : 4;
  } Ctrl;

Why that is risky for MMIO:

  volatile Ctrl *reg = (volatile Ctrl *)0x4000'0000;
  reg->mode = 2;

  This commonly emits:
     LDR  r1, [r0]     read the WHOLE 32-bit register
     BFI  r1, ...      insert 2 bits
     STR  r1, [r0]     write the WHOLE register back

  If any other bit is write-1-to-clear, or changes in hardware
  between the load and the store, that read-modify-write destroys
  it. The source says "set mode"; the bus says "rewrite everything".

The explicit form has no such ambiguity:

  REG_CTRL = (REG_CTRL & ~MODE_Msk) | (2u << MODE_Pos);

Common pitfalls

Opaque types and the module boundary

C has no private keyword, but it has something nearly as good: a struct can be declared in a header without being defined there. Callers can then hold pointers to it and pass them around, but cannot see its members, allocate one on the stack, or take its size. That single restriction gives you a real module boundary - the implementation can change the layout entirely without recompiling any caller, and no caller can reach past the API.

How it is built

  • The header declares typedef struct Uart Uart; and functions taking Uart *. The definition of struct Uart lives only in the .c file.
  • An incomplete type supports pointers to it and nothing else: no objects, no sizeof, no member access.
  • Because callers cannot allocate one, the module supplies either a create function or a static pool it owns.
  • The layout may change freely between releases; only the .c file that defines the struct is recompiled.
  • This is exactly how FILE works in the standard library, and how most RTOS handle types are built.

Design procedure

  1. Put the typedef and the function declarations in the header, and the struct definition in the source file.
  2. Provide explicit lifecycle functions - init or create, and deinit or destroy - since callers cannot construct the object themselves.
  3. On a target without dynamic allocation, hand out pointers into a static array the module owns, and return null when it is exhausted.
  4. Keep every function that touches the members in the one translation unit that can see them.
  5. Publish the size through a function or an opaque byte buffer if callers really must allocate, and hide the assertion that they match.

Key terms

Opaque pointer
A pointer to an incomplete type. Also called a handle.
Incomplete type
A declared but undefined type. Its size is unknown, so objects of it cannot exist.
Information hiding
Exposing an interface while keeping the representation private.
Handle
A value identifying a resource without revealing how it is stored.
Static pool
A fixed array of objects owned by a module, handed out on request, for systems without malloc.

Worked example

The whole pattern, and the pool version that needs no malloc:

  // uart.h - what callers see
  typedef struct Uart Uart;
  Uart *uart_open(int index);
  int   uart_write(Uart *u, const uint8_t *d, size_t n);
  void  uart_close(Uart *u);

  // uart.c - what only this file sees
  struct Uart {
      volatile uint32_t *base;
      uint8_t  rx[256];
      uint16_t head, tail;
      bool     open;
  };

  static struct Uart pool[UART_COUNT];      // no malloc anywhere

  Uart *uart_open(int index) {
      if (index < 0 || index >= UART_COUNT) return NULL;
      if (pool[index].open) return NULL;
      pool[index] = (struct Uart){ .base = base_of(index), .open = true };
      return &pool[index];
  }

What a caller now cannot do, all of it caught at compile time:

  Uart u;                    // ERROR: incomplete type
  sizeof(Uart);              // ERROR: incomplete type
  handle->head = 0;          // ERROR: dereferencing incomplete type

Common pitfalls

Initialisation: designated initialisers and compound literals

C99 changed how aggregates should be written. Designated initialisers name the member being set, so the code no longer depends on declaration order and any member you do not mention is zero-initialised. Compound literals let you create an unnamed object of a struct or array type in the middle of an expression. Together they remove most of the reason to write a sequence of assignments after a declaration, and they make partially-initialised objects a much smaller risk.

How it is built

  • A designated initialiser names the member: (struct Point){ .x = 3 } sets x and zero-initialises y.
  • Any member omitted from a braced initialiser is initialised as if it were static: zero for arithmetic types, null for pointers.
  • Array elements can be designated too, and out of order: int a[5] = { [4] = 1, [0] = 9 };
  • A compound literal is an unnamed object with the enclosing block's lifetime, or static duration at file scope.
  • Assigning a compound literal to a whole struct is the idiomatic way to reset every member at once, including the ones you forget.

Design procedure

  1. Prefer designated initialisers everywhere; they survive members being added or reordered later.
  2. Reset an object with x = (struct T){0}; rather than memset, which is type-aware and cannot get the size wrong.
  3. Use a compound literal to pass a temporary struct to a function without declaring a variable for it.
  4. Do not return a pointer to a compound literal from a function; its lifetime ends with the enclosing block.
  5. Remember that {0} zero-initialises the entire object, not only the first member, which is what makes it a safe default.

Key terms

Designated initialiser
An initialiser naming the member or index it applies to.
Compound literal
An unnamed object created in an expression: (struct T){ ... }.
Zero initialisation
What omitted members get: zero, null, or recursively zeroed for aggregates.
Partial initialisation
Initialising some members explicitly; the rest are zeroed, never left indeterminate.
Lifetime
How long an object exists. A compound literal in a block dies at the closing brace.

Worked example

Order-independent, self-zeroing, and safe to extend:

  struct Config { uint32_t baud; uint8_t parity; bool flow; uint16_t timeout; };

  struct Config c = { .baud = 115200, .timeout = 50 };
     parity = 0 and flow = false automatically.
     Adding a member later leaves this line correct.

  struct Config c = { 115200, 50 };
     positional: 50 lands in PARITY, not timeout. Compiles clean.

Resetting, without a size to get wrong:

  c = (struct Config){0};        // every member, type-aware
  memset(&c, 0, sizeof c);       // works, but the size is manual
                                 // and it is wrong for pointers on
                                 // exotic targets where null is not 0

And the lifetime trap:

  struct Point *bad(void) {
      return &(struct Point){ .x = 1 };   // dies at the closing brace
  }

Common pitfalls

Putting them together: the tagged union state machine

struct, enum, union and typedef were each designed to compose, and the clearest demonstration is a message or state type built from all four: an enum naming the alternatives, a union holding the payload for each, a struct pairing them, and a typedef giving the result a usable name. This is the shape most embedded protocol and state-machine code eventually converges on, and it shows why each construct exists.

How it is built

  • The enum is the discriminator: one value per alternative, plus a COUNT for table sizing.
  • The union holds the per-alternative payload, sized to the largest, so the total cost is not the sum of every case.
  • The struct binds tag and payload so they cannot be separated and passed around independently.
  • The typedef gives the composite a single name, and a matching handler table indexed by the enum keeps dispatch flat.
  • A switch over the enum with no default clause makes the compiler point at every place a new alternative must be handled.

Design procedure

  1. Define the enum first; the alternatives are the design, and the payloads follow from them.
  2. Set the tag and the payload in the same statement, using a designated initialiser, so they cannot disagree.
  3. Dispatch through a switch with no default, or an array of handlers indexed by the tag with a bounds check.
  4. Add a new alternative by extending the enum first, then fixing every warning the compiler produces.
  5. Assert the composite's size if it is stored or queued, so a payload growing unexpectedly fails the build.

Key terms

Discriminated union
A union with an explicit tag saying which member is valid.
Dispatch table
An array of function pointers indexed by the tag, replacing a long switch.
Exhaustiveness
Handling every enumerator. Enforced by -Wswitch when there is no default clause.
Payload
The per-alternative data carried alongside the tag.
Invariant
A property that must always hold - here, that the tag matches the live union member.

Worked example

All four constructs in one type:

  typedef enum { EV_TICK, EV_RX, EV_FAULT, EV_COUNT } EventKind;

  typedef struct {
      EventKind kind;
      union {
          uint32_t ticks;
          struct { uint8_t *data; uint16_t len; } rx;
          struct { uint16_t code; uint8_t severity; } fault;
      };                                  // anonymous: e.rx.len
  } Event;

  _Static_assert(sizeof(Event) <= 16, "Event outgrew the queue slot");

Constructing so the tag cannot disagree with the payload:

  Event e = { .kind = EV_RX, .rx = { .data = buf, .len = n } };

Dispatching so a new event cannot be forgotten:

  switch (e.kind) {                        // no default clause
      case EV_TICK:  on_tick(e.ticks);        break;
      case EV_RX:    on_rx(e.rx.data, e.rx.len); break;
      case EV_FAULT: on_fault(e.fault.code);  break;
      case EV_COUNT: break;
  }

  Adding EV_TIMEOUT to the enum now produces a warning at every
  switch that does not handle it - which is the entire point.

Common pitfalls

More in Embedded C

  • Interrupts, Rings & ConcurrencyThe handler is a second thread you did not declare. What is and is not atomic on a single core, the lock-free ring buffer and the conditions it requires, DMA and cache coherency, low-power modes and their wake sources, priority inversion, and debugging a race you cannot reproduce.
  • Volatile RegistersMaster volatile keyword usage for memory-mapped registers in embedded C. Interactive simulator shows compiler optimization effects on register reads.
  • Embedded C + DSA 0 → 100One self-sufficient course connecting beginner C, Embedded C, hardware-facing APIs, bounded data structures, Embedded DSA practice, compiled code, diagnostics and production capstones.
  • Bits, Fields & Fixed PointRegister fields and the mask conventions that silently disagree, Gray code and where one-bit-at-a-time matters, fixed-point arithmetic and the intermediate width a multiply needs, the undefined-behaviour traps in ordinary bit idioms, wire-format packing, and what each checksum detects.
  • Types / PromotionUnderstand integer promotion and type conversion in embedded C. Interactive lab demonstrates implicit and explicit casting with signed/unsigned types.
  • Compiler Workbench & TestingCompile real C for an embedded target and inspect what the compiler produced, then the discipline around it: where to draw the host-testable boundary, reading the generated assembly, undefined behaviour and the sanitizers, the warnings worth enabling, and measuring size and stack.
  • Functions & ContractsDesign production C functions and modules with explicit preconditions, postconditions, ownership, status codes, opaque types, reentrancy, HAL seams and failure-state behavior.
  • Arrays, Strings & BuffersArrays decay and the length does not travel; strings are a convention, not a type; and every length in a received packet is data rather than fact. Spans, the three string copies, framing and resynchronisation, serialisation, and parsing untrusted input safely.
  • Object Layout & StorageWhere an object lives and what it costs: storage duration and linkage, the sections a declaration lands in, struct layout and the padding that makes a struct larger than its members, allocation without a heap, and integrity checks over stored data.
  • C Basics & the Translation UnitFrom source text to a linked image: declarations against definitions, the translation unit the compiler actually sees, the preprocessor and what a macro can and cannot do, and the four build stages with the error vocabulary each one produces.
  • FSM / DispatchDesign finite state machines with dispatch tables in embedded C. Interactive lab demonstrates state transitions and event handling for firmware.
  • Embedded CA complete source-to-silicon workbench: C semantics, arrays and APIs, compiler and startup, MMIO, interrupts, DMA and caches, bounded systems, testing, safety evidence and production defense.