How to Wrap a C Library in a Safe Rust API

Use the bindgen crate to generate Rust bindings for C headers and wrap them in safe functions for error handling.

You found the C library. Now what?

You found a C library that does exactly what you need. It handles image compression, parses a weird binary format, or talks to a specific hardware sensor. The code is written in C, it's been around for years, and rewriting it in Rust would take months. You want to use it. But calling C directly from Rust feels like walking a tightrope without a net. One wrong pointer and your program segfaults. The goal is to build a safe Rust API that hides the C guts, so your Rust code stays safe, idiomatic, and easy to use.

The wrapper is a contract

Wrapping a C library is about building a safety layer. The C library is the engine. It's powerful, but it exposes raw gears and hot surfaces. Your Rust wrapper is the dashboard. It gives the driver clean buttons and gauges. The dashboard checks inputs before sending them to the engine. It translates the engine's raw signals into readable warnings. If the engine fails, the dashboard reports an error instead of letting the car catch fire.

In Rust terms, you use unsafe blocks to talk to C, but you expose only safe functions to the rest of your code. The unsafe block is not magic. It tells the compiler that you have manually verified the code inside is safe. The compiler still checks types. You can't pass a u32 where a *mut i8 is expected. The unsafe block only disables safety checks, not type checks.

The wrapper is your contract. If the contract is broken, the safety is broken.

Generating bindings with bindgen

Writing FFI declarations by hand is error-prone. C headers contain macros, struct padding rules, and platform-specific types. A small mistake in a struct layout can cause memory corruption that's nearly impossible to debug. The community standard is to use bindgen.

bindgen is a tool that reads C header files and generates Rust code. It parses preprocessor macros, handles #ifdef blocks, and maps C types to Rust types. It creates #[repr(C)] structs that match the C memory layout exactly. It saves you from manually checking struct alignment, which varies across architectures.

The convention is to run bindgen in your build.rs script. This ensures bindings are regenerated whenever headers change. The generated code goes into OUT_DIR, and your Rust source includes it. This keeps your repository clean and ensures the bindings always match the C headers.

// build.rs
use std::env;
use std::path::PathBuf;

fn main() {
    // Tell cargo to look for the C library in the specified directory.
    println!("cargo:rustc-link-search=native=/usr/lib");
    // Link against the library named 'mylib'.
    println!("cargo:rustc-link-lib=mylib");

    // Generate bindings from the header file.
    let bindings = bindgen::Builder::default()
        .header("path/to/mylib.h")
        // Suppress warnings in generated code to keep builds clean.
        .allowlist_function("c_.*")
        .generate()
        .expect("Unable to generate bindings");

    // Write the bindings to the OUT_DIR so they can be included.
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}

Start with pure functions. Verify the binding before touching memory.

Minimal example: The pure function

Not every C function needs complex wrapping. Some functions are pure: they take primitive arguments, return a primitive result, and have no side effects. These are the easiest to wrap. You call the C function inside an unsafe block and return the result.

The SAFETY comment is mandatory here. It documents why the unsafe block is valid. Without it, future maintainers won't know what invariants you relied on. Treat the SAFETY comment as a proof. If you can't write it, you don't have one.

// bindings.rs (generated by bindgen)
pub fn c_add(a: i32, b: i32) -> i32;

// safe_wrapper.rs
/// Adds two integers using the C library.
pub fn safe_add(a: i32, b: i32) -> i32 {
    // SAFETY: c_add is a pure function with no side effects.
    // It takes primitive types and returns a primitive.
    // No pointers, no mutable state, no allocation.
    unsafe { bindings::c_add(a, b) }
}

Start small. Verify the binding works before adding logic.

The realistic wrapper: Memory and errors

Real C libraries manage memory and report errors. They return pointers to allocated structs, and they expect you to free them. They return error codes instead of panicking. Your wrapper must handle both.

The pattern is to create a Rust struct that owns the C pointer. Implement Drop to free the memory when the struct goes out of scope. This ensures memory is always cleaned up, even if an error occurs. Use Result to propagate errors to the caller.

The community convention is to keep unsafe blocks as small as possible. Wrap the FFI call immediately. Don't let raw pointers leak out of the safe wrapper. The wrapper should be the only place where unsafe appears.

// bindings.rs (generated by bindgen)
pub struct c_context {
    pub data: *mut u8,
    pub size: usize,
}

pub fn c_create_context(size: usize) -> *mut c_context;
pub fn c_free_context(ptr: *mut c_context);
pub fn c_process(ptr: *mut c_context) -> i32;

// safe_wrapper.rs
/// Represents a context owned by the C library.
pub struct Context {
    ptr: *mut bindings::c_context,
}

impl Context {
    /// Creates a new context with the given size.
    pub fn new(size: usize) -> Result<Context, String> {
        // Call the C function to allocate the context.
        let ptr = unsafe { bindings::c_create_context(size) };

        // Check for null pointer indicating allocation failure.
        if ptr.is_null() {
            return Err("C library failed to allocate context".to_string());
        }

        Ok(Context { ptr })
    }

    /// Processes the context and returns the result code.
    pub fn process(&mut self) -> Result<i32, String> {
        // SAFETY: self.ptr is valid because it was returned by c_create_context
        // and we haven't dropped it yet. The C function expects a valid pointer.
        let result = unsafe { bindings::c_process(self.ptr) };

        // Map C error codes to Rust Result.
        if result == 0 {
            Ok(0)
        } else {
            Err(format!("C library returned error code {}", result))
        }
    }
}

impl Drop for Context {
    fn drop(&mut self) {
        // SAFETY: self.ptr is valid and hasn't been freed yet.
        // c_free_context expects a pointer returned by c_create_context.
        unsafe { bindings::c_free_context(self.ptr) };
    }
}

Own the memory. If C allocates it, Rust must free it.

Strings: The null terminator trap

C strings are arrays of bytes terminated by a null byte. Rust strings are UTF-8 sequences with a length. They don't use null terminators. Converting between them requires care.

Use CString to create a null-terminated string from a Rust string. Use CStr to read a null-terminated string from C. CString::new returns a Result because Rust strings can contain null bytes, which are invalid in C. This check prevents subtle bugs where a null byte in the middle of a string truncates the data silently.

The convention is to use into_raw() to pass ownership of a CString to C, and from_raw() to take it back. This avoids copying the string data. If C doesn't take ownership, use as_ptr() instead.

use std::ffi::{CString, CStr};
use std::os::raw::c_char;

/// Represents a string owned by the C library.
pub struct CStrOwned {
    ptr: *mut c_char,
}

impl CStrOwned {
    /// Creates a new C string from a Rust string.
    pub fn new(text: &str) -> Result<CStrOwned, String> {
        // CString::new checks for null bytes and adds a terminator.
        let c_string = CString::new(text).map_err(|_| "String contains null byte")?;

        // Transfer ownership to C without copying.
        let ptr = c_string.into_raw();

        Ok(CStrOwned { ptr })
    }

    /// Converts the C string back to a Rust string.
    pub fn to_rust_string(&self) -> Result<String, String> {
        // SAFETY: self.ptr is valid and null-terminated.
        // It was created by CString::new or returned by the C library.
        let c_str = unsafe { CStr::from_ptr(self.ptr) };

        // Convert to Rust string, checking for valid UTF-8.
        c_str.to_str().map(|s| s.to_string()).map_err(|_| "Invalid UTF-8")
    }
}

impl Drop for CStrOwned {
    fn drop(&mut self) {
        // SAFETY: self.ptr is valid and was created by CString::into_raw.
        // We reconstruct the CString to free the memory.
        unsafe {
            let _ = CString::from_raw(self.ptr);
        }
    }
}

Check for null bytes. C strings break on them.

Pitfalls and compiler errors

FFI code hits specific compiler errors. Knowing them helps you debug faster.

If you try to dereference a raw pointer without unsafe, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This is a safety check. You must wrap the dereference in an unsafe block and provide a SAFETY comment.

If you mix up types, you get E0308 (mismatched types). For example, passing a *const i8 where a *mut i8 is expected. The compiler catches this even inside unsafe blocks. Fix the type mismatch before worrying about safety.

C pointers are not thread-safe by default. Rust assumes they aren't Send or Sync. If your C library is thread-safe, you need to tell Rust. Otherwise, you'll hit E0277 (trait bound not satisfied) when trying to share data across threads.

// SAFETY: The C library documentation guarantees thread-safety for c_context.
// Access is protected by internal locks or is immutable after creation.
unsafe impl Send for bindings::c_context {}
unsafe impl Sync for bindings::c_context {}

Trust the compiler on types. Trust yourself on safety.

Decision: When to use what

Use bindgen when you have C header files and want automatic, up-to-date Rust bindings. It handles struct layout, type mapping, and preprocessor macros. It reduces manual errors and keeps bindings in sync with C changes.

Use manual extern "C" blocks when the C API is tiny, stable, and you want zero dependencies. Hand-writing bindings gives you full control and avoids the overhead of a build script. This works well for simple utility functions.

Use cty or libc crates when you need standard C types like c_int or c_char that match the platform exactly. These crates provide type aliases that match the C ABI on all supported platforms.

Use a safe wrapper struct when the C library manages memory or state that Rust needs to track. Wrap pointers in structs with Drop implementations. Expose only safe methods. This isolates unsafe code and provides a clean API for users.

Automate the bindings. Hand-craft the safety.

Where to go next