提示:这是操作系统本人对 MIT 6.S081 的 lab3 实验课的笔记,仅供参考。
文章目录
前言
本文参考资料如下:
提示:以下是本篇文章正文内容,下面案例可供参考
一、理论部分
1.1 指针
首先在内核代码中使用指针较多,因此在书写代码时一定注意处理好边界问题。如下,给出一个错误示例:
// 文件名111.c
#include <stdio.h>
int main(){
int arr[100] = {111,111};
int *p=arr;
printf("*p = %d\n", *(p+1000));
// for(; p<&arr[5]; p++)
// printf("%d\n", (int)(p - arr));
return 0;
}
输出:
由输出
可以看出,当指针p指向地址超出数组arr,程序依然可以正常运行。编译器是不会替我们检查上述问题的!!!!!!!
其次针对如下代码
// 文件名111.c
#include <stdio.h>
int main(){
int arr[100] = {111,111};
int *p=arr;
for(; p<&arr[5]; p++)
printf("%d\n", (int)(p - arr));
return 0;
}
输出为:
由输出可以观察到,虽然指针p和arr都是代表地址,但是两者相减的值是两者之间相差的元素个数,而不是地址相减的差。
1.2 内存分页机制理解
RISC-V指令(用户和内核指令)使用的是虚拟地址,而机器的RAM或物理内存是由物理地址索引的。RISC-V页表硬件通过将每个虚拟地址映射到物理地址来为这两种地址建立联系。
一级分页理解
- 这里虚拟地址是64位的,但我们只用到39位,其中第1到12位用作偏移地址(这是因为每次我们分配内存都是以4096字节作为基本单元进行分配,就是 2 12 2^{12} 212字节)。第13到39位用作对Page table的索引号。
- 这里我们可以将Page table理解成一个 2 27 2^{27} 227大小的数组,而index部分就代表我们要访问的下标。
- 数组存储的内容为一个64位的整数,但我们只用到了第1到第54位,其中第1到10位是指一些标志位,如代表该物理内存是否有效(PTE_V)、可写(PTE_W)或可读(PTE_R)等,而第11位与第54位(即PPN)并结合虚拟地址中的偏移地址部分组成实际物理地址。
三级页表理解
- 在一级页表与三级页表机制下,我自己理解就是虚拟地址转变成物理地址时一级页表只要查询1次pagetable,而三级页表需要查询3次pagetable。当然多级分页还有其他好处,具体可参考资料。
- 在三级分页中,第1到12位还是做为偏移地址使用。而第13到21位作为三级页表的索引号,第22到30位作为二级页表的索引号,第31到39位作为一级页表的索引号。
- 同样这里一级、二级以及三级页表,我们都可以看成一个数组,只不过此时数组大小为 2 9 2^9 29,即512。每级页表存储的具体内容也是一个64位的整型数,但含义不同。每级页表存储内容的第1到10位代表标志位,一级页表存储内容的第11到54位再左移12位(所以这里得到的地址是56位的)存储的是某个二级页表的起始地址,而二级页表存储的第11到54位左移12位得到的是某个三级页表的起始地址,三级页表的第11到54位与虚拟地址的偏移地址部分就组成了最终的物理地址。
以上就是我对页表机制的一个流程理解。
此外还有其他一些要点需要记住
satp寄存器:为了告诉硬件使用页表,内核必须将根页表页的物理地址写入到satp寄存器中(satp的作用是存放根页表页在物理内存中的地址)
1.3 内核虚拟地址空间分配理解
解释:
上述这段代码是内核虚拟内存到物理内存的一个映射图,注意实际的物理地址是56位,而虚拟地址是39位。有如下要点
- 内核使用“直接映射”获取内存和内存映射设备寄存器;也就是说,将资源映射到等于物理地址的虚拟地址。例如,内核本身在虚拟地址空间和物理内存中都位于KERNBASE=0x80000000。
- 有几个内核虚拟地址不是直接映射,如蹦床页面(trampoline page)。它映射在虚拟地址空间的顶部;用户页表具有相同的映射;内核栈页面。每个进程都有自己的内核栈,它将映射到偏高一些的地址,这样xv6在它之下就可以留下一个未映射的保护页(guard page)。如果没有保护页,栈溢出将会覆盖其他内核内存,引发错误操作。恐慌崩溃(panic crash)是更可取的方案。(注:Guard page不会浪费物理内存,它只是占据了虚拟地址空间的一段靠后的地址,但并不映射到物理地址空间。)。
- 权限PTE_R和PTE_X仅针对映射蹦床页面和内核文本页面。
- 内核在权限PTE_R和PTE_W下映射其他页面,这样它就可以读写那些页面中的内存。对于保护页面的映射是无效的。
1.4 物理内存分配
在该实验中,从开始物理地址0x80000000L
到结束物理地址0x80000000L + 128*1024*1024
这意味着从物理地址 0x80000000 到 0x88000000(即 0x80000000 + 128MB)的区域是内核和用户页可以使用的物理内存。内核和用户空间都依赖这块内存进行日常操作。
在kernerl/kalloc.c文件中,定义了一个全局结构体变量kmem
用于对上述物理内存进行分配,这是一个单链表,使用freelist
指针来指向空闲页表的地址,每一页内存空间大小为4096个字节。kmem
还有一个自旋锁,用于分配和释放内存使用,防止两者冲突。
1.5 进程的地址空间
图2.3是一个进程的私有页表示例。
- 首先,不同进程的页表将用户地址转换为物理内存的不同页面,这样每个进程都拥有私有内存。
- 其次,,每个进程看到的自己的内存空间都是以0地址起始的连续虚拟地址,而进程的物理内存可以是非连续的。
- 内核在用户地址空间的顶部映射一个带有蹦床(trampoline)代码的页面,这样在所有地址空间都可以看到一个单独的物理内存页面。即蹦床页面一般映射到相同的物理地址,确保统一的内核入口点。
图3.4更详细地显示了xv6中执行态进程的用户内存布局。
栈包含如下内容:
- 命令行参数的字符串以及指向它们的指针数组位于栈的最顶部。
- 再往下是允许程序在main处开始启动的值(即main的地址、argc、argv)。
1.6 exec函数说明
- exec是一个用于创建和初始化进程地址空间用户部分的系统调用。它的主要功能是用一个新的程序替换当前进程的地址空间。
- exec使用存储在文件系统中的可执行文件(例如ELF文件)来初始化进程的地址空间用户部分。
二、实验
2.1 Print a page table
该实验主要是实现一个打印页表内容的函数。
步骤一:
在文件kernel/defs.h文件中添加 函数原型void vmprint(pagetable_t pagetable);
步骤二:
在文件kernel/vm.c 文件添加如下代码
void
_vmprint(pagetable_t pagetable, int level){
// 三级页表,每一个页表由512个页表条目。我个人理解可以把页表理解成数组,页表条目理解成数组元素
for(int i = 0; i < 512; i++){
pte_t pte = pagetable[i]; // 获取页表条目内容,即数组元素
// PTE_V 表示页表是否有效
if(pte & PTE_V){
for (int j = 0; j < level; j++){
if (j) printf(" ");
printf("..");
}
uint64 child = PTE2PA(pte); // 得到下一级页表地址
printf("%d: pte %p pa %p\n", i, pte, child);
if((pte & (PTE_R|PTE_W|PTE_X)) == 0){ // 该权限只有最后一级页表才拥有,其他级别页表不会拥有该权限
// this PTE points to a lower-level page table.
_vmprint((pagetable_t)child, level + 1);
}
}
}
}
void
vmprint(pagetable_t pagetable){
printf("page table %p\n", pagetable);
_vmprint(pagetable, 1);
}
步骤三:
在kernel/exec.c文件的exec函数中的" return argc; "语句前添加“if(p->pid==1) vmprint(p->pagetable);”然后重新编译,即可运行。
2.2 A kernel page table per process
该实验的主要目的是让每个进程都有自己的内核页表,这样内核中执行时,使用他自己的内核页表副本。
步骤一:在文件kernel/proc.h
的struct proc
结构体中添加如下字段
pagetable_t kernel_pgtbl; // 进程的内核页表
如下图:
步骤二:在文件kernel/vm.c
添加如下函数,在kernel/defs.h
中添加函数声明
// Just follow the kvmmap on vm.c
/*
函数说明:为虚拟地址va和物理地址pa创建映射
参数说明:
pagetable: 根页表地址
va: 虚拟地址
pa: 物理地址
sz: 映射内存大小,以字节为单位
perm: 权限
*/
void
uvmmap(pagetable_t pagetable, uint64 va, uint64 pa, uint64 sz, int perm)
{
if(mappages(pagetable, va, sz, pa, perm) != 0)
panic("uvmmap");
}
// 主要是底层硬件映射,所以这部分和内核页表映射移植
pagetable_t
proc_kpt_init(){
pagetable_t kernelpt = uvmcreate();
if (kernelpt == 0) return 0;
uvmmap(kernelpt, UART0, UART0, PGSIZE, PTE_R | PTE_W); // 映射UART0 设备地址
uvmmap(kernelpt, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W); // 映射VirtIO 设备地址
uvmmap(kernelpt, CLINT, CLINT, 0x10000, PTE_R | PTE_W); // 映射 Core Local Interruptor (CLINT) 的地址
uvmmap(kernelpt, PLIC, PLIC, 0x400000, PTE_R | PTE_W); //映射 Platform-Level Interrupt Controller (PLIC) 的地址
uvmmap(kernelpt, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X); // 映射内核代码段的起始地址到内核代码段结束地址(etext),具有只读和可执行权限(PTE_R | PTE_X)
uvmmap(kernelpt, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W); // 映射从内核代码段结束地址(etext)到物理内存结束地址(PHYSTOP),具有读写权限(PTE_R | PTE_W)。
uvmmap(kernelpt, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X); //映射蹦床页面(trampoline),具有只读和可执行权限(PTE_R | PTE_X)。
return kernelpt;
}
步骤三:
修改文件/kernel/proc.c
中函数allocproc
,添加如下代码:
// 初始化进程私有内核页表
p->kernel_pgtbl = proc_kpt_init();
if(p->kernel_pgtbl == 0){
freeproc(p);
release(&p->lock);
return 0;
}
如下图:
步骤四:
将文件kernel/proc.c
中函数procinit
中下列代码注释
char *pa = kalloc();
if(pa == 0)
panic("kalloc");
uint64 va = KSTACK((int) (p - proc));
kvmmap(va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
p->kstack = va;
如下图所示:
并在函数allocproc
中,添加如下代码:
char *pa = kalloc();
if(pa == 0)
panic("kalloc");
uint64 va = KSTACK((int) (p - proc));
uvmmap(p->kernel_pgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
p->kstack = va;
如下图所示:
步骤五:
在文件kernel/vm.c
添加如下函数,在文件kernel/defs.h
:
void
proc_inithart(pagetable_t kpt){
w_satp(MAKE_SATP(kpt));
sfence_vma();
}
同时在文件kernel/proc.c
中的函数scheduler()
添加如下代码:
...
p->state = RUNNING;
c->proc = p;
proc_inithart(p->kernel_pgtbl); // 切换到进程自己私有的内核页表副本
swtch(&c->context, &p->context);
kvminithart(); // 切换回内核页表
....
如下图所示:
步骤六:
现在需要再freeproc释放一个进程的内核页表。首先在kernel/proc.c
中添加如下函数,在kernel/defs.h
中添加函数声明。
/*
该部分只会释放三级页表本身所占用的内存,不会释放由这些页表映射的物理内存。
因为这部分物理内存是底层硬件以及相关外设,是所有进程以及内核页表所共享的
*/
void
kvm_free_kernelpgtbl(pagetable_t pagetable)
{
// there are 2^9 = 512 PTEs in a page table.
for(int i = 0; i < 512; i++){
pte_t pte = pagetable[i];
uint64 child = PTE2PA(pte);
if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){ // 如果该页表项指向更低一级的页表
// 递归释放低一级页表及其页表项
kvm_free_kernelpgtbl((pagetable_t)child);
pagetable[i] = 0;
}
}
kfree((void*)pagetable); // 释放当前级别页表所占用空间
}
最后在函数freeproc
中添加如下代码:
...
if(p->pagetable)
proc_freepagetable(p->pagetable, p->sz);
// 释放内核栈,添加
uvmunmap(p->kernel_pgtbl, p->kstack, 1, 1);
p->kstack = 0;
// 注意:此处不能使用 proc_freepagetable,因为其不仅会释放页表本身,还会把页表内所有的叶节点对应的物理页也释放掉。
// 这会导致内核运行所需要的关键物理页被释放,从而导致内核崩溃。
// 这里使用 kfree(p->kernelpgtbl) 也是不足够的,因为这只释放了**一级页表本身**,而不释放二级以及三级页表所占用的空间。
kvm_free_kernelpgtbl(p->kernel_pgtbl);
p->kernel_pgtbl = 0;
p->pagetable = 0;
p->sz = 0;
p->pid = 0;
p->parent = 0;
...
如下图所示:
步骤七:
修改kernel/vm.c
文件中的kvmpa
函数,将原来的pte = walk(kernel_pagetable, va, 0);
修改为pte = walk(myproc()->kernelpt, va, 0);
再在该文件中添加头文件#include "spinlock.h" 和 #include "proc.h"
。
至此所有步骤添加完毕,
最后使用make
命令进行编译,使用make qemu
启动系统,在输入命令usertests
进行测试
2.3 copyin/copyinstr
该实验的目标是,在进程的内核态页表中维护一个用户态页表映射的副本,这样使得内核态也可以对用户态传进来的指针(逻辑地址)进行解引用。这样做相比原来 copyin 的实现的优势是,原来的 copyin 是通过软件模拟访问页表的过程获取物理地址的,而在内核页表内维护映射副本的话,可以利用 CPU 的硬件寻址功能进行寻址,效率更高并且可以受快表加速。
步骤一:
在kernel/vm.c
添加如下函数,在kernel/defs.h
添加相关函数声明。
/*
使用户页表内存映射与内核页表内存映射同步
通过遍历用户页表,将用户页表内存映射同步到内核页表
从 oldsz 到 newsz 之间的内存映射同步
*/
void
u2kvmcopy(pagetable_t pagetable, pagetable_t kernelpt, uint64 oldsz, uint64 newsz){
pte_t *pte_from, *pte_to;
oldsz = PGROUNDUP(oldsz);
for (uint64 i = oldsz; i < newsz; i += PGSIZE){
if((pte_from = walk(pagetable, i, 0)) == 0)
panic("u2kvmcopy: src pte does not exist");
if((pte_to = walk(kernelpt, i, 1)) == 0)
panic("u2kvmcopy: pte walk failed");
uint64 pa = PTE2PA(*pte_from);
uint flags = (PTE_FLAGS(*pte_from)) & (~PTE_U);
*pte_to = PA2PTE(pa) | flags;
}
}
// 与 uvmdealloc 功能类似,将程序内存从 oldsz 缩减到 newsz。但区别在于不释放实际内存
// 用于内核页表内程序内存映射与用户页表程序内存映射之间的同步,
uint64
kvmdealloc(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并没有释放页表占用的物理内存和所映射的物理内存(因为最后一个参数为0),只是解除了映射关系
uvmunmap(pagetable, PGROUNDUP(newsz), npages, 0);
}
return newsz;
}
步骤二:
在kernel/exec.c
文件中的exec
函数添加检错程序(为什么添加,题目有提示),如下:
if(sz1 >= PLIC) { // 添加检测,防止程序大小超过 PLIC
goto bad;
}
如下图:
步骤三:
在kernel/proc.c
文件中fork
函数添加如下程序:
// 复制到新进程的内核页表
u2kvmcopy(np->pagetable, np->kernel_pgtbl, 0, np->sz);
如下图:
步骤四:
在kernel/exec.c
文件中的exec
函数添加如下程序段:
// 清除内核页表中对程序内存的旧映射,然后重新建立映射。
uvmunmap(p->kernel_pgtbl, 0, PGROUNDUP(oldsz)/PGSIZE, 0);
// 添加复制逻辑,到此为止,进程所用内存已经分配完毕,因此在这里我们将进程的地址空间复制到内核地址空间
u2kvmcopy(pagetable, p->kernel_pgtbl, 0, sz);
如下图所示:
步骤五:
在kernel/proc.c
文件中的growproc
函数修改如下图所示:
步骤六:
在kernel/proc.c
文件中的userinit
函数修改如下图所示:
步骤七:
替换kernel/vm.c
文件中的copyin
和copyinstr
函数。使用如下代码替代:
// 注意要在`kernel/defs.h`文件中声明函数copyin_new和copyinstr_new
int copyin_new(pagetable_t, char *, uint64, uint64);
int copyinstr_new(pagetable_t, char *, uint64, uint64);
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
return copyin_new(pagetable, dst, srcva, len);
}
int
copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
return copyinstr_new(pagetable, dst, srcva, max);
}
最后make grade
命令直接编译,只能通过60个用例,还有6个用例未通过,后续再检查吧。。。。。。