Pointers in Embedded C: Microcontroller Register Access

Pointers in Embedded C Microcontroller Register Access

Pointers are one of the most important concepts in Embedded C because they allow a program to work directly with memory addresses. In microcontroller-based systems, this becomes especially important because peripherals such as GPIO, UART, timers, ADCs, SPI, and I2C are commonly controlled through hardware registers located at specific memory addresses. Understanding pointers in Embedded C helps engineers understand what happens underneath hardware abstraction layers and peripheral libraries. Instead of treating a register as an abstract software object, a programmer can understand it as a hardware resource associated with a particular address.

For example, a simplified register access may look like this:

volatile unsigned int *reg =

    (volatile unsigned int *)0x40020000U;

*reg = 0x00000001U;

The pointer represents the address 0x40020000, while dereferencing the pointer accesses the register at that address.

This article explains how pointers work in Embedded C, how they are connected to memory-mapped I/O, why the volatile keyword is important, and how pointers are used with GPIO, UART, timer, and ARM Cortex-M registers.

Pointers in Embedded C allow programmers to access specific memory addresses used by microcontroller peripherals.
Memory-mapped registers can be accessed through volatile pointers and manipulated using bitwise operators and masks.
Understanding these concepts helps engineers work with GPIO, UART, timers, ARM Cortex-M, STM32, and low-level firmware.

Table of Contents

What Is a Pointer in Embedded C?

A pointer is a variable that stores the memory address of another object.

Consider a simple C program:

int value = 10;
int *ptr;

ptr = &value;

Here, value is an integer variable and ptr is a pointer to an integer.

The address operator & obtains the memory address of value:

ptr = &value;

The pointer now contains the address where value is stored.

The * operator is used to dereference the pointer:

*ptr = 20;

This changes the value stored at the memory location pointed to by ptr.

The basic relationship can be represented as:

Variable
   |
   | & address operator
   v
Memory Address
   |
   | stored in
   v
Pointer
   |
   | * dereference
   v
Value at that address

In conventional C programming, pointers are frequently used for arrays, structures, dynamic memory, function arguments, and data structures.

In embedded systems, pointers have an additional and extremely important application: accessing hardware registers.

 

 

registor_now_P

 

 

Why Are Pointers Important in Embedded Systems?

A microcontroller contains different types of memory and hardware resources. These may include Flash memory, SRAM, peripheral registers, and system-control registers.

Many peripherals are assigned specific addresses within the microcontroller’s address space.

A simplified example could look like this:

0x40020000    GPIO Control Register
0x40020004    GPIO Status Register
0x40020008    GPIO Output Register

These addresses are examples only. Actual addresses depend on the microcontroller.

A pointer can be used to represent one of these addresses:

volatile unsigned int *gpio_output =
    (volatile unsigned int *)0x40020008U;

The pointer now represents the address of the GPIO output register.

A write operation can then be performed using:

*gpio_output = 0x00000001U;

The CPU performs a memory write to that address, and the associated peripheral hardware responds according to the register definition.

This is the foundation of register-level programming.

Understanding Memory Addresses in a Microcontroller

To understand pointers and hardware registers, it is necessary to understand the concept of a memory address.

A microcontroller uses an address space to identify different memory locations and hardware resources.

A simplified address map might look like:

Higher Addresses
+---------------------------+
| System Control Registers  |
+---------------------------+
| Peripheral Registers      |
+---------------------------+
| SRAM                      |
+---------------------------+
| Flash                     |
+---------------------------+
Lower Addresses

The exact organization depends on the processor architecture and microcontroller.

A memory address identifies a particular location within this address space.

A C pointer provides a way for software to store such an address.

For example:

uint32_t *ptr;

declares a pointer that can store the address of a uint32_t object.

For hardware access:

volatile uint32_t *reg =
    (volatile uint32_t *)0x40020000U;

the numerical address is converted into a pointer.

The pointer can then be dereferenced to perform a register access.

What Is Memory-Mapped I/O?

Memory-mapped I/O is a technique in which hardware peripheral registers are assigned addresses within the processor’s address space.

The CPU can access these registers using memory read and write operations.

For example, assume a hypothetical peripheral register exists at:

0x40020000

A pointer can be created for that address:

volatile uint32_t *REG =
    (volatile uint32_t *)0x40020000U;

Reading the register:

uint32_t value = *REG;

Writing to the register:

*REG = 0x00000001U;

The important point is that REG is not the register itself. It is a pointer containing the address used to access the register.

The relationship can be visualized as:

C Pointer
    |
    v
Register Address
    |
    v
Memory-Mapped Register
    |
    v
Peripheral Hardware

This is why memory mapped registers in Embedded C and pointers are closely related.

Pointer to a Microcontroller Register

A pointer to a microcontroller register generally involves three important elements:

  1. Register address
  2. Correct data type
  3. volatile qualifier

For example:

volatile uint32_t *reg =
    (volatile uint32_t *)0x40020000U;

Each part has a purpose.

Register Address

0x40020000U

represents the address assigned to the hardware register.

The actual address must come from the microcontroller’s official reference manual or device header.

Pointer Type

uint32_t *

indicates that the pointer refers to a 32-bit unsigned value.

The access width must match the register definition and the requirements of the target device.

Volatile Qualifier

volatile

indicates that the value may change independently of normal program execution and that accesses to it have observable effects.

Therefore:

volatile uint32_t *reg =
    (volatile uint32_t *)0x40020000U;

creates a pointer suitable for accessing a 32-bit memory-mapped hardware register.

Understanding the Address Operator &

The address operator & returns the memory address of an object.

For example:

uint32_t data = 100;
uint32_t *ptr = &data;

The expression:

&data

means “address of data.”

The pointer stores that address.

For example, conceptually:

data
 |
 | address
 v
0x20000000
 |
 | stored in
 v
ptr

The actual address will vary depending on the microcontroller and memory layout.

The address operator is fundamental when learning pointers because it explains how a C variable is associated with a particular memory location.

When working with hardware registers, however, programmers normally start with a predefined register address supplied by the microcontroller documentation rather than obtaining the address using &.

Dereferencing a Pointer

Dereferencing means accessing the object or memory location represented by a pointer.

Consider:

uint32_t value = 25;
uint32_t *ptr = &value;

*ptr = 50;

 

The statement:

*ptr = 50;

changes the value stored at the memory location pointed to by ptr.

The same principle applies to a memory-mapped hardware register.

volatile uint32_t *reg =
    (volatile uint32_t *)0x40020000U;

*reg = 0x00000001U;

 

Here, *reg represents an access to the memory location at 0x40020000.

If that address corresponds to a hardware register, the write operation affects the associated peripheral.

This is one of the most important concepts behind accessing registers using pointers in C.

Why Is the volatile Keyword Important?

Hardware registers behave differently from ordinary program variables.

Consider a normal variable:

uint32_t counter;

The compiler generally understands how the program changes counter.

A peripheral register can behave differently because hardware may change its value without the CPU explicitly executing an instruction that modifies a normal C variable.

For example, a UART status register may change when data is received.

Therefore, hardware registers are commonly accessed through volatile objects or pointers.

volatile uint32_t *status_reg =
    (volatile uint32_t *)0x40010000U;

The volatile qualifier tells the compiler that accesses to this memory location are significant and should not be treated like ordinary memory accesses that can freely be removed or combined by optimization.

For example:

while ((*status_reg & 0x01U) == 0U)
{
    /* Wait for hardware status */
}

The program needs to repeatedly read the register because its value may be updated by hardware.

Without appropriate volatile qualification, compiler optimization can potentially result in behavior that does not match the programmer’s intended hardware accesses.

Register Access Using Pointers

A simple register-level program might look like this:

#include 

#define GPIO_REG_ADDR 0x40020000U

volatile uint32_t *gpio_reg =
    (volatile uint32_t *)GPIO_REG_ADDR;

int main(void)
{
    *gpio_reg = 0x00000001U;

    while (1)
    {
    }
}

The important statement is:

*gpio_reg = 0x00000001U;

The program is writing the value 1 to the memory address represented by gpio_reg.

If that address corresponds to a GPIO register, the GPIO peripheral interprets the write according to the register specification.

This illustrates the relationship between:

  • Embedded C pointers
  • Memory addresses
  • Hardware registers
  • Memory-mapped I/O
  • Peripheral control

Bit Manipulation with Hardware Registers

Microcontroller registers usually contain multiple control and status bits.

For example, consider an imaginary 8-bit register:

Bit:  7 6 5 4 3 2 1 0
      -----------------
      0 0 0 0 0 0 0 0
                      ^
                    Bit 0

Suppose bit 0 enables a peripheral.

Instead of replacing the entire register, the program can set only bit 0:

*reg |= (1U << 0);

To clear bit 0:

*reg &= ~(1U << 0);

To toggle bit 0:

*reg ^= (1U << 0);

To check bit 0:

if (*reg & (1U << 0))
{
    /* Bit 0 is set */
}

This combines pointers, bit manipulation, and bitwise operators.

The pointer provides access to the register, while the bitwise operation determines which register bits are modified or tested.

Understanding Bit Masking

Bit masking is commonly used when working with hardware registers.

Suppose bit 3 needs to be modified.

The mask can be created using:

uint32_t mask = (1U << 3);

The resulting mask is:

00001000

To set bit 3:

*reg |= mask;

To clear bit 3:

*reg &= ~mask;

To check bit 3:

if (*reg & mask)
{
    /* Bit 3 is set */
}

Bit masking is important because a register often contains several independent fields.

Changing the entire register when only one bit needs modification can unintentionally affect other settings.

GPIO Register Access Using Pointers

GPIO is a useful example for understanding hardware register access.

A GPIO peripheral may have registers for:

  • Pin direction
  • Input state
  • Output state
  • Pull-up or pull-down configuration
  • Alternate-function selection

Consider a simplified GPIO register map:

GPIO_DIR  → 0x40020000
GPIO_OUT  → 0x40020004
GPIO_IN   → 0x40020008

These addresses are only examples.

The corresponding pointers could be defined as:

#define GPIO_DIR_ADDR 0x40020000U
#define GPIO_OUT_ADDR 0x40020004U

volatile uint32_t *gpio_dir =
    (volatile uint32_t *)GPIO_DIR_ADDR;

volatile uint32_t *gpio_out =
    (volatile uint32_t *)GPIO_OUT_ADDR;

Suppose GPIO pin 5 needs to be configured as an output:

*gpio_dir |= (1U << 5);

To set the output:

*gpio_out |= (1U << 5);

To clear the output:

*gpio_out &= ~(1U << 5);

The exact register names, addresses, and bit positions depend on the microcontroller.

This example demonstrates how GPIO register access using pointers combines pointer operations with bit manipulation.

Read and Write Registers

Pointers can be used for both reading and writing hardware registers.

Reading a Register

uint32_t status = *status_reg;

The CPU reads the current register value.

Writing a Register

*control_reg = 0x05U;

The CPU writes a value to the register.

Read-Modify-Write

A common operation is:

*control_reg |= (1U << 2);

Conceptually, this involves:

Read current register value
          ↓
Create bit mask
          ↓
Set required bit
          ↓
Write modified value

However, read-modify-write should not automatically be used for every register.

Some hardware registers contain fields with special behaviors, such as write-one-to-clear bits, read-to-clear status bits, write-only fields, or reserved bits.

The microcontroller reference manual should always be checked before modifying a hardware register.

ARM Cortex-M Registers and Pointers

ARM Cortex-M microcontrollers make extensive use of memory-mapped registers.

The Cortex-M architecture provides memory-mapped system and processor resources, while individual microcontroller vendors define the addresses and layouts of their own peripheral registers.

In professional development, programmers usually do not manually write every register address.

Instead, CMSIS and vendor device headers commonly provide structures and definitions representing peripheral registers.

A simplified example could be:

typedef struct
{
    volatile uint32_t CTRL;
    volatile uint32_t STATUS;
    volatile uint32_t DATA;
} UART_TypeDef;

A peripheral base address could then be represented as:

#define UART_BASE 0x40010000U

#define UART ((UART_TypeDef *)UART_BASE)

Register access becomes:

UART->CTRL = 0x01U;

Although this syntax looks different from manually dereferencing a pointer, the underlying concept is still based on a pointer to a memory-mapped peripheral structure.

Understanding this makes vendor-specific register definitions much easier to understand.

STM32 Registers and Pointer-Based Access

STM32 microcontrollers use memory-mapped peripheral registers extensively.

In STM32 development, device headers commonly provide structures and definitions that allow code such as:

GPIOA->ODR |= (1U << 5);

The programmer does not normally need to manually write the raw register address.

Conceptually, however, GPIOA represents a peripheral located at a defined base address, while ODR represents a register at a defined offset.

The underlying idea can be simplified as:

Peripheral Base Address
        ↓
Peripheral Structure
        ↓
Register Offset
        ↓
Specific Hardware Register

This abstraction makes register-level programming easier to read and maintain.

Understanding pointers is therefore useful when learning STM32 registers because it explains what is happening underneath the device header definitions.

 

 

Explore Courses - Learn More

 

 

UART Registers and Pointers

UART peripherals normally contain several registers associated with:

  • Control
  • Status
  • Transmit data
  • Receive data
  • Baud-rate configuration

Consider a simplified UART register map:

UART_STATUS → 0x40010000
UART_DATA   → 0x40010004

Pointers could be defined as:

#define UART_STATUS_ADDR 0x40010000U
#define UART_DATA_ADDR   0x40010004U

volatile uint32_t *uart_status =
    (volatile uint32_t *)UART_STATUS_ADDR;

volatile uint32_t *uart_data =
    (volatile uint32_t *)UART_DATA_ADDR;

A program could check a status bit before transmitting data:

if (*uart_status & (1U << 7))
{
    *uart_data = 'A';
}

Bit 7 is only an example. The actual bit position and register address must be obtained from the target microcontroller documentation.

The important concept is that the pointer provides access to the register address, while the register definition determines the meaning of each bit.

Timer Registers and Pointers

Timers are also controlled through hardware registers.

A simplified timer peripheral structure could be:

typedef struct
{
    volatile uint32_t CONTROL;
    volatile uint32_t STATUS;
    volatile uint32_t COUNTER;
    volatile uint32_t PRESCALER;
} TIMER_TypeDef;

Suppose the timer base address is:

#define TIMER_BASE 0x40030000U

The peripheral can be represented as:

#define TIMER ((TIMER_TypeDef *)TIMER_BASE)

The control register can then be accessed as:

TIMER->CONTROL = 0x01U;

The counter could be read as:

uint32_t count = TIMER->COUNTER;

This is a cleaner approach than manually calculating every register address.

The structure-based technique is widely used in professional embedded development because it gives the code a logical representation of the peripheral register map.

Pointer Arithmetic in Embedded C

Pointer arithmetic is another important concept in C.

For a pointer to a uint32_t:

uint32_t *ptr;

incrementing the pointer:

ptr++;

normally advances it by the size of uint32_t.

If uint32_t is four bytes, an example would be:

Before: 0x40020000
After:  0x40020004

This can appear useful when registers are arranged sequentially.

For example:

uint32_t *ptr =
    (uint32_t *)0x40020000U;

ptr++;

However, programmers should not assume that every following address represents another valid register.

Peripheral register maps may contain:

  • Reserved addresses
  • Different register widths
  • Gaps between registers
  • Special-purpose registers

The register layout should always be taken from the microcontroller’s documentation or official device header.

Hardware Register Access in C: Common Mistakes

Using an Incorrect Register Address

A pointer is only useful when it points to the correct address.

volatile uint32_t *reg =
    (volatile uint32_t *)0x12345678U;

If this is not a valid register address for the target device, accessing it may cause unexpected behavior or a processor fault.

Always verify the address using the official device documentation.

Using an Incorrect Data Width

A register may be defined as 8-bit, 16-bit, or 32-bit, depending on the hardware.

For example:

volatile uint8_t *reg;

and:

volatile uint32_t *reg;

represent different access widths.

The appropriate type must be selected according to the register specification.

Forgetting volatile

Hardware registers can change because of peripheral activity.

Using an inappropriate non-volatile access can allow compiler optimizations to interfere with the intended register accesses.

Overwriting an Entire Register

Consider:

*reg = 0xFFFFFFFFU;

This changes every writable bit represented by the access.

If only one control bit needs to change, bit masking is often more appropriate:

*reg |= (1U << 5);

However, even bitwise read-modify-write operations need to be used carefully when registers have special hardware semantics.

Modifying Reserved Bits

Many registers contain reserved bits.

Writing arbitrary values to reserved fields can produce unintended behavior.

The device reference manual should be consulted before writing values to hardware registers.

Pointers vs Register Macros

A hardware register can also be represented using a macro.

For example:

#define GPIO_REG (*(volatile uint32_t *)0x40020000U)

The register can then be accessed as:

GPIO_REG |= (1U << 5);

Another approach is to explicitly define a pointer:

volatile uint32_t *gpio_reg =
    (volatile uint32_t *)0x40020000U;

Then:

*gpio_reg |= (1U << 5);

Both approaches ultimately rely on the same concept: accessing a specific memory address using a correctly typed volatile memory access.

For larger peripherals, a structure-based representation is usually more readable:

typedef struct
{
    volatile uint32_t CTRL;
    volatile uint32_t STATUS;
    volatile uint32_t DATA;
} PERIPHERAL_TypeDef;

This approach makes a peripheral’s register organization easier to understand.

Register-Level Programming vs Hardware Abstraction Layers

Modern embedded projects often use abstraction layers such as vendor HALs, drivers, or middleware.

For example, an application may call:

HAL_GPIO_WritePin(...);

instead of directly manipulating a GPIO register.

This does not mean that pointers and registers are no longer important.

The lower-level implementation still needs to communicate with the hardware.

Understanding low level embedded programming helps engineers:

  • Debug peripheral problems
  • Understand vendor libraries
  • Read device headers
  • Develop device drivers
  • Optimize critical code
  • Work with unfamiliar microcontrollers
  • Interpret reference manuals
  • Diagnose register configuration problems

A high-level API improves maintainability and portability, while register-level knowledge provides a deeper understanding of how the hardware actually operates.

Practical Example: Setting a GPIO Output Bit

The following example demonstrates the complete concept using a hypothetical GPIO register:

#include 

#define GPIO_OUT_ADDR 0x40020000U

volatile uint32_t *gpio_out =
    (volatile uint32_t *)GPIO_OUT_ADDR;

void gpio_set_pin5(void)
{
    *gpio_out |= (1U << 5);
}

void gpio_clear_pin5(void)
{
    *gpio_out &= ~(1U << 5);
}

int main(void)
{
    gpio_set_pin5();

    while (1)
    {
    }
}

The statement:

*gpio_out |= (1U << 5);

can be broken down into several operations.

First, gpio_out contains the register address.

Second, *gpio_out accesses the register.

Third:

(1U << 5)

creates a mask for bit 5.

Finally, the OR operation sets bit 5 while preserving the other bits represented by the register value.

The overall process is:

C Pointer
     ↓
GPIO Register Address
     ↓
Dereference Pointer
     ↓
Read Register
     ↓
Apply Bit Mask
     ↓
Write Register
     ↓
GPIO Hardware

This is a practical example of how register access using pointers works in Embedded C.

Why Register-Level Knowledge Matters for Embedded Engineers

Understanding hardware registers is not only useful for writing low-level code. It also helps engineers understand what happens when a peripheral is configured through a library or development framework.

For example, if a GPIO API configures a pin as an output, somewhere underneath the abstraction, the microcontroller must configure one or more hardware registers.

Similarly, configuring a UART requires registers for baud rate, control, status, and data handling.

A timer requires configuration registers, counter registers, prescaler settings, and status information.

Learning pointers and memory-mapped registers therefore provides a foundation for understanding how these peripherals actually operate.

Key Difference Between a Normal Pointer and a Hardware Register Pointer

A normal C pointer may look like:

uint32_t value = 10;
uint32_t *ptr = &value;

The pointer refers to an ordinary object in program memory.

A hardware register pointer may look like:

volatile uint32_t *reg =
    (volatile uint32_t *)0x40020000U;

The pointer refers to a memory address associated with hardware.

The syntax is similar, but the purpose and behavior are different.

Normal C PointerHardware Register Pointer
Points to program dataRepresents a hardware register address
Usually refers to RAM or another objectRefers to memory-mapped peripheral space
Used for data manipulationUsed for hardware control or status
volatile is not normally requiredvolatile is commonly required
Value is normally changed by softwareHardware may change the value

The key idea is that a hardware register pointer is still a C pointer, but the address represented by the pointer has a hardware-defined meaning.

Best Practices for Using Pointers with Microcontroller Registers

When working with hardware registers in Embedded C, follow these practices:

Use Official Register Definitions

Do not guess register addresses or bit positions. Use the microcontroller’s reference manual, datasheet, or official device header.

Use Appropriate Data Types

Match the pointer and access width to the register requirements.

Use volatile Correctly

Hardware-controlled registers generally require volatile-qualified access so that necessary memory operations remain observable to the compiler.

Prefer Bit Masks for Individual Fields

Avoid replacing an entire register when only one bit or field needs modification, unless the register documentation specifically requires a full-register write.

Understand Register Semantics

Check whether a register is read-only, write-only, read-to-clear, write-one-to-clear, or has reserved fields before accessing it.

Avoid Hard-Coded Addresses Throughout the Application

Centralize register definitions or use the vendor’s device headers and CMSIS definitions.

Understand the Abstraction You Are Using

Even when using a HAL or driver library, understanding the underlying registers makes debugging much easier.

Conclusion

Pointers are a fundamental part of Embedded C because they provide a direct mechanism for working with memory addresses.

In microcontroller programming, this becomes especially powerful because peripherals are commonly controlled through memory-mapped hardware registers. A pointer can represent the address of a register, and dereferencing that pointer allows software to read or write the associated hardware resource.

The basic relationship is:

Pointer
   ↓
Memory Address
   ↓
Hardware Register
   ↓
Bit or Register Field
   ↓
Peripheral Hardware

Concepts such as the address operator, pointer dereferencing, volatile keyword, memory-mapped I/O, bitwise operators, bit masking, and register manipulation form the foundation of register-level programming.

Once these concepts are understood, technologies such as ARM Cortex-M and STM32 device headers become easier to understand because their peripheral definitions are built around the same fundamental relationship between addresses, pointers, structures, and hardware registers.

For embedded engineers, learning pointers is therefore not simply about understanding a C language feature. It is about understanding how software communicates with the hardware at the memory level.

 

 

Talk to Academic Advisor

Frequently Asked Questions

Pointers in Embedded C are used to work with memory addresses. They are especially important for accessing memory-mapped hardware registers and communicating directly with microcontroller peripherals.

A register’s address can be converted into a pointer, and dereferencing that pointer provides access to the memory location associated with the hardware register.

volatile uint32_t *reg =

    (volatile uint32_t *)REGISTER_ADDRESS;

 

*reg = value;

The actual register address and access width must come from the target microcontroller documentation.

volatile tells the compiler that the memory location can change independently of normal program execution and that accesses to it are significant. This is important when working with hardware registers that can be modified by peripherals.

Memory-mapped I/O is a method where peripheral registers are assigned addresses within the processor’s address space. Software can access those addresses using normal memory read and write operations, often through pointers.

A pointer provides access to the hardware register, while bitwise operators and masks allow individual bits or fields within that register to be set, cleared, toggled, or checked.

Yes. STM32 device headers commonly represent peripherals using structures and pointers. Code such as GPIOA->ODR provides a convenient abstraction over the underlying memory-mapped register addresses.

Author

Embedded Systems trainer – IIES

Updated On: 27-08-26


10+ years of hands-on experience delivering practical training in Embedded Systems and it's design