Guide contents

Lesson 81 of 94

Unsafe Rust & FFI

Everything so far has been safe Rust: the compiler guarantees no dangling pointers, no data races, no use-after-free, no buffer overflows. Some code can't be expressed within those rules: talking to C libraries, writing a new collection with raw memory, or squeezing the last bit of performance from a hot loop. For that there's unsafe.

unsafe doesn't turn the checks off. It unlocks five extra operations that the compiler can't verify, and makes you responsible for their correctness:

  1. Dereferencing a raw pointer (*const T, *mut T).
  2. Calling an unsafe fn (including foreign C functions).
  3. Reading or writing a static mut.
  4. Implementing an unsafe trait (like Send and Sync).
  5. Reading a field of a union.

The borrow checker, type checker, and bounds checks all still apply inside unsafe blocks.

Rust marks every spot where you step outside the compiler's guarantees with the unsafe keyword, so reviewers know exactly where to look.

The goal of unsafe Rust is almost always to build a safe abstraction: a small unsafe core, carefully reasoned about, wrapped in an API that can't be misused. Vec, String, Rc, Mutex, and HashMap are all built this way.

Part 1: Unsafe Rust

1. Raw pointers: creating is safe, dereferencing isn't

Raw pointers are like C pointers: they can be null, dangle, or alias a &mut. Creating one is safe (it's just an address). Reading through it needs unsafe, because the compiler can't prove it points to valid data.

Rust
fn main() {
    let mut score = 10;

    let p_write: *mut i32 = &raw mut score; // a raw pointer, made without creating a reference
    let p_read: *const i32 = p_write; // a second pointer to the same place

    unsafe {
        // SAFETY: both pointers come from `score`, which is alive, and no reference to
        // `score` is used while they are.
        *p_write += 5;
        println!("score via raw pointer: {}", *p_read);
    }
    println!("score: {score}");
}
Output
score via raw pointer: 15
score: 15

Deriving p_read from p_write matters. Writing let p_read = &score as *const i32; first and then taking &mut score would compile, but the &mut would invalidate p_read, making the read undefined behavior. (Miri, from example 16, catches exactly that.)

The // SAFETY: comment is the convention (enforced by Clippy lints in many projects) for explaining why each unsafe block is correct.

2. Raw pointers can be null

Unlike references, raw pointers can be null. Check with is_null(), or convert to an Option<&T> with as_ref(), which does the null check for you.

Rust
use std::ptr;

fn describe(p: *const String) -> String {
    // SAFETY: callers pass either null or a pointer to a live String.
    match unsafe { p.as_ref() } {
        Some(s) => format!("points to {s:?}"),
        None => String::from("null pointer"),
    }
}

fn main() {
    let name = String::from("ana");
    println!("{}", describe(&name));
    println!("{}", describe(ptr::null()));
}
Output
points to "ana"
null pointer

3. Pointer arithmetic

p.add(n) moves a pointer n elements forward (not bytes). It's how slices and iterators work underneath. Going outside the allocation is undefined behavior, so it's unsafe.

Rust
fn sum_raw(data: *const u32, len: usize) -> u32 {
    let mut total = 0;
    for i in 0..len {
        // SAFETY: the caller guarantees `data` points to `len` initialized u32 values.
        total += unsafe { *data.add(i) };
    }
    total
}

fn main() {
    let readings = [3u32, 5, 8, 13];
    println!("{}", sum_raw(readings.as_ptr(), readings.len()));
    println!("{}", sum_raw(readings[1..].as_ptr(), 2));
}
Output
29
13

sum_raw should really be an unsafe fn, since its correctness depends on the caller (next example). As written, a safe caller could pass a bad pointer.

4. Unsafe functions and # Safety docs

An unsafe fn has requirements the compiler can't check; callers must uphold them and need an unsafe block to call it. Document the requirements under a # Safety heading. In Rust 2024, the body of an unsafe fn still needs its own unsafe { } blocks around the unsafe operations.

Rust
/// Sums `len` values starting at `data`.
///
/// # Safety
///
/// `data` must be non-null, aligned, and point to at least `len` initialized `u32`s
/// that are not being mutated during the call.
unsafe fn sum_raw(data: *const u32, len: usize) -> u32 {
    // SAFETY: the caller upholds the contract above.
    let slice = unsafe { std::slice::from_raw_parts(data, len) };
    slice.iter().sum()
}

fn main() {
    let v = vec![1u32, 2, 3, 4];
    // SAFETY: `v` holds 4 initialized u32s and isn't mutated during the call.
    let total = unsafe { sum_raw(v.as_ptr(), v.len()) };
    println!("{total}");
}
Output
10

5. Unchecked operations for speed

The standard library has unsafe versions of some checked operations, like get_unchecked. They skip a bounds check when you can prove it's unnecessary. Measure first: the optimizer usually removes redundant checks on its own.

Rust
fn dot(a: &[f64], b: &[f64]) -> f64 {
    let n = a.len().min(b.len());
    let mut sum = 0.0;
    for i in 0..n {
        // SAFETY: i < n <= a.len() and i < n <= b.len().
        sum += unsafe { a.get_unchecked(i) * b.get_unchecked(i) };
    }
    sum
}

fn main() {
    println!("{}", dot(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]));
    println!("{}", dot(&[1.0, 2.0], &[10.0, 20.0, 30.0]));
}
Output
32
50

6. Some safe APIs can't be written with safe code

split_at_mut returns two &mut slices into the same array. It's sound because the halves don't overlap, but the borrow checker can't see that, because it treats the whole slice as one borrow:

Rust✗ does not compile · E0499
fn split_in_two(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut v = [1, 2, 3, 4];
    let (a, b) = split_in_two(&mut v, 2);
    a[0] = 10;
    b[0] = 30;
}
Output
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:2:31
  |
1 | fn split_in_two(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
2 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

7. Building a safe abstraction

With raw pointers we can write it anyway, and wrap it in a safe function. The assert! makes the unsafe code's precondition always true, so no caller can trigger undefined behavior. This is essentially how the standard library implements it.

Rust
fn split_in_two(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    assert!(mid <= len, "mid {mid} out of bounds for length {len}");
    let ptr = values.as_mut_ptr();
    // SAFETY: [0, mid) and [mid, len) are in bounds (checked above) and don't overlap,
    // so the two &mut slices never alias.
    unsafe { (std::slice::from_raw_parts_mut(ptr, mid), std::slice::from_raw_parts_mut(ptr.add(mid), len - mid)) }
}

fn main() {
    let mut v = [1, 2, 3, 4, 5];
    let (a, b) = split_in_two(&mut v, 2);
    a[0] *= 10;
    b[0] *= 100;
    println!("{v:?}");
}
Output
[10, 2, 300, 4, 5]

The unsafe block is small, the reasoning is written down, and the public signature is exactly as safe as any other function. That's the pattern to follow.

8. static mut

A static mut is a global variable anyone can change, which makes data races possible, so every access is unsafe. In Rust 2024, even taking a reference to one is rejected by default (static_mut_refs), because references to data that might be mutated concurrently are undefined behavior.

Rust
static mut REQUESTS: u32 = 0;

fn record_request() {
    // SAFETY: this program is single-threaded, so there's no concurrent access.
    unsafe { REQUESTS += 1 };
}

fn main() {
    for _ in 0..3 {
        record_request();
    }
    let n = unsafe { REQUESTS }; // copy the value out; don't create a reference
    println!("requests: {n}");
}
Output
requests: 3

Prefer the safe alternatives: an atomic (static REQUESTS: AtomicU32), a Mutex, or OnceLock/LazyLock (Atomics, Send & Sync). There's almost never a reason for static mut in new code.

9. Unsafe traits: Send and Sync by hand

An unsafe trait has guarantees the compiler can't check; implementing it is a promise. Send and Sync are the common ones: a type containing a raw pointer is neither by default, and you opt in when you know it's safe.

Rust
use std::thread;

/// A heap buffer owned exclusively through a raw pointer.
struct RawBuffer {
    ptr: *mut u8,
    len: usize,
}

impl RawBuffer {
    fn new(len: usize) -> Self {
        let boxed: Box<[u8]> = vec![0u8; len].into_boxed_slice();
        RawBuffer { ptr: Box::into_raw(boxed) as *mut u8, len }
    }

    fn fill(&mut self, byte: u8) {
        // SAFETY: ptr/len describe the allocation made in `new`, and `&mut self` is exclusive.
        unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) }.fill(byte);
    }

    fn sum(&self) -> u32 {
        // SAFETY: as above; `&self` means no one is writing.
        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }.iter().map(|&b| b as u32).sum()
    }
}

impl Drop for RawBuffer {
    fn drop(&mut self) {
        // SAFETY: reconstructs exactly the Box created in `new`, once.
        unsafe { drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(self.ptr, self.len))) };
    }
}

// SAFETY: RawBuffer owns its allocation exclusively (like Box<[u8]>), so moving it
// to another thread is safe.
unsafe impl Send for RawBuffer {}

fn main() {
    let mut buf = RawBuffer::new(4);
    buf.fill(7);
    let total = thread::spawn(move || buf.sum()).join().unwrap(); // needs Send
    println!("sum computed on another thread: {total}");
}
Output
sum computed on another thread: 28

Without the unsafe impl Send, thread::spawn would reject the closure, as in the Threads lesson's Rc example.

10. UnsafeCell: the base of all interior mutability

Changing data behind a shared reference &T is undefined behavior, with one exception: data inside an UnsafeCell<T>. Cell, RefCell, OnceCell, Mutex, RwLock, and the atomics are all built on it. UnsafeCell::get(&self) returns a *mut T; the unsafe part is guaranteeing that nobody else reads or writes the value at the same time. Here is a minimal Cell:

Rust
use std::cell::UnsafeCell;

struct MyCell<T> {
    value: UnsafeCell<T>,
}

impl<T: Copy> MyCell<T> {
    fn new(value: T) -> MyCell<T> {
        MyCell { value: UnsafeCell::new(value) }
    }

    fn get(&self) -> T {
        // SAFETY: `UnsafeCell` makes `MyCell` !Sync, so only one thread can reach it,
        // and no reference into the value is ever handed out.
        unsafe { *self.value.get() }
    }

    fn set(&self, value: T) {
        // SAFETY: same as `get`: no other access can overlap this write.
        unsafe { *self.value.get() = value }
    }
}

struct PageView {
    hits: MyCell<u32>,
}

impl PageView {
    fn record(&self) {
        // `&self`, yet the counter changes.
        self.hits.set(self.hits.get() + 1);
    }
}

fn main() {
    let page = PageView { hits: MyCell::new(0) };
    page.record();
    page.record();
    page.record();
    println!("hits: {}", page.hits.get());
}
Output
hits: 3

T: Copy matters: get returns a copy, so no reference into the cell ever exists while set writes. RefCell adds a runtime borrow counter to hand out references safely, and Mutex adds a lock so the type can also be Sync.

11. Unions and why you rarely need them

A union stores different types in the same memory, like a C union. Reading a field is unsafe, because Rust can't know which field was last written. They exist mainly for C interop; for everything else, an enum is the safe tagged version, and bit conversions have safe methods.

Rust
#[repr(C)]
union FloatBits {
    f: f32,
    bits: u32,
}

fn main() {
    let u = FloatBits { f: 1.0 };
    // SAFETY: every bit pattern is a valid u32, so reading `bits` is fine.
    let bits = unsafe { u.bits };
    println!("1.0f32 as bits: {bits:#010x}");

    // The safe way to do the same thing:
    println!("to_bits():      {:#010x}", 1.0f32.to_bits());
    println!("from_bits():    {}", f32::from_bits(0x4049_0fdb));
}
Output
1.0f32 as bits: 0x3f800000
to_bits():      0x3f800000
from_bits():    3.1415927

12. transmute and its safe replacements

std::mem::transmute reinterprets the bits of one type as another. It's among the most dangerous functions in Rust (an invalid bool or enum value is instant undefined behavior). For almost every use, there's a safe alternative.

Rust
fn main() {
    // Bytes <-> integers: use from_le_bytes / to_be_bytes instead of transmute.
    let header = [0x2A, 0x00, 0x00, 0x00];
    println!("{}", u32::from_le_bytes(header));
    println!("{:?}", 0x0102_0304u32.to_be_bytes());

    // Integer -> enum: use a checked conversion instead of transmute.
    #[derive(Debug)]
    #[repr(u8)]
    enum Level {
        Low = 0,
        High = 1,
    }
    let from_byte = |b: u8| match b {
        0 => Some(Level::Low),
        1 => Some(Level::High),
        _ => None, // transmute would produce an invalid Level here: undefined behavior
    };
    println!("{:?} {:?}", from_byte(1), from_byte(7));

    // Where transmute is legitimately used, the types must have the same size.
    // SAFETY: [u8; 4] and u32 have the same size, and every bit pattern is valid for both.
    let n: u32 = unsafe { std::mem::transmute([1u8, 0, 0, 0]) };
    println!("{n}");
}
Output
42
[1, 2, 3, 4]
Some(High) None
1

13. MaybeUninit: memory that isn't initialized yet

Rust never lets you read uninitialized memory. When you need to allocate first and initialize later (for performance, or because C will fill it), MaybeUninit<T> holds possibly-uninitialized data. assume_init is the unsafe step where you promise it's now initialized.

Rust
use std::mem::MaybeUninit;

fn squares<const N: usize>() -> [u64; N] {
    let mut arr: [MaybeUninit<u64>; N] = [const { MaybeUninit::uninit() }; N];
    for (i, slot) in arr.iter_mut().enumerate() {
        slot.write((i as u64 + 1).pow(2));
    }
    // SAFETY: the loop wrote every element.
    arr.map(|slot| unsafe { slot.assume_init() })
}

fn main() {
    println!("{:?}", squares::<6>());
}
Output
[1, 4, 9, 16, 25, 36]

In practice, std::array::from_fn(|i| ...) does this safely, and it's what you should use. MaybeUninit matters when C code fills a buffer for you.

14. ptr::read, ptr::write, and ptr::swap

These move values through raw pointers without running destructors or checks, the building blocks of collections. Here they rotate three elements of a slice in place.

Rust
use std::ptr;

fn rotate3<T>(v: &mut [T]) {
    assert!(v.len() >= 3);
    let p = v.as_mut_ptr();
    // SAFETY: indices 0..3 are in bounds (asserted). We read element 0 out (leaving a
    // logical hole), shift 1 and 2 down, then write the saved value into the hole,
    // so every element ends up owned exactly once.
    unsafe {
        let first = ptr::read(p);
        ptr::copy(p.add(1), p, 2);
        ptr::write(p.add(2), first);
    }
}

fn main() {
    let mut names = vec![String::from("ana"), String::from("ben"), String::from("cy"), String::from("dee")];
    rotate3(&mut names);
    println!("{names:?}");
    // The safe standard method for this:
    names[..3].rotate_left(1);
    println!("{names:?}");
}
Output
["ben", "cy", "ana", "dee"]
["cy", "ana", "ben", "dee"]

If a panic happened between read and write, the value in slot 0 would be dropped twice. Unsafe code has to think about panics too; this example has none in the critical section.

15. What undefined behavior looks like

Undefined behavior (UB) isn't a crash you can catch. The compiler assumes it never happens and optimizes accordingly, so a program with UB might work in debug builds, fail in release, or break after a compiler upgrade. This compiles, and is wrong:

Rust
fn dangling() -> *const i32 {
    let local = 42;
    &local as *const i32 // pointer to a stack slot that dies when the function returns
}

fn main() {
    let p = dangling();
    // UB: reading freed stack memory. It might print 42, garbage, or anything else.
    println!("{}", unsafe { *p });
}

Common sources of UB: dereferencing dangling or misaligned pointers, creating two &mut to the same data, data races, reading uninitialized memory, producing invalid values (a bool that's 2, a null reference), and calling a function with the wrong ABI.

16. Testing unsafe code with Miri

Miri is an interpreter for Rust's intermediate code that detects most undefined behavior at runtime: use-after-free, out-of-bounds access, invalid aliasing, uninitialized reads, and data races. Run your tests under it whenever you write unsafe.

Terminal
rustup +nightly component add miri
cargo +nightly miri test     # run the test suite under Miri
cargo +nightly miri run      # run the binary under Miri

Running the previous example under Miri stops with an error like Undefined Behavior: memory access failed: ... has been freed, pointing at the exact line. Rust prevents these bugs at compile time in safe code and gives you Miri for the unsafe parts.

Part 2: FFI, calling C

17. Calling C functions

extern "C" blocks declare functions defined in C. The C standard library is linked into every Rust program, so functions like abs and strlen are available directly. In Rust 2024 the block is written unsafe extern "C", and functions you've checked to be safe for every input can be marked safe fn.

Rust
use std::ffi::{c_char, c_int};

unsafe extern "C" {
    safe fn abs(x: c_int) -> c_int; // safe for any input, so callable without `unsafe`
    fn strlen(s: *const c_char) -> usize; // needs a valid NUL-terminated string
}

fn main() {
    println!("abs(-42) = {}", abs(-42));

    let text = c"hello, C"; // a C string literal: &CStr, NUL-terminated
    // SAFETY: `text` is a valid NUL-terminated string that outlives the call.
    let n = unsafe { strlen(text.as_ptr()) };
    println!("strlen = {n}");
}
Output
abs(-42) = 42
strlen = 8

18. CString and CStr: crossing the string boundary

Rust strings aren't NUL-terminated and may contain \0; C strings are the opposite. CString is an owned C string you can pass to C (it adds the terminator and rejects interior NULs). CStr is a borrowed view of a C string you received.

Rust
use std::ffi::{CStr, CString, c_char};

unsafe extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

fn c_len(s: &str) -> Result<usize, String> {
    let c = CString::new(s).map_err(|e| format!("interior NUL at byte {}", e.nul_position()))?;
    // SAFETY: `c` is NUL-terminated and alive until the end of this function.
    Ok(unsafe { strlen(c.as_ptr()) })
}

fn main() {
    println!("{:?}", c_len("héllo")); // bytes, not chars: é is 2 bytes in UTF-8
    println!("{:?}", c_len("bad\0string"));

    let raw: &CStr = c"from C";
    let back: &str = raw.to_str().unwrap(); // validates UTF-8
    println!("{back:?}, {} bytes plus the NUL", raw.count_bytes());
}
Output
Ok(6)
Err("interior NUL at byte 3")
"from C", 6 bytes plus the NUL

19. The dangling as_ptr() trap

CString::new(...).unwrap().as_ptr() in one expression is a classic bug: the CString is a temporary, freed at the end of the statement, leaving the pointer dangling. The compiler warns about it; keep the CString in a variable instead.

Rust
use std::ffi::{CString, c_char};

unsafe extern "C" {
    fn puts(s: *const c_char) -> i32;
}

fn main() {
    // BUG: the CString is dropped at the `;`, so `p` points to freed memory.
    let p = CString::new("hello").unwrap().as_ptr();
    unsafe { puts(p) };

    // Correct: the owner lives as long as the pointer is used.
    let owned = CString::new("hello").unwrap();
    unsafe { puts(owned.as_ptr()) };
}

20. Reading strings that C owns

getenv returns a pointer into memory owned by the C library (or null). Wrap it with CStr::from_ptr for the duration of the borrow, and copy it into a String if you need to keep it.

Rust
use std::ffi::{CStr, c_char};

unsafe extern "C" {
    fn getenv(name: *const c_char) -> *const c_char;
}

fn env_var(name: &std::ffi::CStr) -> Option<String> {
    // SAFETY: `name` is NUL-terminated; getenv returns null or a valid C string that we
    // copy immediately (no other thread modifies the environment in this program).
    unsafe {
        let p = getenv(name.as_ptr());
        if p.is_null() { None } else { Some(CStr::from_ptr(p).to_string_lossy().into_owned()) }
    }
}

fn main() {
    // SAFETY: single-threaded at this point; no one else reads or writes the environment.
    unsafe { std::env::set_var("APP_MODE", "production") };
    println!("{:?}", env_var(c"APP_MODE"));
    println!("{:?}", env_var(c"SURELY_NOT_SET_12345"));
}
Output
Some("production")
None

(In real code, use std::env::var, which does this safely. Note that set_var itself became unsafe in Rust 2024, because modifying the environment while another thread reads it through C is a data race.)

21. Passing a Rust callback to C

C APIs often take function pointers. An extern "C" fn defined in Rust has the C calling convention and can be passed directly. Here, C's qsort sorts a Rust array using a Rust comparator.

Rust
use std::ffi::{c_int, c_void};

unsafe extern "C" {
    fn qsort(base: *mut c_void, count: usize, size: usize, compare: extern "C" fn(*const c_void, *const c_void) -> c_int);
}

extern "C" fn by_value_desc(a: *const c_void, b: *const c_void) -> c_int {
    // SAFETY: qsort passes pointers to elements of the i32 array we gave it.
    let (a, b) = unsafe { (*(a as *const i32), *(b as *const i32)) };
    b.cmp(&a) as c_int // Ordering is -1, 0, 1
}

fn main() {
    let mut scores = [42, 7, 99, 15, 63];
    // SAFETY: base/count/size describe `scores` exactly; the comparator matches the element type.
    unsafe { qsort(scores.as_mut_ptr().cast(), scores.len(), size_of::<i32>(), by_value_desc) };
    println!("{scores:?}");
}
Output
[99, 63, 42, 15, 7]

A panic must never unwind out of an extern "C" fn into C code. Since Rust 1.81 such a panic aborts the process instead, so keep callbacks simple or catch panics with std::panic::catch_unwind.

22. #[repr(C)]: a layout C understands

Rust may reorder struct fields to reduce padding. #[repr(C)] fixes the order and layout to match C's rules, which is required for any struct shared with C.

Rust
use std::mem::{align_of, size_of};

struct RustOrder {
    a: u8,
    b: u32,
    c: u8,
}

#[repr(C)]
struct COrder {
    a: u8,
    b: u32,
    c: u8,
}

fn main() {
    // Rust packs the two u8 fields together; C keeps declaration order, adding padding.
    println!("Rust layout: {} bytes", size_of::<RustOrder>());
    println!("C layout:    {} bytes (align {})", size_of::<COrder>(), align_of::<COrder>());
    let _ = (RustOrder { a: 0, b: 0, c: 0 }.a, COrder { a: 0, b: 0, c: 0 }.c);
}
Output
Rust layout: 8 bytes
C layout:    12 bytes (align 4)

23. A C library with Rust structs: strtol

Many C functions report errors through out-parameters. Here's strtol (parse an integer), wrapped in a safe Rust function that turns its conventions into a Result.

Rust
use std::ffi::{CStr, CString, c_char, c_int, c_long};

unsafe extern "C" {
    fn strtol(s: *const c_char, end: *mut *mut c_char, base: c_int) -> c_long;
}

/// Parses an integer in `base`, requiring the whole string to be consumed.
fn parse_int(s: &str, base: u32) -> Result<i64, String> {
    let c = CString::new(s).map_err(|_| String::from("contains NUL"))?;
    let mut end: *mut c_char = std::ptr::null_mut();
    // SAFETY: `c` is a valid C string; `end` is a valid place for strtol to write.
    let value = unsafe { strtol(c.as_ptr(), &mut end, base as c_int) };
    // SAFETY: strtol sets `end` to a position inside `c` (or its start).
    let rest = unsafe { CStr::from_ptr(end) }.to_string_lossy();
    if end.cast_const() == c.as_ptr() {
        Err(format!("{s:?}: no digits"))
    } else if !rest.is_empty() {
        Err(format!("{s:?}: trailing characters {rest:?}"))
    } else {
        Ok(value as i64)
    }
}

fn main() {
    println!("{:?}", parse_int("  -1234", 10));
    println!("{:?}", parse_int("ff", 16));
    println!("{:?}", parse_int("12abc", 10));
    println!("{:?}", parse_int("xyz", 10));
}
Output
Ok(-1234)
Ok(255)
Err("\"12abc\": trailing characters \"abc\"")
Err("\"xyz\": no digits")

The safe wrapper is the whole point: callers of parse_int can't misuse strtol.

24. Exposing Rust to C: extern "C" and #[unsafe(no_mangle)]

To let C call Rust, give the function the C ABI and a stable symbol name. Build the crate as a cdylib (shared library) or staticlib. Rust objects go to C as opaque pointers made with Box::into_raw, with a matching free function.

Rust
use std::ffi::{CStr, c_char};

pub struct Counter {
    name: String,
    count: u64,
}

#[unsafe(no_mangle)]
pub extern "C" fn counter_new(name: *const c_char) -> *mut Counter {
    // SAFETY: the C caller passes a valid NUL-terminated string.
    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
    Box::into_raw(Box::new(Counter { name, count: 0 }))
}

#[unsafe(no_mangle)]
pub extern "C" fn counter_increment(c: *mut Counter) -> u64 {
    // SAFETY: `c` came from counter_new and hasn't been freed.
    let c = unsafe { &mut *c };
    c.count += 1;
    c.count
}

#[unsafe(no_mangle)]
pub extern "C" fn counter_free(c: *mut Counter) {
    if !c.is_null() {
        // SAFETY: `c` came from counter_new; this is the only place it's freed.
        let c = unsafe { Box::from_raw(c) };
        println!("freeing counter {:?} at {}", c.name, c.count);
    }
}

fn main() {
    // Simulate the C side calling our API.
    let c = counter_new(c"page_views".as_ptr());
    counter_increment(c);
    counter_increment(c);
    println!("count = {}", counter_increment(c));
    counter_free(c);
}
Output
count = 3
freeing counter "page_views" at 3
TOMLCargo.toml
[lib]
crate-type = ["cdylib"]   # produces libcounter.so / counter.dll / libcounter.dylib
Text
/* counter.h, written by hand or generated with the `cbindgen` tool */
typedef struct Counter Counter;
Counter* counter_new(const char* name);
uint64_t counter_increment(Counter* c);
void counter_free(Counter* c);

25. Realistic: a fixed-capacity vector with MaybeUninit

A StackVec<T, N> stores up to N elements inline (no heap), like the arrayvec crate. The unsafe core tracks which slots are initialized; the public API is entirely safe, including dropping only the initialized elements.

Rust
use std::mem::MaybeUninit;

pub struct StackVec<T, const N: usize> {
    items: [MaybeUninit<T>; N],
    len: usize, // invariant: items[..len] are initialized, items[len..] are not
}

impl<T, const N: usize> StackVec<T, N> {
    pub fn new() -> Self {
        StackVec { items: [const { MaybeUninit::uninit() }; N], len: 0 }
    }

    pub fn push(&mut self, value: T) -> Result<(), T> {
        if self.len == N {
            return Err(value); // full: hand the value back
        }
        self.items[self.len].write(value);
        self.len += 1;
        Ok(())
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.len == 0 {
            return None;
        }
        self.len -= 1;
        // SAFETY: slot `len` was initialized (invariant) and is now outside the live range,
        // so it's read exactly once.
        Some(unsafe { self.items[self.len].assume_init_read() })
    }

    pub fn as_slice(&self) -> &[T] {
        // SAFETY: items[..len] are initialized, and MaybeUninit<T> has the same layout as T.
        unsafe { std::slice::from_raw_parts(self.items.as_ptr().cast::<T>(), self.len) }
    }
}

impl<T, const N: usize> Drop for StackVec<T, N> {
    fn drop(&mut self) {
        for slot in &mut self.items[..self.len] {
            // SAFETY: exactly the initialized slots are dropped, once.
            unsafe { slot.assume_init_drop() };
        }
    }
}

fn main() {
    let mut v: StackVec<String, 3> = StackVec::new();
    for name in ["ana", "ben", "cy", "dee"] {
        if let Err(rejected) = v.push(name.to_string()) {
            println!("full, rejected {rejected:?}");
        }
    }
    println!("{:?}", v.as_slice());
    println!("popped {:?}", v.pop());
    println!("{:?}", v.as_slice());
} // remaining Strings are dropped correctly
Output
full, rejected "dee"
["ana", "ben", "cy"]
popped Some("cy")
["ana", "ben"]

26. Realistic: a queue built on raw pointers

A singly linked queue needs O(1) push at the back and pop at the front, so it keeps pointers to both ends. Safe Box links can't express "the list owns every node, and I also point at the last one". The robust approach is to manage nodes entirely through raw pointers: Box::into_raw to allocate, Box::from_raw to take ownership back and free.

Rust
use std::ptr;

struct Node<T> {
    value: T,
    next: *mut Node<T>,
}

pub struct Queue<T> {
    head: *mut Node<T>, // null when empty
    tail: *mut Node<T>, // null when empty
    len: usize,
}

impl<T> Queue<T> {
    pub fn new() -> Self {
        Queue { head: ptr::null_mut(), tail: ptr::null_mut(), len: 0 }
    }

    pub fn push(&mut self, value: T) {
        let node = Box::into_raw(Box::new(Node { value, next: ptr::null_mut() }));
        if self.tail.is_null() {
            self.head = node;
        } else {
            // SAFETY: `tail` is the last live node, allocated by `push` and not yet freed.
            unsafe { (*self.tail).next = node };
        }
        self.tail = node;
        self.len += 1;
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.head.is_null() {
            return None;
        }
        // SAFETY: `head` came from Box::into_raw in `push` and is freed only here, once.
        let node = unsafe { Box::from_raw(self.head) };
        self.head = node.next;
        if self.head.is_null() {
            self.tail = ptr::null_mut(); // the list is empty: don't leave `tail` dangling
        }
        self.len -= 1;
        Some(node.value)
    }

    pub fn len(&self) -> usize {
        self.len
    }
}

impl<T> Drop for Queue<T> {
    fn drop(&mut self) {
        while self.pop().is_some() {} // free every remaining node
    }
}

fn main() {
    let mut jobs = Queue::new();
    jobs.push("resize");
    jobs.push("compress");
    println!("{:?} (len {})", jobs.pop(), jobs.len());
    jobs.push("upload");
    while let Some(job) = jobs.pop() {
        println!("running {job}");
    }
    jobs.push("again"); // pushing after emptying relies on `tail` being reset
    jobs.push("left over"); // freed by Drop
    println!("{:?}", jobs.pop());
}
Output
Some("resize") (len 1)
running compress
running upload
Some("again")

A tempting shortcut, owning nodes through Option<Box<Node>> links while keeping a raw tail pointer into them, compiles and seems to work, but it's undefined behavior: moving a Box asserts unique access to its contents, which invalidates other pointers into it. Miri reports that version immediately. It's a good illustration of why unsafe code needs Miri. For real code, std::collections::VecDeque is the right queue.

27. Checklist for writing unsafe code

  • Avoid it if you can. Look for a safe standard-library method or a well-reviewed crate first.
  • Keep unsafe blocks tiny, and put them behind a safe API that enforces the preconditions (with assert! if needed).
  • Write a // SAFETY: comment for every unsafe block and a # Safety section for every unsafe fn.
  • Uphold invariants at every step, including when a panic can happen in the middle.
  • Test with Miri, and consider cargo careful or sanitizers for FFI code.
  • For FFI: use #[repr(C)] types, CString/CStr, check nulls, never let a panic cross into C, and pair every Box::into_raw with exactly one Box::from_raw.

Summary

  • unsafe unlocks five operations: dereferencing raw pointers, calling unsafe functions, accessing static mut, implementing unsafe traits, and reading union fields. All other checks remain.
  • Raw pointers may be null, dangling, or aliased; creating them is safe, using them isn't.
  • The purpose of unsafe code is to build safe abstractions (split_at_mut, Vec, StackVec), with the reasoning documented in SAFETY comments.
  • Prefer atomics or locks to static mut, safe conversions to transmute, and array::from_fn to MaybeUninit, unless you truly need them.
  • Undefined behavior is not a runtime error; it's a broken program. Miri detects most of it.
  • FFI uses unsafe extern "C" blocks to call C, CString/CStr for strings, extern "C" fn for callbacks, #[repr(C)] for shared structs, and #[unsafe(no_mangle)] pub extern "C" fn plus opaque Box pointers to expose Rust to C.

Next: no_std: core and alloc: Rust without an operating system.