XV6阅读报告及新功能实现

XV6阅读报告


Xv6中新功能实现

1 实现进程间通信-管道

1.1 特征描述

管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。当一个进程创建了一个管道,并调用fork创建自己的一个子进程后,父进程关闭读管道端,子进程关闭写管道端,这样提供了两个进程之间数据流动的一种方式。

1.2 设计考虑

管道是一个小的内核缓冲区,它以文件描述符对的形式提供给进程,一个用于写操作,一个用于读操作。从管道的一端写的数据可以从管道的另一端读取。管道提供了一种进程间交互的方式。
pipe系统调用,创建了一个新的管道并把读取和写入文件描述符保存在数组p里。fork之后,父子都有了代表管道的文件描述符。子进程把读取端复制到文件描述符0,关闭p中的文件描述符,并执行wc。当wc从自己的标准输入读取时,实际是从管道读取。父关闭了管道的读取端,向管道写入,然后关闭了写入端。

1.3 实施细则

如果没有数据可用,那么对管道执行的read会一直等待,直到有数据了或者其他绑定在这个管道写端口的描述符都已经关闭了。在后一种情况下,read将返回0,就像读到数据文件的结尾一样。事实上,在新数据无法到达之前读取数据块是子进程在执行上面的wc之前关闭管道写入端的一个重要原因:如果wc的一个文件描述符引用了管道的写入端,wc将永远看不到文件结尾。
xv6 shell实现了像grep fork sh.c | wc -l这样的管道。子进程创建了一个管道并把管道的左右两端连接起来。然后它分别为左右两端调用了fork和runcmd,并等待完成。管道的右端也许自己就包含一个管道(比如a | b | c),它自己fork了两个新的子线程(一个给a一个给b)。因此,shell 可能会创建一棵进程树。树的叶子是命令,内部节点是等待左右孩子结束的进程。

mkfifo("/tmp/myfifo",0666);//创建fifo文件
fd = open("/tmp/myfifo",O_WRONLY)//打开管道的fifo文件,返回文件描述符。
write(fd,“hello fifo”,11);//写入数据到fifo文件
close(fd);
读取端:
fd = open("/tmp/myfifo",O_RDONLY);//打开管道fifo文件
read(fd,c_buf,11);//进行读取,类似文件传输。

1.4 特征评价

管道比临时文件有四个优点。如下表所示。
1 管道是自清洁的 因为文件重定向,结束时shell必须很小心删除/tmp/xyz。
2 管道可以传递任意长的数据流 文件重定向需要磁盘上足够的可用空间来存储所有数据。
3 管道允许管道各阶段并行执行 文件处理需要等到第一个程序结束第二个程序才能开始
4 管道的堵塞读写更有效 如果要实现进程间通信,管道的阻塞读写比文件的非阻塞语义更有效。

2 用户线程

2.1 特征描述

线程是轻量级的进程,线程是进程内的一个相对独立的可执行的单元,若把进程称为任务的话,那么线程则是应用中的一个子任务的执行;
操作系统的资源分配的单位是进程,处理机调度的单位是线程;
xv6操作系统实现了一个基于进程 (没有实现线程) 的简单进程管理机制。
xv6 使用结构体 struct proc 来维护一个进程的状态,其中最为重要的状态是进程的页表,内核栈,当前运行状态。每个进程都有一个线程来执行进程的指令。线程可以被暂时挂起,稍后再恢复运行。在这个进程数组保存的进程控制块结构分成两类:一类是未使用的进程控制块结构,另一类是正在使用的进程控制块结构。每次要创建一个进程时,只需要从进程控制块数组中取得一个未使用进程控制块结构进行相应的处理即可。
每个进程都有用户栈和内核栈。当进程运行用户指令时,只有其用户栈被使用,其内核栈则是空的。然而当进程(通过系统调用或中断)进入内核时,内核代码就在进程的内核栈中执行;进程处于内核中时,其用户栈仍然保存着数据,只是暂时处于不活跃状态。进程的线程交替地使用着用户栈和内核栈。要注意内核栈是用户代码无法使用的,这样即使一个进程破坏了自己的用户栈,内核也能保持运行。

xv6的进程中定义了一个context结构,还在proc.h中定义了一个枚举类型proc_state,枚举了EMBRO, SLEEPING, RUNNABLE, RUNNING和ZOMBIE六种状态,分别为未使用态、初始态、等待态、就绪态、运行态和僵尸态。
进程间的转换关系如下:

2.2 设计考虑

进程管理的数据结构是进程控制块 (PCB );
Linux下为/include/linux/sched.h内部的struct task_struct,其中包括管理进程所需的各种信息。创建一个新进程时,系统在内存中申请一个空的task_struct ,并填入所需信息。同时将指向该结构的指针填入到task[]数组中。当前处于运行状态进程的PCB用指针数组current_set[]来指出。
父进程可以利用fork()函数创建多个子进程。首先,为每个新建的子进程分配一个空闲的proc结构并赋予子进程唯一标识pid;其次,以一次一页的方式复制父进程地址空间(采用cow写时复制),获得子进程继承的共享资源的指针;最后将子进程加入就绪队列,对子进程返回标识符0,向父进程返回子进程pid。

2.3 实施细则

cpu使用规定的调度算法从就绪队列选择一个进程执行。进程的切换是由进程状态的变化引起的,而进程状态的变化又与出现的事件有关。当有事件(中断或异常)发生时,当前运行进程暂停,保存当前进程的现场,然后根据调度算法从就绪队列选一个进程换入CPU,同时加载换入进程的现场进行执行;
进程的上下文包括当前进程的程序计数 器PC和当前运行的CPU中各个寄存器的内容。当进程切换和发生中断的时候这些信息要保存下来以便于下次运行时使用;
同一时刻每个cpu上只能有一个进程被执行,且同一时刻一个进程只能被一个cpu调度,同一时刻多个cpu可以同时调度不同的进程,同一时间段内每个cpu可以调度多个进程。

2.3.1 初始化用户进程init

userinit是初始化第一个用户进程init。其处理过程如下所示:
1、调用copyproc函数创建一个新的进程,这里调用copyproc函数的参数为0,这表示是第一个用户进程。由于是第一个进程,因此在copyproc中没有进行内存等相关结构的初始化;
2、对相关结构(包括内存、当前目录cwd、trapframe)进行初始化。期间,将进程的段加上了用户态权限而不 是内核态权限,p->tf->eip = 0表示起始执行的地址为0地址;
3、将第一个用户进程的代码(initcode.S)拷贝到新进程的内存中,然后把进程名字指定为“initcode”,设置进程状态为RUNNABLE;
4、把刚初始化好的进程控制块p赋值全局变量initproc。

2.3.2 创建子进程

在xv6中,可以通过sys_fork来复制父进程内容并创建一个新的子进程。其处理过程如下所示:
1、调用copyproc函数创建一个新的进程,注意这里调用copyproc函数的参数是p,这表示要复制父进程p的相关内容来创建子进程;
2、把新创建的子进程状态设置为RUNNABLE,返回子进程的pid。注意这也是fork返回后,确定是父进程还是子进程返回的一个标志。

2.3.3 复制进程

函数copyproc是对进程进行复制,用户进程通过sys_fork函数来调用copyproc函数,完成对父进程的进程控制块的复制;而userinit函数调用copyproc函数,完成对第一个用户态进程的创建。其处理过程如下所示:
1.分配出一个进程结构用于接下来的PCB复制;
2.给新的进程np开辟内核栈空间;
3.将其trapframe所占的空间放在内核栈栈顶,大小位sizeof(struct trapframe),指针np->tf指向栈顶-sizeof(struct trapframe)的位置;
4.如果p(即父进程)非空,将np的parent指向p,且把p中的状态复制到np中的栈结构中新分配的trapframe中;
5.如果p(即父进程)非空,根据p的sz(进程空间大小)分配np的mem内存空间;
6.如果p(即父进程)非空,把p的内存空间mem复制到np的内存空间mem;
7.如果p(即父进程)非空,调用filedup函数复制父进程打开的文件给子进程np;
8.如果p(即父进程)非空,调用idup函数复制父进程的当前目录给子进程np;
对新的进程的上下文进行设置。这个上下文就是其被第一次sheduler()运行的状态。将eip指向forkret从而将scheduler中加上的锁给释放。由于新的进程是运行在自己的内核栈中,因此esp指向新分配的栈地址。设置从系统调用返回的值,由于是运行fork产生的,因此其作为子进程的返回值应该为0。

2.3.4 调度进程

scheduler对每个CPU进行进程调度。CPU到没有运行用户进程时,就会进入这个过程中。这个过程不断地从进程中选出一个RUNNABLE的进程,然后通过swtch.S中的swtch函数运行该进程。

3.4 特征评价

多数系统将处理器工作状态划分为内核态和用户态。前者一般指操作系统管理程序运行的状态,具有较高的特权级别,又称为特权态、系统态或管态;后者一般指用户程序运行时的状态;具有较低的特权级别,又称为普通态、目态。区分了用户态和内核态就是限定用户什么操作可以做,什么操作不能让用户直接做。如果遇到不能让用户直接做的操作,用户就必须请求操作系统做系统调用,这样操作系统就会进入内核态进行系统操作。内核态的进程就是系统进入内核态之后进行系统操作所产生的进程;
用户态进程是用户通过请求操作而产生的进程;
区别: 运行在不同的系统状态,用户态进程执行在用户态,内核态进程执行在内核态;进入方式不同,用户态进程可直接进入,内核态必须通过运行系统调用命令;返回方式不同,用户态进程直接返回,内核态进程有重新调度过程;内核态进程优先级要高于用户态进程,并且内核态进程特权级别最高,它可以执行系统级别的代码。
Reference
xv6: a simple, Unix-like teaching operating system

3 xv6进程

在xv6中实现了基于进程的简单进程管理机制。xv6的进程中定义了一个context结构,还在proc.h中定义了一个枚举类型proc_state,枚举了EMBRO, SLEEPING, RUNNABLE, RUNNING和ZOMBIE六种状态,分别为未使用态、初始态、等待态、就绪态、运行态和僵尸态。
enum proc_state { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
状态的含义如下:
UNUSED:进程未被创建(即进程控制块空闲)时的状态;
EMBRYO:需要分配一个进程控制块且找到一个处于UNUSED状态的进程控制块时,把此进程控制块状态设置为要使用的状态;
SLEEPING:进程由于等待某资源等原因无法执行,进入睡眠状态,即等待态;
RUNNABLE:进程获得了除CPU之外的所有资源,处于可运行状态,即就绪态;
RUNNING:进程获得CPU,正在运行的状态,即执行态;
ZOMBIE:进程结束的状态。
进程间的转换关系如下:
在这里插入图片描述

xv6 使用结构体 struct proc 来维护一个进程的状态,其中最为重要的状态是进程的页表,内核栈,当前运行状态。每个进程都有一个线程来执行进程的指令。线程可以被暂时挂起,稍后再恢复运行。在这个进程数组保存的进程控制块结构分成两类:一类是未使用的进程控制块结构,另一类是正在使用的进程控制块结构。每次要创建一个进程时,只需要从进程控制块数组中取得一个未使用进程控制块结构进行相应的处理即可。
每个进程都有用户栈和内核栈。当进程运行用户指令时,只有其用户栈被使用,其内核栈则是空的。然而当进程(通过系统调用或中断)进入内核时,内核代码就在进程的内核栈中执行;进程处于内核中时,其用户栈仍然保存着数据,只是暂时处于不活跃状态。进程的线程交替地使用着用户栈和内核栈。要注意内核栈是用户代码无法使用的,这样即使一个进程破坏了自己的用户栈,内核也能保持运行。
当进程使用系统调用时,处理器转入内核栈中,提升硬件的特权级,然后运行系统调用对应的内核代码。当系统调用完成时,又从内核空间回到用户空间:降低硬件特权级,转入用户栈,恢复执行系统调用指令后面的那条用户指令。线程可以在内核中“阻塞”,等待 I/O, 在 I/O 结束后再恢复运行。其中,p->state 指示了进程的状态:新建、准备运行、运行、等待 I/O 或退出状态中。p->pgdir 以 x86 硬件要求的格式保存了进程的页表。xv6 让分页硬件在进程运行时使用 p->pgdir 。进程的页表还记录了保存进程内存的物理页的地址。

3.1 数据结构

在proc.h中地应了几个关键的数据结构,其中,结构体context定义了内核进行上下文切换需要使用的寄存器。

struct context {
uint edi;
uint esi;
uint ebx;
uint ebp;
uint eip;
};
结构体proc定义了进程运行需要的数据:
struct proc {
uint sz; // Size of process memory (bytes)
pde_t* pgdir; // Page table
char *kstack; // Bottom of kernel stack for this process
enum procstate state; // Process state
volatile int pid; // Process ID
struct proc *parent; // Parent process
struct trapframe *tf; // Trap frame for current syscall
struct context *context; // swtch() here to run process
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
};
其中一些重要的变量为
sz是记录进程所占有的内存空间大小;
pgdir是页表;
kstack是进程在内核态的栈;
state是进程的状态;
pid是进程的ID;
chan不为NULL时,是进程睡眠时所挂的睡眠队列;
killed不为0时,表示进程被杀死了;
ofile数组是进程打开的文件数组;
context是切换进程需要维护的硬件寄存器内容;
tf是中断进程后,需要恢复进程继续执行所保存的寄存器内容;
cwd是进程运行时所处的当前目录;
name保存了进程的名字(用于调试)。
在proc.h中还定义了结构体cpu用于记录所有的cpu相关的信息。此外,还记录了进程数组、当前运行进程等全局变量。

3.2 进程相关操作

与进程相关的函数集中在proc.c中,主要包含进程管理初始化、初始化用户进程init、创建子进程、复制进程、进程的空间分配、使用allocproc增加进程的地址空间、进程调度、唤醒进程、睡眠进程、杀死进程、退出进程、等待进程结束和进程上下文切换。
其中,介绍几个比较核心的操作。

初始化用户进程init
userinit是初始化第一个用户进程init。其处理过程如下所示:
1、调用copyproc函数创建一个新的进程,这里调用copyproc函数的参数为0,这表示是第一个用户进程。由于是第一个进程,因此在copyproc中没有进行内存等相关结构的初始化;
2、对相关结构(包括内存、当前目录cwd、trapframe)进行初始化。期间,将进程的段加上了用户态权限而不 是内核态权限,p->tf->eip = 0表示起始执行的地址为0地址;
3、将第一个用户进程的代码(initcode.S)拷贝到新进程的内存中,然后把进程名字指定为“initcode”,设置进程状态为RUNNABLE;
4、把刚初始化好的进程控制块p赋值全局变量initproc。
创建子进程
在xv6中,可以通过sys_fork来复制父进程内容并创建一个新的子进程。其处理过程如下所示:
1、调用copyproc函数创建一个新的进程,注意这里调用copyproc函数的参数是p,这表示要复制父进程p的相关内容来创建子进程;
2、把新创建的子进程状态设置为RUNNABLE,返回子进程的pid。注意这也是fork返回后,确定是父进程还是子进程返回的一个标志。
复制进程
函数copyproc是对进程进行复制,用户进程通过sys_fork函数来调用copyproc函数,完成对父进程的进程控制块的复制;而userinit函数调用copyproc函数,完成对第一个用户态进程的创建。其处理过程如下所示:
1、分配出一个进程结构用于接下来的PCB复制;
2、给新的进程np开辟内核栈空间;
3、将其trapframe所占的空间放在内核栈栈顶,大小位sizeof(struct trapframe),指针np->tf指向栈顶-sizeof(struct trapframe)的位置;
4、如果p(即父进程)非空,将np的parent指向p,且把p中的状态复制到np中的栈结构中新分配的trapframe中;
5、如果p(即父进程)非空,根据p的sz(进程空间大小)分配np的mem内存空间;
6、如果p(即父进程)非空,把p的内存空间mem复制到np的内存空间mem;
7、如果p(即父进程)非空,调用filedup函数复制父进程打开的文件给子进程np;
8、如果p(即父进程)非空,调用idup函数复制父进程的当前目录给子进程np;
9、对新的进程的上下文进行设置。这个上下文就是其被第一次sheduler()运行的状态。将eip指向forkret从而将scheduler中加上的锁给释放。由于新的进程是运行在自己的内核栈中,因此esp指向新分配的栈地址。设置从系统调用返回的值,由于是运行fork产生的,因此其作为子进程的返回值应该为0。
调度进程
scheduler对每个CPU进行进程调度。CPU到没有运行用户进程时,就会进入这个过程中。这个过程不断地从进程中选出一个RUNNABLE的进程,然后通过swtch.S中的swtch函数运行该进程。

4 xv6内存管理

xv6通过页表机制实现了对内存空间的控制。页表使得 xv6 能够让不同进程各自的地址空间映射到相同的物理内存上,还能够为不同进程的内存提供保护。 除此之外,我们还能够通过使用页表来间接地实现一些特殊功能。xv6 主要利用页表来区分多个地址空间,保护内存。另外,xv6把不同地址空间的多段内存映射到同一段物理内存,在同一地址空间中多次映射同一段物理内存,以及通过一个没有映射的页保护用户栈。
在xv6中,可以简化为以下地址映射:
虚拟地址-------->物理地址
分页
分页机制是通过页表来进行转换的,具体转换关系如图:
在这里插入图片描述

x86中所有的虚拟地址都经过页表来完成地址转换,页表由cr3寄存器指定的物理地址来表示,内存地址转换单元mmu通过查找页表来确定最后的物理地址,通过给cr3赋值便能实现不同进程拥有不同的页表,也就是不同进程拥有不同的地址空间。页表由一级的页目录项和二级的页表项组成,每个页目录项下级有1024个连续的页表项(每个页表项4Byte,刚好占用4K空间,也就是一页),页目录项同时也是连续的,一共有1024个页目录项,由于32位系统地址线只有32位,所以最高支持4G的地址空间,页目录项也是连续的,所以页目录刚好也占用一页。

每个页目录项和页表项由下一级的物理地址和相关标志位组成,页目录项拥有下一级页表的物理地址,页表项拥有实际物理地址的部分,通过设置权限位可以实现内核和用户进程代码和数据的保护。具体转换过程如图所示,虚拟地址前十位作为页目录的偏移找到页目录项,找到页表基地址,接下来十位作为页表项的偏移找到页表项,最后将页表项的基址+最后12位偏移得到实际的物理地址。
xv6内存初始化
在初始化main函数最开始处,xv6内存布局如下:
内核代码存在于物理地址低地址的0x100000处,页表为main.c文件中的entrypgdir数组,其中虚拟地址低4M映射物理地址低4M,虚拟地址 [KERNBASE, KERNBASE+4MB) 映射到 物理地址[0, 4MB)。因此现在内核实际能用的虚拟地址空间显然是不足以完成正常工作的,所以初始化过程中需要重新设置页表。
1.物理内存的初始化
xv6在main函数中调用kinit1和kinit2来初始化物理内存,kinit1初始化内核末尾到物理内存4M的物理内存空间为未使用;kinit2初始化剩余内核空间到PHYSTOP为未使用,如下所示:
kinit1(end, P2V(410241024)); // phys page allocator
kinit2(P2V(410241024), P2V(PHYSTOP));
// must come after startothers()
两者的区别在于kinit1调用前使用的还是最初的页表所以只能初始化4M,同时由于后期再构建新页表时也要使用页表转换机制来找到实际存放页表的物理内存空间。xv6通过在main函数最开始处释放内核末尾到4Mb的空间来分配页表,由于在最开始时多核CPU还未启动,所以没有设置锁机制。
initlock(&kmem.lock, “kmem”);
kmem.use_lock = 0;
freerange(vstart, vend);
kinit2在内核构建了新页表后,能够完全访问内核的虚拟地址空间,所以在这里初始化所有物理内存,并开始了锁机制保护空闲内存链表。
void
kinit2(void vstart, void vend)
{
freerange(vstart, vend);
kmem.use_lock = 1;
}
2.内核新页表初始化
main函数通过调用kvmalloc函数来实现内核新页表的初始化:
pde_t kpgdir; // for use in scheduler()
void
kvmalloc(void)
{
kpgdir = setupkvm();
switchkvm();
}
通过初始化,最后内存布局和地址空间如下:
内核末尾物理地址到物理地址PHYSTOP的内存空间未使用 。
虚拟地址空间KERNBASE以上部分映射到物理内存低地址相应位置。
// This table defines the kernel’s mappings, which are present in
// every process’s page table.
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
};

在这里插入图片描述

4.1 物理内存管理

xv6对上层提供kalloc和kfree接口来管理物理内存,上层无需知道具体的细节,kalloc返回虚拟地址空间的地址,kfree以虚拟地址为参数,通过kalloc和kfree能够有效管理物理内存,让上层只需要考虑虚拟地址空间。
xv6通过将未分配的内存构成一个简单的链表来管理物理内存,具体的链表结构如下:
struct run {
struct run *next;
};
struct {
struct spinlock lock;
int use_lock;
struct run freelist;
} kmem;
xv6使用了空闲内存的前部分作为指针域来指向下一页空闲内存,物理内存管理是以页(4K)为单位进行分配的。也就是说物理内存空间上空闲的每一页,都有一个指针域指向下一个空闲页,最后一个空闲页指向NULL 。通过这种方式,只需要保存着虚拟地址空间上的freelist地址即可,kalloc和kfree操作的地址都是虚拟地址,通过页表找到每一物理页的位置,内核映射布局如下:
(void
)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
因此,kalloc和kfree操作的是虚拟地址,但是也能够找到每一页的物理地址。通过kalloc和kfree,屏蔽了对物理内存的管理,使得调用者只需要关心虚拟地址空间,在需要使用新内存空间的时候调用kalloc,在需要释放内存空间的时候调用kfree。
xv6内存管理函数接口
xv6通过提供几个接口来实现内核页表的控制和用户页表的控制,xv6让每个进程都有独立的页表结构,在切换进程时总是需要切换页表,切换页表的接口函数主要有两个switchkvm()和switchuvm()。Switchkvm()简单地将kpgdir设置为cr3寄存器的值,这个页表仅仅在 scheduler内核线程中使用。
页表和内核栈都是每个进程独有的,xv6使用结构体proc将它们统一起来,在进程切换的时候,他们也往往随着进程切换而切换,内核中模拟出了一个内核线程,它独占内核栈和内核页表kpgdir,它是所有进程调度的基础。
switchuvm通过传入的proc结构负责切换相关的进程独有的数据结构,其中包括TSS相关的操作,然后将进程特有的页表载入cr3寄存器,完成设置进程相关的虚拟地址空间环境。进程的页表在使用前需要初始化,其中必须包含内核代码的映射,这样进程在进入内核时便不需要再次切换页表,进程使用虚拟地址空间的低地址部分,高地址部分留给内核,设置页表时通过调用setupkvm、allocuvm、deallocuvm接口完成相关操作。其中setupkvm通过kalloc分配一页内存作为页目录,然后将按照kmap数据结构映射内核虚拟地址空间到物理地址空间。期间调用了工具函数mappages,mappages的具体实现下文再解释。
allocuvm、deallocuvm负责完成用户进程的内存空间,allocuvm在设置页表的同时还会分配物理内存供用户进程使用allocuvm中的参数oldsz,newsz我没怎么搞懂为什么要这样命名,但是意思就是分配虚拟地址oldsz到newsz的以页为单位的内存,deallocuvm则相反,它将newsz到oldsz对应的虚拟地址空间内存置为空闲。
xv6内存管理提供的主要包含两个切换接口,一个设置内核页表的接口,两个为用户进程管理内存,设置用户代码页表的接口,xv6 vm.c文件中还提供了loaduvm将文件系统上的i节点内容读取载入到相应的地址上,通过allocuvm接口为用户进程分配内存和设置页表,然后调用loaduvm接口将文件系统上的程序载入到内存,便能够为exec系统调用提供接口,为用户进程的正式运行做准备。
vm.C中还有一个inituvm函数,为第一个进程所使用,通过调用它能够初始化虚拟地址为0的initcode.S的虚拟地址环境,initcode.S是独立于内核编译和链接的,它的加载地址和运行地址都为0。当进程销毁需要回收内存时,可以调用freevm清除用户进程相关的内存环境,freevm首先调用deallocuvm将0到KERNBASE的虚拟地址空间回收,然后销毁整个进程的页表。
在vm.c中,copyuvm负责复制一个新的页表并分配新的内存,新的内存布局和旧的完全一样,xv6使用这个函数作为fork的底层实现。在vm.c的最后,还有两个函数uva2ka和copyout。其中uva2ka将一个用户地址转化为内核地址,也就是通过用户地址找到对应的物理地址,然后退出这个物理地址在内核页表中的虚拟地址并返回,copyout则调用uva2ka则拷贝p地址len字节到用户地址va中。
总体来说,xv6对于物理内存的管理较为简单,将每一空闲页用链表链接起来,向上提供kalloc和kfree接口来屏蔽管理物理内存的细节。xv6将内存管理分为内核地址空间管理和用户地址空间管理,并提供几个函数供系统调用,很多系统函数例如exec,fork都需要使用到这些接口,vm.c和kalloc.c包含了内存管理的大部分内容,系统调用过程使用这些函数来初始化和处理页表结构。

5 xv6文件系统

xv6 的文件系统分6层实现。 最下面一层通过块缓冲读写 IDE 硬盘, 它同步了对磁盘的访问, 保证同时只有一个内核进程可以修改磁盘块。 第二层使得更高层的接口可以将对磁盘的更新按会话打包, 通过会话的方式来保证这些操作是原子操作(要么都被应用, 要么都不被应用) 。 第三层提供无名文件, 每一个这样的文件由一个 i 节点和一连串的数据块组成。 第四层将目录实现为一种特殊的 i 节点, 它的内容是一连串的目录项, 每一个目录项包含一个文件名和对应的 i 节点。 第五层提供了层次路经名(如/usr/rtm/xv6/fs.c这样的) , 这一层通过递归的方式来查询路径对应的文件。 最后一层将许多UNIX 的资源(如管道, 设备, 文件等) 抽象为文件系统的接口, 极大地简化了程序员的工作。
在这里插入图片描述

文件系统必须设计好在磁盘上的什么地方放置 i 节点和数据块。 xv6 把磁盘划分为几个区块,文件系统不使用第0块(第0块存有 bootloader) 。 第1块叫做超级块;它包含了文件系统的元信息(如文件系统的总块数, 数据块块数, i 节点数, 以及日志的块数) 。 从第2块开始存放 i 节点, 每一块能够存放多个 i 节点。 接下来的块存放空闲块位图。 剩下的大部分块是数据块, 它们保存了文件和目录的内容。 在磁盘的最后是日志块, 它们是会话层的一部分 。
在这里插入图片描述

5.1 块缓冲层

xv6将硬盘中的每个分区编号为各种块,每块512Byte,磁盘读写总是以块为单位,xv6使用结构buf来代表磁盘块数据在内核中的表示:
struct buf {
int flags;
uint dev;
uint blockno;
struct sleeplock lock;
uint refcnt;
struct buf *prev; // LRU cache list
struct buf *next;
struct buf *qnext; // disk queue
uchar data[BSIZE];
};
xv6设置有内核缓冲区来缓存一定量的块,并用LRU来实现缓存替换。
struct {
struct spinlock lock;
struct buf buf[NBUF];

// Linked list of all buffers, through prev/next.
// head.next is most recently used.
struct buf head;
} bcache;
xv6在内核中分配了静态数组然后通过head buf来构成双向链表,双向链表维护着块的使用频率,按照最近使用的顺序来组织结构能让块读取更加效率。
块缓冲层提供有binit,bget,bread,bwrite,brelse接口。binit初始化bcache结构并设置块缓冲区需要使用的锁。
void
binit(void)
{
struct buf *b;

initlock(&bcache.lock, “bcache”);

//PAGEBREAK!
// Create linked list of buffers
bcache.head.prev = &bcache.head;
bcache.head.next = &bcache.head;
for(b = bcache.buf; b < bcache.buf+NBUF; b++){
b->next = bcache.head.next;
b->prev = &bcache.head;
initsleeplock(&b->lock, “buffer”);
bcache.head.next->prev = b;
bcache.head.next = b;
}
}
bread根据参数确定设备号和块编号并调用bget得到块缓冲结构,bget在块缓冲区中找到缓冲块,如果此缓冲块已经有进程占用,则睡眠当前进程等待唤醒。如果bget没有找到相应的块缓冲结构,则在缓冲区中找到一个无效的块缓冲区并返回,由bread调用iderw来将数据读入内核。如果缓冲区满,bget简单滴panic。
bwrite将块缓冲结构写入磁盘
void
bwrite(struct buf *b)
{
if(!holdingsleep(&b->lock))
panic(“bwrite”);
b->flags |= B_DIRTY;
iderw(b);
}
brelse则减少块的引用次数,并移动块的位置实现LRU.

5.2 日志层

xv6使用了日志式文件系统来确保写操作不会导致文件系统的破坏,进程的写操作像一种“原子”操作,如果写操作过程中断电崩溃,将很大可能损坏文件系统,例如,在断电后目录有一个指向空闲i节点的项将可能导致严重的问题。
xv6使用了非常严格的日志读写来使读写操作要么完全完成,要么完成未完成。所有的读写操作首先都会写入磁盘中存放日志的区域,只有当真正的读写操作完成后才会使日志失效,这样,就算任何过程中断电或者其他原因导致系统崩溃,文件系统的组织结构都不会损坏,结果是要么操作完全完成,要么都未完成。尽管这样使得每个操作进行了两次,降低了读写效率。
xv6在硬盘中的日志有一个初始快和数据块,初始快包括一个数组,数组的值为对应数据块的内容应该写入文件系统中的哪一块,初始快还有当前有效数据块的计数。在内存中同样要一样的结构来存储数据。
struct logheader {
int n;
int block[LOGSIZE];
};
struct log {
struct spinlock lock;
int start;
int size;
int outstanding; // how many FS sys calls are executing.
int committing; // in commit(), please wait.
int dev;
struct logheader lh;
};
通过这种方式,bwrite可以使用log_write替代,当修改了内存中的块缓冲区后,log_wirte同时在block数组中记录这个块需要写到磁盘中的哪一块,但是没有立即写入,当调用commit的时候,调用write_log写入日志区域中,并调用write_head更新初始快,然后调用install_trans真正地更新文件系统,此时,发生崩溃都会导致日志有非零的计数,以便重启后再次进行写操作,最后将计数变量置零使日志失效并更新日志初始快。
通过log_write写入磁盘时,数据并不会立即写入磁盘,只有当调用commit来提交日志时,磁盘操作才会正式开始磁盘操作。
static void
commit()
{if (log.lh.n > 0) {
write_log(); write_head(); install_trans();
log.lh.n = 0;
write_head(); // Erase the transaction from the log
}}
xv6日志读写支持并发操作,当要写操作时,调用begin_op,结束时调用end_op,begin_op检查日志是否正在提交,如果正在提交则睡眠当前进程,如果不在提交则增加操作次数,end_op减少操作次数,当没有任何进程正在操作log时,调用commit提交日志。
文件和目录的内容存在磁盘块中, 磁盘块都从一个空闲块池中分配出来。 xv6 的块分配器包含一个磁盘上的空闲块位图, 每个块占一个位。 引导区, 超级块, i 节点块和位图块的位永远都被置为有效。
块分配器提供两个功能: balloc 分配一个新的磁盘块, bfree 释放一个块。 balloc 最开始调用 readsb 从磁盘中读出超级块(或者从块缓冲中) 到 sb 中。 balloc 会算出位图块的位置, 计算的方法是计算多少块被引导区、 超级块和 i节点块占用(用 BBLOCK ) 。 循环 从第0块开始一直到 sb.size (文件系统块总数) , 寻找一个在位图中的位是0的块。 为了提高效率, 这个循环被分成两块。 外层循环读位图的每一块。 内层循环检查这一块中的所有 BPB 那么多个位。 两个进程可能同时申请空闲块, 这就有可能导致竞争, 但事实上块缓冲只允许一个进程同时只使用一个块。
i节点指的是磁盘上的记录文件大小、 数据块扇区号的数据结构。 也可以指内存中的一个 i节点, 它包含了一个磁盘上 i 节点的拷贝, 以及一些内核需要的附加信息。
所有的磁盘上的 i 节点都被打包在一个称为 i 节点块的连续区域中。 每一个 i 节点的大小都是一样的, 所以对于一个给定的数字n, 很容易找到磁盘上对应的 i 节点。 事实上这个给定的数字就是操作系统中 i 节点的编号。
iinit负责初始化i节点相关内容。
ialloc在磁盘中找到空闲i节点并返回内核i节点。
iupdate将内核i节点相关内容写入磁盘i节点。
iget返回一个内核i节点。

6 xv6中断与系统调用

操作系统在开始运行用户进程的时候,内核便开始处于被动状态,只有在出现以下几种情况的时候才会触发硬件机制陷入内核:(1)用户代码由于某种原因引发异常(例如除以0);(2)硬件产生中断并且没有屏蔽触发中断;(3)用户代码调用相关指令(例如x86体系下的int系统调用指令)主动陷入内核。以上三种情况便是异常、中断、系统调用机制。这三种机制由于需要陷入内核所以在进入内核之前必须先保存现场,然后回到用户环境的时候恢复现场,xv6对着三种机制都采用相同的处理方式陷入内核。

6.1 保护现场

在这里插入图片描述

在x86体系下,这三种机制都会触发相同的硬件操作,完成部分保护现场的任务,上图中通过将部分寄存器值压入堆栈来实现保护现场,如果触发中断(由于系统调用、中断、异常具有相同的处理机制,所以一下全以中断代称)前处于内核态,则直接在当前栈中保护现场,如果处于用户态,则根据任务栈描述符得到新的内核栈并压入用户态的ss和esp。在硬件完成操作后,栈中会得到以上数据。
x86规定了中断、异常、系统调用的规范,在发生以上情况时,硬件能够区分上述规定的256种触发中断的原因并通过寻找在内存中保存的中断向量表来得到中断处理程序的地址,然后将控制权交给中断处理程序,这是x86架构下发生中断时硬件完成的操作。
xv6通过一个存放函数指针的数组来作为中断向量表,在main函数初始化过程中,调用tvinit函数完成中断向量表的初始化。tvinit将每一个中断处理程序的地址写入idt数组中,idtinit将数组地址载入中断向量表寄存器,硬件能够根据中断向量表寄存器准确找出中断处理程序,xv6使用vector.pl脚本生成vector数组,vector数组存放着每个中断处理程序的入口地址,xv6简单地将所有的中断处理程序指向alltraps,由alltraps来负责具体的处理。在调用alltraps之前,xv6统一压入errnum和trapnum来区分是256情况中的哪种。
alltraps继续压入寄存器保存现场,得到trapframe结构体,trapframe结构体如图所示,其中oesp没有用处,这是pushal指令统一压栈的。
.globl alltraps
alltraps:
Build trap frame.
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
在这里插入图片描述

在这之后重新设置段寄存器,进入内核态,压入当前栈esp,然后调用C函数trap处理中断,在trap返回时,弹出esp然后trapret弹出寄存器恢复现场。
在这里有必要详细说明的是在调用trap时由于trap是c函数所以会在栈上压入返回地址eip和部分寄存值构成context结构,如图:
struct context {
uint edi;
uint esi;
uint ebx;
uint ebp;
uint eip;
};

6.2 运行进程

xv6通过自建一个“现场”来模拟返回第一个进程,xv6手动写入了trapframe和context结构的上下文来让调度器调度并返回用户进程,这些操作由allocproc负责,allocproc手动构建内核栈并写入内核寄存器来构成进程第一次运行的“现场”,如下面代码所示
if((p->kstack = kalloc()) == 0){
p->state = UNUSED;
return 0;
}
sp = p->kstack + KSTACKSIZE;

// Leave room for trap frame.
sp -= sizeof p->tf;
p->tf = (struct trapframe
)sp;

// Set up new context to start executing at forkret,
// which returns to trapret.
sp -= 4;
(uint)sp = (uint)trapret;

sp -= sizeof p->context;
p->context = (struct context
)sp;
memset(p->context, 0, sizeof *p->context);
p->context->eip = (uint)forkret;
进程第一次运行时的现场如下:
在这里插入图片描述

6.3 中断处理程序trap

trap函数主要根据trapframe中的trapno来确定到底是哪种原因导致中断的发生,如果是系统调用,则通过调用syscall函数负责具体的系统调用处理
if(tf->trapno == T_SYSCALL){
if(proc->killed)
exit();
proc->tf = tf;
syscall();
if(proc->killed)
exit();
return;
}
在这里处理proc->killed的原因是kill系统调用的需要,kill系统调用通过将killed置1,来杀死一个进程,由于迟早进程会由于系统调用或者时钟中断进入trap,此时trap检查到killed置1便能够将进程杀死。
注意到这里重新更新了tf的地址,这样做的原因是trapframe的大小由于可能硬件未压入ss和esp导致不一致,而任务栈ts总是指向内核栈所在页的最高地址处:
cpu->ts.esp0 = (uint)proc->kstack + KSTACKSIZE;
proc->tf最开始的赋值是按照大小包括esp和ss来的,在allocproc中:p->tf = (struct trapframe*)sp;
如果中断产生的原因是硬件中断或者异常,trap则调用相应的函数来进行处理。

6.4 系统调用

syscall通过trapframe中的eax来确定系统调用号以决定调用那个系统函数,当然eax的值或许是库函数在调用int指令的时候设置的,只是保护现场使得存放在了trapframe中,然后通过系统调用号调用具体的系统调用处理函数并返回到trapframe中的eax位置,这样恢复现场时库函数便能根据eax得到系统调用的返回值
voidsyscall(void)
{
int num;

num = proc->tf->eax;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
proc->tf->eax = syscallsnum;
} else {
cprintf("%d %s: unknown sys call %d\n",
proc->pid, proc->name, num);
proc->tf->eax = -1;
}
}
每个系统调用都有不同的参数,那么在内核中的系统调用函数又是如何找到这些参数?参数或许是库函数在陷入内核前压栈的,所以可以根据trapframe中的用户栈esp来找到各种参数,xv6使用了工具函数 argint、argptr 和 argstr来获得第 n 个系统调用参数。
他们分别用于获取整数,指针和字符串起始地址。argint 利用用户空间的 %esp 寄存器定位第 n 个参数:%esp 指向系统调用结束后的返回地址。参数就恰好在 %esp 之上(%esp+4)。因此第 n 个参数就在 %esp+4+4*n。

argint 调用 fetchint 从用户内存地址读取值到 *ip。fetchint 可以简单地将这个地址直接转换成一个指针,因为用户和内核共享同一个页表,但是内核必须检验这个指针的确指向的是用户内存空间的一部分。内核已经设置好了页表来保证本进程无法访问它的私有地址以外的内存:如果一个用户尝试读或者写高于(包含)p->sz的地址,处理器会产生一个段中断,这个中断会杀死此进程,正如我们之前所见。但是现在,我们在内核态中执行,用户提供的任何地址都是有权访问的,因此必须要检查这个地址是在 p->sz 之下的。
argptr 和 argint 的目标是相似的:它解析第 n 个系统调用参数。argptr 调用 argint 来把第 n 个参数当做是整数来获取,然后把这个整数看做指针,检查它的确指向的是用户地址空间。注意 argptr 的源码中有两次检查。首先,用户的栈指针在获取参数的时候被检查。然后这个获取到得参数作为用户指针又经过了一次检查。
argstr 是最后一个用于获取系统调用参数的函数。它将第 n 个系统调用参数解析为指针。它确保这个指针是一个 NUL 结尾的字符串并且整个完整的字符串都在用户地址空间中。
系统调用的实现(例如,sysproc.c 和 sysfile.c)仅仅是封装而已:他们用 argint,argptr 和 argstr 来解析参数,然后调用真正的实现。在第二章,sys_exec 利用这些函数来获取参数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Αиcíеиτеǎг

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值