原创: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)]
元属性等技术手段加以显示地标注。我对这块知识点还是处于“悟道”但未“悟透”的阶段。目前,实在写不明白,逻辑不自恰,应该还有地方理解错了。哎,真难!
这里,与大家共勉,共同进步吧。
评论区
写评论还没有评论