DEV Community

Cover image for How to Use Rust FFI: Complete Guide to Calling C Functions Safely
Aarav Joshi
Aarav Joshi

Posted on

How to Use Rust FFI: Complete Guide to Calling C Functions Safely

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Crossing the chasm between programming languages often feels like translating between two very different cultures. Each has its own customs, rules, and ways of handling memory and execution. I've found that Rust’s Foreign Function Interface (FFI) offers one of the most thoughtful and secure bridges for these cross-language conversations, especially when interacting with C.

At its core, FFI is about communication. It allows Rust to call functions written in C, and vice versa. This is not just a technical curiosity—it’s a practical necessity. Many critical system libraries, drivers, and legacy codebases are written in C. Being able to incorporate them into a Rust project without compromising safety is a significant advantage.

When you call a C function from Rust, you are stepping outside Rust’s guarded memory world. That’s why such calls must be marked as unsafe. It’s Rust’s way of saying, “Here be dragons.” You are taking responsibility for ensuring that what happens in that foreign code doesn’t violate the rules that Rust usually enforces so strictly.

Here’s a simple example. Suppose you want to use the C standard library’s abs function to compute the absolute value of an integer. Here’s how you might declare and use it in Rust:

use std::os::raw::c_int;

extern "C" {
    fn abs(input: c_int) -> c_int;
}

fn main() {
    let result: c_int;
    unsafe {
        result = abs(-10);
    }
    println!("The absolute value is: {}", result);
}
Enter fullscreen mode Exit fullscreen mode

The extern "C" block tells Rust about the existence and signature of the foreign function. The libc crate, which is commonly used, provides type definitions like c_int that match those in C, ensuring compatibility.

But it’s not just about calling out—Rust can also be called from C. This is useful when you want to write performance-critical or safety-oriented components in Rust and use them in an existing C codebase. To make a Rust function callable from C, you need to prevent Rust’s name mangling and specify the C calling convention.

#[no_mangle]
pub extern "C" fn multiply(a: i32, b: i32) -> i32 {
    a * b
}
Enter fullscreen mode Exit fullscreen mode

The #[no_mangle] attribute ensures the function name stays as-is in the compiled output, and extern "C" makes sure it follows C’s calling conventions. This function can now be linked and called from a C program just like any native C function.

One of the trickiest parts of FFI is handling strings. C uses null-terminated arrays of characters, while Rust’s String and str types are more structured and safe. Converting between them requires care.

use std::ffi::CString;
use std::os::raw::c_char;

#[no_mangle]
pub extern "C" fn greet(name: *const c_char) {
    let name_str = unsafe { 
        std::ffi::CStr::from_ptr(name).to_string_lossy().into_owned() 
    };
    println!("Hello, {}!", name_str);
}
Enter fullscreen mode Exit fullscreen mode

In this example, the function expects a C-style string pointer. We use CStr::from_ptr to wrap it safely and then convert it to a Rust string. Note that we’re still using unsafe here because dereferencing a raw pointer is inherently risky.

Memory management across FFI boundaries requires attention. Rust’s ownership system doesn’t extend into C, so you must be explicit about who allocates and who frees memory. It’s easy to introduce leaks or double-frees if you’re not careful.

A common pattern is to provide paired functions for construction and destruction. For example, if you create an object in Rust and pass it to C, you should also provide a function to properly clean it up.

#[repr(C)]
pub struct MyStruct {
    value: i32,
}

#[no_mangle]
pub extern "C" fn create_my_struct(value: i32) -> *mut MyStruct {
    Box::into_raw(Box::new(MyStruct { value }))
}

#[no_mangle]
pub extern "C" fn destroy_my_struct(ptr: *mut MyStruct) {
    if !ptr.is_null() {
        unsafe { Box::from_raw(ptr); }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, Box::into_raw gives up ownership of the boxed value and returns a raw pointer. Later, Box::from_raw reclaims ownership and drops the value. This ensures memory is managed correctly.

Error handling is another area where Rust and C differ. Rust uses Result and Option types, while C typically relies on return codes or error flags. When designing an FFI API, it’s important to translate errors in a way that both sides can understand.

For instance, you might decide that a function returns zero on success and a negative number on failure, with each negative number corresponding to a specific error.

#[no_mangle]
pub extern "C" fn do_something_risky(input: i32) -> i32 {
    match risky_operation(input) {
        Ok(result) => result,
        Err(e) => -1, // or use a more detailed error code mapping
    }
}
Enter fullscreen mode Exit fullscreen mode

For more complex interfaces, tools like bindgen can automate much of the boilerplate. bindgen reads C header files and generates the corresponding Rust FFI code. This reduces human error and keeps things in sync when the C API changes.

Using bindgen is straightforward. You can add it as a build dependency and use a build script to generate Rust bindings during compilation. This is especially useful for large or frequently updated C libraries.

Performance is often a concern when crossing language boundaries. The good news is that well-designed FFI adds very little overhead. Function calls are generally just a pointer indirection, and data can often be passed without copying.

That said, it’s still important to minimize the number of cross-language calls. Each call has a small cost, and frequent calls can add up. Where possible, batch operations or expose higher-level functions that do more work per call.

I’ve used Rust’s FFI in several projects to integrate with existing C libraries. One example was a graphics application that used a C-based rendering engine. By wrapping the engine in safe Rust APIs, we were able to enjoy Rust’s memory safety while leveraging the performance and features of the established C code.

The key was to create a thin unsafe layer that directly interfaced with the C library and then build safe, idiomatic Rust abstractions on top. This contained the unsafety to a small, manageable part of the codebase.

Here’s a simplified example of what that might look like for a hypothetical graphics library:

mod ffi {
    #[repr(C)]
    pub struct Texture {
        // ... internal fields
    }

    extern "C" {
        pub fn create_texture(width: i32, height: i32) -> *mut Texture;
        pub fn destroy_texture(tex: *mut Texture);
        pub fn draw_texture(tex: *mut Texture, x: i32, y: i32);
    }
}

pub struct Texture {
    handle: *mut ffi::Texture,
}

impl Texture {
    pub fn new(width: i32, height: i32) -> Option<Self> {
        let handle = unsafe { ffi::create_texture(width, height) };
        if handle.is_null() {
            None
        } else {
            Some(Self { handle })
        }
    }

    pub fn draw(&self, x: i32, y: i32) {
        unsafe { ffi::draw_texture(self.handle, x, y); }
    }
}

impl Drop for Texture {
    fn drop(&mut self) {
        if !self.handle.is_null() {
            unsafe { ffi::destroy_texture(self.handle); }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern provides a safe interface. The Texture struct ensures that the underlying C resource is properly managed, and the Drop implementation guarantees cleanup. The unsafe code is confined to the small FFI module.

When passing complex data structures, it’s important to consider representation. The #[repr(C)] attribute ensures that a struct’s layout is compatible with C. This is crucial when you’re passing structs by value or when the C code expects a specific memory layout.

Callbacks are another common challenge. C libraries often allow you to register callback functions. Rust can provide these, but it requires matching the expected function signature exactly.

type Callback = extern "C" fn(event_type: i32, data: *mut libc::c_void);

extern "C" {
    fn set_callback(cb: Callback);
}

extern "C" fn my_callback(event_type: i32, data: *mut libc::c_void) {
    // Handle the event...
}

// Register the callback
unsafe {
    set_callback(my_callback);
}
Enter fullscreen mode Exit fullscreen mode

Note that the callback must be defined with extern "C" to use the correct calling convention. Also, be cautious about what you do in the callback—it’s called from C code, so Rust’s usual safety guarantees don’t apply here.

FFI isn’t limited to C. With some effort, you can interface with other languages like Python, Ruby, or JavaScript. However, C remains the most common and well-supported due to its role as a lingua franca for system programming.

Documentation is vital when working with FFI. Since the compiler can’t check the foreign side of the interface, clear comments and documentation become your first line of defense. Describe what each function expects, what it returns, and what safety assumptions are made.

Testing is equally important. It’s a good idea to write tests that verify the behavior of both sides of the interface. This can help catch issues like incorrect type mappings or memory management errors.

In my experience, the effort to build a safe FFI layer pays off. It allows you to reuse existing, well-tested C code while gradually migrating to Rust where it makes sense. This hybrid approach can reduce risk and increase productivity.

Rust’s focus on safety doesn’t mean it avoids the messy reality of existing systems. Instead, it provides the tools to interact with that reality as safely as possible. The FFI system is a testament to that pragmatic design.

Whether you’re wrapping a legacy library or extending a Rust application with C components, FFI makes it possible to do so with confidence. It requires care and attention, but the result is a robust combination of Rust’s modern features and the stability of established C codebases.

The examples and patterns I’ve shared come from real-world use. They represent practices that have worked well in production environments. Your mileage may vary, but these approaches provide a solid starting point for your own cross-language projects.

Remember, the goal isn’t to avoid unsafe code entirely—that’s often impossible when crossing language boundaries. The goal is to isolate and manage unsafety, so the majority of your code can benefit from Rust’s guarantees.

Start small, test thoroughly, and build up your FFI interface incrementally. With patience and attention to detail, you can build bridges that are both safe and efficient.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)