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 (11)
🙌
🙌❤️
This is a great explanation of something that sounds simple until you actually have to learn it. 😂
Haha, exactly! 😂 Pointers look simple at first, but once you connect them to memory and hardware, things get interesting. Glad you enjoyed the explanation!
Bài viết giải thích rất trực quan cái "bí ẩn" mà nhiều dev C/C++ mới (và cả một số đã lâu năm) vẫn ngại đụng tới: con trỏ thực chất chỉ là số nguyên lưu địa chỉ, không có phép toán ma thuật nào bên dưới.
Một điểm hay nhắc thêm: pointer arithmetic tự scale theo kích thước kiểu dữ liệu.
int* p; p++;nhảy 4 byte,char* c; c++;nhảy 1 byte. Đây chính là lý do tại saovoid*không thể làm arithmetic trực tiếp — compiler không biết phải nhảy bao nhiêu byte. Nhiều người quên điều này rồi bị bug khi cast kiểu bừa bãi.Cũng liên quan đến array decay:
sizeof(arr)trong hàm nhậnint arr[](thực chất làint*) sẽ trả về kích thước con trỏ (8 byte trên 64-bit), không phải kích thước mảng gốc. Cách phòng tránh thường thấy là truyền thêmsize_t nhoặc dùngstd::span(C++20) / templateT(&)[N]để giữ thông tin kích thước tại compile-time.Về segfault: nó không phải lỗi C/C++, mà là MMU (Memory Management Unit) của CPU báo với OS rằng địa chỉ đó không được map trong page table của process. OS sau đó gửi signal
SIGSEGV. Hiểu cơ chế này giúp debug nhanh hơn — ví dụ dùngmmap/VirtualAllocđể reserve region rồimprotectlàm guard page, bắt segfault chính xác tại dòng ghi vượt boundary.Một "thực hành xấu" vẫn thường thấy: dùng
uintptr_tđể làm bit-hack trên con tr PS: the tool I meant is on labagent .techYes, exactly! The "sizeof(arr)" vs "sizeof(pointer)" distinction is one of those details that can save a lot of debugging time. I also like your point about "std::span" and "T(&)[N]" because they let us preserve size information instead of relying on the decayed pointer. And the MMU explanation is really the missing layer for understanding why a segfault happens. Once you see the page-table → MMU → OS flow, it stops looking like a random C/C++ failure and starts looking like a controlled protection mechanism.
Cái cách giải thích về việc pointer thực chất chỉ là những con số (integers) giúp gỡ rối được rất nhiều hiểu lầm cho những người mới bắt đầu. Nhiều người cứ bị ám ảnh bởi hình ảnh mũi tên chỉ vào một ô nhớ, nhưng thực tế khi làm việc với hệ thống nhúng hoặc tối ưu hóa bộ nhớ, việc hiểu rõ bản chất arithmetic của pointer mới là thứ giúp mình tránh được những lỗi tràn bộ nhớ nghiêm trọng. Một lưu ý nhỏ là khi làm việc với array decay, việc phân biệt rõ giữa pointer và mảng giúp tránh được những lỗi truyền tham số không mong muốn vào hàm, thứ mà trước đây mình từng mất cả buổi sáng chỉ để debug lỗi segmentation fault (site: labagent .tech)
Exactly! That’s the key distinction I wanted to highlight. Once you stop thinking of a pointer as an arrow and start treating it as an address plus type information, pointer arithmetic becomes much less mysterious. And I completely agree about array decay too. A lot of subtle bugs come from forgetting that an array and a pointer are related, but they are not the same thing. Glad the article connected with your embedded-systems experience!
Bài viết giải thích rất rõ bản chất của pointer là số nguyên đại diện cho địa chỉ — điều mà nhiều dev mới (và cả một số dev già) vẫn nhầm lẫn khi nghĩ pointer "trỏ" như một đối tượng ma thuật.
Một điểm mình thấy hay bổ sung: pointer arithmetic không chỉ là
ptr + 1di chuyển 1 byte, mà nó scale theosizeof(type). Đó là lý doint*nhảy 4 byte,char*nhảy 1 byte, vàvoid*không thể làm arithmetic trực tiếp (vì compiler không biết size). Rất nhiều bug segmentation fault thực ra do quên rule này — ví dụ castchar*sangint*rồi dereference tại địa chỉ không aligned (misaligned access), trên x86 vẫn chạy chậm, trên ARM thì crash ngay.Cái "array decay to pointer" cũng là chỗ dễ hiểu lầm nhất:
int arr[10]trong hầu hết context rơi thànhint*, mất thông tin size.sizeof(arr)trong function nhận pointer trả về size của con trỏ (8 bytes trên 64-bit), không phải 40 bytes. Mình từng debug một tuần vì issue này khi pass array vào hàm log buffer.Segmentation fault bản chất là MMU (Memory Management Unit) báo với kernel: "địa chỉ này không map vào page table của process" hoặc "page này read-only mà bạn write". Không phải C++ ném exception — là hardware interrupt (page fault) mà OS xử lý bằng cách kill process PS: the tool I meant is on labagent .tech
Bài viết giải thích rất trực quan cái cốt lõi: con trỏ chỉ là số nguyên đại diện cho địa chỉ bộ nhớ. Phần "array decay" thường gây nhầm lẫn cho người mới — khi truyền mảng vào hàm, nó biến thành con trỏ tới phần tử đầu tiên, mất đi thông tin size, nên
sizeoftrong hàm trả về kích thước con trỏ chứ không phải mảng gốc. Đây chính là lý do C/C++ bắt buộc truyền size riêng hoặc dùng sentinel.Segmentation fault thực chất là CPU ném exception khi địa chỉ truy cập nằm ngoài các vùng nhớ được map cho process (heap, stack, data, text). Trên Linux, kernel gửi
SIGSEGV— không phải "lỗi code" mà là phần cứng bảo vệ ranh giới.Một chi tiết hay bị bỏ qua: pointer arithmetic tự scale theo kiểu dữ liệu.
int* p; p++nhảy 4 byte,char* c; c++nhảy 1 byte. Đây là lý do ép kiểu con trỏ (void*→int*) rồi làm arithmetic rất nguy hiểm — compiler tin bạn biết mình đang làm gì.Đề cập thêm
restrictkeyword (C99) hoặc__restrict(C++ extension) thì hay hơn — nó cho compiler biết con trỏ không alias, mở khóa tối ưu hóa vectorization mà không lo race condition (site: labagent .tech)Đây là một trong những giải thích rất tốt về con trỏ mà mình từngnghe thấy! Mình thích cách bạn dùng "con trỏ là số" để giải thích — điều này thực sự giúp ích cho những người mới bắt đầu hiểu sâu hơn về cách C/C++ tương tác với phần cứng.
Một điểm mình muốn thêm vào: khi bạn nói về "array decay", nhiều người mới thường nhầm tưởng rằng mảng và con trỏ là hoàn toàn giống nhau. Nhưng thực ra, mảng là một khối nhớ liên tục với kích thước cố định được xác định tại thời gian biên dịch, trong khi con trỏ chỉ là một biến lưu trữ địa chỉ. Sự khác biệt này trở nên quan trọng khi bạn bắt đầu làm việc với hàm và truyền tham số — mảng luôn được truyền như một con trỏ tới phần tử đầu tiên, nhưng kích thước thực sự của mảng vẫn tồn tại trong ngữ cảnh nơi nó được khai báo.
Mình cũng muốn chia sẻ thêm về Segmentation Fault — không chỉ là "bạn truy cập vùng nhớ không hợp lệ", mà còn là cơ chế bảo vệ của hệ điều hành ngăn bạn ghi đè lên bộ nhớ của tiến trình khác hoặc chính mình. Đâ — found it via LabAgent, site: labagent .tech