Categories
程式開發

如果想將C代碼等價轉換為Rust,我們需要注意什麼?


導讀: C2Rust 工具能夠將大多數 C 模塊翻譯成語義上等價的 Rust 代碼。這些模塊將單獨編譯,以生成兼容的目標文件。 C2Rust 是 Galois 和 Immunant 共同推出的項目。由於受到Facebook 和Microsoft 的推崇,Rust 開始走進大眾的視野,InfoQ 曾發表過《Rust 是系統編程的未來,C 是新的Assembly》等文章,那麼問題來了,在將遺留的C 代碼轉換為Rust,會遇到哪些問題,又該如何解決呢?我們翻譯並分享了由兩位 Immunant 聯合創始人 Andrei Homescu、Stephen Crane 撰寫的文章,這篇文章詳細闡述了將 C 代碼轉換為 Rust 的經驗教訓。

C2Rust 項目完全是關於將 C 代碼轉換為等價的、應用二進制接口兼容的 Rust 實現。 (請閱讀我們的 C2Rust 入門博文)。在實踐中,我們發現了 C 語言在實踐中編寫代碼時遇到的一些“黑暗角落”,並發現了 Rust 不能以相同的應用二進制接口(ABI)完全複製相同代碼的地方。這是關於那些 “黑暗角落” 的故事,我們認為 Rust 還需要改進,以達到在語言交互接口(FFI)上與 C 完全兼容。

譯註: FFI(Foreign Function Interface),意即語言交互接口,顧名思義,FFI 是用來與其它語言交互的接口,在有些語言裡面稱為語言綁定(Language Bindings),Java 裡面一般稱為JNI (Java Native Interface)或JNA (Java Native Access)。由於現實中很多程序是由不同編程語言寫的,必然會涉及到跨語言調用,比如A 語言寫的函數如果想在B 語言裡面調用,這時一般有兩種解決方案:一種是將函數做成一個服務,通過進程間通信(IPC) 或網絡協議通信(RPC, RESTful 等);另一種就是直接通過語言交互接口調用。前者需要至少兩個獨立的進程才能實現,而後者直接將其它語言的接口內嵌到本語言中,所以調用效率比前者高。
ABI(Application Binary Interface),應用二進制接口。是指兩程序模塊間的接口;通常其中一個程序模塊會是庫或操作系統所提供的服務,而另一邊的模塊則是用戶所運行的程序。

背景

Rust 是作為一種系統編程語言設計的,它通過借用檢查器(Borrow Checker)在編譯時增強臨時內存安全性,借用檢查器強制執行嚴格的所有權規則,並限制內存分配和指針的別名(Alias) 。此外,Rust 使用 RAII 模式實現了內存管理的自動化,這也是在編譯時通過在每個作用域的末尾調用本地對象的析構函數(Destructors)來實現的。相反,其他編程語言使用垃圾收集或引用計數(Reference Counting)來解決這些問題。然而,垃圾收集器通常是語言運行時的重要組成部分,它的存在會影響語言的其餘部分的設計,包括語言的語言交互接口。將垃圾收集的對象通過語言交互接口傳遞給另一種語言(例如C 語言),而此舉會帶來巨大的挑戰,因為垃圾收集必須跟踪跨語言的指針,否則會有過早釋放指針的風險。另外,垃圾回收語言可能完全不允許通過語言交互接口來傳遞垃圾回收的指針,就像在 Go 一樣,或者將內存分配的負擔轉嫁給開發人員。

譯註: RAII 全稱為 Resource Acquisition Is I nitialization,它是在一些面向對象語言中的一種慣用法。 RAII 源於 C++,在 Java、C#、D、Ada、Vala 和 Rust 中也有應用。 1984-1989 年期間, Bjarne Stroustrup 和 Andrew Koenig 在設計 C++ 異常時,為解決資源管理時的異常安全性而使用了該用法,後來 Bjarne Stroustrup 將其稱為 RAII。 RAII 要求,資源的有效期與持有資源的對象的生命期嚴格綁定,即由對象的構造函數完成資源的分配(獲取),同時由析構函數完成資源的釋放。在這種要求下,只要對象能正確地析構,就不會出現資源洩露問題。

引用計數(Reference Counting)是計算機編程語言中的一種內存管理技術,是指將資源(可以是對象、內存或磁盤空間等等)的被引用次數保存起來,當被引用次數變為零時就將其釋放的過程。使用引用計數技術可以實現自動資源管理的目的。同時引用計數還可以指使用引用計數技術回收未使用資源的垃圾回收算法。

Rust 對內存安全的靜態強制(Static Enforcement)(與通過垃圾收集或引用計數進行動態管理相反)使它在許多用例中成為 C 或 C++ 的有價值的替代方案。這些情況包括:

  • 多種語言必須在一個進程中共存,並通過語言交互接口進行通訊。
  • 添加重量級語言運行時是不可行的。
  • 垃圾收集的暫停時間或內存需求是不可取的。

Rust 是少數幾種可以用於實現中間件和操作系統組件的語言之一,重寫可以視情況專為原始文件的直接替換。實際上,當我們與他人討論 C2Rust 時,最常見的問題之一就是:“你嚐過 transpiling OpenSSL 嗎?”(幸運的是,已經存在用 Rust 編寫的二進制兼容重寫)。另一個很好的 Rust 替換例子是 relibc,它是用 Rust 編寫的一個 C 標準庫。儘管 Rust 在編寫應用程序方面可以與 Java、Go、Swift 和 Python 等語言競爭,但它特別適合編寫庫和低級系統組件,如 kernel modulesfirmware

譯註: Transpiling 是一個特定的術語,用於將一種語言編寫的源代碼轉換為另一種具有相同抽象層次的語言。它是由 transforming(轉換)和 compiling(編譯)組合而成的術語。因此(簡單來說)當你編譯 C# 時,編譯器將函數體轉換為中間語言(IL)。這不能稱為 transpiling,因為這兩種語言的抽象層次完全不同。當你編譯 TypeScript 時,編譯器將它轉換為 JavaScript。二者的抽象層次相同,所以你可以稱之為 transpiling。其他一些常見的可以稱為 transpiling 的組合包括 C++ 到 C,CoffeeScript 到 JavaScript,Dart 到 JavaScript 以及 PHP 到 C++。

在上述情況下,由Rust 編譯的二進製文件並不能在獨立環境中使用,而是作為一個更大的系統組件鏈接(靜態或動態),這個系統可以用一種或幾種其他語言(如C 語言)來編寫。這意味著 Rust 二進製文件必須與 C 二進製文件實現應用二進制接口兼容。在 Linux 上,這樣的二進製文件是使用 gcc 或 clang 編譯的,並使用 GNU 連接器、gold 或 lld 進行鏈接。為了使用 gcc 實現真正的應用二進制奇偶校驗(ABI Parity),理想情況下,rusts 應該支持到 C 的每個 gcc 擴展。 Josh Triplett 在今年早些時候關於 Rust 和系統編程的演講中,他將與 C 的奇偶校驗稱為系統語言採用的一個重要因素,最後總結了這方面目前存在的問題,以及今後在這方面的改進。在本文中,我們給出了 Rust 與 C 還不完全兼容的實際例子,其中一些已經在 Josh 的演講中提及過。

提高 Rust 與 C 語言兼容性的機會

在使用 C2Rust 將 C 代碼轉換為 Rust 時,我們遇到了一些邊緣情況,在這些情況下,Rust 不能完全複製 C 特性(或者至少不能複制 C 語言的 gcc 變體)。如果我們想讓 Rust 成為 C 的應用二進制接口兼容的替代方案,這些都是我們亟需解決的一些問題。

long double 類型

在 C 語言中,long double 類型被指定為至少與 double 一樣長,但通常在 x86 上實現為 80 位浮點值,儘管實現是依賴於平台的。為了與 C 語言實現應用二進制接口完全兼容,Rust 需要支持與所支持的平台(即 f80f128)上使用的實現相匹配的長浮點類型。啟動一個 Pre-RFC 線程 來討論其他可選的浮點類型。有人建議,在 std::arch 下添加這些類型,這似乎是一條不錯的道路。

在我們嘗試轉換 newlib C 庫時,在通用環境下遇到了 long double 類型,這個庫可以選擇構建為支持 long double 類型,包括在它的 printf 實現中。對於 long double 類型,最佳替代方案是 f128包裝箱(Crate),它在內部將其實現為字節數組,並在 C 中實現所有操作。但是,由於大多數 x86 C 編譯器中的 long double 是內部存儲在 128 位中的 80 位浮點數,因此它與 f128包裝箱實現的 __float128 類型並不兼容。這不僅會給變量中的 long double 使用帶來了問題(這也是我們最初遇到的問題的原因),而且在 C 和 Rust 之間傳遞 long double 值也會出現問題。

譯註: Rust 有兩個不同的術語與模塊系統有關:包裝箱(crate)和模塊(module)。包裝箱是其它語言中的庫(Library)或包(Package)的同義詞。

GCC 擴展

C 的許多 gcc 擴展,並沒有 Rust 的等價品。例如,我們遇到了以下擴展的問題:

  • 符號別名(鏈接到公開問題),如 __attribute__((alias("foo"))),由 libxml2 使用。此屬性將同一函數或全局變量導出為多個符號(甚至可能具有不同的可見性)。 Rust 提供了 #no_mangle,這讓我們可以重命名全局變量,但並沒有等價屬性以第二個名稱來導出它。
  • 打包結構也有對齊要求,例如,內核中的 xregs_state。我們在 GitHub 上就此提出了一個問題,目前,該討論仍在進行中。
  • 對齊的全局變量,例如,ioq3 的 ssemask。我們可以通過對齊的結構替換它們來處理這些情況,但這與原始 C 代碼並不完全等同。例如,對於這段 C 代碼:
    struct Foo {
    char x(5);
    };
    struct Foo16 {
    char x(5);
    } __attribute__((aligned(16)));
    struct Foo foo __attribute__((aligned(16))) = { .x = "foo" };
    struct Foo16 foo16;

變量 foofoo16 的對齊都是 16,但它們的大小分別是 5 和 16。

靜態庫大小

大多數情況下,在構建可以直接替換的 Rust 代碼時,我們都希望構建一個 C 共享庫(Cdylib)。然而,當我們想要構建一個靜態庫時,生成的庫非常龐大。例如,構建下面最小的 no_std Rust 模塊,它不依賴於任何 Rust 標準庫,結果,生成一個 1.6M 的靜態庫!

#!(no_std)
extern "C" {
    fn printk(fmt: *const u8, ...);
}
#(no_mangle)
pub unsafe extern "C" fn rb_xcheck(tag: u8, val: u64) {
    printk(b"XCHECK(%u):%lu/%#lxnx00" as *const u8, tag as u32, val, val);
}

結果生成以下包裝箱的大小:

包裝箱類型 大小
staticlib 1.6M
rlib 5K
cdylib 15K

因為我們需要將這段代碼嵌入到內核模塊中,因此,我們不能使用 cdylib(內核模塊是 .ko,它是目標文件,而內核不支持加載共享庫)。rlib 的構建是假設它將鏈接到另一個 Rust 目標,所以我們決定避免使用它。staticlib 的輸出正是我們真正想要的結果,但它的大小要大得多。

順便說一句,cargo 在指定 crate-type = "staticlib" 時會生成 ELF 存檔,但某些構建系統,比如內核,只接受目標文件。我們可以通過使用 ld-r 鏈接一個可重定位的目標文件來解決這個問題(我們將會發表另一篇關於內核模塊的文章)。

未來展望

總結我們在低級 Rust 中的發現和經驗,以下是一些想法,探討了關於 2020 年 Rust 可能的發展方向:

  • 與 GCC 的兼容性:前文已闡述一些當前的邊緣情況。潛在的改進包括對更多類型的支持,比如 long double (可能通過某種方式將所有 LLVM 類型置於 Rust 之下)。另外,Rust 可以支持許多 GCC 屬性。
  • 鏈接:對於 Rust 鏈接的一些小改進,我們有一些想法:
    • 如果 Cargo 生成的是目標文件而不是庫,例如,通過添加一個新的 crate-type = "object"包裝箱類型,那麼在某些構建系統中嵌入 Rust 代碼會更容易。
    • 如上所述,即使對於很小的輸入,staticlib 的輸出存檔文件也可能會變得很大,在最終文件不是鏈接的二進製文件的情況下,那麼減小它的大小也會有所不同。
  • 內聯彙編(Inline assembly):目前,對內聯彙編方面來講,Rust 非常接近於 LLVM,這是一種不同於 gcc 的格式,因此,我們必須解決這種不匹配的問題。我們期待將來有一天,Rust 能夠為內聯彙編提供穩定的支持。在這一過程中需要注意的是,現有的內聯彙編可以被重寫為語義上等價的彙編,無論使用什麼語法。

譯註: 內聯彙編(Inline assembly)是由部分編譯器支持的一種功能。其將非常低級的彙編語言內嵌在高端語言源始碼中。

結語

現在,Rust 幾乎涵蓋了人們在 C 語言中想要做的一切。儘管我們在本文討論的問題微不足道,但它們阻礙了全功能奇偶校驗和真實 C 軟件的替換。我們已經能夠將大多數C 代碼轉換為與語言交互接口兼容的、等價的Rust:lua、NGINX 和zstd 在無需任何更改的情況下進行了transpile,而ioq3 只需在Rust 輸出中進行一個小的更改即可運行(上面所示的 ssemask 問題)。我們希望,隨著 Rust 的成熟,我們可以解決這些阻礙 C 和 Rust 之間的完全兼容的邊緣情況。

作者介紹:

Andrei Homescu 是 Immunant 的聯合創始人和首席科學家。他是基於編譯器和鏈接器的安全技術方面的專家,也是資深 C++ 和 Rust 程序員。 Stephen Crane 是 Immunant 的聯合創始人和首席技術官。他熱衷破解基於編譯器的系統的安全保護。

原文鏈接:

https://immunant.com/blog/2019/11/rust2020/