Rust能力養成系列之(32): 管理陷阱與記憶體安全
前言
上篇末尾提及要做一點小小的吐槽,不負前言,我們花一點篇幅來談一下記憶體管理中的各個坑,而後進入記憶體安全的內容。
記憶體管理陷阱
在使用垃圾收集器(GC)的語言中,處理記憶體的動作會從程式設計師那裡抽離出來,開發者可以在程式碼中宣告和使用這些變數,而至於如何釋放這些變數的實現細節,則是不必擔心的。另一方面,像C/ C++這樣的低階系統程式語言,則不會向程式設計師隱藏這些細節,而且幾乎不提供任何安全性保障。在這裡,程式設計師被賦予了手動釋放程式呼叫,並重新分配記憶體的偉大責任。現在,如果我們看看與記憶體管理相關的軟體中的大多數常見漏洞的報告(Common Vulnerabilities & Exposure(CVEs)),可以明顯感到:人類在這方面真的不是很擅長!比如,程式設計師可能寫反了分配和釋放記憶體空間的函式順序,甚至可能忘記釋放已使用過的記憶體,或者無意間造成非法強制轉換指標,這些都很容易釀就難以除錯的bug。在C語言中,沒有什麼機制可以阻止程式設計師建立一個整數指標,而後在某個地方對其解除引用(dereferencing),結果不一會,程式崩潰,哭吧。應該是眾所周知了吧,在C語言中寫出bug是非常容易的,因為編譯器對此檢查非常之少。
最值得關注的情況,則是釋放堆分配的資料。請千萬記住,要小心使用堆記憶體。如果忘記釋放堆中的值,那麼這樣的值可能會在程式的生命週期內永遠存在,並且可能最終導致程式被核心中的記憶體空間管理(Out Of Memory (OOM) )關掉(kill)。在程式執行時,程式碼中的固有錯誤或使用者造成的錯誤都可能導致程式忘記釋放記憶體,或訪問超出其記憶體佈局邊界的部分,或對受保護的程式碼段中的記憶體地址解除引用。當這種情況發生時,程序會從核心接收到一條trap指令,這是一個segmentation fault的錯誤訊息,隨後是程序被中止。因此,我們必須確保程序及其與記憶體的互動是安全的!作為程式設計師,要麼需要嚴格瞭解malloc和free呼叫,要麼則要使用記憶體安全的語言,來為使用者處理這些細節。
記憶體安全
那麼,所說的記憶體安全的程式有是什麼意思呢?記憶體安全是指程式永遠不會觸及不應該觸及的記憶體位置,程式中宣告的變數不能指向無效記憶體,並且在所有程式碼路徑中都是有效的。換句話說,安全基本上歸結為在程式中始終具有有效引用的指標,並且使用指標的操作不會導致未定義行為。未定義行為是指程式進入編譯器沒有明確解釋的情況時所處的狀態。
我們看一個C語言中未定義行為的例子,該行為是在訪問未初始化的陣列元素:
#include
<
stdio
.
h
>
int
main
()
{
int arr
[
5
];
for
(
int i
=
0
;
i
<
5
;
i
++
)
printf
(
"%d "
,
arr
[
i
]);
}
在該程式碼中,我們有一個包含5個元素的陣列,然後迴圈並列印陣列中的值。使用gcc -o main uninitialized_reads.c && ./main執行這個程式會得到如下輸出:
顯然,可以列印任何值,甚至可能列印一條指令的地址,都有可能。由於這是一個未定義的行為,任何事情都可能發生。發生這種情況後,程式可能會立即崩潰,這應該是最好的情況;當然,還可能繼續工作,而這會顯然會破壞程式現有的正常狀態,報錯是早晚的事了。
另一個違反記憶體安全的例子是c++中的迭代器失效問題:
// iterator_invalidation.cpp
#include
<
iostream
>
#include
<
vector
>
int
main
()
{
std
::
vector
<
int
>
v
{
1
,
5
,
10
,
15
,
20
};
for
(
auto it
=
v
.
begin
();
it
!=
v
.
end
();
it
++
)
if
((
*
it
)
==
5
)
v
.
push_back
(
-
1
);
for
(
auto it
=
v
.
begin
();
it
!=
v
.
end
();
it
++
)
std
::
cout
<<
(
*
it
)
<<
" "
;
return
0
;
}
在這段C++程式碼中,我們建立了一個由整數v組成的向量,並在for迴圈中嘗試使用稱為it的迭代器進行迭代。該程式碼的問題是,這裡有一個指向v的it迭代器指標,同時我們迭代並推入v。現在,由於vector的實現方式,當它們的大小達到其容量時,內部會重新分配到記憶體中的其他位置。而當這種情況發生時,這會使it指標指向某個垃圾值,這被稱為迭代器無效問題( iterator invalidation problem),因為指標現在指向無效記憶體。
最後一個記憶體不安全的例子有關C語言中的緩衝區溢位(buffer overflows)。下面是一段簡單的程式碼,可以表現這個問題:
// buffer_overflow.c
int
main
()
{
char buf
[
3
];
buf
[
0
]
=
'a'
;
buf
[
1
]
=
'b'
;
buf
[
2
]
=
'c'
;
buf
[
3
]
=
'd'
;
}
這段程式碼可以通過編譯,甚至執行時不會出現錯誤,但是最後一次賦值是在已分配的緩衝區上進行的,可能會覆蓋地址中的其他資料或指令。另外可以看到,這就是特別製作的惡意輸入值,適應於體系結構和環境,可以產生任意的程式碼執行。這類錯誤或者病毒以不太明顯的方式在實際程式碼中發生作用,並會導致影響全域性的漏洞。在最近版本的gcc編譯器上,這種被檢測為棧破壞攻擊(stack smash attack ),認定後,gcc會發送一個SIGABRT (abort)訊號來停止程式。
記憶體安全漏洞會導致記憶體洩漏,比如以段錯誤的形式所導致的硬崩潰;在最壞的情況下,還會導致安全重大缺陷。要在C中建立正確和安全的程式,程式設計師務必要十分謹慎的在使用完記憶體後,進行釋放。現在,C++可以通過提供智慧指標型別來防止一些與手動記憶體管理相關的問題,但這並不能做到完全消除。基於虛擬機器的語言(如Java的JVM)使用垃圾收集器來消除全部記憶體安全問題。雖然Rust沒有內建垃圾收集器,但它依賴於語言中內建的具有相同功能的RAII,並根據變數的作用域為開發者自動釋放已使用的記憶體,這比C或C++就要安全得多。這種機制提供了幾個細粒度的抽象,開發者可以根據需要進行選擇。
結語
為了瞭解所有這些記憶體安全機制在Rust中是如何工作的,讓我們在下一篇中, 先來探索一下,那些可以為程式設計師提供編譯時記憶體管理的原則都是什麼。
主要參考和建議讀者進一步閱讀的文獻
https://doc.rust-lang.org/book
Rust程式設計之道,2019, 張漢東
The Complete Rust Programming Reference Guide,2019, Rahul Sharma,Vesa Kaihlavirta,Claus Matzinger
Hands-On Data Structures and Algorithms with Rust,2018,Claus Matzinger
Beginning Rust ,2018,Carlo Milanesi
Rust Cookbook,2017,Vigneshwer Dhinakaran