Pointers in C — Preventing Use-After-Free Crashes
Intermittent SIGSEGV after hours of operation in embedded sensor firmware traced to a dangling pointer after free() — fix by NULL assignment.
- Pointers store memory addresses, not values –
&gets an address,*dereferences to access the value. - Pointer types encode byte-width (e.g.,
intreads 4 bytes,charreads 1), ensuring correct memory access. - Passing a pointer to a function avoids copying large data – it's C's pass-by-reference mechanism.
- Dynamic memory (
malloc/free) returns a pointer to heap memory; forgettingfreeleaks memory, double‑free corrupts the allocator. - Array names decay to pointers when passed to functions, losing size information – always pass a separate length parameter.
- The most common pointer bug: dereferencing an uninitialized or freed pointer leads to undefined behavior (crash or silent corruption).
Imagine your house has a street address. The house is your data — the actual stuff you care about. A pointer is just that street address written on a piece of paper. You can hand that piece of paper to anyone, they can find your house and change what's inside — without you having to move the house itself. That's all a pointer is: a variable that holds an address, not the data directly.
Every time you run a C program, the operating system hands it a chunk of memory. Variables live in that memory, each one sitting at a specific address — like houses on a street. Pointers let you work with those addresses directly, and that capability is the single reason C can do things most high-level languages can't: build operating systems, write device drivers, manage memory with surgical precision, and pass huge data structures around without copying a single byte.
What a Pointer Actually Is — Memory Addresses Made Concrete
When you declare int temperature = 72;, C reserves 4 bytes somewhere in memory and stores the value 72 there. That location has an address — think of it as a number like 0x7ffd3a2c. A pointer is a variable whose job is to hold that address.
The & operator gives you the address of any variable. The * operator — called the dereference operator — goes the other way: given an address, it gives you the value sitting at that address. These two operators are inverses of each other, and once that clicks, the rest of pointers makes sense.
Pointer types matter because the compiler needs to know how many bytes to read starting from an address. A char reads 1 byte, an int reads 4, a double* reads 8. The type encodes that byte‑width contract.
void*.& and * are inverses.Why Pointers Exist — Pass by Reference and Avoiding Costly Copies
C is a pass-by-value language. When you call a function with a variable, C copies that variable. For a single integer, that's fine. But imagine passing a 10,000-element array or a large struct by value on every function call — you'd be copying megabytes of data just to read a few fields. Pointers solve this completely.
By passing a pointer instead of the data, you pass just 8 bytes (on a 64-bit machine) regardless of how big the actual data is. The function then operates on the original data in place. This is what 'pass by reference' means in C — you're passing the address of the original, not a clone.
This pattern shows up everywhere: scanf needs a pointer so it can write back to your variable. qsort takes a comparator that receives pointers so it can compare without copying. String functions like strcpy operate on char* for exactly this reason.
scanf("%d", &age), you're using a pointer. scanf needs to write into your variable, so you hand it the address. If you forget the &, scanf receives the value of age (garbage or zero), tries to treat it as an address, and your program crashes or corrupts memory — one of the most common beginner bugs in C.& in scanf causes a segmentation fault – every beginner hits this.& to give functions write access to your variables.Pointer Arithmetic — How Arrays and Pointers Are the Same Thing
Here's something that surprises most newcomers: in C, an array name is essentially a pointer to its first element. When you write scores[2], the compiler translates that to *(scores + 2) — 'start at the base address, move forward by 2 elements, then dereference.' These are literally identical operations.
Pointer arithmetic is scaled by the type. If int_ptr points at an int (4 bytes), then int_ptr + 1 doesn't add 1 to the address — it adds 4, landing exactly on the next integer. The compiler handles the byte math for you. This is why you must get the pointer type right.
Understanding this relationship is the key to writing efficient string processing, building your own data structures, and reading any C library source code. It's also why C arrays don't carry their own length — they're just a starting address, and the programmer is responsible for knowing where they end.
size_t length parameter. sizeof(arr) inside the function gives you the pointer size (8 bytes), not the array size. This catches even experienced developers off guard.void* is illegal – compiler doesn't know byte size.arr + 1 steps by sizeof(element) bytes, not 1 byte – off-by-one errors if you forget scaling.arr[i] equals *(arr + i).Pointers to Pointers and Dynamic Memory — Where Real Power Lives
A pointer is just a variable, and like any variable, it has its own address. A pointer-to-pointer (int **) stores the address of another pointer. This sounds abstract, but it has two very concrete uses: passing a pointer itself by reference so a function can change what it points to, and managing 2D dynamically allocated arrays.
Dynamic memory is where pointers truly shine. malloc returns a pointer to a freshly allocated block on the heap — memory that persists until you explicitly free it with free. This lets you build data structures whose size you don't know at compile time: linked lists, trees, dynamic arrays.
The golden rule of dynamic memory: every malloc must have exactly one free. Miss the free and you leak memory. Free twice and you corrupt the allocator. Free then use and you get undefined behavior. Pointers make this power possible — and make these bugs your responsibility.
free(readings), the pointer variable still holds the old address — it doesn't become NULL automatically. Reading or writing through it is undefined behavior: your program might crash immediately, silently corrupt data, or appear to work fine today and fail in production. Always set the pointer to NULL right after freeing. Tools like Valgrind and AddressSanitizer (-fsanitize=address) catch these bugs during development.free causes memory leaks – monitored with valgrind.malloc must pair with exactly one free, and set pointer to NULL afterwards.malloc/free are your responsibility.malloc return for NULL.Double Pointers — Using Pointers to Pointers
A pointer to a pointer (int **) stores the address of another pointer. This extra level of indirection is necessary when a function needs to modify the pointer variable in the caller — not just the data it points to, but the pointer itself. Common use cases include:
- Linked list insertion at head: Pass
&head(aNode**) so the insertion can update the head pointer to the new node. Without double pointers, the function would only modify a local copy. - 2D dynamic arrays:
int *matrix = malloc(rows sizeof(int*));then allocate each row. Double pointer is the base. - Reallocating buffers inside functions: As shown in the previous section,
reassign_bufferusesdouble **to change the caller's pointer.
Understanding double pointers makes you ready to implement dynamic data structures and to understand function signatures like main(int argc, char **argv) where argv is a pointer to an array of strings.
Node **head_ptr, it means the function can change the pointer variable passed from the caller. Always pass the address of the pointer (&head) when the function needs to allocate or reassign it.int ** to NULL and check before dereferencing.int * stores address of an int.&ptr to a function expecting T**.Const and Pointers: The 4 Combinations
The const keyword interacts with pointers in four distinct ways. Reading the declaration from right to left is the trick: const int ptr means ptr is a pointer to a constant integer – you can change where ptr points, but you cannot modify the integer through it. int const ptr means ptr is a constant pointer to a integer – you cannot change the address stored in ptr, but you can modify the integer. const int const ptr locks both: the pointer is constant and the data is constant. int ptr is fully mutable.
In APIs, const int signals that a function will not modify the data (read‑only access). int const is rare but useful for hardware registers where you must always point to the same location. Getting these wrong produces compilation errors – a good thing, as the compiler enforces your intent.
const int ptr: ptr is a pointer to const int. int const ptr: ptr is a const pointer to int. This rule never fails.const int p and int const p leads to compilation errors or unintended writes.const int * for input parameters signals that the function won't modify data – good practice.const to enforce read‑only intent on pointer parameters.const declarations from right to left.const int *p – pointer to constant int.int *const p – constant pointer to int.Function Pointers and Callbacks in C
A function pointer stores the address of a function – just as a data pointer stores the address of a variable. They are heavily used for callbacks, plugin architectures, and event‑driven systems. The syntax is unusual: int (func_ptr)(int, int) declares a pointer to a function that takes two ints and returns an int. Assign it with func_ptr = &add; (or func_ptr = add; – the & is optional). Call it via (func_ptr)(3, 4) or func_ptr(3, 4).
The standard library qsort uses a function pointer for its comparator callback. This lets you sort any data type without copy‑pasting the sorting algorithm. Function pointers are also the backbone of interrupt vector tables in embedded systems and dynamic dispatch in low‑level OOP implementations.
void*.volatile.typedef for function pointer types to improve readability.typedef to simplify syntax.Comprehensive Guide to Special Pointer Types — NULL, Void, Wild, and Dangling
Beyond typed pointers, C has several special pointer categories that every developer must understand to avoid bugs and write robust code.
| Type | What It Is | How to Create | Safe to Dereference? | How to Fix/Escalate |
|---|---|---|---|---|
| NULL pointer | Points to address 0, intentionally invalid | int *p = NULL; | No — immediate crash (SIGSEGV) | Check before use: if (p != NULL) { ... } |
*void pointer** | Generic pointer without type information | void *p = malloc(100); | No — must cast to a type first | Cast to appropriate type before dereference |
| Wild pointer | Uninitialized pointer, contains garbage | int *p; (no assignment) | No — writes to random memory | Always initialize: int *p = NULL; or to a valid address |
| Dangling pointer | Previously pointed to valid memory that has been freed | free(p); without NULLing p | No — undefined behavior; may crash or corrupt | Set to NULL after free: free(p); p = NULL; |
What to do when each appears in production: - NULL: Add defensive checks — the pointer is intentionally invalid, and a check prevents crash. - *void: Ensure the code that dereferences it knows the actual type. Use static_cast in C++ or explicit cast in C. Prefer typed pointers when possible. - Wild: Run with AddressSanitizer to detect use of uninitialized memory. Always initialize pointers at declaration. - Dangling**: Use tools like Valgrind or ASan to identify use-after-free. Adopt the pattern of setting pointer to NULL immediately after free and check for NULL before use.
free() with immediate NULL assignment, and compile with -fsanitize=address during development to catch these early.Pointer Size on 32-bit vs 64-bit Architectures — What You Need to Know
The size of a pointer on a given platform depends on the address bus width. On 32-bit systems, pointers are 4 bytes (32 bits), limiting addressable memory to 4 GiB. On 64-bit systems, pointers are 8 bytes (64 bits), allowing a vastly larger address space. This matters for memory consumption, struct packing, and portability.
| Aspect | 32-bit System | 64-bit System |
|---|---|---|
| Pointer size | 4 bytes | 8 bytes |
sizeof(int*) | 4 | 8 |
sizeof(char*) | 4 | 8 |
sizeof(void*) | 4 | 8 |
sizeof(int*) vs sizeof(int) | Both 4 | Pointer larger than int (8 vs 4) |
| Maximum addressable memory | ~4 GB | 16 EB (theoretical) |
| Struct alignment impact | Pointers pack as 4-byte entities | Pointers often cause padding to 8-byte boundaries |
| Typical use cases | Legacy embedded, older microcontrollers | Modern desktop, server, mobile, embedded (ARM64) |
Key implication for structs: On 64-bit, fields after a pointer may be padded to align the pointer to an 8-byte boundary. This can increase struct size unexpectedly. Use #pragma pack or reorder fields to minimize padding.
uintptr_t (from <stdint.h>) if you need to store a pointer as an integer — it's always the correct size for the platform.sizeof() and uintptr_t for portability.uintptr_t to store pointer as integer portably.Use-After-Free in Embedded Sensor Firmware
free() was called on a sensor buffer pointer, the pointer variable still held the old address. A periodic sensor logging task dereferenced that pointer, reading freed heap memory. Over time, the allocator reused that memory, and reading it caused a segmentation fault.NULL immediately after freeing, and add a check if (ptr != NULL) before dereferencing in all tasks that use it. Add AddressSanitizer to the CI build pipeline.- After every free, always set pointer to NULL – it turns use‑after‑free into a clean crash at the point of null dereference.
- Never trust that only one part of the code owns a pointer – cross‑reference all usages after free.
- Use sanitizers (ASan, Valgrind) in development builds to catch these bugs before they reach production.
malloc/free pairs; use valgrind --leak-check=full to find unreachable memory.NULL immediately; check if same pointer is freed more than once.free on a global pointerNULL and add guards.free() and add a null check before every dereference of that pointer.Key takeaways
* operator dereferences it to reach the value, and & gets the address of any variable. These two are inverses.arr[i] and pointer arithmetic *(arr + i) are identical operationsmalloc must be paired with exactly one free, and you should NULL the pointer immediately after freeing it-fsanitize=address in your build pipeline to catch leaks and use-after-free bugs before they reach production.const liberally with pointer parameters to enforce read/write contractstypedef to keep syntax manageable.Common mistakes to avoid
4 patternsUsing an uninitialized pointer
int *ptr = &var) or to the result of malloc(). Set unused pointers to NULL.Forgetting to check malloc return value
malloc returns NULL when memory is exhausted. Dereferencing a NULL pointer causes an immediate segmentation fault.malloc with if (ptr == NULL) { handle_error(); } before touching the returned pointer. This cheap check prevents a whole class of crashes.Off-by-one errors in pointer arithmetic
<= instead of <) reads or writes out of bounds – undefined behavior, may crash or corrupt adjacent memory.<) in loops over arrays. Compile with -fsanitize=address to catch out-of-bounds access immediately.Dereferencing a pointer after free (use-after-free)
free(), the pointer variable still holds the old address. Accessing it later (reading or writing) is undefined behavior – may crash, corrupt data, or appear to work.NULL immediately after free(). Regularly use tools like Valgrind or AddressSanitizer during development.Interview Questions on This Topic
What is the difference between `int *const ptr` and `const int *ptr` in C? What does each guarantee, and when would you use one over the other?
int const ptr is a constant pointer to a non‑constant integer – you cannot change the address stored in ptr, but you can modify the integer it points to. const int ptr is a pointer to a constant integer – you cannot modify the integer via ptr, but you can change ptr to point elsewhere. Use int const when you want to ensure the pointer always points to a specific variable (e.g., a hardware register). Use const int when you want to prevent modification of the pointed‑to data (e.g., reading from a read‑only buffer).Frequently Asked Questions
That's C Basics. Mark it forged?
7 min read · try the examples if you haven't