原创:FFI极简应用场景【字符串·传输】浅谈
导言
这篇文章分享了我对Rust与C程序之间字符串(字节序列)传输机制的“悟道”成果。【FFI字符串·传输】是FFI诸多概念中:
- 最简单的里最难的 — 对比·各种整数类
- 最难的里最简单的 — 对照·样式繁多的自定义数据结构
它算是难度适中,既能讲出点内容来,又不会知识点太过生涩劝退读者。上干货!
知识点“大图”
这还真是一张图。一图抵千词,再配上一些文字描述,应该能够把概念讲清楚。

首先,libc crate是操作系统常用ABI的FFI binding。
- 一方面,在
Cargo.toml中添加libc依赖项·就相当于·在C代码插入一行导入系统头文件的#include语句。 - 另一方面,
libc crate不是系统ABI的跨平台解决方案。所以,libc crate的下游使用者得自己区分在哪个操作系统平台上,调用libc crate的哪个API— 即便实现功能相同,在不同操作系统平台上,多半也得调用不同libc crate API。- 若你想同一套程序跨平台,还是老老实实地上【条件·编译】吧!
- 最后,
libc crate不是包罗万象的。你要知道操作系统ABI有多少,有多庞大。libc crate的绑定范围很窄,粗略包括- 在
-inux系统上,libc,libm,librt,libdl,libutil和libpthread - 在
OSX系统上,libsystem_c,libsystem_m,libsystem_pthread,libsystem_malloc和libdyld - 在
Windows系统上,CRT。若做win32开发,我还是比较推荐winapi crate。
- 在
其次,【Rust字符串】与【C字符串】指的是采用了不同【字节序列·编码格式】的字符串,而不是特指Rust内存里或C内存里的字符串。
- 【
Rust字符串】严格遵循UTF-8编码格式。它的长度信息被保存于- 要么,
String智能指针·结构体的私有字段self.vec.len内。 - 要么,
&str胖指针内。
- 要么,
- 【
C字符串】是以\0(或NUL)结尾的,由任意非\0字节拼合而成的字节序列。- 【
C字符串】的实际长度总比它的有效内容长度多1个字节 —\0。 - 从【
C字符串】向【Rust字符串】的转换是refutable,因为【C字符串】可以是任意的非零字节序列,而不一定是有效的UTF-8字节数组。 - 【强调】【
C字符串】不是被保存于C内存的字符串。相反,Rust内存区域内也能存储【C字符串】。 - 【警告】
libc::strlen(_: *const libc::c_char) -> usize返回的是字符串【有效内容·长度】。当做字符串的逐字节内存复制时,千万别忘了人工地在字符串复本末端添加一个\0字节 [例程1]。 - 【
C字符串】的\0终结位是一个编码“大坑”,因为在对【C字符串】做逐字节内存复制时,\0位需要由开发者自己人工增补上:- 要么,先初始化
vec![0_u8, N + 1]字节数组;然后,用字符串有效内容复写前N个字节;最后,保留尾字节是\0[例程2]。- 其中,
N代表C字符串的有效内容长度。 - 值得注意,
vec![0_u8, N + 1]宏要比系统指令zmalloc()慢得多。如果你特别看重性能,那么下面描述的另一条技术路线应该更合你的意。
- 其中,
- 要么,先
Vec::with_capacity(N)划出一段连续且未初始化内存;再,填充字符串有效内容;最后,由Vec::resize(N, 0)扩展字节数组至N + 1个字节和给尾字节写入\0值 [例程1]。- 其中,
N代表C字符串的有效内容长度。 - 这样就绕过了较慢的
vec![0_u8, N]宏了。
- 其中,
- 要么,先初始化
- 【
接着,【C字符串】的CString与&CStr封装类型就相当于【Rust字符串】的String与&str。
CString与String的共同点- 都是【所有权·智能指针】;
- 其内部【字节序列】都是被保存于
Rust内存里
CString与String的不同点就是:【字节序列·编码格式】不同。CString是以\0(或NUL)结尾的,任意非\0字节序列。String是UTF-8。
&CStr与&str的共同点是- 都是指向【字符串·字节序列】的切片引用
&CStr与&str的不同点是&str是【胖指针】;CStr是【智能指针】,但被【自动·解引用】之后的CStr也是一个【胖指针】。&CStr既能引用C内存里的C字符串,也能引用Rust内存里的C字符串。- 上图中着重描述了其最常见用法:使用
&CStr引用【C内存】里的【C字符串】。
- 上图中着重描述了其最常见用法:使用
- 【注意】没有从【字符串·字面量】或【字节·字符串·字面量】至
CString / &CStr的直接语法指令。 - 【警告】
CString::from_raw(_: *mut libc::c_char)仅能导入由CString::into_raw() -> *mut libc::c_char导出的原始指针。CString::from_raw()导入任意【C字符串】会导致“未定义行为”。- 所以,直接由
C端程序(或libc::malloc())构造的【字符串·字节序列】还是得由&CStr引用才是最安全的。
- 所以,直接由
最后,相对于Vec<u8>的Rust内存字节数组,libc::malloc()就是从C内存里圈出一段连续且未初始化的内存空间,来保存【字符串·字节序列】。所以,由libc::malloc()分配出的内存段完全不受Rust内存安全机制的管控 — 馁馁地“放飞大自然”了。
- 就
Rust技术术语来讲,libc::malloc()输出【字符串·字节序列】的【所有权】属C端,但【引用】却在Rust端。这馁馁是从C至Rust的【按·引用】字符串传递! - 适用场景:
Rust以FFI函数【返回值】的方式向C程序传递【字符串·字节序列】(下面有详细的解释)。在其它任何场景下,libc::malloc()都极不推荐,因为更多的unsafe代码和更高的内存泄漏风险。
最佳实践原则
第一,最小化unsafe代码的数量。即,
-
多使用由
Rust标准库封装的C字符串类型CString&CStr
-
避免·直接操纵原始指针(
*const libc::c_char与*mut libc::c_char)。比如,libc::malloc(_: usize) -> libc::c_void, 在C内存区域内,开辟一段连续的内存空间std::ptr::write<T>(dest: *mut T, src: T)向指定位置写某个类型的数据。std::ptr::null()构造一个未初始化的只读·空指针std::ptr::null_mut()构造一个未初始化的可修改·空指针std::ptr::copy_nonoverlapping<T>(src: *const T, dest: *mut T, count: usize)逐字节的内存复制
等等
第二,尽量【按·引用】传递字符串,而不是【按·值】传递(即,逐字节·内存复制)。
干讲教条很抽象,下面我结合具体的使用场景,来详细地解释
结合场景解析
Rust导出extern "C" fn函数供C程序调用
场景一:Rust端,导出#[no_mangle] extern “C” fn set(_input: *const libc::c_char)函数,以【只读·入参】的形式,接收完全由C程序构造的C字符串。
- 忠告一:不要轻易尝试【按·值】接收【
C字符串·字节序列】。即,借助mut Vec<u8> + std::ptr::copy_nonoverlapping() --> CString --> String的组合“暴击”,将C内存上的C字符串逐字节地复制到Rust内存,再将其转码为Rust字符串 [偏简单·例程2] 和 [偏性能·例程1]。 - 忠告二:相反,借助
&CStr --> &str,构造一个从Rust指向C内存的【引用】 [例程3]。【按·引用】传递才是对内存使用效率最高的做法。
场景二:Rust端,导出#[no_mangle] extern “C” fn get() -> *mut libc::c_char函数,以【返回值】的形式,向C程序发送在Rust内存构造的C字符串。
- 忠告一:不要尝试【按·引用】传递函数的返回值,因为
- 就普通引用而言,
Rust借入检查器不允许·引用的生命周期·比·被引用数据的生命周期·更长。即,在get()函数里构造的C字符串·字节序列在函数结束时就被自动释放了,但是它的引用还要在被其它函数使用。这会招致编译失败。 - 就
unsafe代码与原始指针而言,被指针引用的数据脱离了Drop Checker监控会造成内存泄漏风险。
- 就普通引用而言,
- 忠告二:甩“锅”给
C调用端。于是,先libc::malloc(...)在C内存划出一段未初始化的字节数组;然后,将C字符串有效内容都给填过去;再,塞上尾字节\0;接着,把原始指针丢给C调用端程序;最后,Rust函数安全、合规地结束 [例程4]。完美甩锅!我们的程序已经结束了,数据“本尊”也已经在C内存里,C程序你看着办吧,别漏了!哈哈...
Rust导入与执行C函数
场景三:Rust端,导入extern "C" {fn set(_: *const libc::c_char);}函数,以【只读·实参】的形式,向C程序发送在Rust内存构造的C字符串。
- 忠告一:不要轻易尝试【按·值】发送【
C字符串·字节序列】。即,借助libc::malloc() + std::ptr::copy_nonoverlapping() + std::ptr::write()组合,将Rust内存上的C字符串逐字节地复制到C内存。 - 忠告二:相反,借助
String -> CString,先本地构造一个C字符串·字节序列;再,传递它的原始指针*const libc::c_char给C程序 [例程5]。- 首先,将运行时成本降到最低
- 其次,有限的程序设计心智成本。即,在
C端函数被执行期间,- 不释放本地
C字符串·字节序列的内存。即,让它的生命周期足够地长。 - 不修改
C字符串·字节序列内的字节值。
- 不释放本地
- 最后,甩锅。即,若
C程序需要长期持有此字符串数据,那就得C端开发者考虑:是否需要做一下字符串数据的【按·值】接收了。又一次完美“甩锅”!
场景四:Rust端,导入extern "C" {fn get(buffer: *mut c_char, size: c_uint) -> c_uint;}函数,以【可修改out实参】的形式,接收完全由C程序构造的C字符串。
-
先解释一下被导入函数 — 该导入函数有些不直观了
extern "C" { fn get(buffer: *mut c_char, size: c_uint) -> c_uint; }get(..)函数以【out入参】的方式(而不是·返回值)从C向Rust传递字符串输出值。buffer是【输出·参数】。其指向一段初始化为\0的字节数组。C程序向此指定的字节数组写入欲传递给Rust程序的C字符串(有效内容,不含尾字节\0)。size是【输入·参数】。其是buffer字节数组的长度。- 函数返回值代表了
C程序向buffer字节数组写入实际内容的长度。被写入内容不一定会正好占满整个buffer。
-
忠告一:不要轻易·使用
libc::malloc(),将接收C字符串的\0字节数组buffer直接·放到C端内存中去。 -
忠告二:相反,[例程6]
- 第一步,借助
vec![0_u8; N] -> *mut libc::c_char,本地构造一个\0初始化的Vec<u8>字节数组,和等着C程序向该Rust字节数组写数据。- 【注意】
Vec<u8>字节数组需要被显示地绑定于Rust函数内的某个具名变量,以确保该字节数组的生命周期足够地长,至少也得>= C端函数执行周期。否则,C端程序就会遭遇悬垂指针了。
- 【注意】
- 第二步,借助
Vec<u8> -> CString -> String,将收到的C字符串·字节序列转码成String实例。
这么搞,馁馁地,把控全场!
- 第一步,借助
结束语
其实,FFI传递复杂【自定义·数据结构】的底层原理与处理【字符串】非常相似。只不过,数据结构的编码方式变得更复杂了,没有C字符串与Rust字符串那么泾渭分明。所以,需要使用#[repr(C)]元属性等技术手段加以显示地标注。我对这块知识点还是处于“悟道”但未“悟透”的阶段。目前,实在写不明白,逻辑不自恰,应该还有地方理解错了。哎,真难!
这里,与大家共勉,共同进步吧。
评论区
写评论还没有评论