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

FFI: Letting C Call Rust

Letting C call Rust does not mean exposing ordinary Rust functions directly to C.

C understands C ABI: symbol names, calling conventions, integers, pointers, struct layout, memory ownership, release functions, and error returns. Rust internals such as String, Vec, Result, traits, and panic should not cross the C boundary directly.

A stable model is:

C call
-> extern "C" wrapper
-> validate pointer and length
-> convert to safe Rust types
-> call internal Rust logic
-> convert result to C-compatible return values

This article covers C calling Rust. Rust calling C comes next.

Rust ABI Is Not C ABI

An ordinary Rust function should not be exported directly as a C function:

fn checksum(data: &[u8]) -> u16 {
    data.iter().map(|b| *b as u16).sum()
}

The parameter is &[u8], a safe Rust slice view. C does not know the Rust slice ABI or Rust symbol mangling.

Write a C ABI wrapper:

#[unsafe(no_mangle)]
pub extern "C" fn iot_checksum(ptr: *const u8, len: usize) -> u16 {
    if ptr.is_null() || len == 0 {
        return 0;
    }

    let data = unsafe { std::slice::from_raw_parts(ptr, len) };
    checksum(data)
}

fn checksum(data: &[u8]) -> u16 {
    data.iter().map(|b| *b as u16).sum()
}

There are two layers:

  • iot_checksum is the C boundary, using raw pointer plus length
  • checksum is internal Rust logic, using a safe slice

This split matters. The FFI boundary may be low-level, but internal logic should return to safe Rust quickly.

Write the C Header Clearly

C sees a header:

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

uint16_t iot_checksum(const uint8_t *ptr, size_t len);

The header is the C caller’s contract. It should describe:

  • whether the pointer may be NULL
  • what len means
  • how to interpret the return value
  • whether the function stores the pointer
  • whether it is thread-safe

What Rust code says internally is not visible to C callers. Cross-language boundaries need C-readable documentation.

Return Errors in C-Compatible Forms

Rust internally can use Result:

fn parse_packet(data: &[u8]) -> Result<u8, ParseError> {
    if data.len() < 2 {
        return Err(ParseError::TooShort);
    }

    Ok(data[0])
}

At the C boundary, use integer-like error codes and output parameters:

#[repr(C)]
pub enum IotError {
    Ok = 0,
    Null = 1,
    TooShort = 2,
}

#[unsafe(no_mangle)]
pub extern "C" fn iot_parse_first(ptr: *const u8, len: usize, out: *mut u8) -> IotError {
    if ptr.is_null() || out.is_null() {
        return IotError::Null;
    }

    let data = unsafe { std::slice::from_raw_parts(ptr, len) };

    match parse_packet(data) {
        Ok(value) => {
            unsafe { *out = value };
            IotError::Ok
        }
        Err(ParseError::TooShort) => IotError::TooShort,
    }
}

This is more verbose than internal Rust, but it fits C ABI:

  • return value is a C-compatible enum/integer
  • output storage is provided by C
  • null pointers are checked at the boundary
  • Rust internal errors are mapped to C error codes

Do not expose Result<T, E> directly to C.

String Boundaries Use CStr and CString

C strings are usually NUL-terminated const char *.

Rust can handle them like this:

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

#[unsafe(no_mangle)]
pub extern "C" fn iot_name_len(name: *const c_char) -> usize {
    if name.is_null() {
        return 0;
    }

    let c_name = unsafe { CStr::from_ptr(name) };
    match c_name.to_str() {
        Ok(text) => text.len(),
        Err(_) => 0,
    }
}

There are two boundaries:

  • CStr::from_ptr requires a valid NUL-terminated C pointer
  • to_str requires valid UTF-8

A C string and a Rust &str are not the same thing. Convert C strings to CStr first, then to &str only after validating encoding.

Rust-Allocated Memory Needs a Rust Free Function

If Rust returns newly allocated string memory to C, also provide a release function.

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

#[unsafe(no_mangle)]
pub extern "C" fn iot_make_name() -> *mut c_char {
    let s = CString::new("device-01").unwrap();
    s.into_raw()
}

#[unsafe(no_mangle)]
pub extern "C" fn iot_free_string(ptr: *mut c_char) {
    if ptr.is_null() {
        return;
    }

    unsafe {
        drop(CString::from_raw(ptr));
    }
}

The rule must be explicit:

iot_make_name returns memory allocated by Rust
C caller must release it with iot_free_string
do not call free()
do not release twice

This is one of the most important FFI boundaries: who allocates, who releases, and which function releases.

panic Should Not Cross the C Boundary

Rust panic should not cross a C ABI boundary.

Exported FFI functions should avoid or contain panics and convert errors into C-compatible returns. A simple rule: do not unwrap external input at the boundary, and do not let internal errors panic through C.

Example:

#[unsafe(no_mangle)]
pub extern "C" fn iot_do_work(ptr: *const u8, len: usize) -> IotError {
    if ptr.is_null() {
        return IotError::Null;
    }

    let data = unsafe { std::slice::from_raw_parts(ptr, len) };

    match do_work(data) {
        Ok(()) => IotError::Ok,
        Err(_) => IotError::TooShort,
    }
}

Internal Rust can use Result; the boundary converts it to error codes.

What to Keep from This Article

The first mapping for C calling Rust is:

internal Rust function      -> ordinary safe Rust API
C-callable function         -> pub extern "C" fn
Rust slice                  -> C pointer + len
Rust Result                 -> C error code + output parameter
Rust String / Vec           -> do not cross C boundary directly
C const char *              -> CStr, then maybe &str
Rust memory returned to C    -> provide Rust release function
panic                       -> do not cross C ABI; return error instead

FFI is not mainly syntax. It is a boundary contract.

C sees ABI, pointers, integers, and headers. Rust internals can still use Result, slices, String, Vec, and traits. Good FFI design keeps the two worlds separated by a thin wrapper.

The next article can cover Rust calling C: linking C libraries, declaring extern "C", calling unsafe C functions, and wrapping C return values in Rust APIs.