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

小牛呼噜噜發表於2024-07-04

作者:小牛呼嚕嚕

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

真實模式

代號8086

當計算機啟動時,真實模式執行的時間對我們人來說是無感的,但是並不是其不重要,本文筆者想講的故事,它的起點來源一個產品,一個劃時代晶片,8086,其是Intel公司推出的最早,也是最流行的面向個人電腦的CPU型號

我們可以看到上圖有10個引腳,由於晶片是對稱的,所以8086晶片一共(只)有20個引腳。不像現在的CPU那樣成百上千的都有,腳這麼多可不僅僅是為了爬得快

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

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

  1. 電源線Vcc(40),地線GND(1和20)
  2. 地址/資料引腳
  • 地址/資料 分時複用引腳AD15-AD0(39,2-16):傳送地址時單向輸出,傳送資料時雙向輸入或輸出
  • 地址狀態 分時複用引腳A19/S6~ A16/S3(35-38):輸出、三態引腳。T1狀態做地址線,T2-T4狀態用於輸出狀態資訊
  • 所謂分時複用就是在同一根傳輸線上,在不同時間傳送不同的資訊,所以8086對應的地址線16+4=20根
  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,段由三部分組成:

  • 段基址(Base address):段的初始地址
  • 段界限(limit):表示段的長度,段界限決定了偏移量的最大值,也就是段內偏移最大能夠定址到的位置,
  • 段屬性(Attributes):表示段的屬性,比如是否可讀,可寫,許可權等

那麼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,和頁的固定大小正好相等。

所以二級頁表能夠定址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


作者:小牛呼嚕嚕 ,首發於公眾號「小牛呼嚕嚕」,高質量好文等你關注!

相關文章