Cargo.toml 详解

本页目录

Cargo.toml 是包的构建契约:它声明要生成哪些 target、从哪里获取依赖、启用哪些可选能力,以及用什么构建配置。Cargo.lock 则记录依赖解析结果;两者不能互相替代。

一个 package 可以含一个库 target 和多个二进制、测试或示例 target。crate 是 Rust 的编译单元,不应把 package、crate 和 workspace 当作三个同义词。

Cargo.toml声明依赖来源、版本范围、feature 与目标1Cargo 解析依赖图合并适用 feature,选择满足约束的包版本2Cargo.lock记录这次解析使用的版本、来源和校验信息3编译与链接还依赖工具链、目标、原生库和构建脚本输入4锁文件固定依赖选择,不自动锁定整个操作系统与工具链。构建可重复性:约束、解析结果与环境缺一不可

版本字符串通常是范围

写法允许的常见正式版本范围
"1.2.3" 或 "^1.2.3">=1.2.3 且 <2.0.0
"=1.2.3"精确 1.2.3
"~1.2.3">=1.2.3 且 <1.3.0
"0.2.3">=0.2.3 且 <0.3.0
"0.0.3">=0.0.3 且 <0.0.4

因此 "1.2.3" 不表示固定版本。预发布版本还有额外匹配规则,不要把这张正式版本表直接套到所有 -alpha/-beta 情况。依赖版本语法

版本约束符合 SemVer 范围,也不意味着 Cargo 会检查上游每个 API 是否真正兼容。若锁文件中的已有版本仍满足约束,普通构建一般继续使用它;更新依赖应显式检查解析变化,而不是删除锁文件“碰运气”。

Cargo.lock 可以帮助应用和库仓库重复自己的 CI 结果。当前 Cargo 指南建议不确定时提交锁文件;“所有库一律不提交”已经不是合适的通则。但下游使用一个库时由下游依赖图解析,不受该库仓库自己的 lock 强制限制。manifest 与 lock

版本、来源与导入名称是三个维度

[dependencies]
json_codec = { package = "serde_json", version = "1" }

这里依赖的包仍是 serde_json,Rust 代码却使用 json_codec 路径。package 字段定位包名,表中的键决定依赖在当前代码中的名字;这也是导出宏和过程宏不能随意写死使用方依赖名的原因。

依赖来源解析时固定什么还要维护什么
registry + version 范围满足要求的发布版本lock 中实际选择与校验信息
git + rev指定仓库提交可获取该提交的来源与构建环境
git + branch分支要求,lock 记录解析到的提交分支后续移动不等于现有 lock 自动更新
path本地包位置与清单本地源文件内容,lock 不为它保存内容快照

path 与 version 可以组合:本地开发使用路径,同时校验本地包版本满足约束,并为发布到 registry 后的依赖关系提供版本要求。它不是“本地构建随时自动回退到 registry”的通用机制。修改路径包源文件后即使 Cargo.lock 没变,产物也可能变化。多来源依赖

依赖种类决定在哪一侧可用

配置主要使用位置
dependencies普通库/程序代码
dev-dependencies测试、示例、benchmark 等开发 target
build-dependencies在宿主机运行的 build.rs
target.'cfg(...)'.dependencies满足目标条件时的普通依赖

同一个依赖出现在 build 与普通依赖中,可能需要分别编译给宿主与目标。不能因为名字相同就假定它们共享所有 feature 或构建产物。目标条件也不应用 feature 来代替 Cargo 可选依赖的机制。

feature 是可组合能力

下面是一个完整小项目的 Cargo.toml:

[package]
name = "feature-demo"
version = "0.1.0"
edition = "2024"

[features]
default = []
json = ["dep:serde_json"]

[dependencies]
serde_json = { version = "1", optional = true }

src/main.rs:

#[cfg(feature = "json")]
fn output() -> String {
    serde_json::json!({"ready": true}).to_string()
}

#[cfg(not(feature = "json"))]
fn output() -> String {
    "ready=true".into()
}

fn main() { println!("{}", output()); }
cargo run --no-default-features
cargo run --features json
cargo tree -e features --features json

dep:serde_json 显式激活可选依赖,避免仅为依赖名字自动暴露一个同名 feature。feature 的加法性首先是依赖解析和 API 设计原则:同一包的适用请求通常合并为并集。它不表示 cfg(not(feature=...)) 在语法上禁止删除某段代码。Cargo Features

依赖 A 关闭默认 feature,不代表依赖 B 无法把同一个包的默认 feature 再打开。公共库的功能尽量可组合;互斥 feature 虽可用 compile_error! 检查,却可能使正常的依赖合并无法使用这个库。resolver 的版本及构建上下文决定哪些 feature 集合分开解析,不能泛化成“整个工作区无条件全部合并”。

依赖方 Adefault-features = false依赖方 B启用 default,default 包含 std同一适用解析上下文中的 shared最终 feature 并集仍包含 default 和 std关闭自己这条边的默认功能,不会否决另一条依赖边图示针对同一个包实例;resolver 会按版本、依赖类型和目标等规则区分上下文。

沿依赖边追踪“是谁打开了它”

如果构建中出现意外的 std 或某个后端,不应只检查当前包的 default-features。先用 cargo tree -e features -i 包名 查看反向来源,再确认是否存在另一个正常依赖、开发 target 或构建侧上下文提出要求。

--no-default-features 也不是“整张依赖图都禁用默认功能”。依赖各自的默认功能仍由对应声明与合并规则决定。要支持真正的最小构建,需要用最小 feature 组合单独编译,而不能仅跑一次 --all-features 就断言覆盖全部配置。

谁把默认 feature 又打开了?

对同一依赖的两条适用请求做并集,就能解释“我已经关闭 default,为什么还有 std”。先保留 B 的默认请求,只开关 A;再关闭 B,观察 std 才消失。实际排查还需要先确认依赖版本与 resolver 的构建上下文,再沿 cargo tree 的反向 feature 边找来源。

正在呈现知识画面
谁把默认 feature 又打开了?

A 关闭默认 feature 不会否决 B 的启用请求;适用的请求合并为并集。

profile 控制取舍,不提供最优解保证

dev 默认偏向调试和增量构建,release 默认 opt-level=3。当前默认 release 的 lto=false,并非显式跨 crate 的 lto="thin";false 在部分条件下仍允许本 crate 的局部 ThinLTO,和 off 也不同。Profiles

如果要比较另一组优化,建立独立 profile 更易复现:

[profile.release-lto]
inherits = "release"
lto = "thin"
codegen-units = 1
debug = 1

运行 cargo build --profile release-lto。减少 codegen-units 或开启 LTO 可能增加优化机会,也可能增加时间与内存,未必同时得到最小且最快的产物。s/z 优化大小也应测量,不能把 opt-level=3 当成体积最小选项。

panic="abort" 改变异常处理语义,catch_unwind 无法再捕获这种 panic;它不是只影响体积的无害开关。debug-assertions、overflow-checks 也会影响检查行为,使用者应知道自己的测试和发布配置有何不同。

build.rs 是构建输入到输出的程序

脚本在宿主运行,编译目标可能是另一平台。查询目标应读 TARGET 或 CARGO_CFG_*,不要在 build.rs 里用 cfg!(target_os=...) 判断交叉目标,那看到的是脚本自身的宿主配置。

输出文件放在 OUT_DIR,并通过 rerun-if-changed、rerun-if-env-changed 声明所依赖的文件与环境。OUT_DIR 可能在重建间保留,脚本不应假定每次都是空目录。脚本本身或构建依赖变化也会触发重建,并不是输出一行 rerun 指令就只剩一个触发条件。Build Scripts

编译 C、生成绑定的完整例子见 bindgen。build.rs 不能在已经解析完依赖图后,通过 rustc-cfg 给自己凭空增加新的 Cargo 依赖;自定义 cfg 与 Cargo feature 是两个机制。

一个能看到重新生成结果的 build.rs

在前面的 feature-demo 项目根添加 label.txt,内容为 service-a。再添加下面的 build.rs;Cargo 会自动识别这个默认文件名。脚本读取配置,将其编码为 Rust 字符串字面量写进 OUT_DIR。

use std::{env, fs, path::PathBuf};
fn main() {
    println!("cargo::rerun-if-changed=label.txt");
    let label = fs::read_to_string("label.txt").expect("read label.txt");
    let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
    fs::write(output.join("build_info.rs"),
              format!("pub const LABEL: &str = {:?};\n", label.trim()))
        .expect("write build_info.rs");
}

此步骤把 src/main.rs 替换为:

include!(concat!(env!("OUT_DIR"), "/build_info.rs"));
fn main() {
    assert!(!LABEL.is_empty());
    println!("{LABEL}");
}

第一次 cargo run 打印 service-a;将 label.txt 改成 service-b 后再次运行,应重新生成并打印 service-b。这里 {:?} 用于把字符串编码成带引号和必要转义的 Rust 字面量;不能直接把未经转义的外部文本拼进源码。trim 是本示例的配置策略,会去掉首尾空白,如果标签允许这些字符就应采用别的解析方式。

rerun-if-changed 声明该文件变化需要重跑脚本,OUT_DIR 则提供当前构建实例的生成目录。不要在另一个工作区成员中手拼 target/debug/build 下的散列目录名;目标、profile 或 feature 上下文变化都会让这种路径假设脆弱。

这个例子不将 label 作为 Cargo feature,也不改变依赖图。输入若缺失,expect 使构建失败而不是默默生成默认内容;真实工具可按需求输出更有帮助的构建诊断。

检查配置有没有实现预期

cargo metadata 展示包和 target,cargo tree -e features 解释 feature 来源,cargo tree -d 帮助找重复版本。cargo build --locked 拒绝需要修改锁文件的解析变化,但不禁止联网;--offline 限制网络,可能因本地索引和缓存不足而失败。

需要可复现环境时,还要记录 rust-toolchain、目标、原生依赖和构建脚本外部输入。锁住 crate 版本只是其中一步。

把“锁定、离线、可复现”分开验证

选项约束常见失败解释
--locked不允许修改锁文件清单与现有解析不一致,或尚无所需 lock
--offline不访问网络本地索引或源码缓存不足,也可能改变可选版本集合
--frozen同时要求 locked 和 offline既不能重新解析写 lock,也不能补取依赖

相同 lock 加相同源码还不保证字节级相同产物:工具链、原生编译器、目标配置、环境路径和构建脚本输入都可能影响输出。需要追求可复现构建时,应明确比较的是依赖版本、测试结果、程序行为还是最终产物哈希。

用最小构建矩阵发现被掩盖的问题

对上面的 feature-demo,至少分别检查默认/无默认功能与 json 功能。对公开库,还应检查声明的最低 Rust 版本、受支持目标和必要的开发 target;只在最新工具链上构建一次,不能证明 rust-version 声明成立。

cargo check 通常足以较快发现类型和配置错误,但并不替代最终链接与运行;原生库、导出符号或运行时初始化的问题可能更晚才出现。按修改内容选择验证阶段,避免把“清单能解析”当作“所有配置都能交付”。