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.
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.
malloc'd but never free'd. The pointer was lost before free โ the bytes are unreachable but still claimed.
free'd, but the pointer still holds the old address. It points to memory that no longer belongs to you.
free'd the same pointer twice. The heap's internal bookkeeping is corrupted.
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
}
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.
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.
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.
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.
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.
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
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.
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.
Here's the mental model that prevents most of these bugs:
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:
| Pattern | Who 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 |
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.
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."
| Type | Example | Consequence |
|---|---|---|
| Out-of-bounds access | arr[10] in a 5-element array | Corrupts memory, may crash, may silently overwrite adjacent data |
| Use-after-free | Dereferencing freed pointer | Crash or silent data corruption |
| Null pointer dereference | *NULL | Segfault (crash) |
| Division by zero | x / 0 | Crash (SIGFPE) |
| Modifying a string literal | char *s = "hi"; s[0]='H'; | Crash (write to read-only memory) |
| Uninitialized read | int x; printf("%d", x); | Garbage value, unpredictable |
-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.
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.
--track-origins=yes)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.
-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.
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.
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 mustfreeit when done. This is the same pattern asmystrdupfrom 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."
int *p = malloc(sizeof(int)); *p = 42; free(p); printf("%d\n", *p);Show answer
Undefined behavior. Afterfree(p), the pointerpis dangling. Dereferencing it (the*pinprintf) 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.
void process(void) { int *a = malloc(100); int *b = malloc(200); a = b; free(a); }Show answer
One leak โ 100 bytes.aoriginally pointed to the 100-byte block. Thena = b;overwritesawith 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 (sinceanow holdsb's address). The 200-byte block is fine. But the original 100-byte block is gone forever.
free(p); p = NULL;, is free(p) safe to call again?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.
Print this. Stick it on your monitor. Every malloc you write should pass through this checklist:
if (!ptr) { handle error; } โ every single malloc/calloc/realloc.free(p); p = NULL; โ prevents use-after-free and double-free.p = something_else;, make sure the old allocation is freed.-g and test with Valgrind. Make it a habit, not an afterthought.Write three small programs, each containing one specific memory bug. Then run them under Valgrind to see the tool in action.
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.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.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)"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.
gcc -g -fsanitize=address. Compare the output to Valgrind's. Which do you find easier to read?malloc โ formal specification of allocation functions.