FFI & C Interop

Reviewed & published by Brayan K

Rust's Foreign Function Interface lets you reuse decades of battle-tested C libraries and expose fast Rust code to other languages — all through the shared C ABI.

Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll call C from Rust, export Rust functions to C, convert strings across the boundary, and share C-compatible structs — learning where unsafe is required and why.

What You'll Learn in This Lesson

1️⃣ Calling C from Rust

An extern "C" block declares foreign functions using the C calling convention. Their bodies live in a C library you link against. Because Rust can't verify foreign code, every call must sit inside an unsafe block.

// Calling C FROM Rust. extern "C" declares functions with the C ABI.
// The function bodies live in a C library you link against (here, libc's abs).
use std::os::raw::c_int;

extern "C" {
    // Signature must match the C function exactly.
    fn abs(input: c_int) -> c_int;
}

fn main() {
    // Foreign calls are unsafe: Rust cannot verify what C does.
    let value = unsafe { abs(-42) };
    println!("abs(-42) = {value}");
}

The declared signature must match the C function exactly — a mismatch is undefined behaviour, not a compile error, which is part of why this is unsafe.

2️⃣ Calling Rust from C

To make a Rust function callable from C, give it the C ABI with extern "C" and keep its symbol name with #[no_mangle] so the linker can find it under the expected name.

// Calling Rust FROM C. Export a function with the C ABI and a stable name.
// #[no_mangle] keeps the symbol name; extern "C" sets the calling convention.
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

// In C you would declare and call it like this:
//
//   // header
//   int add(int a, int b);
//
//   // usage
//   int sum = add(2, 3);   // -> 5
//
// Build as a library (crate-type = ["cdylib"] or ["staticlib"]) and link it.

Without #[no_mangle], Rust mangles the symbol and C could never link to add by name.

3️⃣ Strings and C-Compatible Structs

Rust strings are UTF-8 and not NUL-terminated; C strings are. Convert with CString (owned, to send) and CStr (borrowed, to receive). Whoever allocates a buffer must free it.

// Strings need conversion: Rust strings are UTF-8 and NOT NUL-terminated,
// while C strings are NUL-terminated byte buffers.
use std::ffi::{CStr, CString};
use std::os::raw::c_char;

// Take a C string (borrowed), return an owned C string the caller must free.
#[no_mangle]
pub extern "C" fn shout(input: *const c_char) -> *mut c_char {
    // Borrow the incoming C string safely as a CStr.
    let c_str = unsafe { CStr::from_ptr(input) };
    let text = c_str.to_str().unwrap_or("");

    // Build an owned, NUL-terminated CString to hand back to C.
    let result = CString::new(text.to_uppercase()).unwrap();
    result.into_raw() // transfers ownership; caller frees via free_cstr below
}

// C must give the pointer back so Rust can free it (Rust allocated it).
#[no_mangle]
pub extern "C" fn free_cstr(ptr: *mut c_char) {
    if ptr.is_null() { return; }
    unsafe { drop(CString::from_raw(ptr)); }
}

For structs, add #[repr(C)] so Rust lays out the fields exactly as C expects. The libc crate supplies the C types, and bindgen can generate these declarations from a header.

// Sharing structs across the boundary requires a C-compatible layout.
// #[repr(C)] makes Rust use C's field order and layout.
#[repr(C)]
pub struct Point {
    x: f64,
    y: f64,
}

#[no_mangle]
pub extern "C" fn distance(p: Point) -> f64 {
    (p.x * p.x + p.y * p.y).sqrt()
}

// C declaration:
//   typedef struct { double x; double y; } Point;
//   double distance(Point p);
//
// The 'libc' crate provides ready-made C types (c_int, size_t, ...), and the
// 'bindgen' tool can auto-generate these extern blocks from a C header.

Mini-Challenge: Export square to C

Write a C-callable function and the C declaration that would invoke it.

// 🎯 MINI-CHALLENGE: export a C-callable square function.
//
// 1. Write a function  square  that takes an i32 and returns its square.
// 2. Make it callable from C: add #[no_mangle] and  pub extern "C".
// 3. Sketch the C declaration in a comment:  int square(int n);
//
// Expected from C:  square(6) -> 36

// your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — FFI

GoalRust Syntax
Declare C fnextern "C" { fn abs(i: c_int) -> c_int; }
Export to C#[no_mangle] pub extern "C" fn f()
Foreign callunsafe { abs(-1) }
Owned C stringCString::new(s)
Borrow C stringCStr::from_ptr(ptr)
C struct layout#[repr(C)] struct P { .. }

🎉 Lesson Complete — and You've Finished the Rust Course!

Practice quiz

What does FFI stand for?

  • Foreign Function Interface
  • Fast Function Inlining
  • File Format Interop
  • Free Form Input

Answer: Foreign Function Interface. FFI, the Foreign Function Interface, lets Rust call and be called by other languages, usually C.

What does an extern "C" block declare?

  • Inline assembly
  • A new Rust module
  • Functions using the C calling convention / ABI
  • A C compiler

Answer: Functions using the C calling convention / ABI. extern "C" tells Rust to use the C ABI for the listed functions.

Why is #[no_mangle] used on a Rust function exported to C?

  • To mark it unsafe
  • To keep its symbol name unchanged so C can link to it
  • To make it faster
  • To inline it

Answer: To keep its symbol name unchanged so C can link to it. #[no_mangle] disables name mangling so the symbol matches what C expects.

Why must calls to foreign C functions be inside unsafe?

  • unsafe makes it faster
  • It is only a style choice
  • C is slow
  • The compiler cannot verify the foreign code upholds Rust's guarantees

Answer: The compiler cannot verify the foreign code upholds Rust's guarantees. Rust cannot check the safety of foreign code, so you take responsibility with unsafe.

Which type represents an owned, NUL-terminated C string in Rust?

  • CString
  • String
  • OsString
  • Vec<char>

Answer: CString. std::ffi::CString owns a NUL-terminated byte buffer suitable for C.

Which type borrows a NUL-terminated C string (a *const c_char)?

  • str
  • CStr
  • CString
  • String

Answer: CStr. std::ffi::CStr is the borrowed view over an existing C string.

What does #[repr(C)] on a struct guarantee?

  • It cannot be mutated
  • It is copied
  • It is faster
  • C-compatible field layout and ordering

Answer: C-compatible field layout and ordering. #[repr(C)] gives the struct the same memory layout C uses, so it can cross the boundary.

What is the libc crate typically used for?

  • Garbage collection
  • Async runtimes
  • Bindings to standard C library types and functions
  • Web servers

Answer: Bindings to standard C library types and functions. libc provides Rust bindings to platform C library types and functions like c_int and malloc.

What does the bindgen tool do?

  • Compiles Rust to C
  • Generates Rust FFI bindings from C headers
  • Runs benchmarks
  • Formats code

Answer: Generates Rust FFI bindings from C headers. bindgen reads C header files and generates the corresponding Rust extern declarations.

When passing a Rust pointer across FFI, which is true?

  • Raw pointers carry no lifetime or aliasing guarantees, so you uphold them manually
  • Rust frees it automatically in C
  • The borrow checker tracks it across the boundary
  • Lifetimes are enforced in C

Answer: Raw pointers carry no lifetime or aliasing guarantees, so you uphold them manually. Raw pointers drop Rust's safety guarantees; correct use across FFI is the programmer's responsibility.

Continue this course

Frequently asked questions

What is FFI and when do I need it?

FFI stands for Foreign Function Interface: the mechanism that lets Rust call functions written in another language (almost always C) and lets other languages call into Rust. You reach for it when you want to reuse a mature C library instead of rewriting it, expose a fast Rust component to a program written in C, Python, or another language, or talk directly to operating-system APIs. The common ground is the C ABI, so even C++ or Python interop usually goes through a C-compatible interface. Rust's FFI is designed to be low-overhead, but it sits outside the compiler's safety checks, which is why it leans on the unsafe keyword.

Why do FFI calls require unsafe?

The borrow checker and the rest of Rust's safety guarantees only apply to Rust code the compiler can see. When you call a foreign C function, the compiler has no idea whether that function dereferences a null pointer, writes out of bounds, or violates aliasing rules, so it cannot prove the call is sound. Marking the call unsafe is you telling the compiler 'I have verified that this respects the rules.' The same applies to dereferencing the raw pointers that cross the boundary. A good practice is to wrap the unsafe foreign calls in a safe Rust API that validates inputs, so the rest of your program stays in safe code.

How do I pass strings between Rust and C?

Rust String and str values are UTF-8 and are not NUL-terminated, while C strings are NUL-terminated byte buffers, so you must convert. To send a string to C, build a std::ffi::CString, which owns a NUL-terminated buffer, and pass its pointer. To read a C string handed to you, wrap the incoming pointer with std::ffi::CStr using CStr::from_ptr inside unsafe, then call to_str. Ownership matters: whichever side allocated a buffer should free it, which is why FFI APIs that return Rust-allocated strings also expose a function for C to give the pointer back to be freed.

What do #[repr(C)], libc, and bindgen do?

Rust does not guarantee struct field order or layout by default, so to share a struct with C you add #[repr(C)], which forces the same layout and ordering C uses. The libc crate provides Rust definitions of standard C types (like c_int and size_t) and functions, saving you from declaring them by hand. The bindgen tool goes further: you point it at a C header file and it auto-generates the matching Rust extern blocks, type aliases, and struct definitions, which is invaluable when wrapping a large existing C library rather than writing every declaration manually.