Rust 工程实践:超越 cargo build 🟢
讲师简介
- Microsoft SCHIE(Silicon and Cloud Hardware Infrastructure Engineering)团队首席固件架构师
- 在安全、系统编程(固件、操作系统、虚拟机监控器)、CPU 与平台架构以及 C++ 系统方面经验丰富
- 2017 年在 AWS EC2 开始使用 Rust,此后长期深度投入
这是一本聚焦 Rust 工具链实践的实用指南,覆盖许多团队往往接触得太晚的关键能力:构建脚本、交叉编译、基准测试、代码覆盖率,以及借助 Miri 和 Valgrind 做安全验证。每章都基于真实硬件诊断代码库中的具体示例展开,该代码库是一个大型多 crate 工作区,因此书中的每项技巧都能直接映射到生产代码。
如何使用本书
本书适合 自定节奏学习或团队工作坊。各章大体独立,你既可以按顺序阅读,也可以直接跳到当前最需要的主题。
难度说明
| 标记 | 等级 | 含义 |
|---|---|---|
| 🟢 | 入门 | 规则清晰、上手直接,第一天就能用到 |
| 🟡 | 中级 | 需要理解工具链内部机制或平台概念 |
| 🔴 | 进阶 | 涉及更深的工具链知识、nightly 特性或多工具协同 |
学习节奏建议
| 部分 | 章节 | 预计时间 | 关键收获 |
|---|---|---|---|
| I — 构建与交付 | 第 01–02 章 | 3–4 小时 | 构建元数据、交叉编译、静态二进制 |
| II — 度量与验证 | 第 03–05 章 | 4–5 小时 | 统计型基准测试、覆盖率门禁、Miri 与 Sanitizer |
| III — 加固与优化 | 第 06–10 章 | 6–8 小时 | 供应链安全、发布配置、编译期工具、no_std 与 Windows |
| IV — 集成 | 第 11–13 章 | 3–4 小时 | 生产级 CI/CD 流水线、一线实践技巧与综合练习 |
| 16–21 小时 | 完整生产工程流水线视角 |
练习建议
每章都包含带难度标记的 🏋️ 练习。答案放在可展开的 <details> 区块中,建议先做题,再核对答案。
- 🟢 练习通常可在 10–15 分钟内完成
- 🟡 练习通常需要 20–30 分钟,并可能需要本地运行工具
- 🔴 练习通常需要较多环境准备与实验时间(1 小时以上)
前置知识要求
| 概念 | 建议学习位置 |
|---|---|
| Cargo 工作区结构 | Rust Book ch14.3 |
| Feature 标志 | Cargo Reference — Features |
#[cfg(test)] 与基础测试 | Rust Patterns 第 12 章 |
unsafe 代码块与 FFI 基础 | Rust Patterns 第 10 章 |
章节依赖图
┌─────────────────┐
│ 第 00 章 │
│ 简介 (Intro) │
└────┬─────┬───┬──┘
┌─────┬───┬──┴──┬───┴───┴──┬──────┬──────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼
ch01 ch03 ch04 ch05 ch06 ch09 ch10
构建 基准 覆盖率 Miri 依赖 no_std Windows
│ │ │ │ │ │ │
│ └────┴────┘ │ ▼ │
│ │ │ ch10 │
▼ ▼ ▼ Windows │
ch02 ch07 ch07 │ │
交叉编译 发布配置 发布配置 │ │
│ │ │ │ │
│ ▼ │ │ │
│ ch08 │ │ │
│ 编译工具 │ │ │
└──────────┴───────────────┴─────┴───────┘
│
▼
ch11
生产级 CI/CD 流水线
│
▼
ch12 ──── ch13
实践技巧 速查卡
可任意顺序阅读:第 01、03、04、05、06、09 章相互独立。
建议在具备前置知识后阅读:第 02 章(依赖第 01 章),第 07–08 章(先学第 03–06 章效果更好),第 10 章(最好先看第 09 章)。
建议最后阅读:第 11 章(综合收束全书)、第 12 章(技巧汇总)、第 13 章(参考速查)。
带说明的内容目录
第一部分 — 构建与交付
| # | 章节 | 难度 | 说明 |
|---|---|---|---|
| 1 | 第 01 章:构建脚本 — 深入理解 build.rs | 🟢 | 编译期常量、编译 C 代码、protobuf 生成、系统库链接与反模式 |
| 2 | 第 02 章:交叉编译 — 一份源码,多种目标 | 🟡 | 目标三元组、musl 静态二进制、ARM 交叉编译、cross、cargo-zigbuild 与 GitHub Actions |
第二部分 — 度量与验证
| # | 章节 | 难度 | 说明 |
|---|---|---|---|
| 3 | 第 03 章:基准测试 — 衡量真正重要的指标 | 🟡 | Criterion.rs、Divan、perf 火焰图、PGO 与 CI 中的持续基准测试 |
| 4 | 第 04 章:代码覆盖率 — 发现测试遗漏 | 🟢 | cargo-llvm-cov、cargo-tarpaulin、grcov 与 Codecov/Coveralls 集成 |
| 5 | 第 05 章:Miri、Valgrind 与 Sanitizer | 🔴 | MIR 解释器、Valgrind memcheck/Helgrind、ASan/MSan/TSan、cargo-fuzz 与 loom |
第三部分 — 加固与优化
| # | 章节 | 难度 | 说明 |
|---|---|---|---|
| 6 | 第 06 章:依赖管理与供应链安全 | 🟢 | cargo-audit、cargo-deny、cargo-vet、cargo-outdated 与 cargo-semver-checks |
| 7 | 第 07 章:发布配置与二进制体积 | 🟡 | 发布配置结构、LTO 权衡、cargo-bloat 与 cargo-udeps |
| 8 | 第 08 章:编译期与开发者工具 | 🟡 | sccache、mold、cargo-nextest、cargo-expand、cargo-geiger、工作区 lint 与 MSRV |
| 9 | 第 09 章:no_std 与特性验证 | 🔴 | cargo-hack、core/alloc/std 分层、自定义 panic handler 与 no_std 代码测试 |
| 10 | 第 10 章:Windows 与条件编译 | 🟡 | #[cfg] 模式、windows-sys/windows crate、cargo-xwin 与平台抽象 |
第四部分 — 集成
| # | 章节 | 难度 | 说明 |
|---|---|---|---|
| 11 | 第 11 章:综合实战 — 生产级 CI/CD 流水线 | 🟡 | GitHub Actions 工作流、cargo-make、pre-commit hook、cargo-dist 与综合实战 |
| 12 | 第 12 章:一线实践技巧 | 🟡 | 10 个经验证的实战模式:deny(warnings) 陷阱、缓存调优、依赖去重、RUSTFLAGS 等 |
| 13 | 第 13 章:速查卡 | - | 命令速览、60+ 条决策表条目以及延伸阅读链接 |
构建脚本 — 深入理解 build.rs 🟢
你将学到:
build.rs如何融入 Cargo 构建流水线及其运行机制- 五种生产实践模式:编译期常量、C/C++ 编译、protobuf 代码生成、
pkg-config链接和特性检测- 会拖慢构建或破坏交叉编译的反模式
- 如何权衡可追溯性与可复现构建 (Reproducible Builds)
相关章节: 交叉编译 使用构建脚本实现目标平台感知构建 ·
no_std与特性验证 扩展了此处设置的cfg标志 · CI/CD 流水线 在自动化中编排构建脚本
每个 Cargo 包都可以在 crate 根目录下包含一个名为 build.rs 的文件。
Cargo 会在编译你的 crate 之前 编译并执行该文件。构建脚本通过 stdout 上的 println! 指令与 Cargo 进行通信。
什么是 build.rs 以及它何时运行
┌─────────────────────────────────────────────────────────┐
│ Cargo 构建流水线 │
│ │
│ 1. 解析依赖 │
│ 2. 下载 crate │
│ 3. 编译 build.rs ← 普通 Rust 代码,在 HOST(宿机)运行 │
│ 4. 执行 build.rs ← stdout → Cargo 指令 │
│ 5. 编译 crate(使用步骤 4 中的指令) │
│ 6. 链接 │
└─────────────────────────────────────────────────────────┘
关键事实:
build.rs在 宿主 (Host) 机器上运行,而不是在目标 (Target) 机器上。在交叉编译期间,构建脚本在你的开发机上运行,即使最终二进制文件针对的是不同的架构。- 构建脚本的作用范围仅限于其所属的包。它无法影响其他 crate 的编译方式 —— 除非该包在
Cargo.toml中声明了links键,这允许通过cargo::metadata=KEY=VALUE向下游 crate 传递元数据。 - 只要 Cargo 检测到变更,它就会 每次 运行 —— 除非你发出
cargo::rerun-if-changed指令来限制重新运行。
注意 (Rust 1.71+):自 Rust 1.71 起,Cargo 会对编译后的
build.rs二进制文件进行指纹识别 —— 如果二进制文件完全相同,即使源代码时间戳改变了,它也不会重新运行。然而,cargo::rerun-if-changed=build.rs仍然很有价值:如果没有 任何rerun-if-changed指令,Cargo 会在 包内的任何文件 发生变化时重新运行build.rs(而不仅仅是build.rs发生变化)。发出cargo::rerun-if-changed=build.rs可以将重新运行限制在仅当build.rs本身发生变化时 —— 这在大型 crate 中能显著节省编译时间。
- 它可以发出 cfg 标志、环境变量、链接器参数 以及主 crate 消费的 文件路径。
最简 Cargo.toml 配置项:
[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"
build = "build.rs" # 默认值 —— Cargo 会自动寻找 build.rs
# build = "src/build.rs" # 或者将其放在其他位置
Cargo 指令协议
构建脚本通过在标准输出打印指令来与 Cargo 通信。自 Rust 1.77 起,首选前缀是 cargo::(取代了旧的单冒号 cargo: 形式)。
| 指令 | 用途 |
|---|---|
cargo::rerun-if-changed=PATH | 仅当 PATH 变更时重新运行 build.rs |
cargo::rerun-if-env-changed=VAR | 仅当环境变量 VAR 变更时重新运行 |
cargo::rustc-link-lib=NAME | 链接原生库 NAME |
cargo::rustc-link-search=PATH | 向库搜索路径添加 PATH |
cargo::rustc-cfg=KEY | 为条件编译设置 #[cfg(KEY)] 标志 |
cargo::rustc-cfg=KEY="VALUE" | 设置 #[cfg(KEY = "VALUE")] 标志 |
cargo::rustc-env=KEY=VALUE | 设置可通过 env!() 访问的环境变量 |
cargo::rustc-cdylib-link-arg=FLAG | 为 cdylib 目标向链接器传递 FLAG |
cargo::warning=MESSAGE | 在编译期间显示警告 |
cargo::metadata=KEY=VALUE | 存储可由下游 crate 读取的元数据 |
// build.rs — 极简示例
fn main() {
// 仅在 build.rs 本身变化时重新运行
println!("cargo::rerun-if-changed=build.rs");
// 设置编译期环境变量
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".into());
println!("cargo::rustc-env=BUILD_TIMESTAMP={timestamp}");
}
模式 1:编译期常量
最常见的用例:将构建元数据写入二进制文件,以便在运行时报告(git 哈希、构建日期、CI 任务 ID)。
// build.rs
use std::process::Command;
fn main() {
println!("cargo::rerun-if-changed=.git/HEAD");
println!("cargo::rerun-if-changed=.git/refs");
// Git commit hash
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.expect("git not found");
let git_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!("cargo::rustc-env=GIT_HASH={git_hash}");
// 构建配置 (debug 或 release)
let profile = std::env::var("PROFILE").unwrap_or_else(|_| "unknown".into());
println!("cargo::rustc-env=BUILD_PROFILE={profile}");
// 目标三元组 (Target triple)
let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".into());
println!("cargo::rustc-env=BUILD_TARGET={target}");
}
#![allow(unused)]
fn main() {
// src/main.rs — 消费构建期的值
fn print_version() {
println!(
"{} {} (git:{} target:{} profile:{})",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
env!("GIT_HASH"),
env!("BUILD_TARGET"),
env!("BUILD_PROFILE"),
);
}
}
内置 Cargo 环境变量(免费获得,无需 build.rs):
CARGO_PKG_NAME、CARGO_PKG_VERSION、CARGO_PKG_AUTHORS、CARGO_PKG_DESCRIPTION、CARGO_MANIFEST_DIR。 查看 完整列表。
模式 2:使用 cc crate 编译 C/C++ 代码
当你的 Rust crate 封装了 C 库或需要小型 C 辅助程序(在硬件接口中很常见)时,cc crate 简化了 inside build.rs 的编译工作。
# Cargo.toml
[build-dependencies]
cc = "1.0"
// build.rs
fn main() {
println!("cargo::rerun-if-changed=csrc/");
cc::Build::new()
.file("csrc/ipmi_raw.c")
.file("csrc/smbios_parser.c")
.include("csrc/include")
.flag("-Wall")
.flag("-Wextra")
.opt_level(2)
.compile("diag_helpers");
// 这将产生 libdiag_helpers.a 并发出正确的
// cargo::rustc-link-lib 和 cargo::rustc-link-search 指令。
}
#![allow(unused)]
fn main() {
// src/lib.rs — 编译后的 C 代码的 FFI 绑定
extern "C" {
fn ipmi_raw_command(
netfn: u8,
cmd: u8,
data: *const u8,
data_len: usize,
response: *mut u8,
response_len: *mut usize,
) -> i32;
}
/// 封装了原始 IPMI 命令接口的安全包装。
/// 假设:enum IpmiError { CommandFailed(i32), ... }
pub fn send_ipmi_command(netfn: u8, cmd: u8, data: &[u8]) -> Result<Vec<u8>, IpmiError> {
let mut response = vec![0u8; 256];
let mut response_len: usize = response.len();
// SAFETY: 响应缓冲区足够大,且 response_len 已正确初始化。
let rc = unsafe {
ipmi_raw_command(
netfn,
cmd,
data.as_ptr(),
data.len(),
response.as_mut_ptr(),
&mut response_len,
)
};
if rc != 0 {
return Err(IpmiError::CommandFailed(rc));
}
response.truncate(response_len);
Ok(response)
}
}
对于 C++ 代码,使用 .cpp(true) 和 .flag("-std=c++17"):
// build.rs — C++ 变体
fn main() {
println!("cargo::rerun-if-changed=cppsrc/");
cc::Build::new()
.cpp(true)
.file("cppsrc/vendor_parser.cpp")
.flag("-std=c++17")
.flag("-fno-exceptions") // 匹配 Rust 的无异常模型
.compile("vendor_helpers");
}
模式 3:Protocol Buffers 与代码生成
构建脚本非常擅长代码生成 —— 在编译时将 .proto、.fbs 或 .json 等模式文件转换为 Rust 源码。以下是使用 prost-build 的 protobuf 模式:
# Cargo.toml
[build-dependencies]
prost-build = "0.13"
// build.rs
fn main() {
println!("cargo::rerun-if-changed=proto/");
prost_build::compile_protos(
&["proto/diagnostics.proto", "proto/telemetry.proto"],
&["proto/"],
)
.expect("Failed to compile protobuf definitions");
}
#![allow(unused)]
fn main() {
// src/lib.rs — 包含生成的代码
pub mod diagnostics {
include!(concat!(env!("OUT_DIR"), "/diagnostics.rs"));
}
pub mod telemetry {
include!(concat!(env!("OUT_DIR"), "/telemetry.rs"));
}
}
OUT_DIR是 Cargo 提供的目录,构建脚本应在此放置生成的文件。每个 crate 都在target/下拥有自己的OUT_DIR。
模式 4:使用 pkg-config 链接系统库
对于提供 .pc 文件的系统库(如 systemd、OpenSSL、libpci),pkg-config crate 会探测系统并发出正确的链接指令:
# Cargo.toml
[build-dependencies]
pkg-config = "0.3"
// build.rs
fn main() {
// 探测 libpci(用于 PCIe 设备枚举)
pkg_config::Config::new()
.atleast_version("3.6.0")
.probe("libpci")
.expect("libpci >= 3.6.0 not found — install pciutils-dev");
// 探测 libsystemd(可选 — 用于 sd_notify 集成)
if pkg_config::probe_library("libsystemd").is_ok() {
println!("cargo::rustc-cfg=has_systemd");
}
}
#![allow(unused)]
fn main() {
// src/lib.rs — 基于 pkg-config 探测结果的条件编译
#[cfg(has_systemd)]
mod systemd_notify {
extern "C" {
fn sd_notify(unset_environment: i32, state: *const std::ffi::c_char) -> i32;
}
pub fn notify_ready() {
let state = std::ffi::CString::new("READY=1").unwrap();
// SAFETY: state 是一个有效的以 null 结尾的 C 字符串。
unsafe { sd_notify(0, state.as_ptr()) };
}
}
#[cfg(not(has_systemd))]
mod systemd_notify {
pub fn notify_ready() {
// 在没有 systemd 的系统上不执行任何操作
}
}
}
模式 5:特性检测与条件编译
构建脚本可以探测编译环境并设置 cfg 标志,供主 crate 用于条件代码路径。
CPU 架构和操作系统检测(安全 —— 这些是编译期常量):
// build.rs — 检测 CPU 特性和操作系统能力
fn main() {
println!("cargo::rerun-if-changed=build.rs");
let target = std::env::var("TARGET").unwrap();
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
// 在 x86_64 上启用 AVX2 优化路径
if target.starts_with("x86_64") {
println!("cargo::rustc-cfg=has_x86_64");
}
// 在 aarch64 上启用 ARM NEON 路径
if target.starts_with("aarch64") {
println!("cargo::rustc-cfg=has_aarch64");
}
// 检测 /dev/ipmi0 是否可用(编译期检查)
if target_os == "linux" && std::path::Path::new("/dev/ipmi0").exists() {
println!("cargo::rustc-cfg=has_ipmi_device");
}
}
⚠️ 反模式演示 —— 下面的代码显示了一种诱人但有问题的做法。请勿在生产环境中使用。
// build.rs — 坏习惯:在构建时进行运行时硬件探测
fn main() {
// 反模式:二进制文件与构建机器的硬件绑定了。
// 如果你在带 GPU 的机器上构建并部署到不带 GPU 的机器,
// 二进制文件会默认为存在 GPU。
if std::process::Command::new("accel-query")
.arg("--query-gpu=name")
.arg("--format=csv,noheader")
.output()
.is_ok()
{
println!("cargo::rustc-cfg=has_accel_device");
}
}
#![allow(unused)]
fn main() {
// src/gpu.rs — 基于构建期检测进行适配的代码
pub fn query_gpu_info() -> GpuResult {
#[cfg(has_accel_device)]
{
run_accel_query()
}
#[cfg(not(has_accel_device))]
{
GpuResult::NotAvailable("accel-query not found at build time".into())
}
}
}
⚠️ 为什么这是错的:对于可选硬件,运行时设备检测几乎总是优于构建时检测。上面产生的二进制文件会 与构建机器的硬件配置绑定 —— 它在部署目标上的行为可能会有所不同。仅对那些在编译时确实固定总结的能力(架构、操作系统、库的可用性)使用构建时检测。对于像 GPU 这样的硬件,应使用
which accel-query或accel-mgmt探测在运行时进行探测。
反模式与坑点
| 反模式 | 危害 | 修正 |
|---|---|---|
缺少 rerun-if-changed | build.rs 在 每次 构建时都会运行,拖慢迭代速度 | 始终至少发出 cargo::rerun-if-changed=build.rs |
| 在 build.rs 中发起网络请求 | 离线构建失败,不可复现 | 使用 Vendor 文件或单独的 fetch 步骤 |
写入 src/ 目录 | Cargo 不期望源码在构建期间改变 | 写入 OUT_DIR 并使用 include!() |
| 重型计算 | 拖慢每次 cargo build | 将结果缓存至 OUT_DIR,并使用 rerun-if-changed 进行门控 |
| 忽略交叉编译 | 直接使用 Command::new("gcc") 而不尊重 $CC | 使用能正确处理交叉编译工具链的 cc crate |
| 无上下文的 panic | unwrap() 会给出模糊的 “build script failed” 错误 | 使用 .expect("描述性消息") 或打印 cargo::warning= |
应用:嵌入构建元数据
该项目目前使用 env!("CARGO_PKG_VERSION")进行版本报告。构建脚本可以通过更丰富的元数据来扩展这一点:
// build.rs — 建议的添加项
fn main() {
println!("cargo::rerun-if-changed=.git/HEAD");
println!("cargo::rerun-if-changed=.git/refs");
println!("cargo::rerun-if-changed=build.rs");
// 嵌入 git 哈希以便在诊断报告中进行溯源
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--short=10", "HEAD"])
.output()
{
let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!("cargo::rustc-env=APP_GIT_HASH={hash}");
} else {
println!("cargo::rustc-env=APP_GIT_HASH=unknown");
}
// 嵌入构建时间戳以便进行报告关联
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".into());
println!("cargo::rustc-env=APP_BUILD_EPOCH={timestamp}");
// 输出目标三元组 — 在多架构部署中很有用
let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".into());
println!("cargo::rustc-env=APP_TARGET={target}");
}
#![allow(unused)]
fn main() {
// src/version.rs — 消费元数据
pub struct BuildInfo {
pub version: &'static str,
pub git_hash: &'static str,
pub build_epoch: &'static str,
pub target: &'static str,
}
pub const BUILD_INFO: BuildInfo = BuildInfo {
version: env!("CARGO_PKG_VERSION"),
git_hash: env!("APP_GIT_HASH"),
build_epoch: env!("APP_BUILD_EPOCH"),
target: env!("APP_TARGET"),
};
impl BuildInfo {
/// 必要时在运行时解析 epoch(在 stable Rust 上,目前还无法实现 const &str → u64 的转换 —
/// 还没有用于字符串转整数的 const fn)。
pub fn build_epoch_secs(&self) -> u64 {
self.build_epoch.parse().unwrap_or(0)
}
}
impl std::fmt::Display for BuildInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"DiagTool v{} (git:{} target:{})",
self.version, self.git_hash, self.target
)
}
}
}
来自项目的关键洞察:整个代码库中几乎没有任何
build.rs文件,因为它采用了纯 Rust 开发,没有 C 依赖、没有代码生成,也没有系统库链接。当你确实需要这些功能时,build.rs是不二之选 —— 但不要“为了加而加”。在大型代码库中,没有构建脚本往往是一个特性,而非缺失。请参阅 依赖管理 了解该项目如何在没有自定义构建逻辑的情况下管理其供应链,这正是架构简洁的有力信号。
亲自尝试
-
嵌入 Git 元数据:创建一个
build.rs,将APP_GIT_HASH和APP_BUILD_EPOCH作为环境变量输出。在main.rs中使用env!()获取并打印。验证提交代码后哈希值是否发生变化。 -
探测系统库:编写一个
build.rs,使用pkg-config探测libz(zlib)。如果找到,则发出cargo::rustc-cfg=has_zlib指令。在main.rs中根据该标志条件打印 “zlib available” 或 “zlib not found”。基于该 cfg 标志。 -
人为触发构建失败:从你的
build.rs中移除rerun-if-changed行,观察在cargo build和cargo test期间它运行了多少次。然后将其加回,对比差异。
可复现构建 (Reproducible Builds)
第 1 章介绍了如何将时间戳和 Git 哈希嵌入二进制文件。虽然这对溯源很有用,但它会 与可复现构建相冲突 —— 即相同的源代码应当始终产生完全相同的二进制文件。
矛盾点:
| 目标 | 成效 | 代价 |
|---|---|---|
| 可溯源性 | 二进制文件包含 APP_BUILD_EPOCH | 每次构建都是唯一的 — 无法验证完整性 |
| 可复现性 | cargo build --locked 始终产生相同输出 | 缺少构建时的元数据 |
实际解决方案:
# 1. 在 CI 中始终使用 --locked(确保遵循 Cargo.lock)
cargo build --release --locked
# 如果 Cargo.lock 缺失或过时则会失败 — 从而捕获 "在我的机器上能跑" 的问题
# 2. 对于对复现性要求极高的构建,设置 SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) cargo build --release --locked
# 使用最后一次提交的时间戳而非 "现在" — 相同的提交 = 相同的二进制文件
#![allow(unused)]
fn main() {
// 在 build.rs 中:遵循 SOURCE_DATE_EPOCH 以保证复现性
let timestamp = std::env::var("SOURCE_DATE_EPOCH")
.unwrap_or_else(|_| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".into())
});
println!("cargo::rustc-env=APP_BUILD_EPOCH={timestamp}");
}
最佳实践:在构建脚本中使用
SOURCE_DATE_EPOCH,这样发布构建就是可复现的(git-hash + 锁定的依赖 + 确定的时间戳 = 相同的二进制文件),而开发构建依然可以享受实时时间戳带来的便利。
构建流水线决策图
flowchart TD
START["需要编译期工作吗?"] -->|不需要| SKIP["无需 build.rs"]
START -->|需要| WHAT{"哪种类型?"}
WHAT -->|"嵌入元数据"| P1["模式 1\n编译期常量"]
WHAT -->|"编译 C/C++"| P2["模式 2\ncc crate"]
WHAT -->|"代码 generation"| P3["模式 3\nprost-build / tonic-build"]
WHAT -->|"链接系统库"| P4["模式 4\npkg-config"]
WHAT -->|"检测特性"| P5["模式 5\ncfg 标志"]
P1 --> RERUN["始终发出\ncargo::rerun-if-changed"]
P2 --> RERUN
P3 --> RERUN
P4 --> RERUN
P5 --> RERUN
style SKIP fill:#91e5a3,color:#000
style RERUN fill:#ffd43b,color:#000
style P1 fill:#e3f2fd,color:#000
style P2 fill:#e3f2fd,color:#000
style P3 fill:#e3f2fd,color:#000
style P4 fill:#e3f2fd,color:#000
style P5 fill:#e3f2fd,color:#000
🏋️ 练习
🟢 练习 1:版本标记
创建一个包含 build.rs 的最小 crate,将当前 Git 哈希和构建配置 (profile) 嵌入环境变量。在 main() 中打印它们。验证在 debug 和 release 构建之间输出是否发生改变。
答案
// build.rs
fn main() {
println!("cargo::rerun-if-changed=.git/HEAD");
println!("cargo::rerun-if-changed=build.rs");
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "unknown".into());
println!("cargo::rustc-env=GIT_HASH={hash}");
println!("cargo::rustc-env=BUILD_PROFILE={}", std::env::var("PROFILE").unwrap_or_default());
}
// src/main.rs
fn main() {
println!("{} v{} (git:{} profile:{})",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
env!("GIT_HASH"),
env!("BUILD_PROFILE"),
);
}
cargo run # 显示 profile:debug
cargo run --release # 显示 profile:release
🟡 练习 2:条件系统库
编写一个 build.rs,使用 pkg-config 同时探测 libz 和 libpci。为找到的每个库分别发出核心 cfg 标志。在 main.rs 中,打印构建时检测到了哪些库。
答案
# Cargo.toml
[build-dependencies]
pkg-config = "0.3"
// build.rs
fn main() {
println!("cargo::rerun-if-changed=build.rs");
if pkg_config::probe_library("zlib").is_ok() {
println!("cargo::rustc-cfg=has_zlib");
}
if pkg_config::probe_library("libpci").is_ok() {
println!("cargo::rustc-cfg=has_libpci");
}
}
// src/main.rs
fn main() {
#[cfg(has_zlib)]
println!("✅ 检测到 zlib");
#[cfg(not(has_zlib))]
println!("❌ 未找到 zlib");
#[cfg(has_libpci)]
println!("✅ 检测到 libpci");
#[cfg(not(has_libpci))]
println!("❌ 未找到 libpci");
}
关键收获
build.rs在编译时运行在 宿主机 上 —— 务必发出cargo::rerun-if-changed以避免不必要的重新构建。- 使用
cccrate(而非原始gcc命令)进行 C/C++ 编译 —— 它能正确处理交叉编译工具链。 - 将生成的文件写入
OUT_DIR,永远不要写入src/—— Cargo 不希望源码在构建期间发生改变。 - 对于可选硬件,运行时探测优于构建时探测。
- 嵌入时间戳时使用
SOURCE_DATE_EPOCH以保证构建的可复现性。
交叉编译 — 一份源码,多种目标 🟡
你将学到:
- Rust 目标三元组 (Target Triples) 的工作原理以及如何使用
rustup添加它们- 为容器/云端部署构建静态 musl 二进制文件
- 使用原生工具链、
cross和cargo-zigbuild交叉编译到 ARM (aarch64)- 为多架构 CI 设置 GitHub Actions 矩阵构建
相关章节: 构建脚本 — 交叉编译期间 build.rs 在 HOST 上运行 · 发布配置 — 交叉编译发布版二进制文件的 LTO 和 strip 设置 · Windows — Windows 交叉编译与
no_std目标
交叉编译是指在一台机器(宿主机)上构建可在另一台机器(目标机)上运行的可执行文件。宿主机可能是你的 x86_64 笔记本电脑;目标机可能是 ARM 服务器、基于 musl 的容器,甚至是 Windows 机器。
Rust 使这变得非常可行,因为 rustc 本身就是一个交叉编译器 —— 它只需要正确的目标库和兼容的链接器。
目标三元组详解
每个 Rust 编译目标都由一个 目标三元组 (Target Triple) 标识(尽管叫三元组,但通常包含四个部分):
<架构>-<厂商>-<操作系统>-<环境>
示例:
x86_64 - unknown - linux - gnu ← 标准 Linux (glibc)
x86_64 - unknown - linux - musl ← 静态 Linux (musl libc)
aarch64 - unknown - linux - gnu ← ARM 64 位 Linux
x86_64 - pc - windows- msvc ← 带有 MSVC 的 Windows
aarch64 - apple - darwin ← 搭载 Apple Silicon 的 macOS
x86_64 - unknown - none ← 裸机 (无 OS)
列出所有可用目标:
# 显示 rustc 可以编译到的所有目标(约 250 个)
rustc --print target-list | wc -l
# 显示系统中已安装的目标
rustup target list --installed
# 显示当前的默认目标
rustc -vV | grep host
使用 rustup 安装工具链
# 添加目标库(该目标的 Rust 标准库)
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-gnu
# 现在你可以进行交叉编译了:
cargo build --target x86_64-unknown-linux-musl
cargo build --target aarch64-unknown-linux-gnu # 需要链接器 —— 见下文
rustup target add 为你提供了什么:该目标的预编译 std、core 和 alloc 库。它 不 提供 C 链接器或 C 库。对于需要 C 工具链的目标(大多数 gnu 目标),你需要单独安装。
# Ubuntu/Debian — 安装 aarch64 的交叉链接器
sudo apt install gcc-aarch64-linux-gnu
# Ubuntu/Debian — 安装用于静态构建的 musl 工具链
sudo apt install musl-tools
# Fedora
sudo dnf install gcc-aarch64-linux-gnu
.cargo/config.toml — 针对目标的配置
不必在每个命令中都传递 --target,可以在项目根目录或主目录的 .cargo/config.toml 中配置默认值:
# .cargo/config.toml
# 此项目的默认目标(可选 — 省略则保持原生默认值)
# [build]
# target = "x86_64-unknown-linux-musl"
# aarch64 交叉编译的链接器
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
rustflags = ["-C", "target-feature=+crc"]
# musl 静态构建的链接器(通常系统级 gcc 即可胜任)
[target.x86_64-unknown-linux-musl]
linker = "musl-gcc"
rustflags = ["-C", "target-feature=+crc,+aes"]
# ARM 32 位 (Raspberry Pi, 嵌入式)
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
# 适用于所有目标的环境变量
[env]
# 示例:设置自定义 sysroot
# SYSROOT = "/opt/cross/sysroot"
配置文件搜索顺序(匹配即停止):
<项目>/.cargo/config.toml<项目>/../.cargo/config.toml(逐级向上查找父目录)$CARGO_HOME/config.toml(通常是~/.cargo/config.toml)
使用 musl 构建静态二进制文件
为了部署到极简容器(Alpine、scratch Docker 镜像)或无法控制 glibc 版本的系统,请使用 musl 进行构建:
# 安装 musl 目标
rustup target add x86_64-unknown-linux-musl
sudo apt install musl-tools # 提供 musl-gcc
# 构建完全静态的二进制文件
cargo build --release --target x86_64-unknown-linux-musl
# 验证其是否为静态链接
file target/x86_64-unknown-linux-musl/release/diag_tool
# → ELF 64-bit LSB executable, x86-64, statically linked
ldd target/x86_64-unknown-linux-musl/release/diag_tool
# → not a dynamic executable
静态与动态的权衡:
| 维度 | glibc (动态) | musl (静态) |
|---|---|---|
| 二进制体积 | 较小 (共享库) | 较大 (增加约 5-15 MB) |
| 可移植性 | 需要匹配 glibc 版本 | 在任何 Linux 上均可运行 |
| DNS 解析 | 完整的 nsswitch 支持 | 基础解析器 (不支持 mDNS) |
| 部署 | 需要 sysroot 或容器 | 单一二进制文件,无依赖 |
| 性能 | malloc 稍快 | malloc 稍慢 |
dlopen() 支持 | 支持 | 不支持 |
针对本项目:静态 musl 构建是部署到各种服务器硬件的理想选择,因为你无法保证宿主机的操作系统版本。单一二进制文件的部署模型消除了“在我的机器上能跑”的问题。
交叉编译到 ARM (aarch64)
ARM 服务器(AWS Graviton、Ampere Altra、Grace)在数据中心变得越来越普遍。在 x86_64 宿主机上为 aarch64 进行交叉编译:
# 第 1 步:安装目标 + 交叉链接器
rustup target add aarch64-unknown-linux-gnu
sudo apt install gcc-aarch64-linux-gnu
# 第 2 步:在 .cargo/config.toml 中配置链接器(见上文)
# 第 3 步:构建
cargo build --release --target aarch64-unknown-linux-gnu
# 第 4 步:验证二进制文件
file target/aarch64-unknown-linux-gnu/release/diag_tool
# → ELF 64-bit LSB executable, ARM aarch64
运行目标架构的测试需要:
- 一台真实的 ARM 机器
- 或者 QEMU 用户模式模拟
# 安装 QEMU 用户模式(在 x86_64 上运行 ARM 二进制文件)
sudo apt install qemu-user qemu-user-static binfmt-support
# 现在 cargo test 可以通过 QEMU 运行交叉编译的测试
cargo test --target aarch64-unknown-linux-gnu
# (速度较慢 —— 每个测试二进制文件都是模拟运行的。适用于 CI 验证,不建议日常开发使用。)
在 .cargo/config.toml 中将 QEMU 配置为测试运行器:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64-static -L /usr/aarch64-linux-gnu"
cross 工具 — 基于 Docker 的交叉编译
cross 工具使用预配置的 Docker 镜像提供“零设置”交叉编译体验:
# 安装 cross (来自 crates.io — 稳定版)
cargo install cross
# 或者来自 git 以获得最新特性 (不太稳定):
# cargo install cross --git https://github.com/cross-rs/cross
# 交叉编译 — 无需手动设置工具链!
cross build --release --target aarch64-unknown-linux-gnu
cross build --release --target x86_64-unknown-linux-musl
cross build --release --target armv7-unknown-linux-gnueabihf
# 交叉测试 — Docker 镜像中已包含 QEMU
cross test --target aarch64-unknown-linux-gnu
工作原理:cross 替换了 cargo 命令,在预装了正确交叉编译工具链的 Docker 容器内运行构建。你的源码被挂载到容器中,输出结果则存放在正常的 target/ 目录下。
通过 Cross.toml 自定义 Docker 镜像:
# Cross.toml
[target.aarch64-unknown-linux-gnu]
# 使用带有额外系统库的自定义 Docker 镜像
image = "my-registry/cross-aarch64:latest"
# 预安装系统包
pre-build = [
"dpkg --add-architecture arm64",
"apt-get update && apt-get install -y libpci-dev:arm64"
]
[target.aarch64-unknown-linux-gnu.env]
# 将环境变量传递到容器中
passthrough = ["CI", "GITHUB_TOKEN"]
cross 需要 Docker (或 Podman),但它消除了手动安装交叉编译器、sysroot 和 QEMU 的麻烦。它是 CI 的推荐方案。
使用 Zig 作为交叉编译链接器
Zig 在单个约 40 MB 的下载包中捆绑了 C 编译器和针对约 40 个目标的交叉编译 sysroot。这使其成为 Rust 极佳的交叉链接器:
# 安装 Zig (单一二进制文件,无需包管理器)
# 从 https://ziglang.org/download/ 下载
# 或通过包管理器安装:
sudo snap install zig --classic --beta # Ubuntu
brew install zig # macOS
# 安装 cargo-zigbuild
cargo install cargo-zigbuild
为什么要用 Zig? 关键优势在于 glibc 版本定向 (glibc version targeting)。Zig 允许你指定链接的具体 glibc 版本,确保你的二进制文件能在旧版 Linux 发行版上运行:
# 为 glibc 2.17 构建 (兼容 CentOS 7 / RHEL 7)
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.17
# 为 glibc 2.28 构建 aarch64 (Ubuntu 18.04+)
cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.28
# 为 musl 构建 (完全静态)
cargo zigbuild --release --target x86_64-unknown-linux-musl
.2.17 后缀是 Zig 的扩展 —— 它告诉 Zig 链接器使用 glibc 2.17 的符号版本,从而使生成的二进制文件可以在 CentOS 7 及更高版本上运行。无需 Docker,无需 sysroot 管理,也无需安装交叉编译器。
对比:cross vs cargo-zigbuild vs 手动配置:
| 特性 | 手动配置 | cross | cargo-zigbuild |
|---|---|---|---|
| 设置开销 | 高 (需为每个目标安装工具链) | 低 (需要 Docker) | 低 (单一二进制) |
| 是否需要 Docker | 否 | 是 | 否 |
| glibc 版本定向 | 否 (使用宿主机 glibc) | 否 (使用容器 glibc) | 是 (精确版本) |
| 测试执行 | 需要 QEMU | 已包含 | 需要 QEMU |
| macOS → Linux | 困难 | 简单 | 简单 |
| Linux → macOS | 非常困难 | 不支持 | 受限 |
| 二进制体积开销 | 无 | 无 | 无 |
CI 流水线:GitHub Actions 矩阵
一个旨在构建多个目标的生产级 CI 工作流:
# .github/workflows/cross-build.yml
name: Cross-Platform Build
on: [push, pull_request]
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
name: linux-x86_64
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
name: linux-x86_64-static
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
name: linux-aarch64
use_cross: true
- target: x86_64-pc-windows-msvc
os: windows-latest
name: windows-x86_64
runs-on: ${{ matrix.os }}
name: Build (${{ matrix.name }})
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install musl tools
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get install -y musl-tools
- name: Install cross
if: matrix.use_cross
run: cargo install cross
- name: Build (native)
if: "!matrix.use_cross"
run: cargo build --release --target ${{ matrix.target }}
- name: Build (cross)
if: matrix.use_cross
run: cross build --release --target ${{ matrix.target }}
- name: Run tests
if: "!matrix.use_cross"
run: cargo test --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: diag_tool-${{ matrix.name }}
path: target/${{ matrix.target }}/release/diag_tool*
应用:多架构服务器构建
目前的二进制程序还没有设置交叉编译。对于一个部署在各种服务器集群中的硬件诊断工具,建议添加以下结构:
my_workspace/
├── .cargo/
│ └── config.toml ← 每个目标的链接器配置
├── Cross.toml ← cross 工具配置
└── .github/workflows/
└── cross-build.yml ← 针对 3 个目标的 CI 矩阵
建议的 .cargo/config.toml:
# 此项目的 .cargo/config.toml
# 发布配置优化(已在 Cargo.toml 中,此处仅供参考)
# [profile.release]
# lto = true
# codegen-units = 1
# panic = "abort"
# strip = true
# 针对 ARM 服务器(Graviton, Ampere, Grace)的 aarch64
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
# 用于便携式静态二进制文件的 musl
[target.x86_64-unknown-linux-musl]
linker = "musl-gcc"
建议的构建目标:
| 目标 | 使用场景 | 部署至 |
|---|---|---|
x86_64-unknown-linux-gnu | 默认原生构建 | 标准 x86 服务器 |
x86_64-unknown-linux-musl | 静态二进制,适用于任何发行版 | 容器、极简宿主机 |
aarch64-unknown-linux-gnu | ARM 服务器 | Graviton, Ampere, Grace |
关键洞察:工作区根目录
Cargo.toml中的[profile.release]已经设置了lto = true、codegen-units = 1、panic = "abort"和strip = true—— 这是交叉编译部署二进制文件的理想配置(见 发布配置 获取完整的收益表)。结合 musl,这将产生一个约 10 MB 的单一静态二进制文件,且无运行时依赖。
交叉编译排错
| 现象 | 原因 | 修复方法 |
|---|---|---|
linker 'aarch64-linux-gnu-gcc' not found | 缺少交叉链接器工具链 | sudo apt install gcc-aarch64-linux-gnu |
cannot find -lssl (musl 目标) | 系统 OpenSSL 是 glibc 链接的 | 使用 vendored 特性:openssl = { version = "0.10", features = ["vendored"] } |
build.rs 运行了错误的二进制文件 | build.rs 在 HOST 而非目标机运行 | 在 build.rs 中检查 CARGO_CFG_TARGET_OS,而非 cfg!(target_os) |
本地测试通过,在 cross 中失败 | Docker 镜像缺少测试固件 | 通过 Cross.toml 挂载测试数据:[build.env] volumes = ["./TestArea:/TestArea"] |
undefined reference to __cxa_thread_atexit_impl | 目标机 glibc 版本过旧 | 使用 cargo-zigbuild 并显式指定 glibc 版本:--target x86_64-unknown-linux-gnu.2.17 |
| 二进制文件在 ARM 上段错误 (segfault) | 编译成了错误的 ARM 变体 | 验证目标三元组是否匹配硬件:64 位 ARM 应为 aarch64-unknown-linux-gnu |
运行时提示 GLIBC_2.XX not found | 构建机器的 glibc 版本更高 | 使用 musl 进行静态构建,或使用 cargo-zigbuild 固定 glibc 版本 |
交叉编译决策树
flowchart TD
START["需要交叉编译?"] --> STATIC{"是否需要静态二进制文件?"}
STATIC -->|是| MUSL["musl 目标\n--target x86_64-unknown-linux-musl"]
STATIC -->|否| GLIBC{"是否需要旧版 glibc?"}
GLIBC -->|是| ZIG["cargo-zigbuild\n--target x86_64-unknown-linux-gnu.2.17"]
GLIBC -->|否| ARCH{"目标架构?"}
ARCH -->|"相同架构"| NATIVE["原生工具链\nrustup target add + 链接器"]
ARCH -->|"ARM/其他"| DOCKER{"是否有 Docker?"}
DOCKER -->|是| CROSS["cross build\n基于 Docker, 零设置"]
DOCKER -->|否| MANUAL["手动设置 sysroot\napt install gcc-aarch64-linux-gnu"]
style MUSL fill:#91e5a3,color:#000
style ZIG fill:#91e5a3,color:#000
style CROSS fill:#91e5a3,color:#000
style NATIVE fill:#e3f2fd,color:#000
style MANUAL fill:#ffd43b,color:#000
🏋️ 练习
🟢 练习 1:静态 musl 二进制文件
为 x86_64-unknown-linux-musl 构建任意 Rust 二进制程序。使用 file 和 ldd 验证其是否为静态链接。
答案
rustup target add x86_64-unknown-linux-musl
cargo new hello-static && cd hello-static
cargo build --release --target x86_64-unknown-linux-musl
# 验证
file target/x86_64-unknown-linux-musl/release/hello-static
# 输出:... statically linked ...
ldd target/x86_64-unknown-linux-musl/release/hello-static
# 输出:not a dynamic executable
🟡 练习 2:GitHub Actions 交叉构建矩阵
编写一个 GitHub Actions 工作流,针对三个目标平台构建 Rust 项目:x86_64-unknown-linux-gnu、x86_64-unknown-linux-musl 和 aarch64-unknown-linux-gnu。使用矩阵 (matrix) 策略。
答案
name: Cross-build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
target:
- x86_64-unknown-linux-gnu
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-gnu
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross
run: cargo install cross --locked
- name: Build
run: cross build --release --target ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: target/${{ matrix.target }}/release/my-binary
关键收获
- Rust 的
rustc本身就是一个交叉编译器 —— 你只需要匹配正确的目标平台和链接器。 - musl 开箱即用且无运行时依赖,非常适合构建单一静态二进制文件 —— 是容器化部署的理想。
cargo-zigbuild完美解决了针对企业级 Linux 目标的“glibc 版本”问题。cross是处理 ARM 和其他小众目标的最简路径 —— Docker 负责处理 sysroot。- 部署前始终使用
file和ldd进行测试,验证二进制文件是否与目标部署环境匹配。
基准测试 — 衡量真正重要的指标 🟡
你将学到:
- 为什么使用
Instant::now()进行简单计时会产生不可靠的结果- 使用 Criterion.rs 进行统计型基准测试,以及更轻量的替代方案 Divan
- 使用
perf、火焰图 (flamegraphs) 和 PGO (配置文件引导优化) 分析性能热点- 在 CI 中设置持续基准测试,自动捕获性能退化
相关章节: 发布配置 — 找到热动点后,优化二进制文件 · CI/CD 流水线 — 流水线中的基准测试任务 · 代码覆盖率 — 覆盖率告诉你测试了什么,基准测试告诉你什么运行得快
“在大约 97% 的时间里,我们应该忘记微小的效率提升:过早的优化是万恶之源。然而,我们不应在剩下的 3% 的关键机会中失之交臂。” —— Donald Knuth
难点不在于 编写 基准测试,而在于编写能产生 有意义、可复现、可操作 的数据的基准测试。本章涵盖了能让你从“它看起来很快”升级到“我们有统计证据表明 PR #347 使解析吞吐量退化了 4.2%”的工具和技巧。
为什么不用 std::time::Instant?
常见的做法及其弊端:
// ❌ 简陋的基准测试 — 结果不可靠
use std::time::Instant;
fn main() {
let start = Instant::now();
let result = parse_device_query_output(&sample_data);
let elapsed = start.elapsed();
println!("解析耗时 {:?}", elapsed);
// 问题 1:编译器可能会优化掉 `result`(死代码消除)
// 问题 2:样本单一 — 无统计学意义
// 问题 3:CPU 频率缩放、热节流、其他进程干扰
// 问题 4:未控制冷缓存与热缓存
}
手动计时的问题:
- 死代码消除 (Dead code elimination) — 如果结果未被使用,编译器可能会完全跳过计算。
- 缺乏预热 (No warm-up) — 第一次运行包含缓存未命中、JIT 效应(虽然在 Rust 中不适用,但操作系统页错误适用)和惰性初始化。
- 缺乏统计分析 — 单次测量无法告诉你方差、异常值或置信区间。
- 无法检测退化 — 你无法与之前的运行结果进行对比。
Criterion.rs — 统计基准测试
Criterion.rs 是 Rust 微基准测试的事实标准。它使用统计方法产生可靠的测量结果,并自动检测性能退化。
准备工作:
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports", "cargo_bench_support"] }
[[bench]]
name = "parsing_bench"
harness = false # 使用 Criterion 的运行器,而非内置的测试运行器
一个完整的基准测试示例:
#![allow(unused)]
fn main() {
// benches/parsing_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
/// 解析后的 GPU 信息数据类型
#[derive(Debug, Clone)]
struct GpuInfo {
index: u32,
name: String,
temp_c: u32,
power_w: f64,
}
/// 待测函数 — 模拟解析设备查询的 CSV 输出
fn parse_gpu_csv(input: &str) -> Vec<GpuInfo> {
input
.lines()
.filter(|line| !line.starts_with('#'))
.filter_map(|line| {
let fields: Vec<&str> = line.split(", ").collect();
if fields.len() >= 4 {
Some(GpuInfo {
index: fields[0].parse().ok()?,
name: fields[1].to_string(),
temp_c: fields[2].parse().ok()?,
power_w: fields[3].parse().ok()?,
})
} else {
None
}
})
.collect()
}
fn bench_parse_gpu_csv(c: &mut Criterion) {
// 具有代表性的测试数据
let small_input = "0, Acme Accel-V1-80GB, 32, 65.5\n\
1, Acme Accel-V1-80GB, 34, 67.2\n";
let large_input = (0..64)
.map(|i| format!("{i}, Acme Accel-X1-80GB, {}, {:.1}\n", 30 + i % 20, 60.0 + i as f64))
.collect::<String>();
c.bench_function("parse_2_gpus", |b| {
b.iter(|| parse_gpu_csv(black_box(small_input)))
});
c.bench_function("parse_64_gpus", |b| {
b.iter(|| parse_gpu_csv(black_box(&large_input)))
});
}
criterion_group!(benches, bench_parse_gpu_csv);
criterion_main!(benches);
}
运行并阅读结果:
# 运行所有基准测试
cargo bench
# 运行特定的基准测试(按名称过滤)
cargo bench -- parse_64
# 输出示例:
# parse_2_gpus time: [1.2345 µs 1.2456 µs 1.2578 µs]
# ▲ ▲ ▲
# │ 置信区间
# 下限 95% 中位数 上限 95%
#
# parse_64_gpus time: [38.123 µs 38.456 µs 38.812 µs]
# change: [-1.2345% -0.5678% +0.1234%] (p = 0.12 > 0.05)
# 未检测到性能变化。
black_box() 的作用:它是一个编译器提示,用于防止死代码消除和过度激进的常量折叠。编译器无法看穿 black_box,因此它必须实际计算结果。
参数化基准测试与基准测试组
对比多种实现或输入规模:
#![allow(unused)]
fn main() {
// benches/comparison_bench.rs
use criterion::{criterion_group, criterion_main, Criterion, BenchmarkId, Throughput};
fn bench_parsing_strategies(c: &mut Criterion) {
let mut group = c.benchmark_group("csv_parsing");
// 在不同的输入规模上进行测试
for num_gpus in [1, 8, 32, 64, 128] {
let input = generate_gpu_csv(num_gpus);
// 设置吞吐量,以报告每秒处理的字节数
group.throughput(Throughput::Bytes(input.len() as u64));
group.bench_with_input(
BenchmarkId::new("split_based", num_gpus),
&input,
|b, input| b.iter(|| parse_split(input)),
);
group.bench_with_input(
BenchmarkId::new("regex_based", num_gpus),
&input,
|b, input| b.iter(|| parse_regex(input)),
);
group.bench_with_input(
BenchmarkId::new("nom_based", num_gpus),
&input,
|b, input| b.iter(|| parse_nom(input)),
);
}
group.finish();
}
criterion_group!(benches, bench_parsing_strategies);
criterion_main!(benches);
}
报告:Criterion 会在 target/criterion/report/index.html 生成 HTML 报告,包含小提琴图、对比图和退化分析 —— 可直接在浏览器中打开。
Divan — 一个更轻量的替代方案
Divan 是一个新的基准测试框架,它使用属性宏代替了 Criterion 的宏 DSL:
# Cargo.toml
[dev-dependencies]
divan = "0.1"
[[bench]]
name = "parsing_bench"
harness = false
// benches/parsing_bench.rs
use divan::black_box;
const SMALL_INPUT: &str = "0, Acme Accel-V1-80GB, 32, 65.5\n\
1, Acme Accel-V1-80GB, 34, 67.2\n";
fn generate_gpu_csv(n: usize) -> String {
(0..n)
.map(|i| format!("{i}, Acme Accel-X1-80GB, {}, {:.1}\n", 30 + i % 20, 60.0 + i as f64))
.collect()
}
fn main() {
divan::main();
}
#[divan::bench]
fn parse_2_gpus() -> Vec<GpuInfo> {
parse_gpu_csv(black_box(SMALL_INPUT))
}
#[divan::bench(args = [1, 8, 32, 64, 128])]
fn parse_n_gpus(n: usize) -> Vec<GpuInfo> {
let input = generate_gpu_csv(n);
parse_gpu_csv(black_box(&input))
}
// Divan 输出的是简洁的表格:
// ╰─ parse_2_gpus 最快 │ 最慢 │ 中位数 │ 平均值 │ 样本数 │ 迭代次数
// 1.234 µs │ 1.567 µs │ 1.345 µs │ 1.350 µs │ 100 │ 1600
何时选择 Divan 而非 Criterion:
- 更简单的 API(属性宏,更少的样板代码)
- 编译速度更快(依赖更少)
- 适合开发过程中的快速性能检查
何时选择 Criterion:
- 跨运行的统计退化检测
- 带有图表的 HTML 报告
- 成熟的生态系统,更多的 CI 集成
使用 perf 和火焰图进行性能分析
基准测试告诉你 有多快 —— 性能分析 (profiling) 告诉你 时间都花在哪了。
# 第 1 步:构建时包含调试信息(发布速度,调试符号)
cargo build --release
# 确保调试信息可用:
# [profile.release]
# debug = true # 临时添加此项进行分析
# 第 2 步:使用 perf 记录
perf record --call-graph=dwarf ./target/release/diag_tool --run-diagnostics
# 第 3 步:生成火焰图
# 安装:cargo install flamegraph
# 安装:cargo install addr2line --features=bin(可选,加速 cargo-flamegraph)
cargo flamegraph --root -- --run-diagnostics
# 生成并打开一个交互式的 SVG 火焰图
# 另一种方式:使用 perf + inferno
perf script | inferno-collapse-perf | inferno-flamegraph > flamegraph.svg
阅读火焰图:
- 宽度 (Width) = 在该函数中花费的时间(越宽 = 越慢)
- 高度 (Height) = 调用栈深度(越高 ≠ 越慢,只是更深)
- 底部 (Bottom) = 入口点,顶部 (Top) = 执行实际工作的叶子函数
- 寻找顶部宽广的“平原” —— 那些就是你的性能热点。
配置文件引导优化 (PGO):
# 第 1 步:构建带有插桩的可执行文件
RUSTFLAGS="-Cprofile-generate=/tmp/pgo-data" cargo build --release
# 第 2 步:运行代表性的负载
./target/release/diag_tool --run-full # 生成性能分析数据
# 第 3 步:合并性能分析数据
# 使用与 rustc 的 LLVM 版本匹配的 llvm-profdata:
# $(rustc --print sysroot)/lib/rustlib/x86_64-unknown-linux-gnu/bin/llvm-profdata
# 或者如果你安装了 llvm-tools:rustup component add llvm-tools
llvm-profdata merge -o /tmp/pgo-data/merged.profdata /tmp/pgo-data/
# 第 4 步:根据分析反馈重新构建
RUSTFLAGS="-Cprofile-use=/tmp/pgo-data/merged.profdata" cargo build --release
# 典型的提升:计算密集型代码(解析、加密、代码生成)提升 5-20%。
# I/O 密集型或系统调用频繁的代码提升较小,因为 CPU 大部分时间在等待。
提示:在花时间研究 PGO 之前,确保你的 发布配置 已经启用了 LTO —— 这通常只需更少的努力就能获得更大的收益。
hyperfine — 快速端到端计时
hyperfine 对整个命令进行基准测试,而不是单个函数。它非常适合衡量二进制文件的整体性能:
# 安装
cargo install hyperfine
# 或者通过包管理器安装 (Ubuntu 23.04+): sudo apt install hyperfine
# 基础基准测试
hyperfine './target/release/diag_tool --run-diagnostics'
# 比较两种实现
hyperfine './target/release/diag_tool_v1 --run-diagnostics' \
'./target/release/diag_tool_v2 --run-diagnostics'
# 预热运行 + 最小迭代次数
hyperfine --warmup 3 --min-runs 10 './target/release/diag_tool --run-all'
# 将结果导出为 JSON 以便在 CI 中对比
hyperfine --export-json bench.json './target/release/diag_tool --run-all'
何时使用 hyperfine 而非 Criterion:
hyperfine:整体二进制文件计时、重构前后的对比、I/O 密集型负载- Criterion:单个函数的微基准测试、统计型退化检测
在 CI 中进行持续基准测试
在性能退化版本发布前拦截它们:
# .github/workflows/bench.yml
name: Benchmarks
on:
pull_request:
paths: ['**/*.rs', 'Cargo.toml', 'Cargo.lock']
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run benchmarks
# 需要 criterion = { features = ["cargo_bench_support"] } 以支持 --output-format
run: cargo bench -- --output-format bencher | tee bench_output.txt
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'cargo'
output-file-path: bench_output.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
alert-threshold: '120%' # 如果慢了 20% 则报警
comment-on-alert: true
fail-on-alert: true # 检测到退化时阻止 PR 合并
CI 关键考虑点:
- 使用 专用基准测试运行器(而非共享的 CI 节点)以获得一致的结果。
- 如果使用云端 CI,请将运行器固定到特定的机器类型。
- 存储历史数据以检测渐进式的退化。
- 根据负载的容忍度设置阈值(热路径 5%,其他 20%)。
应用:解析性能
该项目有多个对性能敏感的解析路径,它们将从基准测试中受益:
| 解析热点 | Crate | 为什么重要 |
|---|---|---|
| 加速器查询 CSV/XML 输出 | device_diag | 每个 GPU 都会调用,每次运行最多 8 次 |
| 传感器事件解析 | event_log | 繁忙服务器上有数千条记录 |
| PCIe 拓扑 JSON | topology_lib | 复杂的嵌套结构,经过 Golden-file 验证 |
| 报告 JSON 序列化 | diag_framework | 最终报告输出,对体积敏感 |
| 配置 JSON 加载 | config_loader | 启动延迟 |
建议的第一个基准测试 —— 拓扑结构解析器,它已经有了 Golden-file 测试数据:
#![allow(unused)]
fn main() {
// topology_lib/benches/parse_bench.rs (建议)
use criterion::{criterion_group, criterion_main, Criterion, Throughput};
use std::fs;
fn bench_topology_parse(c: &mut Criterion) {
let mut group = c.benchmark_group("topology_parse");
for golden_file in ["S2001", "S1015", "S1035", "S1080"] {
let path = format!("tests/test_data/{golden_file}.json");
let data = fs::read_to_string(&path).expect("golden file not found");
group.throughput(Throughput::Bytes(data.len() as u64));
group.bench_function(golden_file, |b| {
b.iter(|| {
topology_lib::TopologyProfile::from_json_str(
criterion::black_box(&data)
)
});
});
}
group.finish();
}
criterion_group!(benches, bench_topology_parse);
criterion_main!(benches);
}
亲自尝试
-
编写一个 Criterion 基准测试:挑出代码库中任意一个解析函数。创建一个
benches/目录,设置一个 Criterion 基准测试来衡量每秒处理的字节数。运行cargo bench并查看 HTML 报告。 -
生成一份火焰图:在
[profile.release]中设置debug = true构建你的项目,然后运行cargo flamegraph -- <你的参数>。找出火焰图顶部最宽的三个栈 —— 它们就是你的热点。 -
与
hyperfine对比:安装hyperfine并在不同标志下测量二进制文件的整体执行时间。将其与 Criterion 测量的各函数时间进行对比。Criterion 没看到的时间都花在哪了?(提示:I/O、系统调用、进程启动)。
基准测试工具选择
flowchart TD
START["想要衡量性能?"] --> WHAT{"什么层面?"}
WHAT -->|"单个函数"| CRITERION["Criterion.rs\n统计型,退化检测"]
WHAT -->|"快速核对函数"| DIVAN["Divan\n轻量级,属性宏"]
WHAT -->|"整个二进制文件"| HYPERFINE["hyperfine\n端到端,墙钟时间"]
WHAT -->|"查找热点"| PERF["perf + 火焰图\nCPU 采样分析器"]
CRITERION --> CI_BENCH["持续基准测试\n在 GitHub Actions 中"]
PERF --> OPTIMIZE["配置文件引导\n优化 (PGO)"]
style CRITERION fill:#91e5a3,color:#000
style DIVAN fill:#91e5a3,color:#000
style HYPERFINE fill:#e3f2fd,color:#000
style PERF fill:#ffd43b,color:#000
style CI_BENCH fill:#e3f2fd,color:#000
style OPTIMIZE fill:#ffd43b,color:#000
🏋️ 练习
🟢 练习 1:第一个 Criterion 基准测试
创建一个 crate,包含一个对拥有 10,000 个随机元素的 Vec<u64> 进行排序的函数。编写一个 Criterion 基准测试。尝试切换到 .sort_unstable() 并观察 HTML 报告中性能的差异。
答案
# Cargo.toml
[[bench]]
name = "sort_bench"
harness = false
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
rand = "0.8"
#![allow(unused)]
fn main() {
// benches/sort_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use rand::Rng;
fn generate_data(n: usize) -> Vec<u64> {
let mut rng = rand::thread_rng();
(0..n).map(|_| rng.gen()).collect()
}
fn bench_sort(c: &mut Criterion) {
let mut group = c.benchmark_group("sort-10k");
group.bench_function("stable", |b| {
b.iter_batched(
|| generate_data(10_000),
|mut data| { data.sort(); black_box(&data); },
criterion::BatchSize::SmallInput,
)
});
group.bench_function("unstable", |b| {
b.iter_batched(
|| generate_data(10_000),
|mut data| { data.sort_unstable(); black_box(&data); },
criterion::BatchSize::SmallInput,
)
});
group.finish();
}
criterion_group!(benches, bench_sort);
criterion_main!(benches);
}
cargo bench
open target/criterion/sort-10k/report/index.html
🟡 练习 2:火焰图中的热点
在 [profile.release] 中设置 debug = true 构建项目,生成一份火焰图。找出最宽的前三个栈。
答案
# Cargo.toml
[profile.release]
debug = true # 保留火焰图所需的符号
cargo install flamegraph
cargo flamegraph --release -- <你的参数>
# 在浏览器中打开 flamegraph.svg
# 顶部最宽的栈就是你的性能热点
关键收获
- 绝不要用
Instant::now()进行微基准测试 —— 使用 Criterion.rs 获得统计学上的严谨性和退化检测。 black_box()防止编译器将你的基准测试目标通过内联或折叠优化掉。hyperfine衡量整体二进制文件的运行时间;Criterion 衡量单个函数 —— 两者配合使用。- 火焰图展示时间花在 哪 了;基准测试展示花了 多少 时间。
- 在 CI 中设置持续基准测试,可在性能退化上线前及时捕获。
代码覆盖率 — 发现测试遗漏 🟢
你将学到:
- 使用
cargo-llvm-cov进行基于源码的覆盖率分析(最准确的 Rust 覆盖率工具)- 使用
cargo-tarpaulin和 Mozilla 的grcov进行快速覆盖率检查- 在 CI 中使用 Codecov 和 Coveralls 设置覆盖率门禁 (Coverage Gates)
- 优先处理高风险盲点的覆盖率导向型测试策略
相关章节: Miri 与 Sanitizer — 覆盖率发现未测试的代码,Miri 发现已测试代码中的 UB · 基准测试 — 覆盖率展示 测试了什么,基准测试展示 什么运行得快 · CI/CD 流水线 — 流水线中的覆盖率门禁
代码覆盖率衡量你的测试实际执行了哪些行、分支或函数。它不能证明代码的正确性(覆盖到的行仍可能存在 bug),但它能可靠地揭示 盲点 —— 即没有任何测试覆盖到的代码路径。
本项目在多个 crate 中拥有 1,006 个测试,投入了大量的测试精力。覆盖率分析可以回答:“这些投入是否触及了真正关键的代码?”
使用 llvm-cov 进行基于源码的覆盖率分析
Rust 使用 LLVM,它提供了基于源码的插桩覆盖率 —— 这是目前最准确的覆盖率分析方法。推荐使用的工具是 cargo-llvm-cov:
# 安装
cargo install cargo-llvm-cov
# 或者通过 rustup 安装组件(获取原始 llvm 工具)
rustup component add llvm-tools-preview
基础用法:
# 运行测试并显示每个文件的覆盖率摘要
cargo llvm-cov
# 生成 HTML 报告(可浏览器查阅,带有逐行高亮)
cargo llvm-cov --html
# 输出路径:target/llvm-cov/html/index.html
# 生成 LCOV 格式(用于 CI 集成)
cargo llvm-cov --lcov --output-path lcov.info
# 工作区全量覆盖率(所有 crate)
cargo llvm-cov --workspace
# 仅包含特定的包
cargo llvm-cov --package accel_diag --package topology_lib
# 覆盖率包含文档测试 (doc tests)
cargo llvm-cov --doctests
阅读 HTML 报告:
target/llvm-cov/html/index.html
├── 文件名 │ 函数 │ 行 │ 分支 │ 区域
├─ accel_diag/src/lib.rs │ 78.5% │ 82.3% │ 61.2% │ 74.1%
├─ sel_mgr/src/parse.rs │ 95.2% │ 96.8% │ 88.0% │ 93.5%
├─ topology_lib/src/.. │ 91.0% │ 93.4% │ 79.5% │ 89.2%
└─ ...
绿色 = 已覆盖 红色 = 未覆盖 黄色 = 部分覆盖(分支)
覆盖率类型说明:
| 类型 | 衡量内容 | 意义 |
|---|---|---|
| 行覆盖率 (Line) | 执行了哪些源码行 | 基础的“这段代码被跑到了吗?” |
| 分支覆盖率 (Branch) | 执行了哪些 if/match 分支 | 捕捉未测试的条件判断 |
| 函数覆盖率 (Function) | 调用了哪些函数 | 发现死代码 |
| 区域覆盖率 (Region) | 命中了哪些代码区域(子表达式) | 颗粒度最细 |
cargo-tarpaulin — 快捷路径
cargo-tarpaulin 是一个专门针对 Linux 的覆盖率工具,它的设置更简单(无需安装 LLVM 组件):
# 安装
cargo install cargo-tarpaulin
# 基础覆盖率报告
cargo tarpaulin
# HTML 输出
cargo tarpaulin --out Html
# 使用特定选项
cargo tarpaulin \
--workspace \
--timeout 120 \
--out Xml Html \
--output-dir coverage/ \
--exclude-files "*/tests/*" "*/benches/*" \
--ignore-panics
# 跳过特定的 crate
cargo tarpaulin --workspace --exclude diag_tool # 排除二进制 crate
tarpaulin 与 llvm-cov 对比:
| 特性 | cargo-llvm-cov | cargo-tarpaulin |
|---|---|---|
| 准确性 | 基于源码 (最准确) | 基于 ptrace (偶尔会有误报) |
| 平台 | 任何 (基于 llvm) | 仅限 Linux |
| 分支覆盖率 | 支持 | 有限支持 |
| 文档测试 | 支持 | 不支持 |
| 安装设置 | 需要 llvm-tools-preview | 自包含 |
| 速度 | 较快 (编译期插桩) | 较慢 (ptrace 开销) |
| 稳定性 | 非常稳定 | 偶尔会出现伪阳性 |
建议:追求准确性时使用 cargo-llvm-cov。如果你只需要在 Linux 上进行快速检查且不想安装 LLVM 工具,可以使用 cargo-tarpaulin。
grcov — Mozilla 的覆盖率工具
grcov 是 Mozilla 开发的覆盖率聚合器。它消费原始的 LLVM 剖析数据并生成多种格式的报告:
# 安装
cargo install grcov
# 第 1 步:构建带有插桩信息的二进制文件
export RUSTFLAGS="-Cinstrument-coverage"
export LLVM_PROFILE_FILE="target/coverage/%p-%m.profraw"
cargo build --tests
# 第 2 步:运行测试(生成 .profraw 文件)
cargo test
# 第 3 步:使用 grcov 进行聚合
grcov target/coverage/ \
--binary-path target/debug/ \
--source-dir . \
--output-types html,lcov \
--output-path target/coverage/report \
--branch \
--ignore-not-existing \
--ignore "*/tests/*" \
--ignore "*/.cargo/*"
# 第 4 步:查看报告
open target/coverage/report/html/index.html
何时使用 grcov:当你需要将 多次测试运行的覆盖率合并(例如:单元测试 + 集成测试 + 模糊测试)到单一报告中时,它最为有用。
CI 中的覆盖率:Codecov 与 Coveralls
将覆盖率数据上传至跟踪服务,以便查看历史趋势和 PR 批注:
# .github/workflows/coverage.yml
name: Code Coverage
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
# 可选:强制要求最低覆盖率
- name: Check coverage threshold
run: |
cargo llvm-cov --workspace --fail-under-lines 80
# 如果行覆盖率低于 80%,则构建失败
覆盖率门禁 —— 通过读取 JSON 输出,对每个 crate 强制执行最低标准:
# 获取每个 crate 的覆盖率(JSON 格式)
cargo llvm-cov --workspace --json | jq '.data[0].totals.lines.percent'
# 低于阈值则报错
cargo llvm-cov --workspace --fail-under-lines 80
cargo llvm-cov --workspace --fail-under-functions 70
cargo llvm-cov --workspace --fail-under-regions 60
覆盖率导向型测试策略
如果没有策略,覆盖率数值本身毫无意义。以下是如何有效利用覆盖率数据的方法:
第 1 步:按风险进行分类 (Triage by risk)
高覆盖率,高风险 → ✅ 优 — 保持现状
高覆盖率,低风险 → 🔄 可能过度测试 — 如果速度太慢可精简
低覆盖率,高风险 → 🔴 立即编写测试 — 这是 bug 处理的温床
低覆盖率,低风险 → 🟡 跟踪但不必恐慌
第 2 步:关注分支覆盖率,而非行覆盖率
#![allow(unused)]
fn main() {
// 100% 的行覆盖率,但只有 50% 的分支覆盖率 —— 仍然充满风险!
pub fn classify_temperature(temp_c: i32) -> ThermalState {
if temp_c > 105 { // ← 使用 temp=110 测试过 → Critical
ThermalState::Critical
} else if temp_c > 85 { // ← 使用 temp=90 测试过 → Warning
ThermalState::Warning
} else if temp_c < -10 { // ← 从未测试过 → 遗漏了传感器错误情况
ThermalState::SensorError
} else {
ThermalState::Normal // ← 使用 temp=25 测试过 → Normal
}
}
}
第 3 步:排除噪音
# 排除测试代码(它们总是“被覆盖”的)
cargo llvm-cov --workspace --ignore-filename-regex 'tests?\.rs$|benches/'
# 排除生成的代码
cargo llvm-cov --workspace --ignore-filename-regex 'target/'
在代码中标记无法测试的部分:
#![allow(unused)]
fn main() {
// 覆盖率工具可以识别这种模式
#[cfg(not(tarpaulin_include))] // 针对 tarpaulin
fn unreachable_hardware_path() {
// 该路径需要实际的 GPU 硬件才能触发
}
// 对于 llvm-cov,建议采用更有针对性的方法:
// 接受某些路径需要集成/硬件测试而非单元测试。
// 将它们整理进覆盖率例外清单中。
}
补充测试工具
proptest — 基于属性的测试 (Property-Based Testing) 能发现手动编写测试时遗漏的边界情况:
[dev-dependencies]
proptest = "1"
#![allow(unused)]
fn main() {
use proptest::prelude::*;
proptest! {
#[test]
fn parse_never_panics(input in "\\PC*") {
// proptest 生成数千个随机字符串
// 如果 parse_gpu_csv 在任何输入上崩溃 (panic),测试就会失败,
// 并且 proptest 会为你最小化失败用例。
let _ = parse_gpu_csv(&input);
}
#[test]
fn temperature_roundtrip(raw in 0u16..4096) {
let temp = Temperature::from_raw(raw);
let md = temp.millidegrees_c();
// 属性:毫摄氏度应当总是能从原始值推导出来
assert_eq!(md, (raw as i32) * 625 / 10);
}
}
}
insta — 快照测试 (Snapshot Testing) 适用于大型结构化输出(JSON、文本报告):
[dev-dependencies]
insta = { version = "1", features = ["json"] }
#![allow(unused)]
fn main() {
#[test]
fn test_der_report_format() {
let report = generate_der_report(&test_results);
// 第一次运行:创建快照文件。后续运行:与快照进行对比。
// 运行 `cargo insta review` 可以交互式地接受变更。
insta::assert_json_snapshot!(report);
}
}
何时添加 proptest/insta:如果你的单元测试全都是“常用路径”示例,proptest 会帮你找出遗漏的边界情况。如果你正在测试大型输出格式(JSON 报告、DER 记录),insta 快照比手动编写断言更快且更易维护。
应用:1,000+ 测试的覆盖率蓝图
本项目有 1,000 多个测试,但没有覆盖率跟踪。添加覆盖率分析可以揭示测试投入的分布情况。未覆盖的路径是进行 Miri 与 sanitizer 验证的首选对象:
建议的覆盖率配置:
# 工作区快速覆盖率分析(建议的 CI 命令)
cargo llvm-cov --workspace \
--ignore-filename-regex 'tests?\.rs$' \
--fail-under-lines 75 \
--html
# 针对各个 crate 的覆盖率,进行定向提升
for crate in accel_diag event_log topology_lib network_diag compute_diag fan_diag; do
echo "=== $crate ==="
cargo llvm-cov --package "$crate" --json 2>/dev/null | \
jq -r '.data[0].totals | "Lines: \(.lines.percent | round)% Branches: \(.branches.percent | round)%"'
done
预期覆盖率较高的 crate(基于测试密度):
topology_lib— 拥有 922 行的 Golden-file 测试套件event_log— 拥有create_test_record()辅助函数的注册中心cable_diag— 采用了make_test_event()/make_test_context()模式的代码
预期覆盖率缺口(基于代码检查):
- IPMI 通信路径中的错误处理分支
- GPU 硬件特定的分支(需要实际 GPU 环境)
dmesg解析边界情况(依赖特定平台的输出)
覆盖率的 80/20 法则:从 0% 提升到 80% 覆盖率是比较直接的。从 80% 提升到 95% 则需要日益复杂的测试场景。从 95% 提升到 100% 通常需要大量
#[cfg(not(...))]排除项,往往得不偿失。在实践中,建议将 80% 行覆盖率和 70% 分支覆盖率 作为底线。
覆盖率排错
| 现象 | 原因 | 修复方法 |
|---|---|---|
llvm-cov 对所有文件显示 0% | 未启用插桩 | 确保运行的是 cargo llvm-cov,而不是分开运行 cargo test 和 llvm-cov |
覆盖率将 unreachable!() 计为未覆盖 | 编译后的代码中确实存在这些分支 | 使用 #[cfg(not(tarpaulin_include))] 或将其加入排除正则 |
| 测试二进制文件在覆盖率模式下崩溃 | 插桩信息与 sanitizer 冲突 | 不要同时运行 cargo llvm-cov 和 -Zsanitizer=address;请分开运行 |
llvm-cov 与 tarpaulin 结果不一致 | 插桩技术不同 | 以 llvm-cov 为准(编译器原生支持);如果差异过大请提交 issue |
提示 error: profraw file is malformed | 测试二进制文件在执行过程中崩溃 | 首先修复测试失败;如果进程异常退出,profraw 文件会损坏 |
| 分支覆盖率低得离谱 | 优化器为 match 分支、unwrap 等创建了分支 | 在设置门槛时关注 行 覆盖率;分支覆盖率天然较低 |
亲自尝试
-
衡量你项目的覆盖率:运行
cargo llvm-cov --workspace --html并打开报告。找出覆盖率最低的三个文件。它们是未经测试,还是由于硬件依赖性而天生难以测试? -
设置覆盖率门禁:在 CI 中添加
cargo llvm-cov --workspace --fail-under-lines 60。故意注释掉一个测试,验证 CI 是否失败。然后逐步将阈值提高到实际覆盖率减去 2% 的水平。 -
分支 vs 行覆盖率:编写一个带有 3 分支
match的函数,仅测试其中 2 个分支。对比行覆盖率(可能显示 66%)与分支覆盖率(可能显示 50%)。哪种指标对你的项目更有参考价值?
覆盖率工具选择
flowchart TD
START["需要代码覆盖率吗?"] --> ACCURACY{"优先级?"}
ACCURACY -->|"最准确"| LLVM["cargo-llvm-cov\n基于源码,编译器原生驱动"]
ACCURACY -->|"快速检查"| TARP["cargo-tarpaulin\n仅限 Linux,速度快"]
ACCURACY -->|"多次运行聚合"| GRCOV["grcov\nMozilla 出品,合并 profile"]
LLVM --> CI_GATE["CI 覆盖率门禁\n--fail-under-lines 80"]
TARP --> CI_GATE
CI_GATE --> UPLOAD{"上传到?"}
UPLOAD -->|"Codecov"| CODECOV["codecov/codecov-action"]
UPLOAD -->|"Coveralls"| COVERALLS["coverallsapp/github-action"]
style LLVM fill:#91e5a3,color:#000
style TARP fill:#e3f2fd,color:#000
style GRCOV fill:#e3f2fd,color:#000
style CI_GATE fill:#ffd43b,color:#000
🏋️ 练习
🟢 练习 1:第一份覆盖率报告
安装 cargo-llvm-cov,在任意 Rust 项目上运行并打开 HTML 报告。找出行覆盖率最低的三个文件。
答案
cargo install cargo-llvm-cov
cargo llvm-cov --workspace --html --open
# 报告会根据覆盖率对文件进行排序 —— 覆盖率最低的排在前面或按列排序
# 寻找低于 50% 的文件 —— 它们就是你的盲点
🟡 练习 2:CI 覆盖率门禁
在 GitHub Actions 工作流中添加一个覆盖率门禁,如果行覆盖率低于 60% 则报错。通过注释掉一个测试来验证其效果。
答案
# .github/workflows/coverage.yml
name: Coverage
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- run: cargo install cargo-llvm-cov
- run: cargo llvm-cov --workspace --fail-under-lines 60
注释掉一个测试后推送,观察工作流是否失败。
关键收获
cargo-llvm-cov是 Rust 最准确的覆盖率工具 —— 它采用了编译器原生的插桩技术。- 覆盖率不能证明代码正确,但 零覆盖率能证明零测试 —— 利用它寻找盲点。
- 在 CI 中设置覆盖率门禁(例如
--fail-under-lines 80)以防止性能/质量退化。 - 不要盲目追求 100% 覆盖率 —— 重点关注高风险的代码路径(错误处理、unsafe、解析)。
- 绝不要在同一次运行中混合使用覆盖率插桩和 sanitizer。
Miri, Valgrind 与 Sanitizer — 验证 Unsafe 代码 🔴
你将学到:
- Miri 作为 MIR 解释器 — 它能捕捉到什么(别名违规、UB、泄漏)以及它的局限性(FFI、系统调用)
- Valgrind memcheck (内存检查)、Helgrind (数据竞态)、Callgrind (性能分析) 和 Massif (堆分析)
- LLVM sanitizer:ASan、MSan、TSan、LSan 配合 nightly
-Zbuild-std的使用- 使用
cargo-fuzz进行崩溃发现,以及使用loom进行并发模型检查- 选择合适的验证工具的决策树
相关章节: 代码覆盖率 — 覆盖率发现未测试的路径,Miri 验证已测试的路径 ·
no_std与特性验证 —no_std代码通常需要unsafe,可用 Miri 验证 · CI/CD 流水线 — 流水线中的 Miri 任务
Safe Rust 在编译时保证了内存安全和无数据竞态。但当你为了 FFI、手写数据结构或性能优化而写下 unsafe 的那一刻起,这些保证就变成了 你 的责任。本章涵盖了用于验证你的 unsafe 代码是否真正履行了其安全承诺的工具。
Miri — Unsafe Rust 解释器
Miri 是 Rust 中层中间表示 (Mid-level Intermediate Representation, MIR) 的 解释器。它不将代码编译为机器码,而是逐步 执行 你的程序,并在每一步操作中对未定义行为 (Undefined Behavior, UB) 进行详尽检查。
# 安装 Miri (仅限 nightly 组件)
rustup +nightly component add miri
# 在 Miri 下运行测试套件
cargo +nightly miri test
# 在 Miri 下运行特定二进制文件
cargo +nightly miri run
# 运行特定测试
cargo +nightly miri test -- test_name
Miri 工作原理:
源码 → rustc → MIR → Miri 解释 MIR
│
├─ 跟踪每个指针的来源 (provenance)
├─ 验证每次内存访问
├─ 在每次解引用时检查对齐情况
├─ 检测释放后使用 (use-after-free)
├─ 检测数据竞态 (配合线程)
└─ 强制执行 Stacked Borrows / Tree Borrows 规则
Miri 能捕捉到什么 (以及它不能做什么)
Miri 检测项:
| 类别 | 示例 | 运行时会崩溃吗? |
|---|---|---|
| 越界访问 | 对分配空间外的 ptr.add(100).read() | 有时会 (取决于页面布局) |
| 释放后使用 | 通过原始指针读取已掉落的 Box | 有时会 (取决于分配器) |
| 重复释放 | 调用 drop_in_place 两次 | 通常会 |
| 未对齐访问 | 在奇数地址上执行 (ptr as *const u32).read() | 在某些架构上会 |
| 无效值 | 执行 transmute::<u8, bool>(2) | 静默错误 |
| 悬空引用 | 指向已释放内存的 &*ptr | 不会 (静默损坏) |
| 数据竞态 | 两线程一写一读,无同步 | 间歇性,难以复现 |
| 借用规则违规 | 别名化 &mut 引用 (Stacked Borrows) | 不会 (静默损坏) |
Miri 无法检测项:
| 局限性 | 原因 |
|---|---|
| 逻辑 Bug | Miri 检查内存安全,而非逻辑正确性 |
| 并发死锁 | Miri 检查数据竞态,而非活锁 |
| 性能问题 | 解释执行比原生执行慢 10-100 倍 |
| 系统/硬件交互 | Miri 无法模拟系统调用、设备 I/O |
| 所有 FFI 调用 | 无法解释 C 代码 (仅限 Rust MIR) |
| 完备的路径覆盖 | 仅测试你的测试套件所触及的路径 |
具体示例 — 捕捉在实践中能“跑通”但存在风险的代码:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
#[test]
fn test_miri_catches_ub() {
// 这在 release 构建中可能“正常工作”,但属于未定义行为
let mut v = vec![1, 2, 3];
let ptr = v.as_ptr();
// push 可能会导致重新分配,从而使 ptr 失效
v.push(4);
// ❌ UB: 重新分配后 ptr 可能悬空
// 即使分配器恰好没有移动缓冲区,Miri 也会捕捉到这一点。
// let _val = unsafe { *ptr };
// Miri 会报告错误:
// "pointer to alloc1234 was dereferenced after this
// allocation got freed"
// ✅ 正确:在变更后获取新指针
let ptr = v.as_ptr();
let val = unsafe { *ptr };
assert_eq!(val, 1);
}
}
}
在真实 Crate 上运行 Miri
针对含有 unsafe 的 crate 的 Miri 实践流程:
# 第 1 步:在 Miri 下运行所有测试
cargo +nightly miri test 2>&1 | tee miri_output.txt
# 第 2 步:如果 Miri 报错,隔离该测试
cargo +nightly miri test -- failing_test_name
# 第 3 步:使用 Miri 的回溯信息进行诊断
MIRIFLAGS="-Zmiri-backtrace=full" cargo +nightly miri test
# 第 4 步:选择借用模型
# Stacked Borrows (默认,最严格):
cargo +nightly miri test
# Tree Borrows (实验性,更宽松):
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test
常用场景的 Miri 标志:
# 禁用隔离 (允许访问文件系统、环境变量)
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test
# Miri 默认开启内存泄漏检测。
# 若要屏蔽泄漏错误 (例如有意为之的泄漏):
# MIRIFLAGS="-Zmiri-ignore-leaks" cargo +nightly miri test
# 为随机化测试设置种子,以保证结果可复现
MIRIFLAGS="-Zmiri-seed=42" cargo +nightly miri test
# 开启严格的来源 (provenance) 检查
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test
# 组合多个标志
MIRIFLAGS="-Zmiri-disable-isolation -Zmiri-backtrace=full -Zmiri-strict-provenance" \
cargo +nightly miri test
CI 中的 Miri 配置:
# .github/workflows/miri.yml
name: Miri
on: [push, pull_request]
jobs:
miri:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- name: Run Miri
run: cargo miri test --workspace
env:
MIRIFLAGS: "-Zmiri-backtrace=full"
# 默认开启泄漏检查。
# 跳过那些使用了 Miri 无法处理的系统调用的测试
# (如文件 I/O、网络等)
性能提示:Miri 比原生执行慢 10-100 倍。一个原生运行需 5 秒的测试套件,在 Miri 下可能需要 5 分钟。在 CI 中,建议仅对包含
unsafe代码的关键 crate 运行 Miri。
Valgrind 及其 Rust 集成
Valgrind 是经典的 C/C++ 内存检查器。它同样适用于编译后的 Rust 二进制文件,在机器码层面检查内存错误。
# 安装 Valgrind
sudo apt install valgrind # Debian/Ubuntu
sudo dnf install valgrind # Fedora
# 构建时包含调试信息 (Valgrind 需要符号表)
cargo build --tests
# 或者构建带调试信息的 release 版本:
# cargo build --release
# [profile.release]
# debug = true
# 在 Valgrind 下运行特定的测试二进制文件
valgrind --tool=memcheck \
--leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
./target/debug/deps/my_crate-abc123 --test-threads=1
# 运行主二进制文件
valgrind --tool=memcheck \
--leak-check=full \
--error-exitcode=1 \
./target/debug/diag_tool --run-diagnostics
除 memcheck 外的其他 Valgrind 工具:
| 工具 | 命令 | 检测内容 |
|---|---|---|
| Memcheck | --tool=memcheck | 内存泄漏、释放后使用、缓冲区溢出 |
| Helgrind | --tool=helgrind | 数据竞态和锁顺序违规 |
| DRD | --tool=drd | 数据竞态 (不同的检测算法) |
| Callgrind | --tool=callgrind | CPU 指令级分析 (路径级) |
| Massif | --tool=massif | 堆内存随时间的使用情况分析 |
| Cachegrind | --tool=cachegrind | 缓存未命中分析 |
使用 Callgrind 进行指令级分析:
# 记录指令计数 (比墙钟时间更稳定)
valgrind --tool=callgrind \
--callgrind-out-file=callgrind.out \
./target/release/diag_tool --run-diagnostics
# 使用 KCachegrind 可视化
kcachegrind callgrind.out
# 或使用命令行工具查看:
callgrind_annotate callgrind.out | head -100
Miri vs Valgrind — 如何选择:
| 维度 | Miri | Valgrind |
|---|---|---|
| 检查 Rust 特有的 UB | ✅ 支持 Stacked/Tree Borrows | ❌ 不理解 Rust 特有规则 |
| 检查 C FFI 代码 | ❌ 无法解释 C 代码 | ✅ 检查所有机器码 |
| 是否需要 Nightly | ✅ 是 | ❌ 否 |
| 运行速度 | 慢 10-100 倍 | 慢 10-50 倍 |
| 平台支持 | 任何 (解释 MIR) | Linux, macOS (运行原生代码) |
| 数据竞态检测 | ✅ 是 | ✅ 是 (Helgrind/DRD) |
| 内存泄漏检测 | ✅ 是 | ✅ 是 (更彻底) |
| 误报率 | 极低 | 偶有 (尤其是针对分配器时) |
建议两者结合使用:
- Miri 用于纯 Rust 的
unsafe代码(检查 Stacked Borrows、来源等)。 - Valgrind 用于包含大量 FFI 的代码以及全程序的泄漏分析。
AddressSanitizer, MemorySanitizer, ThreadSanitizer
LLVM sanitizer 是编译期插桩手段,它会在运行时插入检查。它们比 Valgrind 更快(开销为 2-5 倍,而 Valgrind 为 10-50 倍),且能捕捉不同类别的 Bug。
# 必需:安装 Rust 源码以便在使用 sanitizer 插桩时重新编译 std
rustup component add rust-src --toolchain nightly
# AddressSanitizer (ASan) — 缓冲区溢出、释放后使用、栈溢出
RUSTFLAGS="-Zsanitizer=address" \
cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# MemorySanitizer (MSan) — 读取未初始化内存
RUSTFLAGS="-Zsanitizer=memory" \
cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# ThreadSanitizer (TSan) — 数据竞态
RUSTFLAGS="-Zsanitizer=thread" \
cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# LeakSanitizer (LSan) — 内存泄漏 (默认已包含在 ASan 中)
RUSTFLAGS="-Zsanitizer=leak" \
cargo +nightly test --target x86_64-unknown-linux-gnu
注意:ASan, MSan 和 TSan 都需要
-Zbuild-std来重新编译标准库以便插入插桩信息。LSan 则不需要。
Sanitizer 对比:
| Sanitizer | 开销 | 捕捉内容 | 是否需 Nightly | 是否需 -Zbuild-std |
|---|---|---|---|---|
| ASan | 2倍内存, 2倍 CPU | 缓冲区溢出、释放后使用、栈溢出 | 是 | 是 |
| MSan | 3倍内存, 3倍 CPU | 读取未初始化内存 | 是 | 是 |
| TSan | 5-10倍内存, 5倍 CPU | 数据竞态 | 是 | 是 |
| LSan | 极小 | 内存泄漏 | 是 | 否 |
实践示例 — 使用 TSan 捕捉数据竞态:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::thread;
fn racy_counter() -> u64 {
// ❌ UB: 未经同步的共享可变状态
let data = Arc::new(std::cell::UnsafeCell::new(0u64));
let mut handles = vec![];
for _ in 0..4 {
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
// SAFETY: 这是一个不健壮的实现 —— 存在数据竞态!
unsafe {
*data.get() += 1;
}
}
}));
}
for h in handles {
h.join().unwrap();
}
// 正确结果应为 4000,但由于竞态,结果是不确定的
unsafe { *data.get() }
}
// Miri 和 TSan 都能捕捉到这一点:
// Miri: "Data race detected between (1) write and (2) write"
// TSan: "WARNING: ThreadSanitizer: data race"
//
// 修复:使用 AtomicU64 或 Mutex<u64>
}
相关工具:模糊测试与并发验证
cargo-fuzz — 覆盖率导向型模糊测试 (用于发现解析器和解码器中的崩溃):
# 安装
cargo install cargo-fuzz
# 初始化模糊测试目标
cargo fuzz init
cargo fuzz add parse_gpu_csv
#![allow(unused)]
fn main() {
// fuzz/fuzz_targets/parse_gpu_csv.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
// 模糊测试器会生成数百万个输入,试图寻找导致 panic 或崩溃的情况。
let _ = diag_tool::parse_gpu_csv(s);
}
});
}
# 运行模糊测试 (持续运行直至手动中断或发现崩溃)
cargo +nightly fuzz run parse_gpu_csv -- -max_total_time=300 # 运行 5 分钟
# 最小化崩溃用例
cargo +nightly fuzz tmin parse_gpu_csv artifacts/parse_gpu_csv/crash-...
何时进行模糊测试:任何解析不可信/半可信输入的函数(如传感器输出、配置文件、网络数据、JSON/CSV)。模糊测试在几乎所有主流 Rust 解析库(如 serde, regex, image)中都发现了真实的 Bug。
loom — 并发模型检查器 (详尽测试原子操作顺序):
[dev-dependencies]
loom = "0.7"
#![allow(unused)]
fn main() {
#[cfg(loom)]
mod tests {
use loom::sync::atomic::{AtomicUsize, Ordering};
use loom::thread;
#[test]
fn test_counter_is_atomic() {
loom::model(|| {
let counter = loom::sync::Arc::new(AtomicUsize::new(0));
let c1 = counter.clone();
let c2 = counter.clone();
let t1 = thread::spawn(move || { c1.fetch_add(1, Ordering::SeqCst); });
let t2 = thread::spawn(move || { c2.fetch_add(1, Ordering::SeqCst); });
t1.join().unwrap();
t2.join().unwrap();
// loom 会探索所有可能的线程交织情况
assert_eq!(counter.load(Ordering::SeqCst), 2);
});
}
}
}
何时使用
loom:当你编写无锁 (lock-free) 数据结构或自定义同步原语时。Loom 会穷尽探索线程间的各种交织 —— 它是一个模型检查器,而非压力测试器。基于Mutex/RwLock的代码通常不需要它。
验证工具决策树
Unsafe 验证决策树:
代码是纯 Rust 吗 (无 FFI)?
├─ 是 → 使用 Miri (捕捉 Rust 特有的 UB, Stacked Borrows)
│ 同时在 CI 中运行 ASan 以实现深度防御
└─ 否 (通过 FFI 调用了 C/C++ 代码)
├─ 担心内存安全?
│ └─ 是 → 同时使用 Valgrind memcheck 和 ASan
├─ 担心并发问题?
│ └─ 是 → 使用 TSan (更灵敏) 或 Helgrind (更彻底)
└─ 担心内存泄漏?
└─ 是 → 使用 Valgrind --leak-check=full
建议的 CI 矩阵:
# 并行运行所有工具以获得快速反馈
jobs:
miri:
runs-on: ubuntu-latest
steps:
- uses: dtolnay/rust-toolchain@nightly
with: { components: miri }
- run: cargo miri test --workspace
asan:
runs-on: ubuntu-latest
steps:
- uses: dtolnay/rust-toolchain@nightly
- run: |
RUSTFLAGS="-Zsanitizer=address" \
cargo test -Zbuild-std --target x86_64-unknown-linux-gnu
valgrind:
runs-on: ubuntu-latest
steps:
- run: sudo apt-get install -y valgrind
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --tests
- run: |
for test_bin in $(find target/debug/deps -maxdepth 1 -executable -type f ! -name '*.d'); do
valgrind --error-exitcode=1 --leak-check=full "$test_bin" --test-threads=1
done
应用:保持 Unsafe 为零 — 及其必要性
本项目在 9 万多行 Rust 代码中实现了 零 unsafe 块。对于一个系统级诊断工具来说,这是一个了不起的成就,证明了 Safe Rust 足以胜任:
- IPMI 通信(通过
std::process::Command调用ipmitool) - GPU 查询(通过
std::process::Command调用accel-query) - PCIe 拓扑解析(纯 JSON/文本解析)
- SEL 记录管理(纯数据结构)
- DER 报告生成(JSON 序列化)
本项目何时会需要 unsafe?
引入 unsafe 的潜在触发因素:
| 场景 | 引入 unsafe 的原因 | 建议的验证手段 |
|---|---|---|
| 基于 ioctl 的直接 IPMI | 使用 libc::ioctl() 绕过 ipmitool 子进程 | Miri + Valgrind |
| 直接调用 GPU 驱动接口 | 使用 accel-mgmt FFI 代替 accel-query 解析 | Valgrind (针对 C 库) |
| 内存映射 PCIe 配置空间 | 使用 mmap 直接读取配置空间 | ASan + Valgrind |
| 无锁 SEL 缓冲区 | 使用 AtomicPtr 进行并发事件采集 | Miri + TSan |
| 裸机 / no_std 变体 | 针对底层硬件进行原始指针操作 | Miri |
准备工作:在引入 unsafe 之前,先将验证工具集成到 CI 中:
# Cargo.toml — 为 unsafe 优化添加特性标志 (feature flag)
[features]
default = []
direct-ipmi = [] # 启用直接 ioctl IPMI,而非 ipmitool 子进程
direct-accel-api = [] # 启用 accel-mgmt FFI,而非 accel-query 解析
#![allow(unused)]
fn main() {
// src/ipmi.rs — 放在特性标志后面
#[cfg(feature = "direct-ipmi")]
mod direct {
//! 通过 /dev/ipmi0 ioctl 直接访问 IPMI 设备。
//!
//! # Safety
//! 本模块使用 `unsafe` 执行 ioctl 系统调用。
//! 已验证工具:Miri (尽可能)、Valgrind memcheck、ASan。
use std::os::unix::io::RawFd;
// ... unsafe ioctl 实现 ...
}
#[cfg(not(feature = "direct-ipmi"))]
mod subprocess {
//! 通过 ipmitool 子进程执行 IPMI (默认方案,完全安全)。
// ... 当前实现 ...
}
}
关键洞察:将
unsafe代码放在 特性标志 后面,这样就可以独立验证。在 CI 中运行cargo +nightly miri test --features direct-ipmi来持续验证 unsafe 路径,而不会影响默认的安全构建。
cargo-careful — Stable 上的额外 UB 检查
cargo-careful 运行时会开启标准库的额外检查 —— 捕捉到一些普通构建会忽略的未定义行为,它不需要 nightly 也不像 Miri 那样慢 100 倍:
# 安装 (需要 nightly,但运行代码的速度接近原生)
cargo install cargo-careful
# 运行带有额外 UB 检查的测试 (可捕捉未初始化内存、无效值)
cargo +nightly careful test
# 运行二进制文件
cargo +nightly careful run -- --run-diagnostics
cargo-careful 能捕捉到而普通构建不能捕捉到的:
- 对
MaybeUninit和zeroed()创建的未初始化内存的读取 - 通过 transmute 创建无效的
bool、char或枚举值 - 未对齐的指针读写
copy_nonoverlapping的范围重叠
它在验证阶梯中的位置:
最小开销 最彻底
├─ cargo test ──► cargo careful test ──► Miri ──► ASan ──► Valgrind ─┤
│ (0层开销) (~1.5倍开销) (10-100倍) (2倍) (10-50倍) │
│ 仅 Safe Rust 捕捉部分 UB 纯 Rust FFI+Rust FFI+Rust │
建议:将
cargo +nightly careful test加入 CI 作为一个快速安全检查。它的运行速度接近原生(不像 Miri),且能捕捉到 Safe Rust 抽象层掩盖的真实 Bug。
Miri 与 Sanitizer 常见排错
| 现象 | 原因 | 修复方法 |
|---|---|---|
Miri does not support FFI | Miri 是 Rust 解释器,无法执行 C 代码 | 改用 Valgrind 或 ASan 处理 FFI 代码 |
error: unsupported operation: can't call foreign function | Miri 触及了 extern "C" 调用 | 模拟 FFI 边界或使用 #[cfg(miri)] 屏蔽 |
Stacked Borrows violation | 违反了别名规则 —— 即使代码能“跑通” | Miri 是正确的;重构代码以避免 &mut 与 & 别名化 |
Sanitizer 提示 DEADLYSIGNAL | ASan 检测到了缓冲区溢出 | 检查数组索引、切片操作和指针算术 |
LeakSanitizer: detected memory leaks | Box::leak()、forget() 或遗漏了 drop() | 有意为之:使用 __lsan_disable() 屏蔽;无意为之:修复泄漏 |
| Miri 运行极其缓慢 | Miri 是解释执行,而非编译执行 | 仅对 --lib 测试运行,或对繁重的测试标记 #[cfg_attr(miri, ignore)] |
TSan: false positive 涉及原子操作 | TSan 可能无法完美理解 Rust 的原子排序模型 | 添加 TSAN_OPTIONS=suppressions=tsan.supp 进行特定屏蔽 |
亲自尝试
-
触发一次 Miri UB 检测:编写一个
unsafe函数,对同一个i32创建两个&mut引用(违反别名规则)。运行cargo +nightly miri test并观察 “Stacked Borrows” 错误。通过使用UnsafeCell或独立的分配空间来修复它。 -
在刻意制造的 Bug 上运行 ASan:创建一个包含
unsafe数组越界访问的测试。使用RUSTFLAGS="-Zsanitizer=address"进行构建并观察 ASan 报告。注意它是如何精准定位到那一行的。 -
衡量 Miri 的开销:在同一测试套件上计时
cargo test --lib与cargo +nightly miri test --lib,计算减速倍数。以此决定哪些测试应在 CI 的 Miri 任务中运行,哪些应使用#[cfg_attr(miri, ignore)]跳过。
安全验证决策树
flowchart TD
START["是否有 unsafe 代码?"] -->|否| SAFE["Safe Rust — 无需\n额外验证"]
START -->|是| KIND{"哪种类型?"}
KIND -->|"纯 Rust unsafe"| MIRI["Miri\nMIR 解释器\n捕捉别名、UB、泄漏"]
KIND -->|"FFI / C 互操作"| VALGRIND["Valgrind memcheck\n或 ASan"]
KIND -->|"并发 unsafe"| CONC{"是否无锁?"}
CONC -->|"原子操作/无锁"| LOOM["loom\n原子映射模型检查器"]
CONC -->|"Mutex/共享状态"| TSAN["TSan 或\nMiri -Zmiri-check-number-validity"]
MIRI --> CI_MIRI["CI: cargo +nightly miri test"]
VALGRIND --> CI_VALGRIND["CI: valgrind --leak-check=full"]
style SAFE fill:#91e5a3,color:#000
style MIRI fill:#e3f2fd,color:#000
style VALGRIND fill:#ffd43b,color:#000
style LOOM fill:#ff6b6b,color:#000
style TSAN fill:#ffd43b,color:#000
🏋️ 练习
🟡 练习 1:触发 Miri UB 检测
编写一个 unsafe 函数,对同一个 i32 创建两个 &mut 引用(违反别名规则)。运行 cargo +nightly miri test 并观察 Stacked Borrows 错误。修复它。
答案
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
#[test]
fn aliasing_ub() {
let mut x: i32 = 42;
let ptr = &mut x as *mut i32;
unsafe {
// BUG: 同一位置存在两个 &mut 引用
let _a = &mut *ptr;
let _b = &mut *ptr; // Miri: Stacked Borrows violation!
}
}
}
}
修复:使用独立的分配空间或 UnsafeCell:
#![allow(unused)]
fn main() {
use std::cell::UnsafeCell;
#[test]
fn no_aliasing_ub() {
let x = UnsafeCell::new(42);
unsafe {
let a = &mut *x.get();
*a = 100;
}
}
}
🔴 练习 2:ASan 越界检测
创建一个包含 unsafe 数组越界访问的测试。在 nightly 上使用 RUSTFLAGS="-Zsanitizer=address" 进行构建并观察 ASan 报告。
答案
#![allow(unused)]
fn main() {
#[test]
fn oob_access() {
let arr = [1u8, 2, 3, 4, 5];
let ptr = arr.as_ptr();
unsafe {
let _val = *ptr.add(10); // 越界访问!
}
}
}
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std \
--target x86_64-unknown-linux-gnu -- oob_access
# ASan 报告:stack-buffer-overflow at <具体地址>
关键收获
- Miri 是用于纯 Rust
unsafe代码的首选工具 —— 它能捕捉到别名规则违规、释放后使用,以及那些能通过编译和测试的内存泄漏。 - Valgrind 是针对 FFI/C 互操作的首选工具 —— 它无需重新编译即可对最终二进制文件生效。
- Sanitizer (ASan, TSan, MSan) 需要 nightly 环境,但其运行速度接近原生 —— 是大型测试套件的理想选择。
loom专门用于验证无锁并发数据结构。- 建议在每次 push 时的 CI 任务中运行 Miri;周期性运行 sanitizer 任务,以免拖慢主流水线。
依赖管理与供应链安全 🟢
你将学到:
- 使用
cargo-audit扫描已知漏洞- 使用
cargo-deny强制执行许可证、漏洞通告和源码策略- 使用 Mozilla 的
cargo-vet进行供应链信任验证- 跟踪过期的依赖并检测破坏性的 API 变更 (Breaking Changes)
- 可视化并去重你的依赖树
相关章节: 发布配置 —
cargo-udeps会在此处清理未使用的依赖 · CI/CD 流水线 — 流水线中的 audit 和 deny 任务 · 构建脚本 —build-dependencies同样也是供应链的一部分
一个 Rust 二进制文件不只是包含你的代码 —— 它包含了 Cargo.lock 中所有的传递依赖。该依赖树中任何地方出现的漏洞、许可证违规或恶意 crate 都会变成 你 的问题。本章涵盖了使依赖管理变得可审计且自动化的工具。
cargo-audit — 已知漏洞扫描
cargo-audit 会根据 RustSec 漏洞通告数据库 检查你的 Cargo.lock,该数据库跟踪了已发布的 crate 中已知的安全性漏洞。
# 安装
cargo install cargo-audit
# 扫描已知漏洞
cargo audit
# 输出示例:
# Crate: chrono
# Version: 0.4.19
# Title: Potential segfault in localtime_r invocations
# Date: 2020-11-10
# ID: RUSTSEC-2020-0159
# URL: https://rustsec.org/advisories/RUSTSEC-2020-0159
# 解决方案: 升级至 >= 0.4.20
# 如果存在漏洞,则使 CI 失败
cargo audit --deny warnings
# 为自动化处理生成 JSON 输出
cargo audit --json
# 通过更新 Cargo.lock 修复漏洞
cargo audit fix
CI 集成:
# .github/workflows/audit.yml
name: Security Audit
on:
schedule:
- cron: '0 0 * * *' # 每日检查 —— 漏洞通告会不断更新
push:
paths: ['Cargo.lock']
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
cargo-deny — 全面的策略强制执行
cargo-deny 的功能远不止漏洞扫描。它从四个维度强制执行策略:
- Advisories (通告) — 已知漏洞(类似于 cargo-audit)
- Licenses (许可证) — 允许/禁止的许可证列表
- Bans (禁止项) — 禁止的 crate 或重复的版本
- Sources (源码) — 允许的注册表 (Registry) 和 Git 源码
# 安装
cargo install cargo-deny
# 初始化配置
cargo deny init
# 创建带有文档注释默认值的 deny.toml
# 运行所有检查
cargo deny check
# 运行特定检查
cargo deny check advisories
cargo deny check licenses
cargo deny check bans
cargo deny check sources
deny.toml 示例:
# deny.toml
[advisories]
vulnerability = "deny" # 对已知漏洞报错
unmaintained = "warn" # 对不再维护的 crate 发出警告
yanked = "deny" # 对已撤回 (yanked) 的 crate 报错
notice = "warn" # 对通告性质的通知发出警告
[licenses]
unlicensed = "deny" # 所有 crate 必须有许可证
allow = [
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
]
copyleft = "deny" # 该项目禁止使用 GPL/LGPL/AGPL
default = "deny" # 拒绝任何未明确允许的项目
[bans]
multiple-versions = "warn" # 如果同一个 crate 出现了 2 个版本则发出警告
wildcards = "deny" # 禁止在依赖中使用 path = "*"
highlight = "all" # 显示所有重复项,而不仅是第一个
# 禁止特定的有问题 crate
deny = [
# openssl-sys 会拉取 C 语言的 OpenSSL —— 建议改用 rustls
{ name = "openssl-sys", wrappers = ["native-tls"] },
]
# 允许特定的重复版本(当无法避免时)
[[bans.skip]]
name = "syn"
version = "1.0" # syn 1.x 和 2.x 往往共存
对于商业项目而言,许可证强制执行非常有价值:
# 检查依赖树中包含哪些许可证
cargo deny list
# 输出示例:
# MIT — 127 个 crate
# Apache-2.0 — 89 个 crate
# BSD-3-Clause — 12 个 crate
# MPL-2.0 — 3 个 crate ← 可能需要法务审核
# Unicode-DFS — 1 个 crate
cargo-vet — 供应链信任验证
Mozilla 出品的 cargo-vet 解决的是另一个问题:不是“这个 crate 是否有已知的漏洞?”,而是“是否有受信任的人员实际审查过这段代码?”
# 安装
cargo install cargo-vet
# 初始化 (创建 supply-chain/ 目录)
cargo vet init
# 检查哪些 crate 需要审查
cargo vet
# 审查完一个 crate 后进行认证:
cargo vet certify serde 1.0.203
# 记录下你已针对自己的标准审计过 serde 1.0.203
# 从受信任的组织导入审计结果
cargo vet import mozilla
cargo vet import google
cargo vet import bytecode-alliance
工作机制:
supply-chain/
├── audits.toml ← 你所在团队的审计认证
├── config.toml ← 信任配置和准则
└── imports.lock ← 从其他组织锁定的导入项
cargo-vet 对于拥有严格供应链要求的组织(政府、金融、基础设施)最有价值。对于大多数团队来说,cargo-deny 已能提供足够的保护。
cargo-outdated 与 cargo-semver-checks
cargo-outdated — 查找拥有新版本的依赖项:
cargo install cargo-outdated
cargo outdated --workspace
# 输出示例:
# 名称 当前版本 兼容版本 最新版本 类型
# serde 1.0.193 1.0.203 1.0.203 Normal
# regex 1.9.6 1.10.4 1.10.4 Normal
# thiserror 1.0.50 1.0.61 2.0.3 Normal ← 存在大版本更新
cargo-semver-checks — 在发布前检测破坏性的 API 变更。这对于库 (Library) 类型的 crate 至关重要:
cargo install cargo-semver-checks
# 检查你的更改是否符合语义化版本 (semver)
cargo semver-checks
# 输出示例:
# ✗ 函数 `parse_gpu_csv` 现在变成了私有 (之前是公有)
# → 这是一个破坏性变更。请提升 MAJOR 版本。
#
# ✗ 结构体 `GpuInfo` 增加了一个新的必需字段 `power_limit_w`
# → 这是一个破坏性变更。请提升 MAJOR 版本。
#
# ✓ 增加了函数 `parse_gpu_csv_v2` (非破坏性变更)
cargo-tree — 依赖可视化与去重
cargo tree 内置于 Cargo 中(无需安装),它是理解依赖关系图的利器:
# 完整的依赖树
cargo tree
# 查找为什么引入了某个特定的 crate
cargo tree --invert --package openssl-sys
# 显示从你的 crate 到 openssl-sys 的所有路径
# 查找重复的版本
cargo tree --duplicates
# 输出示例:
# syn v1.0.109
# └── serde_derive v1.0.193
#
# syn v2.0.48
# ├── thiserror-impl v1.0.56
# └── tokio-macros v2.2.0
# 仅显示直接依赖
cargo tree --depth 1
# 显示依赖的特性 (features)
cargo tree --format "{p} {f}"
# 统计总依赖数
cargo tree | wc -l
去重策略:当 cargo tree --duplicates 显示同一个 crate 有两个大版本时,请检查是否可以更新依赖链以统一它们。每个重复项都会增加编译时间和二进制体积。
应用:多 Crate 依赖健康管理
本工作区使用 [workspace.dependencies] 进行中心化的版本管理 —— 这是一个极佳的实践。结合 cargo tree --duplicates 进行体积分析,可以防止版本漂移并减少二进制膨胀:
# 根目录 Cargo.toml — 在一处锁定所有版本
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
regex = "1.10"
thiserror = "1.0"
anyhow = "1.0"
rayon = "1.8"
建议为本项目添加:
# 添加到 CI 流水线:
cargo deny init # 一次性设置
cargo deny check # 每个 PR 运行 — 检查许可证、漏洞通告、禁止项
cargo audit --deny warnings # 每次 push 运行 — 漏洞扫描
cargo outdated --workspace # 每周运行 — 跟踪可用更新
建议为本项目配置 deny.toml:
[advisories]
vulnerability = "deny"
yanked = "deny"
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-DFS-2016"]
copyleft = "deny" # 硬件诊断工具 —— 不使用 Copyleft 协议
[bans]
multiple-versions = "warn" # 跟踪重复项,暂不阻止
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
供应链审计流水线
flowchart LR
PR["Pull Request"] --> AUDIT["cargo audit\n已知 CVE"]
AUDIT --> DENY["cargo deny check\n许可证 + 禁止项 + 源码"]
DENY --> OUTDATED["cargo outdated\n周度计划"]
OUTDATED --> SEMVER["cargo semver-checks\n仅限 Library crate"]
AUDIT -->|"失败"| BLOCK["❌ 阻止合并"]
DENY -->|"失败"| BLOCK
SEMVER -->|"存在破坏性变更"| BUMP["提升 Major 版本"]
style BLOCK fill:#ff6b6b,color:#000
style BUMP fill:#ffd43b,color:#000
style PR fill:#e3f2fd,color:#000
🏋️ 练习
🟢 练习 1:审计你的依赖
在任意 Rust 项目上运行 cargo audit 和 cargo deny init && cargo deny check。发现了多少条针对已知漏洞的通告?依赖树中包含多少种许可证类别?
答案
cargo audit
# 留意任何安全通告 —— 通常见于 chrono, time 或较旧的 crate
cargo deny init
cargo deny list
# 显示许可证明细:MIT (N), Apache-2.0 (N) 等。
cargo deny check
# 显示横跨四个维度的完整审计结果
🟡 练习 2:查找并消除重复依赖
在工作区运行 cargo tree --duplicates。找出一个出现两个版本的 crate。你能更新 Cargo.toml 来统一它们吗?衡量一下这对编译时间和二进制体积的影响。
答案
cargo tree --duplicates
# 常见情况:syn 1.x 和 syn 2.x
# 查找是谁拉取了旧版本:
cargo tree --invert --package [email protected]
# 输出示例:serde_derive 1.0.xxx -> syn 1.0.109
# 检查是否有使用 syn 2.x 的新版 serde_derive:
cargo update -p serde_derive
cargo tree --duplicates
# 如果 syn 1.x 消失了,说明你成功消除了一处重复
# 衡量影响:
time cargo build --release # 更新前后对比
cargo bloat --release --crates | head -20
关键收获
cargo audit能捕捉已知的 CVE 漏洞 —— 建议在每次 push 时以及按每日计划运行。cargo deny从四个维度强制执行策略:漏洞通告、许可证、禁止项以及源码。- 使用
[workspace.dependencies]在多 crate 工作区中进行版本中心化管理。 cargo tree --duplicates揭示了膨胀;每个重复项都会增加编译时间和二进制体积。cargo-vet适用于高安全性要求的环境;对于大多数团队来说,cargo-deny已足够。
发布配置与二进制体积 🟡
你将学到:
- 发布配置 (Release Profile) 详解:LTO、codegen-units、panic 策略、strip、opt-level
- Thin LTO、Fat LTO 与跨语言 LTO 的权衡
- 使用
cargo-bloat进行二进制体积分析- 使用
cargo-udeps、cargo-machete和cargo-shear清理冗余依赖相关章节: 编译期工具 — 优化的另一面 · 基准测试 — 在优化前先测量运行时间 · 依赖管理 — 剔除依赖可同时减少体积和编译时间
默认的 cargo build --release 已经非常优秀。但对于生产环境部署 —— 尤其是需要分发到数千台服务器的单一二进制工具而言,“优秀”与“极致优化”之间仍有不小的差距。本章涵盖了配置参数以及衡量二进制体积的工具。
发布配置详解
Cargo 配置项 (Profile) 控制着 rustc 编译代码的方式。默认设置较为保守 —— 旨在保证广泛的兼容性,而非追求极致性能:
# Cargo.toml — Cargo 内置的默认值(如果你什么都不写的话)
[profile.release]
opt-level = 3 # 优化级别 (0=无, 1=基础, 2=良好, 3=激进)
lto = false # 禁用链接期优化 (Link-time optimization)
codegen-units = 16 # 并行编译单元 (编译较快,但优化空间受限)
panic = "unwind" # panic 时的栈回溯 (体积较大,可使用 catch_unwind)
strip = "none" # 保留所有符号和调试信息
overflow-checks = false # release 模式下不进行整数溢出检查
debug = false # release 模式不包含调试信息
针对生产优化的配置(本项目已在使用):
[profile.release]
lto = true # 开启全量跨 crate 优化
codegen-units = 1 # 单个生成单元 —— 提供最大的优化机会
panic = "abort" # 无回溯开销 —— 更小、更快
strip = true # 移除所有符号 —— 二进制体积更小
每项设置的影响:
| 设置项 | 默认 → 优化后 | 二进制体积 | 运行时速度 | 编译时间 |
|---|---|---|---|---|
lto = false → true | — | -10% 到 -20% | +5% 到 +20% | 慢 2-5 倍 |
codegen-units = 16 → 1 | — | -5% 到 -10% | +5% 到 +10% | 慢 1.5-2 倍 |
panic = "unwind" → "abort" | — | -5% 到 -10% | 微乎其微 | 微乎其微 |
strip = "none" → true | — | -50% 到 -70% | 无 | 无 |
opt-level = 3 → "s" | — | -10% 到 -30% | -5% 到 -10% | 类似 |
opt-level = 3 → "z" | — | -15% 到 -40% | -10% 到 -20% | 类似 |
其他常用的配置微调:
[profile.release]
# 除上述设置外,还可以:
overflow-checks = true # 即使在 release 中也保留溢出检查 (安全 > 速度)
debug = "line-tables-only" # 仅保留行表,用于回溯时显示行号,而不包含完整的 DWARF
rpath = false # 不嵌入运行时库路径
incremental = false # 禁用增量编译 (为了更干净地构建最终版)
# 针对体积高度优化的构建 (如嵌入式、WASM):
# opt-level = "z" # 极其激进地针对体积进行优化
# strip = "symbols" # 只移除符号信息,保留调试段
针对单个 crate 的配置覆盖 (Overrides) — 仅对热点 crate 进行极致优化,其他保持原样:
# 开发模式:优化依赖项,但不优化自己的代码 (保证快速重新编译)
[profile.dev.package."*"]
opt-level = 2 # 开发模式下优化所有依赖项
# 发布模式:覆盖特定 crate 的优化级别
[profile.release.package.serde_json]
opt-level = 3 # 对 JSON 解析进行最大程度优化
codegen-units = 1
# 测试配置:匹配 release 行为,获取准确的集成测试结果
[profile.test]
opt-level = 1 # 基础优化,避免慢速测试超时
深入理解 LTO — Thin vs Fat vs 跨语言
链接期优化 (Link-Time Optimization) 允许 LLVM 跨越 crate 边界进行优化 —— 例如将 serde_json 的函数内联到你的解析代码中,或者移除 regex 中的死代码等。如果不开启 LTO,每个 crate 都是一个独立的“优化孤岛”。
[profile.release]
# 方案 1: Fat LTO (lto = true 时的默认值)
lto = true
# 所有代码合并为一个 LLVM 模块 → 优化空间最大
# 编译最慢,二进制体积最小、运行最快
# 方案 2: Thin LTO
lto = "thin"
# 保持每个 crate 独立,但 LLVM 会进行跨模块优化
# 比 Fat LTO 编译快,优化效果几乎同样出色
# 大多数项目的最佳折衷方案
# 方案 3: 不开启 LTO
lto = false
# 仅进行 crate 内部优化
# 编译最快,二进制体积较大
# 方案 4: 显式关闭
lto = "off"
# 等同于 false
Fat LTO 与 Thin LTO 对比:
| 维度 | Fat LTO (true) | Thin LTO ("thin") |
|---|---|---|
| 优化质量 | 最好 | 达到 Fat 的 ~95% |
| 编译时间 | 慢 (所有代码都在一个模块中) | 中等 (并行处理模块) |
| 内存占用 | 高 (所有 LLVM IR 都在内存中) | 较低 (流式处理) |
| 并行性 | 无 (单一模块) | 优秀 (跨模块并行) |
| 推荐场景 | 最终发布构建 | CI 构建、日常开发 |
跨语言 LTO — 跨越 Rust 与 C 的边界进行优化:
[profile.release]
lto = true
# Cargo.toml — 使用了 cc crate 的 crate
[build-dependencies]
cc = "1.0"
// build.rs — 启用跨语言 (linker-plugin) LTO
fn main() {
// cc crate 会遵循环境变量中的 CFLAGS。
// 对于跨语言 LTO,编译 C 代码时需使用:
// -flto=thin -O2
cc::Build::new()
.file("csrc/fast_parser.c")
.flag("-flto=thin")
.opt_level(2)
.compile("fast_parser");
}
# 启用链接器插件 LTO (需要兼容的 LLD 或 gold 链接器)
RUSTFLAGS="-Clinker-plugin-lto -Clinker=clang -Clink-arg=-fuse-ld=lld" \
cargo build --release
跨语言 LTO 允许 LLVM 将 C 函数内联到 Rust 调用者中,反之亦然。这对于频繁调用 C 语言小函数(如 IPMI ioctl 封装函数)的 FFI 密集型代码效果显著。
使用 cargo-bloat 进行二进制体积分析
cargo-bloat 能够回答:“二进制文件中哪些函数和 crate 占用的空间最多?”
# 安装
cargo install cargo-bloat
# 显示占用空间最大的前 20 个函数
cargo bloat --release -n 20
# 输出示例:
# 文件 .text 大小 Crate 名称
# 2.8% 5.1% 78.5KiB serde_json serde_json::de::Deserializer::parse_...
# 2.1% 3.8% 58.2KiB regex_syntax regex_syntax::ast::parse::ParserI::p...
# 1.5% 2.7% 42.1KiB accel_diag accel_diag::vendor::parse_smi_output
# ...
# 按 crate 显示(哪个依赖最占空间)
cargo bloat --release --crates
# 输出示例:
# 文件 .text 大小 Crate
# 12.3% 22.1% 340KiB serde_json
# 8.7% 15.6% 240KiB regex
# 6.2% 11.1% 170KiB std
# 5.1% 9.2% 141KiB accel_diag
# ...
# 对比两次构建(优化前后的变化)
cargo bloat --release --crates > before.txt
# ... 进行修改 ...
cargo bloat --release --crates > after.txt
diff before.txt after.txt
常见的代码膨胀来源及修复方法:
| 膨胀来源 | 典型体积 | 修复方法 |
|---|---|---|
regex (全量引擎) | 200-400 KB | 如果不需要 Unicode 支持,改用 regex-lite |
serde_json (全量) | 200-350 KB | 如果注重极致性能,考虑 simd-json 或 sonic-rs |
| 泛型实例化 (Monomorphization) | 可变 | 在 API 边界处使用 dyn Trait |
格式化机制 (Display, Debug) | 50-150 KB | 对大型枚举使用 #[derive(Debug)] 会累积不少体积 |
| Panic 错误字符串 | 20-80 KB | panic = "abort" 移除回溯逻辑,strip 移除字符串 |
| 未使用的特性 (Features) | 可变 | 禁用默认特性:serde = { version = "1", default-features = false } |
使用 cargo-udeps 清理冗余依赖
cargo-udeps 会找出 Cargo.toml 中声明了但代码中并未实际使用的依赖:
# 安装 (需要 nightly)
cargo install cargo-udeps
# 查找未使用的依赖
cargo +nightly udeps --workspace
# 输出示例:
# 未使用的依赖:
# `diag_tool v0.1.0`
# └── "tempfile" (dev-dependency)
#
# `accel_diag v0.1.0`
# └── "once_cell" ← 之前需要,现在有了 LazyLock 变成了冗余
每一个未使用的依赖都会:
- 增加编译时间
- 增加二进制体积
- 增加供应链风险
- 增加潜在的许可证合规复杂性
另一种选择:cargo-machete — 速度更快,基于启发式算法:
cargo install cargo-machete
cargo machete
# 速度快,但由于是单纯的静态扫描,可能会有误报
另一种选择:cargo-shear — 介于 cargo-udeps 和 cargo-machete 之间的平衡点:
cargo install cargo-shear
cargo shear --fix
# 比 machete 慢但比 udeps 快得多
# 误报率远低于 machete
体积优化决策树
flowchart TD
START["二进制文件太大?"] --> STRIP{"是否设置了 strip = true?"}
STRIP -->|"否"| DO_STRIP["添加 strip = true\n体积减少 -50% 到 -70%"]
STRIP -->|"是"| LTO{"是否启用了 LTO?"}
LTO -->|"否"| DO_LTO["添加 lto = true\n以及 codegen-units = 1"]
LTO -->|"是"| BLOAT["运行 cargo-bloat\n--crates 查看明细"]
BLOAT --> BIG_DEP{"是否有超大的依赖?"}
BIG_DEP -->|"是"| REPLACE["替换为轻量级方案\n或禁用默认特性"]
BIG_DEP -->|"否"| UDEPS["使用 cargo-udeps\n移除未使用的依赖"]
UDEPS --> OPT_LEVEL{"还需要更小?"}
OPT_LEVEL -->|"是"| SIZE_OPT["设置 opt-level = 's' 或 'z'"]
style DO_STRIP fill:#91e5a3,color:#000
style DO_LTO fill:#e3f2fd,color:#000
style REPLACE fill:#ffd43b,color:#000
style SIZE_OPT fill:#ff6b6b,color:#000
🏋️ 练习
🟢 练习 1:测量 LTO 的影响
先使用默认的编译配置构建项目,然后再尝试添加 lto = true + codegen-units = 1 + strip = true。对比二进制文件的体积和编译时间。
答案
# 默认配置构建
cargo build --release
ls -lh target/release/my-binary
time cargo build --release # 记录时间
# 优化后的配置 —— 在 Cargo.toml 中添加:
# [profile.release]
# lto = true
# codegen-units = 1
# strip = true
# panic = "abort"
cargo clean
cargo build --release
ls -lh target/release/my-binary # 通常会减少 30-50% 的体积
time cargo build --release # 编译时间通常会增加 2-3 倍
🟡 练习 2:找出你项目中最大的 Crate
在项目上运行 cargo bloat --release --crates。识别出最大的依赖项。你能否通过禁用默认特性或切换到轻量级替代方案来减小它的体积?
答案
cargo install cargo-bloat
cargo bloat --release --crates
# 输出示例:
# 文件 .text 大小 Crate
# 12.3% 22.1% 340KiB serde_json
# 8.7% 15.6% 240KiB regex
# 针对 regex — 如果不需要 Unicode 尝试 regex-lite:
# regex-lite = "0.1" # 比全量 regex 小约 10 倍
# 针对 serde — 如果不需要标准库则禁用默认特性:
# serde = { version = "1", default-features = false, features = ["derive"] }
cargo bloat --release --crates # 修改后再次对比
关键收获
lto = true+codegen-units = 1+strip = true+panic = "abort"是生产环境发布版的理想配置。- Thin LTO (
lto = "thin") 仅需较小的编译成本,即可获得 Fat LTO 约 80% 的收益。 cargo-bloat --crates能清晰展示哪些依赖在吞噬二进制空间。cargo-udeps、cargo-machete和cargo-shear能帮你清理浪费编译时间且增加体积的沉余依赖。- 通过针对单个 crate 的配置覆盖 (Profile Overwrites),可以在不拖慢整体构建的前提下优化性能热点。
编译期与开发工具 🟡
你将学到:
- 使用
sccache为本地和 CI 构建加速- 使用
mold实现更快的链接(比默认链接器快 3-10 倍)cargo-nextest:一个更快、信息更丰富的测试运行器- 开发者生产力工具:
cargo-expand、cargo-geiger、cargo-watch- 配置工作区 Lint (Lints)、MSRV 策略以及文档即 CI
相关章节: 发布配置 — LTO 和二进制体积优化 · CI/CD 流水线 — 这些工具在流水线中的集成 · 依赖管理 — 减少依赖 = 编译更快
编译期优化:sccache, mold, cargo-nextest
漫长的编译时间是 Rust 开发者最大的痛点。以下工具组合可以减少 50-80% 的迭代延迟:
sccache — 共享编译缓存:
# 安装
cargo install sccache
# 配置为 Rust 封装器 (Wrapper)
export RUSTC_WRAPPER=sccache
# 或者在 .cargo/config.toml 中永久设置:
# [build]
# rustc-wrapper = "sccache"
# 第一次构建:正常速度(填充缓存)
cargo build --release # 耗时 3 分钟
# 执行清理并重新构建:命中未改变的 crate 缓存
cargo clean && cargo build --release # 耗时 45 秒
# 检查缓存统计信息
sccache --show-stats
# 编译请求数 1,234
# 缓存命中数 987 (80%)
# 缓存未命中数 247
sccache 支持共享缓存(S3, GCS, Azure Blob),适用于团队协作和 CI 环境下的缓存共享。
mold — 更快的链接器:
链接通常是编译过程中最慢的阶段。mold 比 lld 快 3-5 倍,比默认的 GNU ld 快 10-20 倍:
# 安装
sudo apt install mold # Ubuntu 22.04+
# 注意:mold 针对 ELF 目标 (Linux)。macOS 使用的是 Mach-O 而非 ELF。
# macOS 的链接器 (ld64) 本身已经很快;如果你追求更快:
# brew install sold # sold = 针对 Mach-O 的 mold (实验性)
# 在实践中,macOS 的链接时间很少成为瓶颈。
# 在 .cargo/config.toml 中启用 mold 链接器
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
# 验证是否正在使用 mold
cargo build -v 2>&1 | grep mold
cargo-nextest — 更快的测试运行器:
# 安装
cargo install cargo-nextest
# 运行测试(默认并行,对每个测试设置超时及重试)
cargo nextest run
# 相比 cargo test 的关键优势:
# - 每个测试都在独立的进程中运行 → 隔离性更好
# - 带有智能调度的并行执行
# - 每个测试都有超时控制(CI 不会再卡死)
# - 导出 JUnit XML 格式供 CI 使用
# - 可自动重试失败的测试
# 常用配置
cargo nextest run --retries 2 --fail-fast
# 归档测试二进制文件(适用于 CI:构建一次,在多台机器上测试)
cargo nextest archive --archive-file tests.tar.zst
cargo nextest run --archive-file tests.tar.zst
# .config/nextest.toml
[profile.default]
retries = 0
slow-timeout = { period = "60s", terminate-after = 3 }
fail-fast = true
[profile.ci]
retries = 2
fail-fast = false
junit = { path = "test-results.xml" }
综合开发环境配置示例:
# .cargo/config.toml — 优化开发迭代闭环
[build]
rustc-wrapper = "sccache" # 缓存编译产物
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"] # 更快的链接
# 开发配置 (Dev profile):优化依赖但不优化自己的代码
# (放入 Cargo.toml)
# [profile.dev.package."*"]
# opt-level = 2
cargo-expand 与 cargo-geiger — 可见性工具
cargo-expand — 查看宏生成的代码:
cargo install cargo-expand
# 展开特定模块中的所有宏
cargo expand --lib accel_diag::vendor
# 展开特定的派生宏
# 假设有:#[derive(Debug, Serialize, Deserialize)]
# cargo expand 将显示生成的 impl 代码块
cargo expand --lib --tests
这对于调试 #[derive] 宏输出、macro_rules! 展开以及理解 serde 为你的类型生成了什么非常有价值。
除了 cargo-expand,你也可以直接在 rust-analyzer 中展开宏:
- 将光标移动到目标宏上。
- 打开命令面板 (VSCode 中为
F1)。 - 搜索
rust-analyzer: Expand macro recursively at caret。
cargo-geiger — 统计依赖树中的 unsafe 使用情况:
cargo install cargo-geiger
cargo geiger
# 输出示例:
# 指标格式:x/y
# x = 构建实际使用的 unsafe 代码
# y = crate 中发现的总 unsafe 代码
#
# 函数 表达式 Impls Traits 方法
# 0/0 0/0 0/0 0/0 0/0 ✅ my_crate
# 0/5 0/23 0/2 0/0 0/3 ✅ serde
# 3/3 14/14 0/0 0/0 2/2 ❗ libc
# 15/15 142/142 4/4 0/0 12/12 ☢️ ring
# 符号含义:
# ✅ = 未使用 unsafe
# ❗ = 使用了少量 unsafe
# ☢️ = 重度使用 unsafe
针对本项目的“零 unsafe”政策,cargo geiger 可以验证是否没有任何依赖项在你实际执行的代码路径中引入了 unsafe 代码。
工作区 Lint 配置 — [workspace.lints]
自 Rust 1.74 起,你可以在 Cargo.toml 中统一配置 Clippy 和编译器 Lint —— 不必再在每个 crate 顶层写 #![deny(...)]:
# 根目录 Cargo.toml — 为所有 crate 配置 lint
[workspace.lints.clippy]
unwrap_used = "warn" # 倾向于使用 ? 或 expect("原因")
dbg_macro = "deny" # 禁止在提交的代码中包含 dbg!()
todo = "warn" # 跟踪未完成的实现
large_enum_variant = "warn" # 发现意外的体积膨胀
[workspace.lints.rust]
unsafe_code = "deny" # 强制执行零 unsafe 政策
missing_docs = "warn" # 鼓励编写文档
# 每个 crate 的 Cargo.toml — 选用工作区 lint 配置
[lints]
workspace = true
这取代了零散的 #![deny(clippy::unwrap_used)] 属性,并确保整个工作区的政策一致。
自动修复 Clippy 警告:
# 让 Clippy 自动应用可机器修复的建议
cargo clippy --fix --workspace --all-targets --allow-dirty
# 修复并应用那些可能改变行为的建议(需仔细审核!)
cargo clippy --fix --workspace --all-targets --allow-dirty -- -W clippy::pedantic
提示:在提交代码前运行
cargo clippy --fix。它可以处理大部分琐碎问题(如未使用导入、冗余克隆、类型简化),节省手动修改的时间。
MSRV 政策与 rust-version
最低支持 Rust 版本 (Minimum Supported Rust Version, MSRV) 确保你的 crate 可以在较旧的工具链上编译。这在部署至 Rust 版本固定的系统时非常重要。
# Cargo.toml
[package]
name = "diag_tool"
version = "0.1.0"
rust-version = "1.75" # 所需的最低 Rust 版本
# 验证 MSRV 兼容性
cargo +1.75.0 check --workspace
# 自动探测 MSRV
cargo install cargo-msrv
cargo msrv find
# 输出示例:Minimum Supported Rust Version is 1.75.0
# 在 CI 中验证
cargo msrv verify
MSRV 策略建议:
- 二进制应用程序 (如本项目):使用最新稳定版。通常不需要刻意设置过旧的 MSRV。
- 库类型 crate (发布到 crates.io):将 MSRV 设置为支持你所用特性的最旧 Rust 版本。通常采取
N-2策略(比当前版本落后 2 个版本)。 - 企业部署:设置 MSRV 以匹配你服务器集群中安装的最旧 Rust 版本。
应用:生产环境二进制配置
本项目已经拥有一个出色的 发布配置 (release profile):
# 根目录 Cargo.toml
[profile.release]
lto = true # ✅ 开启全量跨 crate 优化
codegen-units = 1 # ✅ 最大程度优化
panic = "abort" # ✅ 移除回溯开销
strip = true # ✅ 部署时移除符号表
[profile.dev]
opt-level = 0 # ✅ 快速编译
debug = true # ✅ 完整的调试信息
进一步建议:
# 在开发模式下优化依赖项(加快测试执行速度)
[profile.dev.package."*"]
opt-level = 2
# 测试配置:进行基础优化,防止慢速测试超时
[profile.test]
opt-level = 1
# 在发布版中保留溢出检查(出于安全考虑)
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
overflow-checks = true # ← 建议添加:捕捉整数溢出
debug = "line-tables-only" # ← 建议添加:保留用于回溯的行表,压缩体积
建议的开发者工具配置:
# .cargo/config.toml (建议)
[build]
rustc-wrapper = "sccache" # 首次构建后缓存命中率可达 80%+
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"] # 链接速度提升 3-5 倍
预期收益:
| 指标 | 当前 | 优化后 |
|---|---|---|
| 发布版二进制体积 | 约 10 MB (已 strip, 开启 LTO) | 保持一致 |
| 开发环境构建耗时 | 约 45s | 约 25s (sccache + mold) |
| 重新构建 (修改 1 个文件) | 约 15s | 约 5s (sccache + mold) |
| 测试执行 | cargo test | cargo nextest — 快 2 倍 |
| 依赖漏洞扫描 | 无 | CI 中使用 cargo audit |
| 许可证合规检查 | 手动 | CI 中使用 cargo deny 自动化 |
| 冗余依赖检测 | 手动 | CI 中使用 cargo udeps |
cargo-watch — 文件变更自动重新构建
cargo-watch 在源文件发生变化时重新运行命令 —— 是实现快速反馈环的关键:
# 安装
cargo install cargo-watch
# 每次保存时自动检查(即时反馈)
cargo watch -x check
# 变更后运行 clippy + 测试
cargo watch -x 'clippy --workspace --all-targets' -x 'test --workspace --lib'
# 仅监听特定的 crate(大型工作区中速度更快)
cargo watch -w accel_diag/src -x 'test -p accel_diag'
# 每次运行前清屏
cargo watch -c -x check
提示:将其与上述的
mold和sccache结合使用,可以在增量修改后实现亚秒级的重新检查。
cargo doc 与工作区文档
对于大型工作区,生成的文档对于代码的可维护性和上手门槛至关重要。cargo doc 使用 rustdoc 从文档注释和类型签名中生成 HTML 文档:
# 为所有工作区 crate 生成文档 (自动在浏览器打开)
cargo doc --workspace --no-deps --open
# 包含私有项 (开发调试时非常有用)
cargo doc --workspace --no-deps --document-private-items
# 仅检查文档链接是否损坏 (快速 CI 检查)
cargo doc --workspace --no-deps 2>&1 | grep -E 'warning|error'
文档内引用 (Intra-doc links) — 无需 URL 即可在跨 crate 的类型间建立链接:
#![allow(unused)]
fn main() {
/// 使用 [`GpuConfig`] 设置运行 GPU 诊断。
///
/// 具体实现请参考 [`crate::accel_diag::run_diagnostics`].
/// 返回 [`DiagResult`],可序列化为
/// [`DerReport`](crate::core_lib::DerReport) 格式。
pub fn run_accel_diag(config: &GpuConfig) -> DiagResult {
// ...
}
}
在文档中展示平台特定的 API:
#![allow(unused)]
fn main() {
// Cargo.toml: [package.metadata.docs.rs]
// all-features = true
// rustdoc-args = ["--cfg", "docsrs"]
/// 仅限 Windows:通过 Win32 API 读取电池状态。
///
/// 仅可在 `cfg(windows)` 环境下使用。
#[cfg(windows)]
#[doc(cfg(windows))] // 在文档中显示“仅在 Windows 可用”的徽章
pub fn get_battery_status() -> Option<u8> {
// ...
}
}
CI 中的文档检查:
# 添加到 CI 工作流
- name: Check documentation
run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
# 将损坏的文档链接视为错误
针对本项目:由于包含多个 crate,
cargo doc --workspace是新成员快速了解 API 全貌的最佳方式。建议将RUSTDOCFLAGS="-D warnings"加入 CI,以便在合并前拦截损坏的文档链接。
编译期优化决策树
flowchart TD
START["编译太慢?"] --> WHERE{"瓶颈在哪里?"}
WHERE -->|"重新编译\n未修改的 crate"| SCCACHE["sccache\n共享编译缓存"]
WHERE -->|"链接阶段"| MOLD["mold 链接器\n速度提升 3-10 倍"]
WHERE -->|"执行测试"| NEXTEST["cargo-nextest\n并行测试运行器"]
WHERE -->|"全方位加速"| COMBO["上述全部工具 +\n使用 cargo-udeps 清理依赖"]
SCCACHE --> CI_CACHE{"CI 还是本地?"}
CI_CACHE -->|"CI"| S3["S3/GCS 共享缓存"]
CI_CACHE -->|"本地"| LOCAL["自动配置的本地磁盘缓存"]
style SCCACHE fill:#91e5a3,color:#000
style MOLD fill:#e3f2fd,color:#000
style NEXTEST fill:#ffd43b,color:#000
style COMBO fill:#b39ddb,color:#000
🏋️ 练习
🟢 练习 1:配置 sccache + mold
安装 sccache 和 mold,在 .cargo/config.toml 中配置它们,然后测量干净构建 (clean rebuild) 时的编译提速。
答案
# 安装
cargo install sccache
sudo apt install mold # Ubuntu 22.04+
# 配置 .cargo/config.toml
cat > .cargo/config.toml << 'EOF'
[build]
rustc-wrapper = "sccache"
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
EOF
# 第一次构建(填充缓存)
time cargo build --release # 例如 180s
# 执行清理并重新构建(命中缓存)
cargo clean
time cargo build --release # 例如 45s
sccache --show-stats
# 缓存命中率应在 60-80% 以上
🟡 练习 2:切换到 cargo-nextest
安装 cargo-nextest 并运行你的测试套件。对比其与 cargo test 的墙钟时间。提速效果如何?
答案
cargo install cargo-nextest
# 标准测试运行器
time cargo test --workspace 2>&1 | tail -5
# nextest (并行跨二进制文件执行)
time cargo nextest run --workspace 2>&1 | tail -5
# 对于大型工作区,提速通常在 2-5 倍。
# nextest 还提供:
# - 每个测试的耗时统计
# - 对不稳定的测试进行重试
# - 导出供 CI 使用的 JUnit XML
cargo nextest run --workspace --retries 2
关键收获
- 配合 S3/GCS 后端的
sccache可以跨团队和 CI 共享编译缓存。 mold是目前最快的 ELF 链接器 —— 链接时间可从秒级降至毫秒级。cargo-nextest并行运行测试二进制文件,并提供更好的输出支持及重试机制。cargo-geiger用于统计unsafe使用量 —— 在引入新依赖前建议先以此检查。[workspace.lints]可以在大型工作区顶层统一管理 Clippy 和 rustc 的 lint 规则。
no_std 与特性验证 🔴
你将学到:
- 使用
cargo-hack系统地验证特性 (Feature) 组合- Rust 的三个层面:
corevsallocvsstd以及各自的适用场景- 使用自定义 panic 处理程序和分配器构建
no_stdcrate- 在宿主机和 QEMU 上测试
no_std代码相关章节: Windows 与条件编译 — 该话题的另一半:平台相关性 · 交叉编译 — 交叉编译到 ARM 和嵌入式目标 · Miri 与 Sanitizer — 验证
no_std环境中的unsafe代码 · 构建脚本 — 由build.rs发出的cfg标志
从 8 位微控制器到云端服务器,Rust 几乎运行在任何地方。本章涵盖了基础知识:通过 #![no_std] 剥离标准库,并验证你的特性组合是否能实际通过编译。
使用 cargo-hack 验证特性组合
cargo-hack 能够系统地测试所有特性组合 —— 这对于包含 #[cfg(...)] 代码的 crate 来说至关重要:
# 安装
cargo install cargo-hack
# 检查每个特性是否都能独立通过编译
cargo hack check --each-feature --workspace
# 终极方案:测试所有特性组合(指数级增长!)
# 仅建议在特性数少于 8 个的 crate 上运行。
cargo hack check --feature-powerset --workspace
# 务实的折衷方案:分别测试每个特性运行 + 全部特性运行 + 无特性运行
cargo hack check --each-feature --workspace --no-dev-deps
cargo check --workspace --all-features
cargo check --workspace --no-default-features
为什么这对于本项目很重要:
如果你添加了平台特性(如 linux、windows、direct-ipmi、direct-accel-api),cargo-hack 能及时捕捉到会导致构建失败的组合:
# 示例:控制平台代码的特性
[features]
default = ["linux"]
linux = [] # Linux 特有的硬件访问
windows = ["dep:windows-sys"] # Windows 特有的 API
direct-ipmi = [] # 使用 unsafe 的 IPMI ioctl (见第5章)
direct-accel-api = [] # 使用 unsafe 的 accel-mgmt FFI (见第5章)
# 验证所有特性在隔离以及组合情况下均能编译通过
cargo hack check --each-feature -p diag_tool
# 捕捉错误:“feature 'windows' doesn't compile without 'direct-ipmi'”
# 捕捉错误:“#[cfg(feature = "linux")] 拼写错误 — 误写成了 'lnux'”
CI 集成:
# 添加到 CI 流水线 (由于仅执行编译检查,速度很快)
- name: Feature matrix check
run: cargo hack check --each-feature --workspace --no-dev-deps
经验法则:对于任何拥有 2 个以上特性的 crate,建议在 CI 中运行
cargo hack check --each-feature。对于少于 8 个特性的核心库 crate,才运行--feature-powerset—— 它是指数级的($2^n$ 种组合)。
no_std — 何时以及为何使用
#![no_std] 告诉编译器:“不要链接标准库。” 你的 crate 将只能使用 core(以及可选的 alloc)。为什么要这么做?
| 场景 | 为什么选择 no_std |
|---|---|
| 嵌入式固件 (ARM Cortex-M, RISC-V) | 无操作系统、无堆内存、无文件系统 |
| UEFI 诊断工具 | 预启环境,无操作系统 API |
| 内核模块 | 内核空间无法使用用户空间的 std |
| WebAssembly (WASM) | 最小化二进制体积,无操作系统依赖 |
| 引导程序 (Bootloaders) | 在任何操作系统加载前运行 |
| 带有 C 接口的共享库 | 避免在调用者中引入 Rust 运行时 |
对于硬件诊断工具,当构建以下内容时,no_std 变得非常相关:
- 基于 UEFI 的预启诊断工具(在操作系统加载前运行)
- BMC 固件诊断(资源受限的 ARM SoC)
- 内核级 PCIe 诊断(内核模块或 eBPF 探针)
core vs alloc vs std — 三个层面
┌─────────────────────────────────────────────────────────────┐
│ std │
│ 包含 core + alloc 的所有内容,外加: │
│ • 文件 I/O (std::fs, std::io) │
│ • 网络 (std::net) │
│ • 线程 (std::thread) │
│ • 时间 (std::time) │
│ • 环境变量 (std::env) │
│ • 进程 (std::process) │
│ • 操作系统特定 (std::os::unix, std::os::windows) │
├─────────────────────────────────────────────────────────────┤
│ alloc (在 #![no_std] 下可用,需手动声明 extern crate│
│ alloc,且必须配置有全局分配器) │
│ • String, Vec, Box, Rc, Arc │
│ • BTreeMap, BTreeSet │
│ • format!() 宏 │
│ • 任何需要堆空间的代码集合与智能指针 │
├─────────────────────────────────────────────────────────────┤
│ core (即使在 #![no_std] 下也总是可用) │
│ • 基础类型 (u8, bool, char 等) │
│ • Option, Result │
│ • 迭代器、切片、数组、str (指 slice,而非 String) │
│ • Trait: Clone, Copy, Debug, Display, From, Into │
│ • 原子操作 (core::sync::atomic) │
│ • Cell, RefCell (core::cell) —— Pin (core::pin) │
│ • core::fmt (无需分配空间的格式化) │
│ • core::mem, core::ptr (低层内存操作) │
│ • 数学运算: core::num, 基础算术 │
└─────────────────────────────────────────────────────────────┘
失去 std 会带来什么影响:
- 没有
HashMap(需要哈希器 —— 改用alloc中的BTreeMap或hashbrown) - 没有
println!()(需要标准输出 —— 改用core::fmt::Write写入缓冲区) - 没有
std::error::Error(自 Rust 1.81 起已在core中稳定,但许多生态库尚未迁移) - 没有文件 I/O、无网络、无线程(除非由平台 HAL 提供)
- 没有
Mutex(改用spin::Mutex或平台特有的锁)
构建一个 no_std Crate
#![allow(unused)]
fn main() {
// src/lib.rs — 一个 no_std 库 crate
#![no_std]
// 可选:使用堆分配 (heap allocation)
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;
/// 从热传感器读取的温度。
/// 该结构体适用于任何环境 —— 从裸机到 Linux。
#[derive(Clone, Copy, Debug)]
pub struct Temperature {
/// 原始传感器值(对于典型的 I2C 传感器,每 LSB 为 0.0625°C)
raw: u16,
}
impl Temperature {
pub const fn from_raw(raw: u16) -> Self {
Self { raw }
}
/// 转换为摄氏度(定点运算,无需 FPU)
pub const fn millidegrees_c(&self) -> i32 {
(self.raw as i32) * 625 / 10 // 0.0625°C 分辨率
}
pub fn degrees_c(&self) -> f32 {
self.raw as f32 * 0.0625
}
}
impl fmt::Display for Temperature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let md = self.millidegrees_c();
// 处理 -0.999°C 到 -0.001°C 之间的符号显示
// 此时 md / 1000 == 0,但数值实际上是负数。
if md < 0 && md > -1000 {
write!(f, "-0.{:03}°C", (-md) % 1000)
} else {
write!(f, "{}.{:03}°C", md / 1000, (md % 1000).abs())
}
}
}
/// 解析以空格分隔的温度值。
/// 使用了 alloc —— 需要全局分配器。
pub fn parse_temperatures(input: &str) -> Vec<Temperature> {
input
.split_whitespace()
.filter_map(|s| s.parse::<u16>().ok())
.map(Temperature::from_raw)
.collect()
}
/// 无需分配空间的格式化 —— 直接写入缓冲区。
/// 适用于仅 `core` 的环境(无 alloc,无堆)。
pub fn format_temp_into(temp: &Temperature, buf: &mut [u8]) -> usize {
use core::fmt::Write;
struct SliceWriter<'a> {
buf: &'a mut [u8],
pos: usize,
}
impl<'a> Write for SliceWriter<'a> {
fn write_str(&mut self, s: &str) -> fmt::Result {
let bytes = s.as_bytes();
let remaining = self.buf.len() - self.pos;
if bytes.len() > remaining {
// 缓冲区已满 —— 返回错误而非静默截断
return Err(fmt::Error);
}
self.buf[self.pos..self.pos + bytes.len()].copy_from_slice(bytes);
self.pos += bytes.len();
Ok(())
}
}
let mut w = SliceWriter { buf, pos: 0 };
let _ = write!(w, "{}", temp);
w.pos
}
}
# no_std crate 的 Cargo.toml
[package]
name = "thermal-sensor"
version = "0.1.0"
edition = "2021"
[features]
default = ["alloc"]
alloc = [] # 启用 Vec, String 等
std = [] # 启用完整的 std (隐含启用 alloc)
[dependencies]
# 使用兼容 no_std 的 crate
serde = { version = "1.0", default-features = false, features = ["derive"] }
# ↑ default-features = false 移除了对 std 的依赖!
核心 Crate 模式:许多流行 crate(如 serde, log, rand, embedded-hal)通过
default-features = false支持no_std。在no_std上下文中使用依赖项之前,请务必检查其是否强制要求std。请注意,某些 crate(如regex)至少需要alloc,无法在仅core的环境下运行。
自定义 Panic 处理程序与分配器
在 #![no_std] 二进制文件(而非库)中,你必须提供 panic 处理程序,并可选地提供全局分配器:
// src/main.rs — 一个 no_std 二进制文件 (例如 UEFI 诊断程序)
#![no_std]
#![no_main]
extern crate alloc;
use core::panic::PanicInfo;
// 必需:panic 时的处理逻辑(此时无法执行栈回溯)
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// 嵌入式环境下:闪烁 LED、写入串口、由于死循环挂起
// UEFI 环境下:打印到控制台、停机
// 最简方案:无限循环
loop {
core::hint::spin_loop();
}
}
// 如果使用了 alloc 则必需:提供全局分配器
use alloc::alloc::{GlobalAlloc, Layout};
struct BumpAllocator {
// 适用于嵌入式/UEFI 的简单线性分配器 (Bump Allocator)
// 实践中,建议使用 `linked_list_allocator` 或 `embedded-alloc` 等 crate
}
// 警告:这只是一个非功能的占位符!调用 alloc() 将返回空指针,
// 从而导致立即触发 UB(全局分配器契约要求对非零字节的分配返回非空指针)。
// 在实际代码中,请使用成熟的分配器 crate:
// - embedded-alloc (针对嵌入式目标)
// - linked_list_allocator (针对 UEFI / 内核)
// - talc (通用的 no_std 分配器)
unsafe impl GlobalAlloc for BumpAllocator {
unsafe fn alloc(&self, _layout: Layout) -> *mut u8 {
// 占位符 —— 会导致崩溃!请替换为真实的分配逻辑。
core::ptr::null_mut()
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// 线性分配器通常不执行释放操作
}
}
#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {};
// 入口点 (平台相关,而非简单的 fn main)
// 针对 UEFI: #[entry] 或 efi_main
// 针对嵌入式: #[cortex_m_rt::entry]
测试 no_std 代码
测试是在宿主机上运行的,而宿主机拥有 std。技巧在于:你的库虽然是 no_std 的,但你的测试运行器使用的是 std:
#![allow(unused)]
fn main() {
// 你的 crate: 在 src/lib.rs 中声明 #![no_std]
// 但测试会自动在 std 下运行:
#[cfg(test)]
mod tests {
use super::*;
// 此处 std 可用 —— println!, assert!, Vec 等均可正常工作
#[test]
fn test_temperature_conversion() {
let temp = Temperature::from_raw(800); // 50.0°C
assert_eq!(temp.millidegrees_c(), 50000);
assert!((temp.degrees_c() - 50.0).abs() < 0.01);
}
#[test]
fn test_format_into_buffer() {
let temp = Temperature::from_raw(800);
let mut buf = [0u8; 32];
let len = format_temp_into(&temp, &mut buf);
let s = core::str::from_utf8(&buf[..len]).unwrap();
assert_eq!(s, "50.000°C");
}
}
}
在实际目标上测试(当完全没有 std 可用时):
# 使用 defmt-test 进行片上测试 (嵌入式 ARM)
# 使用 uefi-test-runner 进行 UEFI 目标测试
# 使用 QEMU 进行跨架构测试(无需真实硬件)
# 在宿主机上运行 no_std 库测试(始终可行):
cargo test --lib
# 针对 no_std 目标验证 no_std 编译情况:
cargo check --target thumbv7em-none-eabihf # ARM Cortex-M
cargo check --target riscv32imac-unknown-none-elf # RISC-V
no_std 决策树
flowchart TD
START["代码是否需要\n标准库?"] --> NEED_FS{"文件系统、\n网络、线程?"}
NEED_FS -->|"是"| USE_STD["使用 std\n开发普通应用"]
NEED_FS -->|"否"| NEED_HEAP{"是否需要堆分配?\nVec, String, Box"}
NEED_HEAP -->|"是"| USE_ALLOC["#![no_std]\nextern crate alloc"]
NEED_HEAP -->|"否"| USE_CORE["#![no_std]\n仅使用 core"]
USE_ALLOC --> VERIFY["使用 cargo-hack\n验证特性组合"]
USE_CORE --> VERIFY
USE_STD --> VERIFY
VERIFY --> TARGET{"目标是否有 OS?"}
TARGET -->|"是"| HOST_TEST["cargo test --lib\n标准测试流程"]
TARGET -->|"否"| CROSS_TEST["QEMU / defmt-test\n目标设备实战测试"]
style USE_STD fill:#91e5a3,color:#000
style USE_ALLOC fill:#ffd43b,color:#000
style USE_CORE fill:#ff6b6b,color:#000
🏋️ 练习
🟡 练习 1:特性组合验证
安装 cargo-hack 并对一个包含多个特性的项目运行 cargo hack check --each-feature --workspace。它是否发现了失效的组合?
答案
cargo install cargo-hack
# 分别检查每个特性
cargo hack check --each-feature --workspace --no-dev-deps
# 如果某个特性组合报错:
# error[E0433]: failed to resolve: use of undeclared crate or module `std`
# → 这意味着某个特性开关处缺少了 #[cfg] 保护。
# 检查全部特性开启 + 零特性开启 + 逐个特性开启:
cargo hack check --each-feature --workspace
cargo check --workspace --all-features
cargo check --workspace --no-default-features
🔴 练习 2:构建 no_std 库
创建一个能够使用 #![no_std] 编译的库 crate。实现一个简单的栈分配环形缓冲区 (Ring Buffer)。验证其可以针对 thumbv7em-none-eabihf (ARM Cortex-M) 通过编译。
答案
#![allow(unused)]
fn main() {
// lib.rs
#![no_std]
pub struct RingBuffer<const N: usize> {
data: [u8; N],
head: usize,
len: usize,
}
impl<const N: usize> RingBuffer<N> {
pub const fn new() -> Self {
Self { data: [0; N], head: 0, len: 0 }
}
pub fn push(&mut self, byte: u8) -> bool {
if self.len == N { return false; }
let idx = (self.head + self.len) % N;
self.data[idx] = byte;
self.len += 1;
true
}
pub fn pop(&mut self) -> Option<u8> {
if self.len == 0 { return None; }
let byte = self.data[self.head];
self.head = (self.head + 1) % N;
self.len -= 1;
Some(byte)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_pop() {
let mut rb = RingBuffer::<4>::new();
assert!(rb.push(1));
assert!(rb.push(2));
assert_eq!(rb.pop(), Some(1));
assert_eq!(rb.pop(), Some(2));
assert_eq!(rb.pop(), None);
}
}
}
rustup target add thumbv7em-none-eabihf
cargo check --target thumbv7em-none-eabihf
# ✅ 针对裸机 ARM 的编译通过
关键收获
cargo-hack --each-feature对于任何包含条件编译的 crate 来说都是必需的 —— 建议在 CI 中运行。core→alloc→std是分层架构:每一层都增加了功能,但同时也需要更多的运行时支持。- 裸机
#![no_std]二进制文件需要自定义 panic 处理程序和分配器。 - 使用
cargo test --lib在宿主机上测试no_std库 —— 通常不需要真实硬件。 - 仅对特性数少于 8 个的核心库运行
--feature-powerset—— 指数级组合 ($2^n$) 会导致构建爆炸。
Windows 与条件编译 🟡
你将学到:
- Windows 支持模式:
windows-sys/windowscrate 以及cargo-xwin- 使用
#[cfg]进行条件编译 —— 由编译器检查,而非预处理器- 平台抽象架构:何时使用
#[cfg]块就足够,何时需要使用 Trait- 如何在 Linux 上为 Windows 进行交叉编译
相关章节:
no_std与特性验证 —cargo-hack与特性验证 · 交叉编译 — 通用的交叉构建设置 · 构建脚本 — 由build.rs发出的cfg标志
Windows 支持 — 平台抽象
Rust 的 #[cfg()] 属性和 Cargo 特性 (Features) 使得单一代码库能够整洁地支持 Linux 和 Windows。本项目已在 platform::run_command 中展示了这种模式:
#![allow(unused)]
fn main() {
// 本项目中的真实模式 —— 平台特定的 shell 调用
pub fn exec_cmd(cmd: &str, timeout_secs: Option<u64>) -> Result<CommandResult, CommandError> {
#[cfg(windows)]
let mut child = Command::new("cmd")
.args(["/C", cmd])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
#[cfg(not(windows))]
let mut child = Command::new("sh")
.args(["-c", cmd])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// ... 后续部分是平台无关的 ...
}
}
可用的 cfg 断言:
#![allow(unused)]
fn main() {
// 操作系统
#[cfg(target_os = "linux")] // 仅限 Linux
#[cfg(target_os = "windows")] // 仅限 Windows
#[cfg(target_os = "macos")] // 仅限 macOS
#[cfg(unix)] // Linux, macOS, BSDs 等
#[cfg(windows)] // Windows (简写)
// 架构
#[cfg(target_arch = "x86_64")] // x86 64 位
#[cfg(target_arch = "aarch64")] // ARM 64 位
#[cfg(target_arch = "x86")] // x86 32 位
// 指针宽度(架构无关的替代方案)
#[cfg(target_pointer_width = "64")] // 任何 64 位平台
#[cfg(target_pointer_width = "32")] // 任何 32 位平台
// 环境 / C 库
#[cfg(target_env = "gnu")] // glibc
#[cfg(target_env = "musl")] // musl libc
#[cfg(target_env = "msvc")] // Windows 上的 MSVC
// 字节序
#[cfg(target_endian = "little")]
#[cfg(target_endian = "big")]
// 组合使用 any(), all(), not()
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(not(windows))]
}
windows-sys 与 windows Crate
用于直接调用 Windows API:
# Cargo.toml — 使用 windows-sys 进行原始 FFI 调用(更轻量,无抽象)
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_Services",
"Win32_System_Registry",
"Win32_System_Power",
] }
# 注意:windows-sys 的发布不遵循语义化版本兼容 (0.48 → 0.52 → 0.59)。
# 建议锁定到特定的次要版本 —— 每次发布都可能删除或重命名 API 绑定。
# 在开始新项目前,请前往 https://github.com/microsoft/windows-rs 查看最新版本。
# 或者使用 windows crate 以获得安全封装(更重,但更易用)
# windows = { version = "0.59", features = [...] }
#![allow(unused)]
fn main() {
// src/platform/windows.rs
#[cfg(windows)]
mod win {
use windows_sys::Win32::System::Power::{
GetSystemPowerStatus, SYSTEM_POWER_STATUS,
};
pub fn get_battery_status() -> Option<u8> {
let mut status = SYSTEM_POWER_STATUS::default();
// SAFETY: GetSystemPowerStatus 会向提供的缓冲区写入数据。
// 该缓冲区的大小和对齐方式正确。
let ok = unsafe { GetSystemPowerStatus(&mut status) };
if ok != 0 {
Some(status.BatteryLifePercent)
} else {
None
}
}
}
}
windows-sys vs windows crate:
| 维度 | windows-sys | windows |
|---|---|---|
| API 风格 | 原始 FFI (unsafe 调用) | Safe Rust 封装 |
| 二进制体积 | 极小 (仅包含 extern 声明) | 较大 (包含封装代码) |
| 编译时间 | 快 | 慢 |
| 易用性 | C 风格,需手动维护安全性 | 符合 Rust 习惯 |
| 错误处理 | 原始 BOOL / HRESULT | Result<T, windows::core::Error> |
| 适用场景 | 性能关键、轻量级封装 | 应用程序代码、追求开发效率 |
在 Linux 上为 Windows 交叉编译
# 方案 1: MinGW (GNU ABI)
rustup target add x86_64-pc-windows-gnu
sudo apt install gcc-mingw-w64-x86-64
cargo build --target x86_64-pc-windows-gnu
# 生成 .exe 文件 —— 可在 Windows 运行,链接至 msvcrt
# 方案 2: 通过 xwin 编译 MSVC ABI (实现完全的 MSVC 兼容)
cargo install cargo-xwin
cargo xwin build --target x86_64-pc-windows-msvc
# 会自动下载微软的 CRT 和 SDK 头文件
# 方案 3: 基于 Zig 的交叉编译
cargo zigbuild --target x86_64-pc-windows-gnu
Windows 上的 GNU 与 MSVC ABI:
| 维度 | x86_64-pc-windows-gnu | x86_64-pc-windows-msvc |
|---|---|---|
| 链接器 | MinGW ld | MSVC link.exe 或 lld-link |
| C 运行时 | msvcrt.dll (通用) | ucrtbase.dll (现代) |
| C++ 互操作 | GCC ABI | MSVC ABI |
| 在 Linux 交叉编译 | 容易 (MinGW) | 支持 (cargo-xwin) |
| Windows API 支持 | 完整 | 完整 |
| 调试信息格式 | DWARF | PDB |
| 推荐用于 | 简单工具、CI 构建 | 深度 Windows 集成 |
条件编译模式
模式 1:平台特定的模块选择
#![allow(unused)]
fn main() {
// src/platform/mod.rs — 为每个 OS 编译不同的模块
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::*;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
pub use windows::*;
// 两个模块都实现了相同的公共 API:
// pub fn get_cpu_temperature() -> Result<f64, PlatformError>
// pub fn list_pci_devices() -> Result<Vec<PciDevice>, PlatformError>
}
模式 2:特性门控 (Feature-gated) 的平台支持
# Cargo.toml
[features]
default = ["linux"]
linux = [] # Linux 特有的硬件访问
windows = ["dep:windows-sys"] # Windows 特有的 API
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [...], optional = true }
#![allow(unused)]
fn main() {
// 如果试图在未开启特性时为 Windows 构建,则报错:
#[cfg(all(target_os = "windows", not(feature = "windows")))]
compile_error!("请启用 'windows' 特性以构建 Windows 版本");
}
模式 3:基于 Trait 的平台抽象
#![allow(unused)]
fn main() {
/// 硬件访问的平台无关接口。
pub trait HardwareAccess {
type Error: std::error::Error;
fn read_cpu_temperature(&self) -> Result<f64, Self::Error>;
fn read_gpu_temperature(&self, gpu_index: u32) -> Result<f64, Self::Error>;
fn list_pci_devices(&self) -> Result<Vec<PciDevice>, Self::Error>;
fn send_ipmi_command(&self, cmd: &IpmiCmd) -> Result<IpmiResponse, Self::Error>;
}
#[cfg(target_os = "linux")]
pub struct LinuxHardware;
#[cfg(target_os = "linux")]
impl HardwareAccess for LinuxHardware {
type Error = LinuxHwError;
fn read_cpu_temperature(&self) -> Result<f64, Self::Error> {
// 从 /sys/class/thermal/thermal_zone0/temp 读取
let raw = std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")?;
Ok(raw.trim().parse::<f64>()? / 1000.0)
}
// ...
}
#[cfg(target_os = "windows")]
pub struct WindowsHardware;
#[cfg(target_os = "windows")]
impl HardwareAccess for WindowsHardware {
type Error = WindowsHwError;
fn read_cpu_temperature(&self) -> Result<f64, Self::Error> {
// 通过 WMI (Win32_TemperatureProbe) 或 Open Hardware Monitor 读取
todo!("WMI 温度查询待实现")
}
// ...
}
/// 创建对应的平台实现
pub fn create_hardware() -> impl HardwareAccess {
#[cfg(target_os = "linux")]
{ LinuxHardware }
#[cfg(target_os = "windows")]
{ WindowsHardware }
}
}
平台抽象架构
对于针对多个平台的项目,建议将代码组织为三个层面:
┌──────────────────────────────────────────────────┐
│ 应用逻辑 (平台无关) │
│ diag_tool, accel_diag, network_diag, event_log 等│
│ 仅依赖于平台抽象 Trait │
├──────────────────────────────────────────────────┤
│ 平台抽象层 (Trait 定义) │
│ trait HardwareAccess { ... } │
│ trait CommandRunner { ... } │
│ trait FileSystem { ... } │
├──────────────────────────────────────────────────┤
│ 平台具体实现 (受 cfg 保护) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Linux 实现 │ │ Windows 实现 │ │
│ │ /sys, /proc │ │ WMI, 注册表 │ │
│ │ ipmitool │ │ ipmiutil │ │
│ │ lspci │ │ devcon │ │
│ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────┘
测试抽象层:在单元测试中模拟 (Mock) 平台 Trait:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
struct MockHardware {
cpu_temp: f64,
gpu_temps: Vec<f64>,
}
impl HardwareAccess for MockHardware {
type Error = std::io::Error;
fn read_cpu_temperature(&self) -> Result<f64, Self::Error> {
Ok(self.cpu_temp)
}
fn read_gpu_temperature(&self, index: u32) -> Result<f64, Self::Error> {
self.gpu_temps.get(index as usize)
.copied()
.ok_or_else(|| std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("未找到 GPU {index}")
))
}
fn list_pci_devices(&self) -> Result<Vec<PciDevice>, Self::Error> {
Ok(vec![]) // 模拟返回空列表
}
fn send_ipmi_command(&self, _cmd: &IpmiCmd) -> Result<IpmiResponse, Self::Error> {
Ok(IpmiResponse::default())
}
}
#[test]
fn test_thermal_check_with_mock() {
let hw = MockHardware {
cpu_temp: 75.0,
gpu_temps: vec![82.0, 84.0],
};
let result = run_thermal_diagnostic(&hw);
assert!(result.is_ok());
}
}
}
应用:Linux 优先,Windows 就绪
本项目已经部分实现了 Windows 就绪。可以使用 cargo-hack 验证所有特性组合,并利用 交叉编译 在 Linux 上测试 Windows 版本:
现状:
platform::run_command已使用#[cfg(windows)]进行 shell 选择。- 测试代码已使用
#[cfg(windows)]/#[cfg(not(windows))]区分平台特定的测试命令。
建议的 Windows 支持演进路径:
阶段 1:提取平台抽象 Trait (当前 → 2 周)
├─ 在 core_lib 中定义 HardwareAccess Trait
├─ 将当前的 Linux 代码封装进 LinuxHardware 实现中
└─ 所有诊断模块依赖于 Trait,而非 Linux 特有的实现
阶段 2:增加 Windows 存根 (Stubs) (2 周)
├─ 实现 WindowsHardware,暂留 TODO 存根
├─ 在 CI 中增加 x86_64-pc-windows-msvc 编译检查
└─ 确保测试可以在所有平台上通过 MockHardware 运行
阶段 3:Windows 具体实现 (持续进行)
├─ IPMI 实现:通过 ipmiutil.exe 或 OpenIPMI Windows 驱动
├─ GPU 实现:通过 accel-mgmt (accel-api.dll) —— 接口与 Linux 保持一致
├─ PCIe 实现:通过 Windows Setup API (SetupDiEnumDeviceInfo)
└─ NIC 实现:通过 WMI (Win32_NetworkAdapter)
在 CI 中增加跨平台构建:
# 添加到 CI 矩阵
- target: x86_64-pc-windows-msvc
os: windows-latest
name: windows-x86_64
这可以确保即便在 Windows 实现完全闭环前,代码也能在 Windows 环境下编译通过 —— 从而尽早发现 cfg 错误。
关键洞察:抽象层在第一天不必追求完美。可以先在底层函数中直接使用
#[cfg]块(如现在的exec_cmd),当有两三个平台实现时,再考虑重构为 Trait。过早的抽象反而比简单的#[cfg]块更糟糕。
条件编译决策树
flowchart TD
START["是否存在平台特定代码?"] --> HOW_MANY{"支持多少平台?"}
HOW_MANY -->|"2 个 (Linux + Windows)"| CFG_BLOCKS["在底层函数中\n使用 #[cfg] 块"]
HOW_MANY -->|"3 个或更多"| TRAIT_APPROACH["使用平台 Trait\n以及各平台专属实现"]
CFG_BLOCKS --> WINAPI{"是否需要使用 Windows API?"}
WINAPI -->|"仅少量需求"| WIN_SYS["windows-sys\n原始 FFI 绑定"]
WINAPI -->|"重度需求 (COM 等)"| WIN_RS["windows crate\nSafe/习惯用法封装"]
WINAPI -->|"无需求\n(仅 cfg 区分)"| NATIVE["cfg(windows)\ncfg(unix)"]
TRAIT_APPROACH --> CI_CHECK["使用 cargo-hack\n验证各特性组合"]
CFG_BLOCKS --> CI_CHECK
CI_CHECK --> XCOMPILE["在 CI 中交叉编译\ncargo-xwin 或\n原生运行器"]
style CFG_BLOCKS fill:#91e5a3,color:#000
style TRAIT_APPROACH fill:#ffd43b,color:#000
style WIN_SYS fill:#e3f2fd,color:#000
style WIN_RS fill:#e3f2fd,color:#000
🏋️ 练习
🟢 练习 1:平台相关的条件模块
创建一个模块,并分别实现 get_hostname() 函数的 #[cfg(unix)] 和 #[cfg(windows)] 版本。验证其可以通过 cargo check 以及 cargo check --target x86_64-pc-windows-msvc。
答案
#![allow(unused)]
fn main() {
// src/hostname.rs
#[cfg(unix)]
pub fn get_hostname() -> String {
use std::fs;
fs::read_to_string("/etc/hostname")
.unwrap_or_else(|_| "unknown".to_string())
.trim()
.to_string()
}
#[cfg(windows)]
pub fn get_hostname() -> String {
use std::env;
env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostname_is_not_empty() {
let name = get_hostname();
assert!(!name.is_empty());
}
}
}
# 验证 Linux 编译情况
cargo check
# 验证 Windows 编译情况(交叉检查)
rustup target add x86_64-pc-windows-msvc
cargo check --target x86_64-pc-windows-msvc
🟡 练习 2:使用 cargo-xwin 交叉编译 Windows 版本
在 Linux 环境下安装 cargo-xwin,并为 x86_64-pc-windows-msvc 构建一个简单的二进制文件。验证输出是否为 .exe。
答案
cargo install cargo-xwin
rustup target add x86_64-pc-windows-msvc
cargo xwin build --release --target x86_64-pc-windows-msvc
# 会自动下载 Windows SDK 头文件和库
file target/x86_64-pc-windows-msvc/release/my-binary.exe
# 输出示例:PE32+ executable (console) x86-64, for MS Windows
# 你也可以通过 Wine 进行测试:
wine target/x86_64-pc-windows-msvc/release/my-binary.exe
关键收获
- 先在底层函数中使用
#[cfg]块;仅在三个或更多平台的实现逻辑发生分叉时才考虑重构为 Trait。 windows-sys提供原始 FFI 接口;windowscrate 则提供符合 Rust 习惯的安全封装。cargo-xwin允许你在 Linux 上交叉编译至 Windows MSVC ABI —— 无需真实的 Windows 机器。- 即使只在 Linux 上运行,也建议在 CI 中对
--target x86_64-pc-windows-msvc进行编译检查。 - 将
#[cfg]与 Cargo 特性结合使用,以实现可选的平台支持(如feature = "windows")。
综合实践 — 生产级 CI/CD 流水线 🟡
你将学到:
- 构建多阶段 GitHub Actions CI 工作流 (检查 → 测试 → 覆盖率 → 安全 → 交叉编译 → 发布)
- 使用
rust-cache的缓存策略及save-if微调- 分离日常 push 与按计划运行 (Nightly) 的 Miri 和 sanitizer 任务
- 使用
Makefile.toml和 pre-commit 钩子实现任务自动化- 使用
cargo-dist实现自动化发布相关章节: 构建脚本 · 交叉编译 · 基准测试 · 覆盖率 · Miri/Sanitizer · 依赖管理 · 发布配置 · 编译期工具 ·
no_std· Windows
单一工具固然有用,但能将它们有机结合并在每次 push 时自动运行的流水线则具有革命性意义。本章将第 1 章至第 10 章涉及的工具整合为一个完整的 CI/CD 工作流。
完整的 GitHub Actions 工作流
在一个工作流文件中并行运行所有验证阶段:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
CARGO_ENCODED_RUSTFLAGS: "-Dwarnings" # 将警告视为错误 (仅限顶层 crate)
# 注意:与 RUSTFLAGS 不同,CARGO_ENCODED_RUSTFLAGS 不会影响构建脚本或过程宏,
# 从而避免了因第三方库的警告导致构建失败。
# 如果你想对构建脚本也强制执行此规则,请改用 RUSTFLAGS="-Dwarnings"。
jobs:
# ─── 阶段 1: 快速反馈 (< 2 分钟) ───
check:
name: 检查 + Clippy + 格式化
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2 # 缓存依赖
- name: 检查 Cargo.lock
run: cargo fetch --locked
- name: 检查文档
run: RUSTDOCFLAGS='-Dwarnings' cargo doc --workspace --all-features --no-deps
- name: 检查编译
run: cargo check --workspace --all-targets --all-features
- name: Clippy 静态检查
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: 代码格式化
run: cargo fmt --all -- --check
# ─── 阶段 2: 测试 (< 5 分钟) ───
test:
name: 测试 (${{ matrix.os }})
needs: check
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: 运行单元测试
run: cargo test --workspace
- name: 运行文档测试
run: cargo test --workspace --doc
# ─── 阶段 3: 交叉编译 (< 10 分钟) ───
cross:
name: 交叉编译 (${{ matrix.target }})
needs: check
strategy:
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
use_cross: true
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: 安装 musl-tools
if: contains(matrix.target, 'musl')
run: sudo apt-get install -y musl-tools
- name: 安装 cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: 构建 (原生)
if: "!matrix.use_cross"
run: cargo build --release --target ${{ matrix.target }}
- name: 构建 (使用 cross)
if: matrix.use_cross
run: cross build --release --target ${{ matrix.target }}
- name: 上传产物
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: target/${{ matrix.target }}/release/diag_tool
# ─── 阶段 4: 覆盖率 (< 10 分钟) ───
coverage:
name: 代码覆盖率
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: taiki-e/install-action@cargo-llvm-cov
- name: 生成覆盖率数据
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- name: 强制执行最低覆盖率门禁
run: cargo llvm-cov --workspace --fail-under-lines 75
- name: 上传至 Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
# ─── 阶段 5: 安全验证 (< 15 分钟) ───
miri:
name: Miri 验证
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- name: 运行 Miri
run: cargo miri test --workspace
env:
MIRIFLAGS: "-Zmiri-backtrace=full"
# ─── 阶段 6: 基准测试 (仅限 PR, < 10 分钟) ───
bench:
name: 基准测试
if: github.event_name == 'pull_request'
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 运行基准测试
run: cargo bench -- --output-format bencher | tee bench.txt
- name: 与基准线对比
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'cargo'
output-file-path: bench.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
alert-threshold: '115%'
comment-on-alert: true
流水线执行流程:
┌─────────┐
│ check │ ← clippy + fmt + cargo check (2 min)
└────┬────┘
┌─────────┬──┴──┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌────────┐ ┌──────┐ ┌──────┐
│ test │ │cross │ │coverage│ │ miri │ │bench │
│ (2×) │ │ (2×) │ │ │ │ │ │(PR) │
└──────┘ └──────┘ └────────┘ └──────┘ └──────┘
3 min 8 min 8 min 12 min 5 min
总耗时:约 14 分钟 (在 check 门禁后的并行阶段)
CI 缓存策略
Swatinem/rust-cache@v2 是 Rust CI 的标准缓存 Action。它可以缓存 ~/.cargo 和 target/ 目录,但大型工作区需要进行一定的微调:
# 基础用法 (即上述代码中使用的)
- uses: Swatinem/rust-cache@v2
# 针对大型工作区的微调策略:
- uses: Swatinem/rust-cache@v2
with:
# 为每个任务设置独立前缀 —— 防止测试产物污染构建缓存
prefix-key: "v1-rust"
key: ${{ matrix.os }}-${{ matrix.target || 'default' }}
# 仅在 main 分支保存缓存 (PR 分支仅读取不写入)
save-if: ${{ github.ref == 'refs/heads/main' }}
# 缓存 Cargo 注册表 + Git 下载内容 + target 目录
cache-targets: true
cache-all-crates: true
缓存失效的常见陷阱:
| 问题 | 修复方法 |
|---|---|
| 缓存无限增长 (>5 GB) | 设置 prefix-key: "v2-rust" 以强制清理旧缓存 |
| 不同特性 (features) 互相污染 | 使用 key: ${{ hashFiles('**/Cargo.lock') }} |
| PR 缓存覆盖了主分支缓存 | 设置 save-if: ${{ github.ref == 'refs/heads/main' }} |
| 交叉编译目标导致体积膨胀 | 为不同的三元组 (target triple) 设置不同的 key |
跨任务共享缓存:
check 任务负责保存缓存;后续任务(test, cross, coverage)只需读取。通过在 main 分支上设置 save-if,PR 运行任务可以享受到缓存依赖带来的加速,且不会写回失效的缓存。
对大型工作区的实测收益:从未启动构建需 ~4 分钟 → 缓存态构建需 ~45 秒。仅缓存 Action 这一项就在单次流水线运行中节省了约 25 分钟的累计 CI 时间。
使用 cargo-make 的 Makefile.toml
cargo-make 提供了一个跨平台的任务运行器(不同于 make/Makefile):
# 安装
cargo install cargo-make
# 工作区根目录下的 Makefile.toml
[config]
default_to_workspace = false
# ─── 开发者工作流 ───
[tasks.dev]
description = "本地全量验证 (与 CI 检查项一致)"
dependencies = ["check", "test", "clippy", "fmt-check"]
[tasks.check]
command = "cargo"
args = ["check", "--workspace", "--all-targets"]
[tasks.test]
command = "cargo"
args = ["test", "--workspace"]
[tasks.clippy]
command = "cargo"
args = ["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]
[tasks.fmt]
command = "cargo"
args = ["fmt", "--all"]
[tasks.fmt-check]
command = "cargo"
args = ["fmt", "--all", "--", "--check"]
# ─── 覆盖率 ───
[tasks.coverage]
description = "生成 HTML 覆盖率报告"
install_crate = "cargo-llvm-cov"
command = "cargo"
args = ["llvm-cov", "--workspace", "--html", "--open"]
[tasks.coverage-ci]
description = "生成供 CI 上传使用的 LCOV 数据"
install_crate = "cargo-llvm-cov"
command = "cargo"
args = ["llvm-cov", "--workspace", "--lcov", "--output-path", "lcov.info"]
# ─── 基准测试 ───
[tasks.bench]
description = "运行所有基准测试"
command = "cargo"
args = ["bench"]
# ─── 交叉编译 ───
[tasks.build-musl]
description = "构建静态二进制 (musl)"
command = "cargo"
args = ["build", "--release", "--target", "x86_64-unknown-linux-musl"]
[tasks.build-arm]
description = "为 aarch64 构建 (需要 cross)"
command = "cross"
args = ["build", "--release", "--target", "aarch64-unknown-linux-gnu"]
[tasks.build-all]
description = "构建所有发布目标"
dependencies = ["build-musl", "build-arm"]
# ─── 安全验证 ───
[tasks.miri]
description = "在所有测试上运行 Miri"
toolchain = "nightly"
command = "cargo"
args = ["miri", "test", "--workspace"]
[tasks.audit]
description = "检查已知漏洞"
install_crate = "cargo-audit"
command = "cargo"
args = ["audit"]
# ─── 发布 ───
[tasks.release-dry]
description = "预览 cargo-release 的操作"
install_crate = "cargo-release"
command = "cargo"
args = ["release", "--workspace", "--dry-run"]
使用方法:
# 本地模拟 CI 流水线运行
cargo make dev
# 生成并查看覆盖率报告
cargo make coverage
# 为所有目标平台构建
cargo make build-all
# 运行 Miri 安全检查
cargo make miri
# 漏洞审计
cargo make audit
Pre-Commit 钩子:自定义脚本与 cargo-husky
在问题进入 CI 之前就拦截它们。推荐方案是创建一个自定义 Git 钩子 —— 它简单、透明且无外部依赖:
#!/bin/sh
# .githooks/pre-commit
set -e
echo "=== 执行 Pre-commit 检查 ==="
# 先执行速度最快的检查
echo "→ 检查代码格式 (cargo fmt --check)"
cargo fmt --all -- --check
echo "→ 检查编译 (cargo check)"
cargo check --workspace --all-targets
echo "→ 静态分析 (cargo clippy)"
cargo clippy --workspace --all-targets -- -D warnings
echo "→ 快速单元测试 (cargo test --lib)"
cargo test --workspace --lib
echo "=== 所有检查已通过 ==="
# 启用钩子
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
另一种选择:cargo-husky (通过构建脚本自动安装钩子):
⚠️ 注意:
cargo-husky自 2022 年以来未曾更新。它依然能用,但事实上已无人维护。对于新项目,建议采用上述自定义钩子的方式。
# Cargo.toml — 添加至根 crate 的 dev-dependencies
[dev-dependencies]
cargo-husky = { version = "1", default-features = false, features = [
"precommit-hook",
"run-cargo-check",
"run-cargo-clippy",
"run-cargo-fmt",
"run-cargo-test",
] }
发布工作流:cargo-release 与 cargo-dist
cargo-release — 自动化执行版本提升、打标签以及发布操作:
# 安装
cargo install cargo-release
# 工作区根目录下的 release.toml
[workspace]
consolidate-commits = true
pre-release-commit-message = "chore: 发布版本 {{version}}"
tag-message = "v{{version}}"
tag-name = "v{{version}}"
# 禁止发布内部 crate
[[package]]
name = "core_lib"
release = false
[[package]]
name = "diag_framework"
release = false
# 仅发布主要的二进制 crate
[[package]]
name = "diag_tool"
release = true
# 预览发布操作
cargo release patch --dry-run
# 执行发布 (提升版本号、提交更改、打标签、可选发布至 crates.io)
cargo release patch --execute
# 0.1.0 → 0.1.1
cargo release minor --execute
# 0.1.1 → 0.2.0
cargo-dist — 为 GitHub Releases 生成可下载的离线二进制包:
# 安装
cargo install cargo-dist
# 初始化 (生成 CI 工作流及元数据)
cargo dist init
# 预览构建计划
cargo dist plan
# 执行发布构建 (通常由 CI 任务在 push tag 时执行)
cargo dist build
# 由 `cargo dist init` 添加至 Cargo.toml 的部分
[workspace.metadata.dist]
cargo-dist-version = "0.28.0"
ci = "github"
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]
install-path = "CARGO_HOME"
这会生成一个 GitHub Actions 工作流,在 push 标签时会自动执行:
- 为所有目标平台构建二进制文件。
- 创建 GitHub Release 并上传
.tar.gz/.zip压缩包。 - 生成 Shell/PowerShell 安装脚本。
- 发布到 crates.io (若已配置)。
亲身尝试 — 总结性练习
本练习将贯穿之前每一章。你将为一个全新的 Rust 工作区构建完整的工程化流水线:
-
创建一个新工作区,包含两个 crate:库 (
core_lib) 和二进制 (cli)。添加一个build.rs,利用SOURCE_DATE_EPOCH嵌入 Git 哈希和构建时间戳 (第 1 章)。 -
设置交叉编译,针对
x86_64-unknown-linux-musl和aarch64-unknown-linux-gnu。验证两个目标均可通过cargo zigbuild或cross成功构建 (第 2 章)。 -
添加基准测试,使用 Criterion 或 Divan 测量
core_lib某个函数的性能。本地运行并记录基准线 (第 3 章)。 -
测量代码覆盖率,使用
cargo llvm-cov。设置 80% 的最低门槛并确保其通过 (第 4 章)。 -
运行 Miri 测试 与
cargo +nightly careful test。如果你编写了unsafe代码,请确保对应测试能覆盖到它 (第 5 章)。 -
配置
cargo-deny,在deny.toml中禁用openssl并强制执行 MIT/Apache-2.0 许可证准则 (第 6 章)。 -
优化发布配置,启用
lto = "thin",strip = true和codegen-units = 1。使用cargo bloat对比优化前后的二进制体积变化 (第 7 章)。 -
添加特性验证。为一个可选依赖项创建特性标志,并使用
cargo hack --each-feature确保其能独立编译 (第 9 章)。 -
编写 GitHub Actions 工作流 (参考本章),包含上述 6 个阶段。添加微调后的
Swatinem/rust-cache@v2。
达标标准:Push 代码到 GitHub → 所有的 CI 阶段均显示绿色 → cargo dist plan 输出了正确的发布目标。恭喜,你已经拥有了一个生产级的 Rust 流水线。
CI 流水线架构
flowchart LR
subgraph "阶段 1 — 快速反馈 < 2 分钟"
CHECK["编译检查/\nClippy/\n格式化"]
end
subgraph "阶段 2 — 测试 < 5 分钟"
TEST["并行单元测试/\n文档测试"]
end
subgraph "阶段 3 — 覆盖率"
COV["llvm-cov\n门禁 80%"]
end
subgraph "阶段 4 — 安全审计"
SEC["漏洞审计/\n许可证检查"]
end
subgraph "阶段 5 — 交叉编译"
CROSS["多平台静态构建\naarch64 + x86_64"]
end
subgraph "阶段 6 — 发布 (仅限标签)"
REL["cargo dist\nGitHub Release"]
end
CHECK --> TEST --> COV --> SEC --> CROSS --> REL
style CHECK fill:#91e5a3,color:#000
style TEST fill:#91e5a3,color:#000
style COV fill:#e3f2fd,color:#000
style SEC fill:#ffd43b,color:#000
style CROSS fill:#e3f2fd,color:#000
style REL fill:#b39ddb,color:#000
关键收获
- 将 CI 划分为并行阶段:先执行快速反馈任务,慢速任务放在门禁之后。
- 为
Swatinem/rust-cache@v2使用save-if: ${{ github.ref == 'refs/heads/main' }}可防止 PR 导致缓存失效。 - 使用
schedule:(Nightly) 触发器执行 Miri 或繁重的 sanitizer 任务。 Makefile.toml(cargo make) 将多工具工作流打包为单一本地命令。cargo-dist实现了跨平台发布的模版化 —— 不再需要手动编写海量的平台矩阵 YAML。
一线实践技巧 🟡
你将学到:
- 经过实战检验、且不便归入其他章节的工程模式
- 常见陷阱及其修复方法 —— 从 CI 不稳定到二进制膨胀
- 可立即应用于任何 Rust 项目的“快赢”技术
相关章节: 本书的每一章 —— 这些技巧横跨了所有主题
本章收集了在生产级 Rust 代码库中反复出现的工程模式。每个技巧都是独立的 —— 你可以按任何顺序阅读。
1. deny(warnings) 陷阱
问题:在源码中使用 #![deny(warnings)] 会在 Clippy 添加新 Lint 后导致构建失败 —— 昨天还能编译的代码今天就由于新规则报错了。
修复:在 CI 中使用 CARGO_ENCODED_RUSTFLAGS 代替源码级的属性:
# CI: 将警告视为错误,而不触及源码
env:
CARGO_ENCODED_RUSTFLAGS: "-Dwarnings"
或者使用 [workspace.lints] 进行更精细的控制:
# Cargo.toml
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "warn", priority = -1 }
参见 编译期工具,工作区 Lint 了解完整模式。
2. 编译一次,到处测试
问题:在 --lib、--doc 和 --test 之间切换时,cargo test 会重新编译,因为它们使用的是不同的配置项 (Profiles)。
修复:使用 cargo nextest 运行单元测试和集成测试,并单独运行文档测试:
cargo nextest run --workspace # 快:并行运行,有缓存
cargo test --workspace --doc # 文档测试 (nextest 无法运行此类测试)
参见 编译期工具 了解
cargo-nextest的配置。
3. 特性标志 (Feature Flag) 健康管理
问题:一个库 crate 拥有 default = ["std"],但没有人测试过 --no-default-features 情况。某天一个嵌入式用户反馈由于该原因无法编译。
修复:在 CI 中添加 cargo-hack:
- name: 特性矩阵检查
run: |
cargo hack check --each-feature --no-dev-deps
cargo check --no-default-features
cargo check --all-features
参见
no_std与特性验证 了解完整模式。
4. 琐事:Lock 文件该提交还是忽略?
经验法则:
| Crate 类型 | 是否提交 Cargo.lock? | 原因 |
|---|---|---|
| 二进制 / 应用程序 | 是 | 确保构建可复现 |
| 库 (Library) | 否 (加入 .gitignore) | 让下游用户自行选择版本 |
| 包含两者的工作区 | 是 | 以二进制应用为准 |
添加一个 CI 检查项,确保 Lock 文件保持最新:
- name: 检查 Lock 文件
run: cargo update --locked # 如果 Cargo.lock 已过期则报错
5. 带有优化依赖项的调试构建
问题:Debug 构建慢得令人痛苦,因为依赖项(尤其是 serde, regex)没有被优化。
修复:在 dev 配置中优化依赖项,但保持你自己的代码不被优化以实现快速重新编译:
# Cargo.toml
[profile.dev.package."*"]
opt-level = 2 # 在开发模式下优化所有依赖项
这会稍微减慢第一次构建的速度,但在开发期间会显著提升运行效率。这对于依赖数据库的服务和解析器尤为重要。
参见 发布配置 了解针对单个 crate 的配置覆盖 (Profile Overrides)。
6. CI 缓存抖动 (Thrashing)
问题:Swatinem/rust-cache@v2 在每个 PR 上都保存一个新缓存,导致存储膨胀并减慢还原速度。
修复:仅从 main 分支保存缓存,从任何地方还原缓存:
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
对于包含多个二进制文件的工作区,可以添加一个 shared-key:
- uses: Swatinem/rust-cache@v2
with:
shared-key: "ci-${{ matrix.target }}"
save-if: ${{ github.ref == 'refs/heads/main' }}
参见 CI/CD 流水线 了解完整工作流。
7. RUSTFLAGS vs CARGO_ENCODED_RUSTFLAGS
问题:RUSTFLAGS="-Dwarnings" 会应用于 一切 —— 包括构建脚本和过程宏。如果 serde_derive 的 build.rs 里有一个警告,你的 CI 就会挂掉。
修复:使用 CARGO_ENCODED_RUSTFLAGS,它只应用于顶层 crate:
# 不佳 —— 可能会因为第三方库构建脚本的警告而报错
RUSTFLAGS="-Dwarnings" cargo build
# 推荐 —— 仅影响你自己的 crate
CARGO_ENCODED_RUSTFLAGS="-Dwarnings" cargo build
# 同样推荐 —— 在 Cargo.toml 中设置工作区 Lint
[workspace.lints.rust]
warnings = "deny"
8. 使用 SOURCE_DATE_EPOCH 实现可复现构建
问题:在 build.rs 中嵌入 chrono::Utc::now() 会导致构建无法复现 —— 每次构建都会产生不同的二进制哈希。
修复:遵循 SOURCE_DATE_EPOCH:
#![allow(unused)]
fn main() {
// build.rs
let timestamp = std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or_else(|| chrono::Utc::now().timestamp());
println!("cargo:rustc-env=BUILD_TIMESTAMP={timestamp}");
}
参见 构建脚本 了解完整的 build.rs 模式。
9. cargo tree 去重工作流
问题:cargo tree --duplicates 显示有 5 个版本的 syn 和 3 个版本的 tokio-util。编译慢如蜗牛。
修复:系统性地去重:
# 第 1 步:查找重复项
cargo tree --duplicates
# 第 2 步:查找是谁拉取了旧版本
cargo tree --invert --package [email protected]
# 第 3 步:更新导致问题的依赖
cargo update -p serde_derive # 可能会因此拉取 syn 2.x
# 第 4 步:如果无法更新,在 [patch] 中手动锁定
# [patch.crates-io]
# old-crate = { git = "...", branch = "syn2-migration" }
# 第 5 步:验证
cargo tree --duplicates # 列表应该变短了
参见 依赖管理 了解
cargo-deny和供应链安全。
10. 推送前的冒烟测试 (Smoke Test)
问题:你推送了代码,CI 跑了 10 分钟,最后因为格式问题失败了。
修复:在推送前先在本地运行快速检查:
# Makefile.toml (cargo-make)
[tasks.pre-push]
description = "推送前的本地冒烟测试"
script = '''
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --lib
'''
cargo make pre-push # 耗时 < 30 秒
git push
或者使用 git pre-push 钩子:
#!/bin/sh
# .git/hooks/pre-push
cargo fmt --all -- --check && cargo clippy --workspace -- -D warnings
参见 CI/CD 流水线 了解
Makefile.toml模式。
🏋️ 练习
🟢 练习 1:应用三个技巧
从本章挑选三个技巧,并应用到一个现有的 Rust 项目中。哪一个对你的影响最大?
答案
典型的“高收益”组合:
-
[profile.dev.package."*"] opt-level = 2— 立即提升开发模式下的运行速度(对于解析密集型代码通常提速 2-10 倍)。 -
CARGO_ENCODED_RUSTFLAGS— 消除了由于第三方库警告导致的 CI 误报。 -
cargo-hack --each-feature— 通常能在包含 3 个以上特性的项目中找出至少一个失效的特性组合。
# 应用技巧 5:
echo '[profile.dev.package."*"]' >> Cargo.toml
echo 'opt-level = 2' >> Cargo.toml
# 在 CI 中应用技巧 7:
# 将 RUSTFLAGS 替换为 CARGO_ENCODED_RUSTFLAGS
# 应用技巧 3:
cargo install cargo-hack
cargo hack check --each-feature --no-dev-deps
🟡 练习 2:去重你的依赖树
在一个真实项目上运行 cargo tree --duplicates。消除至少一处重复项。测量优化前后的编译耗时。
答案
# 优化前
time cargo build --release 2>&1 | tail -1
cargo tree --duplicates | wc -l # 统计重复行数
# 查找并修复一处重复
cargo tree --duplicates
cargo tree --invert --package <duplicate-crate>@<old-version>
cargo update -p <parent-crate>
# 优化后
time cargo build --release 2>&1 | tail -1
cargo tree --duplicates | wc -l # 数值应该变小了
# 典型结果:每消除一个重复项(尤其是像 syn, tokio 这种重型 crate),
# 编译时间可缩短 5-15%。
关键收获
- 使用
CARGO_ENCODED_RUSTFLAGS代替RUSTFLAGS可避免由于第三方库构建脚本报错。 [profile.dev.package."*"] opt-level = 2是提升开发体验最立竿见影的技巧。- 缓存微调(仅在 main 分支执行
save-if)可防止活跃代码库的 CI 存储膨胀。 cargo tree --duplicates+cargo update是不花钱的编译期提速法 —— 建议每月执行一次。- 使用
cargo make pre-push在本地执行快速检查,避免因琐事在 CI 往返上浪费时间。
速查卡
备忘录:命令一览
# ─── 构建脚本 (Build Scripts) ───
cargo build # 先编译 build.rs,再编译 crate
cargo build -vv # 详细模式 —— 显示 build.rs 的输出
# ─── 交叉编译 (Cross-Compilation) ───
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.17
cross build --release --target aarch64-unknown-linux-gnu
# ─── 基准测试 (Benchmarking) ───
cargo bench # 运行所有基准测试
cargo bench -- parse # 运行名称匹配 "parse" 的基准测试
cargo flamegraph -- --args # 根据二进制程序生成火焰图
perf record -g ./target/release/bin # 记录性能数据
perf report # 交互式查看性能数据
# ─── 代码覆盖率 (Coverage) ───
cargo llvm-cov --html # 生成 HTML 报告
cargo llvm-cov --lcov --output-path lcov.info
cargo llvm-cov --workspace --fail-under-lines 80
cargo tarpaulin --out Html # 备选工具
# ─── 安全验证 (Safety Verification) ───
cargo +nightly miri test # 在 Miri 下运行测试
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test
valgrind --leak-check=full ./target/debug/binary
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# ─── 审计与供应链 (Audit & Supply Chain) ───
cargo audit # 已知漏洞扫描
cargo audit --deny warnings # 若存在漏洞通告则使 CI 失败
cargo deny check # 许可证 + 漏洞通告 + 禁止项 + 源码检查
cargo deny list # 列出依赖树中的所有许可证
cargo vet # 供应链信任验证
cargo outdated --workspace # 查找过期的依赖项
cargo semver-checks # 检测破坏性的 API 变更
cargo geiger # 统计依赖树中的 unsafe 代码行数
# ─── 二进制优化 (Binary Optimization) ───
cargo bloat --release --crates # 统计每个 crate 的体积占用
cargo bloat --release -n 20 # 找出体积最大的 20 个函数
cargo +nightly udeps --workspace # 查找未使用的依赖项
cargo machete # 快速检测未使用的依赖项
cargo expand --lib module::name # 查看宏展开后的结果
cargo msrv find # 探测支持的最低 Rust 版本
cargo clippy --fix --workspace --allow-dirty # 自动修复静态检查出的警告
# ─── 编译期优化 (Compile-Time Optimization) ───
export RUSTC_WRAPPER=sccache # 开启共享编译缓存
sccache --show-stats # 查看缓存命中率统计
cargo nextest run # 更快的测试运行器
cargo nextest run --retries 2 # 自动重试不稳定的测试
# ─── 平台工程 (Platform Engineering) ───
cargo check --target thumbv7em-none-eabihf # 验证 no_std 构建情况
cargo build --target x86_64-pc-windows-gnu # 交叉编译至 Windows (MinGW)
cargo xwin build --target x86_64-pc-windows-msvc # 交叉编译至 Windows (MSVC ABI)
cfg!(target_os = "linux") # 编译期配置 (求值为 bool 值)
# ─── 发布 (Release) ───
cargo release patch --dry-run # 预览发布操作
cargo release patch --execute # 提升版本、提交、打标签并发布
cargo dist plan # 预览分发产物
决策表:在什么场景使用什么工具
| 目标 | 工具 | 何时使用 |
|---|---|---|
| 嵌入 Git 哈希 / 构建信息 | build.rs | 二进制程序需要可追溯性 |
| 使用 Rust 编译 C 代码 | build.rs 中的 cc crate | 需要 FFI 调用小型 C 库 |
| 从 Schema 生成代码 | prost-build / tonic-build | 使用 Protobuf, gRPC, FlatBuffers |
| 链接系统库 | build.rs 中的 pkg-config | 依赖 OpenSSL, libpci, systemd |
| 静态链接的 Linux 二进制程序 | --target x86_64-unknown-linux-musl | 容器化或云端部署 |
| 针对旧版 glibc 进行构建 | cargo-zigbuild | 兼容 RHEL 7, CentOS 7 等老系统 |
| ARM 架构服务器程序 | cross 或 cargo-zigbuild | 部署至 Graviton/Ampere 算力 |
| 统计学意义上的基准测试 | Criterion.rs | 检测性能退化 (Regression) |
| 快速性能检查 | Divan | 开发期间的性能分析 |
| 找出性能热点 | cargo flamegraph / perf | 基准测试发现慢代码后的深度剖析 |
| 行/分支覆盖率 | cargo-llvm-cov | CI 覆盖率门禁、覆盖缺口分析 |
| 快速检查覆盖率 | cargo-tarpaulin | 本地开发期间使用 |
| Rust UB (未定义行为) 检测 | Miri | 纯 Rust 的 unsafe 代码 |
| C FFI 内存安全检查 | Valgrind memcheck | Rust/C 混合的代码库 |
| 数据竞态检测 | TSan 或 Miri | 并发环境下的 unsafe 代码 |
| 缓冲区溢出检测 | ASan | unsafe 指针算术运算 |
| 泄漏检测 | Valgrind 或 LSan | 需要长时间运行的服务 |
| 本地模拟 CI | cargo-make | 开发者工作流自动化 |
| 提交前检查 (Pre-commit) | cargo-husky 或 Git 钩子 | 推送代码前拦截问题 |
| 自动化发布 | cargo-release + cargo-dist | 版本管理与分发自动化 |
| 依赖项审计 | cargo-audit / cargo-deny | 供应链安全保障 |
| 许可证合规性 | cargo-deny (licenses) | 商业或企业级项目 |
| 供应链信任验证 | cargo-vet | 高安全性要求的环境 |
| 查找过期依赖 | cargo-outdated | 定期的维护工作 |
| 检测破坏性 API 变更 | cargo-semver-checks | 二进制库发布前校验 |
| 依赖树分析 | cargo tree --duplicates | 优化并清理依赖图中的冗余 |
| 二进制体积分析 | cargo-bloat | 针对体积敏感的部署场景 |
| 查找冗余依赖 | cargo-udeps / cargo-machete | 缩减编译时间和二进制体积 |
| LTO 微调 | lto = true 或 "thin" | 优化发布版二进制程序 |
| 体积优化的二进制程序 | opt-level = "z" + strip = true | 嵌入式 / WASM / 容器环境 |
| Unsafe 使用情况审计 | cargo-geiger | 安全政策强制执行 |
| 宏调试 | cargo-expand | 调试 derive 或 macro_rules 输出 |
| 链接加速 | mold 链接器 | 提升开发人员的本地迭代速度 |
| 编译缓存 | sccache | CI 与本地构建加速 |
| 测试加速 | cargo-nextest | CI 与本地测试提速 |
| MSRV 兼容性检查 | cargo-msrv | 发布库文件时使用 |
no_std 库开发 | #![no_std] + default-features = false | 嵌入式、UEFI、WASM 环境 |
| Windows 交叉编译 | cargo-xwin / MinGW | 在 Linux 上构建 Windows 程序 |
| 平台抽象模式 | #[cfg] + Trait 模式 | 支持多操作系统的代码库 |
| 调用 Windows API | windows-sys / windows crate | 使用 Windows 原生功能 |
| 端到端耗时测量 | hyperfine | 整体二进制基准测试及前后对比 |
| 基于属性的测试 | proptest | 发现边缘情况、增强解析器健壮性 |
| 快照测试 | insta | 验证大型结构化输出 |
| 覆盖率导向型模糊测试 | cargo-fuzz | 发现解析器中的崩溃 Bug |
| 并发模型检查 | loom | 验证无锁数据结构、原子序 |
| 特性组合测试 | cargo-hack | 针对拥有多个 #[cfg] 特性的 crate |
| 快速 UB 检查 (近乎原生) | cargo-careful | CI 安全门禁,比 Miri 更轻量 |
| 保存自动重构 | cargo-watch | 提升开发迭代反馈速度 |
| 工作区文档 | cargo doc + rustdoc | API 发现、上手文档、文档链接 CI 检查 |
| 可复现构建 | --locked + SOURCE_DATE_EPOCH | 验证发布版本的完整性 |
| CI 缓存微调 | Swatinem/rust-cache@v2 | 缩短构建耗时 (冷构建 → 缓存构建) |
| 工作区 Lint 政策 | Cargo.toml 中的 [workspace.lints] | 跨 crate 统一 Clippy 和编译器 Lint 规则 |
| 自动修复 Lint 警告 | cargo clippy --fix | 自动化清理琐碎代码问题 |
延伸阅读
生成参考资料 —— 作为《Rust 设计模式》与《类型驱动的正确性》的配套指南。
版本 1.3 —— 为了保证内容的完备性,增加了 cargo-hack, cargo-careful, cargo-watch, cargo doc, 可复现构建, CI 缓存策略, 总结性练习以及章节依赖图。
Rust Engineering Practices — Beyond cargo build
Speaker Intro
- Principal Firmware Architect in Microsoft SCHIE (Silicon and Cloud Hardware Infrastructure Engineering) team
- Industry veteran with expertise in security, systems programming (firmware, operating systems, hypervisors), CPU and platform architecture, and C++ systems
- Started programming in Rust in 2017 (@AWS EC2), and have been in love with the language ever since
A practical guide to the Rust toolchain features that most teams discover too late: build scripts, cross-compilation, benchmarking, code coverage, and safety verification with Miri and Valgrind. Each chapter uses concrete examples drawn from a real hardware-diagnostics codebase — a large multi-crate workspace — so every technique maps directly to production code.
How to Use This Book
This book is designed for self-paced study or team workshops. Each chapter is largely independent — read them in order or jump to the topic you need.
Difficulty Legend
| Symbol | Level | Meaning |
|---|---|---|
| 🟢 | Starter | Straightforward tools with clear patterns — useful on day one |
| 🟡 | Intermediate | Requires understanding of toolchain internals or platform concepts |
| 🔴 | Advanced | Deep toolchain knowledge, nightly features, or multi-tool orchestration |
Pacing Guide
| Part | Chapters | Est. Time | Key Outcome |
|---|---|---|---|
| I — Build & Ship | ch01–02 | 3–4 h | Build metadata, cross-compilation, static binaries |
| II — Measure & Verify | ch03–05 | 4–5 h | Statistical benchmarking, coverage gates, Miri/sanitizers |
| III — Harden & Optimize | ch06–10 | 6–8 h | Supply chain security, release profiles, compile-time tools, no_std, Windows |
| IV — Integrate | ch11–13 | 3–4 h | Production CI/CD pipeline, tricks, capstone exercise |
| 16–21 h | Full production engineering pipeline |
Working Through Exercises
Each chapter contains 🏋️ exercises with difficulty indicators. Solutions are provided in expandable <details> blocks — try the exercise first, then check your work.
- 🟢 exercises can often be done in 10–15 minutes
- 🟡 exercises require 20–40 minutes and may involve running tools locally
- 🔴 exercises require significant setup and experimentation (1+ hour)
Prerequisites
| Concept | Where to learn it |
|---|---|
| Cargo workspace layout | Rust Book ch14.3 |
| Feature flags | Cargo Reference — Features |
#[cfg(test)] and basic testing | Rust Patterns ch12 |
unsafe blocks and FFI basics | Rust Patterns ch10 |
Chapter Dependency Map
┌──────────┐
│ ch00 │
│ Intro │
└────┬─────┘
┌─────┬───┬──┴──┬──────┬──────┐
▼ ▼ ▼ ▼ ▼ ▼
ch01 ch03 ch04 ch05 ch06 ch09
Build Bench Cov Miri Deps no_std
│ │ │ │ │ │
│ └────┴────┘ │ ▼
│ │ │ ch10
▼ ▼ ▼ Windows
ch02 ch07 ch07 │
Cross RelProf RelProf │
│ │ │ │
│ ▼ │ │
│ ch08 │ │
│ CompTime │ │
└──────────┴───────────┴─────┘
│
▼
ch11
CI/CD Pipeline
│
▼
ch12 ─── ch13
Tricks Quick Ref
Read in any order: ch01, ch03, ch04, ch05, ch06, ch09 are independent. Read after prerequisites: ch02 (needs ch01), ch07–ch08 (benefit from ch03–ch06), ch10 (benefits from ch09). Read last: ch11 (ties everything together), ch12 (tricks), ch13 (reference).
Annotated Table of Contents
Part I — Build & Ship
| # | Chapter | Difficulty | Description |
|---|---|---|---|
| 1 | Build Scripts — build.rs in Depth | 🟢 | Compile-time constants, compiling C code, protobuf generation, system library linking, anti-patterns |
| 2 | Cross-Compilation — One Source, Many Targets | 🟡 | Target triples, musl static binaries, ARM cross-compile, cross tool, cargo-zigbuild, GitHub Actions |
Part II — Measure & Verify
| # | Chapter | Difficulty | Description |
|---|---|---|---|
| 3 | Benchmarking — Measuring What Matters | 🟡 | Criterion.rs, Divan, perf flamegraphs, PGO, continuous benchmarking in CI |
| 4 | Code Coverage — Seeing What Tests Miss | 🟢 | cargo-llvm-cov, cargo-tarpaulin, grcov, Codecov/Coveralls CI integration |
| 5 | Miri, Valgrind, and Sanitizers | 🔴 | MIR interpreter, Valgrind memcheck/Helgrind, ASan/MSan/TSan, cargo-fuzz, loom |
Part III — Harden & Optimize
| # | Chapter | Difficulty | Description |
|---|---|---|---|
| 6 | Dependency Management and Supply Chain Security | 🟢 | cargo-audit, cargo-deny, cargo-vet, cargo-outdated, cargo-semver-checks |
| 7 | Release Profiles and Binary Size | 🟡 | Release profile anatomy, LTO trade-offs, cargo-bloat, cargo-udeps |
| 8 | Compile-Time and Developer Tools | 🟡 | sccache, mold, cargo-nextest, cargo-expand, cargo-geiger, workspace lints, MSRV |
| 9 | no_std and Feature Verification | 🔴 | cargo-hack, core/alloc/std layers, custom panic handlers, testing no_std code |
| 10 | Windows and Conditional Compilation | 🟡 | #[cfg] patterns, windows-sys/windows crates, cargo-xwin, platform abstraction |
Part IV — Integrate
| # | Chapter | Difficulty | Description |
|---|---|---|---|
| 11 | Putting It All Together — A Production CI/CD Pipeline | 🟡 | GitHub Actions workflow, cargo-make, pre-commit hooks, cargo-dist, capstone |
| 12 | Tricks from the Trenches | 🟡 | 10 battle-tested patterns: deny(warnings) trap, cache tuning, dep dedup, RUSTFLAGS, more |
| 13 | Quick Reference Card | — | Commands at a glance, 60+ decision table entries, further reading links |
Build Scripts — build.rs in Depth 🟢
What you’ll learn:
- How
build.rsfits into the Cargo build pipeline and when it runs- Five production patterns: compile-time constants, C/C++ compilation, protobuf codegen,
pkg-configlinking, and feature detection- Anti-patterns that slow builds or break cross-compilation
- How to balance traceability with reproducible builds
Cross-references: Cross-Compilation uses build scripts for target-aware builds ·
no_std& Features extendscfgflags set here · CI/CD Pipeline orchestrates build scripts in automation
Every Cargo package can include a file named build.rs at the crate root.
Cargo compiles and executes this file before compiling your crate. The build
script communicates back to Cargo through println! instructions on stdout.
What build.rs Is and When It Runs
┌─────────────────────────────────────────────────────────┐
│ Cargo Build Pipeline │
│ │
│ 1. Resolve dependencies │
│ 2. Download crates │
│ 3. Compile build.rs ← ordinary Rust, runs on HOST │
│ 4. Execute build.rs ← stdout → Cargo instructions │
│ 5. Compile the crate (using instructions from step 4) │
│ 6. Link │
└─────────────────────────────────────────────────────────┘
Key facts:
build.rsruns on the host machine, not the target. During cross-compilation, the build script runs on your development machine even when the final binary targets a different architecture.- The build script’s scope is limited to its own package. It cannot affect how
other crates compile — unless the package declares a
linkskey inCargo.toml, which enables passing metadata to dependent crates viacargo::metadata=KEY=VALUE. - It runs every time Cargo detects a change — unless you emit
cargo::rerun-if-changedinstructions to limit re-runs.
Note (Rust 1.71+): Since Rust 1.71, Cargo fingerprints the compiled
build.rsbinary — if the binary is identical, it won’t re-run even if source timestamps changed. However,cargo::rerun-if-changed=build.rsis still valuable: without anyrerun-if-changedinstruction, Cargo re-runsbuild.rswhenever any file in the package changes (not justbuild.rs). Emittingcargo::rerun-if-changed=build.rslimits re-runs to only whenbuild.rsitself changes — a significant compile-time saving in large crates.
- It can emit cfg flags, environment variables, linker arguments, and file paths that the main crate consumes.
The minimal Cargo.toml entry:
[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"
build = "build.rs" # default — Cargo looks for build.rs automatically
# build = "src/build.rs" # or put it elsewhere
The Cargo Instruction Protocol
Your build script communicates with Cargo by printing instructions to stdout.
Since Rust 1.77, the preferred prefix is cargo:: (replacing the older
cargo: single-colon form).
| Instruction | Purpose |
|---|---|
cargo::rerun-if-changed=PATH | Only re-run build.rs when PATH changes |
cargo::rerun-if-env-changed=VAR | Only re-run when environment variable VAR changes |
cargo::rustc-link-lib=NAME | Link against native library NAME |
cargo::rustc-link-search=PATH | Add PATH to the library search path |
cargo::rustc-cfg=KEY | Set a #[cfg(KEY)] flag for conditional compilation |
cargo::rustc-cfg=KEY="VALUE" | Set a #[cfg(KEY = "VALUE")] flag |
cargo::rustc-env=KEY=VALUE | Set an environment variable accessible via env!() |
cargo::rustc-cdylib-link-arg=FLAG | Pass FLAG to the linker for cdylib targets |
cargo::warning=MESSAGE | Display a warning during compilation |
cargo::metadata=KEY=VALUE | Store metadata readable by dependent crates |
// build.rs — minimal example
fn main() {
// Only re-run if build.rs itself changes
println!("cargo::rerun-if-changed=build.rs");
// Set a compile-time environment variable
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".into());
println!("cargo::rustc-env=BUILD_TIMESTAMP={timestamp}");
}
Pattern 1: Compile-Time Constants
The most common use case: baking build metadata into the binary so you can report it at runtime (git hash, build date, CI job ID).
// build.rs
use std::process::Command;
fn main() {
println!("cargo::rerun-if-changed=.git/HEAD");
println!("cargo::rerun-if-changed=.git/refs");
// Git commit hash
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.expect("git not found");
let git_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!("cargo::rustc-env=GIT_HASH={git_hash}");
// Build profile (debug or release)
let profile = std::env::var("PROFILE").unwrap_or_else(|_| "unknown".into());
println!("cargo::rustc-env=BUILD_PROFILE={profile}");
// Target triple
let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".into());
println!("cargo::rustc-env=BUILD_TARGET={target}");
}
#![allow(unused)]
fn main() {
// src/main.rs — consuming the build-time values
fn print_version() {
println!(
"{} {} (git:{} target:{} profile:{})",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
env!("GIT_HASH"),
env!("BUILD_TARGET"),
env!("BUILD_PROFILE"),
);
}
}
Built-in Cargo environment variables you get for free, no build.rs needed:
CARGO_PKG_NAME,CARGO_PKG_VERSION,CARGO_PKG_AUTHORS,CARGO_PKG_DESCRIPTION,CARGO_MANIFEST_DIR. See the full list.
Pattern 2: Compiling C/C++ Code with the cc Crate
When your Rust crate wraps a C library or needs a small C helper (common in
hardware interfaces), the cc crate simplifies
compilation inside build.rs.
# Cargo.toml
[build-dependencies]
cc = "1.0"
// build.rs
fn main() {
println!("cargo::rerun-if-changed=csrc/");
cc::Build::new()
.file("csrc/ipmi_raw.c")
.file("csrc/smbios_parser.c")
.include("csrc/include")
.flag("-Wall")
.flag("-Wextra")
.opt_level(2)
.compile("diag_helpers");
// This produces libdiag_helpers.a and emits the right
// cargo::rustc-link-lib and cargo::rustc-link-search instructions.
}
#![allow(unused)]
fn main() {
// src/lib.rs — FFI bindings to the compiled C code
extern "C" {
fn ipmi_raw_command(
netfn: u8,
cmd: u8,
data: *const u8,
data_len: usize,
response: *mut u8,
response_len: *mut usize,
) -> i32;
}
/// Safe wrapper around the raw IPMI command interface.
/// Assumes: enum IpmiError { CommandFailed(i32), ... }
pub fn send_ipmi_command(netfn: u8, cmd: u8, data: &[u8]) -> Result<Vec<u8>, IpmiError> {
let mut response = vec![0u8; 256];
let mut response_len: usize = response.len();
// SAFETY: response buffer is large enough and response_len is correctly initialized.
let rc = unsafe {
ipmi_raw_command(
netfn,
cmd,
data.as_ptr(),
data.len(),
response.as_mut_ptr(),
&mut response_len,
)
};
if rc != 0 {
return Err(IpmiError::CommandFailed(rc));
}
response.truncate(response_len);
Ok(response)
}
}
For C++ code, use .cpp(true) and .flag("-std=c++17"):
// build.rs — C++ variant
fn main() {
println!("cargo::rerun-if-changed=cppsrc/");
cc::Build::new()
.cpp(true)
.file("cppsrc/vendor_parser.cpp")
.flag("-std=c++17")
.flag("-fno-exceptions") // match Rust's no-exception model
.compile("vendor_helpers");
}
Pattern 3: Protocol Buffers and Code Generation
Build scripts excel at code generation — turning .proto, .fbs, or .json
schema files into Rust source at compile time. Here’s the protobuf pattern
using prost-build:
# Cargo.toml
[build-dependencies]
prost-build = "0.13"
// build.rs
fn main() {
println!("cargo::rerun-if-changed=proto/");
prost_build::compile_protos(
&["proto/diagnostics.proto", "proto/telemetry.proto"],
&["proto/"],
)
.expect("Failed to compile protobuf definitions");
}
#![allow(unused)]
fn main() {
// src/lib.rs — include the generated code
pub mod diagnostics {
include!(concat!(env!("OUT_DIR"), "/diagnostics.rs"));
}
pub mod telemetry {
include!(concat!(env!("OUT_DIR"), "/telemetry.rs"));
}
}
OUT_DIRis a Cargo-provided directory where build scripts should place generated files. Each crate gets its ownOUT_DIRundertarget/.
Pattern 4: Linking System Libraries with pkg-config
For system libraries that provide .pc files (systemd, OpenSSL, libpci),
the pkg-config crate probes the system and
emits the right link instructions:
# Cargo.toml
[build-dependencies]
pkg-config = "0.3"
// build.rs
fn main() {
// Probe for libpci (used for PCIe device enumeration)
pkg_config::Config::new()
.atleast_version("3.6.0")
.probe("libpci")
.expect("libpci >= 3.6.0 not found — install pciutils-dev");
// Probe for libsystemd (optional — for sd_notify integration)
if pkg_config::probe_library("libsystemd").is_ok() {
println!("cargo::rustc-cfg=has_systemd");
}
}
#![allow(unused)]
fn main() {
// src/lib.rs — conditional compilation based on pkg-config probing
#[cfg(has_systemd)]
mod systemd_notify {
extern "C" {
fn sd_notify(unset_environment: i32, state: *const std::ffi::c_char) -> i32;
}
pub fn notify_ready() {
let state = std::ffi::CString::new("READY=1").unwrap();
// SAFETY: state is a valid null-terminated C string.
unsafe { sd_notify(0, state.as_ptr()) };
}
}
#[cfg(not(has_systemd))]
mod systemd_notify {
pub fn notify_ready() {
// no-op on systems without systemd
}
}
}
Pattern 5: Feature Detection and Conditional Compilation
Build scripts can probe the compilation environment and set cfg flags that the main crate uses for conditional code paths.
CPU architecture and OS detection (safe — these are compile-time constants):
// build.rs — detect CPU features and OS capabilities
fn main() {
println!("cargo::rerun-if-changed=build.rs");
let target = std::env::var("TARGET").unwrap();
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
// Enable AVX2-optimized paths on x86_64
if target.starts_with("x86_64") {
println!("cargo::rustc-cfg=has_x86_64");
}
// Enable ARM NEON paths on aarch64
if target.starts_with("aarch64") {
println!("cargo::rustc-cfg=has_aarch64");
}
// Detect if /dev/ipmi0 is available (build-time check)
if target_os == "linux" && std::path::Path::new("/dev/ipmi0").exists() {
println!("cargo::rustc-cfg=has_ipmi_device");
}
}
⚠️ Anti-pattern demonstration — The code below shows a tempting but problematic approach. Do not use this in production.
// build.rs — BAD: runtime hardware detection at build time
fn main() {
// ANTI-PATTERN: Binary is baked to the BUILD machine's hardware.
// If you build on a machine with a GPU and deploy to one without,
// the binary silently assumes a GPU is present.
if std::process::Command::new("accel-query")
.arg("--query-gpu=name")
.arg("--format=csv,noheader")
.output()
.is_ok()
{
println!("cargo::rustc-cfg=has_accel_device");
}
}
#![allow(unused)]
fn main() {
// src/gpu.rs — code that adapts based on build-time detection
pub fn query_gpu_info() -> GpuResult {
#[cfg(has_accel_device)]
{
run_accel_query()
}
#[cfg(not(has_accel_device))]
{
GpuResult::NotAvailable("accel-query not found at build time".into())
}
}
}
⚠️ Why this is wrong: Runtime device detection is almost always better than build-time detection for optional hardware. The binary produced above is tied to the build machine’s hardware configuration — it will behave differently on the deployment target. Use build-time detection only for capabilities that are truly fixed at compile time (architecture, OS, library availability). For hardware like GPUs, detect at runtime with
which accel-queryoraccel-mgmtprobing.
Anti-Patterns and Pitfalls
| Anti-Pattern | Why It’s Bad | Fix |
|---|---|---|
No rerun-if-changed | build.rs runs on every build, slowing iteration | Always emit at least cargo::rerun-if-changed=build.rs |
| Network calls in build.rs | Builds fail offline, non-reproducible | Vendor files or use a separate fetch step |
Writing to src/ | Cargo doesn’t expect source to change during build | Write to OUT_DIR and use include!() |
| Heavy computation | Slows every cargo build | Cache results in OUT_DIR, gate with rerun-if-changed |
| Ignoring cross-compilation | Using Command::new("gcc") without respecting $CC | Use the cc crate which handles cross-compilation toolchains |
| Panicking without context | unwrap() gives opaque “build script failed” error | Use .expect("descriptive message") or print cargo::warning= |
Application: Embedding Build Metadata
The project currently uses env!("CARGO_PKG_VERSION") for version
reporting. A build script would extend this with richer metadata:
// build.rs — proposed addition
fn main() {
println!("cargo::rerun-if-changed=.git/HEAD");
println!("cargo::rerun-if-changed=.git/refs");
println!("cargo::rerun-if-changed=build.rs");
// Embed git hash for traceability in diagnostic reports
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--short=10", "HEAD"])
.output()
{
let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!("cargo::rustc-env=APP_GIT_HASH={hash}");
} else {
println!("cargo::rustc-env=APP_GIT_HASH=unknown");
}
// Embed build timestamp for report correlation
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".into());
println!("cargo::rustc-env=APP_BUILD_EPOCH={timestamp}");
// Emit target triple — useful in multi-arch deployment
let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".into());
println!("cargo::rustc-env=APP_TARGET={target}");
}
#![allow(unused)]
fn main() {
// src/version.rs — consuming the metadata
pub struct BuildInfo {
pub version: &'static str,
pub git_hash: &'static str,
pub build_epoch: &'static str,
pub target: &'static str,
}
pub const BUILD_INFO: BuildInfo = BuildInfo {
version: env!("CARGO_PKG_VERSION"),
git_hash: env!("APP_GIT_HASH"),
build_epoch: env!("APP_BUILD_EPOCH"),
target: env!("APP_TARGET"),
};
impl BuildInfo {
/// Parse the epoch at runtime when needed (const &str → u64 is not
/// possible on stable Rust — there is no const fn for str-to-int).
pub fn build_epoch_secs(&self) -> u64 {
self.build_epoch.parse().unwrap_or(0)
}
}
impl std::fmt::Display for BuildInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"DiagTool v{} (git:{} target:{})",
self.version, self.git_hash, self.target
)
}
}
}
Key insight from the project: The codebase has zero
build.rsfiles across all many crates because it’s pure Rust with no C dependencies, no codegen, and no system library linking. When you need these,build.rsis the tool — but don’t add it “just because.” The absence of build scripts in a large codebase is a feature, not a gap. See Dependency Management for how the project manages its supply chain without custom build logic. is a positive signal of a clean architecture.
Try It Yourself
-
Embed git metadata: Create a
build.rsthat emitsAPP_GIT_HASHandAPP_BUILD_EPOCHas environment variables. Consume them withenv!()inmain.rsand print the build info. Verify the hash changes after a commit. -
Probe a system library: Write a
build.rsthat usespkg-configto probe forlibz(zlib). Emitcargo::rustc-cfg=has_zlibif found. Inmain.rs, conditionally print “zlib available” or “zlib not found” based on the cfg flag. -
Trigger a build failure intentionally: Remove the
rerun-if-changedline from yourbuild.rsand observe how many times it reruns duringcargo buildandcargo test. Then add it back and compare.
Reproducible Builds
Chapter 1 teaches embedding timestamps and git hashes into binaries. This is useful for traceability, but it conflicts with reproducible builds — the property that building the same source always produces the same binary.
The tension:
| Goal | Achievement | Cost |
|---|---|---|
| Traceability | APP_BUILD_EPOCH in binary | Every build is unique — can’t verify integrity |
| Reproducibility | cargo build --locked always produces same output | No build-time metadata |
Practical resolution:
# 1. Always use --locked in CI (ensures Cargo.lock is respected)
cargo build --release --locked
# Fails if Cargo.lock is missing or outdated — catches "works on my machine"
# 2. For reproducibility-critical builds, set SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) cargo build --release --locked
# Uses the last commit timestamp instead of "now" — same commit = same binary
#![allow(unused)]
fn main() {
// In build.rs: respect SOURCE_DATE_EPOCH for reproducibility
let timestamp = std::env::var("SOURCE_DATE_EPOCH")
.unwrap_or_else(|_| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".into())
});
println!("cargo::rustc-env=APP_BUILD_EPOCH={timestamp}");
}
Best practice: Use
SOURCE_DATE_EPOCHin build scripts so release builds are reproducible (git-hash + locked deps + deterministic timestamp = same binary), while dev builds still get live timestamps for convenience.
Build Pipeline Decision Diagram
flowchart TD
START["Need compile-time work?"] -->|No| SKIP["No build.rs needed"]
START -->|Yes| WHAT{"What kind?"}
WHAT -->|"Embed metadata"| P1["Pattern 1\nCompile-Time Constants"]
WHAT -->|"Compile C/C++"| P2["Pattern 2\ncc crate"]
WHAT -->|"Code generation"| P3["Pattern 3\nprost-build / tonic-build"]
WHAT -->|"Link system lib"| P4["Pattern 4\npkg-config"]
WHAT -->|"Detect features"| P5["Pattern 5\ncfg flags"]
P1 --> RERUN["Always emit\ncargo::rerun-if-changed"]
P2 --> RERUN
P3 --> RERUN
P4 --> RERUN
P5 --> RERUN
style SKIP fill:#91e5a3,color:#000
style RERUN fill:#ffd43b,color:#000
style P1 fill:#e3f2fd,color:#000
style P2 fill:#e3f2fd,color:#000
style P3 fill:#e3f2fd,color:#000
style P4 fill:#e3f2fd,color:#000
style P5 fill:#e3f2fd,color:#000
🏋️ Exercises
🟢 Exercise 1: Version Stamp
Create a minimal crate with a build.rs that embeds the current git hash and build profile into environment variables. Print them from main(). Verify the output changes between debug and release builds.
Solution
// build.rs
fn main() {
println!("cargo::rerun-if-changed=.git/HEAD");
println!("cargo::rerun-if-changed=build.rs");
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "unknown".into());
println!("cargo::rustc-env=GIT_HASH={hash}");
println!("cargo::rustc-env=BUILD_PROFILE={}", std::env::var("PROFILE").unwrap_or_default());
}
// src/main.rs
fn main() {
println!("{} v{} (git:{} profile:{})",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
env!("GIT_HASH"),
env!("BUILD_PROFILE"),
);
}
cargo run # shows profile:debug
cargo run --release # shows profile:release
🟡 Exercise 2: Conditional System Library
Write a build.rs that probes for both libz and libpci using pkg-config. Emit a cfg flag for each one found. In main.rs, print which libraries were detected at build time.
Solution
# Cargo.toml
[build-dependencies]
pkg-config = "0.3"
// build.rs
fn main() {
println!("cargo::rerun-if-changed=build.rs");
if pkg_config::probe_library("zlib").is_ok() {
println!("cargo::rustc-cfg=has_zlib");
}
if pkg_config::probe_library("libpci").is_ok() {
println!("cargo::rustc-cfg=has_libpci");
}
}
// src/main.rs
fn main() {
#[cfg(has_zlib)]
println!("✅ zlib detected");
#[cfg(not(has_zlib))]
println!("❌ zlib not found");
#[cfg(has_libpci)]
println!("✅ libpci detected");
#[cfg(not(has_libpci))]
println!("❌ libpci not found");
}
Key Takeaways
build.rsruns on the host at compile time — always emitcargo::rerun-if-changedto avoid unnecessary rebuilds- Use the
cccrate (not rawgcccommands) for C/C++ compilation — it handles cross-compilation toolchains correctly - Write generated files to
OUT_DIR, never tosrc/— Cargo doesn’t expect source to change during builds - Prefer runtime detection over build-time detection for optional hardware
- Use
SOURCE_DATE_EPOCHto make builds reproducible when embedding timestamps
Cross-Compilation — One Source, Many Targets 🟡
What you’ll learn:
- How Rust target triples work and how to add them with
rustup- Building static musl binaries for container/cloud deployment
- Cross-compiling to ARM (aarch64) with native toolchains,
cross, andcargo-zigbuild- Setting up GitHub Actions matrix builds for multi-architecture CI
Cross-references: Build Scripts — build.rs runs on HOST during cross-compilation · Release Profiles — LTO and strip settings for cross-compiled release binaries · Windows — Windows cross-compilation and
no_stdtargets
Cross-compilation means building an executable on one machine (the host) that
runs on a different machine (the target). The host might be your x86_64 laptop;
the target might be an ARM server, a musl-based container, or even a Windows machine.
Rust makes this remarkably feasible because rustc is already a cross-compiler —
it just needs the right target libraries and a compatible linker.
The Target Triple Anatomy
Every Rust compilation target is identified by a target triple (which often has four parts despite the name):
<arch>-<vendor>-<os>-<env>
Examples:
x86_64 - unknown - linux - gnu ← standard Linux (glibc)
x86_64 - unknown - linux - musl ← static Linux (musl libc)
aarch64 - unknown - linux - gnu ← ARM 64-bit Linux
x86_64 - pc - windows- msvc ← Windows with MSVC
aarch64 - apple - darwin ← macOS on Apple Silicon
x86_64 - unknown - none ← bare metal (no OS)
List all available targets:
# Show all targets rustc can compile to (~250 targets)
rustc --print target-list | wc -l
# Show installed targets on your system
rustup target list --installed
# Show current default target
rustc -vV | grep host
Installing Toolchains with rustup
# Add target libraries (Rust std for that target)
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-gnu
# Now you can cross-compile:
cargo build --target x86_64-unknown-linux-musl
cargo build --target aarch64-unknown-linux-gnu # needs a linker — see below
What rustup target add gives you: the pre-compiled std, core, and alloc
libraries for that target. It does not give you a C linker or C library. For targets
that need a C toolchain (most gnu targets), you need to install one separately.
# Ubuntu/Debian — install the cross-linker for aarch64
sudo apt install gcc-aarch64-linux-gnu
# Ubuntu/Debian — install musl toolchain for static builds
sudo apt install musl-tools
# Fedora
sudo dnf install gcc-aarch64-linux-gnu
.cargo/config.toml — Per-Target Configuration
Instead of passing --target on every command, configure defaults in
.cargo/config.toml at your project root or home directory:
# .cargo/config.toml
# Default target for this project (optional — omit to keep native default)
# [build]
# target = "x86_64-unknown-linux-musl"
# Linker for aarch64 cross-compilation
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
rustflags = ["-C", "target-feature=+crc"]
# Linker for musl static builds (usually just the system gcc works)
[target.x86_64-unknown-linux-musl]
linker = "musl-gcc"
rustflags = ["-C", "target-feature=+crc,+aes"]
# ARM 32-bit (Raspberry Pi, embedded)
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
# Environment variables for all targets
[env]
# Example: set a custom sysroot
# SYSROOT = "/opt/cross/sysroot"
Config file search order (first match wins):
<project>/.cargo/config.toml<project>/../.cargo/config.toml(parent directories, walking up)$CARGO_HOME/config.toml(usually~/.cargo/config.toml)
Static Binaries with musl
For deploying to minimal containers (Alpine, scratch Docker images) or systems where you can’t control the glibc version, build with musl:
# Install musl target
rustup target add x86_64-unknown-linux-musl
sudo apt install musl-tools # provides musl-gcc
# Build a fully static binary
cargo build --release --target x86_64-unknown-linux-musl
# Verify it's static
file target/x86_64-unknown-linux-musl/release/diag_tool
# → ELF 64-bit LSB executable, x86-64, statically linked
ldd target/x86_64-unknown-linux-musl/release/diag_tool
# → not a dynamic executable
Static vs dynamic trade-offs:
| Aspect | glibc (dynamic) | musl (static) |
|---|---|---|
| Binary size | Smaller (shared libs) | Larger (~5-15 MB increase) |
| Portability | Needs matching glibc version | Runs anywhere on Linux |
| DNS resolution | Full nsswitch support | Basic resolver (no mDNS) |
| Deployment | Needs sysroot or container | Single binary, no deps |
| Performance | Slightly faster malloc | Slightly slower malloc |
dlopen() support | Yes | No |
For the project: A static musl build is ideal for deployment to diverse server hardware where you can’t guarantee the host OS version. The single-binary deployment model eliminates “works on my machine” issues.
Cross-Compiling to ARM (aarch64)
ARM servers (AWS Graviton, Ampere Altra, Grace) are increasingly common in data centers. Cross-compiling for aarch64 from an x86_64 host:
# Step 1: Install target + cross-linker
rustup target add aarch64-unknown-linux-gnu
sudo apt install gcc-aarch64-linux-gnu
# Step 2: Configure linker in .cargo/config.toml (see above)
# Step 3: Build
cargo build --release --target aarch64-unknown-linux-gnu
# Step 4: Verify the binary
file target/aarch64-unknown-linux-gnu/release/diag_tool
# → ELF 64-bit LSB executable, ARM aarch64
Running tests for the target architecture requires either:
- An actual ARM machine
- QEMU user-mode emulation
# Install QEMU user-mode (runs ARM binaries on x86_64)
sudo apt install qemu-user qemu-user-static binfmt-support
# Now cargo test can run cross-compiled tests through QEMU
cargo test --target aarch64-unknown-linux-gnu
# (Slow — each test binary is emulated. Use for CI validation, not daily dev.)
Configure QEMU as the test runner in .cargo/config.toml:
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
runner = "qemu-aarch64-static -L /usr/aarch64-linux-gnu"
The cross Tool — Docker-Based Cross-Compilation
The cross tool provides a zero-setup
cross-compilation experience using pre-configured Docker images:
# Install cross (from crates.io — stable releases)
cargo install cross
# Or from git for latest features (less stable):
# cargo install cross --git https://github.com/cross-rs/cross
# Cross-compile — no toolchain setup needed!
cross build --release --target aarch64-unknown-linux-gnu
cross build --release --target x86_64-unknown-linux-musl
cross build --release --target armv7-unknown-linux-gnueabihf
# Cross-test — QEMU included in the Docker image
cross test --target aarch64-unknown-linux-gnu
How it works: cross replaces cargo and runs the build inside a Docker
container that has the correct cross-compilation toolchain pre-installed. Your
source is mounted into the container, and the output goes to your normal target/
directory.
Customizing the Docker image with Cross.toml:
# Cross.toml
[target.aarch64-unknown-linux-gnu]
# Use a custom Docker image with extra system libraries
image = "my-registry/cross-aarch64:latest"
# Pre-install system packages
pre-build = [
"dpkg --add-architecture arm64",
"apt-get update && apt-get install -y libpci-dev:arm64"
]
[target.aarch64-unknown-linux-gnu.env]
# Pass environment variables into the container
passthrough = ["CI", "GITHUB_TOKEN"]
cross requires Docker (or Podman) but eliminates the need to manually install
cross-compilers, sysroots, and QEMU. It’s the recommended approach for CI.
Using Zig as a Cross-Compilation Linker
Zig bundles a C compiler and cross-compilation sysroot for ~40 targets in a single ~40 MB download. This makes it a remarkably convenient cross-linker for Rust:
# Install Zig (single binary, no package manager needed)
# Download from https://ziglang.org/download/
# Or via package manager:
sudo snap install zig --classic --beta # Ubuntu
brew install zig # macOS
# Install cargo-zigbuild
cargo install cargo-zigbuild
Why Zig? The key advantage is glibc version targeting. Zig lets you specify the exact glibc version to link against, ensuring your binary runs on older Linux distributions:
# Build for glibc 2.17 (CentOS 7 / RHEL 7 compatibility)
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.17
# Build for aarch64 with glibc 2.28 (Ubuntu 18.04+)
cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.28
# Build for musl (fully static)
cargo zigbuild --release --target x86_64-unknown-linux-musl
The .2.17 suffix is a Zig extension — it tells Zig’s linker to use glibc 2.17
symbol versions, so the resulting binary runs on CentOS 7 and later. No Docker,
no sysroot management, no cross-compiler installation.
Comparison: cross vs cargo-zigbuild vs manual:
| Feature | Manual | cross | cargo-zigbuild |
|---|---|---|---|
| Setup effort | High (install toolchain per target) | Low (needs Docker) | Low (single binary) |
| Docker required | No | Yes | No |
| glibc version targeting | No (uses host glibc) | No (uses container glibc) | Yes (exact version) |
| Test execution | Needs QEMU | Included | Needs QEMU |
| macOS → Linux | Difficult | Easy | Easy |
| Linux → macOS | Very difficult | Not supported | Limited |
| Binary size overhead | None | None | None |
CI Pipeline: GitHub Actions Matrix
A production-grade CI workflow that builds for multiple targets:
# .github/workflows/cross-build.yml
name: Cross-Platform Build
on: [push, pull_request]
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
name: linux-x86_64
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
name: linux-x86_64-static
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
name: linux-aarch64
use_cross: true
- target: x86_64-pc-windows-msvc
os: windows-latest
name: windows-x86_64
runs-on: ${{ matrix.os }}
name: Build (${{ matrix.name }})
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install musl tools
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get install -y musl-tools
- name: Install cross
if: matrix.use_cross
run: cargo install cross
- name: Build (native)
if: "!matrix.use_cross"
run: cargo build --release --target ${{ matrix.target }}
- name: Build (cross)
if: matrix.use_cross
run: cross build --release --target ${{ matrix.target }}
- name: Run tests
if: "!matrix.use_cross"
run: cargo test --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: diag_tool-${{ matrix.name }}
path: target/${{ matrix.target }}/release/diag_tool*
Application: Multi-Architecture Server Builds
The binary currently has no cross-compilation setup. For a hardware diagnostics tool deployed across diverse server fleets, the recommended addition:
my_workspace/
├── .cargo/
│ └── config.toml ← linker configs per target
├── Cross.toml ← cross tool configuration
└── .github/workflows/
└── cross-build.yml ← CI matrix for 3 targets
Recommended .cargo/config.toml:
# .cargo/config.toml for the project
# Release profile optimizations (already in Cargo.toml, shown for reference)
# [profile.release]
# lto = true
# codegen-units = 1
# panic = "abort"
# strip = true
# aarch64 for ARM servers (Graviton, Ampere, Grace)
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
# musl for portable static binaries
[target.x86_64-unknown-linux-musl]
linker = "musl-gcc"
Recommended build targets:
| Target | Use Case | Deploy To |
|---|---|---|
x86_64-unknown-linux-gnu | Default native build | Standard x86 servers |
x86_64-unknown-linux-musl | Static binary, any distro | Containers, minimal hosts |
aarch64-unknown-linux-gnu | ARM servers | Graviton, Ampere, Grace |
Key insight: The
[profile.release]in the workspace’s rootCargo.tomlalready haslto = true,codegen-units = 1,panic = "abort", andstrip = true— an ideal release profile for cross-compiled deployment binaries (see Release Profiles for the full impact table). Combined with musl, this produces a single ~10 MB static binary with no runtime dependencies.
Troubleshooting Cross-Compilation
| Symptom | Cause | Fix |
|---|---|---|
linker 'aarch64-linux-gnu-gcc' not found | Missing cross-linker toolchain | sudo apt install gcc-aarch64-linux-gnu |
cannot find -lssl (musl target) | System OpenSSL is glibc-linked | Use vendored feature: openssl = { version = "0.10", features = ["vendored"] } |
build.rs runs wrong binary | build.rs runs on HOST, not target | Check CARGO_CFG_TARGET_OS in build.rs, not cfg!(target_os) |
Tests pass locally, fail in cross | Docker image missing test fixtures | Mount test data via Cross.toml: [build.env] volumes = ["./TestArea:/TestArea"] |
undefined reference to __cxa_thread_atexit_impl | Old glibc on target | Use cargo-zigbuild with explicit glibc version: --target x86_64-unknown-linux-gnu.2.17 |
| Binary segfaults on ARM | Compiled for wrong ARM variant | Verify target triple matches hardware: aarch64-unknown-linux-gnu for 64-bit ARM |
GLIBC_2.XX not found at runtime | Build machine has newer glibc | Use musl for static builds, or cargo-zigbuild for glibc version pinning |
Cross-Compilation Decision Tree
flowchart TD
START["Need to cross-compile?"] --> STATIC{"Static binary?"}
STATIC -->|Yes| MUSL["musl target\n--target x86_64-unknown-linux-musl"]
STATIC -->|No| GLIBC{"Need old glibc?"}
GLIBC -->|Yes| ZIG["cargo-zigbuild\n--target x86_64-unknown-linux-gnu.2.17"]
GLIBC -->|No| ARCH{"Target arch?"}
ARCH -->|"Same arch"| NATIVE["Native toolchain\nrustup target add + linker"]
ARCH -->|"ARM/other"| DOCKER{"Docker available?"}
DOCKER -->|Yes| CROSS["cross build\nDocker-based, zero setup"]
DOCKER -->|No| MANUAL["Manual sysroot\napt install gcc-aarch64-linux-gnu"]
style MUSL fill:#91e5a3,color:#000
style ZIG fill:#91e5a3,color:#000
style CROSS fill:#91e5a3,color:#000
style NATIVE fill:#e3f2fd,color:#000
style MANUAL fill:#ffd43b,color:#000
🏋️ Exercises
🟢 Exercise 1: Static musl Binary
Build any Rust binary for x86_64-unknown-linux-musl. Verify it’s statically linked using file and ldd.
Solution
rustup target add x86_64-unknown-linux-musl
cargo new hello-static && cd hello-static
cargo build --release --target x86_64-unknown-linux-musl
# Verify
file target/x86_64-unknown-linux-musl/release/hello-static
# Output: ... statically linked ...
ldd target/x86_64-unknown-linux-musl/release/hello-static
# Output: not a dynamic executable
🟡 Exercise 2: GitHub Actions Cross-Build Matrix
Write a GitHub Actions workflow that builds a Rust project for three targets: x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl, and aarch64-unknown-linux-gnu. Use a matrix strategy.
Solution
name: Cross-build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
target:
- x86_64-unknown-linux-gnu
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-gnu
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross
run: cargo install cross --locked
- name: Build
run: cross build --release --target ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: target/${{ matrix.target }}/release/my-binary
Key Takeaways
- Rust’s
rustcis already a cross-compiler — you just need the right target and linker - musl produces fully static binaries with zero runtime dependencies — ideal for containers
cargo-zigbuildsolves the “which glibc version” problem for enterprise Linux targetscrossis the easiest path for ARM and other exotic targets — Docker handles the sysroot- Always test with
fileandlddto verify the binary matches your deployment target
Benchmarking — Measuring What Matters 🟡
What you’ll learn:
- Why naive timing with
Instant::now()produces unreliable results- Statistical benchmarking with Criterion.rs and the lighter Divan alternative
- Profiling hot spots with
perf, flamegraphs, and PGO- Setting up continuous benchmarking in CI to catch regressions automatically
Cross-references: Release Profiles — once you find the hot spot, optimize the binary · CI/CD Pipeline — benchmark job in the pipeline · Code Coverage — coverage tells you what’s tested, benchmarks tell you what’s fast
“We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.” — Donald Knuth
The hard part isn’t writing benchmarks — it’s writing benchmarks that produce meaningful, reproducible, actionable numbers. This chapter covers the tools and techniques that get you from “it seems fast” to “we have statistical evidence that PR #347 regressed parsing throughput by 4.2%.”
Why Not std::time::Instant?
The temptation:
// ❌ Naive benchmarking — unreliable results
use std::time::Instant;
fn main() {
let start = Instant::now();
let result = parse_device_query_output(&sample_data);
let elapsed = start.elapsed();
println!("Parsing took {:?}", elapsed);
// Problem 1: Compiler may optimize away `result` (dead code elimination)
// Problem 2: Single sample — no statistical significance
// Problem 3: CPU frequency scaling, thermal throttling, other processes
// Problem 4: Cold cache vs warm cache not controlled
}
Problems with manual timing:
- Dead code elimination — the compiler may skip the computation entirely if the result isn’t used.
- No warm-up — the first run includes cache misses, JIT effects (irrelevant in Rust, but OS page faults apply), and lazy initialization.
- No statistical analysis — a single measurement tells you nothing about variance, outliers, or confidence intervals.
- No regression detection — you can’t compare against previous runs.
Criterion.rs — Statistical Benchmarking
Criterion.rs is the de facto standard for Rust micro-benchmarks. It uses statistical methods to produce reliable measurements and detects performance regressions automatically.
Setup:
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports", "cargo_bench_support"] }
[[bench]]
name = "parsing_bench"
harness = false # Use Criterion's harness, not the built-in test harness
A complete benchmark:
#![allow(unused)]
fn main() {
// benches/parsing_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
/// Data type for parsed GPU information
#[derive(Debug, Clone)]
struct GpuInfo {
index: u32,
name: String,
temp_c: u32,
power_w: f64,
}
/// The function under test — simulate parsing device-query CSV output
fn parse_gpu_csv(input: &str) -> Vec<GpuInfo> {
input
.lines()
.filter(|line| !line.starts_with('#'))
.filter_map(|line| {
let fields: Vec<&str> = line.split(", ").collect();
if fields.len() >= 4 {
Some(GpuInfo {
index: fields[0].parse().ok()?,
name: fields[1].to_string(),
temp_c: fields[2].parse().ok()?,
power_w: fields[3].parse().ok()?,
})
} else {
None
}
})
.collect()
}
fn bench_parse_gpu_csv(c: &mut Criterion) {
// Representative test data
let small_input = "0, Acme Accel-V1-80GB, 32, 65.5\n\
1, Acme Accel-V1-80GB, 34, 67.2\n";
let large_input = (0..64)
.map(|i| format!("{i}, Acme Accel-X1-80GB, {}, {:.1}\n", 30 + i % 20, 60.0 + i as f64))
.collect::<String>();
c.bench_function("parse_2_gpus", |b| {
b.iter(|| parse_gpu_csv(black_box(small_input)))
});
c.bench_function("parse_64_gpus", |b| {
b.iter(|| parse_gpu_csv(black_box(&large_input)))
});
}
criterion_group!(benches, bench_parse_gpu_csv);
criterion_main!(benches);
}
Running and reading results:
# Run all benchmarks
cargo bench
# Run a specific benchmark by name
cargo bench -- parse_64
# Output:
# parse_2_gpus time: [1.2345 µs 1.2456 µs 1.2578 µs]
# ▲ ▲ ▲
# │ confidence interval
# lower 95% median upper 95%
#
# parse_64_gpus time: [38.123 µs 38.456 µs 38.812 µs]
# change: [-1.2345% -0.5678% +0.1234%] (p = 0.12 > 0.05)
# No change in performance detected.
What black_box() does: It’s a compiler hint that prevents dead-code
elimination and over-aggressive constant folding. The compiler cannot see
through black_box, so it must actually compute the result.
Parameterized Benchmarks and Benchmark Groups
Compare multiple implementations or input sizes:
#![allow(unused)]
fn main() {
// benches/comparison_bench.rs
use criterion::{criterion_group, criterion_main, Criterion, BenchmarkId, Throughput};
fn bench_parsing_strategies(c: &mut Criterion) {
let mut group = c.benchmark_group("csv_parsing");
// Test across different input sizes
for num_gpus in [1, 8, 32, 64, 128] {
let input = generate_gpu_csv(num_gpus);
// Set throughput for bytes-per-second reporting
group.throughput(Throughput::Bytes(input.len() as u64));
group.bench_with_input(
BenchmarkId::new("split_based", num_gpus),
&input,
|b, input| b.iter(|| parse_split(input)),
);
group.bench_with_input(
BenchmarkId::new("regex_based", num_gpus),
&input,
|b, input| b.iter(|| parse_regex(input)),
);
group.bench_with_input(
BenchmarkId::new("nom_based", num_gpus),
&input,
|b, input| b.iter(|| parse_nom(input)),
);
}
group.finish();
}
criterion_group!(benches, bench_parsing_strategies);
criterion_main!(benches);
}
Output: Criterion generates an HTML report at target/criterion/report/index.html
with violin plots, comparison charts, and regression analysis — open in a browser.
Divan — A Lighter Alternative
Divan is a newer benchmarking framework that uses attribute macros instead of Criterion’s macro DSL:
# Cargo.toml
[dev-dependencies]
divan = "0.1"
[[bench]]
name = "parsing_bench"
harness = false
// benches/parsing_bench.rs
use divan::black_box;
const SMALL_INPUT: &str = "0, Acme Accel-V1-80GB, 32, 65.5\n\
1, Acme Accel-V1-80GB, 34, 67.2\n";
fn generate_gpu_csv(n: usize) -> String {
(0..n)
.map(|i| format!("{i}, Acme Accel-X1-80GB, {}, {:.1}\n", 30 + i % 20, 60.0 + i as f64))
.collect()
}
fn main() {
divan::main();
}
#[divan::bench]
fn parse_2_gpus() -> Vec<GpuInfo> {
parse_gpu_csv(black_box(SMALL_INPUT))
}
#[divan::bench(args = [1, 8, 32, 64, 128])]
fn parse_n_gpus(n: usize) -> Vec<GpuInfo> {
let input = generate_gpu_csv(n);
parse_gpu_csv(black_box(&input))
}
// Divan output is a clean table:
// ╰─ parse_2_gpus fastest │ slowest │ median │ mean │ samples │ iters
// 1.234 µs │ 1.567 µs │ 1.345 µs │ 1.350 µs │ 100 │ 1600
When to choose Divan over Criterion:
- Simpler API (attribute macros, less boilerplate)
- Faster compilation (fewer dependencies)
- Good for quick perf checks during development
When to choose Criterion:
- Statistical regression detection across runs
- HTML reports with charts
- Established ecosystem, more CI integrations
Profiling with perf and Flamegraphs
Benchmarks tell you how fast — profiling tells you where the time goes.
# Step 1: Build with debug info (release speed, debug symbols)
cargo build --release
# Ensure debug info is available:
# [profile.release]
# debug = true # Add this temporarily for profiling
# Step 2: Record with perf
perf record --call-graph=dwarf ./target/release/diag_tool --run-diagnostics
# Step 3: Generate a flamegraph
# Install: cargo install flamegraph
# Install: cargo install addr2line --features=bin (optional, speedup cargo-flamegraph)
cargo flamegraph --root -- --run-diagnostics
# Opens an interactive SVG flamegraph
# Alternative: use perf + inferno
perf script | inferno-collapse-perf | inferno-flamegraph > flamegraph.svg
Reading a flamegraph:
- Width = time spent in that function (wider = slower)
- Height = call stack depth (taller ≠ slower, just deeper)
- Bottom = entry point, Top = leaf functions doing actual work
- Look for wide plateaus at the top — those are your hot spots
Profile-guided optimization (PGO):
# Step 1: Build with instrumentation
RUSTFLAGS="-Cprofile-generate=/tmp/pgo-data" cargo build --release
# Step 2: Run representative workloads
./target/release/diag_tool --run-full # generates profiling data
# Step 3: Merge profiling data
# Use the llvm-profdata that matches rustc's LLVM version:
# $(rustc --print sysroot)/lib/rustlib/x86_64-unknown-linux-gnu/bin/llvm-profdata
# Or if llvm-tools is installed: rustup component add llvm-tools
llvm-profdata merge -o /tmp/pgo-data/merged.profdata /tmp/pgo-data/
# Step 4: Rebuild with profiling feedback
RUSTFLAGS="-Cprofile-use=/tmp/pgo-data/merged.profdata" cargo build --release
# Typical improvement: 5-20% for compute-bound code (parsing, crypto, codegen).
# I/O-bound or syscall-heavy code (like a large project) will see much less benefit
# because the CPU is mostly waiting, not executing hot loops.
Tip: Before spending time on PGO, ensure your release profile already has LTO enabled — it typically delivers a bigger win for less effort.
hyperfine — Quick End-to-End Timing
hyperfine benchmarks entire commands,
not individual functions. It’s perfect for measuring overall binary performance:
# Install
cargo install hyperfine
# Or: sudo apt install hyperfine (Ubuntu 23.04+)
# Basic benchmark
hyperfine './target/release/diag_tool --run-diagnostics'
# Compare two implementations
hyperfine './target/release/diag_tool_v1 --run-diagnostics' \
'./target/release/diag_tool_v2 --run-diagnostics'
# Warm-up runs + minimum iterations
hyperfine --warmup 3 --min-runs 10 './target/release/diag_tool --run-all'
# Export results as JSON for CI comparison
hyperfine --export-json bench.json './target/release/diag_tool --run-all'
When to use hyperfine vs Criterion:
hyperfine: whole-binary timing, comparing before/after a refactor, I/O-bound workloads- Criterion: micro-benchmarks of individual functions, statistical regression detection
Continuous Benchmarking in CI
Detect performance regressions before they ship:
# .github/workflows/bench.yml
name: Benchmarks
on:
pull_request:
paths: ['**/*.rs', 'Cargo.toml', 'Cargo.lock']
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run benchmarks
# Requires criterion = { features = ["cargo_bench_support"] } for --output-format
run: cargo bench -- --output-format bencher | tee bench_output.txt
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'cargo'
output-file-path: bench_output.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
alert-threshold: '120%' # Alert if 20% slower
comment-on-alert: true
fail-on-alert: true # Block PR if regression detected
Key CI considerations:
- Use dedicated benchmark runners (not shared CI) for consistent results
- Pin the runner to a specific machine type if using cloud CI
- Store historical data to detect gradual regressions
- Set thresholds based on your workload’s tolerance (5% for hot paths, 20% for cold)
Application: Parsing Performance
The project has several performance-sensitive parsing paths that would benefit from benchmarks:
| Parsing Hot Spot | Crate | Why It Matters |
|---|---|---|
| accelerator-query CSV/XML output | device_diag | Called per-GPU, up to 8× per run |
| Sensor event parsing | event_log | Thousands of records on busy servers |
| PCIe topology JSON | topology_lib | Complex nested structures, golden-file validated |
| Report JSON serialization | diag_framework | Final report output, size-sensitive |
| Config JSON loading | config_loader | Startup latency |
Recommended first benchmark — the topology parser, which already has golden-file test data:
#![allow(unused)]
fn main() {
// topology_lib/benches/parse_bench.rs (proposed)
use criterion::{criterion_group, criterion_main, Criterion, Throughput};
use std::fs;
fn bench_topology_parse(c: &mut Criterion) {
let mut group = c.benchmark_group("topology_parse");
for golden_file in ["S2001", "S1015", "S1035", "S1080"] {
let path = format!("tests/test_data/{golden_file}.json");
let data = fs::read_to_string(&path).expect("golden file not found");
group.throughput(Throughput::Bytes(data.len() as u64));
group.bench_function(golden_file, |b| {
b.iter(|| {
topology_lib::TopologyProfile::from_json_str(
criterion::black_box(&data)
)
});
});
}
group.finish();
}
criterion_group!(benches, bench_topology_parse);
criterion_main!(benches);
}
Try It Yourself
-
Write a Criterion benchmark: Pick any parsing function in your codebase. Create a
benches/directory, set up a Criterion benchmark that measures throughput in bytes/second. Runcargo benchand examine the HTML report. -
Generate a flamegraph: Build your project with
debug = truein[profile.release], then runcargo flamegraph -- <your-args>. Identify the three widest stacks at the top of the flamegraph — those are your hot spots. -
Compare with
hyperfine: Installhyperfineand benchmark the overall execution time of your binary with different flags. Compare it to the per-function times from Criterion. Where does the time go that Criterion doesn’t see? (Answer: I/O, syscalls, process startup.)
Benchmark Tool Selection
flowchart TD
START["Want to measure performance?"] --> WHAT{"What level?"}
WHAT -->|"Single function"| CRITERION["Criterion.rs\nStatistical, regression detection"]
WHAT -->|"Quick function check"| DIVAN["Divan\nLighter, attribute macros"]
WHAT -->|"Whole binary"| HYPERFINE["hyperfine\nEnd-to-end, wall-clock"]
WHAT -->|"Find hot spots"| PERF["perf + flamegraph\nCPU sampling profiler"]
CRITERION --> CI_BENCH["Continuous benchmarking\nin GitHub Actions"]
PERF --> OPTIMIZE["Profile-Guided\nOptimization (PGO)"]
style CRITERION fill:#91e5a3,color:#000
style DIVAN fill:#91e5a3,color:#000
style HYPERFINE fill:#e3f2fd,color:#000
style PERF fill:#ffd43b,color:#000
style CI_BENCH fill:#e3f2fd,color:#000
style OPTIMIZE fill:#ffd43b,color:#000
🏋️ Exercises
🟢 Exercise 1: First Criterion Benchmark
Create a crate with a function that sorts a Vec<u64> of 10,000 random elements. Write a Criterion benchmark for it, then switch to .sort_unstable() and observe the performance difference in the HTML report.
Solution
# Cargo.toml
[[bench]]
name = "sort_bench"
harness = false
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
rand = "0.8"
#![allow(unused)]
fn main() {
// benches/sort_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use rand::Rng;
fn generate_data(n: usize) -> Vec<u64> {
let mut rng = rand::thread_rng();
(0..n).map(|_| rng.gen()).collect()
}
fn bench_sort(c: &mut Criterion) {
let mut group = c.benchmark_group("sort-10k");
group.bench_function("stable", |b| {
b.iter_batched(
|| generate_data(10_000),
|mut data| { data.sort(); black_box(&data); },
criterion::BatchSize::SmallInput,
)
});
group.bench_function("unstable", |b| {
b.iter_batched(
|| generate_data(10_000),
|mut data| { data.sort_unstable(); black_box(&data); },
criterion::BatchSize::SmallInput,
)
});
group.finish();
}
criterion_group!(benches, bench_sort);
criterion_main!(benches);
}
cargo bench
open target/criterion/sort-10k/report/index.html
🟡 Exercise 2: Flamegraph Hot Spot
Build a project with debug = true in [profile.release], then generate a flamegraph. Identify the top 3 widest stacks.
Solution
# Cargo.toml
[profile.release]
debug = true # Keep symbols for flamegraph
cargo install flamegraph
cargo flamegraph --release -- <your-args>
# Opens flamegraph.svg in browser
# The widest stacks at the top are your hot spots
Key Takeaways
- Never benchmark with
Instant::now()— use Criterion.rs for statistical rigor and regression detection black_box()prevents the compiler from optimizing away your benchmark targethyperfinemeasures wall-clock time for the whole binary; Criterion measures individual functions — use both- Flamegraphs show where time is spent; benchmarks show how much time is spent
- Continuous benchmarking in CI catches performance regressions before they ship
Code Coverage — Seeing What Tests Miss 🟢
What you’ll learn:
- Source-based coverage with
cargo-llvm-cov(the most accurate Rust coverage tool)- Quick coverage checks with
cargo-tarpaulinand Mozilla’sgrcov- Setting up coverage gates in CI with Codecov and Coveralls
- A coverage-guided testing strategy that prioritizes high-risk blind spots
Cross-references: Miri and Sanitizers — coverage finds untested code, Miri finds UB in tested code · Benchmarking — coverage shows what’s tested, benchmarks show what’s fast · CI/CD Pipeline — coverage gate in the pipeline
Code coverage measures which lines, branches, or functions your tests actually execute. It doesn’t prove correctness (a covered line can still have bugs), but it reliably reveals blind spots — code paths that no test exercises at all.
With 1,006 tests across many crates, the project has substantial test investment. Coverage analysis answers: “Is that investment reaching the code that matters?”
Source-Based Coverage with llvm-cov
Rust uses LLVM, which provides source-based coverage instrumentation — the most
accurate coverage method available. The recommended tool is
cargo-llvm-cov:
# Install
cargo install cargo-llvm-cov
# Or via rustup component (for the raw llvm tools)
rustup component add llvm-tools-preview
Basic usage:
# Run tests and show per-file coverage summary
cargo llvm-cov
# Generate HTML report (browsable, line-by-line highlighting)
cargo llvm-cov --html
# Output: target/llvm-cov/html/index.html
# Generate LCOV format (for CI integrations)
cargo llvm-cov --lcov --output-path lcov.info
# Workspace-wide coverage (all crates)
cargo llvm-cov --workspace
# Include only specific packages
cargo llvm-cov --package accel_diag --package topology_lib
# Coverage including doc tests
cargo llvm-cov --doctests
Reading the HTML report:
target/llvm-cov/html/index.html
├── Filename │ Function │ Line │ Branch │ Region
├─ accel_diag/src/lib.rs │ 78.5% │ 82.3% │ 61.2% │ 74.1%
├─ sel_mgr/src/parse.rs│ 95.2% │ 96.8% │ 88.0% │ 93.5%
├─ topology_lib/src/.. │ 91.0% │ 93.4% │ 79.5% │ 89.2%
└─ ...
Green = covered Red = not covered Yellow = partially covered (branch)
Coverage types explained:
| Type | What It Measures | Significance |
|---|---|---|
| Line coverage | Which source lines were executed | Basic “was this code reached?” |
| Branch coverage | Which if/match arms were taken | Catches untested conditions |
| Function coverage | Which functions were called | Finds dead code |
| Region coverage | Which code regions (sub-expressions) were hit | Most granular |
cargo-tarpaulin — The Quick Path
cargo-tarpaulin is a Linux-specific
coverage tool that’s simpler to set up (no LLVM components needed):
# Install
cargo install cargo-tarpaulin
# Basic coverage report
cargo tarpaulin
# HTML output
cargo tarpaulin --out Html
# With specific options
cargo tarpaulin \
--workspace \
--timeout 120 \
--out Xml Html \
--output-dir coverage/ \
--exclude-files "*/tests/*" "*/benches/*" \
--ignore-panics
# Skip certain crates
cargo tarpaulin --workspace --exclude diag_tool # exclude the binary crate
tarpaulin vs llvm-cov comparison:
| Feature | cargo-llvm-cov | cargo-tarpaulin |
|---|---|---|
| Accuracy | Source-based (most accurate) | Ptrace-based (occasional overcounting) |
| Platform | Any (llvm-based) | Linux only |
| Branch coverage | Yes | Limited |
| Doc tests | Yes | No |
| Setup | Needs llvm-tools-preview | Self-contained |
| Speed | Faster (compile-time instrumentation) | Slower (ptrace overhead) |
| Stability | Very stable | Occasional false positives |
Recommendation: Use cargo-llvm-cov for accuracy. Use cargo-tarpaulin when
you need a quick check without installing LLVM tools.
grcov — Mozilla’s Coverage Tool
grcov is Mozilla’s coverage aggregator.
It consumes raw LLVM profiling data and produces reports in multiple formats:
# Install
cargo install grcov
# Step 1: Build with coverage instrumentation
export RUSTFLAGS="-Cinstrument-coverage"
export LLVM_PROFILE_FILE="target/coverage/%p-%m.profraw"
cargo build --tests
# Step 2: Run tests (generates .profraw files)
cargo test
# Step 3: Aggregate with grcov
grcov target/coverage/ \
--binary-path target/debug/ \
--source-dir . \
--output-types html,lcov \
--output-path target/coverage/report \
--branch \
--ignore-not-existing \
--ignore "*/tests/*" \
--ignore "*/.cargo/*"
# Step 4: View report
open target/coverage/report/html/index.html
When to use grcov: It’s most useful when you need to merge coverage from multiple test runs (e.g., unit tests + integration tests + fuzz tests) into a single report.
Coverage in CI: Codecov and Coveralls
Upload coverage data to a tracking service for historical trends and PR annotations:
# .github/workflows/coverage.yml
name: Code Coverage
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
# Optional: enforce minimum coverage
- name: Check coverage threshold
run: |
cargo llvm-cov --workspace --fail-under-lines 80
# Fails the build if line coverage drops below 80%
Coverage gates — enforce minimums per crate by reading the JSON output:
# Get per-crate coverage as JSON
cargo llvm-cov --workspace --json | jq '.data[0].totals.lines.percent'
# Fail if below threshold
cargo llvm-cov --workspace --fail-under-lines 80
cargo llvm-cov --workspace --fail-under-functions 70
cargo llvm-cov --workspace --fail-under-regions 60
Coverage-Guided Testing Strategy
Coverage numbers alone are meaningless without a strategy. Here’s how to use coverage data effectively:
Step 1: Triage by risk
High coverage, high risk → ✅ Good — maintain it
High coverage, low risk → 🔄 Possibly over-tested — skip if slow
Low coverage, high risk → 🔴 Write tests NOW — this is where bugs hide
Low coverage, low risk → 🟡 Track but don't panic
Step 2: Focus on branch coverage, not line coverage
#![allow(unused)]
fn main() {
// 100% line coverage, 50% branch coverage — still risky!
pub fn classify_temperature(temp_c: i32) -> ThermalState {
if temp_c > 105 { // ← tested with temp=110 → Critical
ThermalState::Critical
} else if temp_c > 85 { // ← tested with temp=90 → Warning
ThermalState::Warning
} else if temp_c < -10 { // ← NEVER TESTED → sensor error case missed
ThermalState::SensorError
} else {
ThermalState::Normal // ← tested with temp=25 → Normal
}
}
}
Step 3: Exclude noise
# Exclude test code from coverage (it's always "covered")
cargo llvm-cov --workspace --ignore-filename-regex 'tests?\.rs$|benches/'
# Exclude generated code
cargo llvm-cov --workspace --ignore-filename-regex 'target/'
In code, mark untestable sections:
#![allow(unused)]
fn main() {
// Coverage tools recognize this pattern
#[cfg(not(tarpaulin_include))] // tarpaulin
fn unreachable_hardware_path() {
// This path requires actual GPU hardware to trigger
}
// For llvm-cov, use a more targeted approach:
// Simply accept that some paths need integration/hardware tests,
// not unit tests. Track them in a coverage exceptions list.
}
Complementary Testing Tools
proptest — Property-Based Testing finds edge cases that hand-written tests miss:
[dev-dependencies]
proptest = "1"
#![allow(unused)]
fn main() {
use proptest::prelude::*;
proptest! {
#[test]
fn parse_never_panics(input in "\\PC*") {
// proptest generates thousands of random strings
// If parse_gpu_csv panics on any input, the test fails
// and proptest minimizes the failing case for you.
let _ = parse_gpu_csv(&input);
}
#[test]
fn temperature_roundtrip(raw in 0u16..4096) {
let temp = Temperature::from_raw(raw);
let md = temp.millidegrees_c();
// Property: millidegrees should always be derivable from raw
assert_eq!(md, (raw as i32) * 625 / 10);
}
}
}
insta — Snapshot Testing for large structured outputs (JSON, text reports):
[dev-dependencies]
insta = { version = "1", features = ["json"] }
#![allow(unused)]
fn main() {
#[test]
fn test_der_report_format() {
let report = generate_der_report(&test_results);
// First run: creates a snapshot file. Subsequent runs: compares against it.
// Run `cargo insta review` to accept changes interactively.
insta::assert_json_snapshot!(report);
}
}
When to add proptest/insta: If your unit tests are all “happy path” examples, proptest will find the edge cases you missed. If you’re testing large output formats (JSON reports, DER records), insta snapshots are faster to write and maintain than hand-written assertions.
Application: 1,000+ Tests Coverage Map
The project has 1,000+ tests but no coverage tracking. Adding it reveals the testing investment distribution. Uncovered paths are prime candidates for Miri and sanitizer verification:
Recommended coverage configuration:
# Quick workspace coverage (proposed CI command)
cargo llvm-cov --workspace \
--ignore-filename-regex 'tests?\.rs$' \
--fail-under-lines 75 \
--html
# Per-crate coverage for targeted improvement
for crate in accel_diag event_log topology_lib network_diag compute_diag fan_diag; do
echo "=== $crate ==="
cargo llvm-cov --package "$crate" --json 2>/dev/null | \
jq -r '.data[0].totals | "Lines: \(.lines.percent | round)% Branches: \(.branches.percent | round)%"'
done
Expected high-coverage crates (based on test density):
topology_lib— 922-line golden-file test suiteevent_log— registry withcreate_test_record()helperscable_diag—make_test_event()/make_test_context()patterns
Expected coverage gaps (based on code inspection):
- Error handling arms in IPMI communication paths
- GPU hardware-specific branches (require actual GPU)
dmesgparsing edge cases (platform-dependent output)
The 80/20 rule of coverage: Getting from 0% to 80% coverage is straightforward. Getting from 80% to 95% requires increasingly contrived test scenarios. Getting from 95% to 100% requires
#[cfg(not(...))]exclusions and is rarely worth the effort. Target 80% line coverage and 70% branch coverage as a practical floor.
Troubleshooting Coverage
| Symptom | Cause | Fix |
|---|---|---|
llvm-cov shows 0% for all files | Instrumentation not applied | Ensure you run cargo llvm-cov, not cargo test + llvm-cov separately |
Coverage counts unreachable!() as uncovered | Those branches exist in compiled code | Use #[cfg(not(tarpaulin_include))] or add to exclusion regex |
| Test binary crashes under coverage | Instrumentation + sanitizer conflict | Don’t combine cargo llvm-cov with -Zsanitizer=address; run them separately |
Coverage differs between llvm-cov and tarpaulin | Different instrumentation techniques | Use llvm-cov as source of truth (compiler-native); file issues for large discrepancies |
error: profraw file is malformed | Test binary crashed mid-execution | Fix the test failure first; profraw files are corrupt when the process exits abnormally |
| Branch coverage seems impossibly low | Optimizer creates branches for match arms, unwrap, etc. | Focus on line coverage for practical thresholds; branch coverage is inherently lower |
Try It Yourself
-
Measure coverage on your project: Run
cargo llvm-cov --workspace --htmland open the report. Find the three files with the lowest coverage. Are they untested, or inherently hard to test (hardware-dependent code)? -
Set a coverage gate: Add
cargo llvm-cov --workspace --fail-under-lines 60to your CI. Intentionally comment out a test and verify CI fails. Then raise the threshold to your project’s actual coverage level minus 2%. -
Branch vs. line coverage: Write a function with a 3-arm
matchand test only 2 arms. Compare line coverage (may show 66%) vs. branch coverage (may show 50%). Which metric is more useful for your project?
Coverage Tool Selection
flowchart TD
START["Need code coverage?"] --> ACCURACY{"Priority?"}
ACCURACY -->|"Most accurate"| LLVM["cargo-llvm-cov\nSource-based, compiler-native"]
ACCURACY -->|"Quick check"| TARP["cargo-tarpaulin\nLinux only, fast"]
ACCURACY -->|"Multi-run aggregate"| GRCOV["grcov\nMozilla, combines profiles"]
LLVM --> CI_GATE["CI coverage gate\n--fail-under-lines 80"]
TARP --> CI_GATE
CI_GATE --> UPLOAD{"Upload to?"}
UPLOAD -->|"Codecov"| CODECOV["codecov/codecov-action"]
UPLOAD -->|"Coveralls"| COVERALLS["coverallsapp/github-action"]
style LLVM fill:#91e5a3,color:#000
style TARP fill:#e3f2fd,color:#000
style GRCOV fill:#e3f2fd,color:#000
style CI_GATE fill:#ffd43b,color:#000
🏋️ Exercises
🟢 Exercise 1: First Coverage Report
Install cargo-llvm-cov, run it on any Rust project, and open the HTML report. Find the three files with the lowest line coverage.
Solution
cargo install cargo-llvm-cov
cargo llvm-cov --workspace --html --open
# The report sorts files by coverage — lowest at the bottom
# Look for files under 50% — those are your blind spots
🟡 Exercise 2: CI Coverage Gate
Add a coverage gate to a GitHub Actions workflow that fails if line coverage drops below 60%. Verify it works by commenting out a test.
Solution
# .github/workflows/coverage.yml
name: Coverage
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- run: cargo install cargo-llvm-cov
- run: cargo llvm-cov --workspace --fail-under-lines 60
Comment out a test, push, and watch the workflow fail.
Key Takeaways
cargo-llvm-covis the most accurate coverage tool for Rust — it uses the compiler’s own instrumentation- Coverage doesn’t prove correctness, but zero coverage proves zero testing — use it to find blind spots
- Set a coverage gate in CI (e.g.,
--fail-under-lines 80) to prevent regressions - Don’t chase 100% coverage — focus on high-risk code paths (error handling, unsafe, parsing)
- Never combine coverage instrumentation with sanitizers in the same run
Miri, Valgrind, and Sanitizers — Verifying Unsafe Code 🔴
What you’ll learn:
- Miri as a MIR interpreter — what it catches (aliasing, UB, leaks) and what it can’t (FFI, syscalls)
- Valgrind memcheck, Helgrind (data races), Callgrind (profiling), and Massif (heap)
- LLVM sanitizers: ASan, MSan, TSan, LSan with nightly
-Zbuild-stdcargo-fuzzfor crash discovery andloomfor concurrency model checking- A decision tree for choosing the right verification tool
Cross-references: Code Coverage — coverage finds untested paths, Miri verifies the tested ones ·
no_std& Features —no_stdcode often requiresunsafethat Miri can verify · CI/CD Pipeline — Miri job in the pipeline
Safe Rust guarantees memory safety and data-race freedom at compile time. But the
moment you write unsafe — for FFI, hand-rolled data structures, or performance
tricks — those guarantees become your responsibility. This chapter covers the
tools that verify your unsafe code actually upholds the safety contracts it claims.
Miri — An Interpreter for Unsafe Rust
Miri is an interpreter for Rust’s Mid-level Intermediate Representation (MIR). Instead of compiling to machine code, Miri executes your program step-by-step with exhaustive checks for undefined behavior at every operation.
# Install Miri (nightly-only component)
rustup +nightly component add miri
# Run your test suite under Miri
cargo +nightly miri test
# Run a specific binary under Miri
cargo +nightly miri run
# Run a specific test
cargo +nightly miri test -- test_name
How Miri works:
Source → rustc → MIR → Miri interprets MIR
│
├─ Tracks every pointer's provenance
├─ Validates every memory access
├─ Checks alignment at every deref
├─ Detects use-after-free
├─ Detects data races (with threads)
└─ Enforces Stacked Borrows / Tree Borrows rules
What Miri Catches (and What It Cannot)
Miri detects:
| Category | Example | Would Crash at Runtime? |
|---|---|---|
| Out-of-bounds access | ptr.add(100).read() past allocation | Sometimes (depends on page layout) |
| Use after free | Reading a dropped Box through raw pointer | Sometimes (depends on allocator) |
| Double free | Calling drop_in_place twice | Usually |
| Unaligned access | (ptr as *const u32).read() on odd address | On some architectures |
| Invalid values | transmute::<u8, bool>(2) | Silently wrong |
| Dangling references | &*ptr where ptr is freed | No (silent corruption) |
| Data races | Two threads, one writing, no synchronization | Intermittent, hard to reproduce |
| Stacked Borrows violation | Aliasing &mut references | No (silent corruption) |
Miri does NOT detect:
| Limitation | Why |
|---|---|
| Logic bugs | Miri checks memory safety, not correctness |
| Concurrency deadlocks | Miri checks data races, not livelocks |
| Performance issues | Interpretation is 10-100× slower than native |
| OS/hardware interaction | Miri can’t emulate syscalls, device I/O |
| All FFI calls | Can’t interpret C code (only Rust MIR) |
| Exhaustive path coverage | Only tests the paths your test suite reaches |
A concrete example — catching unsound code that “works” in practice:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
#[test]
fn test_miri_catches_ub() {
// This "works" in release builds but is undefined behavior
let mut v = vec![1, 2, 3];
let ptr = v.as_ptr();
// Push may reallocate, invalidating ptr
v.push(4);
// ❌ UB: ptr may be dangling after reallocation
// Miri will catch this even if the allocator happens to
// not move the buffer.
// let _val = unsafe { *ptr };
// Error: Miri would report:
// "pointer to alloc1234 was dereferenced after this
// allocation got freed"
// ✅ Correct: get a fresh pointer after mutation
let ptr = v.as_ptr();
let val = unsafe { *ptr };
assert_eq!(val, 1);
}
}
}
Running Miri on a Real Crate
Practical Miri workflow for a crate with unsafe:
# Step 1: Run all tests under Miri
cargo +nightly miri test 2>&1 | tee miri_output.txt
# Step 2: If Miri reports errors, isolate them
cargo +nightly miri test -- failing_test_name
# Step 3: Use Miri's backtrace for diagnosis
MIRIFLAGS="-Zmiri-backtrace=full" cargo +nightly miri test
# Step 4: Choose a borrow model
# Stacked Borrows (default, stricter):
cargo +nightly miri test
# Tree Borrows (experimental, more permissive):
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test
Miri flags for common scenarios:
# Disable isolation (allow file system access, env vars)
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test
# Memory leak detection is ON by default in Miri.
# To suppress leak errors (e.g., for intentional leaks):
# MIRIFLAGS="-Zmiri-ignore-leaks" cargo +nightly miri test
# Seed the RNG for reproducible results with randomized tests
MIRIFLAGS="-Zmiri-seed=42" cargo +nightly miri test
# Enable strict provenance checking
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test
# Multiple flags
MIRIFLAGS="-Zmiri-disable-isolation -Zmiri-backtrace=full -Zmiri-strict-provenance" \
cargo +nightly miri test
Miri in CI:
# .github/workflows/miri.yml
name: Miri
on: [push, pull_request]
jobs:
miri:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- name: Run Miri
run: cargo miri test --workspace
env:
MIRIFLAGS: "-Zmiri-backtrace=full"
# Leak checking is on by default.
# Skip tests that use system calls Miri can't handle
# (file I/O, networking, etc.)
Performance note: Miri is 10-100× slower than native execution. A test suite that runs in 5 seconds natively may take 5 minutes under Miri. In CI, run Miri on a focused subset: crates with
unsafecode only.
Valgrind and Its Rust Integration
Valgrind is the classic C/C++ memory checker. It works on compiled Rust binaries too, checking for memory errors at the machine-code level.
# Install Valgrind
sudo apt install valgrind # Debian/Ubuntu
sudo dnf install valgrind # Fedora
# Build with debug info (Valgrind needs symbols)
cargo build --tests
# or for release with debug info:
# cargo build --release
# [profile.release]
# debug = true
# Run a specific test binary under Valgrind
valgrind --tool=memcheck \
--leak-check=full \
--show-leak-kinds=all \
--track-origins=yes \
./target/debug/deps/my_crate-abc123 --test-threads=1
# Run the main binary
valgrind --tool=memcheck \
--leak-check=full \
--error-exitcode=1 \
./target/debug/diag_tool --run-diagnostics
Valgrind tools beyond memcheck:
| Tool | Command | What It Detects |
|---|---|---|
| Memcheck | --tool=memcheck | Memory leaks, use-after-free, buffer overflows |
| Helgrind | --tool=helgrind | Data races and lock-order violations |
| DRD | --tool=drd | Data races (different detection algorithm) |
| Callgrind | --tool=callgrind | CPU instruction profiling (path-level) |
| Massif | --tool=massif | Heap memory profiling over time |
| Cachegrind | --tool=cachegrind | Cache miss analysis |
Using Callgrind for instruction-level profiling:
# Record instruction counts (more stable than wall-clock time)
valgrind --tool=callgrind \
--callgrind-out-file=callgrind.out \
./target/release/diag_tool --run-diagnostics
# Visualize with KCachegrind
kcachegrind callgrind.out
# or the text-based alternative:
callgrind_annotate callgrind.out | head -100
Miri vs Valgrind — when to use which:
| Aspect | Miri | Valgrind |
|---|---|---|
| Checks Rust-specific UB | ✅ Stacked/Tree Borrows | ❌ Not aware of Rust rules |
| Checks C FFI code | ❌ Can’t interpret C | ✅ Checks all machine code |
| Needs nightly | ✅ Yes | ❌ No |
| Speed | 10-100× slower | 10-50× slower |
| Platform | Any (interprets MIR) | Linux, macOS (runs native code) |
| Data race detection | ✅ Yes | ✅ Yes (Helgrind/DRD) |
| Leak detection | ✅ Yes | ✅ Yes (more thorough) |
| False positives | Very rare | Occasional (especially with allocators) |
Use both:
- Miri for pure-Rust
unsafecode (Stacked Borrows, provenance) - Valgrind for FFI-heavy code and whole-program leak analysis
AddressSanitizer, MemorySanitizer, ThreadSanitizer
LLVM sanitizers are compile-time instrumentation passes that insert runtime checks. They’re faster than Valgrind (2-5× overhead vs 10-50×) and catch different classes of bugs.
# Required: install Rust source for rebuilding std with sanitizer instrumentation
rustup component add rust-src --toolchain nightly
# AddressSanitizer (ASan) — buffer overflows, use-after-free, stack overflows
RUSTFLAGS="-Zsanitizer=address" \
cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# MemorySanitizer (MSan) — uninitialized memory reads
RUSTFLAGS="-Zsanitizer=memory" \
cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# ThreadSanitizer (TSan) — data races
RUSTFLAGS="-Zsanitizer=thread" \
cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# LeakSanitizer (LSan) — memory leaks (included in ASan by default)
RUSTFLAGS="-Zsanitizer=leak" \
cargo +nightly test --target x86_64-unknown-linux-gnu
Note: ASan, MSan, and TSan require
-Zbuild-stdto rebuild the standard library with sanitizer instrumentation. LSan does not.
Sanitizer comparison:
| Sanitizer | Overhead | Catches | Nightly? | -Zbuild-std? |
|---|---|---|---|---|
| ASan | 2× memory, 2× CPU | Buffer overflow, use-after-free, stack overflow | Yes | Yes |
| MSan | 3× memory, 3× CPU | Uninitialized reads | Yes | Yes |
| TSan | 5-10× memory, 5× CPU | Data races | Yes | Yes |
| LSan | Minimal | Memory leaks | Yes | No |
Practical example — catching a data race with TSan:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::thread;
fn racy_counter() -> u64 {
// ❌ UB: unsynchronized shared mutable state
let data = Arc::new(std::cell::UnsafeCell::new(0u64));
let mut handles = vec![];
for _ in 0..4 {
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
// SAFETY: UNSOUND — data race!
unsafe {
*data.get() += 1;
}
}
}));
}
for h in handles {
h.join().unwrap();
}
// Value should be 4000 but may be anything due to race
unsafe { *data.get() }
}
// Both Miri and TSan catch this:
// Miri: "Data race detected between (1) write and (2) write"
// TSan: "WARNING: ThreadSanitizer: data race"
//
// Fix: use AtomicU64 or Mutex<u64>
}
Related Tools: Fuzzing and Concurrency Verification
cargo-fuzz — Coverage-Guided Fuzzing (finds crashes in parsers and decoders):
# Install
cargo install cargo-fuzz
# Initialize a fuzz target
cargo fuzz init
cargo fuzz add parse_gpu_csv
#![allow(unused)]
fn main() {
// fuzz/fuzz_targets/parse_gpu_csv.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
// The fuzzer generates millions of inputs looking for panics/crashes.
let _ = diag_tool::parse_gpu_csv(s);
}
});
}
# Run the fuzzer (runs until interrupted or crash found)
cargo +nightly fuzz run parse_gpu_csv -- -max_total_time=300 # 5 minutes
# Minimize a crash
cargo +nightly fuzz tmin parse_gpu_csv artifacts/parse_gpu_csv/crash-...
When to fuzz: Any function that parses untrusted/semi-trusted input (sensor output, config files, network data, JSON/CSV). Fuzzing found real bugs in every major Rust parser crate (serde, regex, image).
loom — Concurrency Model Checker (exhaustively tests atomic orderings):
[dev-dependencies]
loom = "0.7"
#![allow(unused)]
fn main() {
#[cfg(loom)]
mod tests {
use loom::sync::atomic::{AtomicUsize, Ordering};
use loom::thread;
#[test]
fn test_counter_is_atomic() {
loom::model(|| {
let counter = loom::sync::Arc::new(AtomicUsize::new(0));
let c1 = counter.clone();
let c2 = counter.clone();
let t1 = thread::spawn(move || { c1.fetch_add(1, Ordering::SeqCst); });
let t2 = thread::spawn(move || { c2.fetch_add(1, Ordering::SeqCst); });
t1.join().unwrap();
t2.join().unwrap();
// loom explores ALL possible thread interleavings
assert_eq!(counter.load(Ordering::SeqCst), 2);
});
}
}
}
When to use
loom: When you have lock-free data structures or custom synchronization primitives. Loom exhaustively explores thread interleavings — it’s a model checker, not a stress test. Not needed forMutex/RwLock-based code.
When to Use Which Tool
Decision tree for unsafe verification:
Is the code pure Rust (no FFI)?
├─ Yes → Use Miri (catches Rust-specific UB, Stacked Borrows)
│ Also run ASan in CI for defense-in-depth
└─ No (calls C/C++ code via FFI)
├─ Memory safety concerns?
│ └─ Yes → Use Valgrind memcheck AND ASan
├─ Concurrency concerns?
│ └─ Yes → Use TSan (faster) or Helgrind (more thorough)
└─ Memory leak concerns?
└─ Yes → Use Valgrind --leak-check=full
Recommended CI matrix:
# Run all tools in parallel for fast feedback
jobs:
miri:
runs-on: ubuntu-latest
steps:
- uses: dtolnay/rust-toolchain@nightly
with: { components: miri }
- run: cargo miri test --workspace
asan:
runs-on: ubuntu-latest
steps:
- uses: dtolnay/rust-toolchain@nightly
- run: |
RUSTFLAGS="-Zsanitizer=address" \
cargo test -Zbuild-std --target x86_64-unknown-linux-gnu
valgrind:
runs-on: ubuntu-latest
steps:
- run: sudo apt-get install -y valgrind
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --tests
- run: |
for test_bin in $(find target/debug/deps -maxdepth 1 -executable -type f ! -name '*.d'); do
valgrind --error-exitcode=1 --leak-check=full "$test_bin" --test-threads=1
done
Application: Zero Unsafe — and When You’ll Need It
The project contains zero unsafe blocks across 90K+ lines of
Rust. This is a remarkable achievement for a systems-level diagnostics tool and
demonstrates that safe Rust is sufficient for:
- IPMI communication (via
std::process::Commandtoipmitool) - GPU queries (via
std::process::Commandtoaccel-query) - PCIe topology parsing (pure JSON/text parsing)
- SEL record management (pure data structures)
- DER report generation (JSON serialization)
When will the project need unsafe?
The likely triggers for introducing unsafe:
| Scenario | Why unsafe | Recommended Verification |
|---|---|---|
| Direct ioctl-based IPMI | libc::ioctl() bypasses ipmitool subprocess | Miri + Valgrind |
| Direct GPU driver queries | accel-mgmt FFI instead of accel-query parsing | Valgrind (C library) |
| Memory-mapped PCIe config | mmap for direct config-space reads | ASan + Valgrind |
| Lock-free SEL buffer | AtomicPtr for concurrent event collection | Miri + TSan |
| Embedded/no_std variant | Raw pointer manipulation for bare-metal | Miri |
Preparation: Before introducing unsafe, add the verification tools to CI:
# Cargo.toml — add a feature flag for unsafe optimizations
[features]
default = []
direct-ipmi = [] # Enable direct ioctl IPMI instead of ipmitool subprocess
direct-accel-api = [] # Enable accel-mgmt FFI instead of accel-query parsing
#![allow(unused)]
fn main() {
// src/ipmi.rs — gated behind a feature flag
#[cfg(feature = "direct-ipmi")]
mod direct {
//! Direct IPMI device access via /dev/ipmi0 ioctl.
//!
//! # Safety
//! This module uses `unsafe` for ioctl system calls.
//! Verified with: Miri (where possible), Valgrind memcheck, ASan.
use std::os::unix::io::RawFd;
// ... unsafe ioctl implementation ...
}
#[cfg(not(feature = "direct-ipmi"))]
mod subprocess {
//! IPMI via ipmitool subprocess (default, fully safe).
// ... current implementation ...
}
}
Key insight: Keep
unsafebehind feature flags so it can be verified independently. Runcargo +nightly miri test --features direct-ipmiin CI to continuously verify the unsafe paths without affecting the safe default build.
cargo-careful — Extra UB Checks on Stable
cargo-careful runs your code
with extra standard library checks enabled — catching some undefined behavior
that normal builds ignore, without requiring nightly or Miri’s 10-100× slowdown:
# Install (requires nightly, but runs your code at near-native speed)
cargo install cargo-careful
# Run tests with extra UB checks (catches uninitialized memory, invalid values)
cargo +nightly careful test
# Run a binary with extra checks
cargo +nightly careful run -- --run-diagnostics
What cargo-careful catches that normal builds don’t:
- Reads of uninitialized memory in
MaybeUninitandzeroed() - Creating invalid
bool,char, or enum values via transmute - Unaligned pointer reads/writes
copy_nonoverlappingwith overlapping ranges
Where it fits in the verification ladder:
Least overhead Most thorough
├─ cargo test ──► cargo careful test ──► Miri ──► ASan ──► Valgrind ─┤
│ (0× overhead) (~1.5× overhead) (10-100×) (2×) (10-50×) │
│ Safe Rust only Catches some UB Pure-Rust FFI+Rust FFI+Rust │
Recommendation: Add
cargo +nightly careful testto CI as a fast safety check. It runs at near-native speed (unlike Miri) and catches real bugs that safe Rust abstractions mask.
Troubleshooting Miri and Sanitizers
| Symptom | Cause | Fix |
|---|---|---|
Miri does not support FFI | Miri is a Rust interpreter; it can’t execute C code | Use Valgrind or ASan for FFI code instead |
error: unsupported operation: can't call foreign function | Miri hit an extern "C" call | Mock the FFI boundary or gate behind #[cfg(miri)] |
Stacked Borrows violation | Aliasing rule violation — even if code “works” | Miri is correct; refactor to avoid aliasing &mut with & |
Sanitizer says DEADLYSIGNAL | ASan detected buffer overflow | Check array indexing, slice operations, and pointer arithmetic |
LeakSanitizer: detected memory leaks | Box::leak(), forget(), or missing drop() | Intentional: suppress with __lsan_disable(); unintentional: fix the leak |
| Miri is extremely slow | Miri interprets, doesn’t compile — 10-100× slower | Run only on --lib tests or tag specific tests with #[cfg_attr(miri, ignore)] for slow ones |
TSan: false positive with atomics | TSan doesn’t understand Rust’s atomic ordering model perfectly | Add TSAN_OPTIONS=suppressions=tsan.supp with specific suppressions |
Try It Yourself
-
Trigger a Miri UB detection: Write an
unsafefunction that creates two&mutreferences to the samei32(aliasing violation). Runcargo +nightly miri testand observe the “Stacked Borrows” error. Fix it withUnsafeCellor separate allocations. -
Run ASan on a deliberate bug: Create a test that does
unsafeout-of-bounds array access. Build withRUSTFLAGS="-Zsanitizer=address"and observe ASan’s report. Note how it pinpoints the exact line. -
Benchmark Miri overhead: Time
cargo test --libvscargo +nightly miri test --libon the same test suite. Calculate the slowdown factor. Based on this, decide which tests to run under Miri in CI and which to skip with#[cfg_attr(miri, ignore)].
Safety Verification Decision Tree
flowchart TD
START["Have unsafe code?"] -->|No| SAFE["Safe Rust — no\nverification needed"]
START -->|Yes| KIND{"What kind?"}
KIND -->|"Pure Rust unsafe"| MIRI["Miri\nMIR interpreter\ncatches aliasing, UB, leaks"]
KIND -->|"FFI / C interop"| VALGRIND["Valgrind memcheck\nor ASan"]
KIND -->|"Concurrent unsafe"| CONC{"Lock-free?"}
CONC -->|"Atomics/lock-free"| LOOM["loom\nModel checker for atomics"]
CONC -->|"Mutex/shared state"| TSAN["TSan or\nMiri -Zmiri-check-number-validity"]
MIRI --> CI_MIRI["CI: cargo +nightly miri test"]
VALGRIND --> CI_VALGRIND["CI: valgrind --leak-check=full"]
style SAFE fill:#91e5a3,color:#000
style MIRI fill:#e3f2fd,color:#000
style VALGRIND fill:#ffd43b,color:#000
style LOOM fill:#ff6b6b,color:#000
style TSAN fill:#ffd43b,color:#000
🏋️ Exercises
🟡 Exercise 1: Trigger a Miri UB Detection
Write an unsafe function that creates two &mut references to the same i32 (aliasing violation). Run cargo +nightly miri test and observe the Stacked Borrows error. Fix it.
Solution
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
#[test]
fn aliasing_ub() {
let mut x: i32 = 42;
let ptr = &mut x as *mut i32;
unsafe {
// BUG: Two &mut references to the same location
let _a = &mut *ptr;
let _b = &mut *ptr; // Miri: Stacked Borrows violation!
}
}
}
}
Fix: use separate allocations or UnsafeCell:
#![allow(unused)]
fn main() {
use std::cell::UnsafeCell;
#[test]
fn no_aliasing_ub() {
let x = UnsafeCell::new(42);
unsafe {
let a = &mut *x.get();
*a = 100;
}
}
}
🔴 Exercise 2: ASan Out-of-Bounds Detection
Create a test with unsafe out-of-bounds array access. Build with RUSTFLAGS="-Zsanitizer=address" on nightly and observe ASan’s report.
Solution
#![allow(unused)]
fn main() {
#[test]
fn oob_access() {
let arr = [1u8, 2, 3, 4, 5];
let ptr = arr.as_ptr();
unsafe {
let _val = *ptr.add(10); // Out of bounds!
}
}
}
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std \
--target x86_64-unknown-linux-gnu -- oob_access
# ASan report: stack-buffer-overflow at <exact address>
Key Takeaways
- Miri is the tool for pure-Rust
unsafe— it catches aliasing violations, use-after-free, and leaks that compile and pass tests - Valgrind is the tool for FFI/C interop — it works on the final binary without recompilation
- Sanitizers (ASan, TSan, MSan) require nightly but run at near-native speed — ideal for large test suites
loomis purpose-built for verifying lock-free concurrent data structures- Run Miri in CI on every push; run sanitizers on a nightly schedule to avoid slowing the main pipeline
Dependency Management and Supply Chain Security 🟢
What you’ll learn:
- Scanning for known vulnerabilities with
cargo-audit- Enforcing license, advisory, and source policies with
cargo-deny- Supply chain trust verification with Mozilla’s
cargo-vet- Tracking outdated dependencies and detecting breaking API changes
- Visualizing and deduplicating your dependency tree
Cross-references: Release Profiles —
cargo-udepstrims unused dependencies found here · CI/CD Pipeline — audit and deny jobs in the pipeline · Build Scripts —build-dependenciesare part of your supply chain too
A Rust binary doesn’t just contain your code — it contains every transitive
dependency in your Cargo.lock. A vulnerability, license violation, or
malicious crate anywhere in that tree becomes your problem. This chapter
covers the tools that make dependency management auditable and automated.
cargo-audit — Known Vulnerability Scanning
cargo-audit
checks your Cargo.lock against the RustSec Advisory Database,
which tracks known vulnerabilities in published crates.
# Install
cargo install cargo-audit
# Scan for known vulnerabilities
cargo audit
# Output:
# Crate: chrono
# Version: 0.4.19
# Title: Potential segfault in localtime_r invocations
# Date: 2020-11-10
# ID: RUSTSEC-2020-0159
# URL: https://rustsec.org/advisories/RUSTSEC-2020-0159
# Solution: Upgrade to >= 0.4.20
# Check and fail CI if vulnerabilities exist
cargo audit --deny warnings
# Generate JSON output for automated processing
cargo audit --json
# Fix vulnerabilities by updating Cargo.lock
cargo audit fix
CI integration:
# .github/workflows/audit.yml
name: Security Audit
on:
schedule:
- cron: '0 0 * * *' # Daily check — advisories appear continuously
push:
paths: ['Cargo.lock']
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
cargo-deny — Comprehensive Policy Enforcement
cargo-deny goes far beyond
vulnerability scanning. It enforces policies across four dimensions:
- Advisories — known vulnerabilities (like cargo-audit)
- Licenses — allowed/denied license list
- Bans — forbidden crates or duplicate versions
- Sources — allowed registries and git sources
# Install
cargo install cargo-deny
# Initialize configuration
cargo deny init
# Creates deny.toml with documented defaults
# Run all checks
cargo deny check
# Run specific checks
cargo deny check advisories
cargo deny check licenses
cargo deny check bans
cargo deny check sources
Example deny.toml:
# deny.toml
[advisories]
vulnerability = "deny" # Fail on known vulnerabilities
unmaintained = "warn" # Warn on unmaintained crates
yanked = "deny" # Fail on yanked crates
notice = "warn" # Warn on informational advisories
[licenses]
unlicensed = "deny" # All crates must have a license
allow = [
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
]
copyleft = "deny" # No GPL/LGPL/AGPL in this project
default = "deny" # Deny anything not explicitly allowed
[bans]
multiple-versions = "warn" # Warn if same crate appears at 2 versions
wildcards = "deny" # No path = "*" in dependencies
highlight = "all" # Show all duplicates, not just first
# Ban specific problematic crates
deny = [
# openssl-sys pulls in C OpenSSL — prefer rustls
{ name = "openssl-sys", wrappers = ["native-tls"] },
]
# Allow specific duplicate versions (when unavoidable)
[[bans.skip]]
name = "syn"
version = "1.0" # syn 1.x and 2.x often coexist
[sources]
unknown-registry = "deny" # Only allow crates.io
unknown-git = "deny" # No random git dependencies
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
License enforcement is particularly valuable for commercial projects:
# Check which licenses are in your dependency tree
cargo deny list
# Output:
# MIT — 127 crates
# Apache-2.0 — 89 crates
# BSD-3-Clause — 12 crates
# MPL-2.0 — 3 crates ← might need legal review
# Unicode-DFS — 1 crate
cargo-vet — Supply Chain Trust Verification
cargo-vet (from Mozilla) addresses a
different question: not “does this crate have known bugs?” but “has a trusted
human actually reviewed this code?”
# Install
cargo install cargo-vet
# Initialize (creates supply-chain/ directory)
cargo vet init
# Check which crates need review
cargo vet
# After reviewing a crate, certify it:
cargo vet certify serde 1.0.203
# Records that you've audited serde 1.0.203 for your criteria
# Import audits from trusted organizations
cargo vet import mozilla
cargo vet import google
cargo vet import bytecode-alliance
How it works:
supply-chain/
├── audits.toml ← Your team's audit certifications
├── config.toml ← Trust configuration and criteria
└── imports.lock ← Pinned imports from other organizations
cargo-vet is most valuable for organizations with strict supply-chain
requirements (government, finance, infrastructure). For most teams,
cargo-deny provides sufficient protection.
cargo-outdated and cargo-semver-checks
cargo-outdated — find dependencies that have newer versions:
cargo install cargo-outdated
cargo outdated --workspace
# Output:
# Name Project Compat Latest Kind
# serde 1.0.193 1.0.203 1.0.203 Normal
# regex 1.9.6 1.10.4 1.10.4 Normal
# thiserror 1.0.50 1.0.61 2.0.3 Normal ← major version available
cargo-semver-checks — detect breaking API changes before publishing.
Essential for library crates:
cargo install cargo-semver-checks
# Check if your changes are semver-compatible
cargo semver-checks
# Output:
# ✗ Function `parse_gpu_csv` is now private (was public)
# → This is a BREAKING change. Bump MAJOR version.
#
# ✗ Struct `GpuInfo` has a new required field `power_limit_w`
# → This is a BREAKING change. Bump MAJOR version.
#
# ✓ Function `parse_gpu_csv_v2` was added (non-breaking)
cargo-tree — Dependency Visualization and Deduplication
cargo tree is built into Cargo (no installation needed) and is invaluable
for understanding your dependency graph:
# Full dependency tree
cargo tree
# Find why a specific crate is included
cargo tree --invert --package openssl-sys
# Shows all paths from your crate to openssl-sys
# Find duplicate versions
cargo tree --duplicates
# Output:
# syn v1.0.109
# └── serde_derive v1.0.193
#
# syn v2.0.48
# ├── thiserror-impl v1.0.56
# └── tokio-macros v2.2.0
# Show only direct dependencies
cargo tree --depth 1
# Show dependency features
cargo tree --format "{p} {f}"
# Count total dependencies
cargo tree | wc -l
Deduplication strategy: When cargo tree --duplicates shows the same crate
at two major versions, check if you can update the dependency chain to unify them.
Each duplicate adds compile time and binary size.
Application: Multi-Crate Dependency Hygiene
The workspace uses [workspace.dependencies] for centralized
version management — an excellent practice. Combined with
cargo tree --duplicates for size
analysis, this prevents version drift and reduces binary bloat:
# Root Cargo.toml — all versions pinned in one place
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
regex = "1.10"
thiserror = "1.0"
anyhow = "1.0"
rayon = "1.8"
Recommended additions for the project:
# Add to CI pipeline:
cargo deny init # One-time setup
cargo deny check # Every PR — licenses, advisories, bans
cargo audit --deny warnings # Every push — vulnerability scanning
cargo outdated --workspace # Weekly — track available updates
Recommended deny.toml for the project:
[advisories]
vulnerability = "deny"
yanked = "deny"
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-DFS-2016"]
copyleft = "deny" # Hardware diagnostics tool — no copyleft
[bans]
multiple-versions = "warn" # Track duplicates, don't block yet
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
Supply Chain Audit Pipeline
flowchart LR
PR["Pull Request"] --> AUDIT["cargo audit\nKnown CVEs"]
AUDIT --> DENY["cargo deny check\nLicenses + Bans + Sources"]
DENY --> OUTDATED["cargo outdated\nWeekly schedule"]
OUTDATED --> SEMVER["cargo semver-checks\nLibrary crates only"]
AUDIT -->|"Fail"| BLOCK["❌ Block merge"]
DENY -->|"Fail"| BLOCK
SEMVER -->|"Breaking change"| BUMP["Bump major version"]
style BLOCK fill:#ff6b6b,color:#000
style BUMP fill:#ffd43b,color:#000
style PR fill:#e3f2fd,color:#000
🏋️ Exercises
🟢 Exercise 1: Audit Your Dependencies
Run cargo audit and cargo deny init && cargo deny check on any Rust project. How many advisories are found? How many license categories are in your tree?
Solution
cargo audit
# Note any advisories — often chrono, time, or older crates
cargo deny init
cargo deny list
# Shows license breakdown: MIT (N), Apache-2.0 (N), etc.
cargo deny check
# Shows full audit across all four dimensions
🟡 Exercise 2: Find and Eliminate Duplicate Dependencies
Run cargo tree --duplicates on a workspace. Find a crate that appears at two versions. Can you update Cargo.toml to unify them? Measure the compile-time and binary-size impact.
Solution
cargo tree --duplicates
# Typical: syn 1.x and syn 2.x
# Find who pulls in the old version:
cargo tree --invert --package [email protected]
# Output: serde_derive 1.0.xxx -> syn 1.0.109
# Check if a newer serde_derive uses syn 2.x:
cargo update -p serde_derive
cargo tree --duplicates
# If syn 1.x is gone, you've eliminated a duplicate
# Measure impact:
time cargo build --release # Before and after
cargo bloat --release --crates | head -20
Key Takeaways
cargo auditcatches known CVEs — run it on every push and on a daily schedulecargo denyenforces four policy dimensions: advisories, licenses, bans, and sources- Use
[workspace.dependencies]to centralize version management across a multi-crate workspace cargo tree --duplicatesreveals bloat; each duplicate adds compile time and binary sizecargo-vetis for high-security environments;cargo-denyis sufficient for most teams
Release Profiles and Binary Size 🟡
What you’ll learn:
- Release profile anatomy: LTO, codegen-units, panic strategy, strip, opt-level
- Thin vs Fat vs Cross-Language LTO trade-offs
- Binary size analysis with
cargo-bloat- Dependency trimming with
cargo-udeps,cargo-macheteandcargo-shearCross-references: Compile-Time Tools — the other half of optimization · Benchmarking — measure runtime before you optimize · Dependencies — trimming deps reduces both size and compile time
The default cargo build --release is already good. But for production
deployment — especially single-binary tools deployed to thousands of servers —
there’s a significant gap between “good” and “optimized.” This chapter covers
the profile knobs and the tools to measure binary size.
Release Profile Anatomy
Cargo profiles control how rustc compiles your code. The defaults are
conservative — designed for broad compatibility, not maximum performance:
# Cargo.toml — Cargo's built-in defaults (what you get if you specify nothing)
[profile.release]
opt-level = 3 # Optimization level (0=none, 1=basic, 2=good, 3=aggressive)
lto = false # Link-time optimization OFF
codegen-units = 16 # Parallel compilation units (faster compile, less optimization)
panic = "unwind" # Stack unwinding on panic (larger binary, catch_unwind works)
strip = "none" # Keep all symbols and debug info
overflow-checks = false # No integer overflow checks in release
debug = false # No debug info in release
Production-optimized profile (what the project already uses):
[profile.release]
lto = true # Full cross-crate optimization
codegen-units = 1 # Single codegen unit — maximum optimization opportunity
panic = "abort" # No unwinding overhead — smaller, faster
strip = true # Remove all symbols — smaller binary
The impact of each setting:
| Setting | Default → Optimized | Binary Size | Runtime Speed | Compile Time |
|---|---|---|---|---|
lto = false → true | — | -10 to -20% | +5 to +20% | 2-5× slower |
codegen-units = 16 → 1 | — | -5 to -10% | +5 to +10% | 1.5-2× slower |
panic = "unwind" → "abort" | — | -5 to -10% | Negligible | Negligible |
strip = "none" → true | — | -50 to -70% | None | None |
opt-level = 3 → "s" | — | -10 to -30% | -5 to -10% | Similar |
opt-level = 3 → "z" | — | -15 to -40% | -10 to -20% | Similar |
Additional profile tweaks:
[profile.release]
# All of the above, plus:
overflow-checks = true # Keep overflow checks even in release (safety > speed)
debug = "line-tables-only" # Minimal debug info for backtraces without full DWARF
rpath = false # Don't embed runtime library paths
incremental = false # Disable incremental compilation (cleaner builds)
# For size-optimized builds (embedded, WASM):
# opt-level = "z" # Optimize for size aggressively
# strip = "symbols" # Strip symbols but keep debug sections
Per-crate profile overrides — optimize hot crates, leave others alone:
# Dev builds: optimize dependencies but not your code (fast recompile)
[profile.dev.package."*"]
opt-level = 2 # Optimize all dependencies in dev mode
# Release builds: override specific crate optimization
[profile.release.package.serde_json]
opt-level = 3 # Maximum optimization for JSON parsing
codegen-units = 1
# Test profile: match release behavior for accurate integration tests
[profile.test]
opt-level = 1 # Some optimization to avoid timeout in slow tests
LTO in Depth — Thin vs Fat vs Cross-Language
Link-Time Optimization lets LLVM optimize across crate boundaries — inlining
functions from serde_json into your parsing code, removing dead code from
regex, etc. Without LTO, each crate is a separate optimization island.
[profile.release]
# Option 1: Fat LTO (default when lto = true)
lto = true
# All code merged into one LLVM module → maximum optimization
# Slowest compile, smallest/fastest binary
# Option 2: Thin LTO
lto = "thin"
# Each crate stays separate but LLVM does cross-module optimization
# Faster compile than fat LTO, nearly as good optimization
# Best trade-off for most projects
# Option 3: No LTO
lto = false
# Only intra-crate optimization
# Fastest compile, larger binary
# Option 4: Off (explicit)
lto = "off"
# Same as false
Fat LTO vs Thin LTO:
| Aspect | Fat LTO (true) | Thin LTO ("thin") |
|---|---|---|
| Optimization quality | Best | ~95% of fat |
| Compile time | Slow (all code in one module) | Moderate (parallel modules) |
| Memory usage | High (all LLVM IR in memory) | Lower (streaming) |
| Parallelism | None (single module) | Good (per-module) |
| Recommended for | Final release builds | CI builds, development |
Cross-language LTO — optimize across Rust and C boundaries:
[profile.release]
lto = true
# Cargo.toml — for crates using the cc crate
[build-dependencies]
cc = "1.0"
// build.rs — enable cross-language (linker-plugin) LTO
fn main() {
// The cc crate respects CFLAGS from the environment.
// For cross-language LTO, compile C code with:
// -flto=thin -O2
cc::Build::new()
.file("csrc/fast_parser.c")
.flag("-flto=thin")
.opt_level(2)
.compile("fast_parser");
}
# Enable linker-plugin LTO (requires compatible LLD or gold linker)
RUSTFLAGS="-Clinker-plugin-lto -Clinker=clang -Clink-arg=-fuse-ld=lld" \
cargo build --release
Cross-language LTO allows LLVM to inline C functions into Rust callers and vice versa. This is most impactful for FFI-heavy code where small C functions are called frequently (e.g., IPMI ioctl wrappers).
Binary Size Analysis with cargo-bloat
cargo-bloat answers:
“What functions and crates are taking up the most space in my binary?”
# Install
cargo install cargo-bloat
# Show largest functions
cargo bloat --release -n 20
# Output:
# File .text Size Crate Name
# 2.8% 5.1% 78.5KiB serde_json serde_json::de::Deserializer::parse_...
# 2.1% 3.8% 58.2KiB regex_syntax regex_syntax::ast::parse::ParserI::p...
# 1.5% 2.7% 42.1KiB accel_diag accel_diag::vendor::parse_smi_output
# ...
# Show by crate (which dependencies are biggest)
cargo bloat --release --crates
# Output:
# File .text Size Crate
# 12.3% 22.1% 340KiB serde_json
# 8.7% 15.6% 240KiB regex
# 6.2% 11.1% 170KiB std
# 5.1% 9.2% 141KiB accel_diag
# ...
# Compare two builds (before/after optimization)
cargo bloat --release --crates > before.txt
# ... make changes ...
cargo bloat --release --crates > after.txt
diff before.txt after.txt
Common bloat sources and fixes:
| Bloat Source | Typical Size | Fix |
|---|---|---|
regex (full engine) | 200-400 KB | Use regex-lite if you don’t need Unicode |
serde_json (full) | 200-350 KB | Consider simd-json or sonic-rs if perf matters |
| Generics monomorphization | Varies | Use dyn Trait at API boundaries |
Formatting machinery (Display, Debug) | 50-150 KB | #[derive(Debug)] on large enums adds up |
| Panic message strings | 20-80 KB | panic = "abort" removes unwinding, strip removes strings |
| Unused features | Varies | Disable default features: serde = { version = "1", default-features = false } |
Trimming Dependencies with cargo-udeps
cargo-udeps finds dependencies
declared in Cargo.toml that your code doesn’t actually use:
# Install (requires nightly)
cargo install cargo-udeps
# Find unused dependencies
cargo +nightly udeps --workspace
# Output:
# unused dependencies:
# `diag_tool v0.1.0`
# └── "tempfile" (dev-dependency)
#
# `accel_diag v0.1.0`
# └── "once_cell" ← was needed before LazyLock, now dead
Every unused dependency:
- Increases compile time
- Increases binary size
- Adds supply chain risk
- Adds potential license complications
Alternative: cargo-machete — faster, heuristic-based approach:
cargo install cargo-machete
cargo machete
# Faster but may have false positives (heuristic, not compilation-based)
Alternative: cargo-shear — sweet spot between cargo-udeps and cargo-machete:
cargo install cargo-shear
cargo shear --fix
# Slower than cargo-machete but much faster than cargo-udeps
# Much less false positives than cargo-machete
Size Optimization Decision Tree
flowchart TD
START["Binary too large?"] --> STRIP{"strip = true?"}
STRIP -->|"No"| DO_STRIP["Add strip = true\n-50 to -70% size"]
STRIP -->|"Yes"| LTO{"LTO enabled?"}
LTO -->|"No"| DO_LTO["Add lto = true\ncodegen-units = 1"]
LTO -->|"Yes"| BLOAT["Run cargo-bloat\n--crates"]
BLOAT --> BIG_DEP{"Large dependency?"}
BIG_DEP -->|"Yes"| REPLACE["Replace with lighter\nalternative or disable\ndefault features"]
BIG_DEP -->|"No"| UDEPS["cargo-udeps\nRemove unused deps"]
UDEPS --> OPT_LEVEL{"Need smaller?"}
OPT_LEVEL -->|"Yes"| SIZE_OPT["opt-level = 's' or 'z'"]
style DO_STRIP fill:#91e5a3,color:#000
style DO_LTO fill:#e3f2fd,color:#000
style REPLACE fill:#ffd43b,color:#000
style SIZE_OPT fill:#ff6b6b,color:#000
🏋️ Exercises
🟢 Exercise 1: Measure LTO Impact
Build a project with default release settings, then with lto = true + codegen-units = 1 + strip = true. Compare binary size and compile time.
Solution
# Default release
cargo build --release
ls -lh target/release/my-binary
time cargo build --release # Note time
# Optimized release — add to Cargo.toml:
# [profile.release]
# lto = true
# codegen-units = 1
# strip = true
# panic = "abort"
cargo clean
cargo build --release
ls -lh target/release/my-binary # Typically 30-50% smaller
time cargo build --release # Typically 2-3× slower to compile
🟡 Exercise 2: Find Your Biggest Crate
Run cargo bloat --release --crates on a project. Identify the largest dependency. Can you reduce it by disabling default features or switching to a lighter alternative?
Solution
cargo install cargo-bloat
cargo bloat --release --crates
# Output:
# File .text Size Crate
# 12.3% 22.1% 340KiB serde_json
# 8.7% 15.6% 240KiB regex
# For regex — try regex-lite if you don't need Unicode:
# regex-lite = "0.1" # ~10× smaller than full regex
# For serde — disable default features if you don't need std:
# serde = { version = "1", default-features = false, features = ["derive"] }
cargo bloat --release --crates # Compare after changes
Key Takeaways
lto = true+codegen-units = 1+strip = true+panic = "abort"is the production release profile- Thin LTO (
lto = "thin") gives 80% of Fat LTO’s benefit at a fraction of the compile cost cargo-bloat --cratestells you exactly which dependencies are eating binary spacecargo-udeps,cargo-macheteandcargo-shearfind dead dependencies that waste compile time and binary size- Per-crate profile overrides let you optimize hot crates without slowing the whole build
Compile-Time and Developer Tools 🟡
What you’ll learn:
- Compilation caching with
sccachefor local and CI builds- Faster linking with
mold(3-10× faster than the default linker)cargo-nextest: a faster, more informative test runner- Developer visibility tools:
cargo-expand,cargo-geiger,cargo-watch- Workspace lints, MSRV policy, and documentation-as-CI
Cross-references: Release Profiles — LTO and binary size optimization · CI/CD Pipeline — these tools integrate into your pipeline · Dependencies — fewer deps = faster compiles
Compile-Time Optimization: sccache, mold, cargo-nextest
Long compile times are the #1 developer pain point in Rust. These tools collectively can cut iteration time by 50-80%:
sccache — Shared compilation cache:
# Install
cargo install sccache
# Configure as the Rust wrapper
export RUSTC_WRAPPER=sccache
# Or set permanently in .cargo/config.toml:
# [build]
# rustc-wrapper = "sccache"
# First build: normal speed (populates cache)
cargo build --release # 3 minutes
# Clean + rebuild: cache hits for unchanged crates
cargo clean && cargo build --release # 45 seconds
# Check cache statistics
sccache --show-stats
# Compile requests 1,234
# Cache hits 987 (80%)
# Cache misses 247
sccache supports shared caches (S3, GCS, Azure Blob) for team-wide and CI
cache sharing.
mold — A faster linker:
Linking is often the slowest phase. mold is 3-5× faster than lld and
10-20× faster than the default GNU ld:
# Install
sudo apt install mold # Ubuntu 22.04+
# Note: mold is for ELF targets (Linux). macOS uses Mach-O, not ELF.
# The macOS linker (ld64) is already quite fast; if you need faster:
# brew install sold # sold = mold for Mach-O (experimental, less mature)
# In practice, macOS link times are rarely a bottleneck.
# Use mold for linking
# .cargo/config.toml
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
# See https://github.com/rui314/mold/blob/main/docs/mold.md#environment-variables
export MOLD_JOBS=1
# Verify mold is being used
cargo build -v 2>&1 | grep mold
cargo-nextest — A faster test runner:
# Install
cargo install cargo-nextest
# Run tests (parallel by default, per-test timeout, retry)
cargo nextest run
# Key advantages over cargo test:
# - Each test runs in its own process → better isolation
# - Parallel execution with smart scheduling
# - Per-test timeouts (no more hanging CI)
# - JUnit XML output for CI
# - Retry failed tests
# Configuration
cargo nextest run --retries 2 --fail-fast
# Archive test binaries (useful for CI: build once, test on multiple machines)
cargo nextest archive --archive-file tests.tar.zst
cargo nextest run --archive-file tests.tar.zst
# .config/nextest.toml
[profile.default]
retries = 0
slow-timeout = { period = "60s", terminate-after = 3 }
fail-fast = true
[profile.ci]
retries = 2
fail-fast = false
junit = { path = "test-results.xml" }
Combined dev configuration:
# .cargo/config.toml — optimize the development inner loop
[build]
rustc-wrapper = "sccache" # Cache compilation artifacts
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"] # Faster linking
# Dev profile: optimize deps but not your code
# (put in Cargo.toml)
# [profile.dev.package."*"]
# opt-level = 2
cargo-expand and cargo-geiger — Visibility Tools
cargo-expand — see what macros generate:
cargo install cargo-expand
# Expand all macros in a specific module
cargo expand --lib accel_diag::vendor
# Expand a specific derive
# Given: #[derive(Debug, Serialize, Deserialize)]
# cargo expand shows the generated impl blocks
cargo expand --lib --tests
Invaluable for debugging #[derive] macro output, macro_rules! expansions,
and understanding what serde generates for your types.
In addition to cargo-expand, you can also use rust-analyzer to expand macros:
- Move cursor to the macro you want to check.
- Open command palette (e.g.
F1on VSCode). - Search for
rust-analyzer: Expand macro recursively at caret.
cargo-geiger — count unsafe usage across your dependency tree:
cargo install cargo-geiger
cargo geiger
# Output:
# Metric output format: x/y
# x = unsafe code used by the build
# y = total unsafe code found in the crate
#
# Functions Expressions Impls Traits Methods
# 0/0 0/0 0/0 0/0 0/0 ✅ my_crate
# 0/5 0/23 0/2 0/0 0/3 ✅ serde
# 3/3 14/14 0/0 0/0 2/2 ❗ libc
# 15/15 142/142 4/4 0/0 12/12 ☢️ ring
# The symbols:
# ✅ = no unsafe used
# ❗ = some unsafe used
# ☢️ = heavily unsafe
For the project’s zero-unsafe policy, cargo geiger verifies that no
dependency introduces unsafe code into the call graph that your code actually
exercises.
Workspace Lints — [workspace.lints]
Since Rust 1.74, you can configure Clippy and compiler lints centrally in
Cargo.toml — no more #![deny(...)] at the top of every crate:
# Root Cargo.toml — lint configuration for all crates
[workspace.lints.clippy]
unwrap_used = "warn" # Prefer ? or expect("reason")
dbg_macro = "deny" # No dbg!() in committed code
todo = "warn" # Track incomplete implementations
large_enum_variant = "warn" # Catch accidental size bloat
[workspace.lints.rust]
unsafe_code = "deny" # Enforce zero-unsafe policy
missing_docs = "warn" # Encourage documentation
# Each crate's Cargo.toml — opt into workspace lints
[lints]
workspace = true
This replaces scattered #![deny(clippy::unwrap_used)] attributes and ensures
consistent policy across the entire workspace.
Auto-fixing Clippy warnings:
# Let Clippy automatically fix machine-applicable suggestions
cargo clippy --fix --workspace --all-targets --allow-dirty
# Fix and also apply suggestions that may change behavior (review carefully!)
cargo clippy --fix --workspace --all-targets --allow-dirty -- -W clippy::pedantic
Tip: Run
cargo clippy --fixbefore committing. It handles trivial issues (unused imports, redundant clones, type simplifications) that are tedious to fix by hand.
MSRV Policy and rust-version
Minimum Supported Rust Version (MSRV) ensures your crate compiles on older toolchains. This matters when deploying to systems with frozen Rust versions.
# Cargo.toml
[package]
name = "diag_tool"
version = "0.1.0"
rust-version = "1.75" # Minimum Rust version required
# Verify MSRV compliance
cargo +1.75.0 check --workspace
# Automated MSRV discovery
cargo install cargo-msrv
cargo msrv find
# Output: Minimum Supported Rust Version is 1.75.0
# Verify in CI
cargo msrv verify
MSRV in CI:
jobs:
msrv:
name: Check MSRV
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.75.0" # Match rust-version in Cargo.toml
- run: cargo check --workspace
MSRV strategy:
- Binary applications (like a large project): Use latest stable. No MSRV needed.
- Library crates (published to crates.io): Set MSRV to oldest Rust version
that supports all features you use. Commonly
N-2(two versions behind current). - Enterprise deployments: Set MSRV to match the oldest Rust version installed on your fleet.
Application: Production Binary Profile
The project already has an excellent release profile:
# Current workspace Cargo.toml
[profile.release]
lto = true # ✅ Full cross-crate optimization
codegen-units = 1 # ✅ Maximum optimization
panic = "abort" # ✅ No unwinding overhead
strip = true # ✅ Remove symbols for deployment
[profile.dev]
opt-level = 0 # ✅ Fast compilation
debug = true # ✅ Full debug info
Recommended additions:
# Optimize dependencies in dev mode (faster test execution)
[profile.dev.package."*"]
opt-level = 2
# Test profile: some optimization to prevent timeout in slow tests
[profile.test]
opt-level = 1
# Keep overflow checks in release (safety)
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
overflow-checks = true # ← add this: catch integer overflows
debug = "line-tables-only" # ← add this: backtraces without full DWARF
Recommended developer tooling:
# .cargo/config.toml (proposed)
[build]
rustc-wrapper = "sccache" # 80%+ cache hit after first build
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"] # 3-5× faster linking
Expected impact on the project:
| Metric | Current | With Additions |
|---|---|---|
| Release binary | ~10 MB (stripped, LTO) | Same |
| Dev build time | ~45s | ~25s (sccache + mold) |
| Rebuild (1 file change) | ~15s | ~5s (sccache + mold) |
| Test execution | cargo test | cargo nextest — 2× faster |
| Dep vulnerability scanning | None | cargo audit in CI |
| License compliance | Manual | cargo deny automated |
| Unused dependency detection | Manual | cargo udeps in CI |
cargo-watch — Auto-Rebuild on File Changes
cargo-watch re-runs a command
every time a source file changes — essential for tight feedback loops:
# Install
cargo install cargo-watch
# Re-check on every save (instant feedback)
cargo watch -x check
# Run clippy + tests on change
cargo watch -x 'clippy --workspace --all-targets' -x 'test --workspace --lib'
# Watch only specific crates (faster for large workspaces)
cargo watch -w accel_diag/src -x 'test -p accel_diag'
# Clear screen between runs
cargo watch -c -x check
Tip: Combine with
mold+sccachefrom above for sub-second re-check times on incremental changes.
cargo doc and Workspace Documentation
For a large workspace, generated documentation is essential for
discoverability. cargo doc uses rustdoc to produce HTML docs from
doc-comments and type signatures:
# Generate docs for all workspace crates (opens in browser)
cargo doc --workspace --no-deps --open
# Include private items (useful during development)
cargo doc --workspace --no-deps --document-private-items
# Check doc-links without generating HTML (fast CI check)
cargo doc --workspace --no-deps 2>&1 | grep -E 'warning|error'
Intra-doc links — link between types across crates without URLs:
#![allow(unused)]
fn main() {
/// Runs GPU diagnostics using [`GpuConfig`] settings.
///
/// See [`crate::accel_diag::run_diagnostics`] for the implementation.
/// Returns [`DiagResult`] which can be serialized to the
/// [`DerReport`](crate::core_lib::DerReport) format.
pub fn run_accel_diag(config: &GpuConfig) -> DiagResult {
// ...
}
}
Show platform-specific APIs in docs:
#![allow(unused)]
fn main() {
// Cargo.toml: [package.metadata.docs.rs]
// all-features = true
// rustdoc-args = ["--cfg", "docsrs"]
/// Windows-only: read battery status via Win32 API.
///
/// Only available on `cfg(windows)` builds.
#[cfg(windows)]
#[doc(cfg(windows))] // Shows "Available on Windows only" badge in docs
pub fn get_battery_status() -> Option<u8> {
// ...
}
}
CI documentation check:
# Add to CI workflow
- name: Check documentation
run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
# Treats broken intra-doc links as errors
For the project: With many crates,
cargo doc --workspaceis the best way for new team members to discover the API surface. AddRUSTDOCFLAGS="-D warnings"to CI to catch broken doc-links before merge.
Compile-Time Decision Tree
flowchart TD
START["Compile too slow?"] --> WHERE{"Where's the time?"}
WHERE -->|"Recompiling\nunchanged crates"| SCCACHE["sccache\nShared compilation cache"]
WHERE -->|"Linking phase"| MOLD["mold linker\n3-10× faster linking"]
WHERE -->|"Running tests"| NEXTEST["cargo-nextest\nParallel test runner"]
WHERE -->|"Everything"| COMBO["All of the above +\ncargo-udeps to trim deps"]
SCCACHE --> CI_CACHE{"CI or local?"}
CI_CACHE -->|"CI"| S3["S3/GCS shared cache"]
CI_CACHE -->|"Local"| LOCAL["Local disk cache\nauto-configured"]
style SCCACHE fill:#91e5a3,color:#000
style MOLD fill:#e3f2fd,color:#000
style NEXTEST fill:#ffd43b,color:#000
style COMBO fill:#b39ddb,color:#000
🏋️ Exercises
🟢 Exercise 1: Set Up sccache + mold
Install sccache and mold, configure them in .cargo/config.toml, then measure the compile time improvement on a clean rebuild.
Solution
# Install
cargo install sccache
sudo apt install mold # Ubuntu 22.04+
# Configure .cargo/config.toml:
cat > .cargo/config.toml << 'EOF'
[build]
rustc-wrapper = "sccache"
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
EOF
# First build (populates cache)
time cargo build --release # e.g., 180s
# Clean + rebuild (cache hits)
cargo clean
time cargo build --release # e.g., 45s
sccache --show-stats
# Cache hits should be 60-80%+
🟡 Exercise 2: Switch to cargo-nextest
Install cargo-nextest and run your test suite. Compare wall-clock time with cargo test. What’s the speedup?
Solution
cargo install cargo-nextest
# Standard test runner
time cargo test --workspace 2>&1 | tail -5
# nextest (parallel per-test-binary execution)
time cargo nextest run --workspace 2>&1 | tail -5
# Typical speedup: 2-5× for large workspaces
# nextest also provides:
# - Per-test timing
# - Retries for flaky tests
# - JUnit XML output for CI
cargo nextest run --workspace --retries 2
Key Takeaways
sccachewith S3/GCS backend shares compilation cache across team and CImoldis the fastest ELF linker — link times drop from seconds to millisecondscargo-nextestruns tests in parallel per-binary with better output and retry supportcargo-geigercountsunsafeusage — run it before accepting new dependencies[workspace.lints]centralizes Clippy and rustc lint configuration across a multi-crate workspace
no_std and Feature Verification 🔴
What you’ll learn:
- Verifying feature combinations systematically with
cargo-hack- The three layers of Rust:
corevsallocvsstdand when to use each- Building
no_stdcrates with custom panic handlers and allocators- Testing
no_stdcode on host and with QEMUCross-references: Windows & Conditional Compilation — the platform half of this topic · Cross-Compilation — cross-compiling to ARM and embedded targets · Miri and Sanitizers — verifying
unsafecode inno_stdenvironments · Build Scripts —cfgflags emitted bybuild.rs
Rust runs everywhere from 8-bit microcontrollers to cloud servers. This chapter
covers the foundation: stripping the standard library with #![no_std] and
verifying that your feature combinations actually compile.
Verifying Feature Combinations with cargo-hack
cargo-hack tests all feature
combinations systematically — essential for crates with #[cfg(...)] code:
# Install
cargo install cargo-hack
# Check that every feature compiles individually
cargo hack check --each-feature --workspace
# The nuclear option: test ALL feature combinations (exponential!)
# Only practical for crates with <8 features.
cargo hack check --feature-powerset --workspace
# Practical compromise: test each feature alone + all features + no features
cargo hack check --each-feature --workspace --no-dev-deps
cargo check --workspace --all-features
cargo check --workspace --no-default-features
Why this matters for the project:
If you add platform features (linux, windows, direct-ipmi, direct-accel-api),
cargo-hack catches combinations that break:
# Example: features that gate platform code
[features]
default = ["linux"]
linux = [] # Linux-specific hardware access
windows = ["dep:windows-sys"] # Windows-specific APIs
direct-ipmi = [] # unsafe IPMI ioctl (ch05)
direct-accel-api = [] # unsafe accel-mgmt FFI (ch05)
# Verify all features compile in isolation AND together
cargo hack check --each-feature -p diag_tool
# Catches: "feature 'windows' doesn't compile without 'direct-ipmi'"
# Catches: "#[cfg(feature = \"linux\")] has a typo — it's 'lnux'"
CI integration:
# Add to CI pipeline (fast — just compilation checks)
- name: Feature matrix check
run: cargo hack check --each-feature --workspace --no-dev-deps
Rule of thumb: Run
cargo hack check --each-featurein CI for any crate with 2+ features. Run--feature-powersetonly for core library crates with <8 features — it’s exponential ($2^n$ combinations).
no_std — When and Why
#![no_std] tells the compiler: “don’t link the standard library.” Your
crate can only use core (and optionally alloc). Why would you want this?
| Scenario | Why no_std |
|---|---|
| Embedded firmware (ARM Cortex-M, RISC-V) | No OS, no heap, no file system |
| UEFI diagnostics tool | Pre-boot environment, no OS APIs |
| Kernel modules | Kernel space can’t use userspace std |
| WebAssembly (WASM) | Minimize binary size, no OS dependencies |
| Bootloaders | Run before any OS exists |
| Shared library with C interface | Avoid Rust runtime in callers |
For hardware diagnostics, no_std becomes relevant when building:
- UEFI-based pre-boot diagnostic tools (before the OS loads)
- BMC firmware diagnostics (resource-constrained ARM SoCs)
- Kernel-level PCIe diagnostics (kernel module or eBPF probe)
core vs alloc vs std — The Three Layers
┌─────────────────────────────────────────────────────────────┐
│ std │
│ Everything in core + alloc, PLUS: │
│ • File I/O (std::fs, std::io) │
│ • Networking (std::net) │
│ • Threads (std::thread) │
│ • Time (std::time) │
│ • Environment (std::env) │
│ • Process (std::process) │
│ • OS-specific (std::os::unix, std::os::windows) │
├─────────────────────────────────────────────────────────────┤
│ alloc (available with #![no_std] + extern crate │
│ alloc, if you have a global allocator) │
│ • String, Vec, Box, Rc, Arc │
│ • BTreeMap, BTreeSet │
│ • format!() macro │
│ • Collections and smart pointers that need heap │
├─────────────────────────────────────────────────────────────┤
│ core (always available, even in #![no_std]) │
│ • Primitive types (u8, bool, char, etc.) │
│ • Option, Result │
│ • Iterator, slice, array, str (slices, not String) │
│ • Traits: Clone, Copy, Debug, Display, From, Into │
│ • Atomics (core::sync::atomic) │
│ • Cell, RefCell (core::cell) — Pin (core::pin) │
│ • core::fmt (formatting without allocation) │
│ • core::mem, core::ptr (low-level memory operations) │
│ • Math: core::num, basic arithmetic │
└─────────────────────────────────────────────────────────────┘
What you lose without std:
- No
HashMap(requires a hasher — useBTreeMapfromalloc, orhashbrown) - No
println!()(requires stdout — usecore::fmt::Writeto a buffer) - No
std::error::Error(stabilized incoresince Rust 1.81, but many ecosystems haven’t migrated) - No file I/O, no networking, no threads (unless provided by a platform HAL)
- No
Mutex(usespin::Mutexor platform-specific locks)
Building a no_std Crate
#![allow(unused)]
fn main() {
// src/lib.rs — a no_std library crate
#![no_std]
// Optionally use heap allocation
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;
/// Temperature reading from a thermal sensor.
/// This struct works in any environment — bare metal to Linux.
#[derive(Clone, Copy, Debug)]
pub struct Temperature {
/// Raw sensor value (0.0625°C per LSB for typical I2C sensors)
raw: u16,
}
impl Temperature {
pub const fn from_raw(raw: u16) -> Self {
Self { raw }
}
/// Convert to degrees Celsius (fixed-point, no FPU required)
pub const fn millidegrees_c(&self) -> i32 {
(self.raw as i32) * 625 / 10 // 0.0625°C resolution
}
pub fn degrees_c(&self) -> f32 {
self.raw as f32 * 0.0625
}
}
impl fmt::Display for Temperature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let md = self.millidegrees_c();
// Handle sign correctly for values between -0.999°C and -0.001°C
// where md / 1000 == 0 but the value is negative.
if md < 0 && md > -1000 {
write!(f, "-0.{:03}°C", (-md) % 1000)
} else {
write!(f, "{}.{:03}°C", md / 1000, (md % 1000).abs())
}
}
}
/// Parse space-separated temperature values.
/// Uses alloc — requires a global allocator.
pub fn parse_temperatures(input: &str) -> Vec<Temperature> {
input
.split_whitespace()
.filter_map(|s| s.parse::<u16>().ok())
.map(Temperature::from_raw)
.collect()
}
/// Format without allocation — writes directly to a buffer.
/// Works in `core`-only environments (no alloc, no heap).
pub fn format_temp_into(temp: &Temperature, buf: &mut [u8]) -> usize {
use core::fmt::Write;
struct SliceWriter<'a> {
buf: &'a mut [u8],
pos: usize,
}
impl<'a> Write for SliceWriter<'a> {
fn write_str(&mut self, s: &str) -> fmt::Result {
let bytes = s.as_bytes();
let remaining = self.buf.len() - self.pos;
if bytes.len() > remaining {
// Buffer full — signal the error instead of silently truncating.
// Callers can check the returned pos for partial writes.
return Err(fmt::Error);
}
self.buf[self.pos..self.pos + bytes.len()].copy_from_slice(bytes);
self.pos += bytes.len();
Ok(())
}
}
let mut w = SliceWriter { buf, pos: 0 };
let _ = write!(w, "{}", temp);
w.pos
}
}
# Cargo.toml for a no_std crate
[package]
name = "thermal-sensor"
version = "0.1.0"
edition = "2021"
[features]
default = ["alloc"]
alloc = [] # Enable Vec, String, etc.
std = [] # Enable full std (implies alloc)
[dependencies]
# Use no_std-compatible crates
serde = { version = "1.0", default-features = false, features = ["derive"] }
# ↑ default-features = false drops std dependency!
Key crate pattern: Many popular crates (serde, log, rand, embedded-hal) support
no_stdviadefault-features = false. Always check whether a dependency requiresstdbefore using it in ano_stdcontext. Note that some crates (e.g.,regex) require at leastallocand don’t work incore-only environments.
Custom Panic Handlers and Allocators
In #![no_std] binaries (not libraries), you must provide a panic handler
and optionally a global allocator:
// src/main.rs — a no_std binary (e.g., UEFI diagnostic)
#![no_std]
#![no_main]
extern crate alloc;
use core::panic::PanicInfo;
// Required: what to do on panic (no stack unwinding available)
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// In embedded: blink an LED, write to UART, hang
// In UEFI: write to console, halt
// Minimal: just loop forever
loop {
core::hint::spin_loop();
}
}
// Required if using alloc: provide a global allocator
use alloc::alloc::{GlobalAlloc, Layout};
struct BumpAllocator {
// Simple bump allocator for embedded/UEFI
// In practice, use a crate like `linked_list_allocator` or `embedded-alloc`
}
// WARNING: This is a non-functional placeholder! Calling alloc() will return
// null, causing immediate UB (the global allocator contract requires non-null
// returns for non-zero-sized allocations). In real code, use an established
// allocator crate:
// - embedded-alloc (embedded targets)
// - linked_list_allocator (UEFI / OS kernels)
// - talc (general-purpose no_std)
unsafe impl GlobalAlloc for BumpAllocator {
/// # Safety
/// Layout must have non-zero size. Returns null (placeholder — will crash).
unsafe fn alloc(&self, _layout: Layout) -> *mut u8 {
// PLACEHOLDER — will crash! Replace with real allocation logic.
core::ptr::null_mut()
}
/// # Safety
/// `_ptr` must have been returned by `alloc` with a compatible layout.
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// No-op for bump allocator
}
}
#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator {};
// Entry point (platform-specific, not fn main)
// For UEFI: #[entry] or efi_main
// For embedded: #[cortex_m_rt::entry]
Testing no_std Code
Tests run on the host machine, which has std. The trick: your library is
no_std, but your test harness uses std:
#![allow(unused)]
fn main() {
// Your crate: #![no_std] in src/lib.rs
// But tests run under std automatically:
#[cfg(test)]
mod tests {
use super::*;
// std is available here — println!, assert!, Vec all work
#[test]
fn test_temperature_conversion() {
let temp = Temperature::from_raw(800); // 50.0°C
assert_eq!(temp.millidegrees_c(), 50000);
assert!((temp.degrees_c() - 50.0).abs() < 0.01);
}
#[test]
fn test_format_into_buffer() {
let temp = Temperature::from_raw(800);
let mut buf = [0u8; 32];
let len = format_temp_into(&temp, &mut buf);
let s = core::str::from_utf8(&buf[..len]).unwrap();
assert_eq!(s, "50.000°C");
}
}
}
Testing on the actual target (when std isn’t available at all):
# Use defmt-test for on-device testing (embedded ARM)
# Use uefi-test-runner for UEFI targets
# Use QEMU for cross-architecture tests without hardware
# Run no_std library tests on host (always works):
cargo test --lib
# Verify no_std compilation against a no_std target:
cargo check --target thumbv7em-none-eabihf # ARM Cortex-M
cargo check --target riscv32imac-unknown-none-elf # RISC-V
no_std Decision Tree
flowchart TD
START["Does your code need\nthe standard library?"] --> NEED_FS{"File system,\nnetwork, threads?"}
NEED_FS -->|"Yes"| USE_STD["Use std\nNormal application"]
NEED_FS -->|"No"| NEED_HEAP{"Need heap allocation?\nVec, String, Box"}
NEED_HEAP -->|"Yes"| USE_ALLOC["#![no_std]\nextern crate alloc"]
NEED_HEAP -->|"No"| USE_CORE["#![no_std]\ncore only"]
USE_ALLOC --> VERIFY["cargo-hack\n--each-feature"]
USE_CORE --> VERIFY
USE_STD --> VERIFY
VERIFY --> TARGET{"Target has OS?"}
TARGET -->|"Yes"| HOST_TEST["cargo test --lib\nStandard testing"]
TARGET -->|"No"| CROSS_TEST["QEMU / defmt-test\nOn-device testing"]
style USE_STD fill:#91e5a3,color:#000
style USE_ALLOC fill:#ffd43b,color:#000
style USE_CORE fill:#ff6b6b,color:#000
🏋️ Exercises
🟡 Exercise 1: Feature Combination Verification
Install cargo-hack and run cargo hack check --each-feature --workspace on a project with multiple features. Does it find any broken combinations?
Solution
cargo install cargo-hack
# Check each feature individually
cargo hack check --each-feature --workspace --no-dev-deps
# If a feature combination fails:
# error[E0433]: failed to resolve: use of undeclared crate or module `std`
# → This means a feature gate is missing a #[cfg] guard
# Check all features + no features + each individually:
cargo hack check --each-feature --workspace
cargo check --workspace --all-features
cargo check --workspace --no-default-features
🔴 Exercise 2: Build a no_std Library
Create a library crate that compiles with #![no_std]. Implement a simple stack-allocated ring buffer. Verify it compiles for thumbv7em-none-eabihf (ARM Cortex-M).
Solution
#![allow(unused)]
fn main() {
// lib.rs
#![no_std]
pub struct RingBuffer<const N: usize> {
data: [u8; N],
head: usize,
len: usize,
}
impl<const N: usize> RingBuffer<N> {
pub const fn new() -> Self {
Self { data: [0; N], head: 0, len: 0 }
}
pub fn push(&mut self, byte: u8) -> bool {
if self.len == N { return false; }
let idx = (self.head + self.len) % N;
self.data[idx] = byte;
self.len += 1;
true
}
pub fn pop(&mut self) -> Option<u8> {
if self.len == 0 { return None; }
let byte = self.data[self.head];
self.head = (self.head + 1) % N;
self.len -= 1;
Some(byte)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_pop() {
let mut rb = RingBuffer::<4>::new();
assert!(rb.push(1));
assert!(rb.push(2));
assert_eq!(rb.pop(), Some(1));
assert_eq!(rb.pop(), Some(2));
assert_eq!(rb.pop(), None);
}
}
}
rustup target add thumbv7em-none-eabihf
cargo check --target thumbv7em-none-eabihf
# ✅ Compiles for bare-metal ARM
Key Takeaways
cargo-hack --each-featureis essential for any crate with conditional compilation — run it in CIcore→alloc→stdare layered: each adds capabilities but requires more runtime support- Custom panic handlers and allocators are required for bare-metal
no_stdbinaries - Test
no_stdlibraries on the host withcargo test --lib— no hardware needed - Run
--feature-powersetonly for core libraries with <8 features — it’s $2^n$ combinations
Windows and Conditional Compilation 🟡
What you’ll learn:
- Windows support patterns:
windows-sys/windowscrates,cargo-xwin- Conditional compilation with
#[cfg]— checked by the compiler, not the preprocessor- Platform abstraction architecture: when
#[cfg]blocks suffice vs when to use traits- Cross-compiling for Windows from Linux
Cross-references:
no_std& Features —cargo-hackand feature verification · Cross-Compilation — general cross-build setup · Build Scripts —cfgflags emitted bybuild.rs
Windows Support — Platform Abstractions
Rust’s #[cfg()] attributes and Cargo features allow a single codebase to
target both Linux and Windows cleanly. The project already
demonstrates this pattern in platform::run_command:
#![allow(unused)]
fn main() {
// Real pattern from the project — platform-specific shell invocation
pub fn exec_cmd(cmd: &str, timeout_secs: Option<u64>) -> Result<CommandResult, CommandError> {
#[cfg(windows)]
let mut child = Command::new("cmd")
.args(["/C", cmd])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
#[cfg(not(windows))]
let mut child = Command::new("sh")
.args(["-c", cmd])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// ... rest is platform-independent ...
}
}
Available cfg predicates:
#![allow(unused)]
fn main() {
// Operating system
#[cfg(target_os = "linux")] // Linux specifically
#[cfg(target_os = "windows")] // Windows
#[cfg(target_os = "macos")] // macOS
#[cfg(unix)] // Linux, macOS, BSDs, etc.
#[cfg(windows)] // Windows (shorthand)
// Architecture
#[cfg(target_arch = "x86_64")] // x86 64-bit
#[cfg(target_arch = "aarch64")] // ARM 64-bit
#[cfg(target_arch = "x86")] // x86 32-bit
// Pointer width (portable alternative to arch)
#[cfg(target_pointer_width = "64")] // Any 64-bit platform
#[cfg(target_pointer_width = "32")] // Any 32-bit platform
// Environment / C library
#[cfg(target_env = "gnu")] // glibc
#[cfg(target_env = "musl")] // musl libc
#[cfg(target_env = "msvc")] // MSVC on Windows
// Endianness
#[cfg(target_endian = "little")]
#[cfg(target_endian = "big")]
// Combinations with any(), all(), not()
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(not(windows))]
}
The windows-sys and windows Crates
For calling Windows APIs directly:
# Cargo.toml — use windows-sys for raw FFI (lighter, no abstraction)
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_Services",
"Win32_System_Registry",
"Win32_System_Power",
] }
# NOTE: windows-sys uses semver-incompatible releases (0.48 → 0.52 → 0.59).
# Pin to a single minor version — each release may remove or rename API bindings.
# Check https://github.com/microsoft/windows-rs for the latest version
# before starting a new project.
# Or use the windows crate for safe wrappers (heavier, more ergonomic)
# windows = { version = "0.59", features = [...] }
#![allow(unused)]
fn main() {
// src/platform/windows.rs
#[cfg(windows)]
mod win {
use windows_sys::Win32::System::Power::{
GetSystemPowerStatus, SYSTEM_POWER_STATUS,
};
pub fn get_battery_status() -> Option<u8> {
let mut status = SYSTEM_POWER_STATUS::default();
// SAFETY: GetSystemPowerStatus writes to the provided buffer.
// The buffer is correctly sized and aligned.
let ok = unsafe { GetSystemPowerStatus(&mut status) };
if ok != 0 {
Some(status.BatteryLifePercent)
} else {
None
}
}
}
}
windows-sys vs windows crate:
| Aspect | windows-sys | windows |
|---|---|---|
| API style | Raw FFI (unsafe calls) | Safe Rust wrappers |
| Binary size | Minimal (just extern declarations) | Larger (wrapper code) |
| Compile time | Fast | Slower |
| Ergonomics | C-style, manual safety | Rust-idiomatic |
| Error handling | Raw BOOL / HRESULT | Result<T, windows::core::Error> |
| Use when | Performance-critical, thin wrapper | Application code, ease of use |
Cross-Compiling for Windows from Linux
# Option 1: MinGW (GNU ABI)
rustup target add x86_64-pc-windows-gnu
sudo apt install gcc-mingw-w64-x86-64
cargo build --target x86_64-pc-windows-gnu
# Produces a .exe — runs on Windows, links against msvcrt
# Option 2: MSVC ABI via xwin (for full MSVC compatibility)
cargo install cargo-xwin
cargo xwin build --target x86_64-pc-windows-msvc
# Uses Microsoft's CRT and SDK headers downloaded automatically
# Option 3: Zig-based cross-compilation
cargo zigbuild --target x86_64-pc-windows-gnu
GNU vs MSVC ABI on Windows:
| Aspect | x86_64-pc-windows-gnu | x86_64-pc-windows-msvc |
|---|---|---|
| Linker | MinGW ld | MSVC link.exe or lld-link |
| C runtime | msvcrt.dll (universal) | ucrtbase.dll (modern) |
| C++ interop | GCC ABI | MSVC ABI |
| Cross-compile from Linux | Easy (MinGW) | Possible (cargo-xwin) |
| Windows API support | Full | Full |
| Debug info format | DWARF | PDB |
| Recommended for | Simple tools, CI builds | Full Windows integration |
Conditional Compilation Patterns
Pattern 1: Platform module selection
#![allow(unused)]
fn main() {
// src/platform/mod.rs — compile different modules per OS
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::*;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
pub use windows::*;
// Both modules implement the same public API:
// pub fn get_cpu_temperature() -> Result<f64, PlatformError>
// pub fn list_pci_devices() -> Result<Vec<PciDevice>, PlatformError>
}
Pattern 2: Feature-gated platform support
# Cargo.toml
[features]
default = ["linux"]
linux = [] # Linux-specific hardware access
windows = ["dep:windows-sys"] # Windows-specific APIs
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [...], optional = true }
#![allow(unused)]
fn main() {
// Compile error if someone tries to build for Windows without the feature:
#[cfg(all(target_os = "windows", not(feature = "windows")))]
compile_error!("Enable the 'windows' feature to build for Windows");
}
Pattern 3: Trait-based platform abstraction
#![allow(unused)]
fn main() {
/// Platform-independent interface for hardware access.
pub trait HardwareAccess {
type Error: std::error::Error;
fn read_cpu_temperature(&self) -> Result<f64, Self::Error>;
fn read_gpu_temperature(&self, gpu_index: u32) -> Result<f64, Self::Error>;
fn list_pci_devices(&self) -> Result<Vec<PciDevice>, Self::Error>;
fn send_ipmi_command(&self, cmd: &IpmiCmd) -> Result<IpmiResponse, Self::Error>;
}
#[cfg(target_os = "linux")]
pub struct LinuxHardware;
#[cfg(target_os = "linux")]
impl HardwareAccess for LinuxHardware {
type Error = LinuxHwError;
fn read_cpu_temperature(&self) -> Result<f64, Self::Error> {
// Read from /sys/class/thermal/thermal_zone0/temp
let raw = std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")?;
Ok(raw.trim().parse::<f64>()? / 1000.0)
}
// ...
}
#[cfg(target_os = "windows")]
pub struct WindowsHardware;
#[cfg(target_os = "windows")]
impl HardwareAccess for WindowsHardware {
type Error = WindowsHwError;
fn read_cpu_temperature(&self) -> Result<f64, Self::Error> {
// Read via WMI (Win32_TemperatureProbe) or Open Hardware Monitor
todo!("WMI temperature query")
}
// ...
}
/// Create the platform-appropriate implementation
pub fn create_hardware() -> impl HardwareAccess {
#[cfg(target_os = "linux")]
{ LinuxHardware }
#[cfg(target_os = "windows")]
{ WindowsHardware }
}
}
Platform Abstraction Architecture
For a project that targets multiple platforms, organize code into three layers:
┌──────────────────────────────────────────────────┐
│ Application Logic (platform-independent) │
│ diag_tool, accel_diag, network_diag, event_log, etc. │
│ Uses only the platform abstraction trait │
├──────────────────────────────────────────────────┤
│ Platform Abstraction Layer (trait definitions) │
│ trait HardwareAccess { ... } │
│ trait CommandRunner { ... } │
│ trait FileSystem { ... } │
├──────────────────────────────────────────────────┤
│ Platform Implementations (cfg-gated) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Linux impl │ │ Windows impl │ │
│ │ /sys, /proc │ │ WMI, Registry│ │
│ │ ipmitool │ │ ipmiutil │ │
│ │ lspci │ │ devcon │ │
│ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────┘
Testing the abstraction: Mock the platform trait for unit tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
struct MockHardware {
cpu_temp: f64,
gpu_temps: Vec<f64>,
}
impl HardwareAccess for MockHardware {
type Error = std::io::Error;
fn read_cpu_temperature(&self) -> Result<f64, Self::Error> {
Ok(self.cpu_temp)
}
fn read_gpu_temperature(&self, index: u32) -> Result<f64, Self::Error> {
self.gpu_temps.get(index as usize)
.copied()
.ok_or_else(|| std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("GPU {index} not found")
))
}
fn list_pci_devices(&self) -> Result<Vec<PciDevice>, Self::Error> {
Ok(vec![]) // Mock returns empty
}
fn send_ipmi_command(&self, _cmd: &IpmiCmd) -> Result<IpmiResponse, Self::Error> {
Ok(IpmiResponse::default())
}
}
#[test]
fn test_thermal_check_with_mock() {
let hw = MockHardware {
cpu_temp: 75.0,
gpu_temps: vec![82.0, 84.0],
};
let result = run_thermal_diagnostic(&hw);
assert!(result.is_ok());
}
}
}
Application: Linux-First, Windows-Ready
The project is already partially Windows-ready. Use
cargo-hack to verify all feature
combinations, and cross-compile
to test on Windows from Linux:
Already done:
platform::run_commanduses#[cfg(windows)]for shell selection- Tests use
#[cfg(windows)]/#[cfg(not(windows))]for platform-appropriate test commands
Recommended evolution path for Windows support:
Phase 1: Extract platform abstraction trait (current → 2 weeks)
├─ Define HardwareAccess trait in core_lib
├─ Wrap current Linux code behind LinuxHardware impl
└─ All diagnostic modules depend on trait, not Linux specifics
Phase 2: Add Windows stubs (2 weeks)
├─ Implement WindowsHardware with TODO stubs
├─ CI builds for x86_64-pc-windows-msvc (compile check only)
└─ Tests pass with MockHardware on all platforms
Phase 3: Windows implementation (ongoing)
├─ IPMI via ipmiutil.exe or OpenIPMI Windows driver
├─ GPU via accel-mgmt (accel-api.dll) — same API as Linux
├─ PCIe via Windows Setup API (SetupDiEnumDeviceInfo)
└─ NIC via WMI (Win32_NetworkAdapter)
Cross-platform CI addition:
# Add to CI matrix
- target: x86_64-pc-windows-msvc
os: windows-latest
name: windows-x86_64
This ensures the codebase compiles on Windows even before full Windows
implementation is complete — catching cfg mistakes early.
Key insight: The abstraction doesn’t need to be perfect on day one. Start with
#[cfg]blocks in leaf functions (likeexec_cmdalready does), then refactor to traits when you have two or more platform implementations. Premature abstraction is worse than#[cfg]blocks.
Conditional Compilation Decision Tree
flowchart TD
START["Platform-specific code?"] --> HOW_MANY{"How many platforms?"}
HOW_MANY -->|"2 (Linux + Windows)"| CFG_BLOCKS["#[cfg] blocks\nin leaf functions"]
HOW_MANY -->|"3+"| TRAIT_APPROACH["Platform trait\n+ per-platform impl"]
CFG_BLOCKS --> WINAPI{"Need Windows APIs?"}
WINAPI -->|"Minimal"| WIN_SYS["windows-sys\nRaw FFI bindings"]
WINAPI -->|"Rich (COM, etc)"| WIN_RS["windows crate\nSafe idiomatic wrappers"]
WINAPI -->|"None\n(just #[cfg])"| NATIVE["cfg(windows)\ncfg(unix)"]
TRAIT_APPROACH --> CI_CHECK["cargo-hack\n--each-feature"]
CFG_BLOCKS --> CI_CHECK
CI_CHECK --> XCOMPILE["Cross-compile in CI\ncargo-xwin or\nnative runners"]
style CFG_BLOCKS fill:#91e5a3,color:#000
style TRAIT_APPROACH fill:#ffd43b,color:#000
style WIN_SYS fill:#e3f2fd,color:#000
style WIN_RS fill:#e3f2fd,color:#000
🏋️ Exercises
🟢 Exercise 1: Platform-Conditional Module
Create a module with #[cfg(unix)] and #[cfg(windows)] implementations of a get_hostname() function. Verify both compile with cargo check and cargo check --target x86_64-pc-windows-msvc.
Solution
#![allow(unused)]
fn main() {
// src/hostname.rs
#[cfg(unix)]
pub fn get_hostname() -> String {
use std::fs;
fs::read_to_string("/etc/hostname")
.unwrap_or_else(|_| "unknown".to_string())
.trim()
.to_string()
}
#[cfg(windows)]
pub fn get_hostname() -> String {
use std::env;
env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostname_is_not_empty() {
let name = get_hostname();
assert!(!name.is_empty());
}
}
}
# Verify Linux compilation
cargo check
# Verify Windows compilation (cross-check)
rustup target add x86_64-pc-windows-msvc
cargo check --target x86_64-pc-windows-msvc
🟡 Exercise 2: Cross-Compile for Windows with cargo-xwin
Install cargo-xwin and build a simple binary for x86_64-pc-windows-msvc from Linux. Verify the output is a .exe.
Solution
cargo install cargo-xwin
rustup target add x86_64-pc-windows-msvc
cargo xwin build --release --target x86_64-pc-windows-msvc
# Downloads Windows SDK headers/libs automatically
file target/x86_64-pc-windows-msvc/release/my-binary.exe
# Output: PE32+ executable (console) x86-64, for MS Windows
# You can also test with Wine:
wine target/x86_64-pc-windows-msvc/release/my-binary.exe
Key Takeaways
- Start with
#[cfg]blocks in leaf functions; refactor to traits only when three or more platforms diverge windows-sysis for raw FFI; thewindowscrate provides safe, idiomatic wrapperscargo-xwincross-compiles to Windows MSVC ABI from Linux — no Windows machine needed- Always check
--target x86_64-pc-windows-msvcin CI even if you only ship on Linux - Combine
#[cfg]with Cargo features for optional platform support (e.g.,feature = "windows")
Putting It All Together — A Production CI/CD Pipeline 🟡
What you’ll learn:
- Structuring a multi-stage GitHub Actions CI workflow (check → test → coverage → security → cross → release)
- Caching strategies with
rust-cacheandsave-iftuning- Running Miri and sanitizers on a nightly schedule
- Task automation with
Makefile.tomland pre-commit hooks- Automated releases with
cargo-distCross-references: Build Scripts · Cross-Compilation · Benchmarking · Coverage · Miri/Sanitizers · Dependencies · Release Profiles · Compile-Time Tools ·
no_std· Windows
Individual tools are useful. A pipeline that orchestrates them automatically on every push is transformative. This chapter assembles the tools from chapters 1–10 into a cohesive CI/CD workflow.
The Complete GitHub Actions Workflow
A single workflow file that runs all verification stages in parallel:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
CARGO_ENCODED_RUSTFLAGS: "-Dwarnings" # Treat warnings as errors (top-level crate only)
# NOTE: Unlike RUSTFLAGS, CARGO_ENCODED_RUSTFLAGS does not affect build scripts
# or proc-macros, which avoids false failures from third-party warnings.
# Use RUSTFLAGS="-Dwarnings" instead if you want to enforce on build scripts too.
jobs:
# ─── Stage 1: Fast feedback (< 2 min) ───
check:
name: Check + Clippy + Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2 # Cache dependencies
- name: Check Cargo.lock
run: cargo fetch --locked
- name: Check doc
run: RUSTDOCFLAGS='-Dwarnings' cargo doc --workspace --all-features --no-deps
- name: Check compilation
run: cargo check --workspace --all-targets --all-features
- name: Clippy lints
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: Formatting
run: cargo fmt --all -- --check
# ─── Stage 2: Tests (< 5 min) ───
test:
name: Test (${{ matrix.os }})
needs: check
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --workspace
- name: Run doc tests
run: cargo test --workspace --doc
# ─── Stage 3: Cross-compilation (< 10 min) ───
cross:
name: Cross (${{ matrix.target }})
needs: check
strategy:
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
use_cross: true
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install musl-tools
if: contains(matrix.target, 'musl')
run: sudo apt-get install -y musl-tools
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build (native)
if: "!matrix.use_cross"
run: cargo build --release --target ${{ matrix.target }}
- name: Build (cross)
if: matrix.use_cross
run: cross build --release --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: target/${{ matrix.target }}/release/diag_tool
# ─── Stage 4: Coverage (< 10 min) ───
coverage:
name: Code Coverage
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- name: Enforce minimum coverage
run: cargo llvm-cov --workspace --fail-under-lines 75
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
# ─── Stage 5: Safety verification (< 15 min) ───
miri:
name: Miri
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- name: Run Miri
run: cargo miri test --workspace
env:
MIRIFLAGS: "-Zmiri-backtrace=full"
# ─── Stage 6: Benchmarks (PR only, < 10 min) ───
bench:
name: Benchmarks
if: github.event_name == 'pull_request'
needs: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run benchmarks
run: cargo bench -- --output-format bencher | tee bench.txt
- name: Compare with baseline
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'cargo'
output-file-path: bench.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
alert-threshold: '115%'
comment-on-alert: true
Pipeline execution flow:
┌─────────┐
│ check │ ← clippy + fmt + cargo check (2 min)
└────┬────┘
┌─────────┬──┴──┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌────────┐ ┌──────┐ ┌──────┐
│ test │ │cross │ │coverage│ │ miri │ │bench │
│ (2×) │ │ (2×) │ │ │ │ │ │(PR) │
└──────┘ └──────┘ └────────┘ └──────┘ └──────┘
3 min 8 min 8 min 12 min 5 min
Total wall-clock: ~14 min (parallel after check gate)
CI Caching Strategies
Swatinem/rust-cache@v2 is the
standard Rust CI cache action. It caches ~/.cargo and target/ between
runs, but large workspaces need tuning:
# Basic (what we use above)
- uses: Swatinem/rust-cache@v2
# Tuned for a large workspace:
- uses: Swatinem/rust-cache@v2
with:
# Separate caches per job — prevents test artifacts bloating build cache
prefix-key: "v1-rust"
key: ${{ matrix.os }}-${{ matrix.target || 'default' }}
# Only save cache on main branch (PRs read but don't write)
save-if: ${{ github.ref == 'refs/heads/main' }}
# Cache Cargo registry + git checkouts + target dir
cache-targets: true
cache-all-crates: true
Cache invalidation gotchas:
| Problem | Fix |
|---|---|
| Cache grows unbounded (>5 GB) | Set prefix-key: "v2-rust" to force fresh cache |
| Different features pollute cache | Use key: ${{ hashFiles('**/Cargo.lock') }} |
| PR cache overwrites main | Set save-if: ${{ github.ref == 'refs/heads/main' }} |
| Cross-compilation targets bloat | Use separate key per target triple |
Sharing cache between jobs:
The check job saves the cache; downstream jobs (test, cross, coverage)
read it. With save-if on main only, PR runs get the benefit of cached
dependencies without writing stale caches.
Measured impact on large-scale workspace: Cold build ~4 min → cached build ~45 sec. The cache action alone saves ~25 min of CI time per pipeline run (across all parallel jobs).
Makefile.toml with cargo-make
cargo-make provides a portable
task runner that works across platforms (unlike make/Makefile):
# Install
cargo install cargo-make
# Makefile.toml — at workspace root
[config]
default_to_workspace = false
# ─── Developer workflows ───
[tasks.dev]
description = "Full local verification (same checks as CI)"
dependencies = ["check", "test", "clippy", "fmt-check"]
[tasks.check]
command = "cargo"
args = ["check", "--workspace", "--all-targets"]
[tasks.test]
command = "cargo"
args = ["test", "--workspace"]
[tasks.clippy]
command = "cargo"
args = ["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]
[tasks.fmt]
command = "cargo"
args = ["fmt", "--all"]
[tasks.fmt-check]
command = "cargo"
args = ["fmt", "--all", "--", "--check"]
# ─── Coverage ───
[tasks.coverage]
description = "Generate HTML coverage report"
install_crate = "cargo-llvm-cov"
command = "cargo"
args = ["llvm-cov", "--workspace", "--html", "--open"]
[tasks.coverage-ci]
description = "Generate LCOV for CI upload"
install_crate = "cargo-llvm-cov"
command = "cargo"
args = ["llvm-cov", "--workspace", "--lcov", "--output-path", "lcov.info"]
# ─── Benchmarks ───
[tasks.bench]
description = "Run all benchmarks"
command = "cargo"
args = ["bench"]
# ─── Cross-compilation ───
[tasks.build-musl]
description = "Build static binary (musl)"
command = "cargo"
args = ["build", "--release", "--target", "x86_64-unknown-linux-musl"]
[tasks.build-arm]
description = "Build for aarch64 (requires cross)"
command = "cross"
args = ["build", "--release", "--target", "aarch64-unknown-linux-gnu"]
[tasks.build-all]
description = "Build for all deployment targets"
dependencies = ["build-musl", "build-arm"]
# ─── Safety verification ───
[tasks.miri]
description = "Run Miri on all tests"
toolchain = "nightly"
command = "cargo"
args = ["miri", "test", "--workspace"]
[tasks.audit]
description = "Check for known vulnerabilities"
install_crate = "cargo-audit"
command = "cargo"
args = ["audit"]
# ─── Release ───
[tasks.release-dry]
description = "Preview what cargo-release would do"
install_crate = "cargo-release"
command = "cargo"
args = ["release", "--workspace", "--dry-run"]
Usage:
# Equivalent of CI pipeline, locally
cargo make dev
# Generate and view coverage
cargo make coverage
# Build for all targets
cargo make build-all
# Run safety checks
cargo make miri
# Check for vulnerabilities
cargo make audit
Pre-Commit Hooks: Custom Scripts and cargo-husky
Catch issues before they reach CI. The recommended approach is a custom git hook — it’s simple, transparent, and has no external dependencies:
#!/bin/sh
# .githooks/pre-commit
set -e
echo "=== Pre-commit checks ==="
# Fast checks first
echo "→ cargo fmt --check"
cargo fmt --all -- --check
echo "→ cargo check"
cargo check --workspace --all-targets
echo "→ cargo clippy"
cargo clippy --workspace --all-targets -- -D warnings
echo "→ cargo test (lib only, fast)"
cargo test --workspace --lib
echo "=== All checks passed ==="
# Install the hook
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
Alternative: cargo-husky (auto-installs hooks via build script):
⚠️ Note:
cargo-huskyhas not been updated since 2022. It still works but is effectively unmaintained. Consider the custom hook approach above for new projects.
cargo install cargo-husky
# Cargo.toml — add to dev-dependencies of root crate
[dev-dependencies]
cargo-husky = { version = "1", default-features = false, features = [
"precommit-hook",
"run-cargo-check",
"run-cargo-clippy",
"run-cargo-fmt",
"run-cargo-test",
] }
Release Workflow: cargo-release and cargo-dist
cargo-release — automates version bumping, tagging, and publishing:
# Install
cargo install cargo-release
# release.toml — at workspace root
[workspace]
consolidate-commits = true
pre-release-commit-message = "chore: release {{version}}"
tag-message = "v{{version}}"
tag-name = "v{{version}}"
# Don't publish internal crates
[[package]]
name = "core_lib"
release = false
[[package]]
name = "diag_framework"
release = false
# Only publish the main binary
[[package]]
name = "diag_tool"
release = true
# Preview release
cargo release patch --dry-run
# Execute release (bumps version, commits, tags, optionally publishes)
cargo release patch --execute
# 0.1.0 → 0.1.1
cargo release minor --execute
# 0.1.1 → 0.2.0
cargo-dist — generates downloadable release binaries for GitHub Releases:
# Install
cargo install cargo-dist
# Initialize (creates CI workflow + metadata)
cargo dist init
# Preview what would be built
cargo dist plan
# Generate the release (usually done by CI on tag push)
cargo dist build
# Cargo.toml additions from `cargo dist init`
[workspace.metadata.dist]
cargo-dist-version = "0.28.0"
ci = "github"
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]
install-path = "CARGO_HOME"
This generates a GitHub Actions workflow that, on tag push:
- Builds the binary for all target platforms
- Creates a GitHub Release with downloadable
.tar.gz/.ziparchives - Generates shell/PowerShell installer scripts
- Publishes to crates.io (if configured)
Try It Yourself — Capstone Exercise
This exercise ties together every chapter. You will build a complete engineering pipeline for a fresh Rust workspace:
-
Create a new workspace with two crates: a library (
core_lib) and a binary (cli). Add abuild.rsthat embeds the git hash and build timestamp usingSOURCE_DATE_EPOCH(ch01). -
Set up cross-compilation for
x86_64-unknown-linux-muslandaarch64-unknown-linux-gnu. Verify both targets build withcargo zigbuildorcross(ch02). -
Add a benchmark using Criterion or Divan for a function in
core_lib. Run it locally and record a baseline (ch03). -
Measure code coverage with
cargo llvm-cov. Set a minimum threshold of 80% and verify it passes (ch04). -
Run
cargo +nightly careful testandcargo miri test. Add a test that exercisesunsafecode if you have any (ch05). -
Configure
cargo-denywith adeny.tomlthat bansopenssland enforces MIT/Apache-2.0 licensing (ch06). -
Optimize the release profile with
lto = "thin",strip = true, andcodegen-units = 1. Measure binary size before/after withcargo bloat(ch07). -
Add
cargo hack --each-featureverification. Create a feature flag for an optional dependency and ensure it compiles alone (ch09). -
Write the GitHub Actions workflow (this chapter) with all 6 stages. Add
Swatinem/rust-cache@v2withsave-iftuning.
Success criteria: Push to GitHub → all CI stages green → cargo dist plan
shows your release targets. You now have a production-grade Rust pipeline.
CI Pipeline Architecture
flowchart LR
subgraph "Stage 1 — Fast Feedback < 2 min"
CHECK["cargo check\ncargo clippy\ncargo fmt"]
end
subgraph "Stage 2 — Tests < 5 min"
TEST["cargo nextest\ncargo test --doc"]
end
subgraph "Stage 3 — Coverage"
COV["cargo llvm-cov\nfail-under 80%"]
end
subgraph "Stage 4 — Security"
SEC["cargo audit\ncargo deny check"]
end
subgraph "Stage 5 — Cross-Build"
CROSS["musl static\naarch64 + x86_64"]
end
subgraph "Stage 6 — Release (tag only)"
REL["cargo dist\nGitHub Release"]
end
CHECK --> TEST --> COV --> SEC --> CROSS --> REL
style CHECK fill:#91e5a3,color:#000
style TEST fill:#91e5a3,color:#000
style COV fill:#e3f2fd,color:#000
style SEC fill:#ffd43b,color:#000
style CROSS fill:#e3f2fd,color:#000
style REL fill:#b39ddb,color:#000
Key Takeaways
- Structure CI as parallel stages: fast checks first, expensive jobs behind gates
Swatinem/rust-cache@v2withsave-if: ${{ github.ref == 'refs/heads/main' }}prevents PR cache thrashing- Run Miri and heavier sanitizers on a nightly
schedule:trigger, not on every push Makefile.toml(cargo make) bundles multi-tool workflows into a single command for local devcargo-distautomates cross-platform release builds — stop writing platform matrix YAML by hand
Tricks from the Trenches 🟡
What you’ll learn:
- Battle-tested patterns that don’t fit neatly into one chapter
- Common pitfalls and their fixes — from CI flake to binary bloat
- Quick-win techniques you can apply to any Rust project today
Cross-references: Every chapter in this book — these tricks cut across all topics
This chapter collects engineering patterns that come up repeatedly in production Rust codebases. Each trick is self-contained — read them in any order.
1. The deny(warnings) Trap
Problem: #![deny(warnings)] in source code breaks builds when Clippy
adds new lints — your code that compiled yesterday fails today.
Fix: Use CARGO_ENCODED_RUSTFLAGS in CI instead of a source-level attribute:
# CI: treat warnings as errors without touching source
env:
CARGO_ENCODED_RUSTFLAGS: "-Dwarnings"
Or use [workspace.lints] for finer control:
# Cargo.toml
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "warn", priority = -1 }
See Compile-Time Tools, Workspace Lints for the full pattern.
2. Compile Once, Test Everywhere
Problem: cargo test recompiles when switching between --lib, --doc,
and --test because they use different profiles.
Fix: Use cargo nextest for unit/integration tests and run doc-tests
separately:
cargo nextest run --workspace # Fast: parallel, cached
cargo test --workspace --doc # Doc-tests (nextest can't run these)
See Compile-Time Tools for
cargo-nextestsetup.
3. Feature Flag Hygiene
Problem: A library crate has default = ["std"] but nobody tests
--no-default-features. One day an embedded user reports it doesn’t compile.
Fix: Add cargo-hack to CI:
- name: Feature matrix
run: |
cargo hack check --each-feature --no-dev-deps
cargo check --no-default-features
cargo check --all-features
See
no_stdand Feature Verification for the full pattern.
4. The Lock File Debate — Commit or Ignore?
Rule of thumb:
| Crate Type | Commit Cargo.lock? | Why |
|---|---|---|
| Binary / application | Yes | Reproducible builds |
| Library | No (.gitignore) | Let downstream choose versions |
| Workspace with both | Yes | Binary wins |
Add a CI check to ensure the lock file stays up-to-date:
- name: Check lock file
run: cargo update --locked # Fails if Cargo.lock is stale
5. Debug Builds with Optimized Dependencies
Problem: Debug builds are painfully slow because dependencies (especially
serde, regex) aren’t optimized.
Fix: Optimize deps in dev profile while keeping your code unoptimized for fast recompilation:
# Cargo.toml
[profile.dev.package."*"]
opt-level = 2 # Optimize all dependencies in dev mode
This slows the first build slightly but makes runtime dramatically faster during development. Particularly impactful for database-backed services and parsers.
See Release Profiles for per-crate profile overrides.
6. CI Cache Thrashing
Problem: Swatinem/rust-cache@v2 saves a new cache on every PR, bloating
storage and slowing restore times.
Fix: Only save cache from main, restore from anywhere:
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
For workspaces with multiple binaries, add a shared-key:
- uses: Swatinem/rust-cache@v2
with:
shared-key: "ci-${{ matrix.target }}"
save-if: ${{ github.ref == 'refs/heads/main' }}
See CI/CD Pipeline for the full workflow.
7. RUSTFLAGS vs CARGO_ENCODED_RUSTFLAGS
Problem: RUSTFLAGS="-Dwarnings" applies to everything — including
build scripts and proc-macros. A warning in serde_derive’s build.rs
fails your CI.
Fix: Use CARGO_ENCODED_RUSTFLAGS which only applies to the top-level
crate:
# BAD — breaks on third-party build script warnings
RUSTFLAGS="-Dwarnings" cargo build
# GOOD — only affects your crate
CARGO_ENCODED_RUSTFLAGS="-Dwarnings" cargo build
# ALSO GOOD — workspace lints (Cargo.toml)
[workspace.lints.rust]
warnings = "deny"
8. Reproducible Builds with SOURCE_DATE_EPOCH
Problem: Embedding chrono::Utc::now() in build.rs makes builds
non-reproducible — every build produces a different binary hash.
Fix: Honor SOURCE_DATE_EPOCH:
#![allow(unused)]
fn main() {
// build.rs
let timestamp = std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or_else(|| chrono::Utc::now().timestamp());
println!("cargo:rustc-env=BUILD_TIMESTAMP={timestamp}");
}
See Build Scripts for the full build.rs patterns.
9. The cargo tree Deduplication Workflow
Problem: cargo tree --duplicates shows 5 versions of syn and 3 of
tokio-util. Compile time is painful.
Fix: Systematic deduplication:
# Step 1: Find duplicates
cargo tree --duplicates
# Step 2: Find who pulls the old version
cargo tree --invert --package [email protected]
# Step 3: Update the culprit
cargo update -p serde_derive # Might pull in syn 2.x
# Step 4: If no update available, pin in [patch]
# [patch.crates-io]
# old-crate = { git = "...", branch = "syn2-migration" }
# Step 5: Verify
cargo tree --duplicates # Should be shorter
See Dependency Management for
cargo-denyand supply chain security.
10. Pre-Push Smoke Test
Problem: You push, CI takes 10 minutes, fails on a formatting issue.
Fix: Run the fast checks locally before push:
# Makefile.toml (cargo-make)
[tasks.pre-push]
description = "Local smoke test before pushing"
script = '''
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --lib
'''
cargo make pre-push # < 30 seconds
git push
Or use a git pre-push hook:
#!/bin/sh
# .git/hooks/pre-push
cargo fmt --all -- --check && cargo clippy --workspace -- -D warnings
See CI/CD Pipeline for
Makefile.tomlpatterns.
🏋️ Exercises
🟢 Exercise 1: Apply Three Tricks
Pick three tricks from this chapter and apply them to an existing Rust project. Which had the biggest impact?
Solution
Typical high-impact combination:
-
[profile.dev.package."*"] opt-level = 2— Immediate improvement in dev-mode runtime (2-10× faster for parsing-heavy code) -
CARGO_ENCODED_RUSTFLAGS— Eliminates false CI failures from third-party warnings -
cargo-hack --each-feature— Usually finds at least one broken feature combination in any project with 3+ features
# Apply trick 5:
echo '[profile.dev.package."*"]' >> Cargo.toml
echo 'opt-level = 2' >> Cargo.toml
# Apply trick 7 in CI:
# Replace RUSTFLAGS with CARGO_ENCODED_RUSTFLAGS
# Apply trick 3:
cargo install cargo-hack
cargo hack check --each-feature --no-dev-deps
🟡 Exercise 2: Deduplicate Your Dependency Tree
Run cargo tree --duplicates on a real project. Eliminate at least one duplicate. Measure compile-time before and after.
Solution
# Before
time cargo build --release 2>&1 | tail -1
cargo tree --duplicates | wc -l # Count duplicate lines
# Find and fix one duplicate
cargo tree --duplicates
cargo tree --invert --package <duplicate-crate>@<old-version>
cargo update -p <parent-crate>
# After
time cargo build --release 2>&1 | tail -1
cargo tree --duplicates | wc -l # Should be fewer
# Typical result: 5-15% compile time reduction per eliminated
# duplicate (especially for heavy crates like syn, tokio)
Key Takeaways
- Use
CARGO_ENCODED_RUSTFLAGSinstead ofRUSTFLAGSto avoid breaking third-party build scripts [profile.dev.package."*"] opt-level = 2is the single highest-impact dev experience trick- Cache tuning (
save-ifon main only) prevents CI cache bloat on active repositories cargo tree --duplicates+cargo updateis a free compile-time win — do it monthly- Run fast checks locally with
cargo make pre-pushto avoid CI round-trip waste
Quick Reference Card
Cheat Sheet: Commands at a Glance
# ─── Build Scripts ───
cargo build # Compiles build.rs first, then crate
cargo build -vv # Verbose — shows build.rs output
# ─── Cross-Compilation ───
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.17
cross build --release --target aarch64-unknown-linux-gnu
# ─── Benchmarking ───
cargo bench # Run all benchmarks
cargo bench -- parse # Run benchmarks matching "parse"
cargo flamegraph -- --args # Generate flamegraph from binary
perf record -g ./target/release/bin # Record perf data
perf report # View perf data interactively
# ─── Coverage ───
cargo llvm-cov --html # HTML report
cargo llvm-cov --lcov --output-path lcov.info
cargo llvm-cov --workspace --fail-under-lines 80
cargo tarpaulin --out Html # Alternative tool
# ─── Safety Verification ───
cargo +nightly miri test # Run tests under Miri
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test
valgrind --leak-check=full ./target/debug/binary
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
# ─── Audit & Supply Chain ───
cargo audit # Known vulnerability scan
cargo audit --deny warnings # Fail CI on any advisory
cargo deny check # License + advisory + ban + source checks
cargo deny list # List all licenses in dep tree
cargo vet # Supply chain trust verification
cargo outdated --workspace # Find outdated dependencies
cargo semver-checks # Detect breaking API changes
cargo geiger # Count unsafe in dependency tree
# ─── Binary Optimization ───
cargo bloat --release --crates # Size contribution per crate
cargo bloat --release -n 20 # 20 largest functions
cargo +nightly udeps --workspace # Find unused dependencies
cargo machete # Fast unused dep detection
cargo expand --lib module::name # See macro expansions
cargo msrv find # Discover minimum Rust version
cargo clippy --fix --workspace --allow-dirty # Auto-fix lint warnings
# ─── Compile-Time Optimization ───
export RUSTC_WRAPPER=sccache # Shared compilation cache
sccache --show-stats # Cache hit statistics
cargo nextest run # Faster test runner
cargo nextest run --retries 2 # Retry flaky tests
# ─── Platform Engineering ───
cargo check --target thumbv7em-none-eabihf # Verify no_std builds
cargo build --target x86_64-pc-windows-gnu # Cross-compile to Windows
cargo xwin build --target x86_64-pc-windows-msvc # MSVC ABI cross-compile
cfg!(target_os = "linux") # Compile-time cfg (evaluates to bool)
# ─── Release ───
cargo release patch --dry-run # Preview release
cargo release patch --execute # Bump, commit, tag, publish
cargo dist plan # Preview distribution artifacts
Decision Table: Which Tool When
| Goal | Tool | When to Use |
|---|---|---|
| Embed git hash / build info | build.rs | Binary needs traceability |
| Compile C code with Rust | cc crate in build.rs | FFI to small C libraries |
| Generate code from schemas | prost-build / tonic-build | Protobuf, gRPC, FlatBuffers |
| Link system library | pkg-config in build.rs | OpenSSL, libpci, systemd |
| Static Linux binary | --target x86_64-unknown-linux-musl | Container/cloud deployment |
| Target old glibc | cargo-zigbuild | RHEL 7, CentOS 7 compatibility |
| ARM server binary | cross or cargo-zigbuild | Graviton/Ampere deployment |
| Statistical benchmarks | Criterion.rs | Performance regression detection |
| Quick perf check | Divan | Development-time profiling |
| Find hot spots | cargo flamegraph / perf | After benchmark identifies slow code |
| Line/branch coverage | cargo-llvm-cov | CI coverage gates, gap analysis |
| Quick coverage check | cargo-tarpaulin | Local development |
| Rust UB detection | Miri | Pure-Rust unsafe code |
| C FFI memory safety | Valgrind memcheck | Mixed Rust/C codebases |
| Data race detection | TSan or Miri | Concurrent unsafe code |
| Buffer overflow detection | ASan | unsafe pointer arithmetic |
| Leak detection | Valgrind or LSan | Long-running services |
| Local CI equivalent | cargo-make | Developer workflow automation |
| Pre-commit checks | cargo-husky or git hooks | Catch issues before push |
| Automated releases | cargo-release + cargo-dist | Version management + distribution |
| Dependency auditing | cargo-audit / cargo-deny | Supply chain security |
| License compliance | cargo-deny (licenses) | Commercial / enterprise projects |
| Supply chain trust | cargo-vet | High-security environments |
| Find outdated deps | cargo-outdated | Scheduled maintenance |
| Detect breaking changes | cargo-semver-checks | Library crate publishing |
| Dependency tree analysis | cargo tree --duplicates | Dedup and trim dep graph |
| Binary size analysis | cargo-bloat | Size-constrained deployments |
| Find unused deps | cargo-udeps / cargo-machete | Trim compile time and size |
| LTO tuning | lto = true or "thin" | Release binary optimization |
| Size-optimized binary | opt-level = "z" + strip = true | Embedded / WASM / containers |
| Unsafe usage audit | cargo-geiger | Security policy enforcement |
| Macro debugging | cargo-expand | Derive / macro_rules debugging |
| Faster linking | mold linker | Developer inner loop |
| Compilation cache | sccache | CI and local build speed |
| Faster tests | cargo-nextest | CI and local test speed |
| MSRV compliance | cargo-msrv | Library publishing |
no_std library | #![no_std] + default-features = false | Embedded, UEFI, WASM |
| Windows cross-compile | cargo-xwin / MinGW | Linux → Windows builds |
| Platform abstraction | #[cfg] + trait pattern | Multi-OS codebases |
| Windows API calls | windows-sys / windows crate | Native Windows functionality |
| End-to-end timing | hyperfine | Whole-binary benchmarks, before/after comparison |
| Property-based testing | proptest | Edge case discovery, parser robustness |
| Snapshot testing | insta | Large structured output verification |
| Coverage-guided fuzzing | cargo-fuzz | Crash discovery in parsers |
| Concurrency model checking | loom | Lock-free data structures, atomic ordering |
| Feature combination testing | cargo-hack | Crates with multiple #[cfg] features |
| Fast UB checks (near-native) | cargo-careful | CI safety gate, lighter than Miri |
| Auto-rebuild on save | cargo-watch | Developer inner loop, tight feedback |
| Workspace documentation | cargo doc + rustdoc | API discovery, onboarding, doc-link CI |
| Reproducible builds | --locked + SOURCE_DATE_EPOCH | Release integrity verification |
| CI cache tuning | Swatinem/rust-cache@v2 | Build time reduction (cold → cached) |
| Workspace lint policy | [workspace.lints] in Cargo.toml | Consistent Clippy/compiler lints across all crates |
| Auto-fix lint warnings | cargo clippy --fix | Automated cleanup of trivial issues |
Further Reading
Generated as a companion reference — a companion to Rust Patterns and Type-Driven Correctness.
Version 1.3 — Added cargo-hack, cargo-careful, cargo-watch, cargo doc, reproducible builds, CI caching strategies, capstone exercise, and chapter dependency diagram for completeness.