How to Integrate Assembly and C Code in Your Rust Projects (The Practical Guide)
Learn how to call C and assembly from Rust using build.rs, the cc crate, bindgen, and inline asm. Practical examples, safe wrappers, and fixes.
Introduction
Rust is great, but it doesn't live in a vacuum. Sooner or later, you'll hit a moment where you need to talk to code that isn't Rust.
Maybe your company has a battle-tested C library that nobody wants to rewrite. Maybe you're working with a hardware SDK that ships only a C header. Or maybe you've found a hot loop where a few hand-written instructions beat what the compiler generates.
So how do you bring C and assembly into a Rust project without turning your build into a mess? And how do you keep Rust's safety guarantees from falling apart at the boundary?
This guide answers both questions. We'll cover the main ways to mix these languages, build a working example step by step, and go through the mistakes that cause most FFI crashes. I'll assume you know basic Rust, but I'll explain the low-level parts as we go.
Note: All examples target x86_64 Linux, since that's where most people start. I'll flag where macOS, Windows, or ARM need changes.
Why Mix Rust With C and Assembly?
Before writing any code, it helps to know when this is actually worth doing.
Good reasons:
- Reusing existing C libraries. OpenSSL, SQLite, zlib, and libcurl represent decades of work. Wrapping them is often smarter than rewriting them.
- Talking to operating systems and hardware. Many drivers, SDKs, and system APIs expose C interfaces.
- Gradual migration. You can move a C codebase to Rust one module at a time instead of in a risky big-bang rewrite.
- Squeezing out performance. Some CPU instructions have no clean Rust equivalent, such as special vector instructions, cycle counters, or system-specific tricks.
- Learning. Writing assembly for a Rust function teaches you a lot about how calling conventions really work.
Bad reasons:
- "C is always faster." It isn't. Rust and C compile down to similar machine code, and LLVM optimizes both well.
- "Hand-written assembly beats the compiler." It rarely does for general code. Always benchmark first.
The Big Picture: Ways to Integrate
Here are the main approaches you'll use:
| Approach | Best For | Where the Code Lives | Difficulty |
|---|---|---|---|
FFI with extern "C" |
Calling C functions from Rust | Separate .c files or a system library |
Easy |
build.rs + cc crate |
Compiling C or .S files as part of cargo build |
Inside your project | Easy to medium |
Inline asm! / global_asm! |
Small snippets of assembly | Inside your Rust source | Medium |
bindgen / cbindgen |
Auto-generating bindings in either direction | Generated at build time | Medium |
Standalone .s/.S files |
Larger assembly routines | Separate assembly files | Advanced |
All of these rely on one idea: the C ABI. It's the agreed-upon convention for how functions receive arguments and return values. Rust, C, and assembly can all speak it, and that shared language is what makes mixing them possible.
Understanding FFI in Rust
FFI stands for Foreign Function Interface. In Rust, it comes down to two things:
- Declaring foreign functions inside an
extern "C"block. - Calling them inside
unsafe, because Rust can't verify what happens on the other side.
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
let x = unsafe { abs(-42) };
println!("{x}");
}
That works because the C standard library is already linked into every Rust program on most platforms.
A quick syntax note: Since Rust 1.82, you can write
unsafe extern "C" { ... }, and it's required in the 2024 edition. Older code uses plainextern "C" { ... }. Both mean the same thing. The newer form lets you mark individual items assafeorunsafe.
Why unsafe?
The Rust compiler has no idea what a C function does. It can't check that pointers are valid, that lengths are correct, or that the function won't write past the end of a buffer. So you're making a promise: "I've checked this, and it's fine."
The best practice is to keep that promise in one place. Wrap each unsafe call in a small safe function, and let the rest of your program use the safe version. We'll do exactly that below.
Mapping Types Between Rust and C
Getting types wrong is the number one source of subtle FFI bugs. Here are the common mappings:
| C Type | Rust Type | Notes |
|---|---|---|
int |
std::ffi::c_int |
Don't assume it's i32 on every platform |
unsigned int |
c_uint |
|
long |
c_long |
32-bit on Windows, 64-bit on 64-bit Linux |
size_t |
usize |
|
uint8_t, uint32_t |
u8, u32 |
Fixed-width types map cleanly |
char * (string) |
*const c_char |
Use CStr/CString to convert |
void * |
*mut c_void |
|
const T * |
*const T |
|
T * |
*mut T |
|
struct |
#[repr(C)] struct |
Without repr(C), layout is not guaranteed |
| function pointer | Option<extern "C" fn(...)> |
Option allows null |
Two rules to remember:
- Always use
#[repr(C)]on structs you share with C. Rust is free to reorder fields in normal structs, and C won't expect that. - Prefer fixed-width types (
u32,i64) in your own C code when you control both sides. It removes a whole class of "works on my machine" problems.
Step-by-Step: Adding C and Assembly to a Rust Project
Let's build something real. Our small project will:
- Call a C function that computes a checksum.
- Call an assembly function that adds two numbers.
- Compile both automatically with
cargo build.
Step 1: Create the Project
cargo new rust-ffi-demo
cd rust-ffi-demo
mkdir csrc asm
Your layout will end up like this:
rust-ffi-demo/
├── Cargo.toml
├── build.rs
├── csrc/
│ ├── checksum.c
│ └── checksum.h
├── asm/
│ └── add.S
└── src/
└── main.rs
Step 2: Write the C Code
csrc/checksum.h
#ifndef CHECKSUM_H
#define CHECKSUM_H
#include <stddef.h>
#include <stdint.h>
uint32_t simple_checksum(const uint8_t *data, size_t len);
#endif
csrc/checksum.c
#include "checksum.h"
uint32_t simple_checksum(const uint8_t *data, size_t len) {
uint32_t sum = 0;
for (size_t i = 0; i < len; i++) {
// rotate left by 1, then XOR in the next byte
sum = (sum << 1) | (sum >> 31);
sum ^= data[i];
}
return sum;
}
Nothing fancy. It's a small function that's easy to verify by hand.
Step 3: Write the Assembly Code
We'll use a .S file (capital S). That capital matters: it tells the compiler to run the C preprocessor first, and it's the file type the cc crate handles well.
asm/add.S
.intel_syntax noprefix
.text
.globl add_asm
.type add_asm, @function
add_asm:
lea rax, [rdi + rsi]
ret
.size add_asm, .-add_asm
.section .note.GNU-stack,"",@progbits
What's happening here?
- On x86_64 Linux (the System V ABI), the first two integer arguments arrive in
rdiandrsi. - The return value goes in
rax. lea rax, [rdi + rsi]computes the sum without touching the flags register. It's a common trick.- The
.note.GNU-stackline tells the linker your code doesn't need an executable stack. Without it, newer linkers may warn.
Heads up for other platforms: On macOS, C symbols get a leading underscore (
_add_asm), and Windows uses a different calling convention entirely. ARM64 uses different registers (x0,x1) and different syntax. Assembly is not portable, and that's normal.
Step 4: Compile Everything With build.rs
Add the cc crate as a build dependency:
Cargo.toml
[package]
name = "rust-ffi-demo"
version = "0.1.0"
edition = "2024"
[build-dependencies]
cc = "1"
Now create build.rs in the project root. Cargo runs this before compiling your main crate.
fn main() {
// Compile the C library
cc::Build::new()
.file("csrc/checksum.c")
.include("csrc")
.opt_level(2)
.warnings(true)
.compile("checksum");
// Compile assembly only where it makes sense
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap();
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
if arch == "x86_64" && os == "linux" {
cc::Build::new()
.file("asm/add.S")
.compile("addasm");
}
// Only re-run this script when these files change
println!("cargo:rerun-if-changed=csrc/checksum.c");
println!("cargo:rerun-if-changed=csrc/checksum.h");
println!("cargo:rerun-if-changed=asm/add.S");
}
Notice we check the target architecture using CARGO_CFG_TARGET_ARCH, not cfg!(target_arch). Inside build.rs, cfg! describes the machine running the build script, not the machine you're compiling for. It makes no difference in a normal build, but it breaks silently when you cross-compile.
The cc crate finds a C compiler (gcc, clang, or MSVC), compiles your files into a static library, and tells Cargo to link it. You don't need to write any linker flags yourself.
Step 5: Call Everything From Rust
src/main.rs
unsafe extern "C" {
fn simple_checksum(data: *const u8, len: usize) -> u32;
#[cfg(all(target_arch = "x86_64", target_os = "linux"))]
fn add_asm(a: i64, b: i64) -> i64;
}
/// Safe wrapper around the C checksum function.
pub fn checksum(data: &[u8]) -> u32 {
// SAFETY: the pointer and length come from a valid slice,
// and the C function only reads `len` bytes.
unsafe { simple_checksum(data.as_ptr(), data.len()) }
}
#[cfg(all(target_arch = "x86_64", target_os = "linux"))]
pub fn add(a: i64, b: i64) -> i64 {
// SAFETY: add_asm is a pure function with no memory access.
unsafe { add_asm(a, b) }
}
fn main() {
let msg = b"hello from Rust";
println!("checksum = {:#010x}", checksum(msg));
#[cfg(all(target_arch = "x86_64", target_os = "linux"))]
println!("2 + 40 = {}", add(2, 40));
}
Run it:
cargo run
You should see the checksum printed, followed by 2 + 40 = 42. That's the whole loop: write C or assembly, describe it in an extern block, wrap it safely, and let Cargo build it all.
Pro tip: Put the
externblock and the safe wrappers in their own module, such asffi.rs. The rest of your codebase should never see the wordunsafe.
Using Inline Assembly Directly in Rust
You don't always need a separate file. For short snippets, Rust has built-in support through the asm! macro, which has been stable since Rust 1.59.
use std::arch::asm;
#[cfg(target_arch = "x86_64")]
fn add_inline(a: u64, b: u64) -> u64 {
let mut result = a;
unsafe {
asm!(
"add {0}, {1}",
inout(reg) result,
in(reg) b,
options(pure, nomem, nostack),
);
}
result
}
Let's break that down:
{0}and{1}are placeholders. Rust picks the actual registers for you, which is much safer than hard-coding them.inout(reg) resultmeans "this value goes in and comes back out."in(reg) bmeans "read-only input."options(pure, nomem, nostack)are promises to the compiler: no side effects, no memory access, no stack use. These let it optimize aggressively, so only add them if they're true. If you lie, you get bugs that are very hard to trace.
Inline asm! vs. Separate .S Files
Inline asm! |
Separate .S file |
|
|---|---|---|
| Register allocation | Compiler handles it | You handle it |
| Inlining | Can be inlined into callers | Always a real function call |
| Setup | None | Needs build.rs |
| Best for | A few instructions | Whole functions or routines |
| Syntax | Intel by default on x86 | Whatever your assembler accepts |
Two More Tools Worth Knowing
global_asm!lets you emit a whole block of assembly at module level, which is handy for defining entire functions without a build script.- Naked functions give you full control over a function's prologue and epilogue. They were stabilized in Rust 1.88 using
#[unsafe(naked)]andnaked_asm!. Check your compiler version before relying on them.
Generating Bindings Automatically With bindgen
Hand-writing extern blocks is fine for three functions. For a library with three hundred, it's a recipe for typos.
That's where bindgen comes in. It reads your C header and generates the Rust declarations for you.
Cargo.toml
[build-dependencies]
cc = "1"
bindgen = "0.71" # check crates.io for the latest version
csrc/wrapper.h
#include "checksum.h"
build.rs (bindgen part)
use std::{env, path::PathBuf};
fn main() {
// ... cc::Build code from before ...
let bindings = bindgen::Builder::default()
.header("csrc/wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("failed to generate bindings");
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out.join("bindings.rs"))
.expect("failed to write bindings");
}
src/ffi.rs
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(dead_code)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
A few things to know about bindgen:
- It needs libclang installed on your machine. On Ubuntu or Debian, that's
sudo apt install libclang-dev. - The generated code is raw and unsafe. You still need to write safe wrappers on top.
- For very large libraries, commit the generated file to your repo so contributors don't all need libclang.
Going the Other Way: Calling Rust From C
Integration works in both directions. If you're migrating a C project to Rust, you'll want C code to call your new Rust functions.
#[unsafe(no_mangle)]
pub extern "C" fn rust_multiply(a: i32, b: i32) -> i32 {
a * b
}
extern "C"gives the function the C calling convention.#[unsafe(no_mangle)]stops Rust from renaming the symbol, so C can find it. On editions before 2024, write it as plain#[no_mangle].
To make C happy, you also need a header. Instead of writing it by hand, use cbindgen, which reads your Rust code and generates a .h file.
cargo install cbindgen
cbindgen --lang c --output rust_api.h
To build a library that C can link against, set the crate type in Cargo.toml:
[lib]
crate-type = ["staticlib"] # or "cdylib" for a shared library
The Panic Problem
What happens if Rust code panics while being called from C? Historically, unwinding across an extern "C" boundary was undefined behavior. Modern Rust makes extern "C" functions abort the process if a panic tries to escape. That's safer than undefined behavior, but it still kills your program.
The fix is to catch panics at the boundary:
use std::panic::catch_unwind;
#[unsafe(no_mangle)]
pub extern "C" fn rust_safe_divide(a: i32, b: i32) -> i32 {
catch_unwind(|| a / b).unwrap_or(-1)
}
Return an error code instead of letting a panic escape. C programmers already expect that style.
Handling Strings, Memory, and Ownership
This is where most real-world FFI bugs live. The rule of thumb is simple: whoever allocates the memory must free it.
Passing Strings to C
Rust strings aren't null-terminated. C strings are. Use CString:
use std::ffi::{CString, c_char};
unsafe extern "C" {
fn puts(s: *const c_char) -> i32;
}
fn main() {
let s = CString::new("hello from Rust").expect("no interior null bytes");
unsafe { puts(s.as_ptr()); }
}
Keep s alive for as long as C uses the pointer. A classic mistake is writing CString::new("hi").unwrap().as_ptr(), which drops the CString immediately and hands C a dangling pointer.
Reading Strings From C
use std::ffi::{CStr, c_char};
unsafe fn read_c_string(ptr: *const c_char) -> String {
unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned()
}
Memory Ownership Rules
- Memory allocated by C (with
malloc) must be freed by C (free). Never pass it to Rust's allocator. - Memory allocated by Rust (like a
BoxorVec) must be freed by Rust. If you hand it to C, give C a matchingfree_thing()function to call back. - Borrowed pointers should never be stored by C beyond the call unless you've guaranteed the data lives long enough.
Real-World Use Cases
Here's where this kind of integration shows up in practice:
- Wrapping system libraries such as libc, libpcap, or libusb, which is how many
*-syscrates work. - Embedded and OS development, where startup code and interrupt handlers are often small assembly routines.
- Cryptography, where libraries pair a Rust API with hand-tuned assembly for hot loops.
- CLI and developer tools, where you might reuse an existing C parser or compression library instead of reimplementing it.
- Performance experiments, where you replace one function with an assembly version and benchmark the difference.
Best Practices
- Isolate
unsafe. Keep FFI declarations in one module and expose only safe functions. - Document every
unsafeblock with a// SAFETY:comment explaining why it's sound. - Use
#[repr(C)]on every shared struct. - Never let panics cross the boundary. Catch them and return error codes.
- Check for null pointers before dereferencing anything that came from C.
- Benchmark before writing assembly. Use
cargo benchor Criterion, and compare against plain Rust first. - Gate platform-specific code with
cfgattributes and always provide a portable fallback. - Test under sanitizers. Tools like Valgrind and AddressSanitizer catch memory errors that Rust can't see across FFI.
- Add
rerun-if-changedlines sobuild.rsdoesn't recompile everything on every build. - Consider a
-syscrate if you're wrapping a library others might use. Keep raw bindings infoo-sysand the safe API infoo.
Conclusion
Mixing Rust with C and assembly sounds intimidating, but the workflow is quite consistent once you've seen it. Declare the foreign functions, compile the foreign code through build.rs and the cc crate, wrap the unsafe parts in safe functions, and test carefully.
Here are the takeaways worth remembering:
- Use
ccto compile C and.Sfiles duringcargo build. - Use
bindgenfor big headers andcbindgenwhen C needs to call Rust. - Match types carefully and add
#[repr(C)]to shared structs. - Keep
unsafein one small module and document why it's sound. - Never let panics cross the boundary, and always follow the "whoever allocates, frees" rule.
- Reach for assembly only after a benchmark tells you to.
Start small. Wrap one C function, get it running, then grow from there. Once that first cargo run prints the right answer, the rest is repetition.