本文具体分析了基于RISC-V多核处理器的xv6操作系统的基本理论与具体实现,通过阅读对应源代码,研究了包括系统调用、陷阱、内存管理、多线程、锁与文件系统等操作系统的重要组成部分,结合mit的lab对其中某些具体实现进行了部分改进。下面将会结合重点核心代码对各部分进行具体阐述。
为了实现不同的系统功能,xv6系统定义了一系列系统调用号与对应的内核处理程序。当应用程序需要使用某一项功能时,可以首先将系统调用号送入a7寄存器,然后执行ecall指令,该指令导致系统陷入内核并执行相应的系统调用处理程序,处理完成后将控制权还给用户程序。
// system calls
int fork(void);
int exit(int) __attribute__((noreturn));
int wait(int*);
int pipe(int*);
int write(int, const void*, int);
int read(int, void*, int);
int close(int);
int kill(int);
int exec(char*, char**);
int open(const char*, int);
int mknod(const char*, short, short);
int unlink(const char*);
int fstat(int fd, struct stat*);
int link(const char*, const char*);
int mkdir(const char*);
int chdir(const char*);
int dup(int);
int getpid(void);
char* sbrk(int);
int sleep(int);
int uptime(void);
int trace(int);
int sysinfo(struct sysinfo *);
sub entry {
my $name = shift;
print ".global $name\n";
print "${name}:\n";
print " li a7, SYS_${name}\n";
print " ecall\n";
print " ret\n";
}
// Per-process state
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
struct proc *parent; // Parent process
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
// these are private to the process, so p->lock need not be held.
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
struct trapframe *trapframe; // data page for trampoline.S
struct context context; // swtch() here to run process
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
int tmask; // Trace mask as args of sys_trace
};
引发操作系统trap的通常有以下几种情况:一种是系统调用,当用户程序执行ecall指令要求内核为其做某事时;另一种情况是异常:一条指令(用户或内核)做了一些非法的事情,如除以零或使用无效的虚拟地址;第三种情况是设备中断,当一个设备发出需要注意的信号时,例如当磁盘硬件完成一个读写请求时。Xv6 trap 处理分为四个阶段:trap迫使控制权转移到内核;内核保存寄存器和其他状态,以便恢复执行;内核执行适当的处理程序代码(例如,系统调用实现或设备驱动程序);内核恢复保存的状态,并从trap中返回,代码从原来的地方恢复执行。
每个RISC-V CPU都有一组控制寄存器,内核写入这些寄存器来告诉CPU如何处理trap,内核可以通过读取这些寄存器来发现已经发生的trap,以下是一些重要的寄存器:
stvec:内核在这里写下trap处理程序的地址;RISC-V跳转到这里来处理trap。
sepc:当trap发生时,RISC-V会将程序计数器保存在这里(因为PC会被stvec覆盖)。sret(从trap中返回)指令将sepc复制到pc中。内核可以写sepc来控制sret的返回到哪里。
scause:RISC -V在这里放了一个数字,描述了trap的原因。
sscratch:内核在这里放置了一个值,在trap处理程序开始时可以方便地使用。
sstatus:sstatus中的SIE位控制设备中断是否被启用,如果内核清除SIE,RISC-V将推迟设备中断,直到内核设置SIE。SPP位表示trap是来自用户模式还是supervisor模式,并控制sret返回到什么模式。
当需要执行trap时,RISC-V硬件对所有的trap类型(除定时器中断外)进行以下操作:
xv6操作系统使用基于页表的虚拟内存管理方式,一个RISC-V页表在逻辑上是一个由 2 27 2^{27} 227(134,217,728)个页表项(Page Table Entry, PTE)组成的数组。每个PTE包含一个44位的物理页号(Physical Page Number, PPN)和一些标志位。分页硬件通过利用39位中的高27位索引到页表中找到一个PTE来转换一个虚拟地址,并计算出一个56位的物理地址,它的前44位来自于PTE中的PPN,而它的后12位则是从原来的虚拟地址复制过来的。如图所示,在逻辑上可以把页表看成是一个简单的PTE数组,操作系统通过页表来控制虚拟地址到物理地址的转换,其粒度为4096( 2 12 2^{12} 212)字节的对齐块,即内存页。
一个页表以三层树的形式存储在物理内存中。树的根部是一个 4096 字节的页表页,它包含 512 个 PTE,这些 PTE 包含树的下一级页表页的物理地址。每一页都包含 512 个 PTE,用于指向下一个页表或物理地址。分页硬件用 27 位中的高 9 位选择根页表页中的 PTE,用中间 9 位选择树中下一级页表页中的 PTE,用低 9 位选择最后的 PTE。
每个 PTE 都包含标志位,用于告诉分页硬件相关的虚拟地址被允许怎样使用。PTE_V 表示 PTE 是否存在:如果没有设置,对该页的引用会引起异常(即不允许)。PTE_R 控制是否允许指令读取该页。PTE_W 控制是否允许指令向该页写入。PTE_X 控制 CPU 是否可以将页面的内容解释为指令并执行。PTE_U 控制是否允许用户态下的指令访问页面;如果不设置 PTE_U, 对应 PTE 只能在内核态下使用。
要告诉硬件使用一个页表,内核必须将对应根页表页的物理地址写入 satp 寄存器中。每个 CPU 都有自己的 satp 寄存器。一个 CPU 将使用自己的 satp 所指向的页表来翻译后续指令产生的所有地址。每个 CPU 都有自己的 satp,这样不同的 CPU 可以运行不同的进程,每个进程都有自己的页表所描述的私有地址空间。
// qemu -machine virt is set up like this,
// based on qemu's hw/riscv/virt.c:
//
// 00001000 -- boot ROM, provided by qemu
// 02000000 -- CLINT
// 0C000000 -- PLIC
// 10000000 -- uart0
// 10001000 -- virtio disk
// 80000000 -- boot ROM jumps here in machine mode
// -kernel loads the kernel here
// unused RAM after 80000000.
// the kernel uses physical memory thus:
// 80000000 -- entry.S, then kernel text and data
// end -- start of kernel page allocation area
// PHYSTOP -- end RAM used by the kernel
struct run {
struct run *next;
};
struct {
struct spinlock lock;
struct run *freelist;
} kmem;
与此相关的是两个核心函数:kalloc()与kfree(),其中kalloc()函数从空闲链表头去除一个空闲页,清理后返回其指针,而kfree()函数则将参数指针指向的内存页清理后重新插入空闲链表,等待下一次分配。
kvminit()函数按照memlayout.h规定的内存布局为内核初始化页表,通过这种方式初始化内核地址空间。与此对应,uvmcreate()与uvminit()用于初始化用户空间页表。
mappages()函数则是用来进行内存映射的主要功能函数,给定页表,虚拟地址va与物理地址pa,该函数在页表中建立对应虚拟地址的PTE表项,将PTE_V位置为1。
walk()函数根据上文提到的页表三层树结构进行逐层遍历,最终返回给定虚拟地址对应的PTE表项,该函数可以根据给定的alloc参数,在页表项不存在时进行分配。
uvmalloc()用于为用户进程页表分配新的空间,uvmdealloc()则用来释放多余的用户空间。uvmcopy()函数用来完成父进程到子进程页表的复制,在原始的实现中,该函数在完成页表项复制的同时也会进行物理内存的完全复制,这种方式显然带来了过多的拷贝开销。在改进的写时复制(COW)实现方式中,我们不再进行实际物理内存的复制,取而代之的是将父子进程对应的页表项标记为只读的写时复制页,这样当且仅当进程企图进行写操作时,这些页才真正完成复制,成为进程的私有页,大大减少了内存拷贝开销,改进了fork()的效率。
除此之外,copyout()与copyin()用于在内核与用户空间之间完成内存拷贝。
多线程并发一直是多处理器操作系统关心的问题,特别地,xv6为我们用户级多线程的解决方案(在这里我们讨论的是mit提供的lab:multithreading)。在该实现中,每个用户级线程都拥有自己的上下文,线程栈与线程状态。管理程序通过维护全局的线程控制数组与运行线程指针来完成用户级的线程调度。
/* Possible states of a thread: */
#define FREE 0x0
#define RUNNING 0x1
#define RUNNABLE 0x2
#define STACK_SIZE 8192
#define MAX_THREAD 4
struct tcontext {
uint64 ra;
uint64 sp;
// callee-saved
uint64 s0;
uint64 s1;
uint64 s2;
uint64 s3;
uint64 s4;
uint64 s5;
uint64 s6;
uint64 s7;
uint64 s8;
uint64 s9;
uint64 s10;
uint64 s11;
};
struct thread {
char stack[STACK_SIZE]; /* the thread's stack */
int state; /* FREE, RUNNING, RUNNABLE */
struct tcontext context; /* thread context */
};
struct thread all_thread[MAX_THREAD];
struct thread *current_thread;
比较重要的线程管理函数有线程创建函数thread_create(),线程调度函数thread_schedule(),其中thread_create()遍历线程控制块数组,找到当前空闲的控制块,将其返回地址寄存器ra设置为给定的函数地址并将状态设置为RUNNABLE以完成一个新线程的插入。thread_schedule()函数从当前运行线程的控制块开始循环遍历线程控制块数组,找到下一个可运行的线程,一旦找到就进行线程切换,开始运行新线程。
线程安全问题是基于并发的多处理器操作系统面临的核心挑战。为了解决这类问题,锁的创建与使用显得至关重要。锁提供了互斥的功能,确保一次只有一个CPU可以持有一个特定的锁。如果程序员为每个共享数据项关联一个锁,并且代码在使用某项时总是持有关联的锁,那么该项每次只能由一个CPU使用。在这种情况下,我们说锁保护了数据项。虽然锁是一种简单易懂的并发控制机制,但其也带来了性能降低的缺点,因为锁将并发操作串行化了。
xv6提供了两种类型的锁:自旋锁(spinlock)和睡眠锁(sleeplock)。自旋锁会让CPU在锁上自旋等待,可能会浪费大量CPU时间,而睡眠锁则会在锁被占用时主动让出CPU,允许其他线程运行并进入阻塞态等待唤醒。因此自旋锁最适合于短的关键部分,而睡眠锁对长的操作很有效。
xv6对锁的使用有粗粒度与细粒度两种方式:作为粗粒度锁的一个例子,xv6的kalloc.c分配器有一个单一的空闲列表,由一个单一的锁构成。如果不同CPU上的多个进程试图同时分配页面,那么每个进程都必须通过在acquire中旋转来等待轮到自己。旋转会降低性能,因为这不是有用的工作。如果争夺锁浪费了相当一部分CPU时间,也许可以通过改变分配器的设计来提高性能,使其拥有多个空闲列表,每个列表都有自己的锁,从而实现真正的并行分配。
作为细粒度锁的一个例子,xv6为每个文件都有一个单独的锁,这样操作不同文件的进程往往可以不用等待对方的锁就可以进行。如果想让进程模拟写入同一文件的不同区域,文件锁方案可以做得更细。最终,锁的粒度决定需要由性能测量以及复杂性考虑来驱动。
// Mutual exclusion lock.
struct spinlock {
uint locked; // Is the lock held?
// For debugging:
char *name; // Name of lock.
struct cpu *cpu; // The cpu holding the lock.
#ifdef LAB_LOCK
int nts;
int n;
#endif
};
// Long-term locks for processes
struct sleeplock {
uint locked; // Is the lock held?
struct spinlock lk; // spinlock protecting this sleep lock
// For debugging:
char *name; // Name of lock.
int pid; // Process holding lock
};
// Acquire the lock.
// Loops (spins) until the lock is acquired.
void
acquire(struct spinlock *lk)
{
push_off(); // disable interrupts to avoid deadlock.
if(holding(lk))
panic("acquire");
#ifdef LAB_LOCK
__sync_fetch_and_add(&(lk->n), 1);
#endif
// On RISC-V, sync_lock_test_and_set turns into an atomic swap:
// a5 = 1
// s1 = &lk->locked
// amoswap.w.aq a5, a5, (s1)
while(__sync_lock_test_and_set(&lk->locked, 1) != 0) {
#ifdef LAB_LOCK
__sync_fetch_and_add(&(lk->nts), 1);
#else
;
#endif
}
// Tell the C compiler and the processor to not move loads or stores
// past this point, to ensure that the critical section's memory
// references happen strictly after the lock is acquired.
// On RISC-V, this emits a fence instruction.
__sync_synchronize();
// Record info about lock acquisition for holding() and debugging.
lk->cpu = mycpu();
}
void
acquiresleep(struct sleeplock *lk)
{
acquire(&lk->lk);
while (lk->locked) {
sleep(lk, &lk->lk);
}
lk->locked = 1;
lk->pid = myproc()->pid;
release(&lk->lk);
}
struct {
struct spinlock lock;
struct run *freelist;
} kmem[NCPU];
void *
kalloc(void)
{
struct run *r;
push_off();//turn interrupts off
int id = cpuid();
pop_off();//turn interrupts on
acquire(&kmem[id].lock);
r = kmem[id].freelist;
if(r)
kmem[id].freelist = r->next;
else{// steal mem from other cpus
for(int i=0; i<NCPU; i++)
{
if(i == id) continue;
acquire(&(kmem[i].lock));
r = kmem[i].freelist;
if(r)
{
kmem[i].freelist = r->next;
release(&(kmem[i].lock));
break;
}
release(&(kmem[i].lock));
}
}
release(&kmem[id].lock);
if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
return (void*)r;
}
struct {
struct spinlock lock;
struct buf buf[NBUF];
// Linked list of all buffers, through prev/next.
// Sorted by how recently the buffer was used.
// head.next is most recent, head.prev is least.
struct buf head;
} bcache;
为了提高并行效率,我们可以改进该结构,使用基于哈希表的细粒度多自旋锁:
struct {
struct spinlock lock[NBUCKET];
struct buf buf[NBUF];
struct buf head[NBUCKET]; // hash buckets of linked list
} bcache;
这样在bget()中为特定标号的磁盘块寻找缓存块时,首先将其哈希到对应的桶中,在对应的桶中完成查找空闲的buffer,如果没找到则从其他桶中“窃取”,这种方式大大提高了磁盘块缓存的并行性能。
如图所示,xv6文件系统的实现分为七层。disk层在virtio磁盘上读写块。Buffer cache缓存磁盘块,并同步访问它们,确保一个块只能同时被内核中的一个进程访问。日志层允许上层通过事务更新多个磁盘块,并确保在崩溃时,磁盘块是原子更新的(即全部更新或不更新)。inode层将一个文件都表示为一个inode,每个文件包含一个唯一的i-number和一些存放文件数据的块。目录层将实现了一种特殊的inode,被称为目录,其包含一个目录项序列,每个目录项由文件名称和i-number组成。路径名层提供了层次化的路径名,如/usr/rtm/xv6/fs.c,可以用递归查找解析他们。文件描述符层用文件系统接口抽象了许多Unix资源(如管道、设备、文件等),使程序员的生产力得到大大的提高。
文件系统必须安排好磁盘存储inode和内容块的位置。为此,xv6将磁盘分为几个部分,如图所示。文件系统不使用块0(它存放boot sector)。第1块称为superblock,它包含了文件系统的元数据(以块为单位的文件系统大小、数据块的数量、inode的数量和日志中的块数)。从块2开始存放着日志。日志之后是inodes,每个块会包含多个inode。在这些块之后是位图块(bitmap),记录哪些数据块在使用。其余的块是数据块,每个数据块要么在bitmap块中标记为空闲,要么持有文件或目录的内容。超级块由一个单独的程序mkfs写入,它建立了一个初始文件系统。
// super block describes the disk layout:
struct superblock {
uint magic; // Must be FSMAGIC
uint size; // Size of file system image (blocks)
uint nblocks; // Number of data blocks
uint ninodes; // Number of inodes.
uint nlog; // Number of log blocks
uint logstart; // Block number of first log block
uint inodestart; // Block number of first inode block
uint bmapstart; // Block number of first free map block
};
// On-disk inode structure
struct dinode {
short type; // File type
short major; // Major device number (T_DEVICE only)
short minor; // Minor device number (T_DEVICE only)
short nlink; // Number of links to inode in file system
uint size; // Size of file (bytes)
uint addrs[NDIRECT+1]; // Data block addresses
};
// Directory is a file containing a sequence of dirent structures.
struct dirent {
ushort inum;
char name[DIRSIZ];
};
在原始的实现中,dinode使用了直接映射与一级间接映射结合的方式,addrs数组前11项直接指向映射的磁盘块地址,而最后一项指向一级索引表的磁盘地址,这种方式最多支持 12 + B S I Z E / s i z e o f ( u i n t ) 12 + BSIZE / sizeof(uint) 12+BSIZE/sizeof(uint)个磁盘块大小的文件。
struct {
struct spinlock lock;
struct inode inode[NINODE];
} icache;
ialloc()函数在磁盘上分配给定类型的dinode块并返回可用的inode结构,iget()函数用于获取给定序号的inode缓存,ilock()与iunlock()用于管理inode上的锁结构。bmap()函数基于上面提到的直接与一级索引结构,查找inode中给定块号的磁盘块地址,而readi()与writei()函数基于bmp()完成给定inode块对应文件的读写操作。
struct file {
#ifdef LAB_NET
enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE, FD_SOCK } type;
#else
enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE } type;
#endif
int ref; // reference count
char readable;
char writable;
struct pipe *pipe; // FD_PIPE
struct inode *ip; // FD_INODE and FD_DEVICE
#ifdef LAB_NET
struct sock *sock; // FD_SOCK
#endif
uint off; // FD_INODE
short major; // FD_DEVICE
};
// in-memory copy of an inode
struct inode {
uint dev; // Device number
uint inum; // Inode number
int ref; // Reference count
struct sleeplock lock; // protects everything below here
int valid; // inode has been read from disk?
short type; // copy of disk inode
short major;
short minor;
short nlink;
uint size;
uint addrs[NDIRECT+1];
};
struct {
struct spinlock lock;
struct file file[NFILE];
} ftable;
作为简化版Unix操作系统,xv6中包含了操作系统各项核心功能(进程调度,内存管理,文件系统等)简单而有效的实现。阅读源代码,结合相关资料进行对比分析,可以帮助我们更加深入而全面地了解操作系统的设计原则与核心精神。
[1]xv6-riscv (https://github.com/mit-pdos/xv6-riscv)
[2]xv6-riscv-book (https://pdos.csail.mit.edu/6.S081/2020/xv6/book-riscv-rev1.pdf)
[2]xv6-riscv-labs (https://pdos.csail.mit.edu/6.828/2020/xv6.html)