Linux kernel boot process——从实模式(real mode)到保护模式(protected mode),再到分页(paging)(转)

        本文简要介绍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表项。

69人参与, 0条评论 登录后显示评论回复

你需要登录后才能评论 登录/ 注册