Skip to content
BoKSA

Structures and User Types

Structures and User Types

Introduction

An int holds one number. Real firmware deals with bundles of related data: a motor has a pin, a voltage, and an RPM; a sensor reading has a timestamp, a value, and a unit; a CAN frame has an ID, length, and payload bytes. Structures let you group those fields into one named type so your code matches how you think about the hardware.

This article also covers typedef (shorter names), unions (same memory, different views), and enums (named constants). Together they are how HAL headers, protocol specs, and driver code stay readable.


Structures — a custom type with named fields

1
2
3
4
5
struct motor {
    int port;
    double volts;
    double rpm;
};

struct motor is a blueprint. Each variable you create from it gets its own copy of all three fields laid out next to each other in memory.

Creating and using variables

1
2
3
4
5
6
7
struct motor m1 = {1, 3.3, 0.0};   /* initialize all at once */

struct motor m2;
m2.port = 2;
m2.rpm = 9100.0;

double speed = m1.rpm;   /* dot operator accesses a field */

In plain terms

A struct is a lunch box with compartments. m1.port opens the "port" compartment. m1.rpm opens another. You carry one box, not three separate bags.

If you forget to initialize, fields contain garbage until you assign — same as plain int variables.


Arrays of structures

When you have several motors or sensors, use an array:

1
2
3
4
5
6
#define NUM_MOTORS 3
struct motor motors[NUM_MOTORS];

motors[0].port = 1;
motors[1].port = 2;
motors[2].volts = 5.0;

Looping is natural: for (int i = 0; i < NUM_MOTORS; i++) { ... motors[i].rpm ... }. This pattern appears in ADC channel tables, LED strips, and multi-axis robot configs.


Pointers to structures — arrow vs dot

When you have a pointer to a struct, use -> instead of (*p).field:

1
2
3
4
5
struct motor m1;
struct motor *pm = &m1;

pm->volts = 5.0;        /* usual style */
(*pm).volts = 5.0;      /* same meaning, harder to read */

Why pass pointers to functions? Copying a large struct costs time and stack space. Passing struct motor *m passes one address; the function can read and update the original:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#define CONV_FACTOR 60

struct motor {
    int port;
    double rpm;
};

void set_motor(struct motor *m) {
    int freq;
    printf("Enter frequency (Hz): ");
    scanf("%d", &freq);
    m->rpm = CONV_FACTOR * freq;   /* changes caller's struct */
}

int main(void) {
    struct motor m1 = {0};
    set_motor(&m1);
    printf("RPM: %.2f\n", m1.rpm);
}

This is the same "give me the address so I can fill it in" idea as in Pointers in depth.


Nested structures — structs inside structs

Real models are hierarchical. A circle has a center point and a radius:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct position { int x, y; };

struct circle {
    struct position center;
    int radius;
};

struct circle circ;
circ.center.x = 10;
circ.center.y = 20;
circ.radius = 5;

You read inside-out: circ.center.x. For a robot, you might nest pose (x, y, theta) inside waypoint, and an array of waypoint inside path.


Dynamic arrays of structures

When the count is only known at runtime (user input, number of nodes on a bus):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct circle *ptr;
int num;

scanf("%d", &num);
ptr = malloc(num * sizeof(struct circle));

for (int k = 0; k < num; k++) {
    (ptr + k)->center.x = rand() % 40;
    scanf("%d", &(ptr + k)->radius);
}

free(ptr);

(ptr + k) points at the k-th circle — pointer arithmetic from Pointers in C.


typedef — a shorter name for a type

In C you normally write:

1
struct motor m1;   /* keyword struct required */

typedef creates an alias:

1
2
3
4
5
6
7
typedef struct motor {
    int port;
    double rpm;
} motor_t;

motor_t m1;
motor_t *pm;

Embedded codebases use _t suffix by convention (gpio_config_t, uart_handle_t). ST HAL and CMSIS are full of these.

You can typedef plain types too:

1
typedef signed char smallint_t;

That documents intent: "this char is a small counter, not ASCII text".


Union — one memory slot, different interpretations

A union holds several fields, but only one at a time — they share the same bytes:

1
2
3
4
5
typedef union number {
    int i;
    float f;
    char s[20];
} number_t;

sizeof(number_t) is roughly the largest member (here 20 bytes for the string), not the sum.

1
2
3
4
5
number_t num;
num.i = 2700;
printf("%d\n", num.i);

num.f = 2.45f;   /* overwrites the bits — num.i is now nonsense */

In plain terms

A union is a power outlet with different shaped plugs in the same hole. Only one plug fits at a time. If you stick in the float plug, the int plug's meaning is gone until you write again.

Why use this in embedded?

  • Hardware registers — read as full uint32_t or as bitfields
  • Protocol parsing — same two bytes as uint16_t length or two uint8_t fields
  • Tagged variants — pair a enum type with a union of payloads

You must track which member is active — C does not do that for you.


Enumeration — named numbers instead of magic constants

1
2
3
4
5
6
7
8
typedef enum {
    CHARGE_UNKNOWN,
    CHARGE_FAST,
    CHARGE_REGULAR,
    CHARGE_NONE
} charge_state_t;

charge_state_t state = CHARGE_UNKNOWN;

Compiler assigns 0, 1, 2, 3 unless you specify values. Compare to:

1
if (state == 2)   /* what is 2? */

vs

1
if (state == CHARGE_REGULAR)   /* clear */

Use enums for states, error codes, pin modes, and protocol message types. They make switch statements self-documenting.


Padding and alignment — why sizeof surprises you

The compiler may insert empty bytes between fields so int and double start at addresses they can read efficiently:

1
2
3
4
5
struct example {
    char a;   /* 1 byte */
    /* 3 bytes padding */
    int b;    /* 4 bytes */
};            /* sizeof often 8, not 5 */

For network packets and hardware registers, wrong layout breaks interoperability. Use stdint types, #pragma pack, or __attribute__((packed)) only when you understand the trade-off — unaligned access can fault on some ARM cores.


Relevant topics


Starting points

  1. Define sensor_reading_t with timestamp, value, and unit enum.
  2. Pass it by pointer to a print_reading(const sensor_reading_t *r) function.
  3. Show union overwrite — print num.i after assigning num.f.
  4. Print sizeof a struct before and after #pragma pack(1) on a test struct (lab only).

Focus points

  • Dot on values, arrow on pointers — mixing them up is a compile error.
  • Initialize structs in safety code — {0} zeros all fields.
  • Unions need a tag or convention for which member is valid.
  • Padding matters for DMA and wire formats — verify with sizeof and layout diagrams.

Key points

  • struct groups related fields into one type.
  • typedef creates readable aliases (motor_t).
  • union overlays fields in one memory region — one active view at a time.
  • enum replaces magic numbers with meaningful names.