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.

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 of main live. They are huge numbers because a process’s virtual memory is huge.
  • line (1 byte) is at …25b and credit (4 bytes) at …25c: right next to each other. price is at …260, four bytes past credit. The compiler places them however it sees fit, not in the order you declare them.
  • sizeof confirms 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:

OperatorReads asYou give itIt gives you back
&x“address of x”a variableits address
*p“what p points to”an addressthe 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; declares p and, in the same line, stores the address of credit in it. Here the * is the declaration one (type) and the & is the “address of” operator.
  • &credit and p print the same address: p is a copy of that number.
  • *p is 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 through p, subtracts the trip and writes the result at that same address. Since that address belongs to credit, credit becomes 845 even though it doesn’t appear on the line.
  • The last line shows that they undo each other: *&credit is “what’s at the address of credit”, that is, credit. And &*p is “the address of what p points to”, that is, p.
A pointer points to creditBox p contains the address 0x7ffc726ab67c, which is the address of credit. Through *p the credit goes from 1000 to 845.int *p0x7ffc726ab67cholds an addressp lives in its own box (8 bytes)int credit1000 → 845at 0x7ffc726ab67cp = &credit*p = *p – 155 writes to credit, not to p
p doesn't contain 1000: it contains where the 1000 is. With * you go to that address.

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 writeWhat changesExample
p = &other;where p pointsp stops pointing at credit
*p = 690;the data pointed tocredit becomes 690
int *p = &credit;declares and points at oncethe * 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 to int): …cc0, …cc4, …cc8. Goes up 4 at a time.
  • pc (pointer to char): …d13, …d14, …d15. Goes up 1 at a time.
  • pd (pointer to double): …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).

Pointer arithmeticArray of 5 ints with their real addresses, going up 4 by 4. pi + 2 points to box 2, which holds 9. Below, the +1 step for char, int and double.int boarding[5]1279415…cc0[0]…cc4[1]…cc8[2]…ccc[3]…cd0[4]pipi + 1pi + 2*(pi + 2) = 9How far +1 moves depending on the type:char *…d13 → …d14 → …d15+1 byteint *…cc0 → …cc4 → …cc8+4 bytesdouble *…ce0 → …ce8 → …cf0+8 bytes
pi + 1 does not add one byte: it adds one element. With int that is 4 bytes; with double, 8.

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.

ExpressionArray boardingPointer p = boarding
[i] and *(… + i)Yes, the sameYes, the same
++ / -- / =No: compile errorYes
sizeofthe 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 dest gives 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 with sizeof(cad) for “prueba”, which gives 7.
  • The for moves p letter by letter and stops when *p is the null. That pattern (*p != '\0') is the heart of strlen, strcpy and every function in <string.h>.
  • stops is 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 them const char *: those letters can’t be modified.
Strings and an array of pointersThe string Teror takes 6 boxes because it ends with the null character. The stops array holds three addresses, each pointing to the start of a text.char dest[] = "Teror";'T''e''r''o''r''\0'[0][1][2][3][4][5]sizeof = 65 letters + the nullconst char *stops[3]…008[0]"San Telmo\0"0x5603093ca008…012[1]"Tamaraceite\0"0x5603093ca012…01e[2]"Teror\0"0x5603093ca01e
A string is an array of char that ends in '\0'; an array of strings is an array of addresses.

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:

  1. Using an uninitialised pointer. Always initialise it: to a valid address or to NULL.
  2. Dereferencing NULL. Before *p, check if (p != NULL) if the pointer comes from a search or from malloc.
  3. Going past the end of the array. <= where < belonged, or a pointer that runs off the end. Compile with ASan when in doubt.
  4. Printing an address with %d or %x. The IC2 notes use %x, but on a 64-bit system the address doesn’t fit in an int and gcc warns. The right way is %p with (void *).
  5. int* p, q; thinking they are two pointers.
  6. *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:

  1. After int *p = &credit;, the box for p doesn’t show a number: it shows an arrow to credit. That arrow is the drawing of the address.
  2. On *p = *p - 155; the value that changes is credit‘s (from 1000 to 845); p‘s arrow stays the same.
  3. When boarding is declared you’ll see four boxes in a row. In the for, q‘s arrow starts at box 0 and jumps one box on every q++.
  4. On the last lap q points 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…CodeBus example
Declare a pointerint *p;pointer to a credit
Store an addressp = &credit;p points at the pass
Read what it points to*p1000
Write to what it points to*p = *p - 155;credit drops to 845
A pointer to nothingint *p = NULL;no pass yet
Move one elementp + 1, p++next stop
Position inside the arrayp - boardingstop number
Walk a stringfor (p = s; *p != '\0'; p++)letters of “Teror”
Print an addressprintf("%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 + 1 moves one element, and v[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.

Next step: practicePointers in C — practice with 4 bus programs: swap, walks, the day summary and a search that can return NULL (to lose your fear of them)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *