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:
&xis the address ofx;*pis the objectppoints to: reading*ploads from the address inp, 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:
- void swap(int *a, int *b) {
- int t = *a;
- *a = *b;
- *b = t;
- }
- int main() {
- int x = 3;
- int y = 7;
- swap(&x, &y);
- return x * 10 + y;
- }
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:
- int sum(int *p, int n) {
- int s = 0;
- int *end = p + n;
- while (p < end) {
- s += *p;
- p++;
- }
- return s;
- }
- int main() {
- int a[5] = {1, 2, 3, 4, 5};
- return sum(a, 5);
- }
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:
- int my_strlen(char *s) {
- char *p = s;
- while (*p)
- p++;
- return p - s;
- }
- int main() {
- return my_strlen("pointer");
- }
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:
- int main() {
- int *p = 0;
- return *p;
- }
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:
- int main() {
- int guard = 7;
- int a[4];
- for (int i = 0; i <= 4; i++)
- a[i] = 99;
- return guard;
- }
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:
- int add(int a, int b) { return a + b; }
- int mul(int a, int b) { return a * b; }
- int apply(int (*op)(int, int), int x, int y) {
- return op(x, y);
- }
- int main() {
- return apply(add, 2, 3) * 10 + apply(mul, 2, 3);
- }
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;
&xtakes an address and*pfollows 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 + 1addssizeof(*p)bytes, andp - qdivides 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
chararrays ending in a zero byte. NULLis 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.