When Rust needs C
You have a Rust project that depends on a C library. Maybe it is a high-performance image filter written in C, or a legacy protocol parser you cannot rewrite yet. You cannot just drop the .c file into src/ and expect cargo build to work. Rust's compiler does not parse C code. You need a bridge that compiles the C code, produces a library, and links it into your Rust binary.
That bridge is a build script. Cargo runs build.rs before compiling your Rust code. Inside that script, you use crates like cc or cmake to drive the C toolchain. These crates handle the messy details: finding the right compiler, passing platform-specific flags, and telling Cargo how to link the result. You get a clean Rust interface to native code without wrestling with Makefiles or linker scripts.
Build scripts and the linking contract
Rust compiles to machine code. C compiles to machine code. To use C from Rust, the C code must become an object file or a static library, and the Rust linker must include that library in the final binary.
The cc crate automates this for simple cases. It finds the system C compiler, compiles your files, and prints instructions to Cargo. The cmake crate is different. It invokes the CMake build tool. You use it when the C library is complex, has its own CMakeLists.txt, and you just want to run that existing build system from within Rust.
Both crates rely on Cargo's build script protocol. The script runs, prints special lines like cargo:rustc-link-lib=..., and Cargo reads those lines to configure the linker. You do not write linker flags manually. The build script handles the plumbing.
Using cc for simple C code
The cc crate is the standard tool for compiling a handful of C files. It works out of the box on Linux, macOS, and Windows. It detects your target platform and picks the appropriate compiler.
// build.rs
fn main() {
// cc::Build creates a builder for compiling C code.
// It automatically detects the host compiler and target architecture.
cc::Build::new()
.file("src/native.c")
// -O2 requests optimization level 2.
// cc passes this flag directly to the underlying C compiler.
.flag("-O2")
// "native_lib" defines the output library name.
// Cargo will link against libnative_lib.a on Unix or native_lib.lib on Windows.
.compile("native_lib");
}
Add cc to your Cargo.toml under [build-dependencies]. This ensures the crate is available only during the build phase, not in the final binary.
[build-dependencies]
cc = "1.0"
How cc works under the hood
When you run cargo build, Cargo detects build.rs and executes it. Inside build.rs, the cc crate performs several steps. It queries the environment for the target triple. It locates the C compiler, checking for gcc, clang, or cl.exe depending on the platform. It compiles each file passed to .file() into an object file. Then it archives those object files into a static library.
Crucially, cc prints metadata to stdout. It emits cargo:rustc-link-lib=static=native_lib and cargo:rustc-link-search=native=.... Cargo parses these lines and adds the corresponding flags to the final Rust compilation command. The linker finds the static library and resolves symbols.
The crate also handles incremental builds. It emits cargo:rerun-if-changed=src/native.c automatically. If you modify the C file, Cargo knows to re-run the build script. You do not need to manage cache invalidation.
Convention aside: The community prefers cc for small native components. If your C code fits in a few files and has no complex dependencies, cc is the right choice. It keeps the build logic transparent and easy to debug.
Realistic cc usage with features and includes
Real projects often need include paths, conditional compilation, or platform-specific flags. The cc builder supports all of these.
// build.rs
fn main() {
let mut build = cc::Build::new();
build.file("src/native.c");
// Add include paths so the C compiler can find headers.
// This is equivalent to passing -Ivendor/mylib/include.
build.include("vendor/mylib/include");
// Conditional compilation based on Rust features.
// If the Rust crate is built with feature "fast_math",
// pass a define to the C compiler to enable optimized routines.
if cfg!(feature = "fast_math") {
build.define("FAST_MATH", "1");
}
// Platform-specific flags.
// cc detects the target and allows conditional logic.
if build.is_like_msvc() {
build.flag("/W4");
} else {
build.flag("-Wall");
build.flag("-Wextra");
}
build.compile("native_lib");
}
This pattern lets you expose Rust features that toggle C behavior. The build script bridges the gap between Cargo's feature system and C's preprocessor.
Cross-compilation works seamlessly. cc looks for environment variables like TARGET_CC or aarch64-unknown-linux-gnu-gcc. If you set the TARGET environment variable, cc finds the right cross-compiler. You do not need to hardcode paths. The crate handles platform detection logic for you.
Trust cc for the plumbing. Focus your energy on the FFI boundary and the safety invariants of your wrapper.
When cc is not enough: entering cmake
Some C libraries are massive. They have hundreds of files, complex dependency trees, and platform-specific build rules. Rewriting all that logic in build.rs is impractical. These projects usually ship with a CMakeLists.txt file.
The cmake crate wraps the CMake tool. It runs CMake to configure and build the project, then helps you link the result. You use it when the C library already has a CMake build system and you just want to invoke it.
// build.rs
fn main() {
// cmake::Config points to the directory containing CMakeLists.txt.
// It runs cmake --build and handles the output.
let dst = cmake::Config::new("vendor/complex-c-lib")
// Pass configuration variables to CMake.
// This sets -DBUILD_TESTS=OFF in the CMake invocation.
.define("BUILD_TESTS", "OFF")
// Build the project. Returns the path to the build directory.
.build();
// CMake might produce a library in a non-standard location.
// You often need to tell Cargo where to find it.
println!("cargo:rustc-link-search=native={}/lib", dst.display());
println!("cargo:rustc-link-lib=static=complex_lib");
}
Add cmake to [build-dependencies] in Cargo.toml. The crate requires CMake to be installed on the system. If CMake is missing, the build script will fail.
The cmake crate does not compile C code itself. It delegates to CMake, which delegates to a native build tool like Ninja or Make. This adds a layer of indirection. Debugging build failures can be harder because errors come from CMake or the underlying tool, not directly from cc.
Convention aside: Keep cmake usage minimal. Only use it when the C project demands it. If you can compile the C code with cc, prefer cc. It produces more predictable builds and integrates better with Cargo's caching.
Pitfalls and compiler errors
Build scripts run in a separate process. Errors here look different from Rust compiler errors.
The most common failure is a missing toolchain. If your system lacks a C compiler, cc will panic during execution. You will see output like couldn't execute cc: No such file or directory. Install build-essential on Debian or Ubuntu, run xcode-select --install on macOS, or install Visual Studio Build Tools on Windows.
If you use cmake, you need CMake installed. The error will mention cmake not found. Install CMake via your package manager or download it from the official site.
Linking errors occur when the Rust code references a C function that does not exist. If you declare an extern "C" function but the C code does not export it, the linker complains about undefined references. The error message mentions the missing symbol. Check the C header files and ensure the function is defined. If the C library uses name mangling, you must use extern "C" in Rust to prevent Rust from mangling the name. Mismatched mangling causes linker errors.
Stale builds happen when Cargo does not re-run the build script after changes. cc emits cargo:rerun-if-changed automatically for files passed to .file(). If you use cmake or add files manually, you must emit these lines yourself. Forgetting them causes Cargo to skip the build script, ignoring changes to C code. Add println!("cargo:rerun-if-changed=vendor/CMakeLists.txt"); to catch configuration changes.
If you forget the extern "C" block in Rust, you might get E0425 (cannot find function) or a linking error depending on symbol resolution. The compiler cannot find the function declaration. Always wrap FFI declarations in extern "C".
Build scripts are the glue. Treat them as infrastructure, not application logic. If your build script is harder to read than the C code it compiles, you are doing it wrong.
Decision matrix
Use cc when you have a small collection of C files and want direct control over compilation flags. Use cc when the C code is simple, has no complex dependencies, and fits neatly inside your Rust crate's src/ or vendor/ directory. Use cc when you need cross-compilation support without manual toolchain configuration.
Use cmake when the C library is large, has its own CMakeLists.txt, and relies on CMake's dependency management. Use cmake when you are wrapping an existing C project that expects a standard CMake build flow. Use cmake when the C project requires complex configuration steps that are already implemented in CMake scripts.
Reach for the pkg-config crate when the library is already installed on the system and you just need to find headers and link flags. Reach for bindgen alongside cc when you need to generate Rust bindings from C headers automatically. Avoid manual linker flags in build.rs unless you have no other choice; the crates handle the heavy lifting.