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->irqandfilp->private_dataconstantly.
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 u8or*mut u8serve 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
&Tis likeconst int *(read-only). Rust's&mut Tis likeint *(read-write). There is no equivalent ofint *constbecause Rust bindings are immutable by default (letvslet 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_operationsis 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_peekfunction that returns the top value without removing it. Use a pointer parameter for the output, just likestack_pop.
Knowledge Check
- What does
*(arr + 3)mean ifarris anintarray? - Why must you pass array length separately in C?
- What is the difference between
const int *pandint *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 incrementsp, 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*).