地址映射的全過程
Linux 核心採用頁式儲存管理。虛擬位址空間劃分成固定大小的“頁面”,由 MMU 在運行時將虛擬位址“映射”成某個實體記憶體中的地址。與段式儲存管理相比,頁式儲存管理有很多好處。首先,頁面都是固定大小的,便於管理。更重要的是,當要將一部分物理空間的內容換出到磁碟上的時候,在段式儲存管理中要將整個段 ( 通常很大 ) 都換出,面在頁式儲存管理中則是按頁進行,效率顯然要高得多。由於 i386 系列的曆史演變過程,它對頁式儲存管理的支援是在其段式儲存管理已經存在了相當長的時間以後才發展起來的。所以,不管程式是怎樣寫的, i386 微處理器一律對程式中的地址先進行段式映射,然後才能進行頁式映射。而 Linux 所採用的方法實際上使段式映射的過程中不起什麼作用。
下面通過一個簡單的程式來看看 Linux 下的地址映射的全過程:
#include
greeting()
{
printf(“Hello world!
”);
}
main()
{
greeing();
}
該程式在主函數中調用 greeting 來顯示 “Hello world!” ,經過編譯和反組譯碼(% objdump -d hello),我們得到了它的反組譯碼的結果。
08048568:<greeting>:
8048568: 55 push1 %ebp
8048856b:89 e5 mov1 %esp,%ebp
804856b: 68 04 94 04 08 push1 $0x8048404
8048570: e8 ff fe ff ff call 8048474
8048575: 83 c4 04 add1 $0x4,%esp
8048578: c9 leave
8048579: c3 ret
804857a: 89 f6 mov1 %esi,%esi
0804857c :
804857c: 55 push1 %ebp
804857d: 89 e5 mov1 %esp,%ebp
804857f: e8 e4 ff ff ff call 8048568
8048584: c9 leave
8048585: c3 ret
8048586: 90 nop
8048587: 90 nop
從上面可以看出, greeting() 的地址為 0x8048568 。在 elf 格式的可執行代碼中,總是在 0x8000000 開始安排程式的 “ 程式碼片段” ,對每個程式都是這樣。
當程式在 main 中執行到了 “call 8048568” 這條指令,要轉移到虛擬位址 8048568 去。
首先是段式映射階段。地址 8048568 是一個程式的入口,更重要的是在執行的過程中有 CPU 的 EIP 所指向的,所以在程式碼片段中。I386cpu 使用 CS 的當前值作為段式映射的“選擇碼”,也就是用它作為在段描述表中的下標。哪一個段描述表呢》是全域段描述表GDT 還是局部段描述表 LDT ?那就要看 CS 中的內容了。
核心在建立一個進程時都要將其段寄存器設定好,有關代碼在 include/asm-i386/processor.h 中:
# define start_thread(regs, new_eip, new_dsp) do { \
__asm__(“movl %0,%%fs; movl %0,%%gs”: :”r” (0)); \
set_fs(user_DS);
regs->xds = __USER_DS;
regs->xes = __USER_DS;
regs->xss = __USER_DS;
regs->xcs = __USER_CS;
regs->eip = new_eip;
regs->esp = new_esp;
} while (0)
這裡把 DS 、 ES 、 SS 都設定成 _USER_DS, 而把 CS 設定成 _USER_CS, 這也就是說,雖然 Intel 的意圖是將一個進程的映象分成程式碼片段、資料區段和堆棧段,但在 Linux 核心中堆棧段和程式碼片段是不分的。
再來看看 USER_CS 和 USER_DS 是什麼。那是在 include/asm-i386/segment.h 中定義的:
Index TI DPL
#define_KERNEL_CS 0x10 0000 0000 0001 0|0|00
#define_KERNEL_DS 0x18 0000 0000 0001 1|0|00
#define_USER_CS 0x23 0000 0000 0010 0|0|11
#define_USER_DS 0x2B 0000 0000 0010 1|0|11
_KERNEL_CS: index=2,TI=0,DPL=0
_KERNEL_DS: index=3,TI=0,DPL=0
_USERL_CS: index=4,TI=0,DPL=3
_USERL_DS: index=5,TI=0,DPL=3
TI 全都是 0 ,都使用全域描述表。LDT在Linux中沒有使用,只有在Linux類比運行Windows軟體和DOS軟體時才會使用。核心的 DPL 都為 0 ,最進階別;使用者的 DPL 都是 3 ,最低層級。 _USER_CS 在 GDT 表中是第 4 項,初始化 GDT 內容是在 arch/i386/kernel/head.S 中定義的,其主要內容在運行中並不改變。代碼如下:
ENTRY(gdt-table)
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x0000000000000000 /* not used */
.quad 0x00cf9a00000ffff /* 0x10 kernel 4GB code at 0x00000000 */
.quad 0x00cf9200000ffff /* 0x18 kernel 4GB data at 0x00000000 */
.quad 0x00cffa00000ffff /* 0x23 user 4GB code at 0x00000000 */
.quad 0x00cff200000ffff /* 0x2b user 4GB data at 0x00000000 */
GDT 表中第一、二項不用,第三至第五項共四項對應於前面的四個段寄存器的數值。
將這四個段描述項的內容展開:
K_CS: 0000 0000 1100 1111 1001 1010 0000 0000
0000 0000 0000 0000 1111 1111 1111 1111
K_DS: 0000 0000 1100 1111 1001 0010 0000 0000
0000 0000 0000 0000 1111 1111 1111 1111
U_CS: 0000 0000 1100 1111 11111 1010 0000 0000
0000 0000 0000 0000 1111 1111 1111 1111
U_DS: 0000 0000 1100 1111 1111 0010 0000 0000
0000 0000 0000 0000 1111 1111 1111 1111
這四個段描述項的下列內容都是相同的。
·BO-B15/B16-B31 都是 0 基地址全為 0
·LO-L15 、 L16-L19 都是 1 段的上限全是 0xfffff
·G 位都是 1 段長均為 4KB
·D 位都是 1 32 位指令
·P 位都是 1 四個段都在記憶體中
結論:每個段都是從 0 地址開始的整個 4GB 虛存空間,虛地址到線性地址的映射保持原值不變。可以看到段基址相同,虛地址到線性地址的映射保持不變。段式映射機制把地址0x08048368映射到了其自身,作為線性地址。因此,討論或理解 Linux 核心的頁式映射時,可以直接將線性地址當作虛擬位址,二者完全一致。
不同之處在於權限等級不同,核心的為 0 級,使用者的為 3 級。另一個是段的類型,或為代碼,或為資料。這兩項都是 CPU 在映射過程中要加以檢查核對的。如果 DPL 為 0 級,而段寄存器 CS 中的 DPL 為 3 級,那就不允許了,因為那說明 CPU 的當前運行層級比想要訪問的區段要低。或者,如果段描述項說是資料區段,而程式中通過 CS 來訪問,那也不允許。實際上,這裡所作的檢查比對在頁式映射的過程中還要進行,所以既然用了頁式映射,這裡的檢查比對就是多餘的。
再回到 greeting 的程式中來,通過段式映射把地址 8048568 映射到自身,得到了線性地址。現在 8048568 是作為線性地址出現了,下面才進入頁式映射的過程。
與段式映射過程中所有的進程全都共用一個 GDT 不一樣,現在可是動真格的了,每個進程都有自身的頁目錄 PGD ,指向這個目錄的指標保持在每個進程的 mm_struct 資料結構中。每當調度一個進程進入運行時,核心都要為即將啟動並執行進程設定好控制寄存器 CR3 ,而MMU 硬體總是從 CR3 中取得當前進程的頁目錄指標。不過, CPU 在執行程式時使用的是虛存地址,而 MMU 硬體在進行映射時所使用的則是物理地址。這是在 inline 函數 switch_mm() 中完成的,其代碼見 include/asm-i386/mmu_context.h 。這裡關心的只是其中最關鍵的一行:
28 static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk, unsigned cpu)
29 {
……
44 asm volatitle(“movl %0,%%cr3”: :”r” ( __pa(next->pgd) ) );
……
59 }
這裡 __pa() 的用途是將下一個進程的頁面目錄 PGD 的物理地址裝入寄存器 %%cr3, 也即 CR3 。這時可能有疑問:這樣,在這一行以前和以後 CR3 的值不一樣,也就是使用不同的倒買倒賣目錄,不會使程式的執行不能連續了嗎?答案是,這是在核心中。不管什麼進程,一旦進入核心就進了系統空間,都有相同的頁面映射,所以不會出問題。
當程式要轉到地址 0x8048568 去的時候,進程正在運行中, CR3 已經設定好了,指向本進程的頁目錄了。
8048568 : 0000 1000 0000 0100 1000 0101 0110 1000
按照線性地址的格式,最高 10 位 0000100000 ,十進位的 32 ,所以 i386CPU( 確切地說是 CPU 中的 MMU ,下同 ) 就以下標32 去頁目錄表中找其頁目錄項。這個頁目錄項的高 20 位指向一個頁面表。 CPU 在這 20 位後面添上 12 個 0 就得到該頁面表的指標。前面講過,每個頁面表佔一個頁面,所以自然就是 4K 位元組邊界對齊的,其起始地址的低 12 位一定是 0. 正因如此,才可以把 32 位目錄項中的低 12 位挪著它用,其中的最低位為 P 標誌柆,為 1 時表示該頁面在記憶體中。
找到頁表後,再看線性地址的中間 10 位 001001000 ,十進位的 72 。就以 72 為下標在找到的頁表中找到相應的表項。與目錄項相似,當頁面表項的 P 標誌位為 1 時表示所映射的頁面在記憶體中。 32 位的頁面表項中的高指向一個實體記憶體頁面,在後邊添上 12 個 0 就得到了實體記憶體頁面的起始地址。所不同的是,這一次指向的不再是一個中間結構,而是映射的目標頁面了。在其起始地址上加上線性地址中的最低 12 位就得到了最終的實體記憶體地址。這時這個線性地址的最低 12 位為 0x568 。所以,如果目標頁面的起始地址為0x740000 的話 ( 具體取決於核心中的動態分配 ) ,那麼 greeting() 入口的物理地址就是 0x740568,greeting() 的執行代碼就儲存在這裡。
在頁式映射的過程中, CPU 要訪問記憶體三次,第一次是頁面目錄,第二次是頁面表,第三次才是真正要訪問的目標。這樣,把原來不用分頁機制一次訪問記憶體就能得到的目標,變為三次訪問記憶體才能得到,明顯執行分頁機制在效率上的犧牲太大了。
頁目錄與頁表
為了減少這種開銷,最近被執行過的地址轉換結果會被保留在 MMU 的轉換後備緩衝( TLB )中。雖然在第一次用到具體的頁面目錄和頁面表時要到記憶體中讀取,但一旦裝入了 TLB 中,就不需要再到記憶體中去讀取了,而且這些都是由硬體完成的,因此速度很快。
TLB 對應許可權大於 0 級的程式來說是不可見的,只有處於系統 0 層的程式才能對其進行操作。
當 CR3 的內容變化時, TLB 中的所有內容會被自動變為無效。 Linux 中的 _flush_tlb 宏就是利用這點工作的。 _flush_tlb 只是兩條彙編指令,把 CR3 的值儲存在臨時變數 tmpreg 裡,然後立刻把 tmpreg 的值拷貝回 CR3 ,這樣就將 TLB 中的全部內容置為無效。除了無效所有的 TLB 中的內容,還能有選擇的無效 TLB 中某條記錄,這就要用到 INVLPG 指令。