Structs, Unions and Enums
Grouping data in structs, designated initializers, the arrow operator, padding and alignment, bit-fields, unions that share memory, enums and typedef.
Structs: grouping data
A struct (structure) is a type made of named members, each with its own type, stored together in one block of memory. You declare the type once and then create as many variables of it as you like. Members are reached with a dot:
#include <stdio.h>
struct meter_reading {
char meter_id[8]; /* "E-001" plus terminator */
int kwh;
int quarter; /* quarter-hour of the day, 0 to 95 */
};
int main(void)
{
struct meter_reading r = {"E-001", 350, 37};
printf("%s: %d kWh in quarter %d\n", r.meter_id, r.kwh, r.quarter);
struct meter_reading copy = r; /* copies every member, the array too */
copy.kwh = 400;
printf("original %d, copy %d\n", r.kwh, copy.kwh);
printf("sizeof: %zu bytes\n", sizeof r);
return 0;
}
E-001: 350 kWh in quarter 37 original 350, copy 400 sizeof: 16 bytes
The type's name is struct meter_reading, both words.
Assignment copies the whole struct, including the array inside it,
which is something plain arrays cannot do. Structs are values, not
references: changing copy left r
alone.
What a struct does not have: comparison with ==. Two
structs can contain padding bytes with arbitrary values (see
padding), so C does not define
a member-by-member comparison for you:
#include <stdio.h>
struct reading {
int meter;
int kwh;
};
int main(void)
{
struct reading a = {30004, 350};
struct reading b = {30004, 350};
if (a == b) {
puts("same reading");
}
return 0;
}
example.c:12:11: error: invalid operands to binary == (have ‘struct reading’ and ‘struct reading’)
Write a small function that compares the members you care about.
Comparing with memcmp looks shorter but also compares
the padding bytes, which may differ even when every member is equal.
Structs, functions and the arrow operator
C passes arguments by value, so a function that receives a struct
gets a copy. That is fine for small structs. For large ones, or when
the function must change the caller's struct, pass a pointer.
Through a pointer you reach a member with the arrow operator:
p->kwh is shorthand for (*p).kwh. The
parentheses in the long form are needed because . binds
more tightly than *.
#include <stdio.h>
struct reading {
int meter;
int kwh;
};
static void correct_by_value(struct reading r) /* gets a copy */
{
r.kwh = 0;
printf("inside by value: %d\n", r.kwh);
}
static void correct_by_pointer(struct reading *r) /* gets an address */
{
r->kwh = 0; /* same as (*r).kwh = 0 */
}
static int total_kwh(const struct reading *list, int count)
{
int total = 0;
for (int i = 0; i < count; i++) {
total += list[i].kwh; /* list[i] is a struct, not a pointer */
}
return total;
}
int main(void)
{
struct reading r = {30004, 350};
correct_by_value(r);
printf("after by value: %d\n", r.kwh);
correct_by_pointer(&r);
printf("after by pointer: %d\n", r.kwh);
struct reading month[] = {{30004, 350}, {30005, 120}, {30006, 95}};
printf("total: %d kWh\n", total_kwh(month, 3));
return 0;
}
inside by value: 0 after by value: 350 after by pointer: 0 total: 565 kWh
const struct reading * in
total_kwh promises that the function only reads: the
compiler refuses a write through that pointer. That is the usual
signature for a function that takes a struct by pointer only to
avoid the copy. How pointers work in general is on
Pointers.
Designated initializers
{"E-001", 350, 37} fills members in the order they are
declared, which breaks silently when someone adds or reorders a
member. A
designated initializer
(C99) names each member it sets, in any order. Members you do not
mention are set to zero, just like the rest of a partly initialized
array.
#include <stdio.h>
struct tariff {
const char *name;
int price_per_kwh; /* ten-thousandths of a euro: 2700 = EUR 0.2700 */
int standing_charge; /* cents per month */
int is_dynamic;
};
int main(void)
{
struct tariff fixed = {
.name = "fixed",
.price_per_kwh = 2700,
};
int readings[6] = {[2] = 130, [5] = 110}; /* works for arrays too */
printf("%s: %d / %d / %d\n", fixed.name, fixed.price_per_kwh,
fixed.standing_charge, fixed.is_dynamic);
for (int i = 0; i < 6; i++) {
printf("%d ", readings[i]);
}
printf("\n");
return 0;
}
fixed: 2700 / 0 / 0 0 0 130 0 0 110
Prices are stored as whole ten-thousandths of a euro, not as a
double, to keep money exact (see
Variables, Types and Integers). A struct value can also be written in an expression as a
compound literal, with the type in parentheses in front:
print_tariff((struct tariff){ .name = "night", .price_per_kwh =
2100 });.
typedef
typedef gives an existing type a new
name. It creates no new type: the new name and the old one are
interchangeable. It is mostly used to save typing
struct everywhere and to give a type a name that says
what it means:
#include <stdint.h>
#include <stdio.h>
typedef int32_t watts; /* a name that documents the unit */
typedef struct {
char serial[12]; /* "KHP1-000231" + terminator */
watts power;
} Dongle;
int main(void)
{
Dongle d = {.serial = "KHP1-000231", .power = 1234};
watts limit = 5000;
printf("%s reports %d W (%s the limit)\n", d.serial, (int)d.power,
d.power > limit ? "above" : "within");
return 0;
}
KHP1-000231 reports 1234 W (within the limit)
Style guides disagree about hiding struct behind a
typedef. The Linux kernel's coding style, for example, asks for
plain struct name so that a reader can see that a
variable is a struct. Both are valid C; follow the code base you
work in. One convention to avoid in your own names is the
_t suffix (size_t, int32_t):
POSIX reserves names ending in _t for the system.
Padding and alignment
Processors read memory fastest, and on some architectures only, when
a value of n bytes starts at an address that is a
multiple of n: its alignment. A
double wants an address divisible by 8, an
int one divisible by 4. To give every member its
alignment, the compiler inserts unused bytes between members and at
the end of the struct. These bytes are called
structure padding. The
order of the members therefore changes the size:
#include <stddef.h>
#include <stdio.h>
struct reading_a { /* small, large, medium */
char flag;
double kwh;
int meter;
};
struct reading_b { /* large to small */
double kwh;
int meter;
char flag;
};
int main(void)
{
printf("reading_a: %zu bytes (flag at %zu, kwh at %zu, meter at %zu)\n",
sizeof(struct reading_a), offsetof(struct reading_a, flag),
offsetof(struct reading_a, kwh), offsetof(struct reading_a, meter));
printf("reading_b: %zu bytes (kwh at %zu, meter at %zu, flag at %zu)\n",
sizeof(struct reading_b), offsetof(struct reading_b, kwh),
offsetof(struct reading_b, meter), offsetof(struct reading_b, flag));
printf("members alone: %zu bytes\n", sizeof(char) + sizeof(double) + sizeof(int));
return 0;
}
reading_a: 24 bytes (flag at 0, kwh at 8, meter at 16) reading_b: 16 bytes (kwh at 0, meter at 8, flag at 12) members alone: 13 bytes
kwh starts at a multiple of 8 and
the struct's size is a multiple of 8, so that every element of an
array of these structs is aligned too.
Thirteen bytes of data took 24 bytes in one order and 16 in the
other. For a single variable that does not matter; for an array of a
million readings in a small device it is 8 MB. Ordering members from
the largest alignment to the smallest is a simple habit that usually
gives the smallest struct. offsetof from
<stddef.h> tells you where each member really
starts.
Bit-fields
A bit-field is a struct member with a width in bits, written after a colon. Several bit-fields share one storage unit, which saves memory for flags and small counters:
#include <stdio.h>
struct dongle_status {
unsigned connected : 1; /* 0 or 1 */
unsigned tls_ok : 1;
unsigned retries : 3; /* 0 to 7 */
unsigned dsmr : 3; /* 4 or 5 fits in 3 bits */
};
int main(void)
{
struct dongle_status s = {.connected = 1, .tls_ok = 1, .dsmr = 5};
for (int attempt = 0; attempt < 9; attempt++) {
s.retries++; /* 3 bits: 7 + 1 wraps to 0 */
}
printf("connected=%u tls=%u retries=%u dsmr=%u\n",
s.connected, s.tls_ok, s.retries, s.dsmr);
printf("sizeof: %zu bytes\n", sizeof s);
return 0;
}
connected=1 tls=1 retries=1 dsmr=5 sizeof: 4 bytes
Nine increments of a 3-bit unsigned field gave 1: the value wraps
modulo 8 without any warning. Bit-fields have more
implementation-defined details than any other part of C: the order
of the fields inside the storage unit, whether a plain
int field is signed, and how fields cross unit
boundaries all depend on the compiler and the platform. They are
fine for flags inside one program. For hardware registers and
protocol bytes, where the exact bit position matters, firmware
usually uses an integer of a fixed width with
bit masks
instead (see
Working with very little memory). You cannot take the address of a bit-field.
Unions: members that share memory
A union looks like a struct, but all its members start at the same address and overlap. It is as large as its largest member, and only the member that was written last holds a meaningful value. Unions save memory when a value can be one of several kinds, but never two at once.
Because a union does not record which member is in use, it is almost always wrapped in a struct together with an enum that says so: a tagged union. Here a P1 telegram value is either a power in watts, a gas volume in liters or a text:
#include <stdio.h>
enum value_kind { POWER_W, GAS_L, TEXT };
struct p1_value {
const char *obis; /* the OBIS code of the line */
enum value_kind kind; /* says which union member is valid */
union {
int watts;
long liters;
char text[16];
} as;
};
static void print_value(const struct p1_value *v)
{
switch (v->kind) {
case POWER_W:
printf("%-11s %d W\n", v->obis, v->as.watts);
break;
case GAS_L:
printf("%-11s %ld L\n", v->obis, v->as.liters);
break;
case TEXT:
printf("%-11s \"%s\"\n", v->obis, v->as.text);
break;
}
}
int main(void)
{
struct p1_value values[] = {
{"1-0:1.7.0", POWER_W, {.watts = 1234}},
{"0-1:24.2.1", GAS_L, {.liters = 4211}},
{"1-3:0.2.8", TEXT, {.text = "50"}},
};
for (int i = 0; i < 3; i++) {
print_value(&values[i]);
}
printf("sizeof the union: %zu bytes\n", sizeof values[0].as);
return 0;
}
1-0:1.7.0 1234 W 0-1:24.2.1 4211 L 1-3:0.2.8 "50" sizeof the union: 16 bytes
The union is 16 bytes, the size of its largest member, the
text array; a struct with all three members would need
at least 28 bytes. Reading as.watts when
kind says TEXT is the bug a tagged union
is meant to prevent, so every access goes through the tag. (The OBIS
codes are those of the
P1 telegram; the values are made up.)
Looking at the bytes of a value
A second use of unions is to look at the bytes of a value. C allows writing one member and reading another; you then see the stored bytes reinterpreted as the other type. (C++ does not allow this.) Here the bit pattern of the IEEE 754 value 1.0:
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
int main(void)
{
union {
float f;
uint32_t bits;
uint8_t bytes[4];
} u;
u.f = 1.0f;
printf("bits = 0x%08" PRIX32 "\n", u.bits);
printf("bytes in memory:");
for (int i = 0; i < 4; i++) {
printf(" %02X", u.bytes[i]);
}
printf("\n");
return 0;
}
bits = 0x3F800000 bytes in memory: 00 00 80 3F
The bytes come out in reverse order because this machine, like
nearly all current PCs, servers and ARM systems, is
little-endian: the least significant byte is stored first.
That is one more reason not to send raw memory to another system.
For copying bytes between types, memcpy is the
alternative that also works in C++.
Enums: named integer constants
An enum (enumeration) declares a set of named integer constants. Without explicit values the first is 0 and each next one is one higher; an explicit value restarts the counting from there.
#include <stdio.h>
enum ticket_status { OPEN, IN_PROGRESS = 5, RESOLVED, CLOSED };
static const char *status_name(enum ticket_status s)
{
switch (s) {
case OPEN: return "open";
case IN_PROGRESS: return "in progress";
case RESOLVED: return "resolved";
case CLOSED: return "closed";
}
return "unknown";
}
int main(void)
{
enum ticket_status s = RESOLVED;
printf("OPEN=%d IN_PROGRESS=%d RESOLVED=%d CLOSED=%d\n",
OPEN, IN_PROGRESS, RESOLVED, CLOSED);
printf("status %d is %s\n", s, status_name(s));
printf("sizeof: %zu\n", sizeof s);
s = 42; /* compiles: an enum is just an int */
printf("status %d is %s\n", s, status_name(s));
return 0;
}
OPEN=0 IN_PROGRESS=5 RESOLVED=6 CLOSED=7 status 6 is resolved sizeof: 4 status 42 is unknown
The last lines show the weak spot: C enums are integers, and the
compiler accepts any integer value. That is why
status_name ends with a fallback return after the
switch. The names themselves are also not scoped to the
enum: two enums in the same scope (in practice, the same file or a
shared header) cannot both have a constant called OPEN,
which is why C code often gives them a prefix
(TICKET_OPEN).
What the compiler does check is a switch over an enum
without a default: -Wall includes
-Wswitch, which reports every constant that has no
case. With -Werror a forgotten status
stops the build:
#include <stdio.h>
enum ticket_status { OPEN, IN_PROGRESS, RESOLVED, CLOSED };
static const char *status_name(enum ticket_status s)
{
switch (s) {
case OPEN: return "open";
case IN_PROGRESS: return "in progress";
case RESOLVED: return "resolved";
}
return "unknown";
}
int main(void)
{
printf("%s\n", status_name(CLOSED));
return 0;
}
example.c:7:5: error: enumeration value ‘CLOSED’ not handled in switch [-Werror=switch]
This is a good reason to leave out default in a
switch over an enum: when someone adds a constant
later, every switch that does not handle it shows up in
the build.
Enums with a fixed type in C23
Before C23 the compiler chooses the integer type behind an enum
(here int, 4 bytes). C23 lets you fix it, which matters
for compact structs and for protocol fields of exactly one byte.
This example is compiled with Clang 21, because gcc 12 on the test
machine does not support it (GCC 13 and later do):
#include <stdio.h>
enum dsmr_version : unsigned char { DSMR_4 = 4, DSMR_5 = 5 };
struct telegram_header {
enum dsmr_version version; /* exactly one byte */
unsigned char flags;
};
int main(void)
{
struct telegram_header h = {.version = DSMR_5, .flags = 0};
printf("version %d, sizeof enum %zu, sizeof header %zu\n",
h.version, sizeof(enum dsmr_version), sizeof h);
return 0;
}
version 5, sizeof enum 1, sizeof header 2
Other C23 changes that touch this page, such as
constexpr structs and empty initializers
= {}, are on
Modern C and Where C Is Used.