圖解 CPU 的實模式與保護模式

大家好,我是呼嚕嚕,由於 x86 保護模式是比較複雜晦澀的,所以特地單拉出來,實模式和保護模式一個重要的更新就是對內存的管理與保護,並且隨着軟件的發展,爲了極致地壓榨 CPU 的性能,硬件和軟件都做出了許多努力,爲了更好的管理內存,引入分段,分頁,段頁等等。本文會沿着內存的主線,穿插於實模式和保護模式之間,並結合歷史淵源,更好地講解這裏面的發展與變化。

實模式

代號 8086

當計算機啓動時,實模式運行的時間對我們人來說是無感的,但是並不是其不重要,本文筆者想講的故事,它的起點來源一個產品,一個劃時代芯片,8086,其是 Intel 公司推出的最早,也是最流行的面向個人電腦的 CPU 型號我們可以看到上圖有 10 個引腳,由於芯片是對稱的,所以 8086 芯片一共 (只) 有 20 個引腳。不像現在的 CPU 那樣成百上千的都有,腳這麼多可不僅僅是爲了爬得快

我們一起來看下 8086 的引腳圖:

這些引腳有哪些作用?主要有下面這幾種:

  1. 電源線 Vcc(40),地線 GND(1 和 20)

  2. 地址 / 數據引腳

  1. 控制引腳
NMI(17):非屏蔽中斷請求信號,不受IF影響,此信號一出現,當前指令,執行結束後立即進行中斷處理。
INTR(18):可屏蔽中斷請求信號,輸入高電平有效。
CLK(19):系統時鐘,輸入
RESET(21):復位信號,輸入,高電平有效。復位信號使處理器馬上結束現行操作,對處理器的內部寄存器進行初始化
READY(22):數據準備好信號線,輸入,高電平有效,由存儲器或I/O端口發來。CPU在每個總線週期的T3狀態對READY採樣,若爲低電平,則自動插入一個或幾個等待狀態Tw,直到變爲高電平才能進入T4狀態
TEST(23):等待測試信號,輸入,CPU執行 WAIT指令時,每隔5個時鐘週期對引腳進行一次測試,若爲高電平,CPU處於等待狀態;低電平時執行下一條指令。
RD(32):讀控制信號,輸出。RD=0,表示執行一個對存儲器或I/O端口的讀操作。
BHE/S7(34):高八位數據總線允許/狀態複用引腳輸出。
MN/MX(33):最小/最大工作方式控制信號,輸入。接高電平時爲最小工作方式。
...大家瞭解一下即可

這裏需要特別注意地址總線,我們知道 CPU 除了還能訪問內存,還能訪問硬件,這些都是通過總線來實現的。

總線是貫穿整個系統的是一組電子管道,是連接各個部件的信息傳輸線,是各個部件共享的傳輸介質,稱作總線,它攜帶信息字節並負責在各個計算機部件間傳遞。總線按系統總線傳輸信息內容的不同,可以分爲 3 種:數據總線、地址總線和控制總線

我們可以發現 8086 的尋址空間是 1M,這個是怎麼得來的呢?尋址空間主要受地址總線寬度影響,地址總線寬度 20,也就表示有 20 根地址線,又因爲內存的單位是字節 Byte, 所以2^20B=1024KB=1MB

對總線感興趣地,拓展可見:什麼是計算機中的高速公路 - 總線?

分段機制

由於 8086 那個時代 CPU、內存都很昂貴, CPU 和寄存器等寬度都是 16 位的,在段不重疊的情況下,能表示的最大地址0xFFFF, 最大可尋址 2^16=64KB,然而 8086 有 20 根地址線,可尋址的最大內存空間是 1MB。CPU 和寄存器的尋址能力遠遠不能滿足使用

所以 Inte 工程師們耗盡頭髮,發明了分段技術,將內存分爲一個個 "段",段最大可爲 64KB,段由三部分組成:

那麼 16 的位的寄存器究竟該如何能訪問 20 位的地址空間呢?

計算方式是:實際物理地址 = segment段基址 <<4 + offset段內偏移地址,左移 4 位就是乘以 16。這樣就實現用 16 位的寄存器,生成 20 位的地址。從而擴大 CPU 尋址能力,實現對 1MB 內存空間的尋址

爲了實現分段,同時 8086 引入專門爲分段而生段寄存器,如CS、DS、ES、SS

  1. CS: 代碼段寄存器,存放代碼段的段基址

  2. DS 是數據段寄存器,存放數據段的段基址

  3. ES 是擴展段寄存器,存放當前程序使用附加數據段的段基址,該段是串操作指令中目的串所在的段

  4. SS 是堆棧段寄存器,存放堆棧段的段基址

  5. 後面 80836 還新增 2 個寄存器:FS 標誌段寄存器、GS 全局段寄存器。

在採用分段機制之前,工程師要在程序中要訪問內存,需要把物理地址寫死在程序中,簡單而粗暴,但是如果其他程序也同時需要同一塊內存地址,只能排隊等待,這太讓人着急了,所以採用分段機制的另一個重要的好處是:程序可以重定位

重定向就是將程序中指令的地址改成另一個地址,但該地址處的內容還是原內存地址處的內容。即使分段後,程序還是直接操作同一塊實際物理內存,但在程序中的邏輯地址是不一樣的,這樣計算機多道程序得以勉強的 "併發" 運行。筆者認爲分段的初衷更多是程序重定向問題的解決

由於這樣程序中指令了只用到 16 位地址,縮短了指令長度,也變相地提高了程序執行速度。

保護模式

但隨着 8086 的普及,人們漸漸發現 "實模式"(那個時候還沒有實模式、保護模式的概念,只有一個工作模式) 有個最大問題,就是安全問題,實模式哪怕引入段後,還是直接操作系統的實際內存,程序之間的地址沒有隔離,自己寫個程序可以訪問別人的程序地址,甚至是操作系統的程序地址,所以一不小心就直接把操作系統給幹掛了,所以那個時候的程序員編寫程序都得小心翼翼的

保護模式概念首次出現於 80286,並將以前 "老辦法" 稱爲實模式,80286 雖然有了保護模式,地址總線是 24 根,尋址空間變成了 2^24 =16MB, 但其 CPU、通用寄存器還是 16 位, 即單獨的一個寄存器還是隻能訪問 64KB 的空間,要想訪問完整的 16MB 內存,只能頻繁地變換段基址,非常影響計算機的性能

因此 80286 太雞肋了,很快 Intel 推出了 80386DX,CPU、寄存器、地址總線都是 32 位的,尋址空間直接達 4GB,在當時 CPU 非常昂貴的時代背景下,可以說 "硬件直接拉滿",從這個時候開始,保護模式才大放異彩!

需要注意的是 80386 並不是立即升到 32 位的,先出的 80386SX 的 CPU、通用寄存器還是 16 位,地址總線是 24 根

此時 CPU、寄存器、地址總線都支持尋址 4GB,更換偏移地址,就能夠訪問內存的每一個字節,那麼其實已經不需要分段機制了。但是爲了向前兼容,兼容性是 CPU 能否長久保持生命力的一個重要保證,還是保留了分段機制,但保護模式下的段基地址都設爲了 0,意味着每個段的起始地址都是一樣的,其實在操作系統層不再分段

那時的程序員訪問內存時被迫用多個小段再加上不斷換段基址的方式訪問,非常容易寫着寫着就忘了前面的內存地址,對程序員的心智產生極大的負擔,不再分段也叫做平坦模式,嗯,對程序員來說以後訪問內存操作一路平坦

80386 和 8086 常用寄存器

保護模式與實模式相比有了許多變化,我們先來看下 80386 和 8086 寄存器的前後對比,由於 80386 的寄存器大部分變成 32 位,同時還必須兼容實模式,所以實模式只用寄存器的前 16 位

80386 寄存器主要爲 3 類:

  1. 通用寄存器。這八個 32 位通用寄存器主要用於包含算術和邏輯運算的操作數。這 8 個通用寄存器都是由 8086 的相應 16 位通用寄存器擴展成 32 位而得。名字分別是:EAX,EBX,ECX,EDX,ESI,EDI,EBP,ESP

  2. 段寄存器。段寄存器 CS、DS、SS、ES、FS、GS 就是用來標識這 6 個當前可尋址的內存段。80386 新增 FS 標誌段寄存器、GS 全局段寄存器,段寄存器因爲 16 位夠用了,所以並沒有擴展到 32 位。這些專用寄存器允許系統軟件設計者選擇平面或分段的內存組織模型

  3. 狀態和指令指針寄存器。這些專用寄存器用於記錄和改變 80386 處理器狀態的某些方面,指令指針寄存器 EIP 是一個 32 位寄存器,是從 8086 的 IP 擴充而來。標誌寄存器 EFLAGS 也是一個 32 位寄存器,其中只使用了 15 位,從 8086 的 FLAGS 寄存器擴展而來。

爲了幫助大家理解,筆者特點畫了張圖,其中粉紅色代表 80386 的擴展部分:

當然 80386 還有其他一些特殊的寄存器,比如 IDTR、GDTR、CR0、CR1、CR2 和 CR3 等,這個我們留待下文再講

GDT、GDTR

我們需要思考一個問題,保護模式是如何保護 程序訪問內存時安全的?

保護程序訪問內存時安全的,其實換個角度就是,讓程序只能訪問安全的內存,更進一步地說,我們可以對內存進行權限控制,規定哪些內存可以被哪一類地程序訪問。

所以保護模式下會在訪問內存時增加了許多 "描述信息", 比如段自身的訪問權限,段的最大長度限制(16 位)、段的線性基址(32 位)、段的特權級、段是否在內存、讀寫許可等等相關信息

那麼這些信息,首先需要一個數據結構來保存所有的相關描述信息,這就是 段描述符,段描述符 8 個字節長,也就是 64bit。需要注意每個段都需要一個段描述符

下面我們就是 80386 段描述符的結構圖:

段描述符核心就是: 段基地址,段界限,訪問權限 DPL。段描述符的具體參數,筆者這裏就不詳細貼出來了,太多太雜,感興趣地可以自行去看 Global Descriptor Table - OSDev Wiki

如果我們直接通過一個 64bit 段描述符來引用一個段的時候,就必須使用一個 64bit 長的段寄存器裝入這個段描述符,但是我們剛剛看到段寄存器仍然是 16bit,這是 Intel 爲了兼容實模式。所以我們就無法直接通過段寄存器來直接引用 64bit 的段描述符。

而且每個段都有自己的段描述符,這些信息非常龐大,不是一個或者幾個寄存器就能夠保存的下去的,需要在內存中開闢出一段空間,當操作系統啓動時,加載到內存中。在這個專門的內存空間中,所有的段描述符都依次排放在一起,這就構成一個 全局描述符表 GDT(Global Descriptor Table ),GDT 是全局的,所以對一個系統來說是唯一的

又因爲全局描述符表 GDT 是在內存中的,CPU 是無法直接找到的,需要告訴它,這就是需要一個全新的寄存器 GDTR,來專門告訴 CPU,GDT 在內存的位置

問題又來了,現在全局描述符表 GDT 有了,有了它我們就能去找內存所有的段,但是我們如何去查這張表呢?我們這裏借鑑一下實模式 (同時也是爲了兼容實模式),在保護模式下,段寄存器(比如 ds、ss、cs)中存放的不再是尋址段的基地址,而是一個一個 "GDT 表索引",稱爲段選擇符(或稱段選擇子

在保護模式下,通過段寄存器存放的段選擇符(或稱段選擇子),由段選擇符從全局描述符表 GDT 中找到 8 個字節長的段描述符,段描述符裏存儲着段基址,再加上偏移地址就可以得到實際內存物理地址。這裏我們只考慮了段模式,頁模式暫不展開,其實頁模式也是基於段模式的

我們段寄存器還是 16 位,那麼段選擇符也是 16 位的,其中的 13bit 用來作 "索引 index", 下面我們看下 80386 段選擇符的結構圖:

當地址訪問時,如果段選擇符請求特權級別 RPL 的權限低於段描述符特權級 DPL 時 (一共分爲四層:0、1、2、3,其中 0 爲最高特權級,3 爲最低特權級),就會拒絕訪問,於是就達到了 "保護" 的作用!

LDT、LDTR

LDT 局部描述符表, LDT 結構和 GDT 是差不多的,主要區別在於 GDT 是全局的,而 LDT 是局部的 (local),GDT 在整個操作系統中是唯一的,而 LDT 在系統中可以存在多個

每一個 LDT 自身作爲一個段存在,存放在 LDT 類型的段裏,這個 LDT 既然也是段,那麼它也會有一個描述符,就放在 GDT 裏面。寄存器 LDTR 內容是一個段選擇符,它是用來到 GDT 裏面尋找 LDT 的

LDT 只是一個可選的數據結構,我們可以完全不使用它,使用它或許可以帶來一些方便性,但同時也帶來複雜性,如果我們想讓自己的操作系統內核保持簡潔,以及可移植性,則最好不要使用它。這裏只做簡單地科普介紹

IDT、IDTR

IDT,Interrupt Descriptor Table,即中斷描述符表,和 GDT 類似,記錄着 0~255 的中斷號和調用函數之間的關係,與中段向量表有些相似,但要包含更多的信息。

中斷機制是操作系統中極爲重要的一個部分。操作系統在管理輸人輸出設備時, 在處理外部的各種事件時, 都需要通過中斷機制進行處理,操作系統在管理輸人輸出設備時, 在處理外部的各種事件時, 都需要通過中斷機制進行處理

實模式下,16 位的中斷機制依賴的是中斷向量表,中斷向量表初始化在0x0000處,位置是固定的。爲了讓操作系統的代碼中的邏輯地址和實際物理地址一致,操作系統啓動時會把 system 模塊搬到零地址處,這樣中斷向量表就會被覆蓋

而在保護模式下,中斷機制用的是中斷描述符表 (IDT),位置是不固定的,設計操作系統時可以靈活設置,只需最後把其地址賦值給 IDTR 寄存器。中斷描述符表寄存器 IDTR 是一個 48 位的寄存器,其低 16 位保存中斷描述符表的大小,高 32 位保存 IDT 的基址。

當中斷髮生時,CPU 獲取到中斷向量後,通過 IDTR 的值,去查找 IDT 中斷描述符表,得到相應的中斷描述符,再根據中斷描述符記錄的信息來作權限判斷,運行級別轉換,最終調用相應的中斷處理程序

段頁機制

在分段機制下的保護模式一切都歲月靜好,直到有一天,我們系統有大量程序在運行,比如微信,釘釘等待,把內存都佔了,只剩下 2 個空閒內存段 1 和空閒內存段 2。現在我們想在我們系統中運行百度網盤 (假設運行需佔用 2 個內存段),明明我們內存中有足夠的內存段,但就因爲不是連續的,會導致百度網盤運行失敗。

我們只能把釘釘先關了,然後百度網盤才能正常打開 或者把釘釘先移到磁盤中,然後就可以運行百度網盤了,這個叫內存交換,但是段的大小比較大,而且磁盤和內存相比要慢很多,所以這種方式效率不高。

通過上面的小例子,相信大家理解了分段機制一些不足的地方: 段的大小比較大,而且由於段的大小是不固定的,導致內存碎片化 (內存有斷斷續續的間隙,且每個間隙都不一樣大!);程序無法動態使用內存;程序只能存放在連續的內存中......

所以 Intel 引入了分頁機制,分頁的初衷是爲了解決內存不足,但由於 80286 的段交換時性能堪憂,決定引入分頁,同時爲了兼容 x86 的分段機制,就形成獨特的段頁機制

將內存劃分爲一個個比段更精細的 "頁",頁的大小固定爲 4K,方便更精細化管理。由於分段機制下,程序都是需要提前指定基地址,加載到指定內存中,現在爲了實現程序運行時,內存地址自動分配,並按需加載。那必須得先解除線性地址與物理地址對應的關係,這一切需要增加一個 "中間層" 來實現。

這個中間層主要是 3 個部分:CR3 控制寄存器,頁目錄表page directory,頁表page Table。當頁功能開啓時,段部件產生的地址就不再是物理地址了,而是線性地址,線性地址還要經頁部件轉換後,纔是物理地址。我們來看下段頁機制的工作流程:

CPU 內部有一個控制寄存器 CR3,存放着當前進程的頁目錄表的物理內存基地址,頁目錄表存放的是頁表的物理內存基地址,頁表存放的是的物理內存基地址

其中當操作系統開啓分頁後,分頁機制接收的線性地址其實是虛擬地址在操作系統看來它是連續的,但它實際上通過頁表映射到多個不連續的物理內存頁,這樣就極大的利用了物理內存,不會出現使用分段機制後產生的大量內存碎片那種情況。

因爲頁表需要映射整個內存地址,如果是單一的,那麼線性地址前 20 位都查一張表的話,2^20=1M,  每個頁表項是 4 字節,如果頁表項全滿的話, 便是 4M 大小,換句話說就是頁表本身也佔用了 4MB 的物理內存空間。如果我們結合系統資源分配和調度運行的基本單位 - 進程來說,爲了保證進程的正常執行,每個進程都得有自己的頁表,那麼如果進程一多,頁表會佔有很大的內存空間。

所以現代操作系統都是採取二級頁表的方式:頁目錄表和頁表,這也是我們上圖畫的結構。其實本質就是拆分,把一個大表 (頁表) 拆成多個小表,而且不一次性地將全部頁表項建好,可以在需要時能夠動態創建頁表。然後統一由一個頁目錄表來存儲這些頁表 , 其中頁目錄項和頁表項一樣,大小都是 4KB

我們將二級頁表內存轉換流程聯繫在一起就是:將線性地址,分爲高 10 位、中間 10 位、低 12 位三個部分,其中高 10 位作爲頁目錄表的索引 (頁目錄表中有2^10=1024個項,PDE),中間 10 位作爲頁表的索引 (每個頁表也有 1024 個項,PTE),低 12 位就是偏移地址, 大小2^12=4KB;因爲一個頁表項大小是 4 個字節,我們可以反推出一個頁表項中有 1024 個物理頁。

所以二級頁表能夠尋址4KB*1024*1024=4G,這也是 32 根地址線能夠尋址的最大地址了。

分頁其實並不是由操作系統決定的,而是由 CPU 決定的。因爲線性地址到物理地址的轉換算法如上圖,已經固定流程套路,而且是比較複雜的 (從頁目錄表到頁表再到物理頁),爲了加快轉換的效率,我們直接在硬件上讓它自動執行轉化。所以 CPU 中集成了專門用來幹這項工作的硬件模塊,這個模塊被稱爲頁部件

當程序中給出一個線性地址時,頁部件分析線性地址, 按照以上算法,自動在頁表中檢索到物理地址。我們需要注意的是 CR3 寄存器存放的是實際物理地址,這個是給 CPU 看的,不是給操作系統看的。操作系統訪問它就必須知道它的線性地址纔行,線性地址必須連續,至於線性地址的對應實際地址可以不連續!

頁目錄表和頁表的參數如下,和之前的 gdt 是類似的,大家感興趣地可以自行查閱 intel 開發手冊,我們這就不展開了

段機制實現虛擬地址到線性地址的轉換,分頁機制實現線性地址到物理地址的轉換,一切的改變都是爲了更好地管理與保護內存!

尾語

通過本文的閱讀與理解,帶着大家穿插瞭解那個年代 x86 的歷史淵源,大家會更容易明白實模式和保護模式的區別以及分段,段頁的所遇到的侷限和改進,許多奇奇怪怪地設定都是爲了向前兼容,因爲一個成熟的產品,良好的兼容性就是它生命力重要的體現。

實模式和保護模式是現代操作系統的前置知識,即使現代操作系統已經天翻地覆的改變,但依舊有他們的影子,理解它們,會讓大家對底層知識有更深刻地理解。本文還是有許多細節沒有講到,歡迎大家討論

參考資料:

英特爾 ® 64 位和 IA-32 架構開發人員手冊:卷 3A - 英特爾 ®

https://pdos.csail.mit.edu/6.828/2008/readings/i386/s02_03.htm

《操作系統真像還原》

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/QOEGazkMHJA8V8isTs35OQ