内存管理

分页的硬件

RISC-V的指令(包括用户态下的或者内核态下的)提供了虚拟地址.对应地,RAM或者叫做物理内存,自然也有物理地址,物理地址真实唯一地标记实际内存空间,我们需要利用RISC-V的页表硬件来完成虚拟地址和物理地址的转换.

我们的页表使用Sv39架构,SV39维护的是一个多级页表:我们发现页表是三级结构,第一级页表的首地址保存在satp寄存器中,有$512$个表项,其中每一级表项存储着下一级页表的首地址.第二级页表也是由$512$个表项组成,其中每一个表项保存着下一级页表的首地址.第三级页表里面存储的就是对应的物理地址的PPN.

所以说$va$分成$L2$,$L1$,$L0$和$Offset$分成四部分.

  • 首先在第一级页表中找到第L2个表项,这样就找到第二级页表的首地址.

  • 然后在第二级页表中找到第L1个表项,这样就找到第三级页表的首地址.

  • 最后在第三级页表中找到第L0个表项,这样就能获取到PPN,然后拿PPN和offset组合在一起就形成物理地址.

  • 如果在任何一次寻找的时候Flags显示这个页表项不可用,那么就引发缺页中断.

但是CPU这样去访问页表需要3次访存操作,导致访问速度降低,因此底层硬件存在一个类似于cache的东西来保存页表信息,叫做TLB.CPU首会在TLB和内存中查找页表元素.如果TLB命中则停止访存操作,否则继续访存获得所需要的页表项,并更新TLB表.

每一个页表都保存若干个页表元素,每一个页表元素都都存储了flag标志,其中PTE_V字段表明页表项是否有效.PTE_W表明这个页是否可写,PTE_X表明这个页是否可执行,PTE_U表明用户态下是否可以访问该页.

在硬件层面上我们必须指定第一级页表的首地址,这个页表首地址存放在satp寄存器中,由于每个CPU的寄存器相互独立,所以说不同CPU的satp寄存器的值都是不一样的,这为每个CPU运行不同的进程提供了硬件支持.

内核地址空间

下面这张图表现了本操作系统在内核态下的基本布局:

vm.c中的kvminit函数构建并初始化了内核态的页表,执行了下面的操作:

  • 对中断控制context以及有关设备进行地址映射.其中所用到的宏定义在memlayout.h文件中.

  • 内核代码区、数据区以及自由空间按照直接映射的方式进行映射,并且设置相应的访存权限.

  • 将虚拟地址区域的顶部即TRAMPOLINE指向的虚拟地址映射到trampoline(trap出入口).

void
kvminit()
{
  kernel_pagetable = (pagetable_t) kalloc();
  // printf("kernel_pagetable: %p\n", kernel_pagetable);

  memset(kernel_pagetable, 0, PGSIZE);
  
  //uint64 FAT32_SIZE=(FAT32_END-FAT32_START+PGSIZE-1)/PGSIZE*PGSIZE;
  //printf("fat32end:%p\n",FAT32_END);
  // file system
  //kvmmap(FAT32_START_V, FAT32_START, FAT32_SIZE, PTE_R | PTE_W);
  // uart registers
  kvmmap(UART0_V, UART0, PGSIZE, PTE_R | PTE_W);
  
  #if QEMU!=SIFIVE_U
  // virtio mmio disk interface
  kvmmap(VIRTIO0_V, VIRTIO0, PGSIZE, PTE_R | PTE_W);
  #endif
  // CLINT
  kvmmap(CLINT_V, CLINT, 0x10000, PTE_R | PTE_W);

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

  #if QEMU==SIFIVE_U
  // GPIOHS
  kvmmap(GPIOHS_V, GPIOHS, 0x1000, PTE_R | PTE_W);

  // DMAC
  kvmmap(DMAC_V, DMAC, 0x1000, PTE_R | PTE_W);

  // GPIO
  // kvmmap(GPIO_V, GPIO, 0x1000, PTE_R | PTE_W);

  // SPI_SLAVE
  kvmmap(SPI_SLAVE_V, SPI_SLAVE, 0x1000, PTE_R | PTE_W);

  // FPIOA
  kvmmap(FPIOA_V, FPIOA, 0x1000, PTE_R | PTE_W);

  // SPI0
  kvmmap(SPI0_V, SPI0, 0x1000, PTE_R | PTE_W);

  // SPI1
  kvmmap(SPI1_V, SPI1, 0x1000, PTE_R | PTE_W);

  // SPI2
  kvmmap(SPI2_V, SPI2, 0x1000, PTE_R | PTE_W);

  // SYSCTL
  kvmmap(SYSCTL_V, SYSCTL, 0x1000, PTE_R | PTE_W);
  
  #endif
  
  // kvmmap(RUSTSBI_BASE, RUSTSBI_BASE, KERNBASE - RUSTSBI_BASE, PTE_R | PTE_X);
  // map kernel text executable and read-only.
  kvmmap(KERNBASE, KERNBASE, (uint64)etext - KERNBASE, PTE_R | PTE_X);
  // map kernel data and the physical RAM we'll make use of.
  kvmmap((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(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);

  #ifdef DEBUG
  printf("kvminit\n");
  #endif
}

上述函数构建好内核态使用的页表kernel_pagetable时,所有核就可以调用kvminithart函数把页表首地址载入到satp寄存器中.

void
kvminithart()
{
  w_satp(MAKE_SATP(kernel_pagetable));
  // reg_info();
  sfence_vma();
  #ifdef DEBUG
  printf("kvminithart\n");
  #endif
}

内存管理系统关键函数

首先需要关注数据结构pagetable_t. pagetable_t本质上就是一个uint64*类型的一个指针, 即第一级页表的首地址. 可以是用户进程页表的首地址, 也可以是内核页表的首地址.

1、walk函数, 这个函数根据虚拟地址va, 找到对应的页表项PTE并返回.

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()) == NULL)
        return NULL;
      memset(pagetable, 0, PGSIZE);
      *pte = PA2PTE(pagetable) | PTE_V;
    }
  }
  return &pagetable[PX(0, va)];
}

这个函数与之前第一节的给定虚拟地址寻找页表项的方法是一样的.

2、mappages函数. 该函数完成虚拟地址va到物理地址pa的映射, 添加对应页表项.

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

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

找到虚拟地址对应的待插入页表项地址, 然后对页表项进行写入即可.

3、copyin函数: 可把用户态或者内核态的数据写入到内核态的地址空间中.

4、copyout函数: 可把内核态的数据写入到用户态或者内核态的地址空间中.

5、walkaddr函数: 该函数以虚拟地址va为输入, 返回对应的物理地址pa. 该函数的功能基于walk函数.

6、vmunmap函数: 该函数通过给定一个va地址和待释放的页数, 将[va,va+npages*PGSIZE]这段虚拟地址空间释放.

物理块管理函数

基本的物理块管理函数在kalloc.c中定义

1、kinit函数,这个函数初始化可用的物理地址空间.

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

2、kalloc函数,这个函数可以返回一个可用的物理地址空间首地址.

void *
kalloc(void)
{
  struct run *r;

  acquire(&kmem.lock);
  r = kmem.freelist;
  if(r)
    kmem.freelist = r->next;
  release(&kmem.lock);

  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk
  return (void*)r;
}

我们知道, 每次申请页面都会调用一次kalloc函数. kalloc函数会从freelist中获取还未使用的物理页面并返回其首地址. 这个freelist已经在kinit函数中初始化好了.

3、kfree函数, 该函数释放pa所指的物理页面, 并将其加入到可用的物理地址空间中(即放置到freelist中).

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);
}

用户进程地址空间

下面有几个要提及的点:

  • 每个用户进程使用单独的页表, 以保证程序的独立性.

  • 用户看见的虚拟地址是连续的, 但对应程序的物理地址不是连续的, 这样加大了内存空间分配的灵活性.

  • trampoline(trap出入口)是所有用户通用的, 每个用户的页表都有MAXVA-PGSIZE->trampoline的映射, 即把用户可用虚拟地址顶部的最后一个页面映射到trap的出入口处理程序, 方便用户态和内核态的切换.

trapframe页直接映射到可用物理空间,在kernel态是直接映射的,所以说不用担心kernel态访问不了用户态的trapframe.

stack页存放由exec程序创建的各种参数以及参数的地址.并且还保存main执行完应该返回的PC.

在每个用户进程创建之前都会调用一次uvminit函数以创建对应用户进程的页表:页表的映射与上文描述相同.

void
uvminit(pagetable_t pagetable, pagetable_t kpagetable, uchar *src, uint sz)
{
  char *mem;

  if(sz >= PGSIZE)
    panic("inituvm: more than a page");
  mem = kalloc();
  // printf("[uvminit]kalloc: %p\n", mem);
  memset(mem, 0, PGSIZE);
  mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
  mappages(kpagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X);
  memmove(mem, src, sz);
  // for (int i = 0; i < sz; i ++) {
  //   printf("[uvminit]mem: %p, %x\n", mem + i, mem[i]);
  // }
}

Last updated