Raw Pointers and FFI

31 min read
On this page

Raw pointers allow expressing states that cannot be directly represented in Rust's reference model, such as nullable external handles, uninitialized storage within containers, low-level allocation, and hardware interfaces. FFI is a significant use case, but not the only one.

*const T and *mut T do not carry lifetimes that the borrow checker can fully track. They may be null or misaligned; you must satisfy all requirements for the specific access operation only when you actually perform it.

Obtain the pointerAllocation and provenance, beyond the numeric address1Confirm storage is still validObject lifetime, access range, alignment, and initialization2Choose access methodRead, write and reference creation have different contracts34A raw pointer moving from an address to a valid access requires satisfying conditions item by itemMaintain aliasing and concurrency constraintsMultiple pointers do not authorize conflicting accessNull or dangling pointer values can exist; being able to hold it and being able to dereference it are two different things.

Pointer Existence and Access Validity Are Different

fn main() {
    let value = 42_u32;
    let pointer = &raw const value;
    // SAFETY: value is still alive and initialized; pointer is aligned, and the read has no competing writes.
    let copied = unsafe { pointer.read() };
    assert_eq!(copied, 42);
    let null = std::ptr::null::<u32>();
    assert!(null.is_null()); // Saving and checking for a null pointer does not require dereferencing
}

Multiple *mut T pointers can point to the same location, but this does not authorize you to create simultaneously valid and mutually conflicting &mut T references, or to perform non-atomic concurrent reads and writes. The const/mut on a pointer indicates the direction of access allowed through that type; it does not automatically make an object that was originally read-only or protected by shared references writable. Pointer Module

ptr.read on non-Copy types bitwise reads out an owned value; the caller must avoid subsequently freeing the same resource as two independent owners. ptr.write does not destruct the original value first; whether to release existing resources before overwriting must be handled separately.

Converting Raw Pointers to References Adds Obligations

&*ptr or &mut *ptr is not just "a more convenient syntax." The new reference requires a non-null, aligned, valid target value, and the corresponding access constraints for the entire borrow period. Even if there is no immediate read or write afterwards, these requirements cannot be ignored.

Similarly, when the length of slice::from_raw_parts is zero, it usually still requires a non-null and aligned pointer; a C interface allowing NULL+0 does not mean you can directly construct a Rust empty slice. External buffers must first verify the requirements of their respective interfaces.

Safe wrappers returning references should also bind the lifetime to the object that actually holds the storage. You cannot give callers an arbitrarily long borrow based on a raw pointer that has no owner information. This is more important than simply marking a function body with unsafe.

Branch for NULL + 0 Separately, But Don't Treat Null Checks as Complete Validation

The following function calculates a byte-wise XOR checksum, allowing empty buffers expressed as NULL + 0. It uses unsafe fn because memory validity for non-null pointers cannot be proven solely by integer checks.

/// # Safety
/// When len > 0 and data is non-null, the caller guarantees it points to len initialized bytes within the same allocation,
/// readable and alive for the entire call duration, with no conflicting writes; length does not exceed isize::MAX.
unsafe fn xor_bytes(data: *const u8, len: usize) -> Result<u8, &'static str> {
    if len == 0 { return Ok(0); }
    if data.is_null() { return Err("Non-zero length corresponds to a null pointer"); }
    if len > isize::MAX as usize { return Err("Length too large"); }
    // SAFETY: Null pointer and excessive length are excluded above; remaining range, initialization, and access prerequisites are guaranteed by the caller.
    let bytes = unsafe { std::slice::from_raw_parts(data, len) };
    Ok(bytes.iter().fold(0, |result, byte| result ^ byte))
}
fn main() {
    let bytes = [1u8, 2, 3, 4];
    // SAFETY: The array remains valid, pointer and length come from the same complete array, no concurrent writes.
    assert_eq!(unsafe { xor_bytes(bytes.as_ptr(), bytes.len()) }, Ok(4));
    // SAFETY: These two combinations are explicitly handled by the interface and do not access null pointers.
    assert_eq!(unsafe { xor_bytes(std::ptr::null(), 0) }, Ok(0));
    assert!(unsafe { xor_bytes(std::ptr::null(), 1) }.is_err());
}

After checking that data is non-null and len has not overflowed, you still don't know how large the buffer actually is, nor whether it has been freed; therefore, you cannot change the function into a pointer validator that can be called safely arbitrarily. The len in from_raw_parts counts elements, not bytes; when switching to u32, you must also consider alignment and total byte size. from_raw_parts

If the upper layer already holds a &[u8], just accept the slice directly; you only need to maintain these additional prerequisites when truly crossing boundaries lacking Rust lifetime information. Don't split a reliable slice into pointers just to demonstrate unsafe, only to guess the length back in another place.

Provenance: Integer Address Equality Is Not Interchangeable

Pointers carry semantics beyond just address, such as their access source. Integer round-tripping, out-of-bounds pointer arithmetic, and stitching across allocations can all break operation prerequisites. Even if two allocations happen to be adjacent, they cannot automatically be synthesized into a Rust slice.

Operations like add/offset have range and overflow requirements; wrapping_add can relax conditions during calculation, but it does not make the final dereference legal. When adjusting addresses, understanding interfaces like addr and with_addr that preserve provenance is easier to express intent than arbitrary as usize conversions back and forth. Rust's complete aliasing model is still evolving; rely on explicit standard library contracts rather than treating a specific interpreter model as the final specification for all details.

Overlapping Copy and Non-Overlapping Copy Are Different Contracts

fn main() {
    let mut bytes = [1u8, 2, 3, 4, 5];
    let pointer = bytes.as_mut_ptr();
    // SAFETY: Source 0..4 and destination 1..5 are both within the same valid array; u8 is aligned, overlapping is allowed.
    unsafe { std::ptr::copy(pointer, pointer.add(1), 4); }
    assert_eq!(bytes, [1, 1, 2, 3, 4]);
    let mut safe = [1u8, 2, 3, 4, 5];
    safe.copy_within(0..4, 1);
    assert_eq!(safe, bytes);
}

ptr::copy semantically allows overlapping, behaving as if saving the source region first and then writing to the destination; you cannot replace it with copy_nonoverlapping just because a test passed. The count for both is the number of elements of type T. For regular slice data, prefer interfaces like copy_within and copy_from_slice that check boundaries. ptr::copy

Bitwise copying is also not calling Clone. For types owning resources like String, copying the pointer/length fields and letting both objects destruct would destroy ownership; if containers use this operation to move elements internally, they must synchronously maintain which slots are still considered to validly own values. The u8 example avoids this destruct responsibility, but that doesn't mean generalizing to arbitrary T is equally simple.

Copy direction cannot replace a non-overlap contract

Move the destination from disjoint to overlapping. Results follow ptr::copy semantics, always using original source values. A handwritten loop direction that happens to work cannot change copy_nonoverlapping’s caller obligations.

Preparing the visual
Copy direction cannot replace a non-overlap contract

ptr::copy allows overlap; copy_nonoverlapping requires disjoint ranges. Copying u8 introduces no duplicate destructors.

Packed: Allows Unaligned Layout, Does Not Allow Unaligned References

#[repr(C, packed)]
struct Packet {
    kind: u8,
    length: u32,
}

fn main() {
    let packet = Packet { kind: 1, length: 42 };
    let pointer = &raw const packet.length;
    // SAFETY: Points to an initialized u32, stored validly during access; uses unaligned read.
    let length = unsafe { pointer.read_unaligned() };
    assert_eq!(length, 42);
}

Do not write &packet.length first and then cast it to a raw pointer; that already attempts to form an unaligned reference. repr(packed) also does not handle endianness; this memory layout does not mean it is designed for cross-platform network encoding. For byte protocols, it is often better to use from_le_bytes/from_be_bytes to avoid relying on struct memory layout.

Three Boundaries of FFI

extern "C" determines the target platform's C calling convention, repr(C) determines the layout rules for corresponding types, and specific pointer/length/release protocols determine resource semantics. The C ABI for x86-64 Unix and Windows are not the same; you cannot generalize the System V register table to all x86-64.

Rust 2024's external function declarations use unsafe extern blocks; exporting fixed names uses #[unsafe(no_mangle)], while still requiring correct extern "C" function types. Integer widths use target aliases from core::ffi etc. to match; do not permanently bind all C int/long to the same Rust width.

The actual offsets of repr(C) structs are influenced by the target ABI and the layout of the fields themselves; an outer repr(C) does not recursively correct inner default Rust types. Valid values for enums and bool across boundaries must also match; you cannot assume safety just because sizeof is the same. Type Layout

CString owns the bufferResponsible for final releaseas_ptr()Temporary read-only borrowExternal ReadNo retained pointerinto_raw(): transfer ownershipOriginal CString no longer auto-releasedfrom_raw(): reclaim onceReclaim once; preserve lengthCross-boundary: First distinguish between borrowing pointers and transferring ownershipTwo different protocols; the result of as_ptr cannot be used with from_raw, nor handed to arbitrary free.

C Strings: Check Terminator, Encoding, and Ownership Separately

use std::ffi::{CStr, CString};
fn main() {
    let name = CString::new("hello").unwrap();
    let pointer = name.as_ptr();
    // SAFETY: pointer is borrowed from a still-alive CString, NUL is within the same allocation, no writes during the period.
    let borrowed = unsafe { CStr::from_ptr(pointer) };
    assert_eq!(borrowed.to_str().unwrap(), "hello");
    assert!(CString::new(b"a\0b".to_vec()).is_err());
    let non_utf8 = CStr::from_bytes_with_nul(b"\xff\0").unwrap();
    assert!(non_utf8.to_str().is_err());
    assert_eq!(non_utf8.to_bytes(), &[0xff]);
}

CString guarantees its own NUL-terminated layout, but does not guarantee the content is UTF-8; CStr can borrow valid non-UTF8 byte strings, so converting to &str still requires validation via to_str. CStr::from_ptr searches for the terminator; the caller must guarantee it is readable within valid storage. If external code provides length, prefer bounded buffer interfaces over unbounded scanning.

Do not write CString::new(...).unwrap().as_ptr() and then keep the pointer for the next statement: the temporary CString might already be freed. Even if you save the CString in a variable, if the external library continues to hold this pointer after the function returns, you must extend the lifetime of the true owner and coordinate the final external access. CString

After Transferring Ownership, You Can Only Retrieve It According to the Original Protocol

use std::ffi::CString;
fn main() {
    let raw = CString::new("owned").unwrap().into_raw();
    // Here you can hand raw to external code as agreed; the example does not modify its content or length.
    // SAFETY: raw comes from the above into_raw, has not been reclaimed, and the terminator position has not changed.
    let restored = unsafe { CString::from_raw(raw) };
    assert_eq!(restored.to_str().unwrap(), "owned");
}

from_raw is not "turning any C string into a Rust owner." It only takes back the CString that matches the one exported by into_raw; if external code changes the terminator position, the recalculated length might be inconsistent with the original allocation, violating the reclamation contract. Strings allocated by external libraries should use the release function they provide; if necessary, copy them into Rust-owned data first.

SourceCommon Correct Release ResponsibilityCannot Directly Replace With
Box::into_rawBox::from_raw once according to original type and allocation contractlibc free, repeated from_raw
CString::into_rawCString::from_raw once after meeting length requirements etc.Arbitrary C string pointers
Handle returned by external createSame library destroy/closeDefault Rust allocator release
Borrow-only as_ptrTrue owner releases after borrow endsCreating a second owner from a borrow pointer

What Work Remains After Binding Generation

bindgen generates declarations from header files; output should be placed in OUT_DIR, consistent with include! paths. It does not automatically discover whether C saves pointers, whether callback threads are concurrent, or who is responsible for freeing a certain return value.

See C ABI and bindgen for complete, runnable C/Rust bindings and resource protocols. The division of labor between the two articles is: this one explains the validity conditions for a single pointer access, and the other explains how to wrap external library contracts into safe interfaces.

Callbacks and Unwinding Are Judged by Specific ABI

Rust panics crossing ordinary non-unwind C ABI boundaries will cause process termination; external exceptions entering Rust boundaries that do not allow unwinding may be undefined behavior. C-unwind provides different conventions for scenarios allowing unwinding; this cannot be attributed to "C stacks definitely having no unwind info."

Using catch_unwind inside callbacks only handles Rust panics that can unwind; it does not catch aborts, nor can it automatically restore data structures interrupted during updates. Whether it is allowed to continue callbacks after deregistration, or whether you must wait for in-flight calls to finish, also belongs to FFI safety conditions. FFI Unwinding

Draw the Last Access for Callbacks

When a registration function receives callback and user_data, you must at least know whether the callback can be concurrent, on which threads it runs, whether deregistration waits for in-flight calls, and whether user_data is still retained if it fails. Only after the external library guarantees no further callback access will Rust be able to release the pointed-to object.

A common safe wrapper involves the owner saving the external handle, deregistering in Drop, and completing necessary in-flight call coordination; if deregistration might synchronously trigger callbacks, you must also avoid holding locks needed by the callback. If the library's destruction protocol requires asynchronous completion, synchronous Drop alone may be insufficient; you need an explicit close interface and define behavior when discarded without closing.

These constraints come from the real contracts of external libraries and cannot be deduced from the signature extern "C" fn(*mut c_void). Type declarations solve calling conventions; safe wrappers are responsible for completing the resource timeline.