Pointers in C — theory with addresses, * and &, and arrays walking the bus stops (and the credit that dropped without touching it)
The first time I saw *p = *p - 155; take money off my bus pass without writing credit anywhere in the program, I thought the compiler had gone mad. It wasn’t magic: it was a pointer. And once you understand what that variable with an asterisk holds, half of IC2 (dynamic memory, strings, processes, threads) stops being scary.
In this article we take pointers in C apart calmly: what a memory address is, what each asterisk and each & does, why p + 1 does not add one byte, why an array and a pointer look so alike (and where they differ) and what really happens when a loop goes one lap too far. All with the same example as in Loops in C: the bus, its stops and the credit on the pass, in cents. Every output comes from a real run with gcc 14.
Table of Contents
Why Python has no pointers and C does
If you come from Python, you have never had to think about where a piece of data lives. You write credit = 1000 and Python creates an object, sticks a label on it and takes care of everything else: reserving memory, moving it, freeing it. That is why, when someone searches for “pointers in Python”, the short answer is that they don’t exist as such: Python uses references, but never lets you see or touch the address.
C is the other way round. When you write int credit = 1000; you reserve 4 specific bytes of memory, at a specific position with a specific number. That number is the address of credit, and C lets you read it, store it in another variable and use it to reach the data by another route. A variable that holds an address is a pointer.
Why would anyone want that? For three reasons you will see again and again in IC2:
- So that a function can modify the caller’s variables (the famous pass by reference, covered in depth in Functions in C).
- To walk through arrays and strings by moving around memory.
- To ask for memory while the program is running with
malloc, which only gives you back an address (Dynamic memory in C).
Total control over memory also means total responsibility. Python warns you with an exception when you step outside a list; C warns you about nothing. At the end of the article you will see a program that reads memory that isn’t its own and carries on as if nothing happened.
What is a memory address?
Picture memory as a very long bus route with a stop at every byte, and every stop with its number. A variable takes one or several stops in a row: a char, one; an int, four; a double, eight. The address of the variable is the number of its first stop.
The & operator in front of a variable gives you its address, and %p prints it in hexadecimal:
#include <stdio.h>
int main(void) {
int credit = 1000; /* cents left on the bus pass */
char line = 'L'; /* letter of the route */
double price = 1.55; /* price of one trip, in euros */
/* %p prints an address; (void *) is the type %p expects */
printf("%-7s= %-5d at %p (%zu bytes)\n", "credit", credit, (void *)&credit, sizeof credit);
printf("%-7s= %-5c at %p (%zu byte)\n", "line", line, (void *)&line, sizeof line);
printf("%-7s= %-5.2f at %p (%zu bytes)\n", "price", price, (void *)&price, sizeof price);
return 0;
}
Output:
credit = 1000 at 0x7ffe0051225c (4 bytes) line = L at 0x7ffe0051225b (1 byte) price = 1.55 at 0x7ffe00512260 (8 bytes)
Three things to look at in this output:
- The addresses start with
0x7ffe…: that is the stack, where the local variables ofmainlive. They are huge numbers because a process’s virtual memory is huge. line(1 byte) is at…25bandcredit(4 bytes) at…25c: right next to each other.priceis at…260, four bytes pastcredit. The compiler places them however it sees fit, not in the order you declare them.sizeofconfirms the sizes: 4, 1 and 8 bytes.
Addresses change on every run. Linux puts the stack somewhere different each time (it’s called ASLR and it is a security measure). If you run this program on your Fedora you’ll see other numbers: what doesn’t change is the idea, nor the distances between elements of the same array.
Why (void *)&credit and not just &credit? Because %p expects a generic pointer (void *). Plain &credit usually works too, but the explicit conversion is the correct thing and gcc has nothing to complain about.
Declaring a pointer: the * in the declaration
A pointer is declared with the type of the data it will point to and an asterisk:
int *p; /* p will hold the address of an int */ char *pc; /* pc, that of a char */ double *pd; /* pd, that of a double */
That asterisk is not a multiplication or an operator: it is part of the type. int *p reads “p is a pointer to int”. The type matters for two reasons: it tells the compiler how many bytes to read when you go to the address and how far to move when you write p + 1 (you’ll see that in the arithmetic).
Here is the trap that turns up in every exam. How many pointers does this line declare?
#include <stdio.h>
int main(void) {
int credit = 1000, trips = 6;
int* p, q; /* looks like it declares two pointers... */
p = &credit;
q = &trips; /* ...but q is a plain int */
printf("%d %d\n", *p, q);
return 0;
}
Just one. The asterisk sticks to the variable, not to the type: int* p, q; is the same as int *p; int q;. That is why gcc 14 refuses to compile:
declare.c: In function 'main': declare.c:7: error: assignment to 'int' from 'int *' makes integer from pointer without a cast [-Wint-conversion]
It is telling you that you’re trying to put an address (int *) into an int. If you want two pointers, each one gets its own asterisk: int *p, *q;. That’s why in C the asterisk is usually written next to the name: it shows who it belongs to.
int* p, q; declares one pointer and one integer. With gcc 14 (Fedora’s) it is a compile error as soon as you assign an address to q; with older compilers it was just a warning and the program did strange things.
The two key operators: & and *
With the pointer declared, two operators come into play that are exact opposites:
| Operator | Reads as | You give it | It gives you back |
|---|---|---|---|
&x | “address of x” | a variable | its address |
*p | “what p points to” | an address | the data stored there |
See it with the bus pass:
#include <stdio.h>
int main(void) {
int credit = 1000; /* 10.00 euros in cents */
int *p = &credit; /* p holds the address of credit */
printf("&credit = %p\n", (void *)&credit);
printf("p = %p\n", (void *)p); /* the same address */
printf("*p = %d\n", *p); /* what's at that address */
*p = *p - 155; /* pay for a trip THROUGH p */
printf("After tapping the pass: credit = %d, *p = %d\n", credit, *p);
/* & and * undo each other */
printf("*&credit = %d, &*p = %p\n", *&credit, (void *)&*p);
return 0;
}
Output:
&credit = 0x7ffc726ab67c p = 0x7ffc726ab67c *p = 1000 After tapping the pass: credit = 845, *p = 845 *&credit = 845, &*p = 0x7ffc726ab67c
Line by line:
int *p = &credit;declarespand, in the same line, stores the address ofcreditin it. Here the*is the declaration one (type) and the&is the “address of” operator.&creditandpprint the same address:pis a copy of that number.*pis 1000: an asterisk in an expression means “go to that address and fetch whatever is there”. That is called dereferencing.*p = *p - 155;reads 1000 throughp, subtracts the trip and writes the result at that same address. Since that address belongs tocredit,creditbecomes 845 even though it doesn’t appear on the line.- The last line shows that they undo each other:
*&creditis “what’s at the address of credit”, that is,credit. And&*pis “the address of what p points to”, that is,p.
Look at the drawing: the box for p doesn’t contain 1000, it contains where the 1000 is. Everything clever about pointers fits in that arrow.
Changing a variable through a pointer
What you just saw with *p = *p - 155; is the star operation. When you write *p = value; you don’t change p (it still points to the same place); you change what’s at the place it points to. If you wanted p to point to another variable, you’d write p = &other; with no asterisk.
The table worth keeping in front of you until it comes naturally:
| You write | What changes | Example |
|---|---|---|
p = &other; | where p points | p stops pointing at credit |
*p = 690; | the data pointed to | credit becomes 690 |
int *p = &credit; | declares and points at once | the * belongs to the type |
Mixing up the declaration asterisk and the expression asterisk. In int *p = &credit; you are NOT doing *p = &credit (storing an address in an int); you are declaring p and giving it a value. That’s why it’s clearer in two lines: int *p; and p = &credit;.
NULL and the uninitialised pointer
A freshly declared pointer with no value contains garbage: whatever bits were in that memory. If you use it, you write to a random address. To say “this pointer doesn’t point to anything yet” there is NULL, a special address (0) that the operating system won’t let you touch.
#include <stdio.h>
int main(void) {
int *pass = NULL; /* doesn't point to any pass yet */
if (pass == NULL) /* also fine: if (!pass) */
printf("No pass: I can't read the credit\n");
printf("I'll read it anyway...\n");
printf("Credit: %d\n", *pass); /* dereferencing NULL */
return 0;
}
Output:
No pass: I can't read the credit I'll read it anyway... Segmentation fault (core dumped)
The if (pass == NULL) does its job; then, on purpose, we dereference NULL and the system kills the process with a Segmentation fault: the program tried to read an address that doesn’t belong to it. It is the most famous error in C and almost always means “you used a pointer that didn’t point to anything valid”. The IC2 notes check it with if (ptr) and if (!ptr), which is the same as comparing with NULL.
And if you don’t initialise it at all? It’s worse, because it isn’t even certain to fail:
#include <stdio.h>
int main(void) {
int *p; /* uninitialised: holds whatever was there */
*p = 155; /* where does this write? nobody knows */
printf("Written\n");
return 0;
}
gcc with -Wall sees it coming:
no_init.c: In function 'main': no_init.c:5: warning: 'p' is used uninitialized [-Wuninitialized] no_init.c:4: note: 'p' was declared here
And even so, in my run the program printed this and finished “fine”:
Written
It wrote 155 somewhere in memory and nobody complained. In another run, or on the lab computer, it may crash or corrupt another variable. That “sometimes it works” is what makes uninitialised pointers so treacherous.
Always initialise pointers when you declare them: with a valid address (int *p = &credit;) or with NULL if you don’t have one yet. A NULL fails on the spot and tells you where; garbage fails later and somewhere else.
Pointer arithmetic
You can add integers to and subtract integers from a pointer, but they aren’t normal sums: p + 1 moves forward one element of the type it points to, not one byte. Check it with three arrays of different types:
#include <stdio.h>
int main(void) {
int boarding[5] = {12, 7, 9, 4, 15}; /* passengers boarding at each stop */
char line[5] = "L201"; /* 4 letters + '\0' */
double km[5] = {0.0, 1.2, 2.9, 4.1, 6.0}; /* kilometre of each stop */
int *pi = boarding; /* same as &boarding[0] */
char *pc = line;
double *pd = km;
for (int i = 0; i < 3; i++) {
printf("+%d: pi=%p pc=%p pd=%p\n", i,
(void *)(pi + i), (void *)(pc + i), (void *)(pd + i));
}
printf("*(pi + 2) = %d, same as boarding[2] = %d\n", *(pi + 2), boarding[2]);
printf("(pi + 4) - pi = %td elements\n", (pi + 4) - pi);
return 0;
}
Output:
+0: pi=0x7ffd86d0ccc0 pc=0x7ffd86d0cd13 pd=0x7ffd86d0cce0 +1: pi=0x7ffd86d0ccc4 pc=0x7ffd86d0cd14 pd=0x7ffd86d0cce8 +2: pi=0x7ffd86d0ccc8 pc=0x7ffd86d0cd15 pd=0x7ffd86d0ccf0 *(pi + 2) = 9, same as boarding[2] = 9 (pi + 4) - pi = 4 elements
Look at the last digits of each column:
pi(pointer toint):…cc0,…cc4,…cc8. Goes up 4 at a time.pc(pointer tochar):…d13,…d14,…d15. Goes up 1 at a time.pd(pointer todouble):…ce0,…ce8,…cf0. Goes up 8 at a time (in hexadecimal,ce8 + 8 = cf0).
The compiler does the multiplication for you: pi + 2 is “the address in pi plus 2 × sizeof(int)“. That’s why *(pi + 2) is 9, same as boarding[2]. And the other way round: subtracting two pointers into the same array gives elements, not bytes: (pi + 4) - pi is 4 (the %td is the format for ptrdiff_t, the type of pointer subtractions).
Adding to or subtracting pointers only makes sense inside one array. Subtracting the addresses of two separate variables compiles, but the result means nothing.
Pointers and arrays: why they are almost the same
This is the revelation that changes everything: when you use the name of an array in an expression, C turns it into the address of its first element. int *p = boarding; is exactly int *p = &boarding[0];. And the [] operator is nothing but pointer arithmetic in disguise: boarding[i] means *(boarding + i).
That’s why these three ways of walking the array give the same result:
#include <stdio.h>
int main(void) {
int boarding[5] = {12, 7, 9, 4, 15};
int *p = boarding;
printf("Index: ");
for (int i = 0; i < 5; i++) printf("%d ", boarding[i]);
printf("\n*(p + i): ");
for (int i = 0; i < 5; i++) printf("%d ", *(p + i));
printf("\nMoving p: ");
for (p = boarding; p < boarding + 5; p++) printf("%d ", *p);
printf("\n");
p = boarding; /* back to the start */
printf("p[3] = %d and 3[boarding] = %d\n", p[3], 3[boarding]);
printf("sizeof boarding = %zu, sizeof p = %zu\n", sizeof boarding, sizeof p);
return 0;
}
Output:
Index: 12 7 9 4 15 *(p + i): 12 7 9 4 15 Moving p: 12 7 9 4 15 p[3] = 4 and 3[boarding] = 4 sizeof boarding = 20, sizeof p = 8
The first three lines are identical. The fourth brings a curiosity: since p[3] is *(p + 3) and addition is commutative, 3[boarding] is *(3 + boarding), which also compiles and gives 4. Never write it in an exam, but it proves the square brackets are pure arithmetic.
So are an array and a pointer the same thing? Almost. The last line shows the first difference: sizeof boarding gives 20 (5 ints × 4 bytes, the whole array) and sizeof p gives 8 (the size of an address on a 64-bit system). The second difference is that the array name can’t be moved:
int main(void) {
int boarding[5] = {12, 7, 9, 4, 15};
boarding++; /* I want to move to the second stop */
return 0;
}
arr_inc.c: In function 'main': arr_inc.c:3: error: lvalue required as increment operand
“lvalue required” means that boarding is not a variable you can change: the array is where it is. If you want to move forward, use a pointer (p++), which is a variable.
| Expression | Array boarding | Pointer p = boarding |
|---|---|---|
[i] and *(… + i) | Yes, the same | Yes, the same |
++ / -- / = | No: compile error | Yes |
sizeof | the whole array (20) | the address (8) |
Working out how many elements an array has with sizeof v / sizeof v[0] inside a function that receives it as a parameter. There v is already a pointer and sizeof v gives 8, not the size of the array. That’s why functions always receive the size separately (you’ll see it in the practice and in Functions in C).
One lap too many: what I promised you in Loops in C
In Loops in C I told you that the extra lap of a for with <= “reads memory that isn’t yours”. Now that you know v[i] is *(v + i), you can see it with your own eyes. The pass stores what each of the week’s 4 trips cost, and the loop goes from 0 to 4 inclusive:
#include <stdio.h>
int main(void) {
int pass[4] = {155, 155, 140, 140}; /* what each trip this week cost */
int total = 0;
int *p = pass;
for (int i = 0; i <= 4; i++) { /* <= : one lap too many */
printf("trip %d: %d\n", i, *(p + i));
total += *(p + i);
}
printf("Total: %d cents\n", total);
return 0;
}
Output:
trip 0: 155 trip 1: 155 trip 2: 140 trip 3: 140 trip 4: 641088528 Total: 641089118 cents
“trip 4” doesn’t exist: p + 4 points to the memory right after the array, and * reads whatever happens to be there, which in this run was 641088528. The program doesn’t complain or stop, and adds that garbage to the total. That’s the worst thing that can happen: a silent failure that gives an absurd result as calmly as anything.
To catch it there’s AddressSanitizer (ASan), a gcc tool that watches every memory access. You switch it on by compiling like this:
gcc -Wall -std=c11 -g -fsanitize=address back.c -o back
The same program, compiled with ASan:
trip 0: 155
trip 1: 155
trip 2: 140
trip 3: 140
=================================================================
==702==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fca61900030
READ of size 4 at 0x7fca61900030 thread T0
#0 in main back.c:9
Address 0x7fca61900030 is located in stack of thread T0 at offset 48 in frame
#0 in main back.c:3
[32, 48) 'pass' (line 4) <== Memory access at offset 48 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow back.c:9 in main
Now it does: the four good trips come out and, on the fifth access, ASan stops the program and tells you what happened (stack-buffer-overflow: you went past an array on the stack), on which line (back.c:9, the printf one) and which variable you overflowed ('pass'): the access at byte 48 falls outside bytes 32-48, which belong to pass.
When a program with arrays or pointers gives odd results, compile it again with -g -fsanitize=address. It runs slower, but points to the exact line of the first bad access. In the IC2 labs it saves hours of searching blindly.
Strings: pointers to char and arrays of pointers
C has no “string” type: a string is an array of char that ends with the null character '\0'. And since it’s an array, you walk it with a pointer just like the numbers, except that the end isn’t marked by a size but by that '\0'.
#include <stdio.h>
int main(void) {
char dest[] = "Teror"; /* 5 letters + '\0' */
const char *stops[] = {"San Telmo", "Tamaraceite", "Teror"};
int letters = 0;
printf("sizeof dest = %zu\n", sizeof dest);
for (char *p = dest; *p != '\0'; p++) { /* up to the null character */
printf("%c", *p);
letters++;
}
printf(" has %d letters\n", letters);
for (int i = 0; i < 3; i++) /* each box holds an address */
printf("stops[%d] = %-11s starts at %p\n", i, stops[i], (void *)stops[i]);
return 0;
}
Output:
sizeof dest = 6 Teror has 5 letters stops[0] = San Telmo starts at 0x5603093ca008 stops[1] = Tamaraceite starts at 0x5603093ca012 stops[2] = Teror starts at 0x5603093ca01e
sizeof destgives 6, not 5: “Teror” is 5 letters plus the'\0'C adds on its own. It’s the same thing that happens in the IC2 notes withsizeof(cad)for “prueba”, which gives 7.- The
formovespletter by letter and stops when*pis the null. That pattern (*p != '\0') is the heart ofstrlen,strcpyand every function in<string.h>. stopsis an array of pointers: each box doesn’t hold a text, it holds the address where a text begins. Its addresses (0x5603…) look nothing like the stack ones: they’re in the program’s read-only data area, where the compiler leaves string literals. That’s why we declare themconst char *: those letters can’t be modified.
Forgetting room for the '\0'. char line[4] = "L201"; compiles, but leaves no space for the null, and any function that walks it will keep reading memory until it finds a zero by chance. For 4 letters you need 5 boxes.
Pointer to pointer
If a pointer is a variable, it also has an address, and that address can be stored in another pointer. A pointer to pointer is declared with two asterisks:
#include <stdio.h>
int main(void) {
int credit = 845;
int *p = &credit;
int **pp = &p; /* pointer to a pointer to int */
printf("credit = %d, *p = %d, **pp = %d\n", credit, *p, **pp);
**pp = 690; /* another 1.55 euro trip, two hops away */
printf("credit now = %d\n", credit);
printf("&credit = %p, *pp = %p\n", (void *)&credit, (void *)*pp);
return 0;
}
Output:
credit = 845, *p = 845, **pp = 845 credit now = 690 &credit = 0x7ffd7ec3ea74, *pp = 0x7ffd7ec3ea74
**pp makes two hops: *pp is p (the address of credit) and **pp is what’s there, the credit. That’s why **pp = 690; charges another trip. Where does this appear in IC2? In main‘s char *argv[], which is an array of pointers (and can be written char **argv), and in functions that need to change where the caller’s pointer points, as you’ll see in dynamic memory.
Common mistakes with pointers
A summary of what breaks programs most often in the lab, from most to least frequent:
- Using an uninitialised pointer. Always initialise it: to a valid address or to
NULL. - Dereferencing
NULL. Before*p, checkif (p != NULL)if the pointer comes from a search or frommalloc. - Going past the end of the array.
<=where<belonged, or a pointer that runs off the end. Compile with ASan when in doubt. - Printing an address with
%dor%x. The IC2 notes use%x, but on a 64-bit system the address doesn’t fit in anintand gcc warns. The right way is%pwith(void *). int* p, q;thinking they are two pointers.*p++when you meant(*p)++. The++goes before the*: it moves the pointer, not the value. You’ll see it in “What’s wrong here?” in the exercises.
Watch it step by step in Python Tutor
Python Tutor runs code line by line and draws memory: every variable in its box and every pointer as an arrow. Pointers are where it shines most. Go there, choose C in the language drop-down and paste this program (fixed data, no scanf):
#include <stdio.h>
int main(void) {
int credit = 1000;
int *p = &credit;
*p = *p - 155;
int boarding[4] = {12, 7, 9, 4};
int total = 0;
for (int *q = boarding; q < boarding + 4; q++) {
total += *q;
}
printf("credit = %d, total = %d\n", credit, total);
return 0;
}
What to watch step by step:
- After
int *p = &credit;, the box forpdoesn’t show a number: it shows an arrow tocredit. That arrow is the drawing of the address. - On
*p = *p - 155;the value that changes iscredit‘s (from 1000 to 845);p‘s arrow stays the same. - When
boardingis declared you’ll see four boxes in a row. In thefor,q‘s arrow starts at box 0 and jumps one box on everyq++. - On the last lap
qpoints past the array and the loop ends without reading it. There it is, drawn, why the limit is<and not<=.
At the end you should get credit = 845, total = 32 (12 + 7 + 9 + 4 = 32).
Summary
| I want to… | Code | Bus example |
|---|---|---|
| Declare a pointer | int *p; | pointer to a credit |
| Store an address | p = &credit; | p points at the pass |
| Read what it points to | *p | 1000 |
| Write to what it points to | *p = *p - 155; | credit drops to 845 |
| A pointer to nothing | int *p = NULL; | no pass yet |
| Move one element | p + 1, p++ | next stop |
| Position inside the array | p - boarding | stop number |
| Walk a string | for (p = s; *p != '\0'; p++) | letters of “Teror” |
| Print an address | printf("%p", (void *)p); | 0x7ffc… |
Three ideas to take away:
- A pointer holds where some data is; with
*you go there and with&you get the address of a variable. p + 1moves one element, andv[i]is just another way of writing*(v + i).- C doesn’t watch memory for you: initialise your pointers, respect the limits with
<and, if something smells odd, compile with ASan.
Now it’s time to use all this in complete programs. In the pointers in C practice we swap the route’s origin and destination, walk its stops in both directions, build the day summary with a function that returns several results and look for stops with a pointer that may come back as NULL.
Did this article help you? Here is how you can support SergioLearns →




What is an operating system — theory with your laptop on a study afternoon, a write() call and a CPU that never rests (and the three programs that weren’t running at the same time)