How to Pass Structs Between Rust and C
You're building a high-performance image processor in Rust. The core algorithm is blazing fast, but you need to integrate a legacy C library that handles a specific compression format. That library expects a ImageHeader struct containing width, height, and a pixel format flag. You write the struct in Rust, call the C function, and the program segfaults. The compiler didn't complain. The linker didn't complain. The crash happens at runtime because Rust and C disagreed on how the bytes are arranged in memory.
Rust and C speak different dialects of memory layout. Rust's compiler is an optimizer. It rearranges struct fields to minimize padding, packs bits together, and reorders members to make cache access faster. C's compiler is more conservative. It lays out fields in declaration order and pads them to align with the architecture's word size.
Imagine you and a friend agree to pass a box of tools back and forth. You decide to pack the box efficiently. You wedge the screwdriver into a gap between the hammer and the wrench. Your friend expects the screwdriver to be in the top left corner. When they reach for it, they grab the hammer instead. The tools are the same, but the packing strategy is different. #[repr(C)] tells Rust to stop optimizing the layout and pack the box exactly how C expects it.
The minimal contract
To pass a struct across the boundary, you need two things. First, the struct must use C's layout rules. Second, the function must use C's calling convention.
use std::os::raw::c_int;
// #[repr(C)] forces Rust to match C's memory layout rules.
// Without this, Rust might reorder fields or remove padding.
#[repr(C)]
pub struct Point {
x: c_int,
y: c_int,
}
// #[no_mangle] prevents Rust from renaming the symbol.
// extern "C" sets the calling convention for C compatibility.
#[no_mangle]
pub extern "C" fn get_point() -> Point {
Point { x: 10, y: 20 }
}
The C side mirrors this structure.
#include <stdio.h>
typedef struct {
int x;
int y;
} Point;
Point get_point(void);
int main() {
Point p = get_point();
printf("x=%d, y=%d\n", p.x, p.y);
return 0;
}
Convention aside: Use std::os::raw::c_int instead of i32 for C integers. The size of int in C varies by platform. c_int maps to the correct size for the target, while i32 is always 32 bits. On some architectures, int is 16 bits. Using i32 there would break the layout.
How the layout works
When you mark a struct with #[repr(C)], the Rust compiler disables its layout optimizations for that type. It places fields in the order you declared them. It inserts padding bytes between fields to satisfy alignment requirements, just like a C compiler would. The size of the struct becomes predictable across languages.
Alignment matters. Modern CPUs read memory in chunks. If a field is misaligned, the CPU might need multiple reads or, on some architectures, will fault entirely. C compilers pad structs so that each field starts at an address divisible by its size.
Consider this struct.
#[repr(C)]
pub struct Header {
id: u8,
timestamp: u64,
}
Without #[repr(C)], Rust might pack id and timestamp tightly, or reorder them. With #[repr(C)], Rust places id at offset 0. It then inserts 7 bytes of padding so that timestamp starts at offset 8, which is aligned to 8 bytes. The total size becomes 16 bytes. If you omit #[repr(C)], Rust might make the struct 9 bytes or reorder the fields. C will read timestamp from the wrong offset and get garbage.
The calling convention is the other half of the contract. extern "C" tells the compiler how to pass arguments and return values. Rust's default calling convention might use different registers or stack layouts than C. Mismatched calling conventions corrupt the stack. The compiler won't catch this if you cast types incorrectly. The crash happens when the function returns and the stack pointer is wrong.
Treat the #[repr(C)] attribute as a binding agreement. If you change the field order in Rust but not in C, the data is corrupted.
Realistic FFI boundary
Real code rarely passes simple points. You usually pass configuration structs, state objects, or buffers. Pointers are common because large structs are expensive to copy and may exceed register limits.
use std::os::raw::{c_int, c_char};
use std::ptr;
// #[repr(C)] ensures the layout matches C.
// Using c_int for the flag avoids bool size mismatches.
#[repr(C)]
pub struct Config {
width: c_int,
height: c_int,
enable_filter: c_int,
}
/// Processes a configuration and returns the pixel count if filtering is enabled.
/// Returns -1 on error.
#[no_mangle]
pub extern "C" fn process_config(config: *const Config) -> c_int {
// SAFETY: Caller must provide a valid pointer.
// 1. config must point to allocated memory.
// 2. config must be properly aligned for Config.
// 3. The data must remain valid for the duration of this call.
unsafe {
if config.is_null() {
return -1;
}
// Dereference the raw pointer to a reference.
let c = &*config;
if c.enable_filter != 0 {
c.width * c.height
} else {
0
}
}
}
Convention aside: Never use Rust's bool in FFI structs. Rust's bool is usually 1 byte, but its alignment and representation are not guaranteed by the ABI. C's _Bool might differ. Use c_int for flags. Treat non-zero as true. This is the standard practice in the Rust FFI community.
The SAFETY comment lists the invariants the caller must uphold. If the C side passes a null pointer, or a dangling pointer, or misaligned memory, your Rust code invokes undefined behavior. The compiler trusts you inside the unsafe block.
Treat the SAFETY comment as a proof. If you can't write the invariants, you don't have a safe wrapper.
Pitfalls and compiler errors
FFI is where Rust's safety guarantees end and your responsibility begins. The compiler can't verify C code. You have to enforce the contract manually.
Omitting #[repr(C)] is the most common mistake. The code compiles. The linker succeeds. The program crashes or produces garbage data. There is no error code for this. You get undefined behavior. Always check that both sides use the same layout attributes.
Using Rust types directly breaks the boundary. String, Vec, Option, and Result have internal representations that C cannot understand. If you try to pass a String to a C function expecting a pointer, you get a type mismatch.
// This fails to compile.
// E0308: mismatched types.
// The function expects a pointer, not a String.
extern "C" fn bad_call(s: String) { ... }
Even if you cast, you're leaking memory or creating a dangling pointer. String contains a pointer, length, and capacity. C sees three integers and has no idea how to free the allocation. Use CString and CStr for text. Use raw pointers and lengths for buffers.
Enums are tricky. Rust enums can hold data. C enums are just integers. If you mark a Rust enum with #[repr(C)], it becomes a tagged union in C. This is complex and error-prone.
// This creates a union in C.
// C code must handle the discriminant and the union payload.
#[repr(C)]
pub enum Shape {
Circle { radius: f32 },
Square { size: f32 },
}
Most C libraries don't expect unions. It's safer to use integers for enums or separate structs. If you need to expose an enum to C, use #[repr(C)] only if the C side knows how to handle the discriminant. Otherwise, map it to c_int.
Dereferencing raw pointers requires unsafe. If you try to dereference a pointer outside an unsafe block, the compiler rejects you.
// E0133: dereference of raw pointer requires unsafe function or block.
let value = *ptr;
Wrap the dereference in unsafe and document the safety conditions. Never dereference a pointer without checking for null if the C side might pass null.
The compiler can't save you from layout mismatches. You have to enforce the contract manually.
When to use what
Use #[repr(C)] structs when you need to share data layouts between Rust and C. This is the foundation of FFI.
Use bindgen when you are consuming a large C header file. It generates the Rust bindings automatically, including #[repr(C)] and type mappings. Hand-writing bindings for complex headers is tedious and error-prone.
Use cbindgen when you are exposing Rust types to C. It generates C header files from your Rust code. This keeps the C header in sync with your Rust definitions.
Use pointers (*const T, *mut T) when passing large structs or optional data. Passing large structs by value can be slow and may exceed register limits. Pointers are cheap to pass and allow C to modify the data if needed.
Use c_int for booleans and flags in FFI structs. Rust's bool has no guaranteed size or alignment in the ABI. c_int matches C's int and avoids subtle layout bugs.
Use #[repr(C, packed)] only when the C library explicitly requires packed structs. Packed structs disable alignment optimizations and can cause performance penalties or hardware faults on some architectures. Most C libraries don't use packed structs.
Automate the bindings when you can. Hand-writing #[repr(C)] is fine for a few types, but bindgen saves you from subtle layout bugs in complex headers.