FFI Testing and Debugging
Bugs at an FFI boundary—such as mismatched struct field offsets, missing #[repr(C)] attributes, use-after-free across C callbacks, or off-by-one buffer lengths—can cause silent memory corruption if left untested. FFI testing and debugging combines compile-time layout assertions, automated header verification, Miri and AddressSanitizer runs, and cross-language round-trip tests.
FFI Verification Stack:
1. Compile-Time Layout Checks (`const { assert!(size_of / align_of / offset_of!) }`)
2. Automated Binding Sync (`bindgen` layout tests & `cbindgen` CI diff check)
3. Rust Pointer/Provenance UB (`cargo +nightly miri test` on Rust-side wrappers)
4. Cross-Language Memory Bugs (`-Zsanitizer=address` across Rust + C compiled code)
1. Compile-Time Layout and Offset Verification
Whenever you write a #[repr(C)] struct by hand to match a C header definition, add a const _: () = { ... }; block that asserts the struct's exact size_of, align_of, and every field's offset_of! at compile time. When bindgen generates bindings from a C header, it automatically emits unit tests verifying these exact layout metrics.
use core::ffi::{c_char, c_int};
use std::mem::{align_of, offset_of, size_of};
/// Matches C `struct c_packet_meta { int32_t id; uint8_t tag[4]; int64_t timestamp; };`
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CPacketMeta {
pub id: c_int,
pub tag: [c_char; 4],
pub timestamp: i64,
}
// Compile-time layout verification: fails compilation immediately if layout drifts!
const _: () = {
assert!(size_of::<CPacketMeta>() == 16);
assert!(align_of::<CPacketMeta>() == 8);
assert!(offset_of!(CPacketMeta, id) == 0);
assert!(offset_of!(CPacketMeta, tag) == 4);
assert!(offset_of!(CPacketMeta, timestamp) == 8);
};
fn main() {
let meta = CPacketMeta {
id: 101,
tag: [b'R' as c_char, b'U' as c_char, b'S' as c_char, b'T' as c_char],
timestamp: 1_700_000_000,
};
println!("Verified CPacketMeta layout (16B): id={}, ts={}", meta.id, meta.timestamp);
}
Verified CPacketMeta layout (16B): id=101, ts=1700000000
2. Testing FFI Entrypoints Under Miri for Edge-Case Pointers
Even when the real C caller is an external project, you can test your exported pub unsafe extern "C" fn entrypoints directly inside #[test] functions and run them under cargo +nightly miri test to verify:
- Passing
NULLpointers (std::ptr::null(),std::ptr::null_mut()) returns clean error codes without dereferencing null. - Passing
(NULL, 0)empty buffers does not triggerslice::from_raw_parts(NULL, 0)Undefined Behavior. - Creating, mutating, and destroying opaque handles (
Box::into_raw/Box::from_raw) leaves zero leaked allocations and violates no Stacked/Tree Borrows aliasing rules.
#[repr(C)]
pub struct CounterHandle {
value: u64,
}
#[unsafe(no_mangle)]
pub extern "C" fn counter_new(initial: u64) -> *mut CounterHandle {
Box::into_raw(Box::new(CounterHandle { value: initial }))
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_add(ptr: *mut CounterHandle, delta: u64) -> i32 {
if ptr.is_null() {
return -1;
}
// SAFETY: Caller guarantees `ptr` is valid and unaliased.
unsafe {
(*ptr).value = (*ptr).value.saturating_add(delta);
}
0
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_free(ptr: *mut CounterHandle) {
if !ptr.is_null() {
unsafe {
drop(Box::from_raw(ptr));
}
}
}
fn main() {
// 1. Test null-pointer defense
assert_eq!(unsafe { counter_add(std::ptr::null_mut(), 10) }, -1);
// 2. Test full create -> mutate -> free lifecycle (Miri verifies zero memory leaks!)
let h = counter_new(50);
assert_eq!(unsafe { counter_add(h, 25) }, 0);
unsafe {
assert_eq!((*h).value, 75);
counter_free(h);
// Also verify freeing NULL is a safe no-op (matching C `free(NULL)` convention):
counter_free(std::ptr::null_mut());
}
println!("FFI lifecycle and null-pointer edge cases verified");
}
FFI lifecycle and null-pointer edge cases verified
3. Debugging Mixed Rust + C Stacks with AddressSanitizer and LLDB
| Tool | How to Run on Mixed Rust + C Code | What It Catches |
|---|---|---|
AddressSanitizer (ASan) |
Compile C code in cc::Build with .flag("-fsanitize=address") and run Rust with RUSTFLAGS="-Zsanitizer=address" |
Heap buffer overflows, use-after-free, and mismatched malloc/Box deallocations across the Rust/C boundary. |
rust-lldb / gdb |
rust-lldb target/debug/deps/ffi_test-* |
Sets breakpoints seamlessly in both Rust functions (b rust_adler32) and C functions (b c_caller), walking unified stack frames via bt. |
nm -gU / objdump -T |
nm -gU target/release/libmy_ffi.dylib |
Lists all exported dynamic symbols to confirm #[unsafe(no_mangle)] symbols are present and internal Rust symbols stay private. |