原文:https://kornel.ski/rust-sys-crate
本文已在twitter上获得作者的翻译授权
翻译:Jonir Rings
校对:Cyus, HuangJJ
译者注:本文翻译按照日常风格,中英夹杂,可能没有翻译腔那么准确。
在 Rust 圈子里面,*-sys
是一种 crates 命名惯例 ,主要出现于 Rust 程序调用 C 语言(系统)库的场景,譬如 libz-sys、 kernel32-sys、lcms2-sys 等。
sys crate 的主要任务就是把 low-level 的 C 接口,最小化地暴露给 Rust (FFI) 方便 Cargo 做库链接。
而添加更加 high-level、更加 Rust 友好的接口则由 sys crate 的更高一级,wrapper crate 来做。
打个比方:rusty-image-app 依赖一个 high-level 的 png-rs,png-rs 依赖 low-level 的 libpng-sys,libpng-sys 依赖于系统的 libz-sys。这种套娃模式,就是咱们说的这种“惯例”。
想要用通用性比较好的方式调用 C 库会有点麻烦,需要做一点小小的工作:
这里面每一步都很棘手,因为操作系统、包管理器、库本身都有自己的癖好,需要特殊处理。
好在 Rust 这边提供了 build script,可以一把梭搞定这些,然后发布为 -sys
的 Rust crate。这样别的 Rust 程序员就不必为了使用 C 库而重新发明 build script 了。
如何做一个 sys crate:
cargo new --lib <library name here>-sys
。Cargo.toml
添加 links = <library name>
。这会告诉 Cargo,当前 crate 需要和某某 C 库链接,Cargo 会保证只会链接一个副本。并且 name 不可以带任何前缀或后缀(例如应是 flrop 而不是 libflorp.so)。另外需要注意的是,这个 link 只是纯通知性,实际并没有链接到任何东西。build.rs
文件,可以放在项目根目录或者由 Cargo.toml
文件中的 build = "<path to build.rs>"
指定。对于 build.rs
脚本,通常会:
别在 Cargo 的专用输出目录 (OUT_DIR
)之外写文件。需要强调的是,也别尝试在系统上安装什么包。如果所需的类库或者依赖找不到:使用错误报告,或者 cargo:warning
,并回退到其他方式。
避免下载任何东西。因为存在打包和部署工具需要在隔离容器内离线运行的情况。如果你想要从源码编译成类库,可以把源码和 Cargo crate 一起打包。
Cargo 的构建过程应该是自我完备的且可离线工作。
pkg-config
是优先尝试的最佳选项。另外 Rust 生态还有一个 pkg_config
crate 用于处理这种情况。pkg-config
也能用,只是有点小毛病。用 pkg-config
动态链接的可执行文件可能不能正常分发。在没有安装 Homebrew 和 相同版本类库的其他设备上会报错崩溃。所以当你使用 pkg-config
的时候,默认静态链接吧。pkg-config
也不能正常工作。你得用最硬核的方式搜索(譬如:clang-sys 搜索 C:\Program Files\LLVM
)。最好是再提供一个从源码编译的备选方案 (案例),以提供一个无后顾之忧的 crate。另外需要注意,Windows 上有两套微微不兼容的工具链:(Visual Studio, 原生) 和 gnu
(MinGW, 像是Linux 上的 Wine);通过 CARGO_CFG_TARGET_ENV
检测他们,没事别混用。最后通过 <LIBRARY_NAME>_PATH
或者 <LIBRARY_NAME>_LIB_DIR
环境变量覆写类库位置(案例)。某些情况下这是必须的,譬如交叉编译,或者自行构建了类库(例如:启用了自定义特性,或者装在 /lib
的类库都快6岁了)。
当你确定了对应的目录,打印 cargo:rustc-link-search=native=<dir>
并告知 Cargo 去使用。如果你使用了像是 pkg-config
的辅助 crate,他们会帮你把这些做了。
你得选择如何链接类库,打印出 cargo:rustc-link-lib=<name>
或者 cargo:rustc-link-lib=static=<name>
。因为大多数用户根本不会配置你的 crate (你的 crate 可能是一个依赖的依赖的依赖……),你必须要有一个充分安全的默认选项:
musl
目标,默认一切都是静态链接,因为它主要用于制作自我完备的 Linux 可执行文件。cargo:rustc-link-lib=framework=…
和 ObjC frameworks 动态链接,注意 pkg-config
有一个 .statik()
选项,但通常啥也干不了。你可能得检查(Linux: ldd
, macOS: otool -L
, Windows: dumpbin /dependents
)并规避它。
对于 Cargo.tmol 配置文件本身,有两个选项,都有点小问题:
在 Cargo.toml
中,你可以设置 [features]
区,使用 static
和 dynamic
选项。
[features]
static = []
dynamic = []
他们俩谁都别放进 default
feature 里面,因为unset Cargo的默认值太难了。
正方: features 方便其他 crates 配置。甚至可能完全通过 Cargo.toml
完全配置构建过程。
反方: Cargo features只能被 set,不能 unset。一旦 crate 配置上了某个 feature,就难以覆写了。而且Cargo也不支持互斥的 features,所以你的 build.rs
需要同时处理 static
和 dynamic
。
你可以检查 <LIBRARY_NAME>_STATIC
环境变量,以确定是否需要静态链接。
正方: 顶层项目可以轻易覆写链接规则,即便 sys crate 嵌套得很深。
反方: Cargo 没法管理环境变量,因此 Cargo 之外还得用对应的 build 脚本/工具。
理想情况下,你可以同时支持 Cargo features 和环境变量,其中环境变量优先于 Cargo features。这使得可以在简单场景下使用 features,环境变量则作为 features 无效情况下的备用方案。
如果对应的类库并非目标系统默认自带,特别是你要支持 Windows 的时候,最好自动从源码编译(且静态链接)。
这对可用性是极大的提升,因为用户在跑了 cargo build
之后,总能获得能运行的东西,而非报错:包查找失败、需要安装依赖、需要设置搜索路等等。
代码下载就有点棘手了,最好的办法就是避免下载。如果要进行下载,build 脚本可能需要依赖一些 HTTP+TLS+解包 的类库,这玩意可能比我们直接把源码一把梭更大,进行 sys crate 编译也更容易出错;且部分用户可能要求离线可用。所以,除非源代码超级大,不然直接把他们源代码拷贝进 Rust crate 吧(当然你还得保证授权是兼容的 license,可以和你的 sys crate 一同分发)。
为了避免别人的代码在你 crate 的 git 仓库中再重复一份(译者注:跟随上游同步会造成大量非 Rust 文件变更),你可以把 C 代码添加为 git submodule。当发布到 crates.io 时,cargo 会把它们当成普通文件夹一样自动复制,所以你 crate 的用户甚至不会察觉这个源代码是一个 submodule。
git submodule add https://example/third-party.git vendor/
开发时候的,你可以用 cargo build -vv
来观察 build 脚本的输出。你可以打印 cargo:warning=…
使得消息对用户可见。最好把你的 crate 名称包含 warning 和 error 中,因为你的 crate 最终很可能被淹没在别人项目中的几层依赖之下。
对于构建过程,你有两个选项:
你得假设所需的构建系统已经安装(譬如 make
、 autotools
、 cmake
),然后就能 run 它了。目前有 cmake 和 make-cmd 这类工具,也许可以提供一些小小的帮助。
你可能需要将 Cargo的环境变量 翻译成合适的构建系统选项(譬如libgit2, libcurl)来控制 输出目录、优化等级、调试符号 以及启用 -fPIC
(Rust 得要 -fPIC
做链接)。
autotools
(./configure
)支持 "out-of-tree builds",所以建议将其的输出目录导向到 OUT_DIR
的子目录中,这样 cargo clean
也能清理 C 构建的临时文件了。
正方: 你只需要照着文档走,就能编译类库,而不用过于深入其中。
反方: 如果编译失败,这些额外的间接过程,可能使得更难诊断和修复。在Windows下可能超级难搞、十分痛苦。
替换构建系统看上去就是自找麻烦,会引入大量的维护工作。然而,对于许多类库而言,构建系统的复杂性主要在于处理不同的操作系统和破碎的编译器问题(cc
crate 就是干这个活的),以及查找类库自身的依赖(其他的 *-sys
crate则来解决这种问题)。
实践中表明,给 cc
crate 一个 .c
文件列表,以及三两个 define()
,就足矣正确构建代码了。顺便尝试运行一下 make --dry-run VERBOSE=1
来检查所需的文件和宏macro吧。
如果你需要针对类库做配置用的 config.h
文件 ,不要在源代码目录里面改。取而代之,将配置用的 config 头文件输出到 OUT_DIR
并将输出目录设置到 include 路径中。
正方: cc
crate 能处理与 Cargo 的集成,甚至是交叉编译。它同样能处理你根本不想做的事,譬如读取 Windows 注册表以查找可用的 Visual Studio 副本。
反方: 这个方式获取适合中小型或者成熟的项目,但是对于快速迭代推进的项目则有点艰巨。
不幸的是,Cargo crate(以及 crates.io)并不适合二进制分发。请使用其他的包管理器譬如(apt/RPM, chocolatey)来分发预编译的共享库(cdylib
)吧, 然后 sys crate 就只能指望这些闭源类库已经预安装了。
C 类库通常使用 #define FOO_SUPPORTED
来做特性的启用禁用。一个好主意是将这些操作转换为 Cargo features。如果 C 类库有些默认启用的特性,那么 Cargo 的默认 features 也用一样的节奏来安排。
[features]
default = ["foo"]
foo = []
bar = []
if cfg!(feature = "foo") {
cc.define("FOO_SUPPORTED", Some("1"));
}
if cfg!(feature = "bar") {
cc.define("BAR_SUPPORTED", Some("1"));
}
有两个地方需要你暴露 C 类库的头文件(.h
):
-sys
crate 中要用 C 代码(可选)。第一个情况十分简单。保证你有在 Cargo.toml
中标明 links
即可:
[package]
name = "foobar-sys"
links = "foobar"
使用 cargo:include=/path/to/include
和类库自身 .h
所在的目录来打印附加信息(cargo:include
并不是一个特殊的名字,你可以使用任何 cargo:=
来提供附加信息)。使用 join_paths
/split_paths
来列举多个目录:
println!("cargo:include={}", absolute_path_to_include_dir.display());
如果你要改相对路径为绝对路径,使用 dunce::canonicalize()
,因为 fs::canonicalize()
目前不可用。
在你的 crate 文档中,如果用户需要这个头文件,提示他们去读取 DEP_<your links name>_INCLUDE
环境变量(例如 libz → libpng):
cc.include(env::var_os("DEP_FOOBAR_INCLUDE").expect("Oops, DEP_FOOBAR_INCLUDE should have been set"));
对于 Rust,你需要转译 C 头文件为包含 extern "C" {}
声明的 Rust 模块。这个可以用 bindgen 自动化处理,但依然有一些细节需要考虑。
Bindgen 有个选项是将 C enum
转译成 Rust enum
。虽然有 enum 是挺美妙的,但是 Rust 这一侧有一些额外要求:enum 必须总是包含有效值!这是 safe Rust 的一个保证。如果 C 一侧违反了这个规则,就会“毒害”Rust代码并崩溃。如果声明为 enum Foo {A=1, B=2}
,但是 C 的某些地方返回 (enum Foo)3
,则无法作为 Rust enum。
如果 C 头文件使用了内联函数,你可以使用 Citrus 来转译函数体。包含了代码和 C++ 模板的宏,就只能后端转译了(例如:macro → fn),或者封装在你 crate 的 C 函数内,并编译成一个私有静态类库。Bindgen 支持一套 C++ 子集,但你还得写一个 C 封装用来处理 C++ 类(案例)。
有个问题:是否你只会运行一次 bindgen 然后直接分发生成的 binding.rs 文件,或者每次项目构建的时候都要运行 bindgen。这取决于对应 C 库的跨版本兼容性有多差。
如果对应 C 类库有一个稳定的、高可移植性的 ABI:新版本只会添加新函数,所有一切都是向下兼容,你就能预生成 binding.rs 文件。这样构建起来也更快(没有bindgen依赖,也没有clang),你甚至能手动微调生成的文件。当然你还得保证能同时兼容32位和64位(通常 #[repr(C)]
就够了,但是你得禁用 bindgen 内存布局测试生成,因为这玩意是架构限定的)。
如果生态中有各种不兼容版本的类库,你就得将 bindgen 作为类库,然后在 build.rs
运行它,给每个用户生成新鲜的 binding.rs 绑定文件。生成的文件必须在你crate的 lib.rs
中的某处使用 include!(concat!(env!("OUT_DIR"),"/filename.rs"));
引入。
如果 C 类库不同主版本号之间的差异较小(例如:只有新增了一些函数,或者几个 struct 结构体的字段变更),你可以尝试直接适配这个版本,或者使用 Cargo features 来启用新特性(例如:mozjpeg-sys 支持不同的 ABI 版本, clang-sys 有 features 对应 LLVM 版本)。
如果 C 类库的版本之间差异巨大到完全不兼容,那只能使用单独的 crate(foo1-sys
和 foo2-sys
),或者使用不同的 sys crate 主版本对应不同的 C 类库主版本,这样 Cargo 就能知道他们是不兼容的。
Rust 能为当前平台之外的系统构建执行文件和类库,例如:在 macOS 上构建程序,或者在64位系统上构建32位类库。
你的 build.rs
程序可能跑在编译环境之外的平台上。这意味着你 build.rs
中所有的 size_of
检查和 #[cfg]
/cfg!()
macro 宏可能对应了错误的机器!如果要查询目标系统或者 CPU,可使用 CARGO_CFG_TARGET_OS
/CARGO_CFG_TARGET_ARCH
/CARGO_CFG_TARGET_POINTER_WIDTH
等环境变量(运行 rustc --print cfg
可获取完成环境变量列表)。唯一的例外是 cfg(feature = "…")
检查,这是 Cargo 内建功能,可以在交叉编译时安全使用。
pkg-config
在检测到交叉编译时会能自动辅助(环境变量中 HOST
!= TARGET
)。如果你用其他法子在磁盘上搜索类库,同样得注意主机系统可能和编译目标并不兼容。
尽量在你 sys crate 的 lib.rs
中多编写针对 C 符号的测试吧。链接器通常很”懒惰“,意味着它检测不到类库的啥问题,但当你在 Rust 中实际用它时则“惊喜”不断。
在外部测试(tests/
目录)和其他 crate 中,请确保通过 extern crate <your lib>_sys;
引用。C 类库仅在 extern crate
时候才会被链接,即便是它被设置为 Cargo.toml
中的 dependency
依赖。
做一份好文档(同时 Cargo.toml
标注好 readme
键),陈述清楚有啥需求和配置项(特别是环境变量)。
不过,就别去烦心地去单独标注 Rust 中的 FFI 了。从定义上讲,sys crate 不会更改 C 类库的行为,也不会添加 C 版本中不存在的功能,因此对于函数特定的文档信息,直接引导用户去原始的 C 文档即可(例如:libc 这个 crate 就没标注任何函数文档)。如果你想让 crate 更易于使用,最好再出点力做第二个 crate,封装一些 higher-level 接口。
没人指望你能7x24(永远)地支持你的 crate。时不时地,crate 作者可能就不再能够提供支持,但是 crate 依然需要升级(例如:紧急地安全/兼容修复),这是用户的一大痛点。
当你把 crate 发布到 crate.io 后,可以要考虑邀请谁来作为 crate 的共同所有者。在 crate 的页面添加“管理所有者”,或者你可以添加你的 GitHub 团队。如果你找不到人,不妨来找我(kornelski)。
感谢 Michael Bryan、 Mark Summerfield 和其他 Rust 爱好者的反馈。
译者注:感谢 Cyus 和 HuangJJ 校对。