Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 38: FFI, Talking to C Without Lying

Prerequisites

You will understand

  • extern "C" and calling conventions
  • Safe wrappers over C libraries
  • Ownership boundaries at FFI edges

Reading time

40 min
+ 20 min exercises
Boundary Map

The FFI Treaty Line

Rust C FFI boundary references ownership types len-tracked strings raw pointers manual ownership null-terminated strings translate honestly do not pretend contracts match automatically
String Conversion

CString vs CStr

CString owned buffer with trailing NUL CStr borrowed view from foreign ptr Rust does not own the bytes

Step 1 - The Problem

Real systems rarely live in one language. You call C libraries, expose Rust to C, link with operating-system APIs, or incrementally migrate an older codebase.

The danger is not just syntax mismatch. It is contract mismatch:

  • different calling conventions
  • different layout expectations
  • null-terminated versus length-tracked strings
  • ownership rules the compiler cannot see

At an FFI boundary, Rust’s type system stops at the edge of what it can express locally. If you lie there, the compiler cannot rescue you.

Step 2 - Rust’s Design Decision

Rust makes FFI explicit:

  • extern "C" for ABI
  • raw pointers for foreign memory
  • repr(C) for layout-stable structs
  • CStr and CString for C strings

Rust accepted:

  • more manual boundary code
  • explicit unsafe at the edge

Rust refused:

  • pretending foreign memory obeys Rust reference rules automatically
  • silently converting incompatible layout or ownership models

Step 3 - The Mental Model

Plain English rule: an FFI boundary is a treaty line. On the Rust side, Rust’s rules apply. On the C side, C’s rules apply. Your job is to translate honestly between them.

Step 4 - Minimal Code Example

unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    let value = unsafe { abs(-7) };
    assert_eq!(value, 7);
}

Step 5 - Line-by-Line Compiler Walkthrough

  1. extern "C" says “use the C calling convention for this symbol.”
  2. The function body is not present in Rust; it will be linked from elsewhere.
  3. Calling it is unsafe because Rust cannot verify the foreign implementation’s behavior.
  4. The returned i32 is trusted only because the ABI contract says this signature is correct.

This highlights the core invariant:

your Rust declaration must exactly match the foreign reality.

If it does not, the program may still compile and link while remaining unsound at runtime.

Step 6 - Three-Level Explanation

FFI means Rust is talking to code written in another language. Rust needs explicit instructions about how that conversation works.

At FFI boundaries:

  • avoid Rust references in extern signatures unless you control both sides and the contract is airtight
  • prefer raw pointers for foreign-owned data
  • keep Rust-side wrappers small and explicit
  • convert strings and ownership once at the edge

An FFI boundary is a bundle of invariants:

  • ABI must match
  • layout must match
  • ownership must match
  • mutability and aliasing expectations must match
  • lifetime expectations must match

repr(C) solves only layout. It does not solve ownership, initialization, or pointer validity.

CStr, CString, #[no_mangle], and repr(C)

use std::ffi::{CStr, CString};
use std::os::raw::c_char;

fn main() {
    let owned = CString::new("hello").unwrap();
    let ptr = owned.as_ptr();

    let borrowed = unsafe { CStr::from_ptr(ptr) };
    assert_eq!(borrowed.to_str().unwrap(), "hello");
}

Use:

  • CString when Rust owns a null-terminated string to pass outward
  • CStr when Rust borrows a null-terminated string from elsewhere

Exposing Rust to C commonly involves:

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Point {
    pub x: i32,
    pub y: i32,
}

#[no_mangle]
pub extern "C" fn point_sum(p: Point) -> i32 {
    p.x + p.y
}
}

#[no_mangle] preserves a stable symbol name for foreign linking.

bindgen and Wrapper Strategy

Use bindgen when large C headers need Rust declarations generated automatically. Use cbindgen when exporting a Rust API to C consumers.

Even with generated bindings, do not dump raw FFI across your codebase. Wrap it:

  • raw extern declarations in one module
  • safe Rust types and errors on top
  • conversion at the edge

Step 7 - Common Misconceptions

Wrong model 1: “repr(C) makes FFI safe.”

Correction: it makes layout compatible. Safety still depends on many other invariants.

Wrong model 2: “If it links, the signature must be correct.”

Correction: ABI mismatches can compile and still be catastrophically wrong.

Wrong model 3: “Rust references are fine in extern APIs because they are pointers.”

Correction: Rust references carry stronger aliasing and validity assumptions than raw C pointers.

Wrong model 4: “String conversion is a minor detail.”

Correction: ownership and termination rules around strings are one of the most common FFI bug sources.

Step 8 - Real-World Pattern

Mature Rust FFI layers usually have three strata:

  1. raw bindings
  2. safe wrapper types and conversions
  3. application code that never touches raw pointers

That shape appears in database clients, graphics bindings, crypto integrations, and OS interfaces because it localizes unsafety and makes review tractable.

Step 9 - Practice Block

Code Exercise

Design a safe Rust wrapper around a hypothetical C function:

int parse_config(const char* path, Config* out);

Explain:

  • how you would represent the input path
  • who owns out
  • where unsafe belongs

Code Reading Drill

What assumptions does this make?

#![allow(unused)]
fn main() {
unsafe {
    let name = CStr::from_ptr(ptr);
}
}

Spot the Bug

Why is this unsound?

#![allow(unused)]
fn main() {
#[repr(C)]
struct Bad {
    ptr: &u8,
}
}

Assume C code is expected to construct and pass this struct.

Refactoring Drill

Take a crate that exposes raw extern calls directly and redesign it so application code only sees safe Rust types.

Compiler Error Interpretation

If the compiler rejects a direct cast or borrow at an FFI boundary, translate it as: “I am trying to pretend foreign memory already satisfies Rust’s stronger guarantees.”

Step 10 - Contribution Connection

After this chapter, you can review:

  • raw binding modules
  • string and pointer conversion boundaries
  • repr(C) structures
  • exported C-facing functions

Good first PRs include:

  • improving safety comments on FFI wrappers
  • replacing Rust references in extern signatures with raw pointers
  • isolating generated bindings from higher-level safe API code

In Plain English

When Rust talks to C, neither side automatically understands the other’s safety rules. You have to translate honestly between them. That matters because FFI bugs often look fine at compile time and fail only after they are deep in production.

What Invariant Is Rust Protecting Here?

Foreign data must be translated into Rust only when ABI, layout, lifetime, validity, and ownership assumptions are all satisfied simultaneously.

If You Remember Only 3 Things

  • repr(C) is necessary for many FFI structs, but it is only one part of correctness.
  • CStr and CString exist because C strings have different representation and ownership rules than Rust strings.
  • Keep raw FFI declarations at the edge and expose safe wrappers inward.

Memory Hook

An FFI boundary is a customs checkpoint. repr(C) is the passport photo. It is necessary, but it is not the whole border inspection.

Flashcard Deck

QuestionAnswer
What does extern "C" specify?The calling convention and ABI expected for the symbol.
Why are foreign function calls usually unsafe?Rust cannot verify the foreign implementation obeys the declared contract.
What is repr(C) for?Making Rust type layout compatible with C expectations.
When do you use CString?When Rust owns a null-terminated string to pass to C.
When do you use CStr?When Rust borrows a null-terminated string from C or another foreign source.
What does #[no_mangle] do?Preserves a stable exported symbol name.
Why are Rust references risky in extern signatures?They imply stronger validity and aliasing guarantees than raw foreign pointers usually can promise.
What is the preferred structure of an FFI crate?Raw bindings at the edge, safe wrappers inward, application code isolated from raw pointers.

Chapter Cheat Sheet

NeedToolWhy
Call C functionextern "C"ABI compatibility
Layout-stable shared structrepr(C)field layout contract
Borrow C stringCStrnull-terminated borrowed string
Own string for CCStringnull-terminated owned buffer
Export Rust symbol to Cpub extern "C" + #[no_mangle]stable callable interface