The Cargo Guide
Runner and Linker Customization
Why Runner and Linker Customization Matters
Cargo can build for many platforms, but building successfully often depends on more than Rust source code alone. Some targets need a custom linker, some need a custom runner, and some need both. This is especially common in cross-compilation, embedded development, emulation workflows, and platform-specific deployment pipelines.
A useful mental model is:
- the linker determines how artifacts are produced for a target
- the runner determines how those artifacts are executed for commands like
cargo run,cargo test, orcargo bench
These are target-environment concerns, which is why Cargo config supports them directly.
The Main Place to Configure Runners and Linkers
Cargo's main configuration surface for runner and linker customization is .cargo/config.toml.
A small project layout might look like this:
my_project/
āāā .cargo/
ā āāā config.toml
āāā Cargo.toml
āāā src/
āāā main.rsA minimal target-specific config might look like this:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64"This keeps platform-specific execution and linking policy outside the package manifest, which is usually the right separation of concerns.
What a Linker Does
The linker is the tool that turns compiled object code into a final executable or library for the target platform.
Cargo can use a target-specific linker through configuration.
Example:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"A useful mental model is:
rustccompiles Rust code into lower-level artifacts- the linker performs the final platform-specific assembly into the target output
Without a suitable linker, cross-compilation often fails at the final stage even if normal Rust compilation succeeded.
What a Runner Does
A runner is the program Cargo uses to execute a built artifact for a target.
Example:
[target.aarch64-unknown-linux-gnu]
runner = "qemu-aarch64"This matters because some targets cannot be executed directly on the host machine. For example, a Linux x86_64 host cannot normally run an AArch64 Linux binary natively. A runner bridges that gap by invoking the right emulator, wrapper, or deployment tool.
Why Cargo Treats Runner Configuration as Target-Specific
Runner behavior depends on the target artifact, not only on the developer machine.
For example:
- one target may run natively with no wrapper
- another target may need QEMU
- an embedded target may need a flashing or probe tool instead of a local process launch
That is why Cargo's config model attaches runner settings to targets rather than to one global universal execution command.
A Simple Host-Native Example
Suppose you are building for the same platform as your host. You often do not need any runner or linker customization at all.
Example program:
fn main() {
println!("hello native world");
}Then:
cargo runusually works with the default linker and no explicit runner. Customization becomes more relevant once the target departs from the host or once the target has unusual linking requirements.
A Simple Cross-Compilation Example
Suppose you are on an x86_64 Linux host and want to build for AArch64 Linux.
.cargo/config.toml:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64"Then:
cargo build --target aarch64-unknown-linux-gnu
cargo run --target aarch64-unknown-linux-gnuThe first command uses the configured linker for the target. The second can use the configured runner to execute the foreign-architecture binary.
Linker Configuration by Exact Target Triple
The most common linker configuration pattern is a target table keyed by an exact target triple.
Example:
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"This keeps platform-specific toolchain choices explicit and readable.
Runner Configuration by Exact Target Triple
Runner configuration follows the same target-keyed pattern.
Example:
[target.aarch64-unknown-linux-gnu]
runner = "qemu-aarch64"
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F407VGTx"This illustrates that runners are not always emulators. In embedded workflows they may be probe tools, flashing tools, or custom wrappers.
Using cfg-Based Target Sections
Cargo config can also use cfg(...) expressions for target sections in some cases.
Example:
[target.'cfg(unix)']
rustflags = ["-Dwarnings"]There is historical support for target.'cfg(...)'.runner as well, which allows runner configuration for a class of matching targets rather than one exact triple.
This is useful when a policy should apply broadly across related target families rather than only one named target.
Default Build Target in Config
Cargo config can set a default target under [build].
Example:
[build]
target = "aarch64-unknown-linux-gnu"
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64"Then a plain command like:
cargo builduses that default target unless overridden.
This is convenient in dedicated target-specific environments, but it also increases the importance of understanding which target Cargo is actually building for.
Target-Specific Toolchain Configuration
Runner and linker settings often live alongside other target-specific toolchain settings such as rustflags.
Example:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64"
rustflags = ["-C", "target-feature=+crt-static"]A useful mental model is:
- linker chooses the final link tool
- runner chooses the execution wrapper
- rustflags tune compilation for that target
Why Toolchain Configuration Belongs in Cargo Config
These settings are usually environment- and target-specific rather than package-semantic. That is why they fit better in .cargo/config.toml than in Cargo.toml.
Package manifests describe what the package is. Cargo config describes how to build and run it in a particular environment.
Embedded Workflow Basics
Embedded Rust workflows often rely heavily on custom runners and sometimes custom linkers.
A typical embedded target cannot be run by simply launching a local process. Instead, the build artifact might need to be flashed to hardware or run through a probe tool.
Example:
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F407VGTx"This turns cargo run --target thumbv7em-none-eabihf into a hardware-oriented workflow rather than a host-local process launch.
Embedded Linker Thinking
Embedded targets may also need special linker behavior, custom linker scripts, or target-specific rustflags.
Example:
[target.thumbv7em-none-eabihf]
rustflags = ["-C", "link-arg=-Tlink.x"]
runner = "probe-rs run --chip STM32F407VGTx"This is a useful example of how linker customization is often partly expressed through both the chosen linker and target-specific compiler or linker arguments.
Invoking Under Emulators
One of the most common runner customizations is emulation.
Example:
[target.aarch64-unknown-linux-gnu]
runner = "qemu-aarch64"This makes Cargo's run-oriented commands use the emulator when trying to execute the built artifact. It is especially useful for cross-testing and basic validation on a host that cannot run the target binary directly.
Cross Testing and Runner Configuration
Runner configuration is especially important for cargo test on foreign targets.
Example:
cargo test --target aarch64-unknown-linux-gnuWithout a configured runner, Cargo may be unable to execute the produced test binaries. With a suitable runner in config, the same command can become a meaningful cross-test workflow.
Per-Target Environment Setup Through Config
Cargo config also supports an [env] section for setting environment variables, and build behavior can be combined with target-specific configuration.
Example .cargo/config.toml:
[env]
SDK_ROOT = "/opt/sdk"
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64"This is useful when a particular target toolchain or runner expects supporting environment variables to exist.
The [env] Section as a Build-Time Helper
Cargo config's [env] section can provide variables to build scripts, rustc, and commands like cargo run.
Example:
[env]
OPENSSL_DIR = "/opt/openssl"
TMPDIR = { value = "/tmp/cargo-tmp", force = true }This is not target-specific by itself, but it often participates in per-target workflows by providing shared toolchain or SDK paths.
A Small Cross-Aware Project
Suppose you create this project:
cargo new runner_demo
cd runner_demo
mkdir -p .cargoUse this config:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64"And this program:
fn main() {
println!("hello from target-aware run");
}Then commands like these become meaningful:
cargo build --target aarch64-unknown-linux-gnu
cargo run --target aarch64-unknown-linux-gnuWorkspace-Level Runner and Linker Policy
In a workspace, runner and linker config is often placed in a root .cargo/config.toml so all members share the same target policy.
Example layout:
workspace_demo/
āāā .cargo/
ā āāā config.toml
āāā Cargo.toml
āāā app/
āāā core/Root config:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64"This is typically cleaner than duplicating target toolchain policy across member-specific scripts or local instructions.
Why Current Directory Still Matters
Even when runner and linker behavior is centralized in root config, Cargo command scoping still depends on the selected manifest. Running from the workspace root or a member directory can change which package is selected by default.
This means runner and linker config and package-selection behavior are related but distinct concerns.
Host vs Target Confusion in Practice
A common sharp edge is assuming that all parts of the build use the same target configuration. But build scripts and proc macros are host-side artifacts, while the final crate may be target-side. Historically, Cargo's host-versus-target behavior around some configuration surfaces has been confusing enough that the unstable documentation calls it out explicitly.
A practical debugging rule is:
- if something strange happens in cross builds, inspect whether the problem belongs to host tools, target tools, or the boundary between them
A Small Debugging Build Script
One practical way to understand runner and linker-related builds is to log host and target in build.rs.
Example:
fn main() {
let host = std::env::var("HOST").unwrap();
let target = std::env::var("TARGET").unwrap();
println!("cargo::warning=host={host} target={target}");
}This is often the fastest way to make cross-build assumptions visible.
A Full Example Config
Here is a more complete .cargo/config.toml showing several relevant ideas together:
[build]
target = "aarch64-unknown-linux-gnu"
[env]
SDK_ROOT = "/opt/sdk"
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64"
rustflags = ["-C", "target-feature=+crt-static"]
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F407VGTx"
rustflags = ["-C", "link-arg=-Tlink.x"]Practical Commands to Try
Given configuration like the above, useful commands include:
cargo build
cargo build --target aarch64-unknown-linux-gnu
cargo run --target aarch64-unknown-linux-gnu
cargo test --target aarch64-unknown-linux-gnu
cargo run --target thumbv7em-none-eabihfThese show how linker and runner configuration turn Cargo from a host-native tool into a target-aware execution pipeline.
Common Beginner Mistakes
Mistake 1: confusing linkers and runners as if they solve the same problem.
Mistake 2: setting a linker but forgetting that foreign binaries still need a runner to execute on the host.
Mistake 3: hardcoding emulator or toolchain commands into shell scripts instead of putting stable target policy into .cargo/config.toml.
Mistake 4: assuming embedded targets behave like host-native targets.
Mistake 5: forgetting that build scripts run on the host even when the final crate is built for another target.
Mistake 6: using environment variables or config in ways that make the target workflow implicit and hard to reproduce.
Hands-On Exercise
Create a small project and add target-specific runner and linker config.
Start here:
cargo new runner_lab
cd runner_lab
mkdir -p .cargoCreate .cargo/config.toml:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64"Use this program:
fn main() {
println!("hello runner lab");
}Then try:
cargo build --target aarch64-unknown-linux-gnu
cargo run --target aarch64-unknown-linux-gnuThen add a build.rs that prints HOST and TARGET and repeat the build. This makes the distinction between host-side build logic and target-side execution concrete.
Mental Model Summary
A strong mental model for runner and linker customization in Cargo is:
- the linker determines how the target artifact is produced
- the runner determines how the target artifact is executed
- both are naturally target-specific and belong in
.cargo/config.toml - cross-compilation, embedded development, and emulation workflows depend heavily on these settings
- target-specific environment and rustflags often participate in the same toolchain configuration story
- host-side build logic and target-side artifact behavior are related but not the same thing
Once this model is stable, Cargo's runner and linker configuration becomes much easier to treat as a clean target policy layer instead of a collection of ad hoc command-line hacks.
