The linker doesn't know your library exists
You've found a C library that does exactly what you need. Maybe it's a legacy math routine, a hardware driver, or a specialized compression algorithm. You write the extern "C" block in Rust, hit build, and the linker explodes. It doesn't know where the library file lives. It doesn't know which symbols to pull in. You need a way to hand the linker a map without hardcoding paths that break on other machines. That map is build.rs.
What build.rs actually does
build.rs is a small Rust program that runs during the build process. It doesn't become part of your final binary. It talks to Cargo by printing special strings to stdout. Cargo reads those strings and adjusts the compiler and linker flags before compiling your main code.
Think of build.rs as a site surveyor. Before the construction crew starts laying bricks, the surveyor walks the site, checks where the pipes are, and hands the foreman a note saying "Water main is at coordinates X, Y." The foreman adjusts the plans. The surveyor's report isn't part of the house; it just guides the construction.
Treat build.rs as a configuration generator. It produces flags, not code.
Minimal example
Create a file named build.rs in your crate root. This file is automatically detected by Cargo. You don't need to add it to Cargo.toml.
// build.rs
fn main() {
// Tell Cargo to link against the static library named "my_c_lib".
// The "static" keyword tells the linker to look for a .a or .lib archive.
println!("cargo:rustc-link-lib=static=my_c_lib");
// Tell the linker to search this directory for the library file.
// This path can be absolute or relative to the crate root.
println!("cargo:rustc-link-search=native=/usr/local/lib");
}
In your source code, declare the external function and call it inside an unsafe block.
// src/lib.rs
/// Declare the C function signature so Rust knows the ABI and argument types.
extern "C" {
fn my_c_function();
}
/// Call the C function.
/// SAFETY: The caller must ensure:
/// 1. my_c_function does not access invalid memory.
/// 2. my_c_function does not violate Rust's aliasing rules.
/// 3. The C library is properly initialized before this call.
pub fn call_c() {
unsafe {
my_c_function();
}
}
Convention aside: The community prefers explicit println! directives over string concatenation. Write println!("cargo:rustc-link-lib={}", name); only if the name is dynamic. Hardcoded names are clearer. Also, never print trailing whitespace in directives. Cargo parses these lines strictly. Extra spaces can cause silent failures.
Walkthrough: from script to binary
When you run cargo build, Cargo checks for build.rs. If it exists, Cargo compiles and runs that script first. Your script executes and prints lines like cargo:rustc-link-lib=.... Cargo captures these lines from stdout. It translates rustc-link-lib into a -l flag for the linker. It translates rustc-link-search into a -L flag.
Then Cargo compiles your actual source code and invokes the linker with those flags. The linker searches the directories you specified, finds the library archive, resolves the symbols referenced in your extern "C" block, and produces the final binary.
The linker is the final boss. If build.rs gives it wrong coordinates, the build fails. Get the flags right.
Realistic example: handling platforms
Hardcoding paths breaks cross-compilation. A path that works on your Linux machine might not exist on macOS or Windows. Use the TARGET environment variable, which Cargo sets automatically, to adapt your flags.
// build.rs
use std::env;
fn main() {
// Cargo sets TARGET to the triple like x86_64-unknown-linux-gnu.
let target = env::var("TARGET").unwrap();
if target.contains("apple") {
// macOS often installs libs in Homebrew paths or frameworks.
println!("cargo:rustc-link-search=native=/opt/homebrew/lib");
println!("cargo:rustc-link-search=native=/usr/local/lib");
} else if target.contains("windows") {
// Windows uses different library extensions and paths.
println!("cargo:rustc-link-search=native=C:\\libs");
} else {
// Linux defaults.
println!("cargo:rustc-link-search=native=/usr/lib");
println!("cargo:rustc-link-search=native=/usr/local/lib");
}
// Link the library dynamically.
// "dylib" tells the linker to look for .so, .dll, or .dylib.
println!("cargo:rustc-link-lib=dylib=ssl");
}
Convention aside: Always check TARGET before emitting platform-specific flags. Cross-compilation is a first-class use case in Rust. If your build.rs assumes the host OS, your crate won't build for other targets. Read the environment and adapt.
Hardcoding paths breaks cross-compilation. Read TARGET and adapt.
Pitfalls and compiler errors
Calling an extern "C" function requires unsafe. If you forget the block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe) or a similar safety error. The call crosses the boundary into unmanaged code. Rust demands you acknowledge the risk.
Linker errors are the most common failure mode. You'll see messages like cannot find -lfoo or undefined reference to bar. These aren't Rust errors. They mean build.rs didn't print the right search path or library name. Check your build.rs output. Run cargo build -vv to see exactly what flags Cargo is passing.
ABI mismatches are silent killers. extern "C" defines the calling convention, but it doesn't check types. If your Rust signature says fn foo(x: i32) but the C function expects int x, you might be fine on most platforms. But if C expects bool and you pass Rust's bool, you get trouble. C bool is often an int (4 bytes), while Rust bool is 1 byte. The caller and callee will disagree on stack layout. Use std::os::raw::c_int or std::os::raw::c_bool for C types. Never assume Rust types map perfectly to C types.
Cache invalidation is another trap. Cargo caches the output of build.rs. If you update the C library but don't touch build.rs, Cargo assumes nothing changed and reuses old flags. You get stale behavior or linker errors. Use println!("cargo:rerun-if-changed=path/to/lib.a"); to tell Cargo to rerun the script when the library file changes.
Cache invalidation is the silent killer of build scripts. Tell Cargo when to rerun.
Convention: debugging build.rs
When build.rs fails, the error messages can be opaque. Use println!("cargo:warning=Your message here"); to print warnings to the user. Cargo displays these in a colored box during the build. This is the standard way to communicate diagnostics from a build script. Never use eprintln! for user messages; Cargo might suppress stderr. Stick to cargo:warning=.
Also, build.rs can set environment variables for the main code using println!("cargo:rustc-env=VAR=value");. This is useful for conditional compilation. You can check option_env!("VAR") in your Rust code to enable features based on build-time discovery.
Decision: when to use build.rs vs alternatives
Use build.rs with direct println! directives when the library location is fixed and known, like a vendored static archive in your repository. Use the pkg-config crate inside build.rs when the target library ships a .pc file and you need to support multiple platforms without hardcoding paths. Use the cmake crate inside build.rs when the C library requires compilation steps before linking, such as running make or configuring headers. Use bindgen alongside build.rs when you have C header files and want to generate safe Rust function signatures automatically instead of writing extern "C" blocks by hand.
Don't write build.rs from scratch if a helper crate exists. The ecosystem solves the hard parts.