Most people just use lifetimes without understanding how they are implemented under the hood. So I decided to dig into the compiled binary to see how Rust actually manages lifetimes at the machine level.

The String Struct Layout

Before diving into the assembly, it helps to recall how Rust represents a String internally. A String is essentially a wrapper around a Vec<u8>, which in turn manages a heap-allocated buffer:

FieldDescription
ptrPointer to the heap-allocated buffer
capCapacity (number of bytes the buffer can hold without reallocating)
lenCurrent length (number of bytes used)

Move value

In Rust, ownership ensures that when a value is passed to a function by move, the caller loses access to it. This guarantees that memory can be safely freed once the function finishes. Unlike in C, where the programmer decides when to free memory, Rust enforces this through the type system.

Here is a simple example that moves a String value into the f function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// src/main.rs

// This function is marked #[inline(never)] and uses black_box to prevent
// the compiler from optimizing it away. Purpose: keep the function visible
// in the final binary for studying move semantics in assembly.
#[inline(never)]
fn f(value: String) {
    std::hint::black_box(value);
}

fn main() {
    let value = String::from("aaaabbbbccccdddd");
    f(value);
}

Let’s look at the main function first.

Function main


   1   testRust::main:
   2    sub     rsp, 24
   3    call    qword, ptr, [rip, +, _RNvCs73fAdSrgOJL_7___rustc35___rust_no_alloc_shim_is_unstable_v2@GOTPCREL]
   4    mov     edi, 16
   5    mov     esi, 1
   6    call    qword, ptr, [rip, +, _RNvCs73fAdSrgOJL_7___rustc12___rust_alloc@GOTPCREL]
   7    test    rax, rax
   8    je      .LBB5_2
   9    movups  xmm0, xmmword, ptr, [rip, +, .Lanon.d3c4e510eecd334b8939fa13e4b98187.3]
  10    movups  xmmword, ptr, [rax], xmm0
  11    mov     qword, ptr, [rsp], 16
  12    mov     qword, ptr, [rsp, +, 8], rax
  13    mov     qword, ptr, [rsp, +, 16], 16
  14    mov     rdi, rsp
  15    call    testRust::f
  16    add     rsp, 24
  17    ret
  18   .LBB5_2:
  19    lea     rdx, [rip, +, .Lanon.d3c4e510eecd334b8939fa13e4b98187.2]
  20    mov     edi, 1
  21    mov     esi, 16
  22    call    qword, ptr, [rip, +, _ZN5alloc7raw_vec12handle_error17h5c9e72494d298ff8E@GOTPCREL]

Memory location

  • 4-6 line: Allocate 16 bytes of memory like malloc
  • 7-8 line: Check rax
    • If rax == 0 -> allocation failed -> jump to error handler

Load string

  • 9-10:
    • load 16 bytes of data from .Lanon...5 (String)
    • Store into allocated memory

Calling f Function

  • 11-13 line: Prepare arguments for f
    • [rsp] = 16
    • [rsp+8] = rax
    • [rsp+16] = 16
  • 14-15 line: Call f function
  • 16-17 line: Finish f function

Function F


   1   testRust::f:
   2    sub     rsp, 24
   3    mov     rax, qword, ptr, [rdi, +, 16]
   4    mov     qword, ptr, [rsp, +, 16], rax
   5    movups  xmm0, xmmword, ptr, [rdi]
   6    movaps  xmmword, ptr, [rsp], xmm0
   7    mov     rax, rsp
   8    #APP
   9    #NO_APP
  10    mov     rsi, qword, ptr, [rsp]
  11    test    rsi, rsi
  12    je      .LBB4_2
  13    mov     rdi, qword, ptr, [rsp, +, 8]
  14    mov     edx, 1
  15    call    qword, ptr, [rip, +, _RNvCs73fAdSrgOJL_7___rustc14___rust_dealloc@GOTPCREL]
  16   .LBB4_2:
  17    add     rsp, 24
  18    ret

Function Prologue

  • Line 3-6: Copy the parameter.

Prepare for Deallocation

  • Line 10-12: Check Deallocate Size
    • rsi size for the deallocate
    • If it’s zero → skip deallocation, else proceed to free memory

Call Deallocator

  • Line 13-15:
    • rdi = [rsp+8] pointer for the deallocate
    • edx = 1 = align for the deallocate
    • Call dealloc function to free the memory

Follow the Stream

Now we can see how this String value is moved:

  1. Allocate 16 bytes memory.
  2. Load the string literal from .Lanon...5.
  3. store the string into the allocated memory.
  4. Pass ownership into f.
  5. At the end of f, the string is deallocated.

allocate → copy literal → pass to f → drop

In this case, the String value is moved into the function, and ownership is dropped inside the function.

Borrow value

Borrowing in Rust means the function only receives a reference, not the ownership of the value. The compiler ensures that the borrowed value will not be dropped while it is still borrowed. In our assembly view, you can see that the f function doesn’t deallocate memory — the deallocation happens when main finishes, because the owner is still main.

Here is a simple example where a String value is borrowed by the f function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// src/main.rs

// This function is marked #[inline(never)] and uses black_box to prevent
// the compiler from optimizing it away. Purpose: keep the function visible
// in the final binary for studying move semantics in assembly.
#[inline(never)]
fn f(value: &String) {
    std::hint::black_box(value);
}

fn main() {
    let value = String::from("aaaabbbbccccdddd");
    f(&value);
}

Function main


   1   testRust::main:
   2    push    r14
   3    push    rbx
   4    sub     rsp, 24
   5    call    qword, ptr, [rip, +, _RNvCs73fAdSrgOJL_7___rustc35___rust_no_alloc_shim_is_unstable_v2@GOTPCREL]
   6    mov     edi, 16
   7    mov     esi, 1
   8    call    qword, ptr, [rip, +, _RNvCs73fAdSrgOJL_7___rustc12___rust_alloc@GOTPCREL]
   9    test    rax, rax
  10    je      .LBB5_4
  11    mov     rbx, rax
  12    movups  xmm0, xmmword, ptr, [rip, +, .Lanon.d3c4e510eecd334b8939fa13e4b98187.3]
  13    movups  xmmword, ptr, [rax], xmm0
  14    mov     qword, ptr, [rsp], 16
  15    mov     qword, ptr, [rsp, +, 8], rax
  16    mov     qword, ptr, [rsp, +, 16], 16
  17    mov     rdi, rsp
  18    call    testRust::f
  19    mov     esi, 16
  20    mov     edx, 1
  21    mov     rdi, rbx
  22    call    qword, ptr, [rip, +, _RNvCs73fAdSrgOJL_7___rustc14___rust_dealloc@GOTPCREL]
  23    add     rsp, 24
  24    pop     rbx
  25    pop     r14
  26    ret
  27   .LBB5_4:
  28    lea     rdx, [rip, +, .Lanon.d3c4e510eecd334b8939fa13e4b98187.2]
  29    mov     edi, 1
  30    mov     esi, 16
  31    call    qword, ptr, [rip, +, _ZN5alloc7raw_vec12handle_error17h5c9e72494d298ff8E@GOTPCREL]
  32   .LBB5_3:
  33    mov     r14, rax
  34    mov     esi, 16
  35    mov     edx, 1
  36    mov     rdi, rbx
  37    call    qword, ptr, [rip, +, _RNvCs73fAdSrgOJL_7___rustc14___rust_dealloc@GOTPCREL]
  38    mov     rdi, r14
  39    call    _Unwind_Resume

Function f


   1   testRust::f:
   2    mov     qword, ptr, [rsp, -, 8], rdi
   3    lea     rax, [rsp, -, 8]
   4    #APP
   5    #NO_APP
   6    ret

As you can see, the contents of main do not change much, and even the call to f looks the same. But inside f, the assembly shows almost nothing happening — it behaves like a dummy function. Borrowing essentially works by copying a pointer into the function. Once the function returns, the original owner (main) is still responsible for deallocating the value at the end of its scope.

allocate → pass pointer to f → f uses pointer → original caller drops

If you compare this with C, the generated binary looks familiar: memory is allocated (similar to malloc) and later freed (similar to free). This is because Rust uses LLVM under the hood, so the low-level instructions resemble C. The crucial difference is when deallocation happens. In C, the programmer must remember to free manually, which can easily cause memory leaks or double frees. Rust, however, enforces ownership rules at compile time, guaranteeing that every allocated value is freed exactly once.


Looking Ahead

These topics will give us a broader perspective on how Rust enforces memory safety — not just in simple cases, but across the wide range of real-world scenarios developers face.