Rust FFI:ABI、指针与句柄所有权
C ABI与数据契约
FFI是跨语言调用的约定集合:符号、调用约定、数据布局、所有权和失败行为都要一致。读者应先理解Rust借用、原始指针与C声明;本专题采用edition 2021和标准库,不运行编译器或示例。研究核对日为2026-10-17。
C ABI负责什么
Rust FFI的C ABI契约要求两端对参数、返回值和调用约定达成一致。extern "C"选择目标平台的C调用约定,no_mangle保留导出符号名;二者分别解决调用约定和符号命名,不能替代头文件。C ABI并非所有平台统一的二进制协议,x86与x64、MSVC与GNU产物不能随意混用。
例如C的int32_t对应Rust的i32,size_t对应本机目标的usize,int64_t对应i64。不要把C的long假设成64位,也不要把Rust引用、String、Vec、trait object或Result直接声明在C接口上。C++宿主应通过extern "C"包装并使用同一头文件,不能直接依赖Rust与C++对象ABI互通。
edition 2021示例使用#[no_mangle];edition 2024要求将此类unsafe attribute写作#[unsafe(no_mangle)]。这不改变导出符号必须全局唯一的责任。升级edition需明确迁移,不能只改Cargo字段。
repr C保证范围
Rust FFI的repr C布局只约束标注类型自身的字段顺序、对齐与填充,内部字段仍必须各自有可用布局。#[repr(C)]不是序列化格式,也不会自动递归转换嵌套的Rust默认布局。结构体按值传递前应同时核对C声明、字段类型与目标架构;不通过packed逃避对齐问题。
#[repr(C)]
pub struct Reading {
pub total: i64,
pub count: u32,
}此片段仅示意布局声明,不是本专题宿主工程中的导出类型。结构体可能有尾部填充,不能把未初始化padding直接写入网络。C枚举可以持有Rust枚举没有对应变体的整数,因此错误码用i32和文档常量,先处理未知值;避免从任意C整数构造Rust enum或bool。
指针长度与空值
Rust FFI的指针长度校验首先定义空缓冲区:本例接受null加零长度,拒绝null加非零长度;非空切片还要求单一分配和总长度不超过isize上界。from_raw_parts即使长度零也要求非空且对齐,所以代码对零长度直接使用空切片。判断非null只检查数值,不证明地址可读、初始化、未释放或归属正确。
调用者保证整个借用期间内存存在且不被写入,长度真实、不跨分配,且不别名句柄内部可变状态。库只检查可判定的参数错误;悬垂指针、伪造句柄、错误容量属于调用契约违规,可能发生未定义行为,不能期待返回ARGUMENT。切片的生命周期由当前调用约束,不能因创建引用就使外部存储自动延寿。
字符串不是字符指针
Rust FFI的UTF8标签协议采用字节长度和UTF-8编码,允许内嵌零字节;str::from_utf8负责编码验证,指针有效性仍是调用者责任。这里不使用CStr,因为CStr要求零终止和读取到终止符期间的可读内存,不能直接表示任意长度数据。char *也不意味着有效UTF-8。
若接入既有C字符串,CStr::from_ptr是unsafe读取:非null、单一分配、终止零在可读范围内,并且借用期间不变。to_str可失败,to_string_lossy会替换非法字节,两者语义不同;配置键/协议字段不应默默有损。需要转移字符串所有权时必须另给成对释放函数,不把CString::into_raw结果交给C free。
链接产物选择
Rust FFI的库产物选择区分cdylib和staticlib:cdylib给外部宿主动态链接,staticlib给外部链接器静态链接。普通Rust dylib包含Rust相关元信息,不能视为稳定的C库交付接口。本例同时声明两种产物,但CMake只消费动态库;静态库还可能需要线程、动态装载等目标系统库,必须按目标链接需求补齐。
Windows MSVC常见产物为DLL和导入库,链接器读导入库,进程加载时需要DLL;Linux是so,macOS是dylib。Rust std与C运行库、目标triple、架构和编译配置共同决定可链接性。接口稳定还应设计ABI版本或新增函数策略,不能向旧结构体任意追加字段后要求旧宿主继续按值调用。
练习:审查接口
给定Rust函数extern "C" fn label() -> String,指出符号和调用约定之外仍缺少的契约,并为二进制标签设计替代接口。
参考解答
String布局、分配器和析构不是C接口。替代方案可用调用者提供的缓冲区、容量与实际长度,或用库分配字节指针和长度并配套库释放函数;必须定义容量不足时是否写入、是否允许内嵌零、哪一方释放及释放后指针状态。本工程同步回调借用输入标签,避免额外返回分配,但不能据此保留标签地址。
官方依据
Rust Reference 类型布局、链接产物、Rustonomicon FFI、标准库CStr。官方当前示例可能使用新edition语法,本文工程明确使用2021。
句柄、回调与退出边界
FFI的安全不来自把函数标成unsafe,而来自调用者与实现者能共同履行的契约。此页以C拥有句柄、Rust拥有分配、同步借用回调为例;完整源码见应用页的完整同源文件。示例未执行。
不透明句柄的所有者
Rust FFI的不透明句柄通过Box转移分配的管理权:Box::into_raw交出地址,C保存指针,最终由同一库的Box::from_raw恢复且释放一次。C只声明未完成类型struct Counter,不能按值复制或读取其字段;Rust内部结构不必为了指针传递而使用repr C,因为跨边界的只有指针。
必须区别“C负责发起释放”和“C分配器负责释放”。C的free无法正确替代Rust析构。同一个地址的多份指针不是多个所有者;释放后所有副本失效。空指针可约定无操作,但二次释放非空旧指针仍是UB;本例不维护句柄注册表,不声称能识别伪造地址。
借用期间与重入
Rust FFI的借用期间规则要求同一句柄的调用串行且独占,禁止回调内重入该句柄或销毁句柄。即使函数没有显式构造长寿命的可变引用,回调销毁会使外层函数协议失效;不能通过“暂时没有访问字段”放行重入。不同句柄是否可并发也需结合回调上下文自己的同步能力审查。
本例Rust不派生线程,不保存外部指针,所有操作由调用线程完成。要在线程间共享,应由C侧互斥保护整个调用与释放过程;不能仅在add内部加锁而让free绕开锁。允许并发的库需要引用计数或会话协议及关闭状态,超出这个最小例子的承诺。
回调context与注销
Rust FFI的回调context生命周期由C持有,Rust只同步借用到counter_add返回;回调函数指针、context和标签地址全部不留存。因此栈上Host在当前调用期间有效,调用返回后没有待执行回调,也无需异步注销。Option<extern "C" fn>对应可空函数指针,None表示不通知,不能用普通Rust闭包代替C函数地址。
回调收到标签字节和长度,只能在回调内读;要保留则复制到C自己的存储。context可以为null,但回调必须自己接受此情况;示例on_total要求非null的Host地址。若以后引入异步通知,应先停止产生通知,再等待在途回调结束,注销并释放context,最后卸载动态库;单纯移除回调地址不能证明线程已经退出。
panic与展开边界
Rust FFI的panic边界用catch_unwind把unwind转换为PANIC错误码,但无法捕获panic abort、内存分配失败终止或未定义行为。普通C ABI不能让Rust panic穿越;在受控边界内捕获是错误处理策略,不是把任意unsafe操作变安全。panic hook在捕获前可能仍会输出信息。
本例三个导出函数都进入boundary;捕获后的panic payload可能有会panic的析构,故刻意forget,代价是每次捕获可能泄漏payload。正常输入错误没有panic,不走此路径。Rust内存分配失败通常终止,不能承诺new返回OOM错误。编译时panic=abort会直接终止,release显式保留unwind也不能防止宿主使用不同构建配置。
失败后的状态
Rust FFI的错误状态协议把可恢复错误和panic区分:参数、UTF-8、checked_add溢出在提交之前返回,因此总和不变且没有回调。成功路径先写入总和再通知C;回调无返回值,不存在通知失败后的自动回滚。PANIC不承诺状态回滚,宿主应停止复用该句柄并调用销毁。
禁止C++异常、longjmp和宿主故障从回调跨回Rust。catch_unwind不是捕获外国异常的通用设施,也不能截获C的崩溃。本例不使用C-unwind;确实需要跨语言展开时必须统一运行时、ABI与清理语义,不能只把ABI字符串替换为C-unwind。
练习:从同步改成异步
有人把context存到全局变量,启动工作线程后立即返回,C宿主仍传栈上Host。指出错误并写出最少关闭协议。
参考解答
返回后Host可能离开作用域,工作线程产生悬垂访问;还会有并发读写和动态库卸载风险。必须由宿主持有堆上上下文直到停止通知、等待所有在途回调完成、注销成功后再释放,并在所有句柄销毁后卸载库。停止和等待不能在同一个正在执行的回调里形成自等待死锁。保留本例同步语义则无需这套机制。
官方依据
Box 原始指针所有权、catch_unwind、Nomicon 回调与展开。这些契约解释了为什么null判断和错误码不能代替调用者安全责任。
继续阅读
- 排错
- 排错
- 排错