源码使用 Linux 2.6.24,基于 x86 平台;参考书是《深入理解 LINUX 内核》第三版
内核跟普通的应用一样,为了使用虚拟内存,也需要一个给 CPU 设置一个页表。在这篇文章中,我们就一起来了解 Linux 是如何为内核创建页表的。需要注意的是,这里我并不打算详细讲解页表的方方面面,硬件相关的基础知识,读者可以参考《深入理解LINUX内核》第3版第2章。本文的目的在于,作为该书的补充,基于真实的源码来讲解这一过程。
临时内核页表的构造 x86 系统刚刚启动时候运行在实模式下,这个时候线性地址就是物理地址。为了进入 32 位保护模式,首先就要启用分页(paging)。这就要求我们构建一个页表;这张页表把线性地址映射转换为物理地址。由于不同的计算机的配置不一样,他们需要的页表大小、页表个数也都不一样,所以需要在运行时动态分配页表,这就要求我们具有动态内存分配能力。
为了解决构造页表时候的鸡生蛋蛋生鸡问题,Linux 使用了一个临时的内核页表。它只有两个页表(这里的页表指的是用来索引页框的最后一级页表)。在不启用 PAE (Page Addression Extension) 和 PSE(Page Size Extension)的情况下,一个页表可以指向 10^2 = 1024
个内存页,一个内存页 4K,所以两个页表允许我们索引 8M 的内存。
顶层的页目录(page directory)使用全局变量 swapper_pg_dir
定义,下面是它的声明:
1 2 3 4 5 extern unsigned long empty_zero_page[1024 ];extern pgd_t swapper_pg_dir[1024 ];
他在 head_32.S
里面定义的:
1 2 3 4 5 6 7 8 9 10 11 12 13 # ${linux_source}/arch/x86/kernel/head_32.S /* * BSS section */ .section ".bss.page_aligned","wa" .align PAGE_SIZE_asm ENTRY(swapper_pg_dir) .fill 1024,4,0 ENTRY(swapper_pg_pmd) .fill 1024,4,0 ENTRY(empty_zero_page) .fill 4096,1,0
这里的 .fill 1024,4,0
的意思是用 0 填充 1024 个 4 byte 长度的内存(一个页目录项(page table entry)的大小是 32 bit)。
接下来是变量 pg0
:
1 2 3 4 extern unsigned long pg0[];
pg0
通过指示链接器,放在了 bss 段的后面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 SECTIONS { /* 前面那些都略去了 */ .bss : AT(ADDR(.bss) - LOAD_OFFSET) { __init_end = .; __bss_start = .; /* BSS */ *(.bss.page_aligned) *(.bss) . = ALIGN(4); __bss_stop = .; _end = . ; /* This is where the kernel creates the early boot page tables */ . = ALIGN(4096); pg0 = . ; } /* ... */ }
有了 swapper_pg_dir
和 pg0
后,接下来的工作就是对它们进行初始化。此时还处于实模式下,这部分工作是由汇编代码完成的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 # ${linux_source}/arch/x86/kernel/head_32.S /* * Initialize page tables. This creates a PDE and a set of page * tables, which are located immediately beyond _end. The variable * init_pg_tables_end is set up to point to the first "safe" location. * Mappings are created both at virtual address 0 (identity mapping) * and PAGE_OFFSET for up to _end+sizeof(page tables)+INIT_MAP_BEYOND_END. * * Warning: don't use %esi or the stack in this code. However, %esp * can be used as a GPR if you really need it... */ # __PAGE_OFFSET 是 0xc000 0000,所以 page_pde_offset 是 0xc00 page_pde_offset = (__PAGE_OFFSET >> 20); default_entry: # __PAGE_OFFSET 是 3G,pg0 是虚拟地址,减去 __PAGE_OFFSET 后就得到了 # pg0 的物理地址。我们把 pg0 的物理地址放在了 edi 寄存器里 movl $(pg0 - __PAGE_OFFSET), %edi # 同理,这里把 swapper_pg_dir 的物理地址放在 edx movl $(swapper_pg_dir - __PAGE_OFFSET), %edx # page directory/table entry 的低 12 位都是一些标志物,各个位代表的含义 # 读者可以参考 https://wiki.osdev.org/Paging 或者书中的第 52 页 movl $0x007, %eax /* 0x007 = PRESENT+RW+USER */ 10: # 下面这两行代码对熟悉 C 语言的读者可能会造成一定的困扰。如果从 C 语言的角度 # 来看,它们是把地址 &pg0 + 7 放到了 swapper_pg_dir 的第一项;但问题在于, # 为什么要 +7? # 其实这里的 7 和前面那个 7 一样,指的是页目录项的标志物 PRESENT+RW+USER, # pg0 的地址是 4K 对齐的,这意味着他的地址的低 12 位都为 0,加上 7 以后,刚 # 好就是我们所需要的页目录项的值。 leal 0x007(%edi),%ecx /* Create PDE entry */ movl %ecx,(%edx) /* Store identity PDE entry */ # 书里有说明,我们要把 0x0000 0000 ~ 0x007f ffff 和 0xc000 0000 ~ 0xc07f ffff # 都映射到物理地址 0x0000 0000 ~ 0x007f ffff,下面这一行设置的 0xc000 0000 # 对应的页目录项。 # 这里的问题在于,按照书里的说明,我们应该设置的是第 0x300 项,这里是加上的却是 0xc00。 # 这里需要提一下平时用 C 语言时编译器帮我们做的事。当我们写下 int *p = NULL; p+2 # 的时候,编译器知道 int 是 4 个字节,所以 p+2 会汇编代码里面是 +8。 # 一个 PDE 也是 32 位,所以真正的偏移量是 0x300 << 2 = 0xc00 movl %ecx,page_pde_offset(%edx) /* Store kernel PDE entry */ # edx + 4 以后,就是下一个页目录项了,下个循环将会继续初始化(一共两个页目录项) addl $4,%edx # 一个页表有 1024 个页表项,这里初始化一个在接下来的循环里面用到的计数器 movl $1024, %ecx 11: # stosl 把 %eax 的内容复制到物理地址 ES:EDI,也就是 pg0 处;并且 %edi + 4 stosl # 加上 0x1000 后,%eax 指向下一个页 addl $0x1000,%eax # %ecx -= 1,如果 %ecx 不为 0,跳转到 11 处。这里总共会循环 1024 次,初始化 1024 个页表项。 loop 11b /* End condition: we must map up to and including INIT_MAP_BEYOND_END */ /* bytes beyond the end of our own page tables; the +0x007 is the attribute bits */ leal (INIT_MAP_BEYOND_END+0x007)(%edi),%ebp cmpl %ebp,%eax jb 10b # 到这里的时候,%edi 的值是我们映射的最后一个页表项的地址,这里我们把它存到变量 # init_pg_tables_end 里。init_pg_tables_end 在 setup_32.c 里定义 movl %edi,(init_pg_tables_end - __PAGE_OFFSET) # 下面是固定映射的,这部分就先不看了 /* Do an early initialization of the fixmap area */ movl $(swapper_pg_dir - __PAGE_OFFSET), %edx movl $(swapper_pg_pmd - __PAGE_OFFSET), %eax addl $0x67, %eax /* 0x67 == _PAGE_TABLE */ movl %eax, 4092(%edx) xorl %ebx,%ebx /* This is the boot CPU (BSP) */ jmp 3f
前面代码的最后一行是一个 jmp 3f
,下面,我们就看看这个 3
处的代码。
启用分页 构建好临时内核页表后,接下来就该启用分页了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 # ${linux_source}/arch/x86/kernel/head_32.S 3: /* * Enable paging */ movl $swapper_pg_dir-__PAGE_OFFSET,%eax # %cr3 寄存器存放的是页表的地址 movl %eax,%cr3 /* set the page table pointer.. */ movl %cr0,%eax # cr0 的最高位是 Paging 位,置 1 后启用分页 # 关于 cr0,参考 https://en.wikipedia.org/wiki/Control_register#CR0 orl $0x80000000,%eax movl %eax,%cr0 /* ..and set paging (PG) bit */
CPU 的分页机制现在已经启用了,但是我们的页表还是不完整的,剩下部分将会使用 C 语言来完成。
构建线性地址的内核页表 完整的页表构建是从函数 pagetable_init
开始的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 static void __init pagetable_init (void ) { unsigned long vaddr, end; pgd_t *pgd_base = swapper_pg_dir; if (cpu_has_pse) set_in_cr4(X86_CR4_PSE); if (cpu_has_pge) { set_in_cr4(X86_CR4_PGE); __PAGE_KERNEL |= _PAGE_GLOBAL; __PAGE_KERNEL_EXEC |= _PAGE_GLOBAL; } kernel_physical_mapping_init(pgd_base); }
实际的页表构建是在函数 kernel_physical_mapping_init
完成的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 static void __init kernel_physical_mapping_init (pgd_t *pgd_base) { unsigned long pfn; pgd_t *pgd; pmd_t *pmd; pte_t *pte; int pgd_idx, pmd_idx, pte_ofs; pgd_idx = pgd_index(PAGE_OFFSET); pgd = pgd_base + pgd_idx; pfn = 0 ; for (; pgd_idx < PTRS_PER_PGD; pgd++, pgd_idx++) { pmd = one_md_table_init(pgd); if (pfn >= max_low_pfn) continue ; for (pmd_idx = 0 ; pmd_idx < PTRS_PER_PMD && pfn < max_low_pfn; pmd++, pmd_idx++) { unsigned int address = pfn * PAGE_SIZE + PAGE_OFFSET; if (cpu_has_pse) { unsigned int address2 = (pfn + PTRS_PER_PTE - 1 ) * PAGE_SIZE + PAGE_OFFSET + PAGE_SIZE-1 ; if (is_kernel_text(address) || is_kernel_text(address2)) set_pmd(pmd, pfn_pmd(pfn, PAGE_KERNEL_LARGE_EXEC)); else set_pmd(pmd, pfn_pmd(pfn, PAGE_KERNEL_LARGE)); pfn += PTRS_PER_PTE; } else { pte = one_page_table_init(pmd); for (pte_ofs = 0 ; pte_ofs < PTRS_PER_PTE && pfn < max_low_pfn; pte++, pfn++, pte_ofs++, address += PAGE_SIZE) { if (is_kernel_text(address)) set_pte(pte, pfn_pte(pfn, PAGE_KERNEL_EXEC)); else set_pte(pte, pfn_pte(pfn, PAGE_KERNEL)); } } } } } static pmd_t * __init one_md_table_init (pgd_t *pgd) { pud_t *pud; pmd_t *pmd_table; #ifdef CONFIG_X86_PAE if (!(pgd_val(*pgd) & _PAGE_PRESENT)) { pmd_table = (pmd_t *) alloc_bootmem_low_pages(PAGE_SIZE); paravirt_alloc_pd(__pa(pmd_table) >> PAGE_SHIFT); set_pgd(pgd, __pgd(__pa(pmd_table) | _PAGE_PRESENT)); pud = pud_offset(pgd, 0 ); if (pmd_table != pmd_offset(pud, 0 )) BUG(); } #endif pud = pud_offset(pgd, 0 ); pmd_table = pmd_offset(pud, 0 ); return pmd_table; } static pte_t * __init one_page_table_init (pmd_t *pmd) { if (!(pmd_val(*pmd) & _PAGE_PRESENT)) { pte_t *page_table = NULL ; #ifdef CONFIG_DEBUG_PAGEALLOC page_table = (pte_t *) alloc_bootmem_pages(PAGE_SIZE); #endif if (!page_table) page_table = (pte_t *)alloc_bootmem_low_pages(PAGE_SIZE); paravirt_alloc_pt(&init_mm, __pa(page_table) >> PAGE_SHIFT); set_pmd(pmd, __pmd(__pa(page_table) | _PAGE_TABLE)); BUG_ON(page_table != pte_offset_kernel(pmd, 0 )); } return pte_offset_kernel(pmd, 0 ); }
这部分代码其实有 4 中情况:有 PAE 和没有 PAE两种,这两种又分别有 PSE 启不启用两种情况。读者可以分情况一个一个看,分情况弄清楚后,再合并一起看。
固定映射的线性地址、非连续内存区的线性地址 处于篇幅和学习目的考虑,固定映射、非连续内存的处理在这里就先略去了,以后有机会再单独开一篇文章补上。内核页表的创建相关的代码我们就先看到这里。