Linux從頭學09:x86 處理器如何進行-層層的記憶體保護?

IOT物聯網小鎮發表於2021-08-30

作 者:道哥,10+年的嵌入式開發老兵。

公眾號:【IOT物聯網小鎮】,專注於:C/C++、Linux作業系統、應用程式設計、物聯網、微控制器和嵌入式開發等領域。 公眾號回覆【書籍】,獲取 Linux、嵌入式領域經典書籍。

轉 載:歡迎轉載文章,轉載需註明出處。

在上一篇文章中,我們已經順利的從真實模式,過渡到了保護模式

保護模式與真實模式最本質的區別就是:保護模式使用了全域性描述符表,用來儲存每一個程式(bootloader,作業系統,應用程式)使用到的每個段資訊:開始地址,長度,以及其他一些保護引數。

這篇文章,我們來看一下 bootloader 是如何來進行自我進化到保護模式的,然後深入看一下保護模式是如何對記憶體進行安全保護的。

作為背景知識,我們先來看一下 x86 中的地址變換過程:

x86 處理器中的分頁機制是可以被關閉的,此時線性地址就等於實體地址,這也是我們一直討論的情況。

下一篇文章,我們就把 x86 中的分頁機制開啟,並與 Linux 中的分段和分頁機制進行對比。

真實模式:bootloader 為程式計算段的基地址

在之前的文章:Linux從頭學06:16張結構圖,徹底理解【程式碼重定位】的底層原理中,我們討論了 bootloader 是如何把應用程式讀取到記憶體中,最後跳入到程式的入口地址的。

這裡所說的程式,可以是作業系統,也可以是應用程式。

下面這張圖,是程式被載入到記憶體中之後,header 中的資訊:

因為程式是被 bootloader 動態讀取到記憶體中的,它是不知道自己被放在記憶體中的什麼位置,因此它也不知道自己程式碼段、資料段、棧的開始地址。

但是,程式要想能夠正常執行,就必須要知道這些資訊,那怎麼辦?

只有 bootloader 才能解決問題,因為是它來把程式從硬碟載入到記憶體中的。

因此,bootloader 在跳入程式的入口地址之前,必須把其中的程式碼段、資料段、棧段的基地址計算出來,然後寫入到程式的 header 中,如下圖所示:

這樣的話,程式開始執行時,就可以從自己的 header 中獲取到這 3 個段基地址,並且賦值給相應的暫存器,從而順利的執行程式。

也就是說:程式的 header 空間,充當了 bootloader 與它進行資訊互動的媒介,用來傳遞 3 個段暫存器的基地址。

以上的這個過程,一直工作在真實模式,因此就沒有段描述符什麼事情。

在以後文章中,我們還會看到在保護模式下,bootloader 仍然會利用 OSheader 空間,來傳遞段的索引號。然後 OS 利用這個段索引號,去查詢 GDT 表,從而找到每一個段的基地址以及其他一些保護資訊。

保護模式:bootloader 為自己建立段描述符

bootloaderBIOS 接管系統之後,剛開始是執行在真實模式下的。

當它完成一些準備工作之後,就可以進入保護模式了,也就是把 CR0 暫存器的 bit0 設定為 1

這個準備工作中,最重要的就是:建立 GDT 這個表,並且把 GDT 的開始地址,儲存到暫存器 GDTR 中

下面這張圖,是 bootloader 被載入到記憶體中的佈局圖:

bootloader 被載入到 0x0000_7C00 地址處。

它最少需要建立 3 個段描述符:程式碼段、資料段和棧段。

確定 GDT 的地址

在建立段描述符之前,需要先確定: 把 GDT 表放在記憶體中的什麼位置?

暫且就把它放在 0x0001_0000 這個地址吧,距離零地址 64K 的位置。

按照處理器的要求,在第 1 個表項(稱之為 item 或者 entry,每本書上都不一樣)必須為空描述符(index = 0)。

建立程式碼段描述符

bootloader 的程式碼放在 0x0000_7C00 開始的地址,長度是 512B

根據這些資訊,就可以構造出程式碼段的描述符了:

建立資料段描述符

bootloader 待會需要把作業系統或其他應用程式,從硬碟讀取到記憶體中,例如:讀取到 0x0002_0000 的位置。

那麼 bootloader 就必須能夠訪問到這個位置,並且是以資料段的讀寫方式。

為了利用全部的 4G 記憶體空間,bootloader 可以把這 4G 空間,作為一個資料段來定義它的描述符,如下:

建立棧段描述符

理論上,bootloader 可以使用記憶體中的任意一塊空閒空間,來作為自己的棧。

因為棧在 push 操作的時候,是向低地址方向增長的。

因此很多書籍都會把棧頂基地址設定為 bootloader 的開始地址,也就是 0x0000_7C00 地址處,並且把棧的空間大小限制在 4K 的範圍。

根據以上這些資訊,就可以建立出棧的段描述符,如下:

當以上這幾個段的描述符都建立好之後,就可以把 GDT 的地址(0x0001_0000),設定到 GDTR 暫存器中了。

最後,再把 CR0 暫存器的 bit0 設定為 1,就正式的進入保護模式來執行 bootloader 中後面的程式碼了。

段描述符是如何確保段的安全訪問的?

段暫存器快取記憶體

進入保護模式之後,雖然對段暫存器中內容的解釋改變了,但是執行每一條指令,還是需要使用到這些段暫存器的: cs, ds, ss等等。

想象一下:每執行一條指令,都會從邏輯地址中,獲取到段索引號,然後去查詢 GDT 表,從而定位到段的基地址

大家都知道程式有個“區域性性”原理,也就是連續執行的程式碼,都是集中在一段連續的程式空間中的。

這個連續的程式空間,它們都是在同一個程式碼段中,因此段的基地址都是相同的,那麼它們都屬於 GDT 中同一個程式碼段描述符所代表的段空間。

如果每一條指令都去查表,就會影響到程式的執行效率。

所以,處理器內部就為每一個段暫存器,安排了一個快取記憶體

拿程式碼段暫存器 cs 來說:當執行一條指令的時候,如果它與上一條指令中的段索引號不同,才會根據新的段索引號到 GDT 中查詢相應的段描述符表項。

查詢到之後,就把這個表項的內容複製到 cs 暫存器的快取記憶體中

當繼續執行後面的指令時,如果邏輯地址中的段索引號沒有變化,處理器就直接從快取記憶體中讀取段描述,從而避免了查表操作,提升了系統效率。

對段暫存器本身的保護

當邏輯地址中段暫存器的索引號改變時,就會根據新的索引號,到 GDT 中去查表。

當然了,這個索引號不能超過 GDT 的界限。

當定位到某一個描述符表項之後,就開始進行一系列檢查。

再來看一下每一個段描述符 8 個位元組的內容:

bit8 ~ bit11 定義了當前這個段的型別。

假如: 我們在切換程式碼段空間的時候,不小心犯錯,定位到了 GDT 中的一個資料段描述符表項,那麼處理器就能夠及時發現:

“當前這個段描述符的型別是資料段,你卻把它當做程式碼段來使用,禁止,殺無赦!”

因此,處理器就會拒絕把這個段描述符複製到程式碼段的快取記憶體中,從而對程式碼段暫存器進行了保護。

對段界限的檢查

在通過了第一層的段型別保護之後,還會繼續對段的界限進行檢查,這就要使用到邏輯地址中的偏移地址( EIP )了。

如果偏移地址超過了描述符中規定的界限,那麼就說明發生錯誤了。

例如:在 bootloader 的程式碼段描述符中,最大的界限是 512B,如果把 EIP 設定為 0x0000_1000,那就肯定錯誤了。

因為這個地址壓根就不屬於程式碼段的空間範圍。

對於資料段來說比較有意思,因為我們把資料段描述符的基地址設定為 0x0000_0000,段的界限是整個 4G 的空間,所以它可以對整個記憶體進行操作。

多想一步:

程式碼段也是屬於這 4G 空間,因此可以通過資料段,來改寫程式碼段空間中的指令內容。

也就是說:如果你想修改程式碼段的指令,直接通過程式碼段來操作是不可以的。

因為程式碼段描述符中規定了:程式碼段的內容只能被讀取、執行,但是不能被寫入

此時,就可以另闢蹊徑:程式碼段也放在 4G 的空間,那麼就可以通過資料段的可寫特性,來改寫程式碼段中的指令。

想一想 gdb 的除錯過程,是不是就利用了這個道理?

在文末的推薦閱讀中,就有一篇文章來介紹 gdb 的除錯原理,有興趣的小夥伴可以看一下。


------ End ------

至此,我們對保護模式下,段描述符的相關內容,就全部討論結束了,不知道對你是否有幫助。

在準備這篇文章的時候,我特意看了一下 《深入理解 Linux 核心》這部書的第二章:"記憶體定址"部分的內容。

書中直接把 x86 處理器中真實模式和保護模式的定址方式作為結論告訴我們了,但是並沒有具體的講解其中的原理。

如果把之前的這幾篇文章都理解了,再去看 Linux 核心的相關書籍,就不會那麼吃力了。

Linux 雖然很複雜,但是它也是建立在處理器所提供的基本功能上的。

就像頂尖的乒乓球運動員許昕,打出那麼多匪夷所思的神仙球,並不總是妙手偶得,而是建立在他們平時嚴格、機械、枯燥的日常訓練,所練就的紮實的基本功。

如果沒有這些堅實的基本功作為支撐,任何高階的花式技巧都只能是曇花一現。

學習也是一樣!

推薦閱讀

【1】C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
【2】一步步分析-如何用C實現物件導向程式設計
【3】原來gdb的底層除錯原理這麼簡單
【4】內聯彙編很可怕嗎?看完這篇文章,終結它!

其他系列專輯精選文章C語言Linux作業系統應用程式設計物聯網

星標公眾號,能更快找到我!

相關文章