Try   HackMD

Crust of Rust : Build Scripts and Foreign-Function Interfaces (FFI)

直播錄影

  • 主機資訊
    ​​​​wilson@wilson-HP-Pavilion-Plus-Laptop-14-eh0xxx ~/CrustOfRust> neofetch --stdout
    ​​​​wilson@wilson-HP-Pavilion-Plus-Laptop-14-eh0xxx 
    ​​​​----------------------------------------------- 
    ​​​​OS: Ubuntu 22.04.3 LTS x86_64 
    ​​​​Host: HP Pavilion Plus Laptop 14-eh0xxx 
    ​​​​Kernel: 6.2.0-37-generic 
    ​​​​Uptime: 22 mins 
    ​​​​Packages: 2367 (dpkg), 11 (snap) 
    ​​​​Shell: bash 5.1.16 
    ​​​​Resolution: 2880x1800 
    ​​​​DE: GNOME 42.9 
    ​​​​WM: Mutter 
    ​​​​WM Theme: Adwaita 
    ​​​​Theme: Yaru-dark [GTK2/3] 
    ​​​​Icons: Yaru [GTK2/3] 
    ​​​​Terminal: gnome-terminal 
    ​​​​CPU: 12th Gen Intel i5-12500H (16) @ 4.500GHz 
    ​​​​GPU: Intel Alder Lake-P 
    ​​​​Memory: 4375MiB / 15695MiB 
    
  • Rust 編譯器版本 :
    ​​​​wilson@wilson-HP-Pavilion-Plus-Laptop-14-eh0xxx ~/CrustOfRust> rustc --version
    ​​​​rustc 1.70.0 (90c541806 2023-05-31) (built from a source tarball)
    

Introduction

0:00:00

This time we go over Cargo build scripts and Rust foreign-function interfaces, including looking at some widely used *-sys crates. We also wrote our own bindings against the libsodium C library: https://doc.libsodium.org/. You can find the Cargo book entry for build scripts at https://doc.rust-lang.org/cargo/, and the nomicon entry for ffi at https://doc.rust-lang.org/nomicon/ffi.html

libsodium

0:02:58

libsodium 是用 C 實作的密碼學函式庫,應用相當廣泛,但也眾所周知,它是許多這些 cryptographic primitives 的非常合理的實作。特別是,它的目標是提供難以誤用且配置相對較低的實作。

Build scripts

0:04:28

建置腳本實際上是一個在編譯你的 crate 之前執行的程式。建置腳本是 cargo 的一個特性,不是 Rust 語言的一個特性。如果你的 cargo 套件的根目錄下有一個名為 build.rs 的檔案,則 cargo 將編譯該檔案,執行該檔案,然後建置你的 crate。即使你的 crate 被另一個 crate 使用,build.rs 也會在 crate 編譯時執行。

由於 Rust 總是進行 transitive compile,因此在本地電腦上執行的建置腳本的輸出不包含在 cargo publish 中,然而建置腳本包含在輸出,這意味著建置腳本必須以在其他人的電腦上執行的方式編寫。這就是讓建置腳本始終正確執行變得非常棘手的部分,處理消費者建置環境的所有可能設定方式可能相當複雜。

你也可以在你的 Cargo.toml 檔案中設定像是 build = 加上路徑名稱,它會編譯那個路徑指定的檔案。你可以使用像是 build/main.rs 這樣的路徑,然後在其中放置你的模組等。如果建置過程非常複雜,我們會看到有一些 crate 也是採取這種做法。

OUT_DIR

0:07:00

當建置腳本執行時,它會存取外部環境變數,而 OUT_DIR 環境變數是目標目錄的一個子目錄,它在建置腳本中是可寫的。當你的 crate 在執行建置腳本後編譯時,OUT_DIR 環境變數也將被設定為相同的目錄。這裡的想法是,建置腳本可以生成 Rust 檔案,將它們放在 OUT_DIR 中,然後你的 Rust 程式碼可以包括 OUT_DIR 下的檔案,並存取生成的原始碼。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
Outputs of the Build Script
Build scripts may save any output files or intermediate artifacts in the directory specified in the OUT_DIR environment variable. Scripts should not modify any files outside of that directory.

開始建置 Rust 專案 :

$ cargo new --bin build-and-ffi
$ cd build-and-ffi
$ vim build.rs

編寫 build.rs 觀察 cargo 的行為 :

fn main() { dbg!(std::env::var("OUT_DIR")); }

cargo 編譯結果 :

$ cargo run warning: unused `Result` that must be used --> build.rs:3:5 | 3 | dbg!(std::env::var("OUT_DIR")); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ... warning: `build-and-ffi` (build script) generated 1 warning Compiling build-and-ffi v0.1.0 (/home/wilson/CrustOfRust/build-and-ffi) Finished dev [unoptimized + debuginfo] target(s) in 0.25s Running `target/debug/build-and-ffi` Hello, world!
  • Line 2 : 建置腳本收到警告
  • Line 9 : 執行建置腳本
  • Line 10 : 編譯 crate
  • Line 12 : 執行 crate

我們並沒有看到剛剛建置腳本的 OUT_DIR 的輸出結果,原因是除非建置腳本失敗,否則建置腳本的輸出不會顯示在 terminal 上。如果我們在建置腳本插入一個 panic 然後執行 cargo run,就會看到這一點 :

fn main() { dbg!(std::env::var("OUT_DIR")); panic!(); }

編譯輸出 OUT_DIR 值 :

$ cargo run
...
  --- stderr
  [build.rs:3] std::env::var("OUT_DIR") = Ok(
      "/home/wilson/CrustOfRust/build-and-ffi/target/debug/build/build-and-ffi-6517aee4353d46a2/out",
  )
  thread 'main' panicked at 'explicit panic', build.rs:4:5
...

編譯器只有在失敗時才輸出建置腳本的結果是因為建置腳本通常會產生大量輸出。稍後我們會了解其中的一些原因。部分原因是因為在它們的輸出中,它們可以發出特殊指令給 cargo,讓它能夠改變連結搜尋路徑以及設定環境變數等。因此,標準輸出通常會相當龐大,因此 cargo 預設會將其消除。

如果你想在成功時也看到建置腳本的輸出,你可以這樣做:
先移除 Line 4 :

fn main() { dbg!(std::env::var("OUT_DIR")); - panic!(); }

尋找到 stderr 檔案 :

$ cd target/debug/build $ tree . ├── build-and-ffi-6517aee4353d46a2 // 建置腳本被執行,真正的 crate 放這 │   ├── invoked.timestamp │   ├── out │   ├── output │   ├── root-output │   └── stderr └── build-and-ffi-787f8f031757c15f // 建置腳本被建置 ├── build-script-build // 編譯建置腳本的結果,有點像建置腳本的 crate ├── build_script_build-787f8f031757c15f └── build_script_build-787f8f031757c15f.d $ ls build-and-ffi-6517aee4353d46a2/out // 無任何檔案 $ cat build-and-ffi-6517aee4353d46a2/stderr [build.rs:3] std::env::var("OUT_DIR") = Ok( "/home/wilson/CrustOfRust/build-and-ffi/target/debug/build/build-and-ffi-6517aee4353d46a2/out", )

如果你進行了一個依賴於 OpenSSL 的建置,例如,OpenSSL crate 會依賴於一個名為 OpenSSL sys 的 crate,該 crate 具有一個建置腳本,用於在 OpenSSL 上執行許多操作。因此,如果你建置了一個依賴於它的專案,你可以使用這個方法來查看 OpenSSL sys 建置腳本的輸出,即使它位於 dependency graph 的深處,它仍然會以這種類型的路徑結束。這對於試圖弄清楚建置腳本是否做了一些奇怪的事情,以及它做了什麼和為什麼這一點非常有用。雖然要發現可能有些困難,但是了解這一點是非常有用的,你將遇到這類問題。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
CARGO_TARGET_DIR
Location of where to place all generated artifacts, relative to the current working directory. See build.target-dir to set via config.

CARGO_TARGET_DIR 環境變數設定為所有的 cargo 專案在主機上共用一個 target 目錄,而不是每個專案都有一個 target 目錄,這樣做通常可以節省磁碟空間,有時也可以節省編譯時間。

main.rs 嘗試印出 OUT_DIR :

fn main() 
{
    println!("{}", env!("OUT_DIR")); // 稍後解釋為什麼使用 env! 巨集
}

接著編譯並執行 :

$ cargo run
...
/home/wilson/CrustOfRust/build-and-ffi/target/debug/build/build-and-ffi-6517aee4353d46a2/out
// 印出的值正是剛剛在 stderr 檔案查看到的路徑。

使用 env! 巨集,而不是 std::env::var("OUT_DIR") 的原因是,env! 巨集在編譯時期讀取環境變數,而不是在執行時期。實際上要如何使用 OUT_DIR? 使用的方法是像這樣,先修改 build.rs:

fn main() { std::fs::write( std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("foo.rs"), r"pub fn foo() {}", ) .unwrap(); // 先在 OUT_DIR 目錄下建置 foo.rs 檔案, // 接著將 "pub fn foo() {}" 寫入 foo.rs 檔案 }

編譯並尋找檔案 :

$ cargo run
$ ls target/debug/build/build-and-ffi-6517aee4353d46a2/out
foo.rs

繼續修改 src/main.rs :

mod foo { include!(concat!(env!("OUT_DIR"), "/foo.rs")); // include! 巨集接收一個路徑,在編譯時期將該巨集的呼叫替換為檔案的內容。 // 這不是 eval,而是將檔案的內容複製貼上到這裡,然後將其視為 Rust 程式碼。 // Rust 的 include! 巨集功能與 C 的 #include <...> 相同 } fn main() { println!("{}", env!("OUT_DIR")); foo::foo(); // 成功呼叫剛剛在 build.rs 創的檔案的函式 }

接著做以下事情來觀察 main.rs 展開:

$ cargo install cargo-expand // cargo-expand  用來查看巨集展開後的結果
$ cargo expand
...
#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
mod foo {
    pub fn foo() {} // include!(concat!(env!("OUT_DIR"), "/foo.rs")); 展開後的結果
}
fn main() {
    {
        ::std::io::_print(
            format_args!(
                "{0}\n",
                "/home/wilson/CrustOfRust/build-and-ffi/target/debug/build/build-and-ffi-6517aee4353d46a2/out",
            ),
        );
    };
    foo::foo();
}

cargo directives

0:18:01

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
Outputs of the Build Script
Build scripts communicate with Cargo by printing to stdout. Cargo will interpret each line that starts with cargo: as an instruction that will influence compilation of the package. All other lines are ignored.

看到 cargo:

cargo:rerun-if-changed=PATHTells Cargo when to re-run the script. 
cargo:rerun-if-env-changed=VARTells Cargo when to re-run the script.

cargo:rustc-link-arg=FLAGPasses custom flags to a linker for benchmarks, binaries, cdylib crates, examples, and tests.
cargo:rustc-link-arg-bin=BIN=FLAGPasses custom flags to a linker for the binary BIN.
cargo:rustc-link-lib=LIBAdds a library to link. // -l
cargo:rustc-link-search=[KIND=]PATHAdds to the library search path. // -L

cargo:rustc-flags=FLAGSPasses certain flags to the compiler.
cargo:rustc-env=VAR=VALUESets an environment variable.

cargo:warning=MESSAGEDisplays a warning on the terminal.

cargo:KEY=VALUEMetadata, used by links scripts.

說明 cargo:rustc-flags=FLAGS 之前,先看到 cfg! 巨集的用途 :

mod foo {3836 include!(concat!(env!("OUT_DIR"), "/foo.rs")); } // #[cfg(sss)] // cfg! 巨集類似於這個 fn main() { println!("{}", env!("OUT_DIR")); // println!("{}", cfg!("hello")); foo::foo(); }

cfg! 巨集基本上是一種說明「這個東西是否可用」的方式,例如以下例子 :

#[cfg(feature = "foo")] $ rustc --cfg=feature=foo

如果使用了 cargo:rustc-flags,等同於把變數丟給編譯器 :

$ rustc --cfg=openssl_1_1_0 // rustc 方面 // xxxx.rs #[cfg(openssl_1_1_0)] // 類似於 C 的條件編譯用到的 -D fn foo() {} // 當 openssl 為 1.1.0 版的時候,才會把這個編譯 // cargo 方面 // Cargo.toml [target.<cfg>] runner = "…" # wrapper to run executables rustflags = ["…", "…"] # custom flags for `rustc` // cargo 會將 toml 檔裡的配置轉成 Rust config flags, // 接著你才可以做條件編譯。

接著看到 cargo:warning=MESSAGE。前面提到,只有在你編譯失敗時,才會印出建置腳本的訊息 cargo:warning=MESSAGE 是例外,它可以在 cargo 執行時讓建置腳本標準輸出 cargo:warning= 後面的訊息,修改 build.rs 測試該功能 :

fn main() { println!("cargo:warning=Generating foo.rs"); // cargo:warning 的用途通常用來 warning ,而不是作為 log 使用。 std::fs::write( std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("foo.rs"), r"pub fn foo() {}", ) .unwrap(); }

編譯成功印出建置腳本的訊息 :

$ cargo run warning: Generating foo.rs ...

值得注意的是,只有當你在該 crate 上工作並且對它具有路徑依賴時,才會產生來自建置腳本的警告,如果你對它們有 transitive dependency,則不會產生來自建置腳本的警告。例如,如果 OpenSSL sys crate 建置腳本有編寫產生此類警告的功能且你恰好只是單純依賴它,則在進行建置時通常不會看到它們。只有當你對 OpenSSL sys 有路徑依賴或你專門手動建置 OpenSSL sys 時,你才會看到它。

接著討論 cargo:KEY=VALUE,它可以讓你可以發出包含實際值的額外訊息,這對於條件編譯很有用。有時候,你希望你的建置腳本做一些事情,比如找出類似 C 依賴的包含目錄在哪裡,並將該路徑通知下游 crate 進行建置,因為它可能會用到特定的 C 標頭檔之類的。你可以透過使用 cargo:KEY=VALUE 語法來實現這一點,這樣下游的建置過程就可以耗用這些環境變數。但環境變數的名稱是 DEP_<LIBRARY_NAME>_KEY=value

一般來說,如果你有一個 crate 連結到特定的共享函式庫,你應該在該 crate 的 Cargo.toml 中宣告 links=<LIBRARY_NAME>。這並不會對連結產生任何魔法效應,你仍然需要使用像 rustc-link-lib 這樣的東西告訴 cargo 也要連結該函式庫,你可能還需要設定搜尋路徑和其他東西,但它的作用是允許 cargo 檢查整個 dependency graph 中只有一個 crate 連結到該共享函式庫,這很有用,因為如果有多個 crate 試圖連結到同一個函式庫,你可能會遇到一些奇怪的情況,例如它們連結到稍微不同的版本,或者它們都靜態連結到某個函式庫,因此你會得到重復的 symbol,然後你會遇到奇怪的連結器失敗。因此,這樣做讓 cargo 能夠對建置進行基本的檢查,確保每個共享函式庫或靜態函式庫只有一個 crate 進行了綁定。

這裡有一些有趣的含義,因為 cargo 檢查每個函式庫名稱只有最多一個 crate 進行綁定,我們不希望出現一個生態系統中有很多提供對同一內容的綁定的 crate。一般來說,在 Rust 的世界中,我們處理這個問題的方式是對於大多數共享函式庫,我們會嘗試只有一個名為 -sys 的 crate。例如,對於 libsodium,它會被稱為 libsodium-dash-sys。該 crate 的唯一功能是與共享函式庫進行綁定,並對該函式庫的原始 FFI 方法進行公開,它不應該進行任何安全的封裝或提供符合 ergonomic 的界面,它只是對該函式庫的純粹綁定,然後,你可以有很多不同的函式庫,它們都使用該 Sys 函式庫並在其上生成良好的綁定。

但概念是,因為你只有一個負責綁定的 sys crate,所以它有 links keyword。這樣只會在一個地方進行連結。這對於語義版本控制也有一些影響,如果你有這樣的 sys crate,其中一個要做的事情是盡量避免對其進行主版本更新,因為如果你對其進行主版本更新,結果將是在 dependency graph 中同時使用兩個不同主版本的 sys crate 將變得不可能,因為兩個主版本被視為兩個不同的 crate。如果兩個不同的 crates 都對相同名稱具有 links keyword,cargo 將會報錯並拒絕建置。如果你對 syscrate 進行了一個破壞性的版本更新,你會希望每個使用該 sys crate 的消費者也同時升級到新版本。

我們等等會談到如何生成 sys crate,不過先說你有幾種手段 :

  • 手動編寫
  • bindgen

最後討論 cargo: 的兩個變數 :

cargo:rerun-if-changed=PATHTells Cargo when to re-run the script. 
cargo:rerun-if-env-changed=VARTells Cargo when to re-run the script.
// 基本上,沒有這兩個 rerun-if 變數的話,即使沒有任何改變,建置腳本每次都會被重新執行,
// 因為 cargo 本身不知道要在什麼條件下才要重新執行建置腳本,但如果你有這個變數就不一定了。
// 例如你的建置腳本有編譯小型 C 程式到共享函式庫檔案,如果 C 程式再重新執行建置腳本。

Build script sandboxing

0:34:09

在使用建置腳本時要牢記一件事,就是目前它們是非常強大的工具,但你很容易因為建置腳本而受傷,因為建置腳本在目前至少還沒有任何有意義的方式上被隔離,建置腳本可以連接到資料庫,可以連接到網路,可以讀取任意檔案,可以寫入任意檔案,只要當前使用者有存取權限。建置腳本是非常強大的工具,因為它們被 implicitly 信任,因為它們會自動為所有依賴項建置和執行,這是令人擔憂的。這讓人聯想到 Unclue Ben 對 Peter Parker 說的一句話,"Great power comes great responsibility",但同時,建置腳本也令人不安,因為你並不真正控制你的依賴項有哪些建置腳本。你可能沒有在審查所有的建置腳本。在生態系統中有一些工作正在進行,試圖找出如何以有意義的方式對建置腳本進行隔離,其中一種方法是將建置腳本編譯和建置為 Wasm,這樣它們對系統的其餘部分的 API 就有了非常大的限制。

libgit2-sys

0:36:36

rust-lang/git2-rslibgit2 綁定,但不是真正的 git C 函式庫,而是 C 中對 git 的重新實作。

看到該 repo 的 Cargo.toml :

[dependencies] libgit2-sys = { path = "libgit2-sys", version = "0.16.2" } # git2-rs repo 包含 git2 以及 libgit2-sys

接著看到 libgit2-sys 的 Cargo.toml :

[package] links = "git2" # 前面提到的 links build = "build.rs" # 非必要,預設就會找到檔名為 build.rs 的檔案

通常,對於這類 sys crates 的建置簡本,你會看到非常相似的模式。一般來說,建置腳本會先查看是否有環境變數告訴它應該去哪裡查找,如果沒有,它們就會使用系統路徑,然後它們會查看是否可以在該路徑找到所需的函式庫,以及函式庫是否是正確的版本。如果有找到正確版本,建置腳本就會生成綁定並告訴 cargo 連結到函式庫。否則,建置腳本通常會從已知來源建置該依賴關係,這稱為 vendoring,因此包含了它連結到的共享函式庫的原始碼,並且會為你建置它到 OUT_DIR,然後連結到它。無論建置腳本何獲取共享函式庫,它還會使用類似 bindgen 的工具生成與該共享函式庫的 Rust 綁定,我們稍後將看一下這些綁定是什麼樣子,因為這涉及到更多的 FFI 領域。

看到 libgit2-sysbuild.rsmain :

fn main() { // 讀取環境變數 let https = env::var("CARGO_FEATURE_HTTPS").is_ok(); let ssh = env::var("CARGO_FEATURE_SSH").is_ok(); // 1: build it from source, 0: never build it from soruce let vendored = env::var("CARGO_FEATURE_VENDORED").is_ok(); let zlib_ng_compat = env::var("CARGO_FEATURE_ZLIB_NG_COMPAT").is_ok(); ... }

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
0:40:04
繼續看到 libgit2-sysCargo.toml :

[features] ssh = ["libssh2-sys"] https = ["openssl-sys"] ssh_key_from_memory = [] vendored = [] vendored-openssl = ["openssl-sys/vendored"] zlib-ng-compat = ["libz-sys/zlib-ng", "libssh2-sys?/zlib-ng-compat"]

libgit2-sys 具有 vendored 的 feature,即如果此 feature 為該 crate 啟用,則從原始碼建置。Feature 的挑戰在於它們可以在 dependency graph 的任何位置啟用,然後對所有依賴者啟用,因為 cargo 只會為給定的 crate 版本建置一次。假設你是 dependency graph 中的一個 crate,你對 git2 僅有依賴,並且未設定 vendored flag,但是你還依賴於 foo crate,並且 foo crate 也對 git2 進行了依賴,但是它設定了 vendored flag,那麼你將會得到它的 vendored 版本,
因為 cargo 會採用 dependency closure 中設定的所有 feature flag 的聯集。

接著看到 libgit2-sysbuild.rstry_system_libgit2 :

fn try_system_libgit2() -> Result<pkg_config::Library, pkg_config::Error> { // pkg_config 很常被使用 // https://docs.rs/pkg-config/latest/pkg_config/ let mut cfg = pkg_config::Config::new(); match cfg.range_version("1.7.2".."1.8.0").probe("libgit2") { ... } ... }

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
若沒安裝 libsodium,請依照手冊安裝,後面會用到。

Crate pkg_config 是對大多數 Unix 系統上的一個命令的相對薄包裝,該命令稱為 pkg-config :

$ pkg-config --libs libsodium #// output all linker flags
-L/usr/local/lib -lsodium
$ pkg-config --cflags libsodium # output all pre-processor and compiler flags
-I/usr/local/include
$ pkg-config --libs --static libsodium # output linker flags for static linking
-L/usr/local/lib -lsodium -lpthread -pthread
$ cat /usr/local/lib/pkgconfig/libsodium.pc
prefix=/usr/local
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include

Name: libsodium
Version: 1.0.19
Description: A modern and easy-to-use crypto library

Libs: -L${libdir} -lsodium
Libs.private: -lpthread -pthread 
Cflags: -I${includedir}

Rust pkg-config 文件

After running pkg-config all appropriate Cargo metadata will be printed on stdout if the search was successful.

這段話的意思是說只要你使用這個 crate,它不僅會告訴你某個函式庫是否可用,還會輸出所有必要的 cargo 標準輸出指令 (cargo:_ ),供建置腳本使用,例如設定連結搜尋路徑和連結器參數等等。因此,這是一種非常方便的方法來進行這類綁定。

繼續看到 libgit2-sysbuild.rsmain :

fn main() { ... if try_to_use_system_libgit2 && try_system_libgit2().is_ok() { // using system libgit2 has worked return; // 找到系統路徑即回傳 } ... // 未找到 system path,發出 cargo:rustc-cfg 的指令 // 接著就可以根據是否從原始碼建置了 libgit2 來進行條件編譯。 println!("cargo:rustc-cfg=libgit2_vendored"); if !Path::new("libgit2/src").exists() { let _ = Command::new("git") .args(&["submodule", "update", "--init", "libgit2"]) // 執行命令 .status(); } }

繼續看到 libgit2-sysCargo.toml :

[package] ... version = "0.16.2+1.7.2" ... exclude = [ "libgit2/ci/*", "libgit2/docs/*", "libgit2/examples/*", "libgit2/fuzzers/*", "libgit2/tests/*", ] ...

當你執行 cargo publish 時,預設情況下將包含所有未被 .gitignore 忽略的檔案。因此,如果他們沒有將 libgit2 添加到 .gitignore 中,則 libgit2 的所有檔案也會包含在內。因此, crates.io 上的 libgit2-sys crate 原始碼 tarball 包含了 libgit2 的原始碼。這就是 Line 3 包含加號的原因,它包含了與 libgit2 的這個版本一起發布和捆綁的 libgit2 的版本。Line 5 - Line 11 則是當你 publish 時不要包含所有這些其他檔案,因為它們只是無關緊要且佔用大量空間。

繼續看到 libgit2-sysbuild.rsmain :

fn main() { ... // 從 source 建置 libgit2 let target = env::var("TARGET").unwrap(); let windows = target.contains("windows"); let dst = PathBuf::from(env::var_os("OUT_DIR").unwrap()); let include = dst.join("include"); let mut cfg = cc::Build::new(); // cc crate 用於建置腳本編譯自定義 C 程式碼的函式庫 fs::create_dir_all(&include).unwrap(); ... // 前面參數都處理完畢之後,觸發編譯器和連結器開始做事 cfg.compile("git2"); ... // 編譯完之後,發出 rustc-link 的指令 if target.contains("windows") { println!("cargo:rustc-link-lib=winhttp") println!("cargo:rustc-link-lib=rpcrt4"); println!("cargo:rustc-link-lib=ole32"); println!("cargo:rustc-link-lib=crypt32"); println!("cargo:rustc-link-lib=secur32"); } ... // 最後使用到了前面提到的 rerun-if 命令 println!("cargo:rerun-if-changed=libgit2/include"); println!("cargo:rerun-if-changed=libgit2/src"); println!("cargo:rerun-if-changed=libgit2/deps"); }

Q : any reason why the api uses .statik and not .static?
A : 因為在 Rust 中,static 是一個 keyword。因此,你不能將它用於函式名稱。但你可以在 static 加個前綴 r# 即可使用 keyword :

fn main() { r#static(); } fn r#static() {}

除了 static 變成 statik 例子,另外還有 crate 變成 krate

Q : does the user have to build the C library on their machines, or can we publish prebuilt .so files?
A : cargo 不會阻止你將 .so 檔 include 在你的建置產物中,在非常罕見的情況下,這樣做是有道理的。通常不建議 include .so 檔,但在像 .o 檔或 .a 檔通常可能會出現在嵌入式平台上,因為為該設備建置硬體綁定很麻煩,所以他們會將它與之捆綁在一起,這樣做的問題在於這些產物與你的建置和執行環境緊密相關。舉個簡單的例子來說,假設你是在 64 位 Linux 上進行建置,而有人正試圖在 32 位 Linux 上運行,你的 .so 檔在他們的機器上將無法運行。但是任何目標的不同、函式庫的版本的不同都可能造成問題。一般來說,使用他們機器上的內容或在他們的機器上進行建置比嘗試為任何可能的消費者建置 .so 檔更安全。.so 檔不是不可能建置成功,但通常應該避免這樣做。

我們在 build.rs 中看到的所有內容,都只是告訴 cargo 如何與 libgit2 進行連結。如果它已經存在於系統上,則只是 -lgit2; 如果是從原始碼建置的,則是 -L OUT_DIR 中的某個位置。但這並沒有解釋我們如何實際呼叫這些函式,最終這只是意味著這些 symbol 在二進位檔中是可用的,但我們如何從 Rust 中呼叫它們呢?有很多方法可以做到這一點,看到 libgit2-syslib.rs :

... #[repr(C)] // 使用到 C 的型別 pub struct git_checkout_options { pub version: c_uint, pub checkout_strategy: c_uint, pub disable_filters: c_int, ... } pub type git_checkout_notify_cb = Option< extern "C" fn( // 使用到 C 的外部函式 git_checkout_notify_t, ... *mut c_void, ) -> c_int, >; ...

bindgen

0:54:31

rust-bindgen automatically generates Rust FFI bindings to C (and some C++) libraries.

看到一個例子,libgit2/include/git2/commit.h 的其中一個函式 :

GIT_EXTERN(int) git_commit_lookup( git_commit **commit, git_repository *repo, const git_oid *id);

對應到 libgit2-syslib.rs :

extern "C" { ... pub fn git_commit_lookup( commit: *mut *mut git_commit, repo: *mut git_repository, id: *const git_oid, ) -> c_int; ... }

extern keyword 主要是改變該函式使用的 calling convention。如果呼叫此 git_commit_lookup 函式,你只會提供函式的宣告而不提供函式的定義。基本上,extern 表示這是在其他地方定義的,所以只需查看二進位檔的 symbol table。如果看到對此的呼叫,就實際呼叫它。然後,它改變了 calling convention,意思是,如果只是寫 extern,比如 extern fn,或者在上面例子使用的 extern block, 其 block 內部的函式會使用 C 的 calling convention 來呼叫此函式,而不使用 Rust 的 calling convention。對於任何 extern C,你必須確保所有參數都是有效的 C 等效參數。因此,這裡的任何結構都必須是 #[repr(C)],以便在這種呼叫中使用。

再來看到令一個例子,libgit2-syslib.rs

pub enum git_commit {}

這表示 git_commit 對我們來說是 opaque 型別,在程式碼的許多地方,我們將傳遞 *mut git_commit。我們不想將 git_commit 轉換為 Rust 結構。git_commit 在 git2 函式庫的某個地方定義,但其內部對我們來說不重要。我們只會傳遞指向它的指標,然後使用方法來存取內部欄位。比如,如果我們想查看 git_commit 的作者,我們會呼叫像 git_commit_author 這樣的方法,並傳入 git_commit 的指標。這是一種說明這種型別實際上只是一種我們只會透過指標處理的 opaque 型別的方式。

Q : Would instead of pub enum git_commit {} a pub struct git_commit; work?
A : 這是因為你不希望有人能夠建構它的 instance。因為你是在說這種型別完全由該函式庫管理,你不希望有人能夠從任何地方建構一個指向它的指標。在 Rust 中,empty enum 無法初始化,你無法建立一個 git_commit,因為沒有合法的 varaint。因此,這是一種真正宣告此型別是 opaque 的方法。就 Rust 那邊而言,如果它是一個 pub struct git_commit,你可以透過 git_commit 就建構它,然而我們不希望人們被允許建構。

看到,libgit2-syslib.rs 手動定義等價於 libgit2git_revspec 結構,不是由 bindgen 產生 :

#[repr(C)] pub struct git_revspec { pub from: *mut git_object, pub to: *mut git_object, pub flags: c_uint, }

手動定義結構有一些優點和缺點。優點是你可以控制所有這些型別的確切佈局,不僅是在記憶體方面的佈局,而且可以控制每個欄位的命名。你可以添加存取器,你可以根據需要實作 Clone,你對這些綁定擁有更多的控制權,而且,隨著時間的推移,手動定義結構是穩定的,你可以永遠繼續使用它們。然而是缺點是,如果底層函式庫,例如,libgit2 發生了改變,而且這改變對你寫的綁定有影響,那麼你就必須更改這些綁定。

自動定義結構的方法是使用像 bindgen 這樣的工具。bindgen 會讀取 C 的標頭檔並為你生成對應的檔案。也因為是程式自動產生,這就意味著你的控制權會減少。bindgen 有時會生成一些非常複雜的綁定,讓人們不容易閱讀,但一般來說是正確的。有時關於確保 Rust 型別和 C 型別之間的記憶體佈局完全相同的一些微妙問題,bindgen 會知道,如果你手工編寫這些綁定,你就必須了解這些問題。使用像 bindgen 這樣的工具自動生成 Rust 綁定的問題在於,隨著時間的推移,它們可能不穩定。如果 bindgen 版本更新,它可能會開始為相同的 C 程式碼生成不同的 Rust 程式碼。因此,這可能對 sys crate 造成向後不兼容性的危障。

想像一下,你的 sys crate 只是在 build.rs 中呼叫 bindgen。這是一個相當常見的模式。

參考 The bindgen User Guide 測試 bindgen 功能 :

  1. 加 bindgen 到 Cargo.toml
[package]
name = "build-and-ffi"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[build-dependencies]
bindgen = "0.65.1"
  1. 創一個 wrapper.h
#include <sodium.h>
  1. 修改 build.rs
use std::env; use std::path::PathBuf; fn main() { println!("cargo:rustc-link-lib=sodium"); println!("cargo:rerun-if-changed=wrapper.h"); let bindings = bindgen::Builder::default() .header("wrapper.h") .parse_callbacks(Box::new(bindgen::CargoCallbacks)) .generate() .expect("Unable to generate bindings"); let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); bindings .write_to_file(out_path.join("bindings.rs")) .expect("Couldn't write bindings!"); }
  1. 修改 src/main.rs
mod ffi { include!(concat!(env!("OUT_DIR"), "/bindings.rs")); } fn main() { println!(concat!(env!("OUT_DIR"), "/bindings.rs")); foo::foo(); }
  1. 編譯並執行
$ cargo run
...
warning: type `wchar_t` should have an upper camel case name
 --> /home/wilson/CrustOfRust/build-and-ffi/target/debug/build/build-and-ffi-c6960a0dba203975/out/bindings.rs:3:27733
  |
3 | ...8 ; pub type wchar_t = :: std :: os :: raw :: c_int ; # [repr (C)] # [repr (align (16))] # [...
  |                 ^^^^^^^ help: convert the identifier to upper camel case: `WcharT`
  |
  = note: `#[warn(non_camel_case_types)]` on by default
...
  1. vim /home/wilson/CrustOfRust/build-and-ffi/target/debug/build/build-and-ffi-c6960a0dba203975/out/bindings.rs

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
bindgen 並不總是產生我們想要的東西
我們不想要建構出 opaque 型別的結構 :

pub struct crypto_aead_aes256gcm_state_ { pub opaque : [:: std :: os :: raw :: c_uchar ; 512usize] , }

我們需要手動作修改 :

pub enum crypto_aead_aes256gcm_state_ {}

你可以只揭露你想揭露的內容,方法有幾種

  • 修改 src/main.rs :
    ​​​​// private 模組 include bindings ​​​​mod ffi ​​​​{ ​​​​ include!(concat!(env!("OUT_DIR"), "/bindings.rs")); ​​​​} ​​​​pub use::ffi::// (欲揭露的 bindings.rs 的結構或函式) ​​​​fn main() ​​​​{ ​​​​ println!(concat!(env!("OUT_DIR"), "/bindings.rs")); ​​​​ foo::foo(); ​​​​}
  • 修改 build.rs :
use std::env; use std::path::PathBuf; fn main() { ... let bindings = bindgen::Builder::default() .header("wrapper.h") .blacklist_type("foobar") // 或 .whitelist_var("CRYPTBOX") .parse_callbacks(Box::new(bindgen::CargoCallbacks)) .generate() .expect("Unable to generate bindings"); ... }

通常對於 sys crate,你希望找到方法來確保你的公用介面至少在某種程度上是穩定的。對於 libgit2,它們的方式就是檢查這些綁定,無論它們是自動生成並進行調整,還是完全自動生成並簽入,還是手寫並簽入以便於不必在每次建置時自動生成它們。事實上,這就是為什麼 bindgen crate 還帶有一個命令列工具,你可以只需要使用一次 bindgen 來生成這些綁定。

libssh2-sys

1:08:08

1:21:45
深入研究 libsodium 之前,先看其他的 crate 的建置腳本,並觀察各建置腳本的差異。ssh2-rs 生成對 libssh2 C 函式庫的綁定。該 crate 裡面有兩個 crate,一個叫做 ssh2,另一個叫做 libssh2-sys

首先看到 libssh2-syslib.rs 是手寫綁定,因為它想控制穩定性 :

pub const SSH_DISCONNECT_HOST_NOT_ALLOWED_TO_CONNECT: c_int = 1; pub const SSH_DISCONNECT_PROTOCOL_ERROR: c_int = 2; pub const SSH_DISCONNECT_KEY_EXCHANGE_FAILED: c_int = 3; pub const SSH_DISCONNECT_RESERVED: c_int = 4; pub const SSH_DISCONNECT_MAC_ERROR: c_int = 5; pub const SSH_DISCONNECT_COMPRESSION_ERROR: c_int = 6; pub const SSH_DISCONNECT_SERVICE_NOT_AVAILABLE: c_int = 7;

Struct bindgen::Builder 有很多配置選項 :

// Bindgen can map C/C++ enums into Rust in different ways. // The way bindgen maps enums depends on the pattern passed to several methods: constified_enum_module() bitfield_enum() newtype_enum() rustified_enum() // 函式 pub fn generate_comments(self, doit: bool) -> Self pub fn blocklist_file<T: AsRef<str>>(self, arg: T) -> Builder pub fn header_contents(self, name: &str, contents: &str) -> Builder pub fn opaque_type<T: AsRef<str>>(self, arg: T) -> Builder pub fn type_alias<T: AsRef<str>>(self, arg: T) -> Builder pub fn conservative_inline_namespaces(self) -> Builder pub fn parse_callbacks(self, cb: Box<dyn ParseCallbacks>) -> Self ...

繼續看到 libssh2-sysbuild.rs :

... fn main() { ... // The system copy of libssh2 is not used by default because it // can lead to having two copies of libssl loaded at once. // See https://github.com/alexcrichton/ssh2-rs/pull/88 if env::var("LIBSSH2_SYS_USE_PKG_CONFIG").is_ok() { // 預設不使用 pkg-config,它需要你明確選擇加入其中。 if zlib_ng_compat { panic!("LIBSSH2_SYS_USE_PKG_CONFIG set, but cannot use zlib-ng-compat with system libssh2"); } // libssh2 並沒有設定函式庫版本要求 // libgit2 建置腳本的 try_to_use_system_libgit2 函式有要求函式庫版本 : // match cfg.range_version("1.7.2".."1.8.0").probe("libgit2") { // .... // } // 如果你可以避免版本要求的話很好,因為在大部份情況下你可以避免從 source 建置。 // libgit2 也有做類似的事情 if let Ok(lib) = pkg_config::find_library("libssh2") { // pkg_config 前面看過了 for path in &lib.include_paths { println!("cargo:include={}", path.display()); } // return; // 找到路徑即回傳 } } ... // libgit2 也有做類似的事情 if !Path::new("libssh2/.git").exists() { let _ = Command::new("git") .args(&["submodule", "update", "--init"]) .status(); } // libgit2 也有做類似的事情 let target = env::var("TARGET").unwrap(); let profile = env::var("PROFILE").unwrap(); let dst = PathBuf::from(env::var_os("OUT_DIR").unwrap()); let mut cfg = cc::Build::new(); ... // 這是一個使用 cargo:KEY=VALUE 格式的 cargo 指令的範例。 // 這意味著 libz-sys crate 會發出一個 cargo:include= 指令, // 其中包含了 Z 函式庫(壓縮函式庫)的 include 路徑。 println!("cargo:rerun-if-env-changed=DEP_Z_INCLUDE"); // 我們在這裡耗用它,以便告訴我們對 libssh2 的建置也要發現壓縮庫的 include 路徑。 // 透過使用這個環境變數,cargo 根據 cargo:key=value 自動設定。 if let Some(path) = env::var_os("DEP_Z_INCLUDE") { cfg.include(path); } ... // 解析出版本並寫回檔案 let libssh2h = fs::read_to_string("libssh2/include/libssh2.h").unwrap(); let version_line = libssh2h .lines() .find(|l| l.contains("LIBSSH2_VERSION")) .unwrap(); let version = &version_line[version_line.find('"').unwrap() + 1..version_line.len() - 1]; ... // 開始編譯 cfg.warnings(false); cfg.compile("ssh2"); ... } ... #[cfg(target_env = "msvc")] // msvc 是微軟開發的 C/ C++ 編譯器 fn try_vcpkg() -> bool { vcpkg::Config::new() ... ... }

openssl-sys

1:14:38

繼續觀察下一個建置腳本,rust-openssl 生成對 libssl C 函式庫的綁定。該 crate 裡面有兩個 crate,一個叫做 openssl,另一個叫做 openssl-sys

openssl-sys 有 build 的子目錄,看到子目錄的 main.rs :

... fn find_openssl(target: &str) -> (Vec<PathBuf>, PathBuf) { #[cfg(feature = "vendored")] { // vendor if the feature is present, unless // OPENSSL_NO_VENDOR exists and isn't `0` if env("OPENSSL_NO_VENDOR").map_or(true, |s| s == "0") { // find_vendored 將會檢查 source,建置,並且連結 return find_vendored::get_openssl(target); } } // not vendored // 從系統路徑尋找,該路徑會使用 pkg-config find_normal::get_openssl(target) } ... fn main() { ... let (lib_dirs, include_dir) = find_openssl(&target); ... for lib_dir in lib_dirs.iter() { println!( "cargo:rustc-link-search=native={}", lib_dir.to_string_lossy() ); } ... // 找到版本 let version = postprocess(&[include_dir]); ... // 嘗試弄清楚建置 openssl 為 .so 檔還是 .a 檔 let kind = determine_mode(&lib_dirs, &libs); } fn determine_mode(libdirs: &[PathBuf], libs: &[&str]) -> &'static str { // 判斷要編譯成哪種類型的函式庫 let kind = env("OPENSSL_STATIC"); match kind.as_ref().and_then(|s| s.to_str()) { Some("0") => return "dylib", Some(_) => return "static", None => {} } ... // 檢查 .a, .so 檔是否在 let can_static = libs .iter() .all(|l| files.contains(&format!("lib{}.a", l)) || files.contains(&format!("{}.lib", l))); let can_dylib = libs.iter().all(|l| { files.contains(&format!("lib{}.so", l)) || files.contains(&format!("{}.dll", l)) || files.contains(&format!("lib{}.dylib", l)) }); // 使用到 .a, .so 是否存在的事實來決定要怎麼做 match (can_static, can_dylib) { (true, false) => return "static", (false, true) => return "dylib", (false, false) => { panic!( "OpenSSL libdir at `{:?}` does not contain the required files \ to either statically or dynamically link OpenSSL", libdirs ); } (true, true) => {} } ... "dylib" // .a 以及 .so 皆存在的話,使用動態連結函式庫 }

建置腳本是一個非常冗長的步驟程序,但你會發現它們在這些不同的 crate 之間非常相似。一般的模式是,如果可以從系統中使用它,那就從系統中使用它。否則,從原始碼建置它。Jon 的建議的是,如果你正在為某個 crate 進行建置,那麼你應該仔細考慮是否值得進行 vendoring,對於消費者來說,vendoring 是方便的,因為這意味著如果他們在本地沒有安裝該 crate,你只需為他們建置它,然後它就可以工作。但是,從原始碼建置它的機會極其複雜且容易出錯。因此更好的作法是,如果在系統中找不到它,你應該發出錯誤並告訴使用者安裝缺少的函式庫。就像在 libsodium 的情況下,如果某人沒有安裝 libsodium,我不想不得不從原始碼建置 libsodium。因此,我們的建置腳本可以發出一個錯誤,告訴使用者安裝 libsodium,然後再試一次。這也是我們等等要做的。我們不會嘗試透過 Rust 中的 CC 來找出如何從原始碼建置它。

另一件事是如何決定是否使用 vendoring,這也有些複雜。一般最佳做法是,如果需要 vendoring,則讓人們可以選擇使用一個功能來啟用 vendoring。但如果你特別需要 vendor,那麼你就有一個環境變數可以覆蓋 never_vendoring 的 feature。這很有用的原因是,有些使用者對如何將原始碼引入其建置過程有非常嚴格的要求,例如,如果他們嘗試進行 hermetic 建置,或者如果你在公司並且想要確保每個引入的原始碼都經過了各種檢查,通常不希望進行任何 vendoring,你希望確保一切都是由你的建置系統提供的。想像一下,如果你使用的是像 Buck 或 Bazel 之類的工具。如果 OpenSSL 不可用,你希望出現錯誤,因為這意味著它沒有在標準建置環境中宣告。你通常不希望大部分應用程式使用同一個 OpenSSL,但是你的 Rust 部分使用另一個 OpenSSL。因此,一般而言,建置腳本需要提供這種覆蓋機制,以表示如果在系統中找不到它,那麼我只想要一個錯誤,而不是讓建置腳本自己建置它。

libsodium-sys

1:19:50

本次實作會參照到以下三個網站 :

Writing our own libsodium-sys

1:21:29

開始建置 Rust 專案 :

$ cargo new --lib libsodium-sys
$ cd libsodium-sys
$ vim Cargo.toml

調整 Cargo.toml 的設定 :

[package] name = "libsodium-sys" version = "0.1.0" edition = "2021" links = "sodium" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [build-dependencies] bindgen = "0.65.1" pkg-config = "0.3.30" [dependencies]

建立 wrapper.h 檔案 :

#include <sodium.h>

查看 libsodium 版本 :

$ pkg-config --libs 'libsodium > 1.0.19'
Requested 'libsodium > 1.0.19' but version of libsodium is 1.0.19

建立 build.rs 檔案 :

use std::env; use std::path::PathBuf; fn main() { // 新增 pkg_config::Config::new() .atleast_version("1.0.19") .probe("libsodium") .unwrap(); println!("cargo:rerun-if-changed=wrapper.h"); let bindings = bindgen::Builder::default() .header("wrapper.h") // 新增 .allowlist_function("sodium_init") .parse_callbacks(Box::new(bindgen::CargoCallbacks)) .generate() .expect("Unable to generate bindings"); let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); bindings .write_to_file(out_path.join("bindings.rs")) .expect("Couldn't write bindings!"); }

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
atleast_version

Find the system library named foo, with no version requirement (not recommended):

fn main() { pkg_config::probe_library("foo").unwrap(); }

這樣做的原因是因為你的綁定很可能真的有版本要求。當你生成綁定時,你是從特定版本的 C 標頭檔生成它們的。這意味著它們將 include 在該版本中存在但在較早版本中不存在的函式。因此,通常你會希望 include 一個 atleast_version

執行命令 :

$ cargo test --verbose
...
   Doc-tests libsodium-sys
     Running `rustdoc --edition=2021 --crate-type lib --crate-name libsodium_sys --test /home/wilson/CrustOfRust/libsodium-sys/src/lib.rs -L dependency=/home/wilson/CrustOfRust/libsodium-sys/target/debug/deps -L dependency=/home/wilson/CrustOfRust/libsodium-sys/target/debug/deps -L native=/usr/local/lib --extern libsodium_sys=/home/wilson/CrustOfRust/libsodium-sys/target/debug/deps/liblibsodium_sys-9a889cf493db70ba.rlib -C embed-bitcode=no --error-format human`
...

看到 natvie 參數可能的值 :

-L native=/usr/lib -l sodium // 需做對應處置 -L native=/usr/local/lib // 不需做對應處置
native 對應處置

Jon 認為這是 pkg-config 的一個 bug,即使該搜尋路徑是標準的共享函式庫位置,它仍會發出用於設定動態函式庫搜尋路徑的 cargo 指令。所以我們想選擇不這樣做,因為這是錯誤的,有時會導致很難除錯問題,新增 print_system_cflags 方法避免這種情形 :

... fn main() { // 新增 pkg_config::Config::new() + .print_system_libs(false) .atleast_version("1.0.19") .probe("libsodium") .unwrap(); ... }

再次執行命令 :

$ cargo test --verbose
...
... native -l sodium
...

查看 bindgen 產生的 /home/wilson/CrustOfRust/libsodium-sys/target/debug/build/libsodium-sys-db361a9ebdcc127c/out/bindings.rs 檔案內容 :

/* automatically generated by rust-bindgen 0.65.1 */ extern "C" { pub fn sodium_init () -> :: std :: os :: raw :: c_int ; }

接著修改 src/lib.rs :

#[allow(non_upper_case_globals)] #[allow(non_camel_case_types)] #[allow(non_snake_case)] #[allow(dead_code)] // Jon 後面才加,這裡先加了 mod ffi { include!(concat!(env!("OUT_DIR"), "/bindings.rs")); } pub use ffi::sodium_init; // unsafe sodium_init

Attribute Macro ctor::ctor : Marks a function or static variable as a library/executable constructor. This uses OS-specific linker sections to call a specific function at load time.
標記 ctor 屬性的函式可以在 main 函式之前執行。我們的 sodium_init 就可以在 main 函式之前執行。ctor 的缺點是,如果有錯誤的話,沒有一個好方法可以回報給使用者。

libsodium-Quickstart and FAQ

  • Boilerplate
    The sodium_init() function must be called before any other function. It is safe to call sodium_init() multiple times or from different threads; it will immediately return 1 without doing anything if the library has already been initialized.
  • I want to write bindings for my favorite language, where should I start?
    Start with the crypto_generichash and crypto_secretstream APIs. These are the trickiest to implement bindings for and will provide good insights about how to design your bindings.

繼續修改 src/lib.rs (編譯時期檢查結構是否 init 的寫法) :

#[allow(non_upper_case_globals)] #[allow(non_camel_case_types)] #[allow(non_snake_case)] #[allow(dead_code)] mod ffi { include!(concat!(env!("OUT_DIR"), "/bindings.rs")); } #[non_exhaustive] //在沒有 implicitly 呼叫我們的建構子的情況下無法建構這種結構。 #[derive(Clone, Debug)] pub struct Sodium; impl Sodium { pub fn new() -> Result<Self, ()> { if unsafe { ffi::sodium_init() } < 0 { Err(()) } else { Ok(Self) } } pub fn crypto_generichash(&self) -> () { } } pub use ffi::sodium_init; // unsafe sodium_init
執行時期檢查結構是否 init 的寫法
... mod ffi { include!(concat!(env!("OUT_DIR"), "/bindings.rs")); } +static HAS_BEEN_INIT: OnceCell<bool> = OnceCell::new(false); #[non_exhaustive] #[derive(Clone, Debug)] pub struct Sodium; impl Sodium { - pub fn new() -> Result<Self, ()> + pub fn init() -> Result<Self, ()> { if unsafe { ffi::sodium_init() } < 0 { Err(()) } else { Ok(Self) } } pub fn crypto_generichash(&self) -> () { + assert!(HAS_BEEN_INIT); } } pub use ffi::sodium_init; // unsafe sodium_init

Q : non_exhaustive on a struct? isn't that only for enums?
A : 在結構上標記為 non_exhaustive 主要是為了讓外部使用者,也就是這個 crate 的函式庫 API 使用者,無法建構或解構這樣的結構 instance。這與給該型別添加非公開欄位具有相同的效果,但無需額外的欄位。

src/lib.rs 寫一個測試 :

#[cfg(test)] mod tests { use super::*; #[test] fn it_works() { Sodium::new.unwrap(); } }
目前程式碼

src/lib.rs :

#[allow(non_upper_case_globals)] #[allow(non_camel_case_types)] #[allow(non_snake_case)] #[allow(dead_code)] mod ffi { include!(concat!(env!("OUT_DIR"), "/bindings.rs")); } #[non_exhaustive] #[derive(Clone, Debug)] pub struct Sodium; impl Sodium { pub fn new() -> Result<Self, ()> { if unsafe { ffi::sodium_init() } < 0 { Err(()) } else { Ok(Self) } } pub fn crypto_generichash(&self) -> () { } } pub use ffi::sodium_init; #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { Sodium::new().unwrap(); } }

編譯並測試,成功呼叫 sodium_init 函式 :

$ cargo test
...
running 1 test
test tests::it_works ... ok
...

Q : I always wanted to port or write a wrapper for a simple C library for learning purposes, but everytime I try to tackle that it becomes way to complicated to understand the C code …
A : 通常你不需要理解 C 程式碼,而是需要理解 C API。對於像 bindgen 這樣的工具,你甚至可能不需要這樣做,因為它會為你生成等價的 Rust 型別。你只需要弄清楚這些方法的語義,基本上就是如何將這種直接的 unsafe C API 轉換為一個良好的人機界面,這可能需要一些工作,就像我們剛剛做的 sodium::new() 一樣。我們也將嘗試 crypto_generichash,只是看看是否能使其正常工作。但大多數情況下,你不應該需要深入研究 C 程式碼本身。

Wrapping crypto_generichash

1:40:11

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
我們應該將 FFI 綁定放在單獨的 sys crate 中,而人機界面不應該在該 sys crate 中。原因是,你更有可能對包裝 API 進行破壞性更改,而你不希望對 sys crate 做出破壞性更改,因為這些更改真的很麻煩。但現在,僅供展示,讓我們假設這裡的 FFI 是一個獨立的 crate。這樣做會更容易設定。

libsodium-Generic hashing-Usage

  • The crypto_generichash() function puts a fingerprint of the message in whose length is inlen bytes into out. The output size can be chosen by the application.
  • The minimum recommended output size is crypto_generichash_BYTES. This size makes it practically impossible for two messages to produce the same fingerprint.
  • However, for specific use cases, the size can be any value between crypto_generichash_BYTES_MIN (included) and crypto_generichash_BYTES_MAX (included).
  • key can be NULL and keylen can be 0. In this case, a message will always have the same fingerprint, like the MD5 or SHA-1 functions for which crypto_generichash() is a faster and more secure alternative.

Q : why did the bindings use c_int (why does that even exist?) instead of using u32?
A : c_int 不一定與 u32 相同。有時 C 中的類型是與平台相關的,例如。我們希望在生成的綁定中捕獲這一點,這就是我們使用 c_int 的原因。

回到 build.rs,允許 crypto_generichash 及相關變數用 bindgen 產生 :

use std::env; use std::path::PathBuf; fn main() { ... println!("cargo:rerun-if-changed=wrapper.h"); let bindings = bindgen::Builder::default() .header("wrapper.h") .allowlist_function("sodium_init") + .allowlist_function("crypto_generichash") + .allowlist_var("crypto_generichash_.*") .parse_callbacks(Box::new(bindgen::CargoCallbacks)) .generate() .expect("Unable to generate bindings"); ... }

修改 src/lib.rsSodium::crypto_generichash 函式簽章以及實作 :

#![feature(maybe_uninit_slice)] // MaybeUninit 僅能在 nightly 下使用 use std::mem::MaybeUninit; ... #[non_exhaustive] #[derive(Clone, Copy, Debug)] // 新增 Copy,因為重要的是 new 至少要被呼叫一次 pub struct Sodium; ... impl Sodium { ... // 1. 我們不需要 `output` 初始化完成,因我我們的函式就會寫入了,所以使用了 MaybeUninit // 2. 我們可以內部配置一個 vector,而不是要求 caller 提供 `output` 參數,這樣可能更合適 // 3. caller 不一定要提供 key pub fn crypto_generichash<'a>( self, // Sodium 可以被 Copy, 所以不用是 &self input: &[u8], key: Option<&[u8]>, out: &'a mut [MaybeUninit<u8>] ) -> Result<&'a mut [u8], ()> // 使用 () 作為 generic failure error { assert!(out.len() >= usize::try_from(ffi::crypto_generichash_BYTES_MIN).unwrap()); // 如果 uszie 裝不下會報錯 // assert!(out.len() >= ffi::crypto_generichash_BYTES_MIN as usize); // 如果 usize 裝不下不會報錯 assert!(out.len() <= usize::try_from(ffi::crypto_generichash_BYTES_MAX).unwrap()); if let Some(key) = key { assert!(key.len() >= usize::try_from(ffi::crypto_generichash_KEYBYTES_MIN).unwrap()); assert!(key.len() <= usize::try_from(ffi::crypto_generichash_KEYBYTES_MAX).unwrap()); } let (key, keylen) = if let Some (key) = key { (key.as_ptr(), key.len()) } else { (std::ptr::null(), 0) }; // SAFETY: We've checked the requirements of the function (MIN/MAX), and the presence of // &self means that init has been called; let res = unsafe { ffi::crypto_generichash( MaybeUninit::slice_as_mut_ptr(out), out.len() as u64, input.as_ptr(), input.len() as u64, key, keylen as u64 ) }; if res < 0 { return Err(()); } // 告訴編譯器我們假設 output 已經初始化了, // 因為 ffi::crypto_generichash 函式會修改 output。 // SAFETY: crypto_generichash writes to (and thus initializes) all the bytes of out Ok(unsafe { MaybeUninit::slice_assume_init_mut(out) }) // 一般使用到 C 外部函式都先使用 raw pointer 傳入函式, // 使用完 C 外部函式,我們需要檢查結果, // 然後假設 C 外部函式做了它所提議或所承諾的事情。 } } ...

Line 59 的問題是,crypto_generichash 實際上承諾了什麼?它承諾會將 out 的所有位元組都寫入嗎?實際上它沒有說它會這樣做,但文件說輸出大小可以由應用程式選擇
libsodium-Generic hashing-Usage

The crypto_generichash() function puts a fingerprint of the message in whose length is inlen bytes into out. The output size can be chosen by the application.

有人在聊天中說,"generichash/BLAKE2b guarantees that it writes the number of bytes you asked for, as long as that number is less than MAX",我們在 Line 17 - Line 23 檢查長度是否小於 MAX,只要成立,這樣就沒問題。

使用 hex crate,修改 Cargo.toml,等等輸出 hash 結果要用 :

[package] name = "libsodium-sys" version = "0.1.0" edition = "2021" links = "sodium" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [build-dependencies] bindgen = "0.65.1" pkg-config = "0.3.30" [dependencies] +[dev-dependencies] +hex = "0.4.3"

新增測試 :

#[cfg(test)] mod tests { ... #[test] fn it_hashes() { let s = Sodium::new().unwrap(); let mut out = [MaybeUninit::uninit(); ffi::crypto_generichash_BYTES as usize]; let bytes = s .crypto_generichash(b"Arbitrary data to hash", None, &mut out) // b : 將字串視為位元字串 .unwrap(); println!("{}", hex::encode(&bytes)); } }

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
程式用 nightly 編譯器 :

$ rustup override set nightly
目前程式碼

Cargo.toml :

[package] name = "libsodium-sys" version = "0.1.0" edition = "2021" links = "sodium" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [build-dependencies] bindgen = "0.65.1" pkg-config = "0.3.30" [dependencies] [dev-dependencies] hex = "0.4.3"

build.rs :

use std::env; use std::path::PathBuf; fn main() { pkg_config::Config::new() .atleast_version("1.0.19") .print_system_libs(false) .probe("libsodium") .unwrap(); println!("cargo:rerun-if-changed=wrapper.h"); let bindings = bindgen::Builder::default() .header("wrapper.h") .allowlist_function("sodium_init") .allowlist_function("crypto_generichash") .allowlist_var("crypto_generichash_.*") .parse_callbacks(Box::new(bindgen::CargoCallbacks)) .generate() .expect("Unable to generate bindings"); let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); bindings .write_to_file(out_path.join("bindings.rs")) .expect("Couldn't write bindings!"); }

wrapper.h :

#include <sodium.h>

src/lib.rs :

#![feature(maybe_uninit_slice)] use std::mem::MaybeUninit; #[allow(non_upper_case_globals)] #[allow(non_camel_case_types)] #[allow(non_snake_case)] #[allow(dead_code)] mod ffi { include!(concat!(env!("OUT_DIR"), "/bindings.rs")); } #[non_exhaustive] #[derive(Clone, Debug)] pub struct Sodium; impl Sodium { pub fn new() -> Result<Self, ()> { if unsafe { ffi::sodium_init() } < 0 { Err(()) } else { Ok(Self) } } pub fn crypto_generichash<'a>( self, input: &[u8], key: Option<&[u8]>, out: &'a mut [MaybeUninit<u8>] ) -> Result<&'a mut [u8], ()> { assert!(out.len() >= usize::try_from(ffi::crypto_generichash_BYTES_MIN).unwrap()); assert!(out.len() <= usize::try_from(ffi::crypto_generichash_BYTES_MAX).unwrap()); if let Some(key) = key { assert!(key.len() >= usize::try_from(ffi::crypto_generichash_KEYBYTES_MIN).unwrap()); assert!(key.len() <= usize::try_from(ffi::crypto_generichash_KEYBYTES_MAX).unwrap()); } let (key, keylen) = if let Some (key) = key { (key.as_ptr(), key.len()) } else { (std::ptr::null(), 0) }; // SAFETY: We've checked the requirements of the function (MIN/MAX), and the presence of // &self means that init has been called; let res = unsafe { ffi::crypto_generichash( MaybeUninit::slice_as_mut_ptr(out), out.len() as u64, input.as_ptr(), input.len() as u64, key, keylen as u64 ) }; if res < 0 { return Err(()); } // SAFETY: crypto_generichash writes to (and thus initializes) all the bytes of out Ok(unsafe { MaybeUninit::slice_assume_init_mut(out) }) } } pub use ffi::sodium_init; #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { Sodium::new().unwrap(); } #[test] fn it_hashes() { let s = Sodium::new().unwrap(); let mut out = [MaybeUninit::uninit(); ffi::crypto_generichash_BYTES as usize]; println!("{}", ffi::crypto_generichash_BYTES); let bytes = s .crypto_generichash(b"Arbitrary data to hash", None, &mut out) .unwrap(); println!("{}", hex::encode(&bytes)); } }

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
編譯器已設為 nightly,仍出現以下錯誤 :

$ cargo test ... error[E0554]: `#![feature]` may not be used on the stable release channel --> src/lib.rs:1:12 | 1 | #![feature(maybe_uninit_slice)] | ^^^^^^^^^^^^^^^^^^

編譯並測試 :

$ cargo test it_hashes -- --nocapture
...
running 1 test
test it_hashes ... ok
...
32 # 使用該長度作為 b2sum 輸入
3dc7925e13e4c5f0f8756af2cc71d5624b58833bb92fa989c3e87d734ee5a600
...
$ echo -n "Arbitrary data to hash" | b2sum -l256 # -n, Do not output a newline ; 32 * 8 = 256
3dc7925e13e4c5f0f8756af2cc71d5624b58833bb92fa989c3e87d734ee5a600

Reverse FFI

2:02:57

從 C 使用 Rust 的外部函式與 Rust 使用 C 的外部函式差異不大 :

// 注意事項 // 1. 型別要是 extern, 傳入參數以及回傳值也要是 C 的合法表達 // 2. 記憶體配置釋放位置要注意,如果你在 Rust/ C 配置記憶體,你會想確保在 Rust/ C 釋放記憶體 // 3. #[no_mangle] 會讓最終二進位 symbol table 中的函式名稱完全相同。 // 如果不加 #[no_mangle] 的話,則編譯器仍將編譯此函式,但它將具有一個自動生成的名稱, // 如果你只傳遞指標給它,這並不是問題,但如果你實際上想要從 C 命名此函式,這可能會成為問題。 // 4. 符合 calling convention #[no_mangle] pub extern "C" fn this_is_rust(arg: std::os::raw::c_int) -> std::os::raw::c_char { b'x' as i8 } &this_is_rust as *fn // 視為函式指標 // this is C: // extern char this_is_rust(int) // #[no_mangle] 確保自動產生的名稱與原本的相同。

Q : are you duplicating the out reference? is that allowed?

pub fn crypto_generichash<'a>( self, input: &[u8], key: Option<&[u8]>, out: &'a mut [MaybeUninit<u8>] // <----------- ) -> Result<&'a mut [u8], ()> // <-----------

A : 傳入 mutable borrow,回傳值會歸還。

autocfg

2:09:30

Crate autocfg 嘗試做的是之前一堆不同的 crate 曾經使用 build.rs 進行的事情,而它們都是以一種特別的方式進行的,這基本上是進行編譯器功能檢測。因此,你希望你的程式碼與舊版本的 Rust 兼容,但在新版本的 Rust 上,你希望利用新功能。autoconfig 可以讓你這樣做,因為它基本上允許你這樣做,它可以測試編譯一個程式並生成一個配置,你可以根據一個給定程式是否編譯來進行條件編譯。看到以下例子 :

extern crate autocfg; fn main() { let ac = autocfg::new(); ac.emit_has_type("i128"); // 檢測是否有該型別。 // 你也可以檢測是否為 nightly feature 或其他東西。 // 讓你可以根據檢測的東西進行不同的配置。 // (optional) We don't need to rerun for anything external. autocfg::rerun_path("build.rs"); }

autocfg 是一個非常輕量級的依賴項,它沒有 transitive dependency,它完全是用於 build.rs。像 Anyhow 這樣的 crate 使用它來確定它是否可以使用 nightly feature,以便讓 backtrace 更好。這是一種有條件地使用 nightly feature 而不是將整個 crate 成為 nightly。

#[cfg(accessible(::path::to::thing))] 以及 #![feature(cfg_version)] 這兩者都能讓你可以根據特定路徑進行條件編譯,就像是在當前版本的 Rust 中使用的 type/trait/constant 是否可用。這基本上意味著你可以在很多情況下不需要 autocfg 就能實現這一點。

cbindgen and cxx

2:12:14

mozilla/cbindgen 做 bindgen 反向的事情,也就是 Rust API 作為輸入,輸出 C 標頭檔,並讓你從 C 呼叫 Rust 程式。cbindgen 對於你有一個以共享函式庫形式建構的 Rust 函式庫很有用,因為你可能會想要能夠從 Python、Node.js、C 或 C++ 中使用它。這些語言對於Rust 一無所知,它們也期望使用 C ABI。因此,你會生成一個 C 標頭檔,然後他們可以使用類似 bindgen 的工具將綁定引入他們的語言中,從而透過C ABI 呼叫你的 Rust 程式碼。

如果你恰好在 Rust 和 C++ 之間進行溝通,bindgen 對於 C 標頭檔來說效果不太好,並且當你受限於 C API 時從 C++ 呼叫 Rust API 會受到一些限制。因此,有一個很棒的 crate 叫做 cxx,它允許你在Rust 和 C++ 之間建立一個更符合 ergonomic 的介面,但它要求你在介面的兩邊都做一些改變。因此,你必須能夠控制 C++ 程式碼和 Rust 程式碼。但如果你這樣做了,你基本上可以使用這個小工具。這個 crate 讓你基本上定義了這兩種語言之間的橋樑,並生成了更好的綁定給每種語言,所以如果你碰巧處於這種情況下,建議你去看看 cxx。

待整理

  1. 0:40:04
  2. error[E0554]