TechLead
Lesson 24 of 28
5 min read
Rust

Unsafe Rust

Learn when and how to use unsafe Rust: raw pointers, FFI with C code, unsafe traits, sound abstractions, and minimizing unsafe scope.

What is Unsafe Rust?

unsafe is a keyword that unlocks five additional capabilities that the compiler cannot verify for safety. It doesn't disable the borrow checker — it gives you extra powers that you promise to use correctly. Well-written Rust code contains small, audited unsafe blocks wrapped in safe abstractions.

Five Unsafe Superpowers

  1. Dereference raw pointers (*const T, *mut T)
  2. Call unsafe functions or methods
  3. Access or modify mutable static variables
  4. Implement unsafe traits
  5. Access fields of unions

Raw Pointers

fn main() {
    let mut value = 42;

    // Creating raw pointers is safe
    let r1 = &value as *const i32;
    let r2 = &mut value as *mut i32;

    // Dereferencing raw pointers is unsafe
    unsafe {
        println!("r1 = {}", *r1);
        *r2 = 100;
        println!("r2 = {}", *r2);
    }

    // Raw pointers can point to arbitrary memory
    let address = 0x012345usize;
    let _r = address as *const i32;
    // Dereferencing this would be undefined behavior!

    // Practical use: splitting a slice
    let mut v = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut v, 3);
    println!("Left: {:?}, Right: {:?}", left, right);
}

// Safe abstraction over unsafe code
fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

Calling C Code (FFI)

// Foreign Function Interface — call C from Rust

extern "C" {
    fn abs(input: i32) -> i32;
    fn sqrt(input: f64) -> f64;
    fn strlen(s: *const i8) -> usize;
}

fn main() {
    // All extern functions are unsafe
    unsafe {
        println!("abs(-42) = {}", abs(-42));
        println!("sqrt(144) = {}", sqrt(144.0));
    }

    // Safe wrapper around C function
    fn safe_abs(n: i32) -> i32 {
        unsafe { abs(n) }
    }

    println!("safe_abs(-10) = {}", safe_abs(-10));
}

// Expose Rust functions to C
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
    a + b
}

// Wrapping a C library safely
mod ffi {
    extern "C" {
        fn c_library_init() -> i32;
        fn c_library_process(data: *const u8, len: usize) -> i32;
        fn c_library_cleanup();
    }

    pub struct Library;

    impl Library {
        pub fn new() -> Result {
            let result = unsafe { c_library_init() };
            if result == 0 {
                Ok(Library)
            } else {
                Err(format!("Init failed: {result}"))
            }
        }

        pub fn process(&self, data: &[u8]) -> i32 {
            unsafe { c_library_process(data.as_ptr(), data.len()) }
        }
    }

    impl Drop for Library {
        fn drop(&mut self) {
            unsafe { c_library_cleanup(); }
        }
    }
}

Unsafe Traits

// Unsafe traits have invariants the compiler can't verify
// Send and Sync are the most common unsafe traits

// Implementing Send means: this type is safe to transfer between threads
// Implementing Sync means: this type is safe to reference from multiple threads

struct MyType {
    data: *mut i32, // Raw pointer — not Send or Sync by default
}

// We assert that MyType is safe to send between threads
// This is our PROMISE to the compiler
unsafe impl Send for MyType {}
unsafe impl Sync for MyType {}

// Custom unsafe trait
unsafe trait TrustedSource {
    fn get_data(&self) -> &[u8];
}

// Implementor promises the data is always valid
unsafe impl TrustedSource for Vec {
    fn get_data(&self) -> &[u8] {
        self.as_slice()
    }
}

Best Practices for Unsafe Code

// 1. Minimize unsafe scope
fn good_example(data: &[u8]) -> u32 {
    // Safe code here...
    let result = unsafe {
        // ONLY the unsafe operation
        *(data.as_ptr() as *const u32)
    };
    // More safe code...
    result
}

// 2. Document safety invariants
/// Reads a u32 from the start of the byte slice.
///
/// # Safety
/// - data must have at least 4 bytes
/// - data must be aligned to 4 bytes
unsafe fn read_u32_unchecked(data: &[u8]) -> u32 {
    *(data.as_ptr() as *const u32)
}

// 3. Create safe wrappers
fn read_u32(data: &[u8]) -> Option {
    if data.len() >= 4 {
        Some(unsafe { read_u32_unchecked(data) })
    } else {
        None
    }
}

// 4. Use MIRI to detect undefined behavior
// cargo +nightly miri test

When NOT to Use Unsafe

Most Rust programs need zero unsafe code. Don't use unsafe to "work around" the borrow checker — the compiler is usually right. Reach for unsafe only when interfacing with C, building data structures that can't be expressed safely, or for verified performance optimizations.

Key Takeaways

  • unsafe unlocks five extra powers — it doesn't disable the borrow checker
  • ✅ Wrap unsafe code in safe abstractions with clear invariant documentation
  • ✅ FFI with C requires unsafe — use RAII wrappers for safe resource management
  • ✅ Minimize unsafe scope to the smallest possible block
  • ✅ Use MIRI (cargo miri) to detect undefined behavior in unsafe code

Continue Learning