用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用於保護程式碼安全性的規則做了簡單一瞥,經過精心提煉的規則可以讓開發者避開明顯的陷阱,輕鬆愜意地程式設計。