Unsafe Rust and FFI · about 50 min

FFI: Calling C from Rust, and Rust from C

Why this matters

An enormous amount of software is written in C: operating system interfaces, compression and crypto libraries, databases, drivers. Rust can call C functions directly and expose Rust functions to C, with no wrapper layer or runtime cost. This is the foreign function interface (FFI). It is how Rust gets into existing systems: a Rust library inside a C program, or a Rust program using a battle-tested C library. Since the compiler cannot check code on the other side of the boundary, FFI is also the most common reason for unsafe.

The ABI: agreeing on how to call

When one function calls another, both sides must agree on where arguments go (which registers, which stack slots), who cleans up, and how values are laid out. That agreement is the ABI (application binary interface). Rust's own ABI is unspecified and may change between compiler versions. extern "C" selects the platform's C ABI, which every language can speak.

Calling C functions

Declare foreign functions in an extern "C" block, with Rust types that match the C signatures:

use std::ffi::{c_int, c_long};

extern "C" {
    fn abs(x: c_int) -> c_int;
    fn labs(x: c_long) -> c_long;
    fn getpid() -> c_int;
}

fn main() {
    // SAFETY: abs and labs are defined for all inputs except the minimum value.
    let (a, b) = unsafe { (abs(-42), labs(-7_000_000_000)) };
    println!("abs(-42) = {a}, labs = {b}");
    // SAFETY: getpid has no preconditions.
    let pid = unsafe { getpid() };
    println!("pid is positive: {}", pid > 0);
}
abs(-42) = 42, labs = 7000000000
pid is positive: true

A wrong declaration is not caught by anyone: declaring abs as taking an f64 compiles and links, then passes garbage. Get signatures from the C header, and in real projects generate them with tools like bindgen.

Strings across the boundary

C strings are nul-terminated: a pointer to bytes ending with a zero byte, with no stored length. Rust strings are pointer plus length, not nul-terminated, and may contain zero bytes. So conversion is explicit:

use std::ffi::{c_char, CStr, CString};

extern "C" {
    fn strlen(s: *const c_char) -> usize;
    fn strchr(s: *const c_char, c: i32) -> *const c_char;
}

fn main() {
    let owned = CString::new("hello, world").unwrap();
    // SAFETY: owned is a valid nul-terminated string that lives until the end of main.
    unsafe {
        println!("strlen = {}", strlen(owned.as_ptr()));
        let p = strchr(owned.as_ptr(), b',' as i32);
        if !p.is_null() {
            let rest = CStr::from_ptr(p);
            println!("from the comma: {:?}", rest.to_str().unwrap());
        }
    }
    println!("{:?}", CString::new("bad\0string"));
}
strlen = 12
from the comma: ", world"
Err(NulError(3, [98, 97, 100, 0, 115, 116, 114, 105, 110, 103]))

Keep the CString alive while C uses its pointer. A classic bug is CString::new(s).unwrap().as_ptr() as one expression: the temporary CString is dropped at the end of the statement, and the pointer dangles (the compiler warns about this specific pattern).

Callbacks: passing Rust functions to C

C APIs often take function pointers. A Rust function declared extern "C" fn has the C ABI and can be passed directly. The standard example is qsort, which the second exercise uses: you write the comparison function in Rust, and C calls it. Rules for callbacks: they must not panic across the boundary (a panic unwinding into C code is undefined behavior; Rust aborts the process instead in an extern "C" function), and they receive raw pointers that must be cast back carefully.

Matching memory layout: #[repr(C)]

Rust may reorder struct fields (module 7). To pass a struct to C, ask for C's layout:

use std::mem::{align_of, size_of};

#[repr(C)]
struct Point {
    tag: u8,
    x: f64,
    y: u8,
}

#[allow(dead_code)]
struct RustPoint {
    tag: u8,
    x: f64,
    y: u8,
}

fn main() {
    println!("repr(C): {} bytes, align {}", size_of::<Point>(), align_of::<Point>());
    println!("Rust:    {} bytes", size_of::<RustPoint>());
    let p = Point { tag: 1, x: 2.5, y: 3 };
    println!("{} {} {}", p.tag, p.x, p.y);
}
repr(C): 24 bytes, align 8
Rust:    16 bytes
1 2.5 3

#[repr(C)] keeps fields in declaration order with C's padding rules, so the struct matches the C definition struct Point { uint8_t tag; double x; uint8_t y; } exactly. The default Rust layout reorders to save space.

Calling Rust from C

The other direction: mark the function extern "C" (C calling convention) and #[no_mangle] (keep its symbol name exactly as written; Rust normally "mangles" names to encode module paths and generics), and build the crate as a library C can link:

// lib.rs
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b }

$ rustc --crate-type staticlib -o libadd.a lib.rs      # or cdylib for a .so
$ gcc main.c libadd.a -lpthread -ldl -lm -o main        # main.c declares: int add(int, int);

In a cargo project, set crate-type = ["staticlib"] or ["cdylib"] under [lib] in Cargo.toml. The third exercise does this with plain rustc and gcc in the sandbox. Functions exposed to C should validate their inputs (null pointers!) and never panic.

Common mistakes

Recap

Going further: read the Rust Book section 20.1 on extern functions and the Rustonomicon chapter "FFI". The bindgen and cbindgen tools generate the declarations for each direction automatically.

Exercises

Exercise 1 · write code

Call C library functions

Declare these C standard library functions in an extern "C" block (they are linked into every Rust program on Linux) and call them:

The given main reads one word and one number (as text) from stdin and prints the word's length according to C, the word uppercased character by character with toupper, and the number parsed by atoi, plus one:

strlen: 5
toupper: HELLO
atoi + 1: 42

for the lines hello and 41. Wrap each call in an unsafe block.

File: main.rs

Starting point

use std::ffi::{c_char, c_int, CString};
use std::io;

fn main() {
    let mut word = String::new();
    io::stdin().read_line(&mut word).unwrap();
    let mut num = String::new();
    io::stdin().read_line(&mut num).unwrap();
    let cword = CString::new(word.trim()).unwrap();
    let cnum = CString::new(num.trim()).unwrap();
}

Example

input

hello
41

expected output

strlen: 5
toupper: HELLO
atoi + 1: 42

Example

input

Rust2021
-8

expected output

strlen: 8
toupper: RUST2021
atoi + 1: -7
Hint 1

extern "C" { fn strlen(s: *const c_char) -> usize; ... } at the top level declares the functions.

Hint 2

Pass cword.as_ptr() to strlen and atoi; for toupper, loop over word.trim().bytes() and convert each result back with as u8 as char.

Reference solution
use std::ffi::{c_char, c_int, CString};
use std::io;

extern "C" {
    fn strlen(s: *const c_char) -> usize;
    fn toupper(c: c_int) -> c_int;
    fn atoi(s: *const c_char) -> c_int;
}

fn main() {
    let mut word = String::new();
    io::stdin().read_line(&mut word).unwrap();
    let mut num = String::new();
    io::stdin().read_line(&mut num).unwrap();
    let cword = CString::new(word.trim()).unwrap();
    let cnum = CString::new(num.trim()).unwrap();

    // SAFETY: cword is a valid nul-terminated string that outlives the call.
    let len = unsafe { strlen(cword.as_ptr()) };
    println!("strlen: {len}");

    let upper: String = word
        .trim()
        .bytes()
        // SAFETY: toupper accepts any value representable as unsigned char.
        .map(|b| unsafe { toupper(b as c_int) } as u8 as char)
        .collect();
    println!("toupper: {upper}");

    // SAFETY: cnum is a valid nul-terminated string.
    let n = unsafe { atoi(cnum.as_ptr()) };
    println!("atoi + 1: {}", n + 1);
}

Exercise 2 · write code

Pass a Rust callback to qsort

C's qsort sorts an array using a comparison function pointer:

void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));

Declare it as fn qsort(base: *mut c_void, n: usize, size: usize, cmp: extern "C" fn(*const c_void, *const c_void) -> c_int); and write extern "C" fn cmp_desc(a: *const c_void, b: *const c_void) -> c_int that compares two i32s for descending order (return negative if a should come first). The given main reads integers, sorts them with qsort and prints them:

[9, 5, 3, 1, -2]

for 3 9 -2 5 1 (one per line).

File: main.rs

Starting point

use std::ffi::{c_int, c_void};
use std::io;

fn main() {
    let mut v: Vec<i32> = io::stdin().lines().map(|l| l.unwrap().trim().parse().unwrap()).collect();
    // SAFETY: v's buffer holds v.len() i32 values of size 4, and cmp_desc compares i32s.
    unsafe {
        qsort(v.as_mut_ptr() as *mut c_void, v.len(), std::mem::size_of::<i32>(), cmp_desc);
    }
    println!("{:?}", v);
}

Example

input

3
9
-2
5
1

expected output

[9, 5, 3, 1, -2]

Example

input

-2147483648
2147483647
0

expected output

[2147483647, 0, -2147483648]
Hint 1

Cast the void pointers back: *(a as *const i32) reads the value (inside unsafe).

Hint 2

For descending order, compare y with x. Avoid returning y - x, which can overflow for extreme values.

Reference solution
use std::ffi::{c_int, c_void};
use std::io;

extern "C" {
    fn qsort(base: *mut c_void, n: usize, size: usize, cmp: extern "C" fn(*const c_void, *const c_void) -> c_int);
}

extern "C" fn cmp_desc(a: *const c_void, b: *const c_void) -> c_int {
    // SAFETY: qsort passes pointers to elements of the i32 array we gave it.
    let (x, y) = unsafe { (*(a as *const i32), *(b as *const i32)) };
    match y.cmp(&x) {
        std::cmp::Ordering::Less => -1,
        std::cmp::Ordering::Equal => 0,
        std::cmp::Ordering::Greater => 1,
    }
}

fn main() {
    let mut v: Vec<i32> = io::stdin().lines().map(|l| l.unwrap().trim().parse().unwrap()).collect();
    // SAFETY: v's buffer holds v.len() i32 values of size 4, and cmp_desc compares i32s.
    unsafe {
        qsort(v.as_mut_ptr() as *mut c_void, v.len(), std::mem::size_of::<i32>(), cmp_desc);
    }
    println!("{:?}", v);
}

Exercise 3 · write code

Call Rust from C

Export two functions with the C ABI so that a C program can call them:

The checker builds your file as a C-compatible static library (rustc --crate-type staticlib) and links it with this C program, which must print the shown lines:

int clamp_add(int a, int b, int max);
unsigned count_vowels(const char *s);
int main(void) {
    printf("%d %d\n", clamp_add(2, 3, 10), clamp_add(2147483647, 1, 100));
    printf("%u %u\n", count_vowels("Hello, World"), count_vowels(NULL));
}
5 100
3 0

Keep a main in your file (it is also built as a normal program, which must print ok).

File: main.rs

Starting point

use std::ffi::{c_char, CStr};

fn main() {
    println!("ok");
}

Example

expected output

ok
Hint 1

#[no_mangle] keeps the symbol name exactly clamp_add so the C linker can find it; extern "C" selects the C calling convention.

Hint 2

Check s.is_null() first, then unsafe { CStr::from_ptr(s) }.to_bytes() gives the bytes without the terminating nul.

Reference solution
use std::ffi::{c_char, CStr};

#[no_mangle]
pub extern "C" fn clamp_add(a: i32, b: i32, max: i32) -> i32 {
    let sum = a as i64 + b as i64;
    sum.min(max as i64) as i32
}

#[no_mangle]
pub extern "C" fn count_vowels(s: *const c_char) -> u32 {
    if s.is_null() {
        return 0;
    }
    // SAFETY: the caller passes a valid nul-terminated string (we checked for null).
    let bytes = unsafe { CStr::from_ptr(s) }.to_bytes();
    bytes.iter().filter(|b| b"aeiouAEIOU".contains(b)).count() as u32
}

fn main() {
    println!("ok");
}