作為新穎、先進的程式語言,Rust的函式庫還沒有C/C++語言的函式庫來得多且完整。在很多時候,我們還是無可避免地必須要去使用現有C/C++程式語言所實作的函式庫來完成我們需要的功能。雖然一般來說,我們還是會比較喜歡用純Rust程式碼來開發程式,確保程式的安全性以及可移植性,但畢竟要把過去每個C/C++程式語言所實作的函式庫,都使用Rust程式語言來改寫,是非常不切實際的事。在開發成本的考量之下,直接讓Rust程式與C/C++程式語言作連結,就變成是一種很常見的折衷作法。



建置腳本(build.rs)

類似於C/C++程式語言程式專案常使用的「configure」,Rust的Cargo程式專案也提供了「建置腳本」的功能,可以讓程式專案在編譯之前,先編譯與執行某個Rust原始碼檔案來設定程式專案的編譯環境。這個「建置腳本」預設會是程式專案根目錄底下的build.rs,如果想要更改路徑的話,可以在Cargo.toml設定檔案中的[package]區塊,添加build設定項目。例如:

[package]
name = "example"
version = "0.1.0"
edition = "2021"
build = "src/build.rs"

[dependencies]

以上設定檔將作為建置腳本的Rust原始碼檔案路徑設定在程式專案根目錄中的src目錄下,檔名為build.rs

建置腳本的撰寫方式可以參考Cargo的官方手冊,網址如下:

底下先舉一個最簡單的例子,來說明如何使用Rust程式語言呼叫C/C++語言的函式庫。

Rust + C語言的Hello World

首先,使用以下指令建立出名叫c-hello-world的Cargo應用程式專案:

cargo new --bin c-hello-world

在程式專案的根目錄中,新增一個hello-world目錄,並在該目錄中新增一個hello.c檔,檔案內容如下:

#include <stdio.h>

void greet() {
    printf("Hello world!\n");
}

hello.c中,我們引入了stdio.h標頭檔,並在greet函數中使用printf函數來印出Hello world!字串。

接著,編輯程式專案根目錄中src目錄內的main.rs檔,檔案內容如下:

#[link(name = "hello-world")]
extern "C" {
    fn greet();
}

fn main() {
    unsafe {
        greet();
    }
}

extern "C"關鍵字所組成的程式區塊中內,以Rust程式語言定義函數的語法來對應C/C++語言程式的函數,在程式區塊上方使用#[link(name = "hello-world")]屬性,其中的hello-world表示要讓這個程式區塊定義的函數連結到libhello-world這個函式庫。當然,我們現在還沒有libhello-world這個東西,所以此時的程式專案是無法成功建置的(但是可以通過編譯檢查)。

在Rust程式中呼叫外部函式庫的函數,必須要在不安全的模式下進行,因此main函數呼叫greet函數時,還另外使用了unsafe關鍵字。

再來,在程式專案的根目錄中,新增一個build.rs檔案,這個檔案就是我們的建置腳本啦!檔案內容如下:

use std::{env, process::Command};

fn main() {
    let out_dir = env::var("OUT_DIR").unwrap();

    Command::new("cc")
        .args(["hello-world/hello.c", "-O3", "-c", "-fPIC", "-o"])
        .arg(&format!("{out_dir}/hello-world.o"))
        .status()
        .unwrap();

    Command::new("ar")
        .args(["crus", "libhello-world.a", "hello-world.o"])
        .current_dir(out_dir.as_str())
        .status()
        .unwrap();

    println!("cargo:rustc-link-search=native={out_dir}");
    println!("cargo:rustc-link-lib=static=hello-world");
}

以上程式,第4行會去取得OUT_DIR環境變數的值,這個環境變數是Cargo程式專案自帶的,而它的值是建構專案時輸出檔案的儲存路徑。第6行到第10行,其實就如同在程式專案根目錄下,執行了以下指令:

cc hello-world/hello.c -O3 -c -fPIC -o "$OUT_DIR/hello-world.o"

參數-O3表示要進行最佳化。參數-c表示讓C/C++編譯器不要進行「link」的動作,因為我們要讓它作為函式庫來使用。參數-fPIC是要開啟「Position-Independent Code」功能,也是為了要讓程式能夠作為函式庫來使用。參數-o則是設定目的檔(object file)要輸出的位置。

程式第12行到第16行,其實就如同在OUT_DIR目錄下,執行了以下指令:

ar crus libhello-world.a hello-world.o

ar crus指令可以將多個目的檔封裝成單一個.a靜態函式庫檔案。在此會把hello-world.o封裝成libhello-world.a

程式第18行,設定程式專案在編譯時會去OUT_DIR目錄尋找原生函式庫,有點類似C/C++編譯器的-L參數。

程式第19行,設定程式專案以靜態的方式來引用hello-world這個函式庫(會去尋找libhello-world.a檔案),有點類似在C/C++編譯器加上-lhello-world參數。引用了hello-world函式庫之後,在Rust程式中就可以使用#[link(name = "hello-world")]屬性和extern關鍵字來對應C/C++的函數。

此時使用cargo run來執行程式專案的話,Cargo在編譯程式專案前,會先去編譯並執行專案根目錄下的build.rs,所以C語言的libhello-world.a靜態函式庫檔案會先被產生出來,然後在編譯程式專案時會引用這個libhello-world.a靜態函式庫檔案,將greet函數的功能帶進Rust程式中。程式執行的時候就可以看到螢幕印出Hello world!字串啦!

Rust型別和C/C++型別的轉換

如果我們需要在Rust程式和C/C++程式間共享記憶體中的變數資料,就必須要透過FFI(Foreign Function Interface,外部函數介面)來完成。

稍微改寫一下剛才寫的Hello World程式來舉例吧!

#include <stdint.h>
#include <stdio.h>

void greet(char* s, int32_t a, int32_t b) {
    printf("Hello %s! %d + %d = %d\n", s, a, b, a + b);
}
use std::ffi::CString;

#[link(name = "hello-world")]
extern {
    fn greet(s: *const std::os::raw::c_char, a: i32, b: i32);
}

fn main() {
    let prompt = CString::new("Rust").unwrap();

    unsafe {
        greet(prompt.as_ptr(), 7, 11);
    }
}

使用cargo run來執行程式專案,程式會輸出:

Hello Rust! 7 + 11 = 18

Rust的聯合體(union)

Rust程式語言其實是有提供的聯合體的,只是在純Rust程式中並不會去用到這個。但是在C/C++上還蠻常看到聯合體的。Rust的聯合體可以用union關鍵字來定義,例如:

union MyUnion {
    f1: u32,
    f2: f32,
}

這個MyUnion聯合的大小為32位元,它可以儲存一個32位元的無號整數或是一個32位元的浮點數。

Rust的聯合體也可以實作函數、方法和特性。此外,聯合體的欄位型別,一定要有實作Copy特性。

我們可以用以下方式來建立出MyUnion聯合的實體:

let u1 = MyUnion {
    f1: 1
};

let u2 = MyUnion {
    f2: 2.5
};

修改MyUnion聯合實體的值的方式如下:

let mut u = MyUnion {
    f1: 1
};

u.f2 = 2.5;

取得MyUnion聯合實體的值時必須要使用unsafe關鍵字來進入不安全模式。如下:

let u = MyUnion {
    f1: 1
};

println!("{}", unsafe { u.f1 });

聯合也可以拿來做型樣匹配,當然,這也是要在不安全模式下進行。如下:

let u = MyUnion {
    f1: 10
};

unsafe {
    match u {
        MyUnion {
            f1: 10,
        } => {
            println!("ten");
        },
        MyUnion {
            f2,
        } => {
            println!("{f2}");
        },
    }
}

在做聯合的型樣匹配時,要注意到樣本中的欄位名稱,只是給編譯器對應型別用的,在程式執行階段,並無法得知當前的聯合到底是用哪個欄位的型別,因此如果第一個match關鍵字裡的arm沒有做任何的條件限定的話,就會永遠被匹配成功。