本文簡要介紹X86-32架構下的Linux kernel被boot loader(如grub)載入到記憶體後,如何從最初的實模式,切換到保護模式,並開啟分頁機制。本文不涉及boot loader如何將核心載入到記憶體,因為這是boot loader的事,跟核心自己無關(當然他們之間一定要有種事先約定的協議來溝通)。因為啟動代碼並不經常變化,所以對這部分的分析基本適用於較早的2.6.24至現在的3.0.4版本。為了簡化起見,我們主要關注不啟動PAE機制的一般情況。看這篇文章前,先確定你對實模式,保護模式及分頁機制的基本原理有瞭解。
先來看boot loader將核心載入到什麼位置。很形象的解釋了核心在實體記憶體中的位置。
總的來說,核心分為兩部分,arch/x86/boot目錄下的代碼被載入到實體記憶體的第一個1M空間內,即X至X + 0x8000處,這部分代碼稱為setup代碼,它是16位實模式代碼。另一部分,也是核心的主要部分被載入到實體記憶體的第一個1M空間之後,在核心完全啟動前,這部分代碼還是被壓縮的。有一點需要注意的是,載入到第一個1M空間之後的核心代碼的位移地址在連結過程中被指定為起始於0xc0100000。
核心從x86/boot/header.S檔案中的_start標號開始運行,此時CPU處於實模式。我們可以注意到,在_start標號以前還有一段代碼,位於bootsect_start標號和_start標號之間,總長度剛好為512位元組,而且最後兩位元組為0xAA55,這段代碼就是經典的開機磁區代碼。不過現代Linux核心不再支援從開機磁區開始運行,如果你研讀這段代碼你可以發現它只是列印了bugger_off_msg處的字串,該字串提示使用者:“不再支援直接從磁碟片啟動,請安裝boot loader"。
位於_start標號處的第一條指令是一條直接用2進位機器碼編寫的段內跳轉指令。
112 .byte 0xeb # short (2-byte) jump
113 .byte start_of_setup-1f
114 1:
這條指令會跳轉到後面的start_of_setup標號處,我們會看到這兩個標號之間有一大串變數,這些變數是用於核心的載入以及初始化過程的,它們的值有的由編譯核心過程中build.c工具程式寫入,有的由boot loader在載入核心時寫入,這些就是前面說的boot loader與核心之間溝通的協議。我們忽略這些值的初始化過程,並預設核心被成功載入後這些值都是可用的。
從start_of_setup標號開始,核心進行了一些簡單的初始化,如劃分堆棧(stack)和堆(heap)空間,我們來看位於末端的幾行代碼。
292 # Zero the bss
293 movw $__bss_start, %di
294 movw $_end+3, %cx
295 xorl %eax, %eax
296 subw %di, %cx
297 shrw $2, %cx
298 rep; stosl
299
300 # Jump to C code (should not return)
301 calll main
292-298行代碼用於將.bbs段初始化為0。我們知道,在普通應用程式開發的概念中,來源程式中未初始化的全域變數被放在.bss段,這個段中的資料會在程式被載入器(loader,注意,這裡不是指前面說的boot loader,而是用來將普通應用程式載入入作業系統中啟動並執行載入器)載入記憶體時初始化為0,可是我們現在研究的是作業系統核心代碼,這個時候連作業系統都沒啟動,更不用說載入器了,所以我們需要在這裡自己初始化.bss資料。這裡的兩個__bss_start和_end標號並不是定義在代碼中,而是定義在x86/boot/setup.ld的串連指令碼中。
300行的call指令使核心跳轉到x86/boot/main.c中的void main(void)函數。不錯,我們進入了C代碼,不過這裡還是以實模式運行C代碼。main函數中還調用了其它一些初始化函數,我們重點關注main函數調用的最後一個函數go_to_protected_mode(),顧名思義,此函數使核心切換到保護模式。該函數位於x86/boot/pm.c檔案中,它在調用enable_a20()開啟A20地址線後,依次調用了如下三個函數:
122 setup_idt();
123 setup_gdt();
124 protected_mode_jump(boot_params.hdr.code32_start,
125 (u32)&boot_params + (ds() << 4));
這三個函數依次構建IDT表,GDT表,以及切換到保護模式。下面是setup_gdt()函數:
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 }
此段代碼很簡單,它初始化了一個boot_gdt數組,也就是傳說中的GDT表,然後初始化了一個gdt變數,該變數指定了GDT表的物理地址與長度,最後通過彙編指令lgdtl將GDT表地址與長度載入到GDTR寄存器。顯然,這時核心只定義了三個段,一個程式碼片段,一個資料區段,一個TSS段。程式碼片段和資料區段都被定義為從0x00000000開始的4G線性地址空間。
有個細節需要注意,在87行指定GDT表物理地址時加上了”額外“的ds()<<4,這是因為到目前為止,核心依然運行在實模式,CPU還是通過”段基址*16+位移地址“來定址,&boot_gdt返回的是boot_gdt數組頭在目前資料區段的位移,在加上"ds()<<4"後,得到boot_gdt數組頭的線性地址,而且,由於沒有開啟分頁機制,線性地址等於物理地址。
在GDT表初始化完成後,核心調用protected_mode_jump()函數切換到保護模式,這個函數位於x86/boot/pmjump.S中,不錯,這又是一段彙編代碼,此後一段時間內,核心都會在彙編代碼與C代碼直接來回跳轉。代碼如下:
26 GLOBAL(protected_mode_jump)
27 movl %edx, %esi # Pointer to boot_params table
28
29 xorl %ebx, %ebx
30 movw %cs, %bx
31 shll $4, %ebx
32 addl %ebx, 2f
33 jmp 1f # Short jump to serialize on 386/486
34 1:
35
36 movw $__BOOT_DS, %cx
37 movw $__BOOT_TSS, %di
38
39 movl %cr0, %edx
40 orb $X86_CR0_PE, %dl # Protected mode
41 movl %edx, %cr0
42
43 # Transition to 32-bit mode
44 .byte 0x66, 0xea # ljmpl opcode
45 2: .long in_pm32 # offset
46 .word __BOOT_CS # segment
47 ENDPROC(protected_mode_jump)
在分析代碼前先介紹下這裡彙編調用C語言函數時的參數傳遞機制,我們知道普通的函數調用規範(std,cdecl)採用堆棧傳遞參數,即從右往坐依次將參數壓入堆棧。而在這裡,核心中arch/boot下的代碼採用另外一種規範(fastcall):三個及三個以內的參數從左往右依次通過EAX,EDX,ECX寄存器傳遞參數,多於三個的參數通過堆棧傳遞。
這樣,結合go_to_protected_mode()函數中的代碼,我們知道在protected_mode_jump()函數中,%eax寄存器中的值為boot_params.hdr.code32_start,%edx中的值為&boot_params+ds()<<4。這裡比較重要的是boot_params.hdr.code32_start參數,該參數就是前面提到的被壓縮的部分核心代碼的起始地址。心細的讀者可能猜到,該boot_params.hdr.code32_start就是在x86/boot/header.S中的code32_start變數,對,前面提到的main函數會將boot/header.S中從hdr標號處開始的變數拷貝的boot_params.hdr中,不過這個值不再是0,因為在核心被boot
loader載入入記憶體後,boot_loader會將該值修改為被壓縮核心真正所在的記憶體位置。
進入protected_mode_jump()函數後,29-32行代碼做了一件非常”詭異“的事,它將%cs寄存器的值左移4位後加到位於標號2的變數處,而該變數之前儲存的的in_pm32標號的位移,這樣,在加上%cs<<4後,該處的變數就變成了in_pm32的線性地址了。這段代碼的作用在後面顯現。先看39-41行的代碼,該代碼設定了%cr0寄存器中的PE標誌,即開啟了保護模式的標誌。
隨即,高潮來了,44-46行跟前面boot/header.S中的_start標號處的跳轉指令一樣,也是直接用2進位編寫的跳轉指令,不過這裡的是段間跳轉指令,45行的變數指定的是目的地址的位移,由於29-32行代碼的作用,該位移等於in_pm32標號在實模式下的線性地址,事實上,我們知道線性地址可以看做段基址為0的位移地址,bingo!,根據前面初始化的GDT表,46行的__BOOT_CS選擇符非常”巧合“的指定目的地址的段基址正好是0,所以此條段間跳轉指令”碰巧“跳轉到in_pm32標號處!所以,從現在開始核心開始在保護模式下運行,並跳轉到in_pm32處。
in_pm32中最後的指令為:
76 jmpl *%eax # Jump to the 32-bit entrypoint
該指令使核心跳轉到%eax指定的代碼處,該代碼位於x86/boot/compressed/head_32.S中的startup_32標號處。該程式碼片段的主要作用是將記憶體中的被壓縮核心解壓縮,然後跳轉到x86/kernel/head_32.S中的startup_32標號處。不錯,有兩個startup_32,不過這兩個標號並不衝突,因為這兩部分代碼並不是同時連結。在這段代碼中,核心會建立一個臨時核心頁表(provisional kernel page tables),並開啟分頁機制。
202page_pde_offset = (__PAGE_OFFSET >> 20);
203
204 movl $pa(__brk_base), %edi
205 movl $pa(initial_page_table), %edx
206 movl $PTE_IDENT_ATTR, %eax
207 10:
208 leal PDE_IDENT_ATTR(%edi),%ecx /* Create PDE entry */
209 movl %ecx,(%edx) /* Store identity PDE entry */
210 movl %ecx,page_pde_offset(%edx) /* Store kernel PDE entry */
211 addl $4,%edx
212 movl $1024, %ecx
213 11:
214 stosl
215 addl $0x1000,%eax
216 loop 11b
217 /*
218 * End condition: we must map up to the end + MAPPING_BEYOND_END.
219 */
220 movl $pa(_end) + MAPPING_BEYOND_END + PTE_IDENT_ATTR, %ebp
221 cmpl %ebp,%eax
222 jb 10b
223 addl $__PAGE_OFFSET, %edi
224 movl %edi, pa(_brk_end)
225 shrl $12, %eax
226 movl %eax, pa(max_pfn_mapped)
此代碼構造的頁表將分別映射從線性地址0x00000000和__PAGE_OFFSET開始的線性空間,並將這兩部分線性空間映射到同一物理空間。先介紹代碼中關鍵宏和標號的定義。
1)宏__PAGE_OFFSET預設定義為0xc0000000,從__PAGE_OFFSET開始的線性地址空間即為核心空間。
2)202行的宏page_pde_offset定義為虛擬位址__PAGE_OFFSET對應的page directory entry,這樣看可能更清楚些:page_pde_offset = (((__PAGE_OFFSET) >> (10 + 12)) << 2)。
3)__brk_base標號為頁表的起始地址,跟前面提到的__bss_start類似,該標號由kernel/vmlinux.lds連結指令碼定義。
4)inital_page_table標號定義在kernel/head_32.S檔案的後面,後面預留了4K空間,它被用作整個臨時頁表的頁目錄表(page directory table)。
5)宏PTE_IDENT_ATTR和PDE_IDENT_ATTR分別為頁表項和頁目錄項中的頁面屬性。
6)宏pa()就是著名的核心空間線性地址到物理地址的轉換函式,實際上等同於以下定義:#define pa(X) ((x) - __PAGE_OFFSET)。當然還有一個相反的轉換函式。
此宏的作用是讓該部分核心代碼在開啟分頁前能正確訪問記憶體。前面提到,這部分核心被載入到實體記憶體第一個1M空間之後,但它的位移地址在連結過程中被指定為起始於0xc0100000。所以在開啟分頁前,核心代碼訪問記憶體需要將各個標號地址減去0xc00000000。實際上,我們可以將pa()函數看作一個簡單的分頁機制。
7)MAPPING_BEYOND_END + pa(_end)為構造的頁表所要映射的物理空間的結束位址。
下面是代碼的詳細分析。
204-205行,將%edi初始化為第一張頁表的物理地址,將%edx初始化為頁目錄表中第一個頁目錄項的物理地址。
208行,在%ecx中產生中構造一個頁目錄項,其中頁表地址欄位為%edi的值,屬性欄位為PDE_IDENT_ATTR
209行,將%ecx中的頁目錄項填充到%edx指向的的頁目錄項中,顯然這是從線性地址0x00000000開始映射實體記憶體。
210行,將同樣的%ecx中的頁目錄項填充到(page_pde_offset + %edx)指向的頁目錄項中,顯然這是從線性地址__PAGE_OFFSET開始映射實體記憶體,可見兩部分線性空間映射到同一物理空間。
211行,將%edx加4,即將其指向下一個頁目錄項。
212-216行,此迴圈填充%edi指向的頁表。206行將%eax初始化為頁表項屬性值PTE_IDENT_ATTR。212行指定此迴圈迭代1024次。215行則在每次迭代填充一個頁表項後將%eax中頁表項的物理頁地址欄位加0x1000,顯然此迴圈由低地址到高地址依次映射物理頁。此迴圈結束後%edi剛好為下一張頁表的物理地址。
220-222行,如果填充的最後一個頁表項小於$pa(_end) + MAPPING_BEYOND_END + PTE _IDENT_ATTR,則回到10標號處再構建一張頁表,否則映射結束。
223-224行,將_brk_end標號處的變數設定為整個頁表的末端線性地址。
225-226行,將max_pfn_mapped標號處的變數設定為此頁表所映射的物理頁總數。
到這裡,臨時頁表就構造完畢了,核心之後會構造一個最終的頁表(final kernel page tables),此頁表在x86/mm/init_32.c中的kernel_physical_mapping_init()函數中構造。主要代碼如下,刪除了部分我們不關注的代碼。
279repeat:
280 pages_2m = pages_4k = 0;
281 pfn = start_pfn;
282 pgd_idx = pgd_index((pfn<<PAGE_SHIFT) + PAGE_OFFSET);
283 pgd = pgd_base + pgd_idx;
284 for (; pgd_idx < PTRS_PER_PGD; pgd++, pgd_idx++) {
285 pmd = one_md_table_init(pgd);
286
287 if (pfn >= end_pfn)
288 continue;
293 pmd_idx = 0;
295 for (; pmd_idx < PTRS_PER_PMD && pfn < end_pfn;
296 pmd++, pmd_idx++) {
297 unsigned int addr = pfn * PAGE_SIZE + PAGE_OFFSET;
298
330 pte = one_page_table_init(pmd);
331
332 pte_ofs = pte_index((pfn<<PAGE_SHIFT) + PAGE_OFFSET);
333 pte += pte_ofs;
334 for (; pte_ofs < PTRS_PER_PTE && pfn < end_pfn;
335 pte++, pfn++, pte_ofs++, addr += PAGE_SIZE) {
336 pgprot_t prot = PAGE_KERNEL;
337 /*
338 * first pass will use the same initial
339 * identity mapping attribute.
340 */
341 pgprot_t init_prot = __pgprot(PTE_IDENT_ATTR);
342
343 if (is_kernel_text(addr))
344 prot = PAGE_KERNEL_EXEC;
345
346 pages_4k++;
347 if (mapping_iter == 1) {
348 set_pte(pte, pfn_pte(pfn, init_prot));
349 last_map_addr = (pfn << PAGE_SHIFT) + PAGE_SIZE;
350 } else
351 set_pte(pte, pfn_pte(pfn, prot));
352 }
353 }
354 }
355 if (mapping_iter == 1) {
363 /*
364 * local global flush tlb, which will flush the previous
365 * mappings present in both small and large page TLB's.
366 */
367 __flush_tlb_all();
368
369 /*
370 * Second iteration will set the actual desired PTE attributes.
371 */
372 mapping_iter = 2;
373 goto repeat;
374 }
Linux將分頁機制抽象為四層模型,PGD(page global directory),PUD(page upper directory),PMD(page middle directory)和PT(page table)。32位架構核心在不開啟PAE情況下只需要兩層模型,PGD和PT。此時,PUD和PMD被摺疊(folded),即PGD,PUD和PMD三者相同。在詳細解釋代碼前,先介紹幾個關鍵宏和函數。
1)宏pgd_index(vaddr)返回線性地址vaddr對應的pgd目錄項的地址。
2)函數one_md_table_init(pgd)建立並返回pgd目錄項對應的下一級PMD表地址,在32位架構核心中,此函數只是簡單返回pgd,即PMD表被摺疊入PGD表。
3)函數one_page_table_init(pmd)建立並返回pmd目錄項對應的下一級PT表地址。
4)函數set_pmd(pmd, pmde)將pmd目錄項填充為pmde。
5)函數set_pte(pte, ptee)將pte表項填充為ptee。
6)宏PTRS_PER_PGD,PTRS_PER_PMD和PTRS_PER_PTE分別對應PGD表,PMD表和PT表中的項數。在32位核心架構下這三個宏分別定義為1024,1,1024。
此段代碼將從第pfn號物理頁開始的物理空間映射到從線性地址__PAGE_OFFSET開始的線性空間。頁表構造過程經過兩次迭代,兩次迭代除了填充的頁表項的屬性欄位不同,其它都相同。
281-283行將pgd_idx初始化第pfn號物理頁對應的PGD目錄項指標。
285行本是建立並返回pgd目錄項對應的PMD表pmd,不過在32位架構下,此函數只是簡單返回pgd,即pmd等於pgd。
295行的迴圈在32位架構下只迭代一次。
330行建立並返回pmd目錄項對應的PT表pte。
332-333行將pte設定為第pfn號物理頁對應的PT表項。
334行開始的迴圈則依次填充pte及其之後的PT表項。