Pointer Arithmetic

Pointer arithmetic is where C stops feeling like Python and starts feeling like assembly with training wheels. The rule is one sentence: p + n advances the address by n * sizeof(*p) bytes, and the compiler does that scaling silently. Miss this once and you’ll spend an afternoon debugging a loop that reads every fourth int correctly and every fifth int as garbage.

The Core Rule

Given T *p; and integer n:

  • p + n(T*)((char*)p + n * sizeof(T))

  • p - q where both are T* → the integer number of T elements between them, type ptrdiff_t

  • *(p + n) is identical to p[n], which is also identical to n[p] (yes, really — a[b] is defined as *(a+b), and addition commutes)

So int *p; p + 1 moves the address by 4 bytes on most platforms, double *q; q + 1 moves by 8, struct foo *r; r + 1 moves by sizeof(struct foo) including any trailing padding.

The void* Rules (And Why GCC Bends Them)

By the C standard, arithmetic on void * is undefined — because sizeof(void) isn’t a thing. In practice GCC and Clang treat sizeof(void) as 1 as an extension, so void *p; p + 1 advances by one byte. Don’t rely on this. Portable code casts to char * when you want byte arithmetic:

void *base = /* ... */;
void *offset_by_5 = (char*)base + 5;   // portable
void *nope        = base + 5;          // GCC extension only

Compile with -Wpedantic and the second line is a diagnostic. Get in the habit.

Array-Pointer Equivalence, and When It Isn’t

An array name in most expression contexts decays to a pointer to its first element. That’s why int a[10]; int *p = a; works without an &. But arrays and pointers are not the same type. sizeof(a) is 10 * sizeof(int); sizeof(p) is 8 (on a 64-bit box). The decay does not happen for sizeof, &, or when the array is the operand of _Alignof.

This matters when you pass arrays to functions. void f(int a[10]) and void f(int *a) are the same declaration — the [10] is documentation, not enforced. Inside f, sizeof(a) is 8, not 40. This is the number-one source of “why doesn’t sizeof(arr)/sizeof(arr[0]) work in this function” confusion.

Casting the Return of malloc: The Great C-vs-C++ Debate

You’ll see two styles:

int *p = malloc(n * sizeof *p);           // no cast
int *p = (int *)malloc(n * sizeof *p);    // with cast

In C, the cast is a mild anti-pattern. malloc returns void *, which converts implicitly to any object pointer type. The cast adds noise and, historically, could hide the bug of forgetting to #include <stdlib.h> (without the include, malloc was assumed to return int, and the cast silenced the warning as the value got truncated on 64-bit systems). Modern C compilers warn on the missing declaration regardless, so the historical argument is weaker, but the aesthetic still holds: no cast.

In C++, you must cast, because C++ doesn’t implicitly convert void* to T*. (Also in C++ you should probably be using new, or better, RAII containers, but that’s a different fight.)

The idiom to memorize is sizeof *p instead of sizeof(int) — if you later change p’s type, the allocation size stays correct automatically. Community consensus on this pattern is remarkably strong; see the Stack Overflow canonical answer which has been the top result for over a decade.

T *p = malloc(n * sizeof *p);   // good — no cast, size follows type

Comparison and the One-Past-The-End Rule

You may form a pointer to one past the last element of an array and compare against it. You may not dereference it. This is why for (p = a; p < a + N; ++p) is legal and idiomatic. Forming pointers further past the end — a + N + 1 — is undefined behavior, even if you never dereference.

Comparing pointers to different objects (arrays) is also undefined by the standard, though in practice it works on flat address spaces. Don’t do it.

Alignment: The Silent Killer

Every type has an alignment requirement — an int typically wants to sit at an address that’s a multiple of 4, a double at a multiple of 8, a _Atomic long long on some ARM platforms at 16. If you cast a char* to a T* and dereference it when the address isn’t T-aligned, you get undefined behavior. On x86 it usually “works” (with a performance hit); on ARMv7 and some other architectures it faults hard.

char buf[16];
int *p = (int *)(buf + 1);   // buf+1 may not be 4-aligned
int x = *p;                  // UB. May fault on ARM. Silent bug on x86.

Use memcpy when you need to move bytes into or out of an oddly-aligned address:

int x;
memcpy(&x, buf + 1, sizeof x);   // legal, portable, and the compiler optimizes it

Since C11 you can query alignment with _Alignof(T) and request aligned allocations with aligned_alloc(alignment, size).

What Most People Get Wrong About This

They think p + 1 is “the next byte.” It’s not. It’s the next element. If you want a byte, cast to char * (or uint8_t *, same alignment) first. This one confusion is behind a huge fraction of buffer-parsing bugs — “I skipped past the header by adding 12 to my struct header *” no, you skipped 12 headers.

Habit: cdecl.org

Bookmark cdecl.org. For any declaration you can’t parse in five seconds, paste it in. int (*(*fp)(int))[3] → “declare fp as pointer to function (int) returning pointer to array 3 of int.” Using it isn’t weakness; not using it and being wrong is.


Return to README.md · Next: 03_malloc_free_lifecycles.md