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
conston 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
- ISO/IEC JTC1/SC22/WG14: C language standards and public working drafts
- GCC documentation: optimization and strict-aliasing behavior
- GCC documentation: alignment and packed/aligned attributes
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.