为什么要从 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
声明是 Rust 的数据类型 c_int
和 c_double
,对应 C 类型里的 int
和 double
。Rust 标准模块 std::os::raw
定义了 14 个类似的类型以确保跟 C 的兼容性。模块 std::ffi
中有 14 个同样的类型定义,以及对字符串的支持。
位于 main
函数上的 extern "C"
区域声明了 3 个 C 库函数,这些函数会在 main
函数内被调用。每次调用都使用了标准的 C 函数名,但每次调用都必须发生在一个 unsafe
区域内。正如每个新接触 Rust 的程序员所发现的那样,Rust 编译器极度强制内存安全。其他语言(特别是 C 和 C++)作不出相同的保证。unsafe
区域其实是说:Rust 对外部调用中可能存在的不安全行为不负责。
第一个程序输出为:
输出的最后一行的 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
以下的 C 程序调用了 asctime
和 mktime
,并使用了其他库函数 strftime
来将 mktime
的返回值转化成一个格式化的字符串。这个程序可被视作 Rust 对应版本的预热:
程序输出为:
(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 在调用库函数 asctime
和 mktime
时,必须处理以下两个问题:
- 将裸指针作为唯一参数传递给每个库函数。
- 把从
asctime
返回的 C 字符串转化为 Rust 字符串。
Rust 调用 asctime 和 mktime
工具 bindgen
会根据类似 math.h
和 time.h
之类的 C 头文件生成 Rust 支持的代码。下面这个简化版的 time.h
就可以用来做例子,简化版与原版主要有两个不同:
- 内置类型
int
被用来取代别名类型time_t
。工具 bindgen 可以处理time_t
类,但是会生成一些烦人的警告,因为time_t
不符合 Rust 的命名规范:time_t
以下划线区分time
和t
;Rust 更偏好驼峰式命名方法,比如TimeT
。 - 出于同样的原因,这里选择
StructTM
作为struct tm
的别名。
以下是一份简化版的头文件,mktime
和 asctime
在文件底部:
bindgen
安装好后,mytime.h
作为以上提到的头文件,以下命令(%
是命令行提示符)可以生成所需的 Rust 代码并将其保存到文件 mytime.rs
:
以下是 mytime.rs
中的重要部分:
Rust 结构体 struct tm
,跟原本在 C 中的一样,包含了 9 个 4 字节的整型字段。这些字段名称在 C 和 Rust 中是一样的。extern "C"
区域声明了库函数 astime
和 mktime
分别需要只一个参数,一个指向可变实例 StructTM
的裸指针。(库函数可能会通过指针改变作为参数传递的结构体。)
#[test]
属性下的其余代码是用来测试 Rust 版的时间结构体的布局。通过命令 cargo test
可以进行这些测试。问题在于,C 没有规定编译器应该如何对结构体中的字段进行布局。比如说,C 的 struct tm
以字段 tm_sec
开头用以表示秒;但是 C 不需要编译版本遵循这个排序。不管怎样,Rust 测试应该会成功,而 Rust 对库函数的调用也应如预期般工作。
设置好第二个案例并开始运行
从 bindgen
生成的代码不包含 main
函数,所以是一个天然的模块。以下是一个 main
函数初始化了 StructTM
并调用了 asctime
和 mktime
:
这段 Rust 代码可以被编译(直接用 rustc
或使用 cargo
)并运行。输出为:
对 C 函数 asctime
和 mktime
的调用必须再一次被放在 unsafe
区域内,因为 Rust 编译器无法对这些外部函数的潜在内存安全风险负责。此处声明一下,asctime
和 mktime
并没有安全风险。调用的两个函数的参数是裸指针 ptr
,其指向结构体 sometime
(在栈stack中)的地址。
asctime
是两个函数中调用起来更棘手的那个,因为这个函数返回的是一个指向 C char
的指针,如果函数返回 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 调用像 asctime
和 mktime
一样,会涉及到结构体和指针的 C 库函数 —— bindgen
工具是优秀的帮手。这个工具会生成支持代码以及所需要的测试。当然,Rust 编译器无法假设 C 代码对内存安全的考虑会符合 Rust 的标准;因此,Rust 必须在 unsafe
区域内调用 C。