3 - Memory Management - Stack, Heap, and Memory Bugs

Introduction

Memory management is one of the most heavily tested topics on COMP201 midterms. This guide covers the stack, heap, dynamic allocation functions (malloc, calloc, realloc, strdup), freeing memory, and the 13 critical memory bug categories that appear repeatedly in exam problems. Understanding these concepts deeply-not just memorizing them-is essential for success.

Part 1: The Stack

The stack is the portion of memory automatically managed by the processor. It stores:

  • Local variables declared inside functions
  • Function parameters
  • Return addresses for function calls

Automatic Management: When you declare a local variable in C, it automatically gets allocated when the function is called and deallocated when the function returns.

void example() {
    int x = 42;        // allocated on stack
    char buffer[100];  // allocated on stack
}  // x and buffer automatically freed here

LIFO (Last-In-First-Out): The stack grows downward as functions are called, and shrinks as they return. Each function call creates a stack frame containing its local variables.

Fixed Size: The total stack size is fixed, typically 8MB on modern systems. Large allocations can cause a stack overflow.

Speed: Stack allocation/deallocation is extremely fast-just a pointer adjustment.

Type Safety: The compiler checks variable types on the stack.

Stack Frames and Function Calls

When a function is called, a new frame is pushed onto the stack:

STACK MEMORY (grows downward)
┌─────────────────┐
│     main        │ ← main's frame
│  x = 10         │   (contains local vars)
├─────────────────┤
│  factorial      │ ← factorial's frame (first call)
│  n = 5          │
├─────────────────┤
│  factorial      │ ← factorial's frame (second call)
│  n = 4          │
├─────────────────┤
│      ...        │
└─────────────────┘

When factorial(5) returns, its frame is freed. When factorial(4) returns, its frame is freed. The stack automatically cleans up.

Critical Stack Danger: Returning Pointer to Local Variable

This is a classic exam bug:

char *create_string(char ch, int num) {
    char new_str[num + 1];     // allocates on STACK
    for (int i = 0; i < num; i++) {
        new_str[i] = ch;
    }
    new_str[num] = '\0';
    return new_str;            // DANGER! Returning pointer to stack memory
}

int main() {
    char *str = create_string('a', 4);
    printf("%s", str);         // UNDEFINED BEHAVIOR
                               // new_str's stack frame was freed when
                               // create_string returned!
}

What goes wrong: When create_string() returns, its stack frame is destroyed. The local variable new_str no longer exists. The pointer str in main now points to freed (recycled) memory. Accessing it is undefined behavior-it might print garbage, crash, or appear to work by coincidence.

Why it happens: new_str is an array on the stack, not a pointer. It's not allocated with malloc(), so we can't return it.

The fix: Allocate the string on the heap instead, which persists until explicitly freed.

Part 2: The Heap

The heap is a portion of memory that you (the programmer) manage explicitly. The operating system provides memory blocks when requested via malloc(), calloc(), and realloc(). You're responsible for freeing memory with free().

Manual Management: You control when memory is allocated and freed.

Grows Upward: Unlike the stack, the heap grows upward as more memory is allocated.

Plentiful: The heap can grow very large (limited mainly by available RAM).

Slow: Heap allocation/deallocation is slower than stack allocation.

No Type Safety: Heap memory is returned as void *. You're responsible for using it correctly.

Lifetime Control: Memory persists until you explicitly call free().

Memory Layout (Simplified 64-bit Linux)

HIGH ADDRESSES
    (reserved)

8MB
    STACK (grows downward)  ↓

    (unallocated gap)

    HEAP (grows upward)     ↑
    Global data
    Text (code)

LOW ADDRESSES

The Heap Solution to Returning Strings

char *create_string(char ch, int num) {
    char *new_str = malloc(sizeof(char) * (num + 1));
    // new_str points to heap memory that PERSISTS

    for (int i = 0; i < num; i++) {
        new_str[i] = ch;
    }
    new_str[num] = '\0';
    return new_str;  // Now safe! Heap memory persists
}

int main() {
    char *str = create_string('a', 4);
    printf("%s", str);  // Works correctly! Prints "aaaa"
    free(str);          // OUR responsibility to free it
    return 0;
}

Key insight: With the heap, we explicitly allocate memory that outlives the function, return a pointer to it, and trust the caller to free it when done.

Part 3: Dynamic Memory Allocation Functions

malloc() - Memory Allocate

Signature:

void *malloc(size_t size);

Purpose: Allocates size bytes of uninitialized memory on the heap.

Returns:

  • A void * pointer to the starting address of the allocated block
  • NULL if allocation fails (out of memory)

Memory Content: The allocated memory is not zeroed out. It contains whatever garbage was previously in that memory location.

Example:

int *nums = malloc(10 * sizeof(int));  // allocate space for 10 ints
assert(nums != NULL);                   // check for allocation failure
nums[0] = 42;
nums[1] = 99;
// ... use the array ...
free(nums);  // MUST free when done

Common Mistake: Forgetting to multiply by sizeof():

int *arr = malloc(100);        // WRONG! Only 100 bytes, not 100 ints
int *arr = malloc(100 * sizeof(int));  // Correct on most systems

calloc() - Cleared Allocate

Signature:

void *calloc(size_t nmemb, size_t size);

Purpose: Allocates memory for nmemb elements of size size each, and zero-initializes all bytes.

Equivalent to:

// These are roughly equivalent:
int *arr1 = calloc(20, sizeof(int));

int *arr2 = malloc(20 * sizeof(int));
for (int i = 0; i < 20; i++) arr2[i] = 0;

When to use: When you need all values initialized to 0. Useful for:

  • Allocating arrays of structs where you want fields to start at 0/NULL
  • Allocating boolean arrays where false=0
  • Any situation where uninitialized data would be problematic

Performance note: calloc() is slower than malloc() because it must zero every byte. Only use when you actually need zeroed memory.

Example:

int *scores = calloc(100, sizeof(int));
// All 100 ints are now 0
free(scores);

realloc() - Resize Allocation

Signature:

void *realloc(void *ptr, size_t new_size);

Purpose: Resize a previously allocated block to a new size.

Behavior:

  1. If there's enough space after the existing block, simply extend it
  2. If not, allocate new memory at a different location, copy old data, free the old block, return pointer to new location

Critical Point: realloc() may move your memory. The returned pointer might be different from the input pointer.

Danger: Naive code that doesn't save the return value leaks memory on failure:

// WRONG - can leak memory:
int *arr = malloc(100 * sizeof(int));
arr = realloc(arr, 200 * sizeof(int));  // if realloc fails, returns NULL
                                         // arr is now NULL, original memory leaked!

Correct Pattern:

int *arr = malloc(100 * sizeof(int));
int *temp = realloc(arr, 200 * sizeof(int));
if (temp == NULL) {
    // realloc failed, arr still points to original memory
    free(arr);
    printf("Allocation failed\n");
    return;
}
arr = temp;  // now safe to use arr

Example: Growing a Dynamic String:

char *str = strdup("Hello");
assert(str != NULL);

// Want to make it hold "Hello world!"
char *addition = " world!";
size_t new_size = strlen(str) + strlen(addition) + 1;
char *temp = realloc(str, new_size);
assert(temp != NULL);
str = temp;

strcat(str, addition);
printf("%s\n", str);  // prints "Hello world!"
free(str);

strdup() - String Duplicate

Signature:

char *strdup(const char *s);

Purpose: Allocate a new string on the heap that's a copy of the input string.

Equivalent to:

// These are equivalent:
char *copy = strdup("Hello");

char *copy = malloc(strlen("Hello") + 1);
strcpy(copy, "Hello");

Returns: A pointer to the new heap-allocated string, or NULL on failure.

Example:

char *original = "Hello, World!";
char *copy = strdup(original);
assert(copy != NULL);

copy[0] = 'J';  // modify the copy
printf("%s\n", original);  // "Hello, World!" - unchanged
printf("%s\n", copy);      // "Jello, World!" - changed
free(copy);

When to use:

  • When you receive a string pointer you don't own (like a function parameter)
  • When you need to modify the string
  • When the original string might be deallocated before you're done using it

Part 4: Freeing Memory

free() - Deallocate

Signature:

void free(void *ptr);

Purpose: Deallocate memory previously allocated by malloc(), calloc(), or realloc().

Critical Rules:

  1. Exactly Once: Each allocated block must be freed exactly once
  2. Starting Address: You must free the pointer you received from allocation (for malloc'd memory)
  3. NULL Safe: Calling free(NULL) is safe-it does nothing

Example:

char *bytes = malloc(4);
// ... use bytes ...
free(bytes);     // correct
free(bytes);     // ERROR! Double-free (undefined behavior)

int *arr = malloc(100 * sizeof(int));
free(arr + 5);   // ERROR! Not the starting address
free(arr);       // if above didn't catch the error, error here too

Memory Cleanup Patterns

Pattern 1: Simple Allocation and Free

char *str = malloc(100);
assert(str != NULL);
// ... use str ...
free(str);

Pattern 2: Strdup and Free

char *copy = strdup("Hello");
assert(copy != NULL);
// ... use copy ...
free(copy);

Pattern 3: Array of Pointers

char **strings = malloc(10 * sizeof(char *));
assert(strings != NULL);

for (int i = 0; i < 10; i++) {
    strings[i] = strdup("test");
    assert(strings[i] != NULL);
}

// MUST free in right order:
for (int i = 0; i < 10; i++) {
    free(strings[i]);  // free each string first
}
free(strings);         // then free the array

Memory Leak

Definition: A memory leak occurs when you allocate memory but fail to free it.

void leak_example() {
    char *str = malloc(100);
    // ... use str ...
    return;  // str not freed! Memory leaked.
}

Consequences:

  • In long-running programs, leaked memory accumulates
  • Eventually the program runs out of heap space
  • malloc() fails, returning NULL

Important Note: Memory leaks are generally less critical than other bugs. A program with leaks often runs fine until it accumulates enough leaked memory. However, exam problems often test memory leaks because they demonstrate understanding of memory management responsibility.

Valgrind: Mental Model

Valgrind is a tool that tracks every malloc and free call. It reports:

  • Still reachable: Memory allocated but not freed (leaks)
  • Definitely lost: Memory allocated and pointer was lost (likely bug)
  • Invalid free: free() called on non-heap memory or already-freed memory

For the exam, understand the concept: Valgrind is tracking whether every allocation has a matching free.

Part 5: Stack vs Heap Comparison

Characteristic Stack Heap
Management Automatic Manual (programmer)
Speed Very fast Slower
Size Fixed (~8MB default) Large (limited by RAM)
Grows Downward Upward
Lifetime Until function returns Until free() called
Type Safety Checked by compiler No safety
Scope Function scope Unlimited scope
Can overflow Stack overflow Fails malloc, returns NULL
Use When Fixed-size local data Variable-size, long-lived, or returned data

When to Use Each

Prefer Stack When:

  • You know the size at compile time
  • Data is only needed within one function
  • Data doesn't need to outlive the function
  • You want automatic cleanup

Need Heap When:

  • Size determined at runtime
  • Data must outlive the function that creates it
  • You need a very large allocation (would overflow stack)
  • You need to resize memory

General Principle: Unless a situation requires heap allocation, stack allocation is preferred. However, many programs mix both-using the stack for fixed-size structures and the heap for dynamically-sized data.

Part 6: The 13 Memory Bug Types

This is the most heavily tested section. Understand not just the name but the intuition, the formal definition, code examples, what goes wrong, and how to fix it.

Bug 1: Memory Leak

Intuition: You ask for memory but never return it to the system.

Definition: Allocating memory with malloc(), calloc(), or realloc() but failing to call free() on that memory, causing it to remain allocated until program termination.

Example:

void process_file(const char *filename) {
    FILE *fp = fopen(filename, "r");
    char *buffer = malloc(1024);

    // Read from file into buffer
    // ... work with buffer ...

    fclose(fp);
    // BUG: buffer not freed!
    // If process_file is called many times, memory leaks accumulate
}

What goes wrong: Each call to process_file() leaves 1024 bytes allocated. After 1000 calls, 1MB is leaked. The heap fills up, malloc eventually returns NULL, and the program crashes.

Fix:

void process_file(const char *filename) {
    FILE *fp = fopen(filename, "r");
    char *buffer = malloc(1024);

    // ... work ...

    fclose(fp);
    free(buffer);  // FIXED
}

Bug 2: Use After Free

Intuition: You return a pointer to memory to the system, then try to use it.

Definition: Accessing heap memory via a pointer after that memory has been freed via free().

Example:

char *str = malloc(10);
strcpy(str, "hello");
free(str);

printf("%s\n", str);  // BUG: Use after free
str[0] = 'H';         // BUG: Use after free

What goes wrong: After free(), the memory is returned to the heap allocator and may be reused for other allocations. Reading from the freed memory might show:

  • Garbage
  • Data from another part of the program (information leak!)
  • Stale data that looks "correct" by coincidence

Writing to freed memory corrupts other allocated blocks.

Fix: Set pointer to NULL after freeing, then check before use:

char *str = malloc(10);
strcpy(str, "hello");
free(str);
str = NULL;
if (str != NULL) {
    printf("%s\n", str);  // Safe: won't execute
}

Bug 3: Double Free

Intuition: You return the same memory to the system twice.

Definition: Calling free() on the same pointer twice (or on multiple pointers to the same block).

Example:

char *str = malloc(10);
strcpy(str, "hello");
free(str);
free(str);  // BUG: Double free

What goes wrong: The heap allocator's internal structures become corrupted. The second free() tries to return already-returned memory to the free list, causing:

  • Immediate crash (most likely)
  • Silent corruption that causes crashes later
  • Security vulnerabilities

Fix: After freeing, set pointer to NULL:

char *str = malloc(10);
strcpy(str, "hello");
free(str);
str = NULL;
free(str);  // Safe: free(NULL) does nothing

Or use pointers more carefully in functions returning pointers:

char *str = malloc(10);
// ... use str ...
// Return to caller; let THEM free it
return str;
// Don't free here!

Bug 4: Freeing Unallocated Storage

Intuition: You try to return memory to the system that never came from malloc/calloc/realloc.

Definition: Calling free() on a pointer that was never returned by malloc(), calloc(), or realloc().

Example:

// Stack variable
int arr[100];
free(arr);  // BUG: arr is on stack, not heap

// Function parameter
void process(char *str) {
    free(str);  // BUG if caller passed stack-allocated string
}

int main() {
    char buf[100] = "hello";
    process(buf);  // Crashes in process()
}

// Literal string
char *msg = "hello";  // points to read-only string literal
free(msg);  // BUG: can't free literal strings

What goes wrong: The heap allocator's bookkeeping is violated. It tries to mark memory that was never allocated as free, corrupting the free list. Usually crashes immediately with "corrupt heap" error.

Fix: Only free memory from malloc/calloc/realloc:

// Right: allocate then free
char *msg = malloc(10);
strcpy(msg, "hello");
free(msg);

// Right: let caller manage
char buf[100] = "hello";
process(buf);  // no free needed

// Right: don't free literals
char *msg = "hello";  // don't free this

Bug 5: Freeing Stack Space

Intuition: A specific case of freeing unallocated storage-trying to free a stack variable.

Definition: Calling free() on a pointer to a stack-allocated variable.

Example:

void process_data() {
    int data[1000];  // Stack allocated
    int *ptr = data;

    // ... use ptr ...

    free(ptr);  // BUG: data is on stack!
}

What goes wrong: Same as "Freeing Unallocated Storage"-heap corruption, crash.

Fix: Don't free stack memory:

void process_data() {
    int data[1000];  // Stack allocated
    int *ptr = data;

    // ... use ptr ...

    // Don't free; ptr is destroyed automatically
}

Bug 6: Dangling Pointer

Intuition: You have a pointer to memory that's been freed (or gone out of scope).

Definition: A pointer that refers to memory that's no longer valid-either freed or stack-allocated that's gone out of scope.

Example:

char *get_temp() {
    char buf[100] = "temporary";  // Stack allocated
    return buf;  // BUG: Returning dangling pointer
}

int main() {
    char *ptr = get_temp();
    printf("%s\n", ptr);  // UNDEFINED: buf is gone
}
char *ptr;
{
    char *temp = malloc(50);
    strcpy(temp, "test");
    ptr = temp;
    free(temp);  // ptr now dangling
    temp = NULL;
}
printf("%s\n", ptr);  // UNDEFINED: dangling pointer

What goes wrong: Accessing the pointer reads undefined memory-garbage, crashed program, or security vulnerability.

Fix: Either return new heap memory, or adjust lifetime:

// Fix 1: Return heap memory
char *get_temp() {
    char *buf = malloc(100);
    strcpy(buf, "temporary");
    return buf;  // Caller must free
}

// Fix 2: Use stack return value (requires caller to copy)
int process(char *dest, size_t size) {
    snprintf(dest, size, "temporary");
    return 0;
}

int main() {
    char buf[100];
    process(buf, sizeof(buf));
}

Bug 7: Returning Pointer to Local Variable

Intuition: A specific dangling pointer case-returning stack variable's address.

Definition: A function returns a pointer to a local variable (stack-allocated). When the function returns, the local variable's memory is freed, and the returned pointer becomes invalid.

Example (classic exam question):

int *get_number() {
    int x = 42;
    return &x;  // BUG: Returning pointer to stack variable
}

int main() {
    int *ptr = get_number();
    printf("%d\n", *ptr);  // UNDEFINED: x no longer exists
}

More subtle:

char *create_string(char ch, int num) {
    char new_str[num + 1];  // Stack allocated
    for (int i = 0; i < num; i++) {
        new_str[i] = ch;
    }
    new_str[num] = '\0';
    return new_str;  // BUG: Returning pointer to local array
}

int main() {
    char *str = create_string('a', 4);
    printf("%s\n", str);  // UNDEFINED
}

What goes wrong: The stack frame is destroyed when the function returns. The array/variable no longer exists. The returned pointer points to memory that's now unallocated and will be reused by future function calls. Reading it returns whatever is currently on the stack. Writing to it corrupts other function's data.

Fix: Use heap allocation:

char *create_string(char ch, int num) {
    char *new_str = malloc(num + 1);  // Heap allocated
    for (int i = 0; i < num; i++) {
        new_str[i] = ch;
    }
    new_str[num] = '\0';
    return new_str;  // Safe: heap memory persists
}

int main() {
    char *str = create_string('a', 4);
    printf("%s\n", str);  // Works: "aaaa"
    free(str);  // Caller's responsibility
}

Bug 8: Buffer Overflow / Insufficient Space

Intuition: Writing more data to a buffer than it can hold.

Definition: Writing past the end of an allocated buffer, corrupting memory beyond the buffer.

Example:

// Static buffer too small
char buf[5];
strcpy(buf, "hello, world");  // BUG: "hello, world" is 12 chars + null
                              // buf only has 5 chars; overflow!

// Malloc'd buffer too small
char *str = malloc(10);
strcpy(str, "this is a very long string");  // BUG: overflow

// Array index out of bounds
int arr[10];
for (int i = 0; i <= 10; i++) {  // BUG: should be i < 10
    arr[i] = 0;  // arr[10] is out of bounds
}

What goes wrong: Data is written past the end of the buffer, corrupting whatever follows in memory:

  • Other local variables get overwritten
  • Heap bookkeeping data gets corrupted
  • Return addresses on the stack get overwritten
  • The program crashes with no clear indication of why

This is a classic security vulnerability.

Fix:

// Use strncpy to limit copying
char buf[10];
strncpy(buf, "hello", sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';

// Allocate enough space
char *str = malloc(strlen("long string") + 1);
strcpy(str, "long string");

// Use bounds checking in loops
for (int i = 0; i < 10; i++) {  // correct: <, not <=
    arr[i] = 0;
}

Bug 9: Uninitialized Pointer Dereference

Intuition: Using a pointer before assigning it a valid address.

Definition: Dereferencing a pointer that hasn't been initialized (or set to an invalid address), causing access to undefined memory.

Example:

int *ptr;        // Uninitialized!
*ptr = 42;       // BUG: ptr points to random memory

char *str;       // Uninitialized!
strcpy(str, "hello");  // BUG: str points to random memory

int arr[10];
int *p = arr;
p++;
p++;
int *q;  // Uninitialized!
*q = *p;  // BUG: q is uninitialized

What goes wrong: The pointer contains whatever garbage was in that memory location. Dereferencing it accesses random memory, likely causing:

  • Immediate crash (segmentation fault)
  • Subtle memory corruption

Fix: Always initialize pointers:

int *ptr = NULL;  // Initialized to NULL
if (something) {
    ptr = malloc(sizeof(int));
    assert(ptr != NULL);
    *ptr = 42;
}
free(ptr);

char *str = NULL;  // Initialized
if (need_string) {
    str = malloc(100);
    strcpy(str, "hello");
}
free(str);

Bug 10: NULL Dereference

Intuition: Following a NULL pointer.

Definition: Dereferencing a pointer that has the value NULL, causing an invalid memory access.

Example:

int *ptr = malloc(10);
if (ptr == NULL) {
    printf("Allocation failed\n");
    // ptr is NULL here
}

*ptr = 42;  // BUG: NULL dereference if allocation failed

char *str = strdup("hello");
// No assert! strdup could have failed
printf("%s\n", str);  // BUG: if str is NULL, crash

int *arr[10];
arr[0] = NULL;
printf("%d\n", *arr[0]);  // BUG: NULL dereference

What goes wrong: Dereferencing NULL is undefined behavior. On most systems, address 0 is not accessible, causing:

  • Immediate crash (segmentation fault)
  • In some contexts, memory corruption

Fix: Always check for NULL:

int *ptr = malloc(10);
if (ptr == NULL) {
    fprintf(stderr, "Allocation failed\n");
    return -1;
}
*ptr = 42;

char *str = strdup("hello");
assert(str != NULL);  // Crash if NULL; better than silent bug
printf("%s\n", str);

int *arr[10] = {NULL};
if (arr[0] != NULL) {
    printf("%d\n", *arr[0]);
}

Bug 11: Array Index Out of Bounds

Intuition: Accessing an array element that doesn't exist.

Definition: Using an array index outside the valid range (0 to size-1), accessing invalid memory.

Example:

int arr[10];
arr[10] = 42;  // BUG: Valid indices are 0-9, not 0-10

char str[5] = "hello";  // Valid indices 0-4 (including '\0' at position 4)
printf("%c\n", str[5]);  // BUG: out of bounds

for (int i = 0; i <= 10; i++) {  // BUG: should be < 10
    arr[i] = i;
}

What goes wrong: Accessing memory beyond the array corrupts other data or crashes.

Fix:

int arr[10];
for (int i = 0; i < 10; i++) {  // < not <=
    arr[i] = i;
}

arr[9] = 42;  // Valid

// For dynamic arrays, track size
int *dyn = malloc(10 * sizeof(int));
for (int i = 0; i < 10; i++) {
    dyn[i] = i;
}

Bug 12: Assignment of Incompatible Types

Intuition: Treating one type of pointer as another type.

Definition: Assigning or casting pointers of incompatible types, leading to misinterpretation of data.

Example:

char *str = "hello";
int *as_int = (int *) str;
printf("%d\n", *as_int);  // BUG: interpreting char data as int

double *d = (double *) malloc(10 * sizeof(int));
// BUG: allocated int space but using as double pointer

struct Point { int x; int y; };
struct Point *p = (struct Point *) malloc(10 * sizeof(int));
// BUG: not enough space for struct

What goes wrong: Data is misinterpreted. Reading an int from char data reads garbage. Writing to misaligned memory corrupts neighboring data.

Fix:

char *str = malloc(10);
strcpy(str, "hello");
// char *str = (char *) str;  // Don't cast
// int *as_int = (int *) str;  // Wrong

double *d = malloc(10 * sizeof(double));  // Allocate right type

struct Point *p = malloc(10 * sizeof(struct Point));  // Right size

Bug 13: Incorrect Pointer Arithmetic

Intuition: Moving a pointer wrong number of bytes.

Definition: Adding/subtracting from a pointer using the wrong scale, causing the pointer to land at unintended addresses.

Important: C's pointer arithmetic scales by the type's size:

int *p;
p += 1;      // Moves by 1 * sizeof(int) = 4 bytes (on 32-bit)

char *c;
c += 1;      // Moves by 1 * sizeof(char) = 1 byte

double *d;
d += 1;      // Moves by 1 * sizeof(double) = 8 bytes

Example (classic exam bug):

int *nums = malloc(10 * sizeof(int));
// nums[0] is at address, say, 0x1000
// nums[1] is at address 0x1004 (4 bytes later)

int *p = nums;
p++;  // Moves to 0x1004 (nums[1]) - CORRECT by accident!

// But accessing wrong element:
*(p + 3);  // Moves from current to +3*4=+12 bytes
           // This is nums[1] + 3 = nums[4]

// Common bug: confusing array index with pointer arithmetic
int *arr = nums + 1;  // arr points to nums[1]
arr[0] = 42;  // Same as nums[1] = 42
arr[1] = 99;  // Same as nums[2] = 99
// This is easy to get confused about!

What goes wrong: The pointer lands at the wrong address, corrupting or reading unintended data.

Fix: Be very careful with pointer arithmetic; use array indexing when possible:

int *nums = malloc(10 * sizeof(int));

// Right: array indexing
nums[3] = 42;
nums[4] = 99;

// Also right: careful pointer arithmetic
*(nums + 3) = 42;
*(nums + 4) = 99;

// Wrong: forgetting scale
// *(nums + 12) = 42;  // Moves 12 elements, not 12 bytes!

Part 7: Stack vs Heap: Common Exam Patterns

Pattern 1: Function Returns Data

Question: When should a function return stack vs heap?

Stack Return:

// Return simple types directly (value, not pointer)
int get_count() {
    int count = 100;
    return count;  // Correct: return by value
}

Heap Return (when caller needs dynamic memory):

// Return pointer to heap memory when size is variable
char *read_line(FILE *fp) {
    char *line = malloc(256);
    fgets(line, 256, fp);
    return line;  // Caller must free
}

Wrong (returns pointer to local variable):

char *get_message() {
    char buf[100] = "hello";
    return buf;  // BUG: dangling pointer!
}

Pattern 2: Array Allocation

Stack when size is known:

void process_data() {
    int scores[100];  // Fixed size, stack
    // ... use scores ...
}  // Automatically freed

Heap when size is runtime-determined:

int *allocate_scores(int n) {
    int *scores = malloc(n * sizeof(int));
    // ... fill scores ...
    return scores;  // Caller must free
}

Pattern 3: Function Parameters

Stack data passed by value or reference:

void update_buffer(char *buf) {
    // buf points to data allocated by caller
    // Don't free it here! Caller manages lifetime
    strcpy(buf, "new value");
}

int main() {
    char mybuf[100];
    update_buffer(mybuf);  // Pass pointer to stack data
    // mybuf still valid here
}

Caller's responsibility when function returns heap:

char *create_string() {
    char *s = malloc(50);
    strcpy(s, "hello");
    return s;  // Caller owns it now
}

int main() {
    char *str = create_string();
    // ... use str ...
    free(str);  // Caller must free
}

Part 8: Common Exam Traps

Trap 1: Malloc returns NULL on failure

int *arr = malloc(size);
arr[0] = 42;  // BUG if malloc returned NULL!

Fix: Always check

int *arr = malloc(size);
if (arr == NULL) {
    fprintf(stderr, "Out of memory\n");
    return -1;
}
arr[0] = 42;

Trap 2: Forgetting to add 1 for null terminator

char *str = malloc(strlen("hello"));  // BUG: off-by-one!
strcpy(str, "hello");  // Buffer overflow!

Fix:

char *str = malloc(strlen("hello") + 1);  // +1 for '\0'
strcpy(str, "hello");  // Correct

Trap 3: Pointer to pointer confusion

int **ptr_to_ptr = malloc(sizeof(int*));
*ptr_to_ptr = malloc(sizeof(int));
**ptr_to_ptr = 42;

// Must free in right order:
free(*ptr_to_ptr);  // Free the int
free(ptr_to_ptr);   // Free the pointer

Trap 4: Lost pointers

char *str = malloc(100);
str = malloc(100);  // BUG: Original 100 bytes leaked!

// Fix: free before reallocating
char *str = malloc(100);
free(str);
str = malloc(100);

// Or use realloc:
str = realloc(str, 200);  // Safely resizes and preserves data

Trap 5: Modifying loop variable

char *arr = malloc(10 * sizeof(char*));
for (int i = 0; i < 10; i++) {
    arr[i] = malloc(50);
}

// Wrong: can't free properly if arr is modified
// arr = arr + 5;  // arr no longer points to start!

// Correct: keep original pointer
char *start = arr;
// ... use arr ...
for (int i = 0; i < 10; i++) {
    free(arr[i]);
}
free(start);

Trap 6: sizeof confusion

char *buf = malloc(100);  // Correct: 100 bytes

char *buf = malloc(sizeof(char) * 100);  // Also correct

int *nums = malloc(10 * sizeof(int));  // Correct

int *nums = malloc(10 * sizeof(int*));  // BUG: allocates pointers!

Trap 7: Function modifying pointers

When passing a pointer to a function, the function gets a copy of the pointer:

void reset(int *ptr) {
    ptr = NULL;  // Changes the local copy, not the original!
}

int main() {
    int *p = malloc(10);
    reset(p);
    *p = 42;  // p is NOT NULL; still valid
}

If the function needs to modify the pointer itself, need a pointer-to-pointer:

void reset(int **ptr) {
    free(*ptr);
    *ptr = NULL;  // Changes the original
}

int main() {
    int *p = malloc(10);
    reset(&p);
    *p = 42;  // ERROR: p is NULL now
}

Trap 8: realloc failures leak memory

char *arr = malloc(100);
arr = realloc(arr, 200);  // If fails, arr becomes NULL and 100 bytes leak!

// Right pattern:
char *temp = realloc(arr, 200);
if (temp == NULL) {
    free(arr);
    return -1;
}
arr = temp;

Trap 9: Strdup on NULL

char *str = NULL;
char *copy = strdup(str);  // Undefined behavior

Trap 10: Free order in structs

struct Node {
    char *data;
    int value;
};

struct Node *node = malloc(sizeof(struct Node));
node->data = malloc(50);

// Wrong order:
free(node);     // Free the struct first
free(node->data);  // Can't access node->data now! Error.

// Right order:
free(node->data);  // Free the contained data first
free(node);        // Then free the struct

Part 9: Exam-Style Practice Problems

Problem 1: String Manipulation with Multiple Allocations

Question: Write a function that concatenates two strings and returns the result. The input strings are read-only (owned by caller). Your function must allocate new memory, and the caller will free it.

// Your implementation:
char *concat(const char *s1, const char *s2) {
    // TODO: implement this
}

// Test code:
int main() {
    char *result = concat("hello", " world");
    assert(result != NULL);
    printf("%s\n", result);  // Should print "hello world"
    free(result);
    return 0;
}

Solution:

char *concat(const char *s1, const char *s2) {
    // Calculate size needed (include null terminator)
    size_t size = strlen(s1) + strlen(s2) + 1;

    // Allocate memory
    char *result = malloc(size);
    assert(result != NULL);

    // Copy both strings
    strcpy(result, s1);
    strcat(result, s2);

    return result;
}

Exam variations:

  • What if s1 or s2 is NULL? (Check before using)
  • What if malloc fails? (Return NULL and let caller check)
  • What if the strings are very large? (Still works; malloc handles it)

Problem 2: Array of Strings

Question: Write a function that takes an array of strings and a count, and returns a new array with copies of those strings. You must allocate the new array and all its strings.

char **copy_strings(const char *const *strings, int count) {
    // TODO: implement this
}

// Test:
int main() {
    const char *original[] = {"apple", "banana", "cherry"};
    char **copy = copy_strings(original, 3);

    for (int i = 0; i < 3; i++) {
        printf("%s\n", copy[i]);
    }

    // Free: must free in right order
    for (int i = 0; i < 3; i++) {
        free(copy[i]);
    }
    free(copy);
}

Solution:

char **copy_strings(const char *const *strings, int count) {
    // Allocate array of pointers
    char **result = malloc(count * sizeof(char *));
    assert(result != NULL);

    // Allocate and copy each string
    for (int i = 0; i < count; i++) {
        result[i] = strdup(strings[i]);
        assert(result[i] != NULL);
    }

    return result;
}

Exam variations:

  • What if count is 0? (malloc(0) is implementation-defined; check it)
  • What if one of the original strings is NULL? (Handle it or document assumptions)
  • What if strdup fails? (Cleanup partially allocated strings)

Problem 3: Dynamic Array Growth with Realloc

Question: Implement a simple dynamic array that starts small and grows as needed. Implement add() function.

typedef struct {
    int *data;
    int size;      // Number of elements currently stored
    int capacity;  // Allocated space
} IntArray;

IntArray *create_array() {
    IntArray *arr = malloc(sizeof(IntArray));
    arr->data = malloc(10 * sizeof(int));
    arr->size = 0;
    arr->capacity = 10;
    return arr;
}

void add(IntArray *arr, int value) {
    // TODO: If size == capacity, reallocate with double capacity
    // Then add the value
}

void free_array(IntArray *arr) {
    // TODO: implement
}

Solution:

void add(IntArray *arr, int value) {
    // Check if we need to grow
    if (arr->size == arr->capacity) {
        arr->capacity *= 2;
        int *temp = realloc(arr->data, arr->capacity * sizeof(int));
        assert(temp != NULL);
        arr->data = temp;
    }

    // Add the value
    arr->data[arr->size] = value;
    arr->size++;
}

void free_array(IntArray *arr) {
    free(arr->data);
    free(arr);
}

Exam variations:

  • What if realloc fails? (Don't modify arr, return error)
  • What if we shrink? (Similar logic, but half capacity)

Problem 4: Memory Bug Identification

Question: Identify all memory bugs in this code:

char *concat_with_separator(const char *s1, const char *s2, char sep) {
    char *result = malloc(strlen(s1) + strlen(s2));  // Line 1
    strcpy(result, s1);
    result[strlen(s1)] = sep;
    strcpy(result + strlen(s1) + 1, s2);
    return result;
}

int main() {
    char *str = concat_with_separator("hello", "world", '-');
    printf("%s\n", str);
    char *copy = str;
    free(copy);
    free(str);  // Line 11
    return 0;
}

Answer:

  1. Line 1: Insufficient space. Need strlen(s1) + strlen(s2) + 2 (for separator and null terminator).
  2. Line 11: Double free. str and copy point to the same memory; freeing both is a double free.

Fixed version:

char *concat_with_separator(const char *s1, const char *s2, char sep) {
    size_t size = strlen(s1) + strlen(s2) + 2;  // +1 for sep, +1 for null
    char *result = malloc(size);
    assert(result != NULL);
    strcpy(result, s1);
    result[strlen(s1)] = sep;
    strcpy(result + strlen(s1) + 1, s2);
    return result;
}

int main() {
    char *str = concat_with_separator("hello", "world", '-');
    printf("%s\n", str);
    free(str);  // Only free once
    return 0;
}

Conclusion

Master these concepts:

  1. Stack: Automatic, fast, fixed size, automatic cleanup
  2. Heap: Manual, slower, flexible, explicit cleanup required
  3. Allocation functions: malloc (uninitialized), calloc (zeroed), realloc (resize), strdup (string copy)
  4. free(): Exactly once, starting address, NULL-safe
  5. 13 Bug types: Understand the intuition, definition, example, and fix for each

The key to exam success is not memorizing, but deeply understanding when to use each approach and why certain patterns lead to bugs. Trace through code carefully: What is allocated? Where? When is it freed? Do all allocations have matching frees?

Memory management is difficult because errors are often silent-they corrupt memory elsewhere. The fix is always vigilance: check malloc returns, free exactly once, use tools like Valgrind, and practice.

3 - Memory Management - Stack, Heap, and Memory Bugs — Umut Yalçın Baki