ByteNoteByteNote
Rust 封装 C++ DLL:一份头文件的 FFI 实战
字

字节笔记本

2026年10月7日 · 约 11 分钟读完

Rust 封装 C++ DLL:一份头文件的 FFI 实战

API中转
¥120

很多团队都遇到过这样的处境:核心存储能力是一套现成的 C++ 动态库——虚拟磁盘、快照、镜像导出这类贴近系统和驱动的模块,多年打磨,动不得;而新的桌面客户端又不想再背传统 UI 框架的包袱,选了 Tauri 这类 Rust 壳。两边一碰头,问题就变成:Rust 怎么把一个只给了一份 extern "C" 头文件的 C++ DLL,包装成前端可以直接 invoke 的命令层?

最近复盘了一次这样的实战:目标 DLL 是一套块存储管理接口,提供配置加载、镜像创建、链式快照、挂载、提交、按进度导出等十余个 C 风格函数。下面以它为例,把从类型映射到缓冲区输出的完整封装套路走一遍,最后给出几个最容易埋雷的细节。

先把头文件读透

拿到头文件先别急着写绑定,接口约定其实都写在宏和签名里。

导出宏与 extern "C":头文件用 BLOCKSTORAGE_API 宏在 dllexport 和 dllimport 之间切换,这是 Windows DLL 的标准导出姿势;而 extern "C" 保证符号不被 C++ name mangling 改名——这是运行时能按 "create_disk" 这样的名字找到函数的前提。看到这两个标记,就可以确定走动态加载的路子。

句柄式设计:create_disk 返回 uint32_t 的 disk_id,之后所有接口都带它;快照同理有 snap_id,而且 create_snapshot 还接受父快照 ID,支持链式快照。这是典型的"不透明句柄"约定——Rust 侧只需拿 u32 当 key,不碰任何内部结构体指针,绑定难度大幅下降。

提前标记三类难点:get_mount_point 返回 const wchar_t*,Windows 宽字符是 16 位 UTF-16;dump_snapshot 带 dump_callback(uint64_t, uint64_t) 进度回调;get_disk_list 走"调用方分配缓冲区"模式。这三类正是 FFI 的三大经典难题,后面逐个拆。

块存储快照接口的生命周期:从 load_config 到 dump_snapshot

选 libloading,而不是链接期导入

调用 DLL 有两条路。一是隐式链接:构建时拿导入库,进程启动时自动加载;二是运行时动态加载,对应 Win32 的 LoadLibrary/GetProcAddress。libloading 就是后者的安全封装:Library::new 相当于 LoadLibrary,lib.get(b"create_disk") 相当于 GetProcAddress,拿到的 Symbol 转成函数指针即可调用。

选运行时加载有三个理由:DLL 缺失或损坏时能在启动阶段优雅降级,而不是整个进程起不来;加载路径可以按环境配置,测试时甚至能指向一份桩实现;符号解析失败能逐个捕获成错误上报。对"存量 C++ 组件持续演进、客户端要兼容多个版本"的场景,这种灵活性很值。反观隐式链接,.lib 把你绑在 MSVC 工具链上,DLL 必须待在系统搜索路径里,加载时机完全不可控,出了问题也只有一个含糊的"找不到入口"错误。

代价是每个函数都要声明完整签名:

rust
let func: Symbol<unsafe extern "C" fn(c_uint, c_uint, c_ulonglong, *const c_char) -> c_uint>
    = lib.get(b"create_snapshot")?;

签名必须和头文件一字不差,参数个数、顺序、宽度错一位,轻则返回乱值,重则栈错位直接崩溃。

封装后的调用链:Tauri 前端、Rust 命令层与 C++ DLL 之间的 FFI 边界

类型映射与字符串

基本类型照着 std::os::raw 映射:uint32_t 对 c_uint、uint64_t 对 c_ulonglong,Rust 原生 u32/u64 在命令层进出。麻烦全在字符串上。

C 侧的 const char* 到 Rust 手里分两步:CString::new(name) 把 String 转成带 NUL 结尾的 C 字符串,再把 .as_ptr() 传进去。这里有个教科书级陷阱——写成 CString::new(name).unwrap().as_ptr() 的话,临时 CString 在语句结束就被释放,函数拿到的是悬垂指针,未定义行为;必须先 let 绑定,让它活过整个调用。另外 CString::new 不允许内容含 NUL,遇到会返回错误,正好用 map_err 并进 Result 的错误通道。

返回侧的 wchar_t* 更典型:Windows 宽字符是 UTF-16 编码的 u16 序列。拿到指针后先按裸指针构造 u16 切片(自己数到 NUL 为止的长度),再 String::from_utf16_lossy 转成 Rust 字符串,挂载路径就这样变成了前端可用的普通文本。用 lossy 版本兜底,遇到非法编码序列是替换而不是 panic,对 GUI 来说比崩溃体面得多。顺带一提,wchar_t 的宽度是平台相关的——Linux 上它是 32 位,这套绑定如果哪天要跨平台,宽字符处理得重写,而其余接口基本可以原样迁移。

缓冲区输出模式

get_disk_list(char* json_buffer, uint32_t size) 代表一类常见 C API:调用方分配内存,函数往里写,返回值是有效长度,负数为失败。Rust 侧的对应写法:

rust
let mut buffer: Vec<u8> = Vec::with_capacity(buffer_size as usize);
let result = func(buffer.as_mut_ptr() as *mut c_char, buffer_size);
if result >= 0 {
    buffer.set_len(result as usize);
    Ok(String::from_utf8_lossy(&buffer).into_owned())
}

关键在 set_len:Vec 只分配了容量、长度还是 0,C 函数写完后要用返回的长度"收尾",之后才能安全读取。同款的 get_snapshot_list 多一个 disk_id 参数,套路完全一致。三个细节值得记住:内容是 JSON,用 from_utf8_lossy 容错;缓冲区大小靠预估,按镜像和快照的数量级宁多勿少,多分配的内存远比截断的列表便宜,若 C 侧支持"先传 NULL 问所需大小"的两段式调用,那是最稳的协议;JSON 的解析留在 Rust 层完成,前端只拿到结构化结果。

进度回调:先做减法

dump_snapshot 的第四个参数是进度回调。第一版封装直接传空指针:函数签名里最后一个参数写成 *mut c_void,调用时传 ptr::null_mut()。导出功能完整,只是拿不到进度条。

跨 FFI 的回调有标准做法:Rust 侧定义 extern "system" 的顶层函数作跳板,把函数指针传给 C。但这里有两个坎:闭包不能直接当 C 回调——带捕获的闭包布局和函数指针不兼容,跳板必须是普通顶层函数;C 接口还得在注册回调时附带一个上下文指针,否则跳板里拿不到要更新的状态。这套头文件里 dump_callback 只有两个 uint64_t 参数(按常见约定是已处理字节数和总量),没预留 user_data 的位置——碰上这种接口,要么用全局的线程安全状态槽凑合,要么推动 C 侧加 context 参数。第一版先传空,把导出功能跑通,是合理的工程取舍,进度条可以后续再加。

五个容易埋雷的细节

  1. 库的复用:示例代码为清晰起见每次调用都 Library::new,正确但昂贵——加载有引用计数和符号查找开销。生产环境用 OnceLock<Library> 加载一次,全进程复用。
  2. Symbol 不得越过 Library:Symbol 借用了 Library,把函数指针缓存到库对象之外编译期就会拦下。缓存 Library 而不是 Symbol,更省心。
  3. DLL 路径:运行时按相对可执行文件的路径找 DLL,开发时的项目目录和打包后的安装目录结构不同,部署前务必在真实安装包里实测;打包配置里要把 DLL 带进资源清单,漏带是最常见的翻车点。
  4. bool 的 ABI:C++ 的 bool 与 Rust 的 bool 在主流平台上都是一个字节,这条接口约定下可以直接映射;但若 C 侧某天改成 int 风格返回,就得同步换成 c_int,返回值宽度对不上时错误码会被静默污染。
  5. unsafe 收口:所有 unsafe 集中在绑定层,命令函数对前端暴露的只有 String、u32 这样的安全类型,前端永远不碰指针。FFI 层薄一点、显式一点,出了问题才好定位——这一层也最容易写单测,几个命令函数真跑一遍就知道签名对不对。

写在最后

回头看,这套封装的价值不在某一行代码,而在分层:DLL 的 C ABI 只被翻译一次,unsafe 被关进 libloading 的笼子,#[tauri::command] 暴露给前端的完全是 Rust 语义的安全接口。前端 invoke("create_disk", ...) 时,感知不到背后还有一层 C++。

存量 C++ 组件加 Rust 新壳,会是未来几年很多桌面团队的现实组合。下次拿到一份只有头文件的 DLL,按"读透接口约定 → 选加载方式 → 映射类型与字符串 → 拆解缓冲区、宽字符、回调三类难题 → 收紧 unsafe"的顺序推进,大部分雷都能提前排掉。

相关文章

分享: