On a 64-bit x86-64 machine, every object pointer in a C program is 8 bytes, whether it points at a char or at a 16-byte struct. A pointer stores where something lives, not what it is, and the compiler keeps track of the “what” for you. That split is why pointers are so useful, and why a wrong one can corrupt memory without a single error message.
This guide starts with & and * and ends with realloc, linked lists, qsort comparators and function pointers, with a small real-life program for each idea. Every example program was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 and Clang 18 under both -std=c11 and -std=c17, with -Wall -Wextra -pedantic and zero warnings, plus clean AddressSanitizer and UndefinedBehaviorSanitizer runs. Every output block comes from a real run. The deliberately broken programs in the pitfalls section are the exception to the zero-warnings claim; they are shown with the compiler warning and a short summary of the sanitizer report (error type and source lines). If you are coming from C++, pointers in C++ builds on everything here.
What Is a Pointer in C?
A pointer in C is a variable that stores the memory address of another object, such as an int, an array element, a struct or a function. You take an address with the & operator and follow it with *. On common 64-bit systems every object pointer occupies 8 bytes, whatever it points to.
Every variable lives somewhere in memory, and “somewhere” is a number: its address. Most of the time you refer to a variable by name and never see the address. A pointer lets you hold that address in another variable, pass it to a function, store it in a data structure, or move it along an array. That one ability is behind output parameters, dynamic memory, linked lists, callbacks and most of the C standard library.
Declaring and Using Pointers
Adding * to a declaration makes the variable a pointer to that type. The & operator gives you the address of an existing variable, and * in front of a pointer follows it back to the object. Writing through *p changes the variable itself.
#include <stdio.h>
int main(void)
{
int score = 70;
int *p = &score; /* p holds the address of score */
printf("score = %d\n", score);
printf("*p = %d\n", *p);
printf("p == &score: %s\n", p == &score ? "yes" : "no");
*p = *p + 21; /* writes to score through the pointer */
printf("after *p = *p + 21, score = %d\n", score);
int *nothing = NULL; /* points at no object at all */
printf("nothing is %s\n", nothing == NULL ? "NULL" : "not NULL");
return 0;
}
Output:
score = 70
*p = 70
p == &score: yes
after *p = *p + 21, score = 91
nothing is NULL
Two things to notice. First, * means two different things: in int *p it is part of the declaration (“p is a pointer to int”), and in *p = ... it is the dereference operator (“the object p points to”). Second, NULL is a pointer that deliberately points at nothing. Initialize every pointer either to a real address or to NULL; an uninitialized one holds garbage, which the pitfalls section below shows in action.
A common style trap is int* a, b;. The * binds to the name, not the type, so only a is a pointer:
sizeof a = 8, sizeof b = 4
If you want two pointers, write int *a, *b;, or better, declare one variable per line.
A pointer is a variable too
A pointer has its own address, its own size and its own value. This program prints all three for an int and the pointer that refers to it:
#include <stdio.h>
int main(void)
{
int score = 70;
int *p = &score;
printf("score: value %d, address %p\n", score, (void *)&score);
printf("p: value %p, address %p\n", (void *)p, (void *)&p);
printf("sizeof(score) = %zu, sizeof(p) = %zu\n", sizeof score, sizeof p);
return 0;
}
Output (one run; addresses change from run to run):
score: value 70, address 0x7fffee3c868c
p: value 0x7fffee3c868c, address 0x7fffee3c8690
sizeof(score) = 4, sizeof(p) = 8
The value stored in p is exactly the address of score, and p itself lives at a different address. Print addresses with %p and cast the pointer to void *, as above; passing any other pointer type to %p is undefined behavior.
Pointer Size, Types and Arithmetic
If every pointer is the same size, why does C make you declare what a pointer points to? Because the type decides how many bytes a dereference reads and how far p + 1 moves. Adding 1 to a pointer advances it by one element, not one byte:
#include <stdio.h>
#include <stddef.h>
struct reading {
char sensor;
double value;
};
int main(void)
{
char c[2];
short s[2];
int i[2];
double d[2];
struct reading r[2];
printf("%-16s %-10s %s\n", "type", "sizeof", "p + 1 moves");
printf("%-16s %-10zu %td bytes\n", "char", sizeof c[0], (char *)(c + 1) - (char *)c);
printf("%-16s %-10zu %td bytes\n", "short", sizeof s[0], (char *)(s + 1) - (char *)s);
printf("%-16s %-10zu %td bytes\n", "int", sizeof i[0], (char *)(i + 1) - (char *)i);
printf("%-16s %-10zu %td bytes\n", "double", sizeof d[0], (char *)(d + 1) - (char *)d);
printf("%-16s %-10zu %td bytes\n", "struct reading", sizeof r[0], (char *)(r + 1) - (char *)r);
printf("\nsizeof(char *) = %zu\n", sizeof(char *));
printf("sizeof(double *) = %zu\n", sizeof(double *));
printf("sizeof(struct reading *) = %zu\n", sizeof(struct reading *));
return 0;
}
Output:
type sizeof p + 1 moves
char 1 1 bytes
short 2 2 bytes
int 4 4 bytes
double 8 8 bytes
struct reading 16 16 bytes
sizeof(char *) = 8
sizeof(double *) = 8
sizeof(struct reading *) = 8
The right-hand column is the whole rule: p + 1 moves by sizeof(*p) bytes. For the 16-byte struct reading the pointer advances 16 bytes, which includes the 7 padding bytes after the char. Subtracting two pointers of the same type gives a count of elements (ptrdiff_t, printed with %td), not bytes.
Pointer arithmetic is only defined inside one array object, plus the single position one past its end. Pointing anywhere else is undefined behavior even if you never dereference the result. That is why loops over arrays compare against a + n but never read it.
Pointers and Arrays
An array name used in an expression turns into a pointer to its first element. This is called decay, and it is why indexing and pointer arithmetic are interchangeable: a[i] is defined as *(a + i).
#include <stdio.h>
#include <stddef.h>
int main(void)
{
int a[5] = {10, 20, 30, 40, 50};
int *p = a; /* a decays to &a[0] */
printf("sizeof(a) = %zu, sizeof(p) = %zu\n", sizeof a, sizeof p);
printf("a[3] = %d, *(a + 3) = %d, *(p + 3) = %d\n", a[3], *(a + 3), *(p + 3));
printf("&a[3] - &a[0] = %td elements\n", &a[3] - &a[0]);
int *q = a;
int v1 = *q++; /* read a[0], then advance q */
printf("*q++ gave %d, q is now at index %td\n", v1, q - a);
int v2 = (*q)++; /* increment the value a[1] itself */
printf("(*q)++ gave %d, a[1] is now %d, q is still at index %td\n", v2, a[1], q - a);
int v3 = *++q; /* advance q first, then read */
printf("*++q gave %d, q is now at index %td\n", v3, q - a);
return 0;
}
Output:
sizeof(a) = 20, sizeof(p) = 8
a[3] = 40, *(a + 3) = 40, *(p + 3) = 40
&a[3] - &a[0] = 3 elements
*q++ gave 10, q is now at index 1
(*q)++ gave 20, a[1] is now 21, q is still at index 1
*++q gave 30, q is now at index 2
The first line shows the one place decay does not happen: sizeof a is 20 (five ints), while sizeof p is 8. Everything else treats a and p alike.
The last three lines are the three increment forms that confuse beginners. Postfix ++ binds tighter than *, so *q++ reads the current element and then moves the pointer. (*q)++ increments the element itself and leaves the pointer alone. *++q moves first and then reads. When in doubt, split it into two statements.
Is pointer access faster than indexing?
Many tutorials say pointer walking is faster than a[i]. To test the claim, the repository’s asm directory holds two versions of the same function, one indexed and one pointer-based, which compare.sh compiles with gcc -O2 -S.
The indexed version, asm/sum_index.c:
#include <stddef.h>
int sum(const int *a, size_t n)
{
int total = 0;
for (size_t i = 0; i < n; i++)
total += a[i];
return total;
}
And the pointer version, asm/sum_pointer.c:
#include <stddef.h>
int sum(const int *a, size_t n)
{
int total = 0;
for (const int *p = a, *end = a + n; p != end; p++)
total += *p;
return total;
}
The assembly is not byte-identical, but the difference is small. This is the diff of the instructions at -O2 (- is the indexed version, + the pointer version):
@@ -1,12 +1,11 @@
endbr64
-testq %rsi, %rsi
-je .L4
leaq (%rdi,%rsi,4), %rdx
xorl %eax, %eax
+cmpq %rdx, %rdi
+je .L4
addl (%rdi), %eax
addq $4, %rdi
-cmpq %rdx, %rdi
+cmpq %rdi, %rdx
jne .L3
ret
-xorl %eax, %eax
ret
The loop body is the same four instructions in both: addl (%rdi), %eax, addq $4, %rdi, a compare and a jump. The compiler turned the indexed loop into pointer arithmetic by itself. The remaining differences are how the empty case is guarded before the loop and the operand order of the final compare. At -O0 the two versions differ more (21 instructions for the indexed function against 24 for the pointer one), and Clang 18 vectorizes both at -O2. No timing was measured here, so this shows instruction counts only, not speed. The practical rule: write whichever version is clearer, and measure before believing either is faster.
Arrays passed to functions lose their length
When you pass an array to a function, only the pointer arrives. The classic bug is using sizeof on the parameter:
sizeof_param.c:6:19: warning: 'sizeof' on array function parameter 'a' will return size of 'int *' [-Wsizeof-array-argument]
real length: 10, computed inside the function: 2
GCC warns about it with -Wsizeof-array-argument, and the result is wrong because the function measured a pointer (8 bytes) divided by an int (4 bytes). The fix is to pass the length as a separate parameter, as the n in sum() above does. For the full picture, see arrays in C.
Pointers and Functions
C passes everything by value, including pointers. When you pass &x, the function receives a copy of the address, and writing through that copy changes the caller’s variable. This is the C way of doing “call by reference”, and it gives you three practical tools.
Real-life example: a sensor summary and a safe integer parser. The program below swaps two values, returns three results from one pass over temperature readings, and parses text into an int without using a magic error value.
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
static void swap_int(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
/* One pass over n readings (n > 0), three results returned through pointers. */
static void stats(const double *v, size_t n, double *min, double *max, double *mean)
{
double sum = v[0];
*min = v[0];
*max = v[0];
for (size_t i = 1; i < n; i++) {
if (v[i] < *min) *min = v[i];
if (v[i] > *max) *max = v[i];
sum += v[i];
}
*mean = sum / (double)n;
}
/* Returns true and stores the value only if the whole string is a valid int. */
static bool parse_int(const char *text, int *out)
{
char *end;
errno = 0;
long v = strtol(text, &end, 10);
if (end == text || *end != '\0' || errno == ERANGE || v < INT_MIN || v > INT_MAX)
return false;
*out = (int)v;
return true;
}
int main(void)
{
int x = 3, y = 9;
swap_int(&x, &y);
printf("after swap: x = %d, y = %d\n", x, y);
double temps[] = {21.5, 19.0, 24.5, 22.0};
double lo, hi, avg;
stats(temps, sizeof temps / sizeof temps[0], &lo, &hi, &avg);
printf("min %.1f, max %.1f, mean %.2f\n", lo, hi, avg);
const char *inputs[] = {"42", "-7", "12abc", "", "99999999999"};
for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) {
int n = 0;
if (parse_int(inputs[i], &n))
printf("\"%s\" -> %d\n", inputs[i], n);
else
printf("\"%s\" -> rejected\n", inputs[i]);
}
return 0;
}
Output:
after swap: x = 9, y = 3
min 19.0, max 24.5, mean 21.75
"42" -> 42
"-7" -> -7
"12abc" -> rejected
"" -> rejected
"99999999999" -> rejected
swap_intneeds pointers because a function that received copies could not change the caller’s variables.statsreturns a minimum, maximum and mean through three output pointers. The input isconst double *, which promises the function will not modify the readings.parse_intreturnstrueorfalseand delivers the value throughint *out. It usesstrtol, which itself takes a pointer to a pointer (&end) so it can report where parsing stopped."12abc", the empty string and a value too large for anintare all rejected, and*outis left untouched on failure.
Returning a status and writing the result through a pointer is the standard C pattern for functions that can fail, because there is no value of int you could safely reserve as “error”. See also function arguments in C.
Strings and Pointers
A C string is an array of char ending in a zero byte, and char * is how you pass one around. There are two ways to create one, and they are not the same:
#include <ctype.h>
#include <stdio.h>
/* Walks the string with a pointer; no index variable needed. */
static size_t count_words(const char *s)
{
size_t words = 0;
int in_word = 0;
for (; *s != '\0'; s++) {
if (isspace((unsigned char)*s)) {
in_word = 0;
} else if (!in_word) {
in_word = 1;
words++;
}
}
return words;
}
int main(void)
{
const char *greeting = "hello"; /* points into read-only memory */
char buffer[] = "hello"; /* a writable copy: 6 bytes with the '\0' */
buffer[0] = 'J';
printf("greeting: %s, sizeof(greeting) = %zu\n", greeting, sizeof greeting);
printf("buffer: %s, sizeof(buffer) = %zu\n", buffer, sizeof buffer);
const char *line = " pointers are just addresses ";
printf("\"%s\" has %zu words\n", line, count_words(line));
return 0;
}
Output:
greeting: hello, sizeof(greeting) = 8
buffer: Jello, sizeof(buffer) = 6
" pointers are just addresses " has 4 words
greeting is a pointer (8 bytes) to a string literal stored in read-only memory. buffer is a 6-byte array holding its own writable copy. Changing buffer[0] is fine. Doing the same through greeting is undefined behavior; on this machine it crashes:
Segmentation fault
exit status 139
GCC says nothing about it at -Wall -Wextra, and only warns when you add -Wwrite-strings. Declare literal pointers as const char * so the compiler can catch the write for you. The count_words function shows the other half of string handling: walking a pointer until it reaches the terminating zero. The character-array side of this is covered in char array vs char pointer.
const and Pointers
const and * combine in three ways, and the trick is to read the declaration right to left:
#include <stdio.h>
int main(void)
{
int a = 1, b = 2;
const int *reader = &a; /* pointer to const int: *reader is read-only */
int *const anchor = &a; /* const pointer: it can never point elsewhere */
const int *const fixed = &a; /* both are locked */
reader = &b; /* fine: retarget the pointer */
*anchor = 10; /* fine: write through the pointer */
printf("reader sees %d, anchor wrote a = %d, fixed sees %d\n", *reader, a, *fixed);
return 0;
}
Output:
reader sees 2, anchor wrote a = 10, fixed sees 10
const int *readeris a pointer to a constant int: you cannot write through it, but you can point it elsewhere.int *const anchoris a constant pointer to an int: you can write through it, but it can never point elsewhere.const int *const fixedlocks both.
The compiler enforces it. Writing through a pointer to const does not compile:
const_write.c:6:8: error: assignment of read-only location '*p'
Make read-only parameters const, as stats did with const double *v. It documents intent, catches mistakes at compile time, and lets callers pass both const and non-const data.
Dynamic Memory: malloc, calloc, realloc and free
Local variables live on the stack and vanish when the function returns. When you need memory whose size is only known at run time, or that must outlive the function that created it, you request it from the heap with malloc, calloc or realloc, and give it back with free.
Real-life example: reading a list of numbers of unknown length. This program parses a line of text into a growing array, then uses calloc to build a histogram.
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int *data;
size_t len;
size_t cap;
} IntList;
static bool list_push(IntList *l, int value)
{
if (l->len == l->cap) {
if (l->cap > SIZE_MAX / 2 / sizeof *l->data)
return false; /* doubling would overflow size_t */
size_t new_cap = l->cap ? l->cap * 2 : 4;
/* Keep the result in a temporary: on failure l->data is still valid. */
int *bigger = realloc(l->data, new_cap * sizeof *bigger);
if (bigger == NULL)
return false;
l->data = bigger;
l->cap = new_cap;
printf(" grew to capacity %zu\n", new_cap);
}
l->data[l->len++] = value;
return true;
}
static void list_free(IntList *l)
{
free(l->data);
l->data = NULL;
l->len = l->cap = 0;
}
int main(void)
{
const char *text = "17 4 23 8 15 42 16 9 31 5";
IntList list = {0};
/* strtol advances `end` past each number it reads. */
const char *cursor = text;
char *end;
for (long v = strtol(cursor, &end, 10); end != cursor; v = strtol(cursor, &end, 10)) {
cursor = end;
if (!list_push(&list, (int)v)) {
fprintf(stderr, "out of memory\n");
list_free(&list);
return 1;
}
}
printf("read %zu numbers\n", list.len);
/* calloc gives zeroed memory: a histogram of the values, bucketed by tens. */
int *buckets = calloc(5, sizeof *buckets);
if (buckets == NULL) {
list_free(&list);
return 1;
}
for (size_t i = 0; i < list.len; i++)
buckets[list.data[i] / 10]++;
for (int b = 0; b < 5; b++)
printf("%2d-%2d: %d\n", b * 10, b * 10 + 9, buckets[b]);
free(buckets);
list_free(&list);
return 0;
}
Output:
grew to capacity 4
grew to capacity 8
grew to capacity 16
read 10 numbers
0- 9: 4
10-19: 3
20-29: 1
30-39: 1
40-49: 1
The patterns that matter:
- Size from the pointer, not the type.
malloc(n * sizeof *p)stays correct if you later changep‘s type. In C you do not cast the result ofmalloc. - Check for
NULL. Allocation can fail, and the result must be tested before use. reallocthrough a temporary. Ifreallocfails it returnsNULLand leaves the old block alive. Assigning the result straight back to your only pointer would leak the old block and lose your data.- Guard the arithmetic. Doubling a capacity or multiplying
n * sizeofcan overflowsize_t. TheSIZE_MAXcheck refuses the request instead of allocating a block smaller than the program expects. calloczeroes the memory, so the histogram needs no initialization loop.freeonce, then forget.list_freesets the pointer toNULLafterward, becausefree(NULL)is harmless and a stale non-null pointer is not.
Ownership is the hard part: every allocation needs exactly one place responsible for freeing it. C++ turns that rule into types; see smart pointers in modern C++.
Pointers to Pointers
A pointer to a pointer (int **, char **) appears whenever a function must change a pointer, not just the thing it points to. strtol did this above with &end, and main receives char **argv for the same reason: an array of string pointers.
Real-life example: command-line arguments and a function that hands back a new allocation.
#include <ctype.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Allocates an upper-case copy of text. The caller owns *out and must free it. */
static bool upper_copy(const char *text, char **out)
{
size_t n = strlen(text);
char *copy = malloc(n + 1);
if (copy == NULL)
return false;
for (size_t i = 0; i <= n; i++)
copy[i] = (char)toupper((unsigned char)text[i]);
*out = copy; /* changes the caller's pointer, not a copy of it */
return true;
}
int main(int argc, char **argv)
{
printf("received %d argument(s)\n", argc - 1);
for (char **arg = argv + 1; *arg != NULL; arg++) {
char *shout = NULL;
if (!upper_copy(*arg, &shout))
return 1;
printf(" %s -> %s\n", *arg, shout);
free(shout);
}
return 0;
}
Output of ./double_pointer alpha beta:
received 2 argument(s)
alpha -> ALPHA
beta -> BETA
upper_copy allocates a string and stores its address in *out, which is the caller’s own char *. The for loop walks argv with a char ** until it reaches the NULL that ends the array.
The mistake this prevents is passing the pointer itself and expecting it to change. The function below allocates, but only its private copy of the pointer ever sees the memory:
data is still NULL
Nothing warns, nothing crashes, and the caller still has NULL (plus a leaked block). Passing &data to a function taking int ** fixes it. More on this in double pointers in C.
Pointers to Structs and Linked Lists
For a pointer to a struct, p->field is shorthand for (*p).field. Pointers in structs are how C builds data structures whose size changes at run time.
Real-life example: a to-do list. Each task points to the next one. The list is built, edited, reversed and freed:
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct task {
char title[24];
int priority;
struct task *next;
};
static struct task *task_new(const char *title, int priority)
{
struct task *t = malloc(sizeof *t);
if (t == NULL)
return NULL;
snprintf(t->title, sizeof t->title, "%s", title);
t->priority = priority;
t->next = NULL;
return t;
}
static void push_front(struct task **head, struct task *t)
{
t->next = *head;
*head = t;
}
/* `link` always points at the pointer that must change: the head or some next. */
static void push_back(struct task **head, struct task *t)
{
struct task **link = head;
while (*link != NULL)
link = &(*link)->next;
*link = t;
}
static bool remove_title(struct task **head, const char *title)
{
for (struct task **link = head; *link != NULL; link = &(*link)->next) {
if (strcmp((*link)->title, title) == 0) {
struct task *gone = *link;
*link = gone->next;
free(gone);
return true;
}
}
return false;
}
static void reverse(struct task **head)
{
struct task *prev = NULL;
while (*head != NULL) {
struct task *next = (*head)->next;
(*head)->next = prev;
prev = *head;
*head = next;
}
*head = prev;
}
static void free_list(struct task **head)
{
while (*head != NULL) {
struct task *next = (*head)->next;
free(*head);
*head = next;
}
}
static void print_list(const struct task *t)
{
for (; t != NULL; t = t->next)
printf(" [%d] %s\n", t->priority, t->title);
}
int main(void)
{
struct task *todo = NULL;
push_back(&todo, task_new("Write report", 2));
push_back(&todo, task_new("Email Sam", 3));
push_front(&todo, task_new("Pay invoice", 1));
printf("Initial list:\n");
print_list(todo);
remove_title(&todo, "Email Sam");
printf("After removing \"Email Sam\":\n");
print_list(todo);
reverse(&todo);
printf("Reversed:\n");
print_list(todo);
free_list(&todo);
return 0;
}
Output:
Initial list:
[1] Pay invoice
[2] Write report
[3] Email Sam
After removing "Email Sam":
[1] Pay invoice
[2] Write report
Reversed:
[2] Write report
[1] Pay invoice
The interesting idiom is struct task **link. In push_back and remove_title, link always points at the pointer that must change: first the head, then each next field in turn. Because of that, inserting or deleting at the head needs no special case, and there is no separate “previous node” variable. reverse re-links the nodes in place, and free_list saves next before freeing each node, since reading (*head)->next after free would be a use-after-free.
Function Pointers and Callbacks
A function has an address too. A pointer to a function lets you pass behavior as data: a comparison rule for sorting, a handler for an event, an entry in a dispatch table.
Real-life example: sorting staff records two ways, and a calculator table.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct employee {
const char *name;
int salary;
};
static int by_name(const void *a, const void *b)
{
const struct employee *x = a; /* qsort passes const void *: cast it back */
const struct employee *y = b;
return strcmp(x->name, y->name);
}
/* qsort is not stable, so ties on salary fall back to the name. */
static int by_salary_desc(const void *a, const void *b)
{
const struct employee *x = a;
const struct employee *y = b;
if (x->salary != y->salary)
return (y->salary > x->salary) - (y->salary < x->salary);
return by_name(a, b);
}
typedef double (*binary_op)(double, double);
static double add(double a, double b) { return a + b; }
static double sub(double a, double b) { return a - b; }
static double mul(double a, double b) { return a * b; }
static double divide(double a, double b) { return b != 0.0 ? a / b : 0.0; }
static const struct {
char symbol;
binary_op apply;
} operations[] = {
{'+', add}, {'-', sub}, {'*', mul}, {'/', divide},
};
static void print_staff(const char *heading, const struct employee *e, size_t n)
{
printf("%s\n", heading);
for (size_t i = 0; i < n; i++)
printf(" %-6s %d\n", e[i].name, e[i].salary);
}
int main(void)
{
struct employee staff[] = {
{"Maya", 52000}, {"Omar", 61000}, {"Lena", 48000}, {"Raj", 61000},
};
size_t n = sizeof staff / sizeof staff[0];
qsort(staff, n, sizeof staff[0], by_name);
print_staff("By name:", staff, n);
qsort(staff, n, sizeof staff[0], by_salary_desc);
print_staff("By salary, highest first:", staff, n);
printf("Calculator table:\n");
for (size_t i = 0; i < sizeof operations / sizeof operations[0]; i++)
printf(" 12 %c 4 = %g\n", operations[i].symbol, operations[i].apply(12.0, 4.0));
return 0;
}
Output:
By name:
Lena 48000
Maya 52000
Omar 61000
Raj 61000
By salary, highest first:
Omar 61000
Raj 61000
Maya 52000
Lena 48000
Calculator table:
12 + 4 = 16
12 - 4 = 8
12 * 4 = 48
12 / 4 = 3
qsort knows nothing about struct employee. It receives a pointer to the array, the element size and a comparator, and calls your function with const void * arguments that you cast back to the real type. typedef double (*binary_op)(double, double); gives the awkward function-pointer syntax a readable name, and the operations table pairs each symbol with its function so adding a new operator is one line.
Note the tie-break in by_salary_desc. qsort is not required to be stable, so two employees with the same salary could come out in either order on different C libraries; falling back to the name makes the result deterministic.
void Pointers and Generic Code
A void * can hold the address of any object, which is how qsort, malloc and memcpy work with any type. The cost is that the compiler no longer knows the size, so you must supply it:
#include <stdio.h>
/* Swaps any two objects of the same size, one byte at a time. */
static void swap_bytes(void *a, void *b, size_t size)
{
unsigned char *x = a;
unsigned char *y = b;
while (size--) {
unsigned char tmp = *x;
*x++ = *y;
*y++ = tmp;
}
}
struct point {
int x, y;
};
int main(void)
{
int i = 1, j = 2;
double d = 1.5, e = 9.25;
struct point p = {1, 2}, q = {30, 40};
swap_bytes(&i, &j, sizeof i);
swap_bytes(&d, &e, sizeof d);
swap_bytes(&p, &q, sizeof p);
printf("ints: %d %d\n", i, j);
printf("doubles: %.2f %.2f\n", d, e);
printf("points: (%d,%d) (%d,%d)\n", p.x, p.y, q.x, q.y);
return 0;
}
Output:
ints: 2 1
doubles: 9.25 1.50
points: (30,40) (1,2)
swap_bytes exchanges two objects of any type by treating them as arrays of bytes. You cannot dereference a void *, and standard C does not define arithmetic on one:
void_pointer_misuse.c:8:20: warning: dereferencing 'void *' pointer
void_pointer_misuse.c:8:20: error: invalid use of void expression
void_pointer_misuse.c:9:11: warning: pointer of type 'void *' used in arithmetic [-Wpointer-arith]
GCC accepts v + 1 as an extension in its default dialect and only warns under -pedantic; the dereference is an error either way. Convert to a concrete type (unsigned char * above) before touching the bytes.
What Goes Wrong: Pointer Bugs Under a Sanitizer
Most pointer bugs compile. The programs below are deliberately broken, live in the repository’s pitfalls directory, and were run with GCC 13.3 and -fsanitize=address. Each report below is a summary of the sanitizer’s output: the error type, the kind of access and the source lines involved. Addresses, library frames and full file paths are left out.
Use after free
use_after_free.c:12:5: warning: pointer 'p' used after 'free' [-Wuse-after-free]
AddressSanitizer: heap-use-after-free
READ of size 4 at use_after_free.c:12
freed at use_after_free.c:11, allocated at use_after_free.c:7
AddressSanitizer names the bug, the exact line that read freed memory, the line that freed it and the line that allocated it. GCC’s compile-time -Wuse-after-free flagged this one before it ran.
Writing one element past the end
AddressSanitizer: heap-buffer-overflow
WRITE of size 4 at heap_overflow.c:10
0 bytes after a 16-byte region allocated at heap_overflow.c:6
The loop uses <= where it needed <. The report says the write landed “0 bytes after 16-byte region”, which is the signature of an off-by-one. Compiled without the sanitizer, this program ran to completion and exited with status 0, which is exactly how this kind of bug survives testing.
A leak
LeakSanitizer: detected memory leaks
Direct leak of 400 byte(s) in 1 object(s), allocated at leak.c:6
Overwriting the only pointer to a block makes it unreachable. LeakSanitizer reports 400 bytes (100 ints) and the line that allocated them.
A dangling pointer the sanitizer could not name
This function returns the address of a local variable, which stops existing when the function returns:
dangling_local.c:7:12: warning: function returns address of local variable [-Wreturn-local-addr]
AddressSanitizer: SEGV on unknown address 0x000000000000
READ at dangling_local.c:13 (address points to the zero page)
GCC’s compile-time warning is the useful diagnostic here. At run time AddressSanitizer reported a read at address zero, not a stack-use-after-return, because GCC had already replaced the returned &count with a null pointer: in the -O0 assembly, make_counter stores 42 into its frame and then returns with movl $0, %eax. A sanitizer reports what the compiled program did, and the compiler may have changed it first. The fix is to return the value, or to let the caller supply the storage.
The bug with no diagnostic at all
An uninitialized pointer compiled with no warning at -Wall -Wextra, with or without -O2. This is its output across three runs, then under Valgrind:
906947862
-1861673706
-463487722
Use of uninitialised value of size 8
The program printed a different number each time, never crashed, and the compiler said nothing. It read through a pointer it never set; Valgrind reported “Use of uninitialised value of size 8”. Where the garbage happens to point at readable memory you get a wrong answer instead of a crash, which is worse.
Summary of what each tool caught
| Bug | GCC 13.3 at compile time | At run time |
|---|---|---|
| Use after free | -Wuse-after-free warning | ASan: heap-use-after-free, READ of size 4 |
| Double free | -Wuse-after-free warning | ASan: attempting double-free |
| Heap overflow by one | no warning | ASan: heap-buffer-overflow, WRITE of size 4 |
| Memory leak | no warning | LeakSanitizer: Direct leak of 400 byte(s) |
| NULL dereference | no warning | ASan: SEGV on unknown address 0x000000000000 |
| Returning a local’s address | -Wreturn-local-addr warning | ASan: SEGV at address 0 (GCC substituted NULL) |
| Uninitialized pointer | no warning | Valgrind: Use of uninitialised value of size 8 |
| Write to a string literal | no warning (-Wwrite-strings warns) | Segmentation fault, exit status 139 |
| Pointer passed by value | no warning | prints data is still NULL |
sizeof on an array parameter | -Wsizeof-array-argument warning | prints 2 for a 10-element array |
Write through pointer to const | compile error | does not build |
Five of these eleven produce a compile-time warning or error at -Wall -Wextra, and six produce none. Build with -fsanitize=address,undefined in your tests: the AddressSanitizer documentation covers the options.
Habits That Prevent Most Pointer Bugs
- Initialize at declaration. A pointer is either a valid address or
NULL, never unset. - Check every allocation, and keep the old pointer when
realloccan fail. - Pass a length with every array, because the pointer carries none.
- Use
conston every pointer parameter that is only read. - Name one owner for every allocation, and free it exactly once, on every path.
- Set a pointer to
NULLafterfreewhen it stays in scope. - Run your tests under AddressSanitizer and fix the first report before looking at the second.
Key Takeaways
- A pointer is a variable holding an address.
&takes an address,*follows one, and a pointer has its own address and its own 8 bytes on a 64-bit system. - The type decides the step.
p + 1moves bysizeof(*p)bytes, and arithmetic is only valid inside one array plus one past its end. - Arrays decay to pointers, which is why
sizeofon an array parameter measures a pointer. Pass the length separately. At-O2GCC compiled the indexed and pointer loops to the same four-instruction loop body. - Pointers give functions output parameters. Return a status, write the result through a pointer, and use
constfor inputs. - Use a pointer to a pointer to change a pointer. Passing the pointer by value changes only a copy.
- Dynamic memory has one rule: one owner, one
free. Size withsizeof *p, check forNULL, and keep the old pointer whenrealloccan fail. - Most pointer bugs compile. Of 11 deliberately broken programs, six produced no compile-time diagnostic, and a sanitizer or Valgrind is what exposed most of them.
Conclusion
Pointers feel dangerous because they remove the compiler’s safety net in exchange for control: you decide what lives where, and for how long. The rules are small (an address, a type, an owner), and every bug in this article came from breaking one of them. Learn to read the sanitizer report and the compiler warning as feedback on those rules rather than as noise.
From here, the natural next steps are the data structures built from these pieces, such as trees, hash tables and graphs, and the rest of the language in the C programming section. Each of them is a pointer with a job.
Source Code and Tests
All programs on this page are in the memory/pointers directory of the MYCPLUS C examples repository.
Build and test:
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
What the build checks. The programs compile with GCC and Clang under C11 and C17 with -Wall -Wextra -pedantic -Werror, and with MSVC under /W4 /WX. Every demo prints exactly the output shown here, except address_demo, whose addresses change on every run, so only the shape of its output is checked. Unit tests cover the integer parser (empty strings, trailing characters, range limits), the growing list (1,000 pushes and the capacity-overflow refusal), the linked list (20,000 random operations against an array model) and the sort comparator. Tests and demos run clean under AddressSanitizer and UndefinedBehaviorSanitizer, and each broken program in pitfalls is checked to still produce the diagnostic quoted above.
The build does not check the assembly comparison, which varies with the compiler version, or exact sanitizer wording on other toolchains.
What I Verified
Every check below was run for this article on three setups: Ubuntu 24.04 (x86-64, GCC 13.3, Clang 18, CMake 3.28), the same Ubuntu release under WSL (GCC 13.3, CMake 3.28, no Clang installed), and Windows 11 Education (MSVC, CMake 4.4.4). The run_all.sh and run_all.ps1 scripts in the repository print these results.
| Check | Ubuntu 24.04 | WSL (Ubuntu 24.04) | Windows 11 |
|---|---|---|---|
12 programs build with warnings as errors (GCC and Clang on Linux, MSVC /W4 /WX on Windows) | C11 and C17 with GCC and Clang: passed, zero warnings | C11 and C17 with GCC: passed (Clang not installed) | C11 and C17 with MSVC: built |
| 18 tests (6 unit-test files plus byte-for-byte output checks) | 18 of 18 passed in all four configurations | 18 of 18 passed with GCC in C11 and C17 | 18 of 18 passed in C11 and C17 |
| AddressSanitizer and UndefinedBehaviorSanitizer over tests and demos | Clean | Clean | Not applicable |
| Each broken program still produces the diagnostic quoted above | All 11 present | All 11 present | Not applicable |
| 13 deliberate mistakes injected into the code to check the tests | 11 caught, 2 survived: a missing ERANGE check (equivalent on 64-bit Linux, matters where long is 32-bit) and a missing unsigned char cast before isspace (glibc tolerates it) | Not run | Not run |
| Index and pointer loops compared in assembly (GCC and Clang) | Run; instruction counts only | Not run | Not run |
Not covered: 32-bit builds, Clang on WSL, sanitizers on Windows, Clang’s own sanitizer runtime (it was not installed on the Ubuntu test machine, so every sanitizer report here comes from GCC), GCC 14 and later, and any timing. The index-versus-pointer comparison counts instructions and makes no claim about speed.



