xv6--内存管理

文章来源: https://mp.weixin.qq.com/s/hEdFqOZfYnDmx_nIzcv6CA

我们知道,用户程序和内核程序中的机器指令使用的地址是虚拟地址,而访问RAM或物理内存用的是物理地址。在xv6中通过页表硬件将每个虚拟地址映射到物理地址的方法来保证程序的正确执行。

最新版的xv6是基于Sv39 RISC-V实现的,Sv39支持39位虚拟内存空间。每一页占用4KB内存,页内使用虚拟地址低12位寻址,虚拟地址的高27位划分为三级页号,每一级都有512个可用的页号。Sv39的页表对应一个物理页,每一个页表项占用64位,512个页表项*64位正好是一个物理页大小(4KB)。

在RISC-V中,物理内存地址是56位。所以物理内存可以大于单个虚拟内存地址空间,但是也最多到2^56。大多数主板还不支持2^56这么大的物理内存。物理内存地址是56位,其中44位是物理页号(PPN,Physical Page Number),剩下12位是页内偏移,这完全继承虚拟地址的低12位,也就是说在地址转换时,只需要将虚拟地址的高27位翻译成物理地址的高44位PPN即可,剩下的12位页内偏移直接拷贝过来即可。

如下图所示,实际转换分三步进行。页表以三级树的形式存储在物理内存中。树的根是一个4KB的页表,包含512个PTE(Page Table Entry),这些页表项包含树的下一级页表的物理地址。这些页面中的每一页都包含树中下一级的512个PTE。分页硬件使用27位中的高9位在根页表中选择一个PTE,中间的9位用于在树的下一级的页表中选择一个PTE,并使用后9位来选择最终的PTE。

图片

如果转换地址所需的三个PTE中的任何一个不存在,则分页硬件将引发错误。每个PTE都包含一些标志位,告诉分页硬件如何允许使用关联的虚拟地址。PTE_V表示PTE是否存在:如果未设置,对页面的引用会导致错误(即不允许)。PTE_R控制是否允许指令读取页。PTE_W控制是否允许指令写入页。PTE_X控制CPU是否可以将页面内容解释为指令并执行它们。PTE_U控制是否允许用户模式下的指令访问页面;如果未设置PTE_U,则PTE只能在管理员模式下使用。标志和所有其他页面硬件相关结构在(kernel/riscv.h)中定义。

为了告诉硬件使用页表,内核必须将根页表的物理地址写入satp寄存器。每个CPU核都有自己的satp。一个CPU核将使用它自己的satp所指向的页表来转换后续指令中的虚拟地址到物理地址,因此,不同的CPU核可以运行不同的进程,每个进程都有一个由其自己的页表描述的私有地址空间。

1.内核地址空间

内核有自己的页表。当进程进入内核时,xv6切换到内核页表,当内核返回到用户空间时,它切换到用户进程的页表。内核的内存是私有的。在kernel/memlayout.h文件中声明了xv6内核内存布局的常量。

QEMU模拟一台包含RAM(物理内存)和I/O设备的计算机(例如磁盘接口)。内存的物理地址从0x80000000开始,一直持续到至少0x86400000(xv6称该地址为PHYSTOP)。QEMU向软件公开设备接口,物理地址空间中0x80000000以下的内存映射为控制寄存器。内核可以通过读/写这些特殊的物理地址与设备交互,这样的读写是与设备硬件通信而非RAM。

内核对大多数虚拟地址使用自身映射,也就是说,内核的大部分地址空间都是“直接映射的”。例如,内核自身位于既是虚拟地址空间也是物理内存的KERNBASE处。这种直接映射简化了内核代码,这些代码既需要读写一个页面(使用其虚拟地址),也需要操作引用同一个页面(具有其物理地址)的PTE。但在xv6中,有以下两处的虚拟地址没有直接映射,它们是:

(1) Trampoline页面。它被映射到虚拟地址空间的顶部,用户页表具有相同的映射。在下面代码分析中,我们会看到页表的一个有趣的用例,一个物理页(保存trampoline代码)在内核的虚拟地址空间中被映射两次:一次在虚拟地址空间的顶部,一次在内核文本中。

(2) 内核堆栈页。每个进程都有自己的内核堆栈,内核堆栈被映射到较高的位置,因此xv6可以在其下方留下一个未映射的保护页。保护页的PTE无效(即未设置PTE_V),这可以确保如果内核溢出内核堆栈会导致错误,并且会死机。如果没有保护页,溢出的堆栈将覆盖其他内核内存,从而导致不正确的操作。最后是恐慌性崩溃。(注:本文先忽略内核栈的介绍,后面再续) 

2.定义内存管理数据结构

struct {
  structspinlock lock;   /* 自旋锁 */
  struct run*freelist;   /* 物理空闲页链表 */
} kmem;

kmem有两个字段,第一个字段是lock,它是自旋锁。第二个字段是freelist。xv6通过这个非常简单的数据结构对xv6内存进行管理。xv6将所有的可用的物理内存页都保存在freelist链表中。这样当通过kalloc函数申请一个内存页时,它就可以从freelist中获取。由于freelist链表是个临界资源,对临界资源的使用需要互斥,即对临界资源访问的代码需要保证原子性,因此,每当对freelist链表进行操作时,都需要先上锁,操作完成后释放锁。

3. 启动时对内存进行初始化

对于RISC-V来说,系统上电启动后处于M模式,此时,指令所用的虚拟地址直接对应物理地址,即虚拟地址就是物理地址。在S模式下,当页表功能没有启用之前与M模式一样,直接用虚拟地址代替物理地址访问内存。为了方便内核的运行,当页表启用后,虚拟地址经过页表映射后的物理地址与虚拟地址相同。

Xv6对内存进行初始化是CPU核由M模式转变为S模式后进行的。当CPU核由M模式变换为S模式后执行的第一个函数是main(),程序如下:

// start() jumps here in supervisor mode on all CPUs.
void
main()
{
  if(cpuid() == 0){
    consoleinit();
    printfinit();
    printf("\n");
    printf("xv6 kernel is booting\n");
    printf("\n");
    kinit();         // physical page allocator
    kvminit();       // create kernel page table
    kvminithart();   // turn on paging
    procinit();      // process table
    trapinit();      // trap vectors

其中第6行初始化串口,第7行初始化输出互斥锁,第8行至第10行输出信息,第11行至13行就是对内存进行初始化,并启用页表。下面我们就来分析这三行代码。

(1)将内存中的空闲物理页放到freelist链表中

在调用kinit函数之前,内存中除了内核和内核栈占用了部分存储空间外,剩下的都是空闲区,所以,xv6对内存初始化的首要任务是将空闲物理页管理起来。具体操作是:把内存中没有使用的内存按物理页插入到空闲块链表kmem.freelist中,以后对内存的分配和回收就是对kmem.freelist进行操作。因kmem.freelist是临界资源,要保证对其操作的互斥性,因此使用互斥锁kmem.lock来实现这一功能。初始化内存代码如下:

void
kinit()
{
   initlock(&kmem.lock, "kmem");  /* 初始化用于管理内存空闲块链表的自旋锁 */
   freerange(end, (void*)PHYSTOP); /* 将end至PHYSTOP 之间的内存页插入到空闲块链表中 */
}

end是内核可分配物理内存页的开始地址,PHYSTOP是内核可使用的物理内存的结束地址。kinit函数完成2项任务,第一项是初始化用于管理freelist链表的自旋锁kmem.lock;第二将内存中的空闲物理页放到freelist链表中,freerange的代码如下:

void
freerange(void *pa_start, void *pa_end)
{
  char *p;
  p =(char*)PGROUNDUP((uint64)pa_start);        /* 向上以PGSIZE大小对齐 */
  for(; p +PGSIZE <= (char*)pa_end; p += PGSIZE)  /* 将所有的空闲块插入到空闲链表中 */
    kfree(p);
}

在freerange中调用了kfree函数,它的功能是回收内存,freerange将所有的空闲物理页看作将要回收的页,调用kfree将其插入到freelist链表中。kfree函数代码如下:

// Free the page of physical memory pointed at byv,
// which normally should have been returned by a
// call to kalloc().  (The exception is when
// initializing the allocator; see kinit above.)
void
kfree(void *pa)
{
  struct run*r;
 
 if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >=PHYSTOP)
   panic("kfree");
 
  // Fillwith junk to catch dangling refs.
  memset(pa,1, PGSIZE);
 
  r = (structrun*)pa;
 
 acquire(&kmem.lock);   /* 申请对空闲块链表的控制权 */
 r->next= kmem.freelist;  /* 将空闲块插入到空闲块链表中 */
 kmem.freelist = r;
 release(&kmem.lock);   /* 释放对空闲块链表控制权 */
}

从上面的程序可以看到在将空闲物理页插入到freelist链表中之前要上锁,即调用acquire函数,将空闲物理页插入到链表之后,调用release函数释放锁。

(2)调用kvminit函数为内核创建页表

kvminit函数用来创建内核页表,代码如下:

/*
 * create adirect-map page table for the kernel.
 */
void
kvminit()
{
 kernel_pagetable = (pagetable_t) kalloc();     /* 申请一块内存 */
 memset(kernel_pagetable, 0, PGSIZE);           /* 初始化存储块 */
 
  // uartregisters
 kvmmap(UART0, UART0, PGSIZE, PTE_R | PTE_W);  
 
  // virtiommio disk interface
 kvmmap(VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
 
  // CLINT
 kvmmap(CLINT, CLINT, 0x10000, PTE_R | PTE_W);
 
  // PLIC
 kvmmap(PLIC, PLIC, 0x400000, PTE_R | PTE_W);
 
  // mapkernel text executable and read-only.
 kvmmap(KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
 
  // mapkernel data and the physical RAM we'll make use of.
 kvmmap((uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R |PTE_W);
 
  // map thetrampoline for trap entry/exit to
  // thehighest virtual address in the kernel.
 kvmmap(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

第7行,kalloc函数申请一个物理页作为内核页表,该页表为根页表,前文已述,xv6采用3级页表,目前只是第一级页表。
第8行,将页表清0。

剩下的程序行通过调用kvmmap将虚拟地址与物理地址填写到页表中,在填写过程中,如果二级或三级页表不存在,程序会调用kalloc申请物理页,将映射关系填入页表中。从程序可以看出,除30行外,在调用kvmmap时的前两个参数一样,这说明虚拟地址与物理地址是相等的。关于TRAMPOLINE前文已介绍过,它有特殊的用途。

11行、14行、17行和20行等为设备建立页表,23行位内核建立页表。30行为跳跳板(TRAMPOLINE)建立页表(本处将TRAMPOLINE称为跳跳板,是因为用户模式过渡到内核模式需要借助与TRAMPOLINE实现,它的作用就相当于跳跳板)。

kvmmap函数代码如下:

// add a mapping to the kernel page table.
// only used when booting.
// does not flush TLB or enable paging.
void
kvmmap(uint64 va, uint64 pa, uint64 sz, int perm)
{
 if(mappages(kernel_pagetable, va, sz, pa, perm) != 0)
   panic("kvmmap");
}

代码很简单,调用mappages成功返回,失败显示出错信息然后死机。mappages函数代码如下:

// Create PTEs for virtual addresses starting at vathat refer to
// physical addresses starting at pa. va and sizemight not
// be page-aligned. Returns 0 on success, -1 ifwalk() couldn't
// allocate a needed page-table page.
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);   /* 如果size=PGSIZE,则a=last */
  for(;;){
    if((pte =walk(pagetable, a, 1)) == 0)
      return-1;
    if(*pte& PTE_V)
     panic("remap");
    *pte =PA2PTE(pa) | perm | PTE_V;
    if(a ==last)
      break;
    a +=PGSIZE;
    pa +=PGSIZE;
  }
  return 0;
}

mappages将虚拟地址页号到物理地址页号的映射安装到页表中。在size范围内的虚拟地址将以页大小为间隔分别执行此操作。对于要映射的每个虚拟地址,mappages调用walk来查找该地址的PTE地址,然后初始化PTE以保存相关的物理页码和所需的权限(PTE_W、PTE_X和/或PTE_R),并将PTE的标志位PTE_V设置为有效。


// Return the address of the PTE in page tablepagetable
// that corresponds to virtual address va.  If alloc!=0,
// create any required page-table pages.
//
// The risc-v Sv39 scheme has three levels ofpage-table
// pages. A page-table page contains 512 64-bitPTEs.
// A 64-bit virtual address is split into fivefields:
//   39..63-- must be zero.
//   30..38-- 9 bits of level-2 index.
//   21..29-- 9 bits of level-1 index.
//   12..20-- 9 bits of level-0 index.
//    0..11-- 12 bits of byte offset within the page.
pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
  if(va >=MAXVA)
   panic("walk");
 
  for(intlevel = 2; level > 0; level--) {    /*处理了level=2和1*/
    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)];   /* 返回0级 */
}

walk函数模拟RISC-V分页硬件,因为它在PTE中查找虚拟地址。walk下移3级页表9位。它使用每个级别的9位虚拟地址来查找下一级页表或最后一页的PTE。如果PTE无效,则尚未分配所需的页;如果设置了alloc参数,walk将分配一个新的页表页并将其物理地址放入PTE中。它返回树中最底层的PTE的地址。

从代码看,这个函数从level2走到level1然后到level0,如果参数alloc不为0,且某一个level的页表不存在,这个函数会创建一个临时的页表,将内容初始化为0,并继续运行。所以最后总是返回的是最低一级的页目录的PTE。

如果参数alloc为0,那么在第一个PTE对应的下一级页表不存在时就会返回。我们看代码知道在调用该函数时,alloc的值被设置为1。

// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
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;
}

当需要内存时,可调用该函数申请一个物理块,如果分配失败返回0,否则返回物理块的地址。

(3) 启用页表--kvminithart

页表设置完毕后,该启用页表了。函数kvminithart完成此项工作。

//Switch h/w page table register to the kernel's page table,
//and enable paging.
void
kvminithart()
{
  w_satp(MAKE_SATP(kernel_pagetable));
  sfence_vma();
}

kvminithart函数首先设置了SATP寄存器,kernel_pagetable变量来自于kvminit第一行。所以这里实际上是内核告诉MMU来使用刚刚设置好的页表。当这条指令执行之后,下一个指令的地址会发生什么? 

在这条指令之前,还不存在可用的页表,所以也就不存在地址翻译。执行完这条指令之后,程序计数器(Program Counter)增加了4,而之后的下一条指令被执行时,程序计数器会被内存中的页表翻译。所以这条指令的执行时刻是一个非常重要的时刻。因为整个地址翻译从这条指令之后开始生效,之后用到的每一个地址都可能对应到与之不同的物理内存地址。因为在这条指令之前,我们使用的都是物理内存地址,这条指令之后页表开始生效,指令中的所有地址都变成了另一个含义,即虚拟地址。 

这里能正常工作的原因是值得注意的。因为前一条指令还是在物理内存中,而后一条指令已经在虚拟内存中了。比如,下一条指令地址是0x80000fee就是一个虚拟内存地址。

图片

为什么这里能正常工作呢?因为内核页表的映射关系中,虚拟地址和对应的物理地址是完全相等的。所以,在我们打开虚拟地址翻译硬件之后,地址翻译硬件会将一个虚拟地址翻译到与它相同的物理地址。所以实际上,我们最终还是能通过内存地址执行到正确的指令,因为经过地址翻译0x80000ffe还是对应0x80000ffe。 

管理虚拟内存的一个难点是,一旦执行了类似于SATP这样的指令,你相当于将一个页表加载到了SATP寄存器,你的世界完全改变了。现在每一个地址都会被你设置好的页表所翻译。那么假设你的页表设置错误了,虚拟地址可能根本就翻译不了,那么内核会停止运行并panic。所以,如果页表中有bug,你将会看到奇怪的错误和崩溃。如果你不够小心,或者你没有完全理解一些细节,有可能会导致内核崩溃,这将会花费一些时间和精力来追踪背后的原因。但这就是管理虚拟内存的一部分,因为对于一个这么强大的工具,如果出错了,相应的你也会得到严重的后果。

kvminithart函数中的sfence.vma()是清空页表缓存。

// flush the TLB.
static inline void
sfence_vma()
{
  // the zero, zero means flush all TLB entries.
  asm volatile("sfence.vma zero, zero");
}

在系统正常运行时,如果页表被切换了,那么就得通知MMU,告诉它页表修改了,快表(TLB)中缓存的信息失效,它们需要被清空,否则地址翻译会出错。在RISC-V中,清空TLB的指令是sfence_vma。所以。每当进程切换,或U模式与S模式互换时均需要清空TLB,执行sfence.vma指令是必须的,但要注意,该指令以s开头,显然只能在S模式下执行。

4. 内存的分配和回收

在第3节中介绍的kalloc和kfree两个函数就是实现内存分配与回收。关于vx6内存管理就介绍到这。本文在编写过程中参考了大量文献,在此谢谢。

关于xv6的微信公众号话题

https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzIyNDQyMzQ5Ng==&action=getalbum&album_id=1906701012115980290#wechat_redirect

  • 2
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

lhw---9999

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

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

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

打赏作者

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

抵扣说明:

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

余额充值