Pointers in C

Pointers are the single most important concept in C. They are how C talks to hardware, manages memory, and builds every non-trivial data structure. If you do not understand pointers, you cannot write a device driver, a kernel module, or any serious systems code.

The Address-Of Operator (&)

Every variable lives at a memory address. The & operator gives you that address.

/* addr.c */
#include <stdio.h>

int main(void)
{
    int x = 42;
    printf("value of x:   %d\n", x);
    printf("address of x: %p\n", (void *)&x);
    return 0;
}

Compile and run:

$ gcc -o addr addr.c && ./addr
value of x:   42
address of x: 0x7ffd3a2b1c4c

The exact address changes every run (ASLR). The point: &x yields a number that identifies where x sits in memory.

Declaring and Dereferencing Pointers

A pointer variable stores an address. The * in a declaration says "this variable holds an address." The * in an expression says "follow this address."

/* deref.c */
#include <stdio.h>

int main(void)
{
    int x = 10;
    int *p = &x;        /* p holds the address of x  */

    printf("x  = %d\n", x);
    printf("*p = %d\n", *p);   /* dereference: follow the address */

    *p = 99;             /* write through the pointer */
    printf("x  = %d\n", x);   /* x changed */

    return 0;
}

Output:

x  = 10
*p = 10
x  = 99

ASCII memory layout:

  Stack
  +--------+--------+
  |  name  | value  |
  +--------+--------+
  |   x    |   99   |  <-- address 0x1000 (example)
  +--------+--------+
  |   p    | 0x1000 |  <-- p stores address of x
  +--------+--------+

Driver Prep: In kernel code, hardware registers are accessed through pointers to specific physical addresses. volatile unsigned int *reg = (volatile unsigned int *)0x40021000; is real embedded C.

NULL Pointers

A pointer that points to nothing should be set to NULL. Dereferencing NULL is undefined behavior -- on most systems, a segfault.

/* null.c */
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *p = NULL;

    if (p == NULL) {
        printf("p is NULL, not dereferencing\n");
    }

    /* Uncomment the next line to crash: */
    /* printf("%d\n", *p); */

    return 0;
}

Caution: Dereferencing a NULL pointer is undefined behavior. The kernel uses NULL-pointer dereference as a common crash vector. Always check before you dereference.

Pointer Arithmetic

When you add 1 to a pointer, it advances by sizeof(*p) bytes, not one byte. This is how C walks through arrays.

/* arith.c */
#include <stdio.h>

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

    for (int i = 0; i < 5; i++) {
        printf("p + %d = %p, value = %d\n", i, (void *)(p + i), *(p + i));
    }

    return 0;
}
p + 0 = 0x7ffc..., value = 10
p + 1 = 0x7ffc..., value = 20
p + 2 = 0x7ffc..., value = 30
p + 3 = 0x7ffc..., value = 40
p + 4 = 0x7ffc..., value = 50

Each step moves 4 bytes (sizeof(int)), not 1.

  Memory (each cell = 4 bytes for int)
  +----+----+----+----+----+
  | 10 | 20 | 30 | 40 | 50 |
  +----+----+----+----+----+
  p+0  p+1  p+2  p+3  p+4

Try It: Change the array to char arr[] and print addresses. Notice that each step now moves 1 byte instead of 4. Pointer arithmetic is always in units of the pointed-to type.

Arrays Decay to Pointers

In most expressions, an array name becomes a pointer to its first element. This is called "decay."

/* decay.c */
#include <stdio.h>

void print_first(int *p)
{
    printf("first element via pointer: %d\n", *p);
}

int main(void)
{
    int arr[] = {100, 200, 300};

    print_first(arr);  /* arr decays to &arr[0] */

    /* These are equivalent: */
    printf("arr[1]   = %d\n", arr[1]);
    printf("*(arr+1) = %d\n", *(arr + 1));

    return 0;
}

The key exception: sizeof(arr) gives the full array size, not the pointer size. Once passed to a function, the size information is lost.

/* sizeof_decay.c */
#include <stdio.h>

void show_size(int *p)
{
    /* This prints the size of the pointer, not the array */
    printf("inside function: sizeof(p) = %zu\n", sizeof(p));
}

int main(void)
{
    int arr[5] = {0};
    printf("in main: sizeof(arr) = %zu\n", sizeof(arr)); /* 20 */
    show_size(arr);   /* 8 on 64-bit */
    return 0;
}

Caution: This is why C functions that take arrays always need a separate length parameter. Forgetting this is the root cause of most buffer overflows.

Pointers to Structs and the -> Operator

When you have a pointer to a struct, you access members with -> instead of .. It is equivalent to (*p).member but far more readable.

/* structptr.c */
#include <stdio.h>

struct point {
    int x;
    int y;
};

void move_right(struct point *p, int dx)
{
    p->x += dx;   /* same as (*p).x += dx */
}

int main(void)
{
    struct point pt = {3, 7};
    printf("before: (%d, %d)\n", pt.x, pt.y);

    move_right(&pt, 10);
    printf("after:  (%d, %d)\n", pt.x, pt.y);

    return 0;
}

Output:

before: (3, 7)
after:  (13, 7)

Driver Prep: Kernel data structures (file_operations, net_device, device_driver) are all accessed through struct pointers. You will write code like dev->irq and filp->private_data constantly.

Double Pointers (Pointer to Pointer)

A double pointer stores the address of another pointer. This is used when a function needs to change which address a pointer holds.

/* doubleptr.c */
#include <stdio.h>
#include <stdlib.h>

void allocate(int **pp, int value)
{
    *pp = malloc(sizeof(int));
    if (*pp == NULL) {
        perror("malloc");
        exit(1);
    }
    **pp = value;
}

int main(void)
{
    int *p = NULL;

    allocate(&p, 42);
    printf("*p = %d\n", *p);

    free(p);
    return 0;
}
  Stack                    Heap
  +------+---------+       +------+
  |  p   | 0x9000 -|------>|  42  |
  +------+---------+       +------+
                            0x9000

  Inside allocate():
  +------+---------+
  |  pp  | &p     -|----> p (on caller's stack)
  +------+---------+

Common uses of double pointers:

  • Functions that allocate memory and return it via a parameter
  • Arrays of strings (char **argv)
  • Linked list head modification

void * -- The Generic Pointer

void * can point to any type. You cannot dereference it directly; you must cast it first. This is C's mechanism for generic programming.

/* voidptr.c */
#include <stdio.h>

void print_bytes(const void *data, int len)
{
    const unsigned char *bytes = (const unsigned char *)data;
    for (int i = 0; i < len; i++) {
        printf("%02x ", bytes[i]);
    }
    printf("\n");
}

int main(void)
{
    int x = 0x12345678;
    float f = 3.14f;

    printf("int bytes:   ");
    print_bytes(&x, sizeof(x));

    printf("float bytes: ");
    print_bytes(&f, sizeof(f));

    return 0;
}

malloc returns void *. The kernel's kmalloc does the same. Every callback mechanism in C uses void * for user data.

Rust Note: Rust does not have void *. Generics and trait objects (dyn Trait) replace it with type safety. In unsafe Rust, *const u8 or *mut u8 serve a similar role.

Common Pointer Bugs

Dangling Pointer

A pointer to memory that has been freed or gone out of scope.

/* dangling.c -- DO NOT DO THIS */
#include <stdio.h>
#include <stdlib.h>

int *bad_function(void)
{
    int local = 42;
    return &local;  /* WARNING: returning address of local variable */
}

int main(void)
{
    int *p = bad_function();
    /* p is now dangling -- local no longer exists */
    printf("%d\n", *p);  /* undefined behavior */
    return 0;
}
$ gcc -Wall -o dangling dangling.c
dangling.c: warning: function returns address of local variable

Always heed compiler warnings. They catch this.

Wild Pointer

An uninitialized pointer contains garbage. Dereferencing it is undefined.

/* wild.c -- DO NOT DO THIS */
#include <stdio.h>

int main(void)
{
    int *p;          /* uninitialized -- points to random address */
    /* *p = 10; */   /* undefined behavior, likely segfault */
    printf("p = %p\n", (void *)p);  /* garbage address */
    return 0;
}

Always initialize pointers to NULL or a valid address.

Use-After-Free

/* use_after_free.c -- DO NOT DO THIS */
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *p = malloc(sizeof(int));
    *p = 42;
    free(p);
    /* p still holds the old address but the memory is freed */
    /* *p = 99; */  /* undefined behavior */

    /* Good practice: set to NULL after free */
    p = NULL;
    return 0;
}

Off-By-One

/* offbyone.c -- DO NOT DO THIS */
#include <stdio.h>

int main(void)
{
    int arr[5] = {1, 2, 3, 4, 5};
    /* Bug: accessing arr[5], which is one past the end */
    for (int i = 0; i <= 5; i++) {   /* should be i < 5 */
        printf("%d ", arr[i]);
    }
    printf("\n");
    return 0;
}

Caution: Off-by-one errors in pointer/array access are the most common source of buffer overflows and security vulnerabilities in C code. The Linux kernel has had hundreds of CVEs from this class of bug.

Pointers and const

The placement of const matters. Learn to read declarations right-to-left.

/* constptr.c */
#include <stdio.h>

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

    const int *p1 = &x;      /* pointer to const int: cannot change *p1 */
    /* *p1 = 99; */           /* ERROR */
    p1 = &y;                  /* OK: can change where p1 points */

    int *const p2 = &x;      /* const pointer to int: cannot change p2 */
    *p2 = 99;                 /* OK: can change the value */
    /* p2 = &y; */            /* ERROR */

    const int *const p3 = &x; /* const pointer to const int: nothing changes */
    /* *p3 = 99; */           /* ERROR */
    /* p3 = &y; */            /* ERROR */

    printf("x = %d\n", x);
    return 0;
}
  Read declarations 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 const int

Rust Note: Rust's &T is like const int * (read-only). Rust's &mut T is like int * (read-write). There is no equivalent of int *const because Rust bindings are immutable by default (let vs let mut).

Function Pointers

Functions have addresses too. A function pointer lets you call a function indirectly -- the basis of callbacks.

/* funcptr.c */
#include <stdio.h>

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

void apply(int (*op)(int, int), int x, int y)
{
    printf("result = %d\n", op(x, y));
}

int main(void)
{
    apply(add, 3, 4);   /* result = 7  */
    apply(mul, 3, 4);   /* result = 12 */

    /* Array of function pointers */
    int (*ops[])(int, int) = {add, mul};
    for (int i = 0; i < 2; i++) {
        printf("ops[%d](5, 6) = %d\n", i, ops[i](5, 6));
    }

    return 0;
}

Driver Prep: The Linux kernel's struct file_operations is a struct of function pointers. Every device driver fills one in. Understanding function pointers is non-negotiable for kernel work.

Putting It Together: A Tiny Stack

/* stack.c */
#include <stdio.h>
#include <stdlib.h>

#define STACK_MAX 16

struct stack {
    int data[STACK_MAX];
    int top;
};

void stack_init(struct stack *s)
{
    s->top = 0;
}

int stack_push(struct stack *s, int value)
{
    if (s->top >= STACK_MAX)
        return -1;   /* full */
    s->data[s->top++] = value;
    return 0;
}

int stack_pop(struct stack *s, int *out)
{
    if (s->top <= 0)
        return -1;   /* empty */
    *out = s->data[--s->top];
    return 0;
}

int main(void)
{
    struct stack s;
    stack_init(&s);

    stack_push(&s, 10);
    stack_push(&s, 20);
    stack_push(&s, 30);

    int val;
    while (stack_pop(&s, &val) == 0) {
        printf("popped: %d\n", val);
    }

    return 0;
}

Output:

popped: 30
popped: 20
popped: 10

Notice: every function takes struct stack *. The caller owns the struct; the functions borrow it via pointer. This is the C pattern that Rust formalizes with borrowing.

Try It: Add a stack_peek function that returns the top value without removing it. Use a pointer parameter for the output, just like stack_pop.

Knowledge Check

  1. What does *(arr + 3) mean if arr is an int array?
  2. Why must you pass array length separately in C?
  3. What is the difference between const int *p and int *const p?

Common Pitfalls

  • Forgetting to check for NULL after malloc -- crashes in production.
  • Returning a pointer to a local variable -- instant dangling pointer.
  • Confusing *p++ precedence -- it increments p, not *p. Use (*p)++.
  • Casting away const -- the compiler lets you, the program breaks at runtime.
  • Not setting freed pointers to NULL -- use-after-free becomes silent corruption.
  • Sizeof on a decayed pointer -- gives pointer size, not array size.
  • Pointer arithmetic on void* -- not standard C (GCC allows it as extension, treating it as char*).