Rust 导出共享库
资料来源:
<>
更新
1
2023.11.28 初始
导语
继续是转载 Rust 中文论坛的精华帖,帮助很大,感谢原作者!!
Rust 导出共享库 01
从前面的章节,我们可以看到,C 与 Rust/Rust 与 C 的交互,核心就是指针的操作。两边的代码使用的是同一个程序栈,栈上的指针能放心地传递,而不用担心被错误释放的问题(栈上内存被调用规则自动管理,C 和 Rust 中都是如此)。两边的代码可能使用不同的堆分配器,因此,堆上的指针的传递需要严格注意,需要各自管理各自的资源,谁创建谁释放。指针传递过程中,需要分析所有权问题。有了这种基本思维模型后,我们用 Rust 进行 FFI 编程,就会心中有数,知道什么时候该做什么,不再是一团浆糊了。
从本篇开始,我们进入新的领域:在 C 代码中调用 Rust 的功能。
我们先来看最简单的例子:C 中向 Rust 函数中,传入两个数,相加,并打印。
调用加法函数,并打印
Rust 代码:
1 | // 在 Cargo.toml 中,加入如下两行 |
要让 Rust 导出动态共享库,需要在 Cargo.toml 中这样设置的,必须。
1 | // src/lib.rs |
执行 *mut c_int
编译。会在 target/debug/ 下生成 lib{cratename}.so (我们这里为 librustffi3.so)这个动态链接库文件。
接下来看 C 代码:
1 |
|
编译:
1 | gcc -o ./ccode01 ./csrc/ccode01.c -L ./ -lrustffi3 |
会在当前目录下生成 *mut SumRecord
二进制文件(我已把 librustffi3.so 文件拷贝至当前目录)。运行
1 | LD_LIBRARY_PATH=. ./ccode01 |
输出下面结果:
1 | print in rust, sum is: 3 |
在 C 中处理返回值
上个示例,Rust 中计算的值,并没有返回给 C 这边。我们看看怎么返回回来。修改上述示例如下。
Rust 代码:
1 | // src/lib.rs |
C 代码:
1 | #include <stdio.h> |
运行生成结果如下:
1 | print in rust, sum is: 30 |
可以看到,直接给 Rust 函数和 C 导入的函数签名添加返回值类型就可以了,两边的类型要保持一致。
C 向 Rust 传入一个数组计算元素的和并返回
前面两个例子是最简单的整型类型的参数传递,能说明 Rust 导出共享库的基本样板操作。但在函数参数这块儿,能说明的问题有限。下面,我们设计一个新的例子:C 向 Rust 传入一个数组计算元素的和并返回。
先来看 C 代码:
1 | // csrc/ccode03.c |
配套的 Rust 代码:
1 | // src/lib.rs |
编译和运行代码:
1 | 编译 rust so:cargo build |
输出:
1 | print in c, sum is: 55 |
分析代码后,可以看到。数组的传递,实际是剖分成两个要素传递:
- 数组的地址,或首元素指针(这两个本质是一样的),数组的指针的类型就是指向数组首元素的指针的类型;
- 数组长度。数组的长度不是数组所占字节的长度,而是元素个数。
可以看到,这个例子中,C 中的数组是分配在栈上的,并且在分配时直接初始化了。
Rust 代码中,参数中的 cargo build
就对应 C 中的 ccode01
。
对于外界传入 Rust 的指针,Rust 这边,总是要先检查一下指针有效性的(确保不为空):
1 | assert!(!array.is_null()); |
Rust 拿到 C 传递过来的指针后,标准的规范是:
- 尽早转换为 Rust 的安全类型进行操作。也就是说,保证不安全(unsafe 块中的)的代码尽量少,并且直接使用这个指针的代码尽可能的少,转换成 Rust 中的标准类型再用。
- 尽量保证 zero cost。避免不必要的内存 copy 操作,影响性能。
为满足第一条规则,在转换前,我们的代码没有任何业务代码。
为满足第二条规则,这里使用了 slice 类型,而不是 Vec 类型:
1 | let array = unsafe { |
注意 *const u32
操作是 unsafe 的,因此需要包在 unsafe {} 中执行。
总结
本篇,我们研究了 Rust 导出动态链接库给 C 用的基本形式和规范。下一篇,我们会探讨字符串作为函数参数和返回值传递的细节。
本文所有代码在这里找到:https://github.com/daogangtang/learn-rust/tree/master/10rustffi3
Rust 导出共享库 02
这一篇我们来探讨 Rust 导出共享库时如何传递字符串,主要涉及字符串作为函数参数和函数返回值的处理细节。我们首先回顾关于字符串的基础知识,了解其在 Rust 和 C 中的区别,然后设计具体的示例进行实践,并整理出传递字符串的 FFI 编程范式。
基础知识
在 C 语言中,字符串可看作是由字符组成的一维的字节数组。但在内存中具体如何保存每个字符,这依赖于特定的字符编码。字符串常量默认是以 NUL 字符结尾,通常用转义序列 '\0'
表示,由 C 编译器自动添加。
字符串可以用指针和字节数组来表示,这是两种不同方式的存储:
将字符串存储在字符类型的数组中时,最初,字符串是字节序列,其中每个字节代表一个字符。但后来为了表示宽字符,ISO C 标准引入了新类型。一般,char
表示 ASCII 和 UTF-8 编码,wchar_t
表示 UTF-16 等 “ 宽 “ 字符编码。
大多数字符串和 I/O 库函数都采用 char *
参数,该参数表示指向字符串中的第一个字符(即存储该字符串的数组的第一个元素)。由于传递给函数的是第一个元素的地址,因此该函数并不知道数组有多大,只能依靠空终止符来判断何时停止处理。
1)共享的只读字符串 char *
。在大多数编译器中,将字符串字面量直接分配给指针后,字符串常量被存储于初始化数据段的只读(.roadata)区域,而指针变量被存储于读写区域中,也就是说可以更改指针以指向其它内容,但不能更改字符串常量的内容。因此,仅当不需要在程序的后期修改字符串时,应使用 char *
方式声明。
2)动态分配的可变字符串 char []
。将字符串对字节数组进行初始化后,在函数执行时会被拷贝到栈区或堆区(使用 malloc
),这时数组的内容是可以被修改的。因此,对于需要修改的字符串,应使用 char[]
方式声明。同时由于 C 指针是一个用数值表示的地址,因此,可以对指针执行算术运算来修改字符串。
代码示例如下:
1 | // ffi/example_01/csrc/hello.c |
在 Rust 语言中,字符串是由字符的 UTF-8 编码组成的字节序列。出于内存安全的考虑,字符串被分为了很多种类型来表示,我们来看一张图。
我们简单介绍以下几个类型,其余类型可以看 Rust 标准库的文档。
str
:这是 Rust 语言核心中仅有的一种字符串类型,Rust 标准库中提供了其它的字符串类型。&str
:表示不可变的 UTF-8 编码的字节序列,它是切片str
的引用类型;String
:表示可变的字符串,拥有所有权,其本质是一个成员变量是Vec<u8>
类型的结构体;CStr
:表示以空字符终止的 C 字符串或字节数组的借用,属于引用类型。一般用于和 C 语言交互,由 C 分配并被 Rust 借用的字符串;CString
:表示拥有所有权的,中间没有空字节,以空字符终止的字符串类型。一般用于和 C 语言交互时,由 Rust 分配并传递给 C 的字符串;
除此之外,从 Rust 的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇( 字母 的概念)。在 Rust 标准库中提供了对字符串按字符处理(chars())和按字节(bytes())处理的操作支持,其中单个字符是用 char
类型来表示,而使用 u8
来表示字节类型。注意:定义字符是使用单引号,用双引号定义的是字符串常量。
我们可以看到 Rust 提供了多种不同的方式来解释计算机储存的原始字符串数据,这样程序就可以选择它需要的表现方式。Rust 相比其他语言更多的暴露出了字符串的复杂性,这种权衡取舍使的程序员在开发中免于处理涉及非 ASCII 字符的错误。
示例实践
了解完这些基础知识后,我们设计示例来展示字符串作为函数参数和函数返回值的处理细节。
- 有
print_str
和change_str
两个函数,其参数均为 C 端生成的一个字符串,分别实现打印和修改该字符串的功能; - 有个
generate_str
函数,其返回值是 Rust 端生成的一个字符串,以及free_str
函数供 C 端调用者将字符串返回给 Rust 释放内存;
头文件如下:
1 | void print_str(char *str); |
Rust 共享库的实现如下:
1 | use std::os::raw::c_char; |
我们可以总结出在 Rust 和 C 之间传递字符串的编程范式。
- 使用
std::ffi::CStr
提供的from_ptr
方法包装 C 的字符串指针,它基于空字符'\0'
来计算字符串的长度,并可以通过它将外部 C 字符串转换为 Rust 的&str
和String
。 - 使用
std::ffi::CString
提供的一对方法into_raw
和from_raw
可以进行原始指针转换,由于将字符串的所有权转移给了调用者,所以调用者必须将字符串返回给 Rust,以便正确地释放内存。 - 我们必须确保 C 中的字符串是有效的 UTF-8 编码,且引用字符串的指针不能为 NULL,因为 Rust 的引用不允许为 NULL。
完整代码:https://github.com/lesterli/rust-practice/tree/master/ffi/example\_01
后记
出于严谨考虑,示例代码我用 valgrind 工具做了个内存泄露分析,发现虽然没有错误,但显示有个 “still reachable: 1,200 bytes in 7 blocks” 类型的泄露,我加上 --show-reachable=yes
选项进行定位,发现均发生在 C 端调用 Rust 的 print_str
函数处。谷歌找了半天原因,最终发现原来是跟 Rust 的行缓冲区 stdout
有关。
Rust 为了进行缓冲,它会分配一个静态的 vec
,它只执行一次,每次调用时重用现有缓冲区。因为我们此处是从 C 端运行,并不能控制其 main
函数,因此它将不会被释放,这就是 valgrind 报告的原因所在。我们知道只是打印字符串到控制台,所以这个泄露不用太担心。
Rust 导出共享库 03
这次,我们来关注一下 Rust 语言的基本特性到 C 的映射。
我们已经了解了,Rust 语言是多泛式(混合泛式)的语言,它可以做命令式(过程式)编程,也可以做面向对象编程,也可以做函数式编程。把 Rust 简单地归类为某种泛式的编程语言,都不太合适。Rust 就是 Rust。
C 语言是比较传统的过程式编程语言,因此,从 Rust 到 C 的转换,就会有一些无法直接对标的东西。于是,做这种映射工作就需要一些额外的规范或约定。
本文我们来关注:
- 结构体的方法的处理
- 泛型的处理
- Type alias
- Enum 到 C 的映射
结构体的方法的处理
我们知道,Rust 中,可以对结构体(或 enum 等)添加方法。这是属于面向对象的特性,而纯 C 是不支持这种特性的。于是,我们必须将这些方法单独实现为一批函数,在这批函数名前面加上统一的前缀,看下面代码:
rust 代码
1 | // rust |
这段代码翻译成 C 的时候,对应的大概会是下面这个样子:
1 | struct Foo { |
然而,这种映射是不能自动转换的(毕竟只是我们自己的约定),需要手动写出来。于是我们需要实现接口层的 Rust 代码:
1 | // We have struct Foo now |
然后,用这个接口层代码编译出动态链接库,C 那边使用就行了。
泛型的处理
泛型的处理稍微复杂一些。但实际原理也不难。在 Rust 中,泛型,我们指的是静态分派,另外还有一种,使用 trait object,实现动态分派。在这里,我们专注于静态分派的分析。
静态分派的意思是,编译器在编译时,根据你对泛型的具体化类型,进行特化展开处理。具体类型有几种,就复制几份不同的特化实现(因此增大了代码量)。这样,在调用时,就直接调用的特化后的函数/方法,而不再需要指针跳转一次了。所以,静态分派相对于动态分派,实际是用空间换时间,效率要高一些。
因此,我们在向 C 导出含泛型的方法时,也用静态分派的思维实现一个接口层就行了。
下面来看实际代码。比如,我们现在有如下 Rust 结构体:
1 |
|
并且实现了方法:
1 | impl<T> Buffer<T> { |
假如我们在实际中,用到了 i32 和 f32 两种类型。那么,我们实现 FFI 层的时候,需要这样写:
1 |
|
然后,对应的 C 这边的代码就是类似下面的:
1 | struct Buffer_i32 { |
可见,我们在 FFI 的 rust 方面,把方法名具体化了。在 C 这边,除了具体化的方法名,还把类型具体化了。就这样,适应了 C 这边无泛型的困扰。
细节的读者可能会发现,如果有 M 个方法,N 种类型,最后分出来的函数有:M x N 个。
Type Alias
Type alias 在 Rust 中,就使用 type
关键字,正好在 C 中,有 typedef 这个关键字,起到类似的功能。
比如,在 Rust 这边,有如下代码:
1 | // type.rs |
对应的 C 代码,会类似下面这个样子:
1 | struct Buffer_i32 { |
Type Alias 能让两边的类型名,看起来更一致。
枚举到 C 的映射
Rust 中,枚举分三大类:空枚举(Empty Enum),无字段枚举(Fieldless Enum)和带负载枚举(Data-carrying enum) 。
空枚举指的是:enum Foo;
这种形式。空枚举没有变体,是一个空类型,等于 !
。
无字段枚举,就是我们通常所说的 C-like 枚举。它的变体中不带有额外数据/字段。
1 | enum SomeEnum { |
带负载枚举是 Rust 的特色,就是变体中还带数据负载的枚举,类似下面这种:
1 | enum Foo { |
既然此处我们是要研究与 C 的对应关系,其实真正 Rust 要导出共享库给 C 使用的场景,涉及到的枚举(基本)都是 Fieldless Enum。所以我们这里只限于说明 Fieldless Enum 到 C 枚举布局上的一些细节。
Rust 的枚举上,可以标注其内存布局,像下面这样:
1 |
|
Rust 的枚举可以标注的布局种类有如下一些:
指定 int 位数布局
#[repr(u8)]
每个变体占用一个字节内存,以下类推#[repr(u16)]
#[repr(u32)]
#[repr(u64)]
#[repr(i8)]
#[repr(i16)]
#[repr(i32)]
#[repr(i64)]
指定 C 布局
#[repr(C)]
指定 C 布局,具体的每一个变体占用多少内存,是由当前平台的 C 编译器来决定的。也就是说 Rust 这边与对手方的 C 编译器的约定保持一致(比如,4 个字节),可能不同的平台,不同的 C 编译器,会有所不同。
组合指定
#[repr(C, u8)]
#[repr(C, u16)]
组合指定只能用在带负载枚举上(但是带负载枚举在实际场合中,跨 FFI 边界的场景并不多,如果有必要,后面开专题说明)。
而 Fieldless enum 只能指定 int 位数布局和 C 布局中的一种,不能组合指定。如:
1 |
|
转换到 C 中,可以把 A 与整数进行比较(从 0 开始递增,此处 A=0,B=1,C=2)。其它后续的就是 C 中枚举的知识了,此不赘述。
重要参考
以下链接,都值得一读。
- https://blog.eqrion.net/announcing-cbindgen/
- https://s3.amazonaws.com/temp.michaelfbryan.com/objects/index.html
- https://rust-lang.github.io/unsafe-code-guidelines/layout/enums.html
Rust 导出共享库 04
这节我们主要关注 Rust 导出共享库时的错误处理。主要涉及到:
- Option 和 Result 的处理
- panic 的处理
错误对于软件来说是不可避免的,错误处理是保证程序健壮性的前提,编程语言一般都会有一些机制来处理出现错误的情况,大致分为两种:抛出异常和作为值返回。
Rust 中没有异常,而是将错误作为值返回,并且通过将错误分成两个主要类别可恢复错误(Result<T, E>
)和不可恢复错误(panic!
)提供了 Rust 特色的错误处理机制。
C 虽然错误处理机制简陋,但最常见也是将错误作为值返回,其中的 POSIX 风格就是函数返回一个 int
值,其中 0
表示成功,而负值表示错误。基于 setjmp
/longjmp
的错误处理不属于此节的讨论范畴,如果有必要后面再做说明。
Option 和 Result 的处理
在 FFI 中允许使用任何 T: Sized
的 Option<&T>
和 Option<&mut T>
,代替显式地进行无效性(nullity )检查的指针。这是由于 Rust 保证了可空指针优化(nullable pointer optimization),在 C 端可以接受可空指针。C 端的 NULL
在 Rust 中被转换为 None
,而非空指针被封装在 Some
中。
我们知道 Rust 中的 Result <T,E>
是用于返回和传播错误的类型,其实质是一个枚举,其中 Ok(T)
表示成功并包含一个值,而 Err(E)
表示错误并包含一个错误值。
在设计 Rust 导出共享库时,我们可以使用返回值的错误处理机制,使 C 调用者可以通过检查返回值来检测何时发生了错误,并获得相关的错误信息。对于 Option 和 Result 的转换,我们一般采取以下一些方法:
- 简单的返回 C 中常用的数值,
0
表示正确,-1
表示错误。 - 返回类似于 C 中的全局
errno
,创建一个线程局部变量(thread_local!
),并在每次收到Option
参数后进行检查,返回相应的错误信息。 - 我们可以使用原始指针
std::ptr::null
和std::ptr::null_mut
来创建表示 C 端的空指针。
本节我们采取简单的返回数值,示例如下:
1 |
|
Panic 的处理
同时跨越 FFI 边界的 panic
会导致未定义的行为(Undefined Behavior,UB),我们还需要确保我们的 FFI 绑定是异常安全(Exception Safety)的。也就是说如果 Rust 导出库的代码可能会出现 panic
,则需要有个处理机制。在 FFI 绑定时我们可以使用 catch_unwind
将其包含在 Rust 中,从而不跨越 FFI 边界。
1 | use std::panic::catch_unwind; |
请注意,catch_unwind
只能捕获 Rust 中的展开(unwinding
)panic
,而不能处理 Rust 中的终止程序(abort
)panic
。
当出现
panic
时,Rust 程序默认会开始展开,这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接终止,这会不清理数据就退出程序。那么程序所使用的内存需要由操作系统来清理。通过在 Cargo.toml 的[profile]
部分增加panic = 'abort'
,程序在panic
时会由展开切换为终止。
完整代码:https://github.com/lesterli/rust-practice/tree/master/ffi/example\_02
相关文章:
- https://s3.amazonaws.com/temp.michaelfbryan.com/errors/index.html
- https://michael-f-bryan.github.io/rust-ffi-guide/errors/index.html
- https://doc.rust-lang.org/nomicon/repr-rust.html
Rust 导出共享库 05
本章,我们继续来玩耍跨 Rust 和 C FFI 边界的指针。
设计
本节,我们设计这样一个示例:
示例 1
- Rust 导出一个 so 库
- main 函数在 C 这边,链接 Rust 的 so 库
- C 中分配栈内存,交由 Rust 端填充
- Rust 端打印
- C 端打印
示例 2
- 同样的示例,C 中分配堆内存,交由 Rust 端填充,并且两边分别打印。
下面我们直接看示例 1 的代码。
示例 1 代码
Rust 端。
1 | // src/lib.rs |
记得 Cargo.toml 加上:
1 | [lib] |
C 端
1 | // csrc/cfoo1.c |
C 端代码这样编译:
1 | gcc -o ./cfoo1 ./cfoo1.c -L ./ -lrustffi4 |
(注意,我已经将 cargo build 生成的 librustffi4.so 文件从 target/debug/ 目录拷贝至 C 代码所在目录)
C 端二进制运行:
1 | LD_LIBRARY_PATH=. ./cfoo1 |
结果如下:
1 | print in rust side: Student { num: 1, total: 100 } |
可以看到,C 的栈空间上分配的结构体数组,已经被 Rust 这边成功填充了。
感觉已经没什么可讲的了。看过本教程之前内容的同学,应该会秒懂。
接着来看示例 2 的代码。
示例 2 的代码
示例 2 的代码,Rust 这边没有变化。下面直接看 C 这边的代码:
1 | // csrc/cfoo2.c |
C 端代码这样编译:
1 | gcc -o ./cfoo2 ./cfoo2.c -L ./ -lrustffi4 |
(注意,我已经将 cargo build 生成的 librustffi4.so 文件从 target/debug/ 目录拷贝至 C 代码所在目录)
C 端二进制运行:
1 | LD_LIBRARY_PATH=. ./cfoo2 |
结果如下:
1 | print in rust side: Student { num: 1, total: 100 } |
可以看到,两个示例打印结果完全一致。
示例 2 的 C 语言这边是在堆上 malloc 了一块内存,所以程序结束的时候,要记得 free 掉。
我们从两个示例的对比可以看到,C 这边栈和堆的指针,都可以用相同的 Rust 的代码。也就是说,Rust 这边,它就认 C 的指针,而不管这个指针是从哪里来,栈也好,堆也好,甚至其它地址的指针也好,对 Rust 来说,其实都一样(本质上都是内存指针)。
结论
本章通过构造两个示例,演示了 Rust 导出共享库 的一个操作场景,例子清晰明了,可细品。
本章示例的所有代码,皆可在:https://github.com/daogangtang/learn-rust/tree/master/11rustffi4 找到。