Welcome
Welcome to the “C to Rust Migration Book”!
This course will teach you techniques for migrating C codebases to Rust.
You’ll learn how to maintain a safe mixed C-Rust codebase, incrementally migrate
modules, translate common C idioms to Rust, and use the debugging tools you’ll
need when things inevitably break.
We assume prior knowledge of Rust and some C.
You’ll build up your knowledge in small, manageable steps. By the end of the course, you will have solved many exercises and should be prepared to migrate even larger C codebases to Rust.
Why FFI matters
Rewriting a large C codebase in one go rarely works. Instead, you migrate it piece by piece, and for a while C and Rust live in the same program and call each other. What makes that possible is a Foreign Function Interface (FFI): a way for code in one language to call functions and use types defined in another. Nearly every language supports the same one: calling functions as if they were written in C.
The boundary between the two languages is where most of the difficulty lives.
Rust’s compiler can’t check the C side, and C doesn’t know about Rust’s rules,
so everything that crosses has to be described, validated, and owned correctly
by hand. This course starts with the unsafe Rust you need to reason about that
boundary, then covers how values cross it, and finally how to move code from one
side to the other.
Methodology
This course is based on the “learn by doing” principle.
It has been designed to be interactive and hands-on.
Mainmatter developed this course to
be delivered in a classroom setting, over four days: each attendee advances
through the lessons at their own pace, with an experienced instructor providing
guidance, answering questions, and diving deeper into the topics as needed.
If you’d like to organize a private session for your company, please
get in touch.
You can also take the course on your own, but we recommend you find a friend or a mentor to help you along the way should you get stuck.
Formats
You can go through the course material [in the browser]([TODO: insert URL]) or [download it as a PDF file]([TODO: insert URL]), for offline reading.
Structure
On the left side of the screen, you can see that the course is divided into sections. To verify your understanding, each section is paired with an exercise that you need to solve.
You can find the exercises in the
companion GitHub repository.
Before starting the course, make sure to clone the repository to your local
machine:
git clone https://github.com/mainmatter/migrating-c-to-rust
We also recommend you work on a branch, so you can easily track your progress and pull in updates from the main repository, if needed:
cd migrating-c-to-rust
git checkout -b my-solutions
All exercises are located in the exercises folder. Each exercise is structured
as a Rust package. The package contains the exercise itself, instructions on
what to do (in src/lib.rs), and a test suite to automatically verify your
solution.
Tools
To work through this course, you’ll need:
-
Rust, installed through
rustup. The repository pins a nightly toolchain inrust-toolchain.toml, because some of the tools the course uses, such as Miri, only run on nightly.rustuppicks it up whenever you runcargoinside the repository. If it isn’t installed yet, runrustup toolchain installfrom the repository root. -
(Optional but recommended) An IDE with Rust autocompletion support. We recommend one of the following:
- RustRover;
- Visual Studio Code with the
rust-analyzerextension.
-
A C compiler. The one provided by your operating system will be good enough.
-
Two extra compilation targets. One exercise type-checks your bindings against platforms that disagree with yours, which needs their standard libraries. Run this from the repository root, so the targets are added to the pinned toolchain:
rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnuNothing is compiled or linked for those targets, so you don’t need a cross compiler.
Workshop runner
To verify your solutions, we’ve also provided a tool to guide you through the
course: the wr CLI, short for “workshop runner”. Install wr by following the
instructions on
its website.
Once you have wr installed, open a new terminal and navigate to the top-level
folder of the repository. Run the wr command to start the course:
wr
wr will verify the solution to the current exercise.
Don’t move on to the next section until you’ve solved the exercise for the
current one.
We recommend committing your solutions to Git as you progress through the course, so you can easily track your progress and “restart” from a known point if needed.
Enjoy the course!
Authors
This course was written by Jonas Kruckenberg,
Engineering Consultant at Mainmatter,
and Artur Jakubiec.
Jonas Kruckenberg is a systems engineer working on operating systems and
low-level infrastructure. He is the lead author of k23, an experimental
high-reliability operating system. As a TC39 Invited Expert, he helps shape the
future of the web by bringing non-browser perspectives to JavaScript language
standardization.
Artur Jakubiec is a long-time C/C++ engineer and was one of the main developers
of Cubasis. He is now a tech lead at a company building enterprise SaaS
productivity software, currently migrating it from C++ to Rust.
License
Copyright © 2024- Mainmatter GmbH (https://mainmatter.com), released under the PolyForm-Noncommercial-1.0.0.
Chapter 1: Unsafe Rust foundations
Safe Rust comes with a guarantee: if the program compiles, it has no undefined behavior. The compiler enforces the rules for references, valid values, and mutation that make that guarantee possible. Those checks stop at the language boundary, though. Rust cannot borrow-check C, and C does not enforce Rust’s invariants. It falls to you to know what each language guarantees about memory, what it assumes, and what happens when those assumptions don’t hold.
This chapter covers the building blocks: what unsafe means and what it does
and doesn’t change, how raw pointers differ from references, how NonNull and
Option let the type system track null pointers, and how size, alignment, and
padding work.
Exercises
The exercises for this chapter are located in exercises/01_foundations.
Exercise
The exercise for this section is located in 01_foundations/00_README
unsafe and safety contracts
Safe Rust can’t cause undefined behavior, but some things
are beyond what the compiler can check, such as whether a pointer handed over by
a C library still points to live memory. For those, Rust has the unsafe
keyword. It marks the places where you, instead of the compiler, are responsible
for keeping the program sound.
What unsafe unlocks
unsafe doesn’t switch off the borrow checker or any other check. It lets you
perform a small set of extra operations the compiler can’t verify:
- dereferencing a raw pointer
- calling an
unsafefunction, which includes most functions implemented in C - reading or writing a
static mutor anextern static - reading a field of a
union - implementing an
unsafetrait such asSendorSync
Everything else inside an unsafe block is checked exactly as it would be
outside of one.
Undefined behavior
The rules these operations can break all lead to undefined behavior (UB). If a program has UB, Rust no longer defines what it does: it might crash, give wrong results, or seem to work fine. The compiler assumes UB never happens and optimizes based on that, so a program that seems fine today can break with the next compiler version.
The Rust Reference keeps the full list of what counts as UB. The entries you’ll meet most often in FFI code are:
- dereferencing a null, dangling, or misaligned pointer
- producing an invalid value, such as a
boolholding3or an enum with a discriminant that doesn’t match any variant - breaking the aliasing rules, for example mutating memory while a shared reference to it exists
- reading uninitialized memory
- data races
Safety contracts
An unsafe operation always comes with conditions that make it sound. We call
those conditions the operation’s safety contract. Rust splits the contract
into two halves.
An unsafe fn declaration says “calling me is only sound if you, the caller,
uphold these conditions”. The conditions go into a # Safety section of the doc
comment:
/// Reads the `u32` that `ptr` points to.
///
/// # Safety
///
/// `ptr` must be non-null, aligned for `u32`, and point to an initialized `u32`
/// that stays alive and remains unmodified for the duration of the call.
pub unsafe fn read_u32(ptr: *const u32) -> u32 {
// SAFETY: the caller guarantees that `ptr` points to a valid `u32`.
unsafe { *ptr }
}
An unsafe block says “I checked the conditions here”. The justification goes
into a // SAFETY: comment right above it:
/// # Safety
/// `ptr` must be non-null, aligned, and point to an initialized `u32`.
unsafe fn read_u32(ptr: *const u32) -> u32 { unsafe { *ptr } }
let x = 41_u32;
// SAFETY: `&x` is non-null and aligned, and `x` is an initialized `u32` that
// outlives the call.
let y = unsafe { read_u32(&x) };
A // SAFETY: comment explains why the code upholds each condition, or, inside
an unsafe fn, points to the condition its caller already promised to uphold.
Safe abstractions and soundness
It’s common for unsafe code to end up wrapped in a safe function. The safe
function checks whatever the contract requires, then performs the unsafe
operation. That wrapper is only correct if no input, however strange, can cause
UB. A safe function that can cause UB is called unsound, and it’s a bug even
if no caller triggers it today.
Here’s an unsound one:
/// Returns the first byte of `bytes`.
pub fn first(bytes: &[u8]) -> u8 {
// SAFETY: ??? An empty slice has no index 0.
unsafe { *bytes.get_unchecked(0) }
}
Calling first(&[]) reads out of bounds, and nothing in the signature warns the
caller about it. The sound version checks the condition before relying on it:
/// Returns the first byte of `bytes`, or `None` if `bytes` is empty.
pub fn first(bytes: &[u8]) -> Option<u8> {
if bytes.is_empty() {
return None;
}
// SAFETY: `bytes` is not empty, so index 0 is in bounds.
Some(unsafe { *bytes.get_unchecked(0) })
}
In real code you would write bytes.first().copied(), which does the same check
without any unsafe. The example uses get_unchecked only to show the pattern:
check, then justify, then perform the operation. Writing the // SAFETY:
comment is often how you find out that a check is missing. If you can’t finish
the sentence, the code isn’t sound yet.
Head to the exercise
The exercise is one function: return the last byte of a slice, or None if the
slice is empty, reading the byte with get_unchecked. Work out the condition
that makes the index valid before you rely on it, and say why in a // SAFETY:
comment.
Exercise
The exercise for this section is located in 01_foundations/01_unsafe
References vs raw pointers
C has one kind of pointer. Whether a T * may be null, who owns the memory
behind it, and how long it stays valid are all conventions, written down in
documentation if you’re lucky. Rust has references instead, and a reference
carries those guarantees in its type.
What a reference guarantees
A reference, &T or &mut T, is always:
- non-null
- aligned for
T - pointing to a live, initialized, valid
T - valid for its lifetime, which the borrow checker enforces
- subject to the aliasing rules: either any number of
&T, or exactly one&mut T
These guarantees aren’t only there to protect you. The compiler optimizes based
on them. Because a &mut T is the only way to reach its value, the compiler can
keep that value in a register instead of re-reading it from memory.
That’s also why creating a reference that breaks one of these guarantees is undefined behavior, even if you never read through it.
Raw pointers
Those guarantees can be quite limiting, though, especially when you want to
interface with C. For this reason Rust also has raw pointers: *const T and
*mut T. They are Rust’s equivalent of C’s const T * and T *, and they come
without any of the guarantees Rust has for references. A raw pointer may be
null, dangling, misaligned, or pointing to memory that is being mutated through
another pointer at the same time.
Creating a raw pointer is a safe operation. Dereferencing one is unsafe,
because that’s the moment all those possibilities matter:
let mut x = 10_i32;
// Creating raw pointers is safe.
let p: *mut i32 = &mut x;
let null: *const i32 = std::ptr::null();
assert!(!p.is_null());
assert!(null.is_null());
// SAFETY: `p` comes from `&mut x`, so it's non-null, aligned, and points to a
// live `i32`. Nothing else accesses `x` while we use `p`.
unsafe {
*p += 1;
assert_eq!(p.read(), 11);
}
Raw pointers have a small API of their own. The methods you’ll mostly use are:
is_nullto check for nullcastto change the pointee type, the equivalent of a C pointer castaddfor pointer arithmetic, which isunsafebecause the result must stay within the same allocationreadandwriteto copy a value out or in without creating a reference
Mutability is about where a pointer came from
In C, casting away const is legal as long as the memory itself isn’t const.
Rust is stricter. Whether you may write through a pointer depends on how it was
created, not only on its type. A *mut T that you got by casting a &T must
never be written through, because a &T guarantees that the value doesn’t
change.
The compiler catches the most obvious version of this:
let x = 10_i32;
let p = &x as *const i32 as *mut i32;
// error: assigning to `&T` is undefined behavior, consider using an `UnsafeCell`
unsafe { *p = 20 };
It doesn’t catch the less obvious ones. This example compiles, runs, and prints
20, but it is undefined behavior all the same:
let mut x = 10_i32;
let p = &mut x as *mut i32;
let r = &x;
// SAFETY: none! `r` is a shared reference, so `x` must not change while it
// is alive.
unsafe { *p = 20 };
println!("{r}");
The borrow checker only tracks references, so it has no idea p and r point
to the same value. Miri does catch it and reports undefined behavior;
Dynamic analysis with Miri puts it to work on a
real port.
Keep in mind that C’s const is a convention. A C function that takes a
const T * can still cast it away and write through it. If you pass it a
pointer derived from a &T, you are trusting that it doesn’t.
From raw pointer to reference
Sooner or later you’ll want to turn a raw pointer from C into a reference, so the rest of your Rust code can work with it safely. There are two ways to do that:
&*ptror&mut *ptrdereference the pointer and borrow the resultptr.as_ref()andptr.as_mut()do the same, but returnNonefor a null pointer
Both approaches are unsafe. The compiler can’t check that the pointer meets
the guarantees a reference needs, so you have to promise that it does. It also
can’t work out how long the memory stays valid, so you have to assign a lifetime
for the reference that matches how long the C side keeps the value alive.
pub struct Config {
pub verbose: bool,
}
/// Borrows the configuration a C library handed us.
///
/// # Safety
///
/// `ptr` must be null, or point to a valid `Config` that stays alive and
/// unmodified for as long as the returned reference is in use.
pub unsafe fn config_from_c<'a>(ptr: *const Config) -> Option<&'a Config> {
// SAFETY: the caller guarantees that `ptr` is null or valid for `'a`.
unsafe { ptr.as_ref() }
}
The # Safety section tells you what 'a means in practice. Without it, a
caller could keep the reference around long after the C library freed the
Config, and nothing would stop them.
Head to the exercise
The exercise is one function: swap the two i32s that a pair of raw pointers
point to.
Exercise
The exercise for this section is located in 01_foundations/02_raw_pointers
NonNull<T> and null-pointer optimization
In FFI code you often know that a pointer isn’t null. NonNull<T> puts that
knowledge in the type. Wrapped in an Option, it also gives you a pointer that
may be null, but that you can’t use without checking first.
NonNull<T>
NonNull<T> behaves like a *mut T with one extra guarantee: it is never null.
You create one from a raw pointer with NonNull::new, which returns None for
null, or from a reference, which is never null to begin with:
use std::ptr::NonNull;
let mut x = 5_u32;
let p: NonNull<u32> = NonNull::from(&mut x);
// SAFETY: `p` comes from `&mut x`, which is alive and not borrowed elsewhere.
unsafe { *p.as_ptr() += 1 };
assert_eq!(x, 6);
let from_c: *mut u32 = std::ptr::null_mut();
assert!(NonNull::new(from_c).is_none());
A few more methods are worth knowing:
NonNull::new_uncheckedskips the null check. It’sunsafe, because passing a null pointer is undefined behavior.as_ptrturns it back into a*mut T, for example to hand it to C.as_refandas_mutturn it into a reference. They’reunsafefor the same reasons as with raw pointers: non-null doesn’t mean aligned, alive, or valid.NonNull::danglingcreates a non-null, aligned pointer that doesn’t point to anything. The standard library uses it for empty collections, which have nothing to point at but still have to hold a pointer.1
Null-pointer optimization
A NonNull<T> can never be all zeros (0 being a forbidden value is literally
the type’s only job), and the compiler takes advantage of that. A bit pattern
that a type can never hold is called a niche, and the compiler looks for one
whenever it lays out an enum. For pointer-like types the trick has a name of its
own: null-pointer optimization (NPO).
use std::mem::size_of;
use std::ptr::NonNull;
const _: () = {
assert!(size_of::<Option<NonNull<u8>>>() == size_of::<NonNull<u8>>());
assert!(size_of::<Option<&u8>>() == size_of::<&u8>());
assert!(size_of::<Option<Box<u8>>>() == size_of::<Box<u8>>());
assert!(size_of::<Option<extern "C" fn()>>() == size_of::<extern "C" fn()>());
};
A raw pointer has none of these guarantees. Every combination of bits can be a
valid *const T or *mut T, so there’s no niche left to mark None with. The
compiler has to store that separately, and pad the result back out to the
pointer’s alignment. On a 64-bit target that doubles the size:
use std::mem::size_of;
const _: () = {
assert!(size_of::<*const u8>() == 8);
assert!(size_of::<Option<*const u8>>() == 16);
};
That size is a symptom rather than the rule: Option<*const T> has no specified
layout, so it wouldn’t be a C pointer even if it happened to be eight bytes
wide. That is the practical reason NonNull exists: Option<NonNull<T>> lets
you say “may be null” in Rust’s type system and get a value that is one pointer
wide, with a layout you’re allowed to rely on.
What’s guaranteed
Niches are an optimization, and in general the compiler is free to decide how to
lay out an Option. For a short list of types, though, the standard library
guarantees
that Option<T> has the same size, alignment, and function-call ABI as T,
with None represented as all zeros:
&Tand&mut TBox<T>NonNull<T>NonZero<u8>,NonZero<u32>, and the otherNonZerointegers- function pointers, including
extern "C" fn #[repr(transparent)]structs around one of the above
For the pointer types, the all-zeros part only holds when T is sized. A
pointer to an unsized type, such as &[u8] or &str, is a pointer plus a
length, and can’t cross the FFI boundary as a single C pointer.
This guarantee is what makes these types usable in FFI signatures. For anything
else, such as Option<bool> or Option<u32>, the layout is a compiler decision
and can’t be used in FFI signatures.
Head to the exercise
The exercise is one function: take a raw pointer the way C would hand it over,
read a field out of what it points to, and return None when there is nothing
to read. Let the type carry the null check rather than writing one by hand.
Exercise
The exercise for this section is located in 01_foundations/03_nonnull
-
VecandBoxdon’t store aNonNull<T>directly. They use an internalUnique<T>, which wrapsNonNull<T>and adds the claim that the pointer is the only one to its value, the way a&mut Tis. ↩
Memory layout: size, alignment, and repr
When C and Rust share data, both sides have to agree on its layout: how many bytes a value takes, and where each field sits inside them. C types derive their default layout rules from the platform ABI. Rust types, by default, have an unspecified layout optimized by the compiler. This section explains how to ensure that size and alignment between C and Rust match predictably across language boundaries.
Size and alignment
Every type has a size and an alignment. The size is how many bytes a value takes up. The alignment is a number that the address of a value has to be a multiple of. Both are known at compile time:
assert_eq!((size_of::<u8>(), align_of::<u8>()), (1, 1));
assert_eq!((size_of::<u32>(), align_of::<u32>()), (4, 4));
assert_eq!((size_of::<[u32; 3]>(), align_of::<[u32; 3]>()), (12, 4));
For primitives, alignment usually equals size. Alignment comes from the hardware: misaligned loads and stores can be much slower than aligned accesses, sometimes requiring the compiler to emit multiple instructions; on some architectures they even raise a fault. Rust doesn’t distinguish between architectures here: accessing a value through a misaligned pointer is undefined behavior on every target.1
Layout
The two numbers travel together often enough that the standard library has a
type for the pair. std::alloc::Layout carries a size and an alignment,
describing either a single value or an array of them:
use std::alloc::Layout;
let one = Layout::new::<u64>();
assert_eq!((one.size(), one.align()), (8, 8));
let three = Layout::array::<u64>(3).unwrap();
assert_eq!((three.size(), three.align()), (24, 8));
Layout::array returns a Result, because the total size of a large array can
overflow.
Allocation APIs are written in terms of Layout: an allocator has to know how
many bytes to allocate and how they need to be aligned.
Allocation, allocators, and mixing them
covers the allocator API in more detail.
How a struct gets its size and alignment
In C, a struct’s size and alignment are calculated from its fields. The fields are placed one after the other, but each one has to start at an offset that is a multiple of its own alignment, so a field may start a few bytes after the previous one ends. The struct as a whole gets the alignment of its most-aligned field, and its size is rounded up to a multiple of that alignment, so that the next value in an array starts aligned too.
Here is a C struct and its layout:
struct sample {
bool flag;
uint32_t value;
uint8_t tag;
};
offset 0 1 4 8 9 12
├ flag ┼──── pad ─────┼─── value ───┼ tag ─┼─── pad ───┤
flag sits at offset 0. value needs an offset that is a multiple of 4, so it
starts at 4. tag follows at 8. The struct’s alignment is 4, from value, so
its size is rounded up to 12.
The unused bytes the compiler inserts to keep fields aligned are called
padding: the three after flag and the three after tag. That’s 6 bytes of
data in 12 bytes of memory.
No guarantees by default
Rust gives the equivalent struct no specified layout:
struct Sample {
flag: bool,
value: u32,
tag: u8,
}
The compiler may place these three fields in any order. With value first, the
same data requires less padding and fits in 8 bytes instead of 12.
For Rust-only code that is a good trade, because the compiler picks a tighter layout than a fixed order would and nothing outside the program depends on the result. It stops working as soon as C is involved: the C side computes field offsets from the struct definition, and those offsets follow C’s rules rather than whatever the Rust compiler decided.
What repr is for
repr is an attribute on a type definition that replaces the default layout
with a specified one. There are two reasons to reach for it:
- The layout has to match a definition outside the program. A struct C reads or writes, a value passed to a syscall, a header from a file format or a network packet. Something else already fixed the byte positions, and the type has to reproduce them.
- The padding or alignment itself matters. Removing padding to match a packed wire format, or raising alignment because hardware asks for it.
repr applies to struct, enum, and union definitions, and it describes
the whole type: there is no way to give an individual field its own
representation, and Rust has no equivalent of C’s bitfields.
The variant you’ll use most is repr(C), which lays a type out by C’s rules.
The others cover enum discriminants, single-field wrappers, and changes to
padding and alignment, and each one comes up together with the problem it
solves.
Head to the exercise
The exercise has two halves. First, work out the size and alignment of a few
types on paper and write the numbers down as constants, which the tests compare
against what the compiler reports. Then take a repr(C) struct that carries 14
bytes of fields in 24 bytes of memory, and reorder its fields so it carries them
in 16.
Exercise
The exercise for this section is located in 01_foundations/04_layout
-
When the data really is misaligned, such as a struct read straight out of a network packet, reach for
ptr::read_unalignedand itswrite_unalignedcounterpart. They copy the bytes without ever forming a misaligned reference, at the cost of the slower access the hardware needs for it. ↩
Chapter 2: Basics
In this chapter we’ll cover the FFI foundations: what FFI is, how we use it to interoperate between C and Rust, and the common pitfalls.
We start with the mechanics of declaring and exporting functions, then work
through the kinds of data that cross the boundary: primitive types, structs,
enums and unions, slices, and opaque handles. Next we look at why hand-written
bindings drift out of sync with the C they describe, and at the tools that
generate them for you: bindgen for Rust bindings to C, and cheadergen for C
headers from Rust. The chapter ends with the rules we follow to make an FFI
boundary trustworthy: validating everything that comes in, and designing an API
that is small and documented well enough to keep correct.
Exercises
The exercises for this chapter are located in exercises/02_basics.
Exercise
The exercise for this section is located in 02_basics/00_README
What is FFI?
A Foreign Function Interface (FFI) bridges different type systems (or lack of type systems), execution models, and code-organization concepts, so that code in one language can call functions and use types from another. The exact syntax and options vary by language, but they almost always have one thing in common: they represent functions as if they were C functions.
This is called the “C ABI” (C Application Binary Interface) or “C calling convention” and, as the name suggests, is a convention on how to call functions (which CPU registers hold what arguments, which register(s) hold return values, when to spill to the stack, etc.)1. Notice that this is just a convention that the industry agreed on over time. The C ABI is predictable, simple, and stable, and it has been so for decades, which is how it became the de facto standard for language interoperability.
Calling an FFI function
Let’s say for example we have a Rust program that needs to call the time
function from libc (a C static library)2. We would use the following
construct:
type time_t = i64;
unsafe extern "C" {
fn time(time: *mut time_t) -> time_t;
}
Before we dissect the syntax though: you may have already noticed that nowhere
in this snippet do we ask for libc. Why is that?
This is because object file formats (ELF on Linux, Mach-O on macOS, PE on
Windows) are all old and therefore simplistic. They have one global namespace,
called a symbol table, that all functions (and statics) share. So you cannot
say “call time from libc”, you can only say “call a function named time”.
When a compiler builds a program, all function calls reference the function by
symbol (“call function named X”). To make this actually executable, we need
to replace every symbol reference with the actual address of the function. This
happens after compilation during the linking step, where a separate program,
the linker, aggregates all object files that make up your final program, lays
them out on disk, and then resolves these symbol names.
To call time from libc we therefore need Rust to emit a reference to the
time symbol and make sure the libc file is also passed to the linker.
This solves the problem of what to call, but we still need to figure out how
to call that function: How many parameters does the function accept? What are
the types of those parameters? How many values does it return? Remember the
symbol references above are plain string names. They carry no information about
the function’s argument or return types, so rustc has no way to work them out
on its own.
This is why we need to tell it about time’s signature through a so-called
“extern block” (or sometimes an “extern C block”) above. This block declares
items that are not defined in the current crate. Each item we declare is a
promise to the compiler: “this is the correct signature of this symbol, trust
me”. We commonly refer to it as a binding.
Get the signature wrong and your Rust program will pass garbage to the FFI
function without any way to check this at compile-time. The exact implementation
won’t be known until link-time, much later than the compiler’s type-checking
pass. This is why bindings are marked unsafe: you as the programmer have to
ensure signatures are correct.
This is the foundation of all Foreign Function Interfaces in Rust. In later exercises we will see how to make this much safer and more ergonomic.
Calling Rust from C
A migration also needs the other direction: C code calling functions that are now implemented in Rust. That takes two annotations on an ordinary Rust function:
#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
extern "C" fn tells rustc to use the C calling convention for this function.
#[unsafe(no_mangle)] keeps the symbol name exactly as written.
Head to the exercise
You’ll write this block for a bm_add function implemented in C.
Exercise
The exercise for this section is located in 02_basics/01_what_is_ffi
-
Technically, there is no “single” calling convention. Every architecture defines its own “C Calling Convention”. For example, the RISC-V C Calling Convention is defined here and lays out the sizes of C primitives and how arguments and return values are passed from and to functions. x86 architectures have many different calling conventions: the Microsoft x64 calling convention and the System V ABI are the most common, but many calling conventions exist to improve calling performance (
fastcall,regcall, and more). When we say “the C calling convention” we usually mean “the C calling convention commonly used on this OS+architecture combination”. ↩ -
Yes, generally speaking
libcis distributed not as a static but as a dynamic library, which is a completely different way of linking and calling functions. ↩
Primitive types in FFI
So far, the exercises have passed data between C and Rust mostly as pointers and plain integers. The following sections cover how other types cross the FFI boundary, starting with primitive types, then structs, enums and unions, slices, and opaque types.
A C header describes its parameters in C’s own types: int, long, char,
size_t. Rust has i32, i64, u8, usize. Writing a binding means pairing
them up, and most of those pairs work on every platform you’re likely to target.
Take this function:
int scale(int value, int factor);
On Windows, macOS, and Linux, on x86-64 and aarch64, an int is the same as an
i32 in Rust. If you only target those platforms, the Rust code looks like
this:
unsafe extern "C" {
fn scale(value: i32, factor: i32) -> i32;
}
The mapping
Here is how the common primitive types map from C to Rust when targeting Windows, macOS, or Linux on x86-64 or aarch64:
| C type | Rust type | Alias |
|---|---|---|
char | i8 (or u8 on aarch64 Linux) | c_char |
signed char | i8 | c_schar |
unsigned char | u8 | c_uchar |
short | i16 | c_short |
unsigned short | u16 | c_ushort |
int | i32 | c_int |
unsigned int | u32 | c_uint |
long | i64 (or i32 on Windows) | c_long |
unsigned long | u64 (or u32 on Windows) | c_ulong |
long long | i64 | c_longlong |
unsigned long long | u64 | c_ulonglong |
float | f32 | c_float |
double | f64 | c_double |
size_t | usize | – |
ptrdiff_t | isize | – |
int32_t, uint64_t, … | i32, u64, … | – |
void * | *mut c_void | – |
T * | *mut T | – |
const T * | *const T | – |
The names in the last column are Rust type aliases for each C type. Each alias stands for whichever Rust type matches that C type on the platform you compile for.
They are defined in core::ffi, and std::ffi re-exports them, so
std::ffi::c_int and core::ffi::c_int are the same type. You’ll also see
std::os::raw::c_int in bindgen output and libc::c_int from the libc
crate. All of them resolve to the same alias.
The aliases are for code that targets platforms where a fixed-width Rust type
doesn’t match. On MSP430 or AVR, for example, C’s int is 16 bits, so c_int
is i16. If your code has to build there as well as on x86-64, where c_int is
an i32, the alias is the right option.
c_char and u8
A C char is a byte, and Rust’s byte type is u8, so writing *const u8 in a
binding where the header says const char * looks harmless, and it’s the most
common mistake in hand-written bindings: c_char is not always u8. It is i8
on x86-64, and on macOS and Windows for aarch64. It is u8 on aarch64 Linux, on
32-bit ARM, and on RISC-V.
The u8 binding might just work on your own machine, which is what makes this
easy to miss. Then someone builds for a target where c_char is an i8 and the
code stops compiling. Anything that hands you a c_char no longer lines up with
the u8 you wrote: CStr::as_ptr gives a *const c_char, and passing it where
a *const u8 is expected is now a type mismatch. So write c_char in the
signature.
The second issue is that c_char is not the type Rust’s byte methods work on.
b'a' literals, u8::to_ascii_lowercase, str::as_bytes, and CStr::to_bytes
all speak u8. So cast a c_char to u8 as soon as you have the value, do the
work there, and cast back on the way out:
use std::ffi::c_char;
/// Lowercases `byte` if it is an ASCII letter, and returns it unchanged
/// otherwise.
#[unsafe(no_mangle)]
pub extern "C" fn lower_byte(byte: c_char) -> c_char {
(byte as u8).to_ascii_lowercase() as c_char
}
Two details about those casts are worth naming:
- They change the type, not the value.
asbetweenc_charandu8keeps the bit pattern exactly, and casting back gives you the same byte again. - They are allowed because the layouts match.
c_charandu8have the same size and the same alignment on every target, and every bit pattern is valid for both. That makes the cast purely a compile time check.
Head to the exercise
The exercise gives you a C library over bytes with its bindings already written, and two small wrappers to finish.
Exercise
The exercise for this section is located in 02_basics/02_primitives
Structs in FFI
Memory layout showed that a Rust struct has no
specified layout unless you ask for one, and that repr(C) asks for the one a C
compiler would pick. This section covers a few more things to know about structs
in FFI.
What can go in the fields
A struct can only cross the FFI boundary if all of its fields can. These types are safe to use as fields:
- Primitives and raw pointers, mapped as in Primitive types in FFI.
- Other
repr(C)structs, nested by value, as well as enums and unions with a C-compatiblerepr, which the next section covers. - Fixed-size arrays. A C field
char name[16]is[c_char; 16]in Rust, stored inline in the struct, exactly as in C. - Nullable pointers as an
Option.Option<NonNull<T>>,Option<&T>, andOption<extern "C" fn(...)>are guaranteed to be one pointer wide, withNoneas null.
Types whose layout Rust keeps to itself, such as String, Vec<T>,
Box<dyn Trait>, tuples, and slices, can’t go in a shared struct. The
improper_ctypes lint warns when a struct used in an extern block lacks
repr(C) or has a field that can’t cross.
By value or by pointer
A C function can take or return a struct by value, or work through a pointer to one:
struct Point {
double x;
double y;
};
struct Point midpoint(struct Point a, struct Point b);
void translate(struct Point *point, double dx, double dy);
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Point {
pub x: f64,
pub y: f64,
}
unsafe extern "C" {
fn midpoint(a: Point, b: Point) -> Point;
fn translate(point: *mut Point, dx: f64, dy: f64);
}
By value, the callee gets its own copy, and nothing is shared once the call
returns. That suits small, plain data like a Point. Pass a pointer instead
when the struct is large, when the callee should modify the caller’s copy, or
when C holds on to the struct after the call returns.
Wrapping a single value
#[repr(transparent)] is for structs with exactly one field. It guarantees that
the struct has the same layout and the same function-call ABI as that field.
That makes it useful for type-safe wrappers around values C knows as plain
integers or pointers:
use std::ffi::c_int;
/// A file descriptor. C sees a plain `int`.
#[repr(transparent)]
pub struct Fd(c_int);
A one-field repr(C) struct has the same size and alignment as its field too,
but some ABIs pass a struct differently from the bare value inside it.
repr(transparent) guarantees they’re passed the same way.
Head to the exercise
The exercise gives you a C header, and you write the matching Rust types from
scratch. Point is a plain struct of two doubles. Shape holds an array, a
nested Point, and a pointer that may be null. Fd wraps an int. The tests
compare the size, alignment, and field offsets of each type against what the C
compiler reports for the same header.
Exercise
The exercise for this section is located in 02_basics/03_structs
Enums and unions in FFI
A Rust enum is a sum type, with variants that can carry data. A C enum is just a set of named integers. Only field-less Rust enums map onto C enums. For variants with data, C pairs an integer tag with a union. This section covers how to handle each at the FFI boundary.
Field-less enums
In memory, a field-less Rust enum is just its discriminant: the integer that
says which variant it is. #[repr(C)] gives the discriminant the size a C
compiler would pick for the same enum.
#[repr(C)]
pub enum Color {
Red = 0,
Green = 1,
Blue = 2,
}
In C, any integer fits in an enum type. You can assign 42 to an enum Color,
and a newer version of a library can add variants your code has never heard of.
In Rust, a value of an enum type must be one of its declared variants. An enum
holding any other discriminant is undefined behavior. So when C sends you an
enum value, declare the binding with the integer type, and convert it using
TryFrom:
use std::ffi::c_int;
#[repr(C)]
pub enum Color { Red = 0, Green = 1, Blue = 2 }
unsafe extern "C" {
fn current_color() -> c_int;
}
impl TryFrom<c_int> for Color {
type Error = c_int;
fn try_from(value: c_int) -> Result<Self, c_int> {
match value {
0 => Ok(Color::Red),
1 => Ok(Color::Green),
2 => Ok(Color::Blue),
other => Err(other),
}
}
}
Unions
A Rust union works like a C union: all fields start at offset 0, and the union
is as large as its largest field. Mark any union that crosses the boundary
#[repr(C)], since the default layout isn’t guaranteed to match C’s:
#[repr(C)]
pub union IntOrFloat {
pub i: u32,
pub f: f32,
}
A union doesn’t know which of its fields holds a value. Writing a field is safe,
but reading one is unsafe, because you’re asserting that the bytes are valid
for that field’s type. Reading a bool, an enum, or a reference from bytes that
hold something else can produce an invalid value, which is undefined behavior.
Union fields are also limited to types without a destructor, which in practice
means Copy types, because the union can’t tell which field’s destructor to
run.
Tagged unions from C
C often pairs an integer tag with a union, and relies on convention to keep the two in sync:
struct Value {
int kind; /* VALUE_INT or VALUE_FLOAT */
union {
int64_t i;
double f;
} data;
};
Mirror that layout as it is, with a repr(C) struct and a repr(C) union, and
keep the tag a c_int. Then convert into a Rust enum in one place, reading only
the union field the tag points to:
use std::ffi::c_int;
pub const VALUE_INT: c_int = 0;
pub const VALUE_FLOAT: c_int = 1;
#[repr(C)] pub union CValueData { pub i: i64, pub f: f64 }
#[repr(C)] pub struct CValue { pub kind: c_int, pub data: CValueData }
pub enum Value {
Int(i64),
Float(f64),
}
fn read_value(value: &CValue) -> Option<Value> {
// SAFETY: each arm reads only the field that `kind` marks as active.
match value.kind {
VALUE_INT => Some(Value::Int(unsafe { value.data.i })),
VALUE_FLOAT => Some(Value::Float(unsafe { value.data.f })),
_ => None,
}
}
An unknown kind becomes None instead of undefined behavior, and the rest of
your code works with a Value whose tag and data can’t disagree.
To send a Rust enum to C in this shape, convert the other way: set the tag, and
write the variant’s data into the matching union field. Writing a union field is
safe, so that direction needs no unsafe at all.
Head to the exercise
The exercise mirrors a C tagged union for you, next to the Rust enum the rest of the code works with. Convert that enum into the C struct, setting the tag and the matching union field for each variant.
Exercise
The exercise for this section is located in 02_basics/04_enums_and_unions
Slices in FFI
C has no slice type. A function that works on an array takes a pointer to the first element and, if you’re lucky, the number of elements as a separate argument:
int sum(const int *items, size_t len);
Rust’s slices are the same pair of pointer and length, packed into one value
with guarantees attached. A &[i32] always points to len valid, initialized
i32s, and the borrow checker makes sure they stay that way while you use them.
Turning C’s pair into a slice is one of the most common operations in FFI code,
and one of the easiest to get subtly wrong.
Slices from C
std::slice::from_raw_parts turns a pointer and a length into a slice, and
from_raw_parts_mut does the same for a mutable one. The
contract is longer
than most people expect:
- The pointer must be non-null and aligned, even when the length is 0.
- It must point to
lenconsecutive, initialized values of typeT, all within a single allocation. - The memory must not be mutated while the slice is in use. For
from_raw_parts_mut, it must not be accessed through any other pointer at all. - The total size,
len * size_of::<T>(), must be at mostisize::MAXbytes. - The lifetime of the slice is whatever the caller picks, so you have to tie it to something that keeps the memory alive.
The first rule is the one that catches people. In C, passing NULL with a
length of 0 is a normal way to say “no items”. For from_raw_parts, it’s
undefined behavior, because a slice’s pointer must never be null, not even an
empty one. So handle the empty case before you build a slice:
use std::slice;
/// # Safety
///
/// If `len` is non-zero, `ptr` must be non-null and aligned, and point to `len`
/// initialized `T`s that stay alive and unmodified for `'a`.
pub unsafe fn slice_from_c<'a, T>(ptr: *const T, len: usize) -> &'a [T] {
if len == 0 {
return &[];
}
// SAFETY: `len` is non-zero, so the caller guarantees the whole contract.
unsafe { slice::from_raw_parts(ptr, len) }
}
Everything else stays the caller’s responsibility. If C says 10 and allocated 8,
you read past the end, and the # Safety section is the place that says whose
job it was to prevent it.
Debug builds check some of these rules at runtime and abort with
unsafe precondition(s) violated when C passes NULL. Release builds skip
those checks, and the same call can appear to work. Don’t depend on this debug
check.
Slices to C
Handing a slice to C is simpler. as_ptr and len give you the two halves, and
as_mut_ptr gives you a *mut T for C functions that write into the buffer:
void scale_all(int32_t *items, size_t len, int32_t factor);
// Stands in for the C implementation, so that this example links.
#[unsafe(export_name = "scale_all")]
unsafe extern "C" fn scale_all_impl(items: *mut i32, len: usize, factor: i32) {
let items = unsafe { std::slice::from_raw_parts_mut(items, len) };
items.iter_mut().for_each(|item| *item *= factor);
}
unsafe extern "C" {
fn scale_all(items: *mut i32, len: usize, factor: i32);
}
let mut items = [1_i32, 2, 3];
// SAFETY: the pointer and length describe `items`, which isn't accessed
// elsewhere during the call.
unsafe { scale_all(items.as_mut_ptr(), items.len(), 2) };
One thing to watch out for: The pointer of an empty slice is dangling: non-null,
but not pointing to any memory. C code that checks ptr != NULL instead of
len != 0 will cause problems.
This only lends the memory to C for the duration of the call. That’s also why
there’s no into_raw_parts to mirror from_raw_parts: a slice borrows its
memory, so it has no ownership to give away. Handing ownership over is a
different matter, which
Allocation, allocators, and mixing them
covers.
Head to the exercise
The exercise asks you to implement two functions that take a pointer and a length the way C would pass them: one that reads the items, and one that modifies them in place. The tests call them with normal input, and with both ways of saying “no items”: a null pointer with a length of 0, and a valid pointer with a length of 0.
Exercise
The exercise for this section is located in 02_basics/05_slices
Opaque types
Some types can’t cross the FFI boundary by value. A Vec is one of those, and
so is a C FILE in the other direction. Neither can be mirrored field by field,
because neither layout is guaranteed: Rust reserves the right to change Vec’s,
and the C standard leaves FILE’s up to each C library.
Instead, pass a pointer across the FFI and keep the type behind it private. C
calls this an opaque type, and it’s the shape most C libraries already use:
FILE *, sqlite3 *, pthread_mutex_t *. One side owns the object, its
layout, and its memory, and the other side only ever holds the address. So an
opaque type is really a question of ownership: who creates the object, and who
frees it.
A Rust type behind a C handle
The C side gets a type name with no definition, and functions that take a pointer to it:
typedef struct Counter Counter;
Counter *counter_new(uint32_t start);
void counter_add(Counter *counter, uint32_t amount);
uint32_t counter_value(const Counter *counter);
void counter_free(Counter *counter);
typedef struct Counter Counter; does two things. struct Counter declares a
struct without defining it, and the typedef lets C code write Counter
instead of struct Counter. The header never defines the struct, so C can pass
Counter * around, but it can’t allocate one, copy one, or read a field.
On the Rust side, Counter is an ordinary Rust struct. The constructor puts it
on the heap and hands ownership to C, and the destructor takes it back:
pub struct Counter {
value: u32,
}
#[unsafe(no_mangle)]
pub extern "C" fn counter_new(start: u32) -> *mut Counter {
Box::into_raw(Box::new(Counter { value: start }))
}
/// # Safety
///
/// `counter` must be null, or come from `counter_new` and not be used again.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_free(counter: *mut Counter) {
if !counter.is_null() {
// SAFETY: the pointer came from `Box::into_raw` in `counter_new`.
drop(unsafe { Box::from_raw(counter) });
}
}
Box::into_raw turns the box into a raw pointer without dropping the counter,
and Box::from_raw turns the pointer back into a box, which drops the counter
when the box goes out of scope.
The operations in between borrow the counter for the duration of one call.
&mut *counter turns the raw pointer into a &mut Counter, and &*counter
into a &Counter:
pub struct Counter { value: u32 }
/// # Safety
///
/// `counter` must come from `counter_new` and must not have been freed. No
/// other call may use the same counter for the duration of this one.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_add(counter: *mut Counter, amount: u32) {
// SAFETY: the caller guarantees a live counter that nothing else uses.
let counter = unsafe { &mut *counter };
counter.value += amount;
}
/// # Safety
///
/// `counter` must come from `counter_new` and must not have been freed. No
/// other call may modify the same counter for the duration of this one.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_value(counter: *const Counter) -> u32 {
// SAFETY: the caller guarantees a live counter that nothing modifies.
let counter = unsafe { &*counter };
counter.value
}
These references are only sound because of the promises in the # Safety
sections: the pointer is live, and nothing else uses the counter while the
reference exists. A &mut Counter must be the only way to reach the counter, so
two overlapping calls on the same counter would be undefined behavior.
Two rules keep this sound:
- Pair every constructor with a destructor. Every
counter_newneeds exactly onecounter_free. After that, the pointer C holds is dangling. - Don’t keep references past the call. C may free the counter at any time, so a reference stored somewhere would outlive it.
A C type behind a Rust handle
In the other direction, a C library hands you a pointer to something you know nothing about:
typedef struct Widget Widget;
Widget *widget_open(int id);
int widget_id(const Widget *widget);
void widget_close(Widget *widget);
Don’t guess its fields. Rust has a pattern for a type that stands for “something whose layout is none of my business”:
use std::ffi::c_int;
use std::marker::{PhantomData, PhantomPinned};
#[repr(C)]
pub struct Widget {
_data: (),
_marker: PhantomData<(*mut u8, PhantomPinned)>,
}
unsafe extern "C" {
fn widget_open(id: c_int) -> *mut Widget;
fn widget_id(widget: *const Widget) -> c_int;
fn widget_close(widget: *mut Widget);
}
Both fields are private, so the only way to get a Widget is a pointer from C.
_data keeps the improper_ctypes lint quiet, which would otherwise flag a
struct made only of PhantomData. The *mut u8 in the marker makes the type
neither Send nor Sync, so Rust won’t assume it can be used from another
thread. PhantomPinned makes it !Unpin, because the C library may rely on the
object staying at the same address.
To make sure every widget is closed exactly once, wrap the pointer in a type
that owns it. NonNull turns C’s null into None, and Drop closes the widget
when the wrapper goes out of scope:
use std::ffi::c_int;
use std::marker::{PhantomData, PhantomPinned};
use std::ptr::NonNull;
#[repr(C)]
pub struct Widget { _data: (), _marker: PhantomData<(*mut u8, PhantomPinned)> }
unsafe extern "C" {
fn widget_open(id: c_int) -> *mut Widget;
fn widget_id(widget: *const Widget) -> c_int;
fn widget_close(widget: *mut Widget);
}
// Stand in for the C library, so that this example links.
#[unsafe(export_name = "widget_open")]
extern "C" fn widget_open_impl(id: c_int) -> *mut Widget {
Box::into_raw(Box::new(id)).cast()
}
#[unsafe(export_name = "widget_id")]
extern "C" fn widget_id_impl(widget: *const Widget) -> c_int {
unsafe { *widget.cast::<c_int>() }
}
#[unsafe(export_name = "widget_close")]
extern "C" fn widget_close_impl(widget: *mut Widget) {
drop(unsafe { Box::from_raw(widget.cast::<c_int>()) });
}
pub struct OwnedWidget(NonNull<Widget>);
impl OwnedWidget {
pub fn open(id: c_int) -> Option<Self> {
// SAFETY: `widget_open` returns either a valid widget or null.
let widget = unsafe { widget_open(id) };
NonNull::new(widget).map(OwnedWidget)
}
pub fn id(&self) -> c_int {
// SAFETY: the widget is still open, because `OwnedWidget` only closes
// it in `drop`.
unsafe { widget_id(self.0.as_ptr()) }
}
}
impl Drop for OwnedWidget {
fn drop(&mut self) {
// SAFETY: the widget came from `widget_open`, and `OwnedWidget` is its
// only owner, so it is closed exactly once.
unsafe { widget_close(self.0.as_ptr()) };
}
}
Head to the exercise
The exercise has both directions, each with a twist the examples above don’t
have. First, expose a Rust Stats type to C as a handle. Unlike the counter, it
owns a Vec of its own, which has to be freed along with it. Then wrap a C
Buffer in a Rust type that frees it exactly once.
Exercise
The exercise for this section is located in 02_basics/06_opaque_types
When hand-written bindings drift
In What is FFI? you wrote an extern "C" block binding to
C code. It worked great, but can you imagine doing that for hundreds, maybe
thousands, of functions and types? There’s a subtler problem, too.
Hand-written bindings will inevitably drift out of sync with the C header,
especially at scale.
Neither the C nor the Rust compiler can detect this, because each operates in
its own small universe, called a compilation unit. A compilation unit is the
single chunk of work that flows through the various stages of the compiler. In
C/C++ this would typically be a .c/.cpp file and in Rust that is typically a
single crate. The compiler parses, typechecks, and optimizes the compilation
unit, then produces an intermediate object file. Once all the compilation
units that make up your project are built, the intermediate object files are
gathered and passed to the linker, which produces the final executable or
library.
┌─────────────────────┐ ┌─────────────────────┐
│ Rust source │ │ C source │
└──────────┬──────────┘ └──────────┬──────────┘
│ rustc │ cc
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ compile │ │ compile │
│ (parse → typecheck → │ │ (parse → typecheck → │
│ optimize → codegen) │ │ optimize → codegen) │
└──────────┬────────────┘ └──────────┬────────────┘
│ object file │ object file
└───────────────┬────────────────┘
▼
┌─────────────────────┐
│ link │
│ (ld / lld) │
└──────────┬──────────┘
▼
┌─────────────────────┐
│ final binary │
└─────────────────────┘
This model is great for compilation performance, because we can process many of these compilation units in parallel. There is a catch: they must not share information, since that would destroy the parallelism. Each compilation must be its own self-contained universe.
And even if we had a mechanism to share information between compilation units, we would need to make it language-agnostic so that a C compilation unit and a Rust compilation unit can interoperate. How would that even work with wildly different type systems?1
So compilers have resorted to manual escape hatches like the extern "C"
block you wrote. You as the programmer promise to the compiler that a function
with a given name and a given signature will exist at link time, and the
compiler takes your word for it.
Possible consequences
The consequences of drift between the two versions can be dire: if C expects
int but Rust expects float, the integer bit patterns are reinterpreted as
floats. The result is almost always nonsensical.
It can be worse: passing too many or too few arguments can result in either overwriting important information on the stack or reading garbage from it.
Here is an especially insidious case. The function, as we have established, is the following:
int bm_add(int a, int b);
but now in the Rust code, we expect it to return 64-bit integers instead of 32-bit integers:
use std::ffi::c_longlong;
unsafe extern "C" {
fn bm_add(a: c_longlong, b: c_longlong) -> c_longlong;
}
How will this example fail? If you try it yourself, you will notice that it
doesn’t. Function arguments are passed in registers, and on a 64-bit machine
those registers are 64 bits wide, so a 32-bit int occupies one of them with
room to spare. What the upper half of the register holds is not something the
declaration can rely on: some ABIs require a narrow integer to be sign-extended
across the full register, others leave the high bits unspecified entirely.
Nothing widens the argument on your behalf. Reading it back as a 64-bit integer
just happens to give the right answer here.
You can see this in Compiler Explorer here:
The correct version passes a and b in the
a0 and a1 registers (the li a0, 1 and li a1, 2 lines do the loading) and
passes the return value in the a0 register. Those registers are 64 bits wide,
so you can see the
incorrect c_longlong-expecting version
just works: the values are small and positive, and nothing has put anything
else in the upper halves of a0 and a1.
Now, if your 64-bit numbers stay below the 32-bit max value, everything just
happens to work. But it is very fragile. As you can see here,
when we compile to a 32-bit target instead,
everything breaks and we end up adding 0 to a instead.
This example is particularly scary, because for 99% of inputs and deployment configurations the mistake is virtually consequence-free. The addition will continue to work as expected. But as soon as the input is unusual, the deployment target is different, or you add code that will make the compiler change the generated code even a bit, this will be a bug that takes you weeks to troubleshoot in the worst case.
Head to the exercise
The exercise turns this around: the Rust binding stays fixed and correct-looking while the C implementation drifts underneath it. It is the same mismatch, and therefore the same bug, from either side. Drifting the C side just keeps the Rust crate compiling, so you can run it and watch what happens.
Play with the different “drifted” C implementations of bm_add to see what
happens on your machine. Feel free to also play around a bit with the Compiler
Explorer playgrounds to see how different miscompilations manifest in the
generated assembly.
Exercise
The exercise for this section is located in 02_basics/07_drift
-
Yes, the LLVM bitcode embedded by toolchains for LTO (Rust’s
-Clto=thin -Cembed-bitcode=yesand Clang’s-flto=thin) does carry the information to catch problems like this at link-time and it is cross-language, but linkers do not generally validate it. (Thewasm-ldlinker does, but only for Wasm.) The reasons are many, the most important being that validating it would break existing compiler optimizations. You could write custom LLVM-bitcode parsing tooling to check this if you wanted. ↩
Generating Rust bindings with bindgen
In the previous exercise you saw how easy it is for manual bindings to go out of
sync, and the scary silent corruption that causes. This section introduces a
tool that avoids all of this: bindgen. It’s a library you call
from your Rust build script to generate Rust bindings to C code from C header
files. It runs libclang on a C header and emits matching Rust declarations:
extern blocks, #[repr(C)] structs, integer constants. The header remains the
single source of truth, with the Rust bindings being generated on every build.
You call bindgen from your build script like so:
let bindings = bindgen::Builder::default()
.header("c_src/bm_legacy.h")
// tell Cargo to re-run when headers change
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("bindgen failed");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings.write_to_file(out_path.join("bindings.rs")).unwrap();
This writes the generated file into the crate’s OUT_DIR (usually
target/<debug or release>/build/<crate name and hash>/out). Inspecting the
generated file, you will see something like this:
/* automatically generated by rust-bindgen 0.72.1 */
unsafe extern "C" {
pub fn bm_add(a: ::std::os::raw::c_int, b: ::std::os::raw::c_int) -> ::std::os::raw::c_int;
}
You can then pull this generated file into your crate with the include! macro
like so:
mod sys {
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}
This is great: we’ve gained end-to-end type checking. A change in the C header
will not silently corrupt our Rust code. But remember, these bindings are as
unsafe as the C code itself. It’s your responsibility to use them correctly.
You would typically wrap them in a safe, idiomatic API. This is the common
-sys crate pattern: a <crate>-sys crate holds the raw bindings, while the
<crate> crate on top exposes the safe abstractions.
Head to the exercise
Replace the hand-written extern block from the previous section with bindgen
in build.rs. The tests stay identical.
Tip
cargo expand -p bm_bindgen shows what bindgen produced. Requires
cargo-expand.
Exercise
The exercise for this section is located in 02_basics/08_bindgen
Exposing Rust to C with cheadergen
With bindgen we generated the Rust declarations for calling C. Now we go the
other way: our goal is to migrate C code to Rust, so C will need to call into
our new Rust code.
extern "C" fn and #[unsafe(no_mangle)] take care of the calling convention
and the symbol name:
use std::ffi::c_char;
#[unsafe(no_mangle)]
pub extern "C" fn bm_strlower(s: *mut c_char) {
// ...
}
What’s still missing is a header file that tells the C compiler about the API.
You could write it by hand, but you’d be maintaining two sources of truth.
cheadergen (the inverse of bindgen) generates C
headers from Rust code.
cheadergen, created and maintained by our colleague Luca Palmieri, reads your
Rust code and generates a C header for the relevant FFI-visible symbols.
Install the CLI from the
cheadergen releases page.
Then you can run the following command in your crate root (for example, the current exercise crate):
cheadergen generate
and a header will appear:
/* Generated by cheadergen — do not edit */
void bm_strlower(char *s);
cheadergen comes with two ways to configure its output. The first is a
project-wide cheadergen.toml file. Here you can tweak the style of the
generated headers, include custom preamble text, and more. See the full
documentation here.
The second configuration option is the #[cheadergen::config(...)] attribute.
You can apply this attribute to any Rust type. It lets you configure per-type
options such as renaming fields, skipping exporting, forcing exporting, and
more. See the full documentation here.
A note on cbindgen
If you’ve been researching Rust FFI tooling, you’ve undoubtedly come across
cbindgen, a similar tool maintained by Mozilla, and you may be
wondering: “Why did you just introduce me to this custom tool instead?”
Well, we wouldn’t have built a new tool if the existing one fit. cbindgen
dates back to 2017, when the Rust ecosystem looked quite different. To read your
Rust and emit headers, it had to parse the source itself with a custom
syn-based parser. At the time, that was the only way, since the compiler
didn’t expose enough information.
But the technique has real limits. The custom parser doesn’t always agree with
rustc, it often needs manual steering and fixups, and when it hits something it
can’t handle it tends to silently skip generating a header rather than tell you
why. None of this is a knock on cbindgen: it’s what the constraints of the era
allowed. cheadergen exists because those constraints have since eased, letting
us build something with better diagnostics and fewer surprises.
Head to the exercise
You’ll port the bm_strlower function to Rust and call
cheadergen generate --lang c --output-dir c_test -p bm_cheadergen to generate
the header automatically.
Exercise
The exercise for this section is located in 02_basics/09_cheadergen
The FFI boundary as a firewall: validate and narrow
By now you’ve written extern "C" functions in both directions, seen bindgen
generate Rust code and cheadergen generate C headers.
On paper that is everything you need, and it’s tempting to go ahead and rewrite your project now. Take your C API and translate it one-for-one into Rust.
The problem is that often you can’t, and just as often you shouldn’t. Real-world C is complicated, idiosyncratic, and (let’s admit it!) full of skeletons. You’re likely thinking about a Rust rewrite not just to address security problems or performance issues; rewriting in Rust is a chance to clean up your codebase: re-establish module boundaries, drop legacy assumptions, write the module you wished you had.
At the same time big-bang rewrites (where you replace the entire codebase at once) famously never work. Which leaves a problem: you have a clean Rust codebase, a messy legacy codebase, and a need to keep both working together for some time1.
During this transitional period, the FFI layer is where those worlds meet. It has to bridge unsafe-everything-goes C and the borrow checker, and bridge your new design and the old one.
Many people think FFI is about type translation. That’s true as far as it
goes, but it’s like saying programming is about typing words into a computer: it
misses the point. The main job of your FFI interface is establishing
confidence. In a legacy codebase you rarely know with 100% certainty that
reality matches your assumptions: some caller might actually pass a null
pointer, or the *const c_char that used to be UTF-8 might now hold binary
data.
The FFI boundary is your chance to turn legacy uncertainty into known-good state before it crosses into your new system. Uncertainty leaks. Your FFI must be a vigilant firewall against it.
To this end, we’ve settled on a handful of rules that we now live by.
Always validate your inputs
Validate every assumption at the FFI boundary. Null checks, length checks, UTF-8 checks. Panic or return an error if anything is even slightly off. In web servers, the HTTP handler function is your primary interface to the “chaos world” of the internet. Think of FFI functions the same way: they are your primary interface to the legacy-C “chaos world”.
You should also lean on Rust’s type system so “forgot to check” isn’t even an
option. For example: at Mainmatter, we encourage contributors to use
Option<NonNull<T>> instead of *mut T as much as possible, for the simple
reason that a *mut T can be dereferenced directly (*ptr), which triggers UB
if the pointer is null. You would have to add a manual if ptr.is_null() {}
check before every pointer dereference. With Option<NonNull<T>>, on the other
hand, the type system forces you to handle the None case explicitly. Even
the laziest .unwrap() will result in a loud panic instead of potentially
silent UB.
// `*mut T` silently accepts null. You'll remember to check. Until you don't.
pub extern "C" fn bm_do_thing(input: *mut Thing) -> BmResult { /* … */
}
// `Option<NonNull<T>>` encodes null as `None` — the compiler forces the branch.
pub extern "C" fn bm_do_thing(input: Option<NonNull<Thing>>) -> BmResult {
let Some(input) = input else {
return BmResult::ErrInvalid;
};
// `input: NonNull<Thing>`, statically non-null.
/* … */
}
Don’t overload primitives
Legacy C code has a habit of packing several meanings into one return type.
POSIX read() is a good example: -1 means error (check errno), 0 means
end-of-file, anything positive is the byte count. It is very easy to mess this
up. Rust gives you better tools: bool for yes/no, an enum for branching
outcomes, an out-parameter for the count. These types usually translate well
into C headers too, so use them.
#[repr(C)]
pub enum BmReadStatus { Ok, Eof, IoError }
pub extern "C" fn bm_read(/* … */, out_bytes: Option<NonNull<usize>>) -> BmReadStatus;
Head to the exercise
We’ll continue the porting work by looking at bm_normalize_url, in
exercises/_bm/src/normalize.c. It will normalize a given URL by lowercasing it
and writing the normalized string into the provided buffer. The exercise already
contains our “first draft” Rust translation: a naive transliteration from C to
Rust that has a number of issues.
Let’s apply what we learned above and fix the implementation.
Hint: check the exercise’s tests for the expected behavior of
bm_normalize_url.
Exercise
The exercise for this section is located in 02_basics/10_validate_and_narrow
-
The exact amount of time of course varies with the scale of your codebase, but from hard-won experience: measure this in years. C codebase migrations have a funny habit of taking much, much longer than you think. As a rule of thumb: take your worst-case estimate, double it, and add a year. Really. ↩
High-quality FFI APIs
We previously introduced two “mechanical” rules: always validate your inputs, and don’t overload primitives. The two that follow are more conceptual. Don’t let that fool you: they are just as important as the first two, if not more so.
Documentation, documentation, documentation
Some invariants you can check at runtime. Many you can’t: who owns this pointer,
whether the string is copied or borrowed, whether free has already run, what
each error variant means. Write them all down.1 Rust’s # Safety convention,
being just a comment, works with extern "C" functions too, and cheadergen
emits those sections as C doc comments in the header files:
/// Normalize a URL into the caller's buffer.
///
/// # Ownership
/// `url` and `out` are borrowed for the call; the caller continues to own both.
///
/// # Safety
/// 1. `url`, if non-null, must be a [valid] pointer to a NUL-terminated UTF-8 byte sequence.
/// 2. `out`, must be a [valid], non-null pointer to a writable buffer of at least `out_len` bytes.
///
/// # Errors
/// - `BmResult::ErrInvalidUrl` if `url` is null or not valid UTF-8.
/// - `BmResult::ErrBufferTooSmall` if the result wouldn't fit in `out_len` bytes.
///
/// [valid]: https://doc.rust-lang.org/std/ptr/index.html#safety
#[unsafe(no_mangle)]
pub unsafe extern "C" fn bm_normalize_url(
url: Option<NonNull<c_char>>,
out: Option<NonNull<c_char>>,
out_len: usize,
) -> BmResult {
let Some(out) = out else {
//...
};
// SAFETY: caller ensured `out` points to at least `out_len` bytes (2.)
unsafe { slice::from_raw_parts_mut(out.as_ptr(), out_len) };
}
Note how the safety invariants are numbered. A plain # Safety section lists
what the caller must uphold; numbering the entries lets every inline
// SAFETY: comment point at the one it relies on. We require each of those
comments to either:
- delegate its local invariant to the surrounding function’s safety comment, in which case it must reference a numbered invariant; or
- explain exhaustively why the code itself upholds the local invariant.
This way we make sure that all invariants are either upheld by the function itself or correctly documented as a responsibility of the caller.
At the moment, the numbered-invariant convention is checked by discipline and PR
review. Clippy can require # Safety documentation and comments on unsafe
blocks, but it cannot verify that a local justification actually upholds an
invariant. There are proposals for tools that could make this kind of
traceability machine-checkable in the future.2
Mind the FFI tax
Every exposed function is API surface you’ll maintain forever, an unsafe
contract to keep correct, and a per-call cost the compiler can’t optimize away.
Cross-language LTO can inline across the boundary, at the cost of a real setup
burden. The cheapest FFI function is the one you didn’t expose. Prefer coarse
operations, such as bm_thing_update_with(...), over one setter per field, and
treat the boundary as a small set of verbs, not a mirror of your internal
struct.
Head to the exercise
You’ll update your solution to exercise 2.10 with the two rules from this section.
Exercise
The exercise for this section is located in 02_basics/11_high_quality_ffi_apis
-
You may say, jadedly, that no one ever reads comments, and you may be right. But with the rise of LLMs, something does read them. We’ve found that LLMs struggle with the nuanced, unspoken invariants of FFI code, which is not surprising. Turning as many of these unspoken invariants into spoken ones helps you get better mileage out of these tools. ↩
-
There are a couple of related proposals, all in the “pre-RFC” stage. The most interesting is the safety-tags pre-RFC. ↩
Chapter 3: Intermediate
From now on we will be porting a “real” C application: the bm bookmark manager
CLI. We will learn how to approach real-world codebases, structure our approach,
and debug our code when it breaks.
In this chapter we’ll pick our first module to rewrite, learn how C and Rust
allocators (don’t) mix, debug across the language boundary with lldb/gdb,
harden our unsafe code with Clippy lints, replace C-isms with Rust-isms, catch
undefined behavior with Miri, and benchmark the port against the C baseline.
Exercises
The exercises for this chapter are located in exercises/03_intermediate
Exercise
The exercise for this section is located in 03_intermediate/00_README
How to rewrite a module
In the previous chapter you learned the FFI building blocks: extern blocks,
generated bindings, FFI-safe types, and how to design a boundary that acts as a
firewall. Now we’ll put them to work on a real codebase. Meet bm, a small
bookmark manager CLI written in C.
Here is an overview of the C source code:
exercises/_bm/src/
├── bookmark.c the Bookmark type
├── bookmark.h
├── cli.c entry point, argument parsing
├── index.c in-memory bookmark index
├── index.h
├── internal.h definitions shared by index.c and storage.c
├── normalize.c URL and tag normalization
├── normalize.h
├── result.h shared result codes and error-string helper
├── storage.c on-disk persistence of .bm files
├── storage.h
├── tag.c tag parsing and matching
├── tag.h
├── util.c string helpers
└── util.h
Picking the first module
There is no universally best place to start. A leaf module is often a good choice because it has few dependencies. The program’s entry point can also work if it mostly calls other modules through simple interfaces. Sometimes the best boundary does not match an existing source file at all, and it is worth extracting a small module before porting it.
The right choice depends on the codebase, but a few guidelines help:
- Prefer a small, clearly defined interface.
- Minimize the number of C functions and data structures the Rust code must use.
- Avoid shared global state and complicated ownership rules in the first port.
- Choose code with useful tests, so you can compare behavior before and after.
- Keep the change small enough to review and, if necessary, revert.
cli → bookmark, index, normalize, result, tag, util
index → bookmark, internal, normalize, storage, tag, util
storage → bookmark, index, internal, normalize, util
tag → normalize, util
bookmark → util
normalize → (no project dependencies)
util → (no project dependencies)
Two things are worth noting. normalize and util are the only real leaves:
every other module pulls in at least one of its siblings. And index and
storage include each other, so this is not the clean tree the file names
suggest. Tangles like that are normal in code that has been maintained for a
while, and they are exactly what you want to know before choosing where to
start.
The exercise uses an even smaller boundary than these production modules: a
one-function bm_version module. Its behavior is intentionally trivial so that
the work is limited to replacing a C object file with a Rust static library.
Preserve the existing contract
When possible, an incremental migration replaces the module’s object file without changing the interface used by the remaining C code. The existing C header describes the ABI our Rust implementation must initially satisfy. The exercise’s header contains this declaration:
#include <stdint.h>
uint32_t bm_version(void);
The Rust replacement must export the same function under the same symbol name and use the C ABI:
#[unsafe(no_mangle)]
pub extern "C" fn bm_version() -> u32 {
1
}
The rest of the C code doesn’t know, and doesn’t need to know, that the implementation behind the symbol changed. The linker resolves the same symbol names as before; it just finds them in our Rust static library instead of the old object file.
That is a migration constraint, not necessarily the interface we want in the long term. Some C headers expose globals, macros, shared data structures, or ownership assumptions that cannot be reproduced cleanly in Rust. In those cases, we can introduce a small compatibility layer and improve the interface separately.
After that, all you have to do is link against the replacement library written in Rust instead of the original one written in C. Cargo must first produce the right kind of artifact:
[lib]
crate-type = ["staticlib"]
before: test_bm_version.o + bm_version.o (C)
after: test_bm_version.o + libbm_version.a (Rust)
Structuring the Rust side
For a substantial module, keep the two layers from the firewall pattern in
section 2.10: a thin extern "C" surface that converts C representations and a
safe Rust core that implements the behavior. bm_version takes no arguments and
returns a fixed-width integer, so in this case the exported function can remain
the entire implementation.
Verifying behavior parity
A rewrite must not change observable behavior. The exercise checks this by running the same C caller twice:
- first it links the caller with
bm_version.cand runs the all-C baseline; - then it omits
bm_version.c, links the unchanged caller with the Rust archive, and runs it again.
A Rust unit test independently checks the function’s return value. The C link is what proves that the archive exposes the ABI and symbol promised by the header.
Tips
If the linker complains about an undefined symbol, inspect the symbols your Rust static library exports. The command depends on your toolchain and platform:
- Unix-like systems:
nm target/debug/lib<crate>.a - LLVM toolchains:
llvm-nm target/debug/lib<crate>.a - Windows with MSVC:
dumpbin /symbols target\debug\<crate>.lib
A missing #[unsafe(no_mangle)] is a common cause.
While both implementations still exist, you can also run the same inputs through the C and Rust versions and compare their results. This is called differential testing. The exercise uses the simpler form of running the same assertions against both implementations.
Head to the exercise
The exercise in exercises/03_intermediate/01_rewrite_a_module replaces
bm_version.c. Configure the crate to produce a static library, then make the
provided Rust function satisfy bm_version.h without changing the header or the
C caller. There is no algorithm to port: the exercise focuses on the build
artifact, ABI, symbol name, and link step.
Exercise
The exercise for this section is located in 03_intermediate/01_rewrite_a_module
Allocation, allocators, and mixing them
In the previous section you saw how to replace a whole C module with Rust while
keeping the interface identical. But bm passes more than integers and borrowed
strings across the boundary: it passes ownership. bookmark_new allocates a
Bookmark that someone else will free later. Who frees it, and with what? This
is where mixed-language codebases hide some of their nastiest bugs and most
difficult design challenges.
The golden rule
Every allocation must be released through its corresponding deallocation API.
That’s the whole rule. C’s malloc/free1 and Rust’s global allocator are
two independent bookkeeping systems2. Handing a Rust-allocated pointer to C’s
free (or a malloc’d pointer to Box::from_raw) is undefined behavior, even
if it happens to “work” on your machine because both forward to the same
underlying malloc today. They might stop doing so tomorrow, or not do so at
all on a different architecture.
┌──────────────┐ Box::into_raw ┌──────────────┐
│ Rust global │ ───────────────► │ C code │
│ allocator │ ◄─────────────── │ (borrows) │
└──────────────┘ Box::from_raw └──────────────┘
Crossing the boundary in practice
There are several workable strategies:
-
Use C allocation APIs from Rust. Rust can call a C constructor such as
bookmark_newand wrap the returned pointer in an owning Rust type. ItsDropimplementation releases the allocation by calling the matching C function,bookmark_free. Keep allocation and deallocation within the same C API; export the functions through FFI to Rust.Opaque types shows the shape of such a wrapper.
-
Use Rust allocators from C. A Rust type that crosses the boundary will need a matching
_new/_freepair exported through FFI._newin Rust is paired with_freein Rust; C only ever holds the pointer and calls the Rust functions to allocate or free an object of that type.The basic pattern for this is to convert a
Boxto and from a raw pointer withinto_rawandfrom_raw:3let foo = Box::new(42); let ptr = Box::into_raw(foo); // This will convert the raw pointer back into a Box and when dropped free the memory: let _foo = unsafe { Box::from_raw(ptr) };Opaque types shows the full
_new/_freepair. -
Transfer ownership of a C allocation across the boundary. Use this when one language calls a C allocation API, transfers ownership of the result to the other language, and the receiving side later calls the matching C deallocation API. For example, Rust can allocate a NUL-terminated string with libc::malloc and pass it to C code that retains it and later calls
free.The reverse direction is valid too: C can transfer ownership of a value it allocated, for example with
malloc, to Rust, and Rust can later call the matching deallocation function through FFI. In both cases, the allocation and deallocation calls originate on opposite sides of the FFI boundary. The layout and ownership contract must exactly match what the receiving API expects. -
Use a
malloc/freeadapter as Rust’s global allocator. On Linux, Rust’s default global allocator typically already uses the system allocator, so this is usually unnecessary. It can still be useful when Rust must use a specific C allocation API. Implementing the adapter is non-trivial: it’s inherentlyunsafe, and you need to carefully uphold all the safety guarantees required by the GlobalAlloc trait. Do not pass Rust-owned values to C for it to free. Even with amalloc/freeglobal allocator, C cannot run Rust destructors; freeing a value containing aString,Vec, or other owned resource leaks what that value owns.Avoid if possible. An allocation adapter is trickier than it seems. The code is small, but getting its allocation contract right requires a lot of implicit knowledge. Only use one when you have a specific interoperability need.
-
Use the Allocator API. As of this writing, the API is still experimental and only available on nightly Rust. It functions similarly to
GlobalAlloc: you have to implement your own allocator, but unlike the global one, it is a lot more flexible and can be used on a per-variable basis. The new API adds an Allocator trait andAllocatortype parameters to many allocation-owning types, such asBoxorVec.pub struct Box<T, A = Global>() // Global is the global allocator implementing the Allocator trait where A: Allocator, T: ?Sized;Additionally, it adds a few new functions where you can pass an allocator, for example
new_in:#![feature(allocator_api)] use std::alloc::System; let foo = Box::new_in(1, System); let custom_alloc = SomeCustomAlloc::new(); let bar = Box::new_in(2, custom_alloc);fooandbarnow use different allocators.Avoid if possible. The same warning as for
GlobalAllocapplies here: implementing an allocator is a non-trivial undertaking and is only worth it if you have a specific use case.This API is primarily useful for arena or bump allocators, where many related values are allocated from the same memory region and released together. Another useful case is selectively integrating parts of your program with a different allocation system while still using standard owning types such as
BoxandVec.
Spotting allocator mismatches
Tools like Valgrind give every allocation a birth certificate: run the test
binary under valgrind --leak-check=full and it will tell you not just that a
block leaked, but which call stack allocated it.
Valgrind has no Apple Silicon support, so on an M-series Mac reach for leaks,
for AddressSanitizer via -Zsanitizer=address, or, for the Rust side of the
boundary, for Miri, which we cover in a later section.
Head to the exercise
In exercises/03_intermediate/02_allocators, you’ll write a global allocator
that wraps the system allocator and counts every allocation and deallocation.
Then you’ll use it to track down a leak: a pair of functions hands strings to C,
and one of them never gives the memory back.
Exercise
The exercise for this section is located in 03_intermediate/02_allocators
-
malloc/freeare standard C allocation functions, but nothing prevents you from using other allocation functions or even rolling your own. For the sake of simplicity, we will always refer tomalloc/freein this section, but read it asmalloc/freeand all other custom (de)allocation functions. ↩ -
Currently, Rust’s default global allocator is unspecified, but on many platforms it is the system
malloc. Libraries such as cdylibs and staticlibs, however, are guaranteed to use theSystemallocator by default. Beware though: C’smalloc/freeAPI and Rust’s global-allocation API are not interchangeable, even when both happen to use the same underlying system allocator. ↩ -
There are some other useful structs, besides
Box, that come with their ownfrom_rawandinto_rawfunctions, likeVecorCString. The same pattern as forBoxapplies here: allocate in Rust → convert into raw pointer and transfer ownership to C → use the pointer in C → free the pointer by transferring ownership back to Rust. ↩
Using lldb/gdb
At some point in a migration, you’ll hit a crash at the FFI boundary with little
to go on. A C caller passes a string without the NUL terminator the API
requires, or Rust hands C a pointer whose lifetime has ended. The process takes
a SIGSEGV and dies before Rust can give you a tidy panic message or backtrace.
gdb and lldb can show the mixed C/Rust stack. Set a breakpoint in Rust, step
into C, and inspect both sides of the boundary in one session.
Build with debug information
Build both sides of the boundary with debug information. Cargo’s dev profile
already sets debug = true; for a release build, add this to Cargo.toml:
[profile.release]
debug = true
If build.rs compiles the C code with cc, pass -g there too:
cc::Build::new()
.file("c_src/shout.c")
.include("c_src")
.flag_if_supported("-g")
.compile("shout");
Forget it and you get Rust frames with line numbers and C frames without: precisely the half of the stack you opened the debugger for.
Start the debugger
Use rust-gdb or rust-lldb rather than plain gdb and lldb. They’re thin
wrappers that load Rust’s pretty-printers, which is what makes a String,
Vec, or Option readable instead of a pile of pointers and capacities.
On macOS, that’s usually rust-lldb:
rust-lldb target/debug/bm_lldb_gdb
On Linux, rust-gdb:
rust-gdb target/debug/bm_lldb_gdb
Program arguments go after -- for rust-lldb and after --args for
rust-gdb.
If you prefer a graphical frontend, rust-gdbgui starts the browser-based
gdbgui with the same pretty-printers loaded. Install
gdbgui separately.
rust-gdbgui target/debug/bm_lldb_gdb
Walk across the boundary
If you already know one debugger and need the equivalent command in the other, keep the official GDB to LLDB command map open. It covers both directions in one place.
Say you’re investigating bm_shout, the C helper called by the Rust exercise.
Set a breakpoint on it, run the program, and look at the stack when execution
stops (output abbreviated):
(lldb) b bm_shout
Breakpoint 1: where = bm`bm_shout at shout.c:6
(lldb) run
Process 51234 stopped
* thread #1, stop reason = breakpoint 1.1
* frame #0: bm`bm_shout at shout.c:6
frame #1: bm`bm_lldb_gdb::shout at lib.rs:17
frame #2: bm`bm_lldb_gdb::demo at lib.rs:30
frame #3: bm`main at main.rs:2
C frames and Rust frames are interleaved, and the numbering tells you which way
to walk: frame #0 is where execution stopped, and every higher number is one of
its callers. up moves toward callers, down back toward the stop. So the code
that handed over the bad value is always up, whichever language crashed. If
Rust crashed, up gets you to the C caller and the arguments it passed. If C
crashed, up gets you to the Rust frame that supplied them.
From there the usual commands work in both debuggers: s steps into a call, n
steps over one, finish runs the current frame to completion, bt prints the
stack, and p prints a value.
For a char *out, inspect the bytes directly: x/s out in gdb, and either
x/s out or memory read --format c-string out in lldb (the format name is
c-string, with the hyphen; cstring is rejected). For a raw buffer, use
x/16xb out in gdb or memory read --size 1 --count 16 --format x out in
lldb. This quickly tells you whether the pointer, length, and terminator agree
with the FFI contract.
When the process dies outright, you don’t need a breakpoint at all. Run it, let it take the signal, and print the stack:
(lldb) run
Process 51234 stopped
* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
(lldb) bt
The frame you land in is where the bad pointer was dereferenced, which is not necessarily where the bug is. Walk up until the values stop making sense.
A note on Rust symbol names
Everything above assumes your debugger can read the names in that stack. Since
Rust 1.97, rustc uses the v0 symbol-mangling scheme by default. Nightly has
done so since November 2025, so the toolchain this book pins already emits v0
symbols.
Symbol mangling is how the compiler turns a function into a linker-safe name.
For a C function, that’s usually just the function name. Rust also has to
distinguish generic instantiations: Vec<u8>::push and Vec<String>::push are
different generated functions and therefore need different symbols.
The old scheme used a hash for much of that distinction. It worked, but a backtrace through generic code often left you with a readable function name and an opaque suffix. The v0 scheme preserves the concrete generic arguments in a form tools can decode, so when you’re debugging or profiling a monomorphized call chain, the backtrace can show which instantiation actually ran rather than a hash.
This doesn’t affect functions exported to C with #[unsafe(no_mangle)], but it
does require every tool that reads Rust symbols to understand v0. Recent gdb,
lldb, and Rust tooling do. Older debuggers, profilers, or crash-symbolication
pipelines may show raw names such as _RNvNtNtCs... instead. Check your whole
debugging path before upgrading a pinned CI or production toolchain.
A quick test is enough: write a small generic function that panics, run it with
RUST_BACKTRACE=1, and read the backtrace.
See the stabilization PR and the v0 symbol-format documentation for details.
Head to the exercise
In this exercise a small C helper and its Rust binding disagree about something
that only shows up once you call across the boundary. Build it with
cargo build -p bm_lldb_gdb, then debug target/debug/bm_lldb_gdb. No
println! allowed.
Exercise
The exercise for this section is located in 03_intermediate/03_lldb_gdb
Clippy lints for unsafe Rust and static verification
In the previous section we learned how to debug an application, but debugging only gets you so far: it starts from a crash you already have, and works backward to the root cause.
Lints, on the other hand, help prevent certain issues in the first place. They catch a narrower set of problems and suspicious patterns before the code runs. Running them can be fully automated, which helps enforce coding standards across an entire team.
They are especially useful in unsafe code. They can require each unsafe
operation to be explicit, flag signatures that don’t state the caller’s
obligations, and surface undocumented assumptions. They can’t prove that a
pointer is valid or that a C caller is honest, but they can make unchecked
assumptions visible for review.
Built-in lints
You already have several useful lints enabled by default.
improper_ctypes and improper_ctypes_definitions are rustc lints,
warn-by-default, and you’ve encountered them already: they’re what complained
about the non-FFI-safe types back in Chapter 2. improper_ctypes covers the
types you declare in an extern block; improper_ctypes_definitions covers the
ones you define with extern "C". Between them they catch most type-level
mistakes long before a linker gets involved.
clippy::not_unsafe_ptr_arg_deref is a correctness lint, so Clippy already
marks it as deny rather than warn. It fires when a public safe function
dereferences a raw-pointer argument even though its signature places no
requirements on the caller. Either make the function unsafe and document its
pointer contract, or keep it safe and accept a type that can be dereferenced
safely. Don’t silence the lint.
clippy::missing_safety_doc flags a public unsafe fn whose docs have no
# Safety section. Set check-private-items = true in clippy.toml to apply
it to private items as well. This is worth doing in an FFI crate, where plenty
of the interesting unsafe code isn’t public.
More lints to turn on
You can configure a lint in code:
#![warn(clippy::undocumented_unsafe_blocks)]
Or you can use lint tables in Cargo.toml. We’ll use Cargo.toml here to keep
this crate-wide policy in one place:1
[lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[lints.clippy]
undocumented_unsafe_blocks = "warn"
multiple_unsafe_ops_per_block = "warn"
unnecessary_safety_doc = "warn"
ptr_as_ptr = "warn"
cast_ptr_alignment = "warn"
transmute_ptr_to_ptr = "warn"
This section focuses on Clippy, but unsafe_op_in_unsafe_fn is a compiler lint,
not a Clippy lint. It’s worth mentioning here because it makes unsafe operations
explicit, which is the foundation for the Clippy lints that follow. An
unsafe fn means its caller has extra obligations. It doesn’t mean every unsafe
operation in the body is automatically justified. With this lint enabled, each
dereference, FFI call, or other unsafe operation needs its own unsafe {}
block. It’s allow-by-default in edition 2021 and warn-by-default in edition
2024, so it’s worth denying explicitly either way.
Two lints take that idea a step further. clippy::undocumented_unsafe_blocks
asks for a // SAFETY: comment before each unsafe block.
clippy::multiple_unsafe_ops_per_block encourages each block to contain a
single unsafe operation, keeping its safety argument focused. Together they turn
a large, vague unsafe region into small claims that can be checked one by one.
clippy::unnecessary_safety_doc catches the opposite documentation mistake: a
# Safety section on a public safe function or trait. A safe API should not
have safety preconditions for its callers or implementors to uphold. This is a
restriction lint and allow-by-default, so enable it explicitly.
The pointer lints earn their keep when you’re porting C habits mechanically. Pointer casts and pointer transmutes often compile without complaint, even when they introduce a stricter alignment requirement or hide a more direct API. A warning doesn’t necessarily mean that the code is wrong, but it’s a good reason to double-check what the cast is claiming.
When the lint is wrong
Porting from C can also produce the opposite problem. Some C naming conventions
conflict directly with rustc’s style lints: a mirrored bm_db_t typedef
triggers non_camel_case_types, an extern static trips
non_upper_case_globals, and a function or variable that keeps its original
name trips non_snake_case. The group covering all three is
nonstandard_style.
Sometimes the name genuinely does have to match, and then an allow is the
right solution. Scope it as tightly as you can:
#[allow(non_camel_case_types)]
pub struct bm_db_t {/* … */}
A crate-root #![allow(non_snake_case)] also silences names you genuinely got
wrong. Prefer applying allows to individual items instead of whole modules or
entire crates. Generated code you don’t control is the exception that can
justify a module-wide allow. Put it on the module containing the generated
bindings rather than on the entire crate.
You can often avoid an allow for functions exposed to C. no_mangle uses the
Rust function’s name for the exported symbol, but export_name lets you choose
a different name for the C API:
#[unsafe(export_name = "BMNormalizeURL")]
pub extern "C" fn bm_normalize_url() {
// ...
}
Note: cheadergen recognizes export_name, so it emits BMNormalizeURL in
the C header while the Rust function keeps its idiomatic name.
Share lint settings across a workspace
The configuration above applies to one crate. To use it across a workspace, put
the tables in the root Cargo.toml instead. They’ve been available since Rust
1.74:
# Cargo.toml at the workspace root
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[workspace.lints.clippy]
undocumented_unsafe_blocks = "warn"
multiple_unsafe_ops_per_block = "warn"
Each member crate then opts in with:
[lints]
workspace = true
// SAFETY: comments are part of the code
Consider a pointer that arrives across the FFI boundary:
use std::ffi::CStr;
let raw = c"https://example.com".as_ptr();
// SAFETY: `raw` is non-null (checked above) and points to a valid
// NUL-terminated byte sequence, as required by `bm_normalize_url`'s contract.
let raw = unsafe { CStr::from_ptr(raw) };
The null check happens in this function; the valid NUL-terminated sequence is part of the caller’s documented contract. A reviewer can check both claims without re-deriving them from the surrounding code.
Compare that with this:
use std::ffi::CStr;
let raw = c"https://example.com".as_ptr();
// SAFETY: this is fine
let raw = unsafe { CStr::from_ptr(raw) };
That comment carries no information. The next reader still has to establish
whether raw is non-null, initialized, and terminated. Write the reasoning
down.
It’s the same discipline as the # Safety documentation from Chapter 2. The
function’s # Safety section describes what its caller must guarantee. The
local // SAFETY: comment describes why one operation can rely on those
guarantees at this exact point in the implementation.
Lints are a starting point, not a proof
Run the lints with:
cargo clippy --workspace --all-targets
--all-targets matters here. Without it, cargo clippy skips tests, and in
these exercises the tests are where most of the FFI calls live.
cargo clippy --fix can apply some mechanical fixes. It wants a clean Git tree
and refuses to run on a dirty one unless you pass --allow-dirty. Review the
diff: replacing a pointer cast is easy; deciding whether the pointer is
correctly aligned, valid, and live is the more difficult work.
Other mistakes won’t trigger a lint at all. No compiler warning can tell you that a C caller lies about a buffer length, or that two libraries expect different allocators. Keep writing tests, use a debugger when they fail, and treat every FFI contract as something to validate at the boundary.
Head to the exercise
You’ll work through a small FFI module with the full lint set already switched
on. Some warnings are mechanical, others point to a real bug. The point isn’t to
make cargo clippy quiet. It’s to understand and document every unsafe
operation that remains.
Exercise
The exercise for this section is located in 03_intermediate/04_clippy_lints
-
The complete list of Clippy lints is available at https://rust-lang.github.io/rust-clippy/master/index.html. ↩
Out-parameters
A literal port can preserve shapes that exist because of C’s type system rather than because of the program’s domain. The code may work, but its Rust API still asks callers to follow conventions that Rust can express directly.
The following sections cover five common transformations, starting here with out-parameters. Each one starts with a recognizable C shape, identifies the information hidden in it, and moves that information into a Rust type or an iterator operation.
What an out-parameter is
Some C functions return values by writing them through pointer arguments. These arguments are called out-parameters. For example, this function splits a duration into minutes and seconds:
void split_duration(uint32_t total_seconds,
uint32_t *out_minutes,
uint32_t *out_seconds);
total_seconds is an input. The function writes its two results through
out_minutes and out_seconds, because a C function can return only one value
directly.
Put outputs in the return type
A literal Rust translation preserves those destinations:
fn split_duration(total_seconds: u32, out_minutes: &mut u32, out_seconds: &mut u32) {
*out_minutes = total_seconds / 60;
*out_seconds = total_seconds % 60;
}
The caller must create two variables before it can call the function, even though those variables have no useful value yet. The signature also describes the mechanism inherited from C rather than the values being computed.
Rust can return both values, as a tuple or a struct:
fn split_duration(total_seconds: u32) -> (u32, u32) {
(total_seconds / 60, total_seconds % 60)
}
Preserve an existing C boundary
Existing C callers still need the original signature. Keep the out-parameters in
a thin compatibility adapter that calls the Rust split_duration above and
writes its two results through the pointers:
fn split_duration(total_seconds: u32) -> (u32, u32) {
(total_seconds / 60, total_seconds % 60)
}
/// # Safety
///
/// `out_minutes` and `out_seconds` must be valid, writable pointers.
#[unsafe(export_name = "split_duration")]
pub unsafe extern "C" fn split_duration_ffi(
total_seconds: u32,
out_minutes: *mut u32,
out_seconds: *mut u32,
) {
let (minutes, seconds) = split_duration(total_seconds);
// SAFETY: The caller must satisfy the pointer requirements above.
unsafe {
out_minutes.write(minutes);
out_seconds.write(seconds);
}
}
The C ABI remains unchanged, but the out-parameter shape stops at the boundary. The calculation and its Rust callers use an ordinary return value.
Some C APIs allow an output pointer to be NULL when the caller doesn’t need
that output. In that case, the adapter checks each pointer before writing
through it.
Recognize values split across channels
An out-parameter can combine with the ordinary return value to describe one
logical result. bm contains this example:
size_t bm_cursor_tags(BmCursor *cursor, const char ***out_tags);
The return value is the number of tags, and out_tags receives a pointer to the
first tag. Together they describe a borrowed sequence. Once the cursor is a safe
Rust type, its API can return that sequence as one value:
fn tags(&self) -> &[Tag];
The same reasoning applies to other paired outputs: identify the value the pieces represent, then choose the Rust return type that represents it directly. The compatibility adapter splits that value back into the return value and out-parameter C expects.
Head to the exercise
In exercises/03_intermediate/05_out_parameters, implement page_location so
that it returns a bookmark’s page and slot as a (page, slot) tuple instead of
writing them through out-parameters. Then complete the C adapter without
changing its void bm_page_location(...) contract.
Exercise
The exercise for this section is located in 03_intermediate/05_out_parameters
Error codes
C APIs commonly report the outcome of an operation with an enum, with one variant for success and one for each kind of failure:
typedef enum {
BM_OK = 0,
BM_ERR_NOT_FOUND = 1,
BM_ERR_IO = 2,
BM_ERR_INVALID_INPUT = 3,
} BmStatus;
BmStatus remove_bookmark(struct Store *store, const char *url);
The return type names the codes, but a C enum is still just an integer. C
converts it to and from int freely and doesn’t stop a function from returning
a code that makes no sense for it, or one that isn’t a variant at all. Correct
propagation relies on control flow and review rather than the type system.
Give failures a type
In the safe Rust API, BM_OK becomes Ok(()), and each failure becomes a
variant of an error enum:
enum RemoveError {
NotFound,
Io,
}
fn remove_bookmark(store: &mut Store, url: &str) -> Result<(), RemoveError>;
BM_ERR_INVALID_INPUT has no variant. It reports a null pointer or a URL that
isn’t valid UTF-8, and the safe function can’t receive either of those, because
the adapter below rejects them first. Don’t merge the remaining failures into
one generic error because the C version shared a return type.
Map once at the C boundary
An unchanged C ABI still returns its specified numbers. Keep that mapping in one adapter:
use std::ffi::{CStr, c_char};
#[derive(Debug)] enum RemoveError { NotFound, Io }
struct Store;
fn remove_bookmark(_: &mut Store, _: &str) -> Result<(), RemoveError> { Ok(()) }
#[repr(C)]
pub enum BmStatus {
Ok = 0,
NotFound = 1,
Io = 2,
InvalidInput = 3,
}
/// # Safety
///
/// `store` must point to a valid, uniquely borrowed `Store`. `url` must point
/// to a readable, NUL-terminated byte string.
#[unsafe(export_name = "remove_bookmark")]
pub unsafe extern "C" fn remove_bookmark_ffi(
store: *mut Store,
url: *const c_char,
) -> BmStatus {
let Some(store) = (unsafe { store.as_mut() }) else {
return BmStatus::InvalidInput;
};
if url.is_null() {
return BmStatus::InvalidInput;
}
// SAFETY: The pointer requirements are part of this function's contract,
// and the null case was rejected above.
let url = unsafe { CStr::from_ptr(url) };
let Ok(url) = url.to_str() else {
return BmStatus::InvalidInput;
};
match remove_bookmark(store, url) {
Ok(()) => BmStatus::Ok,
Err(RemoveError::NotFound) => BmStatus::NotFound,
Err(RemoveError::Io) => BmStatus::Io,
}
}
BmStatus mirrors the C enum. #[repr(C)] gives it the representation the C
compiler picks for the same enum, so the two agree on the values they exchange.
Returning it to C is sound, because a Rust enum always holds one of its
variants. The Rust error enum doesn’t need a C representation: its layout stays
private because it never crosses the boundary. Conversely, if a raw numeric
status arrives from C, match its known values explicitly. Constructing a Rust
enum from an arbitrary integer can create an invalid value.
bm’s BmResult follows this pattern. Its storage layer also shows that not
every C failure code is a Rust error: ENOENT means a missing file, which may
be an empty database, while a read failure or corrupt file remains an error. The
domain decides which outcomes are ordinary; the fact that C reported them as
integers doesn’t.
Head to the exercise
In exercises/03_intermediate/06_error_codes, implement change_bookmark_id so
that it reports its failures as a Result<(), ChangeIdError> instead of C
status codes, leaving the index unchanged when it fails. Then complete the FFI
adapter that maps that result back to the C status codes.
Exercise
The exercise for this section is located in 03_intermediate/06_error_codes
Strings
C and Rust use different representations for strings.
A traditional C string is a sequence of bytes followed by a NUL byte (\0). A
const char * usually carries no separate length: functions find the end by
scanning for that terminator. The bytes before it aren’t necessarily UTF-8.
Rust’s &str and String contain valid UTF-8 and carry their byte length. A
&str borrows its contents, while a String owns them. Neither representation
needs a trailing NUL byte.
Rust provides two more types for traditional C strings:
CStris a borrowed, NUL-terminated sequence of bytes;CStringis an owned, NUL-terminated sequence of bytes.
Passing a string across the boundary therefore takes two steps: establish the C string representation, then decide whether its bytes are valid Rust text.
Borrow a C string as CStr
Use CStr::from_ptr to borrow a traditional C string:
use std::ffi::{CStr, c_char};
/// # Safety
///
/// A non-null `tag` must point to a live, NUL-terminated byte string.
unsafe extern "C" fn tag_byte_len(tag: *const c_char) -> usize {
if tag.is_null() {
return 0;
}
// SAFETY: The caller supplies a live C string and null was handled above.
let tag = unsafe { CStr::from_ptr(tag) };
tag.count_bytes()
}
from_ptr is unsafe because Rust can’t determine how much memory is readable
or whether a NUL terminator will be found. The resulting &CStr borrows the
original allocation and can’t outlive it.
count_bytes() reports the number of bytes before the terminator. Nothing so
far has checked or assumed that those bytes are UTF-8. to_bytes() exposes
those bytes when the API defines the value as arbitrary data. If the value is
text, perform the encoding conversion explicitly.
Convert between CStr and Rust text
CStr::to_str validates UTF-8 and returns a borrowed &str, or an error if the
bytes aren’t valid UTF-8. Turn the &str into a String when the Rust code
needs its own copy. to_string_lossy() replaces invalid sequences, which
silently changes the text; use it only when that’s the API’s stated policy.
Going the other way requires a NUL-terminated representation. CString::new
creates one from Rust text:
use std::ffi::CString;
let tag = String::from("rust");
let c_tag = CString::new(tag)?;
let borrowed = c_tag.as_c_str();
let pointer = borrowed.as_ptr();
let _ = pointer;
Ok::<(), std::ffi::NulError>(())
The conversion is fallible because a Rust string may contain an interior NUL.
That byte would terminate a C string early. The pointer from as_ptr() is valid
only while c_tag is alive, so don’t save a pointer obtained from a temporary
CString.
Expose a Rust string as a pointer and length
A C API can also describe text using a returned pointer and a length out-parameter:
const uint8_t *bm_category_label(uint32_t category, size_t *out_len);
A borrowed Rust string already has both pieces. The adapter returns its pointer and writes its byte length through the out-parameter:
fn category_label(category: u32) -> &'static str {
match category {
1 => "article",
2 => "reference",
_ => "other",
}
}
/// # Safety
///
/// `out_len` must be a valid, writable pointer.
#[unsafe(export_name = "bm_category_label")]
unsafe extern "C" fn category_label_ffi(category: u32, out_len: *mut usize) -> *const u8 {
let label = category_label(category);
// SAFETY: The caller must provide a writable output pointer.
unsafe { out_len.write(label.len()) };
label.as_ptr()
}
The pointer isn’t NUL-terminated. C must use the written length instead of
strlen and must not mutate the bytes. The length is a number of bytes, not
Unicode characters.
This example returns a view of a static string, so no allocation or matching
destructor is required. When the &str borrows from an input or another value
instead, C must not retain the returned pointer after that value stops being
valid.
Keep the boundary explicit
For each string parameter, specify:
- whether it’s a traditional C string or a pointer-and-length view;
- whether
NULLis accepted and how it differs from an empty string; - whether the bytes must be UTF-8;
- how long a returned or borrowed pointer remains valid.
Use CStr and CString for traditional C strings, &str and String for Rust
text, and byte slices when the value isn’t text. Convert between them at the
boundary instead of treating their pointers as interchangeable.
Head to the exercise
In exercises/03_intermediate/07_strings, accept a traditional C string,
convert it through CStr to &str, and return a borrowed tag name as a pointer
with its byte length written to an out-parameter. The returned pointer points
into the input, so it’s valid only while that input string is alive.
Exercise
The exercise for this section is located in 03_intermediate/07_strings
Bit flags
C represents a set of independent options by assigning each option a bit:
#define FIND_URLS (1u << 0)
#define FIND_TAGS (1u << 1)
#define FIND_CASE_INSENSITIVE (1u << 2)
size_t find_bookmarks(const struct Bookmark *bookmarks, size_t len,
const char *query, uint32_t flags);
Callers combine options with | and test them with &. The representation is
compact and crosses an ABI easily, but uint32_t accepts unrelated constants,
unknown bits, and combinations the operation may not support.
Name the set of valid bits
The bitflags crate generates a transparent newtype, constants, bitwise
operators, and set-like methods:
use bitflags::bitflags;
bitflags! {
#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct FindFlags: u32 {
const URLS = 1 << 0;
const TAGS = 1 << 1;
const CASE_INSENSITIVE = 1 << 2;
}
}
The storage width matches the C header, and #[repr(transparent)] gives the
wrapper the layout of that u32. Within Rust, contains requires every bit in
its argument, while intersects asks whether any bit overlaps. insert and
remove mutate a set, and bits() returns the raw representation for C.
Named bits don’t automatically make every combination meaningful. If a search must inspect at least URLs or tags, validate that separately:
use bitflags::bitflags;
bitflags! { struct FindFlags: u32 {
const URLS = 1; const TAGS = 2; const CASE_INSENSITIVE = 4;
} }
fn validate(flags: FindFlags) -> Result<FindFlags, &'static str> {
if !flags.intersects(FindFlags::URLS | FindFlags::TAGS) {
return Err("select URLS, TAGS, or both");
}
Ok(flags)
}
Choose an unknown-bit policy
Converting a raw C integer requires an explicit compatibility decision:
from_bits(raw)rejects any unknown bit;from_bits_truncate(raw)discards unknown bits;from_bits_retain(raw)preserves them.
Rejecting is appropriate when silently ignoring an unknown option would do the wrong thing. Retaining can be useful for a value that must round-trip to a newer library even though this version doesn’t interpret every bit. Truncating is a real behavior choice; it isn’t a neutral default.
A strict C adapter can therefore convert and validate before entering the safe implementation:
use bitflags::bitflags;
bitflags! { #[derive(Clone, Copy)] struct FindFlags: u32 {
const URLS = 1; const TAGS = 2; const CASE_INSENSITIVE = 4;
} }
fn validate(flags: FindFlags) -> Result<FindFlags, ()> {
flags.intersects(FindFlags::URLS | FindFlags::TAGS).then_some(flags).ok_or(())
}
fn flags_from_c(raw: u32) -> Result<FindFlags, ()> {
FindFlags::from_bits(raw).ok_or(()).and_then(validate)
}
The safe search function accepts only FindFlags, so the boundary is the one
place that knows C supplied an untyped integer.
Head to the exercise
In exercises/03_intermediate/08_bit_flags, update a set of bookmark output
fields using separate enable and disable masks. Detect conflicting changes with
typed flags, then strictly convert three raw C masks, apply the update, and
return the resulting raw bits.
Exercise
The exercise for this section is located in 03_intermediate/08_bit_flags
Loops and iterators
A C loop often does several jobs at once: it traverses memory, controls when iteration stops, and computes a result. A literal Rust port can preserve all of that machinery even after the data has become a safe Rust collection.
Consider this loop from bm:
for (size_t i = 0; i < b->n_tags; i++) {
if (strstr(b->tags[i], query))
return 1;
}
return 0;
Initial Rust port
A direct Rust translation looks like this:
struct Bookmark { tags: Vec<String> }
fn matches(bookmark: &Bookmark, query: &str) -> bool {
for index in 0..bookmark.tags.len() {
if bookmark.tags[index].contains(query) {
return true;
}
}
false
}
The calculation never uses the numeric value of index; it only needs the tag
stored there. Rust can iterate over those values directly:
struct Bookmark { tags: Vec<String> }
fn matches(bookmark: &Bookmark, query: &str) -> bool {
for tag in &bookmark.tags {
if tag.contains(query) {
return true;
}
}
false
}
The index existed only because C needs one to reach each element. A for loop
over the values is a good final form, especially when its body does several
things.
Idiomatic Rust port
The tag loop computes whether any tag matches. The any operation states that
directly:
struct Bookmark { tags: Vec<String> }
fn matches(bookmark: &Bookmark, query: &str) -> bool {
bookmark.tags.iter().any(|tag| tag.contains(query))
}
any() stops after the first match, so the control flow is the same as the
early return in the C loop. Keep that kind of behavior in mind when you port a
loop: short-circuiting, ordering, and whether values are borrowed or moved
should all stay the same.
Pointer-bumping loops
The other common C shape walks a string by moving a pointer forward until it reaches the NUL terminator:
size_t n = 0;
for (const char *p = s; *p; p++) {
if (*p == ',')
n++;
}
A literal port keeps the raw pointer, which means every read and every step is
unsafe, and the loop is only correct as long as the string really ends in a
NUL:
use std::ffi::c_char;
let s = c"rust,ffi,c".as_ptr();
let mut n = 0;
let mut p: *const c_char = s;
// SAFETY: `s` points to a live, NUL-terminated string, and the loop stops at
// the terminator.
while unsafe { *p } != 0 {
if unsafe { *p } == b',' as c_char {
n += 1;
}
p = unsafe { p.add(1) };
}
assert_eq!(n, 2);
Borrow the C string as a &CStr instead. to_bytes() gives you the bytes
before the terminator as a slice, so there is no terminator to look for and no
pointer to move. The loop becomes an iterator over those bytes:
let c_str = c"rust,ffi,c";
let n = c_str.to_bytes().iter().filter(|&&byte| byte == b',').count();
assert_eq!(n, 2);
Keep raw pointers at the boundary
This refactoring begins after the FFI layer has validated raw pointers and converted them into safe Rust slices, strings, or collections. Pointer validation and slice construction stay in the boundary adapter; the iterator chain operates only on safe Rust values.
Head to the exercise
The starter code in exercises/03_intermediate/09_loops_and_iterators is
behaviorally correct, so its tests already pass. wr fails because Clippy finds
index-based loops whose indexes aren’t part of the calculation.
Refactor the four functions without changing their results, ordering,
short-circuiting, or borrowing behavior. They correspond to any, position,
filter().count(), and a filter/map/collect pipeline with a nested any.
Exercise
The exercise for this section is located in 03_intermediate/09_loops_and_iterators
Dynamic analysis with Miri
In Chapter 2 we introduced the “firewall” rules that keep FFI interfaces sane, and in the Clippy section we looked at lint rules that help enforce them. Lints and compiler settings can’t observe behavior, though. They can only read your code as the compiler does, and infer from that.
We’ll now introduce another class of tooling to complement static analysis: dynamic analysis with Miri.
Miri is an interpreter for rustc’s MIR (mid-level intermediate representation).
It runs your code, checking each operation against the Rust Abstract Machine.
Miri is especially useful for unsafe Rust, since it catches incorrect
initialization, use-after-free, and borrowing-rule violations: a good fit for
the kind of systems code we tend to translate from C to Rust. Do note that Miri
requires a nightly compiler. This repository’s rust-toolchain.toml already
pins one with the miri component included, so the exercises work without any
extra setup.
What Miri catches
When interpreting your Rust code, Miri tracks the initialization state and
provenance of each allocation. It will flag reads outside the bounds of your
allocation, before it’s correctly initialized (unless you use MaybeUninit), or
after it’s freed. Miri also catches data races, including weak-memory
reorderings, and the aliasing violations that complex unsafe code tends to
produce, such as writing through a mutable reference while other code still
holds references to the same allocation. It also catches memory leaks: if a test
ends without freeing every allocation, for example through Box::into_raw or
CString::into_raw without a matching from_raw, Miri will fail the test.
The downsides
Miri is by design an interpreter, meaning it can only ever see code paths that actually execute. Code paths never exercised by your test suite are invisible to Miri. This also means non-deterministic bugs need luck: a bug that only happens on a specific thread interleaving will remain flaky.1 Miri is also slow: expect a 3,000–7,000× slowdown in some cases.
Quick Miri cheat sheet
Using Miri is just like using Cargo, but with
miriinserted into the commands. For example, to run unit tests:cargo miri test -p <crate>. You can also run binaries, examples, etc.To pass flags to Miri itself, use the
MIRIFLAGSenvironment variable.The first
cargo miriinvocation builds a Miri-specific sysroot, which takes a minute or two. Runcargo miri setuponce ahead of time to get that out of the way.Quite often you will need to tweak your code and test cases to run under Miri at reasonable speed, or at all. For example, you may want to reduce test iterations when running under Miri or disable certain incompatible tests. For this, the
miricfg attribute exists. You can write#[cfg(miri)]on expressions to enable them only when executing under Miri. For incompatible tests you can write#[cfg_attr(miri, ignore)].Note that Miri (being an interpreter) also accepts any
--targettriple which is handy to test certain target features such as big-endian or 32-bit without running an actual emulator of some sort.
Miri and C
Miri is a pure Rust interpreter and cannot run native libraries. The Miri team hand-wrote shims for common ones such as libc, which are built into Miri, but for the long tail of native libraries there is no support. Unfortunately, our mixed C-Rust codebase sits right in that long tail. So the question isn’t “can Miri check my mixed codebase?” (it cannot), but “how much of it can I get Miri to check?”
The strategy we most often employ is “thin glue, thick core”: pure-Rust core
with unsafe extern "C" functions (the uncheckable part) being extremely simple
adapters only. This means the vast majority of the code we actually care about
is already Miri-checkable, and FFI functions can be validated using lints,
reviews, and other tools if necessary.
The only complicating factor is Rust calling into C: our Rust code calling a not-yet-translated module, for example, or some native library we do not intend to port at all. For this we use hand-written shims (just like the Miri team did) that swap out the C functions for Rust stubs when running under Miri. Keep in mind that the stub doesn’t need to implement the C code at all; it simply needs to return the right values: error codes, out-params, and buffers of a given length. One neat side effect is that “stubbing out a C function” forces you to confront the details of a function that we gloss over when we simply call it. That usually leads to a much better understanding of the code, which makes it a worthwhile exercise in itself.
You would usually have something like this:
#[cfg(miri)]
mod stubs {
#[unsafe(no_mangle)]
unsafe extern "C" fn SomeCFunction() {
// ... some stub here ...
}
}
In a regular build we would link against the .so library and get the
SomeCFunction symbol that way. Under Miri we stub out the symbol, pointing it
at a simple pure-Rust function. The stubs live in their own module because Rust
won’t let a definition and the extern declaration of SomeCFunction share a
name.
One word of caution, though: Miri passing with Rust stubs does not mean your codebase with the full C functions is correct. These stubs, written correctly, can give you confidence that the final product will also be correct, but they are not a substitute for full-program tests. It is easy to accidentally test your stubs’ behavior more than the actual C code’s.
Future features
Miri has gained support for running code directly from native libraries:
MIRIFLAGS="-Zmiri-native-lib=path/to/libbm.so" loads the .so and directly
calls the symbols in it. This is a promising direction, but for now it is
limited. It does not support Windows, supports only integer and pointer
arguments and returns (no structs by value), and by design cannot check most
Rust invariants: memory shared with C stops being tracked for init and
provenance. On Linux there is even more experimental support for native tracing
that attempts to trace which bytes C touched and keep tracking for all others,
but none of it is ready for production use yet.
Head to the exercise
The exercise is in exercises/03_intermediate/10_miri. It contains a small Rust
wrapper around tag_normalize from tag.c, a module we haven’t ported yet. Its
tests pass with a plain cargo test but fail under Miri.
Exercise
The exercise for this section is located in 03_intermediate/10_miri
-
The
-Zmiri-many-seedsoption runs several executions at once, which helps catch flakes more often. Miri also has the-Zmiri-address-reuse-rate,-Zmiri-address-reuse-cross-thread-rate,-Zmiri-preemption-rate, and-Zmiri-compare-exchange-weak-failure-rateoptions that help with catching threading bugs more often. But generally it’s better to turn to tools likeloomfor proper concurrency testing. ↩
Performance benchmarking
You have ported a C function to Rust. The tests pass and the output is identical. Great! But what about execution speed?
It is tempting to run the program once, look at the wall clock, and call that a benchmark. The problem is that one number tells you almost nothing. Your laptop may boost one run and throttle the next. The filesystem cache may already be warm, and your browser may wake up and steal a core.
A benchmark repeatedly measures a controlled workload. It can report
latency, how long one operation takes, or throughput, how many operations
finish in a given time. Our benchmark will measure the latency of one call to
bm_normalize_url.
A profile answers a different question: where did a program spend CPU time, allocate memory, or perform I/O? We focus on benchmarking here because it tells us whether the C-to-Rust port regressed. Profiling appears briefly at the end, as a way to investigate an unexpected result.
Benchmarking tools
The main tool for running benchmarks is cargo bench. By itself
it does nothing and requires a benchmark harness. At the time of writing, Rust’s
built-in benchmark harness is nightly-only: #[bench], the test crate, and
test::Bencher are all unstable.1 In stable Rust, you need a third-party
harness such as Criterion or Divan. Criterion stores
baselines and reports changes between runs. Divan provides a smaller,
attribute-based harness and reports the fastest, slowest, median, and mean
times. For simplicity, we will use Divan.
Add Divan to the crate that owns the Rust port. The exercise also uses the cc
crate to compile the C baseline:
# Cargo.toml
[build-dependencies]
cc.workspace = true
[dev-dependencies]
divan = "0.1"
[[bench]]
name = "normalize"
harness = false
harness = false tells Cargo not to start its built-in test harness. The
benchmark binary supplies its own main function and starts Divan instead:
fn main() {
divan::main();
}
The build script compiles the original C implementation:
// Sample build.rs
fn main() {
cc::Build::new()
.file("c_src/normalize.c")
.compile("bm_normalize_c");
}
This keeps both implementations in the same Cargo build. When cargo bench
selects the optimized bench profile, cc reads Cargo’s OPT_LEVEL and
applies the corresponding optimization level to the C code too.2
If you build the C code manually instead of using cc, pass the appropriate
-O flag to the C compiler yourself. Otherwise you may end up comparing
optimized Rust with unoptimized C, which makes the benchmark meaningless.
Benchmark the same work
Now we need to compare the C implementation with the Rust port. A C/Rust comparison is only useful when both benchmarks include the same work.
Start from the C signature. The caller owns the output buffer, and nothing is allocated:
BmResult bm_normalize_url(const char *raw, char *out, size_t out_len);
Porting it gives you a choice, and this decides what you are able to measure:
// Safe and idiomatic. Not comparable: it allocates, and reaching it from
// C's `const char *` costs a UTF-8 check on the way in.
pub fn normalize_url(raw: &str) -> Result<String, InvalidUrl>;
// Comparable: the same contract as C, so the two benchmarks do the same work.
pub unsafe extern "C" fn bm_normalize_url_rust(
raw: Option<NonNull<c_char>>,
out: Option<NonNull<c_char>>,
out_len: usize,
) -> BmResult;
If you benchmark the first one against C, the Rust number carries a validation and an allocation that the C number does not: you have measured two different APIs, not two implementations of the same operation.
This does not mean the safe API is wrong. The allocation may well be the right design. It does mean you need to state what the benchmark includes.
For a direct port comparison, take the second signature, and give both implementations the same input and equivalent reusable output buffers:
use std::ffi::c_char;
type BmResult = i32;
unsafe extern "C" {
#[link_name = "bm_normalize_url"] // bind the C symbol under a second name
fn bm_normalize_url_c(raw: *const c_char, out: *mut c_char, out_len: usize) -> BmResult;
}
#[divan::bench(sample_size = 4096)]
fn c(bencher: divan::Bencher) {
let raw = b"HTTPS://EXAMPLE.COM/Rust\0";
let mut out = [0 as c_char; 2048]; // created once, so it is not timed
bencher.bench_local(|| unsafe {
let result = bm_normalize_url_c(
divan::black_box(raw.as_ptr().cast()),
divan::black_box(out.as_mut_ptr()),
out.len(),
);
divan::black_box(result)
});
}
The benchmark setup happens before bench_local, so creating the output buffer
is not timed. The closure mutably captures that buffer and reuses it for every
iteration. bench_local runs single-threaded.
These calls take only a few dozen nanoseconds. sample_size = 4096 runs each
benchmark that many times in every timing sample; Divan then divides the total
time by 4096. This keeps each sample well above timer precision and makes the
result less sensitive to an interruption during Divan’s automatic calibration.
Use the same sample size for both implementations; adjust it for slower or
faster workloads.
black_boxis not decorative. Without it, the compiler can notice that the result goes nowhere and optimize away some or all of the work. You would then be wowed by the speed of a function that did not run, making the whole benchmark meaningless.
Rule of thumb: Benchmark the semantics you want to compare, not the convenience API you happened to write first.
Check correctness first, then run both benchmarks, from the workspace root:
cargo test -p bm_benchmarking
cargo bench -p bm_benchmarking --bench normalize
You can pass a filter to Divan when you only want one implementation:
cargo bench -p bm_benchmarking --bench normalize -- rust
Divan invokes each function many times. It groups those invocations into samples and reports a per-iteration time:
- median is the middle sample and usually the most useful value for a quick comparison;
- mean is the average and moves more when one sample is unusually slow;
- fastest and slowest show the extremes and help expose noisy runs;
- samples and iters show how many measurements and function calls contributed to the result.
The fastest-to-slowest range shows only the extremes within one run. A single outlier can make it huge, so do not use it to decide whether two implementations differ.
Check for a regression
Suppose repeated runs put Rust around 220 ns and C around 200 ns. That is a 10% difference.
It is a performance regression only if the slowdown remains under the same workload and conditions. Run the benchmark several times on the same otherwise-idle machine and compare the medians. If the difference keeps changing direction, it is too small to distinguish from noise under those conditions. If Rust remains slower by roughly the same amount, investigate.
The instructions are deterministic; the conditions under which they run are not. Caches, frequency scaling, and whatever else the machine is doing all land in your measurement.
Treat a repeatable regression like any other bug:
- Run the correctness tests first. A faster wrong port is not an optimization.
- Record the benchmark command, input, machine, and baseline median.
- Change one thing, then rerun the same benchmark under the same conditions.
- If the regression remains, profile the slow path before changing more code.
Keep the benchmark after the fix. It is now the thing that will catch the regression if it comes back.
CI tip: A continuous-benchmarking tool such as Bencher can store results over time and fail a pull request when performance regresses.
A brief note on profiling
If a benchmark finds a repeatable regression, a profiler can show where the extra work happens.
cargo-samply builds an optimized binary with debug information and
records CPU samples. See its documentation for installation and usage. Look for
functions that occupy the widest frames in the profile. Those are where the
sampled CPU time accumulated. Then record a profile for the C implementation, or
for the version before your change, and compare it with the new profile. This
shows which functions now take more CPU time and explains the slower benchmark.
Hotpath is useful when you want timing or allocation measurements for a small set of functions in an application. Its instrumentation is disabled unless you enable the corresponding Cargo features:
# Cargo.toml
[dependencies]
hotpath = "0.24"
[features]
hotpath = ["hotpath/hotpath"]
hotpath-alloc = ["hotpath/hotpath-alloc"]
Mark the functions you want to measure and the application’s entry point:
#[hotpath::measure] // time every call to this function
fn normalize_url(raw: &str) -> String {
// ...
}
#[hotpath::main] // install the reporter, print the table on exit
fn main() {
// ...
}
cargo run --release --features='hotpath,hotpath-alloc'
Hotpath can also write JSON reports for CI. Its hotpath-utils command compares
reports from the pull request and the base branch, including per-function timing
and allocation differences.3 This is useful for watching a few
representative application paths. Keep Divan for controlled microbenchmarks, and
disable Hotpath while collecting the final Divan numbers because instrumentation
changes the code being measured.
Head to the exercise
The exercise is in exercises/03_intermediate/11_benchmarking. It ships with
the C benchmark. Replace the bm_normalize_url_rust stub and add the matching
Rust benchmark, then run both and compare their median latencies.
The parity test compares return codes on every input, but compares the output
buffers only when the call succeeded. The C version lowercases into the caller’s
buffer before it looks for a tab, so it writes into out even on inputs it
rejects. Your port validates first, so it doesn’t. That is a difference
differential testing is supposed to surface, and one worth keeping: the test
pins it down rather than asking you to reproduce it.
Exercise
The exercise for this section is located in 03_intermediate/11_benchmarking
-
See the
testlibrary feature in the Unstable Book for the current status of#[bench]andtest::Bencher. ↩ -
The
cccrate reads the optimization level from Cargo’sOPT_LEVELenvironment variable. See thecc::Build::opt_leveldocumentation. ↩ -
The Hotpath CI guide shows how to produce the head and base JSON reports and compare them in GitHub Actions. ↩