Pointers are the feature C is both famous and infamous for - the source
of its raw power over memory, and the source of most of its scariest
bugs. Every other guide in this series leans on pointers constantly:
structs passed by pointer, arrays decaying to pointers, function
pointers as callbacks, FILE * handles. This guide is where pointers get
covered properly, on their own terms, from the basic &/* operators
all the way to double pointers, dynamic memory, and the bugs that make
pointers earn their reputation.
0. Why they're needed, why industry can't avoid them, and why your syllabus treats them so gently
Why they're needed at all
Every value in a running program lives somewhere in memory, at some address. A pointer is simply a variable that stores an address instead of an ordinary value - which sounds abstract until you realize it's the only mechanism C gives you for a function to modify a caller's data (the functions guide's whole section 5), for building a data structure where one element refers to another (the structs guide's linked lists), or for managing memory whose size isn't known until the program is running. Without pointers, C would have no arrays that scale to runtime sizes, no linked data structures, and no way to hand a large chunk of data to a function without copying it.
Why real projects can't avoid them
- Efficiency. Passing a pointer instead of copying a large struct or array is dramatically cheaper - a pointer is typically 8 bytes regardless of what it points to.
- Dynamic memory. Anything whose size isn't known at compile time -
a file loaded into memory, a growable list, a user-defined-size buffer
- has to be allocated at runtime and referenced through a pointer; there's no other option in C.
- Data structures. Linked lists, trees, graphs, and hash tables are all built from pointers connecting one allocated node to another - this was covered from the struct side in the structs guide, and this guide covers it from the pointer side.
- APIs and callbacks. Function pointers (section 20) are how C
achieves plug-in behavior -
qsort's comparator, event handlers, driver interfaces. - Systems and hardware access. Reading a specific memory address (a hardware register, a memory-mapped file, shared memory between processes) is fundamentally "treat this number as an address and access what's there" - which is exactly what a pointer is.
Why your syllabus (probably) went gently here
- Pointers are where most beginners' mental model of programming
breaks down - the jump from "a variable holds a value" to "a variable
holds an address of a value" is a genuine conceptual leap, and
courses rightly spend a lot of care getting the basics (
&,*,NULL) solid before moving on. - Pointer arithmetic and multi-level pointers are usually rushed or
skipped because they compound the difficulty - understanding
int **pprequires already being comfortable withint *p, which itself is often still shaky by the time a course needs to move on to other topics. - Dynamic memory bugs (leaks, use-after-free, double-free) are genuinely hard to teach well without a debugger or a tool like Valgrind/AddressSanitizer actually catching them live - reading about a use-after-free in a slide deck is nowhere near as instructive as watching one crash (or, worse, silently "work" until it doesn't).
- Real declaration-reading skills (section 24) are almost never taught explicitly - most courses show you a handful of common patterns and hope you pattern-match from there, rather than teaching the actual right-to-left reading rule that lets you parse any declaration, however gnarly.
Once pointers click, they stop feeling like C's scariest feature and start feeling like its most direct, honest one - there's no hidden reference-counting, no garbage collector quietly doing things behind your back. You're looking at exactly what the machine is doing. That's also exactly why getting it wrong is so unforgiving.
1. What a pointer is: & and *
A pointer is a variable that stores a memory address. & (address-of)
gets a variable's address; * (dereference) accesses the value at an
address a pointer holds.
#include <stdio.h>
int main(void) {
int x = 42;
int *p = &x; // p now holds the ADDRESS of x, not x's value
printf("value of x: %d\n", x);
printf("address of x: %p\n", (void *)&x);
printf("value stored in p: %p\n", (void *)p); // same as &x
printf("value p points to: %d\n", *p); // dereference -- gives 42
*p = 100; // modifies x THROUGH the pointer
printf("x is now: %d\n", x); // 100 -- x itself changed
return 0;
}
p and x are two entirely separate variables; p just happens to
store x's address. Changing *p changes x's actual memory; changing
p itself (e.g. p = &otherVariable;) would just make p point
somewhere else, leaving x untouched.
2. Declaring pointers, and NULL
A pointer's declaration includes the type it points to - int *p means
"p is a pointer to an int." An uninitialized pointer is dangerous
(section 15); NULL is the standard way to represent "points to
nothing."
#include <stdio.h>
#include <stddef.h> // for NULL
int main(void) {
int *p1; // UNINITIALIZED -- holds garbage, NOT NULL, dangerous to dereference
int *p2 = NULL; // explicitly points to nothing -- safe, checkable state
if (p2 == NULL) {
printf("p2 points to nothing\n");
}
int x = 5;
p1 = &x; // now safely initialized
// Declaration syntax quirk: the '*' binds to the VARIABLE, not the type,
// despite how it's often read:
int *a, b; // 'a' is "int *", but 'b' is just a plain "int", NOT "int *"!
int *c, *d; // this is how you declare TWO pointers on one line correctly
(void)a; (void)b; (void)c; (void)d; // silence unused-variable warnings for this example
printf("%d\n", *p1);
return 0;
}
The int *a, b; gotcha is a real, common source of confusion - many C
style guides recommend declaring only one pointer per line specifically
to avoid it.
3. Pointer arithmetic
Adding an integer to a pointer moves it by that many elements of its
pointed-to type, not by that many raw bytes - the compiler automatically
scales the arithmetic by sizeof(the pointed-to type).
#include <stdio.h>
int main(void) {
int numbers[5] = {10, 20, 30, 40, 50};
int *p = numbers; // points to numbers[0]; an array name decays to a pointer to its first element
printf("%d\n", *p); // 10
printf("%d\n", *(p + 1)); // 20 -- moved by 1 int (4 bytes on most platforms), NOT 1 byte
printf("%d\n", *(p + 2)); // 30
p++; // now points to numbers[1]
printf("%d\n", *p); // 20
int *start = numbers;
int *end = numbers + 5; // one PAST the last element -- valid to compute, NOT valid to dereference
printf("elements between start and end: %ld\n", (long)(end - start)); // pointer subtraction: 5
return 0;
}
Pointer arithmetic is only well-defined within (or one-past-the-end of) a single array - computing or dereferencing a pointer that wanders outside those bounds is undefined behavior, even if it happens not to crash.
4. Pointers and arrays - equivalence and decay
An array name, used in most expressions, decays to a pointer to its first element - which is why array indexing and pointer arithmetic are, underneath, the exact same operation.
#include <stdio.h>
int main(void) {
int numbers[5] = {10, 20, 30, 40, 50};
int *p = numbers; // valid WITHOUT '&' -- numbers already decays to &numbers[0]
// These four are all completely equivalent ways to get the 3rd element:
printf("%d\n", numbers[2]);
printf("%d\n", *(numbers + 2));
printf("%d\n", p[2]);
printf("%d\n", *(p + 2));
// But 'numbers' and 'p' are NOT the same kind of thing, despite behaving
// similarly here -- 'numbers' is an array (its size is baked into its
// type), 'p' is just a pointer (no size information at all):
printf("%zu\n", sizeof(numbers)); // 20 -- the whole array's size
printf("%zu\n", sizeof(p)); // 8 (or 4) -- just a pointer's size
return 0;
}
This is exactly the mechanism behind the "array parameters decay to pointers" gotcha from the functions guide (section 6 there) - inside a function, an array parameter is really just a pointer, having lost its original size information entirely.
5. ptr[i] is really *(ptr + i)
C's array indexing syntax is literally defined in terms of pointer
arithmetic - arr[i] is, by the language definition, exactly equivalent
to *(arr + i). This has one genuinely surprising (if rarely useful)
consequence.
#include <stdio.h>
int main(void) {
int numbers[5] = {10, 20, 30, 40, 50};
printf("%d\n", numbers[2]); // 30, the normal way to write it
printf("%d\n", *(numbers + 2)); // 30, exactly equivalent
printf("%d\n", *(2 + numbers)); // 30, addition is commutative!
printf("%d\n", 2[numbers]); // 30, ALSO legal C, because array indexing
// literally IS pointer arithmetic under
// the hood -- 2[numbers] means *(2 + numbers)
return 0;
}
2[numbers] is a real, standards-legal piece of C (a frequent trivia/
interview question) - nobody should actually write code this way, but
understanding why it compiles is a genuine test of whether you
understand what indexing actually means in C.
6. The sizeof pointer gotcha, revisited
This was introduced in the fundamentals guide, but deserves a focused
look here: sizeof on a pointer always gives you the pointer's own size,
never the size of whatever it points to - a mistake that's easy to make
the moment an array has decayed to a pointer.
#include <stdio.h>
void processArray(int *arr) {
// arr is a POINTER here, not an array -- this is ALWAYS wrong inside
// a function that received an array as a parameter:
printf("inside function, sizeof(arr) = %zu\n", sizeof(arr)); // 8 (pointer size)
// int wrongCount = sizeof(arr) / sizeof(arr[0]); // silently WRONG -- computes 2, not 5!
}
int main(void) {
int numbers[5] = {1, 2, 3, 4, 5};
printf("in main, sizeof(numbers) = %zu\n", sizeof(numbers)); // 20 (whole array)
int correctCount = sizeof(numbers) / sizeof(numbers[0]); // 5 -- correct HERE, in main
printf("correct count: %d\n", correctCount);
processArray(numbers); // numbers decays to a pointer the moment it's passed
return 0;
}
The sizeof(arr) / sizeof(arr[0]) element-count idiom only works in the
same scope where the array was actually declared - the instant it's
passed to a function (or otherwise decays), that idiom silently computes
garbage instead of failing loudly.
7. Pointers to pointers (multi-level pointers)
A pointer can itself point to another pointer - int **pp is "a pointer
to a pointer to an int." This is genuinely needed, not just a party
trick, whenever a function needs to modify what a caller's pointer
points to (section 19), or for arrays of pointers (section 17).
#include <stdio.h>
int main(void) {
int x = 42;
int *p = &x; // p points to x
int **pp = &p; // pp points to p
printf("%d\n", x); // 42, directly
printf("%d\n", *p); // 42, one level of dereference
printf("%d\n", **pp); // 42, two levels of dereference
**pp = 100; // modifies x, going through BOTH levels of indirection
printf("%d\n", x); // 100
int y = 7;
*pp = &y; // changes what p itself points to, WITHOUT touching pp
printf("%d\n", *p); // 7 -- p now points to y instead of x
return 0;
}
Read int **pp right-to-left, one star at a time: pp is a pointer, to
a pointer, to an int - the same reading technique scales to arbitrarily
deep pointer levels (rarely more than two in practice).
8. Void pointers - generic pointers
void * is a pointer with no associated type - it can point to
anything, but can't be dereferenced directly (the compiler doesn't know
how many bytes to read) and must be cast to a concrete type first. This
is how C achieves a rough form of generic programming.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int x = 42;
void *generic = &x; // can point to ANY type
// *generic; // COMPILE ERROR -- can't dereference void*, size unknown
int *typed = (int *)generic; // must cast back to a concrete type first
printf("%d\n", *typed); // 42
// This is exactly why malloc returns void* -- it doesn't know or care
// what TYPE of data you're going to store in the memory it allocates:
void *raw = malloc(sizeof(int) * 5);
int *numbers = (int *)raw; // cast (optional in C, required in C++) to use it as ints
numbers[0] = 100;
printf("%d\n", numbers[0]);
free(raw);
return 0;
}
qsort's comparator (from the functions guide, section 15) takes
const void * parameters for exactly this reason - it's how a single
generic sorting function can work on arrays of any type at all, without
the library needing a separate qsort for each possible type.
9. const and pointers - four combinations
const can apply to the pointed-to value, the pointer itself, both, or
neither - each combination means something genuinely different, and the
syntax placement is what determines which.
#include <stdio.h>
int main(void) {
int x = 10, y = 20;
int *p1 = &x; // plain pointer: can change BOTH the pointer and the value
*p1 = 11; // OK
p1 = &y; // OK
const int *p2 = &x; // pointer to const int: value is read-only, pointer can move
// *p2 = 99; // COMPILE ERROR -- can't modify the pointed-to value
p2 = &y; // OK -- the pointer itself isn't const
int *const p3 = &x; // const pointer: value can change, pointer CANNOT move
*p3 = 99; // OK
// p3 = &y; // COMPILE ERROR -- p3 itself is const
const int *const p4 = &x; // const pointer to const int: NEITHER can change
// *p4 = 99; // COMPILE ERROR
// p4 = &y; // COMPILE ERROR
printf("%d %d %d %d\n", *p1, *p2, *p3, *p4);
return 0;
}
Reading tip: read the declaration right-to-left starting from the
variable name - const int *p2 reads as "p2 is a pointer to a const int," while int *const p3 reads as "p3 is a const pointer to an
int." This same technique extends to the arbitrarily complex
declarations in section 24.
10. Dynamic memory allocation: malloc, calloc, realloc
<stdlib.h> provides functions for allocating memory at runtime, whose
size doesn't need to be known until the program is actually executing -
returned as void *, and always in need of an eventual matching free
(section 11).
#include <stdio.h>
#include <stdlib.h>
int main(void) {
// malloc: allocates raw, UNINITIALIZED memory of the given byte size
int *a = malloc(5 * sizeof(int));
if (a == NULL) { // ALWAYS check -- malloc can fail, especially for large requests
fprintf(stderr, "allocation failed\n");
return 1;
}
for (int i = 0; i < 5; i++) a[i] = i * 10; // must initialize yourself -- contents were garbage
// calloc: allocates memory for 'count' elements of 'size' bytes each,
// and ZERO-INITIALIZES it -- unlike malloc
int *b = calloc(5, sizeof(int));
if (b == NULL) { fprintf(stderr, "allocation failed\n"); free(a); return 1; }
printf("calloc'd memory is pre-zeroed: %d\n", b[0]); // 0, guaranteed
// realloc: resizes a previous allocation, preserving existing content
// up to the smaller of the old/new sizes -- may MOVE the memory elsewhere
int *c = realloc(a, 10 * sizeof(int)); // grow 'a' from 5 to 10 ints
if (c == NULL) {
// realloc failure: the ORIGINAL pointer 'a' is still valid and must
// still be freed -- this is why you never do "a = realloc(a, ...)" directly
fprintf(stderr, "realloc failed\n");
free(a);
free(b);
return 1;
}
a = c; // safe now: c is the new (possibly moved) block
printf("%d\n", a[0]); // 0 -- original content preserved after the resize
free(a);
free(b);
return 0;
}
Always check malloc/calloc/realloc's return value for NULL - on
systems with limited memory (embedded devices especially, but even
desktop systems under memory pressure), allocation failure is a real,
recoverable condition, not something to assume away.
11. free() and memory leaks
Every successful malloc/calloc/realloc needs an eventual free -
forgetting one is a memory leak: memory the program can no longer
reach, but the OS still considers "in use," until the process exits.
#include <stdio.h>
#include <stdlib.h>
void leaky(void) {
int *data = malloc(100 * sizeof(int));
if (data == NULL) return;
data[0] = 42;
// MISSING free(data) -- this memory is now unreachable (the only
// pointer to it, 'data', is a local variable about to go out of
// scope) but the OS still considers it allocated. Call this
// function a million times and you'll exhaust available memory.
} // 'data' the POINTER is destroyed here; the MEMORY it pointed to is not
void correct(void) {
int *data = malloc(100 * sizeof(int));
if (data == NULL) return;
data[0] = 42;
free(data); // memory returned to the system -- no leak
}
int main(void) {
leaky();
correct();
return 0;
}
In a long-running program (a server, a game, anything that doesn't just run briefly and exit), even small leaks accumulate into serious memory exhaustion - tools like Valgrind or AddressSanitizer are the standard real-world way to actually find leaks, since they're often invisible in normal testing.
12. Dangling pointers and use-after-free
A dangling pointer points to memory that's no longer valid - most
commonly because it was free'd, but a variable's memory becomes
"invalid" (from the pointer's perspective) the moment its lifetime ends
for any reason. Using a dangling pointer is undefined behavior, and it
often "seems to work" right up until it catastrophically doesn't.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(int));
*p = 42;
free(p); // the memory is returned to the system...
// p still holds the SAME address it did before -- it is now a
// DANGLING pointer, even though it looks unchanged
// printf("%d\n", *p); // USE-AFTER-FREE: undefined behavior. Might
// print 42 anyway (if the memory hasn't
// been reused yet), might print garbage,
// might crash -- there's no way to know,
// and that unpredictability IS the danger
p = NULL; // best practice: null out a pointer immediately after
// freeing it, so any accidental future use fails LOUDLY
// (a crash on *p) instead of silently corrupting data
if (p != NULL) {
printf("%d\n", *p); // never reached -- p is NULL, safely checkable
}
return 0;
}
Setting a pointer to NULL immediately after freeing it is one of the
single most effective, cheap habits for avoiding use-after-free bugs -
it turns a silent, unpredictable corruption into an immediate, debuggable
crash.
13. Double free
Calling free on the same pointer twice is undefined behavior - it
corrupts the memory allocator's internal bookkeeping, and can be
exploited as a security vulnerability in the worst cases.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(int));
*p = 42;
free(p);
// free(p); // DOUBLE FREE: undefined behavior. The memory allocator's
// internal bookkeeping is now corrupted -- this can crash
// immediately, corrupt unrelated memory, or (in the worst
// real-world cases) be exploitable by an attacker who can
// influence what gets allocated next
p = NULL; // exactly the same fix as section 12 -- once nulled, a
// second "free(p)" becomes free(NULL), which is explicitly
// defined by the standard to safely do nothing at all
free(p); // SAFE -- freeing NULL is a guaranteed no-op
return 0;
}
free(NULL) being a guaranteed safe no-op (rather than an error) is
exactly why "null it out after freeing" (section 12) also protects
against double-free, for free (pun intended) - it's the same single habit
defending against two related bugs at once.
14. NULL pointer dereference and defensive checks
Dereferencing a NULL pointer is undefined behavior - on most desktop
platforms it triggers an immediate crash (a segmentation fault), which is
actually the good outcome, since it fails loudly and immediately rather
than corrupting something silently.
#include <stdio.h>
#include <stdlib.h>
int *findValue(int *arr, int len, int target) {
for (int i = 0; i < len; i++) {
if (arr[i] == target) {
return &arr[i];
}
}
return NULL; // explicitly signal "not found" -- the standard C idiom
}
int main(void) {
int numbers[5] = {10, 20, 30, 40, 50};
int *found = findValue(numbers, 5, 30);
if (found != NULL) { // ALWAYS check before dereferencing a pointer that
// might legitimately be NULL, e.g. from malloc or
// a "search" function like this one
printf("found: %d\n", *found);
}
int *notFound = findValue(numbers, 5, 999);
if (notFound != NULL) {
printf("found: %d\n", *notFound);
} else {
printf("not found\n"); // this branch runs -- and safely so
}
// printf("%d\n", *notFound); // would crash: dereferencing NULL
return 0;
}
Returning NULL to mean "no valid result" (as malloc and this
findValue function both do) is one of the most common conventions in
C - and checking for it before dereferencing is one of the most important
defensive habits.
15. Wild (uninitialized) pointers
A wild pointer is one that was never initialized at all - unlike a
NULL pointer (an intentional, checkable "points to nothing"), a wild
pointer holds a genuinely random address left over from whatever was
previously in that memory. This is arguably more dangerous than NULL,
because it usually doesn't crash immediately.
#include <stdio.h>
int main(void) {
int *wild; // UNINITIALIZED -- holds a random, garbage address, NOT NULL
// *wild = 42; // undefined behavior: might crash immediately, might
// silently corrupt some unrelated variable's memory,
// might even appear to "work" -- there's no way to
// know in advance, which is exactly what makes wild
// pointers more insidious than a NULL pointer (which
// at least crashes predictably and immediately)
int *safe = NULL; // ALWAYS initialize pointers, even if just to NULL,
// so an accidental early dereference fails loudly
// and predictably instead of corrupting memory
if (safe != NULL) {
printf("%d\n", *safe);
} else {
printf("safe was never assigned a real address\n");
}
return 0;
}
The practical rule: never leave a pointer uninitialized, even
momentarily - initialize to NULL if you don't have a real address yet,
exactly the same discipline as section 1 of the fundamentals guide
applied specifically to pointers.
16. Pointers and strings
A C string is, fundamentally, just a char * (or char[]) pointing to a
sequence of characters terminated by a '\0' byte - there's no separate
"string type." This has real consequences for mutability that trip up a
lot of beginners.
#include <stdio.h>
#include <string.h>
int main(void) {
char *literal = "Hello"; // points to a STRING LITERAL -- typically stored
// in READ-ONLY memory by the compiler/OS
// literal[0] = 'h'; // undefined behavior on most platforms -- modifying
// a string literal often crashes (though the C
// standard doesn't strictly forbid it, real
// compilers/OSes commonly place literals in
// write-protected memory)
char array[] = "Hello"; // COPIES the characters into a local, WRITABLE array
array[0] = 'h'; // perfectly fine -- this is real, mutable memory
printf("%s\n", array); // hello
// Best practice: use 'const char *' for a pointer to a string literal,
// so the compiler catches accidental modification attempts:
const char *safeLiteral = "World";
// safeLiteral[0] = 'w'; // COMPILE ERROR -- caught at compile time, not runtime
printf("%zu\n", strlen(safeLiteral)); // 5 -- strlen doesn't count the '\0' terminator
printf("%zu\n", sizeof("World")); // 6 -- sizeof DOES count the '\0' terminator
return 0;
}
char *literal = "Hello"; (a pointer to a literal) and
char array[] = "Hello"; (a real, local, writable copy) look almost
identical but behave very differently - this distinction is one of the
most common sources of "why did modifying my string crash" bugs.
17. Arrays of pointers - arrays of strings, and argv
An array whose elements are themselves pointers is a common pattern for
representing a list of strings (since each string is itself just a
pointer) - exactly the shape of argv from the functions guide's
section 20.
#include <stdio.h>
int main(void) {
// An array of 3 pointers, each pointing to a different string literal
const char *fruits[3] = {"apple", "banana", "cherry"};
for (int i = 0; i < 3; i++) {
printf("%s\n", fruits[i]); // fruits[i] is a char*, printed with %s
}
// This is EXACTLY the shape of argv in "int main(int argc, char *argv[])":
// an array of char* pointers, each one pointing to one argument string.
// argv[0] is the program name, argv[1] the first real argument, etc.
printf("address of fruits[0]: %p\n", (void *)&fruits[0]);
printf("address fruits[0] points to: %p\n", (void *)fruits[0]);
// these two addresses are DIFFERENT -- fruits[0] itself lives in the
// array; the STRING "apple" it points to lives somewhere else entirely
// (typically the same read-only literal storage from section 16)
return 0;
}
18. Pointer to an array vs. array of pointers - the syntax difference
int *arr[5] and int (*arr)[5] look almost identical but mean
genuinely different things - one is an array of 5 pointers, the other is
a single pointer to an array of 5 ints. The parentheses are what changes
the meaning entirely.
#include <stdio.h>
int main(void) {
int a = 1, b = 2, c = 3;
int *arrayOfPointers[3] = {&a, &b, &c}; // an ARRAY containing 3 separate int*
printf("%d %d %d\n", *arrayOfPointers[0], *arrayOfPointers[1], *arrayOfPointers[2]);
int numbers[3] = {10, 20, 30};
int (*pointerToArray)[3] = &numbers; // a SINGLE pointer to one whole array of 3 ints
printf("%d %d %d\n", (*pointerToArray)[0], (*pointerToArray)[1], (*pointerToArray)[2]);
printf("%zu\n", sizeof(arrayOfPointers)); // 3 pointers' worth of space (e.g. 24)
printf("%zu\n", sizeof(pointerToArray)); // ONE pointer's worth of space (e.g. 8) --
// even though it "points to" 3 ints' worth
// of data elsewhere
return 0;
}
The [] binds tighter than * unless parentheses say otherwise - that's
the entire reason int *arr[5] (array of pointers) and int (*arr)[5]
(pointer to an array) mean such different things despite looking so
similar. Section 24 covers the general rule this follows.
19. Modifying a pointer itself - why some functions need a double pointer
Passing a plain pointer lets a function modify what it points to (section 1), but not what the caller's pointer variable itself points to - for that, the function needs a pointer to the caller's pointer, i.e. a double pointer.
#include <stdio.h>
#include <stdlib.h>
// WRONG for this purpose: reassigning the LOCAL COPY 'p' inside the
// function has no effect on the caller's pointer at all.
void allocateWrong(int *p) {
p = malloc(sizeof(int)); // only changes the local copy of the pointer
*p = 42;
}
// RIGHT: takes a pointer TO the caller's pointer, so it can modify what
// the CALLER's own pointer variable points to.
void allocateRight(int **p) {
*p = malloc(sizeof(int)); // dereference once to reach the caller's actual pointer
**p = 42; // dereference again to reach the allocated int
}
int main(void) {
int *a = NULL;
allocateWrong(a);
printf("%p\n", (void *)a); // still NULL -- allocateWrong never actually changed it
int *b = NULL;
allocateRight(&b); // pass the ADDRESS of the pointer itself
printf("%d\n", *b); // 42 -- b now correctly points to the allocated memory
free(b);
return 0;
}
This exact pattern - a function taking a Type ** so it can allocate and
hand back a new pointer through an "output parameter" - is extremely
common in real-world C APIs, anywhere a function's job is specifically to
create something and give the caller a way to reach it.
20. Function pointers - a pointer to executable code
Briefly recapped here for completeness (the functions guide covers this in full depth, sections 14–16 there): a function pointer holds the address of a function rather than a data value, letting you store, pass, and call functions indirectly.
#include <stdio.h>
int add(int a, int b) { return a + b; }
int main(void) {
int (*operation)(int, int) = add; // 'operation' points to the FUNCTION 'add'
printf("%d\n", operation(3, 4)); // 7 -- called indirectly through the pointer
return 0;
}
See the functions guide for callbacks (qsort), dispatch tables, and the
full syntax breakdown - it's covered here only to place function pointers
in context alongside the rest of C's pointer family.
21. Pointers to structs
Also briefly recapped here (full depth in the structs guide, sections 1.6
and 1.17): the -> operator is shorthand for dereferencing a struct
pointer and accessing a member in one step.
#include <stdio.h>
typedef struct {
int x, y;
} Point;
int main(void) {
Point p = {3, 4};
Point *ptr = &p;
printf("%d\n", ptr->x); // shorthand for (*ptr).x
ptr->x = 100; // modifies the original p through the pointer
printf("%d\n", p.x); // 100
return 0;
}
22. Dynamically allocated 2D arrays
A true 2D array's size is fixed at compile time (like int grid[3][4]),
but real programs often need dimensions only known at runtime - built out
of an array of pointers, each pointing to a separately-allocated row.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int rows = 3, cols = 4;
// Allocate an array of 'rows' int* pointers...
int **grid = malloc(rows * sizeof(int *));
if (!grid) return 1;
// ...then allocate each ROW separately.
for (int i = 0; i < rows; i++) {
grid[i] = malloc(cols * sizeof(int));
if (!grid[i]) return 1; // (a real program would also free earlier rows here)
}
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
grid[i][j] = i * cols + j; // grid[i][j] works exactly like a real 2D array
}
}
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
printf("%d ", grid[i][j]);
}
printf("\n");
}
// Must free in the OPPOSITE order: every row first, THEN the array of row pointers
for (int i = 0; i < rows; i++) {
free(grid[i]);
}
free(grid);
return 0;
}
The rows aren't guaranteed to be contiguous in memory this way (unlike a
true 2D array) - for performance-sensitive code, a common alternative is
a single malloc(rows * cols * sizeof(int)) block with manual
row * cols + col indexing, trading a bit of readability for one
allocation instead of rows + 1.
23. Pointer casting and strict aliasing
Casting a pointer from one type to another is common (void * to a
concrete type, one numeric type to another), but reading memory through a
pointer of a different, incompatible type than what actually created it
is undefined behavior under C's "strict aliasing" rule - the same
underlying issue as the union type-punning caveat from the structs guide.
#include <stdio.h>
int main(void) {
int x = 0x12345678;
// Reading the same memory through an INCOMPATIBLE pointer type
// violates strict aliasing -- undefined behavior, even though it
// often appears to "work" on common compilers/platforms:
// float *asFloat = (float *)&x;
// printf("%f\n", *asFloat); // technically UB, avoid this pattern
// The safe, portable way to reinterpret bytes between types is memcpy,
// exactly as covered in the structs guide's protocol-parsing section:
float asFloatSafe;
// (Only meaningful if sizeof(int) == sizeof(float), true on most platforms)
if (sizeof(int) == sizeof(float)) {
__builtin_memcpy(&asFloatSafe, &x, sizeof(x)); // or plain memcpy() with <string.h>
printf("%f\n", asFloatSafe);
}
// Casting between pointer-to-COMPATIBLE-type is fine and common,
// e.g. void* to a concrete type (section 8), or up/down a type
// hierarchy of struct types intentionally designed for it.
return 0;
}
This is the same underlying rule referenced in the file handling guide's
advice to memcpy a raw byte buffer into a struct rather than casting the
buffer's pointer directly - casting-and-dereferencing across incompatible
types is the actual violation; memcpy sidesteps it entirely and is the
portable, standard-compliant choice.
24. Reading complex pointer declarations
C's declaration syntax is famously hard to read once pointers, arrays,
and functions combine - the reliable technique is reading outward from
the variable name, applying [] and () before * at each step
(informally called the "spiral rule" or "clockwise/spiral rule").
#include <stdio.h>
int main(void) {
int x = 42;
int *p1 = &x; // p1 is a pointer to int
int **p2 = &p1; // p2 is a pointer to (a pointer to int)
int a1[5]; // a1 is an array of 5 ints
int *a2[5]; // a2 is an array of 5 (pointers to int)
int (*p3)[5]; // p3 is a pointer to (an array of 5 ints)
int (*fp)(int, int); // fp is a pointer to (a function taking (int,int), returning int)
int *(*fp2)(int); // fp2 is a pointer to (a function taking int, returning int*)
(void)p2; (void)a1; (void)a2; (void)p3; (void)fp; (void)fp2;
printf("Reading rule demo compiled successfully.\n");
printf("%d\n", **p2 == x); // 1 -- confirms p2 really does reach x through two levels
return 0;
}
The practical version of the rule: start at the variable's name, look
immediately to the right first ([] for array, () for function), then
to the left (* for pointer), and repeat outward - this parses even the
gnarliest real-world declarations (like fp2 above) mechanically,
without needing to guess.
25. Common pitfalls checklist
Leaving a pointer uninitialized - holds a random address, not
NULL; always initialize, even if just toNULL(section 15).Dereferencing
NULLor a wild pointer without checking first - undefined behavior, typically (but not guaranteed to be) an immediate crash (sections 14, 15).Forgetting to
freeallocated memory - a memory leak, invisible in short-lived programs but serious in anything long-running (section 11).Using a pointer after
freeing it (use-after-free), orfreeing the same pointer twice (double-free) - both undefined behavior; set the pointer toNULLimmediately afterfreeto catch both defensively (sections 12, 13).Assuming
sizeofon a pointer gives you the pointed-to data's size- it only ever gives the pointer's own size (typically 8 bytes) (section 6).
Modifying a string literal through a non-
constchar *- frequently crashes; useconst char *for literals so the compiler catches accidental writes (section 16).Passing a single pointer to a function that needs to reassign the caller's pointer itself - only a double pointer (
Type **) can do that; a single pointer parameter can only modify what it already points to (section 19).Confusing
int *arr[N](array of pointers) withint (*arr)[N](pointer to an array) - the parentheses completely change the meaning (section 18).Casting between incompatible pointer types and dereferencing the result - a strict-aliasing violation; use
memcpyto reinterpret bytes safely instead (section 23).
Quick reference
| Feature | Syntax | Purpose | Section |
|---|---|---|---|
| Address-of | &x | Get a variable's memory address | 1 |
| Dereference | *p | Access the value a pointer points to | 1 |
| Null pointer | NULL (or 0) | Explicit, checkable "points to nothing" | 2 |
| Pointer arithmetic | p + 1, p++ | Move by one ELEMENT (scaled by type size), not one byte | 3 |
| Array/pointer equivalence | arr[i] == *(arr + i) | Indexing is defined in terms of pointer arithmetic | 5 |
| Pointer to pointer | int **pp | A pointer whose target is itself a pointer | 7 |
| Generic pointer | void * | Untyped pointer, must be cast before dereferencing | 8 |
| Pointer to const | const int *p | Value read-only through p; p itself can move | 9 |
| Const pointer | int *const p | p cannot move; value through it can change | 9 |
| Heap allocation | malloc, calloc, realloc | Runtime-sized memory allocation | 10 |
| Deallocation | free(p) | Release allocated memory back to the system | 11 |
| Safe post-free habit | p = NULL; after free(p) | Defends against use-after-free and double-free | 12, 13 |
| String as pointer | char * / const char * | C strings are just pointers to '\0'-terminated bytes | 16 |
| Array of strings | const char *arr[N] | Common shape for string lists and argv | 17 |
| Output-parameter pattern | void f(Type **out) | Let a function hand back a newly allocated pointer | 19 |
| Function pointer | RetType (*fp)(ArgTypes) | Pointer to executable code (full detail: functions guide) | 20 |
| Struct pointer access | ptr->member | Shorthand for (*ptr).member (full detail: structs guide) | 21 |
| Dynamic 2D array | array of malloc'd row pointers | Runtime-sized 2D array via pointer-to-pointer | 22 |
| Safe type reinterpretation | memcpy(&dst, &src, size) | Avoids strict-aliasing UB from incompatible pointer casts | 23 |
| Reading declarations | start at name, []/() before *, work outward | Mechanically parse any pointer declaration | 24 |
Coverage note
This guide covers the full practical scope of pointers in C: the basic
&/* model, pointer arithmetic and its equivalence with array
indexing, multi-level pointers, void * and const combinations,
dynamic memory allocation and its classic bug classes (leaks,
use-after-free, double-free, wild pointers), pointers to strings and
arrays of pointers, the output-parameter double-pointer pattern, and how
to mechanically read even the gnarliest pointer declarations. Combined
with the rest of the Realworld C series - especially the structs/unions
and functions guides, which this one cross-references throughout - this
covers everything needed to read, write, and debug pointer-based C code
with real confidence.
Structs and Unions in C
Functions in C