Skip to main contentbuild.rs, link, and pkg-config | IoT Worker

build.rs, link, and pkg-config

In FFI, declaring the C function is only the first step:

unsafe extern "C" {
    fn sensor_read(out: *mut u8, len: usize) -> i32;
}

This only tells Rust that a C ABI function named sensor_read exists.

The linker still needs to know:

which library contains this function?
where is the library file?
what other dependent libraries are needed?
during cross compilation, are these target libraries or host libraries?

C projects usually handle this with Makefiles, CMake, pkg-config, sysroots, and cross toolchains. Rust projects use #[link], build.rs, and build dependencies for the same job.

If the library name is fixed and the linker can find it in default paths, write:

#[link(name = "sensor")]
unsafe extern "C" {
    fn sensor_read(out: *mut u8, len: usize) -> i32;
}

This tells the linker:

link libsensor.so or libsensor.a

In C command-line terms, this is roughly:

cc main.o -lsensor

If the library type must be specified:

#[link(name = "sensor", kind = "static")]
unsafe extern "C" {
    fn sensor_read(out: *mut u8, len: usize) -> i32;
}

#[link] is good for simple cases: stable library name, path handled by the system or toolchain, no complex probing.

Sometimes the library is not in a default directory. Do not hardcode local paths in Rust source:

// do not design it this way
// #[link_search = "/home/me/vendor/lib"]

Rust commonly uses build.rs to emit link instructions:

fn main() {
    println!("cargo:rustc-link-search=native=vendor/lib");
    println!("cargo:rustc-link-lib=sensor");
}

Cargo runs build.rs during the build, reads these cargo: instructions, and passes them to rustc.

Common instructions include:

cargo:rustc-link-search=native=PATH
cargo:rustc-link-lib=NAME
cargo:rustc-link-lib=static=NAME
cargo:rerun-if-changed=PATH
cargo:rerun-if-env-changed=VAR

build.rs is a build script, not runtime code. It runs at compile time to discover dependencies, generate bindings, and emit link arguments.

build.rs Handles Build Environment Differences

C Makefiles often switch libraries by platform:

ifeq ($(TARGET),linux)
    LDLIBS += -ludev
endif

Rust can inspect target configuration in build.rs:

fn main() {
    let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();

    if target_os == "linux" {
        println!("cargo:rustc-link-lib=udev");
    }
}

Common environment variables:

TARGET
HOST
CARGO_CFG_TARGET_OS
CARGO_CFG_TARGET_ARCH
OUT_DIR

Keep this distinction clear:

HOST   -> where build.rs runs
TARGET -> where the final artifact runs

During cross compilation, they may differ. build.rs runs on the host but emits link arguments for the target artifact.

pkg-config Discovers System Libraries

Many C libraries provide .pc files. C projects often use:

pkg-config --cflags --libs libudev

This outputs include paths, library paths, and dependent libraries. Rust can use the pkg-config crate:

[build-dependencies]
pkg-config = "0.3"

build.rs:

fn main() {
    pkg_config::Config::new()
        .probe("libudev")
        .expect("libudev not found");
}

When probe succeeds, it emits the needed Cargo link instructions automatically.

This is more robust than writing:

println!("cargo:rustc-link-search=native=/usr/lib");
println!("cargo:rustc-link-lib=udev");

Real systems may have different library paths, dependency libraries, and multi-arch directories.

Cross Compilation Must Use the Target sysroot

The easiest cross-compilation mistake is: build.rs runs on the host and accidentally finds host .pc files and host libraries.

For example, you target ARM Linux but link the x86_64 host libudev. The build may fail immediately, or the path mix-up may surface later.

During cross compilation, make pkg-config use the target sysroot. Common variables include:

PKG_CONFIG_ALLOW_CROSS=1
PKG_CONFIG_SYSROOT_DIR=/path/to/sysroot
PKG_CONFIG_PATH=/path/to/sysroot/usr/lib/pkgconfig
PKG_CONFIG_LIBDIR=/path/to/sysroot/usr/lib/pkgconfig

Exact settings depend on the toolchain. The core rule is:

build.rs runs on the host
linked libraries belong to the target
pkg-config must inspect target .pc files and target libraries

Do not hardcode /usr/lib in build.rs. That is usually the host path, not the target system path.

bindgen and cbindgen Are a Different Layer

Build scripts are also often used to generate bindings.

When Rust calls C, bindgen can generate Rust FFI declarations from C headers:

fn main() {
    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .generate()
        .expect("generate bindings");

    let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
    bindings.write_to_file(out.join("bindings.rs")).unwrap();
}

When C calls Rust, cbindgen can generate a C header from exported Rust types.

Keep the layers separate:

bindgen/cbindgen generate declarations
pkg-config/build.rs discover libraries and emit link flags
ABI design is still your responsibility

Generated tools do not decide who frees memory, how errors are represented, or which thread invokes callbacks.

rerun-if Avoids Unnecessary Rebuilds

build.rs may rerun often by default. When generating bindings or probing dependencies, tell Cargo when it needs to rerun:

fn main() {
    println!("cargo:rerun-if-changed=wrapper.h");
    println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");

    pkg_config::Config::new()
        .probe("libudev")
        .expect("libudev not found");
}

Now changes to the header or relevant environment variables retrigger the build script. In large projects this avoids needless rebuilds.

build.rs Should Not Become a Second Build System

build.rs is powerful, but it should not become a complex Makefile.

It is useful for:

  • emitting link arguments
  • probing system libraries
  • generating FFI bindings
  • compiling small C helpers
  • enabling small target-specific branches

It is not a good place to:

  • download random source code
  • modify repository files during build
  • depend on uncontrolled global host state
  • hardcode complex cross-compilation logic

Complex systems should put toolchains, sysroots, environment variables, and dependency versions into explicit build configuration instead of making build.rs guess.

What to Keep

Rust/C build discovery maps roughly like this:

fixed library name                  -> #[link(name = "...")]
need to emit library paths/names      -> build.rs prints cargo:rustc-link-*
system library with .pc file          -> pkg-config crate
cross compilation                     -> pkg-config points at target sysroot
C header to Rust declarations          -> bindgen
Rust exports to C header               -> cbindgen
build script rerun conditions          -> cargo:rerun-if-changed / rerun-if-env-changed

Do not confuse “the function declaration is correct” with “linking is correct.” FFI must handle ABI declarations, library discovery, link arguments, target platform, and runtime library search paths.

The next article covers workspace, features, and build configuration: how a Rust project organizes multiple crates, optional capabilities, and different delivery configurations.