Skip to main content
Technology & EngineeringSystems Programming302 lines

Ffi

Foreign function interfaces for calling between C, C++, Rust, Zig, Python, and other languages safely

Quick Summary26 lines
You are an expert in foreign function interfaces (FFI) for bridging systems-level code across language boundaries.

## Key Points

- Always use `#[repr(C)]` in Rust or `packed struct` / `extern struct` in Zig for types crossing FFI boundaries.
- Document memory ownership explicitly: who allocates, who frees, using which allocator.
- Never throw C++ exceptions across `extern "C"` boundaries; catch and convert to error codes.
- Use opaque handle types (forward-declared structs) to hide implementation details across the C boundary.
- Pin callback user data so it is not moved or freed while the foreign library holds a pointer.
- Test FFI code under AddressSanitizer to catch memory errors at the boundary.
- Passing Rust `String` or `Vec` directly across FFI; they have non-C-compatible layouts. Use `CString` and raw pointers.
- Freeing memory with the wrong allocator (e.g., calling `free()` on memory allocated by Rust's allocator).
- Forgetting that C strings are null-terminated but Rust/Zig slices are not; conversion must add/remove the null.
- Struct padding and alignment differences between languages; always use `#[repr(C)]` or equivalent.
- Passing stack-allocated data to a foreign function that stores the pointer for later use (dangling pointer).
- Thread safety assumptions: C libraries may not be thread-safe even if the calling language uses threads freely.

## Quick Example

```c
// C header
typedef void (*LogCallback)(int level, const char *message, void *user_data);
void set_log_callback(LogCallback cb, void *user_data);
```
skilldb get systems-programming-skills/FfiFull skill: 302 lines
Paste into your CLAUDE.md or agent config

Foreign Function Interfaces — Systems Programming

You are an expert in foreign function interfaces (FFI) for bridging systems-level code across language boundaries.

Core Philosophy

Overview

FFI enables code written in one language to call functions in another. The C ABI is the universal lingua franca: nearly every language can call C functions and expose C-compatible interfaces. FFI involves managing calling conventions, data layout, memory ownership, error handling across boundaries, and build system integration. Getting any of these wrong leads to crashes, memory corruption, or undefined behavior.

Core Concepts

The C ABI as Common Ground

All cross-language FFI ultimately passes through C-compatible function signatures and data layouts.

// shared_lib.h - The C interface contract
#ifndef SHARED_LIB_H
#define SHARED_LIB_H

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

#ifdef __cplusplus
extern "C" {
#endif

typedef struct {
    const char *name;
    uint32_t age;
} Person;

// Caller-allocated buffer pattern
int32_t person_to_json(const Person *p, char *buf, size_t buf_size);

// Library-allocated, library-freed pattern
char *person_to_json_alloc(const Person *p);
void free_string(char *s);

#ifdef __cplusplus
}
#endif
#endif

Rust FFI

Exposing Rust functions to C:

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

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

#[no_mangle]
pub extern "C" fn point_distance(a: *const Point, b: *const Point) -> f64 {
    let a = unsafe { &*a };
    let b = unsafe { &*b };
    ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}

#[no_mangle]
pub extern "C" fn greet(name: *const c_char) -> *mut c_char {
    let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("unknown");
    let greeting = format!("Hello, {name}!");
    CString::new(greeting).unwrap().into_raw()
}

#[no_mangle]
pub extern "C" fn free_rust_string(s: *mut c_char) {
    if !s.is_null() {
        unsafe { drop(CString::from_raw(s)); }
    }
}

Calling C from Rust:

// build.rs
fn main() {
    println!("cargo:rustc-link-lib=sodium");
    println!("cargo:rustc-link-search=/usr/local/lib");
}

// src/lib.rs
extern "C" {
    fn crypto_secretbox_keygen(key: *mut u8);
    fn crypto_secretbox_easy(
        c: *mut u8,
        m: *const u8,
        mlen: u64,
        n: *const u8,
        k: *const u8,
    ) -> i32;
}

pub fn generate_key() -> [u8; 32] {
    let mut key = [0u8; 32];
    unsafe { crypto_secretbox_keygen(key.as_mut_ptr()); }
    key
}

Zig FFI

Zig has first-class C interop with no bindings generation needed:

const c = @cImport({
    @cInclude("sqlite3.h");
});

pub fn openDatabase(path: [*:0]const u8) !*c.sqlite3 {
    var db: ?*c.sqlite3 = null;
    const rc = c.sqlite3_open(path, &db);
    if (rc != c.SQLITE_OK) {
        if (db) |d| c.sqlite3_close(d);
        return error.SqliteOpenFailed;
    }
    return db.?;
}

// Exposing Zig to C
export fn zig_add(a: i32, b: i32) i32 {
    return a + b;
}

Python ctypes and cffi

# Using ctypes for simple C calls
import ctypes

lib = ctypes.CDLL("./libmath.so")

# Define argument and return types
lib.point_distance.argtypes = [
    ctypes.POINTER(ctypes.c_double * 2),
    ctypes.POINTER(ctypes.c_double * 2),
]
lib.point_distance.restype = ctypes.c_double

# Using cffi for more complex interfaces
from cffi import FFI

ffi = FFI()
ffi.cdef("""
    typedef struct {
        const char *name;
        uint32_t age;
    } Person;

    char *person_to_json_alloc(const Person *p);
    void free_string(char *s);
""")

lib = ffi.dlopen("./libperson.so")

person = ffi.new("Person *", {b"name": b"Alice", "age": 30})
json_ptr = lib.person_to_json_alloc(person)
json_str = ffi.string(json_ptr).decode("utf-8")
lib.free_string(json_ptr)  # Must free with the library's allocator

C++ to C Wrapper Pattern

// engine.hpp - C++ API
class Engine {
public:
    Engine(const Config& config);
    ~Engine();
    Result process(std::span<const uint8_t> input);
private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

// engine_c.h - C wrapper
typedef struct EngineHandle EngineHandle;

EngineHandle *engine_create(const char *config_json);
void engine_destroy(EngineHandle *engine);
int32_t engine_process(EngineHandle *engine, const uint8_t *data,
                       size_t len, uint8_t *out, size_t *out_len);

// engine_c.cpp - Implementation
extern "C" {

EngineHandle *engine_create(const char *config_json) {
    try {
        auto config = Config::from_json(config_json);
        return reinterpret_cast<EngineHandle*>(new Engine(config));
    } catch (...) {
        return nullptr;
    }
}

void engine_destroy(EngineHandle *engine) {
    delete reinterpret_cast<Engine*>(engine);
}

int32_t engine_process(EngineHandle *engine, const uint8_t *data,
                       size_t len, uint8_t *out, size_t *out_len) {
    try {
        auto* eng = reinterpret_cast<Engine*>(engine);
        auto result = eng->process({data, len});
        if (result.size() > *out_len) return -1;
        std::memcpy(out, result.data(), result.size());
        *out_len = result.size();
        return 0;
    } catch (...) {
        return -1;
    }
}

} // extern "C"

Implementation Patterns

Callback Functions Across FFI

// C header
typedef void (*LogCallback)(int level, const char *message, void *user_data);
void set_log_callback(LogCallback cb, void *user_data);
// Rust: wrapping a Rust closure as a C callback
extern "C" fn log_trampoline(level: i32, msg: *const c_char, user_data: *mut std::ffi::c_void) {
    let closure: &mut Box<dyn FnMut(i32, &str)> = unsafe { &mut *(user_data as *mut _) };
    let msg = unsafe { CStr::from_ptr(msg) }.to_str().unwrap_or("");
    closure(level, msg);
}

pub fn set_logger(mut callback: impl FnMut(i32, &str) + 'static) {
    let boxed: Box<Box<dyn FnMut(i32, &str)>> = Box::new(Box::new(callback));
    let raw = Box::into_raw(boxed) as *mut std::ffi::c_void;
    unsafe { set_log_callback(log_trampoline, raw); }
    // Note: must ensure raw is eventually freed (e.g., on unset)
}

Error Handling Across Boundaries

// Pattern: error code + thread-local error message
typedef enum {
    LIB_OK = 0,
    LIB_ERR_INVALID_ARG = -1,
    LIB_ERR_IO = -2,
    LIB_ERR_INTERNAL = -3,
} LibError;

LibError lib_do_work(const uint8_t *data, size_t len);
const char *lib_last_error(void); // Returns thread-local error string

Best Practices

  • Always use #[repr(C)] in Rust or packed struct / extern struct in Zig for types crossing FFI boundaries.
  • Document memory ownership explicitly: who allocates, who frees, using which allocator.
  • Never throw C++ exceptions across extern "C" boundaries; catch and convert to error codes.
  • Use opaque handle types (forward-declared structs) to hide implementation details across the C boundary.
  • Pin callback user data so it is not moved or freed while the foreign library holds a pointer.
  • Test FFI code under AddressSanitizer to catch memory errors at the boundary.

Common Pitfalls

  • Passing Rust String or Vec directly across FFI; they have non-C-compatible layouts. Use CString and raw pointers.
  • Freeing memory with the wrong allocator (e.g., calling free() on memory allocated by Rust's allocator).
  • Forgetting that C strings are null-terminated but Rust/Zig slices are not; conversion must add/remove the null.
  • Struct padding and alignment differences between languages; always use #[repr(C)] or equivalent.
  • Passing stack-allocated data to a foreign function that stores the pointer for later use (dangling pointer).
  • Thread safety assumptions: C libraries may not be thread-safe even if the calling language uses threads freely.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add systems-programming-skills

Get CLI access →