Skip to content

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.

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) = 42
▶ Try it Yourself

A 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_int is Rust’s name for C’s int. The std::ffi module has types like c_int, c_long and c_char, so your declaration matches C exactly on every platform.
  • You write unsafe extern "C". In edition 2024 the unsafe here is required, because you are promising the declaration is correct. Older code (and edition 2021) often writes plain extern "C" { ... }. The unsafe version 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)
None
▶ Try it Yourself

The // 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 are where most FFI bugs live, because Rust and C store them differently:

  • A Rust String / &str knows its length and can contain any byte, including 0.
  • A C string is just a pointer to bytes. The string ends at the first 0 byte (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: 3
▶ Try it Yourself

Two surprises here, and both are important:

  1. strlen("héllo") is 6, not 5. C counts bytes, and é takes two bytes in UTF-8.
  2. CString::new fails if your text contains a 0 byte. C would think the string ends there and silently cut off "lo". Rust refuses instead of letting that happen — that’s why CString::new returns a Result.

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")
None
Some(".org")
▶ Try it Yourself

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.

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 2
▶ Try it Yourself

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]
▶ Try it Yourself

(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;
}
Terminal window
$ cc main.c -L target/release -lmathlib -o main
$ LD_LIBRARY_PATH=target/release ./main
2 + 3 = 5

Writing 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.

Terminal window
cargo new hello-c
cd hello-c
cargo add --build cc
src/greet.c
#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);
}
build.rs
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");
}
src/main.rs
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()) };
}
Terminal window
$ cargo run -q
2 + 3 = 5
Hello 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.

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 maturin builds and publishes them as normal pip packages. 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: cdylib plus cbindgen, as shown above.
  • The browser: that’s not FFI in the C sense, but wasm-bindgen plays a similar role. See WebAssembly.

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 function

Fix: 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 from std::ffi like c_int and c_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 CString to send text to C and CStr to read text from C. Keep the CString alive while C uses the pointer.
  • Use #[repr(C)] for structs shared with C, because Rust may reorder fields.
  • extern "C" fn plus #[no_mangle] (#[unsafe(no_mangle)] in edition 2024) lets C call Rust; cdylib builds a library for it.
  • build.rs + cc compiles your C code; bindgen generates declarations; PyO3 and napi-rs connect Rust to Python and Node.js.

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