前言
本文涉及的硬件平台是X86,如果是其他平台的話,如ARM,是會使用到MMU,但是沒有使用到分段機制;
最近在學習Linux內核,讀到《深入理解Linux內核》的內存尋址一章。原本以為自己對分段分頁機制已經理解了,結果發現其實是一知半解。於是,查找了很多資料,最終理順了內存尋址的知識。現在把我的理解記錄下來,希望對內核學習者有一定幫助,也希望大家指出錯誤之處。
分段到底是怎么回事
相信學過操作系統課程的人都知道分段分頁,但是奇怪的是書上基本沒提分段分頁是怎么產生的,這就導致我們知其然不知其所以然。下面我們先扒一下分段機制產生的歷史。
實模式的誕生(16位處理器及尋址)
在8086處理器誕生之前,內存尋址方式就是直接訪問物理地址。8086處理器為了尋址1M的內存空間,把地址總線擴展到了20位。但是,一個尷尬的問題出現了,ALU的寬度只有16位,也就是說,ALU不能計算20位的地址。為了解決這個問題,分段機制被引入,登上了歷史舞台。
為了支持分段,8086處理器設置了四個段寄存器:CS, DS, SS, ES.每個段寄存器都是16位的,同時訪問內存的指令中的地址也是16位的。但是,在送入地址總線之前,CPU先把它與某個段寄存器內的值相加。這里要注意:段寄存器的值對應於20位地址總線的中的高16位,所以相加時實際上是內存總線中的高12位與段寄存器中的16位相加,而低4位保留不變,這樣就形成一個20位的實際地址,也就實現了從16位內存地址到20位實際地址的轉換,或者叫“映射”。
保護模式的誕生(32位處理器及尋址)
- 80286處理器的地址總線為24位,尋址空間達16M,同時引入了保護模式(內存段的訪問受到限制)
- 80386處理器是一個32位處理器,ALU和地址總線都是32位的,尋址空間達 4G。也就是說它可以不通過分段機制,直接訪問4G的內存空間。雖然它是新時代的小王子,超越它的無數前輩,然而,它需要背負家族的使命–兼容前代的處理器。也就是說,它必須支持實模式和保護模式。所以,80386在段寄存器的基礎上構築保護模式,並且保留16位的段寄存器。
- 從80386之后的處理器,架構基本相似,統稱為IA32(32 Bit Intel Architecture)。
IA32的內存尋址機制
尋址硬件
在 8086 的實模式下,把某一段寄存器左移4位,然后與地址ADDR相加后被直接送到內存總線上,這個相加后的地址就是內存單元的物理地址,而程序中的這個地址就叫邏輯地址(或叫虛地址)。在IA32的保護模式下,這個邏輯地址不是被直接送到內存總線而是被送到內存管理單元(MMU)。MMU由一個或一組芯片組成,其功能是把邏輯地址映射為物理地址,即進行地址轉換,如圖所示。
MMU
IA32的三種地址
- 邏輯地址:
機器語言指令仍用這種地址指定一個操作數的地址或一條指令的地址。 這種尋址方式在Intel的分段結構中表現得尤為具體,它使得MS-DOS或Windows程序員把程序分為若干段。每個邏輯地址都由一個段和偏移量組成。 - 線性地址:
線性地址是一個32位的無符號整數,可以表達高達232(4GB)的地址。通常用16進制表示線性地址,其取值范圍為0x00000000~0xffffffff。 - 物理地址:
也就是內存單元的實際地址,用於芯片級內存單元尋址。 物理地址也由32位無符號整數表示。
MMU地址轉化過程
MMU是一種硬件電路,它包含兩個部件,一個是分段部件,一個是分頁部件,在此,我們把它們分別叫做分段機制和分頁機制,以利於從邏輯的角度來理解硬件的實現機制。分段機制把一個邏輯地址轉換為線性地址;接着,分頁機制把一個線性地址轉換為物理地址。
MMU_translate
IA32的段寄存器
IA32中有六個16位段寄存器:CS, DS, SS, ES,FS, GS.跟8086的段寄存器不同的是,這些寄存器存放的不再是某個段的基地址,而是某個段的選擇符(Selector)。
分段機制的實現
段是虛擬地址空間的基本單位,分段機制必須把虛擬地址空間的一個地址轉換為線性地址空間的一個線性地址。
為了實現這種映射,僅僅用段寄存器來確定一個基地址是不夠的,至少還得描述段的長度,並且還需要段的一些其他信息,比如訪問權之類。所以,這里需要的是一個數據結構,這個結構包括三個方面的內容:
- 段的基地址(Base Address):在線性地址空間中段的起始地址。
- 段的界限(Limit):在虛擬地址空間中,段內可以使用的最大偏移量。
- 段的保護屬性(Attribute):表示段的特性。例如,該段是否可被讀出或寫入,或者該段是否作為一個程序來執行,以及段的特權級等等。
上面的數據結構我們稱為段描述符,多個段描述符組成的表稱為段描述符表
段描述符
所謂描述符(Descriptor),就是描述段的屬性的一個8字節存儲單元。在實模式下,段的屬性不外乎是代碼段、堆棧段、數據段、段的起始地址、段的長度等等,而在保護模式下則復雜一些。IA32將它們結合在一起用一個8字節的數表示,稱為描述符 。
IA32的一個通用的段描述符的結構
從圖可以看出,一個段描述符指出了段的32位基地址和20位段界限(即段長)。這里我們只關注基地址和段界限,其他的屬性略過。
段描述符表
各種各樣的用戶描述符和系統描述符,都放在對應的全局描述符表、局部描述符表和中斷描述符表中。描述符表(即段表)定義了IA32系統的所有段的情況。所有的描述符表本身都占據一個字節為8的倍數的存儲器空間,空間大小在8個字節(至少含一個描述符)到64K字節(至多含8K)個描述符之間。
-
全局描述符表(GDT)
全局描述符表GDT(Global Descriptor Table),除了任務門,中斷門和陷阱門描述符外,包含着系統中所有任務都共用的那些段的描述符。 它的第一個8字節位置沒有使用。 -
中斷描述符表IDT(Interrupt Descriptor Table)
中斷描述符表IDT(Interrupt Descriptor Table),包含256個門描述符。IDT中只能包含任務門、中斷門和陷阱門描述符,雖然IDT表最長也可以為64K字節,但只能存取2K字節以內的描述符,即256個描述符,這個數字是為了和8086保持兼容。 -
局部描述符表(LDT)
局部描述符表LDT(local Descriptor Table),包含了與一個給定任務有關的描述符,每個任務各自有一個的LDT。 有了LDT,就可以使給定任務的代碼、 數據與別的任務相隔離。每一個任務的局部描述符表LDT本身也用一個描述符來表示,稱為LDT描述符,它包含了有關局部描述符表的信息,被放在全局描述符表GDT中。
總結
IA32的內存尋址機制完成從邏輯地址–線性地址–物理地址的轉換。其中,邏輯地址的段寄存器中的值提供段描述符,然后從段描述符中得到段基址和段界限,然后加上邏輯地址的偏移量,就得到了線性地址,線性地址通過分頁機制得到物理地址。
首先,我們要明確,分段機制是IA32提供的尋址方式,這是硬件層面的。就是說,不管你是windows還是linux,只要使用IA32的CPU訪問內存,都要經過MMU的轉換流程才能得到物理地址,也就是說必須經過邏輯地址–線性地址–物理地址的轉換。
Linux中分段的實現
前面說了那么多關於分段機制的實現,其實,對於Linux來說,並沒有什么卵用。因為,Linux基本不使用分段的機制,或者說,Linux中的分段機制只是為了兼容IA32的硬件而設計的。
Intel微處理器的段機制是從8086開始提出的, 那時引入的段機制解決了從CPU內部16位地址到20位實地址的轉換。為了保持這種兼容性,386仍然使用段機制,但比以前復雜得多。因此,Linux內核的設計並沒有全部采用Intel所提供的段方案,僅僅有限度地使用了一下分段機制。這不僅簡化了Linux內核的設計,而且為把Linux移植到其他平台創造了條件,因為很多RISC處理器並不支持段機制。但是,對段機制相關知識的了解是進入Linux內核的必經之路。
從2.2版開始,Linux讓所有的進程(或叫任務)都使用相同的邏輯地址空間,因此就沒有必要使用局部描述符表LDT。但內核中也用到LDT,那只是在VM86模式中運行Wine,因為就是說在Linux上模擬運行Winodws軟件或DOS軟件的程序時才使用。
在 IA32 上任意給出的地址都是一個虛擬地址,即任意一個地址都是通過“選擇符:偏移量”的方式給出的,這是段機制存訪問模式的基本特點。所以在IA32上設計操作系統時無法回避使用段機制。一個虛擬地址最終會通過“段基地址+偏移量”的方式轉化為一個線性地址。 但是,由於絕大多數硬件平台都不支持段機制,只支持分頁機制,所以為了讓 Linux 具有更好的可移植性,我們需要去掉段機制而只使用分頁機制。但不幸的是,IA32規定段機制是不可禁止的,因此不可能繞過它直接給出線性地址空間的地址。萬般無奈之下,Linux的設計人員干脆讓段的基地址為0,而段的界限為4GB,這時任意給出一個偏移量,則等式為“0+偏移量=線性地址”,也就是說“偏移量=線性地址”。另外由於段機制規定“偏移量<4GB”,所以偏移量的范圍為0H~FFFFFFFFH,這恰好是線性地址空間范圍,也就是說虛擬地址直接映射到了線性地址,我們以后所提到的虛擬地址和線性地址指的也就是同一地址。看來,Linux在沒有回避段機制的情況下巧妙地把段機制給繞過去了。
另外,由於IA32段機制還規定,必須為代碼段和數據段創建不同的段,所以Linux必須為代碼段和數據段分別創建一個基地址為0,段界限為4GB的段描述符。不僅如此,由於Linux內核運行在特權級0,而用戶程序運行在特權級別3,根據IA32段保護機制規定,特權級3的程序是無法訪問特權級為0的段的,所以Linux必須為內核用戶程序分別創建其代碼段和數據段。這就意味着Linux必須創建4個段描述符——特權級0的代碼段和數據段,特權級3的代碼段和數據段。
總結
分段機制是IA32架構CPU的特色,並不是操作系統尋址方式的必然選擇。Linux為了跨平台,巧妙的繞開段機制,主要使用分頁機制來尋址。
參考資料
《深入分析Linux內核源碼》
在上一篇文章Linux內存尋址之分段機制中,我們了解邏輯地址通過分段機制轉換為線性地址的過程。下面,我們就來看看更加重要和復雜的分頁機制。
分頁機制在段機制之后進行,以完成線性—物理地址的轉換過程。段機制把邏輯地址轉換為線性地址,分頁機制進一步把該線性地址再轉換為物理地址。
硬件中的分頁
分頁機制由CR0中的PG位啟用。如PG=1,啟用分頁機制,並使用本節要描述的機制,把線性地址轉換為物理地址。如PG=0,禁用分頁機制,直接把段機制產生的線性地址當作物理地址使用。分頁機制管理的對象是固定大小的存儲塊,稱之為頁(page)。分頁機制把整個線性地址空間及整個物理地址空間都看成由頁組成,在線性地址空間中的任何一頁,可以映射為物理地址空間中的任何一頁(我們把物理空間中的一頁叫做一個頁面或頁框(page frame))
80386使用4K字節大小的頁。每一頁都有4K字節長,並在4K字節的邊界上對齊,即每一頁的起始地址都能被4K整除。因此,80386把4G字節的線性地址空間,划分為1G個頁面,每頁有4K字節大小。分頁機制通過把線性地址空間中的頁,重新定位到物理地址空間來進行管理,因為每個頁面的整個4K字節作為一個單位進行映射,並且每個頁面都對齊4K字節的邊界,因此,線性地址的低12位經過分頁機制直接地作為物理地址的低12位使用。
為什么使用兩級頁表
假設每個進程都占用了4G的線性地址空間,頁表共含1M個表項,每個表項占4個字節,那么每個進程的頁表要占據4M的內存空間。為了節省頁表占用的空間,我們使用兩級頁表。每個進程都會被分配一個頁目錄,但是只有被實際使用頁表才會被分配到內存里面。一級頁表需要一次分配所有頁表空間,兩級頁表則可以在需要的時候再分配頁表空間。
兩級頁表結構
兩級表結構的第一級稱為頁目錄,存儲在一個4K字節的頁面中。頁目錄表共有1K個表項,每個表項為4個字節,並指向第二級表。線性地址的最高10位(即位31~位32)用來產生第一級的索引,由索引得到的表項中,指定並選擇了1K個二級表中的一個表。
兩級表結構的第二級稱為頁表,也剛好存儲在一個4K字節的頁面中,包含1K個字節的表項,每個表項包含一個頁的物理基地址。第二級頁表由線性地址的中間10位(即位21~位12)進行索引,以獲得包含頁的物理地址的頁表項,這個物理地址的高20位與線性地址的低12位形成了最后的物理地址,也就是頁轉化過程輸出的物理地址。
頁目錄項
- 第31~12位是20位頁表地址,由於頁表地址的低12位總為0,所以用高20位指出32位頁表地址就可以了。因此,一個頁目錄最多包含1024個頁表地址。
- 第0位是存在位,如果P=1,表示頁表地址指向的該頁在內存中,如果P=0,表示不在內存中。
- 第1位是讀/寫位,第2位是用戶/管理員位,這兩位為頁目錄項提供硬件保護。當特權級為3的進程要想訪問頁面時,需要通過頁保護檢查,而特權級為0的進程就可以繞過頁保護。
- 第3位是PWT(Page Write-Through)位,表示是否采用寫透方式,寫透方式就是既寫內存(RAM)也寫高速緩存,該位為1表示采用寫透方式
- 第4位是PCD(Page Cache Disable)位,表示是否啟用高速緩存,該位為1表示啟用高速緩存。
- 第5位是訪問位,當對頁目錄項進行訪問時,A位=1。
- 第7位是Page Size標志,只適用於頁目錄項。如果置為1,頁目錄項指的是4MB的頁面,請看后面的擴展分頁。
- 第9~11位由操作系統專用,Linux也沒有做特殊之用。
頁面項
80386的每個頁目錄項指向一個頁表,頁表最多含有1024個頁面項,每項4個字節,包含頁面的起始地址和有關該頁面的信息。頁面的起始地址也是4K的整數倍,所以頁面的低12位也留作它用。
第31~12位是20位物理頁面地址,除第6位外第0~5位及9~11位的用途和頁目錄項一樣,第6位是頁面項獨有的,當對涉及的頁面進行寫操作時,D位被置1。
4GB的內存只有一個頁目錄,它最多有1024個頁目錄項,每個頁目錄項又含有1024個頁面項,因此,內存一共可以分成1024×1024=1M個頁面。由於每個頁面為4K個字節,所以,存儲器的大小正好最多為4GB。
線性地址到物理地址的轉換
- CR3包含着頁目錄的起始地址,用32位線性地址的最高10位A31~A22作為頁目錄的頁目錄項的索引,將它乘以4,與CR3中的頁目錄的起始地址相加,形成相應頁表的地址。
- 從指定的地址中取出32位頁目錄項,它的低12位為0,這32位是頁表的起始地址。用32位線性地址中的A21~A12位作為頁表中的頁面的索引,將它乘以4,與頁表的起始地址相加,形成32位頁面地址。
- 將A11~A0作為相對於頁面地址的偏移量,與32位頁面地址相加,形成32位物理地址。
擴展分頁
從奔騰處理器開始,Intel微處理器引進了擴展分頁,它允許頁的大小為4MB。
在擴展分頁的情況下,分頁機制把32位線性地址分成兩個域:最高10位的目錄域和其余22位的偏移量。
頁面高速緩存
由於在分頁情況下,每次存儲器訪問都要存取兩級頁表,這就大大降低了訪問速度。所以,為了提高速度,在386中設置一個最近存取頁面的高速緩存硬件機制,它自動保持32項處理器最近使用的頁面地址,因此,可以覆蓋128K字節的存儲器地址。當進行存儲器訪問時,先檢查要訪問的頁面是否在高速緩存中,如果在,就不必經過兩級訪問了,如果不在,再進行兩級訪問。平均來說,頁面高速緩存大約有98%的命中率,也就是說每次訪問存儲器時,只有2%的情況必須訪問兩級分頁機構。這就大大加快了速度。
Linux中的分頁機制
Linux使用了一個適合32位和64位系統的分頁機制。
- 頁全局目錄
- 頁頂級目錄
- 頁中間目錄
- 頁表
頁全局目錄包含若干頁上級目錄的地址,頁上級目錄又依次包含若干頁中間目錄的地址,而頁中間目錄又包含若干頁表的地址。每一個頁表項指向一個頁框。線性地址因此被分成五個部分。圖中沒有顯示位數,因為每一部分的大小與具體的計算機體系結構有關。
對於沒有啟用物理地址擴展的32位系統,兩級頁表已經足夠了。從本質上說Linux通過使“頁上級目錄”位和“頁中間目錄”位全為0,徹底取消了頁上級目錄和頁中間目錄字段。不過,頁上級目錄和頁中間目錄在指針序列中的位置被保留,以便同樣的代碼在32位系統和64位系統下都能使用。內核為頁上級目錄和頁中間目錄保留了一個位置,這是通過把它們的頁目錄項數設置為1,並把這兩個目錄項映射到頁全局目錄的一個合適的目錄項而實現的。
啟用了物理地址擴展的32 位系統使用了三級頁表。Linux的頁全局目錄對應80×86 的頁目錄指針表(PDPT),取消了頁上級目錄,頁中間目錄對應80×86的頁目錄,Linux的頁表對應80×86的頁表。
最后,64位系統使用三級還是四級分頁取決於硬件對線性地址的位的划分。
總結
這里我們不討論代碼實現,只關注原理。從上面的討論可以看到分頁機制主要依賴硬件的實現。Linux采用的四級頁表只是為了最大化兼容不同的硬件實現,單就IA32架構的CPU來說,就有多種分頁實現,常規分頁機制,PAE機制等。
我們雖然討論的是Linux的分頁機制,實際上我們用了大部分篇幅來討論Intel CPU的分頁機制實現。因為Linux的分頁機制是建立在硬件基礎之上的,不同的平台需要有不同的實現。Linux在軟件層面構造的虛擬地址,最終還是要通過MMU轉換為物理地址,也就是說,不管Linux的分頁機制是怎樣實現的,CPU只按照它的分頁實現來解讀線性地址,所以Linux傳給CPU的線性地址必然是滿足硬件實現的。例如說:Linux在32位CPU上,它的四級頁表結構就會兼容到硬件的兩級頁表結構。可見,Linux在軟件層面上做了一層抽象,用四級頁表的方式兼容32位和64位CPU內存尋址的不同硬件實現。
最后分享兩篇linux內存尋址的實驗文檔,結合實例更容易理解。
Linux內存地址映射
Linux內核在x86_64 CPU中地址映射