Skip to content

The Rust Engineering Handbook / Chapter 43

Profiles, Linkers, Cross-Compilation, and Reproducible Builds

Define product-specific compiler and linker policies, provision complete cross toolchains, and prove what entered a release artifact.

One revision of release-probe produces four materially different executables:

target/debug/release-probe
target/service/release-probe
target/cli/release-probe
target/firmware/release-probe

They share source and a lockfile. They do not share optimization, debug information, overflow policy, panic behavior, link-time optimization, code-generation partitioning, or symbol retention. Their byte sizes and hashes differ. That is not nondeterminism; it is evidence that a source revision is not an artifact identity.

A useful artifact identity names at least the source and resolved dependency graph, Rust toolchain, Cargo profile, target, linker and system libraries, declared build inputs, and relevant environment. The deployment contract then adds behavioral evidence: supported CPU and operating-system baseline, panic and overflow behavior, performance and size budgets, crash-symbol workflow, and provenance. “Built with --release” names only one coordinate.

Treat a profile as reviewed product policy, not as a synonym for fast or slow. The question is not which settings are strongest. It is which combination makes the service diagnosable, the CLI portable, or the firmware image fit while preserving the failure contract.

Read the rest of the build as four nested contracts: product behavior selects a profile; the target requires a complete toolchain; controlled inputs make comparison meaningful; artifact inspection proves what was actually produced. A hash is evidence only after the first three contracts are named.

Start with the product, then choose the profile

Cargo supplies dev, release, test, and bench profiles. Commands select them by default: ordinary build/check/run uses dev, tests use test, benchmarks use bench, and --release selects release. Only the workspace root’s manifest defines profiles; a dependency cannot quietly impose its own profile settings.

Custom profiles inherit from a built-in or another custom profile and are selected with --profile. The fixture defines three:

[profile.service]
inherits = "release"
opt-level = 3
debug = "line-tables-only"
overflow-checks = true
panic = "abort"
lto = "thin"
codegen-units = 1
strip = "debuginfo"

The name service carries no Cargo-defined semantics. Its meaning comes from repository policy and verification. This service policy keeps line-level information during compilation, enables checked arithmetic, chooses process termination on panic, and spends more build time on cross-crate optimization. Whether those are correct depends on the operator’s crash-symbol pipeline, supervisor behavior, latency evidence, and build budget.

Compare the intended contracts rather than copying the settings:

Decision Service CLI Firmware
Primary constraint throughput plus diagnosable failure portability, startup, useful diagnostics image size and defined reset/abort behavior
Optimization candidate 3, measured against 2 s or z, measured for size and speed often z, measured on device
Debug evidence line tables or separated symbols retained by release system enough for supportable crash reports map/ELF retained outside stripped image
Overflow explicit checked domain arithmetic; profile checks as defense normally preserve checks choose deliberately; never depend on wrapping by accident
Panic abort can simplify process failure unwind may permit top-level reporting, but panic is not routine error handling target often requires abort or a panic handler
LTO / CGUs thin LTO and fewer units after latency/build measurement thin/fat LTO after size measurement fat LTO and one unit are candidates, not axioms
Strip strip deployed file only after symbol retention is proven avoid destroying supportability strip distributable, archive full evidence

The matrix is a hypothesis register. Benchmark the relevant workload, inspect the artifact, test the failure path, and record the toolchain. Cargo’s own profile documentation warns that higher optimization can be slower and size levels need not produce smaller output. Re-evaluate when rustc, LLVM, the linker, or dependencies change.

Three product lanes compare service, CLI, and firmware profile controls, then pass the same reviewed source through Rust toolchain, target standard library and sysroot, linker and C runtime, and artifact-evidence layers. The target triple does not provide the external linker, sysroot, or C runtime.

Figure 43-1. Profile settings control compiler choices; a usable target toolchain and an accepted artifact require additional, separately provisioned layers.

Know what each compiler control can and cannot promise

opt-level selects an optimization regime, not a performance guarantee. Levels 0 through 3 trade compilation work against optimization opportunities; s and z prioritize size with different choices such as vectorization. Measure wall time, CPU, allocation, latency distribution, executable and resident size, and representative startup. A microbenchmark that removes I/O or data-dependent branches can select the wrong profile for a service.

debug controls generated debug information. line-tables-only can retain filename and line information with less data than full debugging; platform support and split-debug behavior vary. Keep unstripped or separate symbols in an access-controlled artifact store keyed by the exact deployed build. A stack address without the matching binary, build ID, and symbols is not diagnosable evidence.

strip = "debuginfo" removes debug sections while generally retaining more symbol-table material than "symbols". Stripping is a distribution and size choice, not obfuscation or security. Machine code remains inspectable, and aggressive stripping can destroy backtraces, profiling, and incident response.

overflow-checks governs built-in integer operations that would overflow. Enabled checks panic; disabled checks do not turn invalid domain arithmetic into a sound policy. Use checked_*, saturating_*, wrapping_*, or overflowing_* where the domain requires a precise result independent of profile. The fixture uses checked_add so its total is not secretly controlled by the profile:

fn checked_total(values: &[u32]) -> Option<u32> {
    values.iter().try_fold(0_u32, |sum, value| sum.checked_add(*value))
}

panic = "abort" terminates rather than unwinding. It may reduce binary size and removes cleanup by unwinding, but it changes failure operation: destructors on unwound frames do not run, in-process recovery boundaries disappear, and the supervisor becomes responsible for restart. Tests normally ignore the profile panic setting because the standard test harness requires unwinding. A green cargo test therefore does not prove the deployed abort path. Exercise the built binary under the actual supervisor and validate buffered telemetry, in-flight work, and restart behavior.

lto permits optimization across code-generation boundaries, at a link-time and memory cost. Thin LTO is often a useful candidate; fat LTO spends more. codegen-units divides a crate for parallel code generation: more units can compile faster but may give the optimizer less global visibility. One unit plus LTO is not free and may serialize expensive builds. Measure clean build time, incremental developer time, linker peak memory, artifact size, and runtime together.

Profile overrides can optimize selected dependencies or build dependencies, but generics complicate attribution: code may be monomorphized in the consuming crate and use the consumer’s settings. Overrides also cannot set every key—package and build overrides exclude panic, lto, and rpath. Inspect verbose rustc commands before assuming an override changed the hot code.

Do not confuse the test profile with the deployed failure model

The test profile inherits from dev by default, and the standard test harness normally requires unwinding even when the selected product profile says panic = "abort". Unit tests are still valuable, but they execute a different artifact contract. Close the gap with layers:

  1. test pure logic and fallible error paths under cargo test;
  2. build the exact product profile and run its CLI or protocol smoke tests;
  3. trigger a controlled panic in a disposable process and observe exit status, final telemetry, partial writes, and supervisor restart;
  4. symbolize a captured address or crash report against retained symbols;
  5. repeat on each target family whose runtime or loader differs.

The same separation applies to overflow. A debug test may panic because profile checks are enabled while a release artifact wraps. Domain-significant arithmetic needs explicit operations and boundary tests that behave the same in every profile. Profile checks are a secondary detection policy.

Configuration precedence also belongs in the evidence. Environment variables and Cargo configuration can override manifest profile values; RUSTFLAGS and encoded rustflags can add code-generation options. Release CI should either reject unauthorized overrides or record the effective values and verbose compiler commands. A reviewed Cargo.toml does not prove what a mutable runner passed to rustc.

A target triple names output semantics, not a complete toolchain

x86_64-unknown-linux-gnu, aarch64-unknown-linux-musl, and thumbv7em-none-eabihf describe target properties used by the compiler. The components conventionally encode architecture, vendor, operating system, and environment or ABI, but the exact interpretation is target-specific. A target also has a Rust support tier with defined project guarantees; “rustup can install it” and “the Rust project runs its tests” are different statements.

For a cross-build, inventory five layers:

  1. a host rustc and Cargo that can emit code for the target;
  2. the target’s core, alloc, or std libraries, when distributed;
  3. target-specific SDK headers, libraries, and sysroot;
  4. a linker or linker driver capable of producing the target format and ABI;
  5. a runtime, loader, C library, startup objects, and deployment environment where required.

Adding a Rust target component usually supplies Rust target libraries. It does not promise a vendor SDK, C runtime, target linker, emulator, signing tool, or device programmer. Chapter 42’s host/target rule continues to apply: the linker runs as a host process but consumes target objects and target libraries.

Cargo can select a linker by target in .cargo/config.toml:

[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

On many Unix-like targets, this value is a C compiler driver rather than the raw linker because the driver supplies startup objects, system-library paths, and platform conventions. Pin its package or image digest and capture --version. Record sysroot location, C library version, CPU baseline, linker flags, and whether dynamic libraries will exist on the destination. Passing -C linker= fixes selection; it does not make an unpinned binary reproducible.

Cross-compilation is not cross-testing. cargo check --target proves type checking and code generation far enough to create metadata; a successful link proves that the configured layers could form an artifact. Neither proves the binary runs on the oldest supported kernel, CPU, C library, device, or loader. Run on native hardware, a controlled VM/container where meaningful, or an emulator with its limits recorded. Verify dynamic loading, TLS and certificates, filesystem assumptions, endianness-sensitive parsing, signals, time, and panic/reset behavior relevant to the product.

The C-runtime choice is part of portability policy. A GNU target can inherit a glibc floor from the build sysroot; building on a newer distribution can silently require symbols unavailable on an older fleet. A musl target changes static/dynamic and resolver considerations but is not a universal “portable binary” switch. Apple and Windows targets carry SDK and deployment-target rules of their own. Inspect the dynamic requirements and run on the oldest supported environment instead of inferring compatibility from the target string.

Firmware makes the missing layers especially visible. A Rust target may provide core, yet the product still needs a memory layout, linker script, startup/reset path, panic handler, device-specific objects, image conversion, signing, flashing, and hardware tests. Keep the generic firmware profile separate from board configuration: profile settings answer optimization and failure questions; the board support package and target configuration answer memory and ABI questions.

Custom target JSON is an unstable interface whose fields may change and may require nightly for some workflows. Prefer built-in stable targets for production when they meet the contract. If a custom target is unavoidable, version its specification with the compiler, review its data-layout and ABI choices, and treat upgrading rustc as a toolchain migration.

Caches accelerate a build; they do not define it

Cargo’s target directory, incremental state, compiler caches, registry caches, container layers, and remote action caches can all reduce work. A cache key must include every input that can affect its value: compiler and wrapper versions, target, profile, flags, environment, source paths where relevant, generated inputs, linker, sysroot, and native libraries.

Two cache failures look opposite:

  • an incomplete key returns an artifact built under another contract;
  • an overbroad key misses constantly and hides input drift behind poor performance.

Treat remote cache contents as untrusted artifacts unless authenticated and integrity checked. Separate caches by trust domain; prevent unreviewed branches from poisoning release entries; record hit source and digest. Cache restoration should never be the only way to build an old revision.

For reproducibility testing, start cold. Disable incremental compilation, use clean checkout and target directories, and avoid compiler caches—or run a second comparison proving the cache is transparent. A warm rebuild that returns the same file may only prove that the same file was reused.

Determinism begins by closing every input lane

A repeatable command is not automatically a reproducible artifact. Use narrower terms:

  • repeatable build: the procedure can be run again;
  • deterministic step: controlled identical inputs make that step return identical output;
  • reproducible artifact: independent controlled rebuilds produce the equivalence claimed, often byte-for-byte;
  • verifiable artifact: consumers can connect the artifact to source, inputs, procedure, and attestations.

The strongest claim must name its comparison. Bit-for-bit equality is powerful, but some systems compare normalized archives or semantically relevant sections because signatures, paths, or packaging metadata are intentionally added later. Define allowed differences before seeing the result; otherwise normalization becomes a way to erase unexplained inputs.

Control or record:

  • source tree, submodules, generated inputs, dependency sources, and Cargo.lock;
  • Rust compiler, Cargo, standard libraries, LLVM, linker, archiver, SDK, sysroot, and native dependencies;
  • target, profile, features, RUSTFLAGS, Cargo configuration, and build-script environment;
  • paths, working directory, locale, timezone, timestamps, hostname, user name, random seeds, and file enumeration order when observable;
  • post-link steps, symbol separation, signing, compression, archive ordering, installers, and provenance metadata.

--locked rejects lockfile changes. --offline prevents Cargo network access. They do not pin the compiler, linker, system packages, environment, build-script behavior, or packaging. Chapter 42 closed build-script authority; this chapter closes compiler and linker authority around it.

Path remapping can remove build-root paths from debug information and macro expansions, but it is not a general reproducibility switch. Likewise, SOURCE_DATE_EPOCH is a convention honored only by participating tools. Verify the specific toolchain and inspect the resulting sections.

Separate compilation, packaging, and signing comparisons

Release pipelines often compare the wrong boundary. Split the procedure into named products:

compile/link -> full executable -> symbol separation/strip
             -> package/archive -> sign/attest -> distributable

Compare the full executable before signing to isolate compiler and linker behavior. Compare separated symbols and the stripped executable as a matched set. Build the archive with normalized member order, ownership, modes, and timestamps, then compare it independently. Finally apply a signature whose nonce or timestamp may intentionally differ and verify the signature over the previously identified payload.

This decomposition prevents two bad conclusions. A nondeterministic signature does not prove compilation is nondeterministic, and matching pre-signing binaries do not prove installers contain the same files or permissions. Give every stage an input manifest and output digest.

For the fixture, two clean CARGO_TARGET_DIR roots built the service profile to the same SHA-256 on the recorded host. That is useful observed evidence for this controlled source path and toolchain. It is deliberately narrower than a cross-host reproducibility claim: a stronger claim needs independently provisioned builders and comparison of their compiler, linker, system-library, path, and environment inputs.

Inspect the artifact as a structured result

Do not stop at sha256sum. First confirm that the artifact is the intended kind:

file target/service/release-probe
readelf -h -S -l -d target/service/release-probe
readelf -Ws target/service/release-probe
sha256sum target/service/release-probe

The ELF header identifies architecture, format, and entry point. Section and program headers reveal debug material, executable/loadable regions, interpreters, and hardening-relevant layout. The dynamic table and ldd-style inspection reveal runtime library expectations, though never execute an untrusted artifact merely to inspect it. Symbol tables show whether stripping matched policy. Platform equivalents include otool/dwarfdump on Apple systems, dumpbin on MSVC, and firmware-specific map, size, and object tools.

Capture Cargo’s verbose build to identify rustc and linker commands:

cargo +1.97.0 build -p release-probe --profile service --locked --offline -vv

Then rebuild the same profile in two clean, controlled roots. Compare hashes. If they differ, preserve both outputs and localize the first difference with section tables, build IDs, strings, archive-member metadata, debug paths, symbol order, and linker maps. Do not immediately strip more data; the difference is diagnostic evidence.

The fixture deliberately builds three profiles and expects their hashes to differ. Its reproducibility test is same profile against same profile under controlled independent roots—not service against CLI, and not cold output against a cached result whose provenance is unknown.

Failure patterns visible at the artifact boundary

One release profile for every binary. A workspace ships a daemon, migration CLI, and embedded helper under identical settings. Give each shipped class an explicit policy and evidence budget.

Optimization by folklore. opt-level = 3, fat LTO, and one CGU are assumed fastest. Benchmark the workload and include build latency and linker memory.

Symbols deleted before retention. The deployed binary is small, but an incident cannot be symbolized. Archive exact full symbols before stripping and test lookup by build identity.

Target component mistaken for SDK. Rust emits objects, then linking finds host libraries or fails. Provision and pin the target linker, sysroot, C runtime, and startup objects.

Host success mistaken for target support. An x86 test passes while an ARM artifact uses unsupported CPU instructions or an older glibc cannot load it. Run the declared platform matrix.

Cache hit mistaken for proof. CI returns the previous artifact. Rebuild cold in isolated roots and compare.

Hash difference discarded. A timestamp or path is blamed without localization. Preserve evidence, identify the producing tool and input, then decide whether to remove or normalize it.

Exercise: write three artifact contracts

Level: Design and experiment. Define release policies for a network service, a cross-platform CLI, and a constrained firmware image. For each one, deliver:

  1. supported targets, Rust support tier, CPU/OS/runtime baseline, and execution test environment;
  2. the complete host compiler, target libraries, linker, SDK/sysroot, C runtime, and post-link toolchain;
  3. profile settings for optimization, debug information, overflow checks, panic, LTO, codegen units, incremental compilation, and stripping;
  4. a reason tied to latency, throughput, startup, image size, crash diagnosis, update mechanism, or failure containment for every non-default choice;
  5. clean build time, peak linker memory, artifact and debug-symbol size, representative runtime measurements, and accepted regression thresholds;
  6. artifact inspection commands and expected file format, architecture, interpreter, dynamic libraries, symbols, and sections;
  7. cache key inputs, trust boundary, eviction policy, and a cold-build escape path;
  8. two-builder comparison procedure, claimed equivalence, known nondeterministic fields, and investigation method;
  9. storage and access policy for unstripped artifacts, maps, symbols, hashes, SBOM/provenance, and signing inputs;
  10. owner and review trigger for compiler, linker, target, dependency, or profile changes.

Reject any proposal whose only acceptance condition is “cargo build --release succeeds.” A successful design makes every deployed byte traceable to a product decision or a controlled toolchain input.

Make the artifact the end of a reviewed graph

Profiles express compiler policy. Target triples express target semantics. Neither supplies the whole cross toolchain, proves runtime support, controls every input, or establishes reproducibility. Build a complete stack: source and resolution, declared generators, pinned compiler, product-specific profile, target libraries, linker and runtime, cold comparison, structured inspection, and retained operational evidence.

Once an artifact is identifiable, the remaining question is organizational: how can that identity evolve without breaking downstream source, compiler, API, feature, or dependency contracts? Editions, MSRV, SemVer, and the release train provide those controls.

Sources and verification notes

  • Cargo Reference: Profiles and Configuration.
  • rustc book: Code generation options and Platform support.
  • Executable source: examples/rust-engineering-handbook/part-07/release-build-lab/, including three custom profiles, release-probe, artifact-inspection commands, and clean comparison guidance.
  • Profile selection, artifact layout, executable behavior, metadata, and MSRV are observed with Rust/Cargo 1.97.0 and 1.85.0 on x86_64-unknown-linux-gnu. Cross-target requirements are documented policy and an audit method; this fixture does not claim an untested cross-target artifact or cross-host reproducibility.