xv6 内存管理

发布于:2025-04-07 ⋅ 阅读:(28) ⋅ 点赞:(0)

首先了解操作系统中内存管理相关内容:XV6:页表 - 知乎 (zhihu.com)这篇博客讲解得比较通俗易懂。

虚拟地址和物理地址

物理内存相关

代码:物理内存分配器

分配器在 kalloc.ckernel/kalloc.c:1)中。物理内存分配器负责为用户进程、内核栈、页表页和管道缓冲区等分配和释放 4096 字节的整页内存。这个内存分配器的设计非常简单,它使用一个单链表 struct run *freelist 来管理空闲的页面,分配物理内存时调用 kalloc 时从链表头部取出一个页面,释放时调用 kfree 时将页面加入链表的头部。以下是物理内存分配的相关函数:

  • kinit 函数用于初始化内核内存分配器。它首先通过调用 initlock 函数初始化一个自旋锁 kmem.lock,然后调用 freerange 函数,将从 endPHYSTOP(物理内存的结束地址)的内存范围标记为可用,并加入到空闲列表中。

  • freerange 函数用于将一个连续的内存区域加入到空闲列表中。它接受两个参数 pa_startpa_end,分别表示内存区域的起始和结束地址。函数首先将起始地址向上舍入到页面的整数倍,然后遍历这个范围内的每个页面,调用 kfree 函数将其加入到空闲列表中。

  • kfree 函数用于释放一个页面的物理内存。在释放之前,它会先检查传入的地址 pa 是否有效,即是否在允许的范围内,并且是否是4096字节的整数倍。如果地址无效,将调用 panic 函数导致系统崩溃。如果地址有效,kfree 会将该页面的内存用垃圾值填充,然后将该页面加入到 kmem 结构的 freelist 中,以便以后再次使用。

  • kalloc 函数用于分配一个 4096 字节的页面。它首先通过自旋锁保护的 kmem 结构获取一个空闲页面,然后将该页面的内存用垃圾值填充,最后返回该页面的地址。如果在尝试分配内存时没有可用的空闲页面,kalloc 将返回0,表示内存分配失败。

// Physical memory allocator, for user processes,
// kernel stacks, page-table pages,
// and pipe buffers. Allocates whole 4096-byte pages.

#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "spinlock.h"
#include "riscv.h"
#include "defs.h"

void freerange(void *pa_start, void *pa_end);

extern char end[]; // first address after kernel.
                   // defined by kernel.ld.

struct run {
  struct run *next;
};

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

// kinit 函数用于初始化内核内存分配器
void
kinit()
{
  // 初始化一个自旋锁 kmem.lock
  initlock(&kmem.lock, "kmem");
  // 将从 end 到 PHYSTOP(物理内存的结束地址)的内存范围标记为可用,并加入到空闲列表中。
  freerange(end, (void*)PHYSTOP);
}

// 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)    // PGSIZE(4096 字节)
    kfree(p);
}

// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc().  (The exception is when
// initializing the allocator; see kinit above.)
// kfree 函数用于释放一个页面的物理内存。
void
kfree(void *pa)
{
  struct run *r;

  // 它会先检查传入的地址 pa 是否有效,即是否在允许的范围内,并且是否是4096字节的整数倍。
  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  // Fill with junk to catch dangling refs.
  // 如果地址有效,kfree 会将该页面的内存用垃圾值填充
  memset(pa, 1, PGSIZE);

  // 将该页面加入到 kmem 结构的 freelist 中,以便以后再次使用
  r = (struct run*)pa;
  acquire(&kmem.lock);
  r->next = kmem.freelist;
  kmem.freelist = r;
  release(&kmem.lock);
}

// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
// 用于分配一个4096字节的页面
void *
kalloc(void)
{
  struct run *r;

  // 通过自旋锁保护的 kmem 结构获取一个空闲页面
  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 采用 RISV-Sv39 分页方案,该方案使用三级页表来将虚拟地址映射到物理地址。在 xv6 中地址转换函数主要有两个:

  • walk 函数:walk 函数遍历页表,返回与给定虚拟地址va对应的页表项(PTE)。
  • mappagesmappages 函数将一系列虚拟地址映射到对应的物理地址,并设置相应的页表项(PTE)。
代码:walk 和 mappages
// 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.
// 遍历页表并返回与给定虚拟地址va对应的页表项(PTE)
// pagetable 是当前进程的页表,va 是需要映射或访问的虚拟地址,alloc 是一个标志,指示是否在必要时创建新的页表项或页表
//
// 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--) {
    // 对于每个级别的页表,函数首先计算虚拟地址va在当前级别的索引
    pte_t *pte = &pagetable[PX(level, va)];
    // 检查 pte 中的 PTE_V 位,这个位表示页表项是否有效。
    // 如果该位被设置,说明找到了有效的页表项,函数继续向下一级页表遍历。
    if(*pte & PTE_V) {
      pagetable = (pagetable_t)PTE2PA(*pte);
    } else {  // 如果PTE_V位未被设置,说明当前页表项未被分配或不存在,这时需要创建一个新的页表。
      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.
// 将一系列虚拟地址映射到对应的物理地址上,并设置相应的页表项(PTE)
// pagetable:当前进程的页表。
// va:映射开始的虚拟地址。
// size:需要映射的内存大小。
// pa:映射开始的物理地址。
// perm:页表项的权限位,例如读、写和执行权限。
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");
  
  // 使用 PGROUNDDOWN 宏将 va 和 va + size - 1 对齐到最近的页边界。
  // 这样做是为了确保映射的虚拟地址和物理地址都是页对齐的,因为大多数硬件和操作系统要求内存操作必须在页边界上进行。
  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;
}
代码:walkadd - 虚拟地址转物理地址

这个函数只返回页面基地址(物理地址的高 44 位),也就是不包含 offsetoffset 在后续函数(copyin 等函数)中自己计算。

// Look up a virtual address, return the physical address,
// or 0 if not mapped.
// Can only be used to look up user pages.
uint64
walkaddr(pagetable_t pagetable, uint64 va)
{
  pte_t *pte;
  uint64 pa;

  if(va >= MAXVA)
    return 0;

  pte = walk(pagetable, va, 0);
  if(pte == 0)
    return 0;
  if((*pte & PTE_V) == 0)
    return 0;
  if((*pte & PTE_U) == 0)
    return 0;
  pa = PTE2PA(*pte);
  return pa;
}

虚拟内存相关

代码:kvmmapmappages - 添加映射
// 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");
}

// 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;
}
  • kvmmap():封装 mappages(),用于内核映射。

  • mappages()

    • VAVA+size 的范围映射到 PA,设置权限。
    • 对齐到页面边界,逐页创建 PTE
    • 如果 PTE 已存在,报错(防止重映射)。
  • 用途:构建内核和用户页表。

内核地址空间

xv6 为每一个进程维护了一个页表,用于描述每个进程的用户地址空间,以及一个单独的描述内核地址空间的页表。上图左边是xv6中内核地址空间(内核页表)的布局。

QEMU 模拟的计算机包括 RAM(物理内存),从物理地址 0x80000000(KERNBASE) 开始,至少持续到 0x86400000(PHYSTOP)。QEMU 模拟还包括 I/O 设备,如磁盘接口,这些设备接口作为内存映射控制寄存器提供给软件,这些寄存器位于物理地址空间 0x80000000 以下。内核可通过读/写这些特殊的物理地址与设备交互;此类读写与设备硬件而非 RAM 通信。

xv6 中内核进程的 VA 与 PA 是直接映射的(大部分是相等的关系),也就是 VA = PA。但是有两个内核虚拟地址不是直接映射的:

  1. trampoline:trampoline直译过来是蹦床的意思,可以理解为它是一个 U 态到 S 态的蹦床。trampoline 位于虚拟地址空间的顶部,内核地址空间与用户地址空间有相同的映射。trampoline是一个神奇的东西,由于我们需要从用户态转换到内核态,执行内核代码,所以我们肯定要切换用户页表到内核页表,而无论用户页表还是内核页表中都有 trampoline 这个东西,并映射到了相同的位置,所以,在 trampoline 代码中切换页表是安全的,切换页表后程序不会崩溃,对于 trampoline 中的下一条指令,在切换后的页表中仍然存在相同的虚拟地址。
  2. kernel stack:每一个进程都有自己的内核栈,映射在虚拟地址的高地址中,其中有未映射的保护页(guard page),这些保护页是无效页(PTE中User位清空),如果内核栈页溢出,就会触发缺页异常(page fault)并且内核也会崩溃。如果没有保护页发生内核栈溢出将会重写内核内存,会导致错误的操作。

代码:创建内核地址空间

大部分用于操作地址空间和页表的 xv6 代码都在 vm.ckernel/vm.c:1)中。

  • 核心数据结构是 pagetable_t,它实际上是一个指向 RISC-V 根页表页的指针;pagetable_t 可以是内核页表,也可以是进程用户页表。
  • 核心函数是 walk 和 mappages,前者通过虚拟地址得到 PTE,后者将虚拟地址映射到物理地址。和地址空间相关操作都离不开这两个函数,因此要先从这两个函数开始。
  • 以 kvm 开头的函数操作内核页表;以 uvm 开头的函数操作用户页表;其他函数同时用于这两种页表。
  • copyoutcopyin将数据复制到或复制出到用户虚拟地址;它们在 vm.c 中,因为它们需要显式转换用户空间的地址,以便找到相应的物理内存。
kvminit — 初始化全局 kernel_pagetable

kvminit 函数是内核页表初始化的入口点。它调用 kvmmake 函数来创建一个新的内核页表,并将其赋值给 kernel_pagetable 全局变量。这个全局变量在内核中被广泛使用,作为当前运行的内核页表的引用。kvmmake 函数负责创建一个新的内核页表,并通过 kvmmap 完成必要的映射。kvmmap 函数用于将一个物理内存区域映射到内核页表中的指定虚拟地址,该函数调用 mappages 来实际进行映射操作。

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

// 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");
}
kvminithart
  • satp 寄存器设置为内核页表地址,启用分页。
  • sfence_vma():刷新 TLB,确保硬件使用新页表。
void
kvminithart()
{
  w_satp(MAKE_SATP(kernel_pagetable));
  sfence_vma();
}
copyoutcopyin
// 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.
// copyin 函数用于将从用户空间的地址srcva开始的数据复制到内核空间的地址dst开始。它接受以下参数:
//    pagetable: 用户进程的页表,用于映射虚拟地址到物理地址。
//    dst: 内核空间的目的地物理地址。
//    srcva: 用户空间的源虚拟地址。
//    len: 要复制的字节长度。
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
  uint64 n, va0, pa0;

  while(len > 0){
    // PGROUNDDOWN宏的作用是取掉一个地址的低12位(因为4096是2的12次方,page的大小是4096),确保结果地址是页面大小的整数倍。
    // 使用PGROUNDDOWN宏将虚拟地址srcva对齐到页面的起始地址,得到va0,va0相当于当前遍历到的页面的起始虚拟地址
    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;
}

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

进程地址空间

每个进程都有一个单独的页表,当 xv6 在进程间切换时,它也会改变页表。如图 2.3 所示,进程的用户内存从虚拟地址 0 开始,最大可扩展至 MAXVA(#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))),原则上允许一个进程寻址 256 千兆字节的内存。

系统调用exec用于创建进程地址空间。函数首先使用namei获取可执行文件,读取 ELF 头,检查 ELF 中的 magic。之后使用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;
}

proc_pagetable函数中,先使用uvmcreate函数申请一个页面,之后将 trampoline 和 trapframe 映射到高位地址空间中。

exec之后使用uvmalloc申请内存空间,再使用loadseg函数将程序加载到对应页面中。在 Program Header 中描述了各段的 filesz,memsz等信息,当 filesz 小于 memsz 时,中间的空隙用 0 填充(如 C 语言中的全局变量)。

程序加载完成后,再申请两块页面,第一块为 guard page ,使用uvmclear函数将该页面PTE_U设置为0,即不允许 user mode 访问。第二页设置为进程的栈。然后将argcargv和返回地址压栈,完成栈的准备工作。

最后,exec函数更新进程结构体,将旧页表释放。

在 Program Header 的vaddr中,程序可以指定被加载到的虚拟地址,而这可能是危险的,因此在exec中会检查if(ph.vaddr + ph.memsz < ph.vaddr),避免发生加法溢出。

参考


网站公告

今日签到

点亮在社区的每一天
去签到