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_checksumis the C boundary, using raw pointer plus lengthchecksumis 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
lenmeans - 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_ptrrequires a valid NUL-terminated C pointerto_strrequires 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.