页表管理

页表管理

本节内容讲解 Chcore 页表管理部分的源码(不包括缺页管理,缺页管理单独讲解),我们将从 Chcore 页表管理的核心数据结构讲起,并进一步解析其页表管理的函数实际实现

核心数据结构

Chcore 中表示页表的核心数据结构如下方源码所示:

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
/* page_table_entry type */
typedef union {
struct {
u64 is_valid : 1,
is_table : 1,
ignored1 : 10,
next_table_addr : 36,
reserved : 4,
ignored2 : 7,
PXNTable : 1, // Privileged Execute-never for next level
XNTable : 1, // Execute-never for next level
APTable : 2, // Access permissions for next level
NSTable : 1;
} table;
struct {
u64 is_valid : 1,
is_table : 1,
attr_index : 3, // Memory attributes index
NS : 1, // Non-secure
AP : 2, // Data access permissions
SH : 2, // Shareability
AF : 1, // Accesss flag
nG : 1, // Not global bit
reserved1 : 4,
nT : 1,
reserved2 : 13,
pfn : 18,
reserved3 : 2,
GP : 1,
reserved4 : 1,
DBM : 1, // Dirty bit modifier
Contiguous : 1,
PXN : 1, // Privileged execute-never
UXN : 1, // Execute never
soft_reserved : 4,
PBHA : 4; // Page based hardware attributes
} l1_block;
struct {
u64 is_valid : 1,
is_table : 1,
attr_index : 3, // Memory attributes index
NS : 1, // Non-secure
AP : 2, // Data access permissions
SH : 2, // Shareability
AF : 1, // Accesss flag
nG : 1, // Not global bit
reserved1 : 4,
nT : 1,
reserved2 : 4,
pfn : 27,
reserved3 : 2,
GP : 1,
reserved4 : 1,
DBM : 1, // Dirty bit modifier
Contiguous : 1,
PXN : 1, // Privileged execute-never
UXN : 1, // Execute never
soft_reserved : 4,
PBHA : 4; // Page based hardware attributes
} l2_block;
struct {
u64 is_valid : 1,
is_page : 1,
attr_index : 3, // Memory attributes index
NS : 1, // Non-secure
AP : 2, // Data access permissions
SH : 2, // Shareability
AF : 1, // Accesss flag
nG : 1, // Not global bit
pfn : 36,
reserved : 3,
DBM : 1, // Dirty bit modifier
Contiguous : 1,
PXN : 1, // Privileged execute-never
UXN : 1, // Execute never
soft_reserved : 4,
PBHA : 4, // Page based hardware attributes
ignored : 1;
} l3_page;
u64 pte;
} pte_t;

/* page_table_page type */
typedef struct {
pte_t ent[PTP_ENTRIES];
} ptp_t;

共有两种数据结构,分别是:

  • pte_t :即页表项,表示一个具体的页表
  • ptp_t :即页表页,我们可以看见它是由一个 pte_t 的数组构成的结构体

我们这里重点看一下 pte_t 的定义——它采用了 bit-fields 和 union 的语法

所谓 bit-fields,即

1
type member_name : bit_width

每个字段后面的数字表示该字段占用的位数,编译器会自动将这些字段打包到一个 u64 中,字段的总位数不能超过基础类型(这里是 u64)的大小,由此我们可以总结该数据结构的特点:

  • 使用 union 来表示不同类型的页表项
  • 支持 4 种格式:table(指向下级页表)、l1_blockl2_block(大页)和l3_page(4KB 页)
  • 通过 bit-field 精确控制每个控制位的位置
  • 允许以不同方式解释同一块内存,可以直接访问原始值或者结构化的字段

这时候还有一个问题,不同架构所用到的页表项是不一样的,所以我们需要一个通用页表项来处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @brief Architecture-independent PTE structure, containing some useful
* information which is shared by all architectures.
*
* This struct can be used to write architecture-independent code to parse
* or manipulate PTEs.
*/
struct common_pte_t {
/** Physical Page Number */
unsigned long ppn;
/** ChCore VMR permission, architecture-independent */
vmr_prop_t perm;
unsigned char
/** This PTE is valid or not */
valid : 1,
/** This PTE is accessed by hardware or not */
access : 1,
/** This PTE is written by hardware or not */
dirty : 1, _unused : 4;
};

它是一个架构无关的页表项抽象,主要作用是提供一个统一的接口来处理不同架构的页表项

在函数实现中,我们会有相应的辅助函数来提供将页表项和通用页表项之间转换的功能,如下例所示

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
// 从架构相关的PTE转换为通用PTE
void parse_pte_to_common(pte_t *pte, unsigned int level, struct common_pte_t *ret)
{
switch (level) {
case L3:
ret->ppn = pte->l3_page.pfn; // 提取物理页号
ret->perm = 0;
ret->perm |= (pte->l3_page.UXN ? 0 : VMR_EXEC); // 转换执行权限
ret->perm |= __ap_to_vmr_prot(pte->l3_page.AP); // 转换访问权限
ret->perm |= (pte->l3_page.attr_index == DEVICE_MEMORY ?
VMR_DEVICE : 0); // 转换内存属性
ret->access = pte->l3_page.AF; // 转换访问标志
ret->dirty = pte->l3_page.DBM; // 转换脏页标志
ret->valid = pte->l3_page.is_valid; // 转换有效位
break;
}
}

// 从通用PTE更新到架构相关PTE,不过这里目前还只支持L3级别的更新
void update_pte(pte_t *dest, unsigned int level, struct common_pte_t *src)
{
switch (level) {
case L3:
dest->l3_page.pfn = src->ppn; // 更新物理页号
dest->l3_page.AP = __vmr_prot_to_ap(src->perm); // 更新访问权限
dest->l3_page.UXN = ((src->perm & VMR_EXEC) ? // 更新执行权限
AARCH64_MMU_ATTR_PAGE_UX :
AARCH64_MMU_ATTR_PAGE_UXN);
dest->l3_page.is_valid = src->valid; // 更新有效位
dest->l3_page.AF = src->access; // 更新访问标志
dest->l3_page.DBM = src->dirty; // 更新脏页标志
break;
}
}

函数功能实现

正如我们的 Lab 文档所提到的,内核启动阶段所做的事情只是配置了一个粗粒度的页表系统。而实际操作系统所需要的页表管理则远不止于此。我们需要一个更细粒度的页表实现,提供映射、取消映射、查询等功能。而这些功能在源码中则以各种接口(接口在 mmu.h 中)呈现,并在 page_table.c 中实现

还是先看看接口是怎么定义的,再来看实现

接口定义

1
2
3
4
5
6
7
int map_range_in_pgtbl_kernel(void *pgtbl, vaddr_t va, paddr_t pa,
size_t len, vmr_prop_t flags);
int map_range_in_pgtbl(void *pgtbl, vaddr_t va, paddr_t pa,
size_t len, vmr_prop_t flags, long *rss);
int unmap_range_in_pgtbl(void *pgtbl, vaddr_t va, size_t len, long *rss);
int query_in_pgtbl(void *pgtbl, vaddr_t va, paddr_t *pa, pte_t **entry);
int mprotect_in_pgtbl(void *pgtbl, vaddr_t va, size_t len, vmr_prop_t prop);

从上到下介绍一遍;

  • map_range_in_pgtbl :页表映射函数,又分为内核态与用户态,但是实现逻辑基本是一样的,因此在源码中会用一个 common 辅助函数来实现
  • unmap_range_in_pgtbl :取消页表映射函数
  • query_in_pgtbl :页表查询函数
  • mprotect_in_pgtbl :页表权限修改函数

源码解析

map_range_in_pgtbl

我们先对比一下两个函数的接口以及源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int map_range_in_pgtbl_kernel(
void *pgtbl, // 页表基地址
vaddr_t va, // 要映射的虚拟地址起始位置
paddr_t pa, // 要映射的物理地址起始位置
size_t len, // 映射长度
vmr_prop_t flags // 映射属性(读/写/执行权限等)
)

int map_range_in_pgtbl(
void *pgtbl, // 页表基地址
vaddr_t va, // 虚拟地址起始位置
paddr_t pa, // 物理地址起始位置
size_t len, // 映射长度
vmr_prop_t flags, // 映射属性
long *rss // 常驻集大小计数器
)

可以看到,非内核的页表映射函数还多了一个 rss 计数器,它的作用是跟踪用户进程的内存使用情况

再来看其实现,会发现都用到了一个 common 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Map vm range in kernel */
int map_range_in_pgtbl_kernel(void *pgtbl, vaddr_t va, paddr_t pa,
size_t len, vmr_prop_t flags)
{
return map_range_in_pgtbl_common(pgtbl, va, pa, len, flags,
KERNEL_PTE, NULL);
}

/* Map vm range in user */
int map_range_in_pgtbl(void *pgtbl, vaddr_t va, paddr_t pa,
size_t len, vmr_prop_t flags, long *rss)
{
return map_range_in_pgtbl_common(pgtbl, va, pa, len, flags,
USER_PTE, rss);
}

那么关键就在这个 map_range_in_pgtbl_common 函数,我们学习学习它的源码实现

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
static int map_range_in_pgtbl_common(void *pgtbl, vaddr_t va, paddr_t pa, size_t len,
vmr_prop_t flags, int kind, long *rss)
{
s64 total_page_cnt;
ptp_t *l0_ptp, *l1_ptp, *l2_ptp, *l3_ptp;
pte_t *pte;
int ret;
int pte_index; // the index of pte in the last level page table
int i;

BUG_ON(pgtbl == NULL); // alloc the root page table page at first
BUG_ON(va % PAGE_SIZE);
total_page_cnt = len / PAGE_SIZE + (((len % PAGE_SIZE) > 0) ? 1 : 0);

l0_ptp = (ptp_t *)pgtbl;

l1_ptp = NULL;
l2_ptp = NULL;
l3_ptp = NULL;

while (total_page_cnt > 0) {
// l0
ret = get_next_ptp(l0_ptp, L0, va, &l1_ptp, &pte, true, rss);
BUG_ON(ret != 0);

// l1
ret = get_next_ptp(l1_ptp, L1, va, &l2_ptp, &pte, true, rss);
BUG_ON(ret != 0);

// l2
ret = get_next_ptp(l2_ptp, L2, va, &l3_ptp, &pte, true, rss);
BUG_ON(ret != 0);

// l3
// step-1: get the index of pte
pte_index = GET_L3_INDEX(va);
for (i = pte_index; i < PTP_ENTRIES; ++i) {
pte_t new_pte_val;

new_pte_val.pte = 0;
new_pte_val.l3_page.is_valid = 1;
new_pte_val.l3_page.is_page = 1;
new_pte_val.l3_page.pfn = pa >> PAGE_SHIFT;
set_pte_flags(&new_pte_val, flags, kind);
l3_ptp->ent[i].pte = new_pte_val.pte;

va += PAGE_SIZE;
pa += PAGE_SIZE;
if (rss)
*rss += PAGE_SIZE;
total_page_cnt -= 1;
if (total_page_cnt == 0)
break;
}
}

dsb(ishst);
isb();

/* Since we are adding new mappings, there is no need to flush TLBs. */
return 0;
}

总体上就是参数检查——计算需要映射的总页数——开 while 循环开始映射

1
2
3
// 一次循环可以映射的最大页数
最大映射数 = PTP_ENTRIES - pte_index
= 512 - (va >> 12 & 0x1FF)

数据结构关系如下:

1
2
3
L0页表 -> L1页表 -> L2页表 -> L3页表 -> 物理页面
| | | |
512512512512

下面是详细的函数执行逻辑:

  • 参数检查,并计算需要映射的总页数,这里的方式是向上取整
  • 初始化页表指针,为后面的大循环做准备
  • 进入 while 循环,依次获取四级页表的页表页,其本质就是位运算,可以回顾一下机器启动部分关于页表映射的讲解
  • 获取到 L3 页表的索引,并尽可能多的去映射,映射时需要设置其页表项字段以及更新 rss 和页表页数组
  • 重复 while 循环直到映射完毕,并建立数据和指令同步屏障

这里再来明晰一下 while 循环的作用:因为每个 L3 级别的页表页只能映射 2^9=512 个页表项,因此当映射需求较大的时候就需要多轮循环才能映射完毕

我们假设某次映射需求有 2000 个页表项需要被映射,那么会发生如下事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 假设要映射2000个页面(约8MB)
初始:total_page_cnt = 2000

第一次外层循环:
- 找到第一个L3页表
- 从pte_index开始映射
- 假设pte_index = 100
- 可以映射412个页面(512-100)
- total_page_cnt = 1588

第二次外层循环:
- 找到/创建下一个L3页表
- 从索引0开始映射
- 可以映射512个页面
- total_page_cnt = 1076

... 循环继续 ...

循环会一直执行,直到完成所有映射需求或者遇到错误(如内存不足)

unmap_in_range_pgtbl

还是先上源码

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
int unmap_range_in_pgtbl(void *pgtbl, vaddr_t va, size_t len, long *rss)
{
s64 total_page_cnt; // must be signed
s64 left_page_cnt_in_current_level;
ptp_t *l0_ptp, *l1_ptp, *l2_ptp, *l3_ptp;
pte_t *pte;
vaddr_t old_va;

int ret;
int pte_index; // the index of pte in the last level page table
int i;

BUG_ON(pgtbl == NULL);

l0_ptp = (ptp_t *)pgtbl;

total_page_cnt = len / PAGE_SIZE + (((len % PAGE_SIZE) > 0) ? 1 : 0);
while (total_page_cnt > 0) {
old_va = va;
// l0
ret = get_next_ptp(l0_ptp, L0, va, &l1_ptp, &pte, false, NULL);
if (ret == -ENOMAPPING) {
left_page_cnt_in_current_level =
(L0_PER_ENTRY_PAGES
- GET_L1_INDEX(va) * L1_PER_ENTRY_PAGES);
total_page_cnt -=
(left_page_cnt_in_current_level
> total_page_cnt ?
total_page_cnt :
left_page_cnt_in_current_level);
va += left_page_cnt_in_current_level * PAGE_SIZE;
continue;
}

// l1
ret = get_next_ptp(l1_ptp, L1, va, &l2_ptp, &pte, false, NULL);
if (ret == -ENOMAPPING) {
left_page_cnt_in_current_level =
(L1_PER_ENTRY_PAGES
- GET_L2_INDEX(va) * L2_PER_ENTRY_PAGES);
total_page_cnt -=
(left_page_cnt_in_current_level
> total_page_cnt ?
total_page_cnt :
left_page_cnt_in_current_level);
va += left_page_cnt_in_current_level * PAGE_SIZE;
continue;
}

// l2
ret = get_next_ptp(l2_ptp, L2, va, &l3_ptp, &pte, false, NULL);
if (ret == -ENOMAPPING) {
left_page_cnt_in_current_level =
(L2_PER_ENTRY_PAGES
- GET_L3_INDEX(va) * L3_PER_ENTRY_PAGES);
total_page_cnt -=
(left_page_cnt_in_current_level
> total_page_cnt ?
total_page_cnt :
left_page_cnt_in_current_level);
va += left_page_cnt_in_current_level * PAGE_SIZE;
continue;
}

// l3
// step-1: get the index of pte
pte_index = GET_L3_INDEX(va);
for (i = pte_index; i < PTP_ENTRIES; ++i) {
if (l3_ptp->ent[i].l3_page.is_valid && rss)
*rss -= PAGE_SIZE;

l3_ptp->ent[i].pte = PTE_DESCRIPTOR_INVALID;
va += PAGE_SIZE;
total_page_cnt -= 1;
if (total_page_cnt == 0)
break;
}
recycle_pgtable_entry(l0_ptp, l1_ptp, l2_ptp, l3_ptp, old_va, rss);
}

dsb(ishst);
isb();

return 0;
}

整体逻辑和 map 时候的逻辑相似,只是在 while 大循环里,多了一个“跳过未映射区域”的操作,这样可以避免不必要的页表遍历,减少 unmap 的用时和资源开销

1
2
3
4
// 举例:如果L2级别未映射
// L2_PER_ENTRY_PAGES = 512 * 512 = 262144
// 可以一次跳过大量未映射页面
left_page_cnt_in_current_level = L1_PER_ENTRY_PAGES - ...

而在 L3 部分真正解除映射的代码,又会涉及到如下操作:

  • 判断页面是否有效,有效则更新为无效,并更新 rss 计数器
  • 回收掉无效的页表项,即 recycle_pagtable_entry 函数,其实现即用到了之前提到的 kfree 等

同样的,假设我们有解除映射的页表需求,可能的工作流程示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 假设要解除映射1000000个页面
初始:total_page_cnt = 1000000

// 情况1:遇到未映射区域
if (L2未映射) {
跳过整个L2范围
更新total_page_cnt
继续下一个区域
}

// 情况2:找到映射区域
在L3页表中:
解除映射
更新RSS
尝试回收页表页

query_in_pgtbl

和上面两个函数一样,查询函数的实现逻辑是换汤不换药的,但是需要添加相应的特色内容,以满足查询本身的需求

注意后两个参数是输出

1
2
3
4
5
6
int query_in_pgtbl(
void *pgtbl, // 页表基地址
vaddr_t va, // 要查询的虚拟地址
paddr_t *pa, // 输出:对应的物理地址
pte_t **entry // 输出:对应的页表项(可选)
)
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
/*
* Translate a va to pa, and get its pte for the flags
*/
int query_in_pgtbl(void *pgtbl, vaddr_t va, paddr_t *pa, pte_t **entry)
{
/* On aarch64, l0 is the highest level page table */
ptp_t *l0_ptp, *l1_ptp, *l2_ptp, *l3_ptp;
ptp_t *phys_page;
pte_t *pte;
int ret;

// L0 page table
l0_ptp = (ptp_t *)pgtbl;
ret = get_next_ptp(l0_ptp, L0, va, &l1_ptp, &pte, false, NULL);
if (ret < 0)
return ret;

// L1 page table
ret = get_next_ptp(l1_ptp, L1, va, &l2_ptp, &pte, false, NULL);
if (ret < 0)
return ret;
else if (ret == BLOCK_PTP) {
*pa = virt_to_phys((vaddr_t)l2_ptp) + GET_VA_OFFSET_L1(va);
if (entry)
*entry = pte;
return 0;
}

// L2 page table
ret = get_next_ptp(l2_ptp, L2, va, &l3_ptp, &pte, false, NULL);
if (ret < 0)
return ret;
else if (ret == BLOCK_PTP) {
*pa = virt_to_phys((vaddr_t)l3_ptp) + GET_VA_OFFSET_L2(va);
if (entry)
*entry = pte;
return 0;
}

// L3 page table
ret = get_next_ptp(l3_ptp, L3, va, &phys_page, &pte, false, NULL);
if (ret < 0)
return ret;

*pa = virt_to_phys((vaddr_t)phys_page) + GET_VA_OFFSET_L3(va);
if (entry)
*entry = pte;
return 0;
}

整体上除了一级一级页表往下查询+错误处理之外,还添加了一个额外的步骤——支持大页映射查询,如果发现相应的高级页表页是一个大页,那么则直接通过大页基地址+页内偏移得到最终返回值

同样的,如果一路成功到了 L3 页表页,那么说明我们要查询的就是个基本的 4KB 小页,直接同样操作获取物理地址就行。相当于该函数一共支持三种页面大小:

  • 1GB (L1 block)
  • 2MB (L2 block)
  • 4KB (L3 page)

mprotect_in_pgtbl

最后我们再来看看修改页表权限的操作是如何实现的,上源码

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
int mprotect_in_pgtbl(void *pgtbl, vaddr_t va, size_t len, vmr_prop_t flags)
{
s64 total_page_cnt; // must be signed
ptp_t *l0_ptp, *l1_ptp, *l2_ptp, *l3_ptp;
pte_t *pte;
int ret;
int pte_index; // the index of pte in the last level page table
int i;

BUG_ON(pgtbl == NULL);
BUG_ON(va % PAGE_SIZE);

l0_ptp = (ptp_t *)pgtbl;

total_page_cnt = len / PAGE_SIZE + (((len % PAGE_SIZE) > 0) ? 1 : 0);
while (total_page_cnt > 0) {
// l0
ret = get_next_ptp(l0_ptp, L0, va, &l1_ptp, &pte, false, NULL);
if (ret == -ENOMAPPING) {
total_page_cnt -= L0_PER_ENTRY_PAGES;
va += L0_PER_ENTRY_PAGES * PAGE_SIZE;
continue;
}

// l1
ret = get_next_ptp(l1_ptp, L1, va, &l2_ptp, &pte, false, NULL);
if (ret == -ENOMAPPING) {
total_page_cnt -= L1_PER_ENTRY_PAGES;
va += L1_PER_ENTRY_PAGES * PAGE_SIZE;
continue;
}

// l2
ret = get_next_ptp(l2_ptp, L2, va, &l3_ptp, &pte, false, NULL);
if (ret == -ENOMAPPING) {
total_page_cnt -= L2_PER_ENTRY_PAGES;
va += L2_PER_ENTRY_PAGES * PAGE_SIZE;
continue;
}

// l3
// step-1: get the index of pte
pte_index = GET_L3_INDEX(va);
for (i = pte_index; i < PTP_ENTRIES; ++i) {
/* Modify the permission in the pte if it exists */
if (!IS_PTE_INVALID(l3_ptp->ent[i].pte))
set_pte_flags(
&(l3_ptp->ent[i]), flags, USER_PTE);

va += PAGE_SIZE;
total_page_cnt -= 1;
if (total_page_cnt == 0)
break;
}
}

return 0;
}

看过了上面的源码后,这部分源码就显得很亲切了,和 unmap 如出一辙的跳过未映射区域的操作

最后唯一更改的地方仅仅是 L3 的循环,将之前的 unmap 操作换成了调用 set_pte_flag 函数,即完成了对页表权限的修改

这里的 set_pte_flag 函数已经出镜很多次了,其实就是一个负责设置页表项的各种标志位的一个辅助函数。它把那些繁琐的内容封装在了自己体内,对外仅保留一个简单的接口供其他函数调用。如果感兴趣的同学也可以研究研究其内部实现,这里就不过多赘述了。


页表管理
http://example.com/2025/02/17/ptmanage/
作者
思源南路世一劈
发布于
2025年2月17日
许可协议