*-sys is a naming convention for crates that help Rust programs use C ("system") libraries, e.g. libz-sys, kernel32-sys, lcms2-sys. The task of the sys crates is expose a minimal low-level C interface to Rust (FFI) and to tell Cargo how to link with the library. Adding higher-level, more Rust-friendly interfaces for the libraries is left to "wrapper" crates built as a layer on top of the sys crates (e.g. a "rusty-image-app" may depend on high-level "png-rs", which depends on low-level "libpng-sys", which depends on "libz-sys").

Using C libraries in a portable way involves a bit of work: finding the library on the system or building it if it's not available, checking if it is compatible, finding C headers and converting them to Rust modules, and giving Cargo correct linking instructions. Often every step of this is tricky, because operating systems, package managers and libraries have their unique quirks that need special handling.

Fortunately, all this work can be done once in a build script, and published as a <insert library name>-sys Rust crate. This way other Rust programmers will be able to use the C library without having to re-invent the build script themselves.

To make a sys crate:

  1. Read the Cargo build script documentation.
  2. Create a new Cargo crate cargo new --lib <library name here>-sys
  3. In Cargo.toml add links = <library name>. This informs Cargo that this crate links with the given C library, and Cargo will ensure that only one copy of the library is linked. Use names without any prefix/suffix (e.g. florp, not libflorp.so). Note that links is only informational and it does not actually link to anything.
  4. Create build.rs file in the root of the project (or specify build = "<path to build.rs>" in Cargo.toml).

For the build.rs script the general approach is:

  1. Find the library.
  2. Select static or dynamic linking.
  3. Optionally build the library from source.
  4. Expose C headers.

Things that sys crates should NOT do

Don't write any files outside Cargo's dedicated output directory (OUT_DIR). Specifically, do not try to install any packages on the system. If the required library or other dependency is missing: report an error, or cargo:warning and fall back to something else.

Avoid downloading anything. There are packaging and deployment tools that require builds to run off-line in isolated containers. If you want to build the library from source, bundle the source in the Cargo crate.

Cargo build process is supposed to be entirely self-contained and work off-line.

Finding the library

Finally, support overriding library location with <LIBRARY_NAME>_PATH or <LIBRARY_NAME>_LIB_DIR environmental variable (example). This is necessary in some cases, such as cross-compilation and custom builds of the library (e.g. with custom features enabled, or if the library installed in /lib is 6 years old).

Once you know the directory, tell Cargo to use it by printing cargo:rustc-link-search=native=<dir>. Helper crates like pkg-config may do this for you.

Select static or dynamic linking

You select how to link the library by printing cargo:rustc-link-lib=<name> or cargo:rustc-link-lib=static=<name>. Because most users won't configure your crate (it's likely to be a dependency of a dependency of a dependency…), you must have good, safe defaults:

Note that pkg-config has a .statik() option, but it often doesn't do anything. You may need to verify it (Linux: ldd, macOS: otool -L, Windows: dumpbin /dependents) and work around it.

As for the configuration itself, there are two options, both somewhat flawed:

Cargo features

In Cargo.toml you can have [features] section with static and dynamic options.

[features]
static = []
dynamic = []

Don't put any of them as Cargo's default feature, because it's too hard to unset defaults in Cargo.

Pro: The features are easy to set by other crates. It's possible to configure the build entirely via Cargo.toml.

Con: Cargo features can only be set, and never unset. Once any crate anywhere makes the choice, it won't be possible to override it. Also Cargo doesn't support mutually-exclusive features, so your build.rs script will have to deal with both static and dynamic set at the same time.

Environmental variables

You can check <LIBRARY_NAME>_STATIC environmental variable to see if the library should be linked statically.

Pro: Top-level project can easily override linking, even if the sys crate is a deeply nested dependency.

Con: Cargo doesn't help managing env vars, so the proper build will require extra scripts/tools besides Cargo.

Ideally, you can support both, with env vars taking precedence over features. This allows convenient features in simple cases, and env var as a fallback for cases where features fail.

Build from source

If the library is unlikely to be installed on the system by default, especially when you support Windows, it's nice to automatically build it from source (and link statically).

It's a massive usability improvement for users, because they can always run cargo build and get something working, instead of getting errors, searching for packages, installing dependencies, setting up search paths, etc.

Downloading of the source is tricky, so it's best to avoid it. The build script would need to depend on libraries for HTTP + TLS + unarchving, which itself may be bigger and more problematic to build than just bundling sources with the sys crate. Some users require builds to work off-line. So unless the library's sources are really huge, just copy them to the Rust crate (and make sure to use a compatible license for your sys crate).

To avoid having a duplicate copy of someone else's source code in your crate's git repository, you can add the C sources as a git submodule. Publishing to crates.io will copy it as a regular directory, so your crate's users won't even know it was a submodule.

git submodule add https://example/third-party.git vendor/

During development use cargo build -vv to see output from the build script. You can print cargo:warning=… to make messages user-visible. Include name of your crate in warnings and errors, because your crate is likely to end up buried several layers deep in someone else's project.

For building you have two options:

Use the original build system of the library

You assume that the required build system (such as make, autotools, cmake) is already installed, and run it. There are crates like cmake and make-cmd that help with this a little bit.

You may need to translate Cargo's environmental variables into appropriate options for the build system (e.g. libgit2, libcurl) to control output directories, optimization level, debug symbols, and enable -fPIC (Rust always wants -fPIC).

Autotools (./configure) supports "out-of-tree builds". Make it use a subdirectory of OUT_DIR, so that cargo clean will also clean the temp files of the C build.

Pro: You stick to the documented way of building the library and don't peek inside.

Con: If the build fails, the extra indirection makes it even harder to diagnose and fix. Very likely to be painful on Windows.

Replace the build system using cc

Replacing the build system seems like a terrible idea inviting a lot of maintenance work. However, for a lot of libraries all the complexity of the build system exists only to handle differences between operating systems and broken compilers (which the cc crate handles) and searching for the library's own dependencies (which other *-sys crates can do for you).

In practice it often turns out that just giving the list of .c files to the cc crate, with one or two define()s, is enough to build the code properly! Try running make --dry-run VERBOSE=1 to see the files and macros needed.

If you need to make a config.h file for the library, don't modify the source directory. Instead, write the config header to the OUT_DIR and set the out dir in include path first.

Pro: The cc crate handles integration with Cargo, even for cross-compilation. It also handles things you'd rather not do, like reading Windows Registry to find a working copy of Visual Studio.

Con: It's easy for small/medium and mature projects, but may be daunting for big and fast-moving projects.

What about closed-source libraries?

Unfortunately, Cargo crates (and crates.io) are not suited for distribution of binaries. Use another package manager (e.g. apt/RPM, chocolatey) to distribute a pre-compiled shared library (cdylib) and in the sys crate expect it to be pre-installed.

Customization

C libraries often control enabling/disabling features via #define FOO_SUPPORTED. It's a good idea to translate this into Cargo features. If the C library has some features enabled by default, set Cargo's default features the same way.

[features]
default = ["foo"]
foo = []
bar = []
if cfg!(feature = "foo") {
    cc.define("FOO_SUPPORTED", Some("1"));
}
if cfg!(feature = "bar") {
    cc.define("BAR_SUPPORTED", Some("1"));
}

Header files

There are two places where you may need to expose C library's header (.h) files:

  1. To C code in other -sys crates (optional).
  2. To Rust code using your sys crate.

The first case is simpler. Make sure you have links in your Cargo.toml:

[package]
name = "foobar-sys"
links = "foobar"

Print cargo:include=/path/to/include with the directory where the library's own .h files are (cargo:include is not a special name, you can use any cargo:<name>=<value> to provide more information). Use join_paths/split_paths to list multiple directories.

println!("cargo:include={}", absolute_path_to_include_dir.display());

If you need to make a relative path into absolute, use dunce::canonicalize(), because fs::canonicalize() is unusable.

In your crate's documentation instruct others to read DEP_<your links name>_INCLUDE env variable if they need the headers (e.g. libzlibpng):

cc.include(env::var_os("DEP_FOOBAR_INCLUDE").expect("Oops, DEP_FOOBAR_INCLUDE should have been set"));

Bindgen

For Rust you will need to translate C headers into a Rust module containing extern "C" {} declarations. This can be done automatically with bindgen, but there are some things to consider.

Bindgen has an option to translate C enum to Rust enum. It's nice to have enums, but Rust has extra requirements: the enum must contain only valid values at all times. That's a guarantee in safe Rust. If the C side violates it, it will "poison" Rust code and crash it. If definition says enum Foo {A=1, B=2}, but C somewhere returns (enum Foo)3, it can't be a Rust enum.

Macros, inline functions, C++

If C headers use inline functions, you can use Citrus to translate function bodies. Macros containing code and C++ templates need to be translated by hand (e.g. macrofn) or wrapped in C functions in your crate and compiled to a private static library. Bindgen supports a subset of C++, but you may need to write a C wrapper for C++ classes (example).

Stable ABI?

There's a question whether you run bindgen once and ship that file, or whether you run bindgen every time the project is build. It depends on how incompatible different versions of the C library may be.

If the C library has a stable and portable ABI: new versions only add new functions and everything is backwards compatible, then you can pre-generate. It will be faster to build (no dependency on bindgen and clang), and you can even tweak the built file manually. Make sure it works for both 32 and 64-bit architectures (usually #[repr(C)] just works, but you'll need to disable generation of bindgen's layout tests, because they're architecture-specific).

If there are different, incompatible versions of the library in the wild, then you need to use bindgen as a Rust library and run it from your build.rs to generate fresh bindings for every user. The generated file must be included somewhere in your crate's lib.rs. Use include!(concat!(env!("OUT_DIR"),"/filename.rs"));.

Different major versions

If differences between major versions of the C library are small (e.g. only new functions added, or just a couple of struct fields changed), then you could try to automatically adapt to the version or use Cargo features to enable the new features (e.g. mozjpeg-sys supports different ABI versions, clang-sys has features for LLVM versions).

If versions of the C library are totally different and incompatible, then either have separate crates (foo1-sys & foo2-sys) or at least use different major versions of your sys crate for different major versions of the C library, so that Cargo will know that they're not compatible.

Cross-compilation

Rust can build executables and libraries for systems other than the one it's running on, e.g. build Linux programs on macOS, or build 32-bit libraries on a 64-bit OS.

Your build.rs program may be running on a different architecture than the one being compiled. This means that all your size_of checks and #[cfg]/cfg!() macros in build.rs may be for a wrong machine! For querying target system or CPU use CARGO_CFG_TARGET_OS/CARGO_CFG_TARGET_ARCH/CARGO_CFG_TARGET_POINTER_WIDTH env vars instead (run rustc --print cfg for a full list). The only exception are cfg(feature = "…") checks, which are safe to use in cross-compilation.

pkg-config will automatically bail out when it detects cross-compilation (when env var HOST != TARGET). If you're searching for libraries on disk in other ways, also keep in mind that the host system may not be compatible with the target being built.

Linking surprises

Write tests in your sys crate's lib.rs to reference as many C symbols as you can. Linkers often work "lazily" and won't notice any problems with the library unless it's actually used from Rust.

In external tests (in tests/ directory) and other crates make sure to include extern crate <your lib>_sys;. The C library won't be linked unless it's used via extern crate, even if it's set as a dependency in Cargo.toml!

Documentation

Have a good README (and the readme key in Cargo.toml) with clearly stated requirements and configuration options (especially env vars).

However, don't bother documenting individual FFI functions in Rust. Sys crates by definition don't change behavior of the C library and don't add anything that isn't already in the C version, so for function-specific information send users to the original C documentation (e.g. libc intentionally doesn't document any function). If you want to make the library easier to use, it's better to spend effort on making a second crate with a higher-level interface.

Bus factor 1

Nobody can be expected to support their crate 24/7 — forever. From time to time crate authors become unavailable, but their crates need an update (e.g. urgent security or compatibility fixes). It's a huge pain for users.

When you publish your crate on crates.io, consider adding someone else as a co-owner of your crate. There's "Manage owners" link on crate's page, or you can add your GitHub team. If you can't think of anyone, add me (kornelski).


Thanks to Michael Bryan, Mark Summerfield and other rustaceans for their feedback.