硬件支持
如上图,对于x86硬件来说,其在寻址时(保护模式下),会通过CR3寄存器找到页目录表的地址,接着虚拟地址的高十位会作为页目录表的索引,从而找到对应的页表,接着虚拟地址的中间10个bit会被作为页表中的索引,从而找到对应的页的物理地址,最后虚拟地址的低12位被用来指示其在页中的偏移,由于偏移量占了12位,也就意味着每一个页的大小为4k个字节。
页目录表和页表都有1024条记录,这些记录的高20位对应的就是分配的物理地址,低12位为一些标志位,比如P标志位指示了当前该虚拟地址是否分配了物理地址,如果为0,则会产生错误(陷入中断),再比如U标志位指示了用户进程是否允许访问该页表,如果为0,则该页表项只能由内核访问。由于页目录表中和页表中每一项占32bit,所以每个页目录表和页表的大小也就为4096个字节。
进程地址空间
如上图,进程的地址空间从0地址一直到KERNBASE,再往上就是内核空间。上图中的各个宏的定义代码如下。
// Memory layout
#define EXTMEM 0x100000 // Start of extended memory
#define PHYSTOP 0xE000000 // Top physical memory
#define DEVSPACE 0xFE000000 // Other devices are at high addresses
// Key addresses for address space layout (see kmap in vm.c for layout)
#define KERNBASE 0x80000000 // First kernel virtual address
#define KERNLINK (KERNBASE+EXTMEM) // Address where kernel is linked
根据KERNBASE的值可以判断出每个进程所能使用的空间有2GB。
从上图中可以看出内核把自己映射到了0到PHYSTOP的物理地址,PHYSTOP代表物理地址的上限,但是由于内核部分的虚拟空间只有2GB,这也使得即使PHYSTOP大于2GB,也就是说物理内存大于2GB,内核也使用不了,因为物理空间超过了其虚拟空间的大小。
一些内存映射IO设备的物理地址从0xFE000000开始,所以虚拟地址直接映射过去就可以了。
另外内核部分的页表的U标志是清零的,这也代表内核空间是不允许用户进程访问的。
由于每个进程的地址空间都包含了内核和用户两部分,这也使得从用户空间切换到内核空间不需要进行页表的切换。
创建地址空间源码分析
在之前的源码分析系列文章中我们看到了给内核分配地址空间的是kvmalloc函数,其实也就是建立KERNBASE以上虚拟地址的映射,代码如下。
// Allocate one page table for the machine for the kernel address
// space for scheduler processes.
void
kvmalloc(void)
{
kpgdir = setupkvm();
switchkvm();
}
可以看到其主要是调用了setupkvm函数,代码如下。
// Set up kernel part of a page table.
pde_t*
setupkvm(void)
{
pde_t *pgdir;
struct kmap *k;
if((pgdir = (pde_t*)kalloc()) == 0)
return 0;
memset(pgdir, 0, PGSIZE);
if (P2V(PHYSTOP) > (void*)DEVSPACE)
panic("PHYSTOP too high");
for(k = kmap; k < &kmap[NELEM(kmap)]; k++)
if(mappages(pgdir, k->virt, k->phys_end - k->phys_start,
(uint)k->phys_start, k->perm) < 0) {
freevm(pgdir);
return 0;
}
return pgdir;
}
setupkvm函数主要是对内核部分的进程地址空间进行映射,代码定义如下。
static struct kmap {
void *virt;
uint phys_start;
uint phys_end;
int perm;
} kmap[] = {
{ (void*)KERNBASE, 0, EXTMEM, PTE_W}, // I/O space
{ (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0}, // kern text+rodata
{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
{ (void*)DEVSPACE, DEVSPACE, 0, PTE_W}, // more devices
};
看到它首先调用kalloc函数分配了一个页目录的地址,kalloc的作用是分配一个4096个字节大小的物理页并返回其指针。接着将其清零,主要进行映射工作的是mappages函数。代码如下。
// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page-aligned.
static int
mappages(pde_t *pgdir, void *va, uint size, uint pa, int perm)
{
char *a, *last;
pte_t *pte;
a = (char*)PGROUNDDOWN((uint)va);
last = (char*)PGROUNDDOWN(((uint)va) + size - 1);
for(;;){
if((pte = walkpgdir(pgdir, a, 1)) == 0)
return -1;
if(*pte & PTE_P)
panic("remap");
*pte = pa | perm | PTE_P;
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
可以看到其中主要调用了walkpgdir函数来得到PTE,其代码如下。
// Return the address of the PTE in page table pgdir
// that corresponds to virtual address va. If alloc!=0,
// create any required page table pages.
static pte_t *
walkpgdir(pde_t *pgdir, const void *va, int alloc)
{
pde_t *pde;
pte_t *pgtab;
pde = &pgdir[PDX(va)];
if(*pde & PTE_P){
pgtab = (pte_t*)P2V(PTE_ADDR(*pde));
} else {
if(!alloc || (pgtab = (pte_t*)kalloc()) == 0)
return 0;
// Make sure all those PTE_P bits are zero.
memset(pgtab, 0, PGSIZE);
// The permissions here are overly generous, but they can
// be further restricted by the permissions in the page table
// entries, if necessary.
*pde = V2P(pgtab) | PTE_P | PTE_W | PTE_U;
}
return &pgtab[PTX(va)];
}
walkpgdir函数主要是在页目录表里面找到对应的表项,如果该表项未被分配页表,则为其分配页表,最后返回pte到mappages,而在mappages里面会对返回的pte进行设置,主要也就是设置相应的物理地址。
物理内存分配
xv6系统分配的物理内存是在内核所使用的最大内存到PHYSTOP之间,也就是上图中的end到PHYSTOP之间的内存。
最开始的时候,系统刚刚切换到保护模式时,使用的是一个临时页表,定义如下。
// The boot page table used in entry.S and entryother.S.
// Page directories (and page tables) must start on page boundaries,
// hence the __aligned__ attribute.
// PTE_PS in a page directory entry enables 4Mbyte pages.
__attribute__((__aligned__(PGSIZE)))
pde_t entrypgdir[NPDENTRIES] = {
// Map VA's [0, 4MB) to PA's [0, 4MB)
[0] = (0) | PTE_P | PTE_W | PTE_PS,
// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
[KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS,
};
在main函数中可以看到调用了两个函数kinit1和kinit2,这两个函数都是初始化xv6的物理内存分配器的,xv6使用一个物理内存分配器的数据结构来管理可分配的物理内存。其中kinit1初始化了从kernel end到4M之间的物理内存,而kinit2初始化了从4M到PHYSTOP之间的物理内存。之所以使用两个初始化函数,是因为在main函数中,其大部分的工作(kinit1和kinit2之间的函数调用)都不能使用锁也不能使用超过4M的内存。
其中物理内存分配器的数据结构定义如下。
struct run {
struct run *next;
};
struct {
struct spinlock lock;
int use_lock;
struct run *freelist;
} kmem;
kinit1函数定义如下。
// Initialization happens in two phases.
// 1. main() calls kinit1() while still using entrypgdir to place just
// the pages mapped by entrypgdir on free list.
// 2. main() calls kinit2() with the rest of the physical pages
// after installing a full page table that maps them on all cores.
void
kinit1(void *vstart, void *vend)
{
initlock(&kmem.lock, "kmem");
kmem.use_lock = 0;
freerange(vstart, vend);
}
可以看到在kinit1中lock被禁用了,而且物理内存的初始化主要借助了freerange函数,该函数定义如下。
freerange(void *vstart, void *vend)
{
char *p;
p = (char*)PGROUNDUP((uint)vstart);
for(; p + PGSIZE <= (char*)vend; p += PGSIZE)
kfree(p);
}
在此函数中可以看到是对每一个页都调用kfree来将其加入到物理内存分配器中,kfree函数定义如下。
//PAGEBREAK: 21
// 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.)
void
kfree(char *v)
{
struct run *r;
if((uint)v % PGSIZE || v < end || V2P(v) >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs.
memset(v, 1, PGSIZE);
if(kmem.use_lock)
acquire(&kmem.lock);
r = (struct run*)v;
r->next = kmem.freelist;
kmem.freelist = r;
if(kmem.use_lock)
release(&kmem.lock);
}
在kfree函数中,主要是将要free的页的虚拟地址加入struct run的链表中,还有一个操作是将该虚拟地址对应的内存页memset成1,这是为了防止进程引用被释放后的内存单元,因为1是一个无意义的值,进程引用的话会产生错误。
用户侧地址空间
一个进程用户端的地址空间如上图所示,可见主要有堆,栈,数据段和代码段,其中栈中存放了初始的exec的参数,比如argc和argv这些main函数的参数。
sbrk系统调用
当一个进程需要增加或者收缩自己的地址空间的时候,就可以使用sbrk系统调用来实现,在xv6代码里主要是growproc这个函数,代码如下。
// Grow current process's memory by n bytes.
// Return 0 on success, -1 on failure.
int
growproc(int n)
{
uint sz;
struct proc *curproc = myproc();
sz = curproc->sz;
if(n > 0){
if((sz = allocuvm(curproc->pgdir, sz, sz + n)) == 0)
return -1;
} else if(n < 0){
if((sz = deallocuvm(curproc->pgdir, sz, sz + n)) == 0)
return -1;
}
curproc->sz = sz;
switchuvm(curproc);
return 0;
}
可见这个函数既可以增加,也可以收缩进程的地址空间,主要实现方式就是通过修改进程的页表,经过之前的分析可见进程用户侧所能使用的最大地址就是KERNBASE,对应于内核的起始地址,所以进程的地址扩张时是不能超过此界限的。
exec过程
exec的过程就是xv6中的exec函数。该函数首先打开一个二进制ELF文件。
if((ip = namei(path)) == 0){
end_op();
cprintf("exec: fail\n");
return -1;
}
接着对该ELF文件做检查。
// Check ELF header
if(readi(ip, (char*)&elf, 0, sizeof(elf)) != sizeof(elf))
goto bad;
if(elf.magic != ELF_MAGIC)
goto bad;
接着为该进程创建内核页表。
if((pgdir = setupkvm()) == 0)
goto bad;
接着对ELF中的每个段分配页表(allocuvm)并将其加载进内存(loaduvm)中。
// Load program into memory.
sz = 0;
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
if(readi(ip, (char*)&ph, off, sizeof(ph)) != sizeof(ph))
goto bad;
if(ph.type != ELF_PROG_LOAD)
continue;
if(ph.memsz < ph.filesz)
goto bad;
if(ph.vaddr + ph.memsz < ph.vaddr)
goto bad;
if((sz = allocuvm(pgdir, sz, ph.vaddr + ph.memsz)) == 0)
goto bad;
if(ph.vaddr % PGSIZE != 0)
goto bad;
if(loaduvm(pgdir, (char*)ph.vaddr, ip, ph.off, ph.filesz) < 0)
goto bad;
}
接着创建用户的栈(如上一节中的进程侧地址空间图,即用来存放argc和argv)。
// Allocate two pages at the next page boundary.
// Make the first inaccessible. Use the second as the user stack.
sz = PGROUNDUP(sz);
if((sz = allocuvm(pgdir, sz, sz + 2*PGSIZE)) == 0)
goto bad;
clearpteu(pgdir, (char*)(sz - 2*PGSIZE));
sp = sz;
// Push argument strings, prepare rest of stack in ustack.
for(argc = 0; argv[argc]; argc++) {
if(argc >= MAXARG)
goto bad;
sp = (sp - (strlen(argv[argc]) + 1)) & ~3;
if(copyout(pgdir, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
goto bad;
ustack[3+argc] = sp;
}
ustack[3+argc] = 0;
ustack[0] = 0xffffffff; // fake return PC
ustack[1] = argc;
ustack[2] = sp - (argc+1)*4; // argv pointer
sp -= (3+argc+1) * 4;
if(copyout(pgdir, sp, ustack, (3+argc+1)*4) < 0)
goto bad;