Pointers in programming are variables that store memory addresses. The main types are null, void, wild, dangling, function, double (pointer to pointer), constant, volatile, near/far/huge, and smart pointers (C++). Each type has a different job and a different failure mode, so knowing which one you are holding is what separates safe code from segfaults.Almost every embedded developer has a pointer horror story. A board that ran perfectly on Friday freezes on Monday. An LED blinks when nothing in your code touches it. A function returns the right value in the debugger and garbage in the field. Very often, a pointer is behind it.
If you searched for the types of pointer in programming, you have probably met a list of eight names with two-line definitions. This guide goes further. You will see what each type is, why it exists, how it fails, and where it shows up in real firmware.
What you will learn:
& gives the address of a variable, and * follows an address to the value stored there (dereferencing).p + 1 moves forward by sizeof(*p) bytes.#include
int main(void)
{
int temperature = 36;
int *ptr = &temperature; // ptr stores the ADDRESS of temperature
printf("Value of temperature : %d\n", temperature);
printf("Address of temperature: %p\n", (void *)&temperature);
printf("ptr holds : %p\n", (void *)ptr);
printf("Value through ptr : %d\n", *ptr);
return 0;
}Output (the address will differ on your machine):
Value of temperature : 36
Address of temperature: 0x7ffd5c3a1a4c
ptr holds : 0x7ffd5c3a1a4c
Value through ptr : 36| Platform | Pointer size |
|---|---|
| 8051 (Keil C51), generic pointer | 3 bytes |
| AVR (ATmega328P), data pointer | 2 bytes |
| ARM Cortex-M (32-bit) | 4 bytes |
| 64-bit PC (x86-64, ARM64) | 8 bytes |
Pro tip: print sizeof(void *) on your own target. It takes ten seconds and removes guesswork.
Underneath, every pointer is just an address. The “type” comes from the rules wrapped around it. Strictly speaking, the C standard defines pointer types by what they point to (int *, void *, function pointers). Names like wild and dangling describe a pointer’s state, and near, far and huge were compiler extensions. Everyone calls them all “types”, so we will too.
| Family | Pointer types | The question it answers |
|---|---|---|
| State | Null, wild, dangling | Is this address safe to use right now? |
| Target | Void, double, function, array, structure, complex | What lives at this address? |
| Access rules | Const, volatile, restrict | What am I allowed to do through it? |
| Memory model | Near, far, huge, 8051 memory-specific | Which memory space does it reach? |
| Ownership (C++) | unique_ptr, shared_ptr, weak_ptr | Who cleans up the object? |
NULL in C and nullptr in C++11 and later (C23 adds nullptr too).malloc returns NULL when memory runs out), and marking the end of a linked list.#include
#include
int main(void)
{
int *buffer = malloc(100 * sizeof(int));
if (buffer == NULL) // always check before using
{
printf("Allocation failed\n");
return 1;
}
buffer[0] = 7;
free(buffer);
buffer = NULL; // no longer dangling
return 0;
}nullptr in C++: NULL is just the integer 0, so f(NULL) can call the wrong overload. nullptr has its own type and always means “pointer”.0x00000000 is mapped to flash or the vector table, so reading through a NULL pointer may not crash. It quietly returns data. Check for NULL explicitly, and if your core has an MPU, mark address 0 as no-access so the bug fails loudly.NULL, but local pointers do not, so the bug shows up only inside functions.void wild_demo(void)
{
int *ptr; // BAD: uninitialized, holds a garbage address
*ptr = 25; // undefined behavior
int value = 25;
int *safe = &value; // GOOD: points to a real object
int *empty = NULL; // GOOD: deliberately points to nothing
}-Wall -Wextra, and run a static analyzer such as cppcheck or clang-tidy.free(), returning the address of a local variable, and keeping a pointer to a variable whose block has ended.int *read_sensor_bad(void)
{
int reading = 512;
return &reading; // BAD: 'reading' disappears when the function returns
}
void read_sensor_good(int *out)
{
*out = 512; // GOOD: the caller owns the storage
}int *p = malloc(sizeof(int));
*p = 100;
free(p); // memory goes back to the heap
p = NULL; // p no longer dangles| Null | Wild | Dangling | |
|---|---|---|---|
| Initialized? | Yes, to nothing | No | Yes, but the object is gone |
| Safe to test? | Yes: if (p != NULL) | No, the value is garbage | No, it looks valid |
| Typical cause | Deliberate | Missing initialization | free(), scope exit, returned locals |
| Fix | Check before use | Initialize at declaration | Set to NULL after free(), never return locals |
void *) can hold the address of any data type, because it carries no type information.void *. GCC permits it as an extension (treating the size as 1), but that is not portable.void * converts to other object pointers implicitly. In C++ you need an explicit cast.malloc, memcpy and qsort, and RTOS APIs such as FreeRTOS task parameters.#include
void swap_any(void *a, void *b, size_t size)
{
unsigned char *pa = a; // view both objects as raw bytes
unsigned char *pb = b;
while (size--)
{
unsigned char tmp = *pa;
*pa++ = *pb++;
*pb++ = tmp;
}
}int x = 10, y = 20;
swap_any(&x, &y, sizeof x); // x = 20, y = 10
float a = 1.5f, b = 2.5f;
swap_any(&a, &b, sizeof a); // same function, different typeuint8_t * to uint32_t * at an odd address can trigger a HardFault on Cortex-M0/M0+, which cannot do unaligned word access.int **pp.pp → p → value. Here *pp is the pointer and **pp is the value.char **argv and arrays of strings, to build dynamic 2D arrays, and to update a linked list’s head.#include
#include
int allocate_buffer(int **out, size_t count)
{
*out = malloc(count * sizeof(int)); // changes the CALLER's pointer
return (*out != NULL) ? 0 : -1;
}
int main(void)
{
int *samples = NULL;
if (allocate_buffer(&samples, 5) == 0)
{
samples[0] = 100;
printf("First sample: %d\n", samples[0]); // First sample: 100
free(samples);
samples = NULL;
}
return 0;
}malloc and free. The double-pointer pattern still matters for linked structures built from static pools.
return_type (*name)(parameter_types). The parentheses matter: int (*fp)(int) is a pointer to a function, while int *fp(int) is a function returning int *.qsort() takes a comparison function pointer.#include
void led_on(void) { printf("LED ON\n"); }
void led_off(void) { printf("LED OFF\n"); }
int main(void)
{
void (*action)(void); // pointer to a function: no arguments, returns nothing
action = led_on;
action(); // LED ON
action = led_off;
action(); // LED OFF
return 0;
}Output:
LED ON
LED OFFA tidy state machine with a function pointer table:
typedef void (*state_fn_t)(void);
void state_idle(void) { /* wait for a trigger */ }
void state_measure(void) { /* read the sensor */ }
void state_report(void) { /* send data over UART */ }
state_fn_t state_table[] = { state_idle, state_measure, state_report };
state_table[current_state](); // jump straight to the right handler| Declaration | Meaning |
|---|---|
int *p = arr; | Points to the first element (the array “decays”) |
int (*p)[5] = &arr; | Points to the whole array of 5 ints |
int *p[5]; | An array of 5 int pointers |
int arr[5] = {10, 20, 30, 40, 50};
int *p1 = arr; // points to arr[0]
int (*p2)[5] = &arr; // points to the WHOLE array
printf("%d\n", *(p1 + 2)); // 30, same as arr[2]
printf("%d\n", (*p2)[4]); // 50
const char *menu[] = { "Temperature", "Humidity", "Pressure" }; // great for LCD menusRemember: arr[i] is just shorthand for *(arr + i).
struct and reaches members with the arrow operator ->.#include
typedef struct
{
uint16_t id;
float value;
} Sensor_t;
void sensor_update(Sensor_t *s, float new_value)
{
s->value = new_value; // same as (*s).value = new_value;
}
int main(void)
{
Sensor_t temp = { 101, 25.5f };
sensor_update(&temp, 26.1f);
return 0;
}typedef struct Sensor Sensor; and the struct’s fields live only in the .c file. Callers hold a pointer but cannot touch the internals, which gives you encapsulation in plain C.*, [] and (), such as an array of function pointers.int (*fp[3])(char); // fp is an array of 3 pointers to functions taking char and returning int
char *(*handler)(int); // handler is a pointer to a function taking int and returning char *typedef, or paste the declaration into the cdecl tool.| Declaration | Read as | Change the value? | Change the address? |
|---|---|---|---|
int *p | pointer to int | Yes | Yes |
const int *p | pointer to const int | No | Yes |
int *const p | const pointer to int | Yes | No |
const int *const p | const pointer to const int | No | No |
Trick: read the declaration from right to left.
int a = 10, b = 20;
const int *p1 = &a; // pointer to constant
// *p1 = 15; // error: read-only through p1
p1 = &b; // OK
int *const p2 = &a; // constant pointer
*p2 = 15; // OK
// p2 = &b; // error: p2 is fixed
const int *const p3 = &a; // neither can changeconst lookup tables and strings usually stay in flash on ARM toolchains, which saves scarce RAM. Marking parameters like const uint8_t *data also documents that a function will not modify your buffer.| Declaration | Meaning | Typical use |
|---|---|---|
volatile uint32_t *p | Pointer to volatile data | Hardware registers (most common) |
uint32_t *volatile p | The pointer itself is volatile | A pointer modified inside an ISR |
const volatile uint32_t *p | Pointer to read-only volatile data | Status registers you read but never write |
#include
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014U) // STM32F4-style output data register
GPIOA_ODR |= (1U << 5); // PA5 high
GPIOA_ODR &= ~(1U << 5); // PA5 lowvolatile does not make an access atomic and does not replace a critical section for data shared between an ISR and main().memcpy takes restrict parameters (no overlap allowed), while memmove does not.restrict, though compilers offer __restrict.void add_arrays(int *restrict out, const int *restrict a, const int *restrict b, size_t n)
{
for (size_t i = 0; i < n; i++)
out[i] = a[i] + b[i];
}| Pointer | Size | What it holds | Catch |
|---|---|---|---|
| Near | 2 bytes | Offset inside the current segment | Cannot leave the segment |
| Far | 4 bytes | Segment:offset | Not normalized, so arithmetic wraps inside a segment |
| Huge | 4 bytes | Normalized segment:offset | Crosses segments, but slower |
| Declaration | Points to | Pointer size |
|---|---|---|
char data *p | Internal direct RAM | 1 byte |
char idata *p | Internal indirect RAM | 1 byte |
char pdata *p | Paged external RAM | 1 byte |
char xdata *p | External RAM | 2 bytes |
char code *p | Program (code) memory | 2 bytes |
char *p (generic) | Any of the above | 3 bytes |
unsigned char xdata *ext_ptr; // 2 bytes: external RAM
unsigned char code *rom_ptr; // 2 bytes: constants and tables in code memory
unsigned char *generic_ptr; // 3 bytes: works anywhere, but slower__xdata, __code and friends.PROGMEM and pgm_read_byte(), because an ordinary pointer reads RAM.| Smart pointer | Ownership | Overhead | Use when |
|---|---|---|---|
std::unique_ptr | Single owner, move-only | Typically the size of a raw pointer | Default choice for owning an object |
std::shared_ptr | Shared, reference counted | Control block plus counting | Several owners truly share the lifetime |
std::weak_ptr | Non-owning observer of a shared_ptr | Small | Breaking reference cycles, caches, observers |
#include
#include
struct Sensor
{
Sensor() { std::cout << "Sensor created\n"; }
~Sensor() { std::cout << "Sensor destroyed\n"; }
};
int main()
{
auto owner = std::make_unique(); // sole owner (C++14)
auto shared = std::make_shared(); // reference count = 1
auto copy = shared; // reference count = 2
std::weak_ptr watcher = shared; // observes, does not own
std::cout << "use_count = " << shared.use_count() << '\n';
return 0; // everything is cleaned up automatically
}Output:
Sensor created
Sensor created
use_count = 2
Sensor destroyed
Sensor destroyedunique_ptr with a custom deleter is a clean way to wrap locks and peripheral handles. shared_ptr is rarely worth its cost on small MCUs, and heap use may be restricted by your coding standard.std::auto_ptr. It was deprecated in C++11 and removed in C++17.this Pointerthis pointer is a hidden pointer passed to every non-static member function. It points to the object the function was called on.*this for method chaining, and to hand the current object to other functions.class Led
{
int pin;
public:
Led& setPin(int pin)
{
this->pin = pin; // 'this' resolves the clash with the parameter name
return *this; // allows led.setPin(5).setPin(6)
}
};| Pointer | Reference | |
|---|---|---|
| Can be null | Yes | No, it must bind to an object |
| Can be reassigned | Yes | No |
| Dereference needed | *p | No |
| Best for | Optional objects, arrays, hardware addresses | Required parameters, operator overloading |
| Language | Pointer support | Notes |
|---|---|---|
| C / C++ | Full: arithmetic, casting, function pointers | Maximum power, maximum responsibility |
| Rust | References (&T, &mut T), raw pointers (*const T, *mut T), Box, Rc, Arc | The borrow checker rejects dangling references at compile time; raw pointers need unsafe |
| Go | Pointers exist, no pointer arithmetic | Garbage collected |
| C# | Pointers only inside unsafe code | Mostly references |
| Java / Python | No explicit pointers, only object references | null and None play the role of a null pointer |
| If you need to… | Use |
|---|---|
| Mark “no object yet” or signal failure | Null pointer (NULL / nullptr) |
| Accept any data type in a generic API | void * plus a size parameter |
| Let a function change the caller’s pointer | Pointer to pointer |
| Choose behavior at runtime (callbacks, state machines) | Function pointer |
| Read data without modifying it | Pointer to const |
| Talk to a hardware register | Pointer to volatile (const volatile if read-only) |
| Pass a big struct cheaply | Structure pointer (const if read-only) |
| Own heap memory in C++ | std::unique_ptr |
| Share ownership in C++ | std::shared_ptr, with weak_ptr to break cycles |
| Save RAM on an 8051 | Memory-specific pointers (xdata, code, idata) |
malloc‘s return value: always compare with NULL.free(): set the pointer to NULL immediately.void * to the wrong type: keep a type tag or size next to every generic pointer.uint8_t * cast to uint32_t * can fault on Cortex-M0/M0+.const or volatile: it silences the compiler, not the bug.sizeof on an array parameter: inside a function it returns the pointer’s size, not the array’s.new with delete, new[] with delete[], and malloc with free.-Wall -Wextra and treat pointer warnings as errors.cppcheck or clang-tidy for static analysis.-fsanitize=address) or Valgrind.print *ptr and x/8xb ptr to inspect memory.sizeof(void *) and sizeof(int *) on your PC and on your target board, then compare.volatile pointer to toggle a GPIO pin without any HAL call.NULL, and clear pointers after free().const to protect data and volatile to talk to hardware.void * for generic APIs and function pointers for callbacks and state machines.unique_ptr over raw owning pointers.
Pointers stop being scary when you ask the same four questions every time:
Answer those and you can read almost any pointer declaration, spot most pointer bugs before they ship, and write firmware that behaves on the bench and in the field.
A wild pointer was never initialized, so it holds a garbage address. A dangling pointer was valid once, but the memory it points to has been freed or has gone out of scope. Both are unsafe to dereference.
The C standard has no official count. The classic list has eight: null, void, wild, dangling, complex, near, far and huge. In practice you also work with function, double, const, volatile and restrict pointers, and C++ adds smart pointers. Technically, wild and dangling describe a pointer’s state rather than a separate type.
A pointer to volatile, such as volatile uint32_t *. The volatile qualifier forces every read and write to really happen, so the compiler cannot cache or remove register accesses.
Not directly. Cast it to a concrete type first, for example *(int *)ptr. Standard C also does not allow arithmetic on a void pointer.
It depends on the platform: 2 bytes on AVR, 4 bytes on 32-bit ARM Cortex-M, 8 bytes on 64-bit PCs, and 3 bytes for a generic pointer in Keil C51 on the 8051.
Not on modern 32-bit and 64-bit compilers. They belong to 16-bit DOS-era x86, though similar ideas survive in memory-space qualifiers on 8051 and AVR.
Not explicitly. They use object references managed by the runtime, and null or None plays the role of a null pointer.
Indian Institute of Embedded Systems – IIES