首先了解操作系统中内存管理相关内容:XV6:页表 - 知乎 (zhihu.com)这篇博客讲解得比较通俗易懂。
虚拟地址和物理地址
物理内存相关
代码:物理内存分配器
分配器在 kalloc.c
(kernel/kalloc.c:1
)中。物理内存分配器负责为用户进程、内核栈、页表页和管道缓冲区等分配和释放 4096 字节的整页内存。这个内存分配器的设计非常简单,它使用一个单链表 struct run *freelist
来管理空闲的页面,分配物理内存时调用 kalloc
时从链表头部取出一个页面,释放时调用 kfree
时将页面加入链表的头部。以下是物理内存分配的相关函数:
kinit
函数用于初始化内核内存分配器。它首先通过调用initlock
函数初始化一个自旋锁kmem.lock
,然后调用freerange
函数,将从end
到PHYSTOP
(物理内存的结束地址)的内存范围标记为可用,并加入到空闲列表中。freerange
函数用于将一个连续的内存区域加入到空闲列表中。它接受两个参数pa_start
和pa_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)。mappages
:mappages
函数将一系列虚拟地址映射到对应的物理地址,并设置相应的页表项(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 位),也就是不包含 offset
,offset
在后续函数(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;
}
虚拟内存相关
代码:kvmmap
和 mappages
- 添加映射
// 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()
:- 将
VA
到VA+size
的范围映射到PA
,设置权限。 - 对齐到页面边界,逐页创建
PTE
。 - 如果
PTE
已存在,报错(防止重映射)。
- 将
用途:构建内核和用户页表。
内核地址空间
xv6 为每一个进程维护了一个页表,用于描述每个进程的用户地址空间,以及一个单独的描述内核地址空间的页表。上图左边是xv6中内核地址空间(内核页表)的布局。
QEMU 模拟的计算机包括 RAM(物理内存),从物理地址 0x80000000(KERNBASE) 开始,至少持续到 0x86400000(PHYSTOP)。QEMU 模拟还包括 I/O 设备,如磁盘接口,这些设备接口作为内存映射控制寄存器提供给软件,这些寄存器位于物理地址空间 0x80000000 以下。内核可通过读/写这些特殊的物理地址与设备交互;此类读写与设备硬件而非 RAM 通信。
xv6 中内核进程的 VA 与 PA 是直接映射的(大部分是相等的关系),也就是 VA = PA。但是有两个内核虚拟地址不是直接映射的:
- trampoline:trampoline直译过来是蹦床的意思,可以理解为它是一个 U 态到 S 态的蹦床。trampoline 位于虚拟地址空间的顶部,内核地址空间与用户地址空间有相同的映射。trampoline是一个神奇的东西,由于我们需要从用户态转换到内核态,执行内核代码,所以我们肯定要切换用户页表到内核页表,而无论用户页表还是内核页表中都有 trampoline 这个东西,并映射到了相同的位置,所以,在 trampoline 代码中切换页表是安全的,切换页表后程序不会崩溃,对于 trampoline 中的下一条指令,在切换后的页表中仍然存在相同的虚拟地址。
- kernel stack:每一个进程都有自己的内核栈,映射在虚拟地址的高地址中,其中有未映射的保护页(guard page),这些保护页是无效页(PTE中User位清空),如果内核栈页溢出,就会触发缺页异常(page fault)并且内核也会崩溃。如果没有保护页发生内核栈溢出将会重写内核内存,会导致错误的操作。
代码:创建内核地址空间
大部分用于操作地址空间和页表的 xv6 代码都在 vm.c
(kernel/vm.c:1
)中。
- 核心数据结构是
pagetable_t
,它实际上是一个指向 RISC-V 根页表页的指针;pagetable_t
可以是内核页表,也可以是进程用户页表。 - 核心函数是
walk
和mappages
,前者通过虚拟地址得到 PTE,后者将虚拟地址映射到物理地址。和地址空间相关操作都离不开这两个函数,因此要先从这两个函数开始。 - 以
kvm
开头的函数操作内核页表;以uvm
开头的函数操作用户页表;其他函数同时用于这两种页表。 copyout
和copyin
将数据复制到或复制出到用户虚拟地址;它们在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();
}
copyout
和copyin
// 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 访问。第二页设置为进程的栈。然后将argc
、argv
和返回地址压栈,完成栈的准备工作。
最后,exec
函数更新进程结构体,将旧页表释放。
在 Program Header 的vaddr
中,程序可以指定被加载到的虚拟地址,而这可能是危险的,因此在exec
中会检查if(ph.vaddr + ph.memsz < ph.vaddr)
,避免发生加法溢出。