bela-sys 0.8.0

Raw FFI bindings to the Bela core API (libbela) for Bela Gem
Documentation
//! Emits the `libbela` link flags for device targets, and compiles the
//! MIDI shim when the sysroot carries what it is written against.

use std::path::{Path, PathBuf};
use std::{env, fs};

// Shared with the crate's tests: a build script is not a target
// `cargo test` builds, so the string work lives in a file both can
// include. See shim_compiler.rs.
include!("shim_compiler.rs");
include!("link_args.rs");

// Library locations captured on the board; see docs/board-facts.md.
const LIB_DIRS: &[&str] = &[
    "/root/Bela/lib",
    "/usr/evl/lib/aarch64-linux-gnu",
    "/usr/local/lib",
    "/usr/lib/aarch64-linux-gnu",
];

// `libbela.so` is C++ and pulls in the EVL real-time runtime and the
// seasocks web server. Rust does not link a C++ runtime by itself, and
// the transitive dependencies are not resolved automatically when
// cross-linking, so name them explicitly.
//
// `libbelaextra.so` holds the higher-level classes, which is where
// `Midi` is; its own dependencies (`libasound.so.2`, `libNE10.so.10`)
// resolve through the search paths above.
//
// It comes before `bela` because it needs it and does not say so:
// `readelf -d libbelaextra.so` lists no `libbela.so`, while its
// `RtThread`, `SchedulableTask` and `IoUtils` symbols are defined
// there. rustc links with `--as-needed`, so a `libbela` named earlier
// than the library that needs it is dropped as unused and then the
// link fails on those symbols.
// `asound` is for the shim itself, which opens a port non-blocking to
// find out whether it is free — see shim/midi.cpp. It resolves in the
// multiarch directory, and `libbelaextra` needs it anyway.
//
// `NE10` is the FFT: src/ne10.rs calls it directly rather than through
// Bela's `Fft` class (docs/fft.md). `libbelaextra` already depends on
// it, so this adds no library to the board, only a name to the link
// line — and order does not matter for it the way it does for the two
// Bela libraries, since nothing depends on it without saying so.
const LIBS: &[&str] = &[
    "belaextra",
    "bela",
    "asound",
    "seasocks",
    "evl",
    "stdc++",
    "NE10",
];

// The C surface this crate compiles over Bela's `Midi` class. See
// shim/midi.h for what it exports and docs/midi.md for why.
const SHIM_SOURCES: &[&str] = &["shim/midi.cpp", "shim/midi.h"];

// Compile-time assertions that the board's NE10 headers still describe
// what src/ne10.rs declares. Compiled, never linked against for its
// symbols: it defines none, and building it is the check.
const ABI_SOURCE: &str = "abi/ne10_abi.c";

// The NE10 headers abi/ne10_abi.c asserts against: its whole include
// closure other than the C library's own, which is also what
// `vendor/ne10` mirrors. Both are watched, and the first also says
// whether NE10 is there to check against at all.
//
// Watching all of them is the point rather than tidiness. The
// typedefs, the struct layout and the field types the assertions are
// mostly about live in NE10_types.h, so a sysroot refresh that touched
// only that file would leave cargo with no reason to rerun this script
// — and the archive compiled against the old header would be reused,
// with the drift the assertions exist to catch going unremarked until
// something unrelated forced a rebuild.
//
// Both spellings of a build reach them through the same strings: a
// cross build prefixes the sysroot, and a native build on the board
// leaves the prefix empty, which is where the board keeps them.
const ABI_HEADERS: &[&str] = &[
    "/usr/include/ne10/NE10_dsp.h",
    "/usr/include/ne10/NE10_types.h",
];

// What the shim includes, relative to the sysroot. The first is where
// `Bela.h` and the real-time headers are; the second is what makes
// `<libraries/Midi/Midi.h>` resolve, the same way the board's own
// Makefile puts `/root/Bela` on the include path.
const SHIM_INCLUDE_DIRS: &[&str] = &["/root/Bela/include", "/root/Bela"];

// Every spelling cc accepts for the archiver, in its own order of
// preference (cc-1.4.0, `env_tool`).
const AR_ENV: &[&str] = &[
    "AR_aarch64-unknown-linux-gnu",
    "AR_aarch64_unknown_linux_gnu",
    "TARGET_AR",
    "AR",
];

// The header that says whether a sysroot is one the shim can be built
// against. `scripts/sync-sysroot.sh` has carried it since the commit
// that added `/root/Bela/libraries`; sysroots synced before that have
// `include` and not this.
const SHIM_PROBE: &str = "/root/Bela/libraries/Midi/Midi.h";

fn main() {
    println!("cargo::rerun-if-changed=build.rs");
    for source in SHIM_SOURCES {
        println!("cargo::rerun-if-changed={source}");
    }
    println!("cargo::rerun-if-env-changed=BELA_SYSROOT");
    // Named for the same reason as BELA_CC below: it chooses a
    // compiler, and changing it has to rebuild what that compiler made.
    println!("cargo::rerun-if-env-changed=BELA_CXX");
    // What cc reads for the archiver. Named here so that setting one
    // rebuilds the shim, and read in build_shim so that setting one
    // still wins over what this script would pick.
    for name in AR_ENV {
        println!("cargo::rerun-if-env-changed={name}");
    }
    // Nothing here reads BELA_CC — scripts/aarch64-bela-linker.sh
    // does, and cargo cannot see into a linker it was handed as a path.
    // Declaring it makes changing the compiler rebuild this crate, and
    // so relink whatever links it, instead of leaving a binary built by
    // the previous one in place.
    println!("cargo::rerun-if-env-changed=BELA_CC");
    // Read in build_shim to derive the shim's own compiler from the
    // linker Cargo resolved, so a `.cargo/config.toml` linker change
    // rebuilds the shim rather than leaving it built by a mismatched
    // toolchain.
    println!("cargo::rerun-if-env-changed=RUSTC_LINKER");

    let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
    let os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
    if !(arch == "aarch64" && os == "linux") {
        return;
    }

    // Cross builds set BELA_SYSROOT to a copy of the board's
    // filesystem (see docs/cross-compile.md); native builds on the
    // board itself leave it unset and use the absolute paths.
    let sysroot = env::var("BELA_SYSROOT").unwrap_or_default();
    for dir in LIB_DIRS {
        println!("cargo::rustc-link-search=native={sysroot}{dir}");
    }
    if let Some(dir) = gcc_lib_dir(&sysroot) {
        println!("cargo::rustc-link-search=native={}", dir.display());
    }
    // Ahead of the libraries below on purpose: the shim is a static
    // archive calling into `libbelaextra`, and a static archive has to
    // reach the linker before whatever resolves it.
    build_shim(&sysroot);
    check_ne10_abi(&sysroot);
    for lib in LIBS {
        println!("cargo::rustc-link-lib=dylib={lib}");
    }
    propagate_link_args(&sysroot);
}

// Publishes the sysroot-specific arguments a device link needs —
// the same three `scripts/aarch64-bela-linker.sh` adds — as `links`
// metadata, so a dependent's build script can turn them into
// `cargo::rustc-link-arg` for its own targets without going through
// that wrapper. See docs/cross-compile.md.
//
// `links` metadata reaches only immediate dependents (`bela`, here),
// which is why `bela` in turn republishes what it reads under its own
// `links` name for its dependents to reach; see bela/build.rs.
fn propagate_link_args(sysroot: &str) {
    if sysroot.is_empty() {
        // Native build on the board itself: nothing sysroot-specific
        // to add, so nothing is published and a dependent applies no
        // extra link arguments either.
        return;
    }
    let rpath_link = LIB_DIRS
        .iter()
        .map(|dir| format!("{sysroot}{dir}"))
        .collect::<Vec<_>>()
        .join(":");
    let args = vec![
        format!("--sysroot={sysroot}"),
        format!("-B{sysroot}/usr/lib/aarch64-linux-gnu"),
        format!("-Wl,-rpath-link={rpath_link}"),
    ];
    for (key, value) in encode_link_args(&args) {
        println!("cargo::metadata={key}={value}");
    }
}

// Compiles the MIDI shim, when the sysroot carries the sources it is
// written against.
//
// Skipping is the right answer rather than an error, because a build
// without a sysroot is a normal thing to run: `cargo check` and
// `cargo clippy` for the device target never link, and that is what CI
// does, having no board to sync one from. A build that does link
// without a sysroot fails either way — at `-lbela` if not here.
#[allow(
    clippy::panic,
    reason = "a build script reports a misconfiguration by failing the build"
)]
fn build_shim(sysroot: &str) {
    let probe = format!("{sysroot}{SHIM_PROBE}");
    // Declared whether or not it is there. A missing path re-runs this
    // script on every build, which is what makes the warning below
    // recoverable: syncing a sysroot into a path that was already
    // named by BELA_SYSROOT changes no file cargo would otherwise be
    // watching, and the shim would stay uncompiled with nothing said.
    println!("cargo::rerun-if-changed={probe}");
    if !Path::new(&probe).exists() {
        let where_ = if sysroot.is_empty() {
            "BELA_SYSROOT is unset and this is not a board".to_owned()
        } else {
            format!("{sysroot}{SHIM_PROBE} is missing")
        };
        println!(
            "cargo::warning=MIDI shim not compiled ({where_}); \
             linking a device binary will fail on bela_midi_* until \
             scripts/sync-sysroot.sh has run"
        );
        return;
    }

    let compiler = match shim_compiler_from(
        &env::var("BELA_CXX").unwrap_or_default(),
        &env::var("RUSTC_LINKER").unwrap_or_default(),
        &env::var("BELA_CC").unwrap_or_default(),
    ) {
        Ok(compiler) => compiler,
        Err(message) => panic!("{message}"),
    };
    let mut build = cc::Build::new();
    build
        .cpp(true)
        // What Bela compiles its own C++ with (docs/board-facts.md).
        // The shim allocates a `Midi`, so it has to agree with
        // `libbelaextra.so` about that class's layout; measured equal
        // between this toolchain and the board's clang++, and pinning
        // the standard is one fewer way for that to drift.
        .std("c++14")
        .file("shim/midi.cpp")
        .compiler(&compiler);
    // cc resolves the archiver from the target triple rather than from
    // the compiler, so it has to be told; an AR already in the
    // environment is left to win, as it would without this.
    if !AR_ENV.iter().any(|name| env::var_os(name).is_some()) {
        if let Some(archiver) = shim_archiver(&compiler) {
            build.archiver(archiver);
        }
    }
    for dir in SHIM_INCLUDE_DIRS {
        build.include(format!("{sysroot}{dir}"));
    }
    if !sysroot.is_empty() {
        build.flag(format!("--sysroot={sysroot}"));
        // Debian keeps its architecture-specific headers here, and a
        // toolchain built for a different triple —
        // aarch64-unknown-linux-gnu against Debian's
        // aarch64-linux-gnu — does not look for them on its own. Same
        // reason scripts/aarch64-bela-linker.sh passes -B.
        build.include(format!("{sysroot}/usr/include/aarch64-linux-gnu"));
    }
    build.compile("bela_midi_shim");
}

// Compiles the NE10 ABI assertions, when NE10's headers are there to
// assert against.
//
// src/ne10.rs is written by hand, so no build regenerates it when a
// board image moves NE10: a changed typedef or parameter type would
// link, run and go wrong. `cargo xtask check-vendor --board` catches a
// changed header by diffing it, and needs a board; this catches the
// same drift wherever the headers are, which includes CI with a synced
// sysroot and a native build on the board itself.
//
// Skipped rather than fatal when there is nothing to check against —
// the same reasoning as the shim, and for the same builds: `cargo
// check` and `clippy` for the device target never link and CI has no
// sysroot to sync. Quieter than the shim, though: a missing shim
// breaks a link with an unhelpful message, so it warns, while a
// missing ABI check costs nothing until an image changes.
fn check_ne10_abi(sysroot: &str) {
    println!("cargo::rerun-if-changed={ABI_SOURCE}");
    let headers: Vec<String> = ABI_HEADERS
        .iter()
        .map(|header| format!("{sysroot}{header}"))
        .collect();
    for header in &headers {
        println!("cargo::rerun-if-changed={header}");
    }
    // Every one of them has to be there: a partial sysroot would fail
    // the compile below on a missing include, where nothing to check
    // against is meant to skip.
    if !headers.iter().all(|header| Path::new(header).exists()) {
        return;
    }

    let Some(compiler) = abi_compiler_from(
        &env::var("BELA_CC").unwrap_or_default(),
        &env::var("RUSTC_LINKER").unwrap_or_default(),
        &env::var("BELA_CXX").unwrap_or_default(),
    ) else {
        println!(
            "cargo::warning=NE10 ABI assertions not compiled: no C compiler follows from \
             BELA_CXX; set BELA_CC to the matching C compiler. src/ne10.rs is unchecked \
             against this sysroot's headers"
        );
        return;
    };

    let mut build = cc::Build::new();
    build
        .file(ABI_SOURCE)
        // Pinned for the same reason the shim pins C++14, and with
        // more riding on it: `_Static_assert` and `_Alignof` are C11,
        // so a toolchain defaulting to gnu89 would fail the build
        // rather than skip the check — which is the opposite of what
        // this is meant to cost. (`__builtin_types_compatible_p` and
        // `__typeof__` are GNU extensions, which `gnu11` keeps.)
        .std("gnu11")
        .compiler(&compiler);
    if !AR_ENV.iter().any(|name| env::var_os(name).is_some()) {
        if let Some(archiver) = abi_archiver(&compiler) {
            build.archiver(archiver);
        }
    }
    if !sysroot.is_empty() {
        build.flag(format!("--sysroot={sysroot}"));
        // Same as the shim: Debian's architecture-specific headers,
        // which NE10's includes of the C library reach through.
        build.include(format!("{sysroot}/usr/include/aarch64-linux-gnu"));
    }
    // The archive holds one object defining nothing. Compiling it is
    // the point; linking it is how the compile gets run.
    build.compile("bela_ne10_abi");
}

// Debian ships the `libstdc++.so` linker symlink under a
// gcc-version-specific directory rather than the multiarch one.
fn gcc_lib_dir(sysroot: &str) -> Option<PathBuf> {
    let base = PathBuf::from(format!("{sysroot}/usr/lib/gcc/aarch64-linux-gnu"));
    fs::read_dir(base)
        .ok()?
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| path.join("libstdc++.so").exists())
        // Highest version directory wins if several are installed.
        .max()
}