R0KernelPath
module 012 / 262

Pointers: The Heart of C

Pointers give C programs direct access to memory addresses — mastering them is mastering the language the kernel is written in.

~12h focused study10 concepts

Overview

Every program you have ever written—whether you knew it or not—lives in memory. Variables are not abstract containers floating in the ether; they are concrete bytes sitting at specific numeric addresses, and C is one of the few languages that lets you reach in and touch those addresses directly. That ability is called a pointer, and it is the single most powerful—and most misunderstood—feature of the C language.

Most languages hide memory addresses from you on purpose. Python, Java, and JavaScript give you "references" that feel like names for objects, while the runtime quietly shuffles the actual bytes around. C does the opposite: it hands you the raw address and says "here, do something useful with it." This is not a quirk or a historical accident. The Linux kernel, every major operating system, every embedded firmware, every high-performance database, and almost every programming language runtime is written in a language where you can hold a raw address in your hand. That language is C, and that mechanism is pointers.

This module is about building a correct, deep mental model of pointers—not just the syntax, but the physical reality underneath. We will start from the absolute basics (what is an address?) and build up through pointer types, null, double pointers, and const-correctness all the way to the patterns you will see daily in Linux kernel source code. By the end, dereferencing a void ** or reading a const char * const * will feel as natural as reading an integer variable.

One honest warning: pointers are the part of C that humbles every beginner and still surprises every expert. The bugs they enable (buffer overflows, use-after-free, null dereferences) power the majority of real-world security vulnerabilities. Understanding pointers deeply is therefore not just academic—it is the difference between writing systems code and writing exploitable systems code.

why this matters for the kernel

The Linux kernel is essentially a masterclass in pointer manipulation at scale. Every process descriptor is a struct task_struct *, every file is a struct file *, every page of memory is tracked through a struct page *. Linked lists, hash tables, trees, function-pointer dispatch tables (virtual function equivalents), and the infamous container_of macro—all of it rests on a single foundation: the ability to hold an address, pass it around, and dereference it. You cannot read a single file of kernel source without understanding what -> means, what **argv is, or why const placement matters for API contracts.

Beyond the kernel, pointers are the mechanism by which all of systems programming achieves its performance and power. Stack and heap allocation, zero-copy networking buffers, shared memory between processes, device-mapped I/O registers that are not even RAM—every one of these techniques requires you to reason about addresses explicitly. Learning pointers is not learning one feature of C; it is learning to think like the machine thinks, which is the foundational skill of every kernel developer, firmware engineer, and systems programmer who has ever existed.

interactive visualization
0x1000xint420x1008yint70x1010ppointer0x10000x1018pppointer0x1010variablestored bitsaddress
&p — its address
0x1010
where this variable lives
*p — dereference
42
follow 1 arrow to x
chain
p → x
0x1000 → 42

Click a variable. A pointer just stores another box's address; each * follows one arrow. **pp walks two hops to reach x = 42.

Pointer Indirection: From Variable to Double Pointer

rendering diagram…

Three levels of indirection: x holds a value, p holds the address of x, pp holds the address of p. Each dereference (*) moves one level closer to the actual integer. This is the physical reality in RAM — each box is a distinct region of memory holding a numeric value.

The concepts

01What a Pointer Actually Is: Memory as a Byte Array

Before touching pointer syntax, you need a physical picture. Imagine your computer's RAM as an enormously long street of identical houses, numbered starting from 0. Each house holds exactly one byte (8 bits). The house number is the address. A variable like int x = 42; is not magic; it is a reservation of (on a 64-bit system) 4 consecutive houses—say, houses 0x7fff5000 through 0x7fff5003—whose combined bits encode the integer 42.

A pointer is just a variable that holds a house number. Instead of storing 42, it stores an address like 0x7fff5000. That is the whole idea. A pointer is an integer whose value is interpreted as a memory address.

Two corollaries follow immediately:

  1. Because a pointer is itself a variable, it also lives at some address in memory. A pointer has its own address.
  2. The size of a pointer is determined by how many bits you need to represent any address on your machine. On a 64-bit Linux system, every pointer is 8 bytes, regardless of what it points to.

This physical picture—memory as a flat numbered array of bytes—is not just an analogy. It is the actual model that the C abstract machine exposes, and the actual model your hardware implements (modulo virtual memory, which we will cover later in KernelPath).

#include <stdio.h>

int main(void) {
    int x = 42;
    printf("Value of x:   %d\n", x);
    printf("Address of x: %p\n", (void *)&x);
    printf("Size of int:  %zu bytes\n", sizeof(int));
    printf("Size of ptr:  %zu bytes\n", sizeof(&x));
    return 0;
}

02Address-Of (&) and Dereference (*): The Two Fundamental Operations

C gives you exactly two operators to work with addresses:

Address-of (&): Applied to a variable, it returns the address where that variable lives in memory. If x is an int, then &x is the address (a number) of x. The type of &x is int * — "pointer to int".

Dereference (*): Applied to a pointer, it goes to the address stored in that pointer and reads or writes the bytes there. If p is an int * holding address 0x7fff5000, then *p reads the 4 bytes starting at 0x7fff5000 and interprets them as an int.

Think of & as "give me the street address of this house" and * as "go to this street address and open the door."

They are inverse operations: if p = &x, then *p and x refer to exactly the same bytes. Assigning *p = 99 changes x. This is the mechanism by which functions in C can modify their caller's variables—you pass the address, the function dereferences it.

A critical point about syntax: the * in a declaration (int *p;) is not the dereference operator. It is a type annotation saying "p is a pointer to int." The * in an expression (*p = 5;) is the dereference operator. They look identical but mean different things. Many bugs come from confusing these two contexts.

#include <stdio.h>

void double_it(int *n) {   /* n holds the address of caller's variable */
    *n = *n * 2;           /* dereference: read, compute, write back    */
}

int main(void) {
    int x = 7;
    printf("Before: %d\n", x);
    double_it(&x);          /* pass the address of x */
    printf("After:  %d\n", x);  /* prints 14 */
    return 0;
}

03Pointer Types: Why the Type of a Pointer Matters

A pointer value is just a number—an address. So why does C care whether it is an int * or a char *? Because the type tells the compiler three crucial things:

  1. How many bytes to read/write on dereference. A char * dereference reads 1 byte. An int * dereference reads 4 bytes. A double * dereference reads 8 bytes. The address might be the same; the interpretation differs entirely.

  2. How far to step on pointer arithmetic. When you write p + 1, C does not add 1 to the numeric address. It adds 1 * sizeof(*p). So if p is an int * at address 1000, p + 1 is address 1004, not 1001. This makes it natural to walk through arrays: p[i] is exactly *(p + i).

  3. Type safety at compile time. Assigning an int * to a double * without a cast is a warning (or error), protecting you from accidentally reinterpreting bytes.

void * is the typeless pointer. It holds any address but carries no size information, so you cannot dereference or do arithmetic on it directly. You must cast it to a concrete type first. void * is used in generic interfaces—malloc returns void *, memcpy takes void *—because they work on raw bytes regardless of type.

The relationship between arrays and pointers is deep: the name of an array in most contexts decays to a pointer to its first element. int arr[5] used in an expression becomes &arr[0], an int *. This is not a copy—it is the same address. Array subscript arr[i] is defined to mean *(arr + i), and pointer arithmetic takes care of the stride.

#include <stdio.h>

int main(void) {
    int arr[3] = {10, 20, 30};
    int *p = arr;          /* decays to &arr[0] */

    printf("%d\n", *p);       /* 10 */
    printf("%d\n", *(p + 1)); /* 20 — steps by sizeof(int) = 4 bytes */
    printf("%d\n", p[2]);     /* 30 — identical to *(p+2) */

    /* Pointer arithmetic: addresses */
    printf("p   = %p\n", (void *)p);
    printf("p+1 = %p\n", (void *)(p + 1)); /* 4 bytes later */

    /* void* cannot be dereferenced */
    void *vp = p;
    /* *vp = 5; */  /* compile error: cannot dereference void* */
    *(int *)vp = 5; /* cast first, then dereference */
    printf("%d\n", arr[0]); /* 5 */
    return 0;
}

04Null Pointers: The Intentional "Points to Nothing"

A pointer variable that has not been initialized contains garbage—some random bit pattern that happens to be in memory. If you dereference it, you read or write some random location. This is undefined behavior and can corrupt memory silently or crash with a segmentation fault later, far from the actual bug.

Null is the conventional sentinel value meaning "this pointer intentionally points to nothing." In C, the null pointer constant is 0 or the macro NULL (defined in <stddef.h> as (void *)0 or just 0). In C11 you can also use nullptr via <stddef.h> on some implementations, but NULL is universal.

A null pointer has address zero. On virtually every modern system, address 0 is not mapped to real RAM (the operating system deliberately leaves it unmapped), so any attempt to dereference a null pointer immediately triggers a hardware fault, which the OS turns into a SIGSEGV signal—a segfault. This is actually a safety feature: a null dereference crashes loudly at the point of the bug instead of silently corrupting memory.

The rule: always initialize pointers. If you don't have a valid address yet, set them to NULL. Before dereferencing any pointer that might be null, check it. This simple discipline eliminates an entire class of bugs.

In the Linux kernel you will see null checks everywhere: if (!ptr) return -EINVAL; is a canonical idiom. Functions that allocate or look up objects return NULL on failure, and every caller must check. The kernel's own ERR_PTR/IS_ERR mechanism extends this to encode error codes in the low bits of a pointer—only possible because null and valid kernel pointers live in well-defined address ranges.

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

int *safe_alloc(size_t n) {
    int *p = malloc(n * sizeof(int));
    if (p == NULL) {           /* malloc can fail; always check */
        return NULL;
    }
    return p;
}

int main(void) {
    int *arr = safe_alloc(10);
    if (arr == NULL) {
        fprintf(stderr, "Out of memory\n");
        return 1;
    }
    arr[0] = 42;
    printf("%d\n", arr[0]);
    free(arr);
    arr = NULL;   /* prevent use-after-free: null it after freeing */
    return 0;
}

05Pointers to Pointers (**): Indirection All the Way Down

Because a pointer is just a variable that lives at an address, nothing stops you from taking the address of a pointer. The result is a pointer to a pointer, written int **. This adds one more level of indirection.

The mental model: if int *p is "a variable that holds the address of an int," then int **pp is "a variable that holds the address of a variable that holds the address of an int." Each * in the type peels off one level of indirection.

When do you need this?

  1. Modifying a pointer inside a function. A function receives a copy of its arguments. If you pass p (an int *), the function can change *p but not p itself. If you want the function to change what p points to (e.g., redirect it to newly allocated memory), you must pass &p (an int **).

  2. Arrays of strings / argv. The classic int main(int argc, char **argv) signature: argv is a pointer to an array of pointers, each pointing to a null-terminated string. argv[0] is *(argv + 0), a char *—the first string. argv[0][2] is the third character of that string.

  3. Linked data structures and pointer surgery. Kernel linked list insertion, tree rebalancing, and hash table operations often need to update a pointer that is itself stored via a pointer—requiring **.

You can in principle have *** or deeper, but in practice anything beyond ** is a design smell. The kernel almost never goes beyond **.

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

/* Allocates a new int and makes *pp point to it */
void alloc_and_assign(int **pp, int value) {
    *pp = malloc(sizeof(int)); /* write through pp to change caller's pointer */
    if (*pp) **pp = value;     /* two dereferences: pp -> p -> int */
}

int main(int argc, char **argv) {
    /* argv demo */
    for (int i = 0; i < argc; i++)
        printf("argv[%d] = %s\n", i, argv[i]);

    /* double-pointer demo */
    int *p = NULL;
    alloc_and_assign(&p, 99);  /* pass address of p */
    if (p) printf("Allocated: %d\n", *p);
    free(p);
    p = NULL;
    return 0;
}

06const-Correctness with Pointers: Reading the Type Right-to-Left

The const keyword applied to pointers is a source of perpetual confusion because its position relative to * changes the meaning entirely. The trick is to read the type declaration right-to-left.

  • int *p → p is a pointer to int (nothing is const)
  • const int *p → p is a pointer to const int → the pointed-to value cannot be changed via p, but p itself can be reassigned
  • int * const p → p is a const pointer to int → p cannot be reassigned, but *p can be changed
  • const int * const p → p is a const pointer to const int → neither can change

Why does this matter?

API contracts. When a function takes const char *str, it is promising: "I will not modify the bytes you gave me." This lets callers pass string literals (which live in read-only memory), and it communicates intent clearly. The Linux kernel is full of such contracts: printk(const char *fmt, ...) cannot modify your format string.

Read-only hardware registers. Kernel device drivers often map hardware registers as const volatile * — const because you should not write them through this particular pointer, volatile because the hardware may change them underneath you.

const is not about physical immutability. const is a compile-time promise enforced by the type system. You can still cast it away with (int *), but doing so and then writing is undefined behavior if the underlying object was truly read-only (e.g., a string literal in .rodata).

The rule of thumb for library and kernel code: use the most restrictive const that is correct. This makes bugs visible at compile time rather than at runtime.

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

/* Promise: we won't modify the string */
size_t my_strlen(const char *s) {
    size_t n = 0;
    while (*s++) n++;   /* reading *s is fine; *s = 'x' would be a compile error */
    return n;
}

void demo_const(void) {
    const int x = 10;
    const int *p  = &x;   /* pointer to const int */
    int * const q = (int *)&x; /* const pointer — reassigning q is error */
    (void)q;

    /* *p = 20; */  /* compile error: cannot assign to const */
    p = NULL;       /* fine: p itself is not const */

    printf("%zu\n", my_strlen("hello")); /* 5 */
}

int main(void) { demo_const(); return 0; }

07Pointer Arithmetic and the Strict Aliasing Rule

Pointer arithmetic in C is defined only within a single array object (including one past the end). You may add or subtract integers from a pointer to move within that array; comparing two pointers is only defined if they point into the same array.

p[i] is defined as *(p + i). The compiler computes the byte offset as i * sizeof(*p), so subscripts and pointer arithmetic are two syntaxes for the same thing.

The strict aliasing rule is one of C's most dangerous footguns: the compiler is allowed to assume that pointers of different types do not alias (point to the same memory), unless one type is char. This allows aggressive optimizations—if float *f and int *i cannot alias, the compiler can reorder reads and writes freely. Violating strict aliasing (e.g., casting an int * to float * and dereferencing it) is undefined behavior, even if it works on your machine today. The kernel disables strict aliasing optimizations with -fno-strict-aliasing precisely because it does legitimate type-punning in hardware drivers and protocol stacks. For safe type-punning in standard C, use memcpy (the compiler optimizes it away) or a union.

One special case: char * and unsigned char * are allowed to alias anything. This is why memcpy, memset, and similar functions take void * and do their work byte-by-byte internally.

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

/* Safe type punning via memcpy (NOT via cast) */
float int_bits_as_float(unsigned int bits) {
    float f;
    memcpy(&f, &bits, sizeof f);  /* well-defined; compiler elides the copy */
    return f;
}

int main(void) {
    int arr[] = {1, 2, 3, 4, 5};
    int *p = arr;
    int *end = arr + 5;        /* one past end: valid for comparison */

    while (p < end)
        printf("%d ", *p++);
    printf("\n");

    /* Pointer difference gives element count, not bytes */
    printf("Length: %td\n", end - arr);  /* %td for ptrdiff_t */

    unsigned int raw = 0x3f800000u;  /* IEEE 754 representation of 1.0f */
    printf("%f\n", int_bits_as_float(raw));  /* 1.000000 */
    return 0;
}

08Function Pointers: Code Is Data Too

Functions, like variables, live at addresses in memory. The CPU executes a function by jumping to its first instruction's address. C lets you store that address in a function pointer and call the function through it.

Declaration syntax is notoriously ugly: int (*fp)(int, int) means "fp is a pointer to a function taking two ints and returning an int." The parentheses around *fp are mandatory; without them, int *fp(int, int) means "a function named fp that returns int *." Using a typedef tames the syntax: typedef int (*BinaryOp)(int, int);.

Function pointers are the C mechanism for polymorphism, callbacks, and dispatch tables—all patterns that appear everywhere in the kernel:

  • File operations: struct file_operations in <linux/fs.h> is a struct of function pointers (read, write, open, ioctl, …). Every filesystem and device driver fills in this table. When you call read() on a file, the kernel looks up file->f_op->read and calls through the pointer. This is runtime polymorphism without C++.
  • Interrupt handlers: request_irq(irq, handler, ...) takes a function pointer to the interrupt service routine.
  • Work queues and timers: callbacks passed as function pointers.

Function pointers make code both powerful and dangerous. If an attacker can overwrite a function pointer (e.g., via a buffer overflow), they redirect execution. This is the basis of return-oriented programming exploits—and why the kernel enables mitigations like KASLR, CFI (control-flow integrity), and __ro_after_init.

#include <stdio.h>

typedef int (*BinaryOp)(int, int);

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

void apply(BinaryOp op, int x, int y) {
    printf("%d\n", op(x, y));   /* call through function pointer */
}

/* Dispatch table: like kernel's file_operations */
struct Calculator {
    BinaryOp add;
    BinaryOp mul;
};

int main(void) {
    struct Calculator calc = { .add = add, .mul = mul };
    apply(calc.add, 3, 4);   /* 7  */
    apply(calc.mul, 3, 4);   /* 12 */

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

09Pointers to Structs and the Arrow Operator

The most common pointer pattern in real C—and in the kernel in particular—is a pointer to a struct. If p is a struct task_struct *, then (*p).pid accesses the pid field by dereferencing p first, then selecting the member. The parentheses are required because . binds tighter than *.

Because this is so common, C provides the arrow operator -> as syntactic sugar: p->pid means exactly (*p).pid. You will see -> far more often than (*p).field in practice.

The kernel's container_of macro is the masterpiece of pointer-to-struct reasoning. It solves the problem: "I have a pointer to a member field inside a struct; how do I get a pointer to the containing struct?" The macro uses offsetof (which gives the byte offset of a member within a struct) to subtract that offset from the member pointer, yielding the struct's base address. This is how the kernel's intrusive linked-list implementation works: list nodes are embedded inside data structures, and container_of recovers the enclosing structure from the list node pointer.

This is only possible because C pointers are raw numeric addresses. No higher-level language gives you this kind of backward navigation through a struct's memory layout.

#include <stdio.h>
#include <stddef.h>   /* offsetof */

struct Point {
    int x;
    int y;
};

/* Simplified container_of */
#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

struct Node {
    int value;
    struct Node *next;  /* intrusive link */
};

int main(void) {
    struct Point pt = {3, 4};
    struct Point *p = &pt;

    printf("x=%d y=%d\n", p->x, p->y);   /* arrow operator */
    printf("same: x=%d\n", (*p).x);       /* equivalent, uglier */

    /* container_of demo */
    struct Node node = { .value = 42, .next = NULL };
    int *vptr = &node.value;               /* pointer to the member */
    struct Node *recovered = container_of(vptr, struct Node, value);
    printf("Recovered value: %d\n", recovered->value);  /* 42 */
    return 0;
}

10Common Pointer Bugs and How to Recognize Them

Understanding what can go wrong is as important as understanding what is correct. Here are the four canonical pointer bugs:

1. Null dereference. Dereferencing a pointer that is NULL. On Linux, address 0 is unmapped; the hardware raises a page fault; the kernel sends SIGSEGV to your process. Segfault at address (nil) in a debugger is your clue. Fix: null-check before dereferencing.

2. Dangling pointer / use-after-free. A pointer that used to point to valid memory, but that memory has since been freed (or the stack frame has been popped). The memory might be reallocated to something else, so writes through a dangling pointer corrupt live data silently. This is the root cause of the majority of Linux kernel CVEs. Fix: null the pointer immediately after free(). Tools: AddressSanitizer (-fsanitize=address), Valgrind.

3. Buffer overflow. Writing through a pointer past the end of its valid region. Overwrites adjacent memory: other variables, the stack return address, function pointers. This is the classic "smashing the stack" attack. Fix: bounds-check every index and length. Tools: AddressSanitizer, _FORTIFY_SOURCE, compiler stack canaries (-fstack-protector-strong).

4. Uninitialized pointer. Using a pointer before it has been assigned a valid address or NULL. The pointer contains whatever garbage was in that stack slot. Reads return nonsense; writes corrupt random memory. Fix: always initialize. Compiler warning -Wuninitialized catches many cases.

The right tools are not optional luxury—they are part of the discipline. AddressSanitizer is built into GCC and Clang, costs nothing to enable during development, and catches all four categories at runtime. Using it on your programs before reading kernel code will build the instincts you need.

/* Compile with: gcc -fsanitize=address -g bug_demo.c */
#include <stdlib.h>

void demonstrate_bugs(void) {
    /* Bug 1: null dereference */
    int *p = NULL;
    /* *p = 5; */  /* SIGSEGV */

    /* Bug 2: use-after-free */
    int *q = malloc(sizeof(int));
    *q = 10;
    free(q);
    /* *q = 20; */  /* undefined behavior; ASAN catches this */
    q = NULL;       /* correct: null after free */

    /* Bug 3: buffer overflow */
    int arr[3];
    /* arr[5] = 99; */ /* writes past end; ASAN catches this */

    /* Bug 4: uninitialized pointer */
    int *r;           /* garbage value */
    /* *r = 7; */     /* undefined behavior */
    r = malloc(sizeof(int));  /* correct: initialize before use */
    *r = 7;
    free(r);
    r = NULL;
}

int main(void) { demonstrate_bugs(); return 0; }

Key insights

  • ✦A pointer is just an integer whose value is interpreted as a memory address. All pointer magic follows from this one fact.
  • ✦The type of a pointer (int *, char *, etc.) tells the compiler how many bytes to read/write on dereference and how many bytes to skip on arithmetic — the numeric address itself carries no type information at runtime.
  • ✦const placement relative to * has two independent axes: const on the left means the pointed-to data cannot be changed through this pointer; const on the right means the pointer itself cannot be reassigned. Read declarations right-to-left to decode them.
  • ✦A null pointer dereference crashing loudly is a feature, not a bug — it converts silent memory corruption into a detectable signal. Always initialize pointers to NULL if you don't have a valid address yet.
  • ✦arr[i] is defined as *(arr + i) in the C standard — array subscripting and pointer arithmetic are the same operation with different syntax.
  • ✦Function pointers make C polymorphism possible. The entire Linux kernel's driver model (struct file_operations, struct net_device_ops, etc.) is a table of function pointers — runtime dispatch without a C++ vtable.
  • ✦The container_of macro, which underpins the kernel's intrusive linked lists, is only possible because pointers are numeric addresses and offsetof gives you the byte layout of a struct at compile time.
  • ✦Use-after-free is the most dangerous pointer bug and the root cause of most kernel CVEs. Null the pointer immediately after every free() call, and use AddressSanitizer during all development.

Common pitfalls

  • ⚠Confusing the * in a declaration (int *p — type annotation) with the * in an expression (*p — dereference operator). They look identical but mean completely different things.
  • ⚠Forgetting that function arguments in C are passed by value. Passing a pointer gives the function access to the pointed-to object, but to redirect the pointer itself you must pass a pointer-to-pointer.
  • ⚠Reading const int *p as 'a const pointer to int' (wrong) instead of 'a pointer to const int' (correct). The rule is: read right-to-left and attach const to the token immediately to its left.
  • ⚠Freeing memory and continuing to use the pointer (use-after-free). The pointer still holds the old address and may appear to work until the freed memory is reallocated, producing a delayed, hard-to-debug corruption.
  • ⚠Assuming pointer arithmetic adds the integer directly to the address in bytes. p + 1 advances by sizeof(*p) bytes, not 1 byte. Mixing char * and int * arithmetic carelessly causes off-by-size errors.
  • ⚠Violating the strict aliasing rule by casting a pointer to an unrelated type and dereferencing it (e.g., reading an int through a float *). This is undefined behavior even if it works today; the compiler may optimize it away entirely. Use memcpy for type-punning.
  • ⚠Returning a pointer to a local variable from a function. The local variable is on the stack frame that is destroyed when the function returns. The pointer immediately becomes dangling.
  • ⚠Neglecting to null-check the return value of malloc, calloc, or kernel allocators (kmalloc). On failure these return NULL; dereferencing them causes a null dereference that can crash the kernel or your process.

Practice

  1. 01
    easy

    Write a function `swap(int *a, int *b)` that exchanges the values of two integers without using a temporary variable (use XOR swap or a regular temp — your choice). Call it from main, print before and after. Then extend it to `swap_strings(char **a, char **b)` that swaps two string pointers without copying the string contents.

  2. 02
    easy

    Implement your own versions of `strlen`, `strcpy`, and `strcat` using only pointer arithmetic — no array subscript notation allowed (no `s[i]`, only `*p` and `p++`). Verify they match the standard library versions on several test strings including empty strings and strings with embedded spaces.

  3. 03
    medium

    Build a simple singly-linked list of integers using a struct with an int value and a struct Node *next pointer. Implement: insert_front, insert_back, delete_value, and print_list. All insert/delete functions must use a pointer-to-pointer argument (Node **head) so they can update the head pointer. Test with at least 10 insertions and 3 deletions.

  4. 04
    medium

    Write a generic sort function with this signature: `void gsort(void *base, size_t n, size_t size, int (*cmp)(const void *, const void *))`. It should sort any array of any element type. Implement it using bubble sort for simplicity, swapping elements by copying bytes with memcpy. Test it on an array of ints, an array of doubles, and an array of structs sorted by one field. This is the same interface as the standard library's qsort.

  5. 05
    hard

    Implement a minimal 'polymorphic' shape library using function pointers. Define a struct Shape with function pointer fields for area, perimeter, and describe. Create two concrete 'types': Circle and Rectangle, each as a struct containing a Shape member as its first field plus their own fields. Fill in the function pointer table at construction time. Write a function `print_shape_info(const Shape *s)` that calls through the function pointers — it should work for both shapes without knowing their concrete type. This is how Linux's file_operations and other kernel vtables work.

Resources

✎Beej's Guide to C Programming
articlefree

The best free introductory C reference; chapters on pointers and arrays are clear, correct, and complete.

📕C Programming: A Modern Approach (2nd ed.)
bookpaidK. N. King, W. W. Norton — the most rigorous undergraduate C textbook available.

The definitive textbook treatment of pointers; chapter 11-12 cover everything in this module with proofs of the rules.

🔧Compiler Explorer (Godbolt) — see pointer operations as assembly
toolfree

Paste any C snippet and see exactly what machine code the compiler generates. Indispensable for understanding what pointer dereferences, function pointer calls, and const optimizations actually cost.

📄Linux kernel source: include/linux/list.h
docsfree

The canonical intrusive doubly-linked list using container_of; read this after understanding double pointers and struct pointer arithmetic.

📄C Standards committee Rationale (C99) — aliasing rules
docsfree

The authoritative explanation of why strict aliasing exists and what it permits; essential reading before writing any type-punning code.

📕Understanding the Linux Kernel (3rd ed.)
bookpaidBovet & Cesati, O'Reilly — Corbet/Rubini/Kroah-Hartman series.

Shows pointers-to-structs and function-pointer dispatch tables in real kernel context; best read after this module.

📜AddressSanitizer: A Fast Address Sanity Checker (USENIX ATC 2012)
paperfree

The original ASAN paper; explains how it detects use-after-free and buffer overflows — motivates using it on every pointer-heavy project.

📄cppreference.com — Pointer declaration
docsfree

Precise, standards-accurate reference for pointer syntax and rules; use it to resolve any ambiguity about const placement or pointer arithmetic.

Checkpoints — you can now…

  • ✓You can explain the difference between a variable's value and its address, and use & and * correctly in both declarations and expressions.
  • ✓You can predict the numeric result of pointer arithmetic (p + 3 on an int * vs a char *) without running the code.
  • ✓You can read any combination of const and * in a declaration correctly using the right-to-left rule, and write function signatures that correctly express const contracts.
  • ✓You can write a function that modifies a caller's pointer (not just the pointed-to value) using a pointer-to-pointer parameter.
  • ✓You can implement a singly-linked list with insert and delete using Node ** head, correctly handling the front-of-list case.
  • ✓You can define a struct with function pointer fields, fill them in at construction time, and call them through a base pointer — explaining how this relates to Linux's file_operations.
  • ✓You can identify all four canonical pointer bugs (null deref, use-after-free, buffer overflow, uninitialized pointer) in a code review, and name the tool (AddressSanitizer, Valgrind) that would catch each one at runtime.
  • ✓You can read and explain the container_of macro and describe why it works in terms of pointer arithmetic and offsetof.