Rust FFI 和 ABI 是什么关系?从一个字段写错的结构体说起

6,451 字#Rust #ABI #FFI #rpi

先看一个能正常跑起来、但结果是错的例子。

插件侧编译成 cdylib,导出两个函数。cdylib 是供其他代码加载、调用的动态库产物,不要求调用方也是 Rust:

#[repr(C)]
pub struct Point {
    pub x: i32,
    pub y: i32,
}

#[no_mangle]
pub extern "C" fn point_new(x: i32, y: i32) -> Point {
    Point { x, y }
}

#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

宿主用 Windows 的 LoadLibraryA / GetProcAddress 加载库、查找函数。它自己也定义了一个 Point,却把字段顺序写反了。下面节选类型声明和调用,symbol_address 是已查到的函数地址;完整宿主源码见后文下载链接:

#[repr(C)]
struct Point {
    y: i32,
    x: i32,
}

// 从查到的地址构造函数指针;声明由宿主自己保证正确。
let new_fn: extern "C" fn(i32, i32) -> Point = unsafe {
    std::mem::transmute(symbol_address)
};
let p = new_fn(1, 2);
println!("point_new(1,2) -> x={} y={}", p.x, p.y);

在 rustc 1.94.0、x86_64-pc-windows-msvc 环境下,这个实验打印:

add(2,3) = 5
point_new(1,2) -> x=2 y=1

没有编译错误、链接错误,也没有崩溃。插件返回的第一个整数是 1,宿主却把它当作 y;第二个整数是 2,宿主把它当作 x。

这个例子里,两个结构体的字段类型、大小和对齐恰好相同,交换字段名没有改变底层传递形状。错的是双方对这些字节的解释。它提醒我们:调用能完成,不等于接口约定一致。

本文先解释这个 Point,再看调用约定、符号、内存和失败处理。最后把它们对回上一篇《从一个 helloworld 动态库到 rpi package:ABI 插件是怎样被加载的》中的插件接口。不需要先读上一篇。

FFI 提供调用机制,ABI 规定二进制怎样配合

FFI(Foreign Function Interface,外部函数接口)是一组互操作机制。它让 Rust 能声明、调用外部函数,也让外部代码能调用 Rust 导出的函数。外部函数声明、类型映射、回调和安全封装,都在这个范围内。

ABI(Application Binary Interface,应用二进制接口)规定编译后的代码怎样协作。例如参数放在哪个寄存器、返回值怎样传回、符号如何被找到、结构体的字段位于哪个偏移。

Rust 中,extern "C" 为函数选择目标平台的 C 调用 ABI;#[repr(C)] 为结构体等类型选择 C 表示。它们解决不同的问题,不能互相替代:只给函数加 extern "C",不会自动把参数里的 Rust 结构体变成 C 布局。

实际设计接口还要补上所有权、生命周期和失败处理协议。C 调用约定不会告诉你一个指针该由谁释放,也不会替你定义错误码。因此,下文讨论的是一份可用的 FFI 接口需要守住的几类约定,不把它们都归为 extern "C" 提供的保证。

Rust 默认也有 ABI:普通 fn 使用 Rust ABI。问题在于它不提供稳定性保证,不能把某次编译得到的形状当作跨编译器版本的长期合同。

来源:https://doc.rust-lang.org/reference/items/external-blocks.html#abi

先解决 Point:repr(C) 不会替双方统一定义

#[repr(C)] 结构体按字段声明顺序布局,并在字段之间加入满足对齐要求的填充。开头的定义因此得到两种解释:

插件的 Point             宿主的 Point
[0..4) x: i32 = 1        [0..4) y: i32 = 1
[4..8) y: i32 = 2        [4..8) x: i32 = 2

repr(C) 在两边都履行了承诺。它没有承诺“名字相同的两个结构体一定相同”,更不会从动态库里读取字段名,再替宿主调整顺序。

修复这个例子,只需把宿主的定义恢复成 x 在前、y 在后。真实项目更应让两端从同一份接口定义生成或共享声明,避免手抄两份后逐渐分叉。

要在同类 Windows 环境复现,可以下载插件源码和宿主源码,放在同一目录运行:

rustc --edition=2021 --crate-type=cdylib plugin.rs -o plugin.dll
rustc --edition=2021 host.rs -o host.exe
.\host.exe .\plugin.dll

宿主源码保留了写反的字段,也包含 DLL 加载和符号检查。这里显式使用 edition 2021,以便与开头的 #[no_mangle] 写法一致;Rust 2024 的写法稍后说明。

默认 Rust 布局为什么不能靠猜

同样三个字段,使用默认表示与 C 表示,会得到不同布局:

struct RustOrder { a: u8, b: u32, c: u16 }

#[repr(C)]
struct COrder { a: u8, b: u32, c: u16 }

原实验在上述环境中的输出是:

repr(Rust) RustOrder: size=8 align=4 offsets=(6,0,4)
repr(C)    COrder:    size=12 align=4 offsets=(0,4,8)

按字节区间展开,更容易看清差异:

repr(Rust),本次编译共 8 字节
  [0..4)   b: u32
  [4..6)   c: u16
  [6..7)   a: u8
  [7..8)   padding

repr(C),当前目标平台共 12 字节
  [0..1)    a: u8
  [1..4)    padding
  [4..8)    b: u32
  [8..10)   c: u16
  [10..12)  padding

padding 是为对齐留出的空隙。默认 Rust 表示允许编译器重排字段;上面的 (6,0,4) 是本次观察,不是以后每次编译都必须保持的规则。C 表示则按声明顺序计算偏移,但字段自身的大小和对齐仍取决于类型与目标平台。

还有一个限制:repr(C) 不会递归改造内部类型。在 C 布局结构体里放一个默认 Rust 布局的内部结构体,内部类型仍保留自己的表示。

来源:https://doc.rust-lang.org/reference/type-layout.html

大小一样,也未必适合跨边界

枚举除了宽度,还涉及哪些值有效。在本机实验中,一个有两个变体的无载荷枚举,默认表示占 1 字节,repr(C) 占 4 字节,repr(u8) 占 1 字节。这里的 4 字节依赖目标平台,不能当成所有 C 枚举的固定宽度。

rpi 的事件标签显式选择了宽度:

#[repr(u32)]
pub enum EventTag {
    ProjectTrust = 0,
    ResourcesDiscover = 1,
    SessionStart = 2,
    // ...
}

这钉住了存储表示,却不意味着任意 u32 都是合法 EventTag。Rust 枚举只能持有已定义的判别值;若接口需要接受未知标签,应先用整数接收、校验,再转换,不能直接把未知整数当作枚举。

平台类型也需要区分。当前 Windows x64 环境中,c_long 是 4 字节,而 Linux x86_64 通常使用的 LP64 数据模型中,C 的 long 是 8 字节。std::ffi::c_long 表达的是目标平台的 C long,不是一个跨平台固定宽度的整数。Rust 的 char 则是 4 字节的 Unicode 标量值,不是 C 的 char。

String 和 Vec 更不能凭大小传过去。原实验中 String 与 Option<String> 都是 24 字节,但这不承诺它们的布局或可空编码能供外部代码使用。

标准库对部分类型给出了明确保证。例如 Option<extern "C" fn()> 与其中的函数指针具有相同大小、对齐和调用 ABI,None 可以用空指针表示。这个保证不适用于 Option<String>。即使某种类型有可空表示保证,跨边界时仍须核对其中的参数类型和生命周期。

来源:https://doc.rust-lang.org/std/option/index.html#representation

把 String 放进 extern "C" 函数签名,编译器会给出 FFI 安全警告:

warning: `extern` fn uses type `String`, which is not FFI-safe
  = note: this struct has unspecified layout
  = note: `#[warn(improper_ctypes_definitions)]` on by default

这类 lint 检查的是当前可见的声明,不要求对端也是 Rust,也不会核对动态库的实际定义。开头的两个 Point 各自都是合规的 C 表示,所以它检测不到双方把字段含义写反。需要将警告作为硬错误时,可以在项目中启用 #![deny(improper_ctypes, improper_ctypes_definitions)]。

值还要按同一种调用约定传递

布局一致之后,还得保证两端知道值怎样进入、离开函数。两个函数即使操作相同的字节,也可能因为调用约定不同而无法互调。

在 Windows x64 上,前四个参数位置使用寄存器。整数或指针分别对应 RCX、RDX、R8、R9,浮点数按参数位置对应 XMM0–XMM3,并不是各自独立排队。例如第二个参数是浮点数,它使用 XMM1。

结构体返回值会更直观地展示这种约定:

#[repr(C)]
pub struct Small { pub a: u64 }
#[repr(C)]
pub struct Big { pub a: u64, pub b: u64, pub c: u64 }

#[no_mangle]
pub extern "C" fn ret_small() -> Small { Small { a: 7 } }
#[no_mangle]
pub extern "C" fn ret_big() -> Big { Big { a: 1, b: 2, c: 3 } }
#[no_mangle]
pub extern "C" fn take_big(v: Big) -> u64 { v.a + v.b + v.c }

原实验用 rustc --emit=llvm-ir 得到的关键签名如下,省略了无关属性:

define noundef i64 @ret_small() ...
define void @ret_big(ptr ... sret([24 x i8]) ... %_0) ...
define noundef i64 @take_big(ptr ... %v) ...

Small 的 8 字节结果可以直接经寄存器返回。Big 的 24 字节结果则由调用方提供存储,额外把地址传给被调方。IR 中的 sret 就是这个隐藏的返回存储参数。

take_big 虽然在 Rust 源码里按值接收 Big,底层也用指向调用方临时存储的指针传递。这里的指针是调用约定的实现方式,不是把接口语义改成借用。

这套结果适用于当前 Windows x64 目标。extern "C" 选择的是目标平台的 C ABI,不是全球统一的一份寄存器规则。x86_64 Windows 通常对应 Win64 ABI,x86_64 Linux 对应 System V ABI;换目标平台后,应重新核对聚合类型的传参和返回规则。

来源:https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention

找到函数名字,与怎样调用函数是两件事

宿主用 GetProcAddress 查找 point_new,要求动态库确实导出这个名字。extern "C" 负责调用 ABI,no_mangle 则关闭 Rust 的符号名修饰。

原实验的 LLVM IR 中,普通 Rust 函数与显式导出的函数分别是:

define internal i64 @_ZN6mangle9rust_only17ha25a41e58194bcb0E(i64 %v)
define dso_local i64 @c_visible(i64 %v)

前者经过了 mangling,也就是把路径等身份信息编码进符号名。它不适合作为长期对外接口,却也不会每次重编都变化。在相同源码、crate 名与选项下连续编译两次,本机得到的函数符号完全相同。这里缺少的是跨构建条件的稳定性保证。

开头的测试 DLL 导出表中,能找到这两项:

add
point_new

Rust 2024 edition 要把 no_mangle 写成 unsafe attribute:

#[unsafe(no_mangle)]
pub extern "C" fn point_new(x: i32, y: i32) -> Point {
    Point { x, y }
}

这是因为未修饰符号处在全局命名空间中,使用者需要保证名字不会与不兼容的其他定义冲突。它不意味着函数体自动变成了 unsafe,也不替你验证外部调用方使用了正确签名。

来源:https://doc.rust-lang.org/edition-guide/rust-2024/unsafe-attributes.html

rpi SDK 使用固定入口名:

pub const REGISTER_SYMBOL_UNIFIED: &[u8] = b"rpi_plugin_register\0";

末尾的 \0 是 C 字符串终止符。底层 Windows GetProcAddress 需要终止的字符串;但 rpi 使用的 libloading::Library::get 包装接口接受带或不带末尾 NUL 的字节串。显式提供 NUL 结尾可能避免内部为处理符号名进行一次分配,不能说“少它就找不到符号”。

符号名里没有版本号,版本放在入口参数指向的结构体头部。这样能保持查找入口不变,再由入口检查双方支持的协议版本。

来源:https://docs.rs/libloading/0.8.9/libloading/struct.Library.html#method.get

指针传过去之后,内存仍然要有人负责

rpi 用这样的结构体传递拥有所有权的 UTF-8 字符串:

#[repr(C)]
#[derive(Clone, Copy)]
pub struct StbString {
    pub ptr: *mut c_char,
    pub len: usize,
}

单看字段,只能知道地址和长度。SDK 的协议还规定:接收所有权的一方负责释放,但要调用生产方提供的释放函数,而且只能释放一次。借用输入则使用单独的 StbStringRef;不能看到一个 ptr + len 就假定自己拥有它。

下面是一个错误的生命周期示意,不应照着执行:

let mut v: Box<[u8]> = b"hello from plugin".to_vec().into_boxed_slice();
let ptr = v.as_mut_ptr();
// 若返回 ptr,却让 v 在函数末尾被 drop,指针随后就悬垂了。

修复的关键是让分配继续存活,并提供配套回收。现有实验的正确版本用 Box::into_raw(v) 交出分配;释放时,插件根据原来的长度恢复 Box<[u8]>,再让它析构。

原实验记录中,宿主正确读到了:

收到: "hello from plugin" (17 字节)
按合同释放一次,正常结束

记录里也有故意释放两次后进程异常退出的情况。这只能说明那次运行暴露了 double free,不能当作固定结果。释放后读取、重复释放都属于未定义行为,可能崩溃,也可能暂时看起来正常。

让生产方提供释放函数,是为了匹配原来的分配方式、布局和分配器。宿主不能把来源未知的 Rust 分配直接交给 C 的 free。Windows 的不同模块还可能使用不同 CRT 配置,进一步增加混用的风险。

这个规则也有方向:插件产生的字符串,宿主调用插件的释放函数;宿主产生的字符串,插件调用 PluginApi.free_string。不是所有跨边界字符串都统一交给宿主释放。

Copy 不会改变这个协议。复制 StbString 只复制指针和长度,不复制内存;若两份副本都被当作独立所有者释放,仍然会出错。

panic 要在离开插件入口之前处理

extern "C" 不允许 Rust panic 展开穿过该边界。在本文的 rustc 环境下,若函数内部 panic 并一直传播到边界,进程会 abort;把 catch_unwind 放在宿主调用外侧也来不及。

原 DLL 实验的插件函数和宿主函数指针都使用 extern "C"。它证明的是这条不允许展开的边界会终止进程,不能据此推出 C-unwind 跨 DLL 必然失败。

extern "C-unwind" 自 Rust 1.71 起稳定,允许按相应规则进行跨边界栈展开。使用 panic=unwind,在同一程序、同一 Rust runtime 内,它产生的 panic 可以被外层 catch_unwind 捕获。

不过,允许展开不等于任意模块的异常都能安全恢复。标准库说明,来自不同 Rust runtime 等外来异常若被 catch_unwind 捕获,可能导致 abort,也可能返回不透明错误;具体是哪种不作保证。

来源:https://blog.rust-lang.org/2023/07/13/Rust-1.71.0/

对于 rpi 这种不允许 unwind 穿越的插件接口,更直接的办法是在插件内部捕获,再把失败表示成接口值。下面是一个可运行的教学示例,不是完整的 rpi 注册函数:

use std::panic::catch_unwind;

extern "C" fn register() -> i32 {
    // 捕获发生在 C 边界里面,panic 不需要穿过入口。
    match catch_unwind(|| {
        panic!("注册失败");
    }) {
        Ok(()) => 0,
        Err(_) => 70,
    }
}

fn main() {
    println!("status={}", register());
}

把上面的代码保存为 guarded.rs,用下面的命令编译并运行:

rustc --edition=2021 -C panic=unwind guarded.rs -o guarded.exe
.\guarded.exe

默认 panic hook 会先打印错误信息,然后程序打印 status=70,正常返回。panic 被捕获,不等于错误信息不打印。

这仍然有边界。panic=abort 无法由 catch_unwind 恢复,内存破坏也不能靠它变安全。捕获后,还要保证半完成的注册状态能够丢弃或清理。

对持有状态的入口,需要审查 unwind safety,也就是中途退出后状态是否仍可使用。捕获 panic 不是把任意失败变成可恢复错误的开关。

rpi SDK 的 register_entrypoint 在内部包住注册 body,把捕获到的 panic 转成状态码 70。宿主收到非零状态后,放弃该插件及其部分注册结果,避免继续使用未完成的注册状态。

返回整数是这份接口的设计选择,不是错误跨边界的唯一形式。状态码加输出参数、错误对象加配套释放函数,也都可以成为明确约定的方案。

来源:https://doc.rust-lang.org/std/panic/fn.catch_unwind.html

版本头检查的是接口兼容性,不是任意内存的安全性

接口还会演进。即使今天两端的结构体一致,未来增加回调或改变行为约定,也需要让旧宿主和新插件知道是否能继续协作。

下面的 rpi 实现以仓库提交 e247f58b72871ef86a67f4c777cfb321b75542cd 为准,主要涉及 crates/rpi-plugin-sdk/src/lib.rs 的 PluginApi、register_entrypoint,以及 crates/rpi-extensions/src/loader.rs。

PluginApi 的开头是两个 u32:

#[repr(C)]
#[derive(Clone, Copy)]
pub struct PluginApi {
    pub abi_version: u32,
    pub struct_size: u32,
    pub free_string: FreeStringFn,
    // ... 注册回调与上下文
}

abi_version 标识接口协议版本,struct_size 报告宿主提供的结构体字节数。入口先读固定头部,再决定怎样处理后面的字段。

这要求一个前提:调用签名兼容,传入的非空指针也确实指向正确对齐、可读的头部及其声明的有效内存。版本号不能验证任意指针,更不能补救用错误签名调用函数。

当前实现的检查与处理顺序是:

api 为空                     → 返回 2
abi_version 不匹配           → 返回 1
必需前缀缺失或可选槽位不完整 → 返回 3
有效的较短旧结构体           → 缺失可选槽位补为 None
注册 body panic              → 捕获后返回 70
其余情况                     → 返回 body 的状态码

它不是简单地要求 struct_size >= size_of::<PluginApi>()。当前 SDK 允许旧宿主只提供较短的函数表,只要必需前缀完整,尾部可选函数指针槽位也完整。

SDK 在本地建立一份清零的完整结构体,只复制宿主已提供、且当前 SDK 认识的字节。缺少的可选函数槽位因此是 None;必需字段则不能这样省略。这使“增加可选回调”可以保留一条兼容路径,而不是每次函数表变长都拒绝旧宿主。

不过,大小检查无法识别同样大小却含义不同的字段。破坏性的语义变化仍需要版本策略。这里延续的还是 Point 暴露的问题:字节数正确,不代表双方理解一致。

源码:https://github.com/bigfish1913/pi-rust/blob/e247f58b72871ef86a67f4c777cfb321b75542cd/crates/rpi-plugin-sdk/src/lib.rs

回到 rpi:每种约定各有落点

现在可以把上一篇出现的接口写法放回各自的位置:

问题rpi 的做法约束与限制
调用入口和回调使用 extern "C"参数自身还要有合适的表示
寻址固定符号 rpi_plugin_register查到地址后仍需正确函数签名
布局repr(C)、固定宽度标签、函数指针表字段含义、枚举有效值仍需一致
资源生产方的 free_string;插件管理内部状态裸指针和 Copy 不会执行所有权检查
失败入口内部捕获 panic,返回状态码;失败后丢弃部分注册不能恢复 abort 或未定义行为
演进abi_version、struct_size 与可选尾槽不能修复不兼容签名或无效指针

上一篇用 Box::into_raw 产生指针,示意不透明句柄的实现。当前 SDK 的 ToolHandles 则使用不复用的数值 token,在插件内部查表找到状态。宿主只负责传回 token,不解引用,也不自行释放内部 Rust 对象。

两种做法都需要调用协议。token 的好处是未知或已删除句柄可返回 None,重复移除也不会重复释放同一分配;它不会阻止宿主对一个不该解引用的值做非法解引用。当前实现用检查加法分配 token,计数耗尽时拒绝继续分配,而不是回绕后复用旧值。

句柄实现:https://github.com/bigfish1913/pi-rust/blob/e247f58b72871ef86a67f4c777cfb321b75542cd/crates/rpi-plugin-sdk/src/handles.rs

什么时候应换一道边界

同进程的 C ABI 接口适合需要原生互调、接受共同崩溃风险、并且信任所加载代码的场景。插件是否值得留在进程内,还要看调用频率、部署和隔离需求,不能只凭“原生调用快”作决定。

动态库没有天然沙箱。插件里的野指针、abort 或其他进程级故障,都可能带走宿主。若要求不可信代码隔离、独立崩溃恢复或跨机器运行,应考虑进程边界,例如通过标准输入输出或 HTTP 交换消息。独立升级本身并不排斥稳定的进程内 ABI,但进程隔离往往能减少共同故障和部署耦合。

Rust 不承诺稳定的默认 ABI,意味着不能直接把普通 Rust 类型与函数当作长期二进制接口。选择 C ABI 也只解决一部分问题:两端仍要统一声明,写清资源和失败协议,并为演进设置检查。

再看开头的 Point:repr(C) 已经让两端按各自声明排列字段,却没有让两份声明自动一致。FFI 提供互操作机制,ABI 规定二进制协作方式,而一份真正可用的接口,还要让双方对这些字节的含义和使用规则达成一致。


云服务器推荐

如果你需要部署博客、Docker 服务或个人项目,可以参考:

本文包含推广链接。通过链接购买不会增加你的费用,但我可能获得少量佣金。

评论