Everything on the other Rust pages is safe Rust: the compiler proves that a program has no use after free, no double free and no data races, and run-time bounds checks stop every read out of bounds (see Ownership, Borrowing and References and Concurrency). Some things cannot be proven that way: talking to hardware registers, calling a C library, or writing a data structure whose pointers the compiler cannot follow. For those, Rust has an escape hatch, and the way it is designed is one of the main reasons the language works in kernels, firmware and next to existing C code. This page assumes you know what a pointer is in C and what undefined behavior means.

What unsafe allows

Unsafe Rust is the part of the language that the compiler cannot check for memory safety. It is not a different language: it is ordinary Rust with five extra operations, the "unsafe superpowers" in the words of the Rust Book:

  1. dereference a raw pointer;
  2. call an unsafe function or method (this includes C functions);
  3. access or modify a mutable static variable;
  4. implement an unsafe trait (such as Send, by hand);
  5. access the fields of a union.

These operations are only allowed inside an unsafe block, unsafe { … }, or in the body of an unsafe fn through such a block; implementing an unsafe trait is written unsafe impl. The keyword is a contract: by writing it, the programmer states that the code inside cannot cause undefined behavior, for reasons the compiler cannot see. Without it, the compiler refuses:

fn main() {
    let watts: u32 = 1234;
    let p = &watts as *const u32;
    println!("{}", *p);
}
example.rs:4:20: error[E0133]: dereference of raw pointer is unsafe and requires unsafe block: dereference of raw pointer

What unsafe does not do is just as important. The Book is explicit: "unsafe doesn't turn off the borrow checker or disable any of Rust's other safety checks". Types are still checked, slices are still bounds-checked, and moved values stay moved, even inside an unsafe block. Google's Android team calls the opposite belief "a common misconception". Here the block is needed for the call to C's abs, and the borrow checker still catches the use after a move:

unsafe extern "C" {
    fn abs(value: i32) -> i32;
}

fn main() {
    let serial = String::from("KHP1-000231");
    unsafe {
        println!("{}", abs(-1234));
        let moved = serial;
        println!("{moved} {serial}");
    }
}
example.rs:10:28: error[E0382]: borrow of moved value: `serial`: value borrowed here after move

Raw pointers

A raw pointer is Rust's equivalent of a C pointer: *const T for reading, *mut T for writing. Unlike references, raw pointers may be null, may dangle, may point to the same data as a mutable reference, and carry no lifetime, so the borrow checker does not follow them. Creating one is safe, and so is comparing it or checking it for null; only dereferencing it needs unsafe. A function that receives a raw pointer cannot check that it is valid, so it is an unsafe fn whose callers promise that it is. Inside, the usual first step is to turn the pointer back into an Option of a reference as early as possible:

use std::ptr;

/// # Safety
///
/// `p` must be null or point to a live, properly aligned `u32`.
unsafe fn describe(p: *const u32) -> String {
    // SAFETY: the caller guarantees that `p` is null or points to a live u32.
    match unsafe { p.as_ref() } {
        Some(watts) => format!("{watts} W"),
        None => String::from("no reading"),
    }
}

fn main() {
    let watts: u32 = 1234;
    let p: *const u32 = &watts;          // a reference converts to a raw pointer
    let nothing: *const u32 = ptr::null();

    println!("null? {} {}", p.is_null(), nothing.is_null());
    // SAFETY: `p` points to `watts`, which is alive; `nothing` is null.
    unsafe {
        println!("{}", describe(p));
        println!("{}", describe(nothing));
    }

    let mut level: u8 = 40;
    let q = &raw mut level;                 // a raw pointer without a reference first
    // SAFETY: q points to `level`, which is alive, and nothing else uses it here.
    unsafe { *q += 2 };
    println!("level: {level}");
}
null? false true
1234 W
no reading
level: 42

&raw const and &raw mut create a raw pointer directly, without creating a reference first, which matters for unaligned fields and for mutable statics. Printing a pointer with {:p} works, but the address changes on every run, so the examples on this site never do it. The compiler also warns about the most obvious dangling pointer, one to a local variable that is dropped when the function returns (a lint added in Rust 1.91):

fn latest_reading() -> *const u32 {
    let reading: u32 = 350;
    &reading
}

fn main() {
    let p = latest_reading();
    println!("null: {}", p.is_null());
}
example.rs:3:5: error: function returns a dangling pointer to dropped local variable `reading`
warning: function returns a dangling pointer to dropped local variable `reading`
 --> example.rs:3:5
  |
1 | fn latest_reading() -> *const u32 {
  |                        ---------- return type is `*const u32`
2 |     let reading: u32 = 350;
  |         ------- local variable `reading` is dropped at the end of the function
3 |     &reading
  |     ^^^^^^^^
  |
  = note: a dangling pointer is safe, but dereferencing one is undefined behavior
  = note: for more information, see <https://doc.rust-lang.org/reference/destructors.html>
  = note: `#[warn(dangling_pointers_from_locals)]` on by default

warning: 1 warning emitted

The note says it precisely: the pointer itself is harmless, dereferencing it would be undefined behavior. With a reference (-> &u32) the same function would not compile at all (see Lifetimes); with a raw pointer the compiler can only warn, and only in simple cases like this one. This is the C bug from dangling pointers, and in unsafe Rust it is just as possible.

Safe abstractions

The point of unsafe code is to build something safe on top of it. A safe abstraction is a function or type with an ordinary, safe interface that uses unsafe inside, written so that no caller, whatever arguments they pass, can cause undefined behavior. Much of the standard library is built that way: Vec, String and Mutex contain unsafe code, and you use them without ever writing unsafe.

A classic example is splitting a slice into two mutable halves, for instance day readings and night readings. Safe code cannot do it directly, because the borrow checker sees two mutable borrows of the same vector and does not know they do not overlap:

fn main() {
    let mut readings = vec![350, 412, 120, 95];
    let mid = readings.len() / 2;
    let (day, night) = (&mut readings[..mid], &mut readings[mid..]);
    day[0] += 1;
    night[0] += 1;
    println!("{readings:?}");
}
example.rs:4:52: error[E0499]: cannot borrow `readings` as mutable more than once at a time: second mutable borrow occurs here

A function can prove what the compiler cannot: the two halves do not overlap. The unsafe part is two calls to slice::from_raw_parts_mut, and the function checks its input first, so that the promise in the SAFETY comment holds for every possible argument:

use std::slice;

/// Splits `readings` into `[..mid]` and `[mid..]`, both mutable.
///
/// # Panics
///
/// Panics if `mid > readings.len()`.
fn split_readings(readings: &mut [u32], mid: usize) -> (&mut [u32], &mut [u32]) {
    let len = readings.len();
    assert!(mid <= len, "mid {mid} is beyond the end ({len})");
    let p = readings.as_mut_ptr();
    // SAFETY: `mid <= len` was checked above, so both ranges lie inside the
    // slice, and they do not overlap. The returned slices borrow `readings`,
    // so nothing else can use it while they exist.
    unsafe { (slice::from_raw_parts_mut(p, mid), slice::from_raw_parts_mut(p.add(mid), len - mid)) }
}

fn main() {
    let mut readings = vec![350, 412, 120, 95];
    let (day, night) = split_readings(&mut readings, 2);
    day[0] += 1;
    night[0] += 1;
    println!("{readings:?}");
}
[351, 412, 121, 95]

The standard library already has this function, split_at_mut, built in exactly this way; in real code you would use it. Three habits make such code reviewable:

  • Keep the unsafe block as small as possible, so it is clear which operation needs the guarantee.
  • Write a // SAFETY: comment above each block that says why the conditions hold here. It is a convention used throughout the standard library and the Linux kernel, and Clippy can require it (see below).
  • Check what you can, then rely on it. The assert! above turns a wrong argument into a panic instead of undefined behavior.

Unsafe functions

When a function cannot check its own preconditions, it is declared unsafe fn, and the duty moves to every caller. Its documentation then needs a # Safety section that lists the conditions (see documentation comments). Since the 2024 edition, the body of an unsafe fn is safe code like any other: each unsafe operation inside it needs its own unsafe block, so that it can get its own SAFETY comment (the lint unsafe_op_in_unsafe_fn warns otherwise).

/// Returns the reading at `index` without a bounds check.
///
/// # Safety
///
/// `index` must be smaller than `readings.len()`.
unsafe fn reading_at(readings: &[u32], index: usize) -> u32 {
    // SAFETY: the caller guarantees that `index` is in bounds.
    unsafe { *readings.get_unchecked(index) }
}

fn main() {
    let readings = [350, 412, 120];
    // SAFETY: 1 is smaller than readings.len(), which is 3.
    let second = unsafe { reading_at(&readings, 1) };
    println!("{second} kWh");
}
412 kWh

Calling C

The foreign function interface (FFI) is the mechanism that lets code in one language call functions compiled from another, through the platform's C calling conventions (the C ABI, see C as the common interface). In Rust, C functions are declared in an unsafe extern "C" block: the names and signatures as Rust types, with no body. The linker finds the code. Functions from the C library itself, such as abs and strlen, are always linked on Linux, so they need nothing else:

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

unsafe extern "C" {
    safe fn abs(value: c_int) -> c_int;
    fn strlen(text: *const c_char) -> usize;
}

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

    let serial: &CStr = c"KHP1-000231";    // a C string literal, NUL-terminated
    // SAFETY: `serial` is a valid NUL-terminated string that outlives the call.
    let length = unsafe { strlen(serial.as_ptr()) };
    println!("strlen = {length}");
}
abs(-1234) = 1234
strlen = 11

Each declaration is a promise that the C function really has this signature; the compiler cannot check it, because a compiled C library carries no types. That is why the block is unsafe. Inside it, a function that is safe to call with any argument can be marked safe fn, as abs is here; all others, like strlen, which reads memory until it finds a zero byte, stay unsafe to call. The types in std::ffi, such as c_int and c_char, match C's types on the target platform.

extern "C" {
    fn abs(value: i32) -> i32;
}

fn main() {
    println!("{}", unsafe { abs(-1234) });
}
example.rs:1:1: error: extern blocks must be unsafe

Linking a C library

For your own C code, the steps are the ones from Compiling and Running C plus one: compile the C file, put it in a static library with ar, and tell rustc where it is with -L (folder) and -l (library). The C function here is the CRC-16/ARC check from the P1 port page. The Rust side wraps the unsafe call in a safe function that takes a slice, so the pointer and the length can never disagree:

cat > crc.c <<'EOF'
#include <stddef.h>
#include <stdint.h>

uint16_t crc16_arc(const uint8_t *data, size_t length)
{
    uint16_t crc = 0x0000;
    for (size_t i = 0; i < length; i++) {
        crc ^= data[i];
        for (int bit = 0; bit < 8; bit++) {
            crc = (crc & 1u) ? (uint16_t)((crc >> 1) ^ 0xA001u) : (uint16_t)(crc >> 1);
        }
    }
    return crc;
}
EOF
cat > main.rs <<'EOF'
unsafe extern "C" {
    fn crc16_arc(data: *const u8, length: usize) -> u16;
}

/// Safe wrapper: a slice always has a valid pointer and the right length.
fn crc(data: &[u8]) -> u16 {
    // SAFETY: the pointer and length come from a valid slice that outlives the call,
    // and crc16_arc only reads `length` bytes.
    unsafe { crc16_arc(data.as_ptr(), data.len()) }
}

fn main() {
    println!("check value: {:04X}", crc(b"123456789"));
    println!("no data:     {:04X}", crc(b""));
}
EOF
gcc -std=c17 -Wall -Wextra -O2 -c crc.c
ar rcs libcrc.a crc.o
rustc --edition 2024 main.rs -L . -l static=crc
./main
check value: BB3D
no data:     0000

BB3D is the published check value of CRC-16/ARC, so the C function and the Rust declaration agree. In a Cargo project, a build script compiles the C file and prints the linker instructions (cargo::rustc-link-lib=static=crc), often with the help of the cc crate. By convention, a crate named foo-sys contains only the raw declarations for a C library foo, and a crate foo builds the safe wrapper on top of it.

Three layers. On the left, safe Rust code calls crc with a slice; the compiler checks everything. In the middle, the safe wrapper function crc contains one unsafe block with a SAFETY comment; here the programmer guarantees the rules. On the right, the C library function crc16_arc receives a pointer and a length; nothing is checked there. safe Rust crc(b"123456789") slice: pointer + length safe wrapper unsafe { crc16_arc(…) } // SAFETY: … C library crc16_arc(ptr, len) libcrc.a the compiler checks the programmer guarantees nothing is checked Review effort goes to the middle box: it is small and marked.
A safe wrapper confines the unsafe part of a C call to one marked block; callers on the left cannot misuse it.

Strings across the boundary

Rust strings and C strings differ in two ways: a Rust String knows its length and may contain a zero byte, while a C string is a pointer to bytes that ends at the first zero byte (NUL). CString is an owned, NUL-terminated string for passing to C, and CStr is a borrowed one, for example received from C. Creating a CString checks for an interior NUL, which C would silently treat as the end of the string, and returns an error instead:

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

fn main() {
    let serial = CString::new("KHP1-000231").expect("no NUL inside");
    println!("{:?} has {} bytes plus the NUL", serial, serial.as_bytes().len());

    let broken = CString::new("KHP1\0-000231");
    println!("{:?}", broken.map_err(|e| e.nul_position()));

    let from_c: &CStr = c"h0017";
    let text: &str = from_c.to_str().expect("valid UTF-8");
    println!("{text}");
}
"KHP1-000231" has 11 bytes plus the NUL
Err(4)
h0017

A CStr received from C must also be checked for valid UTF-8 before it becomes a &str (to_str returns a Result), and someone has to decide who frees the memory. A CString passed to C with into_raw must come back through CString::from_raw to be freed by Rust; memory that C allocated with malloc must be released with C's free. Mixing the two allocators is undefined behavior. Compare this with the 12-byte serial number in an 8-byte buffer on Strings and Buffer Overflows: in safe Rust that overflow cannot happen, because every string knows its length.

Generating bindings

Writing extern declarations by hand for a large C header is tedious and easy to get wrong. bindgen reads C header files and generates the Rust declarations (the Linux kernel uses it; its documentation lists bindgen 0.71.1 as the minimum version as of October 2026), and cbindgen does the reverse, writing a C header for Rust functions. Generated bindings remove typing errors, not the need for a safe wrapper.

Calling Rust from C

The other direction works the same way. A Rust function that C should call gets extern "C" (the C calling convention) and #[unsafe(no_mangle)] (keep the plain name in the object file, instead of Rust's longer internal name). The attribute is unsafe since the 2024 edition: all linked libraries share one namespace of symbol names, so an unmangled name can collide with another symbol of the same name (in C, the C library or another crate), and the program may silently call the wrong function instead of failing to link:

#[no_mangle]
pub extern "C" fn answer() -> u32 {
    42
}

fn main() {
    println!("{}", answer());
}
example.rs:1:3: error: unsafe attribute used without unsafe: usage of unsafe attribute

With #[unsafe(no_mangle)] it compiles. Here the Rust CRC function is built as a static library (--crate-type staticlib) and called from a C program; because it receives a raw pointer from C, it is itself an unsafe extern "C" fn with a # Safety section:

cat > kestrel.rs <<'EOF'
/// Computes CRC-16/ARC over `length` bytes at `data`.
///
/// # Safety
///
/// `data` must not be null (even when `length` is 0) and must point to
/// `length` readable bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn kestrel_crc16(data: *const u8, length: usize) -> u16 {
    // SAFETY: the caller promises that `data` points to `length` readable bytes.
    let bytes = unsafe { std::slice::from_raw_parts(data, length) };
    let mut crc: u16 = 0;
    for &byte in bytes {
        crc ^= u16::from(byte);
        for _ in 0..8 {
            crc = if crc & 1 != 0 { (crc >> 1) ^ 0xA001 } else { crc >> 1 };
        }
    }
    crc
}
EOF
cat > main.c <<'EOF'
#include <stdint.h>
#include <stdio.h>
#include <string.h>

uint16_t kestrel_crc16(const uint8_t *data, size_t length);

int main(void)
{
    const char *check = "123456789";
    printf("%04X\n", kestrel_crc16((const uint8_t *)check, strlen(check)));
    return 0;
}
EOF
rustc --edition 2024 -O --crate-type staticlib kestrel.rs
gcc -std=c17 -Wall -Wextra main.c libkestrel.a -o main
./main
BB3D

The C program cannot see the Rust function's safety contract; the prototype in C is the only interface, so the # Safety text belongs in the header's documentation too. A panic must never cross into C: unwinding out of an extern "C" function aborts the program, so functions called from C should catch errors and return an error code. This is how Rust is usually introduced into an existing C project: one module at a time, behind a C interface.

Mutable statics and other edition 2024 rules

A static mut is a global variable that any code can change, which is a data race waiting to happen once there are threads. Accessing one is unsafe, and since the 2024 edition even taking a reference to one is refused by default, which rules out the pattern found in many old examples:

static mut READINGS: u32 = 0;

fn main() {
    unsafe {
        READINGS += 1;
        println!("{}", READINGS);
    }
}
example.rs:6:24: error: creating a shared reference to mutable static: shared reference to mutable static

The safe replacement for a global counter is an atomic type (or a Mutex, see Concurrency), which needs no unsafe at all and is correct with any number of threads:

use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;

static READINGS: AtomicU32 = AtomicU32::new(0);

fn main() {
    let workers: Vec<_> = (0..4)
        .map(|_| thread::spawn(|| {
            for _ in 0..1000 {
                READINGS.fetch_add(1, Ordering::Relaxed);
            }
        }))
        .collect();
    for worker in workers {
        worker.join().unwrap();
    }
    println!("readings: {}", READINGS.load(Ordering::Relaxed));
}
readings: 4000

One more 2024 change surprises people: std::env::set_var and remove_var are now unsafe functions, because changing the environment while another thread (or a C library) reads it is undefined behavior on many platforms. Calling them without unsafe gives error[E0133]: call to unsafe function `set_var` is unsafe and requires unsafe block. The better fix is usually not an unsafe block but passing the value to the code that needs it, or setting the variable for a child process with Command::env.

Checking unsafe code

Unsafe code can contain exactly the bugs that safe Rust rules out. Android's November 2025 report describes one near miss: CVE-2025-48530, a buffer overflow in unsafe Rust in an image decoder (CrabbyAVIF), found before it reached a public release, and made harmless by the hardened memory allocator. The same report measured a 1000x lower density of memory-safety vulnerabilities in Rust than in Android's C and C++ code: unsafe code is rare and reviewed, not absent. Several tools help keep it that way.

Miri is an interpreter for Rust's intermediate representation that runs a program or its tests slowly but with checks for undefined behavior: out-of-bounds and use-after-free accesses, invalid values, misaligned pointers, data races and violations of the aliasing rules for references. It only exists for the nightly toolchain and cannot check code written in C, so it is not available on this site's test machine:

rustup +nightly component add miri
cargo +nightly miri test        # run the tests under Miri
cargo +nightly miri run         # run the program under Miri

When Miri finds a problem, it stops at that operation with a message that starts with error: Undefined Behavior:, points at the source line, and explains which rule was broken. Like a sanitizer, it only checks the code paths that actually run, so it is as good as the tests that drive it. The C tools work on Rust too: AddressSanitizer is available with a nightly compiler flag, and fuzzing (see Testing, Docs and Tooling) feeds unusual input to the unsafe parts.

Clippy's undocumented_unsafe_blocks lint (in the opt-in restriction group) reports every unsafe block without a SAFETY comment:

cargo clippy -- -W clippy::undocumented_unsafe_blocks
warning: unsafe block missing a safety comment
 --> src/main.rs:4:13
  |
4 |     let v = unsafe { *p };
  |             ^^^^^^^^^^^^^
  |
  = help: consider adding a safety comment on the preceding line
  = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#undocumented_unsafe_blocks
  = note: requested on the command line with `-W clippy::undocumented-unsafe-blocks`

And a crate that needs no unsafe code at all can say so with #![forbid(unsafe_code)] at the top of lib.rs or main.rs, or unsafe_code = "forbid" in the [lints.rust] table of Cargo.toml: then any unsafe block is a compile error, and a reviewer knows that the crate's memory safety rests entirely on the compiler and its dependencies.

Where you meet unsafe in practice

  • Operating systems. Rust code in the Linux kernel (see C in kernels) talks to the kernel's C interfaces through bindgen-generated bindings and safe wrappers around them, so that drivers can be written mostly in safe Rust.
  • Microcontrollers. Firmware in Rust uses #![no_std] (no operating system, only the core library) and unsafe code to reach hardware registers, wrapped in hardware abstraction layers. It is one of the options on Firmware and Embedded Programming. The Kestrel Home dongle is not an example: its firmware is C (ESP-IDF), written by the outside manufacturer.
  • Safety-critical systems. Qualified toolchains exist for cars, industry and medical devices: Ferrocene, from Ferrous Systems, is qualified for ISO 26262 (ASIL D), IEC 61508 (SIL 3) and IEC 62304 (Class C) as of October 2026. Such projects typically restrict and document every unsafe block.
  • Libraries you depend on. Many popular crates contain some unsafe code for performance or for C bindings. When you review a dependency, searching for unsafe and reading the SAFETY comments is a quick first check.

In this example, if a developer on Kestrel's platform team wanted to compare the Rust prototype of the BUG-1060 parser with the manufacturer's C code directly, the FFI route above is how: compile the C function into a library, declare it in an unsafe extern "C" block, and feed both versions the same telegrams in a test. The firmware on the dongle stays C; the comparison runs on a developer's machine.