如何从Rust调用C库函数

图片[1]-如何从Rust调用C库函数-不念博客
Rust调用C库函数

Rust FFI 和 bindgen 工具是为 Rust 调用 C 库而设计的。Rust 很容易与 C 语言对话,从而与任何其它可以与 C 语言对话的语言对话。

为什么要从 Rust 调用 C 函数?简短的答案就是软件库。冗长的答案则触及到 C 在众多编程语言中的地位,特别是相对 Rust 而言。C、C++,还有 Rust 都是系统语言,这意味着程序员可以访问机器层面的数据类型与操作。在这三个系统语言中,C 依然占据主导地位。现代操作系统的内核主要是用 C 来写的,其余部分依靠汇编语言补充。在标准系统函数库中,输入与输出、数字处理、加密计算、安全、网络、国际化、字符串处理、内存管理等等,大多都是用 C 来写的。这些函数库所代表的是一个庞大的基础设施,支撑着用其他语言写出来的应用。Rust 发展至今也有着可观的函数库,但是 C 的函数库 —— 自 1970 年代就已存在,迄今还在蓬勃发展 —— 是一种无法被忽视的资源。最后一点是,C 依然还是编程语言中的 通用语:大部分语言都可以与 C 交流,透过 C,语言之间可以互相交流。

两个概念证明的例子

Rust 支持 FFI(外部函数接口Foreign Function Interface)用以调用 C 函数。任何 FFI 所需要面临的问题是调用方语言是否涵盖了被调用语言的数据类型。例如,ctypes是 Python 调用 C 的 FFI,但是 Python 并没有包括 C 所支持的无符号整数类型。结果就是,ctypes必须寻求解决方案。

相比之下,Rust 包含了所有 C 中的原始(即,机器层面)类型。比如说,Rust 中的 i32类对应 C 中的int类。C 特别声明了char类必须是一个字节大小,而其他类型,比如int,必须至少是这个大小(LCTT 译注:原文处有评论指出int大小依照 C 标准应至少为 2 字节);然而如今所有合理的 C 编译器都支持四字节的int,以及八字节的double(Rust 中则是f64类),以此类推。

针对 C 的 FFI 所面临的另一个挑战是:FFI 是否能够处理 C 的裸指针,包括指向被看作是字符串的数组指针。C 没有字符串类型,它通过结合字符组和一个非打印终止符(大名鼎鼎的 空终止符)来实现字符串。相比之下,Rust 有两个字符串类型:String&str(字符串切片)。问题是,Rust FFI 是否能将 C 字符串转化成 Rust 字符串——答案是肯定的

出于对效率的追求,结构体指针在 C 中也很常见。一个 C 结构体在作为一个函数的参数或者返回值的时候,其默认行为是传递值(即,逐字节复制)。C 结构体,如同它在 Rust 中的对应部分一样,可以包含数组和嵌套其他结构体,所以其大小是不定的。结构体在两种语言中的最佳用法是传递或返回引用,也就是说,传递或返回结构体的地址而不是结构体本身的副本。Rust FFI 再一次成功处理了 C 的结构体指针,其在 C 函数库中十分普遍。

第一段代码案例专注于调用相对简单的 C 库函数,比如 abs(绝对值)和sqrt(平方根)。这些函数使用非指针标量参数并返回一个非指针标量值。第二段代码案例则涉及了字符串和结构体指针,在这里会介绍工具bindgen,其通过 C 接口(头文件)生成 Rust 代码,比如math.h以及time.h。C 头文件声明了 C 函数的调用语法,并定义了会被调用的结构体。两段代码都能在我的主页上找到。

调用相对简单的 C 函数

第一段代码案例有四处 Rust 对标准数学库内的 C 函数的调用:两处分别调用了 abs(绝对值)和pow(幂),两处重复调用了sqrt(平方根)。这个程序可以直接用rustc编译器进行构建,或者使用更方便的命令cargo build

use std:s::raw::c_int; // 32位 use std:s::raw::c_double; // 64位 // 从标准库 libc 中引入三个函数。 // 此处是 Rust 对三个 C 函数的声明: extern "C" { fn abs(num: c_int) -> c_int; fn sqrt(num: c_double) -> c_double; fn pow(num: c_double, power: c_double) -> c_double; } fn main { let x: i32 = -123; println!("\n{x}的绝对值是: {}.", unsafe { abs(x) }); let n: f64 = 9.0; let p: f64 = 3.0; println!("\n{n}的{p}次方是: {}.", unsafe { pow(n, p) }); let mut y: f64 = 64.0; println!("\n{y}的平方根是: {}.", unsafe { sqrt(y) }); y = -3.14; println!("\n{y}的平方根是: {}.", unsafe { sqrt(y) }); //** NaN = NotaNumber(不是数字) }

顶部的两个 use声明是 Rust 的数据类型c_intc_double,对应 C 类型里的intdouble。Rust 标准模块std:

s::raw 定义了 14 个类似的类型以确保跟 C 的兼容性。模块 std::ffi中有 14 个同样的类型定义,以及对字符串的支持。

位于 main函数上的extern "C"区域声明了 3 个 C 库函数,这些函数会在main函数内被调用。每次调用都使用了标准的 C 函数名,但每次调用都必须发生在一个unsafe区域内。正如每个新接触 Rust 的程序员所发现的那样,Rust 编译器极度强制内存安全。其他语言(特别是 C 和 C++)作不出相同的保证。unsafe区域其实是说:Rust 对外部调用中可能存在的不安全行为不负责。

第一个程序输出为:

-123的绝对值是: 123.
9的3次方是: 729.
64的平方根是: 8.
-3.14的平方根是: NaN.

输出的最后一行的 NaN表示不是数字Not a Number:C 库函数sqrt期待一个非负值作为参数,这使得参数-3.14生成了NaN作为返回值。

调用涉及指针的 C 函数

C 库函数为了提高效率,经常在安全、网络、字符串处理、内存管理,以及其他领域中使用指针。例如,库函数 asctime(ASCII 字符串形式的时间)期待一个结构体指针作为其参数。Rust 调用类似asctime的 C 函数就会比调用sqrt要更加棘手一些,后者既没有牵扯到指针,也不涉及到结构体。

函数 asctime调用的 C 结构体类型为struct tm。一个指向此结构体的指针会作为参数被传递给库函数mktime(时间作为值)。此结构体会将时间拆分成诸如年、月、小时之类的单位。此结构体的字段field类型为time_t,是int(32位)和long(64 位)的别名。两个库函数将这些破碎的时间片段组合成了一个单一值:asctime返回一个以字符串表示的时间,而mktime返回一个time_t值表示自 “纪元Epoch 以来所经历的秒数,这是一个系统的时钟和时间戳的相对时间。典型的纪元设置为 1900 年或 1970 年,1 月 1 日 0 时 0 分 0 秒。(LCTT 校注:Unix、Linux 乃至于如今所有主要的计算机和网络的时间纪元均采用 1970 年为起点。)

以下的 C 程序调用了 asctimemktime,并使用了其他库函数strftime来将mktime的返回值转化成一个格式化的字符串。这个程序可被视作 Rust 对应版本的预热:

#include 

程序输出为:

日期与时间: Fri Feb  1 01:01:01 1901
返回的整数值: 2120218157
更加可读的版本: Fri Feb  1 01:01:01 1901

(LCTT 译注:如果你尝试在自己电脑上运行这段代码,然后得到了一行关于 mktime的错误信息,然后又在网上随便找了个在线 C 编译器,复制代码然后得到了跟这里的结果有区别但是没有错误的结果,不要慌,我的电脑上也是这样的。导致本地机器上mktime失败的原因是作者没有设置tm_isdst,这个是用来标记夏令时的标志。tm_isdst大于零则夏令时生效中,等于零则不生效,小于零标记未知。加入sometime.tm_isdst = 0= -1后应该就能得到跟在线编译器大致一样的结果。不同的地方在于结果第一行我得到的是Mon Feb ...,这个与作者代码中sometime.tm_wday = 1对应,这里应该是作者写错了;第二行我和作者和网上得到的数字都不一样,这大概是合理的,因为这与机器的纪元有关;第三行我跟作者的结果是一样的,1901 年 2 月 1 日也确实是周五,这是因为mktime其实会修正时间参数中不合理的地方。至于夏令时具体是如何影响mktime这个问题,我能查到的只有mktime的计算受时区影响,更底层的原因我也不知道了。)

总的来说,Rust 在调用库函数 asctimemktime时,必须处理以下两个问题:

  • 将裸指针作为唯一参数传递给每个库函数。
  • 把从 asctime返回的 C 字符串转化为 Rust 字符串。

Rust 调用 asctime 和 mktime

工具 bindgen会根据类似math.htime.h之类的 C 头文件生成 Rust 支持的代码。下面这个简化版的time.h就可以用来做例子,简化版与原版主要有两个不同:

  • 内置类型 int被用来取代别名类型time_t。工具 bindgen 可以处理time_t类,但是会生成一些烦人的警告,因为time_t不符合 Rust 的命名规范:time_t以下划线区分timet;Rust 更偏好驼峰式命名方法,比如TimeT
  • 出于同样的原因,这里选择 StructTM作为struct tm的别名。

以下是一份简化版的头文件,mktimeasctime在文件底部:

typedef struct tm {
  int tm_sec;  /* 秒 */
  int tm_min;  /* 分钟 */
  int tm_hour;   /* 小时 */
  int tm_mday;   /* 日 */
  int tm_mon;  /* 月 */
  int tm_year;   /* 年 */
  int tm_wday;   /* 星期 */
  int tm_yday;   /* 一年中的第几天 */
  int tm_isdst;  /* 夏令时 */
} StructTM;

extern int mktime(StructTM*);
extern char* asctime(StructTM*);

bindgen安装好后,mytime.h作为以上提到的头文件,以下命令(%是命令行提示符)可以生成所需的 Rust 代码并将其保存到文件mytime.rs

% bindgen mytime.h > mytime.rs

以下是 mytime.rs中的重要部分:

/* automatically generated by rust-bindgen 0.61.0 */

#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct tm {
  pub tm_sec: ::std:s::raw::c_int, pub tm_min: ::std:s::raw::c_int, pub tm_hour: ::std:s::raw::c_int, pub tm_mday: ::std:s::raw::c_int, pub tm_mon: ::std:s::raw::c_int, pub tm_year: ::std:s::raw::c_int, pub tm_wday: ::std:s::raw::c_int, pub tm_yday: ::std::os::raw::c_int, pub tm_isdst: ::std::os::raw::c_int, } pub type StructTM = tm; extern "C" { pub fn mktime(arg1: *mut StructTM) -> ::std::os::raw::c_int; } extern "C" { pub fn asctime(arg1: *mut StructTM) -> *mut ::std::os::raw::c_char; } #[test] fn bindgen_test_layout_tm { const UNINIT: ::std::mem::MaybeUninit

Rust 结构体 struct tm,跟原本在 C 中的一样,包含了 9 个 4 字节的整型字段。这些字段名称在 C 和 Rust 中是一样的。extern "C"区域声明了库函数astimemktime分别需要只一个参数,一个指向可变实例StructTM的裸指针。(库函数可能会通过指针改变作为参数传递的结构体。)

#[test]属性下的其余代码是用来测试 Rust 版的时间结构体的布局。通过命令cargo test可以进行这些测试。问题在于,C 没有规定编译器应该如何对结构体中的字段进行布局。比如说,C 的struct tm以字段tm_sec开头用以表示秒;但是 C 不需要编译版本遵循这个排序。不管怎样,Rust 测试应该会成功,而 Rust 对库函数的调用也应如预期般工作。

设置好第二个案例并开始运行

从 bindgen生成的代码不包含main函数,所以是一个天然的模块。以下是一个main函数初始化了StructTM并调用了asctimemktime

mod mytime;
use mytime::*;
use std::ffi::CStr;

fn main {
  let mut sometime  = StructTM {
    tm_year: 1,
    tm_mon: 1,
    tm_mday: 1,
    tm_hour: 1,
    tm_min: 1,
    tm_sec: 1,
    tm_isdst: -1,
    tm_wday: 1,
    tm_yday: 1
  };

  unsafe {
    let c_ptr = &mut sometime; // 裸指针

    // 调用,转化,并拥有
    // 返回的 C 字符串
    let char_ptr = asctime(c_ptr);
    let c_str = CStr::from_ptr(char_ptr);
    println!("{:#?}", c_str.to_str);

    let utc = mktime(c_ptr);
    println!("{}", utc);
  }
}

这段 Rust 代码可以被编译(直接用 rustc或使用cargo)并运行。输出为:

Ok(
    "Mon Feb  1 01:01:01 1901\n",
)
2120218157

对 C 函数 asctimemktime的调用必须再一次被放在unsafe区域内,因为 Rust 编译器无法对这些外部函数的潜在内存安全风险负责。此处声明一下,asctimemktime并没有安全风险。调用的两个函数的参数是裸指针ptr,其指向结构体sometime(在栈stack中)的地址。

asctime是两个函数中调用起来更棘手的那个,因为这个函数返回的是一个指向 Cchar的指针,如果函数返回Mon那么指针就指向M。但是 Rust 编译器并不知道 C 字符串 (char的空终止数组)的储存位置。是内存里的静态空间?还是堆heap?asctime函数内用来储存时间的文字表达的数组实际上是在内存的静态空间里。无论如何,C 到 Rust 字符串转化需要两个步骤来避免编译错误:

  • 调用 Cstr::from_ptr(char_ptr)来将 C 字符串转化为 Rust 字符串并返回一个引用储存在变量c_str中。
  • 对 c_str.to_str的调用确保了c_str是所有者。

Rust 代码不会增加从 mktime返回的整型值的易读性,这一部分留作课外作业给感兴趣的人去探究。Rust 模板chrono::format也有一个strftime函数,它可以被当作 C 的同名函数来使用,两者都是获取时间的文字表达。

使用 FFI 和 bindgen 调用 C

Rust FFI 和工具 bindgen都能够出色地协助 Rust 调用 C 库,无论是标准库还是第三方库。Rust 可以轻松地与 C 交流,并透过 C 与其他语言交流。对于调用像sqrt一样简单的库函数,Rust FFI 表现直截了当,这是因为 Rust 的原始数据类型覆盖了它们在 C 中的对应部分。

对于更为复杂的交流 —— 特别是 Rust 调用像 asctimemktime一样,会涉及到结构体和指针的 C 库函数 ——bindgen工具是优秀的帮手。这个工具会生成支持代码以及所需要的测试。当然,Rust 编译器无法假设 C 代码对内存安全的考虑会符合 Rust 的标准;因此,Rust 必须在unsafe区域内调用 C。

© 版权声明
THE END