How can a `void *` carry the address of an integer, string, or complex struct without losing the underlying type? And why does dereferencing `NULL` crash your process instead of reading zero?
Make a guess, then step through the trace below to check it.
02 · Watch it run
Type erasure via void pointer and safe re-castingstep 1 of 5
int num = 1337;void *vptr = #int *iptr = (int *)vptr;int extracted = *iptr;void *null_ptr = NULL;
slot 0num (0x7ffd10): 1337(looked at in this step)
slot 1vptr: unallocated
slot 2iptr: unallocated
slot 3extracted: unset
slot 4null_ptr: unset
Stack allocates 4 bytes for integer num at 0x7ffd10, initialized to 1337.
Machine work: movl $1337, -4(%rbp)
03 · The idea
Start with the problem
In strongly typed languages, every variable, pointer, and parameter must declare its exact type. If you need to copy bytes, you might expect to write a dedicated function for each type: copy_integers(), copy_floats(), copy_strings(), and copy_custom_structs().
In C, a single standard library function handles all of them:
This single signature can copy an array of 5,000 integers, a raw JPEG image file stream, or a proprietary packet structure.
How can a single function manipulate arbitrary memory without knowing what type of data resides inside that memory?
Now consider the opposite side of memory management: signaling that a pointer points to nothing.
When an allocation fails or a search finds no match, C functions return NULL:
c
#include <stdio.h>#include <stdlib.h>int main(void) { int *data = NULL; printf("Pointer address: %p\n", (void *)data); /* What happens if we try to read or write here? */ *data = 42; /* Fatal crash: Segmentation fault */ return 0;}
When you print data, it displays (nil) or 0x0.
Why does assigning zero to a pointer represent "nothing"? Is the number zero in source code literally address zero in physical RAM? And why does dereferencing NULL immediately crash the program with a segmentation fault instead of quietly writing to address zero?
To write robust, modular systems code, you must understand generic pointers (void *), the mechanics of type erasure, and how the CPU and operating system enforce null pointer safety.
How the idea works
C bridges the gap between high-level types and raw hardware addresses through two fundamental abstractions: void * and NULL.
text
Typed Pointer World Generic Memory World┌──────────────────────┐ ┌──────────────────────┐│ int * (4 bytes) │ ──► │ ││ double * (8 bytes) │ ──► │ void * (Raw Address) ││ struct Node * (32 B) │ ──► │ - No byte width │└──────────────────────┘ │ - No arithmetic │ ▲ │ - No dereferencing │ │ (Cast / Re-type) └──────────────────────┘ └─────────────────────────────────┘
1. The Generic Pointer (void *)
A void * (void pointer) is a generic pointer. It represents a raw virtual memory address without associating that address with any specific data type.
Because void represents an incomplete type that cannot have an object size:
You cannot dereference a void * directly: The compiler has no way to know whether *vptr should read 1 byte, 4 bytes, or 8 bytes, nor whether the bits represent an integer, a floating-point number, or a text character.
You cannot perform pointer arithmetic on a void * in standard C: Expressions like vptr + 1 are illegal under ISO C because the stride length (sizeof(void)) is undefined.
In standard C (unlike C++), conversions between void * and any object pointer type are implicit and bidirectional:
c
int x = 42;void *raw = &x; /* Implicit conversion: int * -> void * */int *restored = raw; /* Implicit conversion: void * -> int * (Valid in C, invalid in C++) */
In the trace above:
Step 1 stores the integer 1337 in 4 bytes of stack memory at 0x7ffd10.
Step 2 assigns that address to void *vptr. The type information is erased: vptr retains only the raw hexadecimal address 0x7ffd10.
Step 3 restores type interpretation by assigning vptr to int *iptr.
Step 4 dereferences iptr. Because the pointer is now an int *, the CPU reads exactly 4 bytes from 0x7ffd10 and extracts 1337.
2. Implementing Generic Algorithms
Generic pointers make reusable data structures and algorithms possible in C.
Consider how the standard sorting function qsort() works:
Because qsort accepts void *base, it can sort an array of any type. However, because void * has no intrinsic type information, the caller must supply two additional pieces of information:
size_t size: The exact byte width of each element, allowing qsort to navigate the array using byte arithmetic.
compar: A callback function that casts the const void * arguments back to concrete typed pointers to evaluate comparisons.
c
int compare_ints(const void *a, const void *b) { const int *ia = (const int *)a; const int *ib = (const int *)b; return (*ia - *ib);}int numbers[] = {50, 20, 80, 10};qsort(numbers, 4, sizeof(int), compare_ints);
3. Null Pointer Mechanics: Constant vs. Representation
A null pointer is a reserved pointer value that is guaranteed not to point to any valid object or function in memory.
In C source code, a null pointer constant is defined as an integer constant expression with the value 0, or such an expression cast to void *:
c
#define NULL ((void *)0)
The C language standard establishes a strict semantic contract:
Any integer constant expression evaluating to 0 used in a pointer context is automatically converted by the compiler into a null pointer.
A null pointer compares equal to 0, NULL, and other null pointers:
c
if (ptr == NULL) /* Equivalent to: */if (ptr == 0) /* Equivalent to: */if (!ptr)
4. Virtual Memory Page Zero: The Hardware Trap
Why does dereferencing NULL cause an immediate crash rather than reading or writing zero?
When an operating system boots and establishes the virtual memory page tables for user processes, it intentionally leaves the lowest page of virtual memory (Page Zero) completely unmapped.
When the CPU attempts to execute *ptr = 42 where ptr is NULL, the instruction sends virtual address 0x0 to the Memory Management Unit (MMU).
The MMU looks up address 0x0 in the process page table, finds no valid mapping entry, and raises a Hardware Page Fault Exception.
The OS kernel intercepts this fault, identifies that an illegal memory access occurred in user space, and sends signal 11 (SIGSEGV) to terminate the process.
This design converts what would otherwise be silent data corruption into an immediate, detectable failure.
Why it is correct
Understanding the design choices behind void * and NULL reveals why systems software relies on them.
Zero-Overhead Runtime Safety via MMU Guard Pages
If the CPU did not use hardware guard pages, every single pointer dereference in C would require the compiler to emit software branch checks:
c
/* Hypothetical compiler output if hardware page checking did not exist: */if (ptr == NULL) { raise_exception();}int value = *ptr;
Emitting comparison and branch instructions before every memory access would inflate binary size and degrade execution speed across the entire software stack.
By deliberately unmapping Page Zero in hardware, memory accesses run at native bus speeds with zero software instructions added. Valid memory accesses succeed in a single CPU cycle; invalid accesses to NULL are intercepted automatically by hardware.
Type Erasure as Architectural Decoupling
In operating system kernels, device drivers must communicate with diverse hardware peripherals using standard interfaces.
The POSIX system call interface handles all data read and write operations via generic buffers:
Whether the buffer contains ASCII text, raw network frames, or compressed audio, the kernel treats the buffer as a stream of raw bytes. Decoupling the I/O interface from high-level types keeps the kernel architecture small and reusable.
What it costs
Using void * and handling null pointers introduces concrete runtime and architectural trade-offs:
Factor
Primary Mechanism
Cost / Impact
Type Safety
Compiler bypasses static type checks
High bug risk; invalid casts compile silently
Function Call Overhead
Indirect comparisons via function pointers in generic algorithms
Prevents inline optimization in routines like qsort
Pointer Validation
Defensive if (ptr == NULL) assertions
Branch overhead in hot execution loops
Alignment Traps
Casting unaligned void * to typed pointers
Hardware bus faults or slow multi-cycle memory loads
The Alignment Trap
Different hardware architectures require data types to be aligned at specific memory address multiples:
A 1-byte char can reside at any address.
A 2-byte uint16_t must reside at an even address (divisible by 2).
A 4-byte uint32_t must reside at an address divisible by 4.
An 8-byte uint64_t must reside at an address divisible by 8.
When you convert a concrete pointer to void * and later cast it back, you must ensure that the underlying memory address respects the alignment requirements of the destination type:
c
char raw_bytes[8] = {0};/* DANGEROUS: raw_bytes + 1 has an ODD memory address */void *unaligned_vptr = &raw_bytes[1]; /* Undefined Behavior / Crash on architectures with strict alignment: */uint32_t *bad_ptr = (uint32_t *)unaligned_vptr;uint32_t val = *bad_ptr;
On architectures with strict alignment requirements (such as certain ARM Cortex configurations and SPARC), dereferencing an unaligned pointer triggers a hardware alignment fault that terminates the process. On x86 architectures, unaligned accesses succeed but require the memory controller to execute multiple cache accesses, slowing down execution.
Pitfalls and variations
Pitfall 1: Compiler Extensions on void * Arithmetic
A frequent source of cross-platform build breakage is performing arithmetic on void *:
c
unsigned char buffer[1024];void *raw = buffer;/* NON-STANDARD: Compiles under GCC/Clang extensions, fails under MSVC / ISO C: */void *offset = raw + 16;
GCC and Clang support a non-standard extension that treats sizeof(void) as 1, allowing arithmetic on void pointers. However, compiling with -pedantic-errors or porting code to Microsoft Visual Studio (MSVC) triggers compilation errors.
Always cast to an explicit 1-byte pointer type before performing offset arithmetic:
c
unsigned char buffer[1024];void *raw = buffer;/* Portable and ISO C compliant: */uint8_t *byte_cursor = (uint8_t *)raw;void *offset = byte_cursor + 16;
Pitfall 2: Silent Type Mismatch Bugs
Because C allows implicit conversions to and from void *, the compiler will not warn you if you accidentally convert to the wrong pointer type:
c
double measurement = 98.6;void *vptr = &measurement;/* BUG: Silently casts double* to int* without compiler error! */int *corrupted_ptr = vptr; printf("Value: %d\n", *corrupted_ptr); /* Prints garbage */
The compiler trusts the developer completely when converting through void *. In large codebases, this type erasure can lead to difficult-to-diagnose data corruption.
Pitfall 3: Assuming Uninitialized Pointers Are Null
A widespread misconception among beginners is assuming that declared pointers default to NULL:
c
void process(void) { int *ptr; /* Wild uninitialized pointer! */ if (ptr != NULL) { *ptr = 100; /* CRASH or silent corruption */ }}
Automatic local variables on the stack contain whatever random bits previously occupied that memory slot. The pointer is almost certainly non-null, but its address points to invalid stack memory. Always explicitly initialize pointers:
c
int *ptr = NULL;
Pitfall 4: Misconceptions About Null Pointer Bit Patterns
While the integer constant 0 in source code represents a null pointer, the C standard does not strictly require the underlying hardware bit pattern to be all zeros.
On rare historical systems (such as the Symbolics Lisp Machine), a null pointer was encoded as a non-zero address bit pattern. However, the compiler was required to translate ptr = 0 into that non-zero pattern. On all modern hardware, NULL is physically represented by all-zero bits (0x0000000000000000).
Where it is used
Generic pointers and null pointer mechanics underpin production systems software:
Raw storage APIs: Later lessons use void * for untyped storage because the same address can be viewed through different object-pointer types. Allocation and release rules are covered in the Core dynamic-memory module.
POSIX Multithreading (pthreads): The thread creation API passes arguments and returns values exclusively through void *:
c
This allows developers to pass any arbitrary struct into a worker thread by casting its address to void *.
Zero-Copy Serialization (Linux Kernel sk_buff): In the Linux networking stack, network packets are handled through socket buffer structures (struct sk_buff). Protocol handlers (IP, TCP, UDP) advance through packet payloads by casting generic byte pointers to specific protocol header structs.
In the next lesson, we will see how multiple contiguous elements of a concrete type behave in memory: exploring array layout, indexing mechanics, and the universal rule of array-to-pointer decay.
04 · Prove it
Question 1 of 3 · answer from memory
Why does standard ISO C prohibit evaluating the expression `*ptr` when `ptr` is declared as `void *ptr`?