Rust FFI: Calling C from Rust
FFI stands for Foreign Function Interface. It is how Rust talks to code written in another language. Almost always, that other language is C — or something that speaks C’s rules, because C is the “common language” that nearly every language and operating system understands.
Think of it like two people who don’t share a native language, but both speak a little English. C is that shared language. With FFI you can use a mature C library from Rust, or write a fast Rust library that Python, Node.js or C programs can call. This page builds on Unsafe Rust, so read that first if unsafe blocks are new to you.
Calling a C function
Section titled “Calling a C function”The C standard library is always linked into Rust programs on Linux, macOS and Windows, so its functions are a perfect first test. To call one, you declare it inside an extern "C" block: its name, its parameters and its return type. Then you call it inside unsafe:
use std::ffi::c_int;
unsafe extern "C" { // This is C's `int abs(int x);` written in Rust. fn abs(x: c_int) -> c_int;}
fn main() { let n = unsafe { abs(-42) }; println!("abs(-42) = {n}");}Output:
abs(-42) = 42A few things to notice:
"C"names the ABI — the rules for how arguments are passed and values are returned. C’s rules are the ones everyone agrees on.c_intis Rust’s name for C’sint. Thestd::ffimodule has types likec_int,c_longandc_char, so your declaration matches C exactly on every platform.- You write
unsafe extern "C". In edition 2024 theunsafehere is required, because you are promising the declaration is correct. Older code (and edition 2021) often writes plainextern "C" { ... }. Theunsafeversion works in both, so this page uses it everywhere.
Why every call is unsafe — and how to hide it
Section titled “Why every call is unsafe — and how to hide it”The Rust compiler can’t read C code. It has no idea if abs really takes an int, if it might crash, or if it keeps a pointer you gave it. So every call to a foreign function is unsafe: you are taking responsibility for using it correctly.
The good news: you only have to think hard about it once. The usual pattern is to write a small safe wrapper — a normal Rust function that checks the rules and then makes the unsafe call. Everyone else just calls the wrapper.
Here is a real example. In C, abs(INT_MIN) is undefined behavior, because the answer is too big to fit in an int. Our wrapper checks for that case first:
use std::ffi::c_int;
unsafe extern "C" { fn abs(x: c_int) -> c_int;}
// A safe wrapper: callers don't need `unsafe`.fn safe_abs(x: i32) -> Option<i32> { // abs(i32::MIN) has no valid answer in C, so we refuse it. if x == i32::MIN { return None; } // SAFETY: x is not i32::MIN, so abs has a valid result. Some(unsafe { abs(x) })}
fn main() { println!("{:?}", safe_abs(-42)); println!("{:?}", safe_abs(i32::MIN));}Output:
Some(42)NoneThe // SAFETY: comment is a Rust habit worth copying. It explains why this particular unsafe block is fine, so the next reader doesn’t have to guess.
If a C function really has no rules to follow for any input, you can mark it safe fn inside the unsafe extern block, and then call it without unsafe. abs is not such a function (remember INT_MIN), so we keep it unsafe and check the input ourselves.
Strings: CString, CStr and the NUL byte
Section titled “Strings: CString, CStr and the NUL byte”Strings are where most FFI bugs live, because Rust and C store them differently:
- A Rust
String/&strknows its length and can contain any byte, including0. - A C string is just a pointer to bytes. The string ends at the first
0byte (called NUL). There is no stored length.
So you can’t pass a &str to C directly. Use CString to build an owned, NUL-terminated copy, and .as_ptr() to get the *const c_char that C wants:
use std::ffi::{c_char, CString};
unsafe extern "C" { fn strlen(s: *const c_char) -> usize;}
fn c_strlen(text: &str) -> Option<usize> { // CString::new adds the NUL at the end for us. let c_text = CString::new(text).ok()?; // SAFETY: c_text is a valid NUL-terminated string and lives until the end of this function. Some(unsafe { strlen(c_text.as_ptr()) })}
fn main() { let hello = CString::new("hello").unwrap(); println!("{:?}", hello.as_bytes_with_nul());
println!("{:?}", c_strlen("hello")); println!("{:?}", c_strlen("héllo"));
match CString::new("hel\0lo") { Ok(_) => println!("ok"), Err(e) => println!("error: {e}"), }}Output:
[104, 101, 108, 108, 111, 0]Some(5)Some(6)error: nul byte found in provided data at position: 3Two surprises here, and both are important:
strlen("héllo")is 6, not 5. C counts bytes, andétakes two bytes in UTF-8.CString::newfails if your text contains a0byte. C would think the string ends there and silently cut off"lo". Rust refuses instead of letting that happen — that’s whyCString::newreturns aResult.
Going the other way, when C hands you a char *, wrap it in CStr. CStr is to CString what &str is to String: a borrowed view. C’s strchr returns a pointer to the first matching character, which makes a nice demo:
use std::ffi::{c_char, c_int, CStr, CString};
unsafe extern "C" { fn strchr(s: *const c_char, c: c_int) -> *mut c_char;}
fn find_from(text: &CStr, ch: u8) -> Option<String> { let ptr = unsafe { strchr(text.as_ptr(), ch as c_int) }; if ptr.is_null() { return None; // C says "not found" with a null pointer } // SAFETY: ptr points inside `text`, which is alive and NUL-terminated. let found = unsafe { CStr::from_ptr(ptr) }; Some(found.to_string_lossy().into_owned())}
fn main() { let text = CString::new("hello, world").unwrap(); println!("{:?}", find_from(&text, b',')); println!("{:?}", find_from(&text, b'z'));
// A c"..." literal is a &CStr with the NUL already added. println!("{:?}", find_from(c"rust-lang.org", b'.'));}Output:
Some(", world")NoneSome(".org")to_string_lossy() turns the C bytes into Rust text. It replaces any invalid UTF-8 with �, because C strings don’t promise to be UTF-8. Use .to_str() instead if you’d rather get an error.
Sharing structs with #[repr(C)]
Section titled “Sharing structs with #[repr(C)]”Rust is allowed to reorder struct fields to save space. C never does — fields sit in memory in the order you wrote them, with padding in between. For example, a struct with fields u8, u32, u8 takes 8 bytes with Rust’s layout today (Rust moves the two u8s together), but 12 bytes with C’s layout. If C and Rust disagree about the layout, C reads the wrong bytes — and nothing warns you.
So when a struct crosses the border, tell Rust to use C’s layout with #[repr(C)]. C’s div function returns a div_t struct with two int fields, quot and rem:
use std::ffi::c_int;
// Matches C's: typedef struct { int quot; int rem; } div_t;#[repr(C)]#[derive(Debug)]struct DivT { quot: c_int, rem: c_int,}
unsafe extern "C" { fn div(numerator: c_int, denominator: c_int) -> DivT;}
fn main() { let result = unsafe { div(17, 5) }; println!("{result:?}"); println!("17 / 5 = {} remainder {}", result.quot, result.rem);}Output:
DivT { quot: 3, rem: 2 }17 / 5 = 3 remainder 2Letting C call Rust
Section titled “Letting C call Rust”FFI works in both directions. Write extern "C" fn (without a block) and you get a Rust function that follows C’s calling rules, so C code can call it. C’s qsort takes a comparison function as an argument — we can hand it a Rust one:
use std::ffi::{c_int, c_void};
unsafe extern "C" { fn qsort( base: *mut c_void, count: usize, size: usize, compare: extern "C" fn(*const c_void, *const c_void) -> c_int, );}
// A Rust function that C will call.extern "C" fn compare_i32(a: *const c_void, b: *const c_void) -> c_int { // SAFETY: qsort only gives us pointers into our i32 array. let (a, b) = unsafe { (*(a as *const i32), *(b as *const i32)) }; a.cmp(&b) as c_int // Less = -1, Equal = 0, Greater = 1}
fn sort_with_c(numbers: &mut [i32]) { unsafe { qsort( numbers.as_mut_ptr() as *mut c_void, numbers.len(), std::mem::size_of::<i32>(), compare_i32, ); }}
fn main() { let mut numbers = [42, 7, 19, -3, 0]; sort_with_c(&mut numbers); println!("{:?}", numbers);}Output:
[-3, 0, 7, 19, 42](In real code you’d just call sort(). This is only to show C calling back into Rust.)
When a C program needs to find your function by name, add #[no_mangle]. Normally Rust “mangles” names into something like _ZN4main11add_numbers17h...E so that different crates never clash. #[no_mangle] keeps the plain name add_numbers:
#[no_mangle]pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 { a + b}Edition 2024 note: this site’s Playground examples use edition 2021, where #[no_mangle] is fine. In edition 2024 (the default for a new cargo new project), it must be written #[unsafe(no_mangle)], because two functions with the same unmangled name would break the program. The new spelling also works in edition 2021.
To build a library that C can link to, set the crate type to cdylib in Cargo.toml:
[lib]crate-type = ["cdylib"]// src/lib.rs (edition 2024)#[unsafe(no_mangle)]pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 { a + b}cargo build --release produces target/release/libmathlib.so (.dylib on macOS, .dll on Windows) for a crate named mathlib. A C program then declares the function and links against the library:
#include <stdio.h>#include <stdint.h>
int32_t add_numbers(int32_t a, int32_t b);
int main(void) { printf("2 + 3 = %d\n", add_numbers(2, 3)); return 0;}$ cc main.c -L target/release -lmathlib -o main$ LD_LIBRARY_PATH=target/release ./main2 + 3 = 5Writing that C header by hand gets old fast. cbindgen generates it from your Rust code.
Compiling your own C code with build.rs and cc
Section titled “Compiling your own C code with build.rs and cc”So far we only used functions that are already linked in. To use your own C file, let Cargo compile it. A file named build.rs in the project root is a build script: Cargo runs it before compiling your crate. The cc crate turns that into a few lines.
cargo new hello-ccd hello-ccargo add --build cc#include <stdio.h>
int add_ints(int a, int b) { return a + b;}
void greet(const char *name) { printf("Hello from C, %s!\n", name); fflush(stdout);}fn main() { // Compile src/greet.c into a static library and link it in. cc::Build::new().file("src/greet.c").compile("greet"); // Re-run this script only when the C file changes. println!("cargo::rerun-if-changed=src/greet.c");}use std::ffi::{c_char, c_int, CString};
unsafe extern "C" { fn add_ints(a: c_int, b: c_int) -> c_int; fn greet(name: *const c_char);}
fn main() { let sum = unsafe { add_ints(2, 3) }; println!("2 + 3 = {sum}");
let name = CString::new("Ferris").unwrap(); unsafe { greet(name.as_ptr()) };}$ cargo run -q2 + 3 = 5Hello from C, Ferris!You need a C compiler installed (gcc, clang, or MSVC on Windows); cc finds it for you.
For a big C library with hundreds of functions, writing every extern declaration by hand is slow and easy to get wrong. bindgen reads the C header files and generates the Rust declarations for you, usually from build.rs as well. Most -sys crates on crates.io (like openssl-sys) are built this way.
Beyond C: Python, Node.js and the web
Section titled “Beyond C: Python, Node.js and the web”Because C is the shared language, the same ideas let Rust speak to many other languages. You rarely write raw extern "C" code for these — a crate does the unsafe work and gives you a safe, friendly API:
- Python: PyO3 lets you write Python modules in Rust (and call Python from Rust). Its companion tool
maturinbuilds and publishes them as normalpippackages. Libraries like Polars and pydantic-core use it. - Node.js: napi-rs builds native Node.js add-ons in Rust, with TypeScript types generated for you.
- C and C++ projects:
cdylibplus cbindgen, as shown above. - The browser: that’s not FFI in the C sense, but
wasm-bindgenplays a similar role. See WebAssembly.
Common mistakes
Section titled “Common mistakes”Calling a foreign function outside unsafe.
unsafe extern "C" { fn abs(x: i32) -> i32;}
fn main() { let n = abs(-7); println!("{n}");}error[E0133]: call to unsafe function `abs` is unsafe and requires unsafe function or block --> src/main.rs:6:13 |6 | let n = abs(-7); | ^^^^^^^ call to unsafe functionFix: wrap the call in unsafe { ... } — or better, in a safe wrapper function, as shown above.
A pointer to a CString that’s already gone. This compiles, but it’s a real bug:
use std::ffi::{c_char, CString};
unsafe extern "C" { fn strlen(s: *const c_char) -> usize;}
fn main() { let ptr = CString::new("hello").unwrap().as_ptr(); let len = unsafe { strlen(ptr) }; println!("{len}");}warning: this creates a dangling pointer because temporary `CString` is dropped at end of statement --> src/main.rs:8:46 |8 | let ptr = CString::new("hello").unwrap().as_ptr(); | ------------------------------ ^^^^^^ pointer created here | | | this `CString` is dropped at end of statement | = help: bind the `CString` to a variable such that it outlives the pointer returned by `as_ptr`The CString is a temporary, so it’s freed at the ;. ptr now points at freed memory. Fix: keep the CString in its own variable (let s = CString::new("hello").unwrap();) and call s.as_ptr() while s is alive. Never ignore this warning.
Plain extern "C" in an edition 2024 project. Code copied from older tutorials fails like this:
error: extern blocks must be unsafe --> src/main.rs:3:1 |3 | / extern "C" {4 | | fn add_ints(a: c_int, b: c_int) -> c_int;5 | | fn greet(name: *const c_char);6 | | } | |_^Fix: write unsafe extern "C" { ... }.
Forgetting #[repr(C)]. There’s no error at all — C just reads garbage. Put #[repr(C)] on every struct and enum that crosses the border.
- Declare C functions in an
unsafe extern "C" { ... }block, using the C types fromstd::ffilikec_intandc_char. - Every foreign call is
unsafe. Wrap it once in a safe Rust function that checks the rules, with a// SAFETY:comment. - C strings end at a NUL byte. Use
CStringto send text to C andCStrto read text from C. Keep theCStringalive while C uses the pointer. - Use
#[repr(C)]for structs shared with C, because Rust may reorder fields. extern "C" fnplus#[no_mangle](#[unsafe(no_mangle)]in edition 2024) lets C call Rust;cdylibbuilds a library for it.build.rs+cccompiles your C code;bindgengenerates declarations; PyO3 and napi-rs connect Rust to Python and Node.js.
Quick check
Section titled “Quick check”Quick check
1. Why is every call to a function from an extern "C" block unsafe?
2. What happens with CString::new("hel\0lo")?
3. Why do structs shared with C need #[repr(C)]?
4. In an edition 2024 crate, how do you write the attribute that keeps a function's plain name for C?
Score: 0 / 4
Read more
Section titled “Read more”- FFI — The Rustonomicon — the official deep dive: callbacks, linking, nullable pointers and more.
std::ffi— std docs —CString,CStr,c_int,c_charand friends, with examples.- External blocks — The Rust Reference — the exact rules for
externblocks, ABIs andsafe/unsafeitems. - The bindgen User Guide — generating Rust bindings from C headers.
- The PyO3 User Guide — writing Python extensions in Rust.