MIT6828 学习笔记 007 (book::c3)

MIT6828 学习笔记 007 (book::c3)

RayAlto OP

page table (分页表)是操作系统给每个进程提供私有的地址空间和内存的最流行的方式。 page table 决定了内存地址的意义以及物理内存可被访问的部分, Xv6 就是通过这个机制在一个物理内存上实现了多个进程的私有地址空间的提供。 page table 之所以是一种流行的设计是因为它提供了一定的间接性,使操作系统能实现一些技巧,比如 Xv6 :在一些地址空间映射同一块内存(一个 trampoline page );用一个没有映射的 page 保护内核栈和用户栈,后面会详细展开。

1. paging hardware (分页硬件)

前情提要:虚拟地址由 RISC-V 指令(包括用户指令和内核指令)操控;机器的 RAM 或者说物理内存是用物理地址索引的, RISC-V 的分页表硬件通过给每一个虚拟地址映射一个物理地址来连接这两种地址。

Xv6 运行在 Sv39 RISC-V 也就是说虚拟地址的 64 个 bit 只用底部的 39 个,在这种配置下,一个 RISC-V page table (分页表)逻辑上是一个由个 page table entry (PTE) (分页表入口)组成的数组,每个 PTE 含有一个 44-bit 的 physical page number (PPN) (物理分页号)和一些 flag 。分页硬件用 39 个 bit 的顶部个 bit 索引到分页表来找出 PTE ,用 PTE 的 44 个 bit 的 PPN 和剩下的个 bit 拼成一个个 bit 的物理地址,如下图。分页表为操作系统提供虚拟地址到物理地址转换的控制,其粒度为 4096 () 个 byte ,这样的一块称为分页。

分页硬件的简略工作原理

在 Sv39 RISC-V 中,虚拟地址的顶部 25 个 bit 没被用来转换(到物理地址),物理地址也有一定空间可以扩展,比如 PTE 可以给物理分页数再让出 10 个 bit 。这样的选择是基于 RISC-V 设计者对科技的预测:个 byte 是 512 GB ,这么大的地址空间对于一个跑在 RISC-V 计算机上的程序来说足够了。对于不远的将来来说大小的物理内存空间对于许多 I/O 设备和 DRAM 芯片来说是足够的,即使到了需要扩容的时候, RISC-V 的设计者也定义了 Sv48 ,这个标准下虚拟地址有 48 个 bit (bits=256TB) 。

如上图, RISV-V 处理器把一个虚拟地址转换成物理地址需要三步,实际上从分页表取出 PPN 还可以细分为三步(如下图)。分页表以三级树的形式保存在物理内存里,每一级都是一个包含了 512 个 PTE (每个 PTE 都指向一个下一级分页表分页),大小为 4096-byte 的分页表的分页。分页硬件先用索引 27 个 bit 的顶部 9 个 bit 从根分页表分页取出一个 PTE 、然后用中间 9 个 bit 从下一级分页表分页取出一个 PTE 、最后用底部 9 个 bit 最后一级分页表分页取出最后一个 PTE (上面提到的 Sv48 就是在这基础上再加一级:)。

分页硬件的详细工作原理

分页硬件对一个不存在的地址进行转换会引发 page-fault exception ,来让内核处理这个异常(后面一章会展开)。

通常情况下很多虚拟地址都没有到物理地址的映射(可能物理内存一般没有那么大,不需要那么多虚拟地址来映射),所以这种三级的结构在记录 PET 方面比上面只有一级的结构更能节省内存空间,比如一个应用程序只用地址处的几个分页,则顶级分页表分页之间的 PTE 都是无效的,内核不需要为这些第二级分页表分页以及其后面的个最后一级分页表分页分配空间。虽然这个过程是 CPU 进行加载或存储指令时的一部分,但这依然可能有降低性能的缺点。为了降低从物理内存加载 PTE 的成本, RISC-V 处理器把这些 PTE 把存在 Translation Look-aside Buffer (TLB) (转换查询缓冲器)。每个 PTE 都有一些 flag bit (kernel/riscv.h) 告诉分页硬件应该怎样使用被关联:

flag作用
PTE_VPTE 是否有效,引用无效 PTE 会引起一个异常
PTE_R分页是否可读
PTE_W分页是否可写
PTE_X分页的内容是否可以被 CPU 看作指令来执行
PTE_U分页是否允许被用户模式的指令访问

内核必须把根分页表分页的物理地址写进 satp 寄存器才能让 CPU 使用分页表, CPU 会对后面的指令生成的地址用自己的 satp 寄存器指向的分页表进行转换,每个 CPU 都有自己的 satp ,这样一来每个 CPU 可以运行自己的进程,且每个 CPU 都有一个用自己的分页表描述的地址空间。

内核通常把所有物理内存都映射进它的分页表里,这样它才能用加载或保存指令读写物理内存的所有位置。因为分页目录 (page directory) 保存在物理内存中,所以内核可以对虚拟地址使用标准保存指令来实现对分页目录的任意 PTE 的内容进行编程

  1. 物理内存指 DRAM 里的存储单元,上面的每个 byte 都有一个地址,称为物理地址。
  2. 指令只使用虚拟地址,分页硬件把这个地址转换成对应的物理地址后发给 DRAM 硬件来读写存储单元。
  3. 虚拟内存和物理内存、虚拟地址的概念不同,不是一个物理上的物体,而是一系列内核提供的,用于管理物理内存和虚拟地址的抽象/方法

2. 内核的地址空间

Xv6 内核为每个进程维护一个描述每个进程用户地址空间的分页表,再加上内核自己的描述内核地址空间的一个分页表。内核对自己地址空间的布局进行配置(如下图),使自己能够在可预测的虚拟地址上访问物理内存以及各种硬件资源 (kernel/memlayout.h) 。

内核的地址空间布局

QEMU 模拟一台计算机,这台计算机包含从物理地址 0x80000000 到至少 0x88000000 (Xv6 称其为 PHYSTOP) 的 RAM (物理内存)和例如磁盘接口的 I/O 设备, QEMU 将设备接口作为物理地址中位于 0x80000000 下面的内存映射控制寄存器 (memory-mapped control register) 暴露给软件,内核就通过读写这些特殊的物理地址访问设备而不是与 RAM 通信(下一章会详细展开)。

内核通过直接映射 (direct mapping) 访问 RAM 和内存映射设备寄存器 (memory-mapped device register) 也就是说把资源映射到和物理地址相同的虚拟地址,比如内核本身位于 KERNBASE=0x80000000 ,这个地址既是虚拟地址也是物理地址,直接映射简化了内核读写物理内存的代码,比如 fork 分配用户内存给子进程,分配器返回这块内存的物理地址, fork 在复制父进程的用户内存给子进程时直接把这个地址当作虚拟地址。有一些内核虚拟地址也不是直接映射的:

  • Trampoline page ,被映射到内核虚拟地址空间的顶部(在用户分页表中同样被映射到顶部,第四章会详细描述 Trampoline page 的作用),这里一个物理分页(保存 trampoline 代码)在内核的虚拟地址空间被映射了两次:一次在虚拟地址的顶部、一次直接映射(内核代码是直接映射的)
  • 内核栈分页,每个进程都有自己的内核栈,这个内核栈被映射到高位, Xv6 会在它的下面留下未映射的保护分页 (guard page) ,保护分页的 PTE 是无效的 (PTE_V == 0) ,所以内核一旦溢出了内核栈就会引起异常,内核就会 panic 。如果没有保护分页的话溢出的栈会覆盖其他的内核内存,造成错误的操作,所以相比之下 panic 崩溃更好一些。

内核栈也可以被直接映射,但这种设计下想要实现保护分页就涉及到了虚拟地址映射的取消,如果没有保护分页的话访问虚拟地址就相当于访问了同样位置的物理地址,所以这种设计不是很好用

内核对 trampoline 和 kernel text 的映射带有 PTE_RPTE_X 权限,所以内核可以从这些虚拟地址读取并执行指令;对其他分页的映射带有 PTE_RPTE_W ,所以内核可以读写这些分页的内存;保护分页的映射是无效的。

3. 代码:创建一个地址空间

操控地址空间和分页表的代码在 kernel/vm.c ,核心的数据结构是 pagetable_t (aka uint64_t*) ,指向 RISC-v 的根分页表分页,一个 pagetable_t 要么指向内核分页表,要么指向进程分页表。核心方法有 walk (模拟分页硬件,返回虚拟地址对应的 PTE 并按需创建)

点击展开:walk 代码分析
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
// 返回分页表 pagetable 里虚拟地址 va 对应的 PTE ,如果
// 如果 alloc != 0 则按需创建分页表分页
//
// RISC-V Sv39 格式有三级分页表,每级包含 512 个 64-bit
// 的 PTE ,虚拟地址的 64 个 bit 分配如下:
// 39..63 -- 必须为 0
// 30..38 -- level-2 索引的 9 个 bit
// 21..29 -- level-1 索引的 9 个 bit
// 12..20 -- level-0 索引的 9 个 bit
// 0..11 -- 分页内 12 个 bit 的字节偏移量
pte_t* walk(
pagetable_t pagetable, // 根分页表的物理地址
uint64 va, // 要找的 PTE 的虚拟地址
int alloc // 如果分页表不存在是否分配
) {
if (va >= MAXVA)
// 超过了最大虚拟地址
panic("walk");

// 处理 level-2 和 level-1
for (int level = 2; level > 0; level--) {
// 取出 pagetable 中当前 level 索引对应的 PTE
pte_t* pte = &pagetable[
// PX: Page table indices eXtract?
// 取出 va 中指定 level 的索引的 9 个 bit
PX(level, va)
];

if (*pte & PTE_V) { // PTE 有效
// 用这个 PTE 求出下一级 pagetable 的物理地址
pagetable =
// PTE2PA: PTE to Physical Address
// 先右移 10 位去掉 PTE 的 flags 再左移 12 位成为 56 位物理地址
// (保留 PPN 再补上 12 位 offset )
(pagetable_t)PTE2PA(*pte);
} else { // PTE 无效
// 如果 alloc != 0 则尝试在 pagetable 分配分页表
// 分配失败或 alloc == 0 则返回 0
// 注意这里 pagetable 是新分配的分页表的物理地址
if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
return 0;
// 初始化新分页表的内存
memset(pagetable, 0, PGSIZE);
// 更新这个 PTE 的值并设为有效
*pte =
// PA2PTE: Physical Address to PTE
// 右移 12 位去掉 offset 再左移 10 位成为虚拟地址
// (保留 PPN 在补上 10 位 flag 位)
PA2PTE(pagetable)
// 最后设置 PTE_V 位为 1
| PTE_V;
}
}
// 返回最后一级分页表中 level-0 的 9 位索引对应的 PTE 指针
return &pagetable[PX(0, va)];
}

说实话这不到 20 行代码我看了一个小时才理清,我实在是 dinner

mappages (为新的映射安装 PTE )

点击展开:mappages 代码分析
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
// 给从 va 开始的虚拟地址创建指向从 pa 开始的物理地址的 PTE
// (上面的 walk 只分配了空间,没有给最后取出的 PTE 赋值,
// 也就是说虽然虚拟地址可以取出一个合法的 PTE 但还不能通过这个 PTE
// 计算出物理地址值,这个函数完成了给 PTE 赋物理地址值的操作)
// 注意 va 和 size 应该与分页大小对齐,返回 0 表示成功、如果 walk()
// 不能分配所需的分页表分页时返回 -1
int mappages(
pagetable_t pagetable, // 根分页表的物理地址
uint64 va, // 起始虚拟地址
uint64 size, // PTE 数量
uint64 pa, // 起始物理地址
int perm // 要设置的其他 flag
) {
uint64 a, // 当前要处理的虚拟地址
last; // 最后的虚拟地址
pte_t *pte; // 某个 PTE

// size 不应该为 0
if(size == 0)
panic("mappages: size");

// 取对齐后的起始虚拟地址
// PGROUNDDOWN: PaGe size ROUND DOWN?
// 就是向下对齐到 Page size
// 等效于把最后 12 个 bit 设为 0
// 也就是 offset = 0 确保被 4096 (page size) 整除
a = PGROUNDDOWN(va);
// 需要创建 PTE 的最后一个物理地址(经过对齐)
last = PGROUNDDOWN(va + size - 1);

for(;;){
// 取出分页表 pagetable 下虚拟地址 a 对应的 PTE
if((pte = walk(pagetable, a, 1)) == 0)
// walk() 未能成功创建所需分页表
return -1;
if(*pte & PTE_V)
// 取出的 PTE 无效
panic("mappages: remap");
// 用物理地址 pa 给取出的 PTE 赋值,并设置 flags
*pte = PA2PTE(pa) | perm | PTE_V;
// 如果最后一个虚拟地址处理好了就退出循环
if(a == last)
break;

// 转到下一个地址
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}

kvm (Kernel Virtual Memory?) 开头的函数用于操控内核分页表, uvm (User Virtual Memory?) 开头的则用于操控用户分页表,剩下的其他函数操控两边。 copyout 把数据从内核写进用户内存空间

点击展开:copyout 代码分析
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
// 从内核复制数据给用户,从 src 复制 len 个字节到分页表 pagetable 里的虚拟地址 dstva
// 返回 0 表示成功, -1 表示失败
int copyout (
pagetable_t pagetable, // 分页表的物理地址
uint64 dstva, // 目标虚拟地址
char *src, // 数据来源
uint64 len // 数据长度
) {
uint64 n, va0, pa0;

while (len > 0) {
// 使目标虚拟地址与分页大小向下对齐
// 就结果来说 va0 是一个分页的起点
va0 = PGROUNDDOWN(dstva);
// 取出分页表 pagetable 里虚拟地址 va0 对应的物理地址
// 因为 va0 进行了对齐, walkaddr 不需要且不支持处理 offset
pa0 = walkaddr(pagetable, va0);
if (pa0 == 0)
// 虚拟地址 va0 没有映射物理地址
return -1;
// 计算当前分页的剩余空间
n = PGSIZE - (dstva - va0);
if (n > len)
// 如果当前分页剩余空间足够容纳想要复制的数据则 n = len
n = len;
// 复制数据(最多填满当前分页)
memmove((void *)(pa0 + (dstva - va0)), src, n);

// 计算剩余数据大小
len -= n;
// 更新剩余数据起始位置
src += n;
// 转到下一个分页
dstva = va0 + PGSIZE;
}
return 0;
}

copyin 从用户内存复制数据进内核

点击展开:copyin 代码分析
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
// 从用户内存复制数据进内核
// 从分页表 pagetable 里的虚拟地址 srcva 开始复制 len 个字节进 dst
// 返回 0 表示成功, -1 表示失败
int copyin (
pagetable_t pagetable, // 分页表的物理地址
char *dst, // 目标
uint64 srcva, // 数据来源虚拟地址
uint64 len // 数据长度
) {
uint64 n, va0, pa0;

while(len > 0){
// 同 copyout 12~25
va0 = PGROUNDDOWN(srcva);
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
n = PGSIZE - (srcva - va0);
if(n > len)
n = len;
// 复制数据(最多复制当前分页剩余所有内容)
memmove(dst, (void *)(pa0 + (srcva - va0)), n);

// 同 copyout 29~34
len -= n;
dst += n;
srcva = va0 + PGSIZE;
}
return 0;
}

Xv6 启动时 main (kernel/main.c:20) 调用 kvminit (kernel/vm.c:53) 来创建内核的分页表,其内部使用 kvmmake (kernel/vm.c:19) 。这个调用发生在 Xv6 启用 RISC-V 的分页功能之前,所以所有地址都是物理地址, kvmmake 首先分配一个物理内存的分页来保存根分页表分页 (kernel/vm.c:24) ,然后调用 kvmmap 配置好映射 (kernel/vm.c:27-44)(包括内核指令及数据、最高到 PHYSTOP 的物理内存以及实际为设备的内存区域)然后 (kernel/vm.c:47) 调用 proc_mapstacks (kernel/proc.c:32) , proc_mapstacks 给每个进程分配一个内核栈

点击展开:proc_mapstacks 代码分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 在虚拟地址高位给每个进程分配内核栈,每个内核栈之间用保护栈隔开
void proc_mapstacks(pagetable_t kpgtbl /* kernel page table */)
{
struct proc *p;

for(p = proc; p < &proc[NPROC]; p++) {
// 分配一块物理内存
char *pa = kalloc();
if(pa == 0)
panic("kalloc");
// 计算虚拟地址
// KSTACK: Kernel STACK
// 就结果来说就是从 trampoline 下面开始每隔一个保护栈放一个内核栈
uint64 va = KSTACK((int) (p - proc));
// 给虚拟地址映射物理地址(是 mappages 的包装)
kvmmap(kpgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
}
}

上面的代码依赖于物理地址被直接映射到内核的虚拟地址空间,比如说 walk 处理下一个 level 的分页表时要从 PTE 提取下一个 level 的分页表的物理地址,然后把这个物理地址作为虚拟地址获取再下一个 level 的分页表。然后 main (kernel/main.c:21) 调用 kvminithart (kernel/vm.c:61) 来配置内核分页表, kvminithart 会把根分页表分页的物理地址写进寄存器 satp ,之后 CPU 就会用这个内核分页表来进行地址转换,因为内核代码是直接映射的,所以下一个指令的虚拟地址会被映射到对应的物理内存地址。

RISC-V 的每个 CPU 都会把 PTE 缓存进一个 TLB (Translation Look-aside Buffer) 里, Xv6 更改了一个分页表后必须让 CPU 检查 TLB 里对应的缓存,如果不这样做的话被映射到的物理地址有可能已经是其他进程分配的了, RISC-V 有 sfence.vma 指令来刷新当前 CPU 的 TLB , Xv6 在 kvminithart 时写入 satp 寄存器之后会使用这个指令; kernel/trampoline.S:89 在回到用户空间之前也会使用这个指令。在修改 satp 之前调用 sfence.vma 也可以等待外部的存取结束来保证修改之前的指令都会使用旧的分页表(而不是新的分页表)。

为了避免一刷新就要刷新整个 TLB , RISC-V CPU 可能支持 ASIDs (Address Space IDentifiers ,地址空间标识) ,这样内核可以之刷新指定地址空间的 TLB 条目,然而 Xv6 并不利用这个特性

4. 物理内存分配

内核必须在运行时对分页表、用户内存、内核栈和 pipe 缓冲区分配/释放物理内存, Xv6 把内核代码的末端到 PHYSTOP 之间的内存用于运行时分配,每次分配/释放都向分页对齐 (4096 byte) ,并通过一个连接这些分页的链表记录这些分页,分配内存时从链表移除分页;释放内存时把分页重新添加回链表。

5. 代码:物理内存分配器

相关代码在 kernel/kalloc.c ,核心数据结构是 kmem ,里面包含一个保护自己的 spinlock (第 6 章会详细展开)和一个链表 struct run* freelist ,链表的每个元素都代表一个可用分页,每个元素都有 struct run* next 指向下一块可用分页(这个 next 就保存在自己代表的分页里,实际上不会影响使用,因为如果这个分页被分配了之后就不再是 struct run 了,所以 next 不会影响这个分页的使用)。main (kernel/main.c:19) 调用 kinit (kernel/kalloc.c:26) 对分配器进行初始化,把内核代码的末端到 PHYSTOP 之间的空闲分页装进 freelist

内核应该解析硬件提供的配置信息来计算物理内存的大小,但 Xv6 假定了物理内存为 128 MB (PHYSTOP, kernel/memlayout.h:48)

6. 进程的地址空间

每个进程都有一个独立的分页表, Xv6 在切换进程时同时也会变更分页表。进程地址空间的详细分配如下图,一个进程的虚拟地址从 0 开始,可以最大到 MAXVA (kernel/riscv.h:360) 允许进程使用理论上最大 256 GB 的内存。

进程地址空间的详细分配

仔细设置每个 PTE 的权限 flags 有助于加强用户进程的安全性,比如如果进程的 text 部分带有 PTE_W 的话进程就可以修改自己的代码了,或者如果进程的 data 部分带有 PTE_X 的话进程就可以跳进这个地址里。

Xv6 在 stack 下面放了一个没有 PTE_U 的保护分页来预防用户栈溢出,溢出时表现为硬件生成一个 page-fault (分页错误)异常;而现代的操作系统可能会自动分配更大的用户栈。

当进程向 Xv6 请求更多的用户内存时, Xv6 对进程的 heap 进行扩展:先 kalloc 分配物理分页,然后在用户的分页表里给新分配的物理页创建 PTE ,这些 PTE 带有 PTE_W, PTE_R, PTE_UPTE_V , Xv6 还会把进程用不到的 PTE 去掉 PTE_V 。到这里可以看到一些分页表的作用:

  1. 不同进程的分页表把用户地址转换成不同物理内存分页地址,这样每个进程都有自己的用户内存
  2. 每个进程都有连续的、从 0 开始的连续地址,而对应的物理地址可以不是连续的
  3. 内核给每个用户地址空间的顶部都映射同一个包含 trampoline 代码的分页,且不带 PTE_U ,这样每个地址空间都有一个 PTE 映射同一个物理内存分页,但只有内核可以使用

7. 代码: sbrk

sbrk (kernel/sysproc.c:39) 是用于扩展/缩小进程自己的内存的系统调用,其内部调用 growproc (kernel/proc.c:259) 来实现功能, growproc 内部调用 uvmalloc (kernel/vm.c:225)

点击展开:uvmalloc 代码分析
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
// 分配 PTE 和物理内存使进程从 oldsz 扩展到 newsize (不必与分页大小对齐)
// 返回新大小或失败时返回 0
uint64 uvmalloc (
pagetable_t pagetable, // 进程的根分页表
uint64 oldsz, // 扩展前的大小
uint64 newsz, // 期望扩展后的大小
int xperm // 新 PTE 的 flags
) {
char *mem;
uint64 a;

if (newsz < oldsz)
return oldsz;

// 求出进程尚未分配的最小虚拟地址(与分页大小对齐来方便后面分配新的分页)
oldsz = PGROUNDUP(oldsz);
// 开始逐个分配新的分页直到进程可用虚拟地址超过 newsize
for (a = oldsz; a < newsz; a += PGSIZE) {
mem = kalloc();
if (mem == 0) {
// 期间任何一个分页分配失败都要释放期间成功分配的所有分页
uvmdealloc(pagetable, a, oldsz);
return 0;
}
// 初始化内存空间
memset(mem, 0, PGSIZE);
// 尝试把新的分页映射到进程的虚拟地址
if (mappages(pagetable, a, PGSIZE, (uint64)mem, PTE_R|PTE_U|xperm) != 0) {
// 失败则放弃这个分页并释放期间成功分配的所有分页
kfree(mem);
uvmdealloc(pagetable, a, oldsz);
return 0;
}
}
return newsz;
}

uvdealloc (kernel/vm.c:255)

点击展开:uvdealloc 代码分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 释放进程的分页使进程的大小从 oldsz 缩减到 newsz
// 不必对齐, oldsz 也可以超过进程的实际大小
// 返回进程的新大小
uint64 uvmdealloc (pagetable_t pagetable,
uint64 oldsz,
uint64 newsz
) {
if (newsz >= oldsz)
return oldsz;

if (PGROUNDUP(newsz) < PGROUNDUP(oldsz)) {
// 算出需要释放的分页数
int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
// 从进程最大虚拟地址所在分页的上一个分页开始释放
uvmunmap(pagetable, PGROUNDUP(newsz), npages, 1);
}
return newsz;
}

来实现具体功能。 Xv6 的进程分页表不仅告诉了硬件如何映射用户虚拟地址,还是进程分配物理内存的唯一记录

8. 代码: exec

sys_exec (kernel/sysfile.c:434) 只是把 exec (kernel/exec.c:22) 的参数准备好了,但有几个比较有意思的点:

  1. sys_execexecchar* argv[MAXARG] 的每一个 char* arg 分配空间时用的 kalloc ,统一 4096 个 byte 可以说利用率极低(原因在末尾)
  2. MAXPATH (128, kernel/param.h:13) 规定文件目录名不能超过 128 个 byte
  3. MAXARG (32, kernel.param.h:8) 规定 execargv 大小不能超过 32
  4. argv 时和想象中的一样先取到用户传来的 char** argv ,然后遍历 argv 的每个 char* arg ,遇到 arg == 0 表示结束
点击展开:exec 代码分析
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
int exec(char *path, char **argv) {
char *s, *last;
int i, off;
uint64 argc, sz = 0, sp, ustack[MAXARG], stackbase;
struct elfhdr elf;
struct inode *ip;
struct proghdr ph;
pagetable_t pagetable = 0, oldpagetable;
// 当前进程
struct proc *p = myproc();

// 暂时不重要
begin_op();

// 根据文件目录名拿到 struct inode ,暂时不必深入
if ((ip = namei(path)) == 0) {
// 暂时不重要
end_op();
return -1;
}
// 锁住一个 struct inode ,暂时不必深入
ilock(ip);

// 从 inode 读一个 struct elfhdr
if (readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))
goto bad;

// 如果目标文件有 ELF 的 magic bytes (0x7f454c46, "<del>ELF")
// 就认为目标文件是 ELF 格式的
if (elf.magic != ELF_MAGIC)
goto bad;

// 创建进程的根分页表,只映射 trampoline 和 trapframe 两个分页
if ((pagetable = proc_pagetable(p)) == 0)
goto bad;

// 把程序读进内存
for (i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)) {
// 从 inode 读一个 struct proghdr (表示一个 segment )
if(readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
goto bad;
if(ph.type != ELF_PROG_LOAD)
continue;
// 一个 segment 的 mem size 可以大于 file size (用 0 填充)
// 但不应该小于 file size ( ELF 格式错误)
if(ph.memsz < ph.filesz)
goto bad;
// 防止程序恶意填写奇怪的数字
if(ph.vaddr + ph.memsz < ph.vaddr)
goto bad;
// 没有与分页大小对齐
if(ph.vaddr % PGSIZE != 0)
goto bad;
// 给进程扩容以容纳这个 segment
uint64 sz1;
if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz, flags2perm(ph.flags))) == 0)
goto bad;
sz = sz1;
// 把这个 segment 装进进程内存空间
if(loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0)
goto bad;
}
// 关掉这个文件,暂时不必深入
iunlockput(ip);
end_op();
ip = 0;

p = myproc();
uint64 oldsz = p->sz;

// 再分配两个分页
sz = PGROUNDUP(sz);
uint64 sz1;
if((sz1 = uvmalloc(pagetable, sz, sz + 2*PGSIZE, PTE_W)) == 0)
goto bad;
sz = sz1;
// 取消其中下面的分页的 PTE_U 使其成为保护分页
uvmclear(pagetable, sz-2*PGSIZE);
sp = sz;
// 记录其中上面的分页用于用户栈
stackbase = sp - PGSIZE;

// 把 argv 里的每个字符串放进用户栈里(从顶部往下放)
for (argc = 0; argv[argc]; argc++) {
if(argc >= MAXARG)
goto bad;
// 计算当前 arg 会让栈顶下降到哪里
sp -= strlen(argv[argc]) + 1;
sp -= sp % 16; // RISC-V 必须 16-byte 对齐
if(sp < stackbase)
// 放进去会溢出用户栈
goto bad;
// 把参数填进去
if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
goto bad;
// 记录这个字符串参数的起点虚拟地址到 ustack
ustack[argc] = sp;
}
// nul-terminated
ustack[argc] = 0;

// 把 char** argv 放进用户栈
// 计算 argv 会让栈顶下降到哪里
sp -= (argc+1) * sizeof(uint64);
sp -= sp % 16;
if(sp < stackbase)
// 放进去会溢出用户栈
goto bad;
// 把上面记录的 ustack (每个字符串参数的起点虚拟地址)放进用户栈
if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0)
goto bad;

// 把传给程序的 main(argc, argv) 的 argv 虚拟地址放进 a1 寄存器
// argc 传递过程: exec 返回值 -> sys_exec 返回值 -> syscall 将其放进 a0 寄存器
p->trapframe->a1 = sp;

// 把程序目录名放进 struct proc::name
for(last=s=path; *s; s++)
if(*s == '/')
last = s+1;
safestrcpy(p->name, last, sizeof(p->name));

// 提交用户进程(最后的处理)
// 更新进程根分页表
oldpagetable = p->pagetable;
p->pagetable = pagetable;
// 更新进程内存大小
p->sz = sz;
// 设置 PC 为新程序的起点
p->trapframe->epc = elf.entry;
// 更新栈指针
p->trapframe->sp = sp;
// 释放旧的根分页表
proc_freepagetable(oldpagetable, oldsz);

// 把 argc 返回给 sys_exec ,再返回给 syscall ,最后放进 p->trapframe->a0
return argc;

bad:
// 清理
if(pagetable)
proc_freepagetable(pagetable, sz);
if(ip){
iunlockput(ip);
end_op();
}
return -1;
}

是不是铸币?

目前没弄明白的是之前讨论进程的地址空间的时候说 argv, argc, 返回 PC 都在进程的栈里,但 exec 代码好像把这些放进 struct proc::trapframe 里了,甚至计算把 argv 里的内容压进栈里会不会溢出的时候都没管 argv 指针本身、 argc 和返回 PC 要占的空间,等以后明白了再来改吧。

明白了, RISC-V 手册里说了,参数可以通过栈传递,也可以通过参数寄存器 a0-a7 传递, Xv6 的 exec (kernel/exec.c:22) 把 argv 的每个 char* 包括自己 char*[] 都放进了栈里,然后把 argv 的地址放在了寄存器 a1 ,返回到 syscall (kernel/syscall.c:146) 后由 syscallargc 放进寄存器 a0

点击展开:图示进程内存空间分配过程

下面用图表示进程内存空间分配过程,行号对应上面 exec 代码分析, 34 行创建了根分页表:

exec对进程内存空间分配过程1

52-70 行把程序读进了内存:

exec对进程内存空间分配过程1

81-84 行又创建了两个分页:

exec对进程内存空间分配过程1

86 行使下面的分页变成保护分页,上面的分页用作用户栈:

exec对进程内存空间分配过程1

90-102 行把 argv 里的每个字符串 arg 的内容装进用户栈:

exec对进程内存空间分配过程1

104-110 把 argv 数组(每个字符串的起点)装进用户栈:

exec对进程内存空间分配过程1

9. 联系现实

现代操作系统一般会设计有更复杂的分页和 page-fault 异常设计(将在第四章展开), Xv6 依赖物理内存大小的假定和 QEMU 的特性(如物理内存地址在 0x80000000 ),现实的操作系统需要解析设备树来确保硬件物理内存分布未知情况下也能正常工作。 RISC-V 在物理地址层面支持保护,但 Xv6 没有使用这个特性。现代机器支持 super-page 来降低操控分页的成本,但 Xv6 只使用(假定有) 128MB 内存,所以没有也没必要有这种设计。 Xv6 没有现代操作系统的类 malloc 函数来分配小块内存,只能通过 kalloc 一次性分配 4096 个 byte 。

内存分配一直是个很重要的话题,比如:有效地利用有限的内存、为以后可能存在的特性做准备。现在人们比起空间效率更加注重速度。