前言
上一篇我們了解了x86-16 CPU計算機的內存訪問方式,尋址方式,以及基於MS-DOS的應用程序的內存布局。這一篇會主要介紹32位處理器的內存訪問,內存管理以及應用程序的內存布局。雖然目前64位CPU已經非常普及了,不過相對於32位的內存管理方式並沒有大的變化,而32位相對於16位卻有了極大的改變。
1. IA-32 CPU
1985年10月。Intel推出了80386 CPU 用來取代之前x86-16位的架構,一直到現在差不多塊20年的時間里,雖然處理的速度,制造工藝都在不斷提升,但x86-32的架構都沒有大的改變。一般我們說的IA-32, I386和x86-32是一個意思。
從80386開始,地址線變為了32位,和CPU寄存器以及運算單元位數一樣,最大尋址范圍增加到4G。所以在也不會出現16位CPU時訪問內存出現的問題。另外80386處理器都可以支持虛擬存儲器,支持實模式,保護模式和虛擬8086模式,支持多任務。 而之后的CPU,主要的改進就在於:
- CPU內部集成DMA,計數器,定時器等;
- 制造工藝的提示,更多的晶體管,更快的速度
- 加入更多的指令集,如MMX,SSE,SSE2等
- 集成L1,L2,L3高速緩存,減少外部總線的訪問
- 超線程,多核心提高CPU效率
但是在內存管理訪問,卻沒有太大的變化,所以我們后面介紹的內容基本上可以試用所有的x86-32 CPU而不用特意去區分那個型號的CPU。
1.1 16位CPU內存訪問的問題
前一篇我們已經比較詳細的了解了16位CPU的內存訪問技術,現在可以會頭想想他所存在的缺點,
- 單任務: 16位的CPU只支持單任務,也就是同時只有一個程序在運行,隨着計算機的發展,單任務的缺點在於體驗較差;
- 內存小: 前面我們知道,在運行程序時,會把程序全部加載到內存中,但是當程序大於內存時,程序就無法運行了;
- 地址不確定:每次程序裝載時分配的地址可能都不一樣,使得程序在編寫時處理轉跳等問題非常麻煩。
- 安全差: 因為對於內存訪問沒有太多的限制,所以應用程序很容易去修改操作系統以及BIOS和硬件映射的內存空間,導致系統崩潰;
而當80386引入多任務的支持后,以前的內存管理方式已經不能滿足現狀的需求的了。於是我們需要新的內存管理方式來解決上面的問題:
- 地址空間:這個是對物理內存的一個抽象,就好像進程是對CPU的一個抽象。一個進程可用於尋址的一套地址的集合,每個進程都有自己的地址空間,相互獨立,這就解決了安全問題。
- 交換:把程序全部加載到內存,當進程處於空閑時,把他移除內存,存放到硬盤上,然后載入其他程序。這樣使得每個進程可以使用更多的內存。
- 虛擬內存:在老的內存管理中,一次把程序加載到內存,而當程序過大時就無法正常運行了。而利用到計算機系統的局部性和存儲分層,我們可以只加載一部分需要使用的代碼和數據到內存,當訪問的內容不在內存時,和已經使用完的部分進行交換,這樣就能在小內存的機器上運行大的程序了。對於程序來說這是透明的,看起來自己好像使用了全部內存。而多個應用完全可以使用相同的虛擬地址。
1.2 IA-32 CPU的內存訪問
32位CPU中開始,因為地址線和計算單元同為32位,所以采用了一種全新的內存訪問方式,虛擬尋址。也就是CPU發出的地址並不是真正的物理地址,而是需要轉換才能得到真實的物理地址。初看起來和16位計算機的分段內存訪問好像差不多。但本質卻不同。16位的分段訪問是為了解決地址線位數大於CPU位數的問題。而虛擬尋址則是真正解決了上面提到的那些問題。 當然,Intel為了兼容,仍舊支持16位的分段式內存訪問。
CPU在內部增加了一個MMU(Memory Management Unit)單元來管理內存地址的轉換。我們知道在16位時代,僅僅使用一個地址加法器來計算地址,而這里MMU單元除了可以轉換地址,還能提供內存訪問控制。 比如操作系統固定使用一段內存地址,當進程請求訪問這一段地址時,MMU可以拒絕訪問以保證系統的穩定性。而MMU的翻譯過程則需要操作系統的支持。所以可見硬件和軟件在計算機發展過程中是密不可分的。后面會詳細介紹虛擬地址轉換的過程。這也是本文的重點。
1.3 IA-32 CPU工作模式
從80286開始為了兼容8086引入了實模式和保護模式。但是80386因為引入了對虛擬內存的支持,使得保護模式相對80286有了很大改變。而80286也受限於當時MS-DOS只能工作於實模式,所以無法使用到保護模式。所以我們一般談到保護模式都是指386之后的32位保護模式。而CPU工作模式也和操作系統有關。
- 實模式: 實際就是8086的工作模式,可尋址空間為1M,采用分段式內存訪問。程序可以直接訪問BIOS和硬件的內存映射,所以目前計算機在啟動時都是在實模式下。
- 保護模式: 80386以后因為引入了虛擬存儲器,所以能更好的保護操作系統和各個進程的內存, 它主要體現在4G可尋址空間,采用段頁式虛擬內存訪問,支持多任務。當計算機啟動后,BIOS把控制權交給操作系統,從實模式切換到保護模式。
- 虛擬8086模式: 主要是在保護模式下虛擬執行8086的實模式,這並不是一個CPU的模式,本質還是工作在實模式下,但可以實現多任務。
我們平時會經常聽到實模式和保護模式,我們現在可以了解到他們主要的區別就在於內存訪問的方式上,而CPU工作模式也離不開操作系統的支持。在DOS和Windows 1.X系統中,只支持實模式;Windows3.0中,同時支持實模式和保護模式;而到了Windows3.1,從上面微軟操作系統和Intel CPU的版本圖來看,當時主流已經是80386和80486了,所以移除了對實模式的支持。
1.4 IA-32 CPU寄存器
1.4.1 通用寄存器
IA32的CPU主要包含了8個32位的通用寄存器,
- EAX,EBX,ECX,EDX相對於16位的CPU來說擴展成了32為,當然為了兼容16為CPU,低位的16位和8位寄存器可以被單獨使用。
- ESI,EDI兩個個變址寄存器升級到了32位,其低位的16位對應之前的SI,DI寄存器、
- ESP,EBP2個指針寄存器同樣變為32位,其低位的16位對應之前的SP,BP寄存器。
在8086內存尋址中有介紹,只有BX,BP,SI,DI可以用來存放基址和變址的地址,但是80386開始,以上8個寄存器都可以用來存放指針地址,所以更加的通用。
1.4.2 段寄存器
32位CPU為了保持對16位CPU的兼容性,保留了4個16位段寄存器,CS,SS,DS,ES,同時增加了2個段寄存器FS,GS
- CS,SS,DS,ES: 工作在實模式時與16位CPU的段寄存器作用相同;工作在保護模式則不在存放段值,而是作為選擇子,在虛擬地址轉換時使用。
- FS和GS是新增的附加數據段,通過把段地址存入這兩個寄存器可以實現自定義尋址。
1.4.3 指令指針寄存器和標志寄存器
EIP擴展到了32位,和數據線相同。 低16位作用和IP寄存器相同。在32位計算機中存放的是指令的虛擬地址,16位計算機中存放的是CS段內有效地址。EFLAGS寄存器同樣擴展到32位,具體含義我們這里不做介紹。
1.4.4 新增的寄存器
另外我們也看到,在IA-32中也新增了一些寄存器,GDTR/IDTR/LDTR/TR。他們主要在CPU保護模式下需要用到的寄存器,具體使用在后面會介紹到。
- GDTR是全局描述附表寄存器,主要存放操作系統和各任務公用的描述符;
- LDTR是局部描述符表寄存器,主要存放各個任務的私有描述符;
- IDTR指出了保護模式中斷向量表的起始地址和大小(2K,最多256個中斷);
- TR是任務寄存器;
2. 虛擬存儲器
虛擬存儲器我們一般也稱為虛擬內存(和Windows中的虛擬內存不是一個概念,但是有關聯),它的基本思想是:
- 每個進程都有自己的地址空間;
- 每個地址空間被分為多個塊,每個塊稱為頁,每個頁有連續的地址空間;
- 這些頁被映射到物理內存,但不是所有也都在內存中程序才能運行;
- 當使用的頁不在物理內存中時,由操作系統負責載入相應的頁;
在實模式下,CPU將偏移地址和段寄存器,基址寄存器等進行計算得到的實際的物理地址。 而在保護模式下,引入了虛擬內存的概念,在虛擬內存中使用的地址稱為虛擬地址(線性地址),虛擬地址通過MMU將虛擬地址映射為物理地址,然后送到總線,進行內存訪問。這里最關鍵的就是虛擬地址的映射。
2.1 分頁
對於虛擬內存來說,是對物理內存的抽象,整個虛擬內存空間被划分成了多個大小固定的頁(page),每個頁連續的虛擬地址,組合成了一個完整的虛擬地址空間。同樣,操作系統也會把物理內存划分為多個大小固定的塊,我們稱為頁框(page frame),它只是操作系統為了方便管理在邏輯上的划分的一個數據結構,並不存放實際內存數據,但是我們可以簡單的認為它就是內存。這樣一個虛擬內存的page就可以和一個物理內存的page frame對應起來,產生映射關系。
關於一個虛擬頁的大小,現在的操作系統中一般是512B-64K(准確的說是地址范圍大小,而非容納數據的大小)。但是內存頁的大小會對系統性能產生影響,內存頁設得太小,內存頁會很多,管理內存頁的數組會比較大,耗內存。內存頁設大了,因為一個進程擁有的內存是內存頁大小的整數倍,會導致碎片,即申請了很多內存,真正用到的只有一點。目前Windows和Linux上默認的內存頁面大小都是4K。
從上圖我們也可以看出,虛擬內存的頁和物理內存的頁框並不一定是一一對應的,虛擬內存的大小和系統的尋址能力相關,也就是地址線的位數,而物理內存的頁框數取決於實際的內存大小。所以可能只有一部分頁有對應的頁框,而當訪問的數據不在物理內存中時就會出現缺頁,這個時候操作系統會負責調入新的頁,也就是建立新的映射。這樣就不需要一次把程序全部加載到內存。
2.1.1 虛擬頁是什么?
很多人會有一個疑問,虛擬頁到底是實際存在的還是虛擬的?我們知道內存中存放的是執行文件的代碼和數據,而程序在運行前,它的數據和代碼是存放在這個程序的可執行文件中的(比如.exe和.so),而在運行時需要把可執行文件加載到內存。所以我們把這個硬盤上的文件也划分為4K大小的頁(邏輯上划分,實際是加載過程中加載器完成的),這就是虛擬頁里面實際的東西。但是程序在運行是可能會申請內存,這個時候需要新的虛擬頁來映射,所以我們可以得知虛擬頁應該有3種狀態:
- 已映射:虛擬頁面被創建已經被加載到物理內存,和物理頁之間存在映射關系。
- 未映射:虛擬頁面被創建,但是沒有被加載到內存或已經被調出內存,和物理頁面之間沒有映射關系,當需要使用時調入內存建立映射。
- 未創建:虛擬頁面沒有被創建,可能是因為還沒有訪問到此頁面所以沒有加載或者是調用macllo來分配內存,只有在運行是才會被創建。
2.1.2 存儲器映射
加載應用程序到內存時,因為和虛擬地址有關,我們需要把應用程序文件和虛擬內存關聯起來,在Linux中稱為存儲器映射,同時提供了一個mmap的系統調用來實現次功能。文件被分成頁面大小的片,每一片包含一個虛擬頁面的內容,因為頁面調度程序是按需求調度,所以在這些虛擬頁面並沒有實際的進入內存,而是在CPU發出訪問請求時,會創建一個虛擬頁並加載到內存。我們在啟動一個進程時,加載器為我們完成了文件映射的功能,所以此時我們的執行文件又可以稱為映像文件。實際上加載器並不真正負責從磁盤加載數據到內存,因為和虛擬內存建立了映射關系,所以這個操作是虛擬內存自動進行的。 正是有了存儲器映射的存在,使得我們可以很方便的將程序和數據加載到內存中。
2.1.3 交換分區
當CPU請求一個虛擬頁是,虛擬頁會被創建並加載到內存,而頁面調度算法可能在頁面休眠或在內存滿的情況下更具調度算法將虛擬頁交換出去,在適當的時候可能被交換回來。這個時候就需要一個區域來存放被交換出來的虛擬頁,這個區域稱為交換分區。 這個分區在Linux中稱為swap分區,而在Windows中我們稱為虛擬內存(注意這里和我們談到的虛擬內存技術不是一回事)。
以前電腦內存很小,特別是玩一些游戲時經常會提示內存不足,網上一般會告訴你增大你的虛擬內存(交換分區),這樣一來在內存不足的時候可以存放更多交換出來的虛擬頁,看起來好像內存變大了一樣。從這方面來說Windows把他叫虛擬內存(交換分區)也是很正確的。 交換分區雖然也是硬盤的一部分,但是交換分區沒有普通的文件系統,這樣消除了將文件偏移轉換為頁地址的開銷。但是過於頻繁的交換頁面,IO操作會導致系統性能下降。但是在內存不足時可以保證系統 正常運行。當然這也和交換分區的大小有關。
而如今,一般使用的電腦都已經4G,8G內存了,對於普通需求來說足夠大了。所以虛擬頁會長時間存在與內存中而不被交換出去。所以我們可以禁用掉交換分區,以便提高性能。對於Windwos 從Vista開始有一個Superfetch的內存管理機制,而linux有Preload與之類似。這種內存機制會將用戶經常用的應用的部分虛擬頁提前加載到內存,當用戶使用時就無需在從硬盤加載。而當應用休眠或關閉時,也不會將這些虛擬頁交換出去。
如下圖就是Windows 8上內存使用情況,其中最左灰色部分是給BIOS和硬件保留的內存映射區域;綠色為操作系統,驅動以及用戶進程使用的內存;橙色表示已經修改的內存也,當交換出來時需要先寫回到硬盤;而藍色部分5G內存則是用來緩存了未激活進程的數據和代碼頁;最后剩余的3M才是空閑內存。 當活動進行需要更多內存時會優先使用可用部分,當可用部分沒有內存可用時,會釋放一部分備用區域的內存。
2.2 頁表
上面我們看到當實際物理內存小於虛擬內存時,會存在缺頁以及頁面交換等問題。此時操作系統會處理這些事情,是的這一切對於程序來說是透明的,它們不知道發生了什么,只知道自己可以使用全部的虛擬內存空間。而對於操作系統來說,它們需要負責一切,需要知道程序的那些頁在實際內存中,那些不在。於是出現了頁表,就是用來記錄虛擬內存的頁和物理頁框之間的映射關系。MMU也正是利用頁表來進行虛擬地址和物理地址的轉換。
上面這張圖是一張虛擬內存頁和物理內存頁框之間通過頁表的映射關系,其中虛擬頁面從VP0-VP7,物理頁為PP0-PP3,我們從圖中可以得到幾點信息:
- 不是所有的虛擬內存頁都加載到了物理內存中(VP3,VP6未映射狀態);
- 不是所有虛擬內存頁都被創建(VP0,VP5未被創建)
- 所有的虛擬內存頁在頁表中都有一項紀錄,我們稱為PTE(Page Table Entry);
- 虛擬內存的頁是存放在磁盤上的;
- 頁表紀錄需要占用內存空間;
- MMU通過頁表,將虛擬地址轉換為物理地址;
這里可能會有疑問,為什么VP5沒有被創建?虛擬頁不是應該連續的嗎?這就涉及到內存分段,程序編譯和加載一些列問題了,這個會在介紹程序加載時解釋。
最后我們看下頁表中PTE的結構,一個PTE大小是32位,系統在操作頁表時則會根據這些屬性進行相應操作。
- P:存在標志(1表示當前頁是加載到了物理內存中)
- W:讀寫標志(0時表示只讀)
- U/S: 用戶/超級用戶(0時表示用超級用戶權限)
- PWT:連續寫入
- PCD:禁用緩存
- A:訪問過
- D:臟位(1表示被寫過)
- PAT:頁面屬性索引表
- G:全局標志(TLB中使用)
- Avail:方便操作系統使用
2.3 虛擬地址轉換
通過上圖我們來分析一下虛擬地址轉換的過程:
- CPU送出要訪問的虛擬地址,地址的結構是【頁號+頁內地址】;
- 頁表存放在內存中,頁表的地址和長度信息則存放在一個頁表專用的寄存器中;通過讀取寄存器的信息獲得頁表的起始地址;
- 將虛擬地址的頁號與頁表其實地址相加可以得到頁表的實際地址
- 通過頁表的映射項目,可以得到對應的物理內存頁的號碼
- 通過物理頁號和業內偏移地址就能得到實際要訪問的物理地址
當然,如果訪問過程中出現缺頁,會產生一個中斷,然后操作系統會載入需要的頁面並進行映射(設置頁表),最后返回物理頁號得到物理內存。從上面的過程我們可以知道,每次進行地址變化,MMU都要訪問內存。回憶8086地址變換時是不需要訪問內存的,於是虛擬地址的轉換會影響系統性能,但是相當於虛擬內存帶來的好處,這點代價還是值得的。
2.4 頁表分級
在IA-32平台上,地址線為32位,所以最大的尋址范圍是4G,那么最多能夠支持使用4GB的內存(內存按字節編碼)。那么對於虛擬內存來說,它的地址范圍為4G(0x00000000 ~ 0xFFFFFFFF),而一個內存頁的大小是4K,那么一個程序虛擬內存空間中有1048576個頁(實際上進程可訪問的虛擬地址范圍沒有4G,Linux是3G,Windows是2G或3G)。
從上面我們知道每個虛擬頁都會在頁表中有一個PTE,每個PTE為32位,那么對於一個進程至少需要4MB的內存來維護自己的頁表;而一個系統中可能存在多個進行,僅僅維護頁表這一項就需要消耗比較多內存。但實際上很多PTE項並沒有映射到對應的物理頁,這就造成了浪費。
有人會說那我們就動態建立頁表,在映射時才增加這一項。但是從虛擬地址轉換我們可以看到,找到PTE是通過PT首地址+頁號得到的,所以頁表PTE必須是連續的,但我們又知道並不是所有的虛擬頁都會馬上被創建,在訪問是就會出現問題,比如VP0-VP8中的VP5沒有被創建,當訪問頁號是5時,就會錯誤的訪問到VP6。所以為了解決頁表占用內存過多的問題,引入了分級頁表。注意分級頁表也需要CPU硬件提供支持。
2.4.1 二級頁表
上圖是Linux系統上二級頁表的示意圖。與一級頁表不同的是,多增加了一層目錄,虛擬地址的組成變為了【目錄地址+頁表地址+頁內偏移】。其中頁內偏移地址為12位,頁表(PGT)地址為10位,頁表目錄(PGD)地址為10位。因為總過是32位,他們表示的PTE的個數是不變的。同樣,PEG的每一個項目也有自己的結構。
二級頁表地址轉換的過程也很簡單:
- 首先從cr3寄存器中找到PGD的首地址;(cr3寄存器用來保存PGD的地址)
- PGD首地址和目錄號進行計算得到頁表的首地址;
- 頁表的首地址和頁號進行計算得到物理頁的首地址;
- 頁內偏移和物理頁地址通過計算得到最終的物理地址;
現在談一談為什么頁表分級可以解決內存問題。首先因為頁表需要連續的大的內存空間,通過引入目錄級,我們可以離散對連續大空間的需求,這樣,只有在同一個目錄下的頁表才需要連續的空間。另一方面,如果某個目錄下的頁表中沒有任何映射的記錄,那么這一張頁表就不需要加載。因為其他頁表可以通過其他目錄項來獲得,而不會存在一級表中不加載頁表項導致訪問出錯的問題。但是同一個頁表中,如果只有一個PTE被使用,這張頁表也是需要被加載的。分級的方法同樣用到了程序的局部性原理。
2.4.2 多級頁表
對於32位系統最多能使用4G內存,為了讓系統可以使用更多內存,加入了 物理地址擴展(Physical Address Extension,縮寫為PAE)功能,可以支持36位。在前面8086時候我們見過類似的技術來使用更多內存。這個時候2級頁表就無法滿足要求了,於是引入了三級頁表。其中增加了PMD中間目錄一級。
為了適應64位CPU,操作系統又引入了4級頁表。但是總體上來說他們工作原理都是相同的,這里就不敘述工作工程了。但是要注意的是,分級頁表需要處理器的支持,對於只支持二級或三級頁表的CPU來說,內核中體系結構相關的代碼必須通過空頁表對缺少的頁表進行仿真。因此,內存管理代碼剩余部分的實現是與CPU無關的。目前Windows 2000/XP使用的是二級頁表,而使用PAE時則使用的是三級頁表,對於64位操作系統則采用了四級頁表。Linux則使用了四級頁表。
2.4.3 倒排頁表
在64位操作系統中,因為有64位地址線,所以頁表的大小可能非常非常大,雖然分級頁表可以不必加載全部頁表,IA-32,IA64系統一般使用四級頁表來處理,而在PowerPC等體系中則使用倒排頁表來解決這個問題。與傳統頁表的區別: 使用頁框號而不是虛擬頁號來索引頁表項。因為不是X86體系中常用的方法,這里就不相信介紹了。具體可以查看《現代操作系統》P113
2.5 TLB緩存
前面介紹虛擬地址轉換時說過,相對於8086上的地址轉換而言,這里多了一次內存訪問查找頁表的過程。我們知道內存速度比CPU慢很多,每次從內存取數據都要訪問2次內存,會使得系統性能下降。
為了解決這個問題,在MMU中包含了一個關於PTE的緩沖區TLB(Translation Lookaside Buffer ),TLB是一個寄存器,所以它運行的速度和CPU速度相同。TLB中每一行保存了一個PTE,如上圖所示,每次在去頁表中查找之前,可以先在TLB中進行查找,如果命中則直接拿到物理頁地址,如果不命中則再次訪問內存。我們多次提到程序的局部性,在這里下一個要訪問的地址很可能和前一個是在同一個內存頁中,於是我們可以直接從寄存器中拿到物理內存頁號,而不需要在訪問內存,這樣大大提高的了系統的速度。
上圖是TLB的一個基本結構,對於多級頁表來說,TLB可以緩存每一級的地址,所以同樣能起作用。因為局部性原理,多級頁表的訪問速度並不比一級頁表差。關於TLB更詳細的內容,可以查看《深入理解計算機系統》P607 - P619
2.6 進程調度和虛擬內存
我們知道在系統中,每個進程都有自己獨立的虛擬空間,於是每個進程都有一張屬於自己的內頁表。 而我們翻譯地址時,從cr3中取出頁表目錄的首地址。對於不同的進程,他們都使用同一個寄存器。於是在CPU調度進程的時候,虛擬的地址空間也需要切換。於是對於普通用戶程序需要做下面幾件事情:
- 保存當前進程的頁表目錄地址,也就是保存cr3中存放的地址到進程上下文中
- 清空TLB中緩存的數據
- 調度新的進程到CPU,並設置cr3寄存器的值為頁表目錄的首地址
但是內存中除了用戶程序之外還存在操作系統自身占用的內存。我們可以簡單的把操作系統看成一個超大的進程,他和其他普通進程一樣需要使用虛擬內存,需要使用到頁表。當然作為內核程序,它必須是有一些特權的,下一篇我們將會介紹虛擬內存的布局。而對於內核而言不是存在進程調度的。因為所有的內核進程都是共享內核的虛擬地址空間,而我們一般都稱之為內核線程,而非進程。 當然對於Linux而言,沒有線程的概念,線程和進程唯一不同就是是否共享虛擬地址空間。一般來說內核代碼是常駐在內存的,那么內核會不會缺頁呢?
3 小結
這一篇文章主要介紹了IA-32上的虛擬內存管理,主要的核心就是虛擬內存分頁。這也是現代操作系統和計算機的核心部分。整個虛擬內存部分涉及的內容也非常廣,加上自己理解不深,很多東西就只能簡單介紹了。而且內存管理這一塊不同操作系統可能會有一些不同的,這里我盡量避開這些差異,總體來說都是比較通用的。在下一篇將主要介紹虛擬內存中的分段管理。