MIT6.S081-2020-Lab: page tables

本文详细介绍了MIT6.S081操作系统实验中的页表管理部分,涉及xv6的内核页表初始化、内存布局、页表相关函数,以及系统调用sbrk和exec的实现。实验部分包括打印页表内容、为每个进程添加内核页表以及简化copyin/copyinstr功能,以实现直接解引用用户指针。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

继续上次的xv6实验,从做完第一个实验后,到现在已经过去很久了6.S081-Lab: Xv6 and Unix utilities 操作系统实验分享。中途只做完了系统调用,也没有写上笔记。
这次学习xv6页表一章,从看书到看代码再到编写实验,花费了很多时间,遇到了很多坑,反反复复修改代码才得到正确的结果。
还是和以前一样,对操作系统有兴趣的千万不要错过这个实验。这次的实验会接触到很多内核知识,尤其是编写实验代码过程中,将页表理论知识化为实际的代码,别说有多舒服了。
分页可谓是操作系统中一个非常重要的思想,在操作系统中使用页表来和硬件一起实现内存虚拟化。对页表的理解当然不能仅仅只停留在理论上,
来看看xv6是如何实现它的。

下面的源码是依据 github 上的xv6源码进行解读的,导致我在读完源码后,再看6.S081的实验后有点迷茫。
另外,下面的实验是以 6.S081-2020 的课程进行的,不是 2021 的课程实验;这两年的实验不相同!!!

kernel/riscv.h 分页相关操作的定义

The flags and all other page hardware-related structures are defined in (kernel/riscv.h)
book-riscv-rev2.pdf-P33

riscv.h 文件中定义了页的一些标志和硬件相关的结构, 以下是该文件的部分源码

#define PGSIZE 4096
#define PGSHIFT 12

#define PGROUNDUP(sz)  (((sz)+PGSIZE-1) & ~(PGSIZE-1))
#define PGROUNDDOWN(a) (((a)) & ~(PGSIZE-1))

#define PTE_V (1L << 0)
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4)

#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)

#define PTE2PA(pte) (((pte) >> 10) << 12)

#define PTE_FLAGS(pte) ((pte) & 0x3FF)

#define PXMASK          0x1FF
#define PXSHIFT(level)  (PGSHIFT+(9*(level)))
#define PX(level, va) ((((uint64) (va)) >> PXSHIFT(level)) & PXMASK)

#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))

typedef uint64 pte_t;
typedef uint64 *pagetable_t;

从源码中能理解大部分的 define 语句是做什么的,比如页的大小、最大虚拟地址等,这里挑几个出来讲讲
结合书中的图3.2分析:
img_1.png

  • PGROUNDUP:获取地址sz对应的物理页号(PPN),向上取整
  • PGROUNDDOWN:同上,获取地址sz对应的物理页号(PPN),向下取整
  • PTE_FLAGS:获取页表项对应的标志
  • PXMASK:虚拟地址的低39位被 xv6 使用, 这39bit中的高27bit被用来索引页表项,xv6的页表是三级页表,
    ,这27bit被分为三次使用,每次使用9bit,因此 PXMASK0x1FFPXSHIFT 则是根据是第几级页表来计算移位数

xv6只使用了5个页标志,而不是图中RISC-V的8个标志。 至于 PA2PTEPTE2PA,光看这个文件的话会难以理解,留到后续与其他文件一起解释。

类型 pagetable_t 将会在下面提到,只需记住,它是64位长的指针。

kernel/memlayout.h xv6内存布局

The file (kernel/memlayout.h) declares the constants for xv6’s kernel memory layout.
book-riscv-rev2.pdf-P34

这个文件定义了 xv6 的内存布局

#define UART0 0x10000000L
#define UART0_IRQ 10

#define VIRTIO0 0x10001000
#define VIRTIO0_IRQ 1

#define CLINT 0x2000000L
#define CLINT_MTIMECMP(hartid) (CLINT + 0x4000 + 8*(hartid))
#define CLINT_MTIME (CLINT + 0xBFF8)

#define PLIC 0x0c000000L
#define PLIC_PRIORITY (PLIC + 0x0)
#define PLIC_PENDING (PLIC + 0x1000)
#define PLIC_MENABLE(hart) (PLIC + 0x2000 + (hart)*0x100)
#define PLIC_SENABLE(hart) (PLIC + 0x2080 + (hart)*0x100)
#define PLIC_MPRIORITY(hart) (PLIC + 0x200000 + (hart)*0x2000)
#define PLIC_SPRIORITY(hart) (PLIC + 0x201000 + (hart)*0x2000)
#define PLIC_MCLAIM(hart) (PLIC + 0x200004 + (hart)*0x2000)
#define PLIC_SCLAIM(hart) (PLIC + 0x201004 + (hart)*0x2000)

#define KERNBASE 0x80000000L
#define PHYSTOP (KERNBASE + 128*1024*1024)

#define TRAMPOLINE (MAXVA - PGSIZE)

#define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)

#define TRAPFRAME (TRAMPOLINE - PGSIZE)

上面的代码和 book-riscv-rev2.pdf-P34 的图3.3所示的一致,正是xv6内核的地址空间布局
,比如 KERNBASE 是 0x80000000 RAM 的起始地址,xv6内核使用的内存大小是 128M(KERNBASE ~ PHYSTOP 范围的地址空间);
另外,我们可以注意到,虚拟地址空间最高的那两页被分配给了 TRAMPOLINETRAPFRAME

kernel/vm.c

Most of the xv6 code for manipulating address spaces and page tables resides in vm.c
book-riscv-rev2.pdf-P35

xv6绝大部分用于处理地址空间和页表的代码都在 kernel/vm.c 文件中,
由于源码过长,不容易快速理解,现在按照书P36的内容来分析这里面的代码。
在xv6启动的时候, kernel/main.c 在启用分页功能前,先调用了kvminit函数创建内核页表。

以下是kernel/main.cmain函数代码:

void
main()
{
   
  if(cpuid() == 0){
   
    consoleinit();
    printfinit();
    printf("\n");
    printf("xv6 kernel is booting\n");
    printf("\n");
    kinit();
    kvminit();
    kvminithart();
    procinit();
    trapinit();
    trapinithart();
    plicinit();
    plicinithart();
    binit();
    iinit();
    fileinit();
    virtio_disk_init();
    userinit();
    __sync_synchronize();
    started = 1;
  } else {
   
    while(started == 0)
      ;
    __sync_synchronize();
    printf("hart %d starting\n", cpuid());
    kvminithart();
    trapinithart();
    plicinithart();
  }

  scheduler();        
}

现在我们只关注对vm.c中的函数调用,即kvminitkvminithart

kvminit 初始化内核页表

void
kvminit(void)
{
   
  kernel_pagetable = kvmmake();
}

这个函数只是调用kvmmake获取返回值后赋值给 kernel_pagetablekernel_pagetable声明如下:

pagetable_t kernel_pagetable;

pagetable_t 类型的变量,正是上述的riscv.h中定义的64位长的指针,也就说明kvmmake的返回了内核页表的指针。
现在看看kvmmake函数是如何创建内核页表的。

kvmmake

pagetable_t
kvmmake(void)
{
   
  pagetable_t kpgtbl;

  kpgtbl = (pagetable_t) kalloc();
  memset(kpgtbl, 0, PGSIZE);

  // uart registers
  kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);

  // virtio mmio disk interface
  kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

  // PLIC
  kvmmap(kpgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);

  // map kernel text executable and read-only.
  kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

  // map kernel data and the physical RAM we'll make use of.
  kvmmap(kpgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);

  // map the trampoline for trap entry/exit to
  // the highest virtual address in the kernel.
  kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);

  // map kernel stacks
  proc_mapstacks(kpgtbl);
  
  return kpgtbl;
}
  1. kvmmake首先调用kalloc分配一个物理页并用零值初始化。
  2. kvmmake然后调用kvmmap对该页表设置虚拟地址-物理地址映射,并设置该页的标志(valid、read、write等),使用到的常量大多是在memlayout.h中定义的;
    由于此时还没有开启分页,因此是直接映射(direct mapping),也就是虚拟地址等于物理地址。
  3. kvmmake接着调用proc_mapstacks为每个进程分配一个内核栈和一个守护页(guard page)的内存。
  4. 最后,返回内核页表的指针kpgtbl

现在将注意力集中在页表是如何创建的,内存分配在后面讨论,我们查看kvmmap函数是如何做的:

kvmmap

void
kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
   
  if(mappages(kpgtbl, va, sz, pa, perm) != 0)
    panic("kvmmap");
}

kvmmap 调用了mappages函数,继续看mappages函数

mappages

int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
   
  uint64 a, last;
  pte_t *pte;

  if(size == 0)
    panic("mappages: size");
  
  a = PGROUNDDOWN(va);
  last = PGROUNDDOWN(va + size - 1);
  for(;;){
   
    if((pte = walk(pagetable, a, 1)) == 0)
      return -1;
    if(*pte & PTE_V)
      panic("mappages: remap");
    *pte = PA2PTE(pa) | perm | PTE_V;
    if(a == last)
      break;
    a += PGSIZE;
    pa += PGSIZE;
  }
  return 0;
}

mappages函数的逻辑如下:

  1. 根据虚拟地址的起始地址va和空间大小size,使用riscv.h中的PAGEDOWN获取虚拟地址的页号范围[a,last]
  2. 在for循环中,调用walk获取页号a对应的页目录项(PTE)的地址,walk函数会在页目录项未分配时(即PTE_V是0的状况)
    为页目录项分配一页,分配失败时,walk返回 0,walk的逻辑会在后面详细介绍
  3. 接着会判断该目录项pte是否重复映射,如果重复映射,则panic
  4. 接着会将标志填充至要映射的物理页地址pa,并将其赋给pte,使目录项pte指向该物理页
  5. 更新虚拟页地址a和物理页地址pa并开始新一轮循环。

walk函数是如何获取页表pagetable中的虚拟地址a对应的页表项地址pte呢?

walk

pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
   
  if(va >= MAXVA)
    panic("walk");

  for(int level = 2; level > 0; level--) {
   
    pte_t *pte = &pagetable[PX(level, va)];
    if(*pte & PTE_V) {
   
      pagetable = (pagetable_t)PTE2PA(*pte);
    } else {
   
      if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
        return 0;
      memset(pagetable, 0, PGSIZE);
      *pte = PA2PTE(pagetable) | PTE_V;
    }
  }
  return &pagetable[PX(0, va)];
}

walk函数的逻辑如下:

  1. 如果虚拟地址a超过最大虚拟地址MAXVA(上述riscv.h中定义的),则报错
  2. 进入for循环,循环会进行两次,因为xv6使用的三级页表,要跨过前两级页表,返回第三级页表的页表项地址;在for循环中,利用预处理操作PX
    获取虚拟地址va对应level的9bit大小页表项索引(索引区间[0,511]共512个页表项,因为页表pagetable的大小就是一页,
    页表项大小为8个字节,即4096/8=512,而va中的27bit被分成三个9bit使用,9bit刚好可以索引512个页表项),根据页表项是否valid,判断是否需要为该页表项分配内存
    1. 如果pte所指向的页已分配,则将pagetable指向页表项pte指向的页
    2. 如果pte所指向的页未分配(没有设置valid),则会根据alloc标志参数是否设置,调用kalloc分配一页内存并初始化它
  3. 最终,pagetable指向三级页表的地址,然后使用虚拟地址va索引对应的页表项,返回页表项地址。
  4. 如果页表项地址未成功分配,则返回 0

kvmmap经及其使用的函数我们已经知道了,现在来看kvmmake中调用的最后一个函数proc_mapstacks

proc_mapstacks(proc.c)

void
proc_mapstacks(pagetable_t kpgtbl) {
   
  struct proc *p;
  
  for(p = proc; p < &proc[NPROC]; p++) {
   
    char *pa = kalloc();
    if(pa == 0)
      panic("kalloc");
    uint64 va = KSTACK((int) (p - proc));
    kvmmap(kpgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
  }
}

proc_mapstacks调用kalloc分配一个物理页va用于存放内核栈,然后使用KSTACK生成的虚拟地址va,在内核页表上建立映射。
预处理操作KSTACK定义如下:

#define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)

可见内核栈都是在TRAMPOLINE下面,相邻的内核栈相距2个物理页(1页用于内核栈,一页防止栈溢出覆盖数据),且分配顺序是自上而下(因为栈是向下增长的)。
结合proc_mapstacks中的p-proc不难看出,这是为每个进程划分的一块内核栈,互相独立。
对应于书中的xv6内核地址空间布局图。注意,所有进程的内核栈映射都在内核页表!!!

img.png

book-riscv-rev2.pdf#P34

  • 由于是在初始化内核页表,因此索引到的页表项pte大多情况下都是需要kalloc分配内存页
  • 上述这些操作发生时,还没有开启分页功能,因此是虚拟地址-物理地址的直接映射(direct mapping),walk将页表项指向的页的物理地址当成虚拟地址使用

引用原文的一段描述:

A page table is stored in physical memory as a three-level tree. The root of the tree is a 4096-byte
page-table page that contains 512 PTEs, which contain the physical addresses for page-table pages
in the next level of the tree. Each of those pages contains 512 PTEs for the final level in the tree.
The paging hardware uses the top 9 bits of the 27 bits to select a PTE in the root page-table page,
the middle 9 bits to select a PTE in a page-table page in the next level of the tree, and the bottom
9 bits to select the final PTE.

book-riscv-rev2.pdf#32

现在,kvmminit初始化了内核页表,kernel_pagetable指向了内核根页表(root page-table),main.c接着接着调用
kvminithart函数启用分页并刷新TLB。

kvminithart 启用分页

void
kvminithart()
{
   
  w_satp(MAKE_SATP(kernel_pagetable));
  sfence_vma();
}
  1. w_astp使用汇编指令,将内核页表的物理地址写入到寄存器satp
  2. sfence_vma使用RISC_V的汇编指令刷新CPU的TLB(由于读取页表映射需要进行读取内存,因此使用TLB缓存这些映射,利用局部性原理加快速度)

kvminithart函数执行之后,后续指令使用到的虚拟地址都会被映射为正确的物理地址

kernel/kalloc.c

上述在初始化内核页表时,都是使用kalloc函数分配内存,现在来看看xv6的内存管理机制:
xv6使用一个链表来记录哪些页是可用的, 俗称”空闲链表“,且每次分配或释放的空间都是大小为4KB的页。

内存管理使用到结构体run来存储可分配空间的地址,结构体run定义如下:

struct run {
   
  struct run *next;
};

结构体run形成一个空闲页的链表,链表freelist和该链表的自旋锁则封装在另一个结构体kmem中:

struct {
   
  struct spinlock lock;
  struct run *freelist;
} kmem;

只有在获取到自旋锁时,,才能分配或释放内存,以确保线程安全。
freelist用于记录空闲内存页,那么存储这些记录的内存又从哪里来呢?
xv6将空闲链表节点存储在空闲内存页当中,空闲页page中可以存储这样一个指针p,指针p的地址就是空闲页page的地址,指针p的内容就是下一个空闲页的地址。
记住这一点,将更容易理解下面xv6的源代码。

kinit 初始化可用内存

回到xv6的启动过程,在main.c调用kvminit函数创建页表之前,先调用了kinit函数初始化可用内存。

kinit

void
kinit()
{
   
  initlock(&kmem.lock, "kmem");
  freerange(end, (void*)PHYSTOP);
}

kinit函数中,先初始化了空闲链表的自旋锁,在这里先不考虑自旋锁的创建细节;随后调用freerange释放end~PHYSTOP范围的物理内存。
freerange是如何释放该范围内的物理地址呢?

freerange

void
freerange(void *pa_start, void *pa_end)
{
   
  char *p;
  p = (char*)PGROUNDUP((uint64)pa_start);
  for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
    kfree(p);
}

freerange函数使用riscv.h中预处理操作PGROUNDUP获取起始物理页地址,之后对该范围内的物理页依次调用kfee添加到空闲页。

现在看看kfee函数对物理页做了什么操作:

kfree

void
kfree(void *pa)
{
   
  struct run *r;

  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  // Fill with junk to catch dangling refs.
  memset(pa, 1, PGSIZE);

  r = (struct run*)pa;

  acquire(&kmem.lock);
  r->next = kmem.freelist;
  kmem.freelist = r;
  release(&kmem.lock);
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值