Rust, part 11: unsafe, FFI, and the boundary to C
Part 11from the Rust series · 15 parts in all
unsafe is not "turn off the checks" and it is not a mode you enter. It is a
marker on five specific capabilities, and everything else in the language keeps being
checked. Part 11 is what it unlocks, and the discipline that keeps it from spreading.
Five things, and only five
- Dereference a raw pointer (
*const T/*mut T). - Call an
unsafe fn, including a foreign function. - Access or modify a
static mut. - Implement an
unsafe trait(Send,Sync). - Access a field of a
union.
Inside that block the compiler stops proving those five things and starts trusting you. Note
what it does not stop doing: borrows are still checked, types still match, and a
&mut you made is still exclusive. That gap is where the bugs live — an
unsafe block that creates two aliasing mutable references is undefined behaviour
the compiler will not catch, and that is the entire reason the discipline below matters.
Calling C, and being called
use std::ffi::{c_char, CStr};
extern "C" {
fn strlen(s: *const c_char) -> usize;
}
/// Length of a NUL-terminated C string.
///
/// # Safety
/// `ptr` must point to a NUL-terminated string that stays valid and unmodified
/// for the duration of this call. The caller must own it.
pub unsafe fn c_len(ptr: *const c_char) -> usize {
// SAFETY: the caller has promised the pointer is a valid C string. Reading
// it here does not only exercise that promise; it is the only unsafe act.
unsafe { strlen(ptr) }
}
// The safe wrapper the rest of the program should actually use:
pub fn len_of(s: &CStr) -> usize {
// SAFETY: CStr guarantees NUL termination and a valid pointer.
unsafe { c_len(s.as_ptr()) }
}
Three conventions worth adopting verbatim. Every public unsafe fn carries a
# Safety doc section saying what the caller must guarantee. Every
unsafe block carries a // SAFETY: comment saying why the guarantee
holds, and clippy's undocumented_unsafe_blocks lint enforces it. And the unsafe
function is private: the safe wrapper is the API, so the invariant has one
place to hold.
Making the boundary small
bindgengenerates theextern \"C\"declarations from a C header, so you are not hand-transcribing struct layouts — the most common source of miscompiled FFI.cxxhandles Rust ↔ C++ properly: shared types,std::stringandVecbridges, and exception handling, none of whichextern \"C\"gives you.cbindgengoes the other way, exporting a C header for a Rust library so C can call in.- Keep the boundary shallow. One module, one safe API, and no
unsafein the rest of the crate. Unsafe code is not hard because of what it does; it is hard because the proof obligation lives in your head and disappears from the repo unless you write it down.
Next: the crate that made that boundary mostly unnecessary for the most common task in the ecosystem — moving data between representations.