00_前言

本文主要是理解xv6内核源码对虚拟内存部分的描述。主要是阅读文件:kernel/vm.c

01_代码

kernel/vm.c包含了xv6中绝大部分用于操控地址空间和页表的代码。其中,uvm开头的函数用来操纵用户态地址空间kvm开头的函数用来操纵内核态地址空间,但是它们都在内核态中运行,使用的都是内核页表。

1.三个全局变量
/*
 * the kernel's page table.
 */
pagetable_t kernel_pagetable;

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

extern char trampoline[]; // trampoline.S

在vm.c的最开始是三个全局变量。

(1)kernel_pagetable

首先是第一个全局变量:kernel_pagetable,它是一个pagetable_t类型的变量,转到定义可以看到:

typedef uint64 pte_t;        // 页表项的大小是64位,所以定义为uint64类型
typedef uint64 *pagetable_t; // 512 PTEs,一个级别页表含有512个PTE,正好对应4K的页大小

PTE:page table entry指的是页表条目,在xv6的定义中,一个页表条目的大小是64比特,即8个字节,所以512 PTEs就是4k。众所周知,采用多级页表结构最重要的原因就是节约因为存储页表而耗费的内存页,所以在设置页表级数的时候,要尽可能的保证一级虚拟地址正好映射到一个页表内,防止产生浪费。

pagetable_t的定义中,可以看到pagetable_t就是指向uint64的指针,它是指向了内核页表的根目录页表(root page-table page)的地址,事实上当使用MMU进行虚拟地址转换时,这个物理地址会被存放在SATP寄存器上,这就是第一个变量的全部含义。

(2)etext[]

再来看第二个全局变量:

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

据注释所说,etext将会被链接脚本放置在内核代码的结束位置,链接脚本(kernel/kernel.ld)的对应段:

# kernel/kernel.ld (12-20行)
# 链接脚本中.的含义是当前地址计数器,可以直接引用当前地址位置
.text : {
    *(.text .text.*)        # 将所有文件的text text.*全部放置在kernel的代码段
    . = ALIGN(0x1000);      # 对齐至0x1000(4096),即新开一个页面
    _trampoline = .;        # trampoline放置在下一个页的开头位置
    *(trampsec)             # 放置trampsec段到此处,trampsec段就是trampoline开头声明的
    . = ALIGN(0x1000);      # 再新开一个页面
    ASSERT(. - _trampoline == 0x1000, "error: trampoline larger than one page");
    PROVIDE(etext = .);     # 定义一个全局标号etext,等于此处地址.
  }

这个链接脚本是用于定义操作系统内核的内存布局,特别是 .text 段的布局。并确保 trampoline段被正确地放置在内存中,并且其大小不超过一个页面。通过对齐、符号定义和条件检查等操作,链接脚本帮助控制内核的内存布局,确保系统能够正常启动并运行。

在链接脚本中定义了一个字节指针,指向内核代码部分的结束位置。

(3)trampoline[]
extern char trampoline[]; // trampoline.S

根据注释,这里的含义是它指向了trampoline代码的开始,打开trampoline.S这个汇编代码文件,在开头会有如下内容,它们定义了trampoline是一个全局标号,实际上指向代码的开始:

.section trampsec
.globl trampoline
trampoline:
.align 4
.globl uservec
2.walk函数
  • 函数接收三个参数:根页表 pagetable、虚拟地址 va 以及一个标志 alloc

  • 目标:找到虚拟地址 va 在页表中的最终页表项(PTE),或者在该位置创建一个新的页表结构。

  • 流程:整个流程就是用软件来模拟硬件MMU查找页表的过程,返回值是以pagetable为根页表,经过多级索引之后va这个虚拟地址所对应的页表项,如果alloc != 0,则在需要时创建新的页表页,反之则不用。

walk函数的代码:

pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
  if(va >= MAXVA)            // 如果虚拟地址超过了最大值,陷入错误
    panic("walk");

  // 模拟三级页表的查询过程,三级列表索引两次页表即可,最后一次直接组成物理地址
  for(int level = 2; level > 0; level--) {
    // 索引到对应的PTE项
    pte_t *pte = &pagetable[PX(level, va)];
    // 确认一下索引到的PTE项是否有效(valid位是否为1)
    if(*pte & PTE_V) {
      // 如果有效接着进行下一层索引
      pagetable = (pagetable_t)PTE2PA(*pte);
    } else {
      // 如果无效(说明对应页表没有分配)
      // 则根据alloc标志位决定是否需要申请新的页表
      // < 注意,当且仅当低两级页表页(中间级、叶子级页表页)不存在且不需要分配时,walk函数会返回0 >
      // 所以我们可以通过返回值来确定walk函数失败的原因
      if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
        return 0;
      // 将申请的页表填满0
      memset(pagetable, 0, PGSIZE);
      // 将申请来的页表物理地址,转化为PTE并将有效位置1,记录在当前级页表
      // 这样在下一次访问时,就可以直接索引到这个页表项
      *pte = PA2PTE(pagetable) | PTE_V;
    }
  }
  return &pagetable[PX(0, va)];
}

在xv6 book中指出:上述带阿米可以正常工作的前提是:在进行内核地址空间的映射时,物理内存和虚拟内存采用的是直接映射的方法。

在C语言中所使用的指针,本质上都是一个个虚拟地址,在walk函数中,有这么一段:

// 这行代码从PTE中提取出物理地址,直接赋值给pagetable指针(而它是一个虚拟地址)
// 这样赋值合理吗?只有在虚拟地址==物理地址时合理,即直接映射。
pagetable = (pagetable_t)PTE2PA(*pte);

所以可以看出,只有在内核地址空间中执行的是直接映射策略,才会正常运行(当处理器关闭分页机制的时候,物理地址也等于虚拟地址)

3.mappages函数

mappages函数是用来装载一个新的映射关系,用于将虚拟地址范围 va 到物理地址范围 pa 的映射关系加入到页表中。它的作用是在页表中设置页表项(PTE),使虚拟地址能够正确地映射到物理地址。

// 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)
{
  // a存储的是当前虚拟地址对应的页
  // last存放的是最后一个应设置的页
  // 当 a==last时,表示a已经设置完了所有页,完成了所有任务
  uint64 a, last;
  pte_t *pte;

  // 当要映射的页面大小为0时,这是一个不合理的请求,陷入panic
  if(size == 0)
    panic("mappages: size");

  // a,last向下取整到页面开始位置,设置last相当于提前设置好了终点页
  // PGROUNDDOWN这个宏在后面会详细讲解
  a = PGROUNDDOWN(va);
  last = PGROUNDDOWN(va + size - 1);
  // 开始迭代式地建立映射关系
  for(;;){
    // 调用walk函数,返回当前地址a对应的PTE
    // 如果返回空指针,说明walk没能有效建立新的页表页,这可能是内存耗尽导致的
    if((pte = walk(pagetable, a, 1)) == 0)
      return -1;
    // 如果找到了页表项,但是有效位已经被置位,表示这块物理内存已经被使用
    // 这说明原本的虚拟地址va根本不足以支撑分配size这么多的连续空间,陷入panic
    if(*pte & PTE_V)
      panic("mappages: remap");

    // 否则就可以安稳地设置PTE项,指向对应的物理内存页,并设置标志位permission
    *pte = PA2PTE(pa) | perm | PTE_V;
    // 设置完当前页之后看看是否到达设置的最后一页,是则跳出循环
    if(a == last)
      break;
    // 否则设置下一页
    a += PGSIZE;
    pa += PGSIZE;
  }
  return 0;
}
4.kvmmap函数

这个函数负责在内核页表中添加一个映射项,且此函数仅在启动时初始化内核页表时使用。它仅仅是mappages函数薄薄的一层封装与调用,使用时将内核页表指针传入mappages函数的第一项即可。

void
kvmmap(pagetable_t kpgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
  // 判断mappages是否执行成功,不成功则陷入panic
  if(mappages(kpgtbl, va, sz, pa, perm) != 0)
    panic("kvmmap");
}
5.kvmmake函数

这个函数主要调用的就是上面的kvmmap函数。这个函数的功能是为内核建立了一个直接映射的页表

// Make a direct-map page table for the kernel.
pagetable_t
kvmmake(void)
{
  // 指向内核根页表的指针,也是本函数的返回值
  pagetable_t kpgtbl;

  // 为内核根页表分配一个完整的页面,并将页面初始化
  kpgtbl = (pagetable_t) kalloc();
  memset(kpgtbl, 0, PGSIZE);

  // 映射UART0,大小为一个页面
  kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);

  // 映射VIRTIO disk,大小为一个页面
  kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

  // 映射PLIC(Platform-Level Interrupt Controller), 大小为0x40000
  // 这个大小可以由0x10000000 - 0x0C000000计算得到
  kvmmap(kpgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);

  // 映射内核代码到KERBASE位置,etext是我们上面已经介绍过的内核代码结尾标志
  // 用etext - KERBASE就是代码段长度
  kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

  // 将内核数据段和RAM直接映射过来
  // 使用PHYSTOP - etext就是这两段应该剩余的长度
  kvmmap(kpgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);

  // 将trampline页面映射到内核虚拟地址空间的最高一个页面
  // TRAMPOLINE的定义如下,就是最高虚拟地址减去一个页面大小
  // #define TRAMPOLINE (MAXVA - PGSIZE)
  // 注意阅读上面的链接脚本时,我们将trampsec段放置在了内核代码后面
  // 那其实也是trampoline的开头,也就是说我们其实映射了trampoline页面两次
  kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);

  // 映射各个进程的内核栈到内核页表中
  proc_mapstacks(kpgtbl);

  // 返回映射好的内核页表
  return kpgtbl;
}

在上述代码中,前文提到的三个全局变量,包括etext和trampoline全部都在映射内核页表时使用到了,但是要注意它们本质都是虚拟地址,为什么不用物理地址呢?

因为在执行上述代码的时候xv6的分页机制是关闭的(在kernel/start.c 的34-35行关闭了分页机制),上文也提到,当处理器分页机制关闭的时候,物理地址也等于虚拟地址,所以这些指针虽然是虚拟地址,但是等同于物理地址。

6.kvminit函数
// Initialize the one kernel_pagetable
void
kvminit(void)
{
  kernel_pagetable = kvmmake();
}

这个函数就是简单地调用了一下kvmmake函数,设置好内核地址空间之后,将返回的内核页表指针传递给了全局变量kernel_pagetable。

7.kvminithart函数

前文说到在start.c的34-35行中关闭了分页机制,所以这个函数就是重新打开分页机制的

// Switch h/w page table register to the kernel's page table,
// and enable paging.
void
kvminithart()
{
  // 将kvminit得到的内核页表根目录地址放入SATP寄存器,相当于打开了分页
  w_satp(MAKE_SATP(kernel_pagetable));
  // 清除快表(TLB)
  sfence_vma();
}
  • 第一行是设置内核根页表寄存器的,一旦设置完毕之后相当于也打开了分页机制,自此之后虚拟地址就要经过MMU的翻译才可以转化为物理地址了,但是在内核态下因为大部分页面执行的还是直接映射,所以物理地址和虚拟地址本质上还是相等的(除了内核栈和trampoline页面)。

  • sfence_vma函数

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

    这行汇编代码的作用是完全刷新快表,因为我们重新设置了内核页表,所以之前的缓存必须全部清空

    后续:Mit6.S081-第五天:理解源码中的虚拟内存_2 – 绘白的知识驿站 (huibai.art)


踏上取经路,比抵达灵山更重要