Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 13

C Structures and Unions for Peripheral Registers

Recommended resources

Hello! Let's dive into the next lesson.

In our previous session, we mastered the read-modify-write (RMW) pattern using bitmasks. While effective, you know from your experience that code like REG = (REG & ~0x0000FF00) | (0x4A << 8); can be hard to read, prone to "magic number" errors, and difficult to maintain. Today, we'll build on that by learning the professional, standard practice for abstracting away these low-level operations.

This lesson directly addresses the learning outcome: Define peripheral register layouts in C using structs and unions for improved readability and access. This technique is the backbone of nearly all modern hardware abstraction layers (HALs) and the Cortex Microcontroller Software Interface Standard (CMSIS), turning cryptic bit manipulation into a clean, API-like interaction.

1. From Raw Addresses to Structured Access

In the last lesson, we worked with registers as if they were standalone volatile variables at specific addresses. A more organized approach is to group all registers belonging to a single peripheral into a C struct. The structure's members are ordered to exactly match the register map found in the microcontroller's datasheet.

How does this work?

  1. Define a struct where each member corresponds to a register in the peripheral's memory map. The member's type matches the register's size (e.g., uint32_t), and its order in the struct matches the register's offset from the peripheral's base address.
  2. Use a typedef to give this structure a clear name (e.g., GPIO_TypeDef).
  3. Define a pointer to this struct type, hard-casting it to the peripheral's base address. This pointer now serves as the handle for the entire peripheral.
  4. Access individual registers using the -> operator, which is clean, readable, and benefits from IDE autocompletion.

This is the core principle behind CMSIS. To see a detailed, practical walkthrough of this process, the following video is an excellent guide. It starts by showing how primitive macros are used and then refactors the code to use the modern, struct-based CMSIS approach.

#12 Structures in C and Cortex Microcontroller Software Interface Standard (CMSIS)

The video '#12 Structures in C and Cortex Microcontroller Software Interface Standard (CMSIS)' from Quantum Leaps, LLC, provides a masterful explanation of this concept. It connects the C struct definition directly to the register map in a device datasheet.

Please watch from 21:19 to 31:41. The first segment (21:19 - 28:10) explains how a C struct is designed to mirror a peripheral's register map, introducing CMSIS. The second segment (28:10 - 31:41) demonstrates how to rewrite code using these newly defined structures, highlighting the improvement in clarity.

This struct-based approach provides several key advantages:

  • Readability: GPIOA->ODR is far clearer than *((volatile uint32_t *)0x40020014).
  • Maintainability: All registers for a peripheral are defined in one place.
  • Reduced Errors: The compiler can catch typos in register names (e.g., GPIOA->ODRR), whereas a mistake in a magic address number would compile silently and fail at runtime.
  • Namespacing: It naturally groups registers by peripheral (GPIOA->..., TIM2->...), avoiding name collisions.

2. The Power of Unions for Bit-Level Access

Using structs for the register block is a huge improvement, but when you need to modify a specific bit-field, you still have to perform a manual read-modify-write operation:
GPIOA->MODER = (GPIOA->MODER & ~PIN5_MASK) | PIN5_OUTPUT;

We can do even better. By combining structs with unions, we can create a dual-view access mechanism for a single register. A union allows multiple members to share the same memory location. We can define one member as a uint32_t for whole-register access, and another member as a struct of bit-fields for individual field access.

Struct vs. Union Memory Allocation
This diagram illustrates the core difference between a struct and a union. In a struct, members are laid out sequentially in memory. In a union, all members occupy the same memory location, starting at the same address. We leverage this overlapping property to create different "views" of a hardware register.

Consider a GPIO Mode Register (MODER), which has 2-bit fields for each of the 16 pins. We can define it like this:

typedef union {
    volatile uint32_t reg; // View 1: Access the entire 32-bit register
    
    // View 2: Access the individual 2-bit mode fields
    volatile struct {
        uint32_t MODE0  : 2;
        uint32_t MODE1  : 2;
        uint32_t MODE2  : 2;
        uint32_t MODE3  : 2;
        uint32_t MODE4  : 2;
        uint32_t MODE5  : 2;
        // ... and so on for all 16 pins
        uint32_t        : 0; // Unused bits if any
    } fields;

} GPIO_MODER_t;

// Then, within our main peripheral struct:
typedef struct {
    GPIO_MODER_t MODER;
    volatile uint32_t OTYPER;
    // ... other registers
} GPIO_TypeDef;

#define GPIOA ((GPIO_TypeDef *) 0x40020000)

With this definition, access becomes incredibly intuitive. The compiler handles the bit-shifting and masking for you!

// To write to a specific field (no RMW needed!):
GPIOA->MODER.fields.MODE5 = 0b01; // Set pin 5 to General-purpose output

// To read a specific field:
uint8_t pin5_mode = GPIOA->MODER.fields.MODE5;

// To write to the whole register:
GPIOA->MODER.reg = 0x55555555; 

This technique is powerful and widely used in vendor-provided SDKs. The following article provides an excellent narrative on this evolution from simple pointers to sophisticated struct/union definitions.

Memory Mapped I/O in C

The article 'Memory Mapped I/O in C' by Mattia Maldini is a fantastic resource that walks through the exact progression of techniques we are discussing. It culminates in explaining this struct-and-union approach.

Please read the section titled 'Structures'. It explains the core concept and then beautifully demonstrates how to use anonymous unions and structs with bitfields for maximum precision and readability. Pay close attention to the code examples showing how this simplifies access.

Test your understanding!

A timer's control register (TIM_CR1) at offset 0x00 is 32 bits wide. You are interested in two fields:

  • CEN: Counter Enable (bit 0, 1 bit wide)
  • DIR: Direction (bit 4, 1 bit wide)

Define a union named TIM_CR1_t that allows both whole-register access and individual access to the CEN and DIR bit-fields. You will need to account for the reserved bits between them.

Show answer
typedef union {
    volatile uint32_t reg; // Whole register access

    volatile struct {
        uint32_t CEN      : 1; // Bit 0
        uint32_t          : 3; // Reserved bits 1, 2, 3
        uint32_t DIR      : 1; // Bit 4
        uint32_t          : 27; // The rest of the bits
    } fields;

} TIM_CR1_t;

With this definition, you could enable the counter by simply writing TIMER->CR1.fields.CEN = 1;. The unnamed bit-fields act as padding to ensure subsequent members are at the correct bit position.


3. The Critical Caveat: Implementation-Defined Behavior

This technique feels almost too good to be true, and there is one major "gotcha" that an experienced engineer must be aware of. The C standard makes very few guarantees about memory layout:

  1. Padding: The compiler is free to insert padding bytes between struct members to satisfy alignment requirements. For a register block where registers are tightly packed, this would be disastrous.
  2. Bit-field Order: The standard does not specify whether bit-fields are allocated from the least-significant bit (LSB) upwards or the most-significant bit (MSB) downwards.

This means that, in theory, code using this technique is not portable. However, in practice, this is not a problem for two reasons:

  1. Most embedded compilers (like ARM-GCC) provide extensions to control this behavior. For example, __attribute__((packed)) can be used to tell the compiler not to insert any padding.
  2. For a specific target architecture and compiler (e.g., ARM Cortex-M and GCC), the behavior is consistent and well-documented.

This is why you can rely on vendor-provided files (like CMSIS headers). The chip manufacturer has already done the work of defining these structs and unions correctly for the recommended toolchain. When you write your own, you must be aware of your compiler's specific behavior.

Memory Mapped I/O in C

Understanding this caveat is what separates a proficient user of HALs from a true low-level expert. The 'Memory Mapped I/O in C' article concludes with an excellent section on this exact topic.

Please read the section titled 'The Catch'. It clearly explains the risks associated with struct padding and bitfield ordering and how to mitigate them.


Interview Preparation

  • Question: "You're starting a new driver project for a bare-metal target. Instead of creating #define macros for every register address, what more structured and maintainable approach would you use?"
    • Your Answer: "I would define a C struct that mirrors the memory layout of each peripheral's register block, as specified in the datasheet. Then, I'd create a single volatile pointer to that struct type, casting it to the peripheral's base address. This gives me a clean, namespaced, and type-safe way to access all registers (e.g., GPIOA->ODR), which improves readability and allows for compile-time checking of register names."
  • Question: "How can you use C features to write to a specific 2-bit configuration field within a register without manually performing a read-modify-write with bitmasks?"
    • Your Answer: "I would use a union to provide two 'views' of the register. One member of the union would be a uint32_t for whole-register access. The other member would be a struct containing bit-fields that match the layout of the register. This allows me to write directly to a named field, like TIMER->CR1.fields.MODE = 0b10;, and the compiler automatically generates the correct, efficient assembly to modify only those specific bits."
  • Question (Advanced): "What is the primary risk of using structs and bit-fields to map hardware registers, and how do you ensure your code is reliable?"
    • Your Answer: "The primary risk is that the C standard considers struct padding and bit-field allocation order to be implementation-defined. This means my code might not be portable across different compilers or even compiler versions. To ensure reliability, I would first consult the compiler's documentation to understand its specific behavior. Second, I would use compiler-specific directives like __attribute__((packed)) to prevent unwanted padding. Finally, for any established platform, I would use the vendor-provided CMSIS or SDK header files, as they have already solved these issues for the target hardware and recommended toolchain."

Conclusion

Today, we've elevated our approach to hardware interaction from manual bit-fiddling to a structured, professional methodology. This is a critical skill that demonstrates experience and a focus on creating clean, robust, and maintainable embedded software.

Key Takeaways:

  • C structs can perfectly model a peripheral's register map, providing a clean, namespaced interface.
  • A single volatile pointer to a struct, aimed at a peripheral's base address, is the standard way to access its registers.
  • unions combined with bit-field structs offer a powerful way to get direct, readable access to individual bit-fields without manual masking.
  • This technique relies on implementation-defined behavior, so you must be aware of your compiler's rules regarding padding and bit-field order.

Next Lesson Preview:
Our discussion on how structs and unions lay out data in memory naturally leads to another fundamental concept: endianness. In the next lesson, we will explore how multi-byte data types are stored in memory and write a C program to detect the endianness of the target processor. This is another classic embedded topic and a common interview question.