Table of Contents
KEY TAKEAWAYS
- Unions store all members at the same memory address, so the size equals the largest member. Only one member is valid at any time.
- Type punning with unions lets you reinterpret data (e.g., float to uint32_t) without pointer casting, which is essential for protocol serialization.
- Combining a union with a bitfield struct gives you clean register-level hardware access in embedded C.
- Endianness matters: union-based byte access produces different results on big-endian vs little-endian processors.
- Writing one union member and reading another is implementation-defined in C (defined behavior in C99 for type punning, but be careful with strict aliasing).
Unions are a fundamental data structure in C that allow you to store different types of data in the same memory location. In embedded systems programming, unions are not just a curiosity; they are a practical tool for register access, protocol parsing, and memory-efficient data handling. This guide covers union basics, memory layout, and the advanced embedded-specific patterns you need to know.
What are Unions?
Unions in C are a user-defined data type that allows multiple members to share the same memory location. They are defined using the union keyword, followed by a set of member variables enclosed in braces. Unions can hold values of different data types, but only one member can contain a valid value at a time.
union UnionName {
dataType1 member1;
dataType2 member2;
/* ... */
};To access a member of a union, use the dot (.) operator for direct variables or the arrow (->) operator for pointers, just like with structs.
Memory Layout Visualization
The key difference between unions and structs is how memory is allocated. In a struct, each member gets its own memory region and the total size is the sum of all members (plus padding). In a union, all members share the same starting address and the total size equals the largest member.
/* Memory layout comparison */
struct ExampleStruct {
int i; /* Bytes 0-3 */
float f; /* Bytes 4-7 */
char c; /* Byte 8 (+ 3 bytes padding = 12 total) */
};
/* sizeof(struct ExampleStruct) = 12 bytes */
union ExampleUnion {
int i; /* Bytes 0-3 */
float f; /* Bytes 0-3 (overlaps with i) */
char c; /* Byte 0 (overlaps with i and f) */
};
/* sizeof(union ExampleUnion) = 4 bytes (size of largest member) */
/* Prove it: */
union ExampleUnion u;
printf("Address of u.i: %p\n", (void*)&u.i);
printf("Address of u.f: %p\n", (void*)&u.f);
printf("Address of u.c: %p\n", (void*)&u.c);
/* All three print the SAME address! */
This overlapping memory layout means that writing to one member overwrites the data of all other members. This is both the power and the danger of unions.
Type Punning with Unions
Type punning means reinterpreting the bits of one data type as another. This is extremely common in embedded programming, for example when you need to send a float value over a byte-oriented protocol like UART or CAN. Instead of using pointer casts (which can violate strict aliasing rules), unions provide a clean and portable way to do this.
/* Float to bytes conversion using a union */
#include <stdio.h>
#include <stdint.h>
typedef union {
float f;
uint32_t u;
uint8_t bytes[4];
} FloatConverter_t;
/* Send a float value over UART as 4 bytes */
void send_float_over_uart(float value) {
FloatConverter_t conv;
conv.f = value;
/* Now access the same bits as individual bytes */
for (int i = 0; i < 4; i++) {
uart_send_byte(conv.bytes[i]);
}
}
/* Or inspect the IEEE 754 representation */
void print_float_bits(float value) {
FloatConverter_t conv;
conv.f = value;
printf("Float %.2f = 0x%08X\n", conv.f, conv.u);
printf("Bytes: %02X %02X %02X %02X\n",
conv.bytes[0], conv.bytes[1],
conv.bytes[2], conv.bytes[3]);
}
/* Example output on little-endian ARM:
Float 3.14 = 0x4048F5C3
Bytes: C3 F5 48 40 */Protocol Parsing: Union with Struct for UART/CAN Frames
In embedded communication, you often receive a raw byte buffer and need to interpret it as a structured message. Unions combined with packed structs make this clean and efficient, with zero-copy parsing.
/* CAN-style message parsing with union */
#include <stdint.h>
#include <string.h>
/* Define the message structure (packed to prevent padding) */
typedef struct __attribute__((packed)) {
uint8_t msg_id;
uint8_t length;
uint16_t sensor_value;
int16_t temperature; /* 0.1 degree resolution */
uint8_t status_flags;
uint8_t checksum;
} SensorMessage_t;
/* Union for zero-copy parsing */
typedef union {
SensorMessage_t fields;
uint8_t raw[sizeof(SensorMessage_t)];
} SensorFrame_t;
/* Parse incoming UART data */
void process_received_data(uint8_t *rx_buffer, uint8_t len) {
SensorFrame_t frame;
if (len != sizeof(SensorFrame_t)) return;
/* Copy raw bytes into the union */
memcpy(frame.raw, rx_buffer, sizeof(SensorFrame_t));
/* Now access structured fields directly */
printf("Message ID: 0x%02X\n", frame.fields.msg_id);
printf("Sensor: %u\n", frame.fields.sensor_value);
printf("Temp: %.1f Cn", frame.fields.temperature / 10.0f);
printf("Status: 0x%02X\n", frame.fields.status_flags);
/* Verify checksum */
uint8_t calc_checksum = 0;
for (int i = 0; i < sizeof(SensorFrame_t) - 1; i++) {
calc_checksum += frame.raw[i];
}
if (calc_checksum != frame.fields.checksum) {
printf("Checksum FAILED!\n");
}
}Register Access Pattern: Union with Bitfield Struct
One of the most powerful embedded uses of unions is hardware register manipulation. By combining a union with a bitfield struct, you can access individual bits of a register by name while still being able to read or write the entire register as a single value.
/* Hardware register access using union + bitfield */
#include <stdint.h>
typedef union {
uint32_t reg; /* Access entire 32-bit register */
struct {
uint32_t enable : 1; /* Bit 0: Peripheral enable */
uint32_t direction : 1; /* Bit 1: 0=input, 1=output */
uint32_t speed : 2; /* Bits 2-3: Speed setting */
uint32_t pull : 2; /* Bits 4-5: Pull-up/down */
uint32_t reserved : 2; /* Bits 6-7: Reserved */
uint32_t irq_en : 1; /* Bit 8: Interrupt enable */
uint32_t irq_flag : 1; /* Bit 9: Interrupt flag (write 1 to clear) */
uint32_t _unused : 22; /* Bits 10-31 */
} bits;
} GPIO_Config_t;
/* Usage */
volatile GPIO_Config_t *gpio = (GPIO_Config_t *)0x40020000;
void configure_gpio_pin(void) {
/* Method 1: Set individual bits by name */
gpio->bits.enable = 1;
gpio->bits.direction = 1; /* Output */
gpio->bits.speed = 3; /* High speed */
gpio->bits.pull = 1; /* Pull-up */
gpio->bits.irq_en = 0; /* No interrupt */
/* Method 2: Write entire register at once */
gpio->reg = 0x0000001F; /* Same configuration in one write */
/* Method 3: Read-modify-write */
GPIO_Config_t temp;
temp.reg = gpio->reg; /* Read current value */
temp.bits.speed = 2; /* Modify one field */
gpio->reg = temp.reg; /* Write back */
}Union vs Struct Memory Comparison
Understanding the size difference is critical when working on memory-constrained embedded systems. Here is a direct comparison:
#include <stdio.h>
#include <stdint.h>
struct SensorData_Struct {
uint32_t timestamp; /* 4 bytes */
float temperature; /* 4 bytes */
uint16_t humidity; /* 2 bytes + 2 padding */
uint8_t status; /* 1 byte + 3 padding */
};
/* sizeof = 16 bytes (with typical alignment) */
union SensorData_Union {
uint32_t timestamp; /* 4 bytes */
float temperature; /* 4 bytes (overlaps) */
uint16_t humidity; /* 2 bytes (overlaps) */
uint8_t status; /* 1 byte (overlaps) */
};
/* sizeof = 4 bytes (size of largest member) */
int main(void) {
printf("Struct size: %zu bytesn", sizeof(struct SensorData_Struct));
printf("Union size: %zu bytesn", sizeof(union SensorData_Union));
return 0;
}
/* Output:
Struct size: 16 bytes
Union size: 4 bytes */Endianness Considerations
When you use a union to access individual bytes of a multi-byte value, the byte order depends on the processor endianness. ARM Cortex-M processors are little-endian by default (least significant byte at the lowest address), while some networking protocols and older architectures use big-endian byte order.
/* Endianness detection and handling */
typedef union {
uint32_t word;
uint8_t bytes[4];
} EndianTest_t;
int is_little_endian(void) {
EndianTest_t test;
test.word = 0x01020304;
/* Little-endian: bytes[0] = 0x04 (LSB first) */
/* Big-endian: bytes[0] = 0x01 (MSB first) */
return (test.bytes[0] == 0x04);
}
/* When sending data over a network (big-endian),
swap bytes on little-endian systems: */
uint32_t swap_bytes_32(uint32_t val) {
EndianTest_t in, out;
in.word = val;
out.bytes[0] = in.bytes[3];
out.bytes[1] = in.bytes[2];
out.bytes[2] = in.bytes[1];
out.bytes[3] = in.bytes[0];
return out.word;
}Common Pitfalls
Undefined vs implementation-defined behavior: In C89, writing one union member and reading another was undefined behavior. C99 clarified that this is allowed for type punning (the compiler must handle it correctly). However, some compilers with aggressive optimization may still break this pattern under strict aliasing rules. Using -fno-strict-aliasing or a memcpy approach is the safest portable option.
Alignment issues: Unions inherit the strictest alignment requirement of their members. If a union contains a double (8-byte alignment) and a char array, the entire union will be 8-byte aligned even if you only use the char array. This can waste memory in arrays of unions on alignment-strict architectures.
Bitfield portability: The ordering of bits within a bitfield struct is implementation-defined. Some compilers place bit 0 at the MSB, others at the LSB. When using union-with-bitfield patterns for hardware registers, always verify with your specific compiler and target. Read the processor reference manual to confirm bit positions.
Packed struct padding: When using unions with packed structs for protocol parsing, remember that __attribute__((packed)) is a GCC extension, not standard C. MSVC uses #pragma pack, and IAR uses #pragma pack or __packed. Always test sizeof() to verify the struct is truly packed.
Real Embedded Example: Sensor Data Packet Parsing
Here is a complete, real-world example that combines several union patterns to parse a multi-sensor data packet received over SPI from a sensor hub:
/* Multi-sensor packet parser using unions */
#include <stdint.h>
#include <string.h>
#define PACKET_HEADER 0xAA
#define SENSOR_TEMP 0x01
#define SENSOR_ACCEL 0x02
#define SENSOR_PRESSURE 0x03
typedef struct __attribute__((packed)) {
uint8_t header; /* Always 0xAA */
uint8_t sensor_type; /* Sensor ID */
uint8_t length; /* Payload length */
} PacketHeader_t;
typedef struct __attribute__((packed)) {
int16_t temperature; /* 0.01 degree C resolution */
uint8_t sensor_id;
} TempPayload_t;
typedef struct __attribute__((packed)) {
int16_t x, y, z; /* Raw accelerometer data */
uint8_t range; /* 0=2g, 1=4g, 2=8g, 3=16g */
} AccelPayload_t;
typedef union {
TempPayload_t temp;
AccelPayload_t accel;
uint8_t raw[16];
} SensorPayload_t;
typedef struct __attribute__((packed)) {
PacketHeader_t header;
SensorPayload_t payload;
} SensorPacket_t;
void parse_sensor_packet(uint8_t *data, uint8_t len) {
SensorPacket_t pkt;
memcpy(&pkt, data, len);
if (pkt.header.header != PACKET_HEADER) return;
switch (pkt.header.sensor_type) {
case SENSOR_TEMP:
printf("Temperature: %.2f C (sensor %d)\n",
pkt.payload.temp.temperature / 100.0f,
pkt.payload.temp.sensor_id);
break;
case SENSOR_ACCEL:
printf("Accel X=%d Y=%d Z=%d (range=%dg)\n",
pkt.payload.accel.x,
pkt.payload.accel.y,
pkt.payload.accel.z,
2 << pkt.payload.accel.range);
break;
}
}This pattern is used in production firmware for sensor hubs, telemetry systems, and multi-protocol gateways. The union allows the same buffer to hold different payload types without wasting memory, and the packed structs ensure the layout matches the wire format exactly.
Read about Structures in C – Structures in C – NerdyElectronics

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.







