跳转到主内容静态库、动态库和 ABI | 物联网民工

静态库、动态库和 ABI

前面几篇讲的是函数级 FFI:extern "C"、字符串、内存归属、回调和 void *ctx。

真正交付给 C 项目时,还要回答另一个问题:

C 工程到底链接什么文件?
头文件声明哪些符号?
这些符号的 ABI 能不能长期稳定?
升级 Rust 代码后,C 侧要不要改?

C 开发者熟悉 .a、.so、.h 和 ABI。Rust 交付给 C 使用时,也要回到这些概念,而不是把 Cargo 内部模型暴露给 C。

C 调用方看到的是库文件和头文件

Rust crate 内部可以有模块、泛型、trait、Result、String、Vec。

C 调用方不应该看到这些。C 侧应该看到:

libdevice.a 或 libdevice.so
device.h
链接参数
运行时库搜索路径
版本和 ABI 说明

头文件里声明 C ABI 函数:

#include <stddef.h>
#include <stdint.h>

typedef enum {
    DEVICE_OK = 0,
    DEVICE_NULL = 1,
    DEVICE_FAILED = 2,
} device_status_t;

device_status_t device_checksum(const uint8_t *data, size_t len, uint16_t *out);

Rust 侧导出对应符号:

#[repr(C)]
pub enum DeviceStatus {
    Ok = 0,
    Null = 1,
    Failed = 2,
}

#[unsafe(no_mangle)]
pub extern "C" fn device_checksum(
    data: *const u8,
    len: usize,
    out: *mut u16,
) -> DeviceStatus {
    if data.is_null() || out.is_null() {
        return DeviceStatus::Null;
    }

    let data = unsafe { std::slice::from_raw_parts(data, len) };
    let value = data.iter().map(|b| *b as u16).sum();

    unsafe {
        *out = value;
    }

    DeviceStatus::Ok
}

这就是边界:C 看到稳定 ABI,Rust 内部继续保持 Rust 风格。

staticlib:把 Rust 打成静态库

如果要给 C 链接静态库,在 Cargo.toml 里设置:

[lib]
crate-type = ["staticlib"]

构建后会得到类似:

target/release/libdevice.a

C 侧链接时把它当普通静态库:

cc main.c -L target/release -ldevice -o app

静态库的特点是:

链接进最终可执行文件
部署时少一个运行时 .so
升级库通常要重新链接应用
产物可能更大
还要处理 Rust 依赖的系统库链接

staticlib 适合嵌入到已有 C 程序、交付单个二进制、或部署环境不方便放动态库的场景。

cdylib:生成 C 能加载的动态库

如果要生成 .so、.dylib 或 .dll,用 cdylib:

[lib]
crate-type = ["cdylib"]

构建后可能得到:

target/release/libdevice.so

C 侧链接:

cc main.c -L target/release -ldevice -o app

运行时还要让动态链接器找到 .so:

LD_LIBRARY_PATH=target/release ./app

动态库的特点是:

应用运行时加载库文件
可以独立升级库,但 ABI 必须兼容
需要处理库搜索路径、rpath、安装位置
对符号导出和版本管理更敏感

给 C 项目交付 Rust 动态库时,cdylib 比 Rust 自己的 dylib 更合适。dylib 面向 Rust 编译器生态,不是给 C 当稳定 ABI 的默认选择。

不要把 Rust ABI 当交付契约

Rust 普通函数、trait、泛型、String、Vec、Result 都不是 C ABI 契约。

下面这种不是 C 能稳定调用的接口:

pub fn parse(data: &[u8]) -> Result<Vec<u8>, ParseError> {
    // ...
    Ok(Vec::new())
}

给 C 的边界应该是:

#[unsafe(no_mangle)]
pub extern "C" fn device_parse(
    data: *const u8,
    len: usize,
    out: *mut DeviceBuffer,
) -> DeviceStatus {
    // boundary checks, conversion, error mapping
    DeviceStatus::Ok
}

第一层规则:

Rust 内部 API 可以变
C ABI 包装层要稳定

如果每次重构 Rust 内部模块都影响 C 头文件,那说明边界放错了位置。

符号导出要可控

C 链接器找的是符号名。Rust 默认会改写符号名,所以导出 C 函数时要明确:

#[unsafe(no_mangle)]
pub extern "C" fn device_version() -> u32 {
    1
}

这会导出 device_version 这个符号,而不是 Rust mangled name。

但不要把所有内部函数都 no_mangle pub extern "C"。导出符号越多,ABI 承诺越大。更稳的做法是只导出一小组 C API:

device_create
device_destroy
device_read
device_write
device_last_error

内部 Rust 函数保持普通 fn 或 pub(crate),不要成为 C ABI 的一部分。

如果动态库需要严格控制导出符号,后续还可以用 linker version script、导出列表或平台特定配置。这属于发布工程问题,但原则不变:导出面越小,维护成本越低。

头文件是 ABI 的文字版本

Rust 编译通过,不代表 C 调用方会正确使用。

头文件要写清楚:

/*
 * Returns DEVICE_OK on success.
 * data may be NULL only when len is 0.
 * out must not be NULL.
 * The function does not retain data or out after returning.
 */
device_status_t device_checksum(const uint8_t *data, size_t len, uint16_t *out);

对 C 调用方来说,头文件和注释就是 API。它必须说明:

  • 指针是否允许为 NULL
  • 长度单位是什么
  • 输出参数由谁提供
  • 返回值怎么解释
  • 函数是否保存指针
  • 是否线程安全
  • 哪个版本开始引入这个函数

不要指望 C 用户读 Rust 源码来理解这些契约。

ABI 稳定不是源码兼容

C 头文件不变,也不代表 ABI 一定兼容。

这些改动都可能破坏 ABI:

  • 改变 #[repr(C)] 结构体字段顺序
  • 改变 enum 的底层表示或取值
  • 改变函数参数类型
  • 改变 size_t 和固定宽度整数的对应关系
  • 改变谁分配和谁释放内存
  • 改变动态库导出的符号名
  • 改变回调线程或重入规则

#[repr(C)] 只是在 Rust 侧请求 C 风格布局。你仍然需要把它和 C 头文件保持一致。

必要时给 ABI 加测试:

#[test]
fn layout_matches_c_contract() {
    assert_eq!(std::mem::size_of::<DeviceStatus>(), 4);
    assert_eq!(std::mem::align_of::<DeviceHeader>(), 4);
}

更严格的项目会用 bindgen/cbindgen、C 编译测试、ABI 检查工具或符号列表比较。

版本号要区分 API 和 ABI

Rust crate 的版本号服务 Rust 包生态。C ABI 还需要自己的兼容性判断。

动态库常见做法是:

源代码版本:1.4.2
C API 版本:device_api_version()
动态库 soname:libdevice.so.1
头文件宏:DEVICE_API_VERSION

例如导出一个版本函数:

#[unsafe(no_mangle)]
pub extern "C" fn device_api_version() -> u32 {
    1
}

C 侧可以在初始化时检查:

if (device_api_version() != DEVICE_API_VERSION) {
    /* reject incompatible library */
}

版本策略不一定复杂,但必须存在。尤其是动态库,一旦被多个 C 程序或脚本依赖,ABI 就不能随意改。

这一篇先记住什么

Rust 交付给 C 使用时,核心不是“Cargo 能不能编译”,而是:

staticlib      -> 生成给 C 静态链接的 .a
cdylib         -> 生成给 C 动态链接/加载的 .so/.dylib/.dll
dylib          -> 面向 Rust,不是 C ABI 默认选择
extern "C"     -> C 调用约定
no_mangle      -> 导出稳定符号名
#[repr(C)]     -> C ABI 布局边界
头文件         -> C 调用方看到的真实契约
ABI 稳定       -> 函数签名、布局、符号、内存归属、线程规则都不能随意变

把 Rust 用在 C 项目里,最好分成两层:内部 Rust API 可以持续重构,外层 C ABI 保持小而稳定。

下一篇讲 build.rs、#[link] 和 pkg-config。库形态解决“产物是什么”,构建脚本解决“Rust 或 C 编译器怎样找到并链接它”。