xv6项目开源—03

xv6项目开源—03

理论

1、页表是操作系统为每个进程提供私有地址空间和内存的机制。页表决定了内存地址的含义,以及物理内存的哪些部分可以访问。它们允许xv6隔离不同进程的地址空间,并将它们复用到单个物理内存上。页表还提供了一层抽象(a level of indirection),这允许xv6执行一些特殊操作:映射相同的内存到不同的地址空间中(a trampoline page),并用一个未映射的页面保护内核和用户栈区。

2、页表以三级的树型结构存储在物理内存中。该树的根是一个4096字节的页表页,其中包含512个PTE(页表条目),每个PTE中包含该树下一级页表页的物理地址。这些页中的每一个PTE都包含该树最后一级的512个PTE(也就是说每个PTE占8个字节)。分页硬件使用27位中的前9位在根页表页面中选择PTE,中间9位在树的下一级页表页面中选择PTE,最后9位选择最终的PTE。

3、因为 CPU 在执行转换时会在硬件中遍历三级结构,所以缺点是 CPU 必须从内存中加载三个 PTE 以将虚拟地址转换为物理地址。为了减少从物理内存加载 PTE 的开销,RISC-V CPU 将页表条目缓存在 Translation Look-aside Buffer (TLB) 中。

4、每个PTE包含标志位,这些标志位告诉分页硬件允许如何使用关联的虚拟地址。PTE_V指示PTE是否存在:如果它没有被设置,对页面的引用会导致异常(即不允许)。PTE_R控制是否允许指令读取到页面。PTE_W控制是否允许指令写入到页面。PTE_X控制CPU是否可以将页面内容解释为指令并执行它们。PTE_U控制用户模式下的指令是否被允许访问页面;如果没有设置PTE_U,PTE只能在管理模式下使用。

5、QEMU模拟了一台计算机,它包括从物理地址0x80000000开始并至少到0x86400000结束的RAM(物理内存),xv6称结束地址为PHYSTOP。QEMU模拟还包括I/O设备,如磁盘接口。QEMU将设备接口作为内存映射控制寄存器暴露给软件,这些寄存器位于物理地址空间0x80000000以下。内核可以通过读取/写入这些特殊的物理地址与设备交互;这种读取和写入与设备硬件而不是RAM通信。

6、内核必须在运行时为页表、用户内存、内核栈和管道缓冲区分配和释放物理内存。xv6使用内核末尾到PHYSTOP之间的物理内存进行运行时分配。它一次分配和释放整个4096字节的页面。它使用链表的数据结构将空闲页面记录下来。分配时需要从链表中删除页面;释放时需要将释放的页面添加到链表中。

实践

参考链接:MIT 6.S081 Operating System - 知乎 (zhihu.com)

注意事项:

开始编码之前,请阅读xv6手册的第3章和相关文件:

  • *kernel/memlayout.h*,它捕获了内存的布局。
  • *kernel/vm.c*,其中包含大多数虚拟内存(VM)代码。
  • *kernel/kalloc.c*,它包含分配和释放物理内存的代码。

Lab3这个实验主要涉及了解xv6物理地址和虚拟地址的转换和寻址, 内核态下的内存地址和用户态下的内存地址的差别等. xv6采用3层间接映射的方式来进行虚拟地址到物理地址的寻址. 我们需要为每个进程单独分配一个内核页表而不是所有进程在内核态时共用一个内核页表, 这极大的简化了将数据在内核态和用户态的来回传输, ricsv硬件可以直接帮助地址转化, 让不用我们手动地在内核态里做虚拟地址到物理地址的寻址转换.

1、Print a page table

定义一个名为vmprint()的函数。它应当接收一个pagetable_t作为参数,并以下面描述的格式打印该页表。在exec.c中的return argc之前插入if(p->pid==1) vmprint(p->pagetable),以打印第一个进程的页表。

看一下kernel/vm.c里面的freewalk方法,主要的代码如下:

// Recursively free page-table pages.
// All leaf mappings must already have been removed.
void freewalk(pagetable_t pagetable)
{
  // there are 2^9 = 512 PTEs in a page table.
  for(int i = 0; i < 512; i++){ // 遍历页表中的每一个页表项(PTE)
    pte_t pte = pagetable[i]; // 获取当前页表项
    
    // 检查当前页表项是否指向另一个页表(即是否为内部节点)
    if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
      // 该页表项有效且不包含读写执行权限,意味着它指向另一个页表而不是物理内存页面

      // this PTE points to a lower-level page table.
      uint64 child = PTE2PA(pte); // 将页表项中的物理地址部分提取出来,这是下一级页表的物理地址
      freewalk((pagetable_t)child); // 递归调用freewalk来释放这个子页表
      pagetable[i] = 0; // 清除当前页表项,表示该项不再指向任何页表或物理页面
    } else if(pte & PTE_V){
      // 如果页表项有效且代表一个叶节点(即直接指向物理内存的页表项),则报告错误
      // 因为在开始释放页表内存之前,所有的叶节点应该已经被移除
      panic("freewalk: leaf");
    }
  }
  // 在递归释放完所有子页表后,释放当前页表所占用的内存
  kfree((void*)pagetable);
}

这个函数的主要步骤如下:

  1. 遍历当前页表中的所有页表项(PTE),每个页表通常包含512个项。
  2. 对于每个页表项,检查它是否有效(PTE_V标志位)且不直接指向物理内存页(即没有设置读(PTE_R)、写(PTE_W)或执行(PTE_X)权限),这意味着该页表项指向另一个页表。
  3. 如果页表项指向另一个页表,使用提取的物理地址转换为虚拟地址,递归调用freewalk函数来释放该子页表所占用的内存。
  4. 如果发现任何直接指向物理内存的页表项(即叶节点),则调用panic函数报错,因为所有的叶节点映射应在此函数调用前已经被清除。
  5. 最后,释放当前页表占用的内存。

这个过程确保了整个页表结构被正确且彻底地清理,不会留下悬挂的指针或未释放的内存。

那么,根据freewalk,我们可以写下递归函数。对于每一个有效的页表项都打印其和其子项的内容。如果不是最后一层的页表就继续递归。通过level来控制前缀..的数量。

/**
 * @param pagetable 所要打印的页表
 * @param level 页表的层级
 */
void
_vmprint(pagetable_t pagetable, int level){
  // there are 2^9 = 512 PTEs in a page table.
  for(int i = 0; i < 512; i++){ // 遍历页表中的512个页表项
    pte_t pte = pagetable[i]; // 获取当前页表项
    // PTE_V is a flag for whether the page table is valid
    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); // 递归打印下一级页表
      }
    }
  }
  kfree((void*)pagetable); // 释放当前页表占用的内存(此行应谨慎处理,根据实际情况决定是否需要)
}

/**
 * @brief vmprint 打印页表
 * @param pagetable 所要打印的页表
 */
void
vmprint(pagetable_t pagetable){
  printf("page table %p\n", pagetable); // 打印页表的指针信息
  _vmprint(pagetable, 1); // 从第一层开始递归打印页表
}

/*
### `_vmprint`函数

- `pagetable`: 要打印的页表的指针。
- `level`: 页表的层级,用于辅助打印,使输出具有层次感。

函数遍历页表中的所有页表项(PTE),页表通常有512个页表项。

- 首先检查每个页表项是否有效,即`PTE_V`标志位是否被设置。只有有效的页表项才表示了一个实际的映射或指向另一级页表的指针。
- 对于每个有效的页表项,函数打印出层级前缀(通过打印`.`和空格来表示),使得输出具有层次结构。
- 接着,打印出页表项的索引`i`、页表项本身的值`pte`和它指向的物理地址`child`。物理地址是通过`PTE2PA`宏从页表项中提取出来的。
- 如果页表项没有设置任何读(`PTE_R`)、写(`PTE_W`)或执行(`PTE_X`)权限,这通常意味着该页表项指向另一级的页表,而不是直接映射到物理内存上。在这种情况下,函数会递归地调用自身`_vmprint`,以打印下一级的页表结构,层级`level`增加1。

### `vmprint`函数

这是一个对外的接口函数,用于开始打印页表的过程。

- `pagetable`: 要打印的页表的指针。
- 函数首先打印出页表的指针,然后调用`_vmprint`函数,传入页表指针和初始层级1,开始递归打印过程。

这种递归打印的方式允许我们清晰地看到页表的层级结构,包括每个页表项指向的下一级页表或物理内存。这对于理解和调试操作系统中的虚拟内存管理至关重要。通过这种方式,我们可以详细了解操作系统是如何通过页表项进行地址转换,以及不同页表项之间的关系。
*/

最后记得加到kernel/defs.h里面

int             copyin(pagetable_t, char *, uint64, uint64);
int             copyinstr(pagetable_t, char *, uint64, uint64);
void            vmprint(pagetable_t);

2、A kernel page table per process

xv6有一个单独的用于在内核中执行程序时的内核页表。内核页表直接映射(恒等映射)到物理地址,也就是说内核虚拟地址x映射到物理地址仍然是x。Xv6还为每个进程的用户地址空间提供了一个单独的页表,只包含该进程用户内存的映射,从虚拟地址0开始。因为内核页表不包含这些映射,所以用户地址在内核中无效。因此,当内核需要使用在系统调用中传递的用户指针(例如,传递给write()的缓冲区指针)时,内核必须首先将指针转换为物理地址。

本实验主要是让每个进程都有自己的内核页表,这样在内核中执行时使用它自己的内核页表的副本。

1)首先给kernel/proc.h里面的struct proc加上内核页表的字段。

uint64 kstack;               // Virtual address of kernel stack
uint64 sz;                   // Size of process memory (bytes)
pagetable_t pagetable;       // User page table
pagetable_t kernelpt;      // 进程的内核页表
struct trapframe *trapframe; // data page for trampoline.S

2)在vm.c中添加新的方法proc_kpt_init,该方法用于在allocproc 中初始化进程的内核页表。这个函数还需要一个辅助函数uvmmap,该函数和kvmmap方法几乎一致,不同的是kvmmap是对Xv6的内核页表进行映射,而uvmmap将用于进程的内核页表进行映射。

/*
这个函数尝试将虚拟地址(va)映射到物理地址(pa),映射大小为sz,权限为perm。如果映射失败,会触发内核panic。*/
// 映射虚拟地址(va)到物理地址(pa)。
void uvmmap(pagetable_t pagetable, uint64 va, uint64 pa, uint64 sz, int perm) {
  // 使用mappages函数尝试映射页表,如果失败则触发panic
  if(mappages(pagetable, va, sz, pa, perm) != 0)
    panic("uvmmap"); // 映射失败,触发内核panic
}
/*
这个函数用于为进程创建一个内核页表,并将重要的内核部分(如UART0, VIRTIO0, CLINT, PLIC, 内核代码区域等)映射到这个页表中,确保这些区域在进程的内核模式下可访问。每个uvmmap调用都将特定的物理地址范围映射到虚拟地址空间中,使用不同的权限设置(如只读、可执行、可读写等)。
*/
// 创建一个进程的内核页表
pagetable_t proc_kpt_init(){
  // 使用uvmcreate创建一个新的页表
  pagetable_t kernelpt = uvmcreate();
  if (kernelpt == 0) return 0; // 如果创建失败,返回0
  
  // 映射UART0
  uvmmap(kernelpt, UART0, UART0, PGSIZE, PTE_R | PTE_W);
  // 映射VIRTIO0
  uvmmap(kernelpt, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
  // 映射CLINT
  uvmmap(kernelpt, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
  // 映射PLIC
  uvmmap(kernelpt, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
  // 映射内核代码区域为只读和可执行
  uvmmap(kernelpt, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
  // 映射内核数据区域为可读写
  uvmmap(kernelpt, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
  // 映射trampoline为可读和可执行
  uvmmap(kernelpt, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
  
  return kernelpt; // 返回创建的内核页表
}

3)然后在kernel/proc.c里面的allocproc调用

// An empty user page table.
p->pagetable = proc_pagetable(p); // 为进程p创建一个新的用户空间页表,并将其地址赋值给进程的pagetable字段
if(p->pagetable == 0){ // 检查用户空间页表是否创建成功
  freeproc(p); // 如果创建失败,则调用freeproc函数释放进程资源
  release(&p->lock); // 释放进程锁,允许其他进程或线程访问进程结构
  return 0; // 返回0表示进程创建或初始化失败
}

// Init the kernel page table
p->kernelpt = proc_kpt_init(); // 初始化进程的内核页表,并将其地址赋值给进程的kernelpt字段
if(p->kernelpt == 0){ // 检查内核页表是否初始化成功
  freeproc(p); // 如果初始化失败,则调用freeproc函数释放进程资源
  release(&p->lock); // 释放进程锁,恢复对进程结构的访问
  return 0; // 返回0表示进程创建或初始化失败
}
/*
这段代码的主要目的是在进程创建或初始化阶段为每个进程设置独立的用户空间和内核空间页表。进程的用户空间页表(pagetable)用于管理进程在用户模式下的虚拟内存地址映射,而进程的内核空间页表(kernelpt)用于管理进程在内核模式下的虚拟内存地址映射。这样的设计允许操作系统维护每个进程的地址空间隔离,同时允许进程在内核模式下访问共享的内核资源。

proc_pagetable(p)是一个假定的函数,用于创建并初始化一个新的用户空间页表。这个函数应该返回新页表的地址,如果创建失败,则返回0。
proc_kpt_init()是一个实际的函数调用,它初始化进程的内核空间页表。这个函数应该返回新页表的地址,如果初始化失败,则返回0。
如果在任一步骤中页表的设置失败,代码通过调用freeproc(p)来清理进程所占用的资源,并通过release(&p->lock)释放进程结构的锁,最后通过返回0来表明进程创建或初始化失败。这是操作系统内核代码常见的错误处理模式。
*/

4)根据提示,为了确保每一个进程的内核页表都关于该进程的内核栈有一个映射。我们需要将procinit方法中相关的代码迁移到allocproc方法中。很明显就是下面这段代码,将其剪切到上述内核页表初始化的代码后。

/*
这段代码是操作系统内核中分配并初始化一个进程的内核栈的过程。在许多操作系统中,每个进程都有一个在内核空间中的栈,该栈用于当进程执行内核代码时存储局部变量、函数调用的返回地址等。
*/
// Allocate a page for the process's kernel stack.
char *pa = kalloc(); // 使用kalloc函数分配一个物理内存页作为进程的内核栈
if(pa == 0) // 检查是否成功分配了物理内存
  panic("kalloc"); // 如果没有成功分配,调用panic函数显示错误信息并停止系统

// Map it high in memory, followed by an invalid guard page.
uint64 va = KSTACK((int) (p - proc)); // 计算内核栈的虚拟地址。KSTACK是一个宏或函数,根据进程的索引计算它的内核栈的虚拟地址。这通常会映射到虚拟地址空间的高地址部分。

// 使用uvmmap函数将分配的物理内存页(内核栈)映射到计算出的虚拟地址上。
uvmmap(p->kernelpt, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);

p->kstack = va; // 将内核栈的虚拟地址保存在进程的结构体中,以便将来使用。
/*
这里的关键步骤包括:

内存分配:使用kalloc函数为进程的内核栈分配一个物理内存页。kalloc是一个在内核中常见的内存分配函数,它返回分配的物理内存的地址。如果kalloc返回0,意味着内存分配失败,此时代码通过调用panic函数表明系统出现了致命错误。

虚拟地址计算:使用KSTACK宏或函数根据进程的索引(p - proc计算得到)计算内核栈的虚拟地址。这个计算通常确保每个进程的内核栈位于虚拟地址空间的高地址部分,并且每个进程的内核栈地址是唯一的。

地址映射:通过uvmmap函数将分配的物理页映射到内核空间的计算出的虚拟地址上,设置适当的权限(读写)。这样,进程就可以在执行内核代码时使用这个内核栈了。

保存栈地址:将内核栈的虚拟地址保存到进程的结构体中,这样在进程上下文切换或执行内核代码时,可以方便地访问和使用这个内核栈。
*/

5)我们需要修改scheduler()来加载进程的内核页表到SATP寄存器。提示里面请求阅读kvminithart()

/*
这段代码是操作系统内核中的一部分,用于在RISC-V架构下切换当前硬件(h/w)页表寄存器到内核的页表,并启用分页机制。这是在每个硬件线程(hart)启动时进行的初始化步骤。
*/
// Switch h/w page table register to the kernel's page table,
// and enable paging.
void kvminithart() {
  w_satp(MAKE_SATP(kernel_pagetable)); // 将SATP(Supervisor Address Translation and Protection)寄存器设置为内核页表的地址
  sfence_vma(); // 执行一个序列围栏指令,以确保之前的所有页表更改都已经完成
}

6)kvminithart是用于原先的内核页表,我们将进程的内核页表传进去就可以。在vm.c里面添加一个新方法proc_inithart

/*
这段代码是操作系统内核中的一部分,用于将内核页表的地址加载到SATP(Supervisor Address Translation and Protection)寄存器中,并确保之前对页表所做的任何修改都得到处理,从而在RISC-V架构上启用分页机制。这个过程对于每个硬件线程(hart)的初始化至关重要。
*/
// Store kernel page table to SATP register
void proc_inithart(pagetable_t kpt){
  w_satp(MAKE_SATP(kpt)); // 将内核页表的地址写入SATP寄存器
  sfence_vma(); // 执行序列围栏指令,确保页表更改生效
}

7)然后在scheduler()内调用即可,但在结束的时候,需要切换回原先的kernel_pagetable。直接调用调用上面的kvminithart()就能把Xv6的内核页表加载回去。

/*
这段代码展示了操作系统内核在上下文切换过程中的关键步骤,特别是在进程从等待状态(或其他非运行状态)转换到运行状态,并且在运行完毕后再次返回到内核上下文的过程。
*/
p->state = RUNNING; // 将进程的状态设置为RUNNING,表示进程即将运行

c->proc = p; // 将当前CPU(核心)的进程指针设置为p,表示当前CPU将运行进程p

// Store the kernel page table into the SATP
proc_inithart(p->kernelpt); // 通过调用proc_inithart函数,将进程p的内核页表设置到SATP寄存器中,以便进程p执行时使用其内核空间映射

swtch(&c->context, &p->context); // 执行上下文切换。这里将当前CPU的上下文(c->context)切换为进程p的上下文(p->context),实际执行进程p

// Come back to the global kernel page table
kvminithart(); // 执行完进程p后,重新将SATP寄存器设置为全局内核页表,以确保回到内核空间上下文



/*
p->state = RUNNING;:这是进程状态变更的操作,标志着进程即将从调度队列中被选中并执行。

c->proc = p;:这行代码指示当前的CPU(或硬件线程)正在或即将执行的进程是p。在多核系统中,每个核心(CPU)可能同时执行不同的进程,因此需要记录当前核心正在执行的进程。

proc_inithart(p->kernelpt);:在执行进程p之前,需要确保CPU使用的是进程p的内核页表。这是为了在进程执行内核代码时,能够正确访问内核空间的内存。proc_inithart函数会将进程p的内核页表地址加载到SATP寄存器中。

swtch(&c->context, &p->context);:这是实际执行上下文切换的函数。它保存当前执行上下文(可能是内核或另一个进程的上下文),并加载进程p的上下文以开始执行。这包括切换堆栈指针、程序计数器等。

kvminithart();:进程p执行完毕后,系统需要切换回内核空间来继续执行内核代码,比如进行下一次调度或处理中断。kvminithart函数会恢复使用全局内核页表,保证了即使是在多进程环境下,内核空间的映射始终是一致的。

这个过程确保了系统能够安全地在多个进程和内核空间之间切换,每个进程都能在其自己的独立地址空间中运行,同时又能共享全局内核地址空间。
*/

8)在freeproc中释放一个进程的内核页表。首先释放页表内的内核栈,调用uvmunmap可以解除映射,最后的一个参数(do_free)为一的时候,会释放实际内存。

/*
这段代码是在操作系统的进程管理环节中用来释放已分配给进程内核栈的物理内存。这通常发生在进程结束其生命周期并被清理时。
*/
// free the kernel stack in the RAM
uvmunmap(p->kernelpt, p->kstack, 1, 1); // 从进程的内核页表中取消映射内核栈的虚拟地址
p->kstack = 0; // 将进程结构中内核栈的虚拟地址指针置零
/*
uvmunmap(p->kernelpt, p->kstack, 1, 1);:这个调用的作用是从进程p的内核页表(p->kernelpt)中取消映射内核栈所占用的一页内存。
这里的参数解释如下:
p->kernelpt:指向进程p的内核页表。内核页表包含了内核模式下进程能够访问的所有虚拟地址到物理地址的映射。
p->kstack:这是需要被取消映射的内核栈的起始虚拟地址。
第三个参数1:表示要取消映射的页数,这里是1,意味着内核栈占用了一个内存页。
第四个参数1:这通常表示是否需要释放对应的物理内存页。在这个上下文中,1意味着在取消映射的同时释放物理内存页。
p->kstack = 0;:这一行将进程结构体中内核栈的虚拟地址指针置为0,表示该进程不再有内核栈。这是在内存资源被释放后进行的清理操作,避免产生悬挂指针(即指针指向已经释放或无效的内存)。
*/

9)然后释放进程的内核页表,先在kernel/proc.c里面添加一个方法proc_freekernelpt。如下,历遍整个内核页表,然后将所有有效的页表项清空为零。如果这个页表项不在最后一层的页表上,需要继续进行递归。

/*
这段代码是一个用于递归释放一个进程的内核页表所占用的所有物理内存的函数。在操作系统中,当一个进程结束时,其使用的资源,包括内核页表占用的内存,需要被正确地释放回系统,以保证内存不会泄漏。
*/
void proc_freekernelpt(pagetable_t kernelpt)
{
  // similar to the freewalk method
  // there are 2^9 = 512 PTEs in a page table.
  for(int i = 0; i < 512; i++){ // 遍历页表中的每个页表项(PTE)
    pte_t pte = kernelpt[i]; // 获取当前页表项
    if(pte & PTE_V){ // 检查页表项是否有效,即是否被使用
      kernelpt[i] = 0; // 将当前页表项置为0,即取消映射
      if ((pte & (PTE_R|PTE_W|PTE_X)) == 0){ // 如果页表项不直接映射到物理内存(没有设置读、写、执行权限)
        uint64 child = PTE2PA(pte); // 将页表项转换为物理地址
        proc_freekernelpt((pagetable_t)child); // 递归释放这个子页表
      }
    }
  }
  kfree((void*)kernelpt); // 使用kfree函数释放整个页表所占用的物理内存
}
/*
该函数首先遍历页表的所有页表项(PTE)。每个页表通常包含512个页表项。
对于每个页表项,如果它是有效的(即PTE_V标志位被设置),则执行两个操作:
首先,将该页表项清零,即取消当前页表项的所有映射。
如果当前页表项不直接映射到物理内存(即它没有设置读(PTE_R)、写(PTE_W)或执行(PTE_X)权限),则表明这个页表项指向另一个子页表。在这种情况下,将通过提取页表项中的物理地址,然后递归调用proc_freekernelpt来释放这个子页表占用的内存。
在遍历和处理完所有页表项之后,函数使用kfree函数释放当前页表占用的物理内存。这一步是必要的,因为即使页表项被清零,页表本身所占用的内存页也需要被释放。
*/

10)将需要的函数定义添加到 kernel/defs.h

/*
这段代码是操作系统内核中虚拟内存管理部分的函数声明摘要,主要用于初始化和管理内核页表以及处理硬件线程(hart)在RISC-V架构上的分页机制。这些函数在内核启动、进程创建和执行期间起着关键作用。
*/
// vm.c
void            kvminit(void);
pagetable_t     proc_kpt_init(void); // 用于内核页表的初始化
void            kvminithart(void); 
void            proc_inithart(pagetable_t); // 将进程的内核页表保存到SATP寄存器
...

/*
kvminit(void);
这个函数负责初始化整个内核的虚拟内存系统。通常,这包括为内核空间设置初始页表映射,确保内核代码和数据可以在虚拟地址空间中正确访问。这是系统启动过程中非常早期的一个步骤。
pagetable_t proc_kpt_init(void);
这个函数用于初始化一个进程的内核页表,并返回该页表的地址。内核页表包含了内核空间的虚拟地址到物理地址的映射,这些映射对所有进程都是共享的。每个进程有自己的内核页表副本,以支持在内核模式下的操作,如系统调用执行时的内存访问。
kvminithart(void);
这个函数在每个硬件线程(hart)上被调用,用于将SATP(Supervisor Address Translation and Protection)寄存器设置为内核的页表地址,从而启用分页机制。这个设置过程是每个硬件线程开始执行内核代码之前的必要步骤,确保了内核代码运行在正确的虚拟内存环境中。
void proc_inithart(pagetable_t);
与kvminithart函数类似,proc_inithart函数用于在进程上下文中设置SATP寄存器,但它允许指定一个特定的页表地址作为参数。这个函数通常在进程切换时被调用,用于加载即将执行的进程的内核页表地址到SATP寄存器,以便进程在内核模式下运行时使用正确的内存映射。
*/

11)修改vm.c中的kvmpa,将原先的kernel_pagetable改成myproc()->kernelpt,使用进程的内核页表。

/*
这段代码定义了一个函数kvmpa,它将虚拟地址(va)转换为物理地址(pa)。这个转换过程依赖于当前进程的内核页表,并且是在操作系统内核中执行的。
*/
#include "spinlock.h" // 包含自旋锁的头文件,可能用于多核或多线程同步,但在这段代码中未直接使用
#include "proc.h" // 包含进程管理相关的头文件,提供了访问当前进程信息的函数等

uint64 kvmpa(uint64 va) // 定义kvmpa函数,输入参数为虚拟地址va
{
  uint64 off = va % PGSIZE; // 计算va在其所在页内的偏移量,PGSIZE是页面大小
  pte_t *pte; // 定义一个页表项指针
  uint64 pa; // 用于存储转换后的物理地址

  pte = walk(myproc()->kernelpt, va, 0); // 使用walk函数查找va对应的页表项。myproc()->kernelpt获取当前进程的内核页表地址
  if(pte == 0) // 如果找不到页表项,则触发panic,表示严重错误
    panic("kvmpa");
  if((*pte & PTE_V) == 0) // 检查页表项是否有效。PTE_V是页表项中表示有效性的标志位
    panic("kvmpa");
  pa = PTE2PA(*pte); // 从页表项中提取物理地址
  return pa+off; // 将物理地址与偏移量相加,得到完整的物理地址,并返回
}
/*
walk函数:该函数遍历给定的页表来查找与特定虚拟地址关联的页表项。在这里,它被用来查找与给定虚拟地址(va)相关联的页表项。如果该虚拟地址有效映射到物理地址,则walk函数将返回指向对应页表项的指针。

panic("kvmpa"):这是一种错误处理机制。如果无法找到有效的页表项,或者页表项标记为无效(即不包含有效的物理地址映射),则函数会触发一个内核级的panic,中断当前操作。这通常表明了一个严重的系统错误,可能是因为试图访问一个未映射或保护的内存地址。
*/

12)测试一下我们的代码

$ make qemu
> usertests

3、Simplify copyin/copyinstr(hard)

实验:

内核的copyin函数读取用户指针指向的内存。它通过将用户指针转换为内核可以直接解引用的物理地址来实现这一点。这个转换是通过在软件中遍历进程页表来执行的。在本部分的实验中,您的工作是将用户空间的映射添加到每个进程的内核页表(上一节中创建),以允许copyin(和相关的字符串函数copyinstr)直接解引用用户指针。

本实验是实现将用户空间的映射添加到每个进程的内核页表,将进程的页表复制一份到进程的内核页表就好。

1)首先添加复制函数。需要注意的是,在内核模式下,无法访问设置了PTE_U的页面,所以我们要将其移除。

//这段代码实现了从用户空间页表到内核空间页表的内存区域复制操作。它用于将某个内存区域的映射从用户页表复制到内核页表,通常用于需要在内核空间访问用户空间数据时。
void u2kvmcopy(pagetable_t pagetable, pagetable_t kernelpt, uint64 oldsz, uint64 newsz){
  pte_t *pte_from, *pte_to; // 定义源页表项和目标页表项的指针

  oldsz = PGROUNDUP(oldsz); // 将oldsz向上取整到页大小的倍数,确保从页边界开始复制

  for (uint64 i = oldsz; i < newsz; i += PGSIZE){ // 从oldsz开始,遍历到newsz,步长为一页大小
    if((pte_from = walk(pagetable, i, 0)) == 0) // 在用户页表中查找对应于虚拟地址i的页表项
      panic("u2kvmcopy: src pte does not exist"); // 如果找不到,触发panic
    if((pte_to = walk(kernelpt, i, 1)) == 0) // 在内核页表中也查找(或创建)对应的页表项
      panic("u2kvmcopy: pte walk failed"); // 如果失败,触发panic
    uint64 pa = PTE2PA(*pte_from); // 从源页表项中获取物理地址
    uint flags = (PTE_FLAGS(*pte_from)) & (~PTE_U); // 获取源页表项的标志位,并去除用户模式标志,以便复制到内核页表
    *pte_to = PA2PTE(pa) | flags; // 设置目标页表项,包括物理地址和标志位
  }
}

/*
u2kvmcopy函数的目的是将一段虚拟地址空间从用户页表映射复制到内核页表映射。这在需要内核直接访问或操作用户空间数据时非常有用。

函数接受四个参数:源页表pagetable,目标页表kernelpt,以及要复制的内存区域的旧大小oldsz和新大小newsz。这里的大小指的是虚拟地址空间的范围。

PGROUNDUP宏用于将给定的大小向上取整到最接近的页大小倍数,确保从一个完整的页边界开始复制。

walk函数被用于遍历页表并返回对应于给定虚拟地址的页表项的指针。在源页表查找时,不创建新的页表项(最后一个参数为0);而在目标页表中,如果不存在相应的页表项则创建一个新的页表项(最后一个参数为1)。

对于每个要复制的页,代码首先从源页表项pte_from中提取物理地址,然后复制其标志位到flags,同时确保去除了PTE_U(用户模式访问权限)标志,这表示该页在复制到内核页表后不再对用户模式代码可见。

最后,通过PA2PTE(pa) | flags组合物理地址和标志位设置目标页表项pte_to,完成复制过程。
*/

2)然后在内核更改进程的用户映射的每一处 (fork(), exec(), 和sbrk()),都复制一份到进程的内核页表。

//exec():
//这段代码是操作系统内核中执行新程序的exec函数的一部分。exec函数用于加载一个新的可执行程序到当前进程的地址空间并执行它,替换掉原先的程序。代码的关键部分包括设置新程序的堆栈,以及在加载新程序前,将用户空间的数据复制到内核空间。
int exec(char *path, char **argv){
  ...
  sp = sz; // 设置堆栈指针(sp)为新程序的大小(sz),这通常是新加载程序的虚拟地址空间的顶部
  stackbase = sp - PGSIZE; // 计算堆栈的基址,堆栈在地址空间的最顶部,向下增长

  // 添加复制逻辑
  u2kvmcopy(pagetable, p->kernelpt, 0, sz); // 将用户空间的数据(从地址0到sz)复制到内核空间

  // Push argument strings, prepare rest of stack in ustack.
  for(argc = 0; argv[argc]; argc++) { // 遍历argv数组,计算参数个数
  ...
}
/*
sp = sz; 这行代码将堆栈指针(sp)初始化为新程序的地址空间大小。在多数操作系统中,进程的地址空间包含了代码、数据、堆和栈等几个部分,其中栈通常位于地址空间的顶端,并向下增长。因此,这里sp被初始化为地址空间的顶部,即新程序的大小。

stackbase = sp - PGSIZE; 这里计算堆栈的基地址。由于栈是向下增长的,这里通过从sp减去一个页面大小(PGSIZE),来为堆栈预留出足够的空间。这个空间用于存储函数调用的参数、局部变量等。

u2kvmcopy(pagetable, p->kernelpt, 0, sz); 这行是新增加的逻辑,用于在执行新程序之前,将用户空间的数据复制到内核空间。这是通过调用u2kvmcopy函数完成的,它将从用户页表pagetable到进程p的内核页表p->kernelpt的地址空间内存复制操作进行了封装。这样做的目的可能是为了在内核中备份用户空间的数据,或者是为了内核能够直接访问这些数据。

for(argc = 0; argv[argc]; argc++) { 这段循环通过遍历argv数组来计算传递给新程序的参数个数。argv是一个字符串数组,每个元素指向一个参数,最后一个元素后面是NULL作为结束标志。
*/


//fork():
//这段代码是操作系统内核中fork函数的一部分,用于创建当前进程(父进程)的一个新副本(子进程)。fork函数的核心是复制父进程的地址空间、状态和其他重要信息到子进程中,使得两个进程在fork调用之后拥有几乎相同的状态,但是它们在独立的内存空间中运行。
int fork(void){
  ...
  // Copy user memory from parent to child.
  if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){ // 尝试复制父进程的用户空间内存到子进程
    freeproc(np); // 如果复制失败,释放子进程资源
    release(&np->lock); // 释放子进程的锁
    return -1; // 返回-1,表示fork失败
  }
  np->sz = p->sz; // 将父进程的地址空间大小复制到子进程
  ...
  // 复制到新进程的内核页表
  u2kvmcopy(np->pagetable, np->kernelpt, 0, np->sz); // 将用户空间的数据复制到子进程的内核页表中
  ...
}
/*
if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){:这里调用uvmcopy函数尝试复制父进程的用户空间内存到子进程。uvmcopy需要父进程的页表p->pagetable,子进程的页表np->pagetable,以及要复制的内存大小p->sz。

freeproc(np);和release(&np->lock);:如果内存复制失败,则调用freeproc函数来清理子进程占用的资源,并释放子进程持有的锁,然后函数返回-1,表示fork操作失败。

np->sz = p->sz;:复制父进程的地址空间大小给子进程。这确保了子进程拥有与父进程相同大小的地址空间。

u2kvmcopy(np->pagetable, np->kernelpt, 0, np->sz);:这行代码是新增的,用于将用户空间的数据从子进程的用户页表复制到子进程的内核页表中。这通常是为了让子进程的内核部分能够访问或管理用户空间的数据。u2kvmcopy函数的第一个参数是源页表(这里是子进程的用户页表np->pagetable),第二个参数是目标页表(子进程的内核页表np->kernelpt),接着是要复制的内存区域的起始地址(0)和结束地址(np->sz,即子进程的地址空间大小)。
*/

//sbrk()
//这段代码是操作系统内核中用于调整(增加或减少)进程虚拟地址空间大小的growproc函数。这个函数可以根据参数n的正负来决定是增加还是减少进程的内存大小。具体来说,如果n为正,那么将增加进程的内存大小;如果n为负,那么将减少进程的内存大小。
int growproc(int n)
{
  uint sz;
  struct proc *p = myproc(); // 获取当前进程的进程控制块(PCB)

  sz = p->sz; // 获取当前进程的内存大小
  if(n > 0){ // 如果n为正,表示需要增加进程的内存大小
    // 加上PLIC限制
    if (PGROUNDUP(sz + n) >= PLIC){ // 首先检查增加内存后的大小是否会超过PLIC地址(PLIC为外部中断控制器的起始地址,通常是一个高地址)
      return -1; // 如果超过,函数返回-1,表示操作失败
    }
    if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) { // 使用uvmalloc分配内存,如果分配失败返回0
      return -1; // 分配失败,函数返回-1
    }
    // 复制一份到内核页表
    u2kvmcopy(p->pagetable, p->kernelpt, sz - n, sz); // 将新分配的内存区域的映射复制到内核页表中
  } else if(n < 0){ // 如果n为负,表示需要减少进程的内存大小
    sz = uvmdealloc(p->pagetable, sz, sz + n); // 使用uvmdealloc释放内存
  }
  p->sz = sz; // 更新进程的内存大小
  return 0; // 函数执行成功,返回0
}
/*
myproc():通常是一个函数,返回当前CPU(或当前执行上下文)正在执行的进程的进程控制块(PCB)的指针。

PGROUNDUP(sz + n) >= PLIC:这里的PGROUNDUP宏用于将给定的地址向上取整到最近的页面边界。这一检查确保进程的地址空间不会与PLIC(外部中断控制器的地址空间)冲突,防止进程的地址空间覆盖重要的外设地址空间。

uvmalloc和uvmdealloc:这两个函数用于分配和释放用户虚拟内存。它们调整进程的地址空间,并修改页表以反映这些变化。

u2kvmcopy:这个函数将用户空间新增的内存映射复制到内核页表,这是为了让内核能够访问和管理新增的用户空间内存。
*/

3)然后替换掉原有的copyin()copyinstr()

//这段代码提供了两个函数,copyin和copyinstr,它们用于从用户空间复制数据到内核空间。这两个函数是系统编程中常见的操作,特别是在实现系统调用时,需要安全地从用户程序传递数据到内核进行处理。
int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
  return copyin_new(pagetable, dst, srcva, len);
}
/*pagetable: 指定页表,通常是用户程序的页表,用于解析srcva指定的虚拟地址。
dst: 目标地址,位于内核空间,是要复制数据到的位置。
srcva: 源虚拟地址,位于用户空间,是数据复制的起始位置。
len: 要复制的字节数。
copyin函数的作用是将len字节的数据从用户空间的虚拟地址srcva复制到内核空间的地址dst。它调用copyin_new来执行实际的复制操作。如果复制成功,返回0;如果出错(例如,如果srcva无法在提供的页表中正确解析),返回-1。
*/

int copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max)
{
  return copyinstr_new(pagetable, dst, srcva, max);
}
/*
pagetable: 同上,指定用于解析srcva的页表。
dst: 目标地址,在内核空间,是字符串要复制到的位置。
srcva: 源虚拟地址,位于用户空间,指向要复制的null-terminated字符串的开始。
max: 最大可复制的字节数,用于防止超出目标缓冲区大小。
copyinstr函数的目的是从用户空间的虚拟地址srcva复制一个以null结尾的字符串到内核空间的地址dst,直到遇到字符串的结尾'\0'或达到max字节限制。它调用copyinstr_new来执行实际的复制操作。如果复制成功,返回0;如果出错(例如,如果源地址无效或复制过程中遇到问题),返回-1。
*/

4)并且添加到 kernel/defs.h

kernel/defs.h不是一个标准库文件,而是在操作系统内核开发中自定义的头文件,用于声明内核中使用的各种函数、变量和类型。在一个具体的操作系统项目中,kernel/defs.h可能包含了内核模块之间共享的接口声明、全局变量的外部引用声明以及一些常用的宏定义。

通过在代码中包含kernel/defs.h头文件,开发者可以在不同的内核源文件之间共享和访问这些共同的定义和声明,促进了模块化编程和代码重用。在处理像copyin_newcopyinstr_new这样的函数时,kernel/defs.h可能就包含了这些函数的声明,使得其他内核代码文件可以调用这些函数。

// vmcopyin.c
//这段代码是C语言中的函数原型声明,出自一个名为vmcopyin.c的文件中。它声明了两个函数,copyin_new和copyinstr_new,这两个函数被设计用来从用户空间向内核空间复制数据。以下是对这两个函数原型的解释:
int             copyin_new(pagetable_t, char *, uint64, uint64);
/*
第一个参数是pagetable_t类型,代表要使用的页表,通常是指向用户程序页表的指针,用于虚拟地址到物理地址的转换。
第二个参数是char *类型,表示目标地址,即数据要被复制到的内核空间中的位置。
第三个参数是uint64类型,表示源虚拟地址,即要从用户空间复制数据的起始地址。
第四个参数也是uint64类型,指定要复制的字节数。
该函数的作用是从用户空间指定的虚拟地址开始,复制指定长度的数据到内核空间的指定位置。这是内核操作中的一种常见需求,特别是在处理系统调用时,需要安全地从用户空间获取数据。
*/
int             copyinstr_new(pagetable_t, char *, uint64, uint64);
/*
参数与copyin_new函数相同,但这个函数专门用于复制以null结尾的字符串。

它会从用户空间的指定虚拟地址开始复制字符,直到遇到null字符(字符串结束标志)或达到指定的最大字节数。
这个函数通常用于系统调用需要从用户空间获取字符串参数时。
*/

5)最后跑一下最终测试:

make grade

此方案依赖于用户的虚拟地址范围不与内核用于自身指令和数据的虚拟地址范围重叠。Xv6使用从零开始的虚拟地址作为用户地址空间,幸运的是内核的内存从更高的地址开始。然而,这个方案将用户进程的最大大小限制为小于内核的最低虚拟地址。内核启动后,在XV6中该地址是0xC000000,即PLIC寄存器的地址;请参见***kernel/vm.c***中的kvminit()、*kernel/memlayout.h*。您需要修改xv6,以防止用户进程增长到超过PLIC的地址。

  • 17
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

星空有大海

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值