Rust:一個不再有C/C++的,實現安全實時軟件的未來

Rust作爲新興編程語言深受Haskell和OCaml等函數式編程語言的影響,使得它在語法上與C++類似,但在語義上則完全不同。Rust是靜態類型語言,同時具有完整類型推斷,而不是C++的部分類型推斷,它在速度上可與C++媲美的同時,也保證了內存安全。

索引的故事

在詳細介紹Rust之前,我們先舉一個例子。想象你是一個爲新房子搭建煤氣管道的工人,你的老闆想要你去地下室把煤氣管連到街上的主煤氣管道里,然而你下樓時卻發現有個小問題,這個房子並沒有地下室。所以,現在你要做什麼呢?什麼都不做,還是異想天開地妄圖通過把煤氣主管道連到隔壁辦公室的空調進氣口來解決問題?不管怎麼說,當你向老闆彙報任務完成時,你或許會在煤氣爆炸的土灰中以刑事疏忽罪起訴。

這就是在某些編程語言中會發生的事。在C裏是數組,C++裏可能是向量,當程序試圖尋找第-1個元素時,什麼都有可能發生:或許是每次搜索的結果都不同,讓你意識不到這裏存在問題。這種被稱作是未定義的行爲,它發生的可能性並不能完全被杜絕,因爲底層的硬件操作從本質上來說並不安全,這些操作在其他的編程語言裏可能會被編譯器警告,但是C/C++並不會。

在無法保證內存安全的情況下,未定義行爲極有可能發生。漏洞HeartBleed,一個著名的SSL安全漏洞,就是因爲缺少內存安全防護;Stagefright,同樣出名的安卓漏洞,是因爲C++裏整數溢出造成的未定義行爲。

內存安全不止用來提防漏洞,它對應用程序的正確運行和可靠性同樣至關重要。可靠性的重要性在於它可以保證程序不會突然崩潰。至於準確性,作者有一個曾經在火箭飛行模擬軟件公司工作的朋友,他們發現傳遞相同的初始化數據,但是使用不同的文件名會導致不同的結果,這是因爲有些未初始化的內存被讀取,因此模擬器就不同文件名的原因而使用了垃圾數值做基礎,可以說他們的這個項目毫無用處。

爲什麼不用Python或Java這些可以保障內存安全的語言呢?

PythonJava使用自動垃圾回收來避免內存錯誤,例如:

  • 釋放重引用(Use-After-Free):申請已經被釋放的內存。
  • 多次釋放(double free):對同一片內存區域釋放兩次,導致未定義行爲。
  • 內存泄漏:內存沒有被回收,導致系統可用的內存減少。

自動垃圾收集會作爲JVM或者Python解釋器的一部分運行,在程序運行時不斷地尋找不再使用的模塊,釋放他們相對應的內存或者資源。但是這麼做的代價很大,垃圾回收不僅速度緩慢還會佔用大量內存,而你也永遠不會知道下一秒你的程序會不會暫停運行來回收垃圾。

Python和Java的內存安全犧牲了運行速度。C/C++的運行速度則是犧牲了內存的安全性。

這種讓人無法掌控的垃圾回收讓Python與Java無法應用在實時軟件中,因爲你必須要保證你的程序可以在一定時間內完成運行。這並不是比拼運行速度,而是保障你的軟件在每次運行的時候都可以足夠迅速。

當然,C/C++如此受歡迎還有其他方面的因素:他們已經存在了足夠長的時間來讓人們習慣他們了。但是他們同樣因爲運行速度與運行結果的保障而受到追捧。然而不幸的是,這樣的速度是在犧牲內存安全的前提下。更糟糕的是,許多實時軟件在保障速度的基礎上同樣需要注重安全性,例如車輛或者醫用機器人中的控制軟件。而這些軟件用的仍然是這些並不安全的語言。

在很長的一段時間裏,二者處於魚與熊掌不可兼得的狀態,要麼選擇運行速度和不可預知性,要麼選擇內存安全和可預知性。Rust則完全顛覆了這一點,這也是它爲什麼令人激動的原因。

Rust的設計目標

  • 無需擔心數據的併發運算:只要程序中的不同部分可能在不同的時間或者亂序運行,併發就有可能發生。衆所周知,數據併發在多線程程序中是一個常見的危險因素,這一點我們稍後再詳細描述。
  • 零開銷抽象:指編程語言提供的便利與表現力並不會帶來額外的負擔,也不會降低程序的運行速度。
  • 不需要垃圾回收的內存安全:內存安全和垃圾回收的定義我們已經瞭解了,接下來我們將詳細闡述Rsut是如何平衡速度與安全的關係的。

無需垃圾回收就能實現內存安全

Rust的內存安全保障說簡單也很簡單,說複雜也是複雜。簡單是因爲這裏只包含了幾個非常容易理解的規則。

在Rust中,每一個對象有且只有一個所有者(owner),確保任何資源只能有一個綁定。爲了避免被限制,在嚴格的規則下我們可以使用引用。引用在Rsut中經常被稱作“借用(borrowing)”。

借用規則如下:

  • 任何借用的作用域都能不大於其所有者的。
  • 不能在擁有可變引用的同時擁有不可變引用,但是多個可變引用是可以的。

第一個規則避免了釋放重引用的發生,第二個規則排除了數據互斥的可能性。數據互斥會讓內存處於未知狀態,而它可由這三個行爲造成:

  • 兩個或更多指針同時訪問同一數據。
  • 至少有一個指針被用來寫入數據。
  • 沒有同步數據訪問的機制。

當作者還是嵌入式工程師的時候,堆(heap)還沒有出現,於是便在硬件上設置了一個空指針解引用的陷阱,這樣一來,很多常見的內存問題就顯得不是那麼重要了。數據互斥是作者當時最怕的一種bug;它難以追蹤,當你修改了一部分看起來並不重要的代碼,或是外部條件發生了微小的改變時,互斥的勝利者也就易位了。Therac-25事件,就是因爲數據互斥使得癌症病人在治療過程中受到了過量的輻射,因此造成患者死亡或者重傷。

Rust革新的關鍵也是它聰明的地方,它可以在編譯時強制執行內存安全保障。這些規則對任何接觸過數據互斥的人來說都應當不是什麼新鮮事。

不安全的Rust

如作者之前所說,未定義行爲發生的可能性是不能完全被清除的,這是由於底層計算機硬件固有的不安全性導致的。Rust允許在一個存放不安全代碼的模塊進行不安全操作。C#和Ada應該也有類似禁用安全檢查的方案。在進行嵌入式編程操作或者在底層系統編程的時候,就會需要這樣的一個塊。隔離代碼的潛在不安全部分非常有用,這樣一來,與內存相關的錯誤就必定位於這個模塊內,而不是整個程序的任意部分。

不安全模塊並不會關閉借用檢查,用戶可以在不安全塊中進行解引用裸引針,訪問或修改可變靜態變量,所有權系統的優點仍然存在。

重溫所有權

說起所有權,就不得不提起C++的所有權機制。

C++中的所有權在C++11發佈之後得到了極大的提升,但是它也爲向後兼容性問題付出了不小的代價。對於作者來說,C++的所有權非常多餘,以前簡單的值分類被吊打。不管怎麼說,對C++這樣廣泛使用的語言進行大規模優化是一項偉大的成就,但是Rust卻是將所有權從一開始就當作核心理念進行設計的語言。

C++的類型系統不會對對象模型的生命週期進行建模,因此在運行時是無法檢查釋放後重引用的問題。C++的智能指針只是加在舊系統上的一個庫,而這個庫會以Rust中不被允許的方式濫用和誤用。

下面是作者在工作中編寫的一些經過簡化後的代碼,代碼中存在誤用的問題。

#include <functional>
#include <memory>
#include <vector>

std::vector<DataValueCheck> createChecksFromStrings(
        std::unique_ptr<Data> data,
        std::vector<std::string> dataCheckStrs) {

    auto createCheck = & {
        return DataValueCheck(checkStr, std::move(data));
    };

    std::vector<DataValueCheck> checks;
    std::transform(
            dataCheckStrs.begin(),
            dataCheckStrs.end(),
            std::back_inserter(checks),
            createCheck);

    return checks;
}

這段代碼的作用是,通過字符串dataCheckStrs定義對某些數據的檢查,例如一個特定範圍內的值,然後再通過解析這個字符串創建一個用於檢查對象的向量。

首先創建一個引用捕捉的lambda表達式,由&標識,這個智能指針(unique_ptr)指向的對象在這個lambda內被移動,因此是非法的。

然後用被移動的數據構建的檢查填充向量,但問題是它只能完成第一步。unique_ptr和被指向對象表示一種獨自佔有的關係,不能被拷貝。所以在std::transform的第一個循環之後,unique_ptr很有可能被清空,官方聲明是它會處於一種有效但是未知的狀態,但是以作者對Clang的經驗來看它通常會被清空。

後續使用這個空指針時會導致未定義行爲,作者運行之後得到了一個空指針錯誤,在大多數託管系統的空指針解引用都會報這種錯誤,因爲零內存頁面通常會被保留。但當然這種情況並不會百分百發生,這種bug在理論上可能會被暫時擱置一段時間,然後等着你的就是程序的突然崩潰。

這裏使用lambda的方式很大程度上導致了這種危險的發生。編譯器在調用時只能看到以一個函數指針,它並不能像標準函數那樣檢查lambda。

結合上下文來理解這個bug的話,最初使用shared_ptr來存儲數據,這一部分沒有問題。然而我們卻錯誤地將數據存儲在了unique_ptr裏,當我們試圖進行更改時就會有問題,它並沒有引起注意是因爲編譯器並沒有報錯。

這是C++內存安全問題並沒有引起重視的真實例子,作者和審覈代碼的人直到一次測試前都沒有注意到這點。不管你有多少年的編程經驗,這類bug根本躲不開!哪怕是編譯器都不能拯救你。這時就需要更好的工具了,不僅僅是爲了我們的理智着想,也是爲了公衆安全,這關乎職業道德。

接下來讓我們看一看同樣問題在Rust中的體現。

在Rust中,這種糟糕的move()是不會被允許的。

pub fn create_checks_from_strings(
        data: Box<Data>,
        data_check_strs: Vec<String>)
    -> Vec<DataValueCheck>
{
    let create_check = |check_str: &String| DataValueCheck::new(check_str, data);
    data_check_strs.iter().map(create_check).collect()
}

這是我們第一次看到Rust的代碼。需要注意的是,默認情況下變量都是不可變的,但可以在變量前加 mut 關鍵詞使其可變,mut 類似於C/C++中的 const 的反義詞。

Box類型則表示我們已經在堆上分配了內存,在這裏使用是因爲unique_ptr同樣可以分配到堆。因爲Rust中每個對象一次有且僅有一個所有者的規則,我們並不需要任何unique_ptr類似的東西。接着創建一個閉包,用更高階的函數 map 轉換字符串,類似C++的方式,但並不顯得冗長。但當編譯的時候還是會報錯,下面是錯誤信息:

error[E0525]: expected a closure that implements the `FnMut` trait, but this closure only implements `FnOnce`
   --> bad_move.rs:1:8
    |
  6 |     let create_check = |check_str: &String| DataValueCheck::new(check_str, data);
    |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^----^
    |                        |                                                     |
    |                        |                                 closure is `FnOnce` because it moves
    |                        |                                 the variable `data` out of its environment
    |                        this closure implements `FnOnce`, not `FnMut`
  7 |     data_check_strs.iter().map(create_check).collect()
    |                            --- the requirement to implement `FnMut` derives from here
	
error: aborting due to previous error
 
 For more information about this error, try `rustc --explain E0525`.

Rust社區有一點很棒,它提供給人們的學習資源非常多,也會提供可讀性的錯誤信息,用戶甚至可以向編譯器詢問關於錯誤的更詳細信息,而編譯器則會回覆一個帶有解釋的最小示例。

當創建閉包時,由於有且僅有一個所有者的規則,數據是在其內被移動的。接下來編譯器推斷閉包只能運行一次:沒有所有權的原因,多次的運行是非法的。之後 map 函數就會需求一個可以重複調用並且處於可變狀態的可調用函數,這就是爲什麼編譯器會失敗的原因。

這一段代碼顯示了Rust中類型系統與C++相比有多麼強大,同時也體現了在當編譯器跟蹤對象生命週期時的語言中編程是多麼不同。

在示例中的錯誤信息裏提到了特質(trait)。例如:”缺少實現 FnMut 特質的閉包“。特質是一種告訴Rust編譯器某個特定類型擁有功能的語言特性,特質也是Rust多態機制的體現。

多態性

C++支持多種形式的多態,作者認爲這有助於語言的豐富性。靜態多態中有模板、函數和以及操作符重載;動態多態有子類。但這些表達形式也有非常明顯的缺點:子類與父類之間的緊密耦合,導致子類過於依賴父類,缺乏獨立性;模板則因爲其缺乏參數化的特性而導致調試困難。

Rust中的 trait 則定義了一種指定靜態動態接口共享的行爲。Trait 類似於其他語言中接口(interface)的功能,但Rust中只支持實現(implements)而沒有繼承(extends)關係,鼓勵基於組合的設計而不是實現繼承,降低耦合度。

下面來看一個簡單又有趣的例子:

trait Rateable {
    /// Rate fluff out of 10
    /// Ratings above 10 for exceptionally soft bois
    fn fluff_rating(&self) -> f32;
}

struct Alpaca {
    days_since_shearing: f32,
    age: f32
}

impl Rateable for Alpaca {
    fn fluff_rating(&self) -> f32 {
        10.0 * 365.0 / self.days_since_shearing
    }
}

首先定義一個名爲 Rateable 的 trait,然後需要調用函數 fluff_rating 並返回一個浮點數來實現 Rateable。接着就是在Alpaca結構體上對 Rateable trait的實現。下面是使用同樣的方法定義 Cat 類型。

enum Coat {
    Hairless,
    Short,
    Medium,
    Long
}

struct Cat {
    coat: Coat,
    age: f32
}

impl Rateable for Cat {
    fn fluff_rating(&self) -> f32 {
        match self.coat {
            Coat::Hairless => 0.0,
            Coat::Short => 5.0,
            Coat::Medium => 7.5,
            Coat::Long => 10.0
        }
    }
}

在這段例子中作者使用了Rust的另一特性,模式匹配。它與C中的 switch 語句用法類似,但在語義上卻有很大的區別。switch塊中的case只能用來跳轉,模式匹配中則要求覆蓋全部可能性才能編譯成功,但可選的匹配範圍和結構則賦予了其靈活性。

下面是這兩種類型的實現結合得出的通用函數:

fn pet<T: Rateable>(boi: T) -> &str {
    match boi.fluff_rating() {
        0.0...3.5 => "naked alien boi...but precious nonetheless",
        3.5...6.5 => "increased floof...increased joy",
        6.5...8.5 => "approaching maximum fluff",
        _ => "sublime. the softest boi!"
}

尖括號中的是類型參數,這一點和C++中相同,但與C++模板的不同之處在於我們可以使函數參數化。“此函數只適用於Rateable類型”的說法在Rust中是可以的,但在C++中卻毫無意義,這帶來的後果不僅限於可讀性。類型參數上的trait bound意味着Rust的編譯器可以只對函數進行一次類型檢查,避免了單獨檢查每個具體的實現,從而縮短編譯時間並簡化了編譯錯誤信息。

Trait 也可以動態使用,雖然有的時候是必須的,但是並不推薦,因爲會增加運行開銷,所以作者在本文中並沒有詳細提及。Trait 中另一大部分就是它的互通性,例如標準庫中的Display和Add trait。實現add trait意味着可以重載運算符 +,實現display trait則意味着可以格式化輸出顯示。

Rust的工具

C/C++中並沒有用於管理依賴的標準,倒是有不少工具可以提供幫助,但是它們的口碑都不是很好。基礎的Makefiles用於構建系統非常靈活,但在維護上就是一團垃圾。CMake減少了維護的負擔,但是它的靈活性較弱,又很讓人煩惱。

Rust在這方面就很優秀,Cargo是唯一Rust社區中唯一的可以用來管理包和依賴,同時還可以用來搭建和運行項目。它的地位與Python中的Pipenv和Poetry類似。官方安裝包會自帶Cargo,它好用到讓人遺憾爲什麼C/C++中沒有類似的工具。

我們難道都要轉向Rust嗎?

這個問題沒有標準答案,完全取決於用戶的應用程序場景,這一點在任何編程語言中都是共通的。Rust在不同方面都有成功的案例:包括微軟的Azure IoT項目,Mozilla也支持Rust並將用於部分火狐瀏覽器中,同樣很多人也在使用Rust。Rust已經日漸成熟並可以用於生產,但對於某些應用程序來說,它可能還不夠成熟或缺乏支持庫。

嵌入式

在嵌入式的環境中,Rust的使用體驗完全由用戶定義用它做什麼。Cortex-M已經資源成熟並可以用於生產了,RISC-V也有了一個還在發展尚未常熟的工具鏈。.

x86和arm8架構也發展得不錯,其中就有Raspberry Pi。像是PIC和AVR這樣的老式架構還有些欠缺,但作者認爲,對於大多數的新項目來說應該沒什麼大問題。

交叉編譯支持也適用於所有的LLVM(Low-Level Virtual Machine)的目標,因爲Rust使用LLVM作爲其編譯器後端。

Rust在嵌入式中缺少的另一個部分是生產級的RTOS,在HAL的發展也很匱乏。對許多項目來說,這沒什麼大不了了,但對另一些項目的阻礙依舊存在。在未來幾年內,阻礙可能還會繼續增加。

異步

語言的異步支持還尚在開發階段,async/await的語法都還未被確定。

互通性

至於與其他語言的互操作性,Rust有一個C的外部函數接口(FFI),無論是C++到Rust函數的回調還是將Rust對象作爲回調,都需要經過這一步。在很多語言中這都是非常普遍的,在這裏提到則是因爲如果將Rust合併到現有的C++項目中會有些麻煩,因爲用戶需要在Rust和C++中添加一個C語言層,這毫無疑問會帶來很多問題。

寫在最後

如果要在工作中從頭開始一個項目,那麼作者絕對會選擇Rust編程語言。希望Rust可以成爲一個更可靠,更安全,也更令人享受的未來編程語言。

原文鏈接:
https://mcla.ug/blog/rust-a-future-for-real-time-and-safety-critical-software.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章