Rust FFI: Calling C Libraries Without Losing Your Mind 🦀🔗
Rust FFI: Calling C Libraries Without Losing Your Mind 🦀🔗
Hot take: If you think Rust's ecosystem is small, you're missing the point - Rust can call EVERY C library ever written! That's literally decades of systems programming goodness! 📚
Coming from 7 years of Laravel and Node.js, I was used to ecosystems where everything was packaged nicely. Need Redis? npm install redis. Need image processing? composer require intervention/image. Easy peasy!
Then I started building RF/SDR tools for my radio hobby and hit a wall: the best SDR libraries are all written in C! librtlsdr, liquid-dsp, SoapySDR - all C. I could rewrite them in Rust... or I could learn FFI (Foreign Function Interface) and call them directly! 🎯
Spoiler: FFI is AMAZING. Let me show you why!
What is FFI, Really? 🤔
FFI = Foreign Function Interface = Calling code written in other languages
In JavaScript (Node.js):
// Call native C++ code through Node addons
const addon = require('./build/Release/addon.node');
addon.hello(); // Calls C++ function
// Complex build process, N-API wrappers, etc.
In PHP:
// Call C through extensions
$result = some_c_extension_function();
// But you can't just call ANY C library!
// Need to write PHP extensions in C (painful!)
In Rust:
// Direct C interop, no wrappers needed!
extern "C" {
fn printf(format: *const i8, ...) -> i32;
}
unsafe {
printf("Hello from C!\n\0".as_ptr() as *const i8);
}
// That's it! Call C directly!
The magic: Rust can call C functions directly! No runtime overhead. No wrappers. Just raw, native performance! 🚀
Why Web Devs Like Me Should Care 💡
You might think: "I write JavaScript. Why do I need C libraries?"
Here's why FFI matters:
1. Performance-Critical Code
When I started processing RF signals in real-time, JavaScript wasn't fast enough. The best signal processing libraries? All written in C!
Options:
- ❌ Rewrite everything in Rust (months of work!)
- ✅ Use FFI to call proven C libraries (days of work!)
Real example - My RTL-SDR project:
// librtlsdr is a C library for software-defined radio
extern "C" {
fn rtlsdr_open(dev: *mut *mut RtlsdrDev, index: u32) -> i32;
fn rtlsdr_read_sync(dev: *mut RtlsdrDev,
buf: *mut u8,
len: i32,
n_read: *mut i32) -> i32;
}
// Now I can read from my $20 USB radio dongle!
// Fast as C, safe Rust wrapper around it!
2. Existing System Libraries
Need to talk to hardware? OS APIs? Graphics drivers? They're all C!
Example - Calling Linux system functions:
use libc::{getpid, getuid};
fn main() {
unsafe {
let pid = getpid();
let uid = getuid();
println!("PID: {}, UID: {}", pid, uid);
}
}
// Direct system calls! No overhead!
3. Legacy Codebases
Your company has 20 years of battle-tested C code. Do you:
- Rewrite it all in Rust? (Years! Bugs!)
- Call it from Rust via FFI? (Days! Safe!)
The smart choice: Incremental adoption via FFI! 🎯
The Safety Challenge: Where Rust Meets C 🛡️
Here's the catch: C doesn't have Rust's safety guarantees!
What can go wrong:
- Buffer overflows
- NULL pointer dereferences
- Use-after-free
- Data races
- Memory leaks
Rust's solution: The unsafe keyword!
// Safe Rust
fn add(a: i32, b: i32) -> i32 {
a + b // Compiler guarantees safety!
}
// FFI requires unsafe
extern "C" {
fn c_function(ptr: *mut u8);
}
unsafe {
c_function(std::ptr::null_mut());
// YOU guarantee safety!
// Compiler trusts you (scary!)
}
The pattern: unsafe blocks are like quarantine zones. Keep them small, audit them carefully, and wrap them in safe APIs! 🔒
Building a Safe Wrapper: The Right Way™ ✅
Let me show you how I wrapped librtlsdr safely:
Step 1: Declare the C functions
// Raw C bindings (unsafe!)
mod ffi {
use std::os::raw::{c_int, c_uint, c_void};
#[repr(C)]
pub struct RtlsdrDev {
_private: [u8; 0], // Opaque type!
}
extern "C" {
pub fn rtlsdr_open(dev: *mut *mut RtlsdrDev,
index: c_uint) -> c_int;
pub fn rtlsdr_close(dev: *mut RtlsdrDev) -> c_int;
pub fn rtlsdr_set_center_freq(dev: *mut RtlsdrDev,
freq: c_uint) -> c_int;
pub fn rtlsdr_read_sync(dev: *mut RtlsdrDev,
buf: *mut u8,
len: c_int,
n_read: *mut c_int) -> c_int;
}
}
What's happening:
#[repr(C)]ensures Rust uses C memory layoutextern "C"tells Rust to use C calling convention- All raw pointers (
*mut,*const) - very unsafe!
Step 2: Create a safe Rust wrapper
use std::ptr;
pub struct RtlSdr {
dev: *mut ffi::RtlsdrDev,
}
impl RtlSdr {
// Safe constructor!
pub fn open(index: u32) -> Result<Self, String> {
let mut dev = ptr::null_mut();
let result = unsafe {
ffi::rtlsdr_open(&mut dev, index)
};
if result < 0 {
return Err(format!("Failed to open device {}", index));
}
if dev.is_null() {
return Err("Device pointer is null".to_string());
}
Ok(RtlSdr { dev })
}
// Safe method!
pub fn set_frequency(&mut self, freq: u32) -> Result<(), String> {
let result = unsafe {
ffi::rtlsdr_set_center_freq(self.dev, freq)
};
if result < 0 {
return Err("Failed to set frequency".to_string());
}
Ok(())
}
// Safe reading with proper bounds checking!
pub fn read_sync(&mut self, buffer: &mut [u8]) -> Result<usize, String> {
let mut n_read: i32 = 0;
let result = unsafe {
ffi::rtlsdr_read_sync(
self.dev,
buffer.as_mut_ptr(),
buffer.len() as i32,
&mut n_read
)
};
if result < 0 {
return Err("Read failed".to_string());
}
Ok(n_read as usize)
}
}
// Automatic cleanup when dropped!
impl Drop for RtlSdr {
fn drop(&mut self) {
unsafe {
ffi::rtlsdr_close(self.dev);
}
}
}
The genius of this pattern:
- Encapsulation - raw pointers hidden inside the struct
- Safety - all public APIs are safe, no
unsafeneeded by users - RAII -
Dropensures cleanup happens automatically - Error handling -
Resultinstead of error codes - Bounds checking - slices prevent buffer overflows
Usage is now completely safe:
fn main() -> Result<(), String> {
// No unsafe needed by the user!
let mut sdr = RtlSdr::open(0)?;
sdr.set_frequency(98_500_000)?; // 98.5 MHz FM radio!
let mut buffer = vec![0u8; 1024];
let bytes_read = sdr.read_sync(&mut buffer)?;
println!("Read {} bytes of radio data!", bytes_read);
// Automatic cleanup when sdr goes out of scope!
Ok(())
}
What excited me about this: Coming from JavaScript where you just trust libraries to work, wrapping C libraries taught me to think about safety boundaries! 🧠
Common FFI Patterns 🎨
Pattern 1: Opaque Pointers
// C header: typedef struct foo foo_t;
// You don't know what's inside!
#[repr(C)]
pub struct Foo {
_private: [u8; 0], // Zero-size, opaque type
}
extern "C" {
fn foo_create() -> *mut Foo;
fn foo_destroy(f: *mut Foo);
fn foo_do_something(f: *mut Foo) -> i32;
}
// Safe wrapper
pub struct SafeFoo {
inner: *mut Foo,
}
impl SafeFoo {
pub fn new() -> Self {
SafeFoo {
inner: unsafe { foo_create() }
}
}
pub fn do_something(&self) -> i32 {
unsafe { foo_do_something(self.inner) }
}
}
impl Drop for SafeFoo {
fn drop(&mut self) {
unsafe { foo_destroy(self.inner) }
}
}
This pattern is EVERYWHERE! File handles, database connections, device handles - all opaque pointers!
Pattern 2: Callbacks (The Tricky One!)
// C wants a callback function pointer
extern "C" {
fn register_callback(cb: extern "C" fn(i32));
}
// Define a callback
extern "C" fn my_callback(value: i32) {
println!("C called me with: {}", value);
}
// Register it
unsafe {
register_callback(my_callback);
}
Advanced pattern - Closures as callbacks:
use std::os::raw::c_void;
// C signature: void callback(void* user_data, int value);
extern "C" {
fn set_callback(cb: extern "C" fn(*mut c_void, i32),
user_data: *mut c_void);
}
// Safe wrapper for closures!
pub fn set_rust_callback<F>(mut callback: F)
where F: FnMut(i32) + 'static
{
extern "C" fn trampoline<F>(user_data: *mut c_void, value: i32)
where F: FnMut(i32)
{
let callback = unsafe { &mut *(user_data as *mut F) };
callback(value);
}
let boxed = Box::new(callback);
let raw = Box::into_raw(boxed);
unsafe {
set_callback(trampoline::<F>, raw as *mut c_void);
}
}
For my RF/SDR work: The radio library calls my callback with signal samples! This pattern made it work seamlessly! 📡
Pattern 3: String Conversion
use std::ffi::{CString, CStr};
use std::os::raw::c_char;
// Rust string to C string
let rust_str = "Hello, C!";
let c_str = CString::new(rust_str).unwrap();
let c_ptr: *const c_char = c_str.as_ptr();
unsafe {
some_c_function(c_ptr);
}
// c_str is dropped here, freeing the C string!
// C string to Rust string
extern "C" {
fn get_c_string() -> *const c_char;
}
let c_str_ptr = unsafe { get_c_string() };
let c_str = unsafe { CStr::from_ptr(c_str_ptr) };
let rust_str = c_str.to_str().unwrap();
println!("Got from C: {}", rust_str);
The gotcha: C strings are null-terminated! Rust strings can contain null bytes! Use CString for safety!
Pattern 4: Arrays and Slices
extern "C" {
// C: void process_array(int* data, size_t len);
fn process_array(data: *mut i32, len: usize);
}
// Safe wrapper
pub fn process_data(data: &mut [i32]) {
unsafe {
process_array(data.as_mut_ptr(), data.len());
}
}
// Usage (completely safe!)
let mut numbers = vec![1, 2, 3, 4, 5];
process_data(&mut numbers);
The beauty: Rust slices know their length! No buffer overflows! 🛡️
Real-World Example: Calling OpenSSL 🔐
Let's wrap some OpenSSL crypto functions:
use std::os::raw::{c_int, c_uchar};
mod openssl_ffi {
use super::*;
extern "C" {
pub fn SHA256(data: *const c_uchar,
len: usize,
hash: *mut c_uchar) -> *mut c_uchar;
}
}
// Safe wrapper
pub fn sha256(data: &[u8]) -> [u8; 32] {
let mut hash = [0u8; 32];
unsafe {
openssl_ffi::SHA256(
data.as_ptr(),
data.len(),
hash.as_mut_ptr()
);
}
hash
}
// Usage (completely safe!)
fn main() {
let data = b"Hello, cryptography!";
let hash = sha256(data);
println!("SHA256: {:02x?}", hash);
}
Security benefit: The wrapper ensures bounds checking! C code can't overflow our buffer! 🔒
Performance: Is FFI Slow? ⚡
Short answer: NO!
Benchmarks from my RF/SDR work:
// Pure Rust signal processing
fn rust_fft(signal: &[f32]) -> Vec<f32> {
// ... Rust FFT implementation
}
// FFI to C's FFTW library
fn c_fft_via_ffi(signal: &[f32]) -> Vec<f32> {
// ... Call FFTW through FFI
}
// Results:
// Pure Rust: 1.2ms per FFT
// FFI to FFTW: 0.8ms per FFT
// Overhead: ~0.001ms (negligible!)
The truth: FFI is essentially free! Just a function call! No marshaling, no copying (if you do it right)! 🚀
Coming from Node.js: This blew my mind! Node's N-API has overhead. Rust FFI? Zero cost! 💯
Linking C Libraries: Build Process 🔨
Using build.rs to link C libraries:
// build.rs (runs at compile time)
fn main() {
// Tell Cargo to link against libfoo
println!("cargo:rustc-link-lib=foo");
// Add library search path
println!("cargo:rustc-link-search=/usr/local/lib");
// Rebuild if this file changes
println!("cargo:rerun-if-changed=wrapper.h");
}
Using pkg-config:
// build.rs
use pkg_config;
fn main() {
pkg_config::probe_library("librtlsdr").unwrap();
}
Cargo.toml:
[build-dependencies]
pkg-config = "0.3"
[dependencies]
libc = "0.2"
For system libraries:
[dependencies]
libc = "0.2" # Bindings to C standard library
nix = "0.27" # Unix/Linux system APIs
winapi = "0.3" # Windows APIs
The Bindgen Magic: Auto-Generate Bindings 🪄
Don't want to write extern "C" blocks manually? Use bindgen!
// build.rs
use bindgen;
fn main() {
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks))
.generate()
.expect("Unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}
wrapper.h:
#include <rtl-sdr.h>
#include <liquid/liquid.h>
Use the generated bindings:
// src/lib.rs
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
Bindgen generates ALL the FFI code for you! Structs, enums, functions, constants - everything! 🎉
For my RF/SDR projects: Bindgen saved me DAYS of manual work! Just point it at the C headers and boom - instant Rust bindings! 🚀
Common FFI Mistakes (And How to Avoid Them) ⚠️
Mistake 1: Forgetting to Check NULL
// ❌ WRONG - crashes on NULL!
extern "C" {
fn get_data() -> *mut Data;
}
let data = unsafe { &mut *get_data() }; // 💥 Crash if NULL!
// ✅ CORRECT
let ptr = unsafe { get_data() };
if ptr.is_null() {
return Err("Got NULL pointer!");
}
let data = unsafe { &mut *ptr };
Mistake 2: Dangling Pointers
// ❌ WRONG - pointer outlives the data!
fn broken() -> *const u8 {
let data = vec![1, 2, 3];
data.as_ptr() // 💥 data is freed when function returns!
}
// ✅ CORRECT - return owned data or use 'static
fn fixed() -> Vec<u8> {
vec![1, 2, 3] // Return ownership!
}
Mistake 3: Not Handling C Errors
// ❌ WRONG - ignores errors!
unsafe {
c_function_that_can_fail();
}
// ✅ CORRECT - check return codes!
let result = unsafe { c_function_that_can_fail() };
if result < 0 {
return Err("C function failed!");
}
Mistake 4: Buffer Overflows
// ❌ WRONG - no bounds checking!
extern "C" {
fn read_data(buf: *mut u8, max_len: usize) -> usize;
}
let mut buf = [0u8; 10];
unsafe {
read_data(buf.as_mut_ptr(), 1000); // 💥 Overflow!
}
// ✅ CORRECT - pass actual buffer size!
let mut buf = [0u8; 10];
unsafe {
read_data(buf.as_mut_ptr(), buf.len()); // Safe!
}
When to Use FFI 🎯
Perfect for FFI:
- Calling existing C libraries (OpenSSL, SQLite, etc.)
- Hardware interfacing (RF/SDR, GPIO, USB, etc.)
- System programming (OS APIs, drivers)
- Performance-critical code (proven C libraries)
- Gradual migration (wrap C, rewrite incrementally)
Maybe write pure Rust instead:
- Simple utilities (plenty of pure Rust crates!)
- When type safety is critical (Rust-to-Rust is safer!)
- Cross-platform needs (C libs may not be portable)
- When learning Rust (pure Rust teaches more!)
Real talk: For my RF/SDR hobby, FFI was essential! The best signal processing libraries are all C. I get their speed and Rust's safety! Win-win! 📻
The Learning Curve 📈
Week 1: "Wait, I need to call C functions?" 😰
Week 2: "Oh, extern "C" and unsafe blocks!" 💡
Week 3: "I wrapped a C library safely!" 🎉
Month 2: "I'm using bindgen to auto-generate everything!" 🚀
Month 3: "I just called assembly from Rust!" 🤯
What helped me:
- Start simple - call libc functions first
- Read C headers - understand what you're calling
- Use bindgen - don't write bindings manually
- Wrap unsafely - hide unsafe in small functions
- Test thoroughly - C bugs can crash Rust!
The Bottom Line 🏁
FFI unlocks the entire C ecosystem from Rust:
- Zero overhead - direct function calls, no marshaling!
- Decades of libraries - OpenSSL, SQLite, FFTW, etc.!
- Safety at boundaries - wrap C in safe Rust APIs!
- Gradual migration - wrap C, rewrite later!
- Bindgen magic - auto-generate bindings!
Think about it: Would you rather rewrite a battle-tested C library or safely wrap it in a weekend?
I know my answer! 🦀
Remember:
- C libraries don't have Rust's safety (wrap them!)
- Use
unsafeblocks (keep them small and audited!) - Check NULL pointers (C doesn't guarantee non-null!)
- Validate buffer sizes (prevent overflows!)
- Use bindgen for complex headers (save time!)
Coming from 7 years of web development, FFI was alien territory. But for my RF/SDR projects, it's been a game-changer! I get to use proven C libraries (librtlsdr, liquid-dsp) with Rust's safety guarantees. The best of both worlds!
When I'm decoding radio signals at 2.4 MHz sample rate, I need speed. When I'm writing the decoder logic, I need safety. FFI gives me both! 🔐⚡
Building FFI wrappers or calling C from Rust? Connect with me on LinkedIn - I'd love to hear about your use cases!
Want to see FFI in action? Check out my GitHub for RF/SDR projects that bridge Rust and C!
Now go safely call some C libraries! 🦀🔗