Embedded & FirmwareInternubiquitous

C Language Basics: Pointers, Structs, Unions

Learn how C pointers, structs, and unions map to embedded hardware registers, including padding, volatile, aliasing, and driver patterns.

10 min readAhmet Zahid ArıcanUpdated 8 Oct 2026
Dark technical card titled "C Pointers, Structs & Unions for Embedded" showing a USART1 pointer mapped onto a register struct with the CR1 enable bit set, above an 8-byte struct layout with one padding byte, in cyan and amber.
Contents & prerequisites

Why Pointers Matter in Embedded C

A pointer is just a variable holding an address, but in embedded firmware that address is often not RAM at all — it's a peripheral's control register, a DMA descriptor, or a flash-mapped constant table. Desktop C treats memory as an abstraction the OS manages for you. Firmware C treats memory as a map of real silicon, and the pointer is the only tool the language gives you to reach it.

Direct memory access and hardware registers

Every MCU vendor publishes a memory map: a range of addresses where GPIO, UART, timers, and other peripherals live. Writing to one of those addresses changes physical hardware behavior immediately — no OS call, no abstraction layer required.

#define GPIOA_ODR ((volatile uint32_t *)0x40020014)
*GPIOA_ODR |= (1 << 5);   /* set pin 5 high */

This is the rawest form of register access. Everything else in this article — structs, unions, bit-fields — exists to make this pattern safer and more readable without changing what happens at the silicon level.

Pointer arithmetic for buffer traversal

DMA buffers, UART receive rings, and flash page copies all require walking memory a fixed stride at a time. ptr + 1 advances by sizeof(*ptr) bytes, not one byte, which is why pointer type matters as much as pointer value. Mixing uint8_t* and uint32_t* arithmetic on the same buffer is a common source of off-by-four bugs when porting code between 8-bit and 32-bit peripherals.

Function pointers for interrupt handlers and callbacks

Vector tables are arrays of function pointers, and most peripheral drivers expose callback registration (uart_set_rx_callback(void (*cb)(uint8_t))) instead of hardcoding behavior. This decouples the driver from application logic without the overhead of a full object system. Combined with static scoping — see Static, Inline, and Extern Linkage — function pointers let you build a driver whose internals never leak into the global symbol table while still exposing a clean callback hook.

Pass-by-reference for efficient parameter passing

Passing a 32-byte struct by value means copying 32 bytes onto the stack at every call — expensive on a Cortex-M0 with 4 KB of RAM. Passing a pointer costs 4 bytes, always. For anything larger than a machine word, pass a pointer, and use const on it when the callee shouldn't modify the data (more on this below, and in C const and restrict for Optimization Hints).

Pointer Fundamentals and Syntax

Declaration, dereferencing, and address-of operators

int *p declares a pointer to int. &x takes the address of x. *p dereferences p, accessing the value it points to. Confusion typically starts with declarations like int *p, q; — here q is a plain int, not a pointer, because * binds to the declarator, not the type.

Pointer-to-pointer and void pointers

Pointer-to-pointer (uint8_t **pp) shows up in APIs that need to allocate or reassign a buffer through an out-parameter, and in jagged arrays-of-pointers used for lookup tables. void * is the generic pointer: it holds any address but cannot be dereferenced without a cast. Memory-copy routines (memcpy) and generic linked-list nodes rely on it, trading type safety for flexibility.

Null pointers and common pitfalls

A null pointer dereference on a desktop OS segfaults predictably. On a bare-metal MCU, address 0 is frequently valid memory (often the start of flash, containing the vector table) — dereferencing a null pointer may silently return the initial stack pointer or a vector address instead of crashing loudly, and on parts where address 0 is remapped to RAM, a null write can corrupt live data. Always check pointers returned from allocators or parsers before use, and never assume a null-pointer bug will announce itself.

Stack vs. heap pointers in constrained environments

Many embedded projects avoid malloc entirely because heap fragmentation on a device that runs for months without reboot is a real failure mode, not a theoretical one. Stack allocation is cheap and deterministic but bounded — typically a few KB total — so large buffers belong in statically allocated arrays, not local variables inside deep call chains.

Structs: Organizing Firmware Data

Struct declaration and member access

struct SensorSample {
    uint32_t timestamp;
    int16_t  temperature;
    uint8_t  status;
};

Access is sample.timestamp for a direct instance, sample_ptr->timestamp for a pointer — covered in detail below.

Memory layout, padding, and alignment

The compiler inserts padding so each member lands on an address matching its natural alignment. In SensorSample above, uint32_t timestamp (4-byte aligned) is followed by int16_t temperature (2-byte aligned) and uint8_t status (1-byte), but the struct's total size rounds up to a multiple of the largest member's alignment — likely 8 bytes, not the 7 you'd expect from summing fields. On a memory-constrained MCU, reordering members from largest to smallest alignment requirement minimizes padding and can measurably reduce the footprint of large arrays of structs.

Typedef for cleaner code and portability

typedef struct {
    uint32_t timestamp;
    int16_t  temperature;
    uint8_t  status;
} SensorSample_t;

This removes the need to repeat struct at every declaration and is the convention most vendor HALs follow, which matters when your code has to read alongside CMSIS headers.

Nested structs for hierarchical hardware models

A sensor driver might nest a calibration struct inside a device struct inside a bus-handle struct, mirroring the physical hierarchy: bus → device → channel. This keeps related state together and makes the driver's public API a single pointer to the top-level struct.

Structs for Hardware Register Abstraction

Mapping memory-mapped registers to struct members

Instead of hardcoding offsets, vendors define a struct whose member order matches the register layout, then overlay it on the peripheral's base address:

typedef struct {
    volatile uint32_t SR;   /* 0x00 status    */
    volatile uint32_t DR;   /* 0x04 data      */
    volatile uint32_t BRR;  /* 0x08 baud rate */
    volatile uint32_t CR1;  /* 0x0C control 1 */
    volatile uint32_t CR2;  /* 0x10 control 2 */
} USART_TypeDef;

#define USART1 ((USART_TypeDef *)0x40011000)  /* STM32F4 */
USART1->CR1 |= (1u << 13);                    /* UE: enable the USART */

This is exactly the pattern CMSIS headers use — see Register-Level Peripheral Access via CMSIS for how vendor tooling generates these definitions automatically.

Struct size and layout control for register alignment

The struct's member order must exactly match the silicon's register offsets, with reserved words inserted explicitly for gaps:

typedef struct {
    volatile uint32_t CTRL;       /* 0x00 */
    uint32_t          RESERVED0;  /* 0x04: gap in the register map */
    volatile uint32_t STATUS;     /* 0x08 */
} TIMX_TypeDef;                   /* hypothetical peripheral */

_Static_assert(offsetof(TIMX_TypeDef, STATUS) == 0x08, "register map mismatch");

Any unintended padding here corrupts every register access past the gap, so register structs require you to verify layout against the reference manual's offset table, not trust the compiler to "do the right thing."

Reading and writing peripheral registers via structs

Reads and writes look like ordinary struct member access, but each one compiles to a single load or store instruction at a fixed address — there is no abstraction overhead once compiled, provided volatile is present (see below).

Limitations and when to use alternative approaches

Struct overlays work well for whole-register access but are clumsy for individual bit fields — you still need shifts and masks, or bit-field members (see Bit-Field Struct for Register Mapping), which trade portability for readability since bit-field layout is compiler- and endianness-dependent.

Unions: Space-Efficient Data Representation

Union declaration and member sharing

A union allocates storage for only its largest member, and all members share that same memory:

union Reading {
    uint32_t raw;
    float    value;
};

Writing raw and reading value reinterprets the same 4 bytes two ways. A struct, by contrast, gives each member its own storage — the fundamental difference: structs aggregate, unions overlay.

Size, alignment, and memory overlap rules

sizeof(union Reading) is the size of its largest member, rounded up for alignment. C++ treats only the last-written member as active and makes reading another one undefined behavior, but C (C99 TC3 and later) explicitly allows it: reading a different member than the one last written reinterprets the stored bytes as the new type. The result still depends on the platform's representation (endianness, float format), as the next example shows.

Unions for type reinterpretation and bit-level access

Unions are the common idiom for splitting a 16-bit ADC result into bytes for a transmit buffer:

union {
    uint16_t word;
    uint8_t  bytes[2];
} adc_sample;

adc_sample.word = read_adc();
uart_send(adc_sample.bytes[0]);
uart_send(adc_sample.bytes[1]);

This relies on the platform's endianness, which is fixed per MCU but not portable across architectures — document the assumption at the point of use.

Unions in hardware register parsing and protocol handling

Protocol frames are frequently modeled as a union of a raw byte array and a struct of named fields, letting a driver fill the byte array over SPI/UART and then read named fields without manual bit-shifting. This, combined with function pointers for frame handlers, is a common way to keep a protocol parser small: a dispatch table of (frame_type, handler_fn) pairs avoids a long switch statement and keeps each handler independently testable.

Pointers to Structs and Unions

Arrow operator (->) for member access

p->member is shorthand for (*p).member. Every HAL function that takes a peripheral handle — HAL_UART_Transmit(huart, ...) — uses this syntax internally once past the first indirection.

Pointer arithmetic across struct arrays

sensor_array + i advances by sizeof(SensorSample_t) bytes, trailing padding included, so indexing an array of structs is always correct within C. Padding only bites when the same bytes must match an external layout (a packed buffer, a DMA descriptor, or a frame sent over a wire); check layout explicitly there, e.g. with _Static_assert(sizeof(SensorSample_t) == 8, "layout").

Const pointers and pointers-to-const

const SensorSample_t *p means the pointee can't be modified through p; SensorSample_t *const p means p itself can't be reassigned. Register-access code frequently wants the opposite of the first form — a non-const pointer to a volatile struct — since writing registers is the entire point.

Self-referential structs and linked data structures

typedef struct Node {
    uint8_t data;
    struct Node *next;
} Node;

Linked lists and ring buffers built this way are common in event queues and command buffers, though on memory-constrained MCUs a statically sized array with head/tail indices often outperforms a pointer-linked list by avoiding both allocation and pointer-chasing cache misses.

Common Embedded Patterns and Pitfalls

Volatile and const qualifiers in register structs

volatile tells the compiler the value can change outside the flow of the program — essential for registers, since without it the compiler may cache a register read in a CPU register and never re-read hardware state, or eliminate a "redundant" write entirely. const on a register struct pointer (const volatile uint32_t *) documents a read-only register and lets the compiler catch accidental writes at build time. Combining the two correctly is the difference between a driver that works and one that fails only under -O2.

Pointer casting and strict aliasing violations

Casting a uint32_t* to a float* to reinterpret bits (instead of using a union) violates the C strict-aliasing rule: the compiler is permitted to assume pointers of different types don't alias the same memory, and may reorder or eliminate accesses based on that assumption. The union-based reinterpretation shown earlier is the standard-conforming alternative, or use memcpy for the bytes if you need guaranteed portability.

Buffer overruns and off-by-one errors

Writing past the end of a fixed array corrupts whatever is adjacent in memory — on a microcontroller with no MMU, that's often another peripheral's buffer, a stack frame, or the vector table, with no segfault to flag it. Bounds-check every index derived from external input (UART length bytes, protocol frame sizes) before using it in pointer arithmetic.

Debugging pointer issues in embedded environments

Without an OS or MMU to catch invalid access, pointer bugs in firmware manifest as hard faults, silent data corruption, or behavior that only appears after hours of runtime. A hardware debugger with watchpoints on specific addresses is usually more productive than print-statement debugging for tracking down who is writing where, and a static analyzer run as part of CI catches a meaningful fraction of these bugs before they reach hardware at all.

Learning

Sign in to track your progress.

Evidence

Public projects engineers linked to C Language Basics: Pointers, Structs, Unions.

Add evidence

No engineer has linked a project to this topic yet. Built something that proves it? Add the project and tag it with embedded-firmware-c-language-basics-pointers-structs-unions — it then shows here and on your public profile.