Lec3 内存管理


xv6页表机制

在 xv6 操作系统中,采用64 位地址空间架构

页表是操作系统中实现虚拟地址空间与物理地址空间转换的核心组件,xv6页表项结构如下:

  • 页表项0~9位:对应页表的操作权限
  • 页表项10~53位:对应页表的物理地址

image-20240726142304399

由于 xv6 一页大小为4KB,每个页表项大小为8B,因此一页可容纳512个页表项,虚拟地址空间需要9位标识每个页表项在页表中的索引

xv6 采用三级页表管理进程的虚拟内存,其虚拟地址结构划分如下:

  • 0~12:虚拟地址在三级页表项所指向页的偏移量
  • 13~21,L3索引:表示虚拟地址在三级页表中页表项的索引
  • 22~30,L2索引:表示虚拟地址在二级页表中页表项的索引
  • 31~39,L1索引:表示虚拟地址在一级页表(进程根页表)中页表项的索引

当 CPU 访问内存时,MMU(内存管理单元)会按照以下步骤将CPU提供的虚拟地址转换为物理地址:

  1. 从 SATP(页表基址寄存器) 获取一级页表的物理基地址
  2. 使用虚拟地址的 L1 索引在一级页表中找到对应的 PTE,该 PTE 指向二级页表的物理基地址
  3. 使用 L2 索引在二级页表中查找 PTE,该 PTE 指向三级页表的物理基地址
  4. 使用 L3 索引在三级页表中找到最终的 PTE,该 PTE 包含 目标物理页的基地址
  5. 将物理页基地址与页内偏移量相加,得到最终的物理内存地址

image-20240726142132505

xv6虚拟内存布局

用户态视角

在xv6操作系统中,进程页表按照虚拟地址从小到大分别代表:

  • text:进程二进制程序
  • data:存储静态数据
  • stack:栈
  • guard page:位于栈和数据区之间,避免栈溢出越界覆盖数据区
  • heap:堆
  • trampframe:在进入内核态前保存用户态数据和系统调用参数,返回至用户态时从这恢复数据并获取系统调用结果
  • trampoline:用户内核态切换的二进制程序

image-20240726161811748

内核态视角

在xv6操作系统中,多个进程共用一个内核页表,内核页表按照虚拟地址从小到大分别代表:

  • 0~KERNBASE(0x80000000):外部设备的寄存器
  • KERNBASE ~ etext:内核二进制程序,无写权限
  • etext ~ PHYSTOP(0x86400000):内核全局数据 + 空闲数据,无执行权限以防止恶意程序写入该区域
  • kernel stack:在 xv6 系统中,每个进程都会分配一页内核栈(Kstack)和一页保护页(Guard page)。保护页的页表项有效位被置为 0,且不映射实际物理内存。当攻击者试图通过内核栈溢出破坏内存数据,其实际访问的是保护页,而由于保护页无法转换为有效物理地址,系统会触发缺页异常,从而阻止恶意程序的破坏行为
  • trampoline:一段在trampoline.S中定义的程序,用于实现用户态内核态之间的切换,所有进程的trampoline虚拟地址都映射到这一页

image-20240726143551675

xv6与页表相关的字段

在riscv.h文件中,定义了如下与页表相关的数据结构:

  • pte_t:使用u64表示页表项(8B),存储的是页表项的内容
  • pagetable_t:使用u64的指针表示页表,存储的是该页第一个页表项的地址

riscv.h

typedef uint64 pte_t;
typedef uint64 *pagetable_t; // 512 PTEs

xv6内存管理程序

虚拟地址转化为物理地址

pte_t* walk(pagetable_t pagetable, uint64 va, int alloc)

找到指向va所在页表的页表项

从虚拟地址中依次分别提取三个级别页表索引,通过每级索引定位对应页表项,再从页表项中获取下一级页表基址,逐级查询直至获得物理页框号。

riscv.h

// shift a physical address to the right place for a PTE.
#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10) // 获取pa所在页在三级页表中的页表项的内容
#define PTE2PA(pte) (((pte) >> 10) << 12) // 获取页表项所代表的页的地址
#define PTE_FLAGS(pte) ((pte) & 0x3FF)

// extract the three 9-bit page table indices from a virtual address.
#define PXMASK          0x1FF // 9 bits
#define PXSHIFT(level)  (PGSHIFT+(9*(level)))
#define PX(level, va) ((((uint64) (va)) >> PXSHIFT(level)) & PXMASK)

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

vm.c/walk

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

  for(int level = 2; level > 0; level--) {
    // 获取va第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)];
}

关联虚拟地址与物理地址

int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)

将起始于va、长度为size字节的连续虚拟地址空间,映射到起始于物理地址pa的连续物理内存区域

找到指向虚拟地址所在页的页表项,修改页表项的地址部分 + 设置有效位

  1. 计算va所在的下一个页表首虚拟地址,而不是直接从当前虚拟地址关联
  2. 以页为单位完成虚拟地址的映射,每一页表:
  • 获取该页在三级页表的页表项
  • 如果页表项有效位有效,代表已经为该va所在的页分配物理页,不得关联同一个虚拟地址到两个物理页
  • 如果页表项有效位无效,完善页表项的内容(物理地址pa + 权限)

执行walk时不能分配物理页(alloc参数为1):在寻找页表项的同时分配了物理页,与函数原先关联的物理地址产生冲突

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

alloc参数:

  • =1,不分配物理页,用于数据页表

  • =0,分配物理页,用于非数据页表

页表项PTE_V=1,代表该页表项指向一个物理页,

  • PTE_R/PTE_W/PTE_X中任意一个有效,指向的是数据也表
  • 非数据页表不得含有除PTE_V之外的有效位

取关虚拟地址与物理地址(仅限用户态页表)

void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)

将页表指定区域的虚拟地址的物理地址删除关联,必要时可以清除对应的数据页

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

清除页表

void freewalk(pagetable_t pagetable)

清除页表所有页表项以及页表本身

清除页表项之前,先清除该页表项指向的下一级页表,递归清除完毕后页表项才可以置位为0512个页表项全部置位为0后才可以使用kfree回收页表

注意:调用该函数前要确保第三级页表指向的数据页已被清理,否则报错freewalk: leaf

// 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_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);
      pagetable[i] = 0;
    } else if(pte & PTE_V){
      panic("freewalk: leaf");
    }
  }
  kfree((void*)pagetable);
}

xv6初始化页表

内核态页表

xv6内核启动时,main.c => vm.c/kvminit => vm.c/kvmmake来初始化内核页表:

  1. kalloc申请一个空页表,作为内核一级页表
  2. kvmmap虚拟地址与物理地址的映射
    • 一系列外部设备寄存器
    • 内核程序:已加载至物理地址为 KERNBASEexect 的内存区域,直接关联即可
    • trampoline:已加载物理地址为 trampolinetrampoline + PAGSIZE 的内存区域,直接关联即可
    • 内核栈:往下依次调用proc.c中的proc_mapstacks,包括申请空白页 + 关联

riscv.h:extern关键字表明在别的文件已定义了trampoline和etext程序,并已加载至内存

#define TRAMPOLINE (MAXVA - PGSIZE)
#define KERNBASE 0x80000000L
#define PHYSTOP (KERNBASE + 128*1024*1024)

extern char trampoline[]; // trampoline.S
extern char etext[];  // kernel.ld sets this to end of kernel code.

vm.c/kvminit-kvmmake:申请空白页 + 关联

// Initialize the one kernel_pagetable
void kvminit(void)
{
  kernel_pagetable = kvmmake();
}

// Make a direct-map page table for the kernel.
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;
}

proc.c/proc_mapstacks:为每个进程申请空白页,作为内核态的栈,并关联至内核页

// map kernel stacks beneath the trampoline,
// each surrounded by invalid guard pages.
#define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)

// Allocate a page for each process's kernel stack.
// Map it high in memory, followed by an invalid
// guard page.
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);
  }
}

用户态页表

allocproc创建进程时:

  1. 申请一个空页表,作为trapframe页,在切换至内核态保存进程数据
  2. proc_pagetable初始化页表
    • uvmcreate申请一个空页表,作为用户态页表的一级页表
    • 关联trampoline、trapframe

proc.c/allocproc

// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc* allocproc(void)
{
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state == UNUSED) {
      goto found;
    } else {
      release(&p->lock);
    }
  }
  return 0;

found:
  p->pid = allocpid();
  p->state = USED;

  // Allocate a trapframe page.
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  // An empty user page table.
  p->pagetable = proc_pagetable(p);
  if(p->pagetable == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }

  // Set up new context to start executing at forkret,
  // which returns to user space.
  memset(&p->context, 0, sizeof(p->context));
  p->context.ra = (uint64)forkret;
  p->context.sp = p->kstack + PGSIZE;

  return p;
}

proc.c/proc_pagetable

// Create a user page table for a given process,
// with no user memory, but with trampoline pages.
pagetable_t proc_pagetable(struct proc *p)
{
  pagetable_t pagetable;

  // An empty page table.
  pagetable = uvmcreate();
  if(pagetable == 0)
    return 0;

  // map the trampoline code (for system call return)
  // at the highest user virtual address.
  // only the supervisor uses it, on the way
  // to/from user space, so not PTE_U.
  if(mappages(pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline, PTE_R | PTE_X) < 0){
    uvmfree(pagetable, 0);
    return 0;
  }

  // map the trapframe just below TRAMPOLINE, for trampoline.S.
  if(mappages(pagetable, TRAPFRAME, PGSIZE, (uint64)(p->trapframe), PTE_R | PTE_W) < 0){
    uvmunmap(pagetable, TRAMPOLINE, 1, 0);
    uvmfree(pagetable, 0);
    return 0;
  }

  return pagetable;
}

// create an empty user page table.
// returns 0 if out of memory.
pagetable_t uvmcreate()
{
  pagetable_t pagetable;
  pagetable = (pagetable_t) kalloc();
  if(pagetable == 0)
    return 0;
  memset(pagetable, 0, PGSIZE);
  return pagetable;
}

xv6的第一个进程,main.c -> proc.c/userinit -> vm.c/uvminit,还需要:

  • 申请一个空白页,存储程序代码(不超过1页),并复制初始代码
  • 在trapframe设置epc/sp字段的的值,初始化程序进入用户态时寄存器值

proc.c/userinit

// Set up first user process.
void userinit(void)
{
  struct proc *p;

  p = allocproc();
  initproc = p;
  
  // allocate one user page and copy init's instructions and data into it.
  uvminit(p->pagetable, initcode, sizeof(initcode)); // 只有第一个进程才执行uvminit
  p->sz = PGSIZE;

  // prepare for the very first "return" from kernel to user.
  p->trapframe->epc = 0;      // user program counter
  p->trapframe->sp = PGSIZE;  // user stack pointer

  // ...
}

vm.c/uvminit

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

  if(sz >= PGSIZE)
    panic("inituvm: more than a page");
  mem = kalloc();
  memset(mem, 0, PGSIZE);
  mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
  memmove(mem, src, sz);
}

也就是说,xv6在创建进程时,不会分配任何==数据页==

进程内存操作

申请内存

vm.c/uvmalloc

oldsz page aligned

// 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)
{
  char *mem;
  uint64 a;

  if(newsz < oldsz)
    return oldsz;
  
  // 进程即使内存大小小于pgsize的整数倍,也默认为oldsz~pgsize部分已被分配
  // 此时oldsz和newsz必定是pgsize的整数倍
  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_W|PTE_X|PTE_R|PTE_U) != 0){
      kfree(mem);
      uvmdealloc(pagetable, a, oldsz);
      return 0;
    }
  }
  return newsz;
}

发现xv6申请内存的逻辑比较固定:kalloc => memset => mappages

如果kalloc和mappages执行失败,需要清空已分配的内存并退出程序

清除内存

vm.c/uvdealloc

// 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清除所有数据页:alloc参数为1表示解除关联的同时还需回收数据页本身
  • freewalk清除所有页表

vm.c/uvmfree

// Free user memory pages,
// then free page-table pages.
void
uvmfree(pagetable_t pagetable, uint64 sz)
{
  if(sz > 0)
    uvmunmap(pagetable, 0, PGROUNDUP(sz)/PGSIZE, 1);
  freewalk(pagetable);
}

用户内核数据复制

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

物理页管理器

kalloc.h

分配的地址为[内核空间~PHYSTOP]

以一个页表为单位进行分配/回收

通过链表追踪可用的空闲页表地址,allocator只有一个kmem内存空间,链表上的其他结点信息均存储在空闲页表的首部

sbrk

eager allocation:一旦调用了sbrk,内核会立即分配应物理内存。但会存在过多申请的内存过少使用的情况。

lazy allocation:sbrk系统调用不做任何事情,唯一需要做的事情就是提升p->sz,将p->sz增加n,其中n是需要新分配的内存page数量。但是内核在这个时间点并不会分配任何物理内存。之后某个时间点程序使用到了新申请的那部分内存,会触发page fault,因为我们还没有分配实际的物理内存(即新的内存未映射到page table)。所以,当我们看到了一个page fault,相应的虚拟地址小于当前p->sz,同时大于stack,那么我们就知道这是一个来自于heap的地址,但是内核还没有分配任何物理内存。

Mmap

mmap系统调用:虚拟内存地址,长度,protection,标志位,一个打开的文件描述符,偏移量。从文件描述符对应的文件的偏移量位置开始,映射长度为len的内容到虚拟内存地址VA,同时加上一些保护,比如只读或者读写。

mmap系统调用的过程:先记录一下这个PTE属于这个文件描述符。相应的信息保存在VMA结构体。例如对于这里的文件f,会有一个VMA,在VMA中我们会记录文件描述符,偏移量等等,这些信息用来表示对应的内存虚拟地址的实际内容在哪,这样当我们得到一个位于VMA地址范围的page fault时,内核可以从磁盘中读数据,并加载到内存中。


文章作者: AthenaCrafter
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 AthenaCrafter !
  目录