Unsafe Rust and Calling C
What unsafe allows and what it does not switch off, raw pointers, wrapping unsafe code in a safe interface, calling a C library through the foreign function interface, and tools such as Miri that check unsafe code.
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:
- dereference a raw pointer;
- call an unsafe function or method (this includes C functions);
- access or modify a mutable static variable;
-
implement an unsafe trait (such as
Send, by hand); - 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.
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
# 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
unsafeand 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.