The Cargo Guide
Native Code and FFI Integration
Why Native Integration Exists in Cargo
Rust crates sometimes need to interact with native C or C++ code, system libraries, or platform-specific toolchains. Cargo supports this through build scripts, build dependencies, and linker configuration.
A useful mental model is:
- Rust source defines your crate's Rust-facing API
- build scripts prepare native artifacts and linker instructions
- FFI declarations define how Rust talks to native symbols
The Core Native Integration Pattern
A typical native integration flow looks like this:
1. Cargo compiles and runs build.rs
2. build.rs compiles or discovers native code
3. build.rs emits linker instructions
4. Rust code declares extern functions or uses generated bindings
5. Cargo links everything into the final artifactThis is why native integration is usually centered around build.rs rather than only Cargo.toml or Rust source.
A Minimal FFI Layout
A small native-integration crate might look like this:
native_demo/
├── Cargo.toml
├── build.rs
├── src/
│ └── lib.rs
└── native/
└── hello.cThis layout separates the Cargo manifest, build-time logic, Rust-facing API, and native source.
Using cc to Compile Bundled Native Code
The cc crate is the most common way to compile bundled C, C++, or assembly code in a Cargo build.
Manifest:
[package]
name = "native_demo"
version = "0.1.0"
edition = "2024"
[build-dependencies]
cc = "1"Build script:
fn main() {
cc::Build::new()
.file("native/hello.c")
.compile("hello");
println!("cargo::rerun-if-changed=native/hello.c");
println!("cargo::rerun-if-changed=build.rs");
}Rust source:
unsafe extern "C" {
fn hello_add(a: i32, b: i32) -> i32;
}
pub fn add(a: i32, b: i32) -> i32 {
unsafe { hello_add(a, b) }
}This is the standard pattern when the crate ships native source alongside Rust code.
Why cc Is Better Than Shelling Out to gcc Directly
Beginners sometimes try to call gcc or clang manually from build.rs, but cc is usually the better choice.
A hard-coded approach is fragile:
use std::process::Command;
fn main() {
Command::new("gcc")
.args(["native/hello.c", "-c", "-o", "hello.o"])
.status()
.unwrap();
}This is weaker because it assumes compiler names, flags, and host behavior. By contrast, cc handles target-aware native compilation much more portably.
A Simple Bundled C Example
Suppose native/hello.c contains:
int hello_add(int a, int b) {
return a + b;
}And src/lib.rs contains:
unsafe extern "C" {
fn hello_add(a: i32, b: i32) -> i32;
}
pub fn add(a: i32, b: i32) -> i32 {
unsafe { hello_add(a, b) }
}Then the crate can expose a pure Rust wrapper over the native implementation.
Using pkg-config for System Libraries
When a crate depends on a native library already installed on the system, pkg-config is a common solution.
Manifest:
[package]
name = "libz_wrapper"
version = "0.1.0"
edition = "2024"
links = "z"
[build-dependencies]
pkg-config = "0.3"Build script:
fn main() {
pkg_config::Config::new().probe("zlib").unwrap();
println!("cargo::rerun-if-changed=build.rs");
}Rust source:
use std::os::raw::{c_uint, c_ulong};
unsafe extern "C" {
pub fn crc32(crc: c_ulong, buf: *const u8, len: c_uint) -> c_ulong;
}This is a common pattern for Unix-like systems that provide system packages discoverable through pkg-config.
What the links Manifest Key Means
The links key in [package] tells Cargo that the crate links a native library.
Example:
[package]
name = "libz_wrapper"
version = "0.1.0"
edition = "2024"
links = "z"This is especially important in the native-library ecosystem because Cargo uses it to understand native-linkage relationships and metadata flow between crates.
Using cmake for Native Projects
If the native library itself is built with CMake, the cmake crate is a common build dependency.
Manifest:
[package]
name = "cmake_demo"
version = "0.1.0"
edition = "2024"
[build-dependencies]
cmake = "0.1"Build script:
fn main() {
let dst = cmake::build("native");
println!("cargo::rustc-link-search=native={}/lib", dst.display());
println!("cargo::rustc-link-lib=static=my_native_lib");
}This is useful when the bundled native code already uses a CMake-based build system.
When to Use cc vs cmake
A useful rule of thumb is:
- use
ccwhen you are compiling a relatively small amount of bundled native source directly from Cargo - use
cmakewhen the upstream native project is already CMake-based or has a more complex native build structure
This helps keep the Cargo integration aligned with the native project's actual build reality.
Using bindgen to Generate Rust Bindings
When you need Rust declarations generated from C headers, bindgen is a common solution.
Manifest:
[build-dependencies]
bindgen = "0.70"Build script:
use std::env;
use std::path::PathBuf;
fn main() {
let bindings = bindgen::Builder::default()
.header("native/wrapper.h")
.generate()
.unwrap();
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_dir.join("bindings.rs"))
.unwrap();
println!("cargo::rerun-if-changed=native/wrapper.h");
}Rust source:
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));This is a common approach when hand-writing all FFI declarations would be tedious or error-prone.
Handwritten FFI vs Generated Bindings
A good practical distinction is:
- handwritten bindings are often fine for a very small, stable native surface
bindgenis often better when the header surface is larger, more complex, or changes over time
For example, this is a reasonable handwritten binding:
unsafe extern "C" {
fn hello_add(a: i32, b: i32) -> i32;
}But a larger header surface is usually a better fit for generated bindings.
Linking Semantics from build.rs
Build scripts communicate linker behavior to Cargo by printing directives.
Examples:
fn main() {
println!("cargo::rustc-link-lib=static=hello");
println!("cargo::rustc-link-search=native=/usr/local/lib");
}Useful mental model:
rustc-link-libsays what library to linkrustc-link-searchsays where to search for it
These directives are one of the main bridges between native build logic and Rust compilation.
Static vs Dynamic Linking Thinking
A practical mental distinction is:
- static linking tries to include the native library into the final artifact more directly
- dynamic linking expects the library to be present at runtime in the environment
Example build-script directive for static linking:
println!("cargo::rustc-link-lib=static=hello");And a simpler dynamic form:
println!("cargo::rustc-link-lib=z");The right choice depends on portability goals, licensing constraints, system expectations, and deployment model.
Target-Specific Native Behavior
Native integration often needs target-specific logic because native toolchains and library conventions vary by platform.
Example build script:
fn main() {
let target = std::env::var("TARGET").unwrap();
if target.contains("windows") {
println!("cargo::rustc-link-lib=user32");
}
if target.contains("apple") {
println!("cargo::rustc-link-lib=framework=Security");
}
}This is one reason native integration is often more complex than pure Rust dependency use.
Using TARGET and HOST Correctly
Cargo sets both TARGET and HOST for build scripts.
Example:
fn main() {
let target = std::env::var("TARGET").unwrap();
let host = std::env::var("HOST").unwrap();
println!("cargo::warning=host={host} target={target}");
}This distinction matters in cross-compilation, where the build script runs on the host but prepares artifacts for a different target.
Shipping a Crate That Depends on System Libraries
Crates that depend on system libraries face a packaging and portability decision. Some crates assume the system library is already installed. Others can fall back to bundled source builds. Others expose both paths.
A simple system-library-only model:
[package]
name = "libz_wrapper"
version = "0.1.0"
edition = "2024"
links = "z"
[build-dependencies]
pkg-config = "0.3"A more flexible model might support both pkg-config discovery and bundled compilation depending on features or environment.
System Library Portability Tradeoffs
Depending on system libraries has real tradeoffs.
Pros:
- smaller source package
- can reuse system-managed library updates
- may fit distribution packaging norms better
Cons:
- requires the target system to have the library and headers installed correctly
- behavior can vary across platforms and distributions
- builds can fail in environments without the expected native tooling
This is why many mature native-integration crates support multiple strategies instead of assuming one universal environment.
Bundled Native Source Tradeoffs
Bundling native source also has tradeoffs.
Pros:
- fewer external system prerequisites
- more control over the exact native code version used
- often easier reproducibility across machines
Cons:
- larger crate and build complexity
- more work for the crate maintainer
- more native compilation cost during builds
- potential licensing and maintenance burden
This is why there is no single best native-integration policy for every crate.
A Hybrid Pattern
A common pattern is to prefer a system library when available, but allow a fallback to a bundled build.
Illustrative build script sketch:
fn main() {
if pkg_config::Config::new().probe("zlib").is_ok() {
println!("cargo::warning=using system zlib");
return;
}
cc::Build::new()
.file("vendor/zlib_stub.c")
.compile("z_stub");
println!("cargo::rerun-if-changed=vendor/zlib_stub.c");
}This pattern is common because it balances portability with respect for system installations.
Code Generation and Native Integration Together
Some crates combine native compilation and binding generation.
For example:
ccorcmakebuilds the native librarybindgengenerates Rust bindings from headers- Rust source includes generated bindings from
OUT_DIR
Illustrative build script:
use std::env;
use std::path::PathBuf;
fn main() {
cc::Build::new()
.file("native/hello.c")
.compile("hello");
let bindings = bindgen::Builder::default()
.header("native/wrapper.h")
.generate()
.unwrap();
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings.write_to_file(out_dir.join("bindings.rs")).unwrap();
println!("cargo::rerun-if-changed=native/hello.c");
println!("cargo::rerun-if-changed=native/wrapper.h");
}This is a common advanced FFI pattern.
Avoiding Common Portability Mistakes
A few portability rules help native Cargo integration stay healthier.
First, avoid hard-coding gcc, clang, ar, or platform-specific paths unless there is no better option.
Second, prefer helper crates like cc, pkg-config, and cmake when they match the problem.
Third, use TARGET-aware logic in build scripts rather than assuming host equals target.
Fourth, keep generated outputs in OUT_DIR, not in source directories.
Fifth, declare rerun triggers precisely so rebuild behavior stays understandable.
A Full Small Example
Suppose you want to ship a crate with bundled C code and a Rust wrapper.
Cargo.toml:
[package]
name = "ffi_demo"
version = "0.1.0"
edition = "2024"
[build-dependencies]
cc = "1"build.rs:
fn main() {
cc::Build::new()
.file("native/math.c")
.compile("mathffi");
println!("cargo::rerun-if-changed=native/math.c");
println!("cargo::rerun-if-changed=build.rs");
}native/math.c:
int add_ints(int a, int b) {
return a + b;
}src/lib.rs:
unsafe extern "C" {
fn add_ints(a: i32, b: i32) -> i32;
}
pub fn add(a: i32, b: i32) -> i32 {
unsafe { add_ints(a, b) }
}This is a compact, realistic entry-level FFI integration crate.
Hands-On Exercise
Create a small crate that compiles a bundled C file with cc.
Start here:
cargo new ffi_lab --lib
cd ffi_lab
mkdir -p nativeSet Cargo.toml:
[package]
name = "ffi_lab"
version = "0.1.0"
edition = "2024"
[build-dependencies]
cc = "1"Create build.rs:
fn main() {
cc::Build::new()
.file("native/add.c")
.compile("addffi");
println!("cargo::rerun-if-changed=native/add.c");
}Create native/add.c:
int add_ints(int a, int b) {
return a + b;
}Create src/lib.rs:
unsafe extern "C" {
fn add_ints(a: i32, b: i32) -> i32;
}
pub fn add(a: i32, b: i32) -> i32 {
unsafe { add_ints(a, b) }
}Then run:
cargo build
cargo test
cargo build -vAfter that, try replacing the bundled approach conceptually with a pkg-config-style system-library design and compare the portability tradeoffs.
Common Beginner Mistakes
Mistake 1: invoking native compilers directly instead of using helper crates where appropriate.
Mistake 2: treating host and target as if they are always the same.
Mistake 3: forgetting the links key when designing a native-linking crate.
Mistake 4: generating bindings or code into source directories instead of OUT_DIR.
Mistake 5: assuming a system library will exist on every target environment.
Mistake 6: choosing static versus dynamic linking without thinking about deployment and portability consequences.
Mental Model Summary
A strong mental model for native code and FFI integration in Cargo is:
- build scripts are the normal center of native integration
ccis usually the right tool for compiling bundled native sourcepkg-configis usually the right tool for discovering installed system libraries on supported platformscmakeis useful when the native project already uses CMakebindgenis useful when Rust bindings should be generated from headers- linker behavior is expressed through emitted build-script directives and related manifest keys like
links - target-specific behavior and portability tradeoffs are central, not incidental, to native integration
Once this model is stable, Cargo's native-integration story becomes much easier to reason about as a structured build pipeline rather than as ad hoc glue.
