< 返回我的博客

爱国的张浩予 发表于 2022-07-31 15:21

Tags:ffi,extern,c-string,rust-string,cstr,cstring,libc

原创:FFI极简应用场景【字符串·传输】浅谈

导言

这篇文章分享了我对RustC程序之间字符串(字节序列)传输机制的“悟道”成果。【FFI字符串·传输】是FFI诸多概念中:

  • 最简单的里最难的 — 对比·各种整数类
  • 最难的里最简单的 — 对照·样式繁多的自定义数据结构

它算是难度适中,既能讲出点内容来,又不会知识点太过生涩劝退读者。上干货!

知识点“大图”

这还真是一张图。一图抵千词,再配上一些文字描述,应该能够把概念讲清楚。

image

首先,libc crate是操作系统常用ABIFFI binding

  • 一方面,在Cargo.toml中添加libc依赖项·就相当于·在C代码插入一行导入系统头文件的#include语句。
  • 另一方面,libc crate不是系统ABI的跨平台解决方案。所以,libc crate的下游使用者得自己区分在哪个操作系统平台上,调用libc crate的哪个API — 即便实现功能相同,在不同操作系统平台上,多半也得调用不同libc crate API
    • 若你想同一套程序跨平台,还是老老实实地上【条件·编译】吧!
  • 最后,libc crate不是包罗万象的。你要知道操作系统ABI有多少,有多庞大。libc crate的绑定范围很窄,粗略包括
    • -inux系统上,libclibmlibrtlibdllibutillibpthread
    • OSX系统上,libsystem_clibsystem_mlibsystem_pthreadlibsystem_malloclibdyld
    • 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

  • CStringString的共同点
    • 都是【所有权·智能指针】;
    • 其内部【字节序列】都是被保存于Rust内存里
  • CStringString的不同点就是:【字节序列·编码格式】不同。
    • CString是以\0(或NUL)结尾的,任意非\0字节序列。
    • StringUTF-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端。这馁馁是从CRust的【按·引用】字符串传递!
  • 适用场景:RustFFI函数【返回值】的方式向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_charC程序 [例程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入参】的方式(而不是·返回值)从CRust传递字符串输出值。
    • 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)]元属性等技术手段加以显示地标注。我对这块知识点还是处于“悟道”但未“悟透”的阶段。目前,实在写不明白,逻辑不自恰,应该还有地方理解错了。哎,真难!

这里,与大家共勉,共同进步吧。

评论区

写评论

还没有评论

1 共 0 条评论, 1 页