Module 6.4

Unions and Bit Fields

Master memory-efficient data structures through unions, bit field packing, and enumerations. Learn when to use unions instead of structures, implement hardware registers, and write compact embedded code!

45 min read
Advanced
Hands-on Examples
What You'll Learn
  • Declare and use unions effectively
  • Compare structures vs unions
  • Pack bits efficiently with bit fields
  • Create type-safe enumerations
  • Build hardware register simulations
Contents
01

Union Declaration and Usage

A union is a special data type that allows storing different data types in the same memory location, using only as much memory as its largest member.

While structures allocate separate memory for each member, unions share memory among all members. This makes unions useful when you need to store different types of data but only one at a time, saving memory in the process.

Union Syntax
union UnionName {
    dataType1 member1;
    dataType2 member2;
    dataType3 member3;
};

union UnionName variableName;

Basic Union Example

#include <stdio.h>

union Data {
    int intValue;
    float floatValue;
    char charValue;
};

We define a union called Data with three members: an integer, a float, and a character. All three members share the same memory space. The union's size will be 4 bytes (the size of the largest member - the int and float). The char only uses 1 byte, but it occupies the same memory location as the first byte of the int or float.

int main() {
    union Data data;
    
    printf("Size of union: %zu bytes\n", sizeof(union Data));
    printf("Size of int: %zu bytes\n", sizeof(int));
    printf("Size of float: %zu bytes\n", sizeof(float));
    printf("Size of char: %zu bytes\n\n", sizeof(char));

We declare a variable data of type union Data. Then we print the size of the union and the individual sizes of each data type. Notice that we're comparing the size of the union (4 bytes) to the size of each individual type. Even though the union has three members, it only uses 4 bytes total because all members share the same memory space.

    data.intValue = 42;
    printf("data.intValue = %d\n", data.intValue);

We store the integer value 42 in the intValue member. The entire 4-byte memory of the union now contains this integer. When we print it, we get 42 as expected because we just stored it there.

    data.floatValue = 3.14;
    printf("data.floatValue = %.2f\n", data.floatValue);
    printf("data.intValue now = %d (corrupted)\n\n", data.intValue);

We now store the float value 3.14 in the floatValue member. This overwrites the integer we stored earlier! The same 4 bytes now contain the float representation of 3.14. When we try to read data.intValue, we get garbage (1078523331) because those bytes are being interpreted as an integer but they actually contain float data. This demonstrates the danger of untagged unions.

    data.charValue = 'A';
    printf("data.charValue = %c\n", data.charValue);
    
    return 0;
}

We store the character 'A' in the charValue member. This overwrites the entire 4-byte memory again with the ASCII value of 'A'. When we print the character, we get 'A' correctly. The program ends cleanly by returning 0, demonstrating that each assignment completely overwrites all previous data in the union.

Output
Size of union: 4 bytes
Size of int: 4 bytes
Size of float: 4 bytes
Size of char: 1 bytes

data.intValue = 42
data.floatValue = 3.14
data.intValue now = 1078523331 (corrupted)

data.charValue = A
Important: Only One Member Active at a Time

Since all union members share the same memory, writing to one member overwrites the others. Always read only the member that was most recently written to.

Union with typedef

Using typedef with unions simplifies your code by creating a custom data type alias. Instead of writing union Value every time, you can simply write Value. This makes your code cleaner, more readable, and easier to maintain, especially when working with complex unions.

Typedef is a preprocessor directive that creates a new name for an existing data type. When combined with unions, it reduces verbosity and makes your code more professional and maintainable.

#include <stdio.h>
#include <string.h>

typedef union {
    int integer;
    float decimal;
    char text[20];
} Value;

We create a typedef union called Value with three different members: an integer, a float (decimal), and a character array (text). The typedef keyword creates an alias, so instead of always writing union Value, we can now simply use Value as the type name. This makes our code more concise and easier to read. All three members share the same 20 bytes of memory (the size of the largest member, the text array).

int main() {
    Value v1, v2, v3;
    
    v1.integer = 100;
    v2.decimal = 99.99;
    strcpy(v3.text, "Hello Union");
}

We declare three variables of type Value - v1, v2, and v3. Notice how we can use Value directly without the union keyword, thanks to typedef. Each variable has its own independent copy of the union. We initialize v1 with an integer (100), v2 with a decimal number (99.99), and v3 with a text string ("Hello Union"). Each assignment overwrites the entire 20-byte memory space since all members share the same location.

    printf("v1 as integer: %d\n", v1.integer);
    printf("v2 as decimal: %.2f\n", v2.decimal);
    printf("v3 as text: %s\n", v3.text);
    printf("Size of Value: %zu bytes\n", sizeof(Value));

We print the values we stored in each union. Notice that we can only read the member that was most recently written to (the one currently stored in that union's memory). v1's integer value (100) is valid, v2's decimal value (99.99) is valid, and v3's text string ("Hello Union") is valid. We also print the size of the Value type, which is 20 bytes - the size of the largest member (the text array).

    return 0;
}

The program ends cleanly by returning 0, indicating successful execution. Using typedef unions makes this kind of flexible data storage clean and professional-looking in production code.

Output
v1 as integer: 100
v2 as decimal: 99.99
v3 as text: Hello Union
Size of Value: 20 bytes

Tagged Union Pattern

A tagged union (also called a discriminated union or variant type) combines a union with an enum tag to safely track which member is currently active. This pattern solves the safety problem of unions by ensuring you always know which data type is stored.

Instead of blindly reading whichever member you think is active, a tag field tells you the actual type. This prevents the corruption and undefined behavior that can occur with untagged unions.

typedef enum {
    INT_TYPE,
    FLOAT_TYPE,
    STRING_TYPE
} DataType;

We start by creating an enum that represents all the possible data types our union can hold. Think of this enum as a label or marker that tells us what type of data is currently stored in the union. In this example, we have three options: an integer type, a float type, or a string type. Each enum value (INT_TYPE, FLOAT_TYPE, STRING_TYPE) becomes a unique identifier that helps us remember what we stored.

typedef struct {
    DataType type;      // Tag: tells us which type is active
    union {
        int intVal;
        float floatVal;
        char strVal[32];
    } data;             // The union holds the actual value
} Variant;

Now we combine two things: the enum tag we just created and the union itself. The struct has two members:

  • type - The enum field that stores which data type is currently active (INT_TYPE, FLOAT_TYPE, or STRING_TYPE)
  • data - The union that actually holds the value (either an integer, float, or string)
This combination is the "tagged union" - we always know what type is stored because the tag tells us!

void printVariant(Variant *v) {
    switch (v->type) {  // Check the tag first!
        case INT_TYPE:
            printf("Integer: %d\n", v->data.intVal);
            break;
        case FLOAT_TYPE:
            printf("Float: %.2f\n", v->data.floatVal);
            break;
        case STRING_TYPE:
            printf("String: %s\n", v->data.strVal);
            break;
    }
}

When we want to read the value from a tagged union, we NEVER just pick a member randomly. Instead, we check the tag first using a switch statement. The tag tells us exactly which union member is valid right now. If the tag says INT_TYPE, we read from intVal. If it says FLOAT_TYPE, we read from floatVal. If it says STRING_TYPE, we read from strVal. This is what makes it safe - we always access the correct member!

int main() {
    Variant v1 = { INT_TYPE, .data.intVal = 42 };
    Variant v2 = { FLOAT_TYPE, .data.floatVal = 3.14159 };
    Variant v3 = { STRING_TYPE };
    strcpy(v3.data.strVal, "Hello");
    
    printVariant(&v1);
    printVariant(&v2);
    printVariant(&v3);
    
    return 0;
}

When we create tagged union variables, we always initialize both the tag and the data. For v1, we set type to INT_TYPE and store the integer 42 in intVal. For v2, we set type to FLOAT_TYPE and store 3.14159 in floatVal. For v3, we set type to STRING_TYPE and store "Hello" in strVal. Notice how the tag and the actual data always match - this is the key to the tagged union pattern working correctly!

Output
Integer: 42
Float: 3.14
String: Hello
Practice Questions: Union Declaration

Test your understanding with these hands-on exercises.

Task: Given this union definition, what is its size?

union Data {
    char c;      // 1 byte
    int i;       // 4 bytes
    double d;    // 8 bytes
};

Explain: Why is the size what it is?

Show Solution

Size: 8 bytes

The union size equals the size of its largest member (double = 8 bytes). All members share this same memory space.

Task: What will be printed for each printf statement?

union Data {
    int i;
    float f;
};

union Data d;
d.i = 10;
printf("d.i = %d\n", d.i);  // Output: ?
d.f = 3.14;
printf("d.i = %d\n", d.i);  // Output: ?
printf("d.f = %.2f\n", d.f);  // Output: ?
Show Solution
d.i = 10
d.i = 1078340096 (garbage value - corrupted by float assignment)
d.f = 3.14

When d.f is assigned, it overwrites d.i's value since they share memory. Reading d.i after produces garbage.

Task: Create a tagged union that can store either an integer, float, or string. Write code to safely handle assignments and access.

Hint: Use an enum tag to track which type is currently stored.

Show Solution
typedef enum { INT_TAG, FLOAT_TAG, STRING_TAG } Tag;

typedef struct {
    Tag tag;
    union {
        int i;
        float f;
        char s[32];
    } value;
} Variant;

void printVariant(Variant *v) {
    if (v->tag == INT_TAG) printf("%d\n", v->value.i);
    else if (v->tag == FLOAT_TAG) printf("%.2f\n", v->value.f);
    else printf("%s\n", v->value.s);
}

Task: Design a union that allows reading a 32-bit packet header as either:

  • A single 32-bit integer (raw value)
  • A struct with fields: version(4 bits), type(4 bits), length(24 bits)

Write code that receives a raw 32-bit value and reads the version and type fields.

Show Solution
typedef struct {
    unsigned int version : 4;
    unsigned int type : 4;
    unsigned int length : 24;
} PacketHeader;

typedef union {
    unsigned int raw;
    PacketHeader fields;
} Packet;

int main() {
    Packet p;
    p.raw = 0x1A000040;  // Raw 32-bit packet
    printf("Version: %u\n", p.fields.version);
    printf("Type: %u\n", p.fields.type);
    printf("Length: %u\n", p.fields.length);
}
02

Structures vs Unions

Understanding when to use structures versus unions is crucial for efficient memory usage and proper data organization in C programs.

Feature Structure Union
Memory Allocation Separate memory for each member Shared memory for all members
Size Sum of all members (plus padding) Size of largest member
Member Access All members accessible simultaneously Only one member valid at a time
Use Case Group related data together Store one of several possible types

Memory Layout Comparison

Structure (Separate Memory)
int a (4 bytes)
double b (8 bytes)
char c (1 byte)
Total: 13+ bytes
Union (Shared Memory)
All members occupy
the same 8 bytes
int a, double b, char c
Total: 8 bytes
#include <stdio.h>

struct StructExample {
    int a;
    double b;
    char c;
};

union UnionExample {
    int a;
    double b;
    char c;
};

We define both a struct and a union with identical members: an int (4 bytes), a double (8 bytes), and a char (1 byte). The struct will allocate separate memory for each member, while the union will share the same memory space among them. This difference is crucial - it determines how much memory each type requires and how we can use them.

int main() {
    printf("Size of struct: %zu bytes\n", sizeof(struct StructExample));
    printf("Size of union: %zu bytes\n", sizeof(union UnionExample));
    
    struct StructExample s;
    union UnionExample u;

We declare one struct variable and one union variable. When we check their sizes using sizeof(), we'll see a dramatic difference. The struct is 24 bytes (sum of all members: 4 + 8 + 1 + 11 padding), while the union is only 8 bytes (size of the largest member, the double). This shows why unions are memory-efficient when you only need one member active at a time.

    printf("\nStruct member addresses:\n");
    printf("  &s.a = %p\n", (void*)&s.a);
    printf("  &s.b = %p\n", (void*)&s.b);
    printf("  &s.c = %p\n", (void*)&s.c);

We print the memory addresses of each struct member. Notice that each member has a different address - they are stored at different locations in memory. The addresses are sequential (0x7ffd12340000, 0x7ffd12340008, 0x7ffd12340010), showing that each member gets its own dedicated memory space. This is the fundamental characteristic of structures - all members coexist independently.

    printf("\nUnion member addresses (all same):\n");
    printf("  &u.a = %p\n", (void*)&u.a);
    printf("  &u.b = %p\n", (void*)&u.b);
    printf("  &u.c = %p\n", (void*)&u.c);
    
    return 0;
}

Here's the striking difference: when we print the union member addresses, they're ALL THE SAME (0x7ffd12340020)! Every member of the union occupies the exact same location in memory, just overlaid on top of each other. This is why writing to one member overwrites the others - they literally share the same bytes. This memory overlap is what makes unions space-efficient but requires careful usage.

Output
Size of struct: 24 bytes
Size of union: 8 bytes

Struct member addresses:
  &s.a = 0x7ffd12340000
  &s.b = 0x7ffd12340008
  &s.c = 0x7ffd12340010

Union member addresses (all same):
  &u.a = 0x7ffd12340020
  &u.b = 0x7ffd12340020
  &u.c = 0x7ffd12340020

Choosing Between Structures and Unions

#include <stdio.h>

typedef struct {
    unsigned int isActive : 1;
    unsigned int isAdmin : 1;
    unsigned int priority : 3;
    unsigned int reserved : 27;
} Flags;

union Register {
    unsigned int raw;
    Flags flags;
};

We create a union Register that demonstrates a real-world use case: hardware register manipulation. The union has two "views" of the same 32-bit value: raw (the entire register as one unsigned int) and flags (the same 32 bits broken down into individual flag fields). This is perfect for embedded systems where you need to read/write a hardware register either as a complete value or access individual bit fields within it.

void printRegister(union Register *reg) {
    printf("Register value: 0x%08X\n", reg->raw);
    printf("  isActive: %d\n", reg->flags.isActive);
    printf("  isAdmin: %d\n", reg->flags.isAdmin);
    printf("  priority: %d\n", reg->flags.priority);
}

The printRegister function shows how to access both views of the union. We can read the entire register as a hex value using reg->raw, and simultaneously access individual flags using reg->flags.isActive, reg->flags.isAdmin, etc. The union ensures both views represent the exact same memory - changes to raw automatically affect the flags, and vice versa.

int main() {
    union Register reg = { 0 };
    
    printf("Reading register as raw value:\n");
    printRegister(®);

We initialize the register to all zeros and print its initial state. All flags are 0 (inactive, not admin, priority 0). The raw value is 0x00000000. This demonstrates the starting state before any configuration.

    reg.flags.isActive = 1;
    reg.flags.priority = 5;
    
    printf("\nAfter setting flags:\n");
    printRegister(®);
    printf("Raw value can be transmitted: 0x%X\n", reg.raw);
    
    return 0;
}

We modify the flags by setting isActive to 1 and priority to 5. When we print the register again, we see that reg->raw has automatically updated to 0x0000000A (which is 10 in decimal, or binary 1010). This shows the union's power: changing individual flag fields automatically updates the raw value representation! This raw value can now be written directly to actual hardware registers or transmitted over a network, while still allowing us to work with individual bits programmatically.

Output
Reading register as raw value:
  isActive: 0
  isAdmin: 0
  priority: 0

After setting flags:
Register value: 0x0000000A
  isActive: 1
  isAdmin: 0
  priority: 5
Raw value can be transmitted: 0xA
When to Use Structures
  • Need all members at the same time
  • Modeling complex entities
  • Building linked data structures
When to Use Unions
  • Only one member needed at a time
  • Embedded systems and memory-constrained
  • Hardware register simulation
Practice Questions: Struct vs Union

Test your understanding with these hands-on exercises.

Task: Compare memory usage between struct and union for 1000 instances:

struct Color {
    unsigned char red;
    unsigned char green;
    unsigned char blue;
};

union ColorValue {
    unsigned char c[3];
    unsigned int value;
};

How much memory do 1000 structs use vs 1000 unions?

Show Solution

Struct: 1000 � 3 = 3000 bytes

Union: 1000 � 4 = 4000 bytes (rounded up for alignment)

In this case, struct is more efficient because it stores all fields needed simultaneously.

Task: Draw the memory layout for both and explain the difference:

struct Point {
    int x;    // 4 bytes
    int y;    // 4 bytes
    int z;    // 4 bytes
};

union Register {
    int reg32;     // 4 bytes
    short reg16;   // 2 bytes
    char reg8;     // 1 byte
};
Show Solution

Struct memory: x bytes 0-3 | y bytes 4-7 | z bytes 8-11 (Total: 12 bytes)

Union memory: All share bytes 0-3 (Total: 4 bytes)

Struct stores all values independently; union reuses the same space for different alternatives.

Task: An embedded system reads different sensor types that return different data formats, but only one sensor is active at a time. Should you use struct or union? Why?

Design: Write a structure/union pair to handle temperature, humidity, or pressure sensors.

Show Solution
// Use union since only ONE sensor is active at a time
typedef union {
    struct { float celsius; int precision; } temp;      // Temperature sensor
    struct { float percent; int calibrated; } humidity;  // Humidity sensor
    struct { float pascal; int altitude; } pressure;     // Pressure sensor
} SensorData;

typedef struct {
    int sensorType;  // Which sensor is active
    SensorData data;  // Union reuses memory for one type
} Reading;

Task: A memory-mapped I/O register at address 0x4000_0000 contains:

  • Byte 0: Status flags
  • Byte 1: Control bits
  • Bytes 2-3: Data value

Design a struct or union that allows reading the entire 32-bit register as well as individual fields. Which is better and why?

Show Solution
// Union is perfect for hardware registers
typedef union {
    unsigned int reg32;  // Read/write entire register
    struct {
        unsigned char status;   // Byte 0
        unsigned char control;  // Byte 1
        unsigned short data;    // Bytes 2-3
    } fields;
} HardwareReg;

// Use: HardwareReg *reg = (HardwareReg *)0x40000000;
// Access: reg->reg32 for raw value or reg->fields.status for individual bytes

// Union is better because:
// 1. Exactly 4 bytes (register size)
// 2. Can access whole register atomically or field-by-field
// 3. No padding issues unlike struct
03

Bit Fields

Bit fields allow you to specify the exact number of bits for structure members, enabling compact data storage and direct bit-level manipulation.

When you need to store multiple small values, bit fields let you pack them efficiently. This is especially useful in embedded systems, network protocols, and hardware register manipulation.

Bit Field Syntax
struct StructName {
    type memberName : numberOfBits;
    // ...
};

// Example: Pack flags into a single byte
struct Flags {
    unsigned int isActive : 1;   // 1 bit
    unsigned int isAdmin : 1;    // 1 bit
    unsigned int priority : 3;   // 3 bits (0-7)
    unsigned int status : 3;     // 3 bits (0-7)
};  // Total: 8 bits = 1 byte

Basic Bit Fields

#include <stdio.h>

struct DateCompact {
    unsigned int day   : 5;   // 5 bits (0-31)
    unsigned int month : 4;   // 4 bits (0-15)
    unsigned int year  : 12;  // 12 bits (0-4095)
    unsigned int isLeap: 1;   // 1 bit (0 or 1)
};

We define a struct called DateCompact that uses bit fields to pack date information into a single 32-bit integer. Each member specifies how many bits it occupies: day uses 5 bits (0-31), month uses 4 bits (0-15), year uses 12 bits (0-4095), and isLeap uses 1 bit. In total, that's exactly 22 bits, all fitting within one 4-byte integer! Compare this to a normal struct with four unsigned ints, which would use 16 bytes - the bit field version is 75% more memory efficient.

int main() {
    struct DateCompact d = {25, 12, 2025, 0};
    
    printf("Size with bit fields: %zu bytes\n", sizeof(struct DateCompact));
    printf("Date: %u/%u/%u (Leap: %u)\n", 
           d.day, d.month, d.year, d.isLeap);

We create a DateCompact variable d with values: day=25, month=12, year=2025, isLeap=0. When we print the size, we see it's 4 bytes (32 bits) - the size of the underlying integer, regardless of how many bit fields we packed into it. We also print the date values we just stored, formatted as a typical date string (25/12/2025, Leap: 0).

    d.day = 31;
    d.month = 1;
    d.year = 2026;
    
    printf("New Date: %u/%u/%u\n", d.day, d.month, d.year);
    
    return 0;
}

We modify the date fields - changing day to 31, month to 1, and year to 2026. Notice that even though we're updating the values, the struct still uses only 4 bytes. The bit field mechanism handles packing and unpacking these values automatically - we read and write them like normal integers, but they're stored compressed as individual bit ranges within the 4-byte storage.

Output
Size with bit fields: 4 bytes
Date: 25/12/2025 (Leap: 0)
New Date: 31/1/2026
Bit Field Limitations
  • Cannot take address: & not allowed on bit field members
  • Value truncation: Values exceeding bit width are truncated
  • Portability: Bit ordering varies between compilers/platforms

Hardware Status Register

A hardware status register is a 32-bit memory location that contains multiple flag fields packed together using bit fields. Each bit or group of bits represents a specific status or state - for example, whether a device is ready, if an error occurred, or what error code it has. Status registers are common in embedded systems and are used to communicate between hardware and software.

#include <stdio.h>

struct StatusRegister {
    unsigned int ready     : 1;
    unsigned int error     : 1;
    unsigned int overflow  : 1;
    unsigned int underflow : 1;
    unsigned int reserved  : 4;
    unsigned int errorCode : 8;
};

We define a StatusRegister struct that mirrors a typical hardware register layout. The first four bits are flag fields: ready, error, overflow, and underflow (each is 1 bit, so true/false). We reserve 4 bits for future use, and the last 8 bits store an error code (0-255). This packs 8 meaningful pieces of information into a single 4-byte register, which is exactly how real hardware status registers work in microcontrollers and embedded systems.

void printStatus(struct StatusRegister *reg) {
    printf("Status:\n");
    printf("  Ready: %u\n", reg->ready);
    printf("  Error: %u\n", reg->error);
    printf("  Overflow: %u\n", reg->overflow);
    printf("  ErrorCode: %u\n", reg->errorCode);
}

We create a helper function printStatus() that takes a pointer to a StatusRegister and displays the relevant status flags and error code. Notice we don't print the reserved bits - they're internal bookkeeping. This demonstrates how embedded code typically reads a hardware register: access only the fields you care about, treating the struct like a window into the actual hardware register.

int main() {
    struct StatusRegister status = {0};
    
    printf("Initial state:\n");
    printStatus(&status);

We initialize the StatusRegister to all zeros (no flags set, no error code) and print the initial state. This represents a clean, idle device with no errors. All bits are 0, meaning the device is not ready, no error has occurred, no overflow, no underflow.

    status.ready = 1;
    status.error = 1;
    status.errorCode = 42;
    
    printf("\nAfter setting flags:\n");
    printStatus(&status);

We simulate a hardware event by setting the ready flag to 1, error flag to 1, and error code to 42. This might represent: "Device is ready, but an error (code 42) occurred". When we print the status again, we see these new values. The struct automatically handles the bit packing - we write them like normal integers.

    printf("Size: %zu bytes\n", sizeof(struct StatusRegister));
    
    return 0;
}

Finally, we print the size of the StatusRegister struct, which is 4 bytes (32 bits). Even though we have 18 bits of meaningful data (1+1+1+1+4+8+2 reserved), it all fits in one 4-byte integer. This is the entire purpose of bit fields: pack multiple boolean flags and small values into a single hardware word, which is exactly what microcontroller registers require.

Output
Initial state:
Status:
  Ready: 0
  Error: 0
  Overflow: 0
  ErrorCode: 0

After setting flags:
Status:
  Ready: 1
  Error: 1
  Overflow: 0
  ErrorCode: 42
Size: 4 bytes

Game Flags Example

A practical example of bit fields in game development is storing entity state flags. Each game character or object has multiple boolean properties like isAlive, isInvincible, canJump, onGround, facingRight, and isAnimating. Instead of using 6 separate boolean variables (24 bytes), we can pack all 6 flags into a single byte using bit fields. This is crucial in games where you have thousands of entities and every byte of memory counts!

#include <stdio.h>

struct GameEntityFlags {
    unsigned int isAlive      : 1;
    unsigned int isInvincible : 1;
    unsigned int canJump      : 1;
    unsigned int onGround     : 1;
    unsigned int facingRight  : 1;
    unsigned int isAnimating  : 1;
    unsigned int reserved     : 2;
};

We define a GameEntityFlags struct with 6 boolean fields (each 1 bit) plus 2 reserved bits for future use. That's exactly 8 bits = 1 byte total! Each bit represents a state: is the entity alive, invincible, able to jump, on the ground, facing right, or currently animating. Compare this to a naive approach where each bool uses 1 byte - that would be 8 bytes per entity instead of 1. With thousands of game entities, this saves massive amounts of memory and improves cache performance.

void printEntityState(const char *name, struct GameEntityFlags *flags) {
    printf("%s: ", name);
    if (flags->isAlive) {
        printf("[ALIVE] ");
        printf("%s %s ", flags->facingRight ? "RIGHT" : "LEFT",
               flags->onGround ? "GROUND" : "AIR");
        printf("%s\n", flags->isAnimating ? "(animating)" : "");
    } else {
        printf("[DEAD]\n");
    }
}

We create a helper function printEntityState() that displays the state of an entity in a human-readable format. The function reads the bit fields and interprets them into a meaningful game state description. For example, if isAlive is 1, it shows [ALIVE], then prints the direction (LEFT/RIGHT) and position (GROUND/AIR). If also animating, it adds "(animating)". This shows how bit fields work seamlessly with conditional logic.

int main() {
    struct GameEntityFlags player = {
        .isAlive = 1,
        .isInvincible = 0,
        .canJump = 1,
        .onGround = 1,
        .facingRight = 1,
        .isAnimating = 0
    };

We create a player entity with initial state: alive, not invincible, can jump, standing on ground, facing right, and not animating. We use designated initializers (.fieldName = value) to set specific bit fields clearly. Notice we don't set the reserved bits - they're for internal bookkeeping. This player configuration represents a standing character ready to act.

    struct GameEntityFlags enemy = {
        .isAlive = 1,
        .isInvincible = 0,
        .canJump = 0,
        .onGround = 1,
        .facingRight = 0,
        .isAnimating = 1
    };

We create an enemy entity with different state: alive, not invincible, cannot jump, standing on ground, facing left, and currently animating. This might represent an enemy performing an attack animation. The canJump=0 could indicate this is a heavy enemy that can't jump. Notice how each bit field is independently controlled - the struct lets us manage 6 separate boolean states efficiently.

    printEntityState("Player", &player);
    printEntityState("Enemy", &enemy);
    
    printf("Size per entity: %zu bytes (8 flags fit in 1 byte!)\n",
           sizeof(struct GameEntityFlags));
    
    return 0;
}

We print the state of both entities using our helper function, showing the readable output for each. Then we print the actual size of the GameEntityFlags struct - just 1 byte! All 6 boolean game state flags plus 2 reserved bits fit in a single byte. In a game with 10,000 entities, this saves 70 kilobytes compared to naive boolean storage. Multiply that by modern game complexity with hundreds of such flags per entity, and bit fields become essential for performance.

Output
Player: [ALIVE] RIGHT GROUND 
Enemy: [ALIVE] LEFT GROUND (animating)
Size per entity: 1 bytes (8 flags fit in 1 byte!)
Practice Questions: Bit Fields

Test your understanding with these hands-on exercises.

Task: For each bit field, calculate the maximum value it can store:

// What are max values for:
unsigned int a : 1;   // Max: ?
unsigned int b : 3;   // Max: ?
unsigned int c : 5;   // Max: ?
unsigned int d : 8;   // Max: ?
Show Solution
1 bit:  max = 2^1 - 1 = 1
3 bits: max = 2^3 - 1 = 7
5 bits: max = 2^5 - 1 = 31
8 bits: max = 2^8 - 1 = 255

Task: Design a bit field structure to pack these IPv4 header flags (total 3 bits):

  • Reserved (1 bit - must be 0)
  • Don't Fragment (1 bit)
  • More Fragments (1 bit)

Write code to set and read each flag.

Show Solution
struct IPv4Flags {
    unsigned int reserved : 1;      // Bit 0
    unsigned int dontFragment : 1;  // Bit 1
    unsigned int moreFragments : 1; // Bit 2
    unsigned int padding : 5;       // Fill to byte boundary
};

struct IPv4Flags flags = {0, 1, 0};  // Don't Fragment = 1
if (flags.dontFragment) printf("Fragmentation disabled\n");

Task: Video frame metadata needs to store:

  • Frame type (3 bits: I-frame, P-frame, B-frame)
  • Quality level (4 bits: 0-15)
  • Is keyframe (1 bit)
  • Interlaced (1 bit)
  • Frame number (23 bits)

Design a struct using bit fields and verify its size doesn't exceed necessary bytes.

Show Solution
struct FrameMetadata {
    unsigned int frameType : 3;    // 3 bits
    unsigned int quality : 4;      // 4 bits
    unsigned int isKeyframe : 1;   // 1 bit
    unsigned int interlaced : 1;   // 1 bit
    unsigned int frameNumber : 23; // 23 bits
};  // Total: 32 bits = 4 bytes

printf("Size: %zu bytes\n", sizeof(struct FrameMetadata));  // 4 bytes

Task: USB device descriptors use a compact 2-byte bmAttributes field:

  • Bits 0-4: Reserved (5 bits)
  • Bit 5: Remote Wakeup (1 bit)
  • Bit 6: Self Powered (1 bit)
  • Bit 7: Must be 1 (1 bit)
  • Byte 1: MaxPower in 2mA units (8 bits for up to 500mA)

Create the bit field structure and write code that validates the required bit 7 and reads MaxPower.

Show Solution
struct USBAttributes {
    unsigned char reserved : 5;      // Bits 0-4
    unsigned char remoteWakeup : 1; // Bit 5
    unsigned char selfPowered : 1;  // Bit 6
    unsigned char mustBeOne : 1;    // Bit 7 (required = 1)
};

struct USBDescriptor {
    struct USBAttributes attr;
    unsigned char maxPower;  // In 2mA units
};

void validateUSBDevice(struct USBDescriptor *d) {
    if (!d->attr.mustBeOne) printf("ERROR: Invalid descriptor\n");
    int mAmps = d->maxPower * 2;
    printf("Max power: %dmA, Self-powered: %d\n", 
           mAmps, d->attr.selfPowered);
}
04

Enumerations

Enumerations (enums) define a set of named integer constants, making code more readable and maintainable than using magic numbers.

Enum Syntax
enum EnumName {
    CONSTANT1,      // = 0
    CONSTANT2,      // = 1
    CONSTANT3 = 10, // = 10
    CONSTANT4       // = 11
};

enum EnumName variable = CONSTANT2;

Basic Enumeration

A basic enumeration is the simplest form of enum, where you define a named set of integer constants. Each constant automatically gets a value starting from 0 and incrementing by 1. Instead of using magic numbers (0 for Sunday, 1 for Monday, etc.), enums give those numbers meaningful names that make code self-documenting. This is especially useful for representing days, states, directions, or any fixed set of options.

#include <stdio.h>

enum Day {
    SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY
};

We define an enum called Day with seven constants representing the days of the week. Because we don't specify values, C automatically assigns: SUNDAY=0, MONDAY=1, TUESDAY=2, ..., SATURDAY=6. This enum becomes a new data type we can use to declare variables. Using WEDNESDAY instead of the magic number 3 makes code immediately clear - no guessing what 3 means!

const char* getDayName(enum Day d) {
    const char* names[] = {
        "Sunday", "Monday", "Tuesday", "Wednesday",
        "Thursday", "Friday", "Saturday"
    };
    return names[d];
}

We create a helper function getDayName() that converts an enum Day value into a human-readable string. The array is indexed by the enum value (0-6), so WEDNESDAY (value 3) returns names[3] which is "Wednesday". This shows the power of enums: they work seamlessly as array indices while keeping code readable with named constants.

int main() {
    enum Day today = WEDNESDAY;
    
    printf("Today is: %s (value: %d)\n", getDayName(today), today);

We declare a variable today of type enum Day and set it to WEDNESDAY. We then print both the human-readable name (using our helper function) and the underlying integer value (3). This demonstrates that enums are just integers under the hood - they can be printed as numbers or converted to strings using helper functions.

    switch (today) {
        case SATURDAY:
        case SUNDAY:
            printf("It's the weekend!\n");
            break;
        default:
            printf("It's a weekday.\n");
    }
    
    return 0;
}

We use a switch statement with enum values as cases - this is a common pattern. SATURDAY and SUNDAY both lead to the same message (weekend check), while all other days go to default (weekday). Enums make switch statements type-safe and self-documenting. The compiler can even warn us if we forget to handle certain enum values!

Output
Today is: Wednesday (value: 3)
It's a weekday.

Enum with Custom Values

Sometimes you need enum values to match specific numbers instead of the default 0, 1, 2... sequence. Custom enum values allow you to assign explicit integer values to enum constants. This is crucial when the numbers have real meaning - like HTTP status codes (200, 404, 500), error codes, or hardware register bit positions. After setting a custom value, subsequent constants continue from that value unless explicitly set again.

#include <stdio.h>

enum HttpStatus {
    HTTP_OK = 200,
    HTTP_CREATED = 201,
    HTTP_BAD_REQUEST = 400,
    HTTP_NOT_FOUND = 404,
    HTTP_SERVER_ERROR = 500
};

We define an enum HttpStatus with explicit custom values. HTTP_OK is assigned 200 (the standard HTTP success code), HTTP_CREATED is 201 (standard created code), etc. Each value matches the actual HTTP protocol specification. If we didn't use custom values and relied on auto-incrementing from 0, our code would be confusing - using the magic number 200 everywhere would make it unclear that it represents "OK". With enums, HTTP_OK is self-documenting.

const char* statusMessage(enum HttpStatus status) {
    switch (status) {
        case HTTP_OK: return "OK";
        case HTTP_CREATED: return "Created";
        case HTTP_BAD_REQUEST: return "Bad Request";
        case HTTP_NOT_FOUND: return "Not Found";
        case HTTP_SERVER_ERROR: return "Server Error";
        default: return "Unknown";
    }
}

We create a helper function statusMessage() that converts an HttpStatus enum value to a human-readable message. The switch statement handles all five status codes. Notice we use the enum constants (HTTP_OK, HTTP_NOT_FOUND, etc.) as case labels - this makes the code type-safe and readable. If we received an unexpected status code, default returns "Unknown".

int main() {
    enum HttpStatus responses[] = {
        HTTP_OK, HTTP_NOT_FOUND, HTTP_SERVER_ERROR
    };

We create an array of HttpStatus values representing different HTTP responses: success (200), not found (404), and server error (500). Using enum constants in an array is cleaner than storing raw numbers like [200, 404, 500]. If someone reads this code months later, they immediately understand we're dealing with HTTP responses without needing to look up what 200 or 404 mean.

    for (int i = 0; i < 3; i++) {
        printf("%d: %s\n", responses[i], statusMessage(responses[i]));
    }
    
    return 0;
}

We iterate through the responses array, printing each one as its numeric code and human-readable message. The format "%d: %s\n" prints the integer value (200, 404, 500) and then the message from our statusMessage() function. This demonstrates the real power of custom enum values: they can be used both as meaningful names in code AND as actual numeric values for APIs or output.

Output
200: OK
404: Not Found
500: Server Error

Enum with typedef

Using typedef with enums creates custom type aliases that simplify code and make it more professional. Instead of writing "enum ProcessState" every time you declare a variable, you can simply write "ProcessState". This is especially powerful when combining typedef enums with typedef structs - you can build complex, self-documenting type systems. In production code, typedef enums are the standard way to use enumerations.

#include <stdio.h>

typedef enum {
    STATE_IDLE,
    STATE_RUNNING,
    STATE_PAUSED,
    STATE_STOPPED
} ProcessState;

We define a typedef enum called ProcessState with four states. The typedef keyword lets us use ProcessState directly as a type, without writing "enum ProcessState". This enum represents the possible states of a system process: idle (waiting), running (executing), paused (suspended), or stopped (terminated). The typedef makes our code cleaner - we can declare variables as "ProcessState state;" instead of "enum ProcessState state;".

typedef struct {
    int pid;
    char name[32];
    ProcessState state;
} Process;

We define a typedef struct called Process that represents a system process. It has three members: pid (process ID number), name (process name), and state (the ProcessState enum we just defined). Notice we use ProcessState directly without the "enum" keyword - this is the power of typedef! The struct bundles process data together, using the enum to represent its current state. This creates a clean, professional type system.

const char* stateString(ProcessState s) {
    const char* states[] = {
        "Idle", "Running", "Paused", "Stopped"
    };
    return states[s];
}

We create a helper function stateString() that converts a ProcessState enum value to a human-readable string. The array is indexed by the enum value (0-3), so STATE_RUNNING (value 1) returns "Running". This function demonstrates how typedef enums work smoothly with regular functions - we pass ProcessState as a parameter type naturally.

void printProcess(const Process *p) {
    printf("Process %d (%s): %s\n", 
           p->pid, p->name, stateString(p->state));
}

We create a printProcess() function that displays process information. It takes a pointer to a Process struct and prints the PID, process name, and state (using our stateString helper). This demonstrates good design: by using helper functions and typedef types, our code remains readable and maintainable. Each function has a clear responsibility.

int main() {
    Process processes[] = {
        {101, "WebServer", STATE_RUNNING},
        {102, "Database", STATE_IDLE},
        {103, "BackupJob", STATE_PAUSED}
    };

We create an array of Process structures representing three system processes. Each is initialized with its PID, name, and current state using the typedef enum constants. Notice how clean this looks - STATE_RUNNING, STATE_IDLE, STATE_PAUSED are much clearer than arbitrary numbers. This is the goal of typedef: making code self-documenting.

    for (int i = 0; i < 3; i++) {
        printProcess(&processes[i]);
    }
    
    // Change state
    processes[1].state = STATE_RUNNING;
    printf("\nAfter starting Database:\n");
    printProcess(&processes[1]);
    
    return 0;
}

We iterate through the processes array, printing each one. Then we simulate a state change: the Database process (index 1) transitions from STATE_IDLE to STATE_RUNNING. We print the updated state. This shows how typedef enums enable clean, readable state management. Changing states is as simple as assigning an enum constant - no magic numbers, no confusion about what value means what.

Output
Process 101 (WebServer): Running
Process 102 (Database): Idle
Process 103 (BackupJob): Paused

After starting Database:
Process 102 (Database): Running
Practice Questions: Enumerations

Test your understanding with these hands-on exercises.

Task: What are the values of each enum constant?

enum Status {
    IDLE,           // Value: ?
    RUNNING,        // Value: ?
    PAUSED = 10,    // Value: ?
    STOPPED         // Value: ?
};
Show Solution
IDLE = 0 (default starts at 0)
RUNNING = 1 (auto-increment)
PAUSED = 10 (explicit assignment)
STOPPED = 11 (continues from 10)

Task: Refactor this code to use enums instead of magic numbers:

int result = getUserRole();
switch(result) {
    case 0:
        printf("User\n");
        break;
    case 1:
        printf("Moderator\n");
        break;
    case 2:
        printf("Admin\n");
        break;
}
Show Solution
typedef enum {
    ROLE_USER,
    ROLE_MODERATOR,
    ROLE_ADMIN
} UserRole;

UserRole role = getUserRole();
switch(role) {
    case ROLE_USER:
        printf("User\n");
        break;
    case ROLE_MODERATOR:
        printf("Moderator\n");
        break;
    case ROLE_ADMIN:
        printf("Admin\n");
        break;
}

Task: Design an enum for file operation error codes following Unix conventions:

  • SUCCESS = 0
  • FILE_NOT_FOUND = 1
  • PERMISSION_DENIED = 13
  • NO_SPACE = 28

Write a function that returns error codes and a validator that checks if code is valid.

Show Solution
typedef enum {
    SUCCESS = 0,
    FILE_NOT_FOUND = 1,
    PERMISSION_DENIED = 13,
    NO_SPACE = 28
} FileError;

const char* getErrorMessage(FileError err) {
    switch(err) {
        case SUCCESS: return "Success";
        case FILE_NOT_FOUND: return "File not found";
        case PERMISSION_DENIED: return "Permission denied";
        case NO_SPACE: return "No space left";
        default: return "Unknown error";
    }
}

int isValidError(int code) {
    return (code == SUCCESS || code == FILE_NOT_FOUND || 
            code == PERMISSION_DENIED || code == NO_SPACE);
}

Task: Build a traffic light state machine using enums. Support transitions:

  • RED ? GREEN
  • GREEN ? YELLOW
  • YELLOW ? RED

Write a function that returns the next state and validates transitions.

Show Solution
typedef enum {
    RED, YELLOW, GREEN
} TrafficLight;

TrafficLight nextLight(TrafficLight current) {
    switch(current) {
        case RED: return GREEN;
        case GREEN: return YELLOW;
        case YELLOW: return RED;
    }
    return RED;  // Should never reach
}

int isValidTransition(TrafficLight from, TrafficLight to) {
    return (from == RED && to == GREEN) ||
           (from == GREEN && to == YELLOW) ||
           (from == YELLOW && to == RED);
}

int main() {
    TrafficLight light = RED;
    for (int i = 0; i < 6; i++) {
        TrafficLight next = nextLight(light);
        printf("%d -> %d (valid: %d)\n", light, next, 
               isValidTransition(light, next));
        light = next;
    }
}
05

Key Takeaways

Unions Share Memory

All union members occupy the same memory location. Only one can be used at a time, making unions ideal for memory-constrained systems.

Bit Fields Save Space

Pack multiple boolean flags or small integer values into a single byte using bit fields, reducing memory footprint significantly.

Use Tagged Unions

Include a tag field to track which union member is currently valid, enabling safe type-variant patterns.

Enums Improve Readability

Replace magic numbers with named enum constants for cleaner, more maintainable code with better debugging support.

Hardware Register Simulation

Combine unions with bit fields to model hardware registers, accessing them both as raw values and individual flags.

Choose the Right Tool

Use structures for related data, unions for variants, bit fields for tight packing, and enums for constants.

Knowledge Check

Quick Quiz

Test what you've learned about unions, bit fields, and enumerations

1 What determines the size of a union?
2 If union members share the same memory address, what happens when you assign to one member?
3 How many values can a 4-bit unsigned integer field store?
4 What is the main difference between a regular enum and a typedef enum?
5 Which scenario is better suited for a union rather than a struct?
6 What is a tagged union and why is it safer than an untagged union?
Answer all questions to check your score