Stage 2

Pointers, Arrays & Strings

The heart of C — and the source of most of its bugs. A pointer is an address plus a type. The type decides how big a step is, what you are allowed to read, and what the compiler checks for you.

2.1 The two operators: & and *

pointer-basics.c
int  value = 42;
int *p = &value;   /* & = "address of" — p holds value's address */

*p = 43;             /* * = "follow the pointer" — writes through p */
                     /* value is now 43 */

int *q = NULL;       /* NULL = "points nowhere". Never dereference it. */
if (p != NULL) { *p = 0; }   /* check first, dereference second */
  • A pointer has two things: an address (where) and a pointed-to type (what). int * and char * holding the same address mean different things — the compiler reads/writes different sizes through them.
  • * in a declaration is not the dereference operator. int *p; declares “*p is an int”. Write the * next to the name (int *p), not the type — int* a, b; declares a as pointer, b as plain int.
  • Initialise every pointer. int *p; holds garbage. int *p = NULL; fails loudly and testably instead of corrupting a random address.

2.2 Pointer arithmetic: steps are sized by the type

p + 1 does not add one byte — it adds sizeof(*p) bytes, moving to the next element. This is what makes pointers and arrays interchangeable in expressions, and what makes a wrong pointer type silently read the wrong bytes.

🧭 Pointer arithmetic visualizer

arithmetic.c — the four identities
uint32_t buf[6] = { 10, 20, 30, 40, 50, 60 };
uint32_t *p = buf;            /* array decays to &buf[0] */

p[2]      == *(p + 2);         /* indexing IS pointer arithmetic */
*(p + 2)  == 30;
p++;                          /* now points at buf[1] */
p - buf   == 1;               /* pointer subtraction counts ELEMENTS, not bytes */
&buf[5] - &buf[0] == 5;
⚠︎

Out-of-bounds arithmetic is undefined behavior — even forming a pointer more than one past the end, without dereferencing it, is UB. One-past-the-end is allowed (as a loop sentinel); two-past is not.

2.3 Array decay: where sizeof stops working

decay.c — the most common sizeof bug
int buf[10];

sizeof buf;        /* 40 — whole array, in this scope only */

void f(int a[]) {
    sizeof a;      /* 4 or 8 — POINTER size! a decayed on entry */
}

void f_good(int *a, size_t len) {
    for (size_t i = 0; i < len; i++) { /* ... */ }
}
  • Decay exceptions: an array does not decay when it is the operand of sizeof, &, or a string-literal initialiser. Everywhere else, it becomes a pointer to its first element.
  • &buf vs buf: same address, different types. buf decays to int * (step = one int); &buf is int (*)[10] (step = the whole array, 40 bytes).
  • String literals are read-only. char *s = "hello"; s[0] = 'H'; is undefined behavior. Use const char *s = "hello"; or a mutable array char s[] = "hello";.

2.4 const and pointers: read it right-to-left

const-pointers.c
const int *p;         /* pointer to const int: *p read-only, p movable */
int * const p;        /* const pointer to int: p fixed, *p writable */
const int * const p;  /* both fixed */

void print_buf(const uint8_t *buf, size_t len);  /* "I will not modify buf" */

Rule: read the declaration right-to-left — const applies to what is on its left (or to the right if nothing is on the left). const on a function parameter is a promise to callers and a compiler-checked guard against accidental writes. It does not make the data immutable — another non-const pointer to the same memory can still change it.

2.5 Pointers to pointers: changing the caller’s pointer

out-param.c
/* A function gets a COPY of its arguments — including pointers.
   To change the caller's pointer, pass a pointer TO the pointer. */
bool alloc_buf(uint8_t **out, size_t n) {
    *out = malloc(n);
    return *out != NULL;
}

uint8_t *buf = NULL;
if (!alloc_buf(&buf, 64)) { return ERR_NOMEM; }
free(buf); buf = NULL;
  • One level per job: int * lets a function change an int; int ** lets it change an int *. The same pattern appears in main(int argc, char **argv) (an array of strings) and in char ** tables.
  • void * is the generic pointer. It converts to/from any object pointer implicitly, but you cannot dereference or do arithmetic on it — cast to a concrete type first: uint8_t *bytes = data;
  • Function pointers are covered in depth in Stage 8 (Interfaces & Polymorphism) — the same idea, applied to behavior instead of data.
✖

Never return a pointer to a local. int *f(void) { int x = 5; return &x; } hands back an address whose stack frame is gone. The pointer dangles before the caller uses it. Return the value, take an out-pointer, or allocate — see Stage 3.