Learn › Learn C › C Foundations
gcc - a hands-on Linux lab on a real virtual machine.
A variable lives at a numbered spot in memory called an address. The & operator takes that address, follows it, and writing through a pointer changes the original variable: x = 42, p = &x, then p = 99 makes x 99, proven live on the lab VM. Covers pointer declaration and dereference, arrays as addresses, the missing-& bug and its segfault, all with real captured output. Hands off to Lab: Max of Three, where argv is the array of pointers you can now read.
Remember the functions lesson. When you call a function in C, every argument is copied. The function works on private copies, and you watched that live in gdb: add received its own a=2 and b=40 in its own stack frame.
That rule has a consequence that bites. Look at this program:
void wipe(int n) { n = 0; }
int main(void) { int x = 42; wipe(x); /* x is STILL 42 here */ return 0; }
wipe was supposed to zero out x. It zeroed a copy. The copy died when wipe returned, and x never felt a thing. The change vanished.
And yet C programs change variables through function calls all the time. Something must let a function reach back to the caller's actual variable instead of a copy. That something is the subject of this lesson, and it is the single most important idea in C.
First, a picture of memory. Your computer's memory is one enormous row of numbered boxes. Each box holds one byte. The number on a box is its address.
Every variable you declare lives in some of those boxes. The int x = 42 you have written since the types lesson occupies 4 boxes in a row. The name x exists only in your source code. At the machine level there is just an address and 4 bytes.
A pointer is a variable whose value is the address of another variable. Think of a house and a slip of paper with the house's street address written on it. The slip is not the house. It is small, you can copy it, you can hand it to someone, and whoever holds it can find the house and change what is inside.
C gives you two operators for this, and they are opposites:
&x reads as the address of x. It writes the slip of paper.*p reads as the thing p points at. It follows the slip to the house.& takes an address. * follows one. That pair is the whole mechanism. The rest of this lesson is watching it work on a real machine.
The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every program output shown was captured from the real lab VM (Debian 12, gcc 12.2.0). One thing will differ on your machine: the addresses. Linux places each program at a randomized position in memory on every run, a security defense called ASLR. This lesson keeps the one captured address, 0x7ffd5e189ee4, across all its steps so you can follow a single value. The behavior is identical everywhere.
C was built to write operating systems. An operating system's whole job is managing memory: which process owns which boxes, where a file's bytes land, where the network card drops an incoming packet. A language for that work has to talk about addresses directly, so C does.
You will reach for pointers constantly, for three reasons that matter right now:
Every syscall you will ever trace, every buffer the kernel fills, is a pointer crossing a boundary. Learn this now and the rest of the track opens up.
Time to print one. The file pointers.c is already in your sandbox home directory:
#include <stdio.h>
int main(void) { int x = 42; printf("x = %d, &x = %p\n", x, (void *)&x); return 0; }
Two new things on line 5. %p is the printf conversion for printing an address, the way %d prints an int. And the address is wrapped in (void *), which converts it to the generic pointer type %p expects. gcc -Wall stays quiet if you skip the cast, and on this machine the address prints the same either way, but the C standard only promises %p works on a void *. Make the cast a habit now and it never bites you later.
>>> Names exist only in your source code. By the time the program runs, x is an address and 4 bytes of memory. %d prints what is IN the boxes: 42. %p prints the number ON the boxes: the address. Compile it and see what an address actually looks like.
prompt: operator@camp:~$ answer: gcc -Wall pointers.c -o pointers && ./pointers output: x = 42, &x = 0x7ffd5e189ee4 hint: One line, two commands joined by &&: gcc -Wall pointers.c -o pointers && ./pointers
There it is. x holds 42, and x lives at address 0x7ffd5e189ee4. Addresses are written in hexadecimal, base 16, because in decimal this one would be roughly 140 trillion and unreadable. It is a genuine box number inside your process's memory. Addresses that start with 0x7ff belong to the stack on this machine, the region where local variables live. You met the stack in the functions lesson; now you have a street address inside it.
Run ./pointers twice and the address changes. That is ASLR at work: Linux randomizes where the stack sits on every single run, so an attacker can never predict where your variables live. Your machine will print a different address than this lesson shows, and a different one each run. Same shape, different number, and that is expected.
An address is a value, and values can be stored in variables. A variable that stores an address is a pointer, and it gets its own declaration syntax:
int *p = &x;
Read it in pieces: p is a pointer (the *), it points at an int (the type), and it starts out holding the address of x (the &x). The type matters. When C follows p later, the int tells it to read exactly 4 bytes and interpret them as a whole number.
#include <stdio.h>
int main(void) { int x = 42; int *p = &x; printf("x = %d, &x = %p\n", x, (void *)&x); printf("p = %p, *p = %d\n", (void *)p, *p); return 0; }
Line 7 uses p both ways. (void *)p prints the address stored in p. *p follows that address and prints whatever lives there.
>>> p holds the slip of paper: the address of x. Printing p shows that address. But *p FOLLOWS the slip: go to the address, read the int living there. That int is x, and x is 42. One pointer, two views: the number ON the boxes, and the value IN them.
prompt: operator@camp:~$ answer: gcc -Wall pointers.c -o pointers && ./pointers output: x = 42, &x = 0x7ffd5e189ee4 p = 0x7ffd5e189ee4, *p = 42 hint: Same loop as before: gcc -Wall pointers.c -o pointers && ./pointers
Compare the two lines. &x and p print the identical address, 0x7ffd5e189ee4. That is the proof that p holds exactly the address of x, nothing more mysterious than that. And *p prints 42, because following the address lands on x's 4 bytes. Here is what those bytes look like in memory right now:
{ "kind": "memory", "cells": [ { "addr": "0x7ffd5e189ee4", "bytes": "2a 00 00 00", "label": "x (int, 4 bytes, value 42)" }, { "addr": "0x7ffd5e189ee8", "bytes": "e4 9e 18 5e fd 7f 00 00", "label": "p (int *, 8 bytes)", "pointer": "0x7ffd5e189ee4" } ] }
Two details worth noticing. First, p is a variable too: it occupies 8 boxes of its own, at its own address, sitting near x on the stack. Remember ptr=8 from the types lesson: on this 64-bit machine, every pointer is 8 bytes. Second, p's bytes ARE the address of x. The machine stores the lowest byte first, so read e4 9e 18 5e fd 7f in reverse and you get 0x7ffd5e189ee4. That lowest-first ordering is called little endian, and it gets a proper look in a later lesson. The exact spot where the compiler places p is its own choice; the link from p to x is the part that never varies.
So far the pointer only read. The payoff is writing. Put *p on the LEFT side of an assignment and you write into the boxes it points at:
#include <stdio.h>
int main(void) { int x = 42; int *p = &x; printf("x = %d, &x = %p\n", x, (void *)&x); printf("p = %p, *p = %d\n", (void *)p, *p); *p = 99; printf("after *p = 99: x = %d\n", x); return 0; }
Line 8 is the whole trick. It never mentions x. It says: follow the address stored in p, and put 99 in whatever you find there.
>>> There is no copy anywhere in this program. p holds the address of x, so *p = 99 writes into the exact 4 boxes where x lives. x IS those boxes. Ask for x afterwards and you read 99. A variable just changed without its name appearing in the statement that changed it.
prompt: operator@camp:~$ answer: gcc -Wall pointers.c -o pointers && ./pointers output: x = 42, &x = 0x7ffd5e189ee4 p = 0x7ffd5e189ee4, *p = 42 after *p = 99: x = 99 hint: The habit is fixed now: gcc -Wall pointers.c -o pointers && ./pointers
after *p = 99: x = 99. The write went through the pointer, landed on x's boxes, and x changed. This is the mechanism the cold open was missing. Here is the same memory picture after line 8 ran:
{ "kind": "memory", "cells": [ { "addr": "0x7ffd5e189ee4", "bytes": "63 00 00 00", "label": "x (int, value now 99, hex 63)" }, { "addr": "0x7ffd5e189ee8", "bytes": "e4 9e 18 5e fd 7f 00 00", "label": "p (unchanged, still points at x)", "pointer": "0x7ffd5e189ee4" } ] }
Now the vanishing change from the cold open has a fix. Pass the ADDRESS of x instead of its value, and let the function write through it:
void wipe(int *n) { *n = 0; }
int main(void) { int x = 42; wipe(&x); /* x really is 0 here */ return 0; }
C still copies the argument. But the argument is now the slip of paper, and a copy of an address still leads to the same house. wipe follows it and zeroes the caller's x. This is exactly how scanf changes your variables, and it is why scanf makes you write the & in front of them.
Declare int scores[3] = {10, 20, 30}; and C reserves 12 boxes in a row: three ints, 4 bytes each, side by side. Here is the part that surprises most people. The array's name, used by itself in an expression, is not a bundle of all three values. It becomes the address of the first element, the same value as &scores[0].
Pointer arithmetic moves in whole elements, not bytes. scores + 1 is the address 4 bytes further along, where the second int lives. In fact scores[1] is DEFINED as *(scores + 1): start at the array's address, step forward one element, follow the pointer.
#include <stdio.h>
int main(void) { int scores[3] = {10, 20, 30}; printf("%d %d\n", scores[1], *(scores + 1)); return 0; }
>>> They are the same expression. scores[1] is square-bracket shorthand for *(scores + 1), so both step to the second element and read 20. This is guaranteed by the C language itself, not a quirk of one compiler. Every array access you have ever seen is pointer arithmetic wearing brackets.
File this away for the lab: the argv you met in Hello, machine is exactly this pattern, an array of pointers, one for each argument's text. You are minutes away from indexing into it for real.
Here is the most common pointer bug in existence. The diff from working code is one character on line 5:
#include <stdio.h>
int main(void) { int x = 42; int *p = x; /* meant &x */ printf("*p = %d\n", *p); return 0; }
Without the &, p does not receive the address of x. It receives x's VALUE, 42, stored as if it were an address. The slip of paper now says house number 42. Here is memory in that broken state:
{ "kind": "memory", "cells": [ { "addr": "0x7ffd5e189ee4", "bytes": "2a 00 00 00", "label": "x (int, value 42)" }, { "addr": "0x7ffd5e189ee8", "bytes": "2a 00 00 00 00 00 00 00", "label": "p (holds 42: not a real address, points nowhere)" } ] }
Compare this with the healthy picture two steps back. Same boxes, but p's bytes no longer form the address of anything your program owns. Then line 6 does *p: go to address 42 and read. The kernel sees a process touching memory it has no right to, refuses, and kills the program. The crash message is next.
gcc -Wall does not let this bug pass silently. Compiling broken.c prints this, the exact text from the lab machine:
broken.c: In function 'main':
broken.c:5:14: warning: initialization of 'int *' from 'int' makes pointer from integer without a cast [-Wint-conversion]
5 | int *p = x; /* meant &x */
| ^
A warning is not an error. gcc still produces a binary, so the broken program compiles and then dies at runtime. Treat every pointer warning as an error. With -Wall in your habit from day one, the compiler catches the missing & before the kernel has to.
Three messages you will meet in pointer work. Learn to read them calmly now, because every C engineer sees them for years.
1. The crash
Segmentation fault
The kernel caught your program touching memory it does not own and killed it with signal 11, SIGSEGV. Run echo $? afterwards and the exit code is 139: that is 128 plus the signal number 11, the shell's code for death by signal. What to check first: every * in the program. One of them followed a pointer that never held a real address. The usual suspects are a missing &, a pointer that was never initialized, or NULL.
2. The same crash, under the debugger
Program received signal SIGSEGV, Segmentation fault.
Run a crashing program inside gdb and this line replaces the bare shell message. It is good news: gdb freezes the program at the moment of death and can point at the exact line that followed the bad pointer. What to check first: nothing yet, just recognize it. Driving gdb is its own lesson in module 2.
3. The everyday compile error
broken.c: In function 'main':
broken.c:5:13: error: expected ';' before '}' token
5 | return 7
| ^
You met this one in Hello, machine, and pointer code gives you extra chances to earn it, because lines full of * and & invite typos. It means the statement on the named line never ended. What to check first: the line gcc names, then the line above it; the missing semicolon is almost always on one of the two. Fix the first error and recompile, because later errors are often echoes of the first.
Scaffolding off. No code and no command are shown in this step.
A teammate insists a C variable can only change when its name appears on the left of an equals sign. In your program, x is 42 and p already holds &x. Prove the claim wrong.
>>> p = 99; overwrites the ADDRESS stored in p, breaking the pointer and changing no int anywhere. &p = 99; does not even compile, because an address is not a thing you can assign into. *p = 99; follows the stored address to x's boxes and writes 99 into them. The name x never appears, and x changes anyway.
Now prove it at the machine. The final pointers.c, with the write on line 8, is in your home directory. Recall the whole build habit yourself: warnings on, binary named pointers, compile and run in one line.
prompt: operator@camp:~$ answer: gcc -Wall pointers.c -o pointers && ./pointers output: x = 42, &x = 0x7ffd5e189ee4 p = 0x7ffd5e189ee4, *p = 42 after *p = 99: x = 99 hint: gcc -Wall, then the .c file, then -o and the binary name, then && ./pointers
Three lines, one story. x lives at an address. & captured that address into p. Writing through *p changed x from 42 to 99 without ever naming it. If the teammate wants a demonstration, this eleven-line file is it.
You earned this table. Every row is something you just ran or predicted:
The two-operator summary, one last time: & takes an address, * follows one. Everything else in this lesson was those two moves. And remember the machine facts that came with them: pointers are 8 bytes here because the machine is 64-bit, and every address you print is randomized by ASLR on each run.
You have now watched every core move on a real machine. The & operator printed an address. int *p stored it. *p read through it. *p = 99 wrote through it and changed the original variable. And an array name turned out to be an address in disguise.
Next comes Lab: Max of Three, the module lab, and it is a different animal from this lesson. A real VM boots for you with gcc installed. You write a complete C program from scratch: read three integers from argv, print the largest, and return it as the exit code. No code is shown anywhere. The grader compiles your file, runs it against fixed inputs, and checks both stdout and the exit code, so any correct program passes.
Notice what argv is, now that you can see it: an array of pointers, one slip of paper per argument. You spent this lesson earning the right to read that declaration. Go write the program.
Practice Pointers and memory in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.