Lesson 7 ยท ~25 minutes ยท ties to mission: spot undefined behavior on sight and explain why it's undefined

Memory Errors and the Defensive Programmer

Lesson 6 gave you the tools โ€” malloc, free, calloc, realloc. Now meet the bugs they enable. Four errors account for nearly every heap catastrophe in C. Learn to spot them, and you've already separated yourself from casual C users.

The four heap sins

In lesson 6 we listed them in a callout. Now let's meet each one properly โ€” what it looks like in code, what actually happens at the machine level, and how to prevent it.

1. Memory leak You malloc'd but never free'd. The pointer was lost before free โ€” the bytes are unreachable but still claimed.
2. Dangling pointer You free'd, but the pointer still holds the old address. It points to memory that no longer belongs to you.
3. Double free You free'd the same pointer twice. The heap's internal bookkeeping is corrupted.
4. Use-after-free You read or wrote through a dangling pointer. The memory may have been reassigned to another allocation.

Sin 1 โ€” Memory leak

void greet(const char *name) {
    char *msg = malloc(100);    // 100 bytes on the heap
    sprintf(msg, "Hello, %s!", name);
    printf("%s\n", msg);
    // function returns โ€” msg goes out of scope
    // the 100 bytes are still claimed, but no pointer reaches them
}
What's sprintf? You've used printf to print to the terminal. sprintf does the same formatting โ€” but writes the result into a char buffer instead:
/* printf  โ†’ writes to stdout (the screen) */
printf("Hello, %s!\n", name);

/* sprintf โ†’ writes to a char buffer */
sprintf(msg, "Hello, %s!", name);
// msg now contains "Hello, Wojtek!"

/* snprintf โ†’ same, but capped at n bytes (safer) */
snprintf(msg, 100, "Hello, %s!", name);
Same format specifiers across all three. The s prefix stands for "string." In real code, prefer snprintf โ€” it takes a max length and can't overflow the buffer.

Every time greet is called, 100 bytes vanish. In a short-lived program, you'd never notice. In a server running for weeks, it slowly bleeds to death. The fix is trivial:

    free(msg);    // give it back before the pointer dies

Leak = lost pointer + surviving allocation. If the pointer is still in scope, it's not a leak yet โ€” just sloppy. A leak happens the instant the last pointer to an allocation is gone without a free.

The overwritten-pointer leak This one is sneakier. The pointer is still in scope, but you overwrote it:
char *s = malloc(100);
s = "literal";     // old malloc'd block is now unreachable โ€” leaked!
You now have two leaks: the 100 bytes from malloc, and (arguably) the cognitive leak where you forgot where the memory is.

Sin 2 โ€” Dangling pointer

int *p = malloc(sizeof(int));
*p = 42;
free(p);
// p still holds the address โ€” but the heap has reclaimed it
printf("%d\n", *p);    // UB: use-after-free (sin 4)

After free(p), p is not set to NULL. C doesn't do that for you. The pointer still holds the old address. The heap allocator may reuse that address for the next malloc call โ€” so reading through p might silently read someone else's data.

The defensive habit
free(p);
p = NULL;    // always nullify after free
Now if you accidentally use p later, you get a segfault (from dereferencing NULL) instead of silent corruption. Crashes are good โ€” they tell you where the bug is. Silent corruption is what keeps you up at night.

Sin 3 โ€” Double free

int *p = malloc(sizeof(int));
free(p);
free(p);    // double free โ€” UB

What actually happens? The heap allocator maintains an internal data structure (think of it as a list of free blocks). When you free(p) the first time, the block is added to the free list. The second free(p) tries to add the same block again โ€” corrupting the list. Modern allocators detect this and abort, but you still have a bug.

That p = NULL; habit from sin 2 also prevents this one: free(NULL) is defined to be a no-op. So if you accidentally call free(p) twice and p is NULL, nothing bad happens.

Why free(NULL) is safe The C standard explicitly says free(NULL) does nothing. This makes cleanup code simpler โ€” you don't need to check before freeing:
free(p);
p = NULL;
// later:
free(p);     // no-op, totally safe

Sin 4 โ€” Use-after-free

This is sin 2 + sin 3's evil cousin. Not only do you still have the pointer โ€” you're using it.

char *s = malloc(10);
strcpy(s, "secret");
free(s);

char *t = malloc(10);       // allocator may reuse the same address!
strcpy(t, "other");

printf("%s\n", s);            // prints "other" โ€” or "secret" โ€” or crashes

This is the worst kind of memory bug because it might not crash. The program appears to work. The corrupted data propagates silently. In a real system, this is how passwords leak, databases corrupt, and security vulnerabilities are born.

Bonus: uninitialized memory

Not technically a heap sin โ€” it happens on both stack and heap โ€” but it's closely related:

int *arr = malloc(5 * sizeof(int));
printf("%d\n", arr[2]);    // whatever bytes were there before

malloc does not zero memory. You get whatever was in that region last โ€” could be zeros (lucky), could be old data from a freed allocation, could be anything. If you need zeros, use calloc, or explicitly zero the memory yourself.

The ownership rule โ€” one allocator, one freer

Here's the mental model that prevents most of these bugs:

malloc / calloc โ†’ owner โ†’ free

For every allocation, ask: who owns this memory? The owner is responsible for calling free โ€” exactly once, when done. If ownership is unclear, you get leaks (nobody freed) or double-frees (everybody freed).

Common ownership patterns:

PatternWho frees?Example
Caller allocates, caller frees The same function int *a = malloc(...); ... free(a);
Function returns heap memory The caller char *s = mystrdup("hi"); ... free(s); (your lesson 6 exercise)
Function allocates internally The function itself A buffer used only within a function โ€” allocate, use, free, return result
Never return a pointer to stack memory This is a related but different bug โ€” not a heap sin, but equally fatal:
int *bad(void) {
    int x = 42;
    return &x;      // x dies when the function returns โ€” dangling pointer
}
The stack frame is destroyed on return. The address &x; now points to... whatever the next function call puts there. Always malloc if you need the data to outlive the function.

Undefined behavior โ€” what it really means

The book's table from chapter 26 is worth reproducing. Undefined behavior isn't "it crashes." It's "the compiler is allowed to assume you never did this, and may optimize accordingly."

TypeExampleConsequence
Out-of-bounds accessarr[10] in a 5-element arrayCorrupts memory, may crash, may silently overwrite adjacent data
Use-after-freeDereferencing freed pointerCrash or silent data corruption
Null pointer dereference*NULLSegfault (crash)
Division by zerox / 0Crash (SIGFPE)
Modifying a string literalchar *s = "hi"; s[0]='H';Crash (write to read-only memory)
Uninitialized readint x; printf("%d", x);Garbage value, unpredictable
Why UB is scary Your program may seem fine โ€” pass all your tests โ€” but then you compile with optimizations (-O2 or -O3) and the compiler reorders or eliminates code based on "they'd never do that UB thing." Same code, different behavior. This is why experienced C programmers treat UB as correctness bugs, not "might crash sometimes" bugs.

Enter Valgrind โ€” the memory debugger

You now have the theory. But how do you find these bugs in practice? Reading code carefully catches some. For the rest, there's Valgrind โ€” a tool that runs your program in an instrumented environment and reports every memory error and leak it finds.

What Valgrind detects

Basic usage:

$ gcc -g buggy.c -o buggy        # -g adds debug symbols (line numbers!)
$ valgrind ./buggy

Reading the output is a skill in itself. Here's the anatomy of a leak report:

==12345== HEAP SUMMARY:
==12345==     in use at exit: 400 bytes in 10 blocks
==12345==   total heap usage: 14 allocs, 4 frees, 1,200 bytes allocated
==12345==
==12345== LEAK SUMMARY:
==12345==     definitely lost: 400 bytes in 10 blocks
==12345==     indirectly lost: 0 bytes in 0 blocks
==12345==       possibly lost: 0 bytes in 0 blocks
==12345==     still reachable: 0 bytes in 0 blocks

The key line: "definitely lost: 400 bytes in 10 blocks". That's 10 mallocs that never got free'd. For the line numbers, run with --leak-check=full (which is actually the default):

==12345== 400 bytes in 10 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x483DA83: malloc (vg_replace_malloc.c:381)
==12345==    by 0x1091A7: create_array (buggy.c:15)
==12345==    by 0x109187: main (buggy.c:8)

There it is: buggy.c:15 inside create_array. You allocated something on line 15 that was never freed.

Compile with -g! Without -g, Valgrind can't show you source file names and line numbers โ€” just raw memory addresses. Always compile with -g when debugging memory errors. It costs nothing at runtime.

Alternative: AddressSanitizer

Valgrind is great but slow (~10-20ร— slowdown). For faster feedback during development, use AddressSanitizer (ASan) โ€” it's built into GCC and Clang:

$ gcc -g -fsanitize=address buggy.c -o buggy
$ ./buggy

ASan catches the same errors but at near-native speed. It's the tool most C developers reach for first during active development. Use Valgrind for final verification or when ASan isn't available.

Check yourself

โ‘  This function returns heap memory. Who should free it โ€” the function or the caller?

char *make_greeting(const char *name) {
    char *buf = malloc(100);
    sprintf(buf, "Hello, %s!", name);
    return buf;
}
  
Show answer The caller. The function returns the pointer, transferring ownership. The caller must free it when done. This is the same pattern as mystrdup from lesson 6. The function can't free it โ€” the caller needs the data! Document this ownership transfer in a comment or in your head: "returns malloc'd memory; caller frees."

โ‘ก What does this print โ€” and is it defined?

int *p = malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p);
  
Show answer Undefined behavior. After free(p), the pointer p is dangling. Dereferencing it (the *p in printf) is use-after-free. It might print 42 (if the heap hasn't reused the memory yet), print garbage, or crash. On your machine today it probably prints 42 โ€” which is the dangerous part. It looks correct but isn't.

โ‘ข How many leaks are in this code?

void process(void) {
    int *a = malloc(100);
    int *b = malloc(200);
    a = b;
    free(a);
}
  
Show answer One leak โ€” 100 bytes. a originally pointed to the 100-byte block. Then a = b; overwrites a with the address of the 200-byte block. Now nothing points to the 100-byte block โ€” it's leaked. free(a) frees the 200-byte block (since a now holds b's address). The 200-byte block is fine. But the original 100-byte block is gone forever.

โ‘ฃ After free(p); p = NULL;, is free(p) safe to call again?

Show answer Yes โ€” it's a no-op. free(NULL) is defined by the C standard to do nothing. This is exactly why we nullify pointers after freeing. It makes double-free impossible and cleanup code simpler.

The defensive checklist

Print this. Stick it on your monitor. Every malloc you write should pass through this checklist:

  1. Check for NULL. if (!ptr) { handle error; } โ€” every single malloc/calloc/realloc.
  2. Pair every malloc with a free. Trace ownership: who allocated, who frees, and when.
  3. Nullify after free. free(p); p = NULL; โ€” prevents use-after-free and double-free.
  4. Never return stack pointers. If a value must outlive the function, malloc it.
  5. Don't overwrite your last pointer. Before p = something_else;, make sure the old allocation is freed.
  6. Compile with -g and test with Valgrind. Make it a habit, not an afterthought.

๐Ÿ› ๏ธ Hands-on โ€” be a memory detective with Valgrind

Write three small programs, each containing one specific memory bug. Then run them under Valgrind to see the tool in action.

  1. Program 1: The leak. Write a function that mallocs an array of 10 ints, fills it, prints it โ€” but never frees it. Compile with -g. Run under Valgrind. Find the "definitely lost" line in the output.
  2. Program 2: The use-after-free. malloc an int, set it to 42, free it, then print it again. Run under Valgrind. You'll see "Invalid read of size 4" with the exact line number.
  3. Program 3: The uninitialized read. malloc an array of 5 ints (not calloc), then print arr[3] without writing to it first. Run under Valgrind with --track-origins=yes. You'll see "Conditional jump or move depends on uninitialised value(s)"
  4. Fix all three. For each program, apply the fix (add free, nullify the pointer, use calloc). Run Valgrind again โ€” the errors should vanish. A clean Valgrind run looks like:
    ==12345== HEAP SUMMARY:
    ==12345==     in use at exit: 0 bytes in 0 blocks
    ==12345==   total heap usage: 1 allocs, 1 frees, 40 bytes allocated
    ==12345==
    ==12345== All heap blocks were freed -- no leaks are possible
    "All heap blocks were freed โ€” no leaks are possible" โ€” that's the gold standard.
  5. Bonus. Try AddressSanitizer on one of the buggy programs: gcc -g -fsanitize=address. Compare the output to Valgrind's. Which do you find easier to read?
What you can now do Identify all four heap memory errors (leak, dangling pointer, double-free, use-after-free) in code. Explain why undefined behavior is not just "a crash" but a correctness bug. Use Valgrind and AddressSanitizer to find memory errors in your own programs. Apply the defensive checklist: check for NULL, pair malloc with free, nullify after free, and test with Valgrind.
Questions? Ask your agent. Confused about a Valgrind output line? Want to see what happens with a specific bug? Wondering about realloc and leaks? Type your question โ€” we'll dig in together. You now have the tools to catch the bugs that silently corrupt real C programs.
Sources & further reading
ยท Valgrind Quick Start Guide โ€” how to read the output and common flags.
ยท What Every C Programmer Should Know About Undefined Behavior โ€” LLVM blog post, essential reading.
ยท cppreference: malloc โ€” formal specification of allocation functions.
ยท AddressSanitizer wiki โ€” GCC/Clang's built-in memory error detector.
ยท Companion cheat sheet ยท Lesson 6: malloc/free