原创: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
字节拼合而成的字节序列。
- 要么,先初始化
vec![0_u8, N + 1]
字节数组;然后,用字符串有效内容复写前N
个字节;最后,保留尾字节是\0
[例程2]。
- 要么,先
Vec::with_capacity(N)
划出一段连续且未初始化内存;再,填充字符串有效内容;最后,由Vec::resize(N, 0)
扩展字节数组至N + 1
个字节和给尾字节写入\0
值 [例程1]。
- 其中,
N
代表C
字符串的有效内容长度。
- 值得注意,
vec![0_u8, N + 1]
宏要比系统指令zmalloc()
慢得多。如果你特别看重性能,那么下面描述的另一条技术路线应该更合你的意。
- 其中,
N
代表C
字符串的有效内容长度。
- 这样就绕过了较慢的
vec![0_u8, N]
宏了。
- 【
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
位需要由开发者自己人工增补上:
- 要么,先初始化
C
字符串】的CString
与&CStr
封装类型就相当于【Rust
字符串】的String
与&str
。CString
与String
的共同点
- 都是【所有权·智能指针】;
- 其内部【字节序列】都是被保存于
Rust
内存里
- 都是【所有权·智能指针】;
CString
与String
的不同点就是:【字节序列·编码格式】不同。
CString
是以\0
(或NUL
)结尾的,任意非\0
字节序列。
String
是UTF-8
。
&CStr
与&str
的共同点是
- 都是指向【字符串·字节序列】的切片引用
- 都是指向【字符串·字节序列】的切片引用
&CStr
与&str
的不同点是
- 上图中着重描述了其最常见用法:使用
&CStr
引用【C
内存】里的【C
字符串】。
&str
是【胖指针】;
CStr
是【智能指针】,但被【自动·解引用】之后的CStr
也是一个【胖指针】。
&CStr
既能引用C
内存里的C
字符串,也能引用Rust
内存里的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
的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
向指定位置写某个类型的数据。(dest: *mut T, src: T)
std::ptr::null()
构造一个未初始化的只读·空指针
std::ptr::null_mut()
构造一个未初始化的可修改·空指针
std::ptr::copy_nonoverlapping
逐字节的内存复制(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
的组合“暴击”,将+ 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
字节数组需要被显示地绑定于Rust
函数内的某个具名变量,以确保该字节数组的生命周期足够地长,至少也得>= C
端函数执行周期。否则,C
端程序就会遭遇悬垂指针了。
- 【字符串|原创(FFI极简应用场景【字符串·传输】浅谈)】第一步,借助
vec![0_u8; N] -> *mut libc::c_char
,本地构造一个\0
初始化的Vec
字节数组,和等着C
程序向该Rust
字节数组写数据。
- 第二步,借助
Vec
,将收到的-> CString -> String C
字符串·字节序列转码成String
实例。
- 【注意】
FFI
传递复杂【自定义·数据结构】的底层原理与处理【字符串】非常相似。只不过,数据结构的编码方式变得更复杂了,没有C
字符串与Rust
字符器那么泾渭分明。所以,需要使用#[repr(C)]
元属性等技术手段加以显示地标注。我对这块知识点还是处于“悟道”但未“悟透”的阶段。目前,实在写不明白,逻辑不自恰,应该还有地方理解错了。哎,真难!这里,与大家共勉,共同进步吧。
推荐阅读
- 人工智能|人类越来越懒是技术进步的根源!
- 大数据|80岁还在写代码!Hello World发明人、UNIX命名者项目登上GitHub热榜
- opencv|python-opencv 图像处理基础 (十)图像膨胀腐蚀+开闭操作+顶帽黑帽+形态学梯度
- python|100天精通Python(数据分析篇)——第52天(numpy完结)
- 工具使用|如何修改Jupyter Notebook工具的初始工作目录
- Excel|Day3 数据分析 Excel重要函数【零基础】
- Excel|Day2 数据分析 Excel-基础函数【零基础】
- 机器学习|python—sklearn特征提取
- 计算机视觉|opencv-python 机器视觉(宽度测量)