Pointers, References, Arrays and Data Layout

A pointer is a variable whose value is an address.

Learning Objectives

Use & and *, declare and initialise pointers, and handle null pointers.

Apply pointer arithmetic within its limits and relate arrays to pointers.

Read pointer-to-pointer, const pointer variants, void *, function pointers, pointer-to-array and array-of-pointers declarations.

Predict structure, union and bit-field layout, padding and alignment; use flexible array members.

Explain effective type, strict aliasing and pointer provenance at a practical level.

Compare C pointers with C++ and managed references, value categories, move semantics, spans/slices and Rust borrows.

Write memory-mapped register pointers and apply a pointer-safety checklist.

Plain-Language Explanation

A pointer is a variable whose value is an address. Three different things are involved, and keeping them apart is the whole skill:

the pointer variable's own storage (it lives somewhere and has its own address), the address it holds (its value), the object at that address (what you reach by dereferencing it).

pointer lives in its own 8 bytes; it holds the address of value; *pointer *is* value.

int value = 25;
int *pointer = &value;
*pointer = 99;      /* value is now 99 */

Precise Technical Explanation

Address-of (&) and dereference (*) &x yields a pointer to x. *p designates the object p points to — usable on either side of =. p->m is shorthand for (*p).m.

Pointer declarations and initialization int *p; declares a pointer to int. The * binds to the declarator, so int *p, q; makes p a pointer and q a plain int. Initialise pointers at definition — with a valid address or NULL. An uninitialised pointer holds garbage, and dereferencing it is undefined behavior.

Null pointers and NULL A null pointer compares unequal to every pointer to an object. NULL (C), nullptr (C++11, C23) and the literal 0 in pointer context produce one. Dereferencing a null pointer is undefined behavior; on hosted systems page 0 is unmapped, so it usually crashes immediately — but compilers may optimise based on "this pointer was dereferenced, so it is not null".

Pointer arithmetic and its limits p + n advances by n *elements* (n * sizeof *p bytes). Subtracting two pointers into the same array gives the element distance as ptrdiff_t. Arithmetic is defined only within one array object and one-past-its-end; forming any other pointer — even without dereferencing it — is undefined behavior.

Arrays and array-to-pointer conversion In most expressions an array decays to a pointer to its first element: a becomes &a[0], and a[i] is defined as *(a + i). Exceptions: sizeof a, &a, _Alignof and string-literal initialisers.

Array bounds and length tracking The decayed pointer does not carry the length. Pass (pointer, count) pairs, use a struct holding both, or use language features that keep them together (C++ std::span, Rust slices).

Pointers versus arrays int pp points to a pointer. Used for arrays of strings (char argv), to let a function change the caller's pointer (int grow(int **values,...) in Module 6), and for 2-D arrays of row pointers.

const with pointers Read declarations right to left:

The pointed-to int const int *p (= int const *p) read-only through p int *const p const int *const p void * and generic object pointers

void * can hold the address of any object and converts implicitly to and from other object pointers in C (C++ requires a cast). It cannot be dereferenced or used in arithmetic (GCC allows arithmetic as an extension). Library functions such as malloc, memcpy and qsort use it for genericity.

Function pointers and callbacks int (*cmp)(const void *, const void *) is a pointer to a function. Function pointers enable callbacks (qsort), dispatch tables, plugin interfaces and interrupt vector tables. A typedef makes them readable: typedef int compare_fn(const void *, const void *);. Converting between function pointers and void * is not portable ISO C (POSIX requires it to work for dlsym).

Pointer to array and array of pointers int (*pa)[4] is *one pointer to an array of 4 ints* (row pointer into a 2-D array; pa + 1 advances 16 bytes). int *ap[4] is *an array of 4 pointers*. The parentheses decide.

Structures and unions A struct places members one after another in declaration order (with padding). A union overlaps all members at offset 0; its size is that of the largest member (plus padding). Reading a union member other than the last one written reinterprets the bytes — allowed in C, undefined in C++ (use memcpy or std::bit_cast).

Structure padding and alignment Each member is placed at an offset that is a multiple of its alignment; the struct's alignment is its largest member's; its size is rounded up to that alignment. Ordering members from largest to smallest alignment minimises padding.

Bit-fields and portability caveats unsigned mode: 3; packs a field into 3 bits. The allocation order of bit-fields within a unit, whether they straddle units, and the signedness of plain int bit-fields are implementation-defined — so bit-fields must not be used to describe wire formats or hardware registers in portable code. Use shifts and masks instead.

Flexible array members The last member of a struct may be an array of unspecified size: struct msg { size_t len; unsigned char data[]; };. Allocate sizeof(struct msg) + n bytes to get a header and n bytes of payload in one block.

Object representation, effective type and strict aliasing Every object has an effective type — its declared type, or for allocated memory the type last stored into it. C allows accessing an object only through its effective type, a compatible or signed/unsigned variant, a qualified version, an aggregate containing it, or a character type. Violating this strict-aliasing rule is undefined behavior, and optimizers rely on it: they assume an int * and a float * never point to the same bytes. To reinterpret bytes, use memcpy (or std::bit_cast in C++20) — compilers turn it into a plain register move.

References in C++ and managed languages A C++ reference (int &r = x;) is an alias: it must be bound at initialisation, cannot be reseated, and is never null in a valid program. In Java, C#, Python and JavaScript, a "reference" is a managed pointer to a heap object: it can be null (null/None/undefined), can be reassigned, cannot be used for arithmetic, and keeps its object alive.

Lvalues, rvalues and value categories (C++) An lvalue designates an object with identity (x, *p, a[i]); a prvalue is a pure value (42, x + 1, a function returning by value); an xvalue is an expiring object (std::move(x)). T& binds to lvalues, const T& to anything, T&& to rvalues.

Move semantics and move constructors (C++) A move constructor T(T&& other) steals other's resources (pointers to heap buffers) instead of copying them, leaving other valid but unspecified (usually empty). std::move does not move anything; it is a cast to T&& that permits a move.

Slices, spans and bounds-safe abstractions std::span<T> (C++20), Rust's &[T], Go slices, C#'s Span<T> and Java arrays pair a pointer with a length. Bounds-checked access (.at(), Rust indexing, C#/Java indexing) turns an overflow into an exception or panic instead of silent corruption.

Ownership, borrowing and lifetimes (Rust overview) A Rust reference &T (shared) or &mut T (exclusive) is a pointer the compiler proves never outlives its owner and never coexists with a mutable alias. Lifetimes ('a) are the compiler's names for those proofs.

Pointer provenance and why arbitrary address manipulation is risky

A pointer is not just a number: compilers track which object it was derived from (its provenance). Two pointers with the same numeric address but different provenance — say, one-past-the-end of a and the start of a neighbouring b — are not interchangeable, and compilers may optimise accordingly. Casting integers to pointers, or hiding pointers in integers, is implementation-defined at best; current standards work (C TS 6010) formalises provenance.

Memory-mapped register pointers in embedded C The integer-to-pointer cast is implementation-defined and fine for a specific MCU; volatile forces each access to happen. Real addresses come from the vendor's reference manual or device header (CMSIS).

Pointer safety checklist See the Best Practices Checklist below.

#include <stdint.h>
#define GPIO_OUT (*(volatile uint32_t *)0x40020014u)   /* illustrative address only */
GPIO_OUT |= (1u << 5);                                 /* read-modify-write of a device register */

Why It Matters in Real Systems

Every non-trivial C/C++ data structure, every OS interface and every driver is built from pointers. Struct layout decides binary compatibility of files, network packets and shared-library ABIs. Aliasing and provenance rules explain "impossible" optimisation results.

Key Terms

Access the object a pointer points to Implicit conversion of an array to a pointer to its first element

Adding/subtracting element counts to/from a pointer Generic object pointer Pointer holding a function's address Bytes inserted for alignment Flexible array member

Trailing array of unspecified size in a struct Effective type / strict aliasing

The type an object may be accessed through The object a pointer was derived from lvalue / prvalue / xvalue classification in C++ Pointer + length view

Memory Layout and Execution Flow

address         contents          meaning
value      0x7ffd...a4c    63 00 00 00       int 99 (little-endian)
pointer    0x7ffd...a50    4c ..a.. 7f 00 00  holds &value
pp         0x7ffd...a58    50 ..a.. 7f 00 00  holds &pointer

Syntax

int x = 1, arr[4] = {1, 2, 3, 4};
int *p = &x;              /* pointer to int */
int **pp = &p;            /* pointer to pointer to int */
const int *pc = &x;       /* pointer to const int */
int *const cp = &x;       /* const pointer to int */
int (*row)[4] = &arr;     /* pointer to array of 4 ints */
int *ptrs[4];             /* array of 4 pointers */
void *any = &x;           /* generic object pointer */
int (*fn)(int) = abs;     /* function pointer */
struct S { char c; double d; };   /* 7 bytes of padding after c, typically */

Complete Runnable Example

The three things a pointer involves, pointer arithmetic and decay. GCC 11.2, x86-64 Linux.

#include <stddef.h>
#include <stdio.h>

static void show_array(const int *first, size_t count)      /* decayed pointer + explicit length */
{
    for (size_t i = 0; i < count; ++i) {
        printf("  first + %zu -> %p holds %d\n", i, (const void *)(first + i), *(first + i));
    }
}

int main(void)
{
    int value = 25;
    int *pointer = &value;
    int **pp = &pointer;

    *pointer = 99;
    printf("value = %d\n", value);
    printf("&value   = %p (the object)\n", (void *)&value);
    printf("pointer  = %p (the address it holds)\n", (void *)pointer);
    printf("&pointer = %p (the pointer's own storage)\n", (void *)&pointer);
    printf("**pp     = %d\n", **pp);
    printf("sizeof value=%zu sizeof pointer=%zu sizeof pp=%zu\n", sizeof value, sizeof pointer, sizeof pp);

    int arr[4] = {10, 20, 30, 40};
    int *p = arr;                         /* decay: &arr[0] */
    int (*whole)[4] = &arr;               /* pointer to the entire array */
    printf("sizeof arr=%zu sizeof p=%zu\n", sizeof arr, sizeof p);
    printf("p + 1 advances %td bytes; whole + 1 advances %td bytes\n",
           (char *)(p + 1) - (char *)p, (char *)(whole + 1) - (char *)whole);
    printf("arr[2]=%d *(p 
…

Line-by-Line Explanation

*pointer = 99 writes through the pointer, changing value.

&value and pointer print the same address; &pointer is a different address — the pointer's own storage, a few bytes away in the same stack frame.

sizeof arr is 16 (the whole array) but sizeof p is 8 (just a pointer).

p + 1 moves sizeof(int) = 4 bytes; whole + 1 moves sizeof(int[4]) = 16 bytes.

2[arr] is legal because a[i] means *(a + i) and addition commutes — a curiosity, never write it.

show_array receives a decayed pointer and therefore needs the count as a separate argument.

Expected Output

Output (one real run — addresses vary):

value = 99
&value   = 0x7ffd1a3fc66c (the object)
pointer  = 0x7ffd1a3fc66c (the address it holds)
&pointer = 0x7ffd1a3fc670 (the pointer's own storage)
**pp     = 99
sizeof value=4 sizeof pointer=8 sizeof pp=8
sizeof arr=16 sizeof p=8
p + 1 advances 4 bytes; whole + 1 advances 16 bytes
arr[2]=30 *(p + 2)=30 2[arr]=30
&arr[3] - &arr[0] = 3 elements
  first + 0 -> 0x7ffd1a3fc690 holds 10
  first + 1 -> 0x7ffd1a3fc694 holds 20
  first + 2 -> 0x7ffd1a3fc698 holds 30
  first + 3 -> 0x7ffd1a3fc69c holds 40

Step-by-Step Execution Trace

value = 25 at some stack address A; pointer (at B) = A; pp (at C) = B.

*pointer = 99 stores 99 at A.

**pp reads B → A → 99.

p = address of arr[0]; p + 2 = that address + 8 → 30.

show_array walks first + i for i = 0…3, each 4 bytes apart.

Interactive Visualization

The memory map explorer shows where each pointer and its target live; the byte viewer shows the raw little-endian bytes of an address stored in a pointer.

memory-map

Common Mistakes and Failure Modes

Dereferencing uninitialised, null or dangling pointers.

sizeof on a parameter declared as an array — it is a pointer. GCC warns:

Writing past an array: pointer arithmetic beyond one-past-the-end.

Type-punning through incompatible pointers (strict aliasing). With optimization, the result can change:

The same source prints different results at different optimization levels — the signature of undefined behavior.

Mismatched const: casting away const from an object *defined* const and writing to it.

Using bit-fields or #pragma pack structs to match a wire format across compilers.

#include <stdio.h>

void print_size(int numbers[10])          /* really: int *numbers */
{
    printf("inside the function: sizeof numbers = %zu\n", sizeof numbers);
}

int main(void)
{
    int numbers[10] = {0};
    printf("in main: sizeof numbers = %zu\n", sizeof numbers);
    print_size(numbers);
    return 0;
}
compiler output:
m07_decay.c: In function ‘print_size’:
m07_decay.c:5:66: warning: ‘sizeof’ on array function parameter ‘numbers’ will return size of ‘int *’ [-Wsizeof-array-argument]
    5 |     printf("inside the function: sizeof numbers = %zu\n", sizeof numbers);
      |                                                                  ^~~~~~~
m07_decay.c:3:21: note: declared here
    3 | void print_size(int numbers[10])          /* really: int *numbers */
      |                 ~~~~^~~~~~~~~~~

program output:
in main: sizeof numbers = 40
inside the function: sizeof numbers = 8
/* m07_alias.c — UB on purpose, harmless: returns a wrong value, nothing else */
#include <stdio.h>

int store_and_read(int *i, float *f)
{
    *i = 1;
    *f = 0.0f;        /* compiler assumes this cannot modify *i ... */
    return *i;        /* ... so at -O2 it may return the constant 1 */
}

int main(void)
{
    int x = 0;
    printf("result: %d\n", store_and_read(&x, (float *)&x));   /* same address: aliasing violation */
    return 0;
}
gcc -std=c17 -O0 m07_alias.c -o alias_O0 && echo "-O0: $(./alias_O0)"
gcc -std=c17 -O2 m07_alias.c -o alias_O2 && echo "-O2: $(./alias_O2)"
-O0: result: 0
-O2: result: 1

Corrected / Safe Pattern

Struct layout, padding, unions, bit-fields and flexible array members, measured:

Padded holds 11 bytes of data in 24 bytes — 13 bytes of padding; reordering the same members brings it down to 16. The union shows little-endian byte order. The bit-fields landed in the low bits on GCC/x86-64 (ready = bit 0, mode = bits 1–3: 1 + 5×2 = 0xb) — another compiler or ABI may differ.

Function pointers as callbacks and dispatch tables:

C++ references, value categories, move semantics and std::span (G++ 11.2, C++20):

#include <string.h>
float f = 3.5f;
uint32_t bits;
memcpy(&bits, &f, sizeof bits);   /* well-defined reinterpretation */

uint32_t reg = read_reg();
unsigned mode = (reg >> 4) & 0x7u; /* explicit shift and mask instead of a bit-field */
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct Padded  { char tag; double value; short id; };   /* poor member order */
struct Ordered { double value; short id; char tag; };   /* largest alignment first */
union Word     { uint32_t u32; uint8_t bytes[4]; };
struct Flags   { unsigned ready : 1; unsigned mode : 3; unsigned error : 1; };
struct Message { uint16_t length; uint8_t payload[]; }; /* flexible array member */

int main(void)
{
    printf("Padded : size %zu, align %zu, offsets tag=%zu value=%zu id=%zu\n",
           sizeof(struct Padded), _Alignof(struct Padded),
           offsetof(struct Padded, tag), offsetof(struct Padded, value), offsetof(struct Padded, id));
    printf("Ordered: size %zu, align %zu, offsets value=%zu id=%zu tag=%zu\n",
           sizeof(struct Ordered), _Alignof(struct Ordered),
           offsetof(struct Ordered, value), offsetof(struct Ordered, id), offsetof(struct Ordered, tag));

    union Word w = {.u32 = 0xA1B2C3D4u};
    printf("union bytes: %02x %02x %02x %02x (size %zu)\n", w.bytes[0], w.bytes[1], w.bytes[2], w.bytes[3], sizeof w);

    struct Flags f;
    memset(&f, 0, sizeof f);    
…
Padded : size 24, align 8, offsets tag=0 value=8 id=16
Ordered: size 16, align 8, offsets value=0 id=8 tag=10
union bytes: d4 c3 b2 a1 (size 4)
bit-fields: size 4, raw bits 0xb (layout is implementation-defined)
message: header 2 bytes + payload 6 bytes = "hello"
#include <stdio.h>
#include <stdlib.h>

typedef int binary_op(int, int);

static int add(int a, int b) { return a + b; }
static int mul(int a, int b) { return a * b; }

static int by_value_desc(const void *a, const void *b)
{
    int x = *(const int *)a, y = *(const int *)b;
    return (x < y) - (x > y);          /* avoids the overflow of y - x */
}

int main(void)
{
    binary_op *table[] = {add, mul};    /* dispatch table */
    const char *names[] = {"add", "mul"};
    for (size_t i = 0; i < 2; ++i) {
        printf("%s(6, 7) = %d\n", names[i], table[i](6, 7));
    }

    int readings[] = {42, 7, 19, 88, 3};
    size_t n = sizeof readings / sizeof readings[0];
    qsort(readings, n, sizeof readings[0], by_value_desc);   /* callback */
    printf("sorted descending:");
    for (size_t i = 0; i < n; ++i) {
        printf(" %d", readings[i]);
    }
    printf("\n");
    return 0;
}
add(6, 7) = 13
mul(6, 7) = 42
sorted descending: 88 42 19 7 3
#include <iostream>
#include <span>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

struct Buffer {
    std::vector<int> data;
    explicit Buffer(std::size_t n) : data(n, 7) { std::cout << "  construct " << n << " ints\n"; }
    Buffer(const Buffer &o) : data(o.data) { std::cout << "  COPY (allocates and copies " << data.size() << " ints)\n"; }
    Buffer(Buffer &&o) noexcept : data(std::move(o.data)) { std::cout << "  MOVE (steals the heap buffer)\n"; }
};

int sum(std::span<const int> values)            // pointer + length, any contiguous source
{
    int total = 0;
    for (int v : values) total += v;
    return total;
}

int main()
{
    int x = 1;
    int &alias = x;                              // reference: another name for x
    alias = 5;
    std::cout << "x after writing through the reference: " << x << '\n';

    Buffer a(1000);
    Buffer b = a;                                // a is an lvalue: copy
    Buffer c = std::move(a);                     // xvalue: move
    std::cout << "  a.data.size() after the move: " << a.data.size() << '\n';

    std::vector<int> v{1, 2, 3, 4};
    int raw[] = {10, 20, 30};
    std::cout << "sum(vector)=" << 
…
x after writing through the reference: 5
  construct 1000 ints
  COPY (allocates and copies 1000 ints)
  MOVE (steals the heap buffer)
  a.data.size() after the move: 0
sum(vector)=10 sum(array)=60 sum(first two)=3
v.at(10) threw std::out_of_range instead of reading past the end

Debugging and Diagnostic Techniques

Print pointers with %p and sizes with %zu; offsetof and _Static_assert(sizeof(struct X) == 16, "...") lock layouts down.

pahole (dwarves) prints structure layouts with holes; -Wpadded warns about padding.

-fsanitize=address catches out-of-bounds pointer use; -fsanitize=undefined catches misaligned access and some invalid pointer arithmetic; -Wcast-align, -Wcast-qual and -fstrict-aliasing -Wstrict-aliasing catch risky casts.

A debugger can display *p@10 (gdb) to show 10 elements behind a pointer.

Performance Implications

Pointer chasing (linked lists, trees of individually allocated nodes) defeats caches and prefetchers; arrays and spans keep data contiguous. Strict aliasing and restrict let compilers keep values in registers and vectorise loops. Moves avoid copying heap buffers. Good member ordering shrinks structures and fits more in each cache line.

Security and Safety Implications

Out-of-bounds pointer arithmetic and type confusion (accessing an object as the wrong type) are core vulnerability classes. Prefer spans/slices with bounds checks, const-correct interfaces, and avoid casts. Treat any integer-to-pointer conversion as a red flag outside device drivers.

Platform and Language Caveats

Pointer size: 8 bytes on 64-bit platforms, 4 on 32-bit, and 2 or 4 on small MCUs; some architectures (CHERI) have 128-bit capability pointers.

Struct layout follows the platform ABI; MSVC and GCC agree on simple cases but differ for bit-fields and some packing rules.

Union type punning is defined in C (the bytes are reinterpreted) but undefined in C++.

Java and C# have no pointer arithmetic in safe code; C# allows it in unsafe blocks. Python and JavaScript have no pointers at all; ctypes/ArrayBuffer give byte-level access.

Real-World Application

Linked data structures, intrusive lists in the Linux kernel (container_of uses offsetof), callback-driven APIs (signal handlers, GUI events, qsort), plugin systems via function-pointer tables, zero-copy views with spans, and hardware access with volatile register pointers.

Best Practices Checklist

[ ] Initialise every pointer; set it to NULL when its target is freed.

[ ] Pass lengths with pointers, or use span/slice types.

[ ] Use const for pointers you only read through.

[ ] Never do arithmetic outside an array (plus one-past-the-end).

[ ] Reinterpret bytes with memcpy/std::bit_cast, never with incompatible pointer casts.

[ ] Do not use bit-fields or packed structs for wire formats; serialise explicitly.

[ ] Order struct members by decreasing alignment; lock layouts with static asserts.

[ ] Keep raw integer-to-pointer casts to hardware-access headers.

Exercises

E1 · Beginner. Given int a[5]; int *p = &a[1];, what are p[2], *(p - 1) and p + 4?

Answer: p[2] is a[3]; *(p - 1) is a[0]; p + 4 is one-past-the-end of a — valid to form and compare, not to dereference.

E2 · Intermediate. Compute sizeof and the offsets of struct { char a; int b; char c; short d; } on x86-64, then reorder it to minimise size.

Answer: a@0, b@4, c@8, d@10, size 12. Reordered { int b; short d; char a; char c; }: b@0, d@4, a@6, c@7, size 8.

E3 · Advanced challenge. Write container_of(ptr, type, member) using offsetof, and explain why it is defined behavior only when ptr really points into a type object.

Answer: #define container_of(ptr, type, member) ((type *)((char *)(ptr) - offsetof(type, member))). Subtracting the member's offset from a pointer to the member yields the enclosing object's address; the arithmetic stays inside that object's bytes (via char *), so it is valid only if the member really belongs to a type object — otherwise it computes an address outside any object.

Quiz

? Given int arr[4]; int *p = arr; on x86-64, what are sizeof arr and sizeof p?
- 4 and 4 :: sizeof arr counts all four ints.
+ 16 and 8 :: The array is 4 x 4 bytes; a pointer is 8 bytes.
- 16 and 16 :: p is a pointer, not an array.
- 8 and 8 :: arr does not decay inside sizeof.

? const int *p means...
+ The int cannot be modified through p, but p can point elsewhere. :: Read right to left: pointer to const int.
- p cannot be reassigned. :: That is int *const p.
- Neither p nor the int can change. :: That is const int *const p.
- The int is stored in read-only memory. :: const restricts this access path only.

? Which are valid ways to view a float's bits in C? (select all that apply)
+ memcpy into a uint32_t :: Copying bytes is always well-defined.
+ Reading the bytes through unsigned char * :: Character types may access any object.
- *(uint32_t *)&f :: That violates strict aliasing.
+ A union with a float and a uint32_t member :: C defines this as reinterpreting the bytes (C++ does not).

? Why did store_and_read print different values at -O0 and -O2?
- The compiler has a bug. :: The program has undefined behavior; the compiler is correct.
+ The program violated strict aliasing, 
…

Summary

A pointer has its own storage, holds an address, and designates an object. Arrays decay to pointers that forget their length. Pointer arithmetic is valid only inside one array. Layout follows alignment rules; padding, unions, bit-fields and flexible array members have specific, partly implementation-defined behavior. Strict aliasing and provenance constrain what pointer tricks are legal. C++ references, moves and spans, and managed/Rust references, are safer abstractions over the same idea.

References and Further Reading

K&R, *The C Programming Language*, chapter 5 "Pointers and Arrays".

C17 §6.5 ¶7 (strict aliasing), §6.5.6 (additive operators), §6.7.2.1 (structure and union specifiers).

cppreference.com: "Value categories", "Move constructors", "std::span".

Ralf Jung, "Pointers Are Complicated" (blog series on provenance).