Pointers, arrays and structs in embedded C memory layout

Pointers, arrays and structs solve different problems in Embedded C. They often appear together, but treating them as interchangeable leads to bugs that are especially painful on microcontrollers: out-of-bounds writes, corrupted state, alignment faults, hidden RAM growth and non-portable binary layouts.

The cleanest rule is simple: use an array for a sequence of same-type objects, a pointer when code must refer to an object indirectly, and a struct when several fields belong to one logical object.

Arrays: best when the data is a fixed sequence

An array owns storage for a sequence of elements of one type:

uint16_t samples[128];

This is a good fit for ADC buffers, lookup tables, UART receive buffers and fixed collections of channel values. The compiler knows the total array size in the scope where the array is actually declared.

The important sizeof rule

uint16_t samples[128];

size_t bytes = sizeof samples;
size_t count = sizeof samples / sizeof samples[0];

Here, sizeof samples is the size of the entire array. But once the array is passed to a function, the parameter is adjusted to a pointer type:

void process(uint16_t samples[128])
{
    // Here, samples is a pointer parameter.
    // sizeof(samples) is the size of a pointer,
    // not 128 * sizeof(uint16_t).
}

This behavior comes directly from the C language rules. If a function needs the element count, pass it explicitly.

Pointers: best for indirect access and reusable APIs

A pointer stores the address of another object:

uint16_t value = 250;
uint16_t *p = &value;

*p = 300;

Pointers are fundamental in embedded systems because drivers and libraries need to operate on buffers and state without copying them. They are also used for callback data, linked data structures, dynamically selected objects and memory-mapped hardware access.

A safer buffer API passes both pointer and length

void filter_samples(uint16_t *data, size_t count)
{
    for (size_t i = 0; i < count; ++i) {
        data[i] = apply_filter(data[i]);
    }
}

The pointer tells the function where the buffer starts; count tells it how far it may legally go. That explicit contract is much safer than assuming every caller provides the same buffer size.

Pointer arithmetic has strict boundaries

Pointer arithmetic is defined for positions within the same array object, including a pointer one element past the end for comparison. Dereferencing that one-past pointer is not valid.

uint8_t buffer[16];

uint8_t *begin = buffer;
uint8_t *end   = buffer + 16;

for (uint8_t *p = begin; p != end; ++p) {
    use_byte(*p);
}

Do not use pointer arithmetic as if RAM were one unrestricted byte array. Crossing object boundaries can invoke undefined behavior even if the numerical address happens to look correct.

Structs: best for state, configuration and related fields

A struct groups fields that describe one object:

typedef struct {
    uint16_t raw;
    float voltage;
    bool valid;
} adc_sample_t;

This is clearer than keeping three independent arrays or a collection of unrelated global variables. Structs are especially useful for peripheral configuration, driver state, sensor readings and application state machines.

Pass large structs by pointer when copying is unnecessary

void adc_print(const adc_sample_t *sample)
{
    if (sample->valid) {
        print_voltage(sample->voltage);
    }
}

Using a pointer avoids copying the whole struct into the function call. Adding const also documents that the function should not modify the object.

Arrays and pointers are related, but they are not the same thing

In many expressions, an array expression is converted to a pointer to its first element. That is why this works:

uint8_t data[32];
uint8_t *p = data;

But the objects are still different. The array contains 32 bytes of storage. The pointer contains one address. This difference matters for sizeof, object lifetime, assignment and API design.

You cannot assign one array to another with =, while pointer assignment simply copies an address. Struct assignment, however, copies the struct value.

Use an array when ownership should be obvious

Prefer a real array when a module owns a fixed buffer:

static uint8_t rx_buffer[256];

The lifetime and capacity are obvious. A raw pointer by itself cannot tell a reviewer whether memory exists behind it, how large that memory is, or how long the target object will remain valid.

Use a pointer when the caller owns the object

Driver APIs commonly accept pointers because the caller owns the storage:

bool uart_read(uint8_t *buffer, size_t capacity, size_t *received);

This supports different buffer sizes without forcing the driver to own a global array. It also makes memory ownership explicit if the API is documented well.

Use a struct when fields must stay synchronized

If a timestamp, value and status flag describe the same measurement, storing them in one struct reduces the chance that parallel arrays or unrelated globals become inconsistent.

typedef struct {
    uint32_t timestamp_ms;
    int16_t temperature_x10;
    uint8_t status;
} temperature_sample_t;

temperature_sample_t history[64];

This combines both ideas cleanly: an array represents the sequence, while each struct represents one logical record.

Do not serialize a struct blindly

Writing a struct directly to UART, flash, a network packet or a file is tempting:

send_bytes((uint8_t *)&record, sizeof record);

That can be non-portable because struct layout may contain padding, alignment requirements and implementation-dependent representation details. Endianness also matters for multi-byte fields.

For a protocol or persistent file format, define the byte layout explicitly and encode each field deliberately.

Padding also makes memcmp a poor equality test

Two structs can have equal member values while padding bytes differ. Therefore:

memcmp(&a, &b, sizeof a)

is not a portable replacement for comparing the members that actually define logical equality.

Be careful with packed structs

Compiler-specific packing attributes can remove padding, but that does not automatically make the result safe or efficient. Some processors require aligned accesses; a packed field may force slower byte operations or, on some targets, cause an alignment fault if accessed incorrectly.

Use packed layouts only when a binary interface truly requires them, and verify the compiler, ABI and target behavior. Do not add packed merely to save a few bytes without measuring the consequence.

Pointer lifetime errors are more dangerous than syntax errors

A pointer is valid only while the referenced object exists. This is wrong:

uint8_t *get_buffer(void)
{
    uint8_t local[32];
    return local;   // invalid after the function returns
}

The local array has automatic storage duration and stops existing when the function returns. The returned pointer becomes dangling.

In embedded systems, prefer static ownership, caller-provided buffers or clearly managed dynamic storage instead of hiding lifetime rules.

Use const to communicate direction

For a function that reads but does not modify a buffer:

uint16_t crc16(const uint8_t *data, size_t length);

const makes the API harder to misuse and gives both the compiler and reviewer more information. It does not mean the underlying memory can never change; it means this access path is not allowed to modify it.

Strict aliasing can break clever pointer casts

Code that reinterprets one object through an incompatible pointer type may have undefined behavior. GCC enables strict-aliasing optimizations at common optimization levels such as -O2, -O3 and -Os.

This is why code that “worked at -O0” can fail after optimization. For byte-wise access, character types have special aliasing permissions. For representation conversion, use a method that is valid for your language version and compiler rather than arbitrary casts.

Memory-mapped registers need different reasoning

Peripheral registers are usually accessed through addresses defined by the MCU vendor. These accesses commonly require volatile semantics because hardware can change the value independently of normal program flow.

Do not invent register structs from memory. Use the CMSIS or vendor device headers when available, because register offsets, reserved gaps, access widths and qualifiers must match the actual hardware.

Practical selection guide

Need Best starting choice Why
Fixed ADC sample buffer Array Owns a known number of same-type elements
Function operating on caller data Pointer + length Reusable without copying the whole buffer
Peripheral configuration Struct Keeps related fields together
History of sensor records Array of structs Sequence plus grouped fields
Optional object reference Pointer Can refer to an existing object or NULL if API permits
Binary protocol packet Explicit byte encoding Avoids padding, alignment and endian assumptions

Common embedded mistakes

  • Using sizeof(pointer) as if it were the size of the original array.
  • Passing a buffer without its capacity or element count.
  • Returning a pointer to a local automatic object.
  • Writing beyond an array because the loop limit came from external input.
  • Serializing raw structs without defining padding and byte order.
  • Using packed structs everywhere to reduce RAM.
  • Casting unrelated pointer types to bypass compiler warnings.
  • Forgetting const on read-only APIs.
  • Using a pointer to memory whose owner has already released or reused it.

Related Embedded C optimization

Choosing the right data representation is only one part of building maintainable firmware. See our guide on writing Embedded C that uses less RAM and is easier to debug for stack usage, globals, constants and debugging-oriented design choices.

Primary references

Bottom line

Arrays represent sequences, pointers represent indirect access, and structs represent grouped state. Good Embedded C combines them deliberately: arrays for owned buffers, pointers plus explicit lengths for APIs, and structs for coherent objects. Most serious bugs appear when ownership, bounds, lifetime or binary layout is left implicit.