1. 程式人生 > 其它 >用Rust解決C語言的隱患

用Rust解決C語言的隱患

題記:相對於其它語言,使用Rust開發更能避免低階錯誤。

簡介

對筆者而言,Rust越用越順手,接觸越多也就越不能抵抗它的魅力,也因此才有了本文的誕生——希望大家能瞭解到這種語言的妙處。

對大眾來說,Rust最大的賣點在於它能確保程式碼的安全性,這是Rust相對於C語言的一個極大優勢,也是令Rust與眾不同的關鍵所在,這也是本文的重點。

為了讓大家對Rust的優勢有所瞭解,我們選擇了這個地方入手——Rust是如何令開發者的日常工作更加輕鬆、更加愜意的。本文詳細列舉了樣例,闡明Rust是如何完全地消弭那些繼承自C語言的諸多隱患。這一優勢再加上Rust的新潮功能,就促成了Rust符合人體工程學的體驗——bug更少,程式碼更好 (維護者半夜也能睡個好覺)。

100%的安全性

在列舉例子之前,我們先來討論一下Rust所使用的方式究竟安全在哪裡。

Rust中的大多程式碼被稱為“安全”程式碼,確保程式碼100%的安全性。這個百分之百並非統計學意義上的,它沒有達到編譯器希望的那樣完美,但只要程式碼能夠編譯,記憶體安全性和data-race freedom就能夠保證。

當然,這些措施無法避免開發者引入的邏輯錯誤,也就是說在極少數情況下,這些規則是可以打破的。這種情況下,開發者所編寫的程式碼被稱為“不安全的”程式碼。這類程式碼限制很少,開發者可以任意編寫,但這樣做的代價是:編譯器不再確保安全性,結果可能會一塌糊塗。

隱患

空指標引用(NULL Dereference)

聲名狼藉的程式分段錯誤(Segmentation Fault)是C語言的常見問題,而通常NULL dereferences是第一大誘因。如果開發者忘記了檢查所返回的指標是否正確性,就可能會導致空指標引用。


uint8_t* pointer = (uint8_t*) malloc(SIZE); // Might return NULL
for(int i = 0 ; i < SIZE ; ++i) {
    pointer[i] = i; // Might cause a Segmentation Fault
}
  • 在Rust中

Rust處理這類指標錯誤的方式非常極端,在“安全”程式碼中粗暴簡單地禁用所有裸指標。此外在“安全”程式碼中,Rust還取消了空值。

不過不用擔心,Rust中存在一個優雅的替代方案——引用和借貸的方式。本質上來說,這些引用(references)還是那些老指標,但有了生命週期(Lifetimes)和借貸(Borrowing)規則,系統就能確保程式碼的安全性。

 
let my_var: u32 = 42;
let my_ref: &u32 = &my_var; // <-- This is a reference. References ALWAYS point to valid data!
let my_var2 = *my_ref; // <-- An example for a Dereference.

釋放記憶體後再使用(Use After Free)

這一弊端會產生嚴重的漏洞,導致黑客隨意操控你的程式碼。

下面有一個樣例:

 
uint8_t* pointer = (uint8_t*) malloc(SIZE);


...


if (err) {
  abort = 1;
  free(pointer);
}


...


if (abort) {
  logError("operation aborted before commit", pointer);
}
  • 在Rust中

像C++一樣,Rust也使用資源獲取即初始化(Resource Acquisition Is Initialization)的方式,這意味著每個變數在超出範圍後都一定會被釋放,因此在“安全的”Rust程式碼中,永遠不必擔心釋放記憶體的事情。

 
fn foobar() {
    let foo = Hashmap::new();
^  
|  
|   {
|   let bar = Vec::new();
|   ^
|   |
|   | 
|   |
|   |
|   V
|   } // `bar` will be freed once we get here
|  
V  
} // `foo` will be freed once we get here

但Rust不滿足於此,它更進一步,直接禁止使用者訪問被釋放的記憶體。這一點通過Ownership規則實現。

  • 在Rust中

變數有一個所有權(Ownership)屬性,owner有權隨意呼叫所屬的資料,也可以在有限的lifetime內借出資料(即Borrowing)。

此外,資料只能有一個owner,這樣一來,通過RAII規則,owner的範圍指定了何時釋放資料。最後,ownership還可以被“轉移”,當開發者將ownership分配給另一個不同的變數時,ownership就會轉移。

比如:

 
let foo = Hashmap::new();
{
    {
       let bar = foo; // foo's ownership has been moved!
    } // the Hashmap will be freed here
}

當向函式傳遞變數時,也會出現ownership轉移,比如:

 
let foo = Hashmap::new();
{
    {
       take_ownership(foo); // foo's ownership has been moved!
      // the Hashmap will be freed at the end of `take_ownership`
    }
}

而且被轉移的資料是無法使用的。

 
let foo = Vec::new();
{
    {
        take_ownership(foo);
    }
}
foo.push(42);


// main.rs:7:5: 10:8 error: use of moved value: `foo` [E0382]
// main.rs:7     foo.push(42);
//               ^~~

另外:

執行Copy特性的型別也會被複制,比如執行Copy特性的原始整數型別:

 
let foo = 42;
{
    {
        i_copy(foo);
    }
}
println!("{}", foo); // foo still owns the data

返回懸空指標(Dangling Pointers)

C語言老手都知道,向stack-bound變數返回指標很糟糕, 返回的指標會指向未定義記憶體。雖然這類錯誤多見於新手,一旦習慣堆疊規則和呼叫慣例,就很難出現這類錯誤了。

下面是一個C語言的例子:

 
uint8_t* get_dangling_pointer(void) {
    uint8_t array[4] = {0};
    return &array[0];
}


// Returns a dangling pointer to a previously stack allocated memory
  • 在Rust中

事實證明,Rust的lifetime check不僅適用於本地定義變數,也適用於返回值。

與C語言不同,在返回reference時,Rust的編譯器會確保相關內容可有效呼叫,也就是說,編譯器會核實返回的reference有效。即Rust的reference總是指向有效記憶體。

 
fn get_dangling_pointer() -> &u8 {
    let array = [0; 4];
    &array[0]
}


// main.rs:1:30: 1:33 error: missing lifetime specifier [E0106]
// main.rs:1 fn get_dangling_pointer() -> &u8 {
// 

限於篇幅本文所述有限,不過還是有個問題值得一提,那就是生命週期的管理通常是在後臺操作中進行,某些時候編譯器不會自動推算返回reference的生命週期,這種情況下只需明確指定就可以了。

 
fn get_static_string() -> &'static str {
    "I'm a static string!"
}


// This works because we are returning a string with a `static` lifetime.
// A static lifetime simply means that it'll live for the entire duration of the program

超出訪問許可權(Out Of Bounds Access)

另一個常見問題就是在訪問時,訪問了沒有許可權的記憶體,多半情況就是所訪問的陣列,其索引超出範圍。這種情況也出現在讀寫操作中,訪問超限記憶體會導致可執行檔案出現嚴重的漏洞,這些漏洞可能會給黑客操作你的程式碼大開方便之門。

近來這方面最著名的就是 Heartbleed bug,可以參見相關訊息

下面有個簡單樣例:

 
void print_out_of_bounds(void) {
    uint8_t array[4] = {0};
    printf("%urn", array[4]);
}


// prints memory that's outside `array` (on the stack)
  • 在Rust中

在這種情況下,Rust利用執行時檢查以減少這種不必要的行為,非常方便。

下面是一個樣例:

 
 fn print_panics() {
    let array = [0; 4];
    println!("{}", array[4]);
}


// thread '<main>' panicked at 'index out of bounds: the len is 4 but the index is 4', main.rs:3

結論

目前來說,Rust似乎前途無量,本文只對Rust用於保護程式碼安全性的規則做了簡單一瞥,經過精心提煉的規則可以讓開發者避開明顯的陷阱,輕鬆愜意地程式設計。