How Rust Works: Compiled and Memory-Safe
Rust is a systems programming language that compiles your source code straight down to native machine code before it ever runs — there is no virtual machine, no bytecode interpreter, and no just-in-time compiler standing between your program and the CPU. At the same time, Rust guarantees that your program cannot corrupt memory: no dangling pointers, no use-after-free, no data races between threads. What makes this remarkable is that Rust achieves both without a garbage collector — the compiler proves your code is memory-safe before it produces a binary, and then throws that proof away, so none of the checking costs anything at runtime. This lesson explains exactly how that works.
Overview: How Rust Works
Every language you run falls into roughly one of three camps. Interpreted languages like Python read source code line by line at runtime through an interpreter — flexible, but slower, because the interpreter does extra work on every statement. Managed languages like Java or C# compile to an intermediate bytecode that a virtual machine (the JVM or CLR) interprets or just-in-time compiles while the program runs. Rust belongs to the third camp, the same one as C and C++: ahead-of-time compilation. The rustc compiler translates your entire program into an intermediate representation, hands it to the LLVM backend, and LLVM emits real machine code for your target CPU — x86-64, ARM, or whatever you are building for. The result is a single native executable. When you run it, the operating system loads it and the CPU executes your instructions directly. There is no interpreter loop, no bytecode dispatch, and no virtual machine between your code and the hardware, which is a large part of why Rust programs start instantly and run as fast as equivalent C code.
The harder problem compiled languages must solve is memory management. In C, you allocate heap memory with malloc and must remember to free it yourself — forget, and you leak memory; free it twice, or use a pointer after freeing it, and you get undefined behavior that might crash immediately or might silently corrupt data somewhere unrelated. Managed languages solve this with a garbage collector: a background process that periodically scans memory for objects nothing references anymore and frees them. That is safe, but it costs CPU cycles at runtime and can pause a program unpredictably.
Rust takes a third path called ownership. Every value has exactly one variable that owns it at any given time. When that owning variable goes out of scope, Rust automatically inserts code to free the value’s memory right there — deterministically, at a point the compiler decided while compiling your program, not at some unpredictable moment a garbage collector chooses later. Because there is only ever one owner, there is no way to free the same memory twice, and no background collector is needed at all.
Ownership alone would be inconvenient — you would have to pass every value around and lose access to it every time you handed it to a function. So Rust also lets you borrow a value temporarily through a reference, written &value (or &mut value for a mutable reference), without taking ownership. The compiler enforces one rule about borrowing, checked entirely at compile time: at any moment you may have either one mutable reference to a value or any number of immutable references to it, but never both at once. This single rule prevents data races and use-after-free bugs. If two parts of a program could both write to the same memory at once, or one part could read memory another part just freed, that would be a memory bug — and Rust’s borrow checker refuses to compile code where that is possible. You get a compiler error pointing at the exact line, instead of a crash, or worse, silent corruption, in production.
All of this checking — ownership, borrowing, lifetimes — happens purely at compile time, inside rustc, before machine code is ever generated. None of it exists in your finished binary: no extra runtime check, no reference-counting overhead by default, no garbage-collector thread. This is Rust’s zero-cost abstraction principle: the safety guarantees cost you compiler time (and, at first, some fights with error messages), but they cost nothing when your program actually runs.
Syntax: Compiling and Running Rust Code
Every Rust program needs exactly one fn main() — that is the entry point the compiler looks for. You can compile a single file directly with rustc, or use Cargo, Rust’s official build tool and package manager, which manages larger projects, dependencies, and build profiles for you.
| Command | What it does |
|---|---|
| rustc main.rs | Compiles one file straight into a native executable named main (main.exe on Windows). |
| ./main | Runs the compiled binary directly — no runtime or VM required to start it. |
| cargo new hello_rust | Creates a new Cargo project with a src/main.rs file and a Cargo.toml manifest. |
| cargo build | Compiles the project in debug mode into target/debug/. |
| cargo run | Builds (if needed) and runs the project in a single step. |
| cargo build –release | Compiles with full optimizations into target/release/, for production binaries. |
cargo new hello_rust
cd hello_rust
cargo run
Examples
Example 1: Compiling and running a minimal program
This is the smallest complete Rust program. Save it as main.rs, compile it with rustc main.rs, and run the resulting binary.
fn main() {
let greeting = String::from("Hello, Rust!");
println!("{}", greeting);
}
Output:
Hello, Rust!
String::from allocates a String on the heap and greeting becomes its owner. println! borrows greeting just long enough to read and print it — it never takes ownership. When main ends, greeting goes out of scope and Rust automatically frees the heap memory behind it. No free call, no garbage collector, and it happened at a point the compiler decided while compiling this exact function.
Example 2: Deterministic cleanup with Drop
Rust lets a type customize what happens when its value is dropped by implementing the Drop trait. This example makes that cleanup visible so you can see exactly when it happens.
struct Resource {
name: String,
}
impl Drop for Resource {
fn drop(&mut self) {
println!("Releasing resource: {}", self.name);
}
}
fn main() {
println!("Program starting");
{
let _r1 = Resource { name: String::from("file handle") };
println!("Inside inner scope");
}
println!("Program ending");
}
Output:
Program starting
Inside inner scope
Releasing resource: file handle
Program ending
_r1 owns a Resource. The leading underscore tells Rust (and the reader) that we never read _r1‘s fields directly — we only care that it exists and gets dropped; without the underscore the compiler would warn about an unused variable. The moment execution reaches the closing brace of the inner block, _r1 goes out of scope and Rust calls its drop method immediately, before Program ending ever prints. Compare this to a garbage-collected language, where you cannot predict exactly when (or if) an unused object’s cleanup code runs — Rust’s cleanup is deterministic and tied directly to scope.
Example 3: Safe indexing instead of undefined behavior
In C, reading past the end of an array is undefined behavior — it might return garbage, might crash, might not. Rust’s arrays and slices are bounds-checked, and the get method gives you a safe way to handle an out-of-range index without ever reading invalid memory.
fn main() {
let numbers = [10, 20, 30];
let index = 5;
match numbers.get(index) {
Some(value) => println!("Value at index {}: {}", index, value),
None => println!("Index {} is out of bounds for a slice of length {}", index, numbers.len()),
}
}
Output:
Index 5 is out of bounds for a slice of length 3
numbers.get(index) returns an Option<&i32>: Some(value) if the index is valid, None if it is not. There is no null, no garbage read, and no crash — the match forces you to handle both cases explicitly. This is Rust’s memory safety extending beyond ownership and borrowing: array access is checked too, either at compile time when possible or at runtime via a bounds check that panics safely, rather than reading adjacent memory, if you use direct indexing instead.
How the Compiler Gets You There, Step by Step
Understanding the pipeline rustc runs your code through explains both why compile times are longer than a scripting language’s startup, and why a Rust binary that compiles is already proven free of whole categories of bugs.
- Parsing — rustc tokenizes your .rs file and builds an Abstract Syntax Tree (AST) representing its structure.
- Lowering — the AST is lowered to HIR (High-level IR) and then MIR (Mid-level IR), simplified representations closer to how the program actually executes.
- Borrow checking — the step unique to Rust. The compiler walks the MIR and verifies, for every value, that it has exactly one owner at each point, that it is never used after being moved, and that no mutable reference coexists with any other reference to the same data. Any violation stops compilation immediately with an error pointing at the exact line — this is precisely the stage that rejects the use-after-move mistake shown below.
- Codegen — once MIR passes every check, rustc lowers it to LLVM IR, a lower-level representation the LLVM backend (the same optimizing backend Clang uses for C and C++) understands.
- Optimization and machine code generation — LLVM applies optimizations such as inlining, dead code elimination, and loop unrolling, then emits real machine code for your target CPU.
- Linking — the linker combines your machine code with Rust’s small standard library and system libraries into a single native executable. There is no bytecode file, no VM to install, and nothing left of the borrow checker in the final binary — the safety proof already happened, so it costs nothing when the program runs.
Common Mistakes
Mistake 1: Using a value after it has moved
String does not implement Copy, so assigning it to another variable moves ownership instead of duplicating the data. The original variable becomes invalid, and the compiler refuses to let you use it again.
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);
}
The compiler rejects this before it ever produces a binary, with an error like error[E0382]: borrow of moved value: s1 — ownership moved to s2 on the line above, so s1 is no longer valid. If you actually need two independent copies of the data, clone it explicitly; cloning a heap allocation is not free, so Rust makes you ask for it by name instead of doing it silently.
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("{} {}", s1, s2);
}
Output:
hello hello
Mistake 2: Forgetting mut
Variables in Rust are immutable by default. If you try to change one you did not declare with mut, the compiler stops you — a deliberate design choice, since most variables in a well-written program never need to change after they are initialized.
fn main() {
let count = 0;
count += 1;
println!("{}", count);
}
This fails with error[E0384]: cannot assign twice to immutable variable count. Adding mut to the declaration fixes it:
fn main() {
let mut count = 0;
count += 1;
println!("{}", count);
}
Output:
1
Mistake 3: Indexing past the end of a slice
Unlike get, the [] indexing operator does not return an Option — it assumes the index is valid. If it is not, Rust still will not read invalid memory or silently return garbage the way C might; instead it panics immediately, stopping the program with an error message. That is memory-safe, but it is still a crash you almost always want to avoid in production code.
fn main() {
let numbers = [10, 20, 30];
// computed at runtime so the compiler can't prove it out of bounds ahead of time
let index = std::env::args().count() + 4;
println!("{}", numbers[index]);
}
This compiles cleanly — the index comes from std::env::args().count(), so its value genuinely isn’t known until runtime and the compiler can’t prove it out of bounds ahead of time — but running it panics before the println! ever executes. Nothing reaches stdout; instead the panic message goes to stderr, something like thread 'main' panicked at index out of bounds: the len is 3 but the index is 5. Use get to handle the same situation safely:
fn main() {
let numbers = [10, 20, 30];
let index = 5;
if let Some(value) = numbers.get(index) {
println!("Value: {}", value);
} else {
println!("No value at index {}", index);
}
}
Output:
No value at index 5
Best Practices
- Reach for
cargo runorcargo buildfor anything beyond a single throwaway file; Cargo manages dependencies, incremental builds, and release optimizations for you. - Use
cargo build --release(orcargo run --release) before measuring performance — debug builds skip most optimizations and can run an order of magnitude slower. - Prefer
numbers.get(i)overnumbers[i]whenever the index is not guaranteed valid; let the type system force you to handle the missing case instead of hoping it never happens. - Do not fight the borrow checker by sprinkling
.clone()everywhere to make errors disappear — often it is telling you the data’s ownership structure needs rethinking, a topic covered in depth in the ownership and borrowing lessons ahead. - Treat a successful compile as a real guarantee: safe Rust code that compiles cannot have a data race, a use-after-free, or a double free. Trust that instead of adding defensive runtime checks for things Rust already ruled out at compile time.
- Read compiler errors fully; rustc’s diagnostics usually name the exact rule violated and often suggest the fix directly.
Practice Exercises
- Write a program that creates a
String, moves it into a new variable, and prints only the new variable. Then, as an experiment, add a line that tries to print the original moved-from variable, and read the exact compiler error it produces — identify which rule from this lesson it enforces. - Write a struct that implements
Dropand prints a message when dropped. Create two or three instances inside nested blocks with different exit points, and predict the order the drop messages will print before you compile and check. - Write a program with an array of five
i32values. Look up an index that is out of range usinggetand print a friendly message instead of panicking. Then change the same lookup to use[]directly and compare what happens.
Summary
- Rust compiles ahead-of-time to native machine code via LLVM — there is no VM, bytecode interpreter, or JIT involved in running a finished binary.
- Memory is managed through ownership: each value has exactly one owner, and Rust inserts its cleanup code automatically and deterministically when that owner goes out of scope.
- There is no garbage collector; cleanup timing is decided entirely at compile time, so there is no runtime tracing pass and no unpredictable pause.
- The borrow checker enforces, at compile time, that a value has either one mutable reference or any number of immutable references, never both — this is what rules out data races and use-after-free bugs before your program ever runs.
- All of this checking is a zero-cost abstraction: it costs compile time, not runtime.
- Direct indexing with
[]panics on an out-of-range index;getreturns anOptionso you can handle that case safely without a crash. - A successful rustc compile is a real safety guarantee for safe Rust code, not just a syntax check.
