Skip to main content FFI: Letting Rust Call C | IoT Worker

FFI: Letting Rust Call C

Calling C from Rust can look like just writing an extern "C" declaration.

The real issue is different: a C function does not carry Rust’s borrowing, lifetime, Result, or destructor rules. Rust can call it, but the compiler cannot prove that the call satisfies the C API contract.

A better model is:

safe Rust API
-> validate and prepare arguments
-> call C inside a small unsafe block
-> convert error codes, output parameters, and raw pointers back to Rust types

The C header is the entry point

Suppose an existing C library exposes this header:

#include <stddef.h>
#include <stdint.h>

int sensor_read(uint8_t *out, size_t capacity, size_t *out_len);

A C caller would usually read this as:

  • out is a caller-provided buffer
  • capacity is the buffer capacity
  • out_len receives the number of bytes written
  • 0 means success and non-zero values mean errors

In C, these rules are maintained by documentation and convention. Rust must respect those rules instead of treating the function like an ordinary Rust function.

extern declarations describe the ABI

Rust declares the C function like this:

use std::ffi::c_int;

unsafe extern "C" {
    fn sensor_read(out: *mut u8, capacity: usize, out_len: *mut usize) -> c_int;
}

This is not an implementation. It tells Rust:

  • the function is named sensor_read
  • it uses the C calling convention
  • parameters and the return value follow the C ABI
  • calling it requires unsafe

The point of unsafe extern "C" is the boundary. Rust cannot prove that the C function will not write past the buffer, retain a pointer, or leave partially initialized data after failure.

Wrap the C call in a safe function

Do not let raw pointers and error codes spread into application code.

Start with a Rust wrapper:

use std::ffi::c_int;

#[derive(Debug)]
pub enum SensorError {
    Failed(c_int),
    OutputTooLarge,
}

pub fn read_sensor() -> Result<Vec<u8>, SensorError> {
    let mut buf = vec![0_u8; 256];
    let mut out_len = 0_usize;

    let ret = unsafe { sensor_read(buf.as_mut_ptr(), buf.len(), &mut out_len) };
    if ret != 0 {
        return Err(SensorError::Failed(ret));
    }

    if out_len > buf.len() {
        return Err(SensorError::OutputTooLarge);
    }

    buf.truncate(out_len);
    Ok(buf)
}

This wrapper does several things:

  • Rust allocates the Vec<u8> and keeps the buffer valid
  • C is called only inside the unsafe block
  • C error codes become Result
  • the output length is checked before truncate
  • the caller receives an owned Vec<u8>

The caller does not need to know that the C API used an output parameter or raw pointers.

Strings must be shaped for C first

If a C function accepts const char *:

int device_open(const char *name);

Rust cannot pass &str directly. C expects a NUL-terminated string. Rust’s &str is a UTF-8 byte slice and does not guarantee a trailing \0.

Use CString at the boundary:

use std::ffi::{c_int, CString};
use std::os::raw::c_char;

unsafe extern "C" {
    fn device_open(name: *const c_char) -> c_int;
}

pub fn open_device(name: &str) -> Result<(), c_int> {
    let c_name = CString::new(name).map_err(|_| -1)?;
    let ret = unsafe { device_open(c_name.as_ptr()) };

    if ret == 0 {
        Ok(())
    } else {
        Err(ret)
    }
}

CString::new rejects strings that contain interior NUL bytes. as_ptr() is valid only while c_name is alive, so the C function must not retain that pointer unless the API explicitly says so and ownership is designed around it.

Ask who owns a pointer returned by C

A C API may return a pointer:

const char *device_last_error(void);

If this is a borrowed pointer to an internal read-only string, Rust can copy it out:

use std::ffi::CStr;
use std::os::raw::c_char;

unsafe extern "C" {
    fn device_last_error() -> *const c_char;
}

pub fn last_error() -> Option<String> {
    let ptr = unsafe { device_last_error() };
    if ptr.is_null() {
        return None;
    }

    let text = unsafe { CStr::from_ptr(ptr) };
    Some(text.to_string_lossy().into_owned())
}

After copying into an owned String, Rust code no longer depends on the C library’s internal memory.

If C returns newly allocated memory, the rule changes. Rust must call the C library’s matching free function. It must not free C-allocated memory with Rust’s allocator.

char *device_get_name(void);
void device_free_string(char *ptr);

Rust can wrap that in RAII:

use std::ffi::CStr;
use std::os::raw::c_char;

unsafe extern "C" {
    fn device_get_name() -> *mut c_char;
    fn device_free_string(ptr: *mut c_char);
}

struct CDeviceString(*mut c_char);

impl Drop for CDeviceString {
    fn drop(&mut self) {
        if !self.0.is_null() {
            unsafe { device_free_string(self.0) };
        }
    }
}

pub fn device_name() -> Option<String> {
    let raw = unsafe { device_get_name() };
    if raw.is_null() {
        return None;
    }

    let owned = CDeviceString(raw);
    let text = unsafe { CStr::from_ptr(owned.0) };
    Some(text.to_string_lossy().into_owned())
}

Drop makes sure the C free function is called when the wrapper leaves scope. The public Rust API returns a normal String; the C pointer does not leak outward.

Linking should not bury the API design

After Rust knows the function declarations, the linker still needs to find the C library.

Simple cases can use #[link]:

#[link(name = "sensor")]
unsafe extern "C" {
    fn sensor_read(out: *mut u8, capacity: usize, out_len: *mut usize) -> std::ffi::c_int;
}

More complex projects usually use build.rs to emit link arguments, or use pkg-config to discover system libraries.

These are build concerns. They should not change the API rule: expose a safe Rust API, keep unsafe small, and make pointer, length, string, error, and free-function rules explicit at the boundary.

Do not let C store temporary Rust pointers

This is dangerous:

pub fn register_buffer() {
    let mut buf = vec![0_u8; 128];
    unsafe { c_register_buffer(buf.as_mut_ptr(), buf.len()) };
}

If the C library reads the buffer synchronously, this may be fine. If it stores the pointer, buf is freed when the function returns and C now has a dangling pointer.

When a C API retains memory provided by Rust, the lifetime must be designed explicitly:

  • Rust should create a long-lived owner
  • the owner’s Drop should call the C unregister function
  • C must not access the pointer after unregistering
  • callbacks across threads must state their thread-safety requirements

This usually needs a dedicated wrapper type, not a raw pointer handed to arbitrary callers.

The default shape for Rust calling C

The habit worth keeping is:

C header
-> Rust unsafe extern declaration
-> small unsafe call
-> Result / Vec / String / custom type
-> safe public API

The C boundary may use raw pointers, error codes, and output parameters. Rust internals should not keep using those shapes.

A good FFI wrapper does not translate a C API syntax-for-syntax into Rust. It turns C conventions into Rust types that can be checked, released, and composed.

Continue Reading