Ask any software engineer to draw a pointer on a whiteboard.
Within three seconds, they will sketch two boxes and a little curved arrow pointing between them.
It looks neat. It feels intuitive. Computer science lectures have used that exact drawing for four decades.
And yet, that single diagram is why so many developers hit a psychological brick wall when learning C or C++.
Arrows don't exist inside your computer.
There is no wire inside your CPU labeled "arrow." There is no register that stores "direction." When you draw an arrow, you hide the one fundamental truth that makes low-level programming click:
A pointer is just an ordinary integer holding a street address.
The moment you discard the arrow and look at your computer through the eyes of the silicon, everything that felt terrifying about C and C++ suddenly turns simple. Pointer arithmetic stops looking like dark magic. Array decay makes total sense. And even the dreaded Segmentation Fault transforms from a random nightmare into a protective feature of your operating system.
Let us open the machine and see how C and C++ actually touch hardware.
The Memory Street: What RAM Actually Looks Like
Forget about files, variables, and objects for a moment.
To your CPU, system memory (RAM) is nothing more than one gigantic, continuous street of numbered byte lockers.
Every locker holds exactly one byte (8 bits).
And every single locker has a sequential house number. That house number is what we call an address.
Locker Address: 0x1000 0x1001 0x1002 0x1003 0x1004 0x1005
Stored Value: [ 0x2A ] [ 0x00 ] [ 0x00 ] [ 0x00 ] [ 0x48 ] [ 0x69 ]
When you write a normal variable in C:
int score = 42;
The compiler does not build a box labeled "score."
Instead, it reserves 4 consecutive byte lockers (because a 32-bit int needs 4 bytes), writes 42 in binary into those lockers, and records in its internal symbol table:
Whenever this programmer writes
score, look up locker number0x1000.
The name score exists only for human readability. Once your code compiles into machine instructions, the name disappears forever. The CPU only ever sees numeric addresses.
Shattering the Arrow Lie
Now introduce a pointer:
int score = 42;
int* ptr = &score;
The ampersand & is the address-of operator. It simply asks: "What is the house number where score lives?"
If score lives at address 0x1000, then &score evaluates to the number 0x1000.
ptr is a variable just like any other. It needs space to live. The compiler reserves lockers for it at address 0x2000.
What value does the compiler store inside ptr?
It stores the number 0x1000. That is all. Nothing more, nothing less.
On a modern 64-bit operating system, every address is 64 bits wide (8 bytes). That means every pointer variable occupies exactly 8 bytes of memory, regardless of what type it points to.
You can verify this in your terminal:
#include <stdio.h>
struct HugePayload {
char data[10000];
};
int main(void) {
char* c_ptr;
int* i_ptr;
struct HugePayload* struct_ptr;
printf("char* size: %zu bytes\n", sizeof(c_ptr));
printf("int* size: %zu bytes\n", sizeof(i_ptr));
printf("struct* size: %zu bytes\n", sizeof(struct_ptr));
return 0;
}
Output:
char* size: 8 bytes
int* size: 8 bytes
struct* size: 8 bytes
If a pointer was an arrow, pointing to a 10,000-byte struct might require a bigger arrow than pointing to a 1-byte char.
In reality, a house number is always the same length, whether the house is a tiny studio apartment or a sprawling warehouse. It is just an address.
Dereferencing (*ptr) simply tells the CPU:
Read the house number stored inside
ptr(0x1000). Go to locker0x1000. Read or write the bytes living there.
Why ptr + 1 Doesn't Add One (Pointer Arithmetic)
Look at this common beginner surprise:
int numbers[3] = {10, 20, 30};
int* p = &numbers[0];
printf("Before: %p\n", (void*)p);
p = p + 1;
printf("After: %p\n", (void*)p);
You might expect adding 1 to increment the address by 1. But run it:
Before: 0x1000
After: 0x1004
It skipped 4 bytes.
Why? Because the compiler knows p is an int*. An int occupies 4 bytes.
If p + 1 moved forward by only 1 byte, p would point into the middle of the integer's binary representation, reading corrupted data.
During pointer arithmetic, the compiler scales every step by the size of the underlying type:
Destination Address = Current Address + (Step * sizeof(*p))
- For
char*(1 byte),p + 1moves forward by 1 byte. - For
int*(4 bytes),p + 1moves forward by 4 bytes. - For
double*(8 bytes),p + 1moves forward by 8 bytes.
This explains why arithmetic on a void* is disallowed in standard ISO C. Because void has no defined size, the compiler cannot calculate how many bytes to step forward down the street.
The Array Decay Shock: Why arr[i] Equals i[arr]
In languages like JavaScript or Java, an array is an object with methods and length properties.
In C and C++, an array has zero runtime metadata.
An array is simply a contiguous strip of byte lockers sitting side by side.
When you declare:
int arr[4] = {10, 20, 30, 40};
The compiler places four 4-byte integers consecutively: 0x1000, 0x1004, 0x1008, and 0x100C.
In the C specification, the bracket syntax arr[i] is defined as:
arr[i] is identical to: *(arr + i)
The brackets are just syntactic sugar for pointer arithmetic and dereferencing.
Because addition is commutative (a + b == b + a), *(arr + 2) is identical to *(2 + arr).
And since *(arr + 2) is written as arr[2], then *(2 + arr) can be written as 2[arr].
#include <stdio.h>
int main(void) {
int arr[4] = {10, 20, 30, 40};
printf("arr[2] = %d\n", arr[2]);
printf("*(arr+2) = %d\n", *(arr + 2));
printf("2[arr] = %d\n", 2[arr]);
return 0;
}
Output:
arr[2] = 30
*(arr+2) = 30
2[arr] = 30
Do not write 2[arr] in production code, but understanding why it works clarifies the architecture: in C, arrays don't have magical lookup engines. There are only base addresses, offsets, and byte fetches.
Stack vs. Heap: Physical Reality Inside Your RAM
Where do these street addresses come from?
When your program launches, the operating system assigns it a virtual memory territory divided into two primary working zones: The Stack and The Heap.
The Stack: A Single CPU Register
The stack is fast because it requires almost no bookkeeping.
Your CPU has a dedicated hardware register called RSP (Stack Pointer on x86_64).
When you call a function with local variables:
void calculate(void) {
int a = 10;
int b = 20;
}
The CPU executes a single assembly instruction:
sub rsp, 16
In less than a nanosecond, the stack pointer moves down by 16 bytes. That newly opened space belongs to a and b.
When the function exits:
add rsp, 16
ret
The stack pointer slides back up. Deallocation is instant.
Notice: the CPU never cleared those memory bytes. They still sit in the lockers. The stack pointer simply moved past them. The next function called will overwrite them. This is why uninitialized local variables in C hold random garbage data.
The Heap: The Dynamic Warehouse
When data must outlive the function that created it, or when its size is only known at runtime, you allocate from the Heap:
int* buffer = (int*)malloc(1024 * sizeof(int));
malloc cannot just subtract a register. The heap allocator (like glibc's ptmalloc) searches linked lists of free memory bins for a suitable block. If it runs out of pre-allocated space, it issues a kernel system call (brk or mmap) to expand its boundary.
When finished, you release the block:
free(buffer);
The Dangling Pointer Trap
What happens when you call free(buffer)?
- The allocator marks that memory block as available for future allocations.
- It does NOT touch the
buffervariable. - It does NOT erase the bytes in memory.
buffer still holds the old address. If you access *buffer later, you trigger a Use-After-Free bug. Another thread or function may have received that address, meaning you will read or overwrite someone else's live data.
The fix is immediate:
free(buffer);
buffer = NULL;
What Actually Happens During a Segmentation Fault?
Consider the classic crash:
int* ptr = NULL;
*ptr = 100;
Your program immediately halts with:
Segmentation fault (core dumped)
What actually happened in the machine during that millisecond?
Your code did not break physical RAM. Your pointer never touched physical RAM in the first place.
The Hardware Guardian: The MMU
Modern operating systems never expose raw physical memory directly to applications. Instead, they use Virtual Memory.
Between your CPU core and physical memory sits the MMU (Memory Management Unit).
Memory is organized into 4KB pages. The operating system maintains a Page Table that maps virtual pages to physical RAM frames:
- Virtual page
0x1000maps to Physical RAM0x8A40(Read/Write). - Virtual page
0x0000(NULL) is Unmapped (No access allowed).
When you execute *ptr = 100 with ptr = NULL:
- The CPU sends address
0x0to the MMU. - The MMU looks up
0x0in the Page Table and finds no valid mapping. - The MMU blocks the write and triggers a Hardware Page Fault Exception (CPU Interrupt 14).
- The CPU pauses your program and transfers control to the OS kernel.
- The kernel identifies the violation and sends signal 11 (
SIGSEGV) to your process. - With no custom signal handler registered, the OS terminates the process to protect system stability.
A Segmentation Fault is not a computer malfunction. It is your operating system's security barrier actively preventing illegal memory corruption.
Double Pointers: Demystifying **ptr
Double pointers (char**, int**) often confuse learners because people try to visualize stacked arrows.
Use the street address model instead:
- A regular variable holds data (
val = 42). - A single pointer (
ptr) holds the house number of data (0x1000). - A double pointer (
pptr) holds the house number of another pointer variable (0x2000).
int val = 99; // Address 0x1000: holds 99
int* ptr = &val; // Address 0x2000: holds 0x1000
int** pptr = &ptr; // Address 0x3000: holds 0x2000
Why do we need this?
Because C passes all arguments by value (copying them).
If you want a function to modify an integer in the caller, you pass its address (int*).
Similarly, if you want a function to allocate memory and modify a pointer in the caller, you must pass the pointer's address (int**):
void allocate_memory(int** p) {
*p = (int*)malloc(sizeof(int)); // Modifies the caller's pointer variable
}
int main(void) {
int* data = NULL;
allocate_memory(&data); // Pass address of data
*data = 42;
free(data);
return 0;
}
When you see **p, don't think about arrows. Think: "I am receiving the address of a pointer so I can rewrite where it points."
Practical Mental Model: The Survival Cheat Sheet
| Syntax / Concept | The Arrow Lie | The Hardware Reality |
|---|---|---|
int* ptr |
A line pointing to an integer | An 8-byte variable storing a memory address |
&x |
Drawing an arrow | Extracting the numeric locker number of x
|
*ptr |
Following the arrow | Telling the CPU to read or write bytes at that address |
ptr + 1 |
Advancing one box |
address + (1 * sizeof(*ptr)) bytes forward in RAM |
arr[i] |
Array lookup | Shorthand for *(arr + i). Direct address offset. |
free(ptr) |
Wiping data | Returning the address reservation to the heap manager |
NULL (nullptr) |
Empty space | Address 0x0. Guaranteed unmapped to trigger an MMU trap |
| Segfault | Program glitch | Hardware MMU exception caught by the OS for illegal access |
The Shift That Makes Everything Click
High-level languages like Python and JavaScript provide garbage collection and abstraction to protect developers from raw memory. That convenience is valuable, but it obscures how computers work at the silicon level.
C and C++ strip away that insulation and hand you direct control of the memory bus.
Pointers are not mysterious entities or floating arrows. They are simply the numeric coordinates your computer uses every microsecond to execute software.
Once you picture memory as a numbered street of byte lockers, pointer arithmetic, array decay, and memory allocation all fall into place. You write cleaner code, avoid subtle memory corruption bugs, and truly understand the hardware beneath your programs.
If you have ever wrestled with a baffling segfault or a pointer bug in C or C++, what was the bug, and what was the moment that finally made pointers click for you?


![The Array Decay Illusion: Why arr[i] Equals i[arr]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3y93gyses6qhokgp5hoe.png)


Top comments (0)