Theory-Pointer to Pointer and void Pointer in Embedded C: Double Pointers, NULL and Dangling Pointers-Free Embedded C Programming Course

Need Help or More Information?

Questions about this free C programming course, embedded C training or career guidance? Get in touch.

MODULE 9 · LESSON 9.3 · THEORY

Pointer to Pointer and void Pointer in Embedded C: Double Pointers, NULL and Dangling Pointers

Double pointers and T ** parameters, arrays of pointers, the generic void *, opaque contexts, NULL rules, wild and dangling pointers, safe_free, and static pools instead of malloc. Keep this in mind whenever you apply pointer to pointer and void pointer in embedded C.

55 min
Read Time
Intermediate
Level
M9 · L3
Module / Lesson

What You Will Learn

✓ What a pointer to pointer (int **) is and how to read it
✓ Why a function needs T ** to change the caller’s pointer
✓ Arrays of pointers versus two-dimensional arrays, and argv-style tables
✓ The generic void *: what it allows and what it forbids
✓ Writing generic functions with void * and a size
✓ NULL: the null pointer constant and its checks
✓ Wild, dangling and freed pointers, and how to avoid them
✓ The safe_free pattern and why embedded code prefers pools

Key Terms

Pointer to pointerDouble pointervoid pointerGeneric functionNULLWild pointerDangling pointerUse after freeArray of pointersargvmemcpyqsortMemory poolmallocType punning

1. Pointer to Pointer: int **pp

A pointer is a variable, so it also has an address, and that address can be stored in another pointer. A pointer to pointer (double pointer) declared int **pp holds the address of an int *. Each star adds one level of indirection: pp is the address of a pointer, *pp is that pointer, **pp is the int. Many bugs with pointer to pointer and void pointer in embedded C start here.

ptr_to_ptr.c
#include <stdio.h> int main(void) { int value = 42; int *p = &value; /* p points to value */ int **pp = &p; /* pp points to p */ printf(“value = %d\n”, value); printf(“*p = %d\n”, *p); printf(“**pp = %d\n”, **pp); **pp = 99; /* change value through two levels */ printf(“after **pp = 99: value = %d\n”, value); int other = 7; *pp = &other; /* change WHERE p points, via pp */ printf(“after *pp = &other: *p = %d (p now points to other)\n”, *p); printf(“value is still %d\n”, value); return 0; }
Output (verified by compiling and running)
value = 42 *p = 42 **pp = 42 after **pp = 99: value = 99 after *pp = &other: *p = 7 (p now points to other) value is still 99
Three levels of indirection
pp
address of p
→
p
address of value
→
value
42, then 99

Notice the two different effects: **pp = 99 changes the int; *pp = &other changes the pointer p itself, so it now refers to another variable. This is the core rule of pointer to pointer and void pointer in embedded C.

2. Changing the Caller’s Pointer: T ** Parameters

Lesson 8.2 showed that a function receives a copy of its pointer arguments. To let a function move the caller’s pointer, pass the address of the pointer. The parameter is then a pointer to pointer, and the function assigns through *param. Reviewers check pointer to pointer and void pointer in embedded C closely.

move_cursor.c
#include <stdio.h> #include <stddef.h> /* wrong: p is a copy, the caller’s pointer does not move */ static void skip_wrong(const char *p) { while (*p == ‘ ‘) p++; } /* right: receives the address of the caller’s pointer */ static void skip_right(const char **pp) { while (**pp == ‘ ‘) (*pp)++; } /* hands out buffers from a static pool: the caller’s pointer receives the address */ static char pool[2][8]; static int pool_used; static int pool_get(char **out) { if (pool_used >= 2) { *out = NULL; return –1; } *out = pool[pool_used++]; return 0; } int main(void) { const char *s = ” go”; skip_wrong(s); printf(“after skip_wrong: [%s]\n”, s); skip_right(&s); printf(“after skip_right: [%s]\n”, s); char *b1, *b2, *b3; int r1 = pool_get(&b1); int r2 = pool_get(&b2); int r3 = pool_get(&b3); printf(“pool_get: %d %d %d\n”, r1, r2, r3); printf(“b3 is %s\n”, b3 ? “valid” : “NULL (pool exhausted)”); return 0; }
Output (verified by compiling and running)
after skip_wrong: [ go] after skip_right: [go] pool_get: 0 0 -1 b3 is NULL (pool exhausted)

pool_get shows the second typical use: a function that returns a pointer through a parameter so that the return value can report success or failure. This is how many embedded APIs hand out buffers or descriptors. Interviewers test pointer to pointer and void pointer in embedded C on exactly this point.

GoalParameter typeCall
Change an intint *f(&x)
Change a pointer to intint **f(&p)
Advance a cursor over const textconst char **f(&cursor)
Receive a pointer to a bufferchar **f(&buf)

3. Arrays of Pointers versus Two-Dimensional Arrays

An array of pointers stores addresses; a two-dimensional array stores the data itself. For tables of strings the difference matters in RAM and in flexibility. Master pointer to pointer and void pointer in embedded C by practising this case.

ptr_array.c
#include <stdio.h> int main(void) { const char *names_ptr[] = { “red”, “green”, “blue”, “yellow” }; /* 4 pointers; strings in Flash */ char names_arr[4][10] = { “red”, “green”, “blue”, “yellow” }; /* 40 bytes of data in RAM */ printf(“array of pointers: sizeof = %zu (4 pointers: 8 bytes each on this PC, 4 on the Cortex-M4 = 16)\n”, sizeof names_ptr); printf(“2D char array : sizeof = %zu (4 rows x 10 bytes, same on every machine)\n”, sizeof names_arr); for (int i = 0; i < 4; i++) printf(“%d: %-7s %-7s\n”, i, names_ptr[i], names_arr[i]); const char **walk = names_ptr; /* pointer to the first pointer */ printf(“walk[2] = %s, *(walk + 3) = %s\n”, walk[2], *(walk + 3)); return 0; }
Output (verified by compiling and running)
array of pointers: sizeof = 32 (4 pointers: 8 bytes each on this PC, 4 on the Cortex-M4 = 16) 2D char array : sizeof = 40 (4 rows x 10 bytes, same on every machine) 0: red red 1: green green 2: blue blue 3: yellow yellow walk[2] = blue, *(walk + 3) = yellow
Array of pointers2D array
Memory4 pointers + the string literals (Flash)All data in the array (RAM if not const)
Rows of different lengthYes, no wastePadded to the declared row length
Rows swappable cheaplyYes: swap two pointersNo: copy whole rows
Can be const in FlashYes: static const char *const t[]Yes: static const char t[][8]
Same idea in main(): The second parameter of int main(int argc, char *argv[]) is an array of pointers to the argument strings, equivalent to char **argv. Embedded programs have no command line, but command parsers use the same shape.

4. The Generic Pointer: void *

A void * can hold the address of any object. It carries no type information, so the compiler cannot know the size of what it points to. The consequences: Remember this when you review pointer to pointer and void pointer in embedded C in code reviews.

  • Any object pointer converts to void * and back implicitly in C, without a cast. (C++ requires a cast.)
  • It cannot be dereferenced: you must first convert it to a typed pointer.
  • Pointer arithmetic on void * is not standard C. GCC allows it as an extension, treating sizeof(void) as 1; do not rely on it. Use uint8_t * for byte arithmetic.
  • It is the basis of generic code: memcpy, qsort and callback contexts all use it.
generic_swap.c
#include <stdio.h> #include <stdint.h> #include <string.h> /* swap any two objects of the same size */ static void swap_any(void *a, void *b, size_t size) { uint8_t tmp[16]; /* small objects only in this demo */ if (size > sizeof tmp) return; memcpy(tmp, a, size); memcpy(a, b, size); memcpy(b, tmp, size); } typedef struct { int id; int temp; } sample_t; int main(void) { int a = 3, b = 9; double x = 1.5, y = 2.5; sample_t s1 = { 1, 20 }, s2 = { 2, 35 }; swap_any(&a, &b, sizeof a); swap_any(&x, &y, sizeof x); swap_any(&s1, &s2, sizeof s1); printf(“ints : %d %d\n”, a, b); printf(“doubles: %.1f %.1f\n”, x, y); printf(“structs: {%d,%d} {%d,%d}\n”, s1.id, s1.temp, s2.id, s2.temp); return 0; }
Output (verified by compiling and running)
ints : 9 3 doubles: 2.5 1.5 structs: {2,35} {1,20}

One function swaps ints, doubles and structures because it works on raw bytes; the caller supplies the size. The price is type safety: nothing stops you from calling swap_any(&a, &x, sizeof a) with two different types. Generic drivers depend on pointer to pointer and void pointer in embedded C being understood.

voidderef.c
int get(void *p) { return *p; /* what type is it? */ }
void * cannot be dereferenced
voidderef.c: In function ‘get’: voidderef.c:3:12: warning: dereferencing ‘void *’ pointer 3 | return *p; /* what type is it? */ | ^~ voidderef.c:3:12: error: void value not ignored as it ought to be voidderef.c:4:1: warning: control reaches end of non-void function [-Wreturn-type] 4 | } | ^

5. void * as a Callback Context and in Handles

Lesson 8.4 used void *ctx to pass an object through a library that knows nothing about it. The same technique appears in RTOS task parameters (void *arg), HAL handles and event queues. The rule: the code that creates the context and the callback that receives it agree on the real type; the library in between treats it as an opaque pointer.

opaque.c
#include <stdio.h> #include <stdint.h> typedef void (*task_fn)(void *arg); typedef struct { const char *name; uint32_t period_ms; } blink_cfg_t; typedef struct { uint16_t threshold; } alarm_cfg_t; static void blink_task(void *arg) { const blink_cfg_t *c = arg; /* implicit conversion back to the real type */ printf(“blink task ‘%s’ every %u ms\n”, c->name, (unsigned)c->period_ms); } static void alarm_task(void *arg) { const alarm_cfg_t *c = arg; printf(“alarm task: threshold %u\n”, c->threshold); } static void run(task_fn fn, void *arg) { fn(arg); } /* the “scheduler” knows nothing about the types */ int main(void) { blink_cfg_t b = { “green LED”, 500 }; alarm_cfg_t a = { 3000 }; run(blink_task, &b); run(alarm_task, &a); return 0; }
Output (verified by compiling and running)
blink task ‘green LED’ every 500 ms alarm task: threshold 3000

6. NULL: The Null Pointer

NULL is a macro for the null pointer constant (an integer constant expression with value 0, or such an expression cast to void *). Assigning it to any pointer gives a pointer that compares unequal to every valid object address. Rules:

  • Initialise unused pointers to NULL; test with if (p) or if (p != NULL).
  • Dereferencing NULL is undefined behavior; on the STM32F407 a read often succeeds (address 0 aliases Flash), a write is ignored or faults.
  • Functions that return a pointer use NULL for failure; callers must check.
  • NULL is not the same as address zero on every platform; always write NULL (or 0 in a pointer context), never compare with a hand-written address.
  • Passing NULL to memcpy, strlen and friends is undefined even with a length of 0; check first.
null_api.c
#include <stdio.h> #include <stddef.h> #include <string.h> /* Returns the number of characters copied, or -1 for invalid arguments. */ static int safe_copy(char *dst, size_t dst_size, const char *src) { if (dst == NULL || src == NULL || dst_size == 0) return –1; size_t n = strlen(src); if (n >= dst_size) n = dst_size – 1; /* truncate */ memcpy(dst, src, n); dst[n] = ‘\0’; return (int)n; } int main(void) { char small[6]; printf(“copy \”firmware\” -> %d, text \”%s\”\n”, safe_copy(small, sizeof small, “firmware”), small); printf(“NULL source -> %d\n”, safe_copy(small, sizeof small, NULL)); printf(“NULL destination -> %d\n”, safe_copy(NULL, 6, “abc”)); printf(“zero size -> %d\n”, safe_copy(small, 0, “abc”)); return 0; }
Output (verified by compiling and running)
copy “firmware” -> 5, text “firmw” NULL source -> -1 NULL destination -> -1 zero size -> -1

7. Wild and Dangling Pointers

KindCauseSymptomPrevention
WildUsed before initialisationWrites to a random addressInitialise at declaration
Dangling (scope)Address of a local returned or storedData changes unexpectedlyNever return &local; use static or caller buffers
Dangling (free)Used after free()Corrupted heap, random crashesSet to NULL after free
Dangling (realloc)Old pointer after realloc moved the blockReads stale dataUse only the returned pointer
Out of rangeIndex or arithmetic beyond the objectOverwrites neighboursCarry the length, check bounds

A pointer-to-pointer gives a neat defence: a helper that frees a block and clears the caller’s pointer in one step.

safe_free.c
#include <stdio.h> #include <stdlib.h> static void safe_free(void **pp) { if (pp != NULL && *pp != NULL) { free(*pp); *pp = NULL; /* the caller’s pointer can no longer dangle */ } } int main(void) { int *data = malloc(4 * sizeof *data); if (data == NULL) return 1; data[0] = 10; printf(“data[0] = %d, data is %s\n”, data[0], data ? “set” : “NULL”); safe_free((void **)&data); printf(“after safe_free: data is %s\n”, data ? “set” : “NULL”); safe_free((void **)&data); /* a second call is harmless */ printf(“second safe_free: no crash\n”); return 0; }
Output (verified by compiling and running)
data[0] = 10, data is set after safe_free: data is NULL second safe_free: no crash
Standards note: Converting int ** to void ** by a cast is technically not guaranteed to work (the two pointer types need not have the same representation). It does on all common compilers including arm-none-eabi-gcc, which is why this idiom is widely used, but a macro that takes the pointer variable itself is strictly portable: #define SAFE_FREE(p) do { free(p); (p) = NULL; } while (0).

8. Dynamic Memory in Embedded C: Prefer Pools

malloc and free are available in newlib, but most embedded projects avoid them: the heap fragments over weeks of uptime, allocation time is unpredictable, failure must be handled everywhere, and safety standards (MISRA C rule 21.3) forbid them. The usual replacement is a static pool: a fixed array of equally sized blocks with a free list. Allocation and release take constant time and the total memory is known at link time.

pool.c
#include <stdio.h> #include <stddef.h> #define BLOCKS 3 typedef struct msg { int id; struct msg *next_free; } msg_t; static msg_t pool[BLOCKS]; static msg_t *free_list; static void pool_init(void) { free_list = NULL; for (int i = BLOCKS – 1; i >= 0; i–) /* link every block into the free list */ { pool[i].next_free = free_list; free_list = &pool[i]; } } static msg_t *pool_alloc(void) { msg_t *m = free_list; if (m) free_list = m->next_free; /* pop */ return m; /* NULL when empty */ } static void pool_free(msg_t *m) { m->next_free = free_list; /* push */ free_list = m; } int main(void) { pool_init(); msg_t *a = pool_alloc(), *b = pool_alloc(), *c = pool_alloc(), *d = pool_alloc(); printf(“a,b,c valid: %d, d is %s\n”, a && b && c, d ? “valid” : “NULL (pool empty)”); pool_free(b); msg_t *e = pool_alloc(); printf(“after freeing b, the next allocation reuses it: %s\n”, e == b ? “yes” : “no”); return 0; }
Output (verified by compiling and running)
a,b,c valid: 1, d is NULL (pool empty) after freeing b, the next allocation reuses it: yes

9. Common Mistakes with Pointer to Pointer and void *

MistakeResultFix
Passing p instead of &p to a T ** parameterWrong type or crashPass the address of the pointer
Dereferencing a void *Compile errorConvert to a typed pointer first
Arithmetic on void *GCC extension, not portableUse uint8_t *
Casting away const in a void * round tripWrites to read-only dataKeep const void * for inputs
Using a pointer after freeUndefined behaviorSet to NULL; prefer pools
Converting char ** to const char **Compiler warning (unsafe)Use const char *const * or a cast with care
constpp.c
void use(const char **pp); void f(void) { char *s = (char *)“abc”; use(&s); /* char ** to const char ** */ }
GCC on pointer-to-pointer const conversion
constpp.c: In function ‘f’: constpp.c:6:9: warning: passing argument 1 of ‘use’ from incompatible pointer type [-Wincompatible-pointer-types] 6 | use(&s); /* char ** to const char ** */ | ^~ | | | char ** constpp.c:1:23: note: expected ‘const char **’ but argument is of type ‘char **’ 1 | void use(const char **pp); | ~~~~~~~~~~~~~^~

The conversion is unsafe: through the const char ** the callee could store a pointer to a const object into s, and the caller could then modify it. Declare the parameter as const char *const * if the callee only reads.

10. Best Practices

  • Use T ** only for real needs: changing the caller’s pointer or returning a pointer through a parameter.
  • Prefer a return value, or a struct, when only one result is needed.
  • Use const void * for inputs and void * only for data the callee writes.
  • Always pair a void * buffer with a size.
  • Check every pointer from outside for NULL; document whether NULL is allowed.
  • Avoid malloc/free in firmware; use static pools or static buffers.
  • Set pointers to NULL when the object they refer to ends.
  • Keep the levels of indirection low; more than two stars is a design smell.

Key Takeaways — Lesson 9.3

  • int **pp holds the address of an int *; *pp is the pointer, **pp the int.
  • To change a pointer in the caller, pass its address and assign through *param.
  • An array of pointers stores addresses (flexible, small); a 2D array stores the data.
  • void * holds any object address, cannot be dereferenced and has no standard arithmetic.
  • NULL means no object; check before use.
  • Wild and dangling pointers cause undefined behavior; initialise, clear after free and prefer pools.

Frequently Asked Questions

What is a pointer to pointer in C?
A variable of type T ** that stores the address of a pointer of type T *. Dereferencing it once gives the inner pointer, twice gives the final object.
When do I need a double pointer parameter?
When a function must change the caller’s pointer itself, for example to advance a cursor through a string or to return a pointer through a parameter.
What is a void pointer used for?
It is the generic object pointer. It can hold the address of any object and be converted back without a cast in C, so it is used in memcpy, qsort, callbacks and opaque handles. It cannot be dereferenced directly.
What is the difference between a NULL pointer and a dangling pointer?
A NULL pointer deliberately points to nothing and can be tested. A dangling pointer still holds the address of an object that no longer exists, so it looks valid but is not.
Is malloc safe to use in embedded C?
It works, but the heap can fragment, timing is unpredictable and failures must be handled, so many projects and standards such as MISRA C avoid it in favour of static pools and buffers.

Ready to practise?

Next: worked examples with cursors, string tables, generic helpers, a block pool and a linked list built with double pointers.

Need Help or More Information?

Questions about this free C programming course, embedded C training or career guidance? Get in touch.

2 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *