Introduction
C and Rust have different type systems, memory layouts, and string representations. Correctly mapping between them is crucial for FFI — a single wrong type can cause undefined behavior, memory corruption, or crashes. This lesson covers the complete set of type mappings you need for safe interop.
Key Concepts
#[repr(C)]: Attribute that forces Rust to lay out a struct's fields in the same order and alignment as C.CStr/CString: Rust types for working with null-terminated C strings.c_void: Rust's equivalent of C'svoid *, an opaque pointer type.NonNull<T>: A pointer wrapper that guarantees non-null, useful for FFI return types.
Real World Context
Every C library binding requires type mapping. If you use rusqlite (SQLite), openssl (TLS), or any -sys crate, the underlying bindings map C types to Rust. Getting this wrong leads to memory corruption that is extremely hard to debug.
Deep Dive
Primitive Type Mappings
Use the libc crate for portable type aliases:
| C Type | Rust Type | libc alias |
|--------|-----------|------|---|
| char | i8 or u8 | c_char |
| short | i16 | c_short |
| int | i32 | c_int |
| long | i32 or i64 | c_long |
| long long | i64 | c_longlong |
| size_t | usize | size_t |
| void * | *mut c_void | — |
| float | f32 | c_float |
| double | f64 | c_double |
Do not hardcode i32 for int — use c_int, because the sizes can differ across platforms.
String Conversion
C strings are null-terminated byte arrays. Rust strings are length-prefixed UTF-8. Converting between them requires care:
rustuse std::ffi::{CStr, CString}; // Rust -> C: Use CString (allocates and appends null byte) let rust_str = "hello"; let c_string = CString::new(rust_str).expect("interior null byte"); let c_ptr: *const i8 = c_string.as_ptr(); // IMPORTANT: c_ptr is only valid while c_string is alive! // C -> Rust: Use CStr (borrows from a C pointer) unsafe { let c_str: &CStr = CStr::from_ptr(c_ptr); let rust_str: &str = c_str.to_str().expect("invalid UTF-8"); }
The most common bug is dropping CString before the C function uses the pointer.
Struct Layout with #[repr(C)]
By default, Rust reorders and pads struct fields for optimization. #[repr(C)] forces C-compatible layout:
rustuse libc::c_int; // C: struct Point { int x; int y; }; #[repr(C)] struct Point { x: c_int, y: c_int, }
Without #[repr(C)], passing this struct to C would read garbage values.
Nullable Pointers with NonNull
For C functions that might return NULL, use Option<NonNull<T>> for type safety:
rustuse std::ptr::NonNull; unsafe extern "C" { fn find_item(id: i32) -> *mut Item; } // Safe wrapper returns Option instead of raw pointer fn find(id: i32) -> Option<NonNull<Item>> { // SAFETY: find_item is a valid C function unsafe { NonNull::new(find_item(id)) } }
This eliminates an entire class of null pointer bugs.
Common Pitfalls
- Dropping CString before the pointer is used —
CString::new("hello").unwrap().as_ptr()is a dangling pointer because theCStringis dropped immediately. Bind it to a variable first. - Forgetting
#[repr(C)]— Without it, Rust may reorder fields, causing C to read the wrong values. This is especially dangerous because it can appear to work in debug mode but fail in release. - Using
Stringinstead ofCString— RustStringis not null-terminated. Passing it as a C string reads past the end of the buffer.
Best Practices
- Always use
libctype aliases — They handle platform differences in type sizes. - Use
CStr::to_str()with proper error handling — C strings may contain invalid UTF-8. Useto_string_lossy()when you can tolerate replacement characters. - Keep CString alive for the duration of the FFI call — Bind to a named variable, not a temporary.
Summary
- Use
libctype aliases (c_int,c_char,size_t) for portable FFI. - Convert strings with
CString(Rust to C) andCStr(C to Rust). - Apply
#[repr(C)]to all structs passed across the FFI boundary. - Use
NonNull<T>andOptioninstead of raw nullable pointers. - Always keep
CStringalive while its pointer is in use.
Code Examples
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
// C header:
// struct Person {
// const char* name;
// int age;
// };
// void greet(const struct Person* p);
#[repr(C)]
struct Person {
name: *const c_char,
age: c_int,
}
unsafe extern "C" {
fn greet(person: *const Person);
}
/// Safe wrapper that handles string conversion and lifetime management.
fn greet_person(name: &str, age: i32) {
// CString must outlive the FFI call!
let name_c = CString::new(name).expect("name contained null byte");
let person = Person {
name: name_c.as_ptr(),
age: age as c_int,
};
// SAFETY: person.name points to valid null-terminated string
// (name_c is alive until end of this function)
unsafe {
greet(&person);
}
// name_c dropped here — after greet() has returned
}