Linux中國

從 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::os::raw::c_int;  // 32位
use std::os::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::os::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 <stdio.h>
#include <time.h>

int main () {
  struct tm sometime;  /* 時間被打破細分 */
  char buffer[80];
  int utc;

  sometime.tm_sec = 1;
  sometime.tm_min = 1;
  sometime.tm_hour = 1;
  sometime.tm_mday = 1;
  sometime.tm_mon = 1;
  sometime.tm_year = 1; /*LCTT 校註:注意,相對於 1900 年的年數*/
  sometime.tm_hour = 1;
  sometime.tm_wday = 1;
  sometime.tm_yday = 1;

  printf("日期與時間: %sn", asctime(&sometime));

  utc = mktime(&sometime);
  if( utc < 0 ) {
    fprintf(stderr, "錯誤: mktime 無法生成時間n");
  } else {
    printf("返回的整數值: %dn", utc);
    strftime(buffer, sizeof(buffer), "%c", &sometime);
    printf("更加可讀的版本: %sn", buffer);
  }

  return 0;
}

程序輸出為:

日期與時間: 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::os::raw::c_int,
  pub tm_min: ::std::os::raw::c_int,
  pub tm_hour: ::std::os::raw::c_int,
  pub tm_mday: ::std::os::raw::c_int,
  pub tm_mon: ::std::os::raw::c_int,
  pub tm_year: ::std::os::raw::c_int,
  pub tm_wday: ::std::os::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<tm> = ::std::mem::MaybeUninit::uninit();
  let ptr = UNINIT.as_ptr();
  assert_eq!(
  ::std::mem::size_of::<tm>(),
  36usize,
  concat!("Size of: ", stringify!(tm))
  );
  ...

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 1901n",
)
2120218157

對 C 函數 asctimemktime 的調用必須再一次被放在 unsafe 區域內,因為 Rust 編譯器無法對這些外部函數的潛在內存安全風險負責。此處聲明一下,asctimemktime 並沒有安全風險。調用的兩個函數的參數是裸指針 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 調用像 asctimemktime 一樣,會涉及到結構體和指針的 C 庫函數 —— bindgen 工具是優秀的幫手。這個工具會生成支持代碼以及所需要的測試。當然,Rust 編譯器無法假設 C 代碼對內存安全的考慮會符合 Rust 的標準;因此,Rust 必須在 unsafe 區域內調用 C。

via: https://opensource.com/article/22/11/rust-calls-c-library-functions

作者:Marty Kalin 選題:lkxed 譯者:yzuowei 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國