Linux kernel:Page-Fault 异常的早期处理

本文采用 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_START0x1000000)是个编译时常量,指定了内核加载的物理地址; __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_pgtearly_dynamic_pgtslevel3_kernel_pgtlevel2_kernel_pgtlevel2_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_pgtearly_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_base0x0,内核代码段的加载地址为 __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_map0xffffffff80000000 )是虚拟地址中内核代码映射区 的起始地址; __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_OFFSETy + __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_tpudval_tpgdval_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_indexpmd_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

2、x86-64架构:内存分页机制

3、 GAS 在线文档

4、 Linux Kernel: _text(%rip) 的值如何计算?

5、中断和异常处理程序的早期初始化

6、linux-initialization-1

相关推荐
程序员南飞1 小时前
ps aux | grep smart_webrtc这条指令代表什么意思
java·linux·ubuntu·webrtc
StrokeAce1 小时前
linux桌面软件(wps)内嵌到主窗口后的关闭问题
linux·c++·qt·wps·窗口内嵌
热爱嵌入式的小许5 小时前
Linux基础项目开发1:量产工具——显示系统
linux·运维·服务器·韦东山量产工具
2401_857622666 小时前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
2402_857589366 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
_小猪沉塘7 小时前
L11&12&13 【哈工大_操作系统】内核级线程&内核级线程实现&操作系统之“树”
操作系统
哎呦没7 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch8 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
韩楚风9 小时前
【linux 多进程并发】linux进程状态与生命周期各阶段转换,进程状态查看分析,助力高性能优化
linux·服务器·性能优化·架构·gnu
陈苏同学9 小时前
4. 将pycharm本地项目同步到(Linux)服务器上——深度学习·科研实践·从0到1
linux·服务器·ide·人工智能·python·深度学习·pycharm