xv6(RISC-V)操作系统源码分析第三节——地址映射与内存分配

一、xv6页表的作用

物理内存指DRAM中的存储单元。物理存储器的一个字节有一个地址,称为物理地址。当指令操作虚拟地址时,分页硬件会将其翻译成物理地址,然后发送给DRAM硬件以读写。

而分页硬件操作的核心数据结构就是页表。

页表在物理内存中。

通过页表机制,xv6为每个进程提供各自私有的地址空间和内存。

页表决定了内存地址的含义与物理内存的哪些部分可以被访问。

页表提供了一个间接层次,允许xv6实现如下技巧:

  • 在几个不同的地址空间映射同一内存(trampoline页)
  • 用一个未映射页来保护内核栈和用户栈

二、RISC-V硬件对页表的支持

(一)原理

注意:RISC-V指令(不论是用户还是内核)操作的一定是虚拟地址。

计算机的RAM用物理地址做索引。 

RISC-V的页表硬件支持了虚拟地址到物理地址的转换。

xv6运行在Sv39 RISC-V上, 这意味着只使用64位虚拟地址的低39位,而高25位不使用,高25位作为保留位。

同时,一个页表在逻辑上由2^27(134217728)个页表项Page Table EntryPTE)组成的数组。

每个PTE包含一个44位的物理页号Physical Page NumberPPN)和10位的标志位,其高10位保留。

分页硬件通过利用39位中的高27位索引到页表中找到一个PTE来转换一个虚拟地址,并计算出一个56位的物理地址,该物理地址的前44位来自PTE中的PNN,而它的后12位则是从原来的虚拟地址中的低12位复制过来。

整个转换过程如下图所示:

xv6(RISC-V)操作系统源码分析第三节——地址映射与内存分配_第1张图片

从上面我们可以知道:一个页为2^12(4096)个字节。

(二)xv6页表机制的真实实现

实际上,xv6的地址转换不是按照上面来的,而是分三步进行。

页表以三层树的形式存储在物理内存中。

树的根部是一个4096字节的页表,包含512个页表项。这些页表项包含树的下一级页表的物理地址首址。第二级页表也包含512个页表项,这些页表项包含第三级页表的物理首址。

而树根(第一级页表的物理首址由页表寄存器satp提供)。虚拟地址的低39位的高27位用来定位页框号。这27位被分为3份,每份9位,分别定位对应的页表的页表项。具体过程如下图所示:

xv6(RISC-V)操作系统源码分析第三节——地址映射与内存分配_第2张图片

 可见,satp寄存器为地址转换提供硬件支持。而satp寄存器由内核写入根页表的物理地址。

每个CPU都有自己的satp寄存器。因此,一个CPU将使用自己的satp寄存器所指向的页表来翻译后续指令产生的所有地址。这保证了不同CPU可以运行不同的进程,因为这使每个CPU的进程可以拥有不同的私有地址空间。

缺页异常:若转换一个地址所需的三个页表项中的任何一个不存在,分页硬件就会引发一个缺页异常(page-fault exception),内核会处理这个异常。

 页表项的结构:
xv6(RISC-V)操作系统源码分析第三节——地址映射与内存分配_第3张图片

位名称 功能
V 表示页表项是否存在,没设置则不允许引用该页表项对应的页        
R 决定指令是否允许指令读取该页
W 决定是否允许指令向该页写入
X 决定CPU是否可以将页表的内容解释为指令并执行
U 决定是否允许用户态下的指令访问页面,没设置则只能在内核态下使用

数据结构:

#define PTE_V (1L << 0) // valid
#define PTE_R (1L << 1)
#define PTE_W (1L << 2)
#define PTE_X (1L << 3)
#define PTE_U (1L << 4) // user can access

三、内核地址空间

(一)QEMU的规定

QEMU模拟的计算机包含DRAM(物理内存)与I/O设备。

物理内存从0x80000000开始,至少到0x86400000,xv6称之为PHYSTOP

QEMU将设备接口作为内存映射的控制寄存器暴露给软件,这些寄存器位于物理地址空间的0x80000000以下。内核可以通过读取或写入这些特殊的物理地址与设备进行交互。这种读取与写入不是与DRAM通信,而是与设备硬件通信。

(二)内核映射

内核对DRAM与内存映射的设备寄存器使用直接映射,即将这些资源映射到和它们物理地址相同的虚拟地址上。比如,内核本身在虚拟地址空间和物理地址空间中的位置都是KERNBASE=0x80000000。直接映射简化了读写物理内存的内核代码。比如,当fork为子进程分配用户内存时,分配器返回该内存的物理地址,所以fork在将父进程的用户内存复制到子进程时,直接使用该地址作为虚拟地址。

但有两个内核虚拟地址不是直接映射:

  • trampoline页:它被映射在虚拟地址空间的顶端,用户页表也有这个映射。该页的功能概况参见第二节内容,但在第四节会详细讨论该页。
  • 内核栈页:每个进程拥有自己的内核栈,内核栈被映射到高地址处,所以xv6可以在它后面留下一个未映射的守护页。守护页的页表项是无效的(不设置V位)。守护页的功能是在内核栈溢出时,作为余地来守护后面的内核内存,内核报错。若无守护页,溢出的部分可能会覆盖其他内核内存,造成严重后果。

内核将trampoline和可执行程序的代码段映射为有R与X权限的页,内核从这些页读取和执行指令。内核映射的其他页有R与W权限,以便内核可以读写这些页面的内存。

下图显示了内核如何将内核虚拟地址映射为物理地址:

xv6(RISC-V)操作系统源码分析第三节——地址映射与内存分配_第4张图片

 内核通过高地址映射使用它的栈空间,栈空间也可以通过直接映射的地址被内核访问。

(三)如何创建一个地址空间

pagetable_t是一个指向RISC-V根页表的指针。它可以是内核页表,也可以是进程的页表:

typedef uint64 *pagetable_t; // 512 PTEs

通过虚拟地址得到页表项的函数walk。

walk模仿RISC-V分页硬件查找虚拟地址的页表项。其每次降低9位来查找三级页表。它使用每一级的9位虚拟地址来查找下一级页表或最后一级的页表项。

若页表项无效,则所需的物理页尚未被分配。若alloc参数被设置,walk会分配一个新的页表项,并把它的物理地址放入到页表项中。

walk返回树的最低层页表项的地址。 

// Return the address of the PTE in page table pagetable
// that corresponds to virtual address va.  If alloc!=0,
// create any required page-table pages.
//
// The risc-v Sv39 scheme has three levels of page-table
// pages. A page-table page contains 512 64-bit PTEs.
// A 64-bit virtual address is split into five fields:
//   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(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)];
}

虚拟地址映射为物理地址的函数:

// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page-aligned. Returns 0 on success, -1 if walk() 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;

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

以kvm开头的函数操作内核页表,以uvm开头的函数操作用户页表,其他函数同时用于这两种页表。

copyoutcopyin用于将数据复制到或复制出被作为系统调用参数的用户虚拟地址。

// Copy from kernel to user.
// Copy len bytes from src to virtual address dstva in a given page table.
// Return 0 on success, -1 on error.
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(dstva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (dstva - va0);
    if(n > len)
      n = len;
    memmove((void *)(pa0 + (dstva - va0)), src, n);

    len -= n;
    src += n;
    dstva = va0 + PGSIZE;
  }
  return 0;
}
// Copy from user to kernel.
// Copy len bytes to dst from virtual address srcva in a given page table.
// Return 0 on success, -1 on error.
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    va0 = PGROUNDDOWN(srcva);
    pa0 = walkaddr(pagetable, va0);
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (srcva - va0);
    if(n > len)
      n = len;
    memmove(dst, (void *)(pa0 + (srcva - va0)), n);

    len -= n;
    dst += n;
    srcva = va0 + PGSIZE;
  }
  return 0;
}

kvmmap调用mappages,将指定范围的虚拟地址映射到一段物理地址。它将范围内地址分割成多页(忽略余数),每次映射一页的起始地址。对于每个要映射的虚拟地址(页的起始地址),mappages调用walk找到该地址的最后一级页表项的地址,然后配置页表项,使其持有相关的物理页号、所需的权限(W、X、R或V)。

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

 main调用kvminitharyt来映射内核页表,它将根页表的物理地址写入satp寄存器中。

// Switch h/w page table register to the kernel's page table,
// and enable paging.
void kvminithart()
{
  // wait for any previous writes to the page table memory to finish.
  sfence_vma();

  w_satp(MAKE_SATP(kernel_pagetable));

  // flush stale entries from the TLB.
  sfence_vma();
}

main调用procinit为每个进程分配一个内核栈。它将每个栈映射在KSTACK生成的虚拟地址上,这就为栈守护页留下了空间。

kvmmap将对应的页表项加入内核页表,然后调用kvminithart将内核页表重新加载到satp中,这样硬件就知道了新的页表项。

// initialize the proc table.
void procinit(void)
{
  struct proc *p;
  
  initlock(&pid_lock, "nextpid");
  initlock(&wait_lock, "wait_lock");
  for(p = proc; p < &proc[NPROC]; p++) {
      initlock(&p->lock, "proc");
      p->state = UNUSED;
      p->kstack = KSTACK((int) (p - proc));
  }
}

每个RISC-V的CPU都会在快表(TLB)中缓存页表项。当xv6改变页表时,必须告诉CPU使相应的缓存TLB项无效。否则,TLB可能在以后使用一个旧的缓存映射指向一个物理页,而该物理页在此期间已经分配给了另一个进程。

RISC-V有一条刷新当前CPU的TLB的指令——sfence.vma

xv6会在以下两个地方使用sfence.vma

  • 重新加载satp后,执行该指令来刷新TLB
  • 从内核空间返回用户空间前,切换到用户页表的trampoline代码中执行该指令
            # wait for any previous memory operations to complete, so that
            # they use the user page table.
            sfence.vma zero, zero

 四、物理内存分配

(一)原理

内核必须在运行时动态地为页表、用户内存、内核堆栈和管道缓冲区分配和释放物理内存。

xv6使用内核地址结束到PHYSTOP之间的物理内存进行运行时分配

分配和释放以页(4096字节大小)为单位。

xv6通过保存空闲页链表来记录哪些页是空闲的:

  • 分配:从链表中删除一页
  • 释放:将释放的页面添加到空闲页链表中

(二)如何进行物理页面分配

物理页面分配器的数据结构是一个空闲页链表,每个链表元素是一个结构体:

struct run {
  struct run *next;
};

分配器把每个空闲页的run结构体存放在空闲页自身中。

空闲页链表由一个自旋锁保护,链表和锁被包裹在一个结构体中,以明确锁保护的是结构体中的字段。相关数据结构如下:

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

main调用kinit来初始化分配器,kinit初始化空闲页链表,以保存从内核地址结束到PHYSTOP之间的每一页:

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

 xv6不是通过解析硬件提供的配置信息来确定有多少物理内存可用,而是假设计算机有128M字节的DRAM。kinit通过freerange来添加内存到空闲页链表,freerange则对每一页都调用kfree。由于页表项只能指向按4096字节对齐的物理地址,因此freerange使用PGROUNDUP来确保它只添加对齐的物理地址到空闲页链表中。分配器起初没有内存,这些对kfree的调用给了它内存管理功能。

分配器代码中充满了C类型转换,其原因有两点:

  • 对地址的双重使用:分配器有时将地址当作整数处理,以便对其进行计算(如freerange遍历所有页时),有时把地址当作指针来读写内存(如操作存储在每页中的run结构体)
  • 释放和分配本质上改变了内存的类型

kfree将被释放的内存中的每个字节设置为1。这会使得释放内存后使用内存的代码(使用悬空引用)将读取垃圾而不是旧的有效内容,这会导致这类代码更快地结束。然后kfree将页表预存入释放列表:将pa(物理地址)转为指向结构体run的指针类型,在r->next中记录空闲链表之前的节点,并将释放链表设为r。kalloc移除并返回空闲链表中的第一个元素。

// Free the page of physical memory pointed at by pa,
// 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");

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

五、进程地址空间 

每个进程都有一个属于自己的页表。因此,当xv6切换进程时,也伴随着切换页表。

一个进程的用户空间内存从虚拟地址0开始,可以增长到MAXVA,一个进程最大寻址256GB的内存。

// one beyond the highest possible virtual address.
// MAXVA is actually one bit less than the max allowed by
// Sv39, to avoid having to sign-extend virtual addresses
// that have the high bit set.
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))

当一个进程请求xv6提供更多的用户内存时,xv6首先使用kalloc来分配物理页。

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

 然后xv6将指向新物理页的页表项添加到进程的页表中。xv6设置这些页表项的标志位。由于大多数进程不使用整个用户空间,故xv6将不使用的页表项的V位保持为清除状态。

注意:

  1. 不同进程页表将用户空间转化为物理内存的不同页,这样使得每个进程都有私有的用户内存。
  2. 每个进程都认为自己的内存具有从0开始的连续的虚拟地址,但实际上进程的物理内存可以是不连续的。
  3. 内核会映射带有trampoline代码的页到用户地址空间顶端,所以,有一个物理内存页在所有进程的地址空间中都会出现。

下图显示了xv6中执行进程的用户内存布局:

 xv6(RISC-V)操作系统源码分析第三节——地址映射与内存分配_第5张图片

从图中可以看见,栈只有一页,上图显示的是由exec创建的初始内容。位于栈顶的字符串中包含了命令行中输入的参数和指向他们的指针数组。下方是允许程序在main启动的值,就像函数main(argc, argv)是刚刚被调用一样。

缺页异常:若用户栈溢出,而进程试图使用栈下面的地址,硬件会因为该映射无效而产生一个缺页异常。xv6在stack下方放置了一个无效的保护页(不可被映射,但有空间),其目的是留余地,阻止溢出部分覆盖正常的部分。 

六、sbrk系统调用的实现

sbrk是一个实现增减进程的内存的系统调用。该系统调用由函数growproc实现。

uint64 sys_sbrk(void)
{
  uint64 addr;
  int n;

  argint(0, &n);
  addr = myproc()->sz;
  if(growproc(n) < 0)
    return -1;
  return addr;
}
// Grow or shrink user memory by n bytes.
// Return 0 on success, -1 on failure.
int growproc(int n)
{
  uint64 sz;
  struct proc *p = myproc();

  sz = p->sz;
  if(n > 0){
    if((sz = uvmalloc(p->pagetable, sz, sz + n, PTE_W)) == 0) {
      return -1;
    }
  } else if(n < 0){
    sz = uvmdealloc(p->pagetable, sz, sz + n);
  }
  p->sz = sz;
  return 0;
}

growproc调用uvmalloc或uvmdealloc,取决于n是正数还是负数。

uvmalloc通过kalloc分配物理内存,并使用mappages将页表项添加到用户页表中。

// Allocate PTEs and physical memory to grow process from oldsz to
// newsz, which need not be page aligned.  Returns new size or 0 on error.
uint64 uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz, int xperm)
{
  char *mem;
  uint64 a;

  if(newsz < oldsz)
    return oldsz;

  oldsz = PGROUNDUP(oldsz);
  for(a = oldsz; a < newsz; a += PGSIZE){
    mem = kalloc();
    if(mem == 0){
      uvmdealloc(pagetable, a, oldsz);
      return 0;
    }
    memset(mem, 0, PGSIZE);
    if(mappages(pagetable, a, PGSIZE, (uint64)mem, PTE_R|PTE_U|xperm) != 0){
      kfree(mem);
      uvmdealloc(pagetable, a, oldsz);
      return 0;
    }
  }
  return newsz;
}

 uvmdealloc调用uvmunmap,uvmunmap使用walk查找页表项,并使用kfree释放它们引用的物理内存。

// Deallocate user pages to bring the process size from oldsz to
// newsz.  oldsz and newsz need not be page-aligned, nor does newsz
// need to be less than oldsz.  oldsz can be larger than the actual
// process size.  Returns the new process size.
uint64 uvmdealloc(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(pagetable, PGROUNDUP(newsz), npages, 1);
  }

  return newsz;
}

uvmunmap函数:

// Remove npages of mappings starting from va. va must be
// page-aligned. The mappings must exist.
// Optionally free the physical memory.
void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
  uint64 a;
  pte_t *pte;

  if((va % PGSIZE) != 0)
    panic("uvmunmap: not aligned");

  for(a = va; a < va + npages*PGSIZE; a += PGSIZE){
    if((pte = walk(pagetable, a, 0)) == 0)
      panic("uvmunmap: walk");
    if((*pte & PTE_V) == 0)
      panic("uvmunmap: not mapped");
    if(PTE_FLAGS(*pte) == PTE_V)
      panic("uvmunmap: not a leaf");
    if(do_free){
      uint64 pa = PTE2PA(*pte);
      kfree((void*)pa);
    }
    *pte = 0;
  }
}

七、exec系统调用的实现

exec是创建一个地址空间的用户部分的系统调用。它读取存储在文件系统上的文件用以初始化一个地址空间的用户部分。

xv6应用程序使用ELF格式来描述可执行文件。

ELF格式的定义如下:

// File header
struct elfhdr {
  uint magic;  // must equal ELF_MAGIC
  uchar elf[12];
  ushort type;
  ushort machine;
  uint version;
  uint64 entry;
  uint64 phoff;
  uint64 shoff;
  uint flags;
  ushort ehsize;
  ushort phentsize;
  ushort phnum;
  ushort shentsize;
  ushort shnum;
  ushort shstrndx;
};

// Program section header
struct proghdr {
  uint32 type;
  uint32 flags;
  uint64 off;
  uint64 vaddr;
  uint64 paddr;
  uint64 filesz;
  uint64 memsz;
  uint64 align;
};

一个ELF二进制文件包含一个ELF头和一串程序段头。这两个“头”分别用一个结构体定义。

一个ELF二进制文件以四个字节的“魔法数字”0x7f、E、L、F或ELF_MAGIC开始。若ELF头有正确的“魔法数字”,exec就认为该二进制文件是正确的类型。

xv6程序只有一个程序段头,程序段头描述了应用必须加载到内存中的程序段。

int exec(char *path, char **argv)
{
  char *s, *last;
  int i, off;
  uint64 argc, sz = 0, sp, ustack[MAXARG], stackbase;
  struct elfhdr elf;
  struct inode *ip;
  struct proghdr ph;
  pagetable_t pagetable = 0, oldpagetable;
  struct proc *p = myproc();

  begin_op();

  if((ip = namei(path)) == 0){
    end_op();
    return -1;
  }
  ilock(ip);

  // Check ELF header
  if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))
    goto bad;

  if(elf.magic != ELF_MAGIC)
    goto bad;

  if((pagetable = proc_pagetable(p)) == 0)
    goto bad;

  // Load program into memory.
  for(i=0, off=elf.phoff; isz;

  // Allocate two pages at the next page boundary.
  // Make the first inaccessible as a stack guard.
  // Use the second as the user stack.
  sz = PGROUNDUP(sz);
  uint64 sz1;
  if((sz1 = uvmalloc(pagetable, sz, sz + 2*PGSIZE, PTE_W)) == 0)
    goto bad;
  sz = sz1;
  uvmclear(pagetable, sz-2*PGSIZE);
  sp = sz;
  stackbase = sp - PGSIZE;

  // Push argument strings, prepare rest of stack in ustack.
  for(argc = 0; argv[argc]; argc++) {
    if(argc >= MAXARG)
      goto bad;
    sp -= strlen(argv[argc]) + 1;
    sp -= sp % 16; // riscv sp must be 16-byte aligned
    if(sp < stackbase)
      goto bad;
    if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
      goto bad;
    ustack[argc] = sp;
  }
  ustack[argc] = 0;

  // push the array of argv[] pointers.
  sp -= (argc+1) * sizeof(uint64);
  sp -= sp % 16;
  if(sp < stackbase)
    goto bad;
  if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0)
    goto bad;

  // arguments to user main(argc, argv)
  // argc is returned via the system call return
  // value, which goes in a0.
  p->trapframe->a1 = sp;

  // Save program name for debugging.
  for(last=s=path; *s; s++)
    if(*s == '/')
      last = s+1;
  safestrcpy(p->name, last, sizeof(p->name));
    
  // Commit to the user image.
  oldpagetable = p->pagetable;
  p->pagetable = pagetable;
  p->sz = sz;
  p->trapframe->epc = elf.entry;  // initial program counter = main
  p->trapframe->sp = sp; // initial stack pointer
  proc_freepagetable(oldpagetable, oldsz);

  return argc; // this ends up in a0, the first argument to main(argc, argv)

 bad:
  if(pagetable)
    proc_freepagetable(pagetable, sz);
  if(ip){
    iunlockput(ip);
    end_op();
  }
  return -1;
}

代码解析:

打开二进制文件路径

  if((ip = namei(path)) == 0){
    end_op();
    return -1;
  }

读取ELF头,检查该文件是否是ELF格式。

  if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))
    goto bad;

  if(elf.magic != ELF_MAGIC)
    goto bad;

exec使用proc_pagetable分配一个未使用的页表,使用uvmalloc为每个ELF段分配内存,使用loadseg加载每一个段到内存中。

  if((pagetable = proc_pagetable(p)) == 0)
    goto bad;

  // Load program into memory.
  for(i=0, off=elf.phoff; i

而loadseg使用walkaddr找到分配内存的物理地址,在该地址写入ELF段的每一页,页的内容通过readi从文件中读取。

// Load a program segment into pagetable at virtual address va.
// va must be page-aligned
// and the pages from va to va+sz must already be mapped.
// Returns 0 on success, -1 on failure.
static int
loadseg(pagetable_t pagetable, uint64 va, struct inode *ip, uint offset, uint sz)
{
  uint i, n;
  uint64 pa;

  for(i = 0; i < sz; i += PGSIZE){
    pa = walkaddr(pagetable, va + i);
    if(pa == 0)
      panic("loadseg: address should exist");
    if(sz - i < PGSIZE)
      n = sz - i;
    else
      n = PGSIZE;
    if(readi(ip, 0, (uint64)pa, offset+i, n) != n)
      return -1;
  }
  
  return 0;
}

exec在栈的下方放置了一个不可访问页,这会使程序试图使用多个页面时出现故障。该不可访问页还能允许exec处理过大的参数(exec用来复制参数到栈的copyout函数会注意到目标页不可访问,并访问-1)。

在准备新的内存映像过程中,若exec检测到一个错误,比如一个无效的程序段,它就会跳转到标签bad,释放新的映像,并返回-1。exec必须延迟释放旧映像,直到它确定exec系统调用成功,因为若旧映像消失了,系统调用就不能返回-1。

exec中唯一的错误情况发生在创建映像的过程中。一旦映像完成,exec就可以提交到新的页表,并释放旧的页表。

// Allocate two pages at the next page boundary.
  // Make the first inaccessible as a stack guard.
  // Use the second as the user stack.
  sz = PGROUNDUP(sz);
  uint64 sz1;
  if((sz1 = uvmalloc(pagetable, sz, sz + 2*PGSIZE, PTE_W)) == 0)
    goto bad;
  sz = sz1;
  uvmclear(pagetable, sz-2*PGSIZE);
  sp = sz;
  stackbase = sp - PGSIZE;

  // Push argument strings, prepare rest of stack in ustack.
  for(argc = 0; argv[argc]; argc++) {
    if(argc >= MAXARG)
      goto bad;
    sp -= strlen(argv[argc]) + 1;
    sp -= sp % 16; // riscv sp must be 16-byte aligned
    if(sp < stackbase)
      goto bad;
    if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
      goto bad;
    ustack[argc] = sp;
  }
  ustack[argc] = 0;

  // push the array of argv[] pointers.
  sp -= (argc+1) * sizeof(uint64);
  sp -= sp % 16;
  if(sp < stackbase)
    goto bad;
  if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0)
    goto bad;

  // arguments to user main(argc, argv)
  // argc is returned via the system call return
  // value, which goes in a0.
  p->trapframe->a1 = sp;

  // Save program name for debugging.
  for(last=s=path; *s; s++)
    if(*s == '/')
      last = s+1;
  safestrcpy(p->name, last, sizeof(p->name));
    
  // Commit to the user image.
  oldpagetable = p->pagetable;
  p->pagetable = pagetable;
  p->sz = sz;
  p->trapframe->epc = elf.entry;  // initial program counter = main
  p->trapframe->sp = sp; // initial stack pointer
  proc_freepagetable(oldpagetable, oldsz);

  return argc; // this ends up in a0, the first argument to main(argc, argv)

使用exec创建的第一个用户程序/init的程序段头如下:

# objdump -p _init
user/_init: file format elf64-littleriscv
Program Header:
	LOAD off 	0x00000000000000b0 vaddr 0x0000000000000000
    							   paddr 0x0000000000000000 align 2**3
    	 filesz 0x0000000000000840 memsz 0x0000000000000858 flags rwx
   STACK off 	0x0000000000000000 vaddr 0x0000000000000000
    						 	   paddr 0x0000000000000000 align 2**4
    	 filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw-

 程序段头的filesz可能小于memsz,这时它们间的空隙用0填充,而不是从文件中读取。

对于上面的/init文件来说,files是2112个字节,memsz是2136个字节。这说明,uvmalloc分配了物理内存来容纳2136个字节,但只从文件中读取2112个字节。

exec将ELF文件中的字节按ELF文件指定的地址加载到内存中。用户或进程可以将任意的地址放入ELF文件中。因此,exec存在风险,因为ELF文件中的地址可能包含了指向内核的地址。这个后果十分严重。为此,xv6执行一些检查来规避这些风险,例如:

    if(ph.vaddr + ph.memsz < ph.vaddr)
      goto bad;

上面代码检查总和是否溢出一个64位整数。

由于RISC-V版本的xv6的用户空间和内核空间使用两套页表,用户不可能选择一个对应内核内存的地址。

注意:xv6在验证需要提供给内核的用户数据时,并未完全验证其是否是恶意的,恶意用户程序可能利用这些数据来绕过xv6的隔离。

总结:

第三节重点讲解了xv6是如何利用分页硬件及相关分页机制来实现地址映射和内存分配这两个关键技术。

参考资料:

[1] FrankZn/xv6-riscv-book-Chinese (github.com)

[2] mit-pdos/xv6-riscv: Xv6 for RISC-V (github.com) 

你可能感兴趣的:(risc-v,unix,汇编,vscode)