Build Script Examples
The following sections illustrate some examples of writing build scripts.
Some common build script functionality can be found via crates oncrates.io.Check out thebuild-dependencieskeyword to see what isavailable. The following is a sample of some popular crates1:
bindgen— Automatically generate RustFFI bindings to C libraries.cc— Compiles C/C++/assembly.pkg-config— Detect systemlibraries using thepkg-configutility.cmake— Runs thecmakebuild tool to build a native library.autocfg,rustc_version,version_check— These cratesprovide ways to implement conditional compilation based on the currentrustcsuch as the version of the compiler.
Code generation
Some Cargo packages need to have code generated just before they are compiledfor various reasons. Here we’ll walk through a simple example which generates alibrary call as part of the build script.
First, let’s take a look at the directory structure of this package:
.├── Cargo.toml├── build.rs└── src └── main.rs1 directory, 3 filesHere we can see that we have abuild.rs build script and our binary inmain.rs. This package has a basic manifest:
# Cargo.toml[package]name = "hello-from-generated-code"version = "0.1.0"edition = "2024"Let’s see what’s inside the build script:
// build.rsuse std::env;use std::fs;use std::path::Path;fn main() { let out_dir = env::var_os("OUT_DIR").unwrap(); let dest_path = Path::new(&out_dir).join("hello.rs"); fs::write( &dest_path, "pub fn message() -> &'static str { \"Hello, World!\" } " ).unwrap(); println!("cargo::rerun-if-changed=build.rs");}
There’s a couple of points of note here:
- The script uses the
OUT_DIRenvironment variable to discover where theoutput files should be located. It can use the process’ current workingdirectory to find where the input files should be located, but in this case wedon’t have any input files. - In general, build scripts should not modify any files outside of
OUT_DIR.It may seem fine on the first blush, but it does cause problems when you usesuch crate as a dependency, because there’s animplicit invariant thatsources in.cargo/registryshould be immutable.cargowon’t allow suchscripts when packaging. - This script is relatively simple as it just writes out a small generated file.One could imagine that other more complex operations could take place such asgenerating a Rust module from a C header file or another language definition,for example.
- The
rerun-if-changedinstructiontells Cargo that the build script only needs to re-run if the build scriptitself changes. Without this line, Cargo will automatically run the buildscript if any file in the package changes. If your code generation uses someinput files, this is where you would print a list of each of those files.
Next, let’s peek at the library itself:
// src/main.rsinclude!(concat!(env!("OUT_DIR"), "/hello.rs"));fn main() { println!("{}", message());}This is where the real magic happens. The library is using the rustc-definedinclude! macro in combination with theconcat! andenv! macros to include thegenerated file (hello.rs) into the crate’s compilation.
Using the structure shown here, crates can include any number of generated filesfrom the build script itself.
Building a native library
Sometimes it’s necessary to build some native C or C++ code as part of apackage. This is another excellent use case of leveraging the build script tobuild a native library before the Rust crate itself. As an example, we’ll createa Rust library which calls into C to print “Hello, World!”.
Like above, let’s first take a look at the package layout:
.├── Cargo.toml├── build.rs└── src ├── hello.c └── main.rs1 directory, 4 filesPretty similar to before! Next, the manifest:
# Cargo.toml[package]name = "hello-world-from-c"version = "0.1.0"edition = "2024"For now we’re not going to use any build dependencies, so let’s take a look atthe build script now:
// build.rsuse std::process::Command;use std::env;use std::path::Path;fn main() { let out_dir = env::var("OUT_DIR").unwrap(); // Note that there are a number of downsides to this approach, the comments // below detail how to improve the portability of these commands. Command::new("gcc").args(&["src/hello.c", "-c", "-fPIC", "-o"]) .arg(&format!("{}/hello.o", out_dir)) .status().unwrap(); Command::new("ar").args(&["crus", "libhello.a", "hello.o"]) .current_dir(&Path::new(&out_dir)) .status().unwrap(); println!("cargo::rustc-link-search=native={}", out_dir); println!("cargo::rustc-link-lib=static=hello"); println!("cargo::rerun-if-changed=src/hello.c");}
This build script starts out by compiling our C file into an object file (byinvokinggcc) and then converting this object file into a static library (byinvokingar). The final step is feedback to Cargo itself to say that ouroutput was inout_dir and the compiler should link the crate tolibhello.astatically via the-l static=hello flag.
Note that there are a number of drawbacks to this hard-coded approach:
- The
gcccommand itself is not portable across platforms. For example it’sunlikely that Windows platforms havegcc, and not even all Unix platformsmay havegcc. Thearcommand is also in a similar situation. - These commands do not take cross-compilation into account. If we’re crosscompiling for a platform such as Android it’s unlikely that
gccwill producean ARM executable.
Not to fear, though, this is where abuild-dependencies entry would help!The Cargo ecosystem has a number of packages to make this sort of task mucheasier, portable, and standardized. Let’s try thecccrate fromcrates.io. First, add it to thebuild-dependencies inCargo.toml:
[build-dependencies]cc = "1.0"And rewrite the build script to use this crate:
// build.rsfn main() { cc::Build::new() .file("src/hello.c") .compile("hello"); println!("cargo::rerun-if-changed=src/hello.c");}Thecc crate abstracts a range of build script requirements for C code:
- It invokes the appropriate compiler (MSVC for windows,
gccfor MinGW,ccfor Unix platforms, etc.). - It takes the
TARGETvariable into account by passing appropriate flags tothe compiler being used. - Other environment variables, such as
OPT_LEVEL,DEBUG, etc., are allhandled automatically. - The stdout output and
OUT_DIRlocations are also handled by thecclibrary.
Here we can start to see some of the major benefits of farming as muchfunctionality as possible out to common build dependencies rather thanduplicating logic across all build scripts!
Back to the case study though, let’s take a quick look at the contents of thesrc directory:
// src/hello.c#include <stdio.h>void hello() { printf("Hello, World!\n");}// src/main.rs// Note the lack of the `#[link]` attribute. We’re delegating the responsibility// of selecting what to link over to the build script rather than hard-coding// it in the source file.unsafe extern { fn hello(); }fn main() { unsafe { hello(); }}And there we go! This should complete our example of building some C code from aCargo package using the build script itself. This also shows why using a builddependency can be crucial in many situations and even much more concise!
We’ve also seen a brief example of how a build script can use a crate as adependency purely for the build process and not for the crate itself at runtime.
Linking to system libraries
This example demonstrates how to link a system library and how the buildscript is used to support this use case.
Quite frequently a Rust crate wants to link to a native library provided onthe system to bind its functionality or just use it as part of animplementation detail. This is quite a nuanced problem when it comes toperforming this in a platform-agnostic fashion. It is best, if possible, tofarm out as much of this as possible to make this as easy as possible forconsumers.
For this example, we will be creating a binding to the system’s zlib library.This is a library that is commonly found on most Unix-like systems thatprovides data compression. This is already wrapped up in thelibz-syscrate, but for this example, we’ll do an extremely simplified version. Checkoutthe source code for the full example.
To make it easy to find the location of the library, we will use thepkg-config crate. This crate uses the system’spkg-config utility todiscover information about a library. It will automatically tell Cargo what isneeded to link the library. This will likely only work on Unix-like systemswithpkg-config installed. Let’s start by setting up the manifest:
# Cargo.toml[package]name = "libz-sys"version = "0.1.0"edition = "2024"links = "z"[build-dependencies]pkg-config = "0.3.16"Take note that we included thelinks key in thepackage table. This tellsCargo that we are linking to thelibz library. See“Using another syscrate” for an example that will leverage this.
The build script is fairly simple:
// build.rsfn main() { pkg_config::Config::new().probe("zlib").unwrap(); println!("cargo::rerun-if-changed=build.rs");}Let’s round out the example with a basic FFI binding:
// src/lib.rsuse std::os::raw::{c_uint, c_ulong};unsafe extern "C" { pub fn crc32(crc: c_ulong, buf: *const u8, len: c_uint) -> c_ulong;}#[test]fn test_crc32() { let s = "hello"; unsafe { assert_eq!(crc32(0, s.as_ptr(), s.len() as c_uint), 0x3610a686); }}Runcargo build -vv to see the output from the build script. On a systemwithlibz already installed, it may look something like this:
[libz-sys 0.1.0] cargo::rustc-link-search=native=/usr/lib[libz-sys 0.1.0] cargo::rustc-link-lib=z[libz-sys 0.1.0] cargo::rerun-if-changed=build.rsNice!pkg-config did all the work of finding the library and telling Cargowhere it is.
It is not unusual for packages to include the source for the library, andbuild it statically if it is not found on the system, or if a feature orenvironment variable is set. For example, the reallibz-sys crate checks theenvironment variableLIBZ_SYS_STATIC or thestatic feature to build itfrom source instead of using the system library. Check outthesource for a more complete example.
Using anothersys crate
When using thelinks key, crates may set metadata that can be read by othercrates that depend on it. This provides a mechanism to communicate informationbetween crates. In this example, we’ll be creating a C library that makes useof zlib from the reallibz-sys crate.
If you have a C library that depends on zlib, you can leverage thelibz-syscrate to automatically find it or build it. This is great for cross-platformsupport, such as Windows where zlib is not usually installed.libz-syssetstheincludemetadatato tell other packages where to find the header files for zlib. Our buildscript can read that metadata with theDEP_Z_INCLUDE environment variable.Here’s an example:
# Cargo.toml[package]name = "zuser"version = "0.1.0"edition = "2024"[dependencies]libz-sys = "1.0.25"[build-dependencies]cc = "1.0.46"Here we have includedlibz-sys which will ensure that there is only onelibz used in the final library, and give us access to it from our buildscript:
// build.rsfn main() { let mut cfg = cc::Build::new(); cfg.file("src/zuser.c"); if let Some(include) = std::env::var_os("DEP_Z_INCLUDE") { cfg.include(include); } cfg.compile("zuser"); println!("cargo::rerun-if-changed=src/zuser.c");}Withlibz-sys doing all the heavy lifting, the C source code may now includethe zlib header, and it should find the header, even on systems where it isn’talready installed.
// src/zuser.c#include "zlib.h"// … rest of code that makes use of zlib.Conditional compilation
A build script may emitrustc-cfg instructions which can enable conditionsthat can be checked at compile time. In this example, we’ll take a look at howtheopenssl crate uses this to support multiple versions of the OpenSSLlibrary.
Theopenssl-sys crate implements building and linking the OpenSSL library.It supports multiple different implementations (like LibreSSL) and multipleversions. It makes use of thelinks key so that it may pass information toother build scripts. One of the things it passes is theversion_number key,which is the version of OpenSSL that was detected. The code in the buildscript looks somethinglikethis:
println!("cargo::metadata=version_number={openssl_version:x}");This instruction causes theDEP_OPENSSL_VERSION_NUMBER environment variableto be set in any crates that directly depend onopenssl-sys.
Theopenssl crate, which provides the higher-level interface, specifiesopenssl-sys as a dependency. Theopenssl build script can read theversion information generated by theopenssl-sys build script with theDEP_OPENSSL_VERSION_NUMBER environment variable. It uses this to generatesomecfgvalues:
// (portion of build.rs)println!("cargo::rustc-check-cfg=cfg(ossl101,ossl102)");println!("cargo::rustc-check-cfg=cfg(ossl110,ossl110g,ossl111)");if let Ok(version) = env::var("DEP_OPENSSL_VERSION_NUMBER") { let version = u64::from_str_radix(&version, 16).unwrap(); if version >= 0x1_00_01_00_0 { println!("cargo::rustc-cfg=ossl101"); } if version >= 0x1_00_02_00_0 { println!("cargo::rustc-cfg=ossl102"); } if version >= 0x1_01_00_00_0 { println!("cargo::rustc-cfg=ossl110"); } if version >= 0x1_01_00_07_0 { println!("cargo::rustc-cfg=ossl110g"); } if version >= 0x1_01_01_00_0 { println!("cargo::rustc-cfg=ossl111"); }}Thesecfg values can then be used with thecfg attribute or thecfgmacro to conditionally include code. For example, SHA3 support was added inOpenSSL 1.1.1, so it isconditionallyexcludedfor older versions:
// (portion of openssl crate)#[cfg(ossl111)]pub fn sha3_224() -> MessageDigest { unsafe { MessageDigest(ffi::EVP_sha3_224()) }}Of course, one should be careful when using this, since it makes the resultingbinary even more dependent on the build environment. In this example, if thebinary is distributed to another system, it may not have the exact same sharedlibraries, which could cause problems.
This list is not an endorsement. Evaluate your dependencies to see whichis right for your project.↩