8 Rust FFI Techniques That Make C Interop Safe, Fast, and Crash-Proof

Master Rust FFI safely with 8 proven techniques. Learn pointer wrapping, CString handling, and memory safety patterns with real code examples. Start writing safer FFI today.

8 Rust FFI Techniques That Make C Interop Safe, Fast, and Crash-Proof

I remember the first time I tried to call a C library from Rust. I had a pointer, I had a struct, I had no idea what I was doing. The program crashed. Memory leaked. I felt like I was juggling fire. Over time I learned there are clear patterns that make FFI safe and fast. Here are eight techniques I now use every time. I’ll show you exactly how they work, with code you can copy and adapt.

The first thing I do when I need to hold a raw pointer from C is wrap it in my own type. A raw pointer is like a loaded gun. You can lose it, forget to free it, or dereference it after it’s gone. By putting it inside a struct that owns the pointer and implements Drop, I make sure cleanup always happens, even if my code panics.

pub struct CBuffer {
    ptr: *mut u8,
    len: usize,
}

impl CBuffer {
    pub fn new(size: usize) -> Option<Self> {
        let ptr = unsafe { libc::malloc(size) as *mut u8 };
        if ptr.is_null() {
            None
        } else {
            Some(Self { ptr, len: size })
        }
    }

    pub fn as_slice(&self) -> &[u8] {
        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
    }
}

impl Drop for CBuffer {
    fn drop(&mut self) {
        unsafe { libc::free(self.ptr as *mut libc::c_void) };
    }
}

I keep the constructor unsafe inside because I’m calling malloc. But the public new function does the unsafe work once and returns a safe wrapper. Now any caller can use CBuffer without ever touching a raw pointer. The Drop implementation runs automatically, so I never leak memory. I sleep better.

Strings are another common place where FFI goes wrong. C strings end with a null byte, and if your Rust string contains a null in the middle, the C function will see a shorter string or crash. Rust’s CString type adds the null terminator for you and rejects strings that have interior nulls.

use std::ffi::CString;

fn call_c_function(s: &str) -> Result<(), &'static str> {
    let c_str = CString::new(s).map_err(|_| "string contains null byte")?;
    unsafe {
        c_function(c_str.as_ptr());
    }
    Ok(())
}

extern "C" {
    fn c_function(s: *const libc::c_char);
}

I always convert explicitly. I never pass a &str directly, even if it’s ASCII. The conversion is cheap, and it catches problems at the boundary. When C returns a string, I use CStr::from_ptr to get a &CStr, then convert to &str with to_str(). That handles the null terminator and encoding.

Struct layout is another trap. Rust can reorder fields to optimise alignment, but C expects the fields in the order you wrote them. If I just write struct Point { x: f64, y: f64 } and pass it to C, the memory layout might be different. The fix is simple: add #[repr(C)].

#[repr(C)]
pub struct Point {
    x: f64,
    y: f64,
}

#[no_mangle]
pub extern "C" fn create_point(x: f64, y: f64) -> Point {
    Point { x, y }
}

Now the struct uses C’s layout rules. I also use #[repr(packed)] if I need no padding, but that can cause unaligned access on some CPUs. I test both sides with std::mem::offset_of! or by checking the struct size.

When I write functions that will be called from C, I make them as simple as possible. The biggest rule: never panic. If a Rust function panics, the stack unwinds across the FFI boundary, and that’s undefined behaviour. I wrap the body in catch_unwind.

use std::panic::catch_unwind;

#[no_mangle]
pub extern "C" fn process_data(data: *const u8, len: usize) -> i32 {
    let result = catch_unwind(|| {
        let slice = unsafe { std::slice::from_raw_parts(data, len) };
        // process...
        0 // success
    });
    match result {
        Ok(code) => code,
        Err(_) => -1,
    }
}

If something inside panics, I return an error code instead of crashing the whole C program. I also avoid using ? inside the closure because ? can panic if the error can’t be converted. I handle errors explicitly with match or unwrap_or.

Raw pointers are dangerous because they can be null, dangling, or misaligned. I use debug_assert! to catch these problems during development without slowing down release builds.

unsafe fn read_u32_aligned(ptr: *const u8) -> u32 {
    debug_assert!(!ptr.is_null(), "null pointer");
    debug_assert_eq!(ptr as usize % 4, 0, "misaligned pointer");
    ptr.cast::<u32>().read_unaligned()
}

I sprinkle these assertions wherever I dereference a pointer. They don’t cost anything in release, but they save me hours of debugging. I also check that the pointer is within a valid range if I know the allocation size.

I used to use raw pointers everywhere. Then I discovered NonNull<T>. It tells the compiler and anyone reading my code that this pointer is never null. It also enables optimisation because the compiler can assume non-nullness.

use std::ptr::NonNull;

pub struct OwnedBuffer {
    ptr: NonNull<u8>,
    len: usize,
}

impl OwnedBuffer {
    pub fn new(size: usize) -> Option<Self> {
        let ptr = NonNull::new(unsafe { libc::malloc(size) as *mut u8 })?;
        Some(Self { ptr, len: size })
    }
}

I use NonNull everywhere I can. When the pointer owns the data, I combine it with PhantomData to tell the compiler about ownership. For a buffer that owns [u8], I write PhantomData<[u8]> inside the struct.

Sometimes I have to talk to several C libraries that do similar things. For example, different audio backends: PortAudio, ALSA, JACK. Instead of duplicating unsafe code, I define a trait and implement it for each backend. The unsafe FFI lives only inside the implementations.

pub trait AudioBackend {
    fn open(&self) -> Result<(), String>;
    fn play(&self, buffer: &[f32]) -> Result<(), String>;
    fn close(&self);
}

pub struct PortAudioBackend { /* raw pointers to PaStream */ }

impl AudioBackend for PortAudioBackend {
    fn open(&self) -> Result<(), String> {
        // unsafe FFI calls...
    }
    // ...
}

The rest of my code uses the trait and never sees raw pointers. I can swap backends at compile time with conditional compilation or at runtime with a boxed trait object. This isolates unsafety and makes the code easier to test.

No matter how careful I am, memory bugs happen. I run my FFI tests under Valgrind and AddressSanitizer. On Linux I set RUSTFLAGS="-Z sanitizer=address" and run cargo test. It catches buffer overflows and use-after-free.

RUSTFLAGS="-Z sanitizer=address" cargo test --target x86_64-unknown-linux-gnu

I also run cargo miri on the safe parts, though it can’t execute actual C calls. For integration tests, I write a small C test program that calls my Rust library and run it under Valgrind. This double check has saved me from subtle bugs that only happen at runtime.

Each of these techniques puts a wall between unsafe code and the rest of my program. I keep the unsafe parts small, well-documented, and wrapped in safe interfaces. Over the years I’ve stopped dreading FFI. Instead, I treat it like any other abstraction: I build it carefully, test it thoroughly, and then trust it to do its job while I focus on the logic that matters.


// Keep Reading

Similar Articles