In FFI, extern "C" is only the entry point.
The most fragile parts are usually:
whether a C string is a valid NUL-terminated string
who allocates returned pointers, who frees them, and which function frees them
whether errors cross the boundary as integer codes, output parameters, or text
C code handles these through API documentation and discipline. Rust can put some rules into types, but the language boundary still needs an explicit contract.
This article focuses on strings, memory ownership, and error returns. The previous FFI articles covered C calling Rust and Rust calling C; this one covers contracts both directions need.
const char * Is Not &str
A C string parameter often looks like this:
const char *name;
It may mean:
- a NUL-terminated string
- read-only static memory
- a temporary caller-owned buffer
- possibly
NULL - not UTF-8, but a local encoding or binary name
Rust &str means known-length, valid UTF-8 text. It is not the same as const char *.
At the boundary, use CStr first:
use std::ffi::CStr;
use std::os::raw::c_char;
pub unsafe fn name_from_c(ptr: *const c_char) -> Result<&'static CStr, NameError> {
if ptr.is_null() {
return Err(NameError::Null);
}
Ok(CStr::from_ptr(ptr))
}
This example is intentionally suspicious: returning &'static CStr is usually wrong. The lifetime of the CStr depends on the memory behind the C pointer. Boundary code should usually use it immediately or copy it into a Rust-owned value.
A safer wrapper copies:
fn copy_name_from_c(ptr: *const c_char) -> Result<String, NameError> {
if ptr.is_null() {
return Err(NameError::Null);
}
let name = unsafe { CStr::from_ptr(ptr) };
let name = name.to_str().map_err(|_| NameError::InvalidUtf8)?;
Ok(name.to_owned())
}
After copying, Rust owns a String and no longer depends on the C memory staying valid.
Use CString When Passing Strings to C
Rust &str cannot be passed directly to C:
let name = "device";
// C wants const char *, not &str
Use CString at the boundary:
use std::ffi::CString;
fn open_device(name: &str) -> Result<(), OpenError> {
let c_name = CString::new(name).map_err(|_| OpenError::InteriorNul)?;
let ret = unsafe { ffi::device_open(c_name.as_ptr()) };
if ret == 0 {
Ok(())
} else {
Err(OpenError::Failed(ret))
}
}
CString::new rejects interior NUL bytes, because C stops at the first NUL.
Also pay attention to the lifetime of as_ptr(): the pointer is valid only while c_name is alive. If the C function only reads synchronously, this is fine. If C stores the pointer, this design is wrong.
If C must store the string, ownership needs a different contract:
C copies the string, while Rust only passes it temporarily
or Rust transfers an owned CString to C and provides a Rust free function
or Rust owns a registration object and C only stores a pointer tied to that object
Do not hand C a temporary CString::as_ptr() for long-term storage.
Whoever Allocates Provides the Free Function
Across language boundaries, the first ownership rule is:
The side that allocates usually provides the matching free function.
Rust allocating a string for C:
use std::ffi::CString;
use std::os::raw::c_char;
#[unsafe(no_mangle)]
pub extern "C" fn device_make_name() -> *mut c_char {
match CString::new("device-01") {
Ok(name) => name.into_raw(),
Err(_) => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn device_free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
unsafe {
drop(CString::from_raw(ptr));
}
}
The C contract must be clear:
char *device_make_name(void);
void device_free_string(char *ptr);
Callers must know:
the return value may be NULL
non-NULL return values must be freed with device_free_string
do not call free()
do not free twice
In the other direction, if C allocates memory for Rust, Rust must call the C free function. It must not use Rust’s allocator to release C-allocated memory.
Caller-Provided Buffers Are Often Simpler
For cross-language interfaces, letting the caller provide the buffer is often simpler.
A common C shape:
int device_get_name(char *out, size_t capacity, size_t *out_len);
Rust exported to C can use the same model:
#[repr(C)]
pub enum DeviceError {
Ok = 0,
Null = 1,
TooSmall = 2,
}
#[unsafe(no_mangle)]
pub extern "C" fn device_get_name(
out: *mut u8,
capacity: usize,
out_len: *mut usize,
) -> DeviceError {
if out.is_null() || out_len.is_null() {
return DeviceError::Null;
}
let name = b"device-01\0";
unsafe {
*out_len = name.len();
}
if capacity < name.len() {
return DeviceError::TooSmall;
}
unsafe {
std::ptr::copy_nonoverlapping(name.as_ptr(), out, name.len());
}
DeviceError::Ok
}
The benefit is that C provides the memory, and Rust does not hand newly allocated memory across the ABI. The cost is that callers must handle TooSmall, often with a size query and retry.
This pattern fits frequent calls, embedded boundaries, and APIs that avoid crossing allocator ownership.
Error Codes Cross ABI; Result Stays Inside Rust
Rust internals should use Result:
fn parse_config(text: &str) -> Result<Config, ConfigError> {
// ...
Ok(Config {})
}
C boundaries should return C-shaped status values:
#[repr(C)]
pub enum ConfigStatus {
Ok = 0,
Null = 1,
InvalidUtf8 = 2,
ParseFailed = 3,
}
The boundary function maps between them:
#[unsafe(no_mangle)]
pub extern "C" fn config_parse(name: *const c_char, out: *mut ConfigHandle) -> ConfigStatus {
if name.is_null() || out.is_null() {
return ConfigStatus::Null;
}
let name = unsafe { CStr::from_ptr(name) };
let Ok(name) = name.to_str() else {
return ConfigStatus::InvalidUtf8;
};
match parse_config(name) {
Ok(config) => {
unsafe { *out = ConfigHandle::from_config(config) };
ConfigStatus::Ok
}
Err(_) => ConfigStatus::ParseFailed,
}
}
Do not expose Rust Result, String, or Box<dyn Error> directly across a C ABI.
Error Text Also Needs Ownership
C callers often want error text. There are two robust patterns.
The first is a static string:
#[unsafe(no_mangle)]
pub extern "C" fn config_status_message(status: ConfigStatus) -> *const c_char {
match status {
ConfigStatus::Ok => c"ok".as_ptr(),
ConfigStatus::Null => c"null pointer".as_ptr(),
ConfigStatus::InvalidUtf8 => c"invalid utf-8".as_ptr(),
ConfigStatus::ParseFailed => c"parse failed".as_ptr(),
}
}
Callers do not free these strings, but the text must be fixed.
The second is a newly allocated string plus a matching free function. This is more flexible, but the ownership rule must be explicit.
Never return a pointer into a temporary String:
// wrong model: the String is gone after the function returns
// CString::new(message).unwrap().as_ptr()
For dynamic error text, either let the caller provide a buffer, or use into_raw() with a matching free function.
Header Files Must State Ownership
Many FFI bugs come not from Rust code but from incomplete C contracts.
The header should state ownership rules:
/*
* Returns 0 on success.
* out/out_len must not be NULL.
* If capacity is too small, returns DEVICE_TOO_SMALL and writes required length to out_len.
* The output string is NUL-terminated when DEVICE_OK is returned.
*/
int device_get_name(char *out, size_t capacity, size_t *out_len);
/*
* Returns a Rust-allocated string. Caller must release it with device_free_string.
* Do not call free() on the returned pointer.
*/
char *device_make_name(void);
void device_free_string(char *ptr);
Rust’s type system cannot help C callers infer hidden conventions. Cross-language APIs must write those conventions in headers and documentation C developers can read.
What to Keep
At the C/Rust boundary, design strings, memory, and errors separately:
C const char * input -> *const c_char -> CStr -> optional UTF-8 validation -> String/&str
Rust string passed to C -> CString, and as_ptr must not be stored long-term
Rust allocates for C -> into_raw + Rust free function
C allocates for Rust -> C free function + Rust Drop wrapper
caller-provided buffer -> ptr + capacity + out_len + TooSmall
Rust Result -> mapped to C status codes at the boundary
error text -> static string, or dynamically allocated with explicit ownership
The central FFI question is not “how do I spell the type?” It is who owns each pointer’s memory, how long it lives, and how errors are interpreted.
The next topic is FFI callbacks and void *ctx: the same ownership problem from the other direction. Once C stores a function pointer and a context pointer, Rust must make sure the callback object stays alive, thread rules are clear, and unregistering cannot leave dangling state.