缺页管理

缺页管理

本节内容讲解 Chcore 中对缺页异常的处理,同时也会拓展 ARM 异常相关的知识。由于缺页异常会涉及到进程的虚拟地址区域 (VMR/VMA)和物理内存对象(PMO)的相关知识,所以我们也会对这部分作相应的解析,以帮助大家学习。本节内容顺序如下:

  • ARM 异常基础知识
  • 缺页异常函数的源码解析
  • VMR 和 PMO 相关源码解析

ARM 异常机制

异常类型

ARM 将异常分为同步异常和异步异常两大类。同步异常是由指令执行直接引发的,例如系统调用、页面错误或非法指令等,这类异常具有确定性,每次执行到特定指令时都会触发。而异步异常包括硬件中断(IRQ)、快速中断(FIQ)和错误(ERROR),它们与当前指令无关,通常由外部事件或硬件故障引起

  • sync: 同步异常,如系统调用或页面错误。
  • irq: 硬件中断请求(IRQ),由外部设备生成的中断。
  • fiq: 快速中断请求(FIQ),用于更高优先级的中断处理。
  • error: 处理其他类型的错误,如未定义指令或故障。

处理逻辑

缺页异常说到底也是 ARM 异常的一个子集,在学习缺页异常之前,不妨先看看整体的异常管理设计逻辑是什么样的

根据异常类型和当前的执行模式(内核态或用户态),设计相应的处理逻辑:

  • 同步异常处理逻辑
    • 用户态(EL0)触发的同步异常
      • 目标:不能直接让内核崩溃,必须妥善处理
      • 处理方式:根据异常类型回调对应的处理逻辑。例如,对于页面错误,可以实现需求分页或 COW 机制
    • 内核态(EL1)触发的同步异常
      • 目标:尝试修复一些提前设计的机制和可以处理的操作,其他情况应导致系统崩溃
      • 处理方式:对于可修复的异常,执行相应的修复逻辑;对于不可修复的异常,记录错误信息并触发系统崩溃
  • 中断处理逻辑
    • 目标:快速响应并处理外部设备的中断请求
    • 处理方式:调用中断处理逻辑,完成中断处理后返回到中断发生前的状态
  • 错误处理逻辑
    • 目标:处理不可恢复的错误,确保系统稳定性
    • 处理方式:记录错误信息并触发系统崩溃(panic),以便进行后续的调试和分析

落实到实现本身,则需要根据当前的异常级别寄存器(EL0 或 EL1)来区分内核态和用户态,并在异常发生时保存上下文信息。

于是对于同步异常,就可以通过特定寄存器(如 FAR_ELx)获取产生异常的指令地址,从而进行调试和分析

缺页异常源码解析

有了对 ARM 异常机制与处理逻辑的基本了解,我们就可以进一步分析 do_page_fault 的源码了

可以参考代码中的注释,英文为自带的,中文为附加的助于理解的

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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
void do_page_fault(u64 esr, u64 fault_ins_addr, int type, u64 *fix_addr)
{
vaddr_t fault_addr;
int fsc; // fault status code
int wnr;
int ret;
// 从far_el1寄存器读取汇编
fault_addr = get_fault_addr();
// #define GET_ESR_EL1_FSC(esr_el1) (((esr_el1) >> ESR_EL1_FSC_SHIFT) & FSC_MASK)
fsc = GET_ESR_EL1_FSC(esr);
switch (fsc) {
case DFSC_TRANS_FAULT_L0:
case DFSC_TRANS_FAULT_L1:
case DFSC_TRANS_FAULT_L2:
case DFSC_TRANS_FAULT_L3: {
// 地址转换错误,根据vma进行进一步处理,也就是缺页异常
ret = handle_trans_fault(current_thread->vmspace, fault_addr);
if (ret != 0) {
// 没有正确处理
/* The trap happens in the kernel */
if (type < SYNC_EL0_64) {
// EL1 的 type, 表示内核态的异常,跳转到no_context标签
goto no_context;
}
// 用户态的异常处理失败,打印信息后退出
kinfo("do_page_fault: faulting ip is 0x%lx (real IP),"
"faulting address is 0x%lx,"
"fsc is trans_fault (0b%b),"
"type is %d\n",
fault_ins_addr,
fault_addr,
fsc,
type);
kprint_vmr(current_thread->vmspace);

kinfo("current_cap_group is %s\n",
current_cap_group->cap_group_name);

sys_exit_group(-1);
}
break;
}
case DFSC_PERM_FAULT_L1:
case DFSC_PERM_FAULT_L2:
case DFSC_PERM_FAULT_L3:
// 权限错误
wnr = GET_ESR_EL1_WnR(esr);
// WnR, ESR bit[6]. Write not Read. The cause of data abort.
if (wnr) {
//写权限错误
ret = handle_perm_fault(
current_thread->vmspace, fault_addr, VMR_WRITE);
} else {
//读权限错误
ret = handle_perm_fault(
current_thread->vmspace, fault_addr, VMR_READ);
}

if (ret != 0) {
/* The trap happens in the kernel */
if (type < SYNC_EL0_64) {
goto no_context;
}
sys_exit_group(-1);
}
break;
case DFSC_ACCESS_FAULT_L1:
case DFSC_ACCESS_FAULT_L2:
case DFSC_ACCESS_FAULT_L3:
// Access faults:没有access bit的pte,此处还不支持处理,仅打印信息
kinfo("do_page_fault: fsc is access_fault (0b%b)\n", fsc);
BUG_ON(1);
break;
default:
//默认处理流程,指遇到了奇奇怪怪的的错误,且系统当前还不支持处理它们
//因此这里的处理流程就是打印错误相关的信息,然后触发内核panic终止之
kinfo("do_page_fault: faulting ip is 0x%lx (real IP),"
"faulting address is 0x%lx,"
"fsc is unsupported now (0b%b)\n",
fault_ins_addr,
fault_addr,
fsc);
kprint_vmr(current_thread->vmspace);

kinfo("current_cap_group is %s\n",
current_cap_group->cap_group_name);

BUG_ON(1);
break;
}

return;
// no_context 这一名称来源于内核的异常处理流程。
// 当内核检测到异常发生在内核态时,它发现没有“用户态上下文”
//(即不是用户程序引发的异常),因此称之为 no_context
// 这只是一个逻辑分支,用于区分内核态异常的处理流程
no_context:
kinfo("kernel_fault: faulting ip is 0x%lx (real IP),"
"faulting address is 0x%lx,"
"fsc is 0b%b\n",
fault_ins_addr,
fault_addr,
fsc);
__do_kernel_fault(esr, fault_ins_addr, fix_addr);
}
static void __do_kernel_fault(u64 esr, u64 fault_ins_addr, u64 *fix_addr)
{
kdebug("kernel_fault triggered\n");
// 内核态page fault的时候,查表尝试修复,修复不了就终止内核
if (fixup_exception(fault_ins_addr, fix_addr)) {
return;
}

BUG_ON(1);

sys_exit_group(-1);
}
C

对于这里好多种的 switch 分支,再统一分类说明一下:

转换错误(Translation Fault)

1
2
3
4
case DFSC_TRANS_FAULT_L0:
case DFSC_TRANS_FAULT_L1:
case DFSC_TRANS_FAULT_L2:
case DFSC_TRANS_FAULT_L3:
C

这组错误表示页表项不存在,也就是本文的核心缺页异常,常见场景:

  • 第一次访问堆区新分配的内存
  • 访问未映射的内存区域
  • 栈增长时的新页面访问

权限错误(Permission Fault)

1
2
3
case DFSC_PERM_FAULT_L1:
case DFSC_PERM_FAULT_L2:
case DFSC_PERM_FAULT_L3:
C

这组错误表示访问权限不足,常见场景:

  • 写入只读内存(如代码段)
  • 执行不可执行的内存
  • 用户态访问内核内存

访问错误(Access Fault)

1
2
3
case DFSC_ACCESS_FAULT_L1:
case DFSC_ACCESS_FAULT_L2:
case DFSC_ACCESS_FAULT_L3:
C

这组错误表示硬件级别的访问失败,常见场景:

  • 访问未对齐的地址
  • 硬件级别的内存访问限制
  • TLB(页表缓存)相关错误

后面几种不必过多了解,感兴趣的可以进一步阅读相关源码

接下来我们学习 VMR 和 PMO 的相关代码,之后再回过头来梳理一遍我们的缺页异常处理流程

VMR & PMO

回顾 Lab 文档,我们知道:

在 ChCore 中,一个进程的虚拟地址空间由多段“虚拟地址区域”(VMR,又称 VMA)组成,一段 VMR 记录了这段虚拟地址对应的“物理内存对象”(PMO),而 PMO 中则记录了物理地址相关信息。因此,想要处理缺页异常,首先需要找到当前进程发生页错误的虚拟地址所处的 VMR,进而才能得知其对应的物理地址,从而在页表中完成映射。

我们先来看看 VMR 的数据结构是如何设计的(已经添加了详细的注释):

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
/*
* vmregion表示一个虚拟地址空间中的连续内存区域
* 例如:代码段、数据段、堆、栈等
*/
struct vmregion {
/* 作为vmspace.vmr_list的节点,用于顺序遍历所有vmregion */
struct list_head list_node;

/* 作为vmspace.vmr_tree的节点,用于按地址快速查找vmregion */
struct rb_node tree_node;

/* 作为PMO的mapping_list的节点,用于跟踪所有映射到此PMO的vmregion */
struct list_head mapping_list_node;

/* 指向此vmregion所属的虚拟地址空间 */
struct vmspace *vmspace;

/* 此内存区域的起始虚拟地址 */
vaddr_t start;

/* 此内存区域的大小(字节数)*/
size_t size;

/* 在对应物理内存对象(PMO)中的偏移量 */
size_t offset;

/* 访问权限标志(如:可读、可写、可执行等)*/
vmr_prop_t perm;

/* 指向此区域对应的物理内存对象 */
struct pmobject *pmo;

/* 写时复制(CoW)机制中的私有页面链表 */
struct list_head cow_private_pages;
};

/*
* vmspace表示一个完整的虚拟地址空间
* 通常对应一个进程的整个地址空间
*/
struct vmspace {
/* vmregion链表的头节点,用于顺序遍历所有内存区域 */
struct list_head vmr_list;

/* vmregion红黑树的根节点,用于快速查找特定地址所在的内存区域 */
struct rb_root vmr_tree;

/* 指向此地址空间的页表根节点 */
void *pgtbl;

/* 进程上下文ID,用于避免TLB冲突 */
unsigned long pcid;

/* 用于保护vmregion操作(增删改)的锁 */
struct lock vmspace_lock;

/* 用于保护页表操作的锁 */
struct lock pgtbl_lock;

/*
* TLB刷新相关:
* 记录此vmspace在哪些CPU核心上运行过
* 用于确定需要在哪些CPU上进行TLB刷新
*/
unsigned char history_cpus[PLAT_CPU_NUM];

/* 指向堆区域的边界vmregion,用于堆的动态扩展 */
struct vmregion *heap_boundary_vmr;

/*
* 记录已映射的物理内存大小(Resident Set Size)
* 受pgtbl_lock保护
*/
long rss;
};
C

其实就是 vmregion 包含在 vmspace 里的关系,类比一本书和书里的不同章节

以下是示意图便于理解:

back_ref
1
1
has(both rbtree & list)
1
many
list_node, tree_node
1
1
vmregion
-struct list_head list_node
-struct rb_node tree_node
-struct list_head mapping_list_node
-struct vmspace* vmspace
-vaddr_t start
-size_t size
-size_t offset
-vmr_prop_t perm
-struct pmobject* pmo
-struct list_head cow_private_pages
vmspace
-struct list_head vmr_list
-struct rb_root vmr_tree
-void* pgtbl
-unsigned long pcid
-struct lock vmspace_lock
-struct lock pgtbl_lock
-unsigned char history_cpus[PLAT_CPU_NUM]
-struct vmregion* heap_boundary_vmr
-long rss

观察其设计不难发现一个“奇怪”的现象,那就是它同时维护了双向链表和红黑树的数据结构

这两种数据结构各有优劣,但若同时出现,则是一种“以空间换时间”的策略,以集众数据结构之长。虽然维护两套数据结构需要额外的内存空间和更新开销,但却能够在不同场景下都获得最优的性能表现。从缓存的视角上看,在扫描时,list 能保证新插入的项被优先遍历,有更强的 TLB 亲和性,而红黑树的设计则保证了在查找特定元素时稳定的时间发挥。这种设计在 Linux 中同样被广泛采用

举两个例子分别说明两种情况,以下代码均出自 chcore 中 vma 的操作函数的源码

链表的情况

1
2
3
4
5
6
7
8
9
10
11
static void free_vmregion(struct vmregion *vmr)
{
struct cow_private_page *cur_record = NULL, *tmp = NULL;

// 使用链表遍历所有CoW私有页面
for_each_in_list_safe (cur_record, tmp, node, &vmr->cow_private_pages) {
free_cow_private_page(cur_record);
}
list_del(&vmr->mapping_list_node);
kfree((void *)vmr);
}
C

例如在 freeVMR 时,这种遍历的操作需求就很适合用链表来实现

红黑树的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct vmregion *find_vmr_for_va(struct vmspace *vmspace, vaddr_t addr)
{
struct vmregion *vmr;
struct rb_node *node;

// 使用红黑树快速查找地址对应的VMR
node = rb_search(
&vmspace->vmr_tree,
(const void *)addr,
cmp_vmr_and_va);

if (unlikely(node == NULL))
return NULL;

vmr = rb_entry(node, struct vmregion, tree_node);
return vmr;
}
C

涉及到查找的时候,就该红黑树大显神通了,利用封装好的函数和宏,兼具效率与代码可读性

PMO

在地址空间 vmr 中,还需要保证虚拟内存地址和物理地址的映射,从而避免查进程自身空间的页表,不需要保持内核页表和每个进程页表的项的对应。这就轮到 PMO 发挥作用的时候了,我们看看其数据结构的设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* This struct represents some physical memory resource */
struct pmobject {
paddr_t start;
size_t size;
pmo_type_t type;
/* record physical pages for on-demand-paging pmo */
struct radix *radix;
/*
* The field of 'private' depends on 'type'.
* PMO_FILE: it points to fmap_fault_pool
* others: NULL
*/
void *private;
struct list_head mapping_list;
};
C

这里使用了 start 和 size 的结构,支持 copy-on-writing 和 on demand paging。具体而言,声明时,只需要记录 pmo 的 start+size,在 pmo 之中维护访问过的/没访问的物理地址集合,在出现 pagefault 的时候分配,并更新这个集合就行。而对于如何维护这个集合的问题,chcore 采用了 radix-tree 的形式,在 Linux 中也有相似应用

在 Linux 内核中,radix tree(或其改进版本 xarray)被用于管理 page cache 和内存对象(如 PMO,Physical Memory Object)时的地址到页面映射。这种选择的背后是对性能、功能和扩展性的权衡

似乎只用 bitmap 也能达到一样的效果,那么为什么不用 bitmap 呢?这是因为 radix tree 管理的 pmo 的地址空间通常是很大一段稀疏的(启用 on demand paging)。这对 bitmap 非常不友好,而 radix tree 对稀疏和懒分配有很好的支持。此外,bitmap 只能标记存在与否,而 radix tree 可以存指针,从而达到更灵活的元数据管理

回顾 trans_fault 的处理

有了 VMR&PMO 的知识,我们就可以进一步研究之前处理地址转换错误(也就是缺页异常)的时候的细节操作了:

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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
int handle_trans_fault(struct vmspace *vmspace, vaddr_t fault_addr)
{
struct vmregion *vmr;
struct pmobject *pmo;
paddr_t pa;
unsigned long offset;
unsigned long index;
int ret = 0;

/*
* Grab lock here.
* Because two threads (in same process) on different cores
* may fault on the same page, so we need to prevent them
* from adding the same mapping twice.
*/
lock(&vmspace->vmspace_lock);
vmr = find_vmr_for_va(vmspace, fault_addr);

if (vmr == NULL) {
kinfo("handle_trans_fault: no vmr found for va 0x%lx!\n",
fault_addr);
dump_pgfault_error();
unlock(&vmspace->vmspace_lock);

#if defined(CHCORE_ARCH_AARCH64) || defined(CHCORE_ARCH_SPARC)
/* kernel fault fixup is only supported on AArch64 and Sparc */
return -EFAULT;
#endif
sys_exit_group(-1);
}

pmo = vmr->pmo;
/* Get the offset in the pmo for faulting addr */
offset = ROUND_DOWN(fault_addr, PAGE_SIZE) - vmr->start + vmr->offset;
vmr_prop_t perm = vmr->perm;
switch (pmo->type) {
case PMO_ANONYM:
case PMO_SHM: {
/* Boundary check */
BUG_ON(offset >= pmo->size);

/* Get the index in the pmo radix for faulting addr */
index = offset / PAGE_SIZE;

fault_addr = ROUND_DOWN(fault_addr, PAGE_SIZE);

pa = get_page_from_pmo(pmo, index);
if (pa == 0) {
/*
* Not committed before. Then, allocate the physical
* page.
*/
void *new_va = get_pages(0);
long rss = 0;
if (new_va == NULL) {
unlock(&vmspace->vmspace_lock);
return -ENOMEM;
}
pa = virt_to_phys(new_va);
BUG_ON(pa == 0);
/* Clear to 0 for the newly allocated page */
memset((void *)phys_to_virt(pa), 0, PAGE_SIZE);
/*
* Record the physical page in the radix tree:
* the offset is used as index in the radix tree
*/
kdebug("commit: index: %ld, 0x%lx\n", index, pa);
commit_page_to_pmo(pmo, index, pa);

/* Add mapping in the page table */
lock(&vmspace->pgtbl_lock);
map_range_in_pgtbl(vmspace->pgtbl,
fault_addr,
pa,
PAGE_SIZE,
perm,
&rss);
vmspace->rss += rss;
unlock(&vmspace->pgtbl_lock);
} else {
/*
* pa != 0: the faulting address has be committed a
* physical page.
*
* For concurrent page faults:
*
* When type is PMO_ANONYM, the later faulting threads
* of the process do not need to modify the page
* table because a previous faulting thread will do
* that. (This is always true for the same process)
* However, if one process map an anonymous pmo for
* another process (e.g., main stack pmo), the faulting
* thread (e.g, in the new process) needs to update its
* page table.
* So, for simplicity, we just update the page table.
* Note that adding the same mapping is harmless.
*
* When type is PMO_SHM, the later faulting threads
* needs to add the mapping in the page table.
* Repeated mapping operations are harmless.
*/
if (pmo->type == PMO_SHM || pmo->type == PMO_ANONYM) {
/* Add mapping in the page table */
long rss = 0;
lock(&vmspace->pgtbl_lock);
map_range_in_pgtbl(vmspace->pgtbl,
fault_addr,
pa,
PAGE_SIZE,
perm,
&rss);
vmspace->rss += rss;
unlock(&vmspace->pgtbl_lock);
}
}

if (perm & VMR_EXEC) {
arch_flush_cache(fault_addr, PAGE_SIZE, SYNC_IDCACHE);
}

break;
}
case PMO_FILE: {
unlock(&vmspace->vmspace_lock);
fault_addr = ROUND_DOWN(fault_addr, PAGE_SIZE);
handle_user_fault(pmo, ROUND_DOWN(fault_addr, PAGE_SIZE));
BUG("Should never be here!\n");
break;
}
case PMO_FORBID: {
kinfo("Forbidden memory access (pmo->type is PMO_FORBID).\n");
dump_pgfault_error();

unlock(&vmspace->vmspace_lock);
sys_exit_group(-1);
break;
}
default: {
kinfo("handle_trans_fault: faulting vmr->pmo->type"
"(pmo type %d at 0x%lx)\n",
vmr->pmo->type,
fault_addr);
dump_pgfault_error();

unlock(&vmspace->vmspace_lock);
sys_exit_group(-1);
break;
}
}

unlock(&vmspace->vmspace_lock);
return ret;
}
C

结合这个处理函数,我们就可以构建出 chcore 对缺页异常整体的处理了:

  • 发生缺页异常,触发 do_page_fault 函数
  • 异常在 switch 分支中被归类为 DFSC_TRANS_FAULT_LX ,且会根据是否是内核态的错误作进一步的处理
  • 函数获取当前的虚拟地址和 vmspace,将异常转发给 handle_trans_faults 函数
  • 有了上述二物,处理函数用红黑树查找到具体的 vmregion,并得到对应的 PMO
  • 根据 PMO 的类型作进一步的处理:如匿名页面和共享内存 PMO_ANONYM, PMO_SHM 、文件映射 PMO_FILE 、禁止访问的内存 PMO_FORBID

这里同时需要注意可能出现并发的 pagefault,其语义处理根据不同 type 发生变化:如果是同个进程的多个线程,且类型为 PMO_ANONYM ,那只需要第一个线程更新 radix 即可; 如果是跨进程的线程,则需要各自更新

  • 对于匿名/共享内存,需要查询其在 radix tree 中是否已经存在记录。如果存在,就只需要在自己的页表中设置页表映射; 否则为这个 on demand paging 的页面分配空间并更新

在 PHO_SHM 共享内存的时候,多个进程的物理页面是相同的, 即各自的 vma 引用同一个 pmo。所以并发场景下后来的线程会出现 pa 已经在 radix 之中存在的情况

  • 刷新指令缓存,处理完毕

至此,缺页管理部分的源码解析也到此结束,希望能对你的学习有所裨益!


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