Writing Unsafe Rust
All the Rust code we typically write is strictly enforced to follow memory safety guarantees at compile time. However, when we need to bypass these conservative compile-time checks or interface directly with underlying computer hardware, Rust provides an escape hatch: Unsafe Rust. Unsafe Rust allows developers to opt out of some of the compiler's strict safety guarantees, shifting the responsibility of ensuring memory safety directly onto the programmer.
1. Why Does Unsafe Rust Exist?
Rust’s compiler uses static analysis to guarantee memory safety. However, static analysis is inherently conservative. This design choice leads to two primary reasons why Unsafe Rust is a necessary part of the language:
- The Conservative Nature of Static Analysis: The compiler will reject a valid program if it cannot mathematically prove that the program is 100% memory safe. As a developer, you may know with absolute certainty that a specific operation is safe, but because the compiler lacks the context or capability to prove it, it will block compilation. Unsafe Rust allows you to override this conservative boundary.
- Interfacing with Inherently Unsafe Hardware: Rust is a systems programming language. Computer hardware is fundamentally unsafe by nature. To write low-level systems code—such as operating system kernels, device drivers, or direct memory allocators—you must perform operations that the hardware allows but the compiler's safe rules forbid.
2. The Core Mental Model: The unsafe Keyword
To opt into unsafe operations, you use the unsafe keyword to declare an unsafe block or function.
Note: It is a common misconception that
unsafeturns off the borrow checker or completely disables Rust's safety checks. This is false. If you have a standard reference (&Tor&mut T) inside anunsafeblock, the borrow checker will still enforce its rules on that reference.
Instead of disabling checks, the unsafe keyword simply grants you access to five specific abilities (often called "superpowers") that are unavailable in Safe Rust:
The Developer's Responsibility
Because the compiler cannot verify the safety of these five operations, the onus is entirely on you to ensure that memory is handled appropriately. To minimize risk:
- Keep
unsafeblocks as small and isolated as possible. - Enclose unsafe code within a safe abstraction (providing a safe public API over unsafe internals). This makes debugging significantly easier, as memory issues are isolated to a tiny, readable portion of your codebase.
3. Ability 1: Dereferencing Raw Pointers
In Safe Rust, the compiler ensures that references (&T and &mut T) are always valid. Unsafe Rust introduces two types of raw pointers that bypass these guarantees:
- Immutable raw pointer: Written as
*const T - Mutable raw pointer: Written as
*mut T
Note: The asterisk (
*) in*const Tor*mut Tis not a dereference operator; it is part of the type signature itself.
Differences Between References and Raw Pointers
Creating Raw Pointers (Safe)
Rust allows you to create raw pointers in safe code. The unsafe keyword is only required when you attempt to dereference them to access the underlying value.
fn main() {
let mut num = 5;
// Creating raw pointers from valid references using the 'as' keyword
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
// We can have both an immutable and a mutable raw pointer pointing
// to the same memory location simultaneously. This is forbidden with safe references.
}
Dereferencing Raw Pointers (Unsafe)
To read or write to the memory address stored in a raw pointer, you must wrap the operation in an unsafe block:
fn main() {
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
unsafe {
println!("r1 points to: {}", *r1);
println!("r2 points to: {}", *r2);
}
}
Pointing to Arbitrary Memory Addresses
You can also create a raw pointer pointing to an arbitrary address in memory. However, dereferencing such a pointer is highly dangerous and leads to undefined behavior:
fn main() {
// An arbitrary memory address
let address = 0x012345usize;
let r = address as *const i32;
// DANGER: Dereferencing this will likely cause a segmentation fault
// or be optimized away entirely by the compiler.
// unsafe {
// println!("Arbitrary data: {}", *r);
// }
}
4. Ability 2: Calling Unsafe Functions or Methods
An unsafe function or method is defined by placing the unsafe keyword before the fn keyword.
unsafe fn dangerous_operation() {
// The body of an unsafe function acts as an implicit unsafe block
println!("Performing an operation with unchecked preconditions.");
}
The Contract of Unsafe Functions
When a function is marked unsafe, it indicates that the function has strict requirements (contracts) that the caller must uphold. If you pass incorrect arguments or call it in an invalid state, it can cause undefined behavior.
By calling an unsafe function, you are asserting that:
- You have read the function's documentation.
- You understand its safety requirements.
- You take responsibility for upholding its contract.
Calling Unsafe Functions
Unsafe functions can only be called from within another unsafe function or inside an unsafe block:
fn main() {
// Calling the unsafe function inside an unsafe block
unsafe {
dangerous_operation();
}
}
5. Creating a Safe Abstraction Over Unsafe Code
Just because a function contains unsafe code does not mean the entire function must be marked unsafe. Wrapping unsafe operations inside a safe function is a core design pattern in Rust.
Case Study: Implementing split_at_mut
Let's look at how we might implement a function similar to the standard library's split_at_mut method. This function takes a mutable slice and splits it into two mutable slices at a given index.
The Naive (Safe) Approach
If we try to implement this using only safe Rust, we might write:
// This code will NOT compile
fn split_at_mut_naive(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = slice.len();
assert!(mid <= len);
(
&mut slice[..mid],
&mut slice[mid..]
)
}
If you attempt to compile this, you will receive a compilation error:
error[E0499]: cannot borrow `*slice` as mutable more than once at a time
The borrow checker is not smart enough to realize that &mut slice[..mid] and &mut slice[mid..] point to entirely disjoint, non-overlapping regions of memory. It only sees that we are borrowing the same mutable slice twice in the same scope, which is a violation of Rust's borrowing rules.
The Unsafe (But Correct) Solution
Because we as developers know that the two slices do not overlap, we can use unsafe code to bypass the borrow checker's limitations. Slices are represented internally as a pointer to some data and a length.
use std::slice;
fn split_at_mut_safe(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = slice.len();
// Get a raw mutable pointer to the slice's data
let ptr = slice.as_mut_ptr();
// Ensure we do not access out-of-bounds memory
assert!(mid <= len);
unsafe {
(
// Create a slice from the start pointer up to the midpoint
slice::from_raw_parts_mut(ptr, mid),
// Create a slice starting at the midpoint offset to the end
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
Why This Works Safely
slice::from_raw_parts_mutis unsafe because it trusts that the raw pointer passed to it is valid and points to the correct type of initialized memory.ptr.addis unsafe because it trusts that the offset passed to it does not go out of bounds of the allocated memory block.- By asserting
mid <= len, we guarantee that both operations are safe. We have successfully created a safe abstraction over unsafe code.
6. Interfacing with Foreign Code (FFI & Extern)
Rust provides the extern keyword to facilitate a Foreign Function Interface (FFI), allowing your Rust code to interact with code written in another language (such as C).
Calling Foreign Functions (C Standard Library)
Calling an external function is always unsafe because Rust cannot verify the memory safety guarantees of the foreign language.
// Setting up integration with the 'abs' (absolute value) function from the C standard library
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
// Calling a foreign function must occur inside an unsafe block
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
"C"defines the Application Binary Interface (ABI) being used. The ABI defines how the function is called at the assembly level. The"C"ABI is the most common and matches the C programming language's ABI.
Allowing Foreign Languages to Call Rust
You can also export Rust functions to be called by other languages. This requires the extern keyword in the function signature and the #[no_mangle] attribute:
// Prevent the compiler from mangling (changing) the function name during compilation
#[no_mangle]
pub extern "C" fn call_from_c() {
println!("This Rust function was successfully called from C!");
}
7. Ability 3: Accessing or Modifying Mutable Static Variables
In Rust, global variables are referred to as static variables.
Constants vs. Static Variables
The Danger of Mutable Static Variables
If two threads access the same mutable global variable simultaneously, a data race occurs. Because Rust cannot easily guarantee thread safety for global mutable state at compile time, accessing or modifying a static mut variable is always unsafe.
// Declaring a mutable static variable
static mut COUNTER: u32 = 0;
fn add_to_count(inc: u32) {
// Modifying mutable static state requires unsafe
unsafe {
COUNTER += inc;
}
}
fn main() {
add_to_count(3);
// Accessing mutable static state requires unsafe
unsafe {
println!("Counter value: {}", COUNTER);
}
}
8. Abilities 4 & 5: Unsafe Traits and Union Fields
Ability 4: Implementing an Unsafe Trait
A trait is marked unsafe when at least one of its methods has invariants that the compiler cannot verify. You declare and implement unsafe traits using the unsafe keyword:
unsafe trait UnsafeSystem {
// An unsafe method signature
unsafe fn initialize(&self);
}
struct MySystem;
unsafe impl UnsafeSystem for MySystem {
unsafe fn initialize(&self) {
// Implementation details
}
}
Ability 5: Accessing Fields of a Union
A union is a data structure similar to a struct, but only one of its declared fields is used for any given instance. They are primarily used to interface with C unions.
Because Rust cannot guarantee what type of data is currently stored in the union for a given instance, accessing any field of a union is unsafe:
#[repr(C)]
union MyUnion {
f1: u32,
f2: f32,
}
fn main() {
let u = MyUnion { f1: 1 };
unsafe {
// Accessing union fields is unsafe because the compiler
// cannot verify if f1 or f2 is currently active.
println!("Union field f1: {}", u.f1);
}
}
9. Architectural Flow: Wrapping Unsafe Code
The recommended architectural pattern in Rust is to isolate unsafe operations inside a well-defined boundary and expose a clean, safe public API.
Summary and Key Takeaways
- Unsafe Rust is not "bad" or "forbidden": It is a powerful, necessary feature of the language designed for low-level systems programming and creating high-performance abstractions.
- The
unsafekeyword does not disable the borrow checker: Standard references used inside unsafe blocks are still subjected to normal borrow checking rules. - The Five Superpowers: Unsafe Rust grants you exactly five abilities: dereferencing raw pointers, calling unsafe functions, modifying mutable static variables, implementing unsafe traits, and accessing union fields.
- Raw Pointers (
*const T/*mut T): Can bypass borrowing rules, can be null, do not clean up memory automatically, and can point to arbitrary memory addresses. - Safe Abstractions: The best practice is to wrap unsafe operations inside safe functions (like
split_at_mut) so that the rest of your application remains completely safe. - Foreign Function Interface (FFI): The
externblock allows Rust to call foreign code (like C libraries), and#[no_mangle]allows foreign code to call Rust functions. - Mutable Static Variables: Global mutable state is inherently prone to data races, which is why accessing or modifying
static mutvariables requires anunsafeblock.