Rust FFI
Rust calls C through unsafe extern blocks and exports to C through extern "C" functions. The unsafe surface stays at the boundary; everything behind it is a safe API.
Contract
| Field |
Bound contract |
| Trigger |
Calling a C library from Rust, generating bindings with bindgen, exporting a Rust API to C with cbindgen, wrapping an unsafe FFI boundary in a safe API, or linking a system or vendored C library. |
| Authority |
Reversible local. Writes only the target crate's Cargo.toml, build.rs, wrapper header, source files, and the generated C header inside the project. Rollback is version control. No remote mutation. |
| Side effect |
Creates or edits crate build and source files; bindgen output lands in OUT_DIR, cbindgen output at the named header path. |
| Done |
cargo build succeeds, the FFI boundary is declared or generated, and every unsafe call sits behind a safe wrapper or an exported extern "C" function. |
Inputs
- C library (required): headers, library name, and whether it is system-installed, vendored, or built from source.
- Direction (required): C-to-Rust, Rust-to-C, or both.
- Crate layout (optional): single crate or a
-sys crate plus a safe wrapper crate; inferred from the workspace when omitted.
- Edition (optional): edition 2024 is assumed, so extern blocks are
unsafe extern and no_mangle is written #[unsafe(no_mangle)].
Procedure
- Pick the binding strategy. Declare functions by hand for a handful of calls; use bindgen when the header surface is real. Done when: the strategy is named.
use std::ffi::{c_char, c_int, c_void, CString};
unsafe extern "C" {
fn my_lib_init(config: *const c_char) -> c_int;
fn my_lib_process(handle: *mut c_void, data: *const u8, len: usize) -> c_int;
fn my_lib_cleanup(handle: *mut c_void);
}
- Generate bindings with bindgen when the header surface is large. Restrict output with allowlists so system headers do not leak into the generated file. Done when:
build.rs writes bindings.rs into OUT_DIR and lib.rs includes it.
[build-dependencies]
bindgen = "0.70"
// build.rs
fn main() {
println!("cargo:rerun-if-changed=wrapper.h");
println!("cargo:rustc-link-lib=mylib");
println!("cargo:rustc-link-search=/usr/local/lib");
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.clang_arg("-I/usr/local/include")
.allowlist_function("mylib_.*")
.allowlist_type("MyLib.*")
.allowlist_var("MYLIB_.*")
.derive_debug(true)
.derive_default(true)
.blocklist_type("__va_list_tag")
.generate()
.expect("bindgen failed");
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings.write_to_file(out.join("bindings.rs")).unwrap();
}
// src/lib.rs
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
- Structure the sys crate. A
-sys crate owns the link and the raw bindings; the safe crate depends on it. The links manifest key tells Cargo which native library the crate links. Done when: the layout matches the pattern.
mylib-sys/
Cargo.toml # links = "mylib"
build.rs # probes pkg-config, falls back to a vendored cc build
wrapper.h
src/lib.rs # includes the generated bindings
mylib/
Cargo.toml # depends on mylib-sys
src/lib.rs # safe API
// mylib-sys/build.rs
fn main() {
if let Ok(lib) = pkg_config::probe_library("mylib") {
for path in lib.include_paths {
println!("cargo:include={}", path.display());
}
return;
}
cc::Build::new()
.file("vendor/mylib/src/mylib.c")
.include("vendor/mylib/include")
.compile("mylib");
println!("cargo:rerun-if-changed=vendor/mylib/src/mylib.c");
}
- Wrap every unsafe call in a safe API. Check for null handles, map C error codes to
Result, free resources in Drop, and add Send or Sync only after verifying the C library's threading contract. Done when: no safe public function reaches unsafe without a validity check.
pub struct MyLib {
handle: *mut ffi::mylib_t,
}
impl MyLib {
pub fn new(config: &str) -> Result<Self, Error> {
let c = CString::new(config).map_err(|_| Error::InvalidConfig)?;
let handle = unsafe { ffi::mylib_create(c.as_ptr()) };
if handle.is_null() {
return Err(Error::InitFailed);
}
Ok(Self { handle })
}
}
impl Drop for MyLib {
fn drop(&mut self) {
unsafe { ffi::mylib_destroy(self.handle) };
}
}
- Export Rust to C with cbindgen. Exported functions are
extern "C" with #[unsafe(no_mangle)]; ownership crosses the boundary through Box::into_raw and Box::from_raw. Done when: the generated header exists at the configured path.
#[unsafe(no_mangle)]
pub extern "C" fn mylib_destroy(ptr: *mut MyLib) {
if !ptr.is_null() {
unsafe { drop(Box::from_raw(ptr)) };
}
}
// build.rs
fn main() {
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
cbindgen::Builder::new()
.with_crate(crate_dir)
.with_language(cbindgen::Language::C)
.generate()
.expect("cbindgen failed")
.write_to_file("include/mylib.h");
}
Link the library. Emit link directives from build.rs: cargo:rustc-link-lib=static=name, dylib=name, or framework=name on macOS, plus cargo:rustc-link-search= for nonstandard paths. Done when: cargo build links without unresolved -l errors.
Verify the boundary. Build the crate, confirm the generated bindings or header exist, and exercise one call across the boundary. Done when: the call returns the expected result.
Failure and recovery
| Failure class |
Behavior |
| bindgen cannot parse the header |
Reduce wrapper.h to the needed includes and pass include paths and defines through clang_arg. |
| Library not found at link time |
Add cargo:rustc-link-search, probe with pkg-config, or build the vendored source with cc. |
| C constructor returns null |
Map it to Result or Option; never store a null handle. |
| Thread-safety unknown |
Do not add Send or Sync; the default is neither until the C library's locking contract is verified. |
| Panic would cross the boundary |
Return an error code or use catch_unwind; unwinding out of an extern "C" function aborts. Use extern "C-unwind" only when the C side is built to propagate. |
| Partial result |
Files already written remain; the report names the failed step and its command output. Rollback is version control. |
Output
- A crate that builds with a verified call across the FFI boundary.
- A report listing files written, the safe API surface, and the link directives emitted.
- bindgen builder options, cbindgen.toml keys,
cc, pkg-config, and safety patterns are in references/bindgen-cbindgen.md.
1---2name: rust-ffi-33description: Use when calling C libraries from Rust, generating bindings with bindgen, exporting Rust functions to C with cbindgen, writing safe wrappers over unsafe FFI, or linking system libraries.4---5
6# Rust FFI
7
8Rust calls C through `unsafe extern` blocks and exports to C through `extern "C"` functions. The unsafe surface stays at the boundary; everything behind it is a safe API.
9
10## Contract
11
12| Field | Bound contract |
13|---|---|
14| Trigger | Calling a C library from Rust, generating bindings with bindgen, exporting a Rust API to C with cbindgen, wrapping an unsafe FFI boundary in a safe API, or linking a system or vendored C library. |
15| Authority | Reversible local. Writes only the target crate's Cargo.toml, build.rs, wrapper header, source files, and the generated C header inside the project. Rollback is version control. No remote mutation. |
16| Side effect | Creates or edits crate build and source files; bindgen output lands in `OUT_DIR`, cbindgen output at the named header path. |
17| Done | `cargo build` succeeds, the FFI boundary is declared or generated, and every unsafe call sits behind a safe wrapper or an exported `extern "C"` function. |
18
19## Inputs
20
211. **C library** (required): headers, library name, and whether it is system-installed, vendored, or built from source.
222. **Direction** (required): C-to-Rust, Rust-to-C, or both.
233. **Crate layout** (optional): single crate or a `-sys` crate plus a safe wrapper crate; inferred from the workspace when omitted.
244. **Edition** (optional): edition 2024 is assumed, so extern blocks are `unsafe extern` and `no_mangle` is written `#[unsafe(no_mangle)]`.
25
26## Procedure
27
281. **Pick the binding strategy.** Declare functions by hand for a handful of calls; use bindgen when the header surface is real. Done when: the strategy is named.
29
30```rust
31use std::ffi::{c_char, c_int, c_void, CString};
32
33unsafe extern "C" {
34 fn my_lib_init(config: *const c_char) -> c_int;
35 fn my_lib_process(handle: *mut c_void, data: *const u8, len: usize) -> c_int;
36 fn my_lib_cleanup(handle: *mut c_void);
37}
38```
39
402. **Generate bindings with bindgen when the header surface is large.** Restrict output with allowlists so system headers do not leak into the generated file. Done when: `build.rs` writes `bindings.rs` into `OUT_DIR` and `lib.rs` includes it.
41
42```toml
43[build-dependencies]
44bindgen = "0.70"
45```
46
47```rust
48// build.rs
49fn main() {
50 println!("cargo:rerun-if-changed=wrapper.h");
51 println!("cargo:rustc-link-lib=mylib");
52 println!("cargo:rustc-link-search=/usr/local/lib");
53
54 let bindings = bindgen::Builder::default()
55 .header("wrapper.h")
56 .clang_arg("-I/usr/local/include")
57 .allowlist_function("mylib_.*")
58 .allowlist_type("MyLib.*")
59 .allowlist_var("MYLIB_.*")
60 .derive_debug(true)
61 .derive_default(true)
62 .blocklist_type("__va_list_tag")
63 .generate()
64 .expect("bindgen failed");
65
66 let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
67 bindings.write_to_file(out.join("bindings.rs")).unwrap();
68}
69```
70
71```rust
72// src/lib.rs
73#![allow(non_upper_case_globals)]
74#![allow(non_camel_case_types)]
75#![allow(non_snake_case)]
76
77include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
78```
79
803. **Structure the sys crate.** A `-sys` crate owns the link and the raw bindings; the safe crate depends on it. The `links` manifest key tells Cargo which native library the crate links. Done when: the layout matches the pattern.
81
82```text
83mylib-sys/
84 Cargo.toml # links = "mylib"
85 build.rs # probes pkg-config, falls back to a vendored cc build
86 wrapper.h
87 src/lib.rs # includes the generated bindings
88mylib/
89 Cargo.toml # depends on mylib-sys
90 src/lib.rs # safe API
91```
92
93```rust
94// mylib-sys/build.rs
95fn main() {
96 if let Ok(lib) = pkg_config::probe_library("mylib") {
97 for path in lib.include_paths {
98 println!("cargo:include={}", path.display());
99 }
100 return;
101 }
102 cc::Build::new()
103 .file("vendor/mylib/src/mylib.c")
104 .include("vendor/mylib/include")
105 .compile("mylib");
106 println!("cargo:rerun-if-changed=vendor/mylib/src/mylib.c");
107}
108```
109
1104. **Wrap every unsafe call in a safe API.** Check for null handles, map C error codes to `Result`, free resources in `Drop`, and add `Send` or `Sync` only after verifying the C library's threading contract. Done when: no safe public function reaches `unsafe` without a validity check.
111
112```rust
113pub struct MyLib {
114 handle: *mut ffi::mylib_t,
115}
116
117impl MyLib {
118 pub fn new(config: &str) -> Result<Self, Error> {
119 let c = CString::new(config).map_err(|_| Error::InvalidConfig)?;
120 let handle = unsafe { ffi::mylib_create(c.as_ptr()) };
121 if handle.is_null() {
122 return Err(Error::InitFailed);
123 }
124 Ok(Self { handle })
125 }
126}
127
128impl Drop for MyLib {
129 fn drop(&mut self) {
130 unsafe { ffi::mylib_destroy(self.handle) };
131 }
132}
133```
134
1355. **Export Rust to C with cbindgen.** Exported functions are `extern "C"` with `#[unsafe(no_mangle)]`; ownership crosses the boundary through `Box::into_raw` and `Box::from_raw`. Done when: the generated header exists at the configured path.
136
137```rust
138#[unsafe(no_mangle)]
139pub extern "C" fn mylib_destroy(ptr: *mut MyLib) {
140 if !ptr.is_null() {
141 unsafe { drop(Box::from_raw(ptr)) };
142 }
143}
144```
145
146```rust
147// build.rs
148fn main() {
149 let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
150 cbindgen::Builder::new()
151 .with_crate(crate_dir)
152 .with_language(cbindgen::Language::C)
153 .generate()
154 .expect("cbindgen failed")
155 .write_to_file("include/mylib.h");
156}
157```
158
1596. **Link the library.** Emit link directives from build.rs: `cargo:rustc-link-lib=static=name`, `dylib=name`, or `framework=name` on macOS, plus `cargo:rustc-link-search=` for nonstandard paths. Done when: `cargo build` links without unresolved `-l` errors.
160
1617. **Verify the boundary.** Build the crate, confirm the generated bindings or header exist, and exercise one call across the boundary. Done when: the call returns the expected result.
162
163## Failure and recovery
164
165| Failure class | Behavior |
166|---|---|
167| bindgen cannot parse the header | Reduce `wrapper.h` to the needed includes and pass include paths and defines through `clang_arg`. |
168| Library not found at link time | Add `cargo:rustc-link-search`, probe with pkg-config, or build the vendored source with `cc`. |
169| C constructor returns null | Map it to `Result` or `Option`; never store a null handle. |
170| Thread-safety unknown | Do not add `Send` or `Sync`; the default is neither until the C library's locking contract is verified. |
171| Panic would cross the boundary | Return an error code or use `catch_unwind`; unwinding out of an `extern "C"` function aborts. Use `extern "C-unwind"` only when the C side is built to propagate. |
172| Partial result | Files already written remain; the report names the failed step and its command output. Rollback is version control. |
173
174## Output
175
1761. A crate that builds with a verified call across the FFI boundary.
1772. A report listing files written, the safe API surface, and the link directives emitted.
1783. bindgen builder options, cbindgen.toml keys, `cc`, pkg-config, and safety patterns are in `references/bindgen-cbindgen.md`.