手动绑定 C 库入门
资料来源:
<>
更新
1
2023.11.27 初始
导语
继续是转载 Rust 中文论坛的精华帖,帮助很大,感谢原作者;
手动绑定 C 库入门 01
背景
本篇为一个新的章节《手动绑定 C 库入门》的第一篇。从这个章节开始,我们将会进行使用 Rust 对 C 库进行封装的实践。
这个章节,大概会由 6 ~8 篇文章组成。
从定下这个主题开始,笔者就策划选一个 Linux 下的简单点的 C 库,准备开干。
可惜笔者寻找了很久,尝试分析如下库的源代码:
- libtar
- libcsv
- libsqlite
- libgtop
- libgweather
- libimagemagick/libgraphicsmagick
发现对于第一篇代码实践的教程来说,还是太复杂了,最后还是回归到 Rust Nomicon Book 中的 FFI 小节所举的例子:snappy。
后面我们会对上述 C 库中的某一个或某几个进行实践操作。
Snappy 库的头文件翻译
官方这本书之所以要用 snappy 举例,(我想)也是因为它够简单。我们查看 snappy-c.h 头文件,发现里面只有如下几个定义:
1 | typedef enum { |
Rust Nomicon 这本书,讲得很深入。但可惜,它更多地是一本内部技术参考,而不是一本给初学者看的教程。在 FFI 这一节,也是讲得过于简略,并不适合作为初学者入门之用。本篇会大量摘取其中的内容。
在本系列前面的知识铺垫下,我们可以对上述头文件中的内容,做如下翻译。
先创建一个 Rust lib 项目。
1 | cargo new --lib snappy-rs |
编辑 [dependencies]
,在 [dependencies]
部分加入 libc
1 | [dependencies] |
编辑 src/lib.rs
,加入如下代码:
1 | use libc::{c_int, size_t}; |
到这里,我们就相当于把 snappy-c.h 头文件中的内容,翻译过来了。看起来相似,但是又不同。现在我们就来逐行讲解一下这个代码。
代码解析
1 | use libc::{c_int, size_t}; |
引入 libc 的必要符号,这些都是 C 中定义的符号,有的在 Rust 中有对应类型(比如这种整数类型),有的没有对应类型。这些符号会在下面的定义中用到。
1 |
[[Rust FFI 基础知识#外部块的属性 link
|link]] 属性指示,我们到时要链接 libc
这个库(比如,在 Linux 下就是对应 libsnappy.so 这个文件.
因为我们现在做的正是对 snappy 库的 Rust 封装。snappy 库是 C 写的,编译后,(一般)形成动态链接库,安装在系统约定路径中。C 库会有一个头文件,里面有各种被导出的类型的定义和函数和签名,这个文件就是外界调用这个 C 库的接口。Rust 也不例外,要封装这个 C 库,也要根据这个头文件中的定义,做相应的封装。我们做的是封装层,真正调用功能的时候,就会调到动态库中的 C 编译后的二进制符号中去。在编译时,会有一个链接的过程(详细知识点可以拓展为另一本书),在这个过程中,会进行符号的解析和地址的对接。
这个属性对紧跟在后面的那个 Item 起作用。于是往下看。
1 | extern { |
这个块,表明块里面的东东,是 “ [[Rust FFI 基础知识#1.3 外部块 ExternBlock
|外来]] “ 的。默认会使用 “C” ABI。完整的写法为:
1 | extern "C" { |
然后,看这个块里面的内容。
我们看到的是 5 个函数的定义(签名)。我们会发现,这 5 个函数,是 Rust 函数,Rust 代码,而不是 C 代码!是不是很神奇!那么,是怎么翻译过来的呢?这之间一定有一个对应规则,我们拿第一个函数来对比看一下,其它类似。
第一个函数的 Rust 代码为:
1 | fn snappy_compress(input: *const u8, |
而对应的 C 代码为:
1 | snappy_status snappy_compress(const char* input, |
函数名相同,不表。
先看返回值,Rust 代码返回 src/lib.rs
,C 代码,返回 snappy
类型, 它是个数字枚举类型,可取值为 0, 1, 2 中的一个。因此,两者是基本一样的。只是 Rust 这个封装为了简化,直接用一个 c_int
代替了数字枚举。Rust 中这个返回值的取值范围会大一些,理解上没那么清晰。
接下来看第一个参数。C 代码为 snappy_status
,RUST 代码为 c_int
。
Rust 中,input: *const u8
是指向常量的指针(通过这个指针,不能修改目标对象的值),对应的 const char* input
是指向变量的指针(通过这个指针,可以修改目标对象的值)。然后,后面是 u8。这是因为,在 C 中,一个字符串实际是一个字符数组,而这个字符数组通常用指向这个数组开始地址的一个字符指针 char* 来表示(在前面加 const,表示这个字符串不能被这个指针修改)。C 中的字符,其实就是一个字节,即 u8。故这两种写法,描述的是同一个东西。
接下来看第二个参数。Rust 代码为 *const
,C 代码为 *mut
。
就是定义一个整数变量 input_length,此处无需多言。
接下来看第三个参数。Rust 代码为 input_length: size_t
,C 代码为 size_t input_length
。
前面讲到过,compressed: *mut u8
是 Rust 中的一种指针,指向一个变量,并且可通过这个指针来修改这个变量。这里这个变量就是 compressed。同样,类型为 u8,意思就是指向一个连续内存空间的指针。这个连续内存空间,可用来存放 C 字符串。
接着看第四个参数。Rust 代码为 char* compressed
, C 代码为 *mut
。
这个的意思也类似,它的作用是用来存储压缩后的字符串的长度值。这个值计算出来后,填充到这个 compressed_length 变量中。这实际上是 C 的一种非常基础的设计风格:将计算结果放参数中(利用指针)传递。从 Rust 的设计角度来看,这种方式并不提倡。
至此,函数签名分析完成。可见,它们的转换,有一套内建的规则。其核心就是数据类型的转换。
使用 Extern 函数
那么,我们该如何使用呢?上面的包装,能直接使用吗?
答案是:能!
比如,我们可以这样来用其中的一个函数:
1 | fn main() { |
这个函数的作用,就是输入一个整数,然后计算一个整数输出。它本身的意义是根据给定的缓冲长度,计算压缩后的字符串的最大长度。
重要的是,要注意,调用这个函数,必须套在 compressed_length: *mut size_t
中调用。这里,体现了 Rust 的一个极其重要的设计哲学:所有外来,皆不可信。
也就是说,Rust 通过自己的理论和实践,千辛万苦,好不容易保证了自己这一套是 “ 安全 “ 的。凭什么要相信你一个外来的家伙是安全的?经过理论验证了吗?这种谨慎的设计哲学,使得 Rust 可以真正地严肃地来重新审视过去整个 IT 工业的基础,也使得 Rust 有潜力成为新时代的 IT 工业的基石。
但是,一直使用 size_t* compressed_length
,也不是办法啊,这不是 Rust 的风格。Rust 中应该尽量使用非 unsafe { }
代码。
因此,我们的工作才刚刚做了一半。我们应该封装成 Rust 风格的接口,并对外提供。
更加符合 Rust 口味的接口
上述 5 个接口,其中的 两个 xx_length
都是辅助函数,我们并不需要真正对用户导出。下面我们封装其它三个接口为 Rust 品味的函数。
snappy_max_compressed_length
检查缓冲区数据是不是正确的。
1 | pub fn validate_compressed_buffer(src: &[u8]) -> bool { |
此处,snappy_uncompressed_length
将 slice 转换成 validate_compressed_buffer
。它的定义在 std 文档中可以查到:
1 | pub const fn as_ptr(&self) -> *const T |
接下来是 compress 函数。这是主要函数之一。
1 | pub fn compress(src: &[u8]) -> Vec<u8> { |
这里,src.as_ptr()
把一个 Vec 转换成 *const T
。函数原型为:
1 | pub fn as_mut_ptr(&mut self) -> *mut T |
阅读上述代码,我们可以看出,在封装层函数内部,我们实际是用 Vec 分配了一个一定大小的缓冲区,并将这个缓冲区传入 C 函数使用。实际压缩工作是在 as_mut_ptr()
中做的,最后返回出人见人爱的 *mut T
,happy。
整个过程用 snappy_compress()
括起来。
第三个封装,Vec<u8>
,用于解压缩。
1 | pub fn uncompress(src: &[u8]) -> Option<Vec<u8>> { |
技术细节与前面类似,只是流程反过来。需要注意的是,解压的输入数据,有可能不是有效的压缩数据。因此,要判断处理,并返回一个 unsafe
,这才是 Rust。
三个接口封装完了,其实这个库已经算封装好了。下面看一下如何使用这个 Rust 库。我们在测试用例中体现一下用法
1 |
|
好了,这个简单的库就搞定了!以后,要对自己写的 C 库进行封装,也是同样道理。
Rust 的绑定,由 Rust 语言自己写
本篇代码,我们可以看到,整个 C 库的绑定层,都是 Rust 语言代码。可能你暂时还不熟悉那些指针转换什么的,但那确确实实是 Rust 代码。
如果你以前做过一些其它高级语言绑定 C 库的工作,那么你会对此深有体会,那些语言,都得用 C 语言来写绑定的。
看似简单的事情,其实反映了 Rust 的强大。其在设计之初,就强调了与 C 生态的无缝结合这个目标。同时也让 Rust 具有了对底层系统强大而精确的描述能力。厉害!
FFI 好像很简单
不~~
不是那么简单!
如果 FFI 编程,只有这么简单就好啦。我们在本篇,其实只是选了一个最简单的库。这个库,没有暴露任何结构体定义,参数中,没有数组,没有 void,没有函数指针,没有可变参数,没有回调,返回值也只是最简单的整数。没有考虑资源的所有权,回收问题。等等诸多细节,容我们后面慢慢道来。
本文代码主要参考:https://doc.rust-lang.org/nomicon/ffi.html#callbacks-from-c-code-to-rust-functions
可移步上述地址了解更多细节。
手动绑定 C 库入门 2
本篇是《手动绑定 C 库入门》的第二篇。了解第一篇后,我们知道在调用 C 库时,需要重新在 Rust 中对该 C 库中的数据类型和函数签名进行封装。这篇我们将实践涉及到诸如数组,结构体等类型时,如何进行手动绑定。
备注:有自动生成绑定的工具,比如,
bindgen
可以自动生成 C 库和某些 C ++ 库的 Rust FFI 绑定。但这个章节不涉及这些。
本篇的主要内容有:
- 数组示例
- 结构体示例
repr
属性- 结构体
- opaque 结构体
1. 数组示例
假定我们现在有个 C 库 c_utils.so
,其中有一个函数 int sum(const int* my_array, int length)
,给定一个整数数组,返回数组中所有元素的和。
1 | // ffi/rust-call-c/src/c_utils.c |
在 Rust 中绑定 C 库中的 sum 函数,然后直接通过 unsafe 块中调用。
1 | // ffi/rust-call-c/src/array.rs |
编译,然后执行输出如下结果:
1 | lyys-MacBook-Pro:src lyy$ rustc array.rs -o array -L. -lc_utils |
2. 结构体
结构体是由用户定义的一种复合类型,我们知道不同的语言使用不同的机制在计算机内存中布局数据,这样 Rust 编译器可能会执行某些优化而导致类型布局有所不同,无法和其他语言编写的程序正确交互。
类型布局(Type layout),是指类型在内存中的排列方式,是其数据在内存中的大小,对齐方式以及其字段的相对偏移量。当数据自然对齐时,CPU 可以最有效地执行内存读写。
2.1 repr
属性
为了解决上述问题,Rust 引入了 repr
属性来指定类型的内存布局,该属性支持的值有:
#[repr(Rust)]
,默认布局或不指定repr
属性。#[repr(C)]
,C 布局,这告诉编译器 “ 像 C 那样对类型布局 “,可使用在结构体,枚举和联合类型。#[repr(transparent)]
,此布局仅可应用于结构体为:- 包含单个非零大小的字段( newtype-like ),以及
- 任意数量的大小为 0 且对齐方式为 1 的字段(例如
PhantomData<T>
)
#[repr(u*)]
,#[repr(i*)]
,原始整型的表示形式,如:u8
,i32
,isize
等,仅可应用于枚举。
结构体的成员总是按照指定的顺序存放在内存中,由于各种类型的对齐要求,通常需要填充以确保成员以适当对齐的字节开始。对于 1 和 2 ,可以分别使用对齐修饰符 align
和 packed
来提高或降低其对齐方式。使用 repr
属性,只可以更改其字段之间的填充,但不能更改字段本身的内存布局。repr(packed)
可能导致未定义的行为,不要轻易使用。
以下是 repr
属性的一些示例:
1 | // ffi/rust-call-c/src/layout.rs |
2.2 结构体
为了说明在 Rust 中调用 C 库时,应该如何传递结构体?我试着找了一些 C 库,但由于有些库需要安装,最后决定通过标准库中的 time.h
来做示例。我们假定要在 Rust 程序中实现格式化日期格式的功能,可以通过调用这个标准库中的 strftime()
函数来完成。首先看头文件 time.h
,结构体及函数声明如下:
1 | struct tm { |
该函数根据 format 中定义的格式化规则,格式化结构体 timeptr 表示的时间,并把它存储在 str 中。这个函数使用了指向 C 结构体 tm
的指针,该结构体也必须在 Rust 中重新声明,通过类型布局小节,我们知道可以使用 repr
属性 #[repr(C)]
来确保在 Rust 中,该结构体的内存布局与在 C 中相同。
以下是对 strftime()
函数的 Rust FFI 手动绑定示例:
1 | use libc::{c_int, size_t}; |
接下来我们编写 Rust 程序,调用这个 C 库函数实现日期格式化功能,代码如下:
1 | use std::str; |
2.3 Opaque 结构体
一些 C 库的 API 通常是在不透明指针 (void *
) 指向的结构体上运行的一系列的函数。比如有以下 C 代码:
1 | struct object; |
目前在 Rust 中,比较推荐的一种做法是,通过使用一个拥有私有字段的结构体来声明这种类型。
1 |
|
同样的,对该 C 库中的函数进行 Rust FFI 手动绑定,示例如下:
1 | extern "C" { |
接下来我们调用这些函数,代码如下:
1 | // ffi/rust-call-c/src/opaque.rs |
编译,然后执行输出如下结果:
1 | lyys-MacBook-Pro:src lyy$ rustc opaque.rs -o opaque -L. -lffi_test |
注意:有一个 RFC 1861 ( 链接:https://github.com/canndrew/rfcs/blob/extern-types/text/1861-extern-types.md)用于引入> extern type
语法,但目前还未稳定。
总结
在 Rust 中调用 C 库,进行 Rust FFI 绑定:
- 传递结构体类型的参数时,可以使用
repr
属性#[repr(C)]
确保有一致的内存布局。 - 对于 C 库中的 Opaque 结构体类型的参数,在 Rust 中可以使用一个拥有私有字段的结构体来表示。
本文代码主要参考:https://github.com/lesterli/rust-practice/tree/master/ffi
手动绑定 C 库入门 03
所有权是 Rust 中最核心的关注点之一。在 Rust 中,变量有严格的所有权关系,并于此之上建立了一整套上层建筑。
本篇,我们对 Rust 调用 C 场景下的一种数据所有权场景进行编程。
之前例子为什么不需要关心所有权
上文的两个示例,实际是将 Rust 中的数据传到 C 中执行。为什么没有涉及所有权的问题呢?这里就来分析一下。
第一个示例:
1 | // ffi/rust-call-c/src/c_utils.c |
Rust 这边,将数组中的 int 元素传到 C 函数中执行相加运算。int 本身这种基础类型,默认按值传递(copy 一份传递)。
第二个示例
1 | fn main() { |
将 Rust 中初始化的结构体,转换成指针,传递到 C 函数中进行调用。本身只是借用读一下(不写)。这个结构体的所有权一直在 Rust 这边,由 t
掌控(表述不完全准确,但基本上是这个意思。原因是抽象降级到 C 这一层的时候,就不再自动分辨所有权了)。生命期结束时,由 Rust 的 RAII 规则,自动销毁。
以后,我们对于 int 这种自带 Copy(或按值传递)的类型,就不重点关注了,两边对照写就行了,没有什么有难度的地方在里面。
下面我们来研究一下另外两种场景。
Rust 调用 C,内存在 C 这边分配,在 Rust 中进行填充
为了分析清楚这个场景,我们设计了一个例子。在实现的过程中,遇到了相当多的坑。这方面的资料,中英文都非常缺乏。好在,经过一番摸索,最后算是找到了正确的方法。
这个例子的流程按这样设计:
- 在 C 端,设计一个结构体,字段有整型,字符串,浮点型
- 在 C 端,malloc 一块内存,是一个 n 个结构体实例组成的数组
- C 端,导出三个函数。create, print, release
- C 端代码编译成 .so 动态库
- 这三个函数,导入到 Rust 中使用
- 在 Rust 中,调用 C 的 create 函数,创建一个资源,并拿到指针
- 在 Rust 中,利用这个指针,填充 C 中管理的结构体数组
- 在 Rust 中,打印这个结构体数组
- 利用 C 的 print,打印这个结构体数组
- 调用 C 的 release,实现资源清理。
话不多说,直接上代码。
假如我们创建了一个名为 rustffi
的 cargo 工程。
C 端
1 | // filename: cfoo.c |
使用
1 | gcc -fPIC -shared -o libcfoo.so cfoo.c |
编译生成 libcfoo.so。
Rust 端
1 | use std::os::raw::{c_int, c_float}; |
使用
1 | RUSTFLAGS='-L .' cargo build |
编译。这里,RUSTFLAGS=’-L .’ 指定要链接的 so 的目录。我把上面生成的 libcfoo.so 放到了工程根目录,因此,指定路径为 .
,其它类推。
在工程根目录下,使用下面指令运行:
1 | LD_LIBRARY_PATH="." target/debug/rustffi |
会得到如下输出:
1 | rust side print: [Student { num: 1, total: 100, name: [77, 105, 107, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], scores: [30.0, 40.0, 30.0] }, Student { num: 1, total: 100, name: [77, 105, 107, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], scores: [30.0, 40.0, 30.0] }, Student { num: 1, total: 100, name: [77, 105, 107, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], scores: [30.0, 40.0, 30.0] }] |
可以看到,达到了我们的预期目标:在 Rust 中,修改 C 中创建的结构体数组内容。
完整可运行代码在:https://github.com/daogangtang/learn-rust/tree/master/08rustffi
要点(踩坑)分析
C 和 Rust 的结构体定义,两边要保持一致
比如:
C 中,
1 | typedef struct Students { |
对应的 Rust 中,
1 |
|
我之前翻译成了:
1 |
|
结果可以编译通过,但是一运行就发生段错误。读者可以想一想为什么?:D
name
的占用长度
关于 C 中数组指针的翻译问题
看如下函数签名:
1 | fn create_students(n: c_int) -> *mut Student; |
*mut Student
感觉只是指向一个实例的指针,或者说 分不清是一个实例还是一个实例数组。
对,发现这点就对了,C 语言里面,这个就是这样的,也不分(。。。从现在来看这个设计,其实有点奇葩)。所以 C 里面,在知道指针的情况下,还需要一个长度数据才能准确界定一个数组。
既然这样,那我们就这样写就行了。另外两个接口中的参数也是类似情况,不再说明。
神器 Slice
Rust 的 slice 提供的两个方法:slice::from_raw_parts()
和 slice::from_raw_parts_mut()
。这个东西是神器。实现了我们这个场景下的核心要求,资源在 C 那边管理,Rust 这边只是借用。但是填数据又是在 Rust 这边。
搜索标准库,我们会发现,Vec 也有这两个方法。这其实是对应的。slice 的这两个方法,不获取数据的所有权。Vec 的这两个方法,获取数据的所有权(必要的时候,会进行完全 Copy 一份)。
于是可以看到,Rust 中的所有权基础,直接影响到了 API 的设计和使用。
这两个方法必须用 unsafe 括起来调用。
C 字符串的细节
C 字符串末尾是带 \0
的。
1 | let c_string = CString::new("Mike").expect("CString::new failed"); |
这里这个 as_bytes_with_nul()
就是转成字节的时候,带上后面的 \0
。
1 | elem.name[..bytes.len()].copy_from_slice(bytes); |
这个目的就是把我们生成的数据源 slice,填充到目标 slice,也就是成员的 name 字符中去。
当然,不使用这些现成的 API 也是行的,可以这样
1 | elem.name[0] = b'M'; |
效果等价。但是明显没有用现成的 API 方便和安全。
c_char
c_char 内部定义为 i8,我们这里用的 u8,关系不大,用 c_char 的话,用 as 操作符转一下就好了。
所有权分析
整个 Rust 代码,实际就是调用了 C 导出的函数。C 那边的数据资源,完全由 C 自己掌控,分配和释放都是 C 函数自己做的(这点非常重要)。Rust 这边只是可变借用,然后填充了数据。
因为在这种跨 FFI 边界调用的情况下,内存的分配,完全可能不是同一个分配器做的,混用会出各种 undefined behaviour。所以,这些细节一定要注意。
同时也可以看到,Rust 和 C 竟然可以这样玩儿?Rust 太强大了。除了 C++,我暂时还想不到其它有什么语言能直接与 C 这样互操作的。
下一篇,我们将会分析第二种场景:
Rust 调 C,数据在 Rust 这边生成,在 C 中进行处理
手动绑定 C 库入门 04
本篇,我们说明 Rust 调用 C 的另外一种场景:内存在 Rust 这边分配,在 C 中进行填充。
我们依旧使用上一篇中设计的例子,稍作修改:在 C 端增加一个填充数据的函数 fill_data
,函数签名: Student* fill_data(Student *stu)
。整个示例流程如下:
- 在 C 端,有个结构体,字段有整型,字符串,浮点型;
- 在 C 端,两个函数,打印结构体数据的
print_data
,填充结构体数据的fill_data
; - 在 Rust 中,分配内存资源,初始化,并打印;
- 在 Rust 中,调用 C 中的
fill_data
填充结构体,并调用 C 中的print_data
打印; - 在 Rust 中,再次打印 C 填充后的结构体数据。
话不多说,直接上代码。我们创建了一个名为 example_09 的 cargo 工程。位于 csrc 目录的 C 端代码如下:
1 | // filename: cfoo.c |
使用 gcc -fPIC -shared -o libcfoo.so cfoo.c
编译生成 libcfoo.so
。
Rust 端的代码在 main.rs
中如下:
1 | use std::os::raw::{c_char, c_float, c_int}; |
将 C 端生成的 libcfoo.so
放到工程的根目录,使用 RUSTFLAGS='-L .' cargo build
编译。
然后在工程的根目录,使用下面指令运行: LD_LIBRARY_PATH="." target/debug/example_09
会得到如下输出:
1 | rust side print new_stu: CStudent { num: 0, total: 0, name: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], scores: [0.0, 0.0, 0.0] } |
可以看到,达到了我们的预期目标:在 Rust 中分配内存创建的结构体,在 C 中填充其内容并返回。
所有权分析与智能指针 Box
整个 Rust 代码,首先实现 Default
初始化结构体并打印;其次调用了导出的 C 函数 fill_data
,并在 C 端打印填充结构体的数据;最后再次打印。
在 Rust 中初始化的结构体,要将其传递到 C 函数中进行数据填充时,我们使用了 Rust 的智能指针 Box
。我们知道 Rust 与 C/C++ 不同的是,它不需要开发者显式地调用函数去分配和回收内存。而智能指针 Box
由于实现了 Drop
从而提供自动释放堆内存的功能,我们使用到它提供的两个方法:
fn into_raw(b: Box<T>) -> *mut T
unsafe fn from_raw(raw: *mut T) -> Box<T>
所有权分析如下:
(1)首先使用 Box
分配一块堆内存,并使用 Box::into_raw
函数(标准库描述:https://doc.rust-lang.org/beta/std/boxed/struct.Box.html#method.into_raw)返回其原始指针,在确保和> C 端内存对齐的同时,完成所有权的转移,也就是说执行后, p_stu
负责了由之前 box_new_stu
管理的内存。
(2)然后调用 C 端的函数 fill_data
填充数据,并调 C 端函数 print_data
打印填充后的数据。
(3)最后在 Rust 端再次打印填充后的数据,其中使用了 Box::from_raw
函数(标准库描述:https://doc.rust-lang.org/beta/std/boxed/struct.Box.html#method.from_raw)将原始指针转换回> Box
,所有权又转移到 Rust 这边,从而由 Rust 的 RAII 规则允许 Box
析构函数执行清除操作,正确销毁并释放内存。这个方法必须用 unsafe 括起来调用。
Valgrind
Valgrind(https://valgrind.org/)是用于构建动态分析工具的基础框架。基于它的内存泄露检测工具> Memcheck 可以自动检测许多内存管理和线程错误。
我们使用它验证程序的结果如下:
1 | ➜ example_09 git:(master) /usr/bin/valgrind --tool=memcheck --leak-check=full ./target/debug/example_09 |
其中 25534
是进程 ID,13 行显示:total heap usage: 21 allocs, 21 frees, 4,473 bytes allocated
,表明堆内存的使用情况,共发生 21 次分配和释放,内存大小为 4473 字节;同时 15 行显示:All heap blocks were freed -- no leaks are possible
, 它表明所有的堆内存已被释放,没有泄露。
如果删除掉这行代码 println!("rust side print Bob: {:?}", Box::from_raw(p_stu));
,大家觉得会有怎样的结果呢?
编译执行,程序运行正常,得到如下输出:
1 | rust side print new_stu: CStudent { num: 0, total: 0, name: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], scores: [0.0, 0.0, 0.0] } |
但想一想,这样有问题吗?
我们再次执行工具 Memcheck 检测下是否有内存泄露?
1 | ==13973== Memcheck, a memory error detector |
根据 Memcheck 的手册 分析结果:堆分配信息在 10,11 行显示的 in use at exit: 40 bytes in 1 blocks
和 total heap usage: 21 allocs, 20 frees, 4,473 bytes allocated
表明有一块内存未释放;同时泄露信息在 31 行 definitely lost: 40 bytes in 1 blocks
,这意味着找不到指向该块的指针,可能未在程序退出时将其释放, 此类情况应由程序员解决。
结语
在 Rust 调用 C 时,使用 Box::into_raw
函数返回原始指针并转移所有权将该指针传给 C ,之后在 Rust 端必须显式的使用 Box::from_raw
函数将原始指针转换回 Box
,把所有权转移回来。这样才能正确销毁并释放内存。
完整的示例代码:https://github.com/lesterli/rust-practice/tree/master/ffi/example_09
手动绑定 C 库入门 05
本篇,咱们一起来研究 Rust 与 C 之间的回调函数传递。本篇的目标如下:
- 被调函数在 C 端,接收一个函数指针作为回调函数,并调用;
- 主函数在 Rust 中,在 Rust 中调用 C 端的这个函数;
- 在 Rust 中,传递一个 Rust 中定义的函数,到这个 C 端的被调函数中作为回调函数。
为什么要研究跨 FFI 的回调函数,因为
- 有可能想在底层事件(异步)框架中,注册一个函数,事件触发的时候,调用;
- 底层采用注册一个路由表的形式,在程序开始的时候,注册一堆函数操作进去;
- 其它。
这是一种常见需求,也是一种设计模式。
基础示例
话不多说,我们来设计一个示例流程:
- C 端,设计一个函数,
sum_square_cb01
, 接收两个整型参数 a, b,和一个函数指针,计算 $a^2 + b^2$ 的值,并且将值传递进第三个参数(函数中),进行打印; - Rust 端,定义一个回调函数
cb_func
,在这个回调函数中,打印上述平方和; - Rust 端,引入 C 中定义的
sum_square_cb01
; - 在 Rust 的 main 中,调用
sum_square_cb01
。
好,直接上代码。C 端:
1 | // csrc/ccode01.c |
Rust 端:
1 | // src/r01.rs |
两边代码其实挺简洁。不过也有要注意的一些地方。要点提醒:
- 两边都需要定义回调函数的类型(签名),而且定义要一致。
C 中定义:
1 | typedef void (*SumSquareCB)(int result); |
Rust 中定义:
1 | pub type SumSquareCB = unsafe extern fn(c_int); |
fn
是 Rust 中的函数指针类型。具体可参见标准库文档 fn,解释得非常详尽。
函数指针的功能就是指向函数代码片断,可以用函数指针来调用函数,效果跟函数名一样,如上面 C 代码中的
cb(result)
。
- Rust 中的回调函数定义
1 | pub unsafe extern fn cb_func(result: c_int) { |
是 Rust 中定义回调函数的代码,注意前面加的 unsafe 和 extern 修饰关键字。回调函数签名,要与前面定义的回调函数类型完全一致(此处接受一个整型参数,并且没有返回值)。
- 代码的编译方式,见 前一篇,此不赘述。
运行
1 | RUSTFLAGS='-L .' LD_LIBRARY_PATH="." cargo run --bin r01 |
输出:
1 | The result in callback function is: 25 |
在回调函数中,更新外部数据
我们的上述代码(目前只有一条打印语句),可以适用于在回调函数中不需要改变外界数据的情况。而在实际情况下,我们使用的回调的逻辑,要求用回调更新一些程序中其它地方持有的数据,这种需求,使用上面的代码,就不能满足要求了。
我们很自然地想到了 C 中常用的全局变量大法。非常方便,无脑引用,并且这确实是可以实现的。但是,在 Rust 中,我们严重不推荐使用全局变量,故不举出全局变量的例子(防止只看片断的人,抄出不良风气)。
那我们这样行不行呢?
1 | // src/r01-1.rs |
肯定是不行的。报如下错:
1 | error[E0434]: can't capture dynamic environment in a fn item |
提示这里应该用闭包。闭包跟函数还是不同的。闭包简单来说,由函数 + 被捕获的数据两大块儿组成。
那我们用闭包试试看:
1 | // src/r01-2.rs |
编译,提示:
1 | error[E0308]: mismatched types |
说这里类型不匹配。使用闭包,解决我们的问题,是肯定可以的。但是,需要有更多知识,我们专门放在下一节中讲解。本节,我们专注于用函数指针解决问题。
其实我们遇到的问题,在 C 的领域,早就是一种常见的问题(比如一个 GUI 库的回调函数),所以其实也早就有对应的解决方案,比如,使用 C 中的魔幻主义的 void *
携带一个数据块传递。了解过 void *
的就知道,它和 C 中的其它指针一起,几乎把 C 变成了一门动态语言(所以有一种说法认为 C 其实是 弱类型语言
?)。
void *
是一种通用指针,意思是 “ 指向某个东西的指针 “,它的灵活和强大之处在于,可以强制转换到任何指针类型。这里,我们也可以使用 void *
来传递我们的 “ 数据块 “。
有同学要问,为何不让回调函数直接返回一个值来达到我们想要实现的效果呢?所谓回调函数,一般处于调用链的末端,在这个函数里,实现对外部数据的更新。如果对返回值进行处理,则破坏了逻辑封装的抽象(需要在回调函数外写对应的逻辑代码,而回调函数外往往是框架代码)。
于是,我们继续规划一下我们的示例更新。在前面的基础之上:
- 在 Rust 的 main 函数中,定义一个变量 sum;
- 在 Rust 中定义的回调函数中,更新这个变量 sum;
- 由于需要传递数据块地址,需要修改回调函数的签名定义;
那我们直接上代码。
C 端:
1 | // csrc/ccode02.c |
Rust 端:
1 | use std::os::raw::c_int; |
运行
1 | RUSTFLAGS='-L .' LD_LIBRARY_PATH="." cargo run --bin r01 |
输出:
1 | The sum is 25 |
要点:
- std::ffi::c_void
Rust 端引入了 std::ffi::c_void;
。这是 Rust 给我们提供的强大的基础设施,不然我们真要愁眉苦脸了。从标准库页面可以学习到,Rust 中的 *const c_void
等于 C 的 const void*
,Rust 中的 *mut c_void
等于 C 的 void*
。(C 中的 void 函数返回值本身,与 Rust 的空值类型 () 相等)
请仔细体会上述代码中的各处 void *
与 *mut c_void
的写法和对应关系。
可以看到,void 指针就像一个万能的桥一样,让我们能够到处任意传递数据块。
- 回调函数中的类型强制转换
1 | pub unsafe extern fn cb_func(result: c_int, user_data: *mut c_void) { |
前面提过,void *
的强大就在于可以与任意指针类型进行强制转换。在 Rust 也是对应的,也可以与任意指针类型强制转换。
1 | let data = &mut *(user_data as *mut c_int); |
这一句,先是把 *mut c_void
指针转换成 *mut c_int
指针,然后用 *
取它的数据块,然后用 &mut
取这个数据块的可变引用,进入 Rust 的常规领域(标准使用模式)。
然后,
1 | *data += result; |
就是更新数据块中的值了。
- 传参时的
1 | unsafe { |
这里:
1 | &mut sum as *mut c_int as *mut c_void |
是上述转换过程的逆过程。先将 &mut sum
(sum 的可变引用),转换成 *mut c_int
(c_int 类型的指针),进而转换成 *mut c_void
(通用指针)。
- 打印语句
本身中的打印语句,是在 Rust 的 main 函数中,打印的是 main 函数中定义的 sum(而第一例是在回调中打印的)。因此,可以看到,sum 的值,确实是在回调函数中,被修改过了。达到了我们的目的。
好了,我们的想法其实已经实现了。但是本例仅仅更新了一个整数,貌似没多大用。真实世界中,一般是更新一个结构体。那我们就更进一步,研究一下,怎么更新结构体。
其实非常简单。
更新结构体
同样直接上代码,然后再讲要点。
C 端:
1 |
|
Rust 端:
1 | use std::os::raw::c_int; |
运行:
1 | RUSTFLAGS='-L .' LD_LIBRARY_PATH="." cargo run --bin r03 |
输出:
1 | The sum is SumRecord { sum: 25, elem_number: 1 } |
解析:
- C 中代码没变
可以看到,其实 C 中代码没有变化。
- Rust 中加了结构体定义
1 |
|
就是一个普通的 Rust 结构体定义。
- 魔法在哪里?
Rust 中的回调函数签名都没有变化。变化在下面这里:
1 | let data = &mut *(user_data as *mut SumRecord); |
可以看到,就是把例 2 中的 *mut c_int
变成了 *mut SumRecord
了。然后,更新数据的时候,按 Rust 结构体更新的方式操作就可以了。
1 | unsafe { |
同样,这个逆过程也变化了,仔细体会。
就这样,我们就实现了在回调函数中,更新外部结构体。达成我们的理想要求。
总结
在本篇,我们研究了 Rust 与 C 如何跨 FFI 边界实现回调函数的调用,以及在回调中更新外部数据。全篇内容,主要参考:
- http://adventures.michaelfbryan.com/posts/rust-closures-in-ffi/ 感谢作者的精彩分享
- Rust 标准库文档
文章中的代码在:https://github.com/daogangtang/learn-rust/tree/master/09rustffi2
手动绑定 C 库入门 06
我们继续研究 Rust 与 C 之间传递回调函数,上一篇使用的是函数指针,本文介绍如何使用闭包来实现这个问题。我们回顾下目标:
- 在 C 端有个函数,有个回调函数作为参数;
- 在 Rust 端,有个闭包;并在主函数中,要使用定义的闭包调用 C 端的那个函数。
闭包
我们知道 Rust 的闭包不仅是一个函数指针,这意味着不能使用它作为回调函数的函数指针直接传递给 C 端。
同时我们也知道 Rust 中的所有的闭包都实现了由标准库提供的 trait Fn
、FnMut
或 FnOnce
中的一个。闭包语法 || {}
实际上是 Fn
系列 trait 的语法糖,Rust 会为 “ 环境 “ 创建一个结构体,impl
其中合适的一个 trait,并使用它。
因此,从理论上讲,我们应该能够通过将闭包 “ 拆分 “ 为两部分,匿名类型的实例数据和某种类似 call()
方法的函数。这样我们可以获取其中函数部分的指针,从而实现将闭包传递给 C 端代码。
具体的方法就是:首先创建一个泛型 hook 函数,该函数和回调函数的参数列表一样,在其中构建并调用闭包。然后创建一个 getter 函数,该函数接受闭包的引用作为参数,返回一个函数指针。
我们沿用上篇设计的示例,稍作修改:
- C 端,
sum_square_cb
函数,接收两个整型参数 a, b,一个函数指针,一个void *
; - Rust 端,定义一个 getter 函数
get_callback
; - Rust 端,定义一个闭包,被调用时更新数据 user_data;
- Rust 端,调用 C 中定义的
sum_square_cb
;
好,代码部分 C 端保持不变,我们看 Rust 端的两个函数 hook
和 get_callback
,代码如下:
1 | // ffi/example_10/src/main.rs |
由于我们希望闭包能改变其环境,所以在定义 hook
函数时,我们限定闭包实现为 FnMut
并以 c_int
作为参数。在函数体中的这一句 let closure = &mut *(user_data as *mut F);
,先通过把 *mut c_void
指针转换成 *mut F
指针,然后用 *
取得它的数据块,并使用 &mut
取得可变引用 ,最后调用闭包。
同时 get_callback
函数中仅有的语句,hook::<F>
,我们使用了一个叫做 turbofish ::<>
的语法,用来显式指定返回 F
类型的 hook
函数。
接下来我们 Rust 端的主函数,代码如下:
1 | fn main() { |
这个 let mut closure
语句意味着 closure
包含一个匿名函数的 定义,而不是调用后的 返回值,该函数接受一个 c_int
类型的参数。我们使用闭包的原因是需要事先定义一段代码,并在之后的某个时候才实际调用它。这里我们将期望调用的代码储存在了 closure
中。
接着我们调用 get_callback
,其中有一点非常重要,它返回的函数指针只能在传入的同一闭包上使用。因为我们定义 hook
函数时在未进行任何类型检查的情况下,将 user_data
直接转换为该闭包类型的指针。
同时在调用 C 端函数 sum_square_cb
时,我们通过获取闭包变量 closure
的可变引用,并进行两次指针转换,将其强制转换为 void *
指针来获取其数据。其中我们使用了 _
占位符由 Rust 编译器来推断该位置的闭包类型。
原来的 callback 不再执行逻辑,而是执行 传入的闭包;
原来传入的 user_data: *mut c_void
, 被当作了 闭包的指针, 闭包内包含了逻辑 (对环境变量的修改).
感觉 闭包有点多余吗? 是自己理解未深吧. 就是理解未深入..😂
小结
我们使用 Rust 调用 C 时,要在两者之间传递闭包,可以通过将闭包 “ 拆分 “ 出函数指针来完成这个操作。
本篇的完整代码位于:https://github.com/lesterli/rust-practice/tree/master/ffi/example_10