Skip to content
Home » All Posts » How to Wire Up Rust FFI with C in a CMake Project (Step‑by‑Step)

How to Wire Up Rust FFI with C in a CMake Project (Step‑by‑Step)

Introduction: Why integrate Rust FFI with C in existing CMake projects?

When I first started adding Rust into an old C codebase driven by CMake, I wasn’t trying to rewrite everything; I just wanted safer, faster components without breaking the existing build. That’s exactly where Rust FFI with C shines: you can keep your stable C surface area, while gradually moving critical pieces to Rust.

For legacy or long-lived C projects, this integration offers three big wins: memory safety in new modules, access to Rust’s growing ecosystem, and the ability to evolve the codebase without a risky “big bang” rewrite. CMake remains the central orchestrator, so your existing CI, toolchains, and project structure stay intact while Rust is pulled in as a first-class library.

By the end of this process, the goal is simple: call Rust functions from C as if they were just another C library, built and linked cleanly via CMake, with a workflow your whole team can actually maintain.

Introduction: Why integrate Rust FFI with C in existing CMake projects? - image 1

Prerequisites: Tools, project layout, and basic Rust FFI with C concepts

Required tools and environment

Before wiring up Rust FFI with C in a CMake project, I always make sure the tooling is solid. You’ll need:

  • Rust toolchain (via rustup) with cargo and a stable compiler.
  • C toolchain (GCC/Clang or MSVC) matching what your CMake project already uses.
  • CMake (3.15+ is usually comfortable for modern workflows).

On Unix-like systems I typically verify everything with:

rustc --version
cargo --version
cmake --version
cc --version

If you can build your existing C project with CMake and also run cargo build in a separate Rust directory, you’re ready to combine them.

Recommended project layout

In my experience, keeping Rust and C code logically separated but close by makes maintenance easier. A simple layout I like is:

project-root/
  CMakeLists.txt
  src/              # existing C sources
  include/          # C headers
  rust-ffi/         # Rust crate exposed via FFI
    Cargo.toml
    src/lib.rs

CMake remains the top-level build entry point, while the rust-ffi crate compiles to a static or shared library that CMake links just like any other C library.

Minimal FFI and ABI concepts you should know

You don’t need to be an ABI guru, but a few concepts are essential when working with Rust FFI with C:

  • C ABI: use extern "C" in Rust so symbols and calling conventions match what C expects.
  • Stable types: stick to FFI-safe types such as i32, u32, f64, raw pointers, and #[repr(C)] structs.
  • Ownership boundaries: decide clearly which side (Rust or C) allocates and frees memory; this is where I’ve seen most bugs when teams start out.

A tiny example of an FFI-safe function in Rust looks like this:

#[no_mangle]
pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 {
    a + b
}

This compiles down to a C-callable symbol add_numbers that you can later declare in C and link via CMake. Once these basics are clear, the rest of the integration becomes mostly build-system plumbing rather than language mystery. FFI – The Rustonomicon – Rust Documentation

Step 1: Expose a C‑compatible Rust API with extern “C”

Create a Rust library crate for FFI

The first thing I do in any Rust FFI with C setup is isolate the Rust side into a dedicated library crate. From your project root, create a Rust lib in the rust-ffi folder:

cd rust-ffi
cargo init --lib

Then, in Cargo.toml, make sure Rust builds a C-friendly library:

[package]
name = "rust_ffi"
version = "0.1.0"
edition = "2021"

[lib]
name = "rust_ffi"
crate-type = ["staticlib", "cdylib"]

I usually enable both staticlib and cdylib so I can choose static or shared linking later in CMake without touching the Rust code again.

Write a simple extern “C” API in Rust

Next, expose a function that C can call. In my experience, starting with a tiny, pure function makes debugging much easier than jumping straight into complex structs or heap allocations.

Edit src/lib.rs:

#[no_mangle]
pub extern "C" fn add_in_rust(a: i32, b: i32) -> i32 {
    a + b
}

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

#[no_mangle]
pub extern "C" fn distance_from_origin(p: Point) -> f64 {
    (p.x * p.x + p.y * p.y).sqrt()
}

A few important pieces I always double-check:

  • extern "C" tells Rust to use the C ABI so C code can call these functions.
  • #[no_mangle] keeps symbol names exactly as written (no Rust name mangling), which is critical when linking from C.
  • #[repr(C)] on Point guarantees the same memory layout as a C struct with two double fields.

Once this compiles cleanly with cargo build --release, you’ve got a Rust library exporting a stable C interface.

Decide on FFI‑safe types and ownership rules

One thing I learned the hard way was that most Rust FFI with C bugs come from sloppy type choices and unclear ownership. For simple examples like above, we stick to:

  • Primitive integers and floats (i32, u32, f64).
  • #[repr(C)] structs with only FFI-safe fields.
  • No Rust references (&T) or complex generics crossing the boundary.

As you grow the interface, be explicit about who frees what. For example, if Rust allocates memory and C must free it, I expose a dedicated free_* function on the Rust side rather than relying on C’s free(). Here’s a minimal pattern I’ve used:

use std::ffi::CString;
use std::os::raw::c_char;

#[no_mangle]
pub extern "C" fn make_greeting() -> *mut c_char {
    let s = CString::new("Hello from Rust!").unwrap();
    s.into_raw() // C must later give this back to Rust
}

#[no_mangle]
pub extern "C" fn free_greeting(ptr: *mut c_char) {
    if ptr.is_null() { return; }
    unsafe {
        let _ = CString::from_raw(ptr); // drops and frees
    }
}

This pattern keeps allocation and deallocation on the Rust side, which in my experience avoids a whole class of cross-allocator crashes. With these basics in place, you’re ready to declare and call these functions from C and wire everything up through CMake.

Step 1: Expose a C‑compatible Rust API with extern

Step 2: Build the Rust library and understand the artifacts

Compile the Rust crate in release mode

Once I’m happy with the Rust FFI with C API, I switch focus to the actual artifacts that CMake will consume. From the rust-ffi directory, build the crate in release mode:

cd rust-ffi
cargo build --release

This command creates optimized binaries under target/release/. For FFI, the important outputs are the static and/or shared libraries defined by crate-type in Cargo.toml. I usually stick with --release for anything that’s going into a real C application so performance and inlining behave more like production.

Identify and prepare the library files for CMake

After the build, I inspect the target/release folder to see what CMake will later link:

  • Linux: librust_ffi.a (static) and/or librust_ffi.so (shared).
  • macOS: librust_ffi.a and/or librust_ffi.dylib.
  • Windows (MSVC): rust_ffi.lib and possibly rust_ffi.dll.

On Unix-like systems, I often confirm the exported symbols using a quick check like:

# Linux/macOS example
nm -gU target/release/librust_ffi.a | grep add_in_rust

If I see unmangled names like add_in_rust and distance_from_origin, I know my #[no_mangle] and extern "C" setup is correct. At this point, I usually copy or reference the built library from a predictable path (for example, project-root/build/rust/) so CMake can find it reliably on all platforms. With the artifacts located and understood, wiring them into the CMake build is just a matter of specifying include paths, link directories, and the correct library name. Structuring a Rust mono repo – Reddit

Step 3: Call Rust FFI functions from C and wire them into CMake

Declare the Rust functions in a C header

With the Rust FFI with C library built, the next step I take is to expose its functions to the C side via a normal header. This keeps call sites clean and lets IDEs understand the interface. In your C project (for example under include/), create rust_ffi.h:

#ifndef RUST_FFI_H
#define RUST_FFI_H

#ifdef __cplusplus
extern "C" {
#endif

#include <stdint.h>

int32_t add_in_rust(int32_t a, int32_t b);

typedef struct Point {
    double x;
    double y;
} Point;

double distance_from_origin(Point p);

char *make_greeting(void);
void free_greeting(char *ptr);

#ifdef __cplusplus
}
#endif

#endif // RUST_FFI_H

I mirror the Rust signatures exactly, using fixed-width integer types and a plain C struct for Point. When I first wired this up, I found that aligning these types 1:1 with Rust’s #[repr(C)] definitions avoided subtle alignment bugs.

Use the Rust functions from a C source file

Now you can treat the Rust code like any other C library. Here’s a minimal main.c that I often use as a smoke test:

#include <stdio.h>
#include "rust_ffi.h"

int main(void) {
    int32_t sum = add_in_rust(2, 40);
    printf("sum from Rust: %d\n", sum);

    Point p = {3.0, 4.0};
    double d = distance_from_origin(p);
    printf("distance from origin: %.2f\n", d);

    char *greeting = make_greeting();
    if (greeting) {
        printf("greeting: %s\n", greeting);
        free_greeting(greeting); // return ownership to Rust
    }

    return 0;
}

In my experience, this simple program catches 90% of integration issues early: missing symbols, wrong calling conventions, or mismatched types will all show up as link errors or crashes right here.

Link the Rust library from CMake

The final piece is telling CMake where the Rust artifacts live and linking them into your executable. Assuming your Rust build outputs librust_ffi.a into rust-ffi/target/release/, a basic top-level CMakeLists.txt might look like this:

cmake_minimum_required(VERSION 3.15)
project(rust_ffi_cmake C)

set(CMAKE_C_STANDARD 11)

# C sources
add_executable(app
    src/main.c
)

# Path to the Rust library (adjust for your layout and platform)
set(RUST_FFI_LIB_DIR
    ${CMAKE_CURRENT_SOURCE_DIR}/rust-ffi/target/release
)
set(RUST_FFI_LIB
    rust_ffi
)

# Make header visible
target_include_directories(app PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

# Link search path and library name
target_link_directories(app PRIVATE
    ${RUST_FFI_LIB_DIR}
)

target_link_libraries(app PRIVATE
    ${RUST_FFI_LIB}
)

On Unix-like systems, CMake will automatically translate rust_ffi to link against librust_ffi.a or librust_ffi.so. On Windows, you may point directly to rust_ffi.lib instead. One trick that’s helped me is to wire a custom CMake target to run cargo build --release before linking, so the Rust library is always up to date when you build the C project. enable_language option for Rust/Cargo – CMake Discourse

Step 3: Call Rust FFI functions from C and wire them into CMake - image 1

Step 4: Verify the integration and debug common Rust FFI with C issues

Run and sanity-check your integrated binary

At this point in a Rust FFI with C setup, I always start with the simplest possible test: build and run the C executable. From your CMake build directory:

cmake -S .. -B .
cmake --build . --config Release
./app   # or .\\Release\\app.exe on Windows

You should see output from the Rust-backed functions (for example, the sum, distance, and greeting). If it runs cleanly without crashes or weird values, the basic ABI and linking are usually correct.

Diagnose build and link errors

When things do go wrong, I’ve found that most issues fall into a few categories:

  • Undefined references at link time: usually missing #[no_mangle], wrong library name in CMake, or pointing to the wrong target/release directory.
  • Wrong calling convention: forgetting extern "C" on the Rust side or the extern "C" block in your C header when using C++.
  • Platform-specific library names: on Windows, you may need to link against rust_ffi.lib instead of librust_ffi.a.

On Unix-like systems, I often inspect symbols with:

# Check Rust library exports
nm -gU rust-ffi/target/release/librust_ffi.a | grep add_in_rust

# Check what the app is trying to link (Linux shared libs)
ldd app

If symbol names look mangled or missing, I go back to the Rust extern definitions and header declarations to make sure they match exactly.

Catch ABI and memory mistakes early

Logical bugs are trickier because they don’t always crash immediately. In my experience, a few habits help:

  • Assert expectations in C right after calling Rust functions (e.g., check return values, struct fields) to catch layout mismatches.
  • Use sanitizers like -fsanitize=address,undefined on the C side while keeping Rust in debug mode during early integration.
  • Log from both sides: simple printf in C and println! in Rust often make ownership and lifetime issues obvious.

One pattern that has saved me more than once is writing a tiny C test harness that hammers the Rust API in a loop, then running it under AddressSanitizer or Valgrind to flush out use-after-free or double-free problems before they ever hit production. Mix in Rust with C – Blog – Tweede golf

Conclusion and next steps for scaling Rust FFI with C in large codebases

From small bridge to long-term integration

By this point, you’ve seen the full loop of wiring up Rust FFI with C in a CMake project: define a C-compatible Rust API, build it as a library, declare it in C, and link it through CMake. In my own projects, I start with exactly this kind of tiny “island” of Rust, then gradually grow it as the team gains confidence with the tooling and patterns.

To scale this approach in larger codebases, I’ve found a few practices invaluable: treat Rust crates like well-versioned components, keep FFI boundaries narrow and stable, and generate or centralize headers so the C side never drifts out of sync. Over time, you can introduce more advanced pieces like Rust-based subsystems, shared error-handling conventions, or C++ bindings layered on top of the C ABI. If you keep the boundary clean and well-documented, Rust and C can coexist in a single CMake-driven build without turning into a maintenance burden.

Conclusion and next steps for scaling Rust FFI with C in large codebases - image 1

Join the conversation

Your email address will not be published. Required fields are marked *