Skip to main content

Command Palette

Search for a command to run...

Understanding Rust’s Memory from Scratch

Updated
•24 min read•View as Markdown
Understanding Rust’s Memory from Scratch
S

I am a student, a developer, a tech enthusiasts with some unconventional ideas...

Getting comfortable and familiar with Computer Memory

Before diving into Rust's borrow checker and ownership model, you must understand the sandbox where your program runs. The virtual (logical) address space, the fundamental abstraction provided by the Operating System (OS) that tricks every program into thinking it has exclusive access to the entire system's memory, isolating it from memory accesses made by other processes.

Well a deep breath before we dive in. I don't wanna bombard you with the lots of concepts in one blog and overwhelm your brain. The goal of this blog is to give the whole picture of why Rust developers would have gone with the ownership and borrowing philosophy in Rust programming language and resolve some curiosity and doubts people can usually have when starting out in Rust.

Virtual Address Space

What is it?

Have you ever asked yourself this question: while multiple processes are running on a computer at the same time, how on earth do two different processes not read or write each other's memory? I mean it can happen right? How can we casually allocate a dynamic memory without caring much about it might be occupied by some other processes' variables? While iterating in an unrestricted loop that stops at some condition, how doesn't the iterator steps into the other program's memory and access its variables? The answer is the Logical or Virtual Address Space. Virtual address space is a virtual abstraction of the real physical memory which isolates its process from other processes.

So, consider process A and process B, both have their own Virtual address spaces. Now, this Virtual Address Space is an abstraction provided and managed by the OS, but its implementation involves both the OS and hardware. In a normal case, Process A can only access the memory mapped into its own Virtual address space A, and Process B can only access the memory mapped into its own Virtual address space B, and because of this, all the processes basically get the illusion of having the access to computer's entire memory. Then further, this Virtual address space is mapped to the physical address space with the help of CPU's Memory Management Unit (MMU, a hardware component) and Page Table. The OS maintains Page Tables, which contain mappings between virtual pages and physical memory frames, and the CPU's MMU uses these mappings to translate virtual addresses into physical addresses.

So basically, a process works with virtual memory addresses, not directly with physical memory addresses. The OS manages the mappings, while the CPU's MMU performs the translation and enforces the associated access permissions.

The diagram above gives us a high-level picture of how the virtual addresses used by our program eventually map to physical memory. The Page Table maintains the mapping between virtual pages and physical frames, while the CPU's MMU performs the translation.

You might also notice the TLB (Translation Lookaside Buffer) in some address-translation diagrams. It is essentially a small cache of recently used virtual-page-to-physical-frame mappings that makes this translation faster. We won't go into its details here, as it is outside the scope of this blog.

Regions of Virtual Address Space

When we talk about the layout of a Virtual Address Space, we are simply talking about how the available range of virtual addresses is organised into different regions or mappings, with each region serving a particular purpose. These regions can represent things like our program's instructions, static data, dynamically allocated memory, function call stacks, or memory mapped from files.

The exact layout of a process's Virtual Address Space is not universal. It can vary depending on the Operating System, CPU architecture, executable format, and how the program is loaded at runtime. So, instead of assuming that every process has exactly the same memory layout, let's take a typical Linux user-space process as our reference and understand the major regions we generally encounter.

We can broadly identify regions such as Code (Instructions), Data/BSS, Heap, Stack, and Memory-Mapped Regions.

Note: The layout discussed here is a simplified representation to help us build the mental model. The actual addresses and arrangement can vary between processes, especially because of mechanisms such as ASLR (Address Space Layout Randomization).

Code / Instructions

This is where the machine instructions of our program reside.

When we write something like:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

the CPU eventually executes machine instructions corresponding to this function, and those instructions are mapped into the process's Virtual Address Space as part of the Code/Instruction region.

Data / BSS

Next, we have memory used for global and static data.

For example, if our program has some global or static value, the memory required to hold that value needs to exist for essentially the lifetime of the process.

We can broadly think of this area as containing two kinds of data:

  • Initialized data — global/static values that have an initial value.

  • BSS (Block Started by Symbol) — zero-initialized global/static data. Unlike initialized data, the zero-filled bytes don't need to be stored directly in the executable file; the required memory is allocated and initialized to zero at the time when the program is loaded.

Don't worry too much about the name Block Started by Symbol. It comes from historical assembly/linker terminology; today, BSS is simply used as the conventional term for this category of zero-initialized static storage.

Memory-Mapped Regions

A process can also have portions of its Virtual Address Space created through memory mappings.

For example, the OS can map a file into a process's address space, allowing the program to access the contents of that file through memory addresses. Shared libraries are also commonly loaded through memory mappings.

Conceptually:

File / Shared Library
          │
          │ mapping
          ▼
Virtual Address Space
          │
          ▼
     Program accesses
       it as memory

This is another useful thing to keep in mind: not every memory address used by a process necessarily represents a dynamically allocated heap object.

There can be many different kinds of mappings inside a process's Virtual Address Space.

Heap

Now we arrive at one of the most important regions for our discussion: the Heap.

The heap is the area we commonly associate with dynamic memory allocation. Sometimes a program needs memory while it is running, and we don't know beforehand exactly how much memory it will need or how long it will need it. In such cases, the program can request memory from the heap. This is called dynamic memory allocation.

For example:

let name = String::from("Soham");

Conceptually, the String value itself contains some metadata, while the actual string data is stored in a dynamically allocated region.

We can roughly visualize it as:

Stack
┌───────────────────┐
│ String metadata   │
│ pointer ──────────┼──────────┐
│ length            │          │
│ capacity          │          │
└───────────────────┘          │
                               ▼
                         Heap allocation
                         ┌──────────────┐
                         │ S o h a m    │
                         └──────────────┘

This distinction between the stack and heap is going to become very important later, because dynamically allocated memory introduces a question that is central to our discussion:

Who is responsible for this memory, and when is it safe to release it?

Hold on to this question. We'll come back to it.

Stack

Finally, we have the Stack.

The stack is primarily used to manage function calls and their associated stack frames. When a function is called, a new stack frame is created for that call, containing things such as local variables and other information required to execute the function.

For example:

fn main() {
    let x = 10;
    foo(x);
}

fn foo(x: i32) {
    let y = x + 5;
}

Conceptually, while foo is executing, we can think of the stack as containing something like:

┌───────────────────┐
│ foo's stack frame │
│ x                 │
│ y                 │
└───────────────────┘
┌───────────────────┐
│ main's frame      │
│ x                 │
└───────────────────┘

When foo returns, its stack frame is no longer needed and the stack space associated with that call can be reclaimed.

And this gives us an important distinction:

Stack memory naturally follows the nested lifetime of function calls, while dynamically allocated heap memory can have a lifetime that is independent of a particular function call.

This is exactly where things start getting interesting for memory management.

Bringing everything together

So, at a high level, we can now imagine a typical Linux process's Virtual Address Space as containing different kinds of regions, each serving a different purpose:

Higher Virtual Addresses
┌─────────────────────────────┐
│            Stack            │
├─────────────────────────────┤
│                             │
│     Memory-Mapped Regions   │
│   shared libraries, files   │
│                             │
├─────────────────────────────┤
│            Heap             │
├─────────────────────────────┤
│          Data / BSS         │
├─────────────────────────────┤
│          Code / Text        │
└─────────────────────────────┘
Lower Virtual Addresses

This is only a simplified conceptual representation. The actual layout and addresses can vary between processes and systems.

For our purpose, we don't need to memorize this entire layout. What I want you to take away is that when we write a program, our variables, instructions, dynamically allocated objects, libraries, and other resources ultimately exist within this Virtual Address Space, each with different characteristics and lifetimes.

And among all these regions, the Stack and Heap are particularly important for understanding memory management.

So, let's zoom into these two and understand what makes them different, and more importantly, why the Heap creates a memory-management problem that Rust's Ownership model is designed to solve.

Stack, Heap, and the Memory Management Problem

So, let's zoom into these two and understand what makes them different, and more importantly, why the Heap creates a memory-management problem that Rust's Ownership model is designed to solve.

Stack vs Heap

We have seen that both the Stack and Heap are parts of a process's Virtual Address Space, but they serve different purposes.

The simplest way to understand the difference is to look at how memory is managed and how long it lives.

Stack: Memory tied to function calls

When we call a function, the program needs some memory to store its local variables and other information required while that function is running.

For example:

fn main() {
    let x = 10;
    calculate(x);
}

fn calculate(x: i32) {
    let y = x + 5;
    println!("{}", y);
}

When calculate() is called, a new stack frame is created for that function call.

Conceptually:

Stack

┌──────────────────────┐
│ calculate() frame    │
│ x = 10               │
│ y = 15               │
├──────────────────────┤
│ main() frame         │
│ x = 10               │
└──────────────────────┘

When calculate() returns, its stack frame is no longer needed, so the space associated with that frame can be reclaimed.

This gives us a useful property:

The lifetime of stack memory is naturally tied to function calls.

The structure of function calls gives us a natural way to manage this memory.

But the Heap is different.

Heap: Memory that can outlive a function call

Sometimes a program needs memory while it is running, and we don't know beforehand exactly how much memory it will need or how long it will need it.

In such cases, the program can request memory from the Heap. This is called dynamic memory allocation.

For example:

let name = String::from("Soham");

Conceptually, we can think of this as:

Stack                         Heap

┌─────────────────┐          ┌──────────────┐
│ name            │          │ S o h a m    │
│ pointer ────────┼─────────►│              │
│ length          │          └──────────────┘
│ capacity        │
└─────────────────┘

The exact representation of String isn't important yet.

The important thing is that the memory containing "Soham" is dynamically allocated, and its lifetime doesn't have to be tied directly to the current function call.

And this introduces a new problem:

If the Heap gives us memory that can live independently of a function call, who decides when that memory should be released?

What happens if we don't release it?

Suppose our program requests some memory:

Program
   │
   │ request memory
   ▼
Heap

┌──────────────────────┐
│ Allocation A         │
└──────────────────────┘

After the program finishes using it, that memory should eventually become available for reuse.

But imagine the program loses the pointer to that allocation:

Before:

Pointer ──────────────► Allocation A


After:

Pointer
   X

                       Allocation A
                       ┌───────────┐
                       │ still     │
                       │ allocated │
                       └───────────┘

The allocation still exists, but the program no longer has a way to reach it and release it.

This is a memory leak.

The memory hasn't disappeared. It is simply stuck as an allocation that the program can no longer use.

If this happens repeatedly:

Heap

┌─────────────────────────────────┐
│ ███████  leaked allocation      │
│ ███████  leaked allocation      │
│ ███████  leaked allocation      │
│ ███████  leaked allocation      │
│ ███████  leaked allocation      │
│                                 │
│        available memory         │
└─────────────────────────────────┘

more and more memory remains occupied by allocations that are no longer useful.

Eventually, the process may have too little memory available for new allocations. Allocation requests can then fail, and excessive memory consumption can severely degrade the program or cause the operating system to terminate the process.

So:

A memory leak is memory that is no longer useful to the program but remains allocated, preventing that memory from being reused.

But simply saying "we should always release memory" creates another question.

What happens if we release it at the wrong time?

What happens if we release it too early?

Imagine that two parts of our program have access to the same allocation:

             ┌──────────────────┐
             │   Heap Memory A   │
             └──────────────────┘
                 ▲          ▲
                 │          │
              Pointer 1  Pointer 2

Now Pointer 1 decides that the memory is no longer needed and releases it:

Pointer 1 ──────► free(A)

             ┌──────────────────┐
             │   Memory freed   │
             └──────────────────┘

Pointer 2 ──────► ???

Pointer 2 still contains the old address.

But the allocation that used to exist at that address is gone.

If Pointer 2 tries to access it now, the program is accessing memory after it has been released.

This is called a use-after-free.

And it gets even more interesting.

The allocator can now reuse that memory for another allocation:

             ┌──────────────────┐
             │   Allocation B   │
             │                  │
             └──────────────────┘
                      ▲
                      │
                  Pointer 2

Pointer 2 still has the old address, but that address now belongs to something else.

The program's understanding of memory and the actual state of memory have diverged.

What if we release it twice?

Now imagine that both pointers believe they are responsible for releasing the allocation:

             ┌──────────────────┐
             │   Allocation A   │
             └──────────────────┘
                 ▲          ▲
                 │          │
              Pointer 1  Pointer 2

Pointer 1 releases it:

Pointer 1 ──────► free(A)

The memory is now available for reuse.

But Pointer 2 still believes that it owns the same allocation:

Pointer 2 ──────► free(A)   ❌

The second free is invalid because the allocation has already been released.

And the problem becomes especially dangerous if that memory has already been reused:

After first free:

┌──────────────────┐
│ Available memory │
└──────────────────┘

        ↓ allocator reuses it

┌──────────────────┐
│   Allocation B   │
└──────────────────┘
        ▲
        │
   old Pointer 2

        ↓

Pointer 2 ──────► free(A) ❌

The program is now trying to release memory that no longer represents the allocation it originally referred to.

This is called a double free, and in languages such as C and C++, this results in undefined behavior.

So what is the actual problem?

At this point, we have seen three different problems:

                 Heap allocation
                       │
          ┌────────────┼────────────┐
          │            │            │
          ▼            ▼            ▼
      Never free    Free too early  Free twice
          │            │            │
          ▼            ▼            ▼
    Memory leak    Use-after-free  Double free
          │            │            │
          ▼            ▼            ▼
   Memory cannot   Access to an    Invalid
     be reused      allocation     deallocation
                    that no longer
                      exists

But notice that all of these problems are connected by one fundamental question:

Who is responsible for this memory, and when is it safe to release it?

If nobody releases it, we get a memory leak.

If someone releases it while another part of the program is still using it, we can get a use-after-free.

If multiple parts of the program believe they are responsible for releasing the same allocation, we can get a double free.

So the real challenge isn't simply:

"How do we allocate memory?"

The harder question is:

"How do we know who is responsible for an allocation, and exactly when that responsibility ends?"

And now we're finally at the problem that Rust's Ownership model is designed to address.

Ownership: Giving Every Value a Clear Owner

So, we need some way to answer this question reliably:

Who is responsible for a piece of memory, and when does that responsibility end?

Rust's answer to this problem is Ownership.

The basic idea is surprisingly simple:

Every value in Rust has an owner.

Let's start with a simple example:

fn main() {
    let name = String::from("Soham");
}

Here, name is the owner of the String value.

Conceptually:

Stack                         Heap

┌──────────────┐             ┌──────────────┐
│ name         │────────────►│ S o h a m    │
└──────────────┘             └──────────────┘
       │
       │
     owner

The important part is not that the variable name somehow physically owns a particular address.

Rather, Rust's ownership rules establish that name is responsible for the lifetime of this value.

When name goes out of scope, Rust knows that the value is no longer needed and can clean it up.

fn main() {
    let name = String::from("Soham");

} // name goes out of scope here

Conceptually:

name goes out of scope
        ↓
String is dropped
        ↓
Heap allocation is released

This gives us our first important rule:

When an owner goes out of scope, the value it owns is dropped.

This happens automatically. We don't have to manually call free().

But this immediately raises another question.

What does "goes out of scope" mean?

Consider:

fn main() {
    let name = String::from("Soham");

    {
        let message = String::from("Hello");
        println!("{}", message);
    }

    println!("{}", name);
}

The variable message exists only inside the inner block.

main scope
│
│  name
│
│  ┌── inner scope ───────┐
│  │                      │
│  │  message             │
│  │                      │
│  └──────────────────────┘
│
│  name still exists
│
└──────────────────────────

When the inner block ends, message goes out of scope.

Its value is dropped.

But name continues to exist because its scope hasn't ended yet.

So scope gives us a natural way to determine when an owner stops existing.

And that gives us a natural point at which its value can be cleaned up.

What if I assign one variable to another?

Now things get more interesting.

Consider:

let a = String::from("Hello");
let b = a;

What should happen here?

One naive mental model would be:

a ───────┐
         ├──────► "Hello"
b ───────┘

In other words, both a and b own the same Heap allocation.

But remember the problem we just discussed.

If both variables believe they own the same allocation, then when they go out of scope:

a ──────► free(memory)
b ──────► free(memory)   ❌

we could get a double free.

Rust avoids this by not making a simple assignment create two owners of the same heap allocation.

Instead, ownership is moved.

let a = String::from("Hello");
let b = a;

After this:

Before:

a ─────────────► "Hello"


After:

b ─────────────► "Hello"

a   ✗

b is now the owner.

a can no longer be used as the owner of that value.

This is called a move.

Why does Rust move instead of copying?

Because copying the entire Heap allocation every time we assign a String could be expensive.

Imagine:

a
 │
 ▼
┌──────────────────────────────┐
│ A very large amount of data  │
└──────────────────────────────┘

If:

let b = a;

automatically copied all of that data, we'd have:

a ─────► ┌──────────────────┐
         │ huge allocation  │
         └──────────────────┘

b ─────► ┌──────────────────┐
         │ huge copy        │
         └──────────────────┘

Instead, Rust can simply transfer ownership:

a ─────► Allocation

       move

b ─────► Allocation

The ownership transfer itself doesn't require copying the entire allocation.

This is one reason the ownership model is useful not only for safety, but also for predictable performance.

But what about simple values like integers?

You might now try:

let a = 10;
let b = a;

println!("{}", a);
println!("{}", b);

This works.

Why?

Because an i32 doesn't require a separate Heap allocation. Its entire value can simply be copied.

Types such as integers implement a Rust trait called Copy.

So for these types:

a = 10

      copy

b = 10

There is no shared Heap allocation whose ownership needs to be transferred.

This gives us an important distinction:

Move
─────
Transfer ownership of a value.

Copy
────
Create another independent copy of a small Copy value.

We don't need to go deeply into Copy right now. The important thing is understanding why String moves while an integer can be copied.

But what if I don't want to give ownership away?

This is where borrowing comes in.

Suppose we have:

fn print_name(name: String) {
    println!("{}", name);
}

fn main() {
    let name = String::from("Soham");

    print_name(name);

    println!("{}", name); // error
}

We passed name into the function.

Since String isn't Copy, ownership was moved into print_name.

Conceptually:

main

name ───────► "Soham"

              move
                ↓

print_name

name ───────► "Soham"

After the function receives ownership, the original name cannot be used.

But what if the function only needs to look at the String?

We don't want to transfer ownership just to read it.

So Rust allows us to borrow it.

fn print_name(name: &String) {
    println!("{}", name);
}

fn main() {
    let name = String::from("Soham");

    print_name(&name);

    println!("{}", name);
}

Notice the &.

print_name(&name);

We are saying:

"I'm giving you a reference to this value, not ownership of the value itself."

Conceptually:

                 ┌──────────────┐
                 │  "Soham"     │
                 └──────────────┘
                        ▲
                        │
                     owner
                        │
                      name
                        │
                     borrow
                        │
                       &name

The function can use the value, but it doesn't become responsible for destroying it.

When print_name() returns, the borrow ends.

name is still the owner.

So what exactly is a reference?

A reference is a way of referring to a value without taking ownership of it.

There are two main kinds of references:

&value
&mut value

The first is an immutable reference.

The second is a mutable reference.

Let's start with immutable references.

Immutable references: "You can look, but don't modify"

Suppose:

let name = String::from("Soham");

let a = &name;
let b = &name;

We now have multiple references to the same value:

             ┌──────────────┐
             │  "Soham"     │
             └──────────────┘
               ▲    ▲    ▲
               │    │    │
             name   a    b
                   &    &

This is safe because none of these references are allowed to modify the value.

So Rust allows multiple immutable references at the same time.

In simple terms:

Many people can read the same value simultaneously as long as nobody is modifying it.

What if I want to modify the value?

Then we need a mutable reference.

let mut name = String::from("Soham");

let name_ref = &mut name;

name_ref.push_str(" Panchal");

The mut on the variable allows the value to be modified, and &mut creates a mutable reference to it.

Conceptually:

             ┌──────────────┐
             │  "Soham"     │
             └──────────────┘
                    ▲
                    │
                  &mut
                    │
                name_ref

But Rust imposes an important restriction:

At a given time, you can have either multiple immutable references or one mutable reference to a value — but not both.

For example:

let mut name = String::from("Soham");

let a = &name;
let b = &name;

let c = &mut name; // ❌

Why?

Because a and b still allow someone to read the value while c could modify it.

That creates the possibility of someone reading a value while another part of the program is changing it.

Rust therefore prevents this combination.

Why only one mutable reference?

Consider a simpler example:

             ┌──────────────┐
             │    Data      │
             └──────────────┘
                ▲        ▲
                │        │
              &mut     &mut

If two parts of a program can independently modify the same value, we have no simple guarantee about how those modifications interact.

And this becomes particularly dangerous when multiple threads are involved.

Two threads modifying the same memory without proper synchronization can produce a data race.

Rust's borrowing rules prevent data races at compile time by enforcing the same fundamental rule:

You cannot have multiple active mutable references to the same value.

This is one of the deeper ideas behind Rust's borrowing system:

Aliasing is fine when you're only reading. Aliasing becomes dangerous when mutation is also allowed.

But why can't a reference live longer than the value?

Now we arrive at the idea of lifetimes.

Consider:

fn main() {
    let reference;

    {
        let name = String::from("Soham");
        reference = &name;
    }

    println!("{}", reference); // ❌
}

Here, reference tries to refer to name.

But name is destroyed when the inner block ends:

main scope
│
│  reference ─────────────┐
│                        │
│  ┌── inner scope ──┐   │
│  │                  │   │
│  │ name             │◄──┘
│  │                  │
│  └──────────────────┘
│          ↓
│       dropped
│
└────────────────────────

After the inner scope ends, name no longer exists.

So reference would point to something that has already been destroyed.

That's exactly the use-after-free / dangling reference problem we discussed earlier.

Rust therefore requires:

A reference must never outlive the value it refers to.

And this is where lifetimes come in.

A lifetime describes, conceptually, how long a reference is valid.

In the example above:

name's lifetime
├────────────────┤
                  X

reference's lifetime
├──────────────────────────────┤

The reference would outlive the value.

Rust rejects this.

Does Rust require us to manually write lifetimes everywhere?

No.

This is an important thing to clarify because the word lifetime can make Rust sound much more complicated than it actually is.

Most of the time, Rust can determine the relevant lifetimes from the code itself.

For example:

fn print_name(name: &String) {
    println!("{}", name);
}

We don't explicitly write how long name lives.

The compiler can determine the necessary relationship from the surrounding code.

Explicit lifetime annotations such as:

fn example<'a>(value: &'a String) -> &'a String {
    value
}

become useful when the compiler needs us to explicitly describe relationships between lifetimes.

That's a larger topic on its own, though.

For our current mental model, remember:

A lifetime is about how long a reference is valid, not about manually controlling when memory is freed.

That distinction is important.

Ownership controls who is responsible for the value.

Lifetimes describe how long references to that value are allowed to remain valid.

Let's put all of this together

We've now arrived at the complete picture.

We started with a very simple problem:

Heap memory can outlive the function that created it.

That means something has to determine when that memory is no longer needed.

Without a clear system, we can end up with:

No one releases it
       ↓
Memory leak


Released too early
       ↓
Use-after-free


Released twice
       ↓
Double free

Rust introduces Ownership.

Every value
    ↓
has an owner
    ↓
owner is responsible for its lifetime
    ↓
owner goes out of scope
    ↓
value is dropped

When we want to give the value to someone else:

Ownership
    ↓
Move
    ↓
new owner

When we want someone to use the value without taking ownership:

Borrow
    ↓
Reference

When reading:

&value
    ↓
multiple immutable references allowed

When modifying:

&mut value
    ↓
exclusive mutable access

And finally:

References
    ↓
cannot outlive their owner
    ↓
Lifetimes

So we can think of Rust's model like this:

                         VALUE
                           │
                     ┌─────┴─────┐
                     │           │
                  OWNER      REFERENCES
                     │           │
                     │       ┌───┴────┐
                     │       │        │
                     │      &T      &mut T
                     │       │        │
                     │    read-only  exclusive
                     │
                     ▼
                   DROP
                     │
                     ▼
              Resource cleanup

And this brings us back to the question we started with:

Who is responsible for this memory, and when is it safe to release it?

Rust answers that question through a combination of Ownership, Moves, Borrowing, and Lifetimes.

The compiler checks these rules while we write the program, before the program ever runs.

And that is the fundamental idea behind Rust's memory safety model:

Rust doesn't remove the need to think about memory. Instead, it gives us a set of rules that let the compiler verify that memory is being used safely.

That is the mental model I want you to carry forward.