Skip to content
Home
Rust FFI: Calling C and Exposing Rust Libraries

Rust FFI: Calling C and Exposing Rust Libraries

Rust Rust 8 min read 1601 words Beginner ExcellentWiki Editorial Team

Understanding Rust FFI

Foreign Function Interface (FFI) enables Rust code to call functions written in other languages — primarily C — and to expose Rust functions for consumption by non-Rust programs. FFI is essential for leveraging existing C and C++ libraries, accessing operating system APIs, and embedding Rust in larger multi-language projects. Rust’s FFI model uses the C Application Binary Interface (ABI), the lingua franca of cross-language interoperability. Because the Rust compiler cannot enforce memory safety across language boundaries, all FFI calls are inherently unsafe. The recommended practice is to wrap raw FFI calls in safe Rust abstractions that enforce invariants at the API boundary, isolating unsafety to a minimal surface area. The Rustonomicon devotes extensive coverage to FFI patterns and safety requirements (The Rustonomicon, “Foreign Function Interface,” doc.rust-lang.org/nomicon/ffi.html). Well-designed FFI boundaries are critical for maintaining Rust’s safety guarantees while interoperating with the broader software ecosystem. Many of Rust’s most successful libraries — including libsqlite3-sys, openssl-sys, and cuda-sys — follow this pattern of minimal unsafe wrappers with safe Rust APIs.

The C ABI and Calling Conventions

The C ABI specifies how functions pass arguments and return values at the machine level. On x86-64 Linux, the System V ABI is standard: integer and pointer arguments go in registers RDI, RSI, RDX, RCX, R8, R9, then the stack; floating-point arguments use XMM0-XMM7. Rust’s extern "C" block tells the compiler to use this calling convention. Other ABIs exist: extern "stdcall" (Windows API), extern "fastcall", and platform-specific variants. For portable FFI, always use extern "C". The #[repr(C)] attribute on structs ensures Rust layouts match C struct layouts with predictable field ordering and alignment. The #[repr(align(N))] and #[repr(packed)] attributes provide additional control over memory layout for hardware or protocol-compatible structures. Understanding alignment requirements is essential when interfacing with network protocols, file formats, or hardware registers. Mismatched struct layout is one of the most common FFI bugs and can lead to silent memory corruption. On Windows, the calling convention differs from Linux, requiring conditional compilation with #[cfg(windows)] to select the correct ABI.

Calling C from Rust

The most common FFI pattern is calling into existing C libraries. The extern "C" block declares foreign functions, and the #[link] attribute tells the linker which native library to include. Multiple function declarations can be grouped within a single extern block:

#[link(name = "ssl")]
extern "C" {
    fn SSL_new(ctx: *mut SSL_CTX) -> *mut SSL;
    fn SSL_free(ssl: *mut SSL);
    fn SSL_connect(ssl: *mut SSL) -> i32;
    fn SSL_get_error(ssl: *mut SSL, ret: i32) -> c_int;
---

Linking can be configured in build.rs for more complex scenarios:

fn main() {
    println!("cargo:rustc-link-lib=pq");
    println!("cargo:rustc-link-search=/usr/local/lib");
    println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/local/lib");
---

The pkg-config crate can automate discovery of library paths and link flags for system-installed libraries, handling cross-platform differences automatically. For static linking, use cargo:rustc-link-lib=static=pq instead. The cc crate can compile C source files as part of the build process, useful when you need small C shim layers. For complex build configurations, build.rs can detect the target OS and architecture to select appropriate link flags.

String Conversion Between Rust and C

String handling across FFI requires careful memory management. Rust strings are UTF-8, length-prefixed, and may contain null bytes. C strings are null-terminated byte sequences. The std::ffi module provides conversion types:

use std::ffi::{CStr, CString};

fn safe_ffi_string(input: &str) -> Result<String, &'static str> {
    let c_str = CString::new(input).map_err(|_| "null byte in string")?;
    let result_ptr = unsafe { process_string(c_str.as_ptr()) };
    if result_ptr.is_null() {
        return Err("null pointer returned");
    }
    let result = unsafe { CStr::from_ptr(result_ptr) };
    Ok(result.to_str().map_err(|_| "invalid UTF-8")?.to_owned())
---

CString::new returns an error if the input contains internal null bytes. CStr::from_ptr converts a C pointer to a Rust string reference, but the safety depends on the pointer being valid and the memory being null-terminated. For string arrays (argv-style), use patterns with a Vec<CString> and a terminating null pointer. The std::ffi::OsStr and OsString types handle platform-specific string encodings like Windows UTF-16. Memory ownership is critical: if C allocated the string, C must free it, and Rust must not call CString::from_raw on it.

Exposing Rust to C

Making Rust functions callable from C requires three elements: extern "C" to use the C calling convention, #[no_mangle] to prevent Rust name mangling, and #[repr(C)] on any structs crossing the boundary. Error handling must be adapted: Rust panics must not cross FFI boundaries:

#[no_mangle]
pub extern "C" fn create_buffer(size: usize) -> *mut u8 {
    let mut buf = Vec::with_capacity(size);
    let ptr = buf.as_mut_ptr();
    std::mem::forget(buf);
    ptr
---

#[no_mangle]
pub extern "C" fn free_buffer(ptr: *mut u8, size: usize) {
    if !ptr.is_null() {
        unsafe { let _ = Vec::from_raw_parts(ptr, 0, size); }
    }
---

Generating C Headers with cbindgen

cbindgen generates C headers automatically from Rust source, ensuring that the types and function signatures stay synchronized. It supports C++ header generation, Rust enum handling, and opaque type declarations. The generated headers should be checked into version control and distributed alongside the compiled library.

Using bindgen for Automatic Bindings

bindgen parses C headers and generates corresponding Rust extern "C" blocks automatically. It handles complex macros, type definitions, and inline functions. The --allowlist-* flags limit which functions and types generate bindings, keeping the generated code manageable. For production use, run bindgen as part of build.rs to regenerate bindings when C headers change.

Safety Patterns and Encapsulation

All FFI calls are unsafe. The standard pattern is to create a safe wrapper module that encapsulates FFI operations, providing a clean Rust API while containing unsafety. The ownership protocol must be clearly documented: if C allocates memory, C should free it. For passing Rust closures to C code, use extern "C" fn for captureless closures and a void* context pointer for closures with captured state. The std::panic::catch_unwind can prevent Rust panics from unwinding across FFI boundaries.

Cross-Platform FFI Considerations

Writing portable FFI code requires handling platform differences in calling conventions, type sizes, and library naming. On Windows, functions use the stdcall calling convention by default, and dynamic libraries are .dll files. On Linux, the convention is extern "C" and libraries are .so files. macOS uses .dylib or .dylib (same format as Linux .so). Type sizes vary: c_long is 32 bits on Windows and 64-bit Linux, but 64 bits on macOS. The libc crate provides platform-appropriate type aliases. Library naming differs: #[link(name = "ssl")] links libssl.so on Linux, libssl.dylib on macOS, and ssl.dll on Windows. Conditional compilation with #[cfg(target_os = "linux")] and #[cfg(windows)] handles platform-specific FFI declarations. For complex multi-platform projects, the build.rs script should detect the target triple and configure library paths accordingly. Cross-compilation adds another layer: FFI bindings must match the target architecture’s ABI, not the host’s. The cc crate’s target parameter helps manage cross-compilation of C sources.

Advanced FFI: Callbacks and Closures

Passing Rust closures to C code requires careful handling of the calling convention and lifetime management. C callbacks are plain function pointers (extern "C" fn()), which cannot capture state. To pass stateful closures, use the trampoline pattern: store the closure in a Box<dyn FnMut(...)>, pass a raw pointer to the box as context, and provide a thin C-compatible function that reconstructs the reference and calls the closure. The std::panic::catch_unwind wrapper prevents Rust panics from unwinding through C stack frames, which would be undefined behavior. For Rust libraries that expose C APIs for callbacks, the callback registration function should accept both a function pointer and a void* user_data parameter, following the POSIX callback convention used by libraries like pthread_create and qsort. The cabi_realloc and cabi_free functions from the alloc crate handle memory management for dynamically allocated return values. When implementing async callbacks from C, use a channel to bridge the C callback thread to the Rust async runtime, avoiding lifetime issues with captured async state.

Cross-Compilation for FFI Libraries

Cross-compiling Rust libraries that expose C APIs requires configuring both the Rust target and the C toolchain. The cc crate build script detects the cross-compilation target from the TARGET environment variable set by Cargo. For the C half of the FFI boundary, set CC_target and AR_target environment variables pointing to the cross-compiler. Rust’s raw-dylib linkage (stabilized in Rust 1.68) enables linking against DLLs without import libraries on Windows, using .def files or #[link(kind = "raw-dylib")]. For Linux cross-compilation targeting aarch64-unknown-linux-gnu, install the gcc-aarch64-linux-gnu package and configure the build with CC_aarch64_linux_gnu. The cross tool automates cross-compilation using Docker containers for each target.

The resulting library must handle endianness correctly — avoid bitfields in C structs since their layout varies across architectures. String encoding (UTF-8 vs. wide chars on Windows) and pointer size differences (32-bit vs 64-bit) also require platform-conditional code. The cfg!(target_pointer_width = "64") macro or #[cfg(target_pointer_width = "64")] attributes handle these differences at compile time. For library distribution, build on CI against all supported targets and include the resulting .so, .dylib, or .dll files in releases alongside the C header generated by cbindgen.

Frequently Asked Questions

Is all FFI code unsafe? Yes — all FFI calls are inherently unsafe because the Rust compiler cannot verify memory safety across language boundaries. Wrap FFI calls in safe abstractions.

How do I prevent memory leaks across FFI? Follow the ownership protocol: if C allocates memory, C should free it. If Rust allocates memory, Rust should free it.

What is the difference between bindgen and cbindgen? bindgen generates Rust FFI bindings from C headers. cbindgen generates C headers from Rust code.

How do I pass Rust closures to C code? Use extern "C" fn for captureless closures and a void* context pointer for closures with captured state.

Can I use Rust FFI with C++ libraries? Yes, but C++ has no stable ABI. Use extern "C" wrappers around C++ functions or use the cpp crate for inline C++.

Related: Rust Unsafe Code | Rust Serialization with Serde | Rust Macros Guide

Section: Rust 1601 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top