背景
本篇为一个新的章节《手动绑定 C 库入门》的第一篇。从这个章节开始,我们将会进行使用 Rust 对 C 库进行封装的实践。
这个章节,大概会由 6 ~8 篇文章组成。
从定下这个主题开始,笔者就策划选一个 Linux 下的简单点的 C 库,准备开干。
可惜笔者寻找了很久,尝试分析如下库的源代码:
libtar
libcsv
libsqlite
libgtop
libgweather
libimagemagick/libgraphicsmagick
发现对于第一篇代码实践的教程来说,还是太复杂了,最后还是回归到 Rust Nomicon Book 中的 FFI 小节所举的例子:snappy。
后面我们会对上述 C 库中的某一个或某几个进行实践操作。
官方这本书之所以要用 snappy 举例,(我想)也是因为它够简单。我们查看 snappy-c.h 头文件,发现里面只有如下几个定义:
typedef enum { SNAPPY_OK = 0, SNAPPY_INVALID_INPUT = 1, SNAPPY_BUFFER_TOO_SMALL = 2} snappy_status;
snappy_status snappy_compress(const char* input, size_t input_length, char* compressed, size_t* compressed_length);
snappy_status snappy_uncompress(const char* compressed, size_t compressed_length, char* uncompressed, size_t* uncompressed_length);
size_t snappy_max_compressed_length(size_t source_length);
snappy_status snappy_uncompressed_length(const char* compressed, size_t compressed_length, size_t* result);
snappy_status snappy_validate_compressed_buffer(const char* compressed, size_t compressed_length)
Rust Nomicon 这本书,讲得很深入。但可惜,它更多地是一本内部技术参考,而不是一本给初学者看的教程。在 FFI 这一节,也是讲得过于简略,并不适合作为初学者入门之用。本篇会大量摘取其中的内容。
在本系列前面的知识铺垫下,我们可以对上述头文件中的内容,做如下翻译。
先创建一个 Rust lib 项目。
cargo new --lib snappy-rscd snappy-rs
编辑 Cargo.toml,在 [dependencies] 部分加入 libc
[dependencies]libc = "0.2"
编辑 src/lib.rs,加入如下代码:
use libc::{c_int, size_t};
#[link(name = "snappy")]extern { fn snappy_compress(input: *const u8, input_length: size_t, compressed: *mut u8, compressed_length: *mut size_t) -> c_int; fn snappy_uncompress(compressed: *const u8, compressed_length: size_t, uncompressed: *mut u8, uncompressed_length: *mut size_t) -> c_int; fn snappy_max_compressed_length(source_length: size_t) -> size_t; fn snappy_uncompressed_length(compressed: *const u8, compressed_length: size_t, result: *mut size_t) -> c_int; fn snappy_validate_compressed_buffer(compressed: *const u8, compressed_length: size_t) -> c_int;}
到这里,我们就相当于把 snappy-c.h 头文件中的内容,翻译过来了。看起来相似,但是又不同。现在我们就来逐行讲解一下这个代码。
use libc::{c_int, size_t};
引入 libc 的必要符号,这些都是 C 中定义的符号,有的在 Rust 中有对应类型(比如这种整数类型),有的没有对应类型。这些符号会在下面的定义中用到。
#[link(name = "snappy")]
这个属性指示,我们到时要链接 snappy
这个库(比如,在 Linux 下就是对应 libsnappy.so 这个文件)。
因为我们现在做的正是对 snappy 库的 Rust 封装。snappy 库是 C 写的,编译后,(一般)形成动态链接库,安装在系统约定路径中。C 库会有一个头文件,里面有各种被导出的类型的定义和函数和签名,这个文件就是外界调用这个 C 库的接口。Rust也不例外,要封装这个 C 库,也要根据这个头文件中的定义,做相应的封装。我们做的是封装层,真正调用功能的时候,就会调到动态库中的 C 编译后的二进制符号中去。在编译时,会有一个链接的过程(详细知识点可以拓展为另一本书),在这个过程中,会进行符号的解析和地址的对接。
这个属性对紧跟在后面的那个 Item 起作用。于是往下看。
extern {
}
这个块,表明块里面的东东,是“外来”的。默认会使用 "C" ABI。完整的写法为:
extern "C" {
}
然后,看这个块里面的内容。
我们看到的是 5 个函数的定义(签名)。我们会发现,这 5 个函数,是 Rust 函数,Rust 代码,而不是 C 代码!是不是很神奇!那么,是怎么翻译过来的呢?这之间一定有一个对应规则,我们拿第一个函数来对比看一下,其它类似。
第一个函数的 Rust 代码为:
fn snappy_compress(input: *const u8,
input_length: size_t,
compressed: *mut u8,
compressed_length: *mut size_t) -> c_int;
而对应的 C 代码为:
snappy_status snappy_compress(const char* input,
size_t input_length,
char* compressed,
size_t* compressed_length);
函数名相同,不表。
先看返回值,Rust 代码返回 c_int
,C 代码,返回 snappy_status
类型, 它是个数字枚举类型,可取值为 0, 1, 2 中的一个。因此,两者是基本一样的。只是 Rust 这个封装为了简化,直接用一个 c_int
代替了数字枚举。Rust 中这个返回值的取值范围会大一些,理解上没那么清晰。
接下来看第一个参数。Rust 代码为 input: *const u8
,C 代码为 const char* input
。
Rust 中,*const
是指向常量的指针(通过这个指针,不能修改目标对象的值),对应的 *mut
是指向变量的指针(通过这个指针,可以修改目标对象的值)。然后,后面是 u8。这是因为,在 C 中,一个字符串实际是一个字符数组,而这个字符数组通常用指向这个数组开始地址的一个字符指针 char* 来表示(在前面加 const,表示这个字符串不能被这个指针修改)。C 中的字符,其实就是一个字节,即 u8。故这两种写法,描述的是同一个东西。
接下来看第二个参数。Rust 代码为 input_length: size_t
,C 代码为 size_t input_length
。
就是定义一个整数变量 input_length,此处无需多言。
接下来看第三个参数。Rust 代码为 compressed: *mut u8
,C 代码为 char* compressed
。
前面讲到过,*mut
是 Rust 中的一种指针,指向一个变量,并且可通过这个指针来修改这个变量。这里这个变量就是 compressed。同样,类型为 u8,意思就是指向一个连续内存空间的指针。这个连续内存空间,可用来存放 C 字符串。
接着看第四个参数。Rust 代码为 compressed_length: *mut size_t
, C 代码为 size_t* compressed_length
。
这个的意思也类似,它的作用是用来存储压缩后的字符串的长度值。这个值计算出来后,填充到这个 compressed_length 变量中。这实际上是 C 的一种非常基础的设计风格:将计算结果放参数中(利用指针)传递。从 Rust 的设计角度来看,这种方式并不提倡。
至此,函数签名分析完成。可见,它们的转换,有一套内建的规则。其核心就是数据类型的转换。
那么,我们该如何使用呢?上面的包装,能直接使用吗?
答案是:能!
比如,我们可以这样来用其中的一个函数:
fn main() {
let x = unsafe { snappy_max_compressed_length(100) };
println!("max compressed length of a 100 byte buffer: {}", x);
}
这个函数的作用,就是输入一个整数,然后计算一个整数输出。它本身的意义是根据给定的缓冲长度,计算压缩后的字符串的最大长度。
重要的是,要注意,调用这个函数,必须套在 unsafe { }
中调用。这里,体现了 Rust 的一个极其重要的设计哲学:所有外来,皆不可信。
也就是说,Rust 通过自己的理论和实践,千辛万苦,好不容易保证了自己这一套是“安全”的。凭什么要相信你一个外来的家伙是安全的?经过理论验证了吗?这种谨慎的设计哲学,使得 Rust 可以真正地严肃地来重新审视过去整个 IT 工业的基础,也使得 Rust 有潜力成为新时代的 IT 工业的基石。
但是,一直使用 unsafe
,也不是办法啊,这不是 Rust 的风格。Rust 中应该尽量使用非 unsafe
代码。
因此,我们的工作才刚刚做了一半。我们应该封装成 Rust 风格的接口,并对外提供。
上述 5 个接口,其中的 snappy_max_compressed_length
和 snappy_uncompressed_length
都是辅助函数,我们并不需要真正对用户导出。下面我们封装其它三个接口为 Rust 品味的函数。
validate_compressed_buffer
检查缓冲区数据是不是正确的。
pub fn validate_compressed_buffer(src: &[u8]) -> bool { unsafe { snappy_validate_compressed_buffer(src.as_ptr(), src.len() as size_t) == 0 }}
此处,src.as_ptr()
将 slice 转换成 *const T
。它的定义在 std 文档中可以查到:
pub const fn as_ptr(&self) -> *const T
接下来是 compress 函数。这是主要函数之一。
pub fn compress(src: &[u8]) -> Vec { unsafe { let srclen = src.len() as size_t; let psrc = src.as_ptr();
let mut dstlen = snappy_max_compressed_length(srclen); let mut dst = Vec::with_capacity(dstlen as usize); let pdst = dst.as_mut_ptr();
snappy_compress(psrc, srclen, pdst, &mut dstlen); dst.set_len(dstlen as usize); dst }}
这里,as_mut_ptr()
把一个 Vec 转换成 *mut T
。函数原型为:
pub fn as_mut_ptr(&mut self) -> *mut T
阅读上述代码,我们可以看出,在封装层函数内部,我们实际是用 Vec 分配了一个一定大小的缓冲区,并将这个缓冲区传入 C 函数使用。实际压缩工作是在 snappy_compress()
中做的,最后返回出人见人爱的 Vec
,happy。
整个过程用 unsafe
括起来。
第三个封装,uncompress
,用于解压缩。
pub fn uncompress(src: &[u8]) -> Option> { unsafe { let srclen = src.len() as size_t; let psrc = src.as_ptr();
let mut dstlen: size_t = 0; snappy_uncompressed_length(psrc, srclen, &mut dstlen);
let mut dst = Vec::with_capacity(dstlen as usize); let pdst = dst.as_mut_ptr();
if snappy_uncompress(psrc, srclen, pdst, &mut dstlen) == 0 { dst.set_len(dstlen as usize); Some(dst) } else { None // SNAPPY_INVALID_INPUT } }}
技术细节与前面类似,只是流程反过来。需要注意的是,解压的输入数据,有可能不是有效的压缩数据。因此,要判断处理,并返回一个 Option
三个接口封装完了,其实这个库已经算封装好了。下面看一下如何使用这个 Rust 库。我们在测试用例中体现一下用法。
#[cfg(test)]mod tests { use super::*;
#[test] fn valid() { let d = vec![0xde, 0xad, 0xd0, 0x0d]; let c: &[u8] = &compress(&d); assert!(validate_compressed_buffer(c)); assert!(uncompress(c) == Some(d)); }
#[test] fn invalid() { let d = vec![0, 0, 0, 0]; assert!(!validate_compressed_buffer(&d)); assert!(uncompress(&d).is_none()); }
#[test] fn empty() { let d = vec![]; assert!(!validate_compressed_buffer(&d)); assert!(uncompress(&d).is_none()); let c = compress(&d); assert!(validate_compressed_buffer(&c)); assert!(uncompress(&c) == Some(d)); }}
好了,这个简单的库就搞定了!以后,要对自己写的 C 库进行封装,也是同样道理。
本篇代码,我们可以看到,整个 C 库的绑定层,都是 Rust 语言代码。可能你暂时还不熟悉那些指针转换什么的,但那确确实实是 Rust 代码。
如果你以前做过一些其它高级语言绑定 C 库的工作,那么你会对此深有体会,那些语言,都得用 C 语言来写绑定的。
看似简单的事情,其实反映了 Rust 的强大。其在设计之初,就强调了与 C 生态的无缝结合这个目标。同时也让 Rust 具有了对底层系统强大而精确的描述能力。厉害!
不~~
不是那么简单!
如果 FFI 编程,只有这么简单就好啦。我们在本篇,其实只是选了一个最简单的库。这个库,没有暴露任何结构体定义,参数中,没有数组,没有void,没有函数指针,没有可变参数,没有回调,返回值也只是最简单的整数。没有考虑资源的所有权,回收问题。等等诸多细节,容我们后面慢慢道来。
本文代码主要参考:https://doc.rust-lang.org/nomicon/ffi.html#callbacks-from-c-code-to-rust-functions
可移步上述地址了解更多细节。