Skip to content

Level 2 · Chapter 2.4

Pointers and arrays

A pointer is a variable that holds an address. How & and * work, why pointer arithmetic scales by the element size, how arrays decay to pointers and a[i] becomes one load, strings as NUL-terminated arrays, NULL and segmentation faults, out-of-bounds writes, and function pointers — each shown in C, in assembly, and running.

The previous chapter showed that every variable is a range of bytes at some address. A pointer is a variable whose value is one of those addresses. That's all it is: on a 64-bit machine, 8 bytes holding a number that happens to be a memory address. What makes pointers useful — and dangerous — is what C lets you do with that number.

& and *

Two operators convert between a variable and its address:

  • &x is the address of x;
  • *p is the object p points to: reading *p loads from the address in p, and assigning *p = … stores to it.

The classic example is a function that swaps two variables. C passes arguments by value, so swap(x, y) would only swap copies. Passing their addresses lets the function reach the caller's variables:

Live · swap through pointers
C source — click a line number for a breakpoint
  1. void swap(int *a, int *b) {
  2. int t = *a;
  3. *a = *b;
  4. *b = t;
  5. }
  6. int main() {
  7. int x = 3;
  8. int y = 7;
  9. swap(&x, &y);
  10. return x * 10 + y;
  11. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

The program returns 73: x and y really were swapped. Step into swap and look at the Variables panel: a and b hold stack addresses — the addresses of main's x and y — and the writes through them change main's frame. Zoom in to the assembly and *a = *b is four instructions: load the pointer b from the stack, load the int it points to, load the pointer a, and store the value through it. The hardware has no idea these are pointers; it just sees register-indirect addressing.

A pointer's type says what it points to: an int * points to 4-byte integers, a char * to single bytes. The address itself is the same kind of number either way. The type only tells the compiler how many bytes to load or store at that address, and how to interpret them.

Pointer arithmetic

Adding an integer to a pointer moves it by whole elements, not bytes: if p is an int *, p + 1 is the address 4 bytes further, the next int. The compiler multiplies by the element size for you. Subtracting two pointers into the same array gives the number of elements between them, so the compiler divides. clang -O2 compiles long diff(int *p, int *q) { return p - q; } to:

diff:
    mov rax, rdi
    sub rax, rsi      ; difference in bytes
    sar rax, 2        ; ÷ 4 (sizeof(int)): difference in ints
    ret

Pointer arithmetic is only defined within one array (plus one past its end). Comparing or subtracting pointers into unrelated objects is undefined behavior, even though the machine would happily compute something.

Arrays are not pointers, but they turn into them

An array is a fixed number of elements stored back to back: int a[5] is 20 contiguous bytes. It isn't a pointer — sizeof(a) is 20 — but in almost every expression, an array's name decays into a pointer to its first element. That rule gives C its famous equivalence:

a[i]   ==   *(a + i)

Array indexing is pointer arithmetic followed by a dereference. Because addition commutes, i[a] means the same thing and compiles, though nobody should write it.

Decay also happens when an array is passed to a function, which is why a function can't know the size of the array it received. clang even warns about it:

int f(int a[10]) { return sizeof(a); }

warning: sizeof on array function parameter will return size of 'int *'
         instead of 'int[10]' [-Wsizeof-array-argument]

The [10] in the parameter is ignored; a is an int *, and sizeof(a) is 8. That's why C functions that take arrays also take a length, like sum(int *p, int n) below.

At the machine level, a[i] is a single instruction, thanks to scaled-index addressing. clang -O2 for int get(int *a, int i) { return a[i]; }:

get:
    movsxd rax, esi                    ; sign-extend the int index to 64 bits
    mov    eax, DWORD PTR [rdi+rax*4]  ; load a + i*4
    ret

The same loop can be written with an index or with a moving pointer. Walking a pointer from the start of the array to its end is the idiomatic C form, and this is what it looks like step by step:

Live · Walking an array with a pointer
C source — click a line number for a breakpoint
  1. int sum(int *p, int n) {
  2. int s = 0;
  3. int *end = p + n;
  4. while (p < end) {
  5. s += *p;
  6. p++;
  7. }
  8. return s;
  9. }
  10. int main() {
  11. int a[5] = {1, 2, 3, 4, 5};
  12. return sum(a, 5);
  13. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

It returns 15. Watch p in the Variables panel go up by 4 at each p++, while end stays fixed 20 bytes past the start.

Strings are char arrays

C has no string type. A string is an array of char ending with a NUL byte ('\0', value 0), and a string is passed around as a char * to its first character. The literal "pointer" occupies 8 bytes: 7 letters and the terminator. Finding the length means walking until the zero:

Live · strlen: walk to the terminating zero
C source — click a line number for a breakpoint
  1. int my_strlen(char *s) {
  2. char *p = s;
  3. while (*p)
  4. p++;
  5. return p - s;
  6. }
  7. int main() {
  8. return my_strlen("pointer");
  9. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

It returns 7. That design is compact but has consequences you'll meet everywhere: getting a string's length costs a full scan, a string can't contain a zero byte, and a missing terminator sends every string function reading past the end of the buffer.

Pointers to pointers, and NULL

A pointer is a variable, so it has an address too: int **pp = &p; is a pointer to a pointer, and **pp follows both. char **argv, the argument list of main, is the most familiar example: an array of pointers to strings.

A pointer that points nowhere is NULL, which is address 0 in practice. Dereferencing it doesn't return garbage: operating systems deliberately never map the first page of memory, so the access triggers a page fault, and the kernel kills the process with a segmentation fault. On Linux the shell reports exit status 139, which is 128 + 11, signal 11 being SIGSEGV. The simulator reproduces this:

Live · Dereferencing NULL
C source — click a line number for a breakpoint
  1. int main() {
  2. int *p = 0;
  3. return *p;
  4. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Running it stops with a segmentation fault at address 0. Leaving page 0 unmapped turns the most common pointer bug into an immediate crash instead of silent corruption. That's why NULL is a useful value to reset pointers to.

Nothing checks the bounds

C never checks array indexes. a[i] computes an address and accesses it, whether or not it's inside the array. This loop writes one element too many — i <= 4 should be i < 4:

Live · One element too far
C source — click a line number for a breakpoint
  1. int main() {
  2. int guard = 7;
  3. int a[4];
  4. for (int i = 0; i <= 4; i++)
  5. a[i] = 99;
  6. return guard;
  7. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

It returns 99, not 7. a[4] is the 4 bytes just past the array, and in this stack frame that's where guard lives. The program doesn't crash; it silently corrupts a neighbouring variable. Compiled with gcc -O0 for ARM64 Linux, the real program returns 99 too. With other compilers or options, the layout differs and something else gets overwritten: the behavior is undefined, so any result is allowed.

This is the root of buffer overflows, historically the most exploited class of security bugs: when the overflowing data comes from input, and the frame also holds the function's return address, a bug becomes a way to take over the program. Compilers and operating systems now add defenses — stack canaries, non-executable stacks, ASLR — and tools find these bugs in testing: built with -fsanitize=address, the same program stops at the bad write and reports stack-buffer-overflow … WRITE of size 4 at line 5. Languages like Rust, Java and Python check bounds on every access, and pay for it with a compare and a branch.

Function pointers

Code lives in memory too, so functions have addresses. A function pointer holds one, and calling through it jumps to whatever function it points to. The declaration syntax is famously awkward — the parentheses around *op are what make it a pointer to a function rather than a function returning a pointer:

Live · Calling through a function pointer
C source — click a line number for a breakpoint
  1. int add(int a, int b) { return a + b; }
  2. int mul(int a, int b) { return a * b; }
  3. int apply(int (*op)(int, int), int x, int y) {
  4. return op(x, y);
  5. }
  6. int main() {
  7. return apply(add, 2, 3) * 10 + apply(mul, 2, 3);
  8. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

It returns 56: apply called add the first time and mul the second. In the assembly, passing add is just lea rax, [rip+add], the function's address, and op(x, y) becomes call rax, an indirect call. clang -O2 goes one step further:

apply:
    mov rax, rdi      ; the function pointer
    mov edi, esi      ; x → first argument
    mov esi, edx      ; y → second argument
    jmp rax           ; tail call: op returns directly to apply's caller

Function pointers are how C does callbacks (qsort takes a comparison function), plugin tables, and what C++ does behind the scenes for virtual methods. At the CPU level, every one of them is an indirect branch, which the branch predictor has to guess.

Takeaways

  • A pointer is a variable holding an address; &x takes an address and *p follows one. The type of a pointer only tells the compiler the size and meaning of what's at the address.
  • Pointer arithmetic counts in elements: p + 1 adds sizeof(*p) bytes, and p - q divides by it.
  • An array is contiguous elements; its name decays to a pointer to the first one, so a[i] is *(a + i), one scaled-index load. Arrays passed to functions lose their size.
  • Strings are char arrays ending in a zero byte.
  • NULL is address 0; page 0 is never mapped, so dereferencing it is a segmentation fault (exit status 139 on Linux).
  • C doesn't check bounds: writing past an array silently overwrites whatever is next, the root of buffer overflows. Sanitizers catch it in testing.
  • Function pointers hold code addresses; calling through one is an indirect call.

In this level

  1. 2.1Compiled, interpreted and JIT-compiled languages
  2. 2.2From source code to a running binary
  3. 2.3Variables, types and memory layout
  4. 2.4Pointers and arrays
  5. 2.5Functions, calls and the stack
  6. 2.6Structs, malloc and the heap
  7. 2.7Control flow: if, loops, switch
  8. 2.8Bytecode virtual machines and garbage collection