1. 程式人生 > >第一講 記憶體定址

第一講 記憶體定址

引子

一段程式碼:

#include <stdio.h>
int foo;
void main()
{
    foo = 100;
    printf("%d\n",foo);
}

問題:變數foo存放在記憶體的什麼位置,printf又在什麼位置,CPU如何訪問(修改)它們?
答:在不同作業系統上、不同的編譯器編譯結果、不同硬體平臺上、不同時間執行它、同一時間執行的不同程序中……變數位置都不同。

確定一個變數的位置有兩個步驟,一是編譯連結時期由工具鏈確定的虛擬地址空間的地址,二是執行時作業系統將虛擬地址對映到一個實體地址。CPU執行指令訪問虛擬地址又要經過一系列過程。
從這個意義上,記憶體定址涉及到處理器、程式語言、作業系統三個主題,並且三者之間是密不可分的。本講中將首先從這三個角度依次分析,然後重點講解Linux記憶體定址機制和過程。
處理器、程式語言與作業系統

內容摘要:

編譯、連結、靜態連結、動態連結、重定位。
邏輯地址到線性地址轉化、線性地址到實體地址轉化、頁目錄表、頁表、頁框。
CRn和gdtr/ldtr等暫存器、分段單元、分頁單元、快取、主存、磁碟。

我們還省略了可執行檔案的格式、作業系統載入可執行檔案的過程等主題,作為程序管理中的一部分在以後分析。

生成可執行檔案

從程式語言的發展角度上,確定變數地址是一個不斷延遲配置的過程。
最早用機器語言開發時使用的都是絕對地址,地址值都是直接寫死在程式碼中,如果插入一條指令那麼後續指令全部需要修改。
後來發明了組合語言,使用符號表示一個地址和指令,用匯編器計算符號地址,修正指令,但仍然是絕對地址。
再後來有模組化開發概念後,某個符號可能是另一個模組的,因此需要延遲到連結時期才能確定其地址。
有了動態連結庫的概念後,某些符號可能根本不存在可執行檔案內,需要執行時期在作業系統的幫助下才能確定。

我們先來關注截止到生成可執行檔案時的過程。從原始碼生成可執行檔案有四個步驟:預處理、編譯、彙編、連結。

編譯器經過掃描、語法分析、語義分析、原始碼優化、程式碼生成和目的碼優化等六步,生成目標檔案。目標檔案中涉及其他模組符號的地址都被設定為0,等待連結過程中確定。
多個目標檔案經過連結生成可執行檔案。

連結過程分為靜態連結和動態連結兩種,在連結控制命令中確定。

靜態連結過程中,每個目標檔案中的各個段被提取出來合併。這個過程分兩步。
第一步,空間與地址分配。掃描所有輸入目標檔案,獲得各段長度、屬性和位置,並且將輸入目標檔案的符號定義和引用收集起來放到一個全域性符號表。
在這一步中每個段在連結後的虛擬地址已經確定,例如Linux下的可執行檔案的.text段起始地址為0x08048000等。因為各個符號在段內的位置固定,因此給每個符號加上一個偏移量即可確定符號的虛擬地址。
第二步,符號解析與重定位。讀取輸入檔案中段的資料、重定位資訊,並進行符號解析與重定位、調整程式碼中的地址等,這是連結過程的核心。
具體是這樣的:先掃描重定位表,確定所有需要重定位的符號引用位置和指令調整方式,然後利用前面確定的符號虛擬地址修正程式碼中的未確定地址。重定位表在ELF中是一個或多個段,一般以.rel開頭,如程式碼段.text的重定位表存在.rel.text段。
第二步中如果發現某個引用符號在全域性符號表中不存在,則會出現符號未定義的錯誤,這也是編譯過程中最常見的錯誤之一。

動態連結。
靜態連結原理簡單,但是實現困難,原因之一是大量公共庫函式在記憶體中將重複存在。例如Linux系統中一個普通程式會用到1MB以上C語言靜態庫,多個程序將造成巨大的空間浪費。
另一個原因是更新、部署和釋出困難。一個細小的改動需要重新發布整個程式,浪費傳輸資源。
使用動態連結時,對於輸入動態連結庫中的符號定義,連結器將只標記,不進行重定位,將重定位過程延遲到裝載時期或更晚的符號第一次被使用時。
這是一個巨集大的主題,具體可以參考《連結、裝載與庫》一書。

最終連結完成的可執行檔案格式如下圖所示。
ELF檔案格式

可執行檔案中已經包含了靜態連結後變數的虛擬地址值,以及需要動態確定地址的共享庫符號,以備讓作業系統為其提供地址。作業系統載入可執行檔案的過程中,將解析這些符號,在載入期或執行期確定所用符號的虛擬地址,分配在虛擬地址空間上。

在Linux載入執行後的虛擬地址空間如下所示。
Linux程序虛擬地址空間

x86的定址機制

本節我們關注CPU如何用虛擬地址確定真實的實體地址,將其放到總線上完成定址的過程。我們以Intel的80x86系列CPU為例講述。

處理器最早是沒有虛擬地址和保護模式等概念,CPU訪問記憶體使用是實地址、可無保護訪問任意地址。這就導致了程式設計師必須瞭解平臺的物理特性、謹慎的編寫程式碼。
舉例:C6678需要統計應用的空間佔用需求。
最早的8086處理器使用的就是實地址。後來Intel為了解決地址寬度不足的問題引入分段機制,再後來為進一步保護資料又引入分頁機制,相應衍生出MMU、CRn等暫存器和物理單元,演變為現今的分段加分頁的定址系統。如圖所示。
x86地址轉換過程

段式定址

引入段式定址的直接原因是處理器位寬的增加。
一般講處理器的位寬,是指ALU的寬度,資料匯流排一般與ALU等寬。而地址匯流排一般也與資料匯流排一致。這是因為從程式設計角度,一個地址也就是一個指標,最好與一個整型長度一致。

8086和8088是16位CPU,從80386開始為32位。當初8086定址範圍64K太小,於是Intel決定將其擴充套件到1MB,即20位地址寬度。為此Intel發明了一種巧妙的方法,即分段。在CPU中設定了四個段暫存器:CS、DS、SS、ES,用於訪問指令、資料、堆疊和其他。將記憶體對應劃分為多段,用段暫存器配合偏移量來完成定址。
回憶微機原理課程。在8086處理器上寫組合語言時,訪問一個記憶體需要兩步,將段地址寫入DS暫存器,將偏移量寫入BX,然後使用[DS:BX]組合完成定址。這就是段式定址。這時DS:BX的組合稱為邏輯地址,定址前經過一個分段單元的硬體電路轉化成線性地址。但這種定址模式存在安全風險,即缺少許可權管理,任意程序都能訪問所有地址空間。這種定址方式稱為實地址模式。

從80286開始,Intel開始實現保護模式。這種模式下寫入段暫存器的不再是段地址,而是一個段描述符,多了一步轉化過程。
段式定址的進化
詳細的轉化過程是這樣的:邏輯地址由16位段選擇符和32位偏移量組成,段選擇符存放段暫存器裡。有六個段暫存器,分別是cs,ss,ds,es,fs和gs。每個段選擇符有一個TI位表示是哪個描述符表,有13位索引號欄位表示是段描述符表中的哪一個,還有RPL位表示訪問許可權。
段選擇符

有兩類段描述符表:全域性描述符表GDT或區域性描述符表LDT,每個描述符表有多個段描述符。GDT的地址和大小在gdtr控制暫存器定義,當前使用的LDT地址和大小在ldtr控制暫存器中定義。
每個段描述符8位元組,表示一個段。有幾種不同型別的段和描述符,在Linux中廣泛採用的有程式碼段描述符、資料段描述符、任務狀態段描述符、區域性描述符表描述符,其格式有所不同。之後會講到。

Linux中用到的幾種段選擇符

這樣要訪問一個地址,先將該地址所在段的段選擇符放入段暫存器,由此按索引欄位找到段描述符,找到段基址,再加上偏移量,就轉化成了線性地址。在沒有分頁單元的情況下,線性地址等於實體地址,可以直接放到地址總線上。
在這個過程中,通過段長和段訪問許可權,就可以控制程序無法訪問到非法地址。
更詳細的x86的結構不再詳述。

頁式定址

頁式地址管理從80年代中期在Unix等作業系統上實現,它比段式管理更為先進。因此Intel不得不在80386上開始實現頁式管理。
這時,線性地址不能直接放到總線上,而是要再經過一個分頁單元的硬體電路,將線性地址轉化成實體地址。在這個過程中,很關鍵的一個任務是請求的訪問型別與線性地址的訪問許可權相比較,如果這次記憶體訪問是無效的(如線性地址還未對映到實體地址上),就產生一個缺頁異常。缺頁異常處理程式在第三講中詳述。

插入異常分類。(在講Linux中斷和異常時詳述)
異常可以分為四類:中斷、陷阱、故障、終止。
異常的分類
舉例:硬體引腳變化引起中斷;系統呼叫使用陷阱;缺頁異常是故障;SRAM資料錯誤引起終止。

為了效率起見,線性地址被分為以固定長度為單位的頁,一般為4KB。這組連續的線性地址,會被對映到連續的實體地址中。名詞解釋:頁和頁框,不要將頁與物理儲存器的頁框混淆,更不要與Flash的頁混淆。
使用頁是為了減少對映表的數量和減少地址許可權描述符的數量。後面將Linux的頁管理時會講到該描述符,存放該描述符的資料結構稱為頁表,在啟用分頁單元前需要由核心對該結構進行初始化。
從80386開始,所有x86處理器都支援分頁,通過將CR0暫存器的PG標誌置位實現。其頁大小為4KB。啟用分頁後,32位的線性地址分為三個域:高10位的目錄、中間10位的頁表和低10位的偏移量。

x86的頁式定址

線性地址的轉化分兩步完成,每一步都基於一種轉換表,第一種稱為頁目錄表,第二種稱為頁表。從圖中解釋這個轉換過程。

考慮一個問題:能否只用一個頁表,一步完成定址?
看起來二級模式是多此一舉的,並且還多佔用了系統記憶體來存放這些表。實際上使用二級模式正是為了減少所需記憶體數量。如果只使用一級頁表,那麼每個程序需要高達2^20個表項(每個4位元組則4MB)來描述其地址空間。因為頁表內不能有空洞。使用二級模式,則那些不使用的地址不需要分配頁表。可以大大減少所需記憶體數量。
舉例計算:一個4GB線性空間需要4MB頁表空間來描述。如果有100個程序,全部描述其線性空間需要400MB,這是不太實現的。在第三講會提到,每個程序理論可訪問的空間是4GB,但其生命週期中有可能大多數線性空間都永遠不會訪問,因此大量的描述符是沒有必要的,這樣可以節省巨大記憶體。

每個活動程序需要一個頁目錄和多個頁表,頁目錄的實體地址存放在CR3中。各個欄位依次疊加可以完成最終的定址。

講述頁目錄項和頁表項的結構:
頁目錄項和頁表項

Present標誌、包含頁框實體地址的最高20位的欄位、Accessed標誌、Dirty標誌、Read/Write標誌、User/Supervisor標誌、。其中User/Supervisor標誌表示頁和頁表的特權級,Read/Write標誌表示其存取許可權。PCD和PWT標誌、PageSize標誌、AVL。

用下圖描述分頁機制下CPU訪問一個記憶體的硬體操作步驟。說明正常分頁與缺頁、許可權或訪問方式錯誤。
頁面命中
缺頁

擴充套件分頁:從奔騰開始,x86處理器引入的擴充套件分頁(與後續的實體地址擴充套件PAE區分),使用20位偏移和10位目錄來定址,每個頁可以達到4MB,通過設定CR4的PSE標誌使擴充套件分頁與正常分頁共存。隨著記憶體容量和磁碟容量的增加,以及磁碟訪問速度的顯著提高,以及對影象處理要求的日益增加,4MB位元組的頁面大小有可能會成為主流,在這點上說明了Intel的遠見。然而Linux目前沒有采用這種機制。
擴充套件分頁

PAE分頁機制。由於32位地址管腳只能定址4GB地址空間,而當今的伺服器中需要同時執行數以千計的程序,對Intel造成了壓力,因此其從Pentium Pro開始管腳數提升到36,定址空間達到64GB,這也需要一種新的分頁機制。
另外對於64的x86_64系統,需要兩級以上分頁。稍後會講Linux如何解決不同硬體平臺需要不同級數分頁的問題。

這裡省去了多核處理器的講解,可以參考C6678培訓材料自行學習。

總例

某次程序切換過程中的定址的整個過程。
1. schedule()在核心空間,需要獲取到切換到的程序的結構體task_struct以及mm_struct,假設已經拿到其地址值。由於這個結構體在核心空間,因此全域性描述符表暫存器gdtr不需要改變,核心向段暫存器中寫入段選擇符,向某個通用暫存器寫入偏移量,呼叫一條記憶體載入彙編指令載入該值,這時分段單元先從記憶體的全域性段描述符表中載入段描述符,找到段基址,加上偏移量構成線性地址。
2. 接下來分頁單元對該線性地址分為三部分:頁目錄、頁表、偏移量。通過CR3暫存器的值讀一次記憶體載入頁目錄表,找到頁目錄項,再讀一次記憶體載入頁表,找到頁表項,最後讀一次記憶體把真正需要的值返回給CPU。
3. 此時讀到的值僅是為了寫入CR3暫存器的該程序頁目錄的實體地址。核心還需要讀取其他很多項資料,每次讀取都重複上述1、2步。
4. 最終核心完成準備過程後將頁目錄的實體地址載入入CR3暫存器,然後進入該程序,此時才能進入到該程序的地址空間後才能訪問該程序的資料。
5. 假如程序開始執行後需要獲取資料,則要用新的段描述符表、頁目錄表,全部都要從記憶體中重新載入一遍。
6. 以上過程中省略了讀取段描述符、許可權檢查的過程,簡化了有可能存在的修改gdtr暫存器的過程、缺頁時的處理等步驟,否則真實的過程將更加複雜。

關於效率

一次定址操作有可能要訪問多次記憶體,這樣複雜的設計為的是獲得更好的可移植性、更方便的管理和更高的安全性。表面上看效率極其低下,但實際上硬體已經為這樣的定址方式提供了巨大的支援。

為了加速段式定址轉換速度,Intel額外提供了多個附加的非程式設計暫存器,每個8位元組,用於存放段描述符。每次一個段選擇符載入到段暫存器後,處理器將由存中將對應的段描述符載入到對應的非程式設計暫存器,這樣只要段暫存器內容不變,就可以省去每次訪問記憶體中GDT和LDT的過程。
為了加速頁式定址的轉換速度,MMU中有一個小的、虛擬定址的快取,稱為翻譯後備緩衝器TLB,其中每行都儲存著一個由單個PTE組成的塊。實體地址轉化成的線性址儲存在TLB中,以後同一個程序訪問同樣的線性地址則不再需要從記憶體中讀取頁表進行轉換,而是可以直接從TLB中獲得轉換後的實體地址。下圖展示了存在TLB時線性地址命中與不命中的定址步驟。
TLB的加速作用

當CR3修改時(意味著程序切換),硬體自動使本地TLB所有項都無效。同時Linux提供了一些函式來部分或全部使TLB無效。

現代CPU中還存在多級Cache,進一步加快了定址過程,分頁後的實體地址在放入匯流排後,先經過多級Cache,前面訪問過的資料在各級快取中,不必真正去DDR儲存器裡讀取。根據軟體的區域性性原理,只有很低概率才需要觸發真正的記憶體訪問操作,這樣就能獲得巨大的系統性能提升。

Linux定址機制

從作業系統發展歷史上,地址訪問也是逐漸嚴格限制的過程。早期作業系統包括當今的多數嵌入式作業系統沒有許可權等概念,每個任務都可以訪問任意地址,改寫其他任務的地址空間,從而導致其他任務異常甚至系統崩潰。
舉例,兩個任務棧緊鄰分配,一個棧溢位導致另一個任務執行不正常。可能造成系統崩潰,或更糟糕的是執行結果不正確。

現在的Linux和Windows等作業系統都對每個程序劃分地址空間,每個程序不能隨意訪問其他程序的地址空間,甚至本程序空間內的地址也有明確的訪問許可權。這樣一個程序的程式設計錯誤也不會影響其他程序,使用者空間的程序錯誤也不會影響核心,大大增強了系統穩定性。
Linux的線性地址空間共4GB,分為核心空間與程序空間,核心空間佔據3GB以上地址,內容對於所有程序都一樣,程序空間是低3GB,每個程序各不相同。
Linux地址空間劃分
程序地址空間的引入需要一套對應的定址機制。這將是前三講的內容。我們以x86為例對其進行詳細講述。

Linux的分段

分段是x86首創,而ARM和MIPS並不支援分段。Linux要照顧所有平臺,因此僅在x86上使用了分段,且使用方式非常有限。即只使用了有限幾個段如使用者程式碼和資料段、核心程式碼和資料段,並將所有這四個段的段描述符的基址都設定為零。這意味著在使用者態和核心態程序都可以使用相同的邏輯地址,並且邏輯地址與線性地址是永遠相等的。用程式碼來說明。

=================== arch/x86/kernel/process_32.c 247 263 =====================
247 void
248 start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
249 {
250     set_user_gs(regs, 0);
251     regs->fs        = 0;
252     set_fs(USER_DS);
253     regs->ds        = __USER_DS;
254     regs->es        = __USER_DS;
255     regs->ss        = __USER_DS;
256     regs->cs        = __USER_CS;
257     regs->ip        = new_ip;
258     regs->sp        = new_sp;
259     /*
260      * Free the old FP and other extended state
261      */
262     free_thread_xstate(current);
263 }

在核心程式碼中,四個段選擇符分別被定義為常數值:
選擇符定義
TI都是0,說明全部使用GDT,在GDT中這四個段選擇符對應的段描述符被初始化為如下值,且永遠不變。
段描述符
可見每個段都是從0地址開始覆蓋了全部4GB地址空間。
另外Linux在GDT中還使用了其他幾個專門的段:任務狀態段、預設區域性描述符表段、區域性執行緒儲存段、高階電源管理相關段、支援即插即用功能的BIOS服務程式相關段、雙重錯誤異常的特殊TSS段。此處略去兩千字分析。
Linux全域性描述符表

Intel設計段式定址意圖是讓每個程序使用各自的LDT,且讓每個段描述符對應不同的地址空間。Linux的做法顯然與之不符。實際上Linux只在有限情況下程序需要建立自己的區域性描述符表,如Wine程式。Linux提供了modify_ldt()系統呼叫供這樣的程式修改自己的區域性描述符表。解釋Wine:在posix相容系統上執行面向段的微軟windows應用的相容層。如Wine qq。
Linux不使用區域性描述符表,因此核心定義了一個預設的LDT供大多數程序共享,在default_ldt陣列中,核心只使用了其中兩個呼叫門相關項。

問:是否可以通過惡意設定CS、DS繞過Intel的段式保護機制?
答:可以,但Linux真正重要的是分頁機制裡的保護機制。

Linux的分頁

32位的x86使用兩級對映,而64位系統使用四級對映,為了相容,Linux從2.6.11開始使用了一種通用的四級分頁模型來匹配所有支援的硬體。這四種頁表分別是頁全域性目錄、頁上級目錄、頁中間目錄和頁表。對於沒有啟用PAE的32位系統,兩級頁表足夠,因此使頁上級目錄和頁中間目錄全部為0,取消了這兩個欄位。對於啟用了PAE的32位系統,啟用了三級頁表。對於64位系統,使用三級還是四級頁表取決於硬體。具體做法在編譯期間配置巨集實現。

Linux四級頁表
Linux高度依賴分頁,分頁是後面兩講的記憶體管理和程序地址空間的基礎。

Linux核心頁表建立過程

Linux的定址機制的正常執行,有賴於在啟動過程中逐步建立了核心頁目錄和頁表。本節講述建立核心頁表過程。這裡參考了maxwellxxx的文章Linux核心初始化階段記憶體管理的幾種階段(1)。感謝作者。

bootloader載入核心映象

作業系統啟動前時啟動bootloader,並用bootloader載入和解壓核心映象到記憶體中。真實模式下bootloader(示例中是grub)不能訪問1MB以上的記憶體,需要暫時開啟保護模式,將保護模式核心放入1MB以上的地址空間。載入完成後的記憶體佈局如圖。
核心載入完成後的記憶體佈局
Bootloader執行完使命後跳轉到核心arch/x86/boot/header.S的_start處開始執行。

準備進入第一次保護模式(記憶體管理:野蠻)

核心彙編程式碼要設定堆疊(ss,esp)和清理bss段,然後跳入C語言的arch/x86/boot/main.c執行,呼叫go_to_protected_mode()進入保護模式(pm.c)。
為了進入保護模式,需要先設定gdt,這個時候的gdt為boot_gdt,程式碼段和資料段描述符中的基址都為0,設定完後就開啟保護模式。

==================== arch/x86/boot/pm.c 66 90 ====================
66 static void setup_gdt(void)
67 {           
68     /* There are machines which are known to not boot with the GDT
69        being 8-byte unaligned.  Intel recommends 16 byte alignment. */
70     static const u64 boot_gdt[] __attribute__((aligned(16))) = {
71         /* CS: code, read/execute, 4 GB, base 0 */
72         [GDT_ENTRY_BOOT_CS] = GDT_ENTRY(0xc09b, 0, 0xfffff),
73         /* DS: data, read/write, 4 GB, base 0 */
74         [GDT_ENTRY_BOOT_DS] = GDT_ENTRY(0xc093, 0, 0xfffff),
75         /* TSS: 32-bit tss, 104 bytes, base 4096 */
76         /* We only have a TSS here to keep Intel VT happy;
77            we don't actually use it for anything. */
78         [GDT_ENTRY_BOOT_TSS] = GDT_ENTRY(0x0089, 4096, 103),
79     };      
80     /* Xen HVM incorrectly stores a pointer to the gdt_ptr, instead
81        of the gdt_ptr contents.  Thus, make it static so it will
82        stay in memory, at least long enough that we switch to the
83        proper kernel GDT. */
84     static struct gdt_ptr gdt;
85             
86     gdt.len = sizeof(boot_gdt)-1;
87     gdt.ptr = (u32)&boot_gdt + (ds() << 4);
88             
89     asm volatile("lgdtl %0" : : "m" (gdt)); //載入段描述符
90 }

其中GDT_ENTRY巨集的定義如下:

==================== arch/x86/include/asm/segment.h 4 11 ====================
4 /* Constructor for a conventional segment GDT (or LDT) entry */
5 /* This is a macro so it can be used in initializers */
6 #define GDT_ENTRY(flags, base, limit)         \
7   ((((base)  & 0xff000000ULL) << (56-24)) |   \
8    (((flags) & 0x0000f0ffULL) << 40) |        \
9    (((limit) & 0x000f0000ULL) << (48-16)) |   \
10   (((base)  & 0x00ffffffULL) << 16) |        \
11   (((limit) & 0x0000ffffULL)))

由於尚未開啟分頁,所以寫入gdtr暫存器的需要是實體地址,上面87行說明了在真實模式下段式定址的過程,將變數的地址加上段地址才是實體地址。

第一次進入保護模式,為了解壓核心(記憶體管理:野蠻)

進入arch/x86/boot/compressed/head_32.S中的startup_32(),用於解壓剩餘的核心。

==================== arch/x86/boot/pm.c 121 125 ====================
121     /* Actual transition to protected mode... */
122     setup_idt();
123     setup_gdt();
124     protected_mode_jump(boot_params.hdr.code32_start,
125             (u32)&boot_params + (ds() << 4));

第二次進入保護模式(第二次設定gdtr)

由於整個vmlinux的編譯連結地址都是從線性地址0xc0000000開始的,有必要重新設定下段定址。這一次設定的原因是在之前的處理過程中,指令地址是從實體地址0x100000開始的,而此時整個vmlinux的編譯連結地址是從虛擬地址0xC0000000開始的,所以需要在這裡重新設定boot_gdt的位置。

=================== arch/x86/boot/compressed/head_32.S  ====================
2.    ENTRY(startup_32)
3.    movl pa(stack_start),%ecx       //棧開始的實體地址
4.
5.    /* test KEEP_SEGMENTS flag to see if the bootloader is asking
6.        us to not reload segments */
7.    testb $(1<<6), BP_loadflags(%esi)   //看是否需要設定保護模式環境
8.    jnz 2f
9.
10./*
11. * Set segments to known values.
12. */
13.    lgdt pa(boot_gdt_descr)         //設定gdtr
14.    movl $(__BOOT_DS),%eax          //設定各個段選擇子
15.    movl %eax,%ds
16.    movl %eax,%es
17.    movl %eax,%fs
18.    movl %eax,%gs
19.    movl %eax,%ss
20.2:
21.    leal -__PAGE_OFFSET(%ecx),%esp      //設定棧頂

小結

啟動過程中壓縮核心被搬移到64MB處,然後解壓到1MB開始的實體地址處,(在PC機上,前1MB物理空間包含BIOS資料和圖形卡,不能被核心使用)核心是從第二個MB開始的。假設Linux核心需要小於3MB的RAM,圖示了x86的前3MB實體地址分配。
x86前3MB記憶體分配
核心在編譯過程中都是使用的0xc0000000開始的線性地址,因此需要立即開啟分頁。

開啟第一次分頁(建立臨時核心頁表)

核心中編有一個初始頁全域性目錄initial_page_table,靜態分配好了空間。

662 ENTRY(initial_page_table)
663    .fill 1024,4,0  //填充一個頁面(4k)的空間

有了全域性目錄,還需要分配頁表空間。在連結vmlinux時,有一個BRK段,其開始地址為brk_base(有的版本稱為pg0變數,緊挨著_end)。這個段的作用是保留給使用者通過brk()系統呼叫向核心申請記憶體空間用的,我們先不管它的這個用處,在這個步驟中頁表空間就從brk_base分配。假設我們需要對映8MB空間,則只需要不超過2048個頁表項。
核心把頁目錄的0和768項指向同樣的頁表、1和769項指向同樣的頁表,使得線性地址0和0xc0000000指向同樣的實體地址,把其餘頁目錄項全部清零,使得其他地址都未對映。來看初始化後的頁表和頁目錄。
初始化後的頁表和頁目錄

設定好頁目錄和頁表項後,將initial_page_table的值賦給CR3,就開啟了頁式定址。

movl $pa(initial_page_table), %eax
movl %eax,%cr3      /* set the page table pointer.. */
movl $CR0_STATE,%eax
movl %eax,%cr0      /* ..and set paging (PG) bit */

由於在連結中initial_page_table是線性地址,需要經過pa()巨集轉化為實體地址後才能寫入CR3。

這些工作都完成後,就完成了將實體地址0x00000000到核心_end記憶體空間對映到線性地址0x00000000開始和0xc0000000開始的記憶體空間。這樣的話,用邏輯地址0x00000000或者0xc0000000類似的地址都能訪問到實體地址0x00000000開始的空間。

第三次開啟保護模式(第三次設定gdtr)

上面過程中分段模式一直開啟,但gdtr寫入的一直是實體地址,這時候需要將其重新配置為線性地址。

第二次設定分頁(第二次設定cr3)

start_arch()中把initial_page_table複製給swapper_pg_dir,在這以後swapper_pg_dir就一直當做全域性目錄表使用了。隨後設定cr3,棄用以前的initial_page_table。

873     /*
874      * copy kernel address range established so far and switch
875      * to the proper swapper page table
876      */
877     clone_pgd_range(swapper_pg_dir     + KERNEL_PGD_BOUNDARY,              
878             initial_page_table + KERNEL_PGD_BOUNDARY,
879             KERNEL_PGD_PTRS);
880     
881     load_cr3(swapper_pg_dir);

真正的記憶體管理

下面進入第一個真正的核心管理函式:setup_memory_map()

前面在真實模式的main函式中,曾經獲取過實體記憶體結構並將其存放在e820結構體陣列中,這裡利用結構體得到最大物理頁框號max_pfn,與MAXMEM_PFN進行比較取小賦給max_low_pfn,比較結果決定是否需要開啟高階記憶體,如果需要,二者取差得到highmem_pages表示高階記憶體總頁框數。高階記憶體的問題在後續介紹。

1. struct e820map {
2.  __u32 nr_map;
3.  struct e820entry map[E820_X_MAX];//E820MAX定義為128
4.};
5.
6.struct e820entry {
7.    __u64 addr;    /* start of memory segment */
8.    __u64 size;    /* size of memory segment */
9.    __u32 type;    /* type of memory segment */
10.} __attribute__((packed));

小結

我們來總結下現在核心的狀態。

  1. 已經設定了最終gdt,開啟了段式記憶體管理,段基址都是0,因此邏輯地址和線性地址相同。
  2. 已經設定了cr3,開啟了分頁式記憶體管理。最後的全域性目錄表是swapper_pg_dir,頁表還是在__brk_base開始。將實體地址0x00000000~_end的空間對映到虛擬地址(線性地址)0x00000000開始和0xc0000000開始(即全域性目錄同時從0項和767項分配)。因此由虛擬地址0xc0000000開始連結的核心可以正常定址到實體地址上。
  3. 核心通過int 0x15獲取實體記憶體佈局,並存在e820全域性陣列中。
    需要注意的是,這個時候核心基本不能動態分配管理記憶體,唯一動態分配記憶體的方式也僅僅是從brk中分配頁表。

著手建立最終核心頁表

注意這時候swapper_pg_dir裡只對映到了核心所在的空間(上面假設的8MB),這時候需要將所有物理空間都進行對映,即建立最終頁表。建立時分為三種情況:

  • 第一種:RAM小於896MB時,也就是沒有高階記憶體的情況。
    這種情況是把所有的RAM全部對映到0xc0000000開始。由核心頁表所提供的最終對映必須把從0xc0000000開始的線性地址轉化為從0開始的實體地址,主核心頁全域性目錄仍然儲存在swapper_pg_dir變數中。
  • 第二種:RAM在896MB到4GB之間,也就是存在高階記憶體的情況。
    這種情況就不能把所有的RAM全部對映到核心空間了,Linux在初始化階段只是把一個具有896MB的RAM對映到核心線性地址空間。如果一個程式需要對現有RAM的其餘部分定址,那就需要使用896MB~1GB的這段線性空間中的一部分來分時對映到所需的RAM,在下一講中詳述。這時候頁目錄初始化方法和上一種情況相同。
  • 第三種:RAM在4GB以上。
    現代計算機,特別是些高效能的伺服器記憶體遠遠超過4GB,這要求CPU支援實體地址擴充套件(PAE),且核心以PAE支援來編譯。這時儘管PAE處理36位實體地址,但是線性地址依然是32位。如前所述,Linux對映一個896MB的RAM到核心地址空間;剩餘RAM留著不對映,並由下一講中動態重對映來處理。所不同的是使用三級分頁模型。在這種情況下,即使我們的CPU支援PAE,但是也只能有定址能力為64GB的核心頁表,大量的高階記憶體使用動態重對映的方法,對效能產生了很大的挑戰。所以如果要建立更高效能的伺服器,建議改善動態重對映演算法,或者乾脆升級為64位的處理器。

既然要建立對映,按照從前建立臨時頁表的套路,應該要有個全域性目錄表,然後分配若干頁表並初始化完成對映。這裡還使用全域性目錄表swapper_pg_dir,呼叫init_memory_mapping ()函式完成低端記憶體的對映。

低端記憶體對映

它負責對映低端記憶體空也就是0-896MB。而這部分記憶體也分為兩個部分開始對映:

  • 首先對映0-ISA_END_ADDRESS(0x100000)的RAM
  • 接著對映0x100000~896mb的RAM

先重新確定記憶體範圍(對齊,分割、合併等)後,來到kernel_physical_mapping_init(),這個才是真正的建立頁表,設定頁表項,進行記憶體對映,注意,這裡沒有定義PAE。
這個函式的作用是將實體記憶體地址都對映到從核心空間開始的地址(0xc0000000),函式從虛擬地址0xc0000000處開始對映,也就是從目錄表中的768項開始設定。從768到1024這256個表項被linux核心設定為核心目錄項,低768個目錄項被使用者空間使用。接下來就是進入迴圈,準備填充從768號全域性目錄表項開始剩餘目錄項的內容。

注意這裡是按照Linux的四級分頁模型進行的,但上級目錄和中級目錄在32位系統中暫不使用。它們都是隻有一個數組元素的陣列。
這裡要繼續分配頁表,每個頁表4KB大小即佔一頁,這裡從已經對映的記憶體中分配一頁記憶體來作為頁表空間。–梯雲縱。

至此,核心低端記憶體頁表建立完畢。

896MB問題

按照固定偏移0xc0000000的方案,高於1GB的實體記憶體其線性地址將超出32位地址範圍。這是一個Linux發展史上的歷史問題,最早的核心設計者沒有預想到記憶體空間可以發展到1GB以上,因此核心就是為1GB實體記憶體設計的。

當出現大容量記憶體後,需要這個問題。另外核心還需要在其線性地址空間內對映外設暫存器空間等I/O空間,為了解決這個問題,在x86處理器平臺上給核心增添了一個經驗值設定:896MB,更高的128MB線性地址空間用於對映高階記憶體以及I/O地址空間。就是說,如果系統中的實體記憶體(包括記憶體孔洞)大於896MB,那麼將前896MB實體記憶體固定對映到核心邏輯地址空間0xC0000000~0xC0000000+896MB(=high_memory)上,而896MB之後的實體記憶體則不按照0xC0000000偏移建立對映,這部分記憶體就叫高階實體記憶體。此時核心線性地址空間high_memory~0xFFFFFFFF之間的128MB空間就稱為高階記憶體線性地址空間。

在嵌入式系統中可以根據具體情況修改896MB這個閾值。比如,MIPS中將這個值設定為0x20000000(512MB),那麼只有當系統中的實體記憶體空間容量大於512MB時,核心才需要配置CONFIG_HIGHMEM選項,使能核心對高階記憶體的分配和對映功能。

高階記憶體的對映方法在下一講中講述。程序對於高階實體記憶體天然地可以通過設定CR3和頁目錄表合法訪問。

固定記憶體對映中的高階記憶體對映

我們看到核心線性地址第四個GB的前最多896MB部分對映系統的實體記憶體。其餘至少128MB的線性地址總是留作他用,例如將IO和BIOS以及實體地址空間對映到在這128M的地址空間內,使得kernel能夠訪問該空間並進行相應的讀寫操作;還用來臨時對映高階記憶體,使得核心能夠訪問高階記憶體。

其中有一段虛擬地址用於固定對映,也就是fixed map。固定對映的線性地址(fix-mapped linear address)是一個固定的線性地址,它所對應的實體地址是人為強制指定的。每個固定的線性地址都對映到一塊實體記憶體頁。固定對映線性地址能夠對映到任何一頁實體記憶體。

每個固定對映的線性地址都由定義於enum fixed_addresses列舉資料結構中的整型索引來表示:

enum fixed_addresses {
    FIX_HOLE,
    FIX_VDSO,
    FIX_DBGP_BASE,
    FIX_EARLYCON_MEM_BASE,
#ifdef CONFIG_PROVIDE_OHCI1394_DMA_INIT
    FIX_OHCI1394_BASE,
#endif
#ifdef CONFIG_X86_LOCAL_APIC
    FIX_APIC_BASE,  /* local (CPU) APIC) -- required for SMP or not */
#endif
#ifdef CONFIG_X86_IO_APIC
    FIX_IO_APIC_BASE_0,
    FIX_IO_APIC_BASE_END = FIX_IO_APIC_BASE_0 + MAX_IO_APICS - 1,
#endif
#ifdef CONFIG_X86_VISWS_APIC
    FIX_CO_CPU, /* Cobalt timer */
    FIX_CO_APIC,    /* Cobalt APIC Redirection Table */
    FIX_LI_PCIA,    /* Lithium PCI Bridge A */
    FIX_LI_PCIB,    /* Lithium PCI Bridge B */
#endif
#ifdef CONFIG_X86_F00F_BUG
    FIX_F00F_IDT,   /* Virtual mapping for IDT */
#endif
#ifdef CONFIG_X86_CYCLONE_TIMER
    FIX_CYCLONE_TIMER, /*cyclone timer register*/
#endif
    FIX_KMAP_BEGIN, /* reserved pte's for temporary kernel mappings */
    FIX_KMAP_END = FIX_KMAP_BEGIN+(KM_TYPE_NR*NR_CPUS)-1,
#ifdef CONFIG_PCI_MMCONFIG
    FIX_PCIE_MCFG,
#endif
#ifdef CONFIG_PARAVIRT
    FIX_PARAVIRT_BOOTMAP,
#endif
    FIX_TEXT_POKE1, /* reserve 2 pages for text_poke() */
    FIX_TEXT_POKE0, /* first page is last, because allocation is backward */
    __end_of_permanent_fixed_addresses,
    /*
     * 256 temporary boot-time mappings, used by early_ioremap(),
     * before ioremap() is functional.
     *
     * If necessary we round it up to the next 256 pages boundary so
     * that we can have a single pgd entry and a single pte table:
     */
#define NR_FIX_BTMAPS       64
#define FIX_BTMAPS_SLOTS    4
#define TOTAL_FIX_BTMAPS    (NR_FIX_BTMAPS * FIX_BTMAPS_SLOTS)
    FIX_BTMAP_END =
     (__end_of_permanent_fixed_addresses ^
      (__end_of_permanent_fixed_addresses + TOTAL_FIX_BTMAPS - 1)) &
     -PTRS_PER_PTE
     ? __end_of_permanent_fixed_addresses + TOTAL_FIX_BTMAPS -
       (__end_of_permanent_fixed_addresses & (TOTAL_FIX_BTMAPS - 1))
     : __end_of_permanent_fixed_addresses,
    FIX_BTMAP_BEGIN = FIX_BTMAP_END + TOTAL_FIX_BTMAPS - 1,
    FIX_WP_TEST,
#ifdef CONFIG_INTEL_TXT
    FIX_TBOOT_BASE,
#endif
    __end_of_fixed_addresses
}; 

這些線性地址都位於第四個GB的末端。在初始化階段指定其對映的實體地址。

還是回到init_mem_mapping(void)裡面,當低端記憶體完成分配以後,緊接著還有一個函式early_ioremap_page_table_range_init(),這個函式就是用來建立固定記憶體對映區域的。

518 void __init early_ioremap_page_table_range_init(void)
519 {  
520     pgd_t *pgd_base = swapper_pg_dir;
521     unsigned long vaddr, end;
522    
523     /*
524      * Fixed mappings, only the page table structure has to be
525      * created - mappings will be set by set_fixmap():
526      */
527     vaddr = __fix_to_virt(__end_of_fixed_addresses - 1) & PMD_MASK;
528     end = (FIXADDR_TOP + PMD_SIZE - 1) & PMD_MASK;  //unsigned long __FIXADDR_TOP = 0xfffff000;
529     page_table_range_init(vaddr, end, pgd_base);
530     early_ioremap_reset();
531 }

fix_to_virt()函式計算從給定索引開始的常量線性地址:

========== arch/x86/mm/pgtable_32.c 98 98 ===============
unsigned long __FIXADDR_TOP = 0xfffff000;
============ arch/x86/include/asm/fixmap.h 41 41 ============ 
#define FIXADDR_TOP ((unsigned long)__FIXADDR_TOP)
==================== arch/x86/include/asm/fixmap.h 185 210 ====================
185 #define __fix_to_virt(x)    (FIXADDR_TOP - ((x) << PAGE_SHIFT))
195 static __always_inline unsigned long fix_to_virt(const unsigned int idx)
196 {
206 if (idx >= __end_of_fixed_addresses)
207     __this_fixmap_does_not_exist();
208
209 return __fix_to_virt(idx);
210 }

因此,每個固定對映的線性地址都對映一個實體記憶體的頁框。但是各列舉標識的分割槽並不是從低地址往高地址分佈,而是從整個線性地址空間的最後4KB即線性地址0xfffff000向低地址進行分配。在最後4KB空間與固定對映線性地址空間的頂端空留一頁(未知原因),固定對映線性地址空間前面的地址空間叫做vmalloc分配的區域,他們之間也空有一頁。
核心線性地址空間對映
下一講將詳細描述核心線性地址空間分佈圖。

有了這個固定對映的線性地址後,如何把一個實體地址與固定對映的線性地址關聯起來呢,核心使用set_fixmap(idx, phys)set_fixmap_nocache(idx, phys)巨集。這兩個函式都把fix_to_virt(idx)線性地址對應的一個頁表項初始化為實體地址phys(注意,頁目錄地址仍然在swapper_pg_dir中,這裡只需要設定頁表項);不過,第二個函式也把頁表項的PCD標誌置位,因此,當訪問這個頁框中的資料時禁用硬體快取記憶體反過來,clear_fixmap(idx)用來撤消固定對映線性地址idx和實體地址之間的連線。

這個固定地址對映到底拿來做什麼用呢?一般用來代替一些經常用到的指標。我們想想,就指標變數而言,固定對映的線性地址更有效。事實上,間接引用一個指標變數比間接引用一個立即常量地址要多一次記憶體訪問。比如,我們設定一個FIX_APIC_BASE指標,其所指物件之間存在於對應的實體記憶體中,我們通過set_fixmap和clear_fixmap建立好二者的關係以後,就可以直接定址了,沒有必要像指標那樣再去間接一次定址。

最後init_mem_mapping()呼叫load_cr3()重新整理CR3暫存器,__flush_tlb_all()則用於重新整理TLB,由此啟用新的記憶體分頁對映。

至此,核心頁表建立完畢。

因此對於核心來講其對映是個簡單的線性對映,只需要加減一個兩者間的偏移量0xC0000000即可。該值在程式碼中稱為PAGE_OFFSET,在arch/x86/include/asm/page_types.h中定義。同時PAGE_OFFSET也代表使用者空間的上限,所以常數TASK_SIZE就是用它定義的(arch/x86/include/asm/processor.h)。

從核心頁表建立過程可以看出,固定偏移是後續的基礎。如果沒有這個固定偏移,頁式定址就成為一個雞生蛋蛋生雞的問題了,因為程序切換時頁目錄表的基地址CR3是個實體地址,如果核心連這個實體地址也沒辦法計算,那麼就沒有辦法實現頁式定址。實際上核心確實是使用pa()巨集獲得CR3裡的值的。

程序的頁對映在程序啟動過程中確定,具體過程在第三講詳述。

Linux定址的加速

Linux核心使用了一些技術,提高了硬體快取記憶體和TLB的命中效率,實現定址過程的加速。

快取記憶體

快取記憶體按照快取行Cahce Line為單位定址,在Pentium4之前的Intel模型中CacheLine為32位元組,而Pentium 4上緩衝行為128位元組。為了使快取記憶體的命中率達到最優化,核心在決策中考慮體系結構:

  1. 一個數據結構中最常使用的欄位放在資料結構的低偏移部分,以便他們能處於快取記憶體的同一行。
  2. 當為一大組資料結構分配空間時,核心試圖把它們都存放在記憶體中,以便所有快取記憶體行都按照同一方式使用。

80x86的微處理器能夠自動處理快取記憶體的同步,但是有些處理器卻不能。Linux核心為這些不能同步快取記憶體的處理器提供了快取記憶體重新整理介面。

x86體系中當CR3替換時,處理器將所有TLB自動置無效。除此之外處理器不知道TLB快取是否有效,因此不能自動同步自己的TLB快取記憶體。需要由核心處理TLB的重新整理。Linux核心提供了多種獨立於體系結構的TLB巨集,針對不同體系結構選擇是否實現。

TLB

一般來講程序切換意味著要更換活動頁表集,對於過期頁表,核心把新的CR3寫入完成重新整理,但是為了效率期間,有些情況下Linux會避免TLB重新整理。
當兩個使用相同頁表集的普通程序之間切換時。後續程序管理章節講述schedule()函式時候詳述。
當一個普通程序和一個核心執行緒之間切換時。在之後第三講程序地址空間中將會講到,核心執行緒沒有自己的頁表集,它們使用剛剛在CPU上執行過的程序的頁表集。因為核心執行緒基本上只使用核心地址空間的資訊,這些資訊與程序地址空間的高1G線性空間那部分相同。

除了程序切換外,還有一些情況核心需要重新整理TLB中的一些表項。例如在核心為某個使用者態程序分配頁框並將它的實體地址存入頁表項時,它必須重新整理與相應線性地址對應的TLB表項。
為了避免無效重新整理,核心使用一種叫做懶惰TLB(Lazy TLB)模式的技術。其基本思想是,如果有多個CPU在使用相同的頁表,而且必須對這些CPU上的一個TLB表項進行重新整理,那麼在某些情況下正在執行核心執行緒的那些CPU上的重新整理就可以延遲。
當某個CPU正在執行一個核心執行緒時,核心把它設定為懶惰TLB模式,當發出清除TLB表項的請求時,並不立即清除,而是標記對應的程序地址空間無效。只要有一個懶惰TLB模式的CPU用一個不同的頁表集切換到一個普通程序,硬體就自動重新整理,同時把CPU置為非懶惰模式。然而如果切換到一個同樣頁表集的程序,那麼核心需要自己實施重新整理操作。

核心使用一些額外的資料結構實現懶惰TLB。這些資料結構的具體用法涉及到程序排程,不在這裡詳細描述。

==================== arch/x86/include/asm/tlbflush.h 148 154 ====================
148 #define TLBSTATE_OK 1
149 #define TLBSTATE_LAZY   2
150 
151 struct tlb_state {
152     struct mm_struct *active_mm;
153     int state;
154 };
==================== arch/x86/mm/tlb.c 16 17 ====================
16 DEFINE_PER_CPU_SHARED_ALIGNED(struct tlb_state, cpu_tlbstate)
17          = { &init_mm, 0, };

總結與預告

Intel在發展過程中為了相容過去的處理模式,採用了分段加分頁的定址實現方式,使得定址過程異常複雜。但是硬體上的一些設計使得這種冗餘模式保持了一定的效率。
Linux為了提供跨平臺支援,沒有跟隨x86的段式管理意圖,而是使得偏移量等於線性地址。完全依賴分頁機制。同時還提供了一些技術實現定址的加速。
Linux的分頁機制在初始化