Pointers
Complete visual pointer laboratory: addresses, dereferencing, pointer arithmetic, arrays and decay, double pointers, dynamic memory, function pointers, const/volatile, MMIO, lifetime bugs — with a step-through memory simulator, 100+ interview questions and a mastery exam.
Addresses and values
Every object your program declares occupies bytes of storage somewhere, and that 'somewhere' is its address. A variable name is just a human-readable label the compiler maps onto that storage; the machine itself only knows addresses and the byte values stored at them.
Memory is byte-addressable: each byte has a distinct numeric address, like a numbered pigeonhole. When you write `int x = 42;`, the compiler reserves sizeof(int) consecutive bytes — 4 bytes on our illustrative 32-bit model — and records that the name x refers to them. On that model, stack objects are bump-allocated from 0x1000 in declaration order, so x might live at 0x1000. The number 42 is the value stored in those bytes; 0x1000 is the address of the first byte. These two numbers are fundamentally different things, and most pointer confusion comes from mixing them up.
An object larger than one byte occupies a run of consecutive addresses, and 'the address of the object' conventionally means the address of its first (lowest-addressed) byte. On a little-endian machine — which our illustrative models are — the least significant byte of a multi-byte value sits at the lowest address, so the int value 1 stored at 0x1000 appears as the byte pattern 01 00 00 00 at 0x1000–0x1003. Endianness only matters when you inspect memory byte by byte, but in embedded work you do exactly that all the time: hexdumps, protocol buffers, shared memory with another processor.
Addresses are not mystical. On a bare-metal microcontroller they are physical locations in SRAM, flash, or a peripheral register block — the datasheet's memory map is literally a table of addresses and what lives at each. On a hosted OS they are virtual addresses translated by the MMU, so two processes can both have an x at 0x1000 that names different physical bytes. Either way, the C abstract machine promises one thing: each object has a stable address for its lifetime, and two distinct live objects never share an address. That promise is what makes pointers possible.
Vocabulary
- object
- A region of storage that holds a value of some type; what a variable names.
- address
- The numeric location of a byte (or an object's first byte) in memory.
- byte-addressable
- Each individual byte has its own address.
- little-endian
- The least significant byte of a value sits at the lowest address.
- storage duration
- How long an object's storage exists: static, automatic, or allocated.
- memory map
- A table saying which address ranges hold RAM, flash, or device registers.
Three declarations on the illustrative 32-bit model
- int x = 42; is declared first: stack bump allocator places it at 0x1000; the 4 bytes 2A 00 00 00 (little-endian) are stored at 0x1000–0x1003.
- char c = 'A'; is declared next: it lands at 0x1004 and the single byte 0x41 appears there.
- short s = 1000; follows: aligned to 2 bytes, so it sits at 0x1006 (0x1005 is padding); bytes E8 03 appear at 0x1006–0x1007.
- double d = 1.5; is 8-aligned: padding fills 0x1008–0x100F, then d occupies 0x1010–0x1017.
- Now 'x' in source means 'the 4 bytes at 0x1000'; reading x fetches them and interprets them as an int, yielding 42.
Each variable is a name for storage at a concrete address — x at 0x1000 holds 42 — and alignment, not declaration adjacency, decides the exact layout.
Common mistakes
- DO NOT CONFUSE the address of a variable with its value: x holding 42 and x living at 0x1000 are separate facts that change independently.
- An address is not a position in an array of objects; it is the number of a single byte, and larger objects span several addresses.
- DO NOT CONFUSE the variable's name with the storage: after the compiler is done, names mostly vanish; only addresses and bytes remain.
- Same address in two processes does not mean same byte: virtual memory maps each process separately.
NEXT CONNECTION: if every object has an address, a variable could store an address as its value — that single idea is the pointer, next chapter.
What a pointer stores
A pointer is an ordinary variable whose value is the address of another object (or a function). It has its own storage, its own address, and its own size — on the illustrative 32-bit model that is 4 bytes; on a 64-bit model it is 8 bytes — and the bytes it holds encode an address, nothing more and nothing less.
Strip away the mystique: a pointer is storage that holds an address, the same way an int is storage that holds a whole number. When you write `int *p = &x;`, two objects now exist. There is x, an int holding 42 at, say, 0x1000; and there is p, a pointer holding the address 0x1000 at its own location, say 0x1004. p's value is 0x00001000. p also has an address — 0x1004 — which is why pointers to pointers are possible later. Nothing links p and x except the number currently inside p.
Because a pointer is just a variable, everything true of variables is true of it. It can be uninitialized, in which case its value is indeterminate garbage — reading it is undefined behavior, not a crash you can count on. It can be assigned, copied into other pointers, passed to functions by value, and overwritten. Changing p does not change x; changing x does not change p. The connection exists only at the instant you dereference p, when the machine takes the stored address and follows it to memory.
The null pointer deserves early respect. NULL is a null pointer constant — a source-code way to write 'points to nothing'. The standard guarantees a null pointer compares unequal to any pointer to a real object, but it does not say the null pointer's bit pattern is all-zero bytes; that representation is implementation-defined, even though it is all-zero on every machine you are likely to touch. Concretely: `p = NULL;` records 'no target' in a way you can test with `if (p == NULL)`. Dereferencing it is always a bug, classified as a null deref. C++: prefer nullptr over NULL — it is typed, so it cannot silently convert to integer 0 in overload resolution.
Vocabulary
- pointer
- A variable whose value is the address of an object or function.
- pointee
- The object a pointer currently refers to.
- null pointer
- A pointer value guaranteed to point at no object; tests as 'no target'.
- uninitialized pointer
- A pointer with indeterminate value; dereferencing it is undefined behavior.
- address-of (&)
- Operator producing the address of its operand, yielding a pointer.
- dereference
- Following the stored address to reach the pointee.
A pointer is born on the illustrative 32-bit model
- int x = 42; — x at 0x1000 holds the value 42 (bytes 2A 00 00 00).
- int *p = &x; — p is the next stack object at 0x1004, 4 bytes wide; &x evaluates to 0x00001000, so p at 0x1004 now stores 0x00001000.
- int *q = p; — q at 0x1008 copies p's value, so q also stores 0x00001000; p and q are independent variables that happen to agree.
- p = NULL; — p at 0x1004 now stores the null pointer value; q still stores 0x00001000 and still refers to x.
- Testing `p == NULL` is true; testing `q == NULL` is false. Only the values changed — x at 0x1000 still holds 42 throughout.
p and q are ordinary 4-byte variables whose contents happen to be addresses; assigning between them copies numbers, never the pointee.
Common mistakes
- DO NOT CONFUSE 'p is a pointer to x' with a permanent bond: p just stores x's address right now, and can be reseated to point elsewhere at any time.
- A pointer is not always 8 bytes: on the illustrative 32-bit model it is 4 bytes; size depends on the machine model, and this is revisited with sizeof later.
- DO NOT CONFUSE NULL with 'physically address zero in the hardware': NULL is a null pointer constant; the actual representation is implementation-defined.
- An uninitialized pointer does not 'point nowhere safely' — it holds garbage, and using it is undefined behavior, worse than a clean null crash.
NEXT CONNECTION: a pointer stores an address, but C gives you three symbols — *, &, and [] — to create, follow, and index through that address; that is next.
The three symbols: *, &, []
Three symbols do all pointer work. & is address-of: it makes a pointer from an object. * has two lives: in a declaration it says 'this variable is a pointer', and in an expression it dereferences, following the pointer to its pointee. [] is subscript: a[i] is defined as exactly *(a + i), so it is dereference plus arithmetic in disguise.
The ampersand, &, is the entry point: applied to an object it yields that object's address, with the matching pointer type. If x is an int at 0x1000, then &x is a value of type int* whose content is 0x00001000. You cannot take the address of a literal or a temporary expression — &42 is nonsense — because there is no named storage to point at. The unary & is unrelated to bitwise AND; context tells them apart: unary takes one operand and means address-of.
The asterisk, *, does double duty and that is the classic stumbling block. In a declaration — `int *p;` — it is part of the type machinery, announcing that p is a pointer to int. In an expression — `*p` — it is the dereference operator, meaning 'go to the address stored in p and treat what is there as an int'. Same character, different grammar slot, different meaning. A useful reading habit: when * touches the type side of a line it declares; when it leads an expression it dereferences.
The subscript operator, [], is not array magic at all. The standard defines a[i] as exactly *(a + i): pointer arithmetic followed by dereference. Because addition is commutative, a[i] and i[a] literally compile to the same thing — a party trick that proves how mechanical the definition is. This also explains why a[i] works identically on arrays and on pointers: by the time subscript runs, an array has decayed to a pointer to its first element, and only the arithmetic matters. Everything you will ever do with these symbols reduces to: make an address (&), follow an address (*), or step-and-follow ([]).
Vocabulary
- address-of (&)
- Unary operator producing a pointer to its operand.
- dereference (*)
- Unary operator in an expression that follows a pointer to its pointee.
- declarator
- The part of a declaration stating a name and how its type is built.
- subscript ([])
- Indexing operator defined as *(a + i); arithmetic plus dereference.
- decay
- An array in most expressions converts to a pointer to its first element.
- lvalue
- An expression naming storage, so it can stand on the left of an assignment.
All three symbols on one small program
- int arr[3] = {10, 20, 30}; on the 32-bit model: arr occupies 0x1000–0x100B; arr[0]=10 at 0x1000, arr[1]=20 at 0x1004, arr[2]=30 at 0x1008.
- int *p = &arr[0]; — &arr[0] is 0x00001000; p at 0x100C now stores 0x00001000. (& declares nothing here: it manufactures a pointer value.)
- *p reads through the address: yields 10. *p = 99 writes through it: arr[0] at 0x1000 now holds 99.
- p[2] is *(p + 2): p + 2 advances 2 elements = 8 bytes to 0x00001008, dereference yields 30. Same bytes as arr[2].
- 2[p] compiles too, because it is *(2 + p) — identical arithmetic — but never write it in real code.
& manufactured a pointer, * read and wrote through it, and [] proved to be shorthand for pointer arithmetic plus dereference on the very same bytes.
Common mistakes
- DO NOT CONFUSE the * in `int *p` with dereference: there it is declarator syntax declaring p as a pointer; dereference only happens in expressions.
- DO NOT CONFUSE unary & (address-of) with binary & (bitwise AND): they share a glyph and nothing else.
- [] is not an 'array operator' — a[i] is *(a + i) by definition, so it works on any pointer, and arrays merely decay into pointers before it applies.
- DO NOT CONFUSE `&arr[i]` (address of element i) with something exotic: it is just arr + i, a pointer value like any other.
NEXT CONNECTION: * means one thing in a declaration and another in an expression — the next chapter makes that split rigorous, because interviews live on it.
Declaration vs expression
A C declaration mirrors use: `int *p;` says '*p is an int', from which you infer p is a pointer to int. The * in the declaration binds to the variable name, not to the type word, which is why `int *p, q;` declares one pointer and one plain int — a trap interviewers love.
K&R's rule is 'declaration follows use': the shape of the declarator mimics the shape of the expression that will use the variable. Read `int *p;` as a claim about an expression — whenever you later write *p, you get an int. From that single claim the compiler deduces p must be a pointer to int. The same rule scales to the scary cases: `int *arr[10];` declares arr such that *arr[i] is an int, i.e. arr is an array of 10 pointers to int, while `int (*arr)[10];` declares arr such that (*arr)[i] is an int, i.e. arr is a pointer to an array of 10 ints. Parentheses override binding exactly as they do in arithmetic.
Binding is the interview trap. In `int *p, q;` the grammar attaches * to p's declarator only; q is a plain int. Writing `int* p, q;` does not change that — the whitespace is cosmetic and fools the reader, not the compiler. This is why many style guides insist on one declarator per line and on writing `int *p;` with the star hugging the name: it makes the true binding visible. If you genuinely want two pointers, write `int *p, *q;` or two separate lines.
The same * reappears in expressions with a totally different job. In `int *p = &x; *p = 5;` the first * is declarator syntax; the second is the dereference operator performing a store through p. Keeping the two grammars separate prevents a family of bugs: `*p = &x;` in an expression tries to store a pointer value into an int, while `int *p = x;` tries to initialize a pointer from an int — both type errors caught at compile time. When you read any line containing *, first ask: am I in a declaration or an expression? That one question resolves most confusion. C++: the same declarator rules apply, but C++ adds references (`int &r = x;`), where & is again declarator syntax, not address-of.
Vocabulary
- declarator
- The name plus *, [], or () machinery describing how a variable's type is composed.
- declaration follows use
- Declarator shape mirrors the expression shape that yields the base type.
- base type
- The type word (e.g. int) shared by all declarators on one declaration line.
- binding
- Which declarator pieces attach to which name; * binds to the name, not the base type.
- type qualifier
- const/volatile decorating a type; position of * vs const changes meaning.
Parsing three declarations the compiler's way
- `int *p, q;` on the 32-bit model: p is int* (4 bytes at 0x1000), q is plain int (4 bytes at 0x1004). The * never touched q.
- `int (*pa)[4];` — parentheses make pa a pointer to an array of 4 ints; sizeof the pointee is 16, so pa + 1 would stride 16 bytes.
- `int *ap[4];` — [] binds tighter than *, so ap is an array of 4 pointers; ap occupies 0x1008–0x1017, each slot 4 bytes.
- In an expression now: `*pa` is the whole 4-int array (which decays to int*), while `ap[2]` is one int* slot. Different types, different strides, from declarator shape alone.
- Const placement: `const int *c1` and `int *const c2` — first is pointer to const int (pointee read-only through c1), second is a const pointer to int (c2 itself cannot be reseated).
The declarator's shape fully determines the type: same words, different * placement, completely different objects and arithmetic strides.
Common mistakes
- DO NOT CONFUSE `int* p, q;` with 'two pointers': the star binds to p's declarator only; q is an int regardless of spacing.
- DO NOT CONFUSE `int *a[4]` with `int (*a)[4]`: the first is an array of pointers, the second a pointer to an array — sizeof and stride differ by design.
- The * in a declaration does not dereference anything; nothing is followed, no memory is read — it only shapes the type.
- DO NOT CONFUSE `const int *p` with `int *const p`: one forbids writing the pointee through p, the other forbids reseating p itself.
NEXT CONNECTION: now that the declaration grammar is solid, put the expression-side * to work — dereference as a read, the most common pointer operation, is next.
Dereference as read
Reading *p is a two-step memory operation: first the CPU loads p's own bytes to get the stored address, then it loads sizeof(pointee) bytes from that address and interprets them as the pointee type. The value of p is only a waypoint — the answer comes from the pointee's storage.
In an expression like `int y = *p;`, evaluation is concrete enough to trace. The compiler emits a load of p (on the 32-bit model, 4 bytes from p's stack slot, say 0x1004) producing the address 0x00001000; then it emits a second load, 4 bytes from 0x1000, interpreted as an int. Two loads, two different locations: the pointer's storage and the pointee's storage. This is the whole mechanism behind every 'read through a pointer' you will ever write, from linked lists to MMIO register blocks.
The pointee type is doing critical work in the second load. It decides how many bytes are fetched and how the bit pattern is interpreted. If p is `char *` pointing at 0x1000, the second load fetches 1 byte; if p is `int *` at the same address, it fetches 4 bytes little-endian; if `double *`, 8 bytes. The address alone does not carry this information — C pointers are typed so the compiler knows the read's width and meaning without runtime metadata. A wrong type means reading the right address with the wrong width and interpretation, which is a silent data bug, not necessarily a crash.
When the read is legal and when it is not is a classification worth memorizing. Dereferencing a null pointer, an uninitialized pointer, a dangling pointer (pointee's lifetime ended), a freed pointer, an out-of-bounds address, or a misaligned address are all invalid dereferences — undefined behavior at the language level, whatever the hardware happens to do. On a microcontroller a wild read may silently return bus garbage; on an OS it may segfault; in an optimized build the compiler may assume it never happens and delete surrounding code. The safe habit: a pointer is readable only if you can name the live object it points at. volatile note: marking a pointer's pointee volatile forces the compiler to emit the load every time — essential for hardware registers — but it gives no atomicity and no synchronization whatsoever.
Vocabulary
- dereference read
- Loading the pointee's bytes via the address stored in a pointer.
- indirection
- Reaching data through a pointer rather than by its own name.
- pointee type
- The type a pointer points to; fixes read width and interpretation.
- indeterminate value
- Garbage content of uninitialized storage; reading it is undefined behavior.
- volatile
- Qualifier forcing the compiler to emit each access; no atomicity, no ordering guarantee.
- segfault
- OS-level fault when a load/store hits an unmapped or protected page.
Two loads behind `int y = *p;` on the 32-bit model
- Setup: int x = 42 at 0x1000; int *p = &x at 0x1004, so p's 4 bytes hold 0x00001000; int y will be placed at 0x1008.
- Load 1: the CPU reads p at 0x1004 and obtains the address value 0x00001000.
- Load 2: the CPU reads 4 bytes (int width) from 0x1000 and obtains 42; y at 0x1008 now holds 42.
- Contrast: char *c = (char *)p; *c performs load 2 with width 1, reading only the byte at 0x1000, which is 0x2A little-endian — value 42 here, but only by luck of the low byte.
- If instead p were NULL, load 2 dereferences the null pointer: classified null deref, undefined behavior — no bytes are legitimately read.
y received 42 via two loads; the pointer's own storage (0x1004) was read first, the pointee's storage (0x1000) second, and the pointee type fixed the read width.
Common mistakes
- DO NOT CONFUSE reading p with reading *p: p is the stored address, *p is the value at that address — different loads from different locations.
- A dereference read does not copy the pointee's address or move data 'into the pointer'; it only observes bytes through the stored address.
- DO NOT CONFUSE 'the hardware allows the read' with 'the read is defined C': wild or misaligned reads are undefined behavior even if the bus answers.
- volatile does not make concurrent reads safe: it only forces the compiler to actually emit the load each time, nothing more.
NEXT CONNECTION: *p in an expression reads through the pointer; put *p on the left side of an assignment and the same mechanism runs in reverse — dereference as write is next.
Dereference as write
`*p = v;` stores v into the object whose address p holds: the CPU reads p to get the target address, then writes sizeof(pointee) bytes of v there. The pointer's own storage is untouched. This store-through-address is how functions modify their callers' variables and how firmware programs hardware registers.
An assignment needs a place to put the value — an lvalue. The expression *p is an lvalue precisely because it names the pointee's storage: whatever object p currently points at. So `*p = 7;` means 'store 7 into the object at the address inside p'. Mechanically it mirrors the read: load p's bytes (4 bytes from p's slot on the 32-bit model) to obtain the target address, then perform a store of the pointee width to that address. p itself keeps the same value before and after; only the pointee's bytes change.
This is the mechanism that gives C its dual personality. In application code, it is how a function writes results into a caller's variable: `void swap(int *a, int *b)` receives copies of two addresses, and `*a` and `*b` reach back into the caller's stack frame — pass-by-value of the pointer, but shared access to the pointee. In embedded code, the identical mechanism programs hardware: a peripheral's control register has a fixed address from the datasheet's memory map, so `*(volatile uint32_t *)0x40020000 = 0x1;` stores a bit pattern into a device register. Same operator, same two-step trace; only the address's destination (RAM vs MMIO) differs.
The failure modes mirror the read side, with sharper teeth. Storing through a null, uninitialized, dangling, freed, out-of-bounds, or misaligned pointer is undefined behavior — and a wild write is usually worse than a wild read because it silently corrupts some other object's bytes: a neighbor variable, a return address, a heap header. Bugs like this are the classic 'works until we change optimization level' crash. Classification helps debugging: if the pointee's lifetime ended, it is dangling; if malloc'd and freed, freed; if past the object's end, out-of-bounds; if the address is not a multiple of the pointee's alignment, misaligned. Naming the class narrows the hunt immediately.
Vocabulary
- lvalue
- An expression naming storage; *p is one, so it can receive an assignment.
- store through pointer
- Writing the pointee's bytes via the address inside the pointer.
- pass by value
- Functions receive copies; with pointers the address is copied but the pointee is shared.
- MMIO
- Memory-mapped I/O: device registers accessed by load/store at fixed addresses.
- dangling pointer
- Pointer to an object whose lifetime has ended; using it is undefined behavior.
- misaligned access
- Access whose address is not a multiple of the type's alignment requirement.
swap() and one MMIO store, traced
- Caller: int a = 1 at 0x1000, int b = 2 at 0x1004. swap(&a, &b) passes copies: parameter pa at 0x100C stores 0x00001000, pb at 0x1010 stores 0x00001004.
- Inside swap: `int t = *pa;` loads pa (0x100C) → 0x00001000, loads int at 0x1000 → t at 0x1014 holds 1.
- `*pa = *pb;` reads through pb (0x1004 → value 2), then stores 2 to the address inside pa: a at 0x1000 now holds 2. pa's own bytes are unchanged.
- `*pb = t;` stores 1 through pb: b at 0x1004 now holds 1. The swap happened entirely in the caller's storage; the parameters were only address couriers.
- MMIO twin: `*(volatile uint32_t *)0x40020000 = 0x1;` loads nothing first — the constant address is used directly — and stores 4 bytes to the device register; volatile forces the store to be emitted exactly once per source statement, with no atomicity implied.
a and b exchanged values without the caller's variables ever being named inside swap — stores flowed through copied addresses; the MMIO line shows the same operator touching hardware.
Common mistakes
- DO NOT CONFUSE `*p = v` with `p = v`: the first writes the pointee, the second reseats the pointer; one changes the target's bytes, the other changes p's.
- Passing a pointer to a function is not 'pass by reference': the pointer is copied by value; only the pointee is shared. C++: references (int &) are true aliases — that is a C++ feature, not C.
- DO NOT CONFUSE a wild write with a harmless no-op: it corrupts unknown memory and may surface far from the bug; classify it (null/uninitialized/dangling/freed/out-of-bounds/misaligned) to debug it.
- volatile on an MMIO pointer does not synchronize with anything — it only guarantees the compiler emits the store; ordering against other accesses needs barriers.
NEXT CONNECTION: reads and writes through p depend on the pointee's type — how wide, how aligned, how interpreted — so the next chapter nails down pointer types and what sizeof really says about a pointer.
Pointer types and sizeof(pointer)
A pointer's type records what it points at, and that pointee type governs everything: how many bytes a dereference touches, how the bits are interpreted, what alignment is required, and how far p + 1 strides. The pointer's own size is a property of the machine model, not the pointee: 4 bytes on the illustrative 32-bit model, 8 bytes on a 64-bit model — for char*, int*, and double* alike.
Types are interpretation contracts. The bytes at address 0x1000 are just bytes; the type int* tells the compiler to read 4 of them little-endian and treat the pattern as a signed integer, char* says read 1, double* says read 8 and apply IEEE-754. This is why casting a pointer is powerful and dangerous in equal measure: `(short *)p` reinterprets the same address with width 2 and alignment 2. Nothing at runtime checks you — the type system is compile-time knowledge about how to treat memory, and a wrong belief produces wrong-width, wrong-interpretation accesses that may run silently.
Now the sizeof facts that interviews actually test. sizeof(p) — any object pointer p — is the pointer size of the model: 4 on ILLUSTRATIVE_32, 8 on ILLUSTRATIVE_64, independent of the pointee. But sizeof(*p) is the pointee size: 1 for char, 2 for short, 4 for int, 8 for double on our models. So `sizeof(char *) == sizeof(double *)` is true, while `sizeof(char) != sizeof(double)` — the pointer's width is uniform, the pointee's width is not. Never claim pointers are 'always 8 bytes'; that is only the 64-bit model, and plenty of shipping firmware is 32-bit. Also note sizeof(int) is 4 on both models here — int width and pointer width are separate choices.
void * is the type-erased pointer: it holds an object address without any pointee interpretation, so you cannot dereference it and you cannot do arithmetic on it in standard C — dereference needs a width the type no longer carries. The standard guarantees round-tripping: any object pointer converts to void * and back without loss, which is why malloc returns void * and why generic APIs (qsort, memcpy) speak void *. In C, malloc's result must NOT be cast — void * converts implicitly to any object pointer, and a cast only hides a missing #include <stdlib.h>. C++: the cast is required, because C++ does not implicitly convert void *; that is one of the visible C/C++ splits. One more rule with teeth: arithmetic on void * is a GNU extension; standard C requires casting to a character type (or another complete object type) first.
Vocabulary
- pointee type
- The T in T*; fixes dereference width, interpretation, alignment, and stride.
- sizeof operator
- Compile-time size in bytes of a type or expression's type.
- void *
- Generic object pointer with no pointee type; not dereferenceable, round-trips other object pointers.
- alignment
- Required address multiple for a type: 1 char, 2 short, 4 int/pointer, 8 double on our models.
- data model (ILP32/LP64)
- The platform's chosen widths for int, long, and pointer; pointer size follows the model, not the pointee.
- type pun / cast
- Reinterpreting one pointer type as another; compile-time belief change about the same address.
Same address, four types, on the 32-bit model
- Memory: bytes 78 56 34 12 at 0x1000–0x1003. Four pointers are declared: char *pc, short *ps, int *pi, double *pd — each is 4 bytes on the stack (0x1004–0x1013), each storing 0x00001000.
- *pc reads 1 byte: 0x78 → 120. sizeof(*pc) is 1; pc + 1 strides to 0x1001.
- *ps reads 2 bytes little-endian: 0x5678 → 22136. sizeof(*ps) is 2; ps + 1 strides to 0x1002.
- *pi reads 4 bytes: 0x12345678 → 305419896. sizeof(*pi) is 4; pi + 1 strides to 0x1004.
- Meanwhile sizeof(pc) == sizeof(ps) == sizeof(pi) == sizeof(pd) == 4 (would be 8 on the 64-bit model): the pointer width is the machine's, the stride and read width are the pointee's.
Identical stored addresses produced different reads and different strides purely from pointee type; every pointer itself stayed 4 bytes on the 32-bit model.
Common mistakes
- DO NOT CONFUSE sizeof(p) with sizeof(*p): the first is the pointer's own size (4 or 8 by machine model), the second the pointee's size — the interview's favorite swap.
- Pointers are not always 8 bytes: 4 on the illustrative 32-bit model, 8 on the 64-bit model, regardless of whether the pointee is char or double.
- DO NOT CONFUSE void * with a pointer you can dereference or step: it has no pointee type, so standard C forbids both; void * arithmetic is only a GNU extension — cast to a character type first in portable code.
- DO NOT CONFUSE casting malloc's result with good C: in C the cast is wrong practice (void * converts implicitly); C++ requires it — know which language you are in.
NEXT CONNECTION: the pointee type deciding the stride of p + 1 is the whole subject of pointer arithmetic — p + n moves n elements, never n bytes — next chapter.
Pointer arithmetic
p + n computes the address n ELEMENTS past p: the byte address changes by n × sizeof(pointee). The compiler multiplies by the pointee size automatically, so `int *p; p + 1` advances 4 bytes while `char *q; q + 1` advances 1. Subtraction of two pointers into the same array gives the element count between them, of type ptrdiff_t.
Pointer arithmetic is element arithmetic wearing an address disguise. When you write p + n, the machine forms address(p) + n × sizeof(*p). On the illustrative 32-bit model with int *p storing 0x00001000, p + 1 is 0x00001004 and p + 3 is 0x0000100C. The unit is the element, always. This is why `a[i]` being `*(a + i)` works for any type without the programmer ever mentioning bytes: the type carried inside the pointer does the scaling. It is also why printing addresses of successive array elements shows gaps of exactly sizeof(type).
Increment forms are the same arithmetic in postfix/prefix clothing, and interviews love the combinations. `*p++` parses as `*(p++)`: yield the pointee at the current address, then advance p by one element — the classic buffer-drain idiom. `(*p)++` increments the pointee, leaving p where it is. `*++p` advances p first, then yields the new pointee. `++*p` increments the pointee and yields the new value. Each parenthesization maps to a different memory story; being able to narrate all four without hesitation is a genuine seniority signal. Pointer difference is the inverse operation: q − p, both pointing into the same array, yields how many elements separate them, signed, of type ptrdiff_t.
Legality has boundaries worth stating now and deepening next chapter. Arithmetic is defined only within an array object (a single object counts as an array of one) plus one element past its end; computing an address outside that span is undefined behavior even if you never dereference it. The operands must be pointers of compatible type into the same array for subtraction. And a repeated warning in a new costume: arithmetic on void * is not standard C — it is a GNU extension that treats void * like char *; portable code casts to a character type first. On microcontrollers you will also meet arithmetic across a peripheral's register map, e.g. REG_BASE + 4 * index stepping a uint32_t register array — element arithmetic again, with volatile-qualified pointees.
Vocabulary
- stride
- Byte distance p + 1 advances: exactly sizeof(pointee).
- element arithmetic
- Pointer math counted in pointee-sized elements, not bytes.
- ptrdiff_t
- Signed type of a pointer difference, counted in elements.
- post-increment (p++)
- Yields the old value of p, then advances p by one element.
- pre-increment (++p)
- Advances p by one element, then yields the new value.
- array of one
- Rule treating a lone object as a 1-element array so arithmetic on it is defined.
Buffer drain with *p++ on the 32-bit model
- int buf[4] = {5, 6, 7, 8} at 0x1000–0x100F; int *p = buf at 0x1010, p stores 0x00001000; int sum at 0x1014 holds 0.
- sum += *p++; — yields *p = 5 from 0x1000, then p advances by sizeof(int)=4: p now stores 0x00001004; sum holds 5.
- sum += *p++; — yields 6 from 0x1004, p becomes 0x00001008; sum holds 11.
- sum += *p++; — yields 7 from 0x1008, p becomes 0x0000100C; sum holds 18.
- sum += *p++; — yields 8 from 0x100C, p becomes 0x00001010 (one past buf's end); sum holds 26. p − buf == 4 elements: 0x10 bytes ÷ 4.
Four *p++ steps walked p across 16 bytes in 4-byte strides; the final p is one-past-the-end (0x00001010), and p − buf reports 4 elements visited.
Common mistakes
- DO NOT CONFUSE p + 1 with 'next byte': it is next ELEMENT — p's address grows by sizeof(pointee), 4 bytes for int* on our models.
- DO NOT CONFUSE *p++ with (*p)++: the first advances the pointer after yielding the pointee; the second increments the pointee and never moves p.
- DO NOT CONFUSE q − p with a byte count: it is an element count of type ptrdiff_t; multiply by sizeof if you want bytes.
- Adding two pointers is not a thing: only pointer ± integer and pointer − pointer are defined.
NEXT CONNECTION: p ended one-past-the-end in the trace and that was perfectly legal — the exact rules of valid arithmetic, the sacred one-past-end pointer, and when p[-1] is legal come next.
Valid arithmetic and one-past-the-end
Pointer arithmetic is defined only inside one array object (a lone object counts as an array of one) plus one position past its last element. The one-past-the-end pointer may be formed, compared, and used in subtraction, but must never be dereferenced. It exists so loops can test `p != end` cleanly; it is the C foundation of the C++ end() iterator idiom.
The standard's rule is compact: given an array of n elements, pointers may range from &a[0] to &a[n] — that last one is one-past-the-end. Any arithmetic that would form a pointer before &a[0] or after &a[n] is undefined behavior at the moment of computation, even if no dereference ever happens; real machines with segmented or checked addressing can trap on forming the address, and compilers optimize on the assumption you stayed in bounds. Within the range, dereference is legal everywhere except the one-past-end slot: &a[n] is a valid pointer value but not dereferenceable. That asymmetry is the entire design: one sentinel address that is safe to compute and compare, marking 'no more elements'.
Why the language bothers: iteration needs an endpoint test that works for every array including empty ones. The idiom `for (int *p = a; p != a + n; p++)` uses a + n as the end marker; it never dereferences the marker, only compares against it. Dereferencing it is classified out-of-bounds — undefined behavior even if the bytes physically belong to some adjacent object on the stack, because the C abstract machine reasons per object, not per byte. This per-object view also forbids arithmetic that hops between separate arrays: two arrays back-to-back in memory are still two objects, and marching p from the end of one into the other is UB regardless of what the hexdump shows.
The symmetric question interviews ask: is p[-1] legal? By definition p[-1] is *(p − 1), so it is legal exactly when p − 1 lands on a real element of the same array — for example when p points at &a[1] or is one-past-the-end of a non-empty array, p[-1] is the last element, a genuinely useful idiom in algorithms that walk an end pointer. It is undefined when p points at a[0], because computing p − 1 forms an out-of-range pointer. Boundary arithmetic therefore demands one mental habit: before any p ± k or p[k], name the array and name the range [&a[0], &a[n]]; if the result stays in that closed-open span it is defined, and only the open end is barred from dereference. Heap note: the same rule applies to malloc'd blocks — the block is an array for arithmetic purposes, its one-past-end is legal to form, and this classification (in-bounds vs one-past vs out-of-bounds) is exactly what the PointerLab engine reports.
Vocabulary
- one-past-the-end
- Pointer just after an array's last element; valid to form and compare, never to dereference.
- half-open range
- [begin, end): end marks termination without being an element.
- sentinel
- A marker value signaling the boundary; one-past-end is the address sentinel.
- out-of-bounds
- Access outside an object's element range; undefined behavior.
- same-array rule
- Arithmetic and subtraction are defined only within one array object plus its one-past-end.
- p[-1] idiom
- Indexing one element before p; legal only when p − 1 is a real element of the same array.
Walking to the edge on the 32-bit model
- int a[3] = {10, 20, 30} at 0x1000–0x100B; int *end = a + 3 at 0x100C stores 0x0000100C — the one-past-the-end pointer, legally formed.
- Loop: `for (int *p = a; p != end; p++)` dereferences 0x1000, 0x1004, 0x1008 — the three real elements — then p becomes 0x0000100C, equals end, and the loop exits without dereferencing it.
- `end[-1]` is *(end − 1): end − 1 is 0x00001008, a real element, so end[-1] yields 30. Legal and idiomatic.
- `int *p = a; p[-1]` would compute 0x00000FFC — before &a[0]; forming that pointer is already undefined behavior, classified out-of-bounds.
- `*end` reads 0x0000100C — past the array; undefined behavior (out-of-bounds deref) even though that byte range exists on the stack and belongs to the end variable itself.
The loop's end test relied on a legal-but-not-dereferenceable sentinel at 0x0000100C; end[-1] reached the last element legally, while p[-1] from a[0] and *end are both out-of-bounds UB.
Common mistakes
- DO NOT CONFUSE 'one-past-the-end is a valid pointer' with 'one-past-the-end is dereferenceable': forming and comparing it is legal; *end is out-of-bounds UB.
- DO NOT CONFUSE 'the bytes exist in RAM' with 'the access is defined': C bounds are per object; reading a neighbor stack object through an over-run pointer is still UB.
- Computing an out-of-range address is UB even without dereferencing — the illegal act is forming the pointer, not only using it.
- DO NOT CONFUSE p[-1] with 'always negative-index UB': it is *(p − 1) and legal whenever p − 1 names a real element of the same array, e.g. end[-1].
NEXT CONNECTION: you have been indexing arrays through pointers all chapter — next comes the deep identity between arrays and pointers: decay, why arrays are not pointers, and what sizeof reveals about the difference.
Pointer subtraction and comparison
`q - p` returns the number of ELEMENTS between two pointers of the same type, as a signed `ptrdiff_t` — not a byte count. Equality (`==`, `!=`) is defined for any pointers that can be compared at all, but relational comparisons (`<`, `>`, `<=`, `>=`) are only defined when both pointers point into the same array object (or one past its end). Comparing pointers into unrelated objects is undefined behavior, not merely a wrong number.
Pointer subtraction is the exact inverse of pointer addition: if `q = p + n`, then `q - p` is `n`. The result counts elements, so the byte distance is `(q - p) * sizeof(element)`. On the illustrative 32-bit model, if `p` is `int *` pointing at 0x1000 and `q` points at 0x100c, then `q - p` is 3, because the addresses are 12 bytes apart and each `int` is 4 bytes. The type is `ptrdiff_t` from `<stddef.h>`, a signed integer type guaranteed to hold any valid difference. Printing it needs `%td`.
Relational comparison inherits the same array discipline as arithmetic. The standard defines `p < q` only when both pointers address elements of one array, including the one-past-the-end slot. That is what makes the classic loop `for (int *p = a; p < a + 10; ++p)` well-defined: both sides live in the same array-or-one-past domain. Once you compare pointers into different arrays, or compare a pointer against an integer address you fabricated, the behavior is undefined — on flat-address machines it will usually 'work', but the compiler is allowed to optimize on the assumption that you never did it.
Equality comparison is more permissive: `p == q` asks whether two pointers designate the same object or function, or are both null. A pointer and a null pointer constant can always be compared for equality, and pointers of different object types can be compared after the usual conversions. Subtraction across array boundaries is not defined either: subtracting a pointer into `b` from a pointer into `a` yields no meaningful element count, and on segmented or checked implementations it can trap rather than produce a garbage number.
Vocabulary
- ptrdiff_t
- Signed integer type of a pointer difference; print with %td.
- element units
- Pointer arithmetic counts elements, never bytes.
- same-array domain
- An array plus its one-past-the-end slot; relational ops are defined only here.
- equality vs relational
- == works broadly; < and > require a shared array.
- byte distance
- (q - p) * sizeof(element); the hardware view of a difference.
Tracing q - p and a boundary comparison (illustrative 32-bit model)
- int a[6]; is bump-allocated at 0x1000, so a[0] sits at 0x1000 and a[5] at 0x1014 (4-byte ints).
- int *p = &a[1]; stores 0x1004 into p at 0x1018. int *q = &a[5]; stores 0x1014 into q at 0x101c.
- q - p computes (0x1014 - 0x1004) / 4 = 0x10 / 4 = 4. The result is the ptrdiff_t value 4: a[5] is four elements after a[1].
- p - q yields -4. The sign carries direction; the magnitude carries element count.
- The loop condition p < a + 6 compares 0x1004 against the one-past-the-end pointer 0x1018 (a+6): same array, so the comparison is defined and true.
- Comparing p < &b where b is a separate array is undefined behavior — the compiler may assume it never happens.
q - p == 4 (elements, not bytes); relational comparison stays defined only because both pointers address a[0..5] or one past it.
Common mistakes
- DO NOT CONFUSE q - p with a byte count: it is an element count, so divide the byte distance by sizeof(element) before trusting the number.
- DO NOT CONFUSE 'it printed a sane value' with defined behavior: comparing or subtracting pointers into different arrays is undefined even when it looks fine on x86.
- DO NOT CONFUSE ptrdiff_t with size_t: differences are signed; p - q can be negative, and %zu is the wrong format.
- The one-past-the-end pointer participates fully in ==, <, and subtraction within its array; it is only *dereferencing* it that is out-of-bounds.
NEXT CONNECTION: subtraction and comparison only make sense inside one array — so the next chapter asks what an array actually is, and why its name keeps turning into &a[0] in expressions.
Arrays and decay
An array is a contiguous block of elements — a real object with storage — not a pointer. In MOST expressions the array name undergoes 'decay': it is implicitly converted to a pointer to its first element, of type pointer-to-element. That is why `a` and `&a[0]` print the same address. But `a`, `&a`, and `&a[0]` have three different TYPES even when two of them share a value, and the exceptions to decay (sizeof, unary &, _Alignof) prove the array still exists as an array.
The declaration int a[10] reserves one unbroken region of 40 bytes on the illustrative 32-bit model. There is no hidden pointer variable anywhere in that storage: `a` is a name for the whole object, and indexing reaches in with address arithmetic. A pointer, by contrast, is a separate 4-byte (or 8-byte) object whose VALUE is an address. Confusing the two leads to the classic bug of expecting assignment between arrays (`a = b`) to copy contents — arrays are not assignable at all.
Decay is a conversion rule, not a type. When `a` appears in most expressions — as a function argument, on the right of +, as the operand of * — the compiler quietly substitutes a pointer of type int * whose value is the address of a[0]. This is why the idiom a[i] works: after decay it is *(pointer + i). But decay does not happen as the operand of sizeof or unary &, which is why sizeof(a) is 40 and &a has type int (*)[10].
Three expressions, two addresses, three types: &a[0] is int * pointing at the first element's address. `a` in a decayed context is also int * at that same address. &a is int (*)[10] — a pointer to the WHOLE array — whose value is again the same address but whose arithmetic steps 40 bytes instead of 4. The address printed is identical; adding 1 to each of them shows the types are not. This type distinction is what the compiler checks when you pass arguments, and getting it wrong is a constraint violation, not a style issue.
Vocabulary
- decay
- Implicit conversion of an array name to a pointer to its first element.
- array object
- The contiguous storage itself; not a pointer, not assignable.
- &a[0]
- int * — address of the first element.
- &a
- int (*)[10] — pointer to the whole array; same address, different arithmetic.
- decay exceptions
- sizeof, unary &, _Alignof, and string-literal initialization keep the array type.
One address, three types (illustrative 32-bit model)
- int a[10]; occupies 0x1000 through 0x1027 — one 40-byte block, no pointer variable inside it.
- a, &a, and &a[0] all evaluate to the address 0x00001000; printing each shows the same hex value.
- (a + 1) is 0x1004: decay gave int *, so +1 steps sizeof(int) = 4 bytes.
- (&a + 1) is 0x1028: &a is int (*)[10], so +1 steps sizeof(a) = 40 bytes, clean past the end of the block.
- (&a[0] + 1) is 0x1004, matching a + 1 — the element pointer and the decayed name agree.
- Attempting a = &a[0]; fails to compile: an array is not a modifiable lvalue, pointer or not.
a, &a, and &a[0] print 0x00001000, but +1 lands at 0x1004, 0x1028, and 0x1004 — the types, not the address, carry the information.
Common mistakes
- DO NOT CONFUSE an array with a pointer: the array has no stored address inside it; decay CREATES a temporary pointer value in expressions.
- DO NOT CONFUSE &a with a 'pointer to a pointer': &a is int (*)[10], a pointer to an array, and dereferencing it once yields the array, not an int.
- DO NOT CONFUSE same printed address with same type: (a+1) and (&a+1) prove the arithmetic differs by a factor of 10.
- Arrays are not assignable and not modifiable lvalues: a = b and a++ are constraint violations even though p = q and p++ are fine for pointers.
NEXT CONNECTION: decay's most visible exception is sizeof — the next chapter pins down exactly when sizeof sees the whole 40-byte array and when it sees only the 4- or 8-byte pointer.
sizeof(array) vs sizeof(pointer)
sizeof is one of the two places (with unary &) where array decay does NOT happen: applied to an actual array object it yields the total byte size of all elements. Applied to a pointer — including a function parameter that was declared like an array — it yields the size of the pointer itself: 4 bytes on the illustrative 32-bit model, 8 bytes on a 64-bit model. If sizeof(a) changes value across a function boundary, the 'array' in the callee was never an array; it was a pointer wearing array syntax.
sizeof is evaluated against the TYPE of its operand at compile time (variable-length arrays aside). For int a[10] on the illustrative 32-bit model, that type is 'array of 10 int', so sizeof(a) is 10 * 4 = 40. The count is a constant baked into the generated code; no memory is read. This gives the robust element-count idiom sizeof(a) / sizeof(a[0]), which stays correct if the element type changes.
Inside a callee, void f(int a[]) is adjusted to void f(int *a) — the next chapter covers that mechanism — so sizeof(a) in the body asks for the size of an int *. On the illustrative 32-bit model that is 4 bytes; on a 64-bit model it is 8 bytes. The pointer size is a property of the target, not of what the pointer addresses. NEVER claim pointers are always 8 bytes: embedded 32-bit targets are everywhere, and sizeof is exactly the tool that tells the truth on each one.
The safe mental rule: sizeof tells you the size of what the NAME denotes at this point in the code, after any parameter adjustment or decay you forced by hand. If you need the element count in a callee, it must arrive as a separate argument — no amount of sizeof on a pointer can recover it. _Alignof and unary & are the other decay exceptions: &a is int (*)[10], while if a had decayed, & on the resulting prvalue would be ill-formed. Together with sizeof they are the proof that the array type survives until an expression consumes it.
Vocabulary
- sizeof
- Compile-time byte size of a type or expression's type; no decay on array operands.
- sizeof(a)/sizeof(a[0])
- The portable element-count idiom for a real array.
- pointer size
- 4 bytes on the illustrative 32-bit model, 8 on 64-bit; a target property.
- parameter adjustment
- Array parameters silently become pointers before sizeof ever sees them.
- VLA
- Variable-length array; the one case where sizeof is evaluated at run time.
sizeof across a call boundary (illustrative 32-bit model)
- In main: int a[10]; is allocated at 0x1000, 40 bytes. sizeof(a) is 40; sizeof(a[0]) is 4; the count idiom yields 10.
- main calls f(a). The argument decays to int * with value 0x00001000; f receives that 4-byte pointer in its own slot at, say, 0x1060.
- Inside f(int a[]) — adjusted to f(int *a) — sizeof(a) is 4, the pointer size on this model. The 40-byte knowledge died at the call.
- sizeof(*a) inside f is still 4 (one int), so sizeof(a)/sizeof(*a) computes 4/4 = 1 — a silently wrong 'length'.
- Recompiled for a 64-bit model, step 3 prints 8 instead of 4; the array in main still reports 40 because int stays 4 bytes wide.
- Fix: f takes (int *a, size_t n) and main passes (a, sizeof a / sizeof a[0]) while the count is still knowable.
sizeof(a) is 40 where a is the real array, 4 (32-bit) or 8 (64-bit) where a is only a pointer — the element count must travel as a separate argument.
Common mistakes
- DO NOT CONFUSE sizeof(p) with the size of what p points at: a pointer to a megabyte buffer is still 4 or 8 bytes.
- DO NOT CONFUSE sizeof(a)/sizeof(a[0]) as universally safe: it is only correct where a is a genuine array, never inside a callee.
- NEVER claim pointers are always 8 bytes: on the illustrative 32-bit model and on real 32-bit MCUs they are 4; only the target decides.
- DO NOT CONFUSE sizeof with strlen: sizeof counts bytes of storage including the terminator for a literal-sized array; strlen counts characters before the terminator at run time.
NEXT CONNECTION: the callee's sizeof surprise is caused by a rule — array parameters are pointers in disguise — and that rule is the whole subject of the next chapter.
Array parameters
A function parameter declared as an array of T is adjusted by the compiler to pointer to T: void f(int a[]), void f(int a[10]), and void f(int *a) are the SAME function type. The number you write in the brackets is ignored for type purposes (it documents intent, and [static 10] adds a contract). Because the callee receives only a pointer, the element count cannot be recovered inside the function and must travel separately as a length argument.
The adjustment rule exists because of decay: an array argument converts to a first-element pointer at the call site, so the callee could never receive the array anyway. Making the parameter type honest — pointer to element — keeps the type system consistent with what actually crosses the call boundary. Consequences follow immediately: sizeof(a) inside f is the pointer size, the [10] is not checked against the argument's real length, and f(a) compiles happily with an array of 3.
The length must travel separately by convention. The two living idioms are an explicit count — void f(const int *a, size_t n) — or a sentinel value such as the '\0' that terminates C strings. Both are conventions, not language enforcement: the compiler will not stop you from passing n = 100 with an array of 10. C99 added the qualifier form void f(int a[static 10]), which promises the caller passes at least 10 non-null elements and lets the compiler warn or optimize, but it is still a promise, not a runtime check.
Multi-dimensional parameters keep the rule precise: only the FIRST (leftmost) dimension decays, so void g(int m[][4]) adjusts to void g(int (*m)[4]) — a pointer to rows of 4 ints. The remaining dimensions must be written and must match, because the callee needs the row width to compute a[i][j] addresses. Writing g(int m[][]) is ill-formed: without the row width the compiler cannot do the arithmetic. This asymmetry — first dimension optional, the rest mandatory — is decay applied exactly once.
Vocabulary
- parameter adjustment
- Array-of-T parameter becomes pointer-to-T before anything else happens.
- length argument
- The size_t that must accompany a decayed array.
- sentinel
- An in-band end marker like '\0'; the alternative to a count.
- [static n]
- C99 promise that the argument provides at least n elements; enables warnings and optimization.
- int (*)[4]
- What a 2D array parameter really is: pointer to a row of 4 ints.
Three prototypes, one function (illustrative 32-bit model)
- In main: int samples[4] = {10, 20, 30, 40}; sits at 0x1000, 16 bytes; 4 is the true length.
- main calls sum(samples, 4). samples decays to int * with value 0x00001000; that 4-byte pointer and the size_t 4 are the only things passed.
- The prototype written as sum(int a[100]) adjusts to sum(int *a): the 100 is discarded, so no length information reaches the callee through the type.
- Inside sum, the loop reads a[i] = *(0x1000 + i*4) for i < n; correctness depends entirely on n == 4 being right.
- Calling sum(samples, 100) compiles cleanly; at i = 4 the loop reads 0x1010 and beyond — out-of-bounds, classified as an out-of-bounds deref, with no diagnostic required.
- Rewriting the prototype as sum(int a[static 4]) lets a modern compiler warn at the call site when the promise is visibly broken.
void f(int a[]), void f(int a[10]), and void f(int *a) are one type; the count travels in n, and nothing enforces it except [static n] hints and your discipline.
Common mistakes
- DO NOT CONFUSE the [10] in a parameter with a bound check: it is documentation; the compiler neither verifies nor uses it for indexing.
- DO NOT CONFUSE passing an array with copying an array: only a first-element pointer crosses the call; the callee reads the caller's storage.
- DO NOT CONFUSE 'the function knows the length' with reality: sizeof on the parameter is the pointer size (4 on the illustrative 32-bit model, 8 on 64-bit), never the array size.
- DO NOT CONFUSE int m[][] with legal syntax: after the first dimension decays, every remaining dimension must be a compile-time expression so the callee can compute row strides.
NEXT CONNECTION: once every array argument is a pointer, indexing itself is just pointer arithmetic in a trench coat — the next chapter proves a[i] and *(a+i) are the same operation, commutative operand included.
The indexing identity
Because the subscript operator is DEFINED as a[i] ≡ *(a + i) — there is no separate indexing machinery. The + between a pointer and an integer is commutative, so a + 3 and 3 + a are the same address computation, which makes *(3 + a), i.e. 3[a], valid and identical to a[3]. It compiles for the same reason a[3] compiles; readability, not legality, is the reason you will never write it twice.
The standard specifies the subscript operator through addition and dereference: E1[E2] is identical to (*((E1) + (E2))). One operand must be a pointer to a complete object type and the other an integer; which is which is unconstrained. So the 'array' in a[i] need not be an array at all — after decay it is a pointer — and need not even be on the left. Everything you know about pointer arithmetic transfers verbatim: the step is sizeof(element), and the result must land inside the array or one past it to stay defined.
The identity also explains error messages and edge cases. Negative indices are legal in form: p[-1] is *(p - 1), perfectly defined if p points into the middle of an array — a genuinely useful idiom with base pointers. a[0.5] fails because the integer operand must be an integer type, not because indexing is special. And out-of-bounds a[10] on int a[10] is *(a + 10): the one-past-the-end ADDRESS is a valid pointer value, but dereferencing it is an out-of-bounds access, undefined behavior.
The practical payoff is translating between notations when debugging. p[i] where p is int * is *(p + i): take the pointer's stored address, add i * 4 on the illustrative 32-bit model, dereference. When a heap block from malloc is indexed, or a string is walked, the same identity applies — arr[k], ptr[k], and *(base + k) are three spellings of one address computation. Learn to read any of them as 'base plus k elements, then fetch'.
Vocabulary
- indexing identity
- E1[E2] is defined as *((E1) + (E2)); indexing IS pointer arithmetic.
- commutativity
- a + 3 ≡ 3 + a, so a[3] ≡ 3[a].
- negative index
- p[-1] ≡ *(p - 1); defined when the result stays inside the array.
- complete object type
- The pointed-to type must have a known size for the arithmetic to mean anything.
- one-past dereference
- a[n] on int a[n] forms a valid address but dereferencing it is out-of-bounds.
a[3] and 3[a] byte by byte (illustrative 32-bit model)
- int a[5] = {7, 11, 13, 17, 19}; occupies 0x1000..0x1013; a[0]=7 sits at 0x1000, a[3]=17 at 0x100c.
- a[3] expands to *(a + 3): decay gives int * = 0x00001000, add 3 * 4 = 12 bytes, dereference 0x100c, read 17.
- 3[a] expands to *(3 + a): the same 0x1000 + 12 = 0x100c; dereference reads 17. Both forms emit identical address arithmetic.
- int *p = &a[3]; stores 0x100c in p; p[-1] is *(0x100c - 4) = *0x1008 = a[2] = 13, fully defined.
- a[5] is *(a + 5): the address 0x1014 is the valid one-past-the-end pointer, but dereferencing it is classified out-of-bounds — undefined behavior.
- Compiling 3[a] under -Wall -Wextra is silent: the language has nothing to warn about; only humans object.
a[3] and 3[a] both compute *(&a[0] + 3) = 17; the identity a[i] ≡ *(a+i) is definitional, so commutativity of + is inherited for free.
Common mistakes
- DO NOT CONFUSE 3[a] compiling with 3[a] being good style: it is defined behavior and a readability crime; write a[3].
- DO NOT CONFUSE the subscript operator with an array-only feature: p[i] on a bare pointer is equally *(p+i), which is why malloc'd blocks index fine.
- DO NOT CONFUSE forming a one-past-the-end address with using it: &a[n] is valid; *(&a[n]) is out-of-bounds.
- DO NOT CONFUSE negative indices with inherently undefined code: p[-1] is legal when p points past the first element of a real array.
NEXT CONNECTION: indexing hides one addition and one dereference — but when a declarator itself contains both * and [], reading WHICH binds to the name first becomes the whole game: int (*p)[10] versus int *p[10].
int (*p)[10] vs int *p[10]
int *p[10] is an ARRAY of 10 pointers to int — 10 separate pointer objects in a row. int (*p)[10] is a POINTER to one array of 10 ints — a single pointer whose arithmetic steps whole rows of 40 bytes. The parentheses decide, because [] binds tighter than *. Read any declarator with the clockwise/spiral intuition: start at the name, resolve postfix [] and () before prefix *, and let parentheses redirect you outward.
Precedence in declarations mirrors precedence in expressions: postfix operators [] and () bind tighter than prefix *. So in int *p[10], the name meets [10] first: p is an array of 10, and the int * describes the elements — ten int * objects. On the illustrative 32-bit model that object is 40 bytes of pointer slots, each 4 bytes, each independently null, pointing somewhere, or uninitialized. sizeof(p) is 40 and p + 1 (after decay to int **) steps one pointer, 4 bytes.
In int (*p)[10] the parentheses force * to bind first: p is a pointer, and what it points at is an array of 10 int. sizeof(p) is the pointer size — 4 on the illustrative 32-bit model, 8 on a 64-bit model — and p + 1 steps sizeof(int[10]) = 40 bytes. This is exactly the type produced by &a for int a[10], and the type that 2D array parameters decay into. Dereferencing once (*p) yields the array (which then decays to int * in most expressions); (*p)[i] is one int.
The clockwise/spiral rule is a reading discipline, not a language feature: begin at the identifier, move right to postfix []/(), then left to prefix *, bounce outward through parentheses, and finish at the base type. Applied to int *(*f[5])(void): f is an array of 5 pointers to a function taking void and returning int *. The same skill decodes the types compilers print in error messages — 'expected int (*)[10] but argument is of type int **' is precisely the confusion this chapter exists to prevent.
Vocabulary
- int *p[10]
- Array of 10 int pointers; 10 pointer objects, each independently set.
- int (*p)[10]
- Pointer to an array of 10 ints; one pointer, steps 40 bytes (4-byte ints).
- clockwise/spiral rule
- Mnemonic for reading declarators: name, postfix first, prefix second, parentheses redirect.
- postfix precedence
- [] and () bind tighter than prefix * in declarations, as in expressions.
- row pointer
- A pointer-to-array value; what &a yields and 2D parameters receive.
Two declarations side by side (illustrative 32-bit model)
- int data[10]; occupies 0x1000..0x1027 (40 bytes). int (*rowp)[10] = &data; stores 0x00001000 in rowp at 0x1040; type: pointer to int[10].
- rowp + 1 computes 0x1028: the step is sizeof(int[10]) = 40 bytes. (*rowp)[3] reads the int at 0x100c.
- int a0 = 1, a1 = 2; int *ps[10]; occupies 0x1060..0x1087: ten 4-byte pointer slots, currently uninitialized garbage.
- ps[0] = &a0; writes 0x1090 into the slot at 0x1060; ps[1] = &a1; writes 0x1094 into 0x1064. Each element is an independent int *.
- ps + 1 (ps decayed to int **) steps 4 bytes — one pointer slot, not one array; contrast with rowp + 1 stepping 40.
- sizeof tells the story: sizeof(rowp) is 4 (a pointer on this model), sizeof(ps) is 40 (a real array of ten pointers).
int *p[10] is 10 pointer slots stepping 4 bytes; int (*p)[10] is one pointer stepping 40 — the parentheses decide which object exists.
Common mistakes
- DO NOT CONFUSE int (*p)[10] with int **: a pointer to an array of 10 ints has no pointer-to-pointer inside it; the chapter after next shows why the layouts are incompatible.
- DO NOT CONFUSE declaration precedence with expression precedence being different: they match — [] before *, parentheses override, in both.
- DO NOT CONFUSE sizeof(p) across the two declarations: 4 (pointer, 32-bit model) versus 40 (array of ten 4-byte pointers) — same letters, different objects.
- DO NOT CONFUSE *p[10] with (*p)[10] in use either: *ps[i] dereferences one stored pointer, while (*rowp)[i] indexes the single array rowp points at.
NEXT CONNECTION: pointer-to-array is the native type of 2D arrays — the next chapter lays int a[3][4] out as one contiguous 48-byte block and shows why a row pointer steps 16 bytes.
2D arrays are row-major
int a[3][4] is ONE contiguous block of 12 ints in row-major order: all of row 0, then all of row 1, then row 2. a[i][j] lives at base + (i * 4 + j) * sizeof(int) — the column index j moves bytes fastest, the row index i moves in strides of a whole row (16 bytes with 4-byte ints). There are no pointer arrays, no indirection: a[i][j] is two multiplications and one add away from the base address.
C multidimensional arrays are arrays of arrays, and the memory is flat. int a[3][4] is an array of 3 elements, each of type int[4]; since arrays are contiguous, the rows lie back to back with no padding between them beyond element alignment. On the illustrative 32-bit model the block is 48 bytes: row 0 at offsets 0..15, row 1 at 16..31, row 2 at 32..47. sizeof(a) is 48, and a flat int * walk from &a[0][0] to &a[0][0] + 12 visits every element in order.
Decoding a[i][j] applies the indexing identity twice: a[i] is *(a + i), where a decays to int (*)[4], so a + i steps i * sizeof(int[4]) = i * 16 bytes and yields the row object, an int[4]. That row then decays to int * for [j], stepping j * 4 bytes. Net address: base + i*16 + j*4. The row pointer type int (*)[4] is what carries the stride — lose the 4 (as in an int m[][] parameter) and the compiler cannot compute the address.
Row-major layout has performance consequences: sequential access along the LAST index walks consecutive bytes, which is what caches and prefetchers want; walking the first index strides by whole rows and can thrash. It also explains what a memcpy of the whole 2D array does — one 48-byte block — and why treating &a[0][0] as a flat int * to clear all 12 elements is well-defined: an array object can be accessed through a pointer to its element type within its bounds.
Vocabulary
- row-major
- The last index varies fastest; whole rows are contiguous.
- array of arrays
- int a[3][4] is 3 objects of type int[4], back to back.
- row stride
- sizeof(int[4]) = 16 bytes on the illustrative 32-bit model; the row pointer's step.
- address formula
- base + (i * cols + j) * sizeof(element).
- flat walk
- Iterating &a[0][0] as int * across all 12 elements; same bytes, simpler view.
Addressing a[2][3] (illustrative 32-bit model)
- int a[3][4]; occupies 0x1000..0x102f: 3 rows x 4 ints x 4 bytes = 48 contiguous bytes.
- Row 0 spans 0x1000..0x100f, row 1 spans 0x1010..0x101f, row 2 spans 0x1020..0x102f.
- a decays to int (*)[4] with value 0x00001000; a + 2 steps 2 x 16 = 32 bytes to 0x1020 — the row pointer for row 2.
- *(a + 2) is the row object int[4] at 0x1020; it decays to int * 0x00001020 for the second subscript.
- Adding 3 steps 3 x 4 = 12 bytes: &a[2][3] = 0x102c, matching the formula 0x1000 + (2*4 + 3)*4.
- A flat int *f = &a[0][0]; f[11] reads the same address 0x102c: element 11 is row 2, column 3 (11 = 2*4 + 3).
a[2][3] is at 0x1000 + (2*4+3)*4 = 0x102c; the 2D array is one flat block, and a row pointer (int (*)[4]) steps 16 bytes per row.
Common mistakes
- DO NOT CONFUSE int a[3][4] with an array of pointers: there are no row-pointer objects in memory; the 'rows' are just address arithmetic on one block.
- DO NOT CONFUSE column-major with C: Fortran, MATLAB, and R put the FIRST index fastest; C always makes the last index fastest.
- DO NOT CONFUSE a[i][j]'s cost with a[i] + [j] magic: it is base + i*stride + j*4, two multiplies and adds, no loads until the final fetch.
- DO NOT CONFUSE int m[][] with a legal parameter: the inner dimension is the stride and must be written so the compiler can compute addresses.
NEXT CONNECTION: if 2D arrays are flat blocks with no pointers inside, then int ** — which is nothing but pointers — cannot be the same thing; the next chapter puts the two layouts side by side.
Why int** is not a 2D array
Because the memory layouts are fundamentally different. int a[3][4] is one contiguous 48-byte block with NO pointers in it; int ** expects to find an array of int * row pointers, each pointing at a separate row somewhere else. Decaying the 2D array yields int (*)[4] — a row pointer with a 16-byte stride — which is a different type from int ** with different arithmetic. The correct parameter for a true 2D array is int (*)[4] (or int m[][4]); int ** is the correct type only for an explicitly built array of row pointers.
Follow a[1][2] in the two worlds. For int a[3][4]: compute base + 1*16 + 2*4 and load — one address computation, one memory access. For int **r: load the pointer stored at r + 1 (one memory access), then load the int at that row pointer + 2 (a second memory access). The compiler must know WHICH protocol to use before it can emit code, and the parameter type is how it knows. int (*)[4] selects the flat-block protocol; int ** selects the pointer-chase protocol. They cannot substitute for each other because the bytes in memory are organized differently.
The int ** layout — often called a jagged array — is built row by row: int **r = malloc(3 * sizeof *r); then r[i] = malloc(4 * sizeof *r[i]) per row. Its payoff is flexibility: rows can have different lengths, live anywhere, and be swapped by exchanging pointers (r[0] and r[2] trade places with no data movement). Its costs are an extra indirection per access, N+1 allocations to make and free, and rows scattered across the heap with poor locality. In C the malloc result must NOT be cast (void * converts implicitly); C++ requires the cast. C++: prefer std::vector<std::vector<int>> or a flat vector plus an index helper over hand-built int **.
When you genuinely must bridge the two layouts — say, an API demanding int ** while you hold int a[3][4] — you construct a translation layer: int *rows[3]; rows[i] = a[i]; then pass rows (which decays to int **). This works because a[i] is the address of row i inside the flat block; the row pointers now mimic the jagged layout. Note the direction: you synthesize the pointer array from the flat block. There is no cheap conversion in the other direction, because the flat block does not exist in an int ** structure until you copy the data.
Vocabulary
- flat block
- int a[3][4]: 48 contiguous bytes, no pointers inside.
- jagged array
- Array of row pointers; rows may differ in length and location.
- row pointer type
- int (*)[4] — what a true 2D array decays into; 16-byte stride with 4-byte ints.
- pointer chase
- int ** access loads the row pointer first, then the element: two memory accesses.
- translation layer
- int *rows[3] = { a[0], a[1], a[2] } — synthesizing int ** from a flat block.
Two layouts, two access protocols (illustrative 32-bit model)
- int a[3][4]; at 0x1000: one 48-byte block. Reading a[1][2] = load from 0x1000 + 16 + 8 = 0x1018. One load.
- int **r = malloc(3 * sizeof *r); the malloc'd pointer array lands at 0x4000 (heap bump). r itself, a local at 0x1060, stores 0x00004000.
- r[0] = malloc(4 * sizeof **r); returns row storage at 0x400c, stored into the slot at 0x4000. r[1]'s row lands at 0x401c, slot at 0x4004. Rows are separate blocks.
- Reading r[1][2]: load the pointer at 0x4004 (yields 0x401c), then load the int at 0x401c + 8 = 0x4024. Two loads, one dependent on the other.
- Passing a to f(int **p) is a type error: int (*)[4] is not int **, and if forced with a cast, f would read the integer a[0][0]'s value as if it were a pointer and dereference garbage.
- Bridge: int *rows[3] = { a[0], a[1], a[2] }; builds slots at 0x1080 holding 0x1000, 0x1010, 0x1020; rows decays to int ** and f now works over the flat block.
int (*)[4] does flat-block arithmetic (one load per element); int ** chases row pointers (two loads); the compiler needs the parameter type to pick the protocol, so they are not interchangeable.
Common mistakes
- DO NOT CONFUSE a cast with a conversion: (int **)a silences the compiler but makes f interpret int values as addresses — a 'trust me' cast that dereferences garbage.
- DO NOT CONFUSE int ** with 'pointer to a 2D array': pointer to int[4] is int (*)[4]; int ** points at a pointer, full stop.
- DO NOT CONFUSE the jagged layout with a free lunch: it costs an extra indirection, N+1 allocations, and scattered rows; the flat block wins on locality and single malloc/free.
- DO NOT CONFUSE rows[i] = a[i] with copying data: the bridge stores row ADDRESSES; mutating through rows[i][j] mutates the original flat block.
NEXT CONNECTION: int ** is not always a mistake — it is the honest type of a pointer variable that itself is pointed at; the next chapter gives pointer-to-pointer its own ground: **pp, reading and writing through two levels.
Pointer to pointer
An int ** stores the address of an int * variable — one more level of the same idea: pp is a pointer whose pointee is itself a pointer. *pp reads or writes that inner pointer object (changing WHERE it points); **pp reads or writes the final int (changing the VALUE at the end of the chain). Two dereferences, two different objects, two different effects — and every level must be initialized before it is dereferenced.
Pointer-to-pointer is ordinary pointer logic applied twice. int x = 5; int *p = &x; int **pp = &p; builds a chain: pp holds p's address, *pp is p (an int * holding x's address), **pp is x. The types line up one star per dereference: ** on int ** yields int, * on int ** yields int *. Nothing about the second level is exotic — it is the address-of operator and the dereference operator composed once more. On the illustrative 32-bit model every level is a 4-byte object; on a 64-bit model, 8 bytes each.
The two write paths do different jobs. *pp = &y; retargets the inner pointer: p now points at y, and anyone holding p sees the change — this is exactly how a callee moves a caller's pointer, as in void alloc(int **out) { *out = malloc(...); }. **pp = 42; writes through both levels to the final int, identical in effect to *p = 42 or x = 42. Choosing the wrong star count either fails to compile (type mismatch) or, worse, compiles against a wrong-but-compatible type and corrupts the wrong object.
Classic uses cluster around output parameters and dynamic structures: functions that must return a pointer by writing into the caller's variable (getline's char **lineptr), argv as char ** walking an array of string pointers, and the jagged arrays of the previous chapter. The discipline is initialization at every level: pp must point at a real int * object, and that object must hold a valid int * before **pp is touched. Dereferencing an uninitialized pp is an uninitialized-pointer fault; dereferencing *pp when p is null is a null deref — two distinct failure classes from two distinct levels.
Vocabulary
- int **
- Pointer to a pointer to int; stores the address of an int * variable.
- *pp
- The inner pointer object itself; writing it retargets where p points.
- **pp
- The final int; writing it changes the value at the end of the chain.
- output parameter
- Passing &p so a callee can set the caller's pointer, e.g. alloc(&p).
- level discipline
- Every level must be initialized before its dereference; faults classify per level.
Building and using pp -> p -> x (illustrative 32-bit model)
- int x = 5; lands at 0x1000 holding 5. int *p = &x; lands at 0x1004 storing 0x00001000.
- int **pp = &p; lands at 0x1008 storing 0x00001004 — the address of the pointer object, not of x.
- Reading **pp: load pp (0x1004), load *pp = p (0x1000), load *p = 5. Two dependent loads from one expression.
- **pp = 42; writes 42 to 0x1000: x is now 42, identical to *p = 42.
- *pp = &y; where y sits at 0x100c: writes 0x0000100c into the object at 0x1004, so p now points at y; x is untouched.
- void alloc(int **out) { *out = malloc(4); } called as alloc(&p): the callee writes the heap address 0x4000 into the caller's p — the caller's pointer moved because its address was passed.
pp at 0x1008 stores 0x00001004 (address of p); **pp = 42 rewrote x, *pp = &y retargeted p — two stars, two different objects written.
Common mistakes
- DO NOT CONFUSE *pp = q with **pp = v: the first retargets the inner pointer, the second writes the final value; picking the wrong level is the classic double-pointer bug.
- DO NOT CONFUSE int ** with requiring heap memory: &p where p is a plain local int * is a perfectly good int ** with no malloc anywhere.
- DO NOT CONFUSE 'two stars means 2D array' with reality: as the previous chapter showed, int ** is a pointer chain, not a flat block; only a hand-built row-pointer array matches it.
- DO NOT CONFUSE the null checks: pp, *pp, and the final object are three separate things; checking pp != NULL says nothing about *pp being initialized.
NEXT CONNECTION: chains of pointers end where ownership questions begin — the next chapter leaves static storage and starts dynamic memory: malloc, free, and who is responsible for every byte.
Modifying the caller's pointer
Pass the address of the caller's pointer — an int ** — so the callee can write through it and retarget the caller's int *. The alternative is to return the new pointer value and have the caller assign it. Passing the int * itself only gives the callee a copy of the value.
C passes every argument by value, and pointers are no exception. When you write `void fix(int *p)`, the callee receives a copy of the pointer value; assigning `p = &other;` inside the function changes only that copy, and the caller's pointer is untouched. To mutate the caller's pointer object itself, you need a pointer to it, exactly as you would pass `int *` to mutate an `int`. That pointer's type is `int **`.
Inside the callee, `*pp` names the caller's pointer object, so `*pp = new_target;` writes through one extra level of indirection and lands in the caller's stack frame. This is the same dereference rule as always — `*` followed by assignment writes to the pointee — applied one level up. The allocate-via-double-pointer pattern (`int make_buffer(int **out, size_t n)` returning a status) is the idiomatic C way to hand a freshly allocated object back to a caller when you also need to report an error code.
The alternative is simply to return the new value: `p = make_buffer(n);`. Prefer the return-value form when there is nothing else to report; use the double-pointer (or 'out-parameter') form when the function must also produce an error code or several results. Both are correct; what is never correct is assigning to a by-value pointer parameter and expecting the caller to see it. The caller sees only what it can reach through the pointer you gave the callee.
Vocabulary
- out-parameter
- A pointer argument whose pointee the callee fills in as a result.
- int **
- Pointer to a pointer to int; lets a callee retarget the caller's int *.
- pass by value
- The callee gets a copy of the argument, pointer included.
- *pp
- The caller's pointer object, as seen inside the callee.
- status return
- Returning an error code while results travel through out-parameters.
- retarget
- Store a new pointer value into an existing pointer object.
Retarget through int **
- Caller declares `int x = 42; int *p = &x;` — illustrative 32-bit model: x at 0x1000 holds 42, p at 0x1004 stores 0x00001000.
- Caller declares `int y = 7;` — y lands at 0x1008. Caller calls `retarget(&p, &y);` passing 0x00001004.
- Callee's parameter pp holds 0x00001004. `*pp` names the object at 0x1004 — the caller's p.
- Callee executes `*pp = q;` where q holds 0x00001008: the bytes at 0x1004 are overwritten with 0x00001008.
- Back in the caller, p now points at y: `*p` reads 7 from 0x1008, and x at 0x1000 is unchanged.
Writing through one extra level of indirection let the callee change the caller's pointer; passing p itself would have changed only a temporary copy.
Common mistakes
- DO NOT CONFUSE `p = &y;` inside `void f(int *p)` with retargeting the caller — that assignment only overwrites the callee's private copy of the pointer value.
- DO NOT CONFUSE `*pp = q;` with `**pp = v;` — the first writes a pointer value into the caller's pointer, the second writes an int into the caller's pointee.
- DO NOT CONFUSE an out-parameter with a return value — both are valid, but a function that both returns the pointer and writes it through an out-parameter invites the two to disagree.
NEXT CONNECTION: retargeting assumes the new target is a valid pointer — next we classify the three ways a pointer becomes invalid: NULL, wild, and dangling.
NULL, wild, and dangling pointers
A null pointer is deliberately set to the null pointer constant (NULL) and derefencing it is a clean, diagnosable error. A wild pointer was never initialized and holds whatever bytes were in its storage. A dangling pointer once pointed at a valid object whose lifetime has ended — a freed heap block or a dead stack frame. All three are undefined to dereference; only NULL is cheap to check for.
NULL is a language-level null pointer constant: `p = NULL;` gives p a value that is guaranteed to compare unequal to the address of any object or function, and `if (p)` tests it reliably. The standard does not promise that the null pointer is physically address zero — the bit representation is implementation-defined, and the compiler translates the constant `0` in pointer context into whatever the platform's null pointer actually is. Dereferencing a null pointer is undefined behaviour, but on hosted systems it typically faults at a reserved page, which is why NULL is the 'safe' failure mode.
A wild pointer is an uninitialized pointer: `int *p;` inside a function leaves p holding indeterminate bytes from whatever previously occupied that stack slot. Reading p's value at all is undefined behaviour, and dereferencing it can land literally anywhere — it may appear to work, corrupt unrelated memory, or crash, nondeterministically. The fix is cultural, not technical: initialize every pointer at declaration, to NULL if no target exists yet, so the failure mode becomes checkable instead of random.
A dangling pointer was once valid but its pointee died: `free(p)` frees the block but p still holds the old address, and a pointer to a local variable dangles the moment its function returns. The pointer object itself is fine; the address it stores no longer designates a live object. Dereferencing it may read stale bytes that look correct until the storage is reused — the classic 'works in debug, corrupts in release' bug. The discipline: set pointers to NULL after free, and never return addresses of locals.
Vocabulary
- null pointer constant
- Source-level constant (NULL or 0) the compiler turns into the platform's null pointer value.
- wild pointer
- A pointer whose value was never initialized.
- dangling pointer
- A pointer whose pointee's lifetime has ended.
- indeterminate value
- Whatever bytes happen to occupy uninitialized storage; reading them is UB.
- lifetime
- The span of execution during which an object exists and its address is valid.
- null check
- `if (p)` before dereferencing; only meaningful because NULL is a defined sentinel.
Three invalid pointers in one trace
- `int *a = NULL;` — a at 0x1000 (illustrative 32-bit model) stores the null pointer value; `a == NULL` is true, `*a` would be UB.
- `int *b;` — b at 0x1004 is never written; it holds leftover bytes, say 0xDEADBEEF from a previous frame; `*b` is UB and could touch anywhere.
- `int *c = malloc(4); *c = 9;` — heap block at 0x4000 holds 9, c at 0x1008 stores 0x00004000; `*c` is valid.
- `free(c);` — the block at 0x4000 returns to the allocator, but c at 0x1008 still stores 0x00004000: c is now dangling.
- `*c` now reads whatever the allocator wrote over 0x4000, or UB; `c = NULL;` after the free would have made the error detectable.
NULL fails predictably and is testable; wild and dangling pointers fail unpredictably because their stored address no longer means anything.
Common mistakes
- DO NOT CONFUSE the null pointer constant with physical address zero — the representation is implementation-defined; the language only guarantees it differs from every real object address.
- DO NOT CONFUSE `free(p)` with 'p is cleared' — free releases the block; p keeps the stale address until you overwrite it.
- DO NOT CONFUSE 'it ran without crashing' with 'the pointer was valid' — wild and dangling dereferences often appear to work until storage is reused.
- DO NOT CONFUSE NULL with 0-valued pointee — `p = NULL` says where p points; `*p = 0` writes through a valid pointer.
NEXT CONNECTION: dangling usually starts at the heap boundary — next, malloc, calloc, realloc, and free, and the ownership rules that keep heap pointers valid.
malloc, calloc, realloc, free
malloc returns uninitialized bytes, calloc returns zeroed bytes, realloc resizes a block and may move it, free returns it. Every malloc needs exactly one free, on the same pointer value that was returned. `p = realloc(p, n)` is dangerous because on failure realloc returns NULL and the original block is still allocated — assigning NULL over p leaks it. In C, do not cast malloc's result; C++ requires the cast.
malloc(n) returns a pointer to n bytes of uninitialized storage (or NULL on failure); calloc(count, size) returns count*size bytes zero-initialized; free(p) returns the block to the allocator, where p must be a pointer previously returned by an allocation function (or NULL, which is a no-op). The heap gives you storage with dynamic duration: it lives until you free it, regardless of which function allocated it. That freedom is the point — and the source of every leak, double free, and use-after-free.
realloc(p, n) resizes the block p points to, preserving the old contents up to the smaller of old and new size. Crucially, it may move the block: it can allocate elsewhere, copy the data, free the old block, and return a different address. The pattern `p = realloc(p, n)` therefore has a leak built in: if realloc fails it returns NULL, the assignment overwrites p, and the original block — still allocated — becomes unreachable. The correct idiom is `int *tmp = realloc(p, n); if (tmp) p = tmp;`. And after any successful realloc, every other pointer into the old block is dangling.
Two symmetric sins complete the picture. Double free — calling free twice on the same pointer value — corrupts allocator metadata and is undefined behaviour. Use-after-free — dereferencing a pointer after its block was freed — reads or writes storage the allocator may already have recycled. Defences: free once per malloc, set the pointer to NULL immediately after free (free(NULL) is legal, so the second free becomes harmless), and treat realloc results through a temporary. In C the malloc result must NOT be cast — `void *` converts implicitly to any object pointer, and a cast can hide a missing `#include <stdlib.h>`. C++ requires the cast because the implicit void* conversion was removed; C++ code should prefer new/delete or smart pointers anyway.
Vocabulary
- malloc
- Allocate n uninitialized bytes; returns NULL on failure.
- calloc
- Allocate count*size bytes, zero-initialized.
- realloc
- Resize a heap block, possibly moving it; NULL on failure, original block untouched.
- free
- Return a block to the allocator; free(NULL) is a no-op.
- double free
- free() on the same pointer value twice; corrupts the allocator.
- use-after-free
- Dereferencing a pointer whose block was already freed.
- leak
- Losing the only pointer to a still-allocated block.
realloc done wrong and right
- `int *p = malloc(4 * sizeof *p);` — heap block at 0x4000, 16 bytes; p at 0x1000 (illustrative 32-bit model) stores 0x00004000.
- Caller fills p[0..3], then needs 8 ints: `p = realloc(p, 8 * sizeof *p);` — WRONG form.
- Suppose the heap is exhausted: realloc returns NULL and the block at 0x4000 is still allocated — but p at 0x1000 now stores NULL; the 16 bytes and their data are unreachable: a leak.
- Right form: `int *tmp = realloc(p, 8 * sizeof *p);` — suppose the block moves to 0x4100: tmp holds 0x00004100, the four ints are copied, 0x4000 is freed.
- `if (tmp) { p = tmp; }` — p at 0x1000 now stores 0x00004100; on failure p still holds 0x00004000 and the data survives for error handling.
realloc through a temporary keeps the old block reachable on failure and picks up the new address on success; direct assignment risks a silent leak.
Common mistakes
- DO NOT CONFUSE realloc failure with freeing the old block — on failure the original allocation survives; only a successful realloc frees the old address.
- DO NOT CONFUSE `p = realloc(p, n)` with safe resizing — on failure it overwrites your only handle to a live block (the realloc-loss pattern).
- DO NOT CONFUSE C and C++ here — in C, casting malloc's result is discouraged (void* converts implicitly, and the cast can mask a missing header); C++ requires the cast.
- DO NOT CONFUSE free(p) with making p safe — the pointer still holds the old address until you set it to NULL.
NEXT CONNECTION: the heap lives until freed, the stack dies at return — next, storage duration and why returning a pointer to a local is always wrong.
Storage duration and lifetimes
Because the local's storage duration ends when the function returns. Automatic objects live from declaration to end of block; static objects live for the whole program; allocated (heap) objects live from malloc to free. A pointer to a local outlives its pointee the moment the frame is popped — the address becomes dangling, and dereferencing it is UB even when it sometimes prints the right answer.
C gives every object a storage duration. Automatic: locals and parameters, born at block entry, dead at block exit — on our illustrative model they are bump-allocated on the stack and reclaimed when the frame is popped. Static: globals and `static` locals, created once before main and alive until program exit, so taking their address is always safe. Allocated: heap blocks from malloc, alive until free, independent of any function's lifetime. The duration, not the pointer, decides whether an address stays valid.
`int *f(void) { int x = 42; return &x; }` compiles because &x is a perfectly good int* value — the function returns a copy of the address 0x1000. But the object at 0x1000 ceased to exist when f returned; the stack space is reused by the very next call. Reading through the returned pointer may print 42 if nothing has overwritten the slot yet, which is exactly why this bug survives casual testing: UB does not mean 'must crash', it means 'no guarantees'.
The fixes follow the duration you actually need. If the caller must own the result, allocate it: `int *p = malloc(sizeof *p);` and document who frees. If the value is small, return it by value — most 'return a pointer' APIs should be return-by-value. If several calls share one object, make it static (with its thread-safety caveats) or pass a caller-supplied buffer as an out-parameter. What you may never do is let an address escape the scope that owns the object.
Vocabulary
- automatic storage duration
- Lives from block entry to block exit; locals and parameters.
- static storage duration
- Lives for the entire program run; globals and static locals.
- allocated storage duration
- Lives from malloc/calloc/realloc until free.
- stack frame
- The region of stack storage one function call owns, reclaimed at return.
- escaping address
- An address that outlives the scope owning the object it names.
- dangling return
- Returning &local; the returned pointer dangles immediately.
Pointer to a local, step by step
- f() is called; `int x = 42;` — x at 0x1000 (illustrative 32-bit model, stack bump allocation) holds 42.
- `return &x;` copies the value 0x00001000 into the return slot; caller's p receives it.
- f's frame is popped: the object x no longer exists, though the bytes at 0x1000 still happen to read 42.
- Caller calls g(), which declares `int y = 99;` — g's frame reuses 0x1000; the bytes now hold 99.
- Caller finally dereferences p: `*p` reads 0x1000 and gets 99 — a different program could read anything; the behaviour was undefined from step 2.
The returned address stayed the same; the object it named died at return, so later reads observe whatever reuse left behind — UB that 'sometimes works'.
Common mistakes
- DO NOT CONFUSE 'it printed the right value' with defined behaviour — a dangling stack pointer often reads stale-but-plausible bytes until the frame is reused.
- DO NOT CONFUSE a static local with a normal local — `static int x;` lives for the whole program, so returning its address is legal (but shared and not reentrant).
- DO NOT CONFUSE the pointer's lifetime with the pointee's — p in the caller is alive and well; only the object it points to is gone.
NEXT CONNECTION: once lifetimes are under control, the next layer of correctness is access rights — the three placements of const on a pointer.
const placement, three forms
Read the declarator right to left. `const int *p`: p is a pointer to const int — you cannot modify the pointee through p, but p itself can be retargeted. `int *const p`: p is a const pointer to int — p must always point at the same object, but you can modify that object. `const int *const p`: both — a fixed pointer to an unmodifiable pointee.
The right-to-left reading rule is mechanical: start at the identifier and walk left. `const int *p` reads 'p is a pointer to an int that is const' — const binds to the pointee type, so `*p = 5;` is a compile error while `p = &other;` is fine. `int *const p` reads 'p is a const pointer to int' — const binds to the pointer, so p must be initialized and can never be retargeted, while `*p = 5;` is fine. The const always qualifies whatever stands immediately to its left, or the type to its right if nothing is left of it.
Semantics, not just syntax: const-on-pointee is a promise about this access path, not about the object. `const int *p = &x;` forbids writing x through p, but x itself may be non-const and mutable through another pointer — const is a read-only view, not a read-only object. This is precisely why it is the right tool for function parameters: `size_t strlen(const char *s)` promises the callee will not modify your string, which is a contract the compiler enforces at every call site.
The third form combines both: `const int *const p = &x;` — a fixed read-only view of x. All three forms appear constantly in real APIs: const-pointee for inputs, const-pointer for things like register handles or fixed lookup tables, and both for immutable configuration. A useful exercise: pointer-to-pointer adds another slot — `const char *const *argv` means you may change neither the strings nor the pointers to them, but argv itself may advance.
Vocabulary
- const int *p
- Pointer to const int: pointee read-only through p, p retargetable.
- int *const p
- Const pointer to int: p fixed at init, pointee writable.
- const int *const p
- Const pointer to const int: neither p nor *p can change.
- right-to-left rule
- Read declarators starting at the identifier, moving left through * and const.
- read-only view
- Const qualifies the access path, not necessarily the underlying object.
- API contract
- A const parameter promises callers their data will not be modified.
Three pointers to the same int
- `int x = 10, y = 20;` — x at 0x1000 holds 10, y at 0x1004 holds 20 (illustrative 32-bit model).
- `const int *a = &x;` — a at 0x1008 stores 0x00001000. `*a = 5;` is rejected by the compiler; `a = &y;` is legal and a now stores 0x00001004.
- `int *const b = &x;` — b at 0x100C stores 0x00001000 forever. `*b = 15;` writes 15 into x at 0x1000 — legal. `b = &y;` is rejected.
- `const int *const c = &x;` — c at 0x1010 stores 0x00001000; both `*c = 5;` and `c = &y;` are rejected.
- Meanwhile `x = 30;` directly is still legal: the object x was never const — only the views a and c were.
Each placement of const forbids exactly one of the two write paths: through the pointer's value, or through the pointer's target.
Common mistakes
- DO NOT CONFUSE `const int *p` with `int *const p` — the first freezes the pointee, the second freezes the pointer; they fail on opposite assignments.
- DO NOT CONFUSE a const pointee with a const object — `const int *p = &x;` makes the path read-only; x can still change through x itself or another pointer.
- DO NOT CONFUSE const with volatile — const is a compile-time access contract; volatile concerns how the object is accessed, not who may write it.
NEXT CONNECTION: const says who may write; volatile says the value can change behind the compiler's back — next, volatile and memory-mapped I/O.
volatile and memory-mapped I/O
volatile tells the compiler the object can change for reasons outside the program's visible flow, so every read and write in the source must actually be emitted — no caching in a register, no optimizing away, no merging. That is exactly what memory-mapped I/O registers need: reading a status register twice must perform two real reads. volatile is NOT atomicity and NOT a synchronization primitive.
Hardware registers live at fixed physical addresses and are accessed like memory: the datasheet says a status register sits at, say, 0x40020000, and the code models it as `volatile uint32_t *const STATUS = (volatile uint32_t *)0x40020000;`. Every read of *STATUS must reach the bus because the hardware updates the register asynchronously; a cached copy would report stale flags. Every write must be emitted because writing a control register has a side effect even if the program never reads the value back — without volatile the compiler may legally delete a write whose result is 'unused'.
The three placement forms mirror const: `volatile int *p` — pointer to volatile int (pointee is volatile, p retargetable: the common MMIO form); `int *volatile p` — volatile pointer to int (the pointer itself may change unexpectedly, rare); `volatile int *volatile p` — both. As with const, read right to left and note that volatile qualifies the access path. The MMIO idiom usually combines both qualifiers: `volatile uint32_t *const REG` — a fixed pointer to a volatile register.
The limits matter as much as the guarantee. volatile forces each access to be emitted, in order relative to other volatile accesses — nothing more. It does NOT make a read-modify-write atomic: `REG |= 1;` is still a load, an or, and a store that an interrupt can split. It does NOT synchronize threads: it creates no happens-before relationship, no ordering against non-volatile accesses, and no CPU memory barrier. For concurrency use C11 atomics; for device ordering use the platform's barrier instructions. volatile is for memory whose contents change outside the abstract machine — MMIO registers, and objects shared with signal handlers.
Vocabulary
- volatile
- Qualifier forcing the compiler to emit every access; object may change outside program flow.
- MMIO
- Memory-mapped I/O: hardware registers accessed through fixed addresses like memory.
- volatile int *p
- Pointer to volatile int: accesses through p are always emitted.
- side effect
- An observable change (like a register write) the compiler must preserve.
- read-modify-write
- Load, modify, store sequence — NOT atomic merely because the object is volatile.
- compiler barrier effect
- Volatile accesses are not reordered with respect to each other; nothing more is promised.
Polling a status register
- Datasheet: UART status register at 0x40020000, bit 0 = 'data ready'. Code: `volatile uint32_t *const UART_SR = (volatile uint32_t *)0x40020000;`
- `while ((*UART_SR & 1u) == 0) { }` — each loop iteration emits a real load from 0x40020000; the compiler may not hoist it out of the loop.
- Without volatile, the compiler proves the value 'cannot change' inside the empty loop and loads once — an infinite loop that never sees the hardware flag.
- The character arrives; hardware sets bit 0; the next emitted load returns 1; the loop exits.
- `REG |= 1;` elsewhere is still three emitted bus operations (load, or, store) — volatile emits them but does not fuse them: an interrupt between load and store can still clobber the flag.
volatile turns the source-level reads into guaranteed bus reads, which is the whole job for MMIO — atomicity and thread ordering are separate problems.
Common mistakes
- DO NOT CONFUSE volatile with atomicity — `volatile int x; x++;` can still lose updates between threads or interrupts.
- DO NOT CONFUSE volatile with synchronization — it establishes no happens-before edge and no ordering against non-volatile memory; use C11 atomics for threads.
- DO NOT CONFUSE volatile placement with const placement being different — they follow the same right-to-left rule; `volatile int *p` makes the pointee volatile, not p.
- DO NOT CONFUSE 'the compiler may not optimize the access' with 'the hardware access is safe' — alignment, bus width, and ordering barriers are your responsibility.
NEXT CONNECTION: registers are fixed-layout words; the same fixed-layout idea at the language level is the struct — next, struct pointers and the -> operator.
Struct pointers and ->
`p->member` is exactly `(*p).member`: dereference p, then select the member at its fixed offset within the struct. Structs are passed around by pointer because copying a whole struct is expensive and because the callee often must modify the caller's object — so nearly every real C API is a set of functions taking a pointer to a struct it operates on.
A struct lays its members out in declaration order at fixed, implementation-chosen offsets, with padding inserted to satisfy each member's alignment. `p->x` asks the compiler for 'the int at offset of x from the address p holds' — the offset is a compile-time constant, so the arrow is a single base-plus-offset load or store. `p->member` and `(*p).member` are identical by definition; the arrow exists purely because the dereference-then-select pattern is ubiquitous.
Why pointers and not values: passing a struct by value copies every byte onto the stack, and the callee's modifications land on the copy. Passing `struct uart *u` passes 4 bytes (illustrative 32-bit model) and gives the callee a direct handle on the caller's object — cheap, and mutation works naturally. This is why the standard library and every driver API look like `int uart_init(struct uart *u, unsigned baud);`: an object plus functions that take a pointer to it is C's object-oriented core.
Member offsets also explain two practical rules. First, member order affects size: reordering `char a; int b; char c;` wastes padding bytes between members. Second, `offsetof(struct T, m)` (from <stddef.h>) gives the byte offset portably, which underpins tricks like container_of used throughout kernel code — recover the enclosing struct from a pointer to one of its members. Never compute offsets by hand; alignment and padding are implementation-defined.
Vocabulary
- ->
- Member access through a pointer; p->m ≡ (*p).m.
- member offset
- Fixed byte distance of a member from the struct's base address.
- padding
- Bytes the compiler inserts between members to satisfy alignment.
- offsetof
- <stddef.h> macro yielding a member's byte offset portably.
- pass-by-pointer API
- Functions taking struct T * to avoid copies and enable mutation.
- self pointer
- The leading struct parameter playing the role of C++'s this.
Arrow access on a small struct
- `struct point { int x; int y; };` — x at offset 0, y at offset 4, sizeof = 8 bytes (illustrative 32-bit model).
- `struct point pt = {3, 4}; struct point *p = &pt;` — pt at 0x1000 holds x=3 at 0x1000 and y=4 at 0x1004; p at 0x1008 stores 0x00001000.
- `p->y` compiles to: load p's value (0x00001000), add offset 4, access the int at 0x1004 → reads 4.
- `p->x = 9;` stores 9 at 0x1000; the caller's object pt is modified directly — no copy was ever made.
- `(*p).y = 8;` produces the identical access as `p->y = 8;` — the arrow was only ever shorthand.
The arrow is base-plus-constant-offset addressing; one 4-byte pointer gives a callee full read-write access to the caller's struct.
Common mistakes
- DO NOT CONFUSE `p->m` with pointer arithmetic — the member offset is added in bytes at compile time by the compiler; you never write `p + 4` to reach a member.
- DO NOT CONFUSE struct layout with member declaration order being free — reordering members changes padding and therefore sizeof(struct).
- DO NOT CONFUSE `p->m` on an uninitialized or dangling p with a null-deref crash guarantee — it is UB like any other invalid dereference; the offset math just starts from a garbage base.
NEXT CONNECTION: structs bundle data; C can also bundle behaviour — next, pointers to functions and the callback pattern.
Function pointers and callbacks
`int (*fp)(int)` declares fp as a pointer to a function taking int and returning int — the parentheses around *fp are mandatory. Store functions in tables to dispatch by index or event, pass them as arguments for callbacks (qsort's comparator is the canonical example), and typedef the signature to stay sane. The comparator trap: `return a - b;` can overflow; compare with relational operators instead.
Declarator anatomy, read right-to-left again: `int (*fp)(int)` — fp is a pointer to a function (int) returning int. Without the parentheses, `int *fp(int)` declares a function returning int* — a completely different thing. Calling is `fp(5)` or equivalently `(*fp)(5)`; assignment is `fp = abs;` (a function name decays to its address, like an array decays to a pointer to its first element). A typedef collapses the syntax: `typedef int (*binop)(int, int);` then `binop op = add;`.
The two workhorse patterns. Handler tables: an array of function pointers indexed by event or opcode — `handler table[N]; table[ev](ctx);` replaces a switch and lets registration happen at runtime, which is how interrupt vector tables, protocol parsers, and menu systems are built. Callbacks: a library function takes a function pointer and calls it back at the right moment — qsort calls your comparator for each pair, so one generic sort works for any type. The void* parameters in qsort's comparator exist because the library cannot know your element type; you cast them back inside the comparator.
The comparator trap is interview canon. `int cmp(const void *pa, const void *pb) { return *(const int*)pa - *(const int*)pb; }` looks harmless, but the subtraction can overflow int (INT_MAX - (-1) is undefined), and overflow can produce a wrong-sign result — sorting garbage. The correct form never subtracts: `int x = *(const int*)pa, y = *(const int*)pb; return (x > y) - (x < y);`. Note also the standard only requires a negative/zero/positive result — returning the difference works only when you can prove no overflow, which in general you cannot.
Vocabulary
- int (*fp)(int)
- Pointer to a function taking int, returning int; parentheses around *fp are mandatory.
- callback
- A function pointer passed to library code that calls it back later.
- handler table
- Array of function pointers used for indexed/event dispatch.
- comparator
- Callback returning negative/zero/positive for less/equal/greater.
- function decay
- A function name in an expression converts to a pointer to that function.
- subtraction-overflow trap
- return a-b in a comparator can overflow int and yield the wrong sign.
A two-entry handler table
- `int inc(int v) { return v + 1; }` and `int dbl(int v) { return 2 * v; }` — code addresses illustratively 0x0800 and 0x0840.
- `typedef int (*op)(int);` then `op table[2] = { inc, dbl };` — table at 0x1000 stores 0x00000800, table[1] at 0x1004 stores 0x00000840 (illustrative 32-bit model, 4-byte function pointers).
- `int (*fp)(int) = table[1];` — fp holds 0x00000840; `fp(21)` jumps to 0x0840 and returns 42.
- qsort-style: `cmp(pa, pb)` with ints 5 and -2: the WRONG version returns 5 - (-2) = 7; fine here, but 2147483647 - (-1) overflows — UB, wrong sign possible.
- The RIGHT version: `(x > y) - (x < y)` evaluates 1 - 0 = 1 for x=5, y=-2 — positive, correct, and overflow-proof for every int pair.
Function pointers make code selectable at runtime; comparators must compute the sign relationally because the subtraction idiom can overflow.
Common mistakes
- DO NOT CONFUSE `int (*fp)(int)` with `int *fp(int)` — the first is a pointer to a function; the second declares a function returning int*.
- DO NOT CONFUSE `return a - b;` with a safe comparator — int subtraction can overflow and flip the sign; use (a > b) - (a < b).
- DO NOT CONFUSE the comparator's contract with returning ±1 — qsort accepts any negative/zero/positive; the sign is all that matters.
- DO NOT CONFUSE a function pointer with a closure — C function pointers carry no captured state; pass context through a separate void* argument.
NEXT CONNECTION: the most common thing passed through all these pointers is text — next, NUL-terminated strings and the two very different meanings of char *.
Strings and char pointers
A C string is a char array terminated by a NUL byte ('\0'); every string function scans for that terminator. `char *s = "hi";` makes s point to a string literal — read-only storage, modifying it is undefined behaviour. `char s[] = "hi";` creates a mutable char array initialized with a copy of the literal's characters. Same bytes on screen, completely different objects and rights.
The string literal "hi" is an anonymous static array of {'h','i','\0'} with static storage duration. `char *s = "hi";` stores its address in s — s itself is a normal mutable pointer (you may do s = other), but the array it points to must not be written: `s[0] = 'H';` is undefined behaviour, and many platforms put literals in read-only memory so it faults. The correct declaration is `const char *s = "hi";`, which lets the compiler enforce what the standard only warns about.
`char s[] = "hi";` is a different construct entirely: it declares a genuine array of three chars in the current scope and initializes it by copying the literal's contents. s[0] = 'H' is perfectly legal; sizeof(s) is 3, not the pointer size. The initializer is syntactic sugar available only at declaration — later you cannot write `s = "hi";` because arrays are not assignable; you copy with strcpy or memcpy. Note again: arrays decay to a pointer to their first element in most expressions, but s is an array, not a pointer.
Every string library function is a pointer walk to the NUL: strlen counts until '\0' (the terminator is not counted, but storage must include it), strcpy copies including the terminator, strcmp compares byte by byte. Two consequences matter daily. Buffer sizing must include the terminator: "hi" needs 3 bytes, so `char buf[2]; strcpy(buf, "hi");` overflows by one. And functions like strncpy that may omit the terminator leave you with a non-string; terminating manually is on you.
Vocabulary
- NUL terminator
- The '\0' byte that marks the end of a C string; counted in storage, not in strlen.
- string literal
- Anonymous static read-only char array created by "..."; modifying it is UB.
- char *s = "hi"
- Pointer to a literal: retargetable pointer, unmodifiable pointee.
- char s[] = "hi"
- Mutable array initialized with a copy of the literal's chars.
- decay
- An array expression converts to a pointer to its first element in most contexts.
- buffer sizing
- A string of n visible chars needs n+1 bytes including the terminator.
Two declarations, two fates
- String literal "hi" lives in static storage at 0x2000: bytes 0x68 0x69 0x00.
- `char *p = "hi";` — p at 0x1000 (illustrative 32-bit model) stores 0x00002000. sizeof(p) is 4, the pointer size.
- `p[0] = 'H';` attempts a store to 0x2000 — undefined behaviour; on a typical target the literal sits in read-only memory and the store faults.
- `char a[] = "hi";` — array a at 0x1004 holds a COPY: a[0]='h' at 0x1004, a[1]='i' at 0x1005, a[2]='\0' at 0x1006. sizeof(a) is 3.
- `a[0] = 'H';` writes 0x48 to 0x1004 — legal; `strlen(a)` walks from 0x1004 to the NUL at 0x1006 and returns 2.
The pointer form borrows read-only static storage; the array form owns a mutable copy — choose based on whether you ever write the characters.
Common mistakes
- DO NOT CONFUSE `char *s = "hi"` with owning the characters — s points to read-only static storage; only `char s[]` gives you a writable copy.
- DO NOT CONFUSE sizeof on the two forms — sizeof(char-array) counts the characters including NUL; sizeof(char*) is the pointer size (4 on the illustrative 32-bit model).
- DO NOT CONFUSE strlen with the buffer size — strlen excludes the terminator; the buffer must include it.
- DO NOT CONFUSE arrays with pointers — an array decays to a pointer to its first element in most expressions, but it is still an array: not assignable, and sizeof sees the whole object.
NEXT CONNECTION: the last chapter — what the compiler is allowed to assume about all these pointers: aliasing, alignment, and the true meaning of undefined behaviour.
Aliasing, alignment, and undefined behaviour
Strict aliasing lets the compiler assume pointers of incompatible types do not point at the same object, so it can reorder and cache aggressively; violating it (e.g. reading a float through an int*) is UB. restrict is your promise that a pointer is the only access path to its object. Misaligned access — dereferencing an int* whose address is not a multiple of 4 — is UB too. UB means the compiler may assume it never happens and optimize accordingly; 'it worked on my machine' is not a defence.
Every object has an effective type — the type it was last written as — and accessing it through an lvalue of an incompatible type violates strict aliasing. The classic violation is type-punning: `float f = 1.0f; int bits = *(int*)&f;`. The compiler may assume the int* write or read cannot affect f, reorder the accesses, and produce nonsense. The legal routes are memcpy (which the compiler compiles to a plain move) and character types — `char *` / `unsigned char *` may alias anything, which is why byte-level code is safe.
restrict is the reverse direction: a promise from you to the compiler. `void copy(int *restrict dst, const int *restrict src, size_t n)` declares that within the call, dst and src are the sole access paths to their respective objects — they do not overlap. With that promise the compiler can vectorize and reorder freely, which is why memcpy is declared restrict and why overlapping memcpy is UB (use memmove). Lie in a restrict declaration and the miscompilation is your fault, silently.
Alignment is the third assumption. On our illustrative 32-bit model an int must sit at an address that is a multiple of 4; a pointer cast that fabricates a misaligned int* — e.g. `(int*)(buf + 1)` — is UB the moment you dereference it, whatever the hardware tolerates. Undefined behaviour ties it all together: the standard imposes no requirements, so optimizers treat UB code paths as unreachable and delete, reorder, or 'prove' things around them. The practical rules: never type-pun through casts (use memcpy), only declare restrict when you can prove non-overlap, take addresses only from properly typed objects, and compile with warnings and sanitizers because the compiler will not always tell you.
Vocabulary
- strict aliasing
- The compiler's licence to assume incompatible pointer types never designate the same object.
- effective type
- The type an object was last written as; governs which accesses are legal.
- type-punning
- Reinterpreting an object through an incompatible pointer type — UB outside the legal exceptions.
- restrict
- Promise that a pointer is the only access path to its object in that scope.
- misaligned access
- Dereferencing a pointer whose address violates its type's alignment; UB.
- memcpy exception
- Copying bytes with memcpy is the portable, legal way to reinterpret an object's representation.
Three UB traps in one buffer
- `unsigned char buf[8];` at 0x1000 (illustrative 32-bit model) holds raw bytes from a network packet.
- Trap 1: `int n = *(int*)(buf + 1);` — the int* points at 0x1001, not a multiple of 4: misaligned dereference, UB. Legal form: `memcpy(&n, buf + 1, 4);`.
- Trap 2: `float f = 1.0f; int b = *(int*)&f;` — reading a float through an int lvalue breaks strict aliasing; the compiler may cache f and ignore the read. Legal form: `memcpy(&b, &f, 4);`.
- Trap 3: `void fill(int *restrict a, int *restrict b)` called with a == b — the restrict promise is broken; the optimizer's reordering may store results in the wrong order.
- Each snippet can print the 'right' answer at -O0 and a wrong one at -O2 — the optimizer exploited assumptions the source violated.
Aliasing, restrict, and alignment are contracts; breaking them hands the optimizer licence to produce any behaviour, including 'works until the flags change'.
Common mistakes
- DO NOT CONFUSE 'the CPU tolerates unaligned loads' with defined behaviour — C makes misaligned dereference UB regardless of hardware forgiveness.
- DO NOT CONFUSE memcpy with an expensive workaround — compilers inline small memcpys into single moves; it is both the legal and the fast route for type-punning.
- DO NOT CONFUSE restrict with a check the compiler performs — it is an unverified promise; overlapping restrict pointers are UB you invited.
- DO NOT CONFUSE undefined behaviour with 'implementation-defined' — implementation-defined is documented and stable; UB releases the compiler from all obligations.
NEXT CONNECTION: this closes the chain — return to the laboratory to re-run every trace, then take the mastery exam sections that diagnose each chapter by id.
Pointers, Aliasing, and Storage Duration
A pointer in C is not an address; it is a typed reference to an object, and the difference decides what the compiler may assume. The standard permits a pointer to point at an object, one past the end of an array, or nowhere - and nothing else. Arithmetic that leaves that set is undefined even if the resulting address is perfectly valid on the hardware. On top of that sits the strict aliasing rule, which lets the compiler assume that two pointers of incompatible types never refer to the same storage, and the object lifetime rules, which say when the storage is valid at all. Firmware runs into all three constantly, because it casts buffers to structures, shares memory between an ISR and a main loop, and hands raw addresses to DMA engines. Getting them wrong produces code that works until the optimiser gets better.
How it is built
- Pointer arithmetic is defined only within a single array object, plus the one-past-the-end position. Comparing or subtracting pointers into different objects has no meaning, and forming a pointer more than one past the end is undefined even without dereferencing it. The hardware would happily compute the address; the language declines to define what it means.
- Strict aliasing says an object may only be accessed through an lvalue of a compatible type, with a character type as the universal exception. Casting a uint8_t buffer to a struct pointer and reading through it violates this, and the compiler may keep a stale cached value because it assumed no such write could have occurred. memcpy into a properly typed object is the portable way to reinterpret bytes.
- restrict is a promise from the programmer, not a check. It tells the compiler that for the lifetime of the pointer, the object it points at is accessed only through it. That licence enables real optimisation in copy and filter loops, and breaking the promise is undefined behaviour with no diagnostic.
- Storage duration comes in four kinds. Automatic ends at block exit, and a pointer to it is dangling immediately after. Static lasts the whole program. Allocated lasts until freed. Thread storage is per thread. Returning a pointer to an automatic object is the most common lifetime bug, and it usually appears to work because the stack slot has not been reused yet.
- Alignment is a property of the type, and accessing an object through a misaligned pointer is undefined. Some cores fault; some silently rotate the value; Cortex-M0 traps on an unaligned word access. Casting an arbitrary byte offset in a receive buffer to a uint32_t pointer is the standard way to meet this.
- const on a pointer has two positions with different meanings: a pointer to const promises not to modify the pointee through this pointer, while a const pointer promises not to repoint it. Neither makes the underlying object immutable, and casting const away and then writing is undefined if the object was genuinely const.
Design procedure
- Work through the pointer laboratory at /foundations/pointers first if the declarations themselves are the difficulty - decay, function pointers, double pointers and the four const positions are all covered there in depth. This topic assumes you can read a declaration and asks what the compiler is entitled to assume about it.
- Keep pointer arithmetic inside one array object, and carry an explicit length alongside every pointer that crosses a function boundary.
- Reinterpret bytes with memcpy into a correctly typed object rather than a pointer cast. Compilers recognise the pattern and generate the same load when it is safe.
- Use restrict only where you can state the non-overlap guarantee out loud, and document it in the contract above the prototype.
- Never return a pointer to an automatic object. If a caller needs storage, have the caller supply it - that is also what makes the function testable.
- Check alignment explicitly before any cast to a wider type, or avoid the cast entirely by assembling the value from bytes.
- Write const on the pointee wherever the function does not modify it. It documents the contract and lets the compiler diagnose accidental writes.
Key terms
- Object
- A region of storage with a type and a lifetime. A pointer refers to one of these, not to an address.
- One past the end
- The only out-of-array pointer value C defines. It may be formed and compared, never dereferenced.
- Strict aliasing
- The assumption that pointers of incompatible types do not refer to the same storage. Character types are exempt.
- restrict
- A programmer's promise of non-overlap. Unchecked, and undefined behaviour when broken.
- Storage duration
- Automatic, static, allocated, or thread. Decides when the object exists.
- Dangling pointer
- A pointer to storage whose lifetime has ended. Usually appears to work.
- Alignment
- The address granularity a type requires. Violating it is undefined and faults outright on some cores.
Worked example
#include <stdint.h>
#include <string.h>
/* Strict aliasing violation - may read a stale value: */
uint32_t bad(uint8_t *buf) {
return *(uint32_t *)buf; /* wrong type AND maybe unaligned */
}
/* Portable, and compiles to the same load when it is safe: */
uint32_t good(const uint8_t *buf) {
uint32_t v;
memcpy(&v, buf, sizeof v);
return v;
}
/* Dangling: the array's lifetime ends at the closing brace. */
const char *name(void) {
char buf[16];
return buf; /* valid-looking, undefined */
}
# The four storage durations, and when the object exists:
#
# automatic block entry -> block exit (stack)
# static program start -> program end (.data / .bss)
# allocated malloc -> free (heap, if you have one)
# thread thread start -> thread end
# const, in its two positions:
#
# const uint8_t *p; /* cannot write *p - can repoint p */
# uint8_t *const p; /* can write *p - cannot repoint p */
# const uint8_t *const p; /* neither */Common pitfalls
More in Foundations
- Computer Systems 0 → 100A beginner-first path from what a computer is through bits, CPUs, addresses, memory hierarchy, SRAM, DRAM, ROM, flash, SSDs, buses, PCIe, SATA, AHCI, NVMe, M.2, boot and performance—with comparisons and interview practice.
- Cache Coherency 0 → 100A first-principles course from cache lines and the coherence problem through MSI, MESI, MOESI, snooping, directories, memory ordering, atomics, false sharing, DMA, NUMA, measurement and verification—with a live protocol engine and code lab.
- From Power-On to main()What runs before main(): the Cortex-M reset sequence that gives C the machine it assumes, the linker script that decides where every section lives and why .data has two addresses, the four build stages and which one your error came from, and the order to work through a debug probe that will not connect.
- Number RepresentationBases and why hex is the one you read, two's complement and the asymmetry that makes abs(INT_MIN) undefined, the bit-manipulation idioms with the edge case in each, and byte order - which matters in exactly three places and in none of the arithmetic.
- FoundationsComputer systems from first principles—memory, storage and interconnects through cache coherence, ordering, atomics and DMA—then the firmware foundations of numbers, linking, interrupts and pointers.
References and further reading
- ISO C standard (C17/C23)
- K&R The C Programming Language
- C FAQ (comp.lang.c)
- CERT C Coding Standard
- cppreference (C and C++)
- POSIX / system API documentation
- MCU datasheet (MMIO chapters)
- Arm architecture resources