Field Notes № 02 · Systems An Interactive Explainer

Pointers vs Arrays
— when each one earns its place.

They look alike. They almost behave alike. The compiler will even forgive you for confusing them, most of the time. But behind the syntax, an array is a fixed shelf in memory and a pointer is a signpost that holds an address — and that single difference decides whether your code lives in firmware, in a kernel, or in a textbook.

Reading time~12 minutes
LevelIntermediate C / C++
FormatInteractive · scroll-driven
01 ——

The mental model.

Same destination, two different vehicles.

An array is born knowing where it lives. The compiler reserves a contiguous block of memory at compile time and the array's name is the address of its first byte. A pointer, by contrast, is just a small variable — typically eight bytes on a 64-bit machine — whose only job is to hold an address. It can point to anything, including an array. That's why arr[i] and *(arr + i) compile to the same instruction: both forms ask the same question — what is at address arr + i × sizeof(element)?

Model A · Array

A shelf of fixed boxes.

Five 4-byte cells, side by side, allocated in one go. The name arr is shorthand for the first cell's address.

0x1000 0x1004 0x1008 0x100C 0x1010 10 20 30 40 50 [0] [1] [2] [3] [4] arr = 0x1000 int arr[5] = {10,20,30,40,50}; sizeof(arr) = 20
Model B · Pointer

A signpost holding an address.

The pointer p is itself a variable, stored elsewhere. It contains the address 0x1000, which it can be reassigned to forget at any moment.

0x1000 0x1004 0x1008 0x100C 0x1010 10 20 30 40 50 0xFF20 0x1000 p int *p = arr; sizeof(p) = 8

How arr[2] and *(arr + 2) resolve.

Both forms compile to the same load. Click to watch the resolution.

step 1 · base address: 0x1000 step 2 · offset: 2 × sizeof(int) = 8 bytes step 3 · effective address: 0x1008 step 4 · load → 30 memory 0x1000 0x1004 0x1008 0x100C 0x1010 10 20 30 40 50 [0] [1] [2] [3] [4]
Awaiting input. Click a button above.
02 ——

Where they secretly differ.

Five tests that tell them apart.

An array name is often described as "a pointer to its first element," and the compiler encourages this lie by silently decaying arrays to pointers in most contexts. But the two are not interchangeable. Here are the five tests that expose the difference — and why each one matters in real code.

Aspect
Array
Pointer
Demonstration
sizeof
Total bytes of the entire array — element size × count.
Always 8 bytes on a 64-bit machine. Just the address slot.
int arr[5];
int *p = arr;

sizeof(arr); // 20
sizeof(p);   // 8
lvalue?
No. The name cannot be reassigned. It's not a variable, it's a label baked into the binary.
Yes. A pointer is a variable. It can be moved, swapped, or made to forget.
int arr[5], other[5];
int *p = arr;

arr = other; // ✗ error
p   = other; // ✓ ok
Decay rule
When passed to a function or used in an expression, decays to T*. The size is lost.
Already a pointer. Nothing to decay. Carries no length information ever.
void f(int a[5]) {
  sizeof(a); // 8, not 20!
}              // a is really int*
& operator
&arr has type int(*)[5] — a pointer to the whole array. Numerically the same address, different type.
&p is the address of the pointer itself, type int**.
&arr;     // int (*)[5]
&arr+1;   // jumps 20 bytes
&p;       // int **
&p+1;     // jumps 8 bytes
Storage
The cells are the variable. Lives wherever it was declared — stack, BSS, .rodata, heap.
Two allocations: the pointer (8 bytes) and whatever it points to (separate object, possibly heap).
// stack: 20 bytes
int arr[5];

// stack: 8 bytes,
// heap:  20 bytes
int *p = malloc(20);
03 ——

Where arrays win.

Three places fixed shelves beat signposts.

When the size is known, the count is small, and the lifetime is the function's lifetime, arrays are faster, smaller, and safer than anything heap-allocated. The compiler can see them, the cache can hold them, and the linker can place them in the right segment. Three real cases where the choice writes itself:

Embedded · firmware

A UART buffer that never mallocs.

On a microcontroller with 32 KB of RAM, there is no heap. Or rather, there is, but the safety-critical coding standards (MISRA, AUTOSAR, NASA's Power of Ten) forbid it after initialization. Allocations are nondeterministic — they can fragment, fail, or stall a real-time loop.

So a serial driver declares its receive buffer as a fixed array in BSS: static uint8_t rx_buf[256];. The address is known at link time, the memory is reserved before main() runs, and the ISR can write to it without touching any allocator. Predictable, bounded, debuggable.

Every Linux kernel driver, every FreeRTOS task, every Arduino sketch you've ever seen does this. The pointer version doesn't even compile against half their toolchains.

STM32 · 32KB RAM UART RX → static uint8_t rx_buf[256] .bss segment · zero-initialized · 256 bytes no malloc · no free · no fragmentation
FIG. 3A · UART RX BUFFER
ROM · lookup tables

Sine, CRC, glyphs — computed once, looked up forever.

An audio synthesizer needs sin(x) a million times a second. Computing it from a Taylor series each time costs cycles you don't have. The fix is ancient: precompute 1024 values, store them in a const array, index by phase. One memory load replaces dozens of multiplications.

Because the array is const, the linker places it in .rodata — read-only memory, often Flash on embedded devices. It costs zero RAM. The same trick powers CRC tables in every TCP/IP stack, bitmap font glyphs in your terminal, and the trigonometric tables inside a CD player's DSP.

You can't put a heap-allocated buffer in ROM. Only an array.

sin_table[1024] const · placed in .rodata · zero RAM cost phase → index → value · O(1) lookup
FIG. 3B · SINE TABLE
Performance · stack locals

int buf[256] on the stack beats malloc.

Allocating 256 ints on the stack is one instruction: sub rsp, 1024. Deallocating them is another single instruction when the function returns. The cache is already warm — the stack frame is right where the CPU just was.

Calling malloc(256 * sizeof(int)) instead means: a function call, free-list traversal, possibly a brk or mmap syscall, a thread-safety lock, and a corresponding free() later. Hundreds of cycles vs one. Plus a cache miss when you finally touch the heap memory.

The rule: if the buffer fits comfortably (a few KB) and lives no longer than the function, always use a stack array. Reach for the heap only when the size is huge or the lifetime extends beyond the call.

Stack buf[256] 1024 B 1 instruction vs Heap malloc 1024 B ~200 cycles
FIG. 3C · STACK vs HEAP
04 ——

Where pointers win.

Three places signposts beat shelves.

The moment your data doesn't know its size at compile time, or its shape changes during the program, arrays surrender. Pointers are the only way to build structures that grow, branch, or refer to one another — and the only way to pass them around without copying.

Dynamic · linked structures

Lists, trees, graphs — nodes that find their neighbours.

An array stores its elements in one contiguous block. That works beautifully until you need to insert in the middle (shift everyone) or grow past the allocated size (reallocate, copy, repoint). Both are O(n) operations dressed up as constant-time ones.

A linked list pays the opposite price: O(1) insertion, O(n) lookup. Each node carries a next pointer to wherever the next node happens to live. That "wherever" is the whole point — nodes can be allocated anywhere, freed independently, rearranged without copying any data, only by rewiring pointers.

Trees and graphs generalize the idea. The Linux kernel's struct list_head is one of the most-used pointer constructs in computing.

42 0x800 17 0xA40 99 0xC18 8 0xE00 struct node { int v; struct node *next; };
FIG. 4A · LINKED LIST
Functions · pass-by-reference

A 1 MB struct passed by pointer is O(1).

Pass a struct by value and the compiler must copy every byte into the callee's stack frame. For a 1 MB struct, that's a million bytes of memcpy — every call, every time. Cache obliterated, latency spiked.

Pass it by pointer and only 8 bytes move: the address. The callee dereferences when it needs the data. This is why every non-trivial C API takes pointers, why C++ has references, why Rust has borrows.

The pointer also lets the callee modify the original. That's how fread(buf, ...) fills your buffer, how scanf("%d", &n) writes to your variable, and how every output-parameter idiom in C ever written actually works.

by value struct 1 MB memcpy 1,048,576 B copy 1 MB by pointer struct 1 MB just the address 8 B ptr 128,000× less data moved · same effect
FIG. 4B · BY VALUE vs BY POINTER
Polymorphism · dispatch tables

Function pointers — polymorphism in plain C.

How does the Linux kernel call the right read() for a file on ext4, on a pipe, on a TCP socket, all through one syscall? It doesn't know. Each filesystem registers a struct of function pointers — file_operations — and the kernel dispatches through it: file->f_op->read(file, buf, ...).

This is virtual methods, but written by hand. Drivers, state machines, callback APIs, libc's qsort taking a comparator, OpenGL's vtable, every event loop ever — all the same trick. An array of function pointers is a vtable.

Without pointers, "what to do next" must be hardcoded. With them, behavior is data — and data can be swapped at runtime.

struct file_operations .open .read .write .close .ioctl .mmap ext4_file_open ext4_read_iter ext4_write_iter ext4_release ext4_ioctl ext4_file_mmap file->f_op->read(file, buf, len, &pos); one call site · many implementations · runtime dispatch
FIG. 4C · DISPATCH TABLE
05 ——

The interactive playground.

Step through a program. Watch memory move.

Run the lines below one at a time. The right-hand panel shows what happens in memory: cells fill in, the pointer arrow physically moves, and the status reports what the machine just did. When you're ready, toggle a common mistake to watch the same code go subtly, instructively wrong.

1int arr[5] = {10, 20, 30, 40, 50};
2int *p = arr;
3p++;
4*p = 99;
5p += 2;
6printf("%d", *p);

Common mistakes — toggle to demo:

addr cell name 0xFF20 int *p
Ready. Press Step to execute the next line, or click a mistake to demonstrate.
06 ——

In production.

Real codebases, real tradeoffs.

The choice between pointer-driven flexibility and array-driven locality isn't academic. It's the dividing line between systems that fit in 32 KB and ones that don't, between databases that hit a million queries per second and ones that don't, between game engines that hold 60 FPS and ones that don't.

Pointers

Linux kernel

Intrusive linked lists via struct list_head are everywhere — task lists, page caches, network buffers. Each struct embeds the list pointers directly, so traversal is just dereference-and-go. Zero allocations per node.

Arrays

SQLite

Database files are split into fixed-size pages (typically 4 KB) held as a contiguous array in the page cache. Predictable indexing, no fragmentation, and the whole B-tree algorithm assumes constant-time page lookup.

Hybrid

Redis · SDS

Simple Dynamic Strings prepend a small header (length, capacity) to a regular C array of bytes. The user gets a char* compatible with every libc function — but Redis still has O(1) length and bounds checks. Array layout, pointer interface.

Arrays only

Embedded RTOS

FreeRTOS, Zephyr, AUTOSAR — many real-time systems forbid malloc after init. Tasks, queues, semaphores all live in statically allocated arrays sized at compile time. If it fits, it ships; if not, refactor.

The debate

Game engines · ECS

The data-oriented design revolution argued that pointer-heavy OOP destroys cache locality. Modern engines (Unity DOTS, Unreal Mass) store components as arrays of structs — iterating one frame is sequential memory, not pointer-chasing. Pointers lost this round.

Pointers

PostgreSQL planner

Query plan trees are deeply nested tagged-union nodes connected by pointers. The planner builds, rewrites, and discards thousands of these per query. An array layout would force preallocation of the worst case — wasteful and rigid.

The closing thought is older than C itself: data structures are the program. Choose the wrong layout and no amount of clever code recovers the cycles, the cache lines, the deterministic memory you needed. Choose the right one and the algorithm almost writes itself. Arrays and pointers are the two atoms — everything else is just how you arrange them.

Field Notes № 02 Set in Fraunces & JetBrains Mono An interactive explainer · 2026