C 项目交付给别人用时,问题通常不在“源码能不能编译”,而在这些细节:
工具怎么安装到 PATH
库文件和头文件放在哪里
用户该链接哪个版本
升级会不会破坏已有代码
公开 API 改了算不算兼容
二进制 ABI 能不能长期稳定
Rust 也要处理这些问题。只是发布对象分成了两类:
CLI -> 给人直接运行
crate -> 给别的 Rust 项目依赖
C ABI 库 -> 给 C 或其他语言链接
这三类不要混在一个发布模型里看。CLI 关注安装和运行体验;crate 关注源码级 API 兼容;C ABI 库才关注动态链接、符号和 ABI。
CLI 可以用 cargo install 发布
一个 Rust 命令行工具通常是 binary crate:
[package]
name = "device-tool"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "device-tool"
path = "src/main.rs"
本地安装:
cargo install --path .
如果发布到了 crates.io,用户可以安装:
cargo install device-tool
它更接近 C 世界里的:
make
make install
区别是 Cargo 会从源码构建并安装可执行文件,默认放到 Cargo 的 bin 目录。这个目录需要在 PATH 里。
CLI 发布时要关心:
- 命令名是否稳定
- 参数是否向后兼容
- stdout 是否适合脚本解析
- stderr 是否只放诊断信息
- 退出码是否稳定
- 配置文件和环境变量是否有清楚边界
对 CLI 来说,命令行参数、输出格式和退出码就是 API。
crate 发布的是源码级库 API
Rust crate 发布到 crates.io 后,别的项目这样依赖:
[dependencies]
device-core = "0.3"
这不是发布一个 .a 或 .so 给别人链接,而是发布源码包。下游项目会用自己的编译目标、feature、profile 和依赖图重新编译它。
所以 Rust crate 的兼容性主要是源码级 API 兼容:
pub struct DeviceId(String);
pub fn parse_device_id(input: &str) -> Result<DeviceId, ParseError> {
// ...
}
这些东西一旦 pub 出去,就进入兼容性范围:
- public struct、enum、trait、fn
- public module 路径
- 函数参数和返回类型
- trait bound
- enum variant
- feature 名称
- 文档里承诺的行为
C 里头文件暴露什么,用户就会依赖什么。Rust 里 pub 暴露什么,用户也会依赖什么。
Cargo.toml 是发布清单
发布 crate 前,Cargo.toml 需要像一个可读的发布清单:
[package]
name = "device-core"
version = "0.3.0"
edition = "2021"
description = "Device protocol parsing and validation"
license = "MIT OR Apache-2.0"
repository = "https://github.com/example/device-core"
readme = "README.md"
keywords = ["device", "protocol"]
categories = ["command-line-utilities"]
exclude = [
"tests/fixtures/private-*",
]
常见发布检查:
cargo package
cargo publish --dry-run
cargo package 会生成实际要上传的包。不要只看工作目录,要看包里是否包含了该包含的文件,也排除了不该发布的文件。
可以查看打包结果:
cargo package --list
这一步类似 C 项目发布 tarball 前检查:
头文件是否齐全
示例是否可编译
LICENSE 是否存在
私有测试数据是否被排除
生成文件是否不该混进去
版本号表达兼容承诺
Rust crate 通常遵循语义化版本:
MAJOR.MINOR.PATCH
大致含义:
PATCH -> 修 bug,不破坏兼容
MINOR -> 增加兼容能力
MAJOR -> 允许破坏兼容
例如:
0.3.1 -> 0.3.2 通常是补丁
0.3.1 -> 0.4.0 可能增加能力,也可能在 0.x 阶段破坏兼容
1.2.3 -> 1.3.0 增加兼容 API
1.2.3 -> 2.0.0 允许破坏 API
Rust 生态里还要注意 0.x:在 1.0.0 之前,兼容承诺通常更弱。库还没稳定时可以停留在 0.x,但一旦有人依赖,就要认真对待升级影响。
对 C 开发者来说,可以把版本号理解成公开头文件的承诺。改内部实现不需要大版本;删函数、改类型、改语义,就要考虑破坏兼容。
Cargo 依赖版本不是精确锁死
下游写:
device-core = "1.2.3"
通常表示允许兼容版本更新,不是只允许 1.2.3。Cargo 会按兼容规则选择版本,应用项目还会用 Cargo.lock 固定最终解析结果。
库 crate 一般提交 Cargo.toml,但不依赖自己的 Cargo.lock 影响下游。应用和 CLI 则应该保留 Cargo.lock,让交付构建可复现。
一个实用判断:
library crate -> 关注依赖范围是否过窄或过宽
CLI/app -> 关注 Cargo.lock 是否固定最终版本
workspace -> 关注多个 crate 是否共享同一套依赖约束
这和 C 项目也类似:库的 pkg-config 约束不能乱写,应用交付时则要知道最终链接了哪些版本。
feature 也是公开接口
feature 名称发布后,也会被用户写进依赖配置:
[dependencies]
device-core = { version = "1", features = ["serde"] }
所以 feature 不能随意改名或删除。它们和函数名一样,会变成外部依赖。
好的 feature 通常表达可选能力:
[features]
default = []
serde = ["dep:serde"]
std = []
差的 feature 容易变成全局宏开关:
[features]
fast = []
new-mode = []
use-v2 = []
如果一个 feature 改变了核心语义,下游很难判断自己到底依赖了哪个库。feature 更适合表达“加不加某个能力”,不适合表达“同一个 API 换一种含义”。
C ABI 库不要承诺 Rust ABI
如果发布对象是给 C 调用的库,版本问题又回到 ABI:
#[repr(C)]
pub struct DeviceInfo {
version: u32,
flags: u32,
}
#[no_mangle]
pub extern "C" fn device_open(path: *const c_char) -> *mut DeviceHandle {
// ...
}
这里要稳定的是:
- 导出的符号名
extern "C"函数签名#[repr(C)]类型布局- 结构体大小和字段顺序
- 内存归属规则
- 错误码和错误消息获取方式
不要把 Rust crate 的源码级 API 兼容,误认为 C ABI 兼容。Rust 普通函数、trait、泛型和 enum 都不是稳定 C ABI。
更稳妥的做法是分层:
device-core -> Rust 内部库,按 Rust API 演进
device-ffi -> C ABI 包装层,少暴露类型,稳定符号
device-cli -> 命令行工具,稳定参数和输出
这样发布边界清楚,版本策略也清楚。
发布前检查清单
CLI 发布前检查:
cargo build --release
cargo install --path .
device-tool --help
device-tool --version
crate 发布前检查:
cargo test
cargo package
cargo publish --dry-run
库 API 检查:
pub 暴露是否过多
错误类型是否适合下游处理
feature 是否稳定
README 示例是否能编译
版本号是否匹配兼容性变化
C ABI 库检查:
头文件是否和导出符号一致
结构体是否 repr(C)
内存释放函数是否成对提供
错误码是否稳定
动态库 soname 或包版本是否符合发布策略
Rust 降低了构建和发布的摩擦,但没有替你决定兼容承诺。发布出去的东西,一旦被脚本、Cargo.toml、C 头文件或动态链接器依赖,就已经是 API。