25.4Calling C from Rust

Last updated June 13, 2026

The single most common reason to use unsafe in real Rust isn't clever data structures, it's talking to code written in other languages. Decades of essential software, operating system APIs, cryptography, compression, graphics, audio, are written in C, and rewriting all of it in Rust is neither possible nor desirable. So Rust can call C functions directly, through its foreign function interface (FFI). This pays off the loose thread from lesson 5.1, which mentioned "talking to other languages" as a future reason some things matter. Here it is. And because the C code on the other side has none of Rust's guarantees, every call across the boundary is unsafe.

Declaring a foreign function

To call a C function, you first declare it in Rust so the compiler knows its name and signature. You don't write its body, it lives in some C library, you just describe its interface inside an extern "C" block:

extern "C" {
    fn abs(input: i32) -> i32;   // the C standard library's abs()
}

fn main() {
    let n = -42;
    unsafe {
        println!("abs({n}) = {}", abs(n));
    }
}
abs(-42) = 42

Read the pieces. extern "C" introduces a block of foreign function declarations, and the "C" specifies the ABI, the "application binary interface," the low-level convention for how arguments are passed and values returned. "C" is the near-universal lingua franca that almost every language can speak, which is why FFI is usually "C FFI" regardless of what language the other side is actually written in. Inside, fn abs(input: i32) -> i32; declares C's abs function: name, argument types, return type, but no body, because the body is in the C standard library, which the linker connects automatically.

Then the call: abs(n) must be inside unsafe. Calling any foreign function is one of the things that requires unsafe, and the reason is fundamental.

Why every foreign call is unsafe

Think about what crossing the boundary means. The Rust compiler has checked your code, but it knows nothing about the C function on the other side. It can't verify that abs actually matches the signature you declared. It can't check that the C code respects ownership, or won't write past a buffer, or won't keep a pointer you passed and use it after you freed it. The C function might do anything, and Rust's guarantees stop at the boundary like a jurisdiction's border. So Rust requires unsafe on every foreign call as a blanket acknowledgment: "I'm leaving the zone the compiler protects, and I'm vouching that the foreign code behaves as its signature claims." You can't prove that statically, so you take responsibility for it.

This is the chapter's whole philosophy made concrete. The unsafety isn't Rust being paranoid; it's an honest admission that it cannot reason about code it didn't compile. The unsafe marks exactly where its knowledge ends.

Passing data across: the C string example

Calling abs with an i32 is easy because integers mean the same thing in both languages. Most real FFI is fiddlier, because Rust types and C types don't match. The classic mismatch is strings: a Rust String is a length-tracked UTF-8 buffer (chapter 18), while a C string is a raw pointer to bytes terminated by a zero byte, no length, no UTF-8 promise. To pass a string to C you must convert it to C's format, using CString:

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

extern "C" {
    fn strlen(s: *const c_char) -> usize;   // C's strlen
}

fn main() {
    let rust_string = CString::new("hello, C").expect("no interior null byte");

    unsafe {
        let length = strlen(rust_string.as_ptr());
        println!("C says the length is {length}");
    }
}
C says the length is 8

CString::new builds a C-compatible, null-terminated string from a Rust string (it returns a Result because C strings can't contain a zero byte mid-string, so an interior null is an error). as_ptr() hands C a *const c_char, a raw pointer (lesson 25.2), because that's the only thing C understands, raw addresses. The c_char type is Rust's name for "C's char," from the std::os::raw module of C-compatible type aliases. The boundary forces you to think in C's terms: raw pointers, null terminators, matching integer widths. Getting any of it wrong, a mismatched type, a pointer that outlives its data, is undefined behavior the compiler can't catch, which is the whole reason the call is unsafe.

The signature is a promise only you can keep

When you write fn strlen(s: *const c_char) -> usize;, you are asserting that the real C strlen has exactly that signature. The compiler takes your word for it, it has no way to check the foreign function. If you get the types wrong (say you declare a parameter as i32 when C expects a 64-bit value, or mix up the return type), the code compiles, links, and then corrupts memory or crashes at run time with no warning. FFI signatures are a contract you must get right by hand, against the C library's documentation or headers. This is the most error-prone part of FFI and a major reason to prefer audited wrapper crates (below) over hand-rolling declarations.

In practice: bindgen and wrapper crates

Hand-writing extern "C" declarations for a whole C library would be tedious and dangerous, exactly the place to get a signature wrong. So the ecosystem has tooling. bindgen is a tool that reads a C library's header files and generates the matching Rust extern declarations automatically, eliminating the by-hand transcription and the errors that come with it. And for most popular C libraries, someone has already done this work and published a crate with a safe wrapper, the unsafe FFI confined behind a safe Rust API, exactly the lesson-25.3 pattern at the language boundary. So in real projects you rarely write raw FFI yourself; you add a wrapper crate from crates.io and call safe functions, while the audited unsafe lives in the crate. Knowing how the boundary works (this lesson) is what lets you understand and trust those crates.

Quiz time

Question #1

What does an extern "C" block do, and what is the "C" part?

Show solution

An extern "C" block declares foreign functions, their names, argument types, and return types, so Rust can call them; the bodies live in an external (usually C) library that the linker connects. The "C" specifies the ABI (application binary interface): the low-level convention for passing arguments and returning values. "C" is the near-universal standard nearly every language can speak, which is why FFI is done through it regardless of the other language.

Question #2

Why does calling a foreign (C) function always require unsafe?

Show solution

Because the Rust compiler can't verify anything about the foreign code: it can't confirm the function matches the signature you declared, respects ownership, stays within buffers, or doesn't misuse pointers you pass. Rust's guarantees stop at the language boundary. The unsafe is your acknowledgment that you're leaving the compiler-checked zone and vouching that the foreign code behaves as its declared signature claims, something you can't prove statically.

Question #3

Why can't you pass a Rust String directly to a C function, and what do you use instead?

Show solution

A Rust String is a length-tracked UTF-8 buffer, while a C string is a raw pointer to bytes terminated by a null (zero) byte, with no length and no UTF-8 requirement, different representations. You convert with CString::new(...) (which returns a Result, since C strings can't contain an interior null) and pass .as_ptr(), a *const c_char raw pointer, which is the only string form C understands. The boundary forces Rust data into C's representation.

You can now call into C. The reverse direction, letting C (or any language) call your Rust code, is the final FFI piece and the closing pitch of the chapter. The next lesson (25.5) covers exposing Rust functions with #[no_mangle] and extern "C", so Rust can be embedded inside almost anything.