Dynamic Memory: malloc/free vs Box/Vec

Stack memory is fast but limited in size and lifetime. When you need memory that outlives a function call or whose size is not known at compile time, you allocate it on the heap. C gives you raw tools; Rust gives you safe abstractions over the same tools.

malloc and free

malloc requests bytes from the heap. free returns them. Everything between is your responsibility.

/* malloc_basic.c */
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *p = malloc(sizeof(int));
    if (p == NULL) {
        perror("malloc");
        return 1;
    }

    *p = 42;
    printf("*p = %d\n", *p);

    free(p);
    p = NULL;   /* good practice: prevent use-after-free */

    return 0;
}
  Before malloc:              After malloc:
  Stack                       Stack           Heap
  +---+------+                +---+--------+  +----+
  | p | NULL |                | p | 0x5000-|->| 42 |
  +---+------+                +---+--------+  +----+

  After free:
  Stack           Heap
  +---+------+    +----+
  | p | NULL |    | ?? |  <-- memory returned to allocator
  +---+------+    +----+

calloc and realloc

calloc allocates and zero-initializes. realloc resizes an existing allocation.

/* calloc_realloc.c */
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    /* calloc: allocate 5 ints, all zeroed */
    int *arr = calloc(5, sizeof(int));
    if (arr == NULL) {
        perror("calloc");
        return 1;
    }

    for (int i = 0; i < 5; i++) {
        arr[i] = (i + 1) * 10;
    }

    /* realloc: grow to 10 ints */
    int *tmp = realloc(arr, 10 * sizeof(int));
    if (tmp == NULL) {
        perror("realloc");
        free(arr);
        return 1;
    }
    arr = tmp;

    /* New elements are uninitialized (not zeroed!) */
    for (int i = 5; i < 10; i++) {
        arr[i] = (i + 1) * 10;
    }

    for (int i = 0; i < 10; i++) {
        printf("arr[%d] = %d\n", i, arr[i]);
    }

    free(arr);
    return 0;
}

Caution: Never do arr = realloc(arr, new_size). If realloc fails, it returns NULL and you lose the original pointer -- a memory leak. Always use a temporary variable.

Memory Leaks

A memory leak occurs when allocated memory is never freed. The process holds onto memory it can never use again.

/* leak.c -- DO NOT DO THIS */
#include <stdio.h>
#include <stdlib.h>

void leaky(void)
{
    int *p = malloc(1024);
    if (p == NULL) return;
    *p = 42;
    /* forgot to free(p) -- leaked 1024 bytes */
}

int main(void)
{
    for (int i = 0; i < 1000; i++) {
        leaky();  /* leaks 1 MB total */
    }
    printf("Done (but leaked ~1 MB)\n");
    return 0;
}

Run with Valgrind to detect:

$ gcc -g -o leak leak.c
$ valgrind --leak-check=full ./leak
...
==12345== LEAK SUMMARY:
==12345==    definitely lost: 1,024,000 bytes in 1,000 blocks

Double-Free

Freeing the same pointer twice is undefined behavior. It can corrupt the allocator's internal data structures, leading to crashes or exploits.

/* double_free.c -- DO NOT DO THIS */
#include <stdlib.h>

int main(void)
{
    int *p = malloc(sizeof(int));
    *p = 42;
    free(p);
    /* free(p); */  /* UNDEFINED BEHAVIOR: double-free */

    p = NULL;       /* Setting to NULL after free prevents double-free */
    free(p);        /* free(NULL) is safe -- it does nothing */

    return 0;
}

Caution: Double-free bugs are a major source of security vulnerabilities. Attackers can exploit heap corruption caused by double-free to execute arbitrary code.

Rust's Box<T>

Box<T> allocates a single value on the heap. When the Box goes out of scope, the memory is freed automatically.

// box_demo.rs
fn main() {
    let b = Box::new(42);
    println!("*b = {}", *b);

    // b goes out of scope here -> memory freed automatically
    // No free() needed. No leak possible. No double-free possible.
}
  Stack                  Heap
  +---+---------+       +----+
  | b | 0x5000 -|------>| 42 |
  +---+---------+       +----+

  When b drops:
  - Heap memory at 0x5000 is freed
  - b is gone from the stack

Rust's Vec<T>

Vec<T> is a growable heap array. It replaces C's malloc/realloc/free pattern.

// vec_grow.rs
fn main() {
    let mut v: Vec<i32> = Vec::new();
    println!("len={}, cap={}", v.len(), v.capacity());

    for i in 0..10 {
        v.push(i * 10);
        println!("pushed {}: len={}, cap={}", i * 10, v.len(), v.capacity());
    }

    println!("\ncontents: {:?}", v);
}
$ rustc vec_grow.rs && ./vec_grow
len=0, cap=0
pushed 0: len=1, cap=4
pushed 10: len=2, cap=4
pushed 20: len=3, cap=4
pushed 30: len=4, cap=4
pushed 40: len=5, cap=8
pushed 50: len=6, cap=8
pushed 60: len=7, cap=8
pushed 70: len=8, cap=8
pushed 80: len=9, cap=16
pushed 90: len=10, cap=16

contents: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]

Vec doubles its capacity when full (the exact growth factor is an implementation detail). Compare this to manually calling realloc in C.

RAII and the Drop Trait

RAII (Resource Acquisition Is Initialization) means: tie resource cleanup to scope exit. In Rust, every type can implement the Drop trait to run cleanup code when a value goes out of scope.

// drop_demo.rs
struct Resource {
    name: String,
}

impl Resource {
    fn new(name: &str) -> Self {
        println!("[{}] acquired", name);
        Resource {
            name: String::from(name),
        }
    }
}

impl Drop for Resource {
    fn drop(&mut self) {
        println!("[{}] released", self.name);
    }
}

fn main() {
    let _a = Resource::new("A");
    {
        let _b = Resource::new("B");
        let _c = Resource::new("C");
        println!("-- end of inner scope --");
    }  // B and C dropped here (reverse order)
    println!("-- end of main --");
}  // A dropped here
$ rustc drop_demo.rs && ./drop_demo
[A] acquired
[B] acquired
[C] acquired
-- end of inner scope --
[C] released
[B] released
-- end of main --
[A] released

Drop order is reverse of creation order, just like C++ destructors.

Driver Prep: The Rust-for-Linux project uses RAII extensively. When a device driver struct is dropped, it automatically unregisters the device, frees DMA buffers, and releases IRQs. This eliminates an entire class of kernel resource leaks.

C Equivalent of RAII: goto cleanup

C does not have destructors. The standard pattern is goto cleanup:

/* goto_cleanup.c */
#include <stdio.h>
#include <stdlib.h>

int process_data(int n)
{
    int ret = -1;

    int *buf1 = malloc(n * sizeof(int));
    if (buf1 == NULL) goto out;

    int *buf2 = malloc(n * sizeof(int));
    if (buf2 == NULL) goto free_buf1;

    /* Do work with buf1 and buf2 */
    for (int i = 0; i < n; i++) {
        buf1[i] = i;
        buf2[i] = i * 2;
    }
    printf("Processed %d elements\n", n);
    ret = 0;

    free(buf2);
free_buf1:
    free(buf1);
out:
    return ret;
}

int main(void)
{
    if (process_data(10) != 0) {
        fprintf(stderr, "processing failed\n");
        return 1;
    }
    return 0;
}

Driver Prep: This goto cleanup pattern is the single most common pattern in Linux kernel code. Every probe() function looks like this. Rust's RAII replaces it entirely.

Detecting Memory Bugs

Valgrind

$ gcc -g -o program program.c
$ valgrind --leak-check=full --show-leak-kinds=all ./program

Valgrind instruments every memory access at runtime. It catches:

  • Memory leaks
  • Use-after-free
  • Double-free
  • Buffer overflows (heap)
  • Uninitialized reads

AddressSanitizer (ASan)

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

ASan is faster than Valgrind (2x slowdown vs 20x). It catches the same bugs plus stack buffer overflows. Both GCC and Clang support it.

/* asan_demo.c -- compile with -fsanitize=address */
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *p = malloc(5 * sizeof(int));
    p[5] = 99;  /* heap-buffer-overflow */
    free(p);
    return 0;
}
$ gcc -g -fsanitize=address -o asan_demo asan_demo.c && ./asan_demo
=================================================================
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
WRITE of size 4 at 0x... thread T0
    #0 0x... in main asan_demo.c:8

Try It: Compile the leak example above with -fsanitize=address and compare the output to Valgrind. ASan's reports are often more readable.

Side-by-Side: Linked List

This is the definitive comparison. A singly-linked list shows every difference between C and Rust memory management.

C Linked List

/* linked_list_c.c */
#include <stdio.h>
#include <stdlib.h>

struct node {
    int value;
    struct node *next;
};

struct node *list_push(struct node *head, int value)
{
    struct node *n = malloc(sizeof(struct node));
    if (n == NULL) {
        perror("malloc");
        exit(1);
    }
    n->value = value;
    n->next = head;
    return n;
}

void list_print(const struct node *head)
{
    const struct node *cur = head;
    while (cur != NULL) {
        printf("%d -> ", cur->value);
        cur = cur->next;
    }
    printf("NULL\n");
}

void list_free(struct node *head)
{
    struct node *cur = head;
    while (cur != NULL) {
        struct node *next = cur->next;
        free(cur);
        cur = next;
    }
}

int main(void)
{
    struct node *list = NULL;

    list = list_push(list, 10);
    list = list_push(list, 20);
    list = list_push(list, 30);

    list_print(list);   /* 30 -> 20 -> 10 -> NULL */

    list_free(list);
    return 0;
}

Three things that can go wrong in the C version:

  1. Forget list_free -- memory leak
  2. Use list after list_free -- use-after-free
  3. Free a node twice -- double-free

Rust Linked List

// linked_list_rust.rs

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

impl List {
    fn push(self, value: i32) -> List {
        Cons(value, Box::new(self))
    }

    fn print(&self) {
        let mut current = self;
        loop {
            match current {
                Cons(val, next) => {
                    print!("{} -> ", val);
                    current = next;
                }
                Nil => {
                    println!("Nil");
                    break;
                }
            }
        }
    }
}

fn main() {
    let list = Nil
        .push(10)
        .push(20)
        .push(30);

    list.print();  // 30 -> 20 -> 10 -> Nil

    // list goes out of scope here.
    // Each Box is dropped recursively. No leak. No double-free.
}
$ rustc linked_list_rust.rs && ./linked_list_rust
30 -> 20 -> 10 -> Nil
  Memory layout:

  C version:
  head -> [30|*]----> [20|*]----> [10|NULL]
          malloc'd    malloc'd    malloc'd
          (must free  (must free  (must free
           manually)   manually)   manually)

  Rust version:
  list = Cons(30, Box::new(
           Cons(20, Box::new(
             Cons(10, Box::new(Nil))))))

  Stack           Heap           Heap           Heap
  +------+       +------+       +------+       +------+
  | list |------>| 30   |       | 20   |       | 10   |
  +------+       | Box -|------>| Box -|------>| Nil  |
                 +------+       +------+       +------+
                 (auto-drop)    (auto-drop)    (auto-drop)

No list_free function needed. When list goes out of scope, each Box drops its contents, which triggers the next drop, recursively freeing the entire chain.

Rust Note: For long lists, recursive drop can overflow the stack. In production, you would implement Drop manually with an iterative loop. The standard library's LinkedList<T> handles this.

Comparing Memory Management Styles

  +-------------------+-------------------------+------------------------+
  | Operation         | C                       | Rust                   |
  +-------------------+-------------------------+------------------------+
  | Allocate one      | malloc(sizeof(T))       | Box::new(val)          |
  | Allocate array    | malloc(n * sizeof(T))   | Vec::with_capacity(n)  |
  | Zero-allocate     | calloc(n, sizeof(T))    | vec![0; n]             |
  | Resize            | realloc(p, new_size)    | v.reserve(additional)  |
  | Free              | free(p)                 | automatic (Drop)       |
  | Detect leaks      | valgrind, ASan          | not needed*            |
  | Detect use-after  | valgrind, ASan          | compile error           |
  | Detect double-free| valgrind, ASan          | compile error           |
  +-------------------+-------------------------+------------------------+
  * Rust can still leak via mem::forget or Rc cycles, but accidental
    leaks from forgetting free() are impossible.

std::mem::drop and Early Cleanup

Sometimes you want to free memory before a scope ends. Rust's drop() function consumes a value, triggering its destructor.

// early_drop.rs
fn main() {
    let data = vec![1, 2, 3, 4, 5];
    println!("data = {:?}", data);

    drop(data);  // free heap memory now

    // println!("{:?}", data);  // ERROR: use of moved value
    println!("data has been freed");
}

This is safe because drop takes ownership (moves the value). After the move, the compiler prevents any further access.

Try It: Create a struct that holds a large Vec<u8> (say, 10 MB). Print its size, then drop it, then allocate another. Observe that you cannot accidentally use the first after dropping.

Knowledge Check

  1. What happens if realloc fails and you wrote p = realloc(p, new_size)?
  2. What is the Rust equivalent of C's goto cleanup pattern?
  3. Why can Rust guarantee no double-free at compile time?

Common Pitfalls

  • Forgetting to check malloc's return -- it can return NULL on allocation failure.
  • Using realloc incorrectly -- always assign to a temporary first.
  • Mixing allocators -- do not free() memory from a custom allocator, or vice versa.
  • Forgetting to free in every code path -- C's goto cleanup exists because error paths leak memory.
  • Leaking Rc cycles in Rust -- Rc<T> does not use a garbage collector. Cycles leak. Use Weak<T> to break them.
  • Calling mem::forget casually -- it prevents Drop from running. Use it only when you know what you are doing.
  • Heap fragmentation -- many small allocations can fragment memory. Use pool allocators or arena allocation for high-frequency allocation patterns.