How to Debug FFI Issues in Rust

Fix Rust FFI errors by matching extern signatures, verifying symbol names, and wrapping calls in unsafe blocks.

When the bridge breaks

You spent three hours linking a C library. The build succeeds. You run the binary. Segfault. Or worse, the program crashes with a "symbol not found" error that looks like a typo, except you checked the spelling five times. FFI is where Rust hands the wheel to C, and C doesn't care if you drive off a cliff. Debugging these issues means bridging two worlds that speak different languages and follow different rules.

FFI stands for Foreign Function Interface. It's a bridge. On one side, Rust has types, lifetimes, and a borrow checker. On the other side, C has raw pointers, manual memory management, and no promises. When you call C from Rust, you're making a promise to the compiler that you know what you're doing. The unsafe block is your signature on that promise. If the promise is broken, the compiler won't save you. The crash happens at runtime, often in ways that are hard to trace back to the Rust code.

The bridge relies on the ABI (Application Binary Interface). This is the low-level contract about how arguments are passed, how return values come back, and how the stack looks. If Rust packs arguments one way and C expects them another, you get garbage. Symbol mangling is another trap. C++ mangles names to encode types; C usually doesn't. Rust needs to know exactly what name to look for in the compiled library.

The contract you sign

Every FFI call starts with an extern block. This block declares functions that exist outside Rust. The compiler trusts you completely. It does not check if the function exists. It does not check if the signature matches the C header. It generates a placeholder and moves on.

The linker tries to fill that placeholder later. If the linker can't find the symbol, you get a link error. If it finds it, the binary runs. At runtime, the CPU jumps to the C function. If the signature is wrong, the CPU reads the wrong registers or stack slots. You get a wrong answer or a segfault.

#[link(name = "my_c_lib")]
extern "C" {
    /// Calls the C function `calculate` from the linked library.
    /// Signature must match the C declaration exactly.
    fn calculate(x: i32, y: i32) -> i32;
}

fn main() {
    unsafe {
        // SAFETY:
        // 1. The C library exports `calculate` with signature `int calculate(int, int)`.
        // 2. The arguments are valid i32 values.
        // 3. The C function does not return a dangling pointer.
        let result = calculate(10, 20);
        println!("Result: {}", result);
    }
}

The extern "C" string tells Rust to use the C ABI. This is critical. If you omit "C", Rust uses the Rust ABI, which is different. The C library will misinterpret the arguments. Always use "C" for C libraries.

Convention aside: Use std::os::raw types when mapping C types. c_int, c_char, c_void. These types match the C ABI exactly on every platform. Using i32 for int works on most modern systems, but int can be 16 bits on some embedded targets. c_int adapts automatically.

Strings and the null terminator

Real code rarely passes just integers. You pass strings, structs, or pointers. Strings are the first boss fight. C uses null-terminated byte arrays. Rust uses &str or String. They are incompatible. You must convert.

Passing a Rust string directly to C is undefined behavior. The C function will read past the end of the string until it finds a null byte, which might be pages away. This causes a crash or a security vulnerability.

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

#[link(name = "my_c_lib")]
extern "C" {
    /// C function that greets a name.
    /// void greet(const char *name);
    fn greet(name: *const c_char);
}

fn safe_greet(name: &str) {
    // Convert Rust string to C-compatible null-terminated string.
    // This allocates on the heap and adds a null byte.
    // Panics if the string contains a null byte.
    let c_name = CString::new(name).expect("String contained null byte");
    
    unsafe {
        // SAFETY:
        // 1. `c_name.as_ptr()` is valid for the duration of the call.
        // 2. The C function does not store the pointer; it only reads it.
        // 3. The C function respects the null terminator.
        greet(c_name.as_ptr());
    }
    // `c_name` is dropped here, freeing the C memory.
}

CString::new checks for null bytes. If your Rust string contains a null byte, CString::new returns an error. This is a safety check. C strings cannot contain null bytes in the middle. If you have binary data with nulls, use CStr::from_bytes_with_nul or a different approach.

The pointer returned by as_ptr() is only valid while c_name is alive. If the C function stores the pointer and uses it later, you have a use-after-free. The memory is freed when c_name goes out of scope. The C function must copy the data if it needs to keep it.

Convention aside: The community convention is to keep unsafe blocks as small as possible. Call the C function inside the block. Do not do other work inside the block. This makes it easier to verify the safety invariants.

Realistic debugging workflow

When the crash happens, where do you look? Start with the symbol table. Use nm or objdump to verify the C library exports the expected symbols.

Run nm -D your_lib.so | grep function_name. If you see T function_name, the symbol is exported. T means it's in the text section and is defined. If you see U function_name, it's undefined, meaning the library expects another library to provide it. If you see nothing, the symbol is missing or mangled.

If the symbol is missing, check the C code. Did you forget __attribute__((visibility("default")))? Some compilers hide symbols by default. Or check if the name is mangled. C++ mangles names. If you're linking a C++ library, wrap the declarations in extern "C" in the C++ header, or use c++filt to demangle the name.

If the symbol exists but the program crashes, enable sanitizers. AddressSanitizer catches memory errors like out-of-bounds access and use-after-free.

Set RUSTFLAGS="-Z sanitizer=address" and run cargo run. You need nightly Rust for this. If the crash is in C code, ASan still catches it. It wraps allocations and detects errors. The output gives you a stack trace showing exactly where the error occurred.

For release builds, debug symbols are stripped by default. Add debug = true to [profile.release] in Cargo.toml to keep symbols. Or use RUSTFLAGS="-g" cargo build --release. Without debug symbols, stack traces are useless.

Convention aside: Always set RUST_BACKTRACE=1 in your environment. Without it, Rust panics silently or with minimal info. With it, you get a full stack trace. This helps you see where the Rust code called the C function.

If you need to step through code, use gdb or lldb. Rust code is debuggable in these tools. You can set breakpoints in Rust and C functions. The variable names are preserved. Use rust-lldb for better Rust support.

Pitfalls and compiler errors

FFI pitfalls fall into three categories: signature mismatches, lifetime issues, and ABI violations.

Signature mismatches happen when the Rust declaration doesn't match the C definition. If you pass a String where *const c_char is expected, the compiler rejects you with E0308 (mismatched types). The compiler saves you here. The danger is when the types match Rust but not C. For example, passing i32 when C expects int, and on this platform int is 16 bits. Rust assumes int is i32 on most platforms, but not all. Use c_int to be safe.

Lifetime issues happen when pointers outlive the data they point to. If you pass a pointer to a local variable, and the C function stores it, you have a dangling pointer. The compiler cannot check this across FFI boundaries. You must ensure the data lives long enough. Use CString or allocate memory that the C code owns.

ABI violations happen when the calling convention is wrong. extern "C" uses the C ABI. If the C library uses a different convention, like stdcall on Windows, you must specify it. Use extern "stdcall" for Windows DLLs. If you use the wrong convention, the stack gets corrupted. The crash might happen later, making it hard to trace.

Convention aside: Treat the SAFETY comment as a proof. If you can't write the invariants, you don't have a safe wrapper. The comment forces you to think about what could go wrong.

Tools and decisions

Choosing the right tool depends on the size of the interface and the build complexity.

Use bindgen when the C header is large or complex. It generates Rust bindings automatically, reducing manual errors. Run bindgen header.h to generate the extern blocks. This is the standard approach for wrapping large C libraries.

Use manual extern blocks when the interface is tiny and stable. You have full control and no dependency on code generation. This is better for small utilities or when you need custom logic around the calls.

Use the cc crate when you need to compile C code as part of the Rust build. It handles compiler flags and platform differences. This is useful when you bundle C source files with your Rust crate.

Use pkg-config when the library is installed on the system and you need to find headers and flags dynamically. The pkg-config crate helps you query the system for the correct linker flags.

Use std::os::raw types when mapping C types. c_int, c_char, c_void. They match the C ABI exactly. This prevents subtle bugs on exotic platforms.

Use AddressSanitizer when debugging memory issues. It catches errors that are hard to reproduce. Enable it with RUSTFLAGS="-Z sanitizer=address".

Use nm when you suspect symbol issues. Check the symbol table before you check your code. It saves time.

Where to go next