免費下載!
[!--downpath--]本文是Intel兼容計算機(x86)的顯存與保護系列文章的第一篇,延續了啟動引導系列文章的主題,進一步剖析操作系統內核的工作流程。與先前一樣,我將引用Linux內核的源代碼,但對只給出示例(抱歉,我忽視了BSD,Mac等系統,但大部份的討論對它們一樣適用)。文中若果有錯誤,請不吝討教。
在支持Intel的顯卡芯片組上,CPU對顯存的訪問是通過聯接著CPU和南橋芯片的后端總線來完成的。在后端總線上傳輸的顯存地址都是數學顯存地址,編號從0開始仍然到可用化學顯存的最低端。這種數字被南橋映射到實際的顯存條上。化學地址是明晰的、最終用在總線上的編號,何必轉換,何必分頁,也沒有特權級檢測。但是,在CPU內部,程序所使用的是邏輯顯存地址,它必須被轉換成化學地址后,才會用于實際顯存訪問。從概念上講,地址轉換的過程如右圖所示:
x86CPU開啟分頁功能后的顯存地址轉換過程
此圖并未強調詳盡的轉換方法,它僅僅描述了在CPU的分頁功能開啟的情況下顯存地址的轉換過程。假如CPU關掉了分頁功能,或運行于16位實模式,這么從分段單元(unit)輸出的就是最終的化學地址了。當CPU要執行一條引用了顯存地址的指令時,轉換過程就開始了。第一步是把邏輯地址轉換成線性地址。并且,為何不跳過這一步,而讓軟件直接使用線性地址(或化學地址呢?)其理由與:"人類為什么要長有膽囊?它的主要作用僅僅是被感燙發炎而已"大致相同。這是進化過程中形成的獨特構造。要真正理解x86分段功能的設計,我們就必須回溯到1978年。
最初的8086處理器的寄存器是16位的,其指令集大多使用8位或16位的操作數。這促使代碼可以控制216個字節(或64KB)的顯存。但是Intel的工程師們想要讓CPU可以使用更多的顯存,而又不用擴充寄存器和指令的顯存。于是她們引入了段寄存器(),拿來告訴CPU一條程序指令將操作哪一個64K的顯存區塊。一個合理的解決方案是:你先加載段寄存器,相當于說"這里!我準備操作開始于X處的顯存區塊";以后,再用16位的顯存地址來表示相對于哪個顯存區塊(或段)的偏斜量。總共有4個段寄存器:一個用于棧(ss),一個用于程序代碼(cs),兩個用于數據(ds,es)。在哪個年代,大部份程序的棧、代碼、數據都可以塞入對應的段中,每段64KB長,所以分段功能時常是透明的。
現在,分段功能仍然存在,仍然被x86處理器所使用著。每一條會訪問顯存的指令都隱式的使用了段寄存器。例如什么是物理地址,一條跳轉指令會用到代碼段寄存器(cs),一條壓棧指令(stackpush)會使用到堆棧段寄存器(ss)。在大部份情況下你可以使用指令明晰的改寫段寄存器的值。段寄存器儲存了一個16位的段選擇符();它們可以經由機器指令(例如MOV)被直接加載。惟一的例外是代碼段寄存器(cs),它只能被影響程序執行次序的指令所改變,例如CALL或JMP指令。其實分段功能仍然是開啟的,但其在實模式與保護模式下的運作方法并不相同的。
在實模式下,例如在引導啟動的早期,段選擇符是一個16位的數值,指示出一個段的開始處的化學顯存地址。這個數值必須被以某種形式放大,否則它也會受限于64K當中,分段就沒有意義了。例如,CPU可能會把這個段選擇符當成數學顯存地址的高16位(只需將之左移16位,也就是除以216)。這個簡單的規則促使:可以按64K的段為單位,一塊塊的將4GB的顯存都輪詢到。遺憾的是,Intel做了一個很奇特的設計,讓段選擇符僅僅除以24(或16),一舉將輪詢范圍限制在了1MB,還引入了過度復雜的轉換過程。下列圖例顯示了一條跳轉指令,cs的值是:
實模式分段功能
實模式的段地址以16個字節為步長,從0開始編號仍然到(即1MB)。你可以將一個從0到的16位偏斜量(邏輯地址)加在段地址上。在這個規則下,對于同一個顯存地址,會有多個段地址/偏斜量的組合與之對應,但是化學地址可以超過1MB的邊界,只要你的段地址足夠高(參見臭名昭著的A20線)。同樣的,在實模式的C語言代碼中,一個遠表針(far)既包含了段選擇符又包含了邏輯地址,用于輪詢1MB的顯存范圍。這麼"遠"的啊。隨著程序顯得越來越大,超出了64K的段,分段功能以及它奇特的處理方法,致使x86平臺的軟件開發顯得十分復雜。這些設定可能聽上去有些怪異,但它卻把當時的程序員加快了令人崩潰的深淵。
在32位保護模式下,段選擇符不再是一個單純的數值,取而代之的是一個索引編號,用于引用段描述符表中的表項。這個表為一個簡單的鏈表,元素寬度為8字節,每位元素描述一個段。看上去如下:
段描述符
有三種類型的段:代碼,數據,系統。為了簡約明了,只有描述符的共有特點被勾畫下來。基地址(base)是一個32位的線性地址,指向段的開始;段界限(limit)強調這個段有多大。將基地址加到邏輯地址上就產生了線性地址。DPL是描述符的特權級(level),其值從0(最高特權,內核模式)到3(最低特權,用戶模式),用于控制對段的訪問。
這種段描述符被保存在兩個表中:全局描述符表(GDT)和局部描述符表(LDT)。筆記本中的每一個CPU(或一個處理核心)都富含一個稱作gdtr的寄存器,用于保存GDT的首個字節所在的線性顯存地址。為了選出一個段,你必須向段寄存器加載符合以下格式的段選擇符:
段選擇符
對GDT,TI位為0;對LDT,TI位為1;index強調想要表中哪一個段描述符(今譯:原文是段選擇符,應當是疏漏)。對于RPL,懇請特權級(Level),之后我們都會詳盡討論。如今,須要好好想想了。當CPU運行于32位模式時,不管怎么,寄存器和指令都可以輪詢整個線性地址空間,所以根本就不須要再去使用基地址或其他哪些鬼東西。那為何不干脆將基地址設成0,好讓邏輯地址與線性地址一致呢?Intel的文檔將之稱為"扁平模型"(flatmodel),并且在現代的x86系統內核中就是如此做的(非常強調,它們使用的是基本扁平模型)。基本扁平模型(basicflatmodel)等價于在轉換地址時關掉了分段功能。這么一來多么美好啊。就讓我們來瞧瞧32位保護模式下執行一個跳轉指令的反例,其中的數值來自一個實際的Linux用戶模式應用程序:
保護模式的分段
段描述符的內容一旦被訪問,都會被cache(緩存),所以在此后的訪問中,就不再須要去實際讀取GDT了,否則會有損性能。每位段寄存器都有一個隱藏部份用于緩存段選擇符所對應的那種段描述符。假如你想了解更多細節,包括關于LDT的更多信息,請參閱《IntelGuide》3A卷的第三章。2A和2B卷述說了每一個x86指令,同時也指明了x86輪詢時所使用的各種類型的操作數:16位,16位加段描述符(可被用于實現遠表針),32位,等等。
在Linux上,只有3個段描述符在引導啟動過程被使用。她們使用宏來定義并儲存在鏈表中。其中兩個段是扁平的,可對整個32位空間輪詢:一個是代碼段,加載到cs中,一個是數據段,加載到其他段寄存器中。第三個段是系統段,稱為任務狀態段(TaskState)。在完成引導啟動之后,每一個CPU都擁有一份屬于自己的GDT。其中大部份內容是相同的,只有少數表項依賴于正在運行的進程。你可以從.h見到LinuxGDT的布局以及其實際的樣子。這兒有4個主要的GDT表項:2個是扁平的,用于內核模式的代碼和數據,另兩個用于用戶模式。在看這個LinuxGDT時,請留心這些用于確保數據與CPU緩存線對齊的填充字節——目的是克服馮·諾依曼困局。最后要談談,那種精典的Unix錯誤信息"fault"(分段錯誤)并不是由x86風格的段所造成的,而是因為分頁單元檢查到了非法的顯存地址。哎呦什么是物理地址,上次再討論這個話題吧。
Intel巧妙的繞開了她們原本設計的那種拼堆砌湊的分段方式,而是提供了一種富有彈性的方法來讓我們選擇是使用段還是使用扁平模型。因為很容易將邏輯地址與線性地址合二為一,于是這成為了標準,例如現今在64位模式中就強制使用扁平的線性地址空間了。并且即便是在扁平模型中,段對于x86的保護機制也極其重要。保護機制用于抵擋用戶模式進程對系統內核的非法顯存訪問,或各個進程之間的非法顯存訪問,否則系統將會步入一個狗咬狗的世界!在下一篇文章中,我們將窺探保護級別以及怎樣用段來實現這種保護功能。