1.1 Programming Fundamentals

Phase 1 · Programming Foundations · Study time: 90–150 h

How a program really runs: compilation and linking, memory as numbered bytes, pointers, stack vs heap, arrays and pointer arithmetic, recursion, undefined behavior, and debugging with a real debugger. The full chapter is written out below.

Here is the one idea this whole chapter teaches, in plain words: a computer only ever reads and writes numbers stored in memory — that is the only thing it really does. Every impressive thing a game does is, underneath, exactly that. So the first step to getting good is to actually see what "memory" is and how your code changes it. We will build that picture slowly, one small step at a time, and check every claim by running real code and looking at the real output.

This is a long chapter on purpose — treat it like a class. Do not just read it. Type every example, compile it, and run it yourself. The understanding comes from watching the output on your own screen, not from my words. Almost every bug in C or C++ is the same mistake: you thought a piece of memory held one value, but it actually held another. Once you can picture memory clearly, those bugs stop being scary.

0. Setup: getting a compiler and running your first program

Before anything else, you need a compiler — the program that turns your C code into something the computer can run. Skip this if you already have one.

Now make a file called hello.c with this in it:

#include <stdio.h>

int main(void) {
    printf("Hello!\n");   // print the word Hello and a new line
    return 0;             // 0 tells the operating system "everything went fine"
}

Compile it, then run it:

$ gcc -Wall -Wextra -g hello.c -o hello
$ ./hello
Hello!

Line by line, what you just typed: gcc is the compiler. -Wall -Wextra turn on warnings (they tell you about suspicious code — always keep them on). -g keeps extra info so a debugger can help you later. hello.c is your source file. -o hello says "name the finished program hello". Then ./hello runs it. If you saw Hello!, you are ready. If you got an error, read it — it usually names the exact line.

Tip Keep every example from this chapter in its own file and run it. When something surprises you, change one line and run it again. That habit — poke it and see what happens — is most of how programmers actually learn.

1. What happens when you run a program

You write your program as text. The CPU (the chip that runs it) only understands numbers called machine instructions. Four tools turn your text into those numbers. Why learn their names? Because when a build fails, the error comes from one of these four tools, and knowing which one instantly tells you what kind of mistake you made.

We will use a program split into two files, because real projects have hundreds or thousands of files, not one. A game engine is not a single file — it is a giant pile of them, linked together.

// math.c  --  this file contains the REAL code of add()
int add(int a, int b) { return a + b; }
// main.c  --  this file USES add(), but only promises it exists
#include <stdio.h>
int add(int a, int b);            // a DECLARATION: "a function called add exists somewhere"

int main(void) {
    printf("%d\n", add(2, 3));
    return 0;
}

Notice the difference between the two lines about add. In math.c we define it — we write its body, the actual code. In main.c we only declare it — we promise "a function shaped like this exists, trust me, its real code is elsewhere." That promise is enough for the compiler to keep going. Build both files together and run:

$ gcc -Wall -Wextra -g main.c math.c -o app
$ ./app
5

The four tools, one at a time

1. The preprocessor. This is a plain text tool. It finds every line starting with # and acts on it. #include <stdio.h> means "paste the entire contents of the file stdio.h right here." #define MAX 100 means "everywhere you see MAX below, replace it with 100." After the preprocessor runs, there are no #include or #define lines left — just one big blob of plain C. It does not understand types or logic; it only shuffles text.

2. The compiler. It reads one blob at a time (one file, completely separate from the others) and translates it into machine instructions, saved in an object file (a .o file). Here is the key thing beginners miss: while compiling main.c, the compiler has never seen math.c. So when it reaches add(2, 3), it does not know where add actually lives in memory. It just trusts your declaration, writes down "call the function named add — someone will fill in the real location later," and moves on.

3. The linker. Now we have two object files, main.o and math.o, each with blanks where they referred to things in the other file. The linker's job is to look through all of them, find the real add inside math.o, and fill in the blank in main.o so the call points to the right place. Only after linking do the separate files become one working program (the executable, app).

4. The loader. This is part of the operating system. When you type ./app, the loader copies the program into memory and jumps to main to start it.

Now you can read the most common beginner error

Watch what happens if we forget to include math.c on the command line:

$ gcc main.c -o app
/usr/bin/ld: /tmp/ccQ2v.o: in function `main':
main.c:(.text+0x15): undefined reference to `add'
collect2: error: ld returned 1 exit status

Look at the very first word: ld. That is the name of the linker. So this is a linker error, not a compiler error — and that single fact tells you what went wrong. The compiler was perfectly happy: your declaration promised add existed, so main.c compiled with no complaints. The problem came later, at link time, when the linker went looking for the real body of add and found nothing — because we never gave it math.o. "Undefined reference to add" almost never means your code is wrong. It means a definition is missing from the build: a file you forgot to compile, or a library you forgot to link.

Tip Before reading any build error's message, check which tool printed it. Compiler errors are about the syntax and types inside one file ("expected ';'", "x was not declared"). Linker errors are about definitions across files ("undefined reference", "multiple definition"). They live in different places and need completely different fixes, so telling them apart saves you hours.
Check yourself If you see "undefined reference to sqrt", is that the compiler or the linker complaining? (Answer: the linker — the function was declared in a header you included, so the compiler was fine, but its real code lives in the math library, which you forgot to link with -lm.)

2. Memory is a huge row of numbered boxes

Picture your computer's memory (its RAM) as one enormously long row of boxes. Each box holds exactly one byte. A byte is a number from 0 to 255 (it is made of 8 bits, and 8 bits can count from 0 to 255). Each box has a permanent number called its address, just like every house on a street has a house number. That is the entire model. Everything else in this chapter is built on it.

A variable is simply a name we give to one or more boxes. The variable's type decides two things: how many boxes it uses, and how to read the bits inside them. Let us prove all of this with code. Two tools: &x means "the address of x", and sizeof(T) means "how many boxes type T uses".

#include <stdio.h>

int main(void) {
    int   x = 42;
    char  c = 'A';
    double d = 3.5;

    printf("int    uses %zu boxes\n", sizeof(int));     // usually 4
    printf("char   uses %zu boxes\n", sizeof(char));    // always 1
    printf("double uses %zu boxes\n", sizeof(double));  // usually 8

    printf("x lives at address %p\n", (void*)&x);
    printf("c lives at address %p\n", (void*)&c);
    return 0;
}

Run it. You will see something like this. The addresses will be different every time you run the program — the operating system puts your variables in a different spot each run on purpose, so nothing can depend on an exact address:

int uses 4 boxes char uses 1 boxes double uses 8 boxes x lives at address 0x7ffee1a03a1c c lives at address 0x7ffee1a03a1b

A short word on hex, because addresses use it

That 0x7ffee1a03a1c looks scary but it is just a number written in hexadecimal (base 16). Normal numbers use 10 digits (0-9). Hex uses 16: 0-9 then a, b, c, d, e, f (where a=10, b=11, ... f=15). Programmers use hex because one hex digit is exactly 4 bits, so two hex digits are exactly one byte. When you see 0x2a, that is hex for the number 42. You do not need to do hex math in your head; just know that 0x means "the following is a hex number."

Looking inside the boxes: little-endian

Our int x = 42 takes 4 boxes. What is actually stored in them? Here is a small surprise. On normal PCs and phones, a multi-byte number is stored with its smallest part first. This is called little-endian. Since 42 is 0x2a in hex, the four boxes hold 2a 00 00 00, not 00 00 00 2a:

address box value what it is -------------- --------- ------------------------------------- 0x7ffee1a03a1b 41 char c = 'A' (letter 'A' is number 65 = hex 41) 0x7ffee1a03a1c 2a | 0x7ffee1a03a1d 00 | int x = 42, spread across 4 boxes, 0x7ffee1a03a1e 00 | smallest part first (little-endian) 0x7ffee1a03a1f 00 |

You will not usually care about the exact byte order — but when you save data to a file on one computer and load it on another, or send it over a network, endianness can bite you, so it is good to know the word.

The one thing you must burn into your memory from this section: values live at addresses, and things declared next to each other tend to sit next to each other in the boxes. Look at the output above — c is at ...1b and x starts at ...1c, right next to each other. This "next to each other" fact is quietly the most important idea for performance in the whole curriculum. It is why arrays are fast, why some data structures are slow, and why CPU caches exist. We will keep coming back to it.

Check yourself Why do the printed addresses change every time you run the program? (Answer: on purpose, for security — if addresses were always the same, attackers could rely on them. It also means you must never hard-code an address.)

3. The stack: where local variables actually live

The variables you declare inside a function (called local variables) do not float in some abstract space. They live in a specific region of memory called the stack. Understanding the stack explains a whole family of bugs, so we will go slowly.

Every time you call a function, the program instantly sets aside a block of stack memory for that call. This block is called a stack frame. The frame holds that call's local variables, plus a small note recording where to jump back to when the function finishes (the "return address"). When the function returns, its frame is thrown away right then and there.

Let us trace it step by step. Here is a tiny program:

int square(int n) {
    int result = n * n;   // 'result' and 'n' live in square's frame
    return result;
}

int main(void) {
    int a = 5;            // 'a' lives in main's frame
    int b = square(a);    // while square runs, its frame sits on top of main's
    return 0;
}

Watch the stack as this runs. Time flows downward:

STEP 1: main starts. STEP 2: main calls square(5). +---------------------+ +---------------------+ | main: a = 5 | | square: n=5, result=? | <- new frame on top | main: b = ? | +---------------------+ +---------------------+ | main: a = 5, b = ? | +---------------------+ STEP 3: square computes STEP 4: square returned. Its result = 25, returns it. frame is GONE. b = 25. +---------------------+ +---------------------+ | square: n=5, res=25 | gone-> | main: a = 5, b = 25 | +---------------------+ +---------------------+ | main: a = 5, b = ? | +---------------------+

The picture shows the two facts you must never forget. First, setting up a frame is extremely cheap — the computer just moves one internal marker (the "stack pointer") to reserve the space, which is why local variables are basically free to create. Second, and this is what causes bugs: a frame is temporary. The instant square returns, its n and result are gone, and any address pointing into that frame is now pointing at abandoned space.

There is also a hard size limit on the stack — usually just a few megabytes. That sounds like a lot, but we will see in section 7 how it can run out.

4. Pointers

This is the big one. A pointer is a variable that stores an address instead of a normal value. That is the entire definition — but it takes a while to feel natural, so we will build it up carefully.

First, why would you ever want a variable that holds an address? Because sometimes one part of your program needs to reach a variable that lives in another part. Think of a function that must change its caller's variable, or a list where each item needs to point at the next item. The name of a variable does not travel between functions — a in main is invisible inside another function. But the variable's address can travel anywhere. A pointer is the thing that carries an address around.

int  x = 10;
int *p = &x;      // read this as: p is a pointer-to-int, holding the ADDRESS of x
x p +-----------+ +------------------+ | 10 | <------------ | address of x | +-----------+ +------------------+ a normal int, value 10 a pointer; its value IS x's address

The two symbols, and why * is confusing

Beginners tangle up * because it means two different things depending on where it appears:

Watch all three work together, and run it yourself:

int  x = 10;
int *p = &x;           // p now holds x's address

printf("%d\n", x);    // 10  -- read x directly
printf("%d\n", *p);   // 10  -- follow p to x, read the value there
*p = 99;               // follow p to x, and WRITE 99 into that box
printf("%d\n", x);    // 99  -- x itself changed!

Sit with that last line. We never wrote the name x in the assignment *p = 99, yet x changed to 99. That is the whole power of pointers in one example: p gave us a second door to the same box. If you pass p into a function, that function now has a door into your x too — which is exactly how a function can change its caller's variable.

A pointer is itself a variable in a box

Here is a detail that makes pointers click. A pointer is just a variable, so it lives in memory too, and it has its own address. Its value happens to be an address. On a 64-bit computer, an address is 8 bytes, so every pointer — no matter what it points to — takes 8 boxes:

printf("%zu\n", sizeof(int));    // 4  -- an int is 4 boxes
printf("%zu\n", sizeof(int*));   // 8  -- a pointer is 8 boxes (a 64-bit address)
printf("%zu\n", sizeof(char*));  // 8  -- still 8; the address is the same size
printf("%zu\n", sizeof(double*));// 8  -- still 8

The two ways pointers go wrong

Null pointers. A pointer can hold the special address 0, which means "I point at nothing." We write it as NULL. If you dereference a null pointer, the program crashes immediately with a "segmentation fault". This is actually the friendly kind of failure: it happens right at the mistake, loudly, every time, so it is easy to find. The habit that prevents it: if a pointer might be null (for example, one that came back from a function that can fail), check it before you follow it: if (p != NULL) { ... }.

int *p = NULL;
printf("%d\n", *p);   // CRASH: "Segmentation fault" -- you followed a pointer to nothing

Dangling pointers. These are the nasty ones. A dangling pointer still holds an address, but the thing that used to live there has already been destroyed. From section 3, we know a local variable dies when its function returns — so returning its address creates a dangling pointer:

#include <stdio.h>

int *broken(void) {
    int local = 5;
    return &local;     // returning the address of a variable that is about to die
}                      // 'local' is destroyed here; the returned address now points at junk

int main(void) {
    int *bad = broken();
    printf("%d\n", *bad);   // undefined behavior: maybe 5, maybe garbage, maybe a crash
    return 0;
}
Common mistake A pointer into a stack frame is only valid while that frame's function is still running. Reading through a dangling pointer sometimes gives the old value (so it looks like it "works" and you ship the bug), sometimes gives garbage, and sometimes corrupts other data and crashes much later in unrelated code — which is a nightmare to track down. Two rules keep you safe: never return the address of a local variable, and never keep using a pointer after the thing it points to has been freed or has gone out of scope.

5. The heap: memory that stays until you free it

Section 3 left us with a real problem. Stack frames vanish when a function returns, so how do you create data that must live longer than the function that made it — a level you loaded, a character you built, a saved game? The answer is a second region of memory called the heap. You ask the heap for memory with malloc (short for "memory allocate"), and you give it back with free. Nothing on the heap is cleaned up automatically. Its lifetime is entirely in your hands, which is both the power and the danger.

#include <stdio.h>    // printf lives here
#include <stdlib.h>   // malloc and free live here

int *make_squares(int n) {
    int *a = malloc(n * sizeof(int));   // ask the heap for room for n ints
    if (a == NULL) return NULL;         // malloc returns NULL if it could not get the memory
    for (int i = 0; i < n; i++)
        a[i] = i * i;                   // fill the boxes: 0, 1, 4, 9, ...
    return a;                           // SAFE this time: heap memory does not die on return
}

int main(void) {
    int *sq = make_squares(5);          // sq now points to {0,1,4,9,16} on the heap
    if (sq != NULL) {
        for (int i = 0; i < 5; i++)
            printf("%d ", sq[i]);
        printf("\n");                   // prints: 0 1 4 9 16
        free(sq);                       // hand the memory back to the heap, exactly once
    }
    return 0;
}

Compare this carefully with the broken function in section 4. There, returning the address of a local was a disaster because the stack frame died. Here, returning heap memory is perfectly safe, because heap memory does not die when the function returns — it only dies when you call free, and you get to choose when that is.

Always check malloc, and understand why

malloc can fail — if the computer is out of memory, it returns NULL instead of a real address. If you then use that NULL as if it were memory, you dereference a null pointer and crash (section 4). That is why every malloc is followed by a check. It feels like busywork on a machine with plenty of RAM, but on a phone or a console with tight memory, it matters.

The three famous heap bugs

The freedom to control lifetime by hand is exactly where three of the most common and dangerous bugs in all of C come from:

Behind all three is a single discipline, easy to say and hard to always follow: every piece of heap memory has exactly one owner, and that owner calls free exactly once, after the last time the memory is used, and never touches the pointer again. Deciding who owns each piece of memory and when they free it is called ownership, and it is genuinely one of the hardest parts of programming in C. The good news: the very next chapter, on modern C++, is largely the story of getting the compiler to handle ownership for you, with a tool called unique_ptr that calls free automatically when the owner goes away. But you cannot appreciate what that tool does for you until you have felt the manual version, which is why we do it by hand first.

Check yourself A function does int *p = malloc(40); ... ; return; and never frees p. Which bug is that, and why is it worse in a long-running game than in a small command-line tool? (Answer: a memory leak; a tool exits in a second and the OS reclaims everything, but a game runs for hours and the leak keeps growing.)

6. Arrays, strings, and pointer math

An array is a row of equal-sized items stored back to back, with no gaps between them. That "back to back, no gaps" part is where both its speed and its most common bug come from, so let us make the layout completely explicit. Here is int a[4] = {10, 20, 30, 40}:

index: 0 1 2 3 index 4 does NOT exist +------+------+------+------+ value: | 10 | 20 | 30 | 40 | +------+------+------+------+ address: a a+1 a+2 a+3 (a+4 would be one step past the end)

The name of the array, a, acts like a pointer to its first item. Because the items are all the same size and sit right next to each other, the computer can find item number i by starting at a and stepping forward i items. That stepping is called pointer math, and it means the square-bracket notation you already know is not a separate feature — it is defined in terms of pointers. These two lines mean exactly the same thing, by the rules of the language:

a[i]   is exactly the same as   *(a + i)     // "step i items forward, then read"

printf("%d\n", a[2]);        // 30
printf("%d\n", *(a + 2));    // 30  -- identical

Notice that a + 2 does not add 2 bytes — it adds 2 items. Since each int is 4 bytes, a + 2 actually moves 8 bytes forward. The compiler multiplies by the item size for you. That is why pointer math needs to know the type it is pointing at.

Strings are just arrays with a special ending

A string in C is nothing more than a char array that obeys one rule: it ends at the first zero byte, written '\0'. That zero is not decoration — it is the only way functions like printf or strlen know where the text stops, because nothing else records the length. So the word "cat" takes 4 boxes, not 3:

char name[] = "cat"; +------+------+------+------+ value: | 'c' | 'a' | 't' | '\0' | 3 letters + 1 end marker = 4 bytes +------+------+------+------+ strlen("cat") walks from the start, counting, and stops at '\0'. It returns 3.

The single most common bug in C: going past the end

Look again at our 4-item array. The valid indexes are 0, 1, 2, 3. There is no box at index 4. But nothing stops you from writing a[4] — the language trusts you. When you read or write a[4], you are touching whatever memory happens to sit just after the array. Maybe it is another variable; maybe it is important bookkeeping. Either way it is a bug, even though the code looks innocent and might not crash:

int a[4] = {10, 20, 30, 40};
a[4] = 999;   // BUG: there is no a[4]. This writes into memory that is not ours.

The most frequent version of this bug is the off-by-one loop. To visit n items you must loop while the index is strictly less than n:

for (int i = 0; i < n; i++)  ...   // CORRECT: i goes 0,1,2,3 for n=4
for (int i = 0; i <= n; i++) ...   // WRONG: i goes 0,1,2,3,4 -- that last 4 is past the end
Common mistake Using <= instead of < is so common it has a name (off-by-one), and it is exactly the bug we will hunt with tools in section 9. Whenever you write a loop over an array, pause and ask: does the last value of i actually exist in the array?

7. Functions, the call stack, and recursion

Before recursion, one crucial fact about how C passes information to functions. C passes arguments by copying them. The function gets its own private copy, so changing the copy does nothing to the caller's original:

#include <stdio.h>

void tryToChange(int n) {
    n = 999;    // this only changes the LOCAL copy inside tryToChange
}

int main(void) {
    int x = 5;
    tryToChange(x);
    printf("%d\n", x);   // still 5 -- the function changed its own copy, not x
    return 0;
}

This is why, in section 4, we had to pass a pointer to change the caller's variable. A pointer is copied too — but the copy still holds the same address, so following it reaches the same original box. Keep that in mind; it is the reason pointers exist.

Recursion, traced frame by frame

Now, the stack in motion. Every function call adds a frame (section 3); every return removes one. Recursion just means a function that calls itself. It is not magic — it is the same add-and-remove, using the same function's code, several frames deep. Here is factorial (written 3!, meaning 3 × 2 × 1 = 6):

long factorial(int n) {
    if (n <= 1) return 1;          // the STOP condition, called the base case
    return n * factorial(n - 1);   // call itself with a smaller number
}

Trace factorial(3). First the calls pile up, each one waiting on the one below it. Then, once the base case is hit, the answers come back up one at a time:

GOING DOWN (calls pile up) COMING BACK UP (answers return) factorial(3): needs 3 * factorial(2) ... must wait factorial(2): needs 2 * factorial(1) ... must wait factorial(1): n <= 1, so returns 1 <-- base case, no more waiting factorial(2): now 2 * 1 = 2, returns 2 factorial(3): now 3 * 2 = 6, returns 6 Final answer: 6

The same thing as stack frames:

At the deepest point, three frames are stacked: +----------------------+ | factorial(1) n = 1 | returns 1, then its frame is removed +----------------------+ | factorial(2) n = 2 | then returns 2, frame removed +----------------------+ | factorial(3) n = 3 | then returns 6, frame removed +----------------------+ | main() | receives 6 +----------------------+

Two lessons hide in this. First, a recursive function must have a base case — a condition that stops it — or it calls itself forever. Second, since every call uses a stack frame and the stack has a size limit (section 3), going too deep runs the stack out of space and the program dies. This crash has a name: stack overflow. Here is how to cause one on purpose:

int forever(int n) {
    return forever(n + 1);   // no base case -- calls itself endlessly
}
// Running this prints, after a moment: "Segmentation fault"
// The stack filled up with millions of frames and ran out of room.

And here is the payoff that ties this chapter together: when a real program crashes, the debugger shows you a list of the frames that were on the stack — this list is called a stack trace, and it is literally the picture above, frozen at the instant things broke. Learning to read it (section 9) is a huge part of debugging.

8. Undefined behavior: the reason C feels harsh

C and C++ give you direct, unguarded access to memory, and in return they trust you completely to follow the rules. When you break a rule, the language does not promise you a crash or an error message. It says the result is undefined behavior (often shortened to UB), which means the program is allowed to do literally anything: crash, print a wrong number, or — worst of all — appear to work perfectly today and then fail mysteriously next month on a different computer. We have already met several sources of UB; here they are together:

int x;                     // 1) never given a value
printf("%d\n", x);        //    UB: reads leftover garbage, could be anything

int a[4];
a[4] = 1;                  // 2) writing past the end of an array -- UB

int *p = NULL;
*p = 1;                    // 3) following a null pointer -- UB (usually a crash)

int big = 2147483647;      // this is the biggest an int can hold
big = big + 1;             // 4) signed overflow -- UB, not "wraps to a negative"

Why does the language allow such a scary rule? Because those checks would cost speed, and C was built to be fast. The bargain is: the compiler assumes you never trigger undefined behavior, and it optimizes your code based on that assumption. This is exactly why UB is so sneaky — a program with UB can produce the right answer for months, purely by luck, and then a new compiler version or a small unrelated change makes the luck run out.

The practical takeaway is short: "it works on my machine" does not prove your code is correct in C. You cannot rely on being careful by hand, because humans are not reliable enough. Instead you make the machine catch these mistakes for you — which is exactly what the next section is about.

9. Debugging: a full worked example

Most bugs do not announce themselves on the line where you made the mistake. You corrupt something in one place, the program keeps running, and it finally falls over somewhere else entirely, later. Debugging is the craft of working backward from where it fell over to the real cause. Your first line of defense costs nothing: turn on compiler warnings (-Wall -Wextra) and read them, because the compiler often spots the mistake before you even run. Your second line of defense is the two tools below. Instead of describing them, let us take one real bug all the way from "it prints a wrong number" to "here is the exact line, and here is the fix." Type this in as bug.c:

#include <stdio.h>

int sum_first_n(int *arr, int n) {
    int total = 0;
    for (int i = 0; i <= n; i++)   // the bug is here: <= reads one item past the end
        total += arr[i];
    return total;
}

int main(void) {
    int data[3] = {10, 20, 30};
    printf("%d\n", sum_first_n(data, 3));   // we expect 10+20+30 = 60
    return 0;
}

Step 1 — Run it, and fall into the trap

$ gcc bug.c -o bug
$ ./bug
60

It printed 60 — exactly the number we wanted. If your only test is "did it print 60?", this bug just passed and shipped to players. But look at the loop condition: i <= n with n = 3 means i takes the values 0, 1, 2, and 3. So the last step reads arr[3], which is one past a 3-item array (section 6). That is undefined behavior. This particular run, the memory right after the array happened to hold a 0, so the total came out right by pure luck. Run it on another computer, or after an unrelated change, and it might print 60, or 32827, or crash.

Step 2 — Let AddressSanitizer find it for you

Rebuild with the sanitizer switched on — just add -fsanitize=address — and run again. You add no print statements and make no guesses. The instrumented program simply refuses to read out of bounds quietly:

$ gcc -g -fsanitize=address bug.c -o bug
$ ./bug
=================================================================
==4711==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc9a3d20cc
READ of size 4 at 0x7ffc9a3d20cc thread T0
    #0 0x5573... in sum_first_n bug.c:6
    #1 0x5573... in main bug.c:12

Address 0x7ffc9a3d20cc is located in stack of thread T0
  'data' (line 11) is the overflowed variable

Read it with me, because it is handing you the whole diagnosis for free. First line: the problem is a stack-buffer-overflow (you went past the end of something on the stack). Next line: it was a READ of size 4 — you read 4 bytes, which is exactly one int. Then it names the exact spot: line bug.c:6 (our loop), reached from bug.c:12 (the call in main). And the last lines even name the victim: the variable data is what you overran. You did not have to suspect the loop in advance — the tool pointed straight at it. That is the difference these tools make: the bug went from invisible to a file, a line, and a variable name.

Step 3 — Confirm it with your own eyes in the debugger

Some bugs are wrong logic that never touches illegal memory, so the sanitizer cannot catch them. For those you look inside the program while it runs, using a debugger — gdb on Linux, lldb on Mac. Here we use it just to see the garbage for ourselves:

$ gcc -g bug.c -o bug
$ gdb ./bug
(gdb) break sum_first_n            # pause the instant we enter this function
(gdb) run                          # start the program
Breakpoint 1, sum_first_n (arr=0x7fffffffde3c, n=3) at bug.c:4
(gdb) print n                      # how many items does the loop think there are?
$1 = 3
(gdb) print arr[2]                 # the last REAL item
$2 = 30
(gdb) print arr[3]                 # the item the buggy loop also reads
$3 = 21845                         # garbage -- proof we are reading past the array

Each command earns its place. break tells the program to freeze the moment it enters that function, so we can look around before anything goes wrong. run starts it, and notice gdb even shows the arguments it was called with (n = 3). Then print lets us read any variable live: arr[2] is the genuine last item, 30, while arr[3] is meaningless garbage — visible, undeniable proof that the loop steps one place too far. We did not sprinkle print statements and guess; we asked the program direct questions and it answered.

Step 4 — The fix

    for (int i = 0; i < n; i++)    // '<' stops at the last real index, n-1
        total += arr[i];

Rebuild under the sanitizer once more: now it runs clean and prints 60 for the right reason, instead of by luck.

Why not just use printf?

You could have found this bug with printf. It is worth being exact about what that would have cost, because the comparison is the real lesson. To put a useful printf in the right place, you first have to suspect that the loop is the problem — but suspecting the cause is most of the battle, and the sanitizer needed no suspicion at all. Print statements also bury the real output under noise, and because printing takes time, they can even make timing-related bugs disappear while you are looking for them. Worst of all, when a program dies deep inside a chain of function calls, a printf tells you nothing about who called whom — whereas one command in the debugger, backtrace (short: bt), prints that entire chain (the stack picture from section 7) instantly. For a whole class of bugs, these tools are not a convenience; they are what makes the bug visible in the first place.

Summary

Glossary

Exercises

Write and run every one. Reading the answer before you struggle with the problem teaches you almost nothing — the understanding is in the struggle.

Exercise 1 Write swap.c with a function void swap(int *a, int *b) that swaps the two ints its arguments point to. In main, set x = 3, y = 7, call swap(&x, &y), and print them. Then answer in one sentence: why must the function take pointers instead of plain int parameters?
Show answer
#include <stdio.h>

void swap(int *a, int *b) {
    int tmp = *a;   // save the value a points to
    *a = *b;        // copy b's value into a's box
    *b = tmp;       // put the saved value into b's box
}

int main(void) {
    int x = 3, y = 7;
    swap(&x, &y);
    printf("%d %d\n", x, y);   // prints: 7 3
    return 0;
}

Why pointers: C passes arguments by copying them (section 7), so plain int parameters would be copies, and swapping the copies would leave the real x and y untouched. Passing their addresses lets the function reach the real variables through the pointers.

Exercise 2 Write reverse.c that reads a line into char buf[128] and prints it backwards, using two pointers walking toward each other (no [] indexing). Compile with -fsanitize=address and deliberately paste in a 200-character line. What does the sanitizer report, and what is the correct fix?
Show answer
#include <stdio.h>
#include <string.h>

int main(void) {
    char buf[128];
    if (!fgets(buf, sizeof(buf), stdin)) return 0;  // fgets reads at most 127 chars
    buf[strcspn(buf, "\n")] = '\0';                 // remove the trailing newline

    char *lo = buf;                  // points at the first character
    char *hi = buf + strlen(buf) - 1;// points at the last character
    while (lo < hi) {                // move the two pointers toward each other
        char t = *lo; *lo = *hi; *hi = t;   // swap the characters they point to
        lo++; hi--;
    }
    printf("%s\n", buf);
    return 0;
}

The 200-character test: if you had read input with an unsafe function like gets, the extra characters would spill past the end of buf, and AddressSanitizer would report a stack-buffer-overflow naming the exact line and the variable buf — the same kind of report we read in section 9. The fix is exactly what this code does: read with fgets(buf, sizeof(buf), stdin), which physically cannot write past the buffer. The general rule: every read into a fixed buffer must be limited by that buffer's size.

Exercise 3 The code below leaks memory, and a careless "fix" would introduce a use-after-free. Find both problems, explain what causes each, and rewrite the two functions correctly.
char *greeting(void) {
    char *s = malloc(6);
    // ... copies "hello" into s ...
    return s;
}
void demo(void) {
    char *g = greeting();
    printf("%s\n", g);
    // (nothing else)
}
Show answer

The leak: greeting hands the heap memory to demo, but demo never calls free(g), so those 6 bytes are lost on every call (section 5). The trap: the tempting "fix" is to add free(g) and then keep using g afterward — but once freed, g is dangling, and using it is undefined behavior. A third, quieter flaw: the original never checks whether malloc returned NULL. A correct version:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char *greeting(void) {
    char *s = malloc(6);          // 5 letters + '\0' = 6 bytes
    if (!s) return NULL;          // check malloc, always
    strcpy(s, "hello");
    return s;                     // the caller now owns this memory
}

void demo(void) {
    char *g = greeting();
    if (!g) return;
    printf("%s\n", g);
    free(g);                      // free once, after the last use
    g = NULL;                     // optional but wise: makes an accidental reuse crash loudly
}

The lesson: "one owner, free once, and stop touching the pointer the moment you free it" is the whole rule. The next chapter shows how C++'s unique_ptr performs the free for you automatically, so this bug simply cannot happen.

← Back to all chapters