xv6(RISC-V)操作系统源码分析第二节——操作系统组织

一、一个操作系统的基本要求

一个操作系统至少需要满足以下三个要求:

  1. 多路复用
  2. 进程隔离
  3. 进程通信

(一)多路复用

硬件CPU的数量有限,且往往少于同时存在的进程数量。而操作系统需要支持进程的并发执行,所以操作系统应该能使多个进程分时共享计算机的资源。

(二)进程隔离

一个进程的运行,应当具有一定的独立性,这个独立性指该进程在一定程度上不受其他进程的影响。这可以保证出了bug的程序不会严重影响其他程序的正常运行。

(三)进程通信

如上所说,进程的隔离并非是完全的,而是一定程度上的。进程间可能需要通信来协调进程的执行。

二、操作系统隔离了应用程序与硬件资源

倘若应用程序与硬件资源直接交互,并把之前的系统调用视为一个库,应用程序与之连接。这样做,应用程序可能会实现高效、可预测的性能。一些用于嵌入式设备或实时系统的操作系统就是采用这样的方式。

但缺点就是,多个应用程序间必须配合的天衣无缝。比如每个应用程序必须定期放弃CPU,以使其他应用程序可以运行,这样做要求所有应用程序间都相互信任且没有bug。但这往往是难以实现的,所以操作系统实现应用程序与硬件资源的强隔离是非常有必要的。

操作系统实现强隔离的方式是将资源抽象为服务。比如Unix应用程序只通过文件系统的open、write等系统调用去与文件系统直接交互,而不是直接读写磁盘,这带来了路径名的便利。

总得来说,操作系统强隔离了应用程序与硬件资源,将资源抽象为服务,提高了开发与程序执行的效率。

三、实现强隔离的硬件支持——RISC-V的三种模式

RISC-V指令集架构的CPU有三种模式,分别是机器模式监督者模式用户模式

  1. 一个CPU在机器模式下启动,因为机器模式下执行的指令具有完全的权限。机器模式主要用于配置计算机。xv6在机器模式下执行几条指令后就会转为监督者模式。
  2. 在监督者模式下,CPU被允许执行特权指令,例如:启用和禁用中断、读写保存页表地址的寄存器。处于监督者模式的软件可以执行特权指令,被称为运行在内核空间。运行在内核空间(或称监督者模式)的软件被称为内核。
  3. 应用程序只能运行在用户模式,若应用程序试图执行一条特权指令,CPU不会去执行它,而是使用ecall指令,将CPU从用户模式切换到监督者模式,并在内核指定的入口处进入内核。监督者模式(内核)的代码会终止该应用程序,并验证系统调用的参数,决定是否允许应用程序执行请求的操作,然后拒绝或执行该操作。

注意:内核控制监督者模式的入口点,如果是应用程序决定内核的入口点,那么恶意程序就能够在跳过参数验证的情况下进入内核。

四、xv6操作系统的设计结构(内核组织)——宏内核 

(一)宏内核与微内核

宏内核与微内核是内核的两种组织方式。

宏内核:又名单内核。它的用户服务与内核服务都保存在相同的地址空间,有内核进行统一管理。

微内核:用户服务与内核服务保存在不同的地址空间。

如下图所示:

在宏内核中,内核是一个大的整体,一个大的进程,函数间的调用链路少,之间通信简单高效。

在微内核中,微内核的功能被划分为独立的进程,进程间通过IPC通信,高度模块化,一个服务的故障不会影响另一个服务。但由于模块化,函数之间调用链路偏长,进程间不会之间通信,而是通过内核服务相互通信。

宏内核的执行效率高于微内核,因为微内核涉及到跨模块调用。但宏内核的扩展性比微内核差。

(二)xv6的宏内核组织

xv6采用宏内核组织,其所有系统调用都在监督者模式下运行。整个操作系统以全硬件权限运行。

这导致xv6的开发者需要仔细地琢磨各个接口,一旦有一个错误就是致命的。因为监督者模式下的错误往往会导致内核崩溃,内核崩溃导致计算机停止工作,所有应用程序崩溃,计算机必须重启。

xv6的内核源码在kernel/子目录下。按照模块化的设计思想,源码被分为了多个文件,下图列出了这些文件及其功能。

文件名功能描述
bio.c文件系统的磁盘块缓存
console.c连接用户键盘与屏幕
exec.cexec系统调用
file.c文件描述符的支持文件
fs.c文件系统
kalloc.c物理页面分配程序
log.c文件系统记录与崩溃恢复
main.c在启动过程中控制其他模块的初始化
pipe.c管道
plic.cRISC-V中断控制器
printf.c到控制台的格式化输出
proc.c进程和调度
sleeplock.c自锁并让出CPU
spinlock.c自锁但不让出CPU
start.c机器模式启动代码
string.cC 字符串和字节数组库
syscall.c分派系统调用处理函数
sysfile.c文件相关系统调用
sysproc.c进程相关系统调用
trap.c陷阱和中断的响应返回C程序
uart.c串行端口控制台设备驱动程序
virtio_disk.c磁盘驱动程序
vm.c管理页表和地址空间
entry.S首次开机引导程序
kernelvec.S内核陷阱和计时器中断的处理程序
swtch.S线程切换
trampoline.S用于切换用户空间与内核空间的汇编代码

五、xv6的进程实现 

xv6的隔离单位是一个进程。进程的实现,可以防止一个进程破坏或监视另一个进程的内存、CPU、文件描述符等。同时还可以防止进程破坏内核。

xv6内核用来实现进程的机制包括:用户/监督模式标志地址空间线程的时间片轮转

(一)进程的内存空间

进程的隔离基于进程抽象,进程抽象为一个进程提供了一种错觉,即该进程以为自己有私有的机器(私有的内存系统与私有的CPU)。其他进程不能对该进程的内存系统进行读写。

xv6使用页表(由硬件实现)给每个进程提供属于它自己的内存空间(地址空间)。RISC-V页表将虚拟地址(RISC-V指令操作的地址)映射为物理地址(CPU芯片发送到内存的地址)。

xv6为每个进程维护一个页表,定义该进程的地址空间,如下图所示:

 进程的用户空间的内存地址空间从虚拟地址0开始,指令存放在最前面,其次是全局变量,然后是栈,最后是一个堆区(用于malloc,以便进程根据需要扩展)。

但进程的地址空间是有限的,它被一些因素限制,分析如下:

  • RISC-V的指针宽度是64位,硬件在页表中查找虚拟地址时只用了低的39位,而xv6只使用这39位中的38位,因此最大地址为2^38-1,即0x3fffffffff,代码定义如下:
  • // one beyond the highest possible virtual address.
    // MAXVA is actually one bit less than the max allowed by
    // Sv39, to avoid having to sign-extend virtual addresses
    // that have the high bit set.
    #define MAXVA (1L << (9 + 9 + 9 + 12 - 1))

 在地址空间的顶端,xv6保留了一页,用于trampoline和映射进程trapframe的页,以便切换到内核。

  • trampoline:跳板,实现动态CPU调度的机制,每个CPU都有一个trampoline,它包含了一段指令序列和一些数据结构,用于保存CPU的状态并调度其他CPU上的任务。当一个任务需要在不同的CPU之间切换时,它会通过trampoline将自己的状态保存到一个共享的数据结构中,并将控制流传递给另一个CPU的trampoline。接收到控制流的trampoline会根据共享数据结构中的信息恢复任务的状态,并继续执行任务。
  • trapframe:保存系统关键的寄存器。

(二)进程的状态

 xv6为每个进程维护了许多状态,记录在proc结构体中。一个进程最重要的内核状态是它的页表、内核栈和运行状态。xv6用p->XXX表示proc结构的元素,例如:

  • p->pagetable是指向进程页表的指针。它以RISC-V硬件需要的格式保存进程的页表,当进程在用户空间执行时,xv6使分页硬件使用进程的p->pagetable。
  • p->state表示进程的状态是创建、就绪、执行、等待I/O或退出。

proc结构体如下:

// Per-process state
struct proc {
  struct spinlock lock;

  // p->lock must be held when using these:
  enum procstate state;        // Process state
  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

  // wait_lock must be held when using this:
  struct proc *parent;         // Parent process

  // 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)
};

(三)进程的线程 

每个进程都有一个执行线程(简称线程),执行进程的指令。

一个线程可以被暂停,然后再恢复。为了在进程间透明地切换,内核会暂停当前运行的线程,并恢复另一个进程的线程。

线程的大部分状态(局部变量、函数调用返回地址)都存储在线程的栈中。每个进程有两个栈:用户栈和内核栈(p->kstack)。当进程执行用户指令时,只有它的用户栈在被使用,而它的内核栈是空的。当进程在内核中时,它的用户栈仍然包含保存的数据,但不被主动使用。进程的线程在用户栈和内存栈中交替执行。内核栈是独立的,受到保护,不受用户代码的影响。所以,即使一个进程用户栈被破坏了,内核也可以执行。

(四)进程通过ecall与sret指令在内核空间与用户空间之间交替运行

一个进程通过执行RISC-V的ecall指令进行系统调用。该指令提高硬件权限级别,并将程序计数器改变为内核定义的入口点。入口点代码会切换到内核栈,并执行实现系统调用的内核指令。当系统调用完成后,内核切换到用户栈,并通过调用sret指令返回用户空间,降低硬件权限级别,恢复执行系统调用前的用户指令。

进程的线程可以在内核中阻塞等待I/O,当I/O完成后,再从离开的地方恢复。

六、xv6的启动与第一个进程 

当RISC-V计算机开机时,它会初始化自己,并运行一个存储在只读存储器中的boot loader程序。boot loader将xv6内核加载到内存中。

boot loader将xv6内核加载到物理地址0x80000000的内存中。

注意:内核不放在0x0,而放在0x80000000处,是因为地址范围0x0~0x80000000包含了I/O设备。 

然后在机器模式下,CPU从_entry开始执行xv6。RISC-V的启动过程中禁用分页硬件,即此时的虚拟地址直接映射到物理地址。

(一)_entry代码分析

_entry的代码:

        # qemu -kernel loads the kernel at 0x80000000
        # and causes each hart (i.e. CPU) to jump there.
        # kernel.ld causes the following code to
        # be placed at 0x80000000.
.section .text
.global _entry
_entry:
        # set up a stack for C.
        # stack0 is declared in start.c,
        # with a 4096-byte stack per CPU.
        # sp = stack0 + (hartid * 4096)
        la sp, stack0
        li a0, 1024*4
        csrr a1, mhartid
        addi a1, a1, 1
        mul a0, a0, a1
        add sp, sp, a0
        # jump to start() in start.c
        call start
spin:
        j spin

_entry代码解析:

xv6在start.c的声明空间中声明了初始栈的空间,这些空间的首地址存放于stack0中。

注意:4096*NCPU。这是因为xv6运行在多核的RISC-V处理器上,所以初始栈的空间等于核数乘以4096。

在RISC-V调用约定标准中,栈是向下增长并且栈指针总是16字节对齐。

// entry.S needs one stack per CPU.
__attribute__ ((aligned (16))) char stack0[4096 * NCPU];

下面分析_entry的代码:

将stack0的地址存入栈指针寄存器sp中。

la sp, stack0

在a0寄存器中存放一个核所需的初始栈空间4096字节。

li a0, 1024*4

mhartid是运行当前程序的CPU核的ID,其值范围是0~MaxCore-1。

在a1寄存器中存放核数。

csrr a1, mhartid
addi a1, a1, 1

计算出总的初始栈空间。

mul a0, a0, a1

将sp置于栈顶。

add sp, sp, a0

此时sp等于stack0+NCPU*4096字节,指向了栈顶。

栈设置好后,该汇编程序调用start这个C程序函数。

call start

调用后,正常情况下,程序不会返回到_entry程序,即下面两条指令不会被执行。

spin:
        j spin

若从调用start返回,说明操作系统存在问题,系统转入死循环。

(二)start函数代码分析

start的代码:

// entry.S jumps here in machine mode on stack0.
void start()
{ 
  // set M Previous Privilege mode to Supervisor, for mret.
  unsigned long x = r_mstatus();
  x &= ~MSTATUS_MPP_MASK;
  x |= MSTATUS_MPP_S;
  w_mstatus(x);

  // set M Exception Program Counter to main, for mret.
  // requires gcc -mcmodel=medany
  w_mepc((uint64)main);

  // disable paging for now.
  w_satp(0);

  // delegate all interrupts and exceptions to supervisor mode.
  w_medeleg(0xffff);
  w_mideleg(0xffff);
  w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

  // configure Physical Memory Protection to give supervisor mode
  // access to all of physical memory.
  w_pmpaddr0(0x3fffffffffffffull);
  w_pmpcfg0(0xf);

  // ask for clock interrupts.
  timerinit();

  // keep each CPU's hartid in its tp register, for cpuid().
  int id = r_mhartid();
  w_tp(id);

  // switch to supervisor mode and jump to main().
  asm volatile("mret");
}

start代码分析:

读mstatus寄存器的值保存在x中

unsigned long x = r_mstatus();

将对应的MPP位设置为01,即标志着前模式为监督者模式。

第一条语句将x的前模式清0,其他位保持不变。

第二条语句将x的前模式的低位置1。

  x &= ~MSTATUS_MPP_MASK;
  x |= MSTATUS_MPP_S;

将修改后的x写回mstatus寄存器,当执行mret指令时,从机器模式切换至监督者模式。

w_mstatus(x);

将main的地址看作机器模式下发生异常时指令的地址,并将其保存至mepc寄存器中,当执行mret时,程序从发生异常的指令处恢复执行。

w_mepc((uint64)main);

将0写入页表寄存器satp来禁用虚拟地址转换。

w_satp(0);

将所有中断核异常委托给监督者模式。

  w_medeleg(0xffff);
  w_mideleg(0xffff);
  w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

  配置物理内存保护,以便让监督者模式访问所有物理内存。

  w_pmpaddr0(0x3fffffffffffffull);
  w_pmpcfg0(0xf);

对时钟芯片编程以产生计时器中断

timerinit();

将核ID保存至tp寄存器中。

  int id = r_mhartid();
  w_tp(id);

执行mret指令,系统由机器模式切换至监督者模式,并从main()开始处运行。

asm volatile("mret");

注意:_entry和start函数都是在机器模式下运行,start执行mret指令后,机器模式才切换至监督者模式,并执行main函数。

(三)main函数代码分析

main函数代码:

// start() jumps here in supervisor mode on all CPUs.
void main()
{
  if(cpuid() == 0){
    consoleinit();
    printfinit();
    printf("\n");
    printf("xv6 kernel is booting\n");
    printf("\n");
    kinit();         // physical page allocator
    kvminit();       // create kernel page table
    kvminithart();   // turn on paging
    procinit();      // process table
    trapinit();      // trap vectors
    trapinithart();  // install kernel trap vector
    plicinit();      // set up interrupt controller
    plicinithart();  // ask PLIC for device interrupts
    binit();         // buffer cache
    iinit();         // inode table
    fileinit();      // file table
    virtio_disk_init(); // emulated hard disk
    userinit();      // first user process
    __sync_synchronize();
    started = 1;
  } else {
    while(started == 0)
      ;
    __sync_synchronize();
    printf("hart %d starting\n", cpuid());
    kvminithart();    // turn on paging
    trapinithart();   // install kernel trap vector
    plicinithart();   // ask PLIC for device interrupts
  }

  scheduler();        
}

 main函数代码分析:

main函数当前执行核是否是核0,若是则执行if语句中的,其余核执行else语句中的。

核0执行代码:

初始化终端。

consoleinit();

初始化输出互斥锁。

printfinit();

显示提示信息:“xv6 kernel is booting\n”。

    printf("\n");
    printf("xv6 kernel is booting\n");
    printf("\n");

初始化物理内存页。

kinit(); 

创建内核页表。

kvminit();  

将h/w页表寄存器切换到内核的页表,并启用分页。

kvminithart();

初始化进程表。

procinit();

设置trap向量。

trapinit();

安装内核陷阱向量。

trapinithart();

设置中断控制器。

plicinit();

对监督者模式的硬件线程设置uart启用位,及优先级阈值为0。

plicinithart();

缓冲区初始化。

binit(); 

inode缓冲区初始化。

iinit(); 

文件表初始化。

fileinit(); 

初始化虚拟磁盘。

virtio_disk_init();

创建第一个用户进程。第一个进程执行一个小程序initcode.S(user/initcode.S:1),该程序通过调用exec系统调用重新进入内核。

userinit();

同步。

__sync_synchronize();

started=1。

started = 1;

其余核执行代码:

等待核0初始化完成。

while(started == 0)
      ;

同步。

__sync_synchronize();

输出核ID信息。

printf("hart %d starting\n", cpuid());

将h/w页表寄存器切换到内核的页表,并启用分页。

kvminithart(); 

安装内核陷阱向量。

trapinithart(); 

对监督者模式的硬件线程设置uart启用位,及优先级阈值为0。

plicinithart(); 

main函数调用userinit来创建第一个进程,第一个进程是一个使用RISC-V汇编编写的小程序initcode.S。其代码如下:

# Initial process that execs /init.
# This code runs in user space.

#include "syscall.h"

# exec(init, argv)
.globl start
start:
        la a0, init
        la a1, argv
        li a7, SYS_exec
        ecall

# for(;;) exit();
exit:
        li a7, SYS_exit
        ecall
        jal exit

# char init[] = "/init\0";
init:
  .string "/init\0"

# char *argv[] = { init, 0 };
.p2align 2
argv:
  .long init
  .long 0

代码分析:

initcode.S通过调用exec系统调用重新进入内核,exec使用一个新程序init替换当前进程的内存和寄存器。一旦内核完成exec,它就会在init进程中返回到用户空间。init的代码如下:

int main(void)
{
  int pid, wpid;

  if(open("console", O_RDWR) < 0){
    mknod("console", CONSOLE, 0);
    open("console", O_RDWR);
  }
  dup(0);  // stdout
  dup(0);  // stderr

  for(;;){
    printf("init: starting sh\n");
    pid = fork();
    if(pid < 0){
      printf("init: fork failed\n");
      exit(1);
    }
    if(pid == 0){
      exec("sh", argv);
      printf("init: exec sh failed\n");
      exit(1);
    }

    for(;;){
      // this call to wait() returns if the shell exits,
      // or if a parentless process exits.
      wpid = wait((int *) 0);
      if(wpid == pid){
        // the shell exited; restart it.
        break;
      } else if(wpid < 0){
        printf("init: wait returned an error\n");
        exit(1);
      } else {
        // it was a parentless process; do nothing.
      }
    }
  }
}

init在需要时会创建一个新的控制台设备文件,然后以文件描述符0、1和2的形式打开它,然后在控制台上启动一个shell(使用exec执行shell程序),这样系统就启动了。

总结:

第二节通过重点讲解xv6进程的实现概况及xv6操作系统的启动,解释了xv6操作系统是如何启动这个重要问题。

参考资料:

[1] FrankZn/xv6-riscv-book-Chinese (github.com)

[2] mit-pdos/xv6-riscv: Xv6 for RISC-V (github.com) 

[3] 一文了解宏内核和微内核_微内核和宏内核的区别-CSDN博客

[4] xv6实验课程:xv6启动过程分析-CSDN博客

[5] 请问Linux内核的trampoline是啥意思? - 知乎 (zhihu.com)

[6] 【xv6学习之番外篇】详解struct Env 与 struct Trapframe-CSDN博客

  • 29
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值