本文采用 Linux 内核 v3.10 版本 x86_64架构
一、前置知识
1.1 x86_64 内存分页理论基础
x86_64 架构支持 4 级及 5 级分页。在 Intel 手册上,对于分页结构的命名,由高到低分别是:5 级页映射表(Page Map Level 5,PML5)table、4 级页映射表(Page Map Level 4,PML4)、页目录指针表(Page-Directory-Pointer Table,PDPT)、页目录(Page Directory,PD)以及页表(Page Table,PT)。对应的页结构项(Paging Structure Entry),分别为:PML5E、PML4E、PDPTE、PDE、PTE。
4 级分页及 5 级分页下,可以把线性地址映射到 4KB 、2MB 或者 1GB 大小的页。
在 4 级分页下,页大小为 4KB 时,线性地址的组成及地址转换如下图所示:
在 4 级分页下,页大小为 2MB 时,线性地址的组成及地址转换如下图所示:
之所以要提到 2MB 的页,是因为在构建早期页表时,使用的就是 2MB 的页。在本文随后的小节中,会看到内核是如何构建早期页表的。
更多 x86_64 内存分页相关内容,请参考 x86-64架构:内存分页机制。
1.2 Linux 内核中的分页
在 Linux 内核中,各层级的页结构有了不同名称。 4 级分页下,它们的名称分别为全局页目录(Page Global Directory,PGD)、上层页目录(Page Upper Directory,PUD)、中层页目录(Page Middle Directory,PMD)、页表(Page Table,PT)。
针对各层级页结构的偏移位数及页结构中项的数量,内核也定义了常量:
c
// file: arch/x86/include/asm/pgtable_64_types.h
#define PAGETABLE_LEVELS 4 // 页表层数
/*
* PGDIR_SHIFT determines what a top-level page table entry can map
*/
#define PGDIR_SHIFT 39 // 全局页目录偏移位数
#define PTRS_PER_PGD 512 // 每个全局页目录中包含的指针(项)数
/*
* 3rd level page
*/
#define PUD_SHIFT 30 // 上层页目录偏移位数
#define PTRS_PER_PUD 512 // 每个上层页目录中包含的指针(项)数量
/*
* PMD_SHIFT determines the size of the area a middle-level
* page table can map
*/
#define PMD_SHIFT 21 // 中层页目录偏移位数
#define PTRS_PER_PMD 512 // 每个中层页目录中包含的指针(项)数量
/*
* entries per page directory level
*/
#define PTRS_PER_PTE 512 // 每个页表中包含的指针(项)数量
c
// arch/x86/include/asm/page_types.h
#define PAGE_SHIFT 12 // 页表偏移位数
Linux 内核中的线性地址组成如下(4KB 页):
另外,我们也看到了,每种页结构包含 512 个项(entry);#define PAGETABLE_LEVELS 4
说明使用的是 4 级页表。
内核为每种页结构项定义了单独的类型:
c
// file: arch/x86/include/asm/pgtable_64_types.h
typedef struct { pteval_t pte; } pte_t;
c
// file: arch/x86/include/asm/pgtable_types.h
typedef struct { pgdval_t pgd; } pgd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { pmdval_t pmd; } pmd_t;
pgd_t
用于全局页目录项,pud_t
用于上层页目录项,pmd_t
用于中层页目录项,pte_t
用于页表项,它们又分别引用了其它类型:
c
// file: arch/x86/include/asm/pgtable_64_types.h
typedef unsigned long pteval_t;
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long pgdval_t;
1.3 虚拟内存布局
x86_64 架构下,虚拟内存中属于内核空间的各内存区域,其起始地址、空间大小、用途都是预先设计好的。4 级分页下,内存布局如下所示:
bash
// file: Documentation/x86/x86_64/mm.txt
Virtual memory map with 4 level page tables:
0000000000000000 - 00007fffffffffff (=47 bits) user space, different per mm
hole caused by [48:63] sign extension
ffff800000000000 - ffff80ffffffffff (=40 bits) guard hole
ffff880000000000 - ffffc7ffffffffff (=64 TB) direct mapping of all phys. memory
ffffc80000000000 - ffffc8ffffffffff (=40 bits) hole
ffffc90000000000 - ffffe8ffffffffff (=45 bits) vmalloc/ioremap space
ffffe90000000000 - ffffe9ffffffffff (=40 bits) hole
ffffea0000000000 - ffffeaffffffffff (=40 bits) virtual memory map (1TB)
... unused hole ...
ffffffff80000000 - ffffffffa0000000 (=512 MB) kernel text mapping, from phys 0
ffffffffa0000000 - ffffffffff5fffff (=1525 MB) module mapping space
ffffffffff600000 - ffffffffffdfffff (=8 MB) vsyscalls
ffffffffffe00000 - ffffffffffffffff (=2 MB) unused hole
其中,地址 0x0000 7FFF FFFF FFFF - 0x0000 7FFF FFFF FFFF 共128T(47位),属于用户空间;地址 0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 共128T(47位), 属于内核空间。
示意图如下:
这里我们重点关注下物理内存直接映射区 和内核代码映射区。
物理内存直接映射区 ,虚拟地址区间为 0xFFFF 8800 0000 0000 - 0xFFFF E900 0000 0000 , 共 64T 大小。Linux 内核会把所有的物理内存映射到该虚拟地址区间。内核定义了宏 __PAGE_OFFSET
以及 PAGE_OFFSET
,用来表示该区间的起始地址:
c
// file: arch/x86/include/asm/page_64_types.h
#define __PAGE_OFFSET _AC(0xffff880000000000, UL)
c
// file: arch/x86/include/asm/page_types.h
#define PAGE_OFFSET ((unsigned long)__PAGE_OFFSET)
该区间内的地址减去 __PAGE_OFFSET
,就可以得到对应的物理地址。
内核代码映射区 ,虚拟地址区间为 0xFFFF FFFF 8000 0000 - 0xFFFF FFFF A000 0000 ,共 512M 大小。该区域用于映射内核代码段、数据段、bss 段等内容。内核定义了宏 __START_KERNEL_map
来表示该区间的起始地址:
c
// file: arch/x86/include/asm/page_64_types.h
#define __START_KERNEL_map _AC(0xffffffff80000000, UL)
同理,该区域内的地址减去 __START_KERNEL_map
后,就能得到对应的物理地址。
1.4 内核代码的映射
在 Linux 内核的链接脚本中,将内核的虚拟地址起点设定为 __START_KERNEL
:
assembly
// file: arch/x86/kernel/vmlinux.lds.S
SECTIONS
{
#ifdef CONFIG_X86_32
...
#else
. = __START_KERNEL;
phys_startup_64 = startup_64 - LOAD_OFFSET;
#endif
...
}
宏 __START_KERNEL
扩展为 0xffffffff81000000
, 定义在文件 arch/x86/include/asm/page_64_types.h
中:
c
// file: arch/x86/include/asm/page_64_types.h
#define __START_KERNEL (__START_KERNEL_map + __PHYSICAL_START) // __START_KERNEL 扩展为 0xffffffff81000000
#define __PHYSICAL_START ((CONFIG_PHYSICAL_START + \ // __PHYSICAL_START 扩展为 0x1000000
(CONFIG_PHYSICAL_ALIGN - 1)) & \
~(CONFIG_PHYSICAL_ALIGN - 1))
c
// file: include/generated/autoconf.h
#define CONFIG_PHYSICAL_START 0x1000000
从定义中可以看到,__START_KERNEL
由 __START_KERNEL_map
和 __PHYSICAL_START
相加得到。__PHYSICAL_START
( 0x1000000
)是个编译时常量,指定了内核加载的物理地址; __START_KERNEL_map
是内核映射区的起始地址;__START_KERNEL
表示内核代码的起始地址。
内核物理地址和虚拟地址的映射关系如下:
物理地址 0x0
映射到虚拟地址 __START_KERNEL_map(0xffffffff80000000)
;内核加载的物理地址 __PHYSICAL_START(0x1000000)
映射到虚拟地址 __START_KERNEL(0xffffffff81000000)
。内核映射区的最大尺寸为 512M,但内核实际大小很可能会小于该值。不管怎样,都会分配一个单独的中层页目录(PMD,可映射 512M 内存),来映射内核。
内核最大尺寸限制:
c
// file: arch/x86/include/asm/page_64_types.h
/*
* Kernel image size is limited to 512 MB (see level2_kernel_pgt in
* arch/x86/kernel/head_64.S), and it is mapped here:
*/
#define KERNEL_IMAGE_SIZE (512 * 1024 * 1024) // 内核最大尺寸限制
注:内核的默认加载地址是 __PHYSICAL_START(0x1000000
);但是,如果配置选项 CONFIG_RELOCATABLE=y
,内核会被加载到其它地址,此时映射关系会有变化。
shell
// file: arch/x86/Kconfig
config PHYSICAL_START
hex "Physical address where the kernel is loaded" if (EXPERT || CRASH_DUMP)
default "0x1000000"
---help---
This gives the physical address where the kernel is loaded.
If kernel is a not relocatable (CONFIG_RELOCATABLE=n) then
bzImage will decompress itself to above physical address and
run from there. Otherwise, bzImage will run from the address where
it has been loaded by the boot loader and will ignore above physical
address.
...
...
1.5 早期页表的创建
内核定义了一系列全局变量,用来存储早期的各级页目录。
assembly
// file: arch/x86/kernel/head_64.S
__INITDATA
NEXT_PAGE(early_level4_pgt)
.fill 511,8,0
.quad level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE
NEXT_PAGE(early_dynamic_pgts)
.fill 512*EARLY_DYNAMIC_PAGE_TABLES,8,0
.data
...
NEXT_PAGE(level3_kernel_pgt)
.fill L3_START_KERNEL,8,0
/* (2^48-(2*1024*1024*1024)-((2^39)*511))/(2^30) = 510 */
.quad level2_kernel_pgt - __START_KERNEL_map + _KERNPG_TABLE
.quad level2_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE
NEXT_PAGE(level2_kernel_pgt)
/*
* 512 MB kernel mapping. We spend a full page on this pagetable
* anyway.
*
* The kernel code+data+bss must not be bigger than that.
*
* (NOTE: at +512MB starts the module area, see MODULES_VADDR.
* If you want to increase this then increase MODULES_VADDR
* too.)
*/
PMDS(0, __PAGE_KERNEL_LARGE_EXEC,
KERNEL_IMAGE_SIZE/PMD_SIZE)
NEXT_PAGE(level2_fixmap_pgt)
.fill 506,8,0
.quad level1_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE
/* 8MB reserved for vsyscalls + a 2MB hole = 4 + 1 entries */
.fill 5,8,0
NEXT_PAGE(level1_fixmap_pgt)
.fill 512,8,0
early_level4_pgt
、early_dynamic_pgts
、level3_kernel_pgt
、level2_kernel_pgt
、level2_fixmap_pgt
以及 level1_fixmap_pgt
这些数据结构,都和早期页表的创建相关。其中,以 early_*
命名的变量,只在内核初始化时使用,初始化完成后,占用的空间会被释放。以 *_kernel_*
字样命名的变量,跟内核代码映射相关。
代码开头,我们看到了宏 __INITDATA
,该宏定义了节 ".init.data"
。以 .init
开头的节(section),都是内核初始化时临时使用的,初始化完成后,这些数据占用的内存都会释放掉。
c
// file: include/linux/init.h
#define __INITDATA .section ".init.data","aw",%progbits
从__INITDATA
到 .data
之间的数据,都属于.init.data
节,该节包含 early_level4_pgt
和 early_dynamic_pgts
变量。early_level4_pgt
就是早期使用的顶级页表,即全局页目录(PGD);early_dynamic_pgts
是用来临时分配页表的。
宏 NEXT_PAGE
会将内存对齐到 PAGE_SIZE
(扩展为 4096)字节大小,并将入参 name 声明为全局符号。
assembly
// file: arch/x86/kernel/head_64.S
#define NEXT_PAGE(name) \
.balign PAGE_SIZE; \
GLOBAL(name)
宏 PAGE_SIZE
定义如下:
c
// file: arch/x86/include/asm/page_types.h
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT) // 扩展为 4096
宏 GLOBAL
定义如下:
c
// file: arch/x86/include/asm/linkage.h
#define GLOBAL(name) \
.globl name; \ // 将 name 声明为全局符号
name:
1.5.1 早期的全局页目录 -- early_level4_pgt
来看下 early_level4_pgt
变量,先是通过 .fill
指令,用数字 0 填充了 511 个 8 字节大小的空间(因为填充的是全 0,这些页表项都没有用到);然后通过 .quad
指令,将 level3_kernel_pgt
的物理地址以及页表项访问权限和状态标志 写入随后的 8个字节内。最终,early_level4_pgt
拥有 512个元素,总大小为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 512 × 8 = 4096 512 \times 8 = 4096 </math>512×8=4096 字节。early_level4_pgt
本质上是拥有 512 个元素的数组,如下所示:
c
// file: arch/x86/kernel/head64.c
extern pgd_t early_level4_pgt[PTRS_PER_PGD];
宏 PTRS_PER_PGD
表示每个全局页目录中的 指针/项 数量,扩展为 512。
由于在 Linux 内核的链接脚本中,将内核的虚拟地址起点设定为 __START_KERNEL
,所以内核编译文件中符号的地址,都是大于 __START_KERNEL
的,当然更是大于 __START_KERNEL_map
。换句话说,内核文件中的这些数据,将会被加载到虚拟地址空间的内核代码映射区。level3_kernel_pgt
当然也处于内核代码映射区中,所以 level3_kernel_pgt - __START_KERNEL_map
,就会得到 level3_kernel_pgt
的物理地址。
注:如上文所述,编译时,__START_KERNEL_map
映射到物理地址 0x0
。
内核镜像 vmlinux 中,符号 level3_kernel_pgt
的地址如下所示:
shell
$ nm vmlinux|grep level3_kernel_pgt
ffffffff81c11000 D level3_kernel_pgt
注:.fill
及 .quad
指令详情,见 GAS 在线文档。
宏 _PAGE_TABLE
定义了页表项的访问权限,用位图来表示:
c
// file: arch/x86/include/asm/pgtable_types.h
#define _PAGE_TABLE (_PAGE_PRESENT | _PAGE_RW | _PAGE_USER | \
_PAGE_ACCESSED | _PAGE_DIRTY)
由于level3_kernel_pgt
的地址对齐到 4KB,所以其低 12 位全是0,可用来存储访问权限及状态信息。
至于为什么要把内核代码映射区安装到 PGD 的第 511 项,只要把 __START_KERNEL_map(0xFFFF80000000,48位)
右移 PGDIR_SHIFT(39)
位就知道了,正好等于 511。
early_level4_pgt
是 4 级页表,即 Intel 文档中的 PML4(Page Map Level 4) 表,4 级页表项的格式如下所示:
各字段说明如下:
1.5.2 临时页目录空间 -- early_dynamic_pgts
early_dynamic_pgts
是为内核创建临时页表分配的空间,其地址同样是对齐到 4KB,该空间大小为 64 个 4KB。其中,宏 EARLY_DYNAMIC_PAGE_TABLES
扩展为 64:
c
// file: arch/x86/include/asm/pgtable_64_types.h
#define EARLY_DYNAMIC_PAGE_TABLES 64
使用时,其声明如下:
c
// file: arch/x86/kernel/head64.c
extern pmd_t early_dynamic_pgts[EARLY_DYNAMIC_PAGE_TABLES][PTRS_PER_PMD];
宏 PTRS_PER_PMD
扩展为 512。
1.5.3 上层页目录 -- level3_kernel_pgt
assembly
// file: arch/x86/kernel/head_64.S
NEXT_PAGE(level3_kernel_pgt)
.fill L3_START_KERNEL,8,0
/* (2^48-(2*1024*1024*1024)-((2^39)*511))/(2^30) = 510 */
.quad level2_kernel_pgt - __START_KERNEL_map + _KERNPG_TABLE
.quad level2_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE
level3_kernel_pgt
表示 3 级页表(上层页目录,PUD
),其地址同样是 4KB 对齐的(使用 NEXT_PAGE
宏创建的符号,其地址都会对齐到 4KB)。
先计算出虚拟地址__START_KERNEL_map
在 上层页目录(PUD
)的索引,即 L3_START_KERNEL
;然后把小于该索引的所有项全部置 0。
来看下 L3_START_KERNEL
的计算:
assembly
L3_START_KERNEL = pud_index(__START_KERNEL_map) // L3_START_KERNEL 等于 510
#define pud_index(x) (((x) >> PUD_SHIFT) & (PTRS_PER_PUD-1)) // PUD_SHIFT 扩展为 30,PTRS_PER_PUD 扩展为 512
填充完 510 个无效页表项之后,把 level2_kernel_pgt
的物理地址 level2_kernel_pgt - __START_KERNEL_map
存入对应的 PUD
表项(即索引为 L3_START_KERNEL
的表项)中。由于 level2_kernel_pgt
的地址是 4KB 对齐的,低 12 位全为 0,可用来存储页表项访问权限及信息。
宏 _KERNPG_TABLE
定义了访问权限及信息:
assembly
// file: arch/x86/include/asm/pgtable_types.h
#define _KERNPG_TABLE (_PAGE_PRESENT | _PAGE_RW | _PAGE_ACCESSED | \
_PAGE_DIRTY)
同理,把 level2_fixmap_pgt
的物理地址写入到第 511 个表项。
level3_kernel_pgt
是 3 级页表,对应于 Intel 文档中的页目录指针表(Page-Directory-pointer table,PDPT)。当该表中的项(PDPTE)指向页目录(Page )时,其格式如下图所示:
各字段说明如下:
1.5.4 中层页目录 -- level2_kernel_pgt
assembly
// file: arch/x86/kernel/head_64.S
NEXT_PAGE(level2_kernel_pgt)
/*
* 512 MB kernel mapping. We spend a full page on this pagetable
* anyway.
*
* The kernel code+data+bss must not be bigger than that.
*
* (NOTE: at +512MB starts the module area, see MODULES_VADDR.
* If you want to increase this then increase MODULES_VADDR
* too.)
*/
PMDS(0, __PAGE_KERNEL_LARGE_EXEC,
KERNEL_IMAGE_SIZE/PMD_SIZE)
先来看一下 PMDS
宏,该宏接收 3 个参数-- 物理起始地址 START
、权限 PERM
、数量 COUNT
,其功能是把从物理起始地址 START
开始,共COUNT
个中层页目录项,写入当前位置。
assembly
// file: arch/x86/kernel/head_64.S
/* Automate the creation of 1 to 1 mapping pmd entries */
#define PMDS(START, PERM, COUNT) \
i = 0 ; \
.rept (COUNT) ; \
.quad (START) + (i << PMD_SHIFT) + (PERM) ; \
i = i + 1 ; \
.endr
再来看看入参。宏 __PAGE_KERNEL_LARGE_EXEC
定义了页表权限和信息:
c
// file: arch/x86/include/asm/pgtable_types.h
#define __PAGE_KERNEL_LARGE_EXEC (__PAGE_KERNEL_EXEC | _PAGE_PSE)
#define __PAGE_KERNEL_EXEC \
(_PAGE_PRESENT | _PAGE_RW | _PAGE_DIRTY | _PAGE_ACCESSED | _PAGE_GLOBAL)
我们重点关注下_PAGE_PSE
位(位 7),该位置位后,中层页目录项会映射到页,不会再引用页表(PT)了。现在我们是在二级页表(PMD)的页表项里将该位置位的,也就是说每个中层页表项直接映射到页了,该页大小为 2M。所以,在建立早期页表时,使用的是 2MB 大小的页。
level2_kernel_pgt
是 2 级页表,对应 Intel 文档中的页目录(Page Directory,PD)。当 PD 直接映射到页时,页目录项(PDE)的格式如下图所示:
各字段说明如下:
KERNEL_IMAGE_SIZE/PMD_SIZE
计算了需要安装的页表项数量,其中宏 KERNEL_IMAGE_SIZE
定义了内核镜像的允许的最大尺寸,扩展为 512M;宏PMD_SIZE
定义了每个 PMD 项映射的内存大小,扩展为 2M;这 2 个宏定义如下:
c
// file: arch/x86/include/asm/page_64_types.h
/*
* Kernel image size is limited to 512 MB (see level2_kernel_pgt in
* arch/x86/kernel/head_64.S), and it is mapped here:
*/
#define KERNEL_IMAGE_SIZE (512 * 1024 * 1024)
c
// file: arch/x86/include/asm/pgtable_64_types.h
#define PMD_SIZE (_AC(1, UL) << PMD_SHIFT) // PMD_SIZE 扩展为 2M
#define PMD_SHIFT 21
最终,将物理地址 0 ~ 512M 处的共 512M 空间,共 256 个中层页目录项(PMDE),写入到 level2_kernel_pgt
地址处。
因为内核就处于物理地址 0 ~ 512M 处,所以这段代码执行完后,就把内核代码(.text+.data+.tss)映射到了内核代码映射区。正如 1.4 节图中所展示的那样。
1.5.5 level2_fixmap_pgt
assembly
NEXT_PAGE(level2_fixmap_pgt)
.fill 506,8,0
.quad level1_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE
/* 8MB reserved for vsyscalls + a 2MB hole = 4 + 1 entries */
.fill 5,8,0
level2_fixmap_pgt
处定义了一个 4KB 的 PMD表,其第 506 项存储着 level1_fixmap_pgt
页表的物理地址及访问权限;其余各项均初始化为 0。
1.5.6 level1_fixmap_pgt
全部 512 个页表项均初始化为 0,未使用。
1.5.7 页表结构图
页表创建后,其结构如图所示:
1.6 phys_base
phys_base
是指内核的物理基地址。编译阶段,初始化为 0:
assembly
// file: arch/x86/kernel/head_64.S
ENTRY(phys_base)
/* This must match the first entry in level2_kernel_pgt */
.quad 0x0000000000000000
使用时,声明如下:
c
// file: arch/x86/include/asm/page_64.h
extern unsigned long phys_base;
在上文中,我们介绍过,编译时内核的加载地址为 __PHYSICAL_START
,即 0x1000000
。如果开启了配置选项 CONFIG_RELOCATABLE=y
,运行时,内核会被加载到其它地址,而不再是 __PHYSICAL_START
。我们需要计算出编译时和运行时加载地址的差值,来修正编译时写入页表的那些物理地址。phys_base
中保存的就是这个差值。
assembly
// file: arch/x86/kernel/head_64.S
startup_64:
/*
* Compute the delta between the address I am compiled to run at and the
* address I am actually running at.
*/
leaq _text(%rip), %rbp
subq $_text - __START_KERNEL_map, %rbp
...
/* Fixup phys_base */
addq %rbp, phys_base(%rip)
...
首先,我们看到,把有效地址 _text(%rip)
存入了 %rbp
寄存器。符号 _text
定义在链接文件 vmlinux.lds.S
中,其值为 __START_KERNEL
。
assembly
// file: arch/x86/kernel/vmlinux.lds.S
SECTIONS
{
#ifdef CONFIG_X86_32
...
#else
. = __START_KERNEL;
phys_startup_64 = startup_64 - LOAD_OFFSET;
#endif
/* Text and read-only data */
.text : AT(ADDR(.text) - LOAD_OFFSET) {
_text = .;
...
} :text = 0x9090
...
}
_text(%rip)
是指令指针相对寻址,获取到的是 _text
实际运行的地址。由于运行到 startup_64
时,内核代码段的虚拟地址与物理地址是相等的(identity mapping
),都处于内存的低端位置,所以_text(%rip)
得到的是内核代码段的实际加载地址,即物理地址。
关于 _text(%rip)
的计算,可参考 Linux Kernel: _text(%rip) 的值如何计算?。
接下来,计算编译时和运行时加载地址的差值。$_text - __START_KERNEL_map
计算的是编译时的物理地址,其值为 0x1000000
;%rbp
原始值为实际加载地址,sub
指令执行后,保存的是两者的差值 delta
。
最后,把 %rbp
的值,存入 phys_base
处。
所以,在运行时,phys_base
处保存的是内核实际加载地址与编译时的加载地址之差。
1.7 修正页表内的物理地址
在上文中讲到,在编译时期,我们就填充了一些页表项,甚至把整个内核代码映射到了虚拟地址。但是,在编译时,我们是填充内核映射区的页表项时,物理基地址 phys_base
为 0x0
,内核代码段的加载地址为 __PHYSICAL_START
;运行时,内核代码段实际加载地址为__PHYSICAL_START + delta
,其中 delta
为两者的差值。这种情况下,编译时存入页表中的物理地址跟运行时的实际物理地址就不一致了,需要将它们进行修复,否则地址就是错误的。修复过程如下:
assembly
startup_64:
/*
* Compute the delta between the address I am compiled to run at and the
* address I am actually running at.
*/
leaq _text(%rip), %rbp // 内核运行时加载地址
subq $_text - __START_KERNEL_map, %rbp // 计算 内核运行时地址 与 编译时加载地址 之差
...
/*
* Fixup the physical addresses in the page table
*/
addq %rbp, early_level4_pgt + (L4_START_KERNEL*8)(%rip) // 修正 level3_kernel_pgt 的物理地址
addq %rbp, level3_kernel_pgt + (510*8)(%rip) // 修正 level2_kernel_pgt 的物理地址
addq %rbp, level3_kernel_pgt + (511*8)(%rip) // 修正 level2_fixmap_pgt 的物理地址
addq %rbp, level2_fixmap_pgt + (506*8)(%rip) // 修正 level1_fixmap_pgt 的物理地址
...
/*
* Fixup the kernel text+data virtual addresses. Note that
* we might write invalid pmds, when the kernel is relocated
* cleanup_highmap() fixes this up along with the mappings
* beyond _end.
*/
leaq level2_kernel_pgt(%rip), %rdi
leaq 4096(%rdi), %r8
/* See if it is a valid page table entry */
1: testq $1, 0(%rdi)
jz 2f
addq %rbp, 0(%rdi)
/* Go to the next page */
2: addq $8, %rdi
cmp %r8, %rdi
jne 1b
...
...
头 2 行计算出了内核代码段编译时加载地址和运行时加载地址的差值 delta
,并保存在 %rbp
寄存器。这个我们在前面的小节里已经分析过了。
在编译时,向页表项里填入了一些地址:
C
early_level4_pgt[511] => level3_kernel_pgt
level3_kernel_pgt[510] => level2_kernel_pgt
level3_kernel_pgt[511] => level2_fixmap_pgt
level2_fixmap_pgt[506] => level1_fixmap_pgt
随后的 4 行代码,将这些物理地址加上 %rbp
,完成对这些页表项的修正。
另外,编译时,还将地址 0x0 ~ 0x10000000 处共 512M 的物理内存映射到了level2_kernel_pgt
表里,共写入了 256 项,4096 字节大小。现在这些页表项中的物理地址也需要修正。
随后的代码,遍历这些项,对有效的表项进行修复;无效的表项,直接跳过。页表项的 P 位(Present,位 0)表明该项是否有效,P 位为 1 时,说明该项有效;否则,该项无效。
这里通过标签 1
处的testq
指令来验证页表项是否有效,该指令将常量 1 与页表项内容进行按位与操作。当结果为 0 时,说明 P 位为 0,页表项无效,则跳到下一项进行处理;当结果为 1 时,说明页表项有效,把该页表项中保存的地址增加 delta
(即 %rbp
的值)进行修正;如此反复循环,直到达到页表尺寸 4096 字节。
修正之后,内核地址的映射关系变化如下:
1.8 物理地址与虚拟地址间转换 __pa
vs. __va
宏__pa
用来把虚拟地址转换为物理地址,该宏定义在头文件 arch/x86/include/asm/page.h
中:
c
// file: arch/x86/include/asm/page.h
#define __pa(x) __phys_addr((unsigned long)(x))
__pa(x)
扩展为内联函数 __phys_addr_nodebug
。
c
// file: arch/x86/include/asm/page_64.h
#define __phys_addr(x) __phys_addr_nodebug(x)
static inline unsigned long __phys_addr_nodebug(unsigned long x)
{
unsigned long y = x - __START_KERNEL_map;
/* use the carry flag to determine if x was < __START_KERNEL_map */
x = y + ((x > y) ? phys_base : (__START_KERNEL_map - PAGE_OFFSET));
return x;
}
宏 __START_KERNEL_map
( 0xffffffff80000000 )是虚拟地址中内核代码映射区 的起始地址; __PAGE_OFFSET
(0xffff880000000000 )是物理内存直接映射区的起始地址。
y = x - __START_KERNEL_map
计算待转换虚拟地址和内核代码映射区起始地址的差值:
- 当 x > y 时,说明待转换虚拟地址处于内核代码映射区 ,返回值 x = y + phys_base 。根据上文可知,
phys_base
是内核映射区的物理基地址,y + phys_base
就是 x 的物理地址。 - 当 x < y 时,说明待转换虚拟地址处于物理内存直接映射区 ,返回值
x = y + __START_KERNEL_map - PAGE_OFFSET
。y + __START_KERNEL_map
会回绕到 x ,所以最终x = x - PAGE_OFFSET
,得到 x 的物理地址。
宏__va
将物理地址转虚拟地址,定义如下:
c
// file: arch/x86/include/asm/page.h
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
可以看到,将物理地址直接加上 PAGE_OFFSET
,得到虚拟地址。
二、Page-Fault 早期处理
在中断和异常处理程序的早期初始化中,我们看到,当向量号为 14 (Page-Fault 异常)时,会调用 early_make_pgtable
函数进行处理。该函数定义在文件 arch/x86/kernel/head64.c
中:
c
/* Create a new PMD entry */
int __init early_make_pgtable(unsigned long address)
{
unsigned long physaddr = address - __PAGE_OFFSET;
unsigned long i;
pgdval_t pgd, *pgd_p;
pudval_t pud, *pud_p;
pmdval_t pmd, *pmd_p;
/* Invalid address or early pgt is done ? */
if (physaddr >= MAXMEM || read_cr3() != __pa(early_level4_pgt))
return -1;
...
...
...
}
当发生 Page-Fault 异常时,引起页故障的线性地址被保存到 CR2 寄存器中。该函数的参数,就是引起页故障(Page Fault)的线性地址,该值是从 CR2 寄存器读取后传递过来的。
函数一开始,通过 address - __PAGE_OFFSET
,计算发生 Page-Fault 处的物理地址。
然后,声明了pmdval_t
、pudval_t
、pgdval_t
类型的变量和指针。这 3 种类型,实际都是 unsigned long 类型的别名。
c
// file: arch/x86/include/asm/pgtable_64_types.h
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long pgdval_t;
然后,判断该物理地址是否大于系统允许的最大物理地址(MAXMEM),以及 4 级页目录地址与 CR3 寄存器中保存的顶级页目录地址是否一致。如果全都 OK,那么程序继续进行,否则返回 -1 。
MAXMEM
宏定义如下:
c
// file: arch/x86/include/asm/sparsemem.h
#define MAXMEM _AC(__AC(1, UL) << MAX_PHYSMEM_BITS, UL)
# define MAX_PHYSMEM_BITS 46
可见,Linux 内核支持最大 46 位的物理地址。
read_cr3
函数,会调用 native_read_cr3
函数,读取 CR3 寄存器的值。
c
// file: arch/x86/include/asm/special_insns.h
static inline unsigned long native_read_cr3(void)
{
unsigned long val;
asm volatile("mov %%cr3,%0\n\t" : "=r" (val), "=m" (__force_order));
return val;
}
CR3 寄存器的值,被初始化为修正后的 early_level4_pgt
的物理地址:
assembly
// file: arch/x86/kernel/head_64.S
movq $(early_level4_pgt - __START_KERNEL_map), %rax // %rax 里保存的是编译时 early_level4_pgt 的物理地址
...
/* Setup early boot stage 4 level pagetables. */
addq phys_base(%rip), %rax // 对 early_level4_pgt 的物理地址进行修正,加上 delta
movq %rax, %cr3 // 将 early_level4_pgt 的物理地址(该地址对齐到 4KB)存入 %cr3 寄存器
验证通过之后,就需要定位到缺失的页表项或页,并将其填充完整。由于页表是分级的,所以要从高到低一级级定位。
2.1 检查并填充全局页目录项
c
again:
pgd_p = &early_level4_pgt[pgd_index(address)].pgd;
pgd = *pgd_p;
先使用pgd_index
宏计算出页故障地址在顶级页表中的索引。pgd_index
宏定义如下:
c
// file: arch/x86/include/asm/pgtable.h
/*
* the pgd page can be thought of an array like this: pgd_t[PTRS_PER_PGD]
*
* this macro returns the index of the entry in the pgd page which would
* control the given virtual address
*/
#define pgd_index(address) (((address) >> PGDIR_SHIFT) & (PTRS_PER_PGD - 1))
/* to find an entry in a page-table-directory. */
static inline unsigned long pud_index(unsigned long address)
{
return (address >> PUD_SHIFT) & (PTRS_PER_PUD - 1);
}
/*
* the pmd page can be thought of an array like this: pmd_t[PTRS_PER_PMD]
*
* this macro returns the index of the entry in the pmd page which would
* control the given virtual address
*/
static inline unsigned long pmd_index(unsigned long address)
{
return (address >> PMD_SHIFT) & (PTRS_PER_PMD - 1);
}
与pgd_index
类似的函数还有 pud_index
和 pmd_index
。这三个函数(或宏),功能类似,分别计算出虚拟地址在全局页目录、上层页目录及中层页目录中的索引。
计算出页故障地址在 PGD
表的索引之后,early_level4_pgt[pgd_index(address)]
根据索引获取到对应全局页目录项(Page Global Directory Entry,PGDE)。
c
// file: arch/x86/kernel/head64.c
extern pgd_t early_level4_pgt[PTRS_PER_PGD];
在 1.2 小节,我们介绍过 pgd_t
类型。这是一个结构体类型,只有一个成员,保存的是全局页目录项。
c
typedef struct { pgdval_t pgd; } pgd_t;
全局页目录项里保存着上层页目录(Page Upper Directory,PUD)的物理基地址及访问权限。全局页目录项格式,请参考 1.5.1 小节。
通过计算,我们得到了全局页目录项(PGDE)的地址,即指向全局页目录项的指针,把它保存到变量 pgd_p
里;通过解引用,获取到全局页目录项的数据,存入变量pgd
中。获取到全局页目录项后,我们需要检查其是否有效。还记得么,在初始化各级页目录时,凡是未使用到的项,我们都直接初始化为 0。
如果 pgd
有效,说明全局页目录项 所引用的上层页目录 已经存在了,我们可以直接计算出上层页目录 的虚拟地址 。注意,这里计算的是虚拟地址。开启分页后,即使是内核,想要访问内存,也需要使用虚拟地址,然后由 CPU 内部转换成物理地址。
c
/*
* The use of __START_KERNEL_map rather than __PAGE_OFFSET here is
* critical -- __PAGE_OFFSET would point us back into the dynamic
* range and we might end up looping forever...
*/
if (pgd)
pud_p = (pudval_t *)((pgd & PTE_PFN_MASK) + __START_KERNEL_map - phys_base);
else {
...
}
PTE_PFN_MASK
宏最终扩展为 0x00003ffffffff000
,会屏蔽掉(pte|pmd|pud|pgd)val_t
类型的低 12 位(权限位)以及高位的保留位,得到页结构物理基地址。因为物理地址 phys_base
映射到虚拟地址 __START_KERNEL_map
,所以减去 phys_base
再加上__START_KERNEL_map
,就得到了页结构的虚拟地址。
c
// file: arch/x86/include/asm/pgtable_types.h
/* PTE_PFN_MASK extracts the PFN from a (pte|pmd|pud|pgd)val_t */
#define PTE_PFN_MASK ((pteval_t)PHYSICAL_PAGE_MASK) // PTE_PFN_MASK 扩展为 0x00003ffffffff000
c
// file: arch/x86/include/asm/page_types.h
/* Cast PAGE_MASK to a signed type so that it is sign-extended if
virtual addresses are 32-bits but physical addresses are larger
(ie, 32-bit PAE). */
#define PHYSICAL_PAGE_MASK (((signed long)PAGE_MASK) & __PHYSICAL_MASK) // PHYSICAL_PAGE_MASK 扩展为 0x00003ffffffff000
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT) // PAGE_SIZE 扩展为 4096
#define PAGE_MASK (~(PAGE_SIZE-1)) // PAGE_MASK 扩展为 0xfffffffffffff000
#define __PHYSICAL_MASK ((phys_addr_t)((1ULL << __PHYSICAL_MASK_SHIFT) - 1)) // __PHYSICAL_MASK 扩展为 0x00003fffffffffff
c
// file: arch/x86/include/asm/page_64_types.h
#define __PHYSICAL_MASK_SHIFT 46 // linux 支持的最大物理地址位数
如果 pgd
无效,说明该全局目录项没有引用任何上层页目录( PUD
)。这时就需要为 PUD
分配内存空间,然后把 PUD
的内存地址及权限组合成全局页目录项,存入变量 pgd
中。
assembly
/*
* The use of __START_KERNEL_map rather than __PAGE_OFFSET here is
* critical -- __PAGE_OFFSET would point us back into the dynamic
* range and we might end up looping forever...
*/
if (pgd)
...
else {
if (next_early_pgt >= EARLY_DYNAMIC_PAGE_TABLES) {
reset_early_page_tables();
goto again;
}
pud_p = (pudval_t *)early_dynamic_pgts[next_early_pgt++];
for (i = 0; i < PTRS_PER_PUD; i++)
pud_p[i] = 0;
*pgd_p = (pgdval_t)pud_p - __START_KERNEL_map + phys_base + _KERNPG_TABLE;
}
在分配之前,我们先检查 next_early_pgt
是否比EARLY_DYNAMIC_PAGE_TABLES
(扩展到 64)大。
c
// file: arch/x86/include/asm/pgtable_64_types.h
#define EARLY_DYNAMIC_PAGE_TABLES 64
next_early_pgt
是一个全局变量,也是一个索引,用来指示临时页表空间的可用位置。由于是在临时页表空间 early_dynamic_pgts
里为 PUD
分配内存,该空间最多能存放 64 个页表,所以当索引超出 63 时,就说明空间溢出了,这时需要使用 reset_early_page_tables
函数来重置页表,释放空间,然后从again
标签处重新执行。
c
// file: arch/x86/kernel/head64.c
extern pmd_t early_dynamic_pgts[EARLY_DYNAMIC_PAGE_TABLES][PTRS_PER_PMD];
reset_early_page_tables
函数实现如下:
c
// file: arch/x86/kernel/head64.c
/* Wipe all early page tables except for the kernel symbol map */
static void __init reset_early_page_tables(void)
{
unsigned long i;
for (i = 0; i < PTRS_PER_PGD-1; i++)
early_level4_pgt[i].pgd = 0;
next_early_pgt = 0;
write_cr3(__pa(early_level4_pgt));
}
该函数会把全局页目录中除第 511 项之外的其它项,全部设置为0,使其失效,达到释放空间的目的。第 511 项 保存着内核代码映射相关的项,所以需要保留。然后将索引 next_early_pgt
重置为 0;最后重新加载了CR3 寄存器。
如果还有空间可用,就获取当前可用的地址,并把它赋值给pud_p
;之后,索引 next_early_pgt
自增,指向下一个可用地址。next_early_pgt
每增加 1,就会分配 <math xmlns="http://www.w3.org/1998/Math/MathML"> 512 × 8 = 4096 512 \times 8 = 4096 </math>512×8=4096 字节的空间。分配好空间后,将该上层页目录( PUD
) 下的所有项初始化为 0。分配空间时,使用的是虚拟地址;而全局页目录项中需要的是物理地址,所以要把pud_p
转换为物理地址且合并权限位后,才能存入全局页目录项中去。
c
*pgd_p = (pgdval_t)pud_p - __START_KERNEL_map + phys_base + _KERNPG_TABLE;
到目前为止,不管是哪种情况,我们已经保证了全局页目录项是有效的,也获得了 PUD
的虚拟地址 pud_p
。
此时,各变量如下图所示:
2.2 检查并填充上层页目录项
接下来,就需要定位到对应的上层页目录项,检查其是否有效。
pud_index(address)
计算出发生 Page-Fault 处的虚拟地址在 PUD
里的索引,pud_p
加上索引之后,得到对应的上层页目录项的虚拟地址。通过解引用,就得到了上层页目录项的值。
C
pud_p += pud_index(address);
pud = *pud_p;
此时,各变量如下所示:
之后,就需要检查上层页目录项是否有效。上层页目录项格式请参见 1.5.3 节。
如果 pud
有效,就可以直接计算出中层页目录的虚拟地址 pmd_p
;否则,需要先为 PMD
分配 4KB 的内存,然后将内存的物理地址合并权限位后,保存到变量 pud
中去。
c
if (pud)
pmd_p = (pmdval_t *)((pud & PTE_PFN_MASK) + __START_KERNEL_map - phys_base);
else {
if (next_early_pgt >= EARLY_DYNAMIC_PAGE_TABLES) {
reset_early_page_tables();
goto again;
}
pmd_p = (pmdval_t *)early_dynamic_pgts[next_early_pgt++];
for (i = 0; i < PTRS_PER_PMD; i++)
pmd_p[i] = 0;
*pud_p = (pudval_t)pmd_p - __START_KERNEL_map + phys_base + _KERNPG_TABLE;
}
处理完成后,上层页目项已经保证是有效的了,pmd_p
指向中层页目录基地址。
此时,各变量如下图所示:
2.3 检查并填充中层页目录项
接下来,该处理中层页目录项了。中层页目录项格式,请参见 1.5.4 节。
c
pmd = (physaddr & PMD_MASK) + early_pmd_flags;
pmd_p[pmd_index(address)] = pmd;
physaddr
是 Page-Fault 处的物理地址,PMD_MASK
是中层页目录的掩码,两者按位与之后,将物理地址 physaddr
的低21位屏蔽掉。
c
// file: arch/x86/include/asm/pgtable_64_types.h
#define PMD_MASK (~(PMD_SIZE - 1))
#define PMD_SIZE (_AC(1, UL) << PMD_SHIFT)
#define PMD_SHIFT 21
然后,并合并权限位 early_pmd_flags
,保存到对应的中级页目录项里。
c
// file:
pmdval_t early_pmd_flags = __PAGE_KERNEL_LARGE & ~(_PAGE_GLOBAL | _PAGE_NX);
// file: arch/x86/include/asm/pgtable_types.h
#define __PAGE_KERNEL_LARGE (__PAGE_KERNEL | _PAGE_PSE)
#define __PAGE_KERNEL (__PAGE_KERNEL_EXEC | _PAGE_NX)
#define __PAGE_KERNEL_EXEC \
(_PAGE_PRESENT | _PAGE_RW | _PAGE_DIRTY | _PAGE_ACCESSED | _PAGE_GLOBAL)
这里要重点提一下,因为early_pmd_flags
里含有_PAGE_PSE
标志(Page-Size,位 7),所以该中级页目录项会映射到 2MB 大小的页,就没有页表(Page Table,PT)的事了。
至此,页表建立完成。此时,各变量如下图所示:
三、参考资料
1、Intel 开发者手册:Intel 64 and IA-32 Architectures Software Developer Manuals Volume 3A, Chapter 4 Paging
3、 GAS 在线文档