< 返回版块

Mike Tang 发表于 2020-05-07 19:54

背景

本篇为一个新的章节《手动绑定 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 头文件,发现里面只有如下几个定义:

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-rs
cd 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 的设计角度来看,这种方式并不提倡。

至此,函数签名分析完成。可见,它们的转换,有一套内建的规则。其核心就是数据类型的转换。

使用 extern 函数

那么,我们该如何使用呢?上面的包装,能直接使用吗?

答案是:能!

比如,我们可以这样来用其中的一个函数:

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 风格的接口,并对外提供。

更加符合 Rust 口味的接口

上述 5 个接口,其中的 snappy_max_compressed_lengthsnappy_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<u8> {
    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<u8>,happy。

整个过程用 unsafe 括起来。

第三个封装,uncompress,用于解压缩。

pub fn uncompress(src: &[u8]) -> Option<Vec<u8>> {
    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<Vec>,这才是 Rust。

三个接口封装完了,其实这个库已经算封装好了。下面看一下如何使用这个 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 库进行封装,也是同样道理。

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

可移步上述地址了解更多细节。

评论区

写评论
chenwei767 2020-05-08 10:26

收下了. 某天估计可以用上

zydxhs 2020-05-07 22:53

Up

1 共 2 条评论, 1 页