作者:小牛呼嚕嚕 大家好,我是呼嚕嚕,由於x86保護模式是比較複雜晦澀的,所以特地單拉出來,實模式和保護模式一個重要的更新就是對記憶體的管理與保護,並且隨著軟體的發展,為了極致地壓榨CPU的性能,硬體和軟體都做出了許多努力,為了更好的管理記憶體,引入分段,分頁,段頁等等。本文會沿著記憶體的主線,穿插於實模 ...
作者:小牛呼嚕嚕
大家好,我是呼嚕嚕,由於x86保護模式是比較複雜晦澀的,所以特地單拉出來,實模式和保護模式一個重要的更新就是對記憶體的管理與保護,並且隨著軟體的發展,為了極致地壓榨CPU的性能,硬體和軟體都做出了許多努力,為了更好的管理記憶體,引入分段,分頁,段頁等等。本文會沿著記憶體的主線,穿插於實模式和保護模式之間,並結合歷史淵源,更好地講解這裡面的發展與變化。
實模式
代號8086
當電腦啟動時,實模式運行的時間對我們人來說是無感的,但是並不是其不重要,本文筆者想講的故事,它的起點來源一個產品,一個劃時代晶元,8086,其是Intel公司推出的最早,也是最流行的面向個人電腦的CPU型號
我們可以看到上圖有10個引腳,由於晶元是對稱的,所以8086晶元一共(只)有20個引腳。不像現在的CPU那樣成百上千的都有,腳這麼多可不僅僅是為了爬得快
我們一起來看下8086的引腳圖:
這些引腳有哪些作用?主要有下麵這幾種:
- 電源線Vcc(40),地線GND(1和20)
- 地址/數據引腳
- 地址/數據 分時復用引腳AD15-AD0(39,2-16):傳送地址時單向輸出,傳送數據時雙向輸入或輸出
- 地址狀態 分時復用引腳A19/S6~ A16/S3(35-38):輸出、三態引腳。T1狀態做地址線,T2-T4狀態用於輸出狀態信息
- 所謂分時復用就是
在同一根傳輸線上,在不同時間傳送不同的信息
,所以8086對應的地址線16+4=20根
- 控制引腳
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
:
- CS:代碼段寄存器,存放代碼段的段基址
- DS是數據段寄存器,存放數據段的段基址
- ES是擴展段寄存器,存放當前程式使用附加數據段的段基址,該段是串操作指令中目的串所在的段
- SS是堆棧段寄存器,存放堆棧段的段基址
- 後面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類:
- 通用寄存器。這八個 32 位通用寄存器主要用於包含算術和邏輯運算的操作數。這8個通用寄存器都是由8086的相應16位通用寄存器擴展成32位而得。名字分別是:EAX,EBX,ECX,EDX,ESI,EDI,EBP,ESP
- 段寄存器。段寄存器CS、DS、SS、ES、FS、GS就是用來標識這6個當前可定址的記憶體段。80386新增FS標誌段寄存器、GS全局段寄存器,段寄存器因為16位夠用了,所以並沒有擴展到32位。這些專用寄存器允許系統軟體設計者選擇平面或分段的記憶體組織模型
- 狀態和指令指針寄存器。這些專用寄存器用於記錄和改變 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
作者:小牛呼嚕嚕 ,首發於公眾號「小牛呼嚕嚕」,高質量好文等你關註!