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
The FFI Treaty Line
CString vs CStr
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 structsCStrandCStringfor 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
extern "C"says “use the C calling convention for this symbol.”- The function body is not present in Rust; it will be linked from elsewhere.
- Calling it is unsafe because Rust cannot verify the foreign implementation’s behavior.
- The returned
i32is 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:
CStringwhen Rust owns a null-terminated string to pass outwardCStrwhen 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:
- raw bindings
- safe wrapper types and conversions
- 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.CStrandCStringexist 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
| Question | Answer |
|---|---|
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
| Need | Tool | Why |
|---|---|---|
| Call C function | extern "C" | ABI compatibility |
| Layout-stable shared struct | repr(C) | field layout contract |
| Borrow C string | CStr | null-terminated borrowed string |
| Own string for C | CString | null-terminated owned buffer |
| Export Rust symbol to C | pub extern "C" + #[no_mangle] | stable callable interface |