Rust FFI Error Codes
An FFI error code is a number that reports how a call went. Zero means fine, and any other number is a code the caller looks up.
The answer rides an out pointer
The return value says how it went. The pointer carries the answer.
pub extern "C" fn calc_divide(a: i32, b: i32, out: *mut i32) -> i32 {
// 0 = fine, 1 = bad pointer, 2 = division by zero
}Null earns a code
The first check guards the out pointer. Trouble becomes a number, never a crash.
if out.is_null() {
set_error("nowhere to write the answer");
return 1;
}Only the happy path writes
The checks run first. The single write happens last.
unsafe { *out = a / b };
0Catch the panic at the border
catch_unwind turns a panic into a value you can grade. The C caller only ever sees the code.
let result = panic::catch_unwind(|| risky_work());
match result {
Ok(v) => v,
Err(_) => -1,
}A message waits on the side
The caller asks for the text after a failure. The message lives in thread-local storage until the next call on that thread.
pub extern "C" fn calc_last_error() -> *const c_char {
LAST_ERROR.with(|cell| cell.borrow().as_ptr())
}Example
use std::cell::RefCell;
use std::ffi::{c_char, CStr, CString};
use std::panic;
thread_local! {
static LAST_ERROR: RefCell<CString> = RefCell::new(CString::new("").unwrap());
}
fn set_error(message: &str) {
LAST_ERROR.with(|cell| {
*cell.borrow_mut() = CString::new(message).unwrap_or_default();
});
}
pub extern "C" fn calc_divide(a: i32, b: i32, out: *mut i32) -> i32 {
if out.is_null() {
set_error("nowhere to write the answer");
return 1;
}
if b == 0 {
set_error("division by zero");
return 2;
}
unsafe { *out = a / b };
0
}
pub extern "C" fn calc_risky() -> i32 {
let result = panic::catch_unwind(|| -> i32 {
let values = [1, 2];
let i = values.len() + 3;
values[i]
});
match result {
Ok(v) => v,
Err(_) => {
set_error("index out of bounds, caught at the border");
-1
}
}
}
pub extern "C" fn calc_last_error() -> *const c_char {
LAST_ERROR.with(|cell| cell.borrow().as_ptr())
}
fn last_error_text() -> String {
let ptr = calc_last_error();
let cstr = unsafe { CStr::from_ptr(ptr) };
cstr.to_str().unwrap().to_string()
}
fn main() {
let mut out = 0;
println!("divide code = {}", calc_divide(10, 2, &mut out));
println!("answer = {out}");
println!("zero code = {}", calc_divide(10, 0, &mut out));
println!("error: {}", last_error_text());
println!("risky code = {}", calc_risky());
println!("error: {}", last_error_text());
println!("still alive at the end");
}Key points
- Return a status code from every exported function.
- Zero means success.
- Each failure gets its own code
- Send the real answer through an out pointer.
- The return value is busy carrying the code
- Check pointer arguments first.
- A null out pointer earns a code, not a crash
- Wrap the body in catch_unwind when it might panic.
- A panic that unwinds into C is undefined behaviour
- Keep a last-error message on the side.
- A second exported function hands it to the caller as a C string
- An error code turns a failure into a number the C caller can check.
- Codes travel.
- Meanings do not.
In this example, main plays the C caller. calc_divide writes 5 through the out pointer and returns 0. The division by zero returns 2. calc_last_error then explains the failure in words. calc_risky indexes past the end of an array on purpose. catch_unwind catches that panic inside the exported function. The function stores a message and returns -1. You will see the panic report on stderr. The caller still receives a calm number. The program reaches its last line alive. A real C program calling this same code as a library sees exactly the same codes.
Note
Write down what each code means in the header file, or the C side will guess
Try it Yourself
Edit the code, then press Run.
Running is simulated · Rust Playground
Exercise
An exported Rust function might panic deep inside. What must the exported function do?