跳转到主内容workspace、feature 和构建配置 | 物联网民工

workspace、feature 和构建配置

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 配置要进一步回答“最终交付物怎样优化、怎样跑到目标平台、怎样控制体积”。