内存管理源码分析-内核页表的创建以及索引方式(基于ARM64以及4级页表)

简介

页表的主要作用是完成虚拟地址到物理地址的转换,更详细的介绍可以参考这个优秀的博客,很好地介绍了页表的理论。Linux如何实现这个页表理论呢?以及如何进行寻址呢?本文将会结合代码,从代码出发,基于ARM64的架构,分析Linux从源码上如何实现页表理论。

从一个页的地址说起

对于ARM64的架构,一个虚拟地址的大小是64bit。但是实际上并不是全部64bit都是用来寻址的,其中一部分bit会基于架构的不同有不一样的作用,但是一个最基本的应用是区分当前地址是用户态还是内核态的地址。内核可以通过宏CONFIG_ARM64_VA_BITS将用来寻址的bit的大小配置为36,39,42,47,48,52bit。不同的数目的寻址bit可以组成不同level的页表,可以参考上面推荐的博客,这里不做介绍。我们基于48bit的地址线大小结合4级页表,分析ARM64架构下的内存页是如何进行映射的。

假设目前有一个页,它的64bit虚拟地址是0xffff018140e09000。由于内存是字节寻址的,因此我们可以以字节的形式进行访问,所以这个例子是:

访问这个页的第一个字节,地址是0xffff018140e09000

访问这个页的第二个字节,地址是0xffff018140e09001

访问这个页的第二个字节,地址是0xffff018140e09002

他们只是尾部的数据有点不同,其他的位置没有变化,但是为什么会这样呢?

问题一: 这个虚拟地址隐藏了什么信息?

基于4级页表,可以知道页表共有4层映射关系,即PGDPUDPMDPTE。页表首先会根据虚拟地址找到了PGD,再从PGD里面找PUD,再从PUD里面找PMD,再从PMD里面找PTE,最后PTE表的表项记录的是页的物理地址。PGDPUDPMDPTE表的大小都是512,因此可以使用9bit来表示(1 << 9 = 512)每一个表的entry的位置信息。需要注意的是,前面提及的PGDPUDPMDPTE表中,每一个表都包含一个8字节的表项,用于记录下一级表的索引信息。

由于PTE表的表项记录的是页的物理地址,因此我们可以根据PTE获得一个页。同时由于内存是字节寻址,我们还需要对这个页内的每一个字节进行寻址,因此Linux对页内的每一个字节的位置,使用Offset来表示。如Offset=0表示页内第一个字节,Offset=1表示页内第二个字节。由于一个页的大小是4096字节,所以需要12bit来表示(1 << 12 = 4096)。最终,页内每一个字节的位置被这个12bit的地址信息全部表示出来。

根据前面分析,由此我们可以知道,内存里的每一个字节,是通过PGDPUDPMDPTE以及Offset进行索引的,它的总体结构如下:

在这里插入图片描述
如上图,虚拟地址的地址线部分由PGDPUDPMDPTEOffset,它门一起作为一个索引值,最终索引到内存的某一个字节处。以虚拟地址0xffff018140e09000为例,它的二进制值是:

1111111111111111000000011000000101000000111000001001000000000000

其中

[63:48]bit值是1111111111111111,不用来直接寻址,一般是用来区分地址是用户态还是内核态,如全1表示内核态,全0表示用户态。

[47:39]bit值是000000011,十进制值是3,因此PGD=3

[38:30]bit值是000000101,十进制值是5,因此PUD=5

[29:21]bit值是000000111,十进制值是7,因此PMD=7

[20:12]bit值是000001001,十进制是11,因此PTE=11

[11:0]bit值是000000000000,十进制是0,因此Offset=0,表示页内的第一个字节

以此类推,对于虚拟地址0xffff018140e09001,以及0xffff018140e09002,它们只是在[11:0]bit处,即Offset处不同,十进制值分别是12,分别表示页内的第二个字节以及第三个字节。

问题二: 虚拟地址是如何跟物理地址对应起来?

从问题一的论述,我们知道虚拟地址是通过一定的地址索引设计所组织起来,我们通过这个虚拟地址,可以找到内存中每一个字节在内存中的位置。但是实际上的物理内存是怎么分布的呢? PGDPUDPMDPTE等寻址表,又是怎么样进行索引到物理内存的地址呢?在探讨这个问题之前先介绍一下内核页表进程页表

内核页表和进程页表

前面提及的虚拟地址的[63:48]bit用于区分当前地址是用户态的虚拟地址,还是内核态的虚拟地址。例如使用malloc函数分配的地址就是用户态的虚拟地址,而kmalloc函数分配的地址就是内核态的虚拟地址。Linux有两种类型的页表,分别是进程页表内核页表。进程页表是进程私有的页表,内核页表是所有进程共享的页表。内核页表在系统初始化的时候就会创建,而进程页表则会在用户态进程创建的时候将内核页表复制给当前的进程页表。当进程在用户态运行时,它使用的是进程页表。当进程在内核态运行时,它使用的是内核页表。

进程页表例子: 当一个用户态的进程通过malloc分配了一个大小N个页内存区域A,然后通过指针不断访问内存空间A,此时由于程序在用户态运行,因此它使用的是进程页表进行寻址。

内核页表例子: 当进程写一个文件的时候,它会通过系统调用(如sys_write)进入内核态,此时就会使用内核页表进行寻址。

进程页表在内核对应的索引是task_struct.mm.pgd而内核页表在内核的索引是init_mm.pgd中。内核页表的索引,最终会找到内核页表对应的结构,全局变量swapper_pg_dir,它是一个数组(如下定义),记录了各个PGD在物理内存位置,因此其实这个结构就是上图提及的PGD映射表。

             pgd_t swapper_pg_dir[PTRS_PER_PGD]; // 一般PTRS_PER_PGD = 512
PGD、PUD、PMD、PTE的初始化

用于虚拟地址寻址的PGDPUDPMDPTE等寻址表在系统建立虚拟地址和物理地址之间的映射关系时建立。其初始化的代码在arch/arm64/mm/mmu.cpaging_init函数,请参考注释:

paging_init函数分析:

void __init paging_init(void)
{
	phys_addr_t pgd_phys = early_pgtable_alloc(); // 分配一个物理页构建新的PGD映射表
	pgd_t *pgdp = pgd_set_fixmap(pgd_phys); // pgd_phys是物理地址,因此通过这个函数转换为虚拟地址,这里这要理解为虚拟地址pgdp是物理地址pgd_phys的映射

	map_kernel(pgdp); // 完成内核进程地址空间的一些保留位置的映射,如.text,.data、.bss段等映射,同时完成映射的同时也会创建对应的pgd、pud、pmd、pte
	map_mem(pgdp); // 完成pgd、pud、pmd、pte的映射

	cpu_replace_ttbr1(__va(pgd_phys)); // 切换成当前页表为临时页表
	memcpy(swapper_pg_dir, pgdp, PGD_SIZE); // 将新页表对赋予给swapper_pg_dir全局页表
	cpu_replace_ttbr1(lm_alias(swapper_pg_dir)); // 再切回swapper_pg_dir页表,完成更新操作

	pgd_clear_fixmap();
	memblock_free(pgd_phys, PAGE_SIZE); // 新的映射表更新完成,释放掉临时空间

	memblock_free(__pa_symbol(swapper_pg_dir) + PAGE_SIZE,
		      __pa_symbol(swapper_pg_end) - __pa_symbol(swapper_pg_dir)
		      - PAGE_SIZE);
}
  1. 从上图以及上面代码可以知道,由于构建PGD映射表需要512个表项,每一个表项的大小是8字节,因此需要4096字节空间才可以构建PGD映射表。因此early_pgtable_alloc函数分配一个物理页(4K),用于构建临时的PGD映射表。由于页表是处于虚拟地址空间进行构建的,因此物理地址pgd_phys需要先转化为虚拟地址即pgdp
  2. 完成内核进程地址空间的一些保留位置的映射,如.text,.data、.bss段等映射,同时完成映射的同时也会创建对应的PGDPUDPMDPTE等。
  3. 完成PGDPUDPMDPTE的,即创建PGD-PUD映射,然后创建PUD-PMD重点分析map_mem这个函数。
  4. 用于新的临时PGD映射表已经构建完成,已经可以称为页表了,因为临时表已经可以根据PGDPUDPMDPTE找到对应的物理页。接下来就要替换旧的内核页表,因此首先调用cpu_replace_ttbr1函数以及memcpy函数完成页表的更新。内核页表swapper_pg_dir中会在系统运行中一直维持着,因此当系统根据虚拟地址搜索PGD时,首先就是访问这个表。内核页表可以通过init_mm.pgd进行访问,即swapper_pg_dir是所有进程共享的页表。用户进程拥有自己私有的页表,这个私有页表的初始化的。
map_mem函数分析:
static void __init map_mem(pgd_t *pgdp)
{
	phys_addr_t kernel_start = __pa_symbol(_text);
	phys_addr_t kernel_end = __pa_symbol(__init_begin);
	struct memblock_region *reg;
	int flags = 0;

	memblock_mark_nomap(kernel_start, kernel_end - kernel_start);

	for_each_memblock(memory, reg) { // 遍历所有的memblock,对嵌入式设备,一般只有一个
		phys_addr_t start = reg->base; // 物理内存的起始地址
		phys_addr_t end = start + reg->size; // 物理内存的结束地址,reg->size表示物理内存大小

		if (start >= end)
			break;
		if (memblock_is_nomap(reg))
			continue;

		__map_memblock(pgdp, start, end, PAGE_KERNEL, flags);
	}

	__map_memblock(pgdp, kernel_start, kernel_end,
		       PAGE_KERNEL, NO_CONT_MAPPINGS);
	memblock_clear_nomap(kernel_start, kernel_end - kernel_start);
}

遍历所有的memblock,对于一般嵌入式设备只有一个。然后将物理内存的起始地址和结束地址传入进去__map_memblock函数进一步处理。

static void __init __map_memblock(pgd_t *pgdp, phys_addr_t start,
				  phys_addr_t end, pgprot_t prot, int flags)
{
	__create_pgd_mapping(pgdp, start, __phys_to_virt(start), end - start,
			     prot, early_pgtable_alloc, flags);
}

这个函数将物理地址转换为虚拟地址,作为另外一个参数传入到__create_pgd_mapping函数。

static void __create_pgd_mapping(pgd_t *pgdir, phys_addr_t phys,
				 unsigned long virt, phys_addr_t size,
				 pgprot_t prot,
				 phys_addr_t (*pgtable_alloc)(void),
				 int flags)
{
	unsigned long addr, length, end, next;
	pgd_t *pgdp = pgd_offset_raw(pgdir, virt); // 获取addr对应的PUD对应的表项
    
	// 下面三个计算,是为了让物理内存由原来的按字节计算位置,改为按页计算位置
	phys &= PAGE_MASK; // 获得起始物理地址的页偏移,一般phys=0,那么起始页编号就是0
	addr = virt & PAGE_MASK; // 获得起始虚拟地址的页偏移
	length = PAGE_ALIGN(size + (virt & ~PAGE_MASK)); // 按PAGE算,内存的大小是多少(N个PAGE)

	end = addr + length; // 这里是按页算的虚拟地址的结束地址
    // 上面步骤的目的是: 算出目前正在初始化的内存,一共包含多少个页,而且页的起始地址和结束地址是什么
	do {
		next = pgd_addr_end(addr, end); // 找到当前PGD的结束地址,一般来说只会有一个PGD,因为一个PGD的范围很大,一个PGD=512GB,因此只会循环一次
		alloc_init_pud(pgdp, addr, next, phys, prot, pgtable_alloc, flags); // 初始化该PGD的
		phys += next - addr;
	} while (pgdp++, addr = next, addr != end);
}

再一次注意注意!!!分析这个函数之前,需要明确一点,上面的PGDPUDPMDPTE的分析都是基于虚拟地址!因此,我们在计算PGDPUDPMDPTE的时候,需要先将物理地址转换为虚拟地址。

pgdirpaging_init函数分配临时PGD映射表的物理内存空间pgd_phys对应的虚拟地址,pgd_offset_raw函数用于计算当前的内存起始地址属于PGD映射表的第几个表项,然后将该表项作为指针传递出来,类似于:

pgd_t *pgdp = &PGD_Entry[N]

下一步如注释所示,即获取该一段物理地址的低12位,然后获取虚拟地址的低12位,这样做的目的是让物理内存由原来的按字节计算,变为按页计算。从这里开始,物理内存的起始、物理内存的大小都是以页作为基本单位。接下来算出目前正在初始化的内存,一共包含多少个页,而且页的起始地址和结束地址是什么。

下一步进入循环,由于每一个PGD包含512个PUD,而每一个PUD包含512PMD,每一个PMD包含512个PTE,也就是512个页,因此一个PUD的的表示范围很大,如下:

1 PGD = 512 PUD = 512 * 512 PMD = 512 * 512 * 512 PTE(页) = 512 * 512 * 512 * 4KB = 512GB

因此大部分情况下(内存少于512GB),只会有一个PGD,也只会针对这个PGD构建页表(因此节省了许多内存)。

下一步就是在该PGD下通过alloc_init_pud函数构建PUD表项:

static void alloc_init_pud(pgd_t *pgdp, unsigned long addr, unsigned long end,
			   phys_addr_t phys, pgprot_t prot,
			   phys_addr_t (*pgtable_alloc)(void),
			   int flags)
{
	unsigned long next;
	pud_t *pudp;
	pgd_t pgd = READ_ONCE(*pgdp); // 获得了PUD映射表的头地址

    if (pgd_none(pgd)) { // 如果该pgd下的表项还没有分配,那么就一次性分配一个物理页,创建512个entry
		phys_addr_t pud_phys;
		BUG_ON(!pgtable_alloc);
		pud_phys = pgtable_alloc();
		__pgd_populate(pgdp, pud_phys, PUD_TYPE_TABLE); // 然后与PUD关联起来
		pgd = READ_ONCE(*pgdp);
	}
    
	pudp = pud_set_fixmap_offset(pgdp, addr); // 基于addr计算出当前addr属于PUD表的第几个表项
	do {
		pud_t old_pud = READ_ONCE(*pudp);
		next = pud_addr_end(addr, end); // PUD起始和结束位置,大小是1GB
		alloc_init_cont_pmd(pudp, addr, next, phys, prot, pgtable_alloc, flags);
		phys += next - addr;
	} while (pudp++, addr = next, addr != end);

	pud_clear_fixmap();
}

这里传入参数pgdp就是PGD映射表的对应的页表项指针,这个指针保存的值就是该PGD对应的PUD映射表的头地址。因此第一步通过pgd_t pgd = READ_ONCE(*pgdp)获得PUD映射表的头地址。然后通过pud_set_fixmap_offset函数基于addr计算出当前addr属于当前PUD映射表的第几个表项,然后循环地在各个PUD映射表表项建立对应的PMD映射表Entry,如下。

static void alloc_init_cont_pmd(pud_t *pudp, unsigned long addr,
				unsigned long end, phys_addr_t phys,
				pgprot_t prot,
				phys_addr_t (*pgtable_alloc)(void), int flags)
{
	unsigned long next;
	pud_t pud = READ_ONCE(*pudp); // 获得了PMD映射表的头地址

    if (pud_none(pud)) { // 如果该pud表项还没有分配,那么就一次性分配一个物理页,创建512个entry
		phys_addr_t pmd_phys;
		pmd_phys = pgtable_alloc();
		__pud_populate(pudp, pmd_phys, PUD_TYPE_TABLE); // 与pmd关联起来
		pud = READ_ONCE(*pudp);
	}
    
	do {
		pgprot_t __prot = prot;
		next = pmd_cont_addr_end(addr, end); // 计算一个PMD的起始和结束位置,大小是2MB
		init_pmd(pudp, addr, next, phys, __prot, pgtable_alloc, flags);
		phys += next - addr;
	} while (addr = next, addr != end);
}

alloc_init_pud函数差不多,不多做解释,接着看下一个函数:

static void init_pmd(pud_t *pudp, unsigned long addr, unsigned long end,
		     phys_addr_t phys, pgprot_t prot,
		     phys_addr_t (*pgtable_alloc)(void), int flags)
{
	unsigned long next;
	pmd_t *pmdp;

	pmdp = pmd_set_fixmap_offset(pudp, addr); // 获取addr对应的PMD对应的表项
	do {
		pmd_t old_pmd = READ_ONCE(*pmdp); // 便利PMD的表项,即遍历不同的PTE表

		next = pmd_addr_end(addr, end); // 计算一个PMD的起始和结束位置,一般是4KB(一个页)

		alloc_init_cont_pte(pmdp, addr, next, phys, prot, pgtable_alloc, flags);
		phys += next - addr;
	} while (pmdp++, addr = next, addr != end);

	pmd_clear_fixmap();
}

PTE映射表的作用是直接记录物理页在内存的位置(即页帧号),每一个PTE表项就记录了一个页帧号的信息。因此一个PTE表可以记录512个物理页的位置信息。因此我们继续看PTE如何进行初始化:

static void alloc_init_cont_pte(pmd_t *pmdp, unsigned long addr,
				unsigned long end, phys_addr_t phys,
				pgprot_t prot,
				phys_addr_t (*pgtable_alloc)(void),
				int flags)
{
	unsigned long next;
	pmd_t pmd = READ_ONCE(*pmdp); // 获得了PTE映射表的头地址

	if (pmd_none(pmd)) { // 同理,创建对应的entry
		phys_addr_t pte_phys;
		BUG_ON(!pgtable_alloc);
		pte_phys = pgtable_alloc();
		__pmd_populate(pmdp, pte_phys, PMD_TYPE_TABLE);
		pmd = READ_ONCE(*pmdp);
	}

	do {
		pgprot_t __prot = prot;
		next = pte_cont_addr_end(addr, end);
		init_pte(pmdp, addr, next, phys, __prot); // 初始化每一个PTE的表项记录的值(物理页页帧)
		phys += next - addr;
	} while (addr = next, addr != end);
}

依然是一个循环,循环当前的PTE表所有的Entry,调用init_pte初始化物理页信息。

static void init_pte(pmd_t *pmdp, unsigned long addr, unsigned long end,
		     phys_addr_t phys, pgprot_t prot)
{
	pte_t *ptep;

	ptep = pte_set_fixmap_offset(pmdp, addr); // 根据addr找到对应的PTE Entry的位置
	do {
		pte_t old_pte = READ_ONCE(*ptep); // 读这个entry的值,一般来说新建的entry是没有valid的值
		set_pte(ptep, pfn_pte(__phys_to_pfn(phys), prot)); // 将物理地址转换为页帧,然后写入PTE
		phys += PAGE_SIZE;
	} while (ptep++, addr += PAGE_SIZE, addr != end);

	pte_clear_fixmap();
}

static inline void set_pte(pte_t *ptep, pte_t pte)
{
	WRITE_ONCE(*ptep, pte);
	if (pte_valid_not_user(pte))
		dsb(ishst);
}

首先通过pte_set_fixmap_offset函数找到对应的PTE映射表的页表项的地址。通过__phys_to_pfn函数

将物理地址转换为物理页帧号pfnprot则是保护标记,可以将当前的物理页设置为只读、读写、可执行等权限。然后通过pfn_pte将页帧号先转换为物理地址,然后和权限prot组合成PTE表的Entry的结构,即:
在这里插入图片描述

最后通过set_pte函数,将组合后的PTE的Entry的值写入到对应的PTE表项中。由此,页表的建立的主要流程已经完成。下面通过一个例子,系统是如何利用页表对一个虚拟地址进行寻址的:

这里以虚拟地址0xffff000140e09000为例,二进制为:

1111111111111111000000000000000101000000111000001001000000000000

[47:39]bit值是000000000,十进制值是0,因此PGD=0

[38:30]bit值是000000101,十进制值是5,因此PUD=5

[29:21]bit值是000000111,十进制值是7,因此PMD=7

[20:12]bit值是000001001,十进制是11,因此PTE=11

它索引方式,如下图所。

  1. 根据[47:39]bit得到PGD=0的值,然后在PGD表(swapper_pg_dir)找到对应的表项(蓝色部分),表项记录的数据是下一级PUD表的头地址。
  2. 根据[38:30]bit得到了PUD=5的值,以及上一步获得的PUD表的头地址,可以获取到访问到PUD表对应的表项(绿色部分),表项记录的数据是下一级PMD表的头地址。
  3. 根据[29:21]bit得到了PMD=7的值,以及上一步获得的PMD表的头地址,可以获取到访问到PMD表对应的表项(棕黄部分),表项记录的数据是下一级PTE表的头地址。
  4. 根据[20:12]bit得到了PTE=11的值,以及上一步获得的PTE表的头地址,可以获取到访问到PTE表对应的表项(红色部分),PTE表项记录的数据是物理页的地址,以及保护信息。
  5. 获得了PTE的Entry信息后,首先通过位操作,分别得到该物理页的保护信息,以及物理地址信息。如果保护信息允许访问,那么根据物理地址信息访问物理内存,然后返回数据。

在这里插入图片描述

  • 0
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值