| name | rust-impl-ffi-bindgen |
| description | Use when the user binds a Rust library to C (cbindgen) or imports a C library into Rust (bindgen), declares `extern "C"` functions, uses `#[repr(C)]` types, manages opaque pointers, or works with file descriptors via `BorrowedFd` / `OwnedFd`. Prevents `unsafe extern` requirement misses in 2024, `Box<dyn Fn>` callback ABI issues, raw `c_char` ownership confusion, and fd-leak via raw `RawFd`. Covers: C FFI patterns (`extern "C"`, `#[repr(C)]`, `#[unsafe(no_mangle)]` in 2024, `unsafe extern` block in 2024), bindgen workflow (build.rs config, header inclusion, allowlist / blocklist), cbindgen workflow (cbindgen.toml, generate C header from Rust API), opaque types (pointer-to-incomplete pattern), string passing (CString / CStr / *const c_char), error handling across FFI, function-pointer callbacks, `BorrowedFd` / `OwnedFd` / `AsFd` / `AsRawFd` for unix fd safety, Drop guards for foreign resources. Keywords: FFI, "extern C", "#[repr(C)]", "#[no_mangle]", "#[unsafe(no_mangle)]", "unsafe extern", bindgen, cbindgen, "opaque pointer", "opaque type", "CString", "CStr", "c_char", "*const c_char", "callback FFI", "function pointer", "BorrowedFd", "OwnedFd", "AsFd", "AsRawFd", RawFd, "fd ownership", "C library", "linking C", "Rust C interop".
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires Rust 1.85+, edition 2024. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
rust-impl-ffi-bindgen
C interop has two directions, and each has a generator. To call a C library FROM Rust, write unsafe extern "C" { ... } declarations (by hand for small APIs) or run bindgen against the header (for everything else). To expose a Rust library TO C, mark the exported fn items with extern "C" plus #[unsafe(no_mangle)] and run cbindgen to emit the matching .h file. The ABI surface in between is governed by #[repr(C)] types, opaque pointers, null-terminated strings (CString / CStr), function pointers, and Drop guards for foreign-allocated resources. On Unix, file descriptors crossing the boundary use OwnedFd (owning, closes on Drop) and BorrowedFd<'_> (non-owning, lifetime-bounded), never raw RawFd for ownership-bearing APIs.
Cross-references: [[rust-syntax-unsafe]] (unsafe operations, UB categories, unsafe extern rationale), [[rust-syntax-edition-2024]] (mandatory unsafe extern + #[unsafe(no_mangle)]), [[rust-impl-build-scripts]] (build.rs to invoke bindgen / cbindgen, cargo:rustc-link-lib), [[rust-impl-cargo-project]] (build-dependencies, links key).
When to use this skill
- User writes
extern "C" { ... } and the compiler rejects it in edition 2024.
- User adds
#[no_mangle] and gets the unsafe_attr_outside_unsafe warning or error.
- User wants to call a C library from Rust and asks "do I need bindgen".
- User wants to expose a Rust API to C / other languages and asks "do I need cbindgen".
- User defines a struct shared with C and the layout is wrong.
- User passes a Rust
String or &str to a C function and the call segfaults.
- User receives a
*const c_char from C and reads it as a Rust &str directly.
- User stores a Rust closure in a struct passed to C and the callback ABI is wrong.
- User passes file descriptors across FFI and asks "is
RawFd correct" or hits double-close.
- User reaches for
transmute to convert FFI types instead of using CStr / CString / OwnedFd.
The 2024 edition rules: unsafe extern + unsafe attributes
Two changes ship with edition 2024 (Rust 1.85+). Every FFI declaration MUST use the new syntax:
unsafe extern "C" {
pub fn strlen(p: *const std::ffi::c_char) -> usize;
pub safe fn sqrt(x: f64) -> f64;
}
#[unsafe(no_mangle)]
pub extern "C" fn my_rust_function(x: i32) -> i32 {
x * 2
}
ALWAYS write unsafe extern "C" { ... }, NEVER the bare extern "C" { ... }. The pre-2024 form is a hard error in edition 2024 and a future-compat warning in earlier editions. ALWAYS write #[unsafe(no_mangle)], NEVER plain #[no_mangle] in edition 2024.
Inside an unsafe fn body in edition 2024, the unsafe_op_in_unsafe_fn lint is warn-by-default. Wrap each individual unsafe operation in its own unsafe { ... } block to keep the soundness comment local. See [[rust-syntax-unsafe]] for the full unsafe-block hygiene rules.
#[repr(C)] : the only stable layout for FFI
#[repr(Rust)] (the default) is explicitly unspecified: the compiler may reorder fields, pick any padding, and change layout between versions. NEVER expose a #[repr(Rust)] type across FFI.
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[repr(transparent)]
pub struct Handle(*mut std::ffi::c_void);
#[repr(C)]
pub enum Status {
Ok = 0,
InvalidArg = 1,
NotFound = 2,
}
Choose #[repr(C)] for shared structs and shared enums with explicit discriminants. Choose #[repr(transparent)] for wrapper types that must keep the inner ABI (e.g. NonNull<T> newtypes). Choose #[repr(u8)] / #[repr(i32)] for C-style enums whose tag size MUST be fixed.
bindgen workflow: C headers to Rust
bindgen is invoked from build.rs and writes a generated bindings.rs into OUT_DIR. The crate consumes it via include!(concat!(env!("OUT_DIR"), "/bindings.rs")).
[package]
name = "mylib-sys"
edition = "2024"
links = "mylib"
[build-dependencies]
bindgen = "0.71"
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo:rustc-link-lib=mylib");
println!("cargo:rerun-if-changed=wrapper.h");
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.clang_arg("-I/usr/local/include")
.allowlist_function("mylib_.*")
.allowlist_type("mylib_.*")
.allowlist_var("MYLIB_.*")
.blocklist_type("FILE")
.derive_default(true)
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("bindgen failed");
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out.join("bindings.rs"))
.expect("write bindings");
}
#![allow(non_camel_case_types, non_snake_case, non_upper_case_globals)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
ALWAYS use allowlist_* to keep the binding small: every type bindgen emits is part of your ABI surface. ALWAYS pair the -sys crate with a safe wrapper crate that exposes idiomatic Rust types; never expose raw bindings as the crate's public API. See references/methods.md for the full builder option list.
cbindgen workflow: Rust API to C header
cbindgen walks the public Rust API and emits a C / C++ header. Run it either from build.rs (for inclusion in your own build) or as a one-shot CLI before publishing.
language = "C"
header = "/* Generated by cbindgen. Do not edit. */"
include_guard = "MYLIB_H"
autogen_warning = "/* Warning, this file is autogenerated by cbindgen. */"
no_includes = false
sys_includes = ["stdint.h", "stddef.h"]
cpp_compat = true
style = "type"
[export]
prefix = "mylib_"
[export.rename]
"Status" = "mylib_status_t"
[enum]
prefix_with_name = true
fn main() {
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let out_dir = std::env::var("OUT_DIR").unwrap();
cbindgen::Builder::new()
.with_crate(crate_dir)
.with_config(cbindgen::Config::from_file("cbindgen.toml").unwrap())
.generate()
.expect("cbindgen failed")
.write_to_file(format!("{out_dir}/mylib.h"));
}
ALWAYS keep cbindgen.toml under source control. NEVER hand-edit the generated header: regenerate. Mark public functions with both #[unsafe(no_mangle)] and pub extern "C" so cbindgen emits them.
Opaque types: hide Rust internals behind a pointer
A C consumer holds an opaque pointer; the Rust struct's fields are not visible to C. Two patterns exist; both are stable and ABI-safe.
pub struct Engine {
cfg: Vec<u8>,
state: SomeState,
}
#[unsafe(no_mangle)]
pub extern "C" fn engine_new() -> *mut Engine {
Box::into_raw(Box::new(Engine { cfg: Vec::new(), state: SomeState::init() }))
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn engine_free(p: *mut Engine) {
if p.is_null() { return; }
drop(unsafe { Box::from_raw(p) });
}
cbindgen emits a forward declaration so the C side sees struct Engine; as an incomplete type and can only handle Engine *.
#[repr(C)]
pub struct Foo {
_private: [u8; 0],
_marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>,
}
ALWAYS pair Box::into_raw with a free-function that calls Box::from_raw exactly once. NEVER let C call free() on a pointer that Rust allocated, and NEVER let Rust Box::from_raw a pointer that C malloc-ed.
Strings across FFI: CString, CStr, c_char
Rust strings are not null-terminated and not necessarily valid for C consumption. std::ffi::{CString, CStr} are the bridge types.
use std::ffi::{CStr, CString, c_char};
pub fn pass_to_c(s: &str) -> Result<(), std::ffi::NulError> {
let owned = CString::new(s)?;
unsafe { c_takes_string(owned.as_ptr()) };
Ok(())
}
unsafe extern "C" {
fn c_takes_string(s: *const c_char);
fn c_returns_static() -> *const c_char;
}
pub fn read_from_c() -> Result<String, std::str::Utf8Error> {
let cstr = unsafe { CStr::from_ptr(c_returns_static()) };
cstr.to_str().map(|s| s.to_owned())
}
ALWAYS construct CString::new(...) before sending a Rust string to C, and keep the CString alive for the entire duration of the C call. NEVER cast &str directly to *const c_char: a Rust &str lacks the trailing \0. NEVER take ownership of a *const c_char returned by a C function unless its API explicitly transfers ownership; if it does, wrap with CString::from_raw and let Rust free it.
File descriptors: BorrowedFd, OwnedFd, AsFd
On Unix, std::os::fd distinguishes ownership at the type level. ALWAYS prefer these over raw RawFd for any API that transfers or borrows an fd.
| Type | Owns? | Closes on Drop? | Lifetime | Use for |
|---|
RawFd (= i32) | no | no | none | The integer at the very edge of FFI, never as an API type. |
BorrowedFd<'fd> | no | no | 'fd | A short-lived non-owning view of an fd that someone else owns. |
OwnedFd | yes | yes | static | Returning an fd from a constructor; storing an fd in a struct. |
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd, RawFd};
unsafe extern "C" {
fn dup(fd: RawFd) -> RawFd;
fn close(fd: RawFd) -> i32;
}
pub fn duplicate(fd: BorrowedFd<'_>) -> std::io::Result<OwnedFd> {
let raw = unsafe { dup(fd.as_raw_fd()) };
if raw < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(unsafe { OwnedFd::from_raw_fd(raw) })
}
}
fn pass_borrowed<T: AsFd>(t: &T) {
let _b: BorrowedFd<'_> = t.as_fd();
}
ALWAYS return OwnedFd from any function that produces a new fd, and ALWAYS take BorrowedFd<'_> (or &impl AsFd) for functions that only need to read or operate on an fd. NEVER store a RawFd in a struct alongside other state: there is no Drop to close it, and ownership becomes ambiguous. NEVER call close() on an fd you hold a BorrowedFd to: closure is the owner's job.
Callbacks: function pointers, not closures with captures
Rust closures are types with hidden state; they have no stable C ABI. The portable form is extern "C" fn(...) -> ... plus a separate *mut c_void user-data pointer for state.
use std::ffi::c_void;
type Callback = extern "C" fn(event: i32, user: *mut c_void);
unsafe extern "C" {
fn register_callback(cb: Callback, user: *mut c_void);
}
#[repr(C)]
struct MyState { hits: u32 }
extern "C" fn on_event(event: i32, user: *mut c_void) {
let state = unsafe { &mut *(user as *mut MyState) };
state.hits = state.hits.saturating_add(1);
let _ = event;
}
pub fn install(state: &mut MyState) {
unsafe { register_callback(on_event, state as *mut MyState as *mut c_void) };
}
ALWAYS use a plain extern "C" fn for the callback type and pass closure state via a void* user-data parameter. NEVER cast a Box<dyn Fn> or a closure-with-captures to a function pointer: the ABI does not match, even if the cast compiles.
A panic that unwinds across an extern "C" boundary is undefined behavior. Since Rust 1.81 the language aborts on an uncaught panic in extern "C" rather than continuing into C, which is safer but still terminates the process. Wrap the callback body with std::panic::catch_unwind if recovery matters; see references/examples.md.
Errors across FFI: status codes, out-parameters, or Result inside Rust
C has no Result<T, E>. Three idioms cover essentially every case:
- Return an integer status code (
0 = ok, negative or named enum for errors). Most ergonomic for callers in any language.
- Out-parameter for the value, status for the error.
int32_t fn(InputT in, OutputT* out).
- Translate at the boundary. Inside Rust, work with
Result<T, E>; at the extern "C" entry point, catch errors and convert to a status code, optionally setting a thread-local "last error" string.
#[repr(C)]
pub enum MylibStatus { Ok = 0, InvalidArg = 1, Internal = 2 }
#[unsafe(no_mangle)]
pub extern "C" fn mylib_compute(in_: i32, out: *mut i32) -> MylibStatus {
if out.is_null() { return MylibStatus::InvalidArg; }
match do_compute(in_) {
Ok(v) => { unsafe { *out = v }; MylibStatus::Ok }
Err(_) => MylibStatus::Internal,
}
}
NEVER let a Rust panic escape an extern "C" fn. ALWAYS catch_unwind if the function calls into Rust code that may panic, and translate the panic to a status code. See [[rust-impl-error-handling]] for the Rust-side Result design.
Drop guards for foreign resources
Foreign resources (file descriptors, library handles, malloc-ed buffers) escape Rust's RAII unless wrapped in a type with a Drop impl that calls the C deallocator.
pub struct CBuffer { ptr: *mut u8, len: usize }
impl CBuffer {
pub fn new(len: usize) -> Option<Self> {
let ptr = unsafe { libc::malloc(len) as *mut u8 };
(!ptr.is_null()).then_some(Self { ptr, len })
}
pub fn as_slice(&self) -> &[u8] {
unsafe { core::slice::from_raw_parts(self.ptr, self.len) }
}
}
impl Drop for CBuffer {
fn drop(&mut self) {
unsafe { libc::free(self.ptr as *mut _) }
}
}
ALWAYS pair every foreign allocator with the matching deallocator in a Drop impl. NEVER panic from Drop; if cleanup fails, log it. See [[rust-syntax-unsafe]] §"Drop and panic" for the full rationale.
Quick decision tree
- C library to call from Rust ?
- Header is small (under ~30 symbols) and stable -> hand-write
unsafe extern "C" { ... }.
- Header is large or generated ->
bindgen from build.rs. Always pair with a safe wrapper crate.
- Rust library to expose to C ?
- Mark exports with
#[unsafe(no_mangle)] pub extern "C" fn ....
- Generate
mylib.h with cbindgen. Keep cbindgen.toml in the repo.
- Shared struct ?
#[repr(C)]. Single-field wrapper that must match inner ABI -> #[repr(transparent)].
- Hide Rust struct from C ? Box it into
*mut T, expose only opaque pointer + new / free pair.
- Strings ?
CString::new(...) Rust to C; CStr::from_ptr(...) C to Rust. Always document who owns the bytes.
- File descriptors ?
OwnedFd if owning, BorrowedFd<'_> if borrowing, AsFd for generic input. Never RawFd in API types.
- Callbacks ?
extern "C" fn(..., *mut c_void), user-data void* for closure state. Never cast a Rust closure to a fn pointer.
- Errors ? Status-code return or out-parameter pattern,
catch_unwind around any Rust body that may panic.
Reference files
references/methods.md : full bindgen::Builder and cbindgen::Config option index, std::os::fd API table, std::ffi API table, ABI-relevant attributes (#[repr(...)], #[unsafe(...)], #[link], extern "ABI").
references/examples.md : end-to-end -sys crate, end-to-end Rust-to-C crate, opaque-type round-trip, callback with catch_unwind, fd-passing round-trip.
references/anti-patterns.md : the seven mandatory anti-patterns plus six more drawn from real GitHub issues (linkage cycles, missing links = ..., transmuting &str to *const c_char, etc.).
Sources