C 项目长大以后,通常会出现这些东西:
多个静态库和命令行工具
公共头文件和内部头文件
Makefile 子目录
CONFIG_XXX / #ifdef 开关
Debug/Release 编译选项
目标平台差异
Rust 项目也会遇到同样的问题,只是工具名字不同:
workspace -> 管多个 crate
crate -> 一个库或一个可执行目标的边界
feature -> 可选能力和可选依赖
profile -> debug/release 等构建参数
cfg -> 平台和条件编译
这一篇不讲 Cargo 全部语法,只讲写库、CLI 和 C/Rust 边界时最常用的工程组织方式。
workspace 管多个相关 crate
C 项目里常见目录结构:
project/
libcore/
libffi/
tools/
tests/
Rust 里可以用 workspace:
[workspace]
members = [
"crates/device-core",
"crates/device-ffi",
"tools/device-cli",
]
resolver = "2"
典型结构:
project/
Cargo.toml
crates/
device-core/
Cargo.toml
src/lib.rs
device-ffi/
Cargo.toml
src/lib.rs
tools/
device-cli/
Cargo.toml
src/main.rs
这样做的好处是:
- 多个 crate 共享一个
Cargo.lock - 可以一次性测试整个工程
- 内部库、FFI 包装层、CLI 工具可以分开
- 发布边界更清楚
对 C 开发者来说,可以把 workspace 理解成一个顶层工程;每个 crate 类似一个库、工具或适配层。
crate 边界要按 API 责任拆
不要为了“看起来模块化”过早拆很多 crate。crate 边界比 mod 更重:
mod -> 一个 crate 内部的代码组织
crate -> 编译单元、依赖边界、发布边界
一个实用拆法:
device-core -> 纯 Rust 业务逻辑,尽量无 FFI
device-ffi -> extern "C" 包装、C ABI、头文件生成
device-cli -> 命令行入口、参数解析、输出
device-core 可以被 CLI、测试和 FFI 层复用。device-ffi 只负责把 C ABI 转成 Rust 内部安全 API。
这和 C 里把 core.c、ffi.c、main.c 分开类似,但 Rust crate 边界会更明确地影响依赖和可见性。
workspace dependencies 统一依赖版本
多 crate 工程里,依赖版本分散写容易漂。
可以在 workspace 根部统一:
[workspace.dependencies]
thiserror = "1"
clap = { version = "4", features = ["derive"] }
成员 crate 里引用:
[dependencies]
thiserror.workspace = true
好处是:版本升级集中在一个地方,多个 crate 不会悄悄依赖不同版本。
这类似 C/C++ 项目里顶层统一第三方库版本,而不是每个子目录自己找一套。
feature 表达可选能力
C 里常用宏开关:
#ifdef ENABLE_JSON
/* json support */
#endif
Rust 里通常用 Cargo feature:
[features]
default = ["std"]
std = []
json = ["dep:serde", "dep:serde_json"]
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
serde_json = { version = "1", optional = true }
代码里用:
#[cfg(feature = "json")]
pub fn encode_json(value: &Packet) -> Result<String, serde_json::Error> {
serde_json::to_string(value)
}
调用方启用:
[dependencies]
device-core = { path = "../device-core", features = ["json"] }
feature 适合表达“这个 crate 是否启用某个可选能力”,尤其是可选依赖、协议格式、CLI 子功能、std 支持等。
feature 是累加的,不是互斥配置
Cargo feature 有一个很重要的规则:feature 是累加的。
如果依赖图里一个地方启用了 json,另一个地方没启用,最终这个 crate 通常会以启用 json 的方式构建。
所以不要把 feature 设计成互斥开关:
[features]
backend-a = []
backend-b = []
然后假设只能选一个。依赖合并后,两个可能同时启用。
如果确实需要互斥后端,要在代码里显式检查:
#[cfg(all(feature = "backend-a", feature = "backend-b"))]
compile_error!("features backend-a and backend-b cannot be enabled together");
更好的做法是尽量把 feature 设计成累加能力:
支持 json
支持 tracing
支持 ffi
支持 cli
而不是“选择唯一模式”。
cfg 处理平台差异
平台差异用 cfg:
#[cfg(target_os = "linux")]
fn platform_name() -> &'static str {
"linux"
}
#[cfg(target_os = "windows")]
fn platform_name() -> &'static str {
"windows"
}
也可以组合 feature:
#[cfg(all(target_os = "linux", feature = "udev"))]
mod linux_udev;
对 C 开发者来说,cfg 类似编译条件,但 Rust 更鼓励把平台差异收在小模块里,而不是让 #[cfg] 到处散落。
例如:
platform/
mod.rs
linux.rs
windows.rs
对外暴露同一个 Rust API,内部根据平台选择实现。
profile 管 debug/release 行为
C 里常见 CFLAGS=-O2 -g。Rust 用 profile:
[profile.release]
opt-level = 3
lto = "thin"
debug = "line-tables-only"
strip = "symbols"
panic = "abort"
这些设置影响优化、调试信息、链接时优化、符号和 panic 策略。
不要一开始就把 profile 调得很复杂。常见原则是:
开发阶段保留快编译和调试信息
发布阶段再考虑 LTO、strip、panic=abort、codegen-units
涉及 FFI 和崩溃定位时,不要把调试信息全部丢掉
profile 是交付配置的一部分,后面讲 release 和 binary size 时会再展开。
配置不要都塞进 feature
很多 C 项目会把宏当万能配置系统。Rust 里不要把 Cargo feature 当运行时配置或部署配置。
这些适合 feature:
是否编译 JSON 支持
是否启用 FFI 导出
是否依赖某个系统库
是否启用 std
这些不适合 feature:
设备 IP 地址
日志级别
配置文件路径
运行时超时时间
产品序列号
运行时配置应该来自配置文件、环境变量、命令行参数或上层 API,而不是编译 feature。
一个实用判断:
会改变编译依赖和代码体积 -> 可能是 feature
每次部署或运行会变 -> 不应该是 feature
FFI crate 的 feature 要谨慎
如果一个 crate 导出 C ABI,不要让 feature 随意改变 C 头文件和 ABI。
危险做法:
#[cfg(feature = "extra")]
#[unsafe(no_mangle)]
pub extern "C" fn device_extra() {}
这会导致同一个库名在不同 feature 下导出不同符号。C 调用方如果头文件和实际库 feature 不匹配,就会链接失败或运行失败。
更稳的做法是:
- C ABI 面保持小而稳定
- 可选能力通过版本函数或能力查询暴露
- 头文件和库一起生成、一起发布
- 不同 ABI 变体使用不同库名或明确版本
Rust 内部 feature 可以很灵活;C ABI feature 要保守。
这一篇先记住什么
Rust 工程组织的第一层对应关系:
C 顶层工程 / 多 Makefile -> Cargo workspace
C 子库 / 工具 -> crate
C 头文件可见性 -> pub / pub(crate) / crate 边界
CONFIG_XXX / 可选库 -> Cargo feature
平台宏 -> cfg(target_os, target_arch, ...)
CFLAGS Debug/Release -> Cargo profile
统一第三方版本 -> workspace.dependencies
部署时变化的配置 -> 环境变量、配置文件、CLI 参数,不是 feature
workspace 解决多 crate 组织,feature 解决可选编译能力,profile 解决构建方式。把这三件事分开,Rust 项目会比一堆 #ifdef 和 Makefile 变量更容易维护。
下一篇讲 release、交叉编译和 binary size。构建配置决定“怎么组织能力”,release 配置要进一步回答“最终交付物怎样优化、怎样跑到目标平台、怎样控制体积”。