翻譯并修改自:http://duartes.org/gustavo/blog/
微信公衆号:技術原理君
可能你憑借直覺就知道應用程式的功能受到了Intel x86計算機的某種限制,有些特定的任務隻有作業系統的代碼才可以完成,但是你知道這到底是怎麼一回事嗎?在這篇文章裡,我們會接觸到x86的特權級(privilege level),看看作業系統和CPU是怎麼一起合謀來限制使用者模式的應用程式的。特權級總共有4個,編号從0(最高特權)到3(最低特權)。有3種主要的資源受到保護:記憶體,I/O端口以及執行特殊機器指令的能力。在任一時刻,x86 CPU都是在一個特定的特權級下運作的,進而決定了代碼可以做什麼,不可以做什麼。這些特權級經常被描述為保護環(protection ring),最内的環對應于最高特權。即使是最新的x86核心也隻用到其中的2個特權級:0和3。

x86的保護環
在諸多機器指令中,隻有大約15條指令被CPU限制隻能在ring 0執行(其餘那麼多指令的操作數都受到一定的限制)。這些指令如果被使用者模式的程式所使用,就會颠覆保護機制或引起混亂,是以它們被保留給核心使用。如果企圖在ring 0以外運作這些指令,就會導緻一個一般保護錯(general-protection exception),就像一個程式使用了非法的記憶體位址一樣。類似的,對記憶體和I/O端口的通路也受特權級的限制。但是,在我們分析保護機制之前,先讓我們看看CPU是怎麼記錄目前特權級的吧,這與前篇文章中提到的段選擇符(segment selector)有關。如下所示:
資料段和代碼段的段選擇符
資料段選擇符的整個内容可由程式直接加載到各個段寄存器當中,比如ss(堆棧段寄存器)和ds(資料段寄存器)。這些内容裡包含了請求特權級(Requested Privilege Level,簡稱RPL)字段,其含義過會兒再說。然而,代碼段寄存器(cs)就比較特别了。首先,它的内容不能由裝載指令(如MOV)直接設定,而隻能被那些會改變程式執行順序的指令(如CALL)間接的設定。而且,不像那個可以被代碼設定的RPL字段,cs擁有一個由CPU自己維護的目前特權級字段(Current Privilege Level,簡稱CPL),這點對我們來說非常重要。這個代碼段寄存器中的2位寬的CPL字段的值總是等于CPU的目前特權級。Intel的文檔并未明确指出此事實,而且有時線上文檔也對此含糊其辭,但這的确是個硬性規定。在任何時候,不管CPU内部正在發生什麼,隻要看一眼cs中的CPL,你就可以知道此刻的特權級了。
記住,CPU特權級并不會對作業系統的使用者造成什麼影響,不管你是根使用者,管理者,訪客還是一般使用者。所有的使用者代碼都在ring 3上執行,所有的核心代碼都在ring 0上執行,跟是以哪個OS使用者的身份執行無關。有時一些核心任務可以被放到使用者模式中執行,比如Windows Vista上的使用者模式驅動程式,但是它們隻是替核心執行任務的特殊程序而已,而且往往可以被直接删除而不會引起嚴重後果。
由于限制了對記憶體和I/O端口的通路,使用者模式代碼在不調用系統核心的情況下,幾乎不能與外部世界互動。它不能打開檔案,發送網絡資料包,向螢幕列印資訊或配置設定記憶體。使用者模式程序的執行被嚴格限制在一個由ring 0之 神所設定的沙盤之中。這就是為什麼從設計上就決定了:一個程序所洩漏的記憶體會在程序結束後被統統回收,之前打開的檔案也會被自動關閉。所有的控制着記憶體或 打開的檔案等的資料結構全都不能被使用者代碼直接使用;一旦程序結束了,這個沙盤就會被核心拆毀。這就是為什麼我們的伺服器隻要硬體和核心不出毛病,就可以 連續正常運作600天,甚至一直運作下去。這也解釋了為什麼Windows 95/98那麼容易當機:這并非因為微軟差勁,而是因為系統中的一些重要資料結構,出于相容的目的被設計成可以由使用者直接通路了。這在當時可能是一個很好的折中,當然代價也很大。
CPU會在兩個關鍵點上保護記憶體:當一個段選擇符被加載時,以及,當通過線形位址通路一個記憶體頁時。是以,保護也反映在記憶體位址轉換的過程之中,既包括分段又包括分頁。當一個資料段選擇符被加載時,就會發生下述的檢測過程:
x86的分段保護
因為越高的數值代表越低的特權,上圖中的MAX()用于挑出CPL和RPL中特權最低的一個,并與描述符特權級(descriptor privilege level,簡稱DPL)比較。如果DPL的值大于等于它,那麼這個通路就獲得許可了。RPL背後的設計思想是:允許核心代碼加載特權較低的段。比如,你可以使用RPL=3的段描述符來確定給定的操作所使用的段可以在使用者模式中通路。但堆棧段寄存器是個例外,它要求CPL,RPL和DPL這3個值必須完全一緻,才可以被加載。
事實上,段保護功能幾乎沒什麼用,因為現代的核心使用扁平的位址空間。在那裡,使用者模式的段可以通路整個線形位址空間。真正有用的記憶體保護發生在分頁單元中,即從線形位址轉化為實體位址的時候。一個記憶體頁就是由一個頁表項(page table entry)所描述的位元組塊。頁表項包含兩個與保護有關的字段:一個超級使用者标志(supervisor flag),一個讀寫标志(read/write flag)。超級使用者标志是核心所使用的重要的x86記憶體保護機制。當它開啟時,記憶體頁就不能被ring 3通路了。盡管讀寫标志對于實施特權控制并不像前者那麼重要,但它依然十分有用。當一個程序被加載後,那些存儲了二進制鏡像(即代碼)的記憶體頁就被标記為隻讀了,進而可以捕獲一些指針錯誤,比如程式企圖通過此指針來寫這些記憶體頁。這個标志還被用于在調用fork建立Unix子程序時,實作寫時拷貝功能(copy on write)。
最後,我們需要一種方式來讓CPU切換它的特權級。如果ring 3的程式可以随意的将控制轉移到(即跳轉到)核心的任意位置,那麼一個錯誤的跳轉就會輕易的把作業系統毀掉了。但控制的轉移是必須的。這項工作是通過門描述符(gate descriptor)和sysenter指令來完成的。一個門描述符就是一個系統類型的段描述符,分為了4個子類型:調用門描述符(call-gate descriptor),中斷門描述符(interrupt-gate descriptor),陷阱門描述符(trap-gate descriptor)和任務門描述符(task-gate descriptor)。調用門提供了一個可以用于通常的CALL和JMP指令的核心入口點,但是由于調用門用得不多,我就忽略不提了。任務門也不怎麼熱門(在Linux上,它們隻在處理核心或硬體問題引起的雙重故障時才被用到)。
剩下兩個有趣的:中斷門和陷阱門,它們用來處理硬體中斷(如鍵盤,計時器,磁盤)和異常(如缺頁異常,0除數異常)。我将不再區分中斷和異常,在文中統一用"中斷"一詞表示。這些門描述符被存儲在中斷描述符表(Interrupt Descriptor Table,簡稱IDT)當中。每一個中斷都被賦予一個從0到255的編号,叫做中斷向量。處理器把中斷向量作為IDT表項的索引,用來指出當中斷發生時使用哪一個門描述符來進行中斷。中斷門和陷阱門幾乎是一樣的。下圖給出了它們的格式。以及當中斷發生時實施特權檢查的過程。我在其中填入了一些Linux核心的典型數值,以便讓事情更加清晰具體。
伴随特權檢查的中斷描述符
門中的DPL和段選擇符一起控制着通路,同時,段選擇符結合偏移量(Offset)指出了中斷處理代碼的入口點。核心一般在門描述符中填入核心代碼段的段選擇符。一個中斷永遠不會将控制從高特權環轉向低特權環。特權級必須要麼保持不變(當核心自己被中斷的時候),或被提升(當使用者模式的代碼被中斷的時候)。無論哪一種情況,作為結果的CPL必須等于目的代碼段的DPL。如果CPL發生了改變,一個堆棧切換操作就會發生。如果中斷是被程式中的指令所觸發的(比如INT n),還會增加一個額外的檢查:門的DPL必須具有與CPL相同或更低的特權。這就防止了使用者代碼随意觸發中斷。如果這些檢查失敗,正如你所猜測的,會産生一個一般保護錯(general-protection exception)。所有的Linux中斷處理器都以ring 0特權退出。
在初始化階段,Linux核心首先在setup_idt()中建立IDT,并忽略全部中斷。之後它使用include/asm-x86/desc.h的函數來填充普通的IDT表項(參見arch/x86/kernel/traps_32.c)。在Linux代碼中,名字中包含"system"字樣的門描述符是可以從使用者模式中通路的,而且其設定函數使用DPL 3。"system gate"是Intel的陷阱門,也可以從使用者模式通路。除此之外,術語名詞都與本文對得上号。然而,硬體中斷門并不是在這裡設定的,而是由适當的驅動程式來完成。
有三個門可以被使用者模式通路:中斷向量3和4分别用于調試和檢查數值運算溢出。剩下的是一個系統門,被設定為SYSCALL_VECTOR。對于x86體系結構,它等于0x80。它曾被作為一種機制,用于将程序的控制轉移到核心,進行一個系統調用(system call),然後再跳轉回來。在那個時代,我需要去申請"INT 0x80"這個沒用的牌照 J。從奔騰Pro開始,引入了sysenter指令,從此可以用這種更快捷的方式來啟動系統調用了。它依賴于CPU上的特殊目的寄存器,這些寄存器存儲着代碼段、入口點及核心系統調用處理器所需的其他零散資訊。在sysenter執行後,CPU不再進行特權檢查,而是直接進入CPL 0,并将新值加載到與代碼和堆棧有關的寄存器當中(cs,eip,ss和esp)。隻有ring 0的代碼enable_sep_cpu()可以加載sysenter 設定寄存器。
最後,當需要跳轉回ring 3時,核心發出一個iret或sysexit指令,分别用于從中斷和系統調用中傳回,進而離開ring 0并恢複CPL=3的使用者代碼的執行。噢!Vim提示我已經接近1,900字了,是以I/O端口的保護隻能下次再談了。這樣我們就結束了x86的運作環與保護之旅。感謝您的耐心閱讀。