Hello! Welcome back to our advanced course on C pointers.
In our last lesson, we navigated the complex territory of the Strict Aliasing Rule (SAR). We established that the only truly standard-compliant and portable way to perform type punning is with memcpy, and that direct pointer casting for this purpose is undefined behavior. You might be wondering, "If memcpy is the correct way, why bother with anything else?"
Today, we'll answer that by exploring a powerful, idiomatic, and extremely common technique used in systems programming for this exact purpose: the union. While memcpy is the portable champion, unions offer a syntactically cleaner and often more intuitive way to reinterpret memory, especially in the embedded world you're so familiar with.
This lesson will address the learning outcome: Use unions with pointers for type punning, and describe its use in embedded systems. We'll examine how they work, where they shine in automotive and embedded contexts, and the important standard-related nuances you need to know.
What is a Union?
First, let's establish what a union is and how it differs from a struct. While a struct allocates memory for all of its members sequentially, a union allocates only enough memory to hold its largest member. All members of a union share that same memory space.
This fundamental difference is the key to its power.
Union Data Types | C Programming Tutorial
To start, let's get a clear visual of how unions work. This video from Portfolio Courses provides an excellent foundational explanation.
Watch the following segments: 00:00 - 04:03: The video explains the core concept of a union, how its members overwrite each other in shared memory, and contrasts this with a struct. 04:03 - 07:09: Pay attention to how the sizeof a union is determined by its largest member. 07:09 - 08:11: This part briefly demonstrates how pointers to unions work, using the familiar arrow (->) notation.
Type Punning: Memory Reinterpretation
The fact that all members of a union occupy the same memory address allows us to perform type punning: writing to one member and reading from another to reinterpret the same underlying bits as a different type.
This is not a memory copy; it's a direct reinterpretation of the bytes in place.
Unions - Embedded C Programming
The cheat sheet 'Unions - Embedded C Programming' from EWskills provides a concise summary of this core concept and its applications.
Read the introductory section and the section titled 'Key Concept: Memory Reinterpretation'. This will reinforce the idea that unions provide a way to look at the same data through different 'lenses' (types) without any conversion or copying.
For example, consider a common task in communications: breaking a 32-bit integer into its constituent bytes for transmission over a serial bus like CAN or UART.
#include <stdio.h>
#include <stdint.h>
// A union to reinterpret a 32-bit integer as 4 bytes
typedef union {
uint32_t word;
uint8_t bytes[4];
} Converter;
int main() {
Converter c;
c.word = 0x12345678; // Write as a 32-bit integer
// Read as individual bytes
// The order depends on the system's endianness!
// On a little-endian system (like x86):
printf("Byte 0: 0x%02x\n", c.bytes[0]); // 0x78
printf("Byte 1: 0x%02x\n", c.bytes[1]); // 0x56
printf("Byte 2: 0x%02x\n", c.bytes[2]); // 0x34
printf("Byte 3: 0x%02x\n", c.bytes[3]); // 0x12
return 0;
}
The Standards Minefield: C vs. C++
Now, let's address the elephant in the room from our last lesson. Is this legal?
- In C (C99 and later): Yes. The standard was updated to make this a well-defined behavior. You can write to one member of a union and read from another. It's a key feature of the language.
- In C++: It is technically Undefined Behavior (UB). The C++ standard did not adopt the same rule as C. Reading from a union member that wasn't the one last written to is not strictly allowed.
So why is it used everywhere, even in C++-based embedded projects? Because it's a common and widely-supported compiler extension. Major compilers like GCC and Clang, especially in the context of embedded development, support this behavior.
Bare Metal C++ Register Access API - AllThingsEmbedded
This reality is discussed in detail in professional embedded contexts. The article 'Bare Metal C++ Register Access API' explains this exact dilemma when discussing the ARM CMSIS libraries, which are foundational in the ARM ecosystem.
Read the section 'Getting help from unions and bitfields'. Focus on the author's discussion of using unions for type punning, the acknowledgment that it's UB in C++, and the pragmatic reality that it's still used in major libraries like CMSIS, often within an extern "C" block to signal C-style semantics to the compiler.
The pragmatic takeaway for a professional like you is to know the formal rule but also understand the common practice for your target platform and toolchain. For MISRA compliance, this is an area where deviations are often required and must be rigorously justified.
Application 1: The Killer App for Unions - Hardware Register Access
Your work with microcontrollers, ADAS, and gateway development involves constant interaction with memory-mapped hardware registers. This is where unions are most powerful.
Peripherals often have control and status registers where different bits or groups of bits have specific meanings. You could manipulate them with manual bitwise operations, but this is error-prone and hard to read.
A much cleaner approach is to use a union to overlay a struct of bitfields onto the raw register value.
the cleanest feature in C that you've probably never heard of
The 'Low Level' YouTube channel has a fantastic visualization of this exact technique.
Watch the section from 03:26 to 04:35. It demonstrates how to define a register with different fields (control, direction, parity) and use a union to access either the entire register value at once or the individual bitfields by name.
Let's formalize this with an example. Imagine a GPIO control register at address 0x40020000.
#include <stdint.h>
// Define the bitfields of the register
typedef struct {
volatile uint32_t MODE : 2; // Bits 0-1: Port mode
volatile uint32_t SPEED : 2; // Bits 2-3: Output speed
volatile uint32_t PUPD : 2; // Bits 4-5: Pull-up/pull-down
volatile uint32_t OTYPE : 1; // Bit 6: Output type
volatile uint32_t : 25; // Bits 7-31: Reserved
} GPIO_Ctrl_Bits;
// Create a union to access as bits or as a whole word
typedef union {
GPIO_Ctrl_Bits bits;
volatile uint32_t word;
} GPIO_Ctrl_Reg;
// Create a pointer to the actual hardware address
#define GPIO_CTRL ((GPIO_Ctrl_Reg *)0x40020000)
void setup_gpio_pin() {
// Method 1: Modify individual fields (causes multiple read-modify-writes)
GPIO_CTRL->bits.MODE = 1; // Set mode to General purpose output
GPIO_CTRL->bits.SPEED = 2; // Set speed to high
// Method 2: Atomic update (preferred for setting multiple fields)
uint32_t temp_reg = GPIO_CTRL->word; // Read current state
temp_reg &= ~((0x3 << 2) | (0x3 << 0)); // Clear MODE and SPEED fields
temp_reg |= (1 << 0) | (2 << 2); // Set new values
GPIO_CTRL->word = temp_reg; // Write back in a single operation
}
Notice the use of volatile. Just as with raw pointers to hardware, you must use volatile to prevent the compiler from optimizing away essential reads and writes.
Important Caveats:
- Bitfield Order: The C standard says the layout of bitfields in memory is
implementation-defined. This code is not portable. However, for a given target and compiler (e.g., ARM Cortex-M witharm-none-eabi-gcc), the behavior is consistent and reliable. - Atomicity: As the code comments show, writing to individual bitfields often compiles to a separate read-modify-write sequence for each access. If you need to change multiple fields atomically, it's better to read the whole
.word, modify it in a temporary variable, and write it back once.
Application 2: Protocol Data Handling (Zero-Copy Buffers)
In your work with CAN, SOME/IP, and automotive Ethernet, you constantly pack and unpack data frames. Unions are perfect for this. You can define a union that contains both a struct for the logical fields of a message and a raw byte array for the physical representation.
Unions - Embedded C Programming
The EWskills cheat sheet provides an excellent example of this pattern.
In the section 'Common Union Patterns in Firmware', look at example #3, 'Overlay Struct on Union (Packet Layout)'. This shows exactly how to define a union for a packet with both a structured view and a raw byte array view.
This allows for a zero-copy workflow:
- Receiving Data: The hardware DMA or driver writes raw bytes directly into the
raw[]member of your union. - Parsing: You can immediately access the logical fields via the
structmember (my_packet.fields.header) without calling any parsing functions ormemcpy. - Transmitting Data: You populate the
structmembers and then pass a pointer to theraw[]member to the transmit function.
Test your understanding!
A sensor communicates its status using a 16-bit register. The format is:
- Bits
0-11: ADC reading (data) - Bits
12-13: Gain setting (gain) - Bit
14: Data ready flag (ready) - Bit
15: Error flag (error)
- Define a
unionnamedADC_Resultthat allows access to this register as both auint16_tnamedrawand astructof bitfields namedbits. - Write a short code snippet that, given
ADC_Result res, checks if theerrorflag is set. If it is, it should clear only theerrorflag in a single, atomic write operation, leaving all other bits unchanged.
Show answer
#include <stdint.h>
// 1. Define the union
typedef union {
struct {
volatile uint16_t data : 12;
volatile uint16_t gain : 2;
volatile uint16_t ready : 1;
volatile uint16_t error : 1;
} bits;
volatile uint16_t raw;
} ADC_Result;
void clear_error_flag(ADC_Result* res) {
// 2. Check and clear the error flag atomically
if (res->bits.error) {
// To clear only the error bit atomically, we perform a read-modify-write
// on the entire raw register value.
// We use a bitwise AND with the inverted bit mask for the error flag.
uint16_t error_mask = (1 << 15);
res->raw = res->raw & (~error_mask);
// A more direct way with some compilers/architectures:
// res->raw &= ~(1 << 15);
}
}
This solution demonstrates the union/bitfield pattern and the correct way to perform an atomic modification by operating on the entire .raw member, not the individual .bits member.
Conclusion
Today, we've added the union to your toolkit as a powerful mechanism for type punning, sitting alongside the portable memcpy method we learned about previously. While its formal standing in C++ is tricky, its practical utility in C and embedded C++ is undeniable.
Key Takeaways:
- A
unionis a data structure where all members share the same memory location, making it a natural tool for memory reinterpretation. - The primary use case in embedded systems is accessing hardware registers, using a
unionto overlay astructof bitfields on a raw integer type. This provides readable, named access to register fields. - Unions are also excellent for zero-copy handling of communication protocol data, allowing you to seamlessly switch between a raw byte buffer and a structured view of a packet.
- You must always be mindful of non-portable behaviors, especially endianness and compiler-defined bitfield layouts.
- When modifying multiple bitfields, prefer a single write to the union's whole-word member to ensure atomicity.
Preview of the Next Lesson:
Our discussion of hardware access has naturally led us to the next topic. When you cast a hardcoded address like 0x40020000 to a pointer, you're making a big assumption: that the CPU can actually perform a 32-bit access at that specific address. What if it can't? In our next lesson, we will begin our module on "Pointers in Automotive and Embedded Systems" by learning how to handle pointer alignment requirements for different embedded architectures. This is a critical factor for correctness and performance on many processors.