前言

进程的虚拟地址空间分为用户地址虚拟空间和内核地址虚拟空间,每个进程的用户地址虚拟空间的页表项都不一样,但是每个进程的内核地址虚拟空间的页表项都是一样的,都是同一份内核页表,等于主内核全局目录相应的表项(内核的顶级页表)。

用户地址虚拟空间的页表管理请参考:
Linux 页表管理(一)
Linux 页表管理(二)

一、内核页表的创建

1.1 swapper_pg_dir

swapper_pg_dir是内核的顶级页表,占用一个物理页,大小为4K。

// linux-3.10.1/arch/x86/include/asm/pgtable_64.h
extern pud_t level3_kernel_pgt[512];
extern pud_t level3_ident_pgt[512];
extern pmd_t level2_kernel_pgt[512];
extern pmd_t level2_fixmap_pgt[512];
extern pmd_t level2_ident_pgt[512];
extern pgd_t init_level4_pgt[];

#define swapper_pg_dir init_level4_pgt

其中 XXX_ident_pgt 对应的是直接映射区,XXX_kernel_pgt 对应的是内核代码区,XXX_fixmap_pgt 对应的是固定映射区。

如果是用户态进程页表,会有 mm_struct 指向进程顶级目录 pgd,对于内核来讲,也定义了一个 mm_struct,指向 swapper_pg_dir。

// linux-3.10.1/mm/init-mm.c

struct mm_struct init_mm = {
	.mm_rb		= RB_ROOT,
	.pgd		= swapper_pg_dir,
	.mm_users	= ATOMIC_INIT(2),
	.mm_count	= ATOMIC_INIT(1),
	.mmap_sem	= __RWSEM_INITIALIZER(init_mm.mmap_sem),
	.page_table_lock =  __SPIN_LOCK_UNLOCKED(init_mm.page_table_lock),
	.mmlist		= LIST_HEAD_INIT(init_mm.mmlist),
	INIT_MM_CONTEXT(init_mm)
};

1.2 内核页表的创建

内核页表的创建早于用户态页表,在系统初始化期间。

// linux-3.10.1/arch/x86/include/asm/page_64_types.h

#define __PAGE_OFFSET           _AC(0xffff880000000000, UL)

#define __START_KERNEL_map		_AC(0xffffffff80000000, UL)
// linux-3.10.1/arch/x86/kernel/head_64.S

/* we are not able to switch in one step to the final KERNEL ADDRESS SPACE
 * because we need identity-mapped pages.
 *
 */

#define pud_index(x)	(((x) >> PUD_SHIFT) & (PTRS_PER_PUD-1))

//直接映射区
L4_PAGE_OFFSET = pgd_index(__PAGE_OFFSET)
L3_PAGE_OFFSET = pud_index(__PAGE_OFFSET)
//内核代码段映射区
L4_START_KERNEL = pgd_index(__START_KERNEL_map)
L3_START_KERNEL = pud_index(__START_KERNEL_map)

......

	__INITDATA	
NEXT_PAGE(init_level4_pgt)											/* ------- PGD(L4) ------- */
	.quad   level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE   // 0 pgd entry: identity mapping
	.org    init_level4_pgt + L4_PAGE_OFFSET*8, 0					// 一个entry(页表项) = 8个字节
	.quad   level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE	// x pgd entry: direct mapping x=L4_PAGE_OFFSET
	.org    init_level4_pgt + L4_START_KERNEL*8, 0
	/* (2^48-(2*1024*1024*1024))/(2^39) = 511 */
	.quad   level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE	// 511 pgd entry: kernel mapping

NEXT_PAGE(level3_ident_pgt)											/* ------- PUD(L3): identity mapping/direct mapping ------- */
	.quad	level2_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE
	.fill	511, 8, 0
NEXT_PAGE(level2_ident_pgt)											/* ------- PMD(L2): identity mapping/direct mapping ------- */
	/* Since I easily can, map the first 1G.
	 * Don't set NX because code runs from these pages.
	 */
	PMDS(0, __PAGE_KERNEL_IDENT_LARGE_EXEC, PTRS_PER_PMD)			// pmd huge page大小为2M,定义了一个page的pmd entry,总大小为1G:2M*512 = 1G

NEXT_PAGE(level3_kernel_pgt)										/* ------- PUD(L3): kernel ------- */
	.fill	L3_START_KERNEL,8,0										// 0 - L3_START_KERNEL pud entry,entry value=  0
	/* (2^48-(2*1024*1024*1024)-((2^39)*511))/(2^30) = 510 */		
	.quad	level2_kernel_pgt - __START_KERNEL_map + _KERNPG_TABLE	// 510 pud entry: kernel image
	.quad	level2_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE	// 511 pud entry: fixmap

NEXT_PAGE(level2_kernel_pgt)										/* ------- PMD(L2): kernel image ------- */
	/*
	 * 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,								// pmd huge page大小为2M,总大小为512M
		KERNEL_IMAGE_SIZE/PMD_SIZE)

NEXT_PAGE(level2_fixmap_pgt)										/* ------- PMD(L2): fixmap ------- */
	.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)										/* ------- PTE(L1): fixmap ------- */
	.fill	512,8,0

由于页表是存放在物理内存中,所以上述都减去了__START_KERNEL_map内核虚拟地址。
比如:level3_ident_pgt是直接映射区页表的三级目录,level3_ident_pgt 是在虚拟地址的内核代码段里的,而 __START_KERNEL_map 正是虚拟地址空间的内核代码段的起始地址。

#define __START_KERNEL_map	_AC(0xffffffff80000000, UL)

_PAGE_TABLE和_KERNPG_TABLE是页表项的属性。

#define _PAGE_PRESENT	(_AT(pteval_t, 1) << _PAGE_BIT_PRESENT)
#define _PAGE_RW	(_AT(pteval_t, 1) << _PAGE_BIT_RW)
#define _PAGE_USER	(_AT(pteval_t, 1) << _PAGE_BIT_USER)
#define _PAGE_ACCESSED	(_AT(pteval_t, 1) << _PAGE_BIT_ACCESSED)
#define _PAGE_DIRTY	(_AT(pteval_t, 1) << _PAGE_BIT_DIRTY)

#define _PAGE_TABLE	(_PAGE_PRESENT | _PAGE_RW | _PAGE_USER |	\
			 _PAGE_ACCESSED | _PAGE_DIRTY)
#define _KERNPG_TABLE	(_PAGE_PRESENT | _PAGE_RW | _PAGE_ACCESSED |	\
			 _PAGE_DIRTY)
level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE

init_level4_pgt有三项:
(1)第一项指向的是 level3_ident_pgt,也即直接映射区页表的三级目录。

.quad   level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE

(2)第二项跳到了L4_PAGE_OFFSET位置,指向 level3_ident_pgt,直接映射区。

L4_PAGE_OFFSET = pgd_index(__PAGE_OFFSET)
	.quad   level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE

(3)第三项跳到 L4_START_KERNEL 的位置,再定义一项。从定义可以看出,这一项应该是 __START_KERNEL_map 对应的项,__START_KERNEL_map 是虚拟地址空间里面内核代码段的起始地址。第三项指向 level3_kernel_pgt,内核代码区.

L4_START_KERNEL = pgd_index(__START_KERNEL_map)
	.quad   level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE

(4)初始化表项,然后指向下一级目录,如下图所示:
在这里插入图片描述
其中主要建立了4块区域的映射:

regionsizedesctipt
identity mapping1G虚拟地址和物理地址相等
direct mapping1G线性映射空间,起始虚拟地址为PAGE_OFFSET
kernel image512M内核映像映射空间
fixmap-固定映射空间

内核页表定义完了,一开始这里面的页表能够覆盖的内存范围比较小。例如,内核代码区 512M,直接映射区 1G。这个时候,其实只要能够映射基本的内核代码和数据结构就可以了。可以看出,里面还空着很多项,可以用于将来映射巨大的内核虚拟地址空间,等用到的时候再进行映射。

1.3 __INITDATA

内核页表的顶级目录 init_level4_pgt是一个全局变量,定义在 __INITDATA 里面,告诉链接器应该把数据放置在 内核镜像的__INITDATA(__initdata)区中。

readelf -S vmlinux

在这里插入图片描述

二、内核页表初始化

上面定义完了内核页表,接下来是初始化内核页表,在系统启动的时候 start_kernel 会调用 setup_arch。
在start_kernel() → setup_arch() → init_mem_mapping()时会把页表切换成swapper_pg_dir。

// linux-3.10.1/init/main.c
start_kernel()
	-->// linux-3.10.1/arch/x86/kernel/setup.c
		setup_arch()
		-->// linux-3.10.1/arch/x86/mm/init.c
			init_mem_mapping()
// linux-3.10.1/arch/x86/kernel/setup.c

void __init setup_arch(char **cmdline_p)
{
	......
	init_mm.start_code = (unsigned long) _text;
	init_mm.end_code = (unsigned long) _etext;
	init_mm.end_data = (unsigned long) _edata;
	init_mm.brk = _brk_end;
	......

	init_mem_mapping();
	
}
// linux-3.10.1/arch/x86/mm/init.c

void __init init_mem_mapping(void)
{
	......
	/* the ISA range is always mapped regardless of memory holes */
	/* #define ISA_END_ADDRESS		0x100000*/
	/* (1) 创建1M(0x100000)以下的线性地址区的映射 */
	init_memory_mapping(0, ISA_END_ADDRESS);
	......
	
	/* xen has big range in reserved near end of ram, skip it at first.*/
	addr = memblock_find_in_range(ISA_END_ADDRESS, end, PMD_SIZE, PMD_SIZE);

	/*
	 * We start from the top (end of memory) and go to the bottom.
	 * The memblock_find_in_range() gets us a block of RAM from the
	 * end of RAM in [min_pfn_mapped, max_pfn_mapped) used as new pages
	 * for page table.
	 */
	/* (2) 创建 1M 以上的线性地址区的映射
			调用init_range_memory_mapping() → init_memory_mapping()
			使用for_each_mem_pfn_range()逐个遍历memblock.memory中的区域,建立起对应的direct mapping映射
	 */
	//start = ISA_END_ADDRESS; 从 1M 开始的线性地址区映射
	init_range_memory_mapping(start,last_start);

	/* (3) 重新加载cr3,正式启用`swapper_pg_dir`页表 */
	load_cr3(swapper_pg_dir);
	__flush_tlb_all();
	......
}
/*
 * We need to iterate through the E820 memory map and create direct mappings
 * for only E820_RAM and E820_KERN_RESERVED regions. We cannot simply
 * create direct mappings for all pfns from [0 to max_low_pfn) and
 * [4GB to max_pfn) because of possible memory holes in high addresses
 * that cannot be marked as UC by fixed/variable range MTRRs.
 * Depending on the alignment of E820 ranges, this may possibly result
 * in using smaller size (i.e. 4K instead of 2M or 1G) page tables.
 *
 * init_mem_mapping() calls init_range_memory_mapping() with big range.
 * That range would have hole in the middle or ends, and only ram parts
 * will be mapped in init_range_memory_mapping().
 */
static unsigned long __init init_range_memory_mapping(
					   unsigned long r_start,
					   unsigned long r_end)
{
	unsigned long start_pfn, end_pfn;
	unsigned long mapped_ram_size = 0;
	int i;

	for_each_mem_pfn_range(i, MAX_NUMNODES, &start_pfn, &end_pfn, NULL) {
		u64 start = clamp_val(PFN_PHYS(start_pfn), r_start, r_end);
		u64 end = clamp_val(PFN_PHYS(end_pfn), r_start, r_end);
		if (start >= end)
			continue;

		/*
		 * if it is overlapping with brk pgt, we need to
		 * alloc pgt buf from memblock instead.
		 */
		can_use_brk_pgt = max(start, (u64)pgt_buf_end<<PAGE_SHIFT) >=
				    min(end, (u64)pgt_buf_top<<PAGE_SHIFT);
		init_memory_mapping(start, end);
		mapped_ram_size += end - start;
		can_use_brk_pgt = true;
	}

	return mapped_ram_size;
}
// linux-3.10.1/arch/x86/mm/init.c

/*
 * Setup the direct mapping of the physical memory at PAGE_OFFSET.
 * This runs before bootmem is initialized and gets pages directly from
 * the physical memory. To access them they are temporarily mapped.
 */
unsigned long __init_refok init_memory_mapping(unsigned long start,
					       unsigned long end)
{
	struct map_range mr[NR_RANGE_MR];
	unsigned long ret = 0;
	int nr_range, i;

	pr_info("init_memory_mapping: [mem %#010lx-%#010lx]\n",
	       start, end - 1);

	memset(mr, 0, sizeof(mr));
	/* (1) 将目标区域按照对齐,尽可能的切割成大块。
			因为direct mapping区域一旦创建就不会动态的撤销,所以我们尽可能使用huge page去映射
			pud huge page = 1G
			pmd huge page = 2M
	 */
	nr_range = split_mem_range(mr, 0, start, end);

	/* (2) 针对切割后的物理地址区域,创建`pud/pmd/pte`映射页表 */
	for (i = 0; i < nr_range; i++)
		ret = kernel_physical_mapping_init(mr[i].start, mr[i].end,
						   mr[i].page_size_mask);

	add_pfn_range_mapped(start >> PAGE_SHIFT, ret >> PAGE_SHIFT);

	return ret >> PAGE_SHIFT;
}
// linux-3.10.1/arch/x86/mm/init_64.c

unsigned long __meminit
kernel_physical_mapping_init(unsigned long start,
			     unsigned long end,
			     unsigned long page_size_mask)
{
	bool pgd_changed = false;
	unsigned long next, last_map_addr = end;
	unsigned long addr;

	start = (unsigned long)__va(start);
	end = (unsigned long)__va(end);
	addr = start;

	//逐个创建地址对应的`pud/pmd/pte`映射页表结构 */
	for (; start < end; start = next) {
		// 从swapper_pg_dir中获取pgd 
		pgd_t *pgd = pgd_offset_k(start);
		pud_t *pud;

		next = (start & PGDIR_MASK) + PGDIR_SIZE;

		if (pgd_val(*pgd)) {
			pud = (pud_t *)pgd_page_vaddr(*pgd);
			last_map_addr = phys_pud_init(pud, __pa(start),
						 __pa(end), page_size_mask);
			continue;
		}

		pud = alloc_low_page();
		last_map_addr = phys_pud_init(pud, __pa(start), __pa(end),
						 page_size_mask);

		spin_lock(&init_mm.page_table_lock);
		pgd_populate(&init_mm, pgd, pud);
		spin_unlock(&init_mm.page_table_lock);
		pgd_changed = true;
	}

	if (pgd_changed)
		sync_global_pgds(addr, end - 1);

	__flush_tlb_all();

	return last_map_addr;
}

在 kernel_physical_mapping_init 里,先通过 __va 将物理地址转换为虚拟地址,然后再创建虚拟地址和物理地址的映射页表。你可能会问,怎么这么麻烦啊?既然对于内核来讲,我们可以用 __va 和 __pa 直接在虚拟地址和物理地址之间直接转来转去,为啥还要辛辛苦苦建立页表呢?因为这是 CPU 和内存的硬件的需求,也就是说,CPU 在保护模式下访问虚拟地址的时候,就会用 CR3 这个寄存器,这个寄存器是 CPU 定义的,作为操作系统,我们是软件,只能按照硬件的要求来。

三、内核页表映射到用户页表

3.1 内核页表与用户页表关联

在上面系统初始化后内核页表后,主内核页表还未被任何进程或者任何内核线程直接使用,以主内核页表的最高目录项部分(即swapper_pg_dir)作为参考模型,为系统每个普通进程对应的页全局目录项提供参考模型。进程的页表pgd会映射整个内核地址空间。

内核态地址对应的相关页表项,对于所有进程来说都是相同的(因为内核空间对所有进程来说都是共享的),而这部分页表内容其实就来源于“内核页表”,即每个进程的“进程页表”中内核态地址相关的页表项都是“内核页表”的一个拷贝。

// linux-3.10.1/kernel/fork.c

do_fork()
	-->copy_process()
		-->copy_mm()
			-->dup_mm() /*
						 * Allocate a new mm structure and copy contents from the
						 * mm structure of the passed in task structure.
						 */
				-->mm_alloc_pgd()
					-->pgd_alloc()
						-->pgd_ctor()

pgd_ctor拷贝了对于 swapper_pg_dir 的引用。swapper_pg_dir 是内核页表的最顶级的全局页目录。
一个进程的虚拟地址空间包含用户态和内核态两部分。为了从虚拟地址空间映射到物理页面,页表也分为用户地址空间的页表和内核页表。在内核里面,映射靠内核页表,这里内核页表会拷贝一份到进程的页表。
x86_64体系架构只提供一个页表基地址寄存器CR3,内核不使用单独的页表,而是把自己映射到应用程序的高地址部分,因此在系统调用是不会切换页表。(这里没有考虑内核页表隔离的情况)。
关于内核页表隔离,请参考:内核页表隔离 (KPTI) 详解

比如:以 32 位的 x86 为例,一个用户进程有4GB虚拟地址空间,内核占据 1GB 的高地址虚拟地址空间,用户进程占据余下的 3GB。用户进程的这1GB高虚拟地址空间就是内核将自己的虚拟地址映射到了每个用户态进程的虚拟地址空间,映射的这部分地址空间相当于被 process 和 kernel 共享,因此进程调用系统调用时就不需要切换页表了,避免进入和离开内核时的地址空间(页表)切换开销。
内核的这 1GB 空间通过设置权限位以避免被用户态读写。

如果没有KPTI,每当执行用户空间代码(应用程序)时,Linux会在其分页表中保留整个内核内存的映射,并保护其访问。这样做的优点是当应用程序向内核发送系统调用或收到中断时,内核页表始终存在,可以避免绝大多数上下文切换相关的开销(TLB刷新、页表交换等)。

// linux-3.10.1/arch/x86/include/asm/pgtable.h
/*
 * clone_pgd_range(pgd_t *dst, pgd_t *src, int count);
 *
 *  dst - pointer to pgd range anwhere on a pgd page
 *  src - ""
 *  count - the number of pgds to copy.
 *
 * dst and src can be on the same page, but the range must not overlap,
 * and must not cross a page boundary.
 */
static inline void clone_pgd_range(pgd_t *dst, pgd_t *src, int count)
{
       memcpy(dst, src, count * sizeof(pgd_t));
}
// linux-3.10.1/arch/x86/mm/pgtable.c
static void pgd_ctor(struct mm_struct *mm, pgd_t *pgd)
{
	/* If the pgd points to a shared pagetable level (either the
	   ptes in non-PAE, or shared PMD in PAE), then just copy the
	   references from swapper_pg_dir. */
	if (PAGETABLE_LEVELS == 2 ||
	    (PAGETABLE_LEVELS == 3 && SHARED_KERNEL_PMD) ||
	    PAGETABLE_LEVELS == 4) {
		clone_pgd_range(pgd + KERNEL_PGD_BOUNDARY,
				swapper_pg_dir + KERNEL_PGD_BOUNDARY,
				KERNEL_PGD_PTRS);
	}

	/* list required to sync kernel mapping updates */
	if (!SHARED_KERNEL_PMD) {
		pgd_set_mm(pgd, mm);
		pgd_list_add(pgd);
	}
}

3.2 内核页表更新

“内核页表”由内核自己维护并更新,以vmalloc为例(最常使用),vmalloc分配在虚拟地址空间连续但物理地址空间不连续的物理内存,这部分区域对应的线性地址在内核使用vmalloc分配内存时,其实就已经分配了相应的物理内存,并做了相应的映射,建立了相应的页表项,但相关页表项仅写入了“内核页表”,并没有实时更新到“进程页表中”,内核在这里使用了“延迟更新”的策略,将“进程页表”真正更新推迟到第一次访问相关线性地址,发生page fault时,此时在page fault的处理流程中进行“进程页表”的更新。

在vmalloc区发生page fault时,才会将“内核页表”同步到“进程页表”中。

内核会确保对内核页表全局目录的修改能给同步到每个进程实际使用的页全局目录中。

参考资料

Linux 3.10.0

极客时间:趣谈操作系统
https://blog.csdn.net/pwl999/article/details/112055498
https://www.jianshu.com/p/242ba363e4ed
https://mp.weixin.qq.com/s/L06_XqWk2pA9vMTVGiHFbg

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐