Skip to main content

Pointers Deep Dive

Pointers are C’s superpower. They enable direct memory manipulation, efficient data structures, and system-level programming. While they have a reputation for being difficult, they are just variables that store addresses.

Pointers in C

At its core, a pointer is just a number—the address of a byte in memory. C pointers and memory model

Why Does C Have Pointers?

The Fundamental Problem

Before learning pointer syntax, understand why they exist: The problem: How do you write efficient, low-level code? In high-level languages, you work with “values”:
  • Pass a 1MB struct to a function? Copy all 1MB
  • Return a large array? Copy it back
  • Modify a parameter? Can’t affect the original
The solution: Pointers give you direct memory addresses:
  • Pass a pointer (8 bytes) instead of copying 1MB
  • Return a pointer instead of copying data
  • Modify data through a pointer to affect the original
Pointer vs Value Memory Layout Real-world impact: Without pointers, C couldn’t:
  • Implement efficient data structures (linked lists, trees)
  • Interface with hardware (memory-mapped I/O)
  • Implement system calls (kernel needs to modify user memory)
  • Build operating systems and drivers
The tradeoff: Power and efficiency vs safety. Pointers give you direct memory access, but with that comes responsibility.

Pointer Fundamentals

Memory Model

#include <stdio.h>

int main(void) {
    int x = 42;
    int *p = &x;  // p stores the ADDRESS of x

    printf("Value of x:          %d\n", x);       // 42
    printf("Address of x:        %p\n", (void*)&x); // 0x7fff...
    printf("Value of p:          %p\n", (void*)p);  // 0x7fff... (same)
    printf("Value at address p:  %d\n", *p);      // 42 (dereference)
    printf("Address of p:        %p\n", (void*)&p); // Different address

    *p = 100;  // Modify x through p
    printf("New value of x:      %d\n", x);       // 100

    return 0;
}
Memory Layout:
┌─────────────────────────────────────────────────────────────┐
│ Address        │ Name  │ Value                              │
├─────────────────────────────────────────────────────────────┤
│ 0x7fff5000     │ x     │ 42                                 │
│ 0x7fff5008     │ p     │ 0x7fff5000 (points to x)          │
└─────────────────────────────────────────────────────────────┘
Pointer vs Value Memory Layout

Pointer Types

int x = 42;
int *p = &x;        // p is a pointer to int

char c = 'A';
char *pc = &c;      // pc is a pointer to char

double d = 3.14;
double *pd = &d;    // pd is a pointer to double

// Type matters for:
// 1. Dereferencing (how many bytes to read/write)
// 2. Pointer arithmetic (how much to increment)

printf("sizeof(int*):    %zu\n", sizeof(int*));    // 8 (on 64-bit)
printf("sizeof(char*):   %zu\n", sizeof(char*));   // 8
printf("sizeof(double*): %zu\n", sizeof(double*)); // 8
// All pointers are same size, but types differ

Pointer Arithmetic

Pointer arithmetic is one of the most confusing yet powerful features of C. It allows you to navigate memory based on the type of data you are pointing to. Pointer Arithmetic
#include <stdio.h>

int main(void) {
    int arr[] = {10, 20, 30, 40, 50};
    int *p = arr;  // Points to first element

    printf("p:       %p, *p = %d\n", (void*)p, *p);       // 10
    printf("p + 1:   %p, *(p+1) = %d\n", (void*)(p+1), *(p+1)); // 20
    printf("p + 2:   %p, *(p+2) = %d\n", (void*)(p+2), *(p+2)); // 30
    ptrdiff_t diff = end - p;  // 4 (number of elements, not bytes)

    // Increment/decrement
    p++;  // Now points to arr[1]
    p--;  // Back to arr[0]

    return 0;
}

What’s Allowed

// Legal pointer operations:
p + n      // Add integer to pointer
p - n      // Subtract integer from pointer
p1 - p2    // Subtract two pointers (same array only!)
p1 == p2   // Compare pointers
p1 < p2    // Relational comparison (same array only!)

// Illegal:
p1 + p2    // Can't add two pointers
p * 2      // Can't multiply pointer
p / 2      // Can't divide pointer

Pointer Arithmetic Visualized

Pointer Arithmetic Key Insight: Pointer arithmetic is scaled by the size of the pointed-to type. When you write p + 1, the compiler doesn’t add 1 byte to the address. Instead, it adds sizeof(*p) bytes. This is why pointer arithmetic “just works” with arrays:
int arr[] = {10, 20, 30, 40};
int *p = arr;

// Memory layout (assuming 4-byte ints):
// Address:  0x1000   0x1004   0x1008   0x100C
// Value:    10       20       30       40
//           ↑
//           p

p + 0;  // 0x1000 (points to arr[0])
p + 1;  // 0x1004 (points to arr[1]) - advanced by 4 bytes!
p + 2;  // 0x1008 (points to arr[2]) - advanced by 8 bytes!
Why This Matters:
  • Enables efficient array traversal
  • Allows treating arrays as pointers
  • Foundation for dynamic data structures
  • Critical for understanding sizeof and alignment
Common Mistake:
int *p = (int*)0x1000;
p + 1;  // 0x1004, NOT 0x1001!

Arrays and Pointers

Array Decay

In C, arrays and pointers are closely related but NOT identical. The most important concept to grasp is “array decay”. Array Decay Visualization
#include <stdio.h>

void print_array(int *arr, size_t size) {
    // arr is a POINTER here, not an array!
    printf("sizeof(arr) in function: %zu\n", sizeof(arr)); // 8 (pointer size)
    
    for (size_t i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main(void) {
    int arr[] = {1, 2, 3, 4, 5};
    
    printf("sizeof(arr) in main: %zu\n", sizeof(arr)); // 20 (5 * 4 bytes)
    
    // When passed to function, array "decays" to pointer
    print_array(arr, 5);
    
    // arr and &arr[0] are the same address, but different types!
    int *p1 = arr;       // OK: int* from int[]
    int *p2 = &arr[0];   // OK: int* from int*
    
    // But:
    int (*p3)[5] = &arr; // &arr is pointer to array of 5 ints!
    
    printf("arr:       %p\n", (void*)arr);
    printf("&arr:      %p\n", (void*)&arr);
    printf("arr + 1:   %p\n", (void*)(arr + 1));   // +4 bytes (next int)
    printf("&arr + 1:  %p\n", (void*)(&arr + 1));  // +20 bytes (past entire array!)
    
    return 0;
}

Multi-dimensional Arrays

#include <stdio.h>

int main(void) {
    int matrix[3][4] = {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12}
    };

    // matrix decays to pointer to first row: int (*)[4]
    int (*row_ptr)[4] = matrix;
    
    // Accessing elements
    printf("%d\n", matrix[1][2]);        // 7
    printf("%d\n", *(*(matrix + 1) + 2)); // 7
    printf("%d\n", row_ptr[1][2]);       // 7

    // Memory is contiguous (row-major)
    int *flat = &matrix[0][0];
    for (int i = 0; i < 12; i++) {
        printf("%d ", flat[i]); // 1 2 3 4 5 6 7 8 9 10 11 12
    }
    
    return 0;
}

// Passing 2D arrays to functions
void process(int mat[][4], size_t rows) {
    // Must specify all dimensions except first
    for (size_t i = 0; i < rows; i++) {
        for (size_t j = 0; j < 4; j++) {
            printf("%d ", mat[i][j]);
        }
    }
}

// Or with pointer syntax
void process2(int (*mat)[4], size_t rows) {
    // Same as above
}

Pointers to Pointers

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    int x = 42;
    int *p = &x;
    int **pp = &p;   // Pointer to pointer
    int ***ppp = &pp; // And so on...

    printf("x:      %d\n", x);        // 42
    printf("*p:     %d\n", *p);       // 42
    printf("**pp:   %d\n", **pp);     // 42
    printf("***ppp: %d\n", ***ppp);   // 42

    **pp = 100;  // Modify x through pp
    printf("x:      %d\n", x);        // 100

    return 0;
}

// Real use case: modifying a pointer in a function
void allocate_buffer(char **buf, size_t size) {
    *buf = malloc(size);  // Modify the pointer itself
}
int main(void) {
    char *buffer = NULL;
    allocate_buffer(&buffer, 1024);  // Pass address of pointer
    
    if (buffer) {
        strcpy(buffer, "Hello");
        printf("%s\n", buffer);
        free(buffer);
    }
    
    return 0;
}

Array of Pointers vs Pointer to Array

#include <stdio.h>

int main(void) {
    // Array of pointers (common for strings)
    char *strings[] = {"Hello", "World", "!"};
    // strings[0] is char*, points to "Hello"
    // strings[1] is char*, points to "World"
    // strings[2] is char*, points to "!"
    
    for (int i = 0; i < 3; i++) {
        printf("%s\n", strings[i]);
    }

    // Pointer to array
    int arr[5] = {1, 2, 3, 4, 5};
    int (*ptr_to_arr)[5] = &arr;
    // ptr_to_arr points to entire array
    
    printf("%d\n", (*ptr_to_arr)[2]); // 3

    // Common confusion:
    int *ptr_arr[5];    // Array of 5 pointers to int
    int (*arr_ptr)[5];  // Pointer to array of 5 ints
    
    return 0;
}

Const Correctness

#include <stdio.h>

int main(void) {
    int x = 10;
    int y = 20;

    // 1. Pointer to const int (can't modify *p)
    const int *p1 = &x;
    // *p1 = 5;   // ERROR: can't modify value
    p1 = &y;     // OK: can change what p1 points to

    // 2. Const pointer to int (can't change pointer)
    int *const p2 = &x;
    *p2 = 5;     // OK: can modify value
    // p2 = &y;  // ERROR: can't change pointer

    // 3. Const pointer to const int (can't change either)
    const int *const p3 = &x;
    // *p3 = 5;  // ERROR
    // p3 = &y;  // ERROR

    // Read it right-to-left:
    // const int *p     → "p is a pointer to int that is const"
    // int *const p     → "p is a const pointer to int"
    // const int *const p → "p is a const pointer to int that is const"

    return 0;
}

// Best practice: use const for read-only parameters
size_t strlen_safe(const char *s) {
    // Promises not to modify the string
    size_t len = 0;
    while (*s++) len++;
    return len;
}

Void Pointers

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// Generic swap function
void swap(void *a, void *b, size_t size) {
    void *temp = malloc(size);
    memcpy(temp, a, size);
    memcpy(a, b, size);
    memcpy(b, temp, size);
    free(temp);
}

// Generic print (needs type info)
void print_value(const void *ptr, char type) {
    switch (type) {
        case 'i': printf("%d", *(const int*)ptr); break;
        case 'd': printf("%f", *(const double*)ptr); break;
        case 'c': printf("%c", *(const char*)ptr); break;
        case 's': printf("%s", (const char*)ptr); break;
    }
}

int main(void) {
    int a = 5, b = 10;
    printf("Before: a=%d, b=%d\n", a, b);
    swap(&a, &b, sizeof(int));
    printf("After:  a=%d, b=%d\n", a, b);

    double x = 1.5, y = 2.5;
    swap(&x, &y, sizeof(double));

    // void* rules:
    void *vp;
    int i = 42;
    vp = &i;         // Any pointer converts to void*
    int *ip = vp;    // void* converts to any pointer (in C)
    
    // Can't dereference void* directly
    // *vp = 5;      // ERROR
    *(int*)vp = 5;   // OK after cast

    // Can't do arithmetic on void*
    // vp++;         // ERROR (size unknown)
    
    return 0;
}

Generic Container with void*

typedef struct {
    void *data;
    size_t size;
    size_t capacity;
    size_t elem_size;
} Vector;

Vector* vector_create(size_t elem_size, size_t initial_capacity) {
    Vector *v = malloc(sizeof(Vector));
    v->data = malloc(elem_size * initial_capacity);
    v->size = 0;
    v->capacity = initial_capacity;
    v->elem_size = elem_size;
    return v;
}

void vector_push(Vector *v, const void *elem) {
    if (v->size == v->capacity) {
        v->capacity *= 2;
        v->data = realloc(v->data, v->elem_size * v->capacity);
    }
    memcpy((char*)v->data + v->size * v->elem_size, elem, v->elem_size);
    v->size++;
}

void* vector_get(Vector *v, size_t index) {
    return (char*)v->data + index * v->elem_size;
}

// Usage
int main(void) {
    Vector *v = vector_create(sizeof(int), 10);
    
    for (int i = 0; i < 20; i++) {
        vector_push(v, &i);
    }
    
    for (size_t i = 0; i < v->size; i++) {
        int *elem = vector_get(v, i);
        printf("%d ", *elem);
    }
    
    free(v->data);
    free(v);
    return 0;
}

The Restrict Keyword

// restrict tells compiler: this pointer is the ONLY way to access this memory
// Allows better optimization (no aliasing concerns)

void copy(int *restrict dest, const int *restrict src, size_t n) {
    // Compiler knows dest and src don't overlap
    for (size_t i = 0; i < n; i++) {
        dest[i] = src[i];
    }
}

// Without restrict, compiler must assume they might overlap
// and reload values from memory more often

// memcpy uses restrict (undefined if regions overlap)
void *memcpy(void *restrict dest, const void *restrict src, size_t n);

// memmove does NOT (handles overlap)
void *memmove(void *dest, const void *src, size_t n);

Function Pointers

#include <stdio.h>
#include <stdlib.h>

// Function pointer declaration
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

int main(void) {
    // Declare function pointer
    int (*op)(int, int);
    
    op = add;  // or: op = &add (same thing)
    printf("add: %d\n", op(5, 3));  // or (*op)(5, 3)
    
    op = sub;
    printf("sub: %d\n", op(5, 3));

    // Array of function pointers (jump table)
    int (*ops[])(int, int) = {add, sub, mul};
    
    for (int i = 0; i < 3; i++) {
        printf("ops[%d](10, 3) = %d\n", i, ops[i](10, 3));
    }

    // Typedef makes it cleaner
    typedef int (*BinaryOp)(int, int);
    BinaryOp my_op = mul;
    printf("mul: %d\n", my_op(5, 3));

    return 0;
}

// Callbacks
void process_array(int *arr, size_t n, int (*transform)(int)) {
    for (size_t i = 0; i < n; i++) {
        arr[i] = transform(arr[i]);
    }
}

int double_it(int x) { return x * 2; }
int square_it(int x) { return x * x; }

// qsort comparison function
int compare_ints(const void *a, const void *b) {
    return (*(const int*)a) - (*(const int*)b);
}

int main(void) {
    int arr[] = {3, 1, 4, 1, 5, 9};
    qsort(arr, 6, sizeof(int), compare_ints);
    return 0;
}

Common Pitfalls

Dangling Pointers

int *dangling(void) {
    int x = 42;
    return &x;  // DANGER! x is destroyed when function returns
}

char *also_dangling(void) {
    char buf[100];
    strcpy(buf, "Hello");
    return buf;  // DANGER! Local array destroyed
}

// Safe alternatives
int *safe_alloc(void) {
    int *p = malloc(sizeof(int));
    *p = 42;
    return p;  // Caller must free
}

// Or return by value for small structs
typedef struct { int x, y; } Point;
Point make_point(int x, int y) {
    return (Point){x, y};  // Safe, returns copy
}

Null Pointer Dereference

void process(int *p) {
    // Always check for NULL
    if (p == NULL) {
        return;  // or handle error
    }
    *p = 42;
}

// Or use assert for programming errors
#include <assert.h>
void process_required(int *p) {
    assert(p != NULL);  // Crash with message if NULL
    *p = 42;
}

Wild Pointers

int *p;       // Uninitialized! Contains garbage address
*p = 42;      // UNDEFINED BEHAVIOR - writing to random memory

// Always initialize
int *p = NULL;   // Safe, will crash predictably if dereferenced
int *p = malloc(...);  // Or allocate immediately

Exercises

1

Pointer Puzzle

Given int a = 5, *p = &a, **pp = &p;, trace what each of these expressions evaluates to: *p, **pp, *pp, &p, &a, p[0].
2

Custom memcpy

Implement your own void *my_memcpy(void *dest, const void *src, size_t n) using only pointer operations.
3

Reverse Array In-Place

Write void reverse(int *arr, size_t n) using only pointer arithmetic (no array indexing []).
4

Generic Sorting

Write a sorting function that accepts array, size, element size, and comparison function pointer, like qsort.
5

2D Dynamic Array

Implement a dynamically allocated 2D array with functions to create, access, and free it.

Next Up

Memory Layout & Segments

Understand how programs use memory