M3ngL

Lec02:OS特性与xv6启动过程

xv6课程翻译: https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081

xv6教学文档:https://th0ar.gitbooks.io/xv6-chinese/content/index.html

Multiplexing与内存隔离

使用操作系统的主要原因就是为了实现multiplexing和内存隔离,也就是硬件资源的隔离,且应用程序不能够打破对它的隔离

但也存在某些情况,不需要进行硬件资源的隔离:将操作系统设计成一个库(实时操作系统中可以看到这样的设计),因为在这些实时操作系统中,应用程序之间可以彼此信任。

抽象硬件资源的例子

  1. 进程抽象了CPU,实现了 multiplexing

操作系统不是直接将CPU提供给应用程序,而是向应用程序提供进程,进程抽象了CPU,这样 操作系统才能在多个应用程序之间复用一个或者多个CPU。

xv6 实验中使用的RISC-V处理器实际上是有4个核。因此可以同时运行4个进程,一个进程占用一个核。

但是假设有8个应用程序,操作系统会分时复用这些CPU核,比如说对于一个进程运行100毫秒,之后内核会停止运行并将那个进程从CPU中卸载,再加载另一个应用程序并再运行100毫秒。通过这种方式使得每一个应用程序都不会连续运行超过100毫秒。

  1. exec 抽象了内存,实现了内存隔离的一部分

执行exec系统调用的时候,我们会传入一个文件名,而这个文件名在内存管理中对应了一个应用程序的内存镜像,该镜像中包括了程序对应的指令,全局的数据。

应用程序可以逐渐扩展自己的内存,但是应用程序并没有直接访问物理内存的权限,例如应用程序不能直接访问物理内存的1000-2000这段地址。

不能直接访问的原因是,操作系统提供了内存隔离并控制内存,操作系统会在应用程序和硬件资源之间提供一个中间层。exec系统调用就是该中间层的一部分,使得应用程序不能直接访问物理内存。

  1. files 抽象了磁盘,实现了内存隔离的一部分

应用程序不会直接读写挂在计算机上的磁盘本身,而是调用 files 对抽象磁盘进行操作后,由操作系统决定如何将文件与磁盘中的块对应,确保一个磁盘块只出现在一个文件中,并且确保用户A不能操作用户B的文件。

通过files的抽象,可以实现不同用户之间和同一个用户的不同进程之间的文件强隔离。

隔离性

硬件对隔离性的支持包括了两部分:

  1. user/kernel mode
  2. page table 或者虚拟内存(Virtual Memory)。

kernel mode 在RISC-V中被称为 Supervisor mode

普通/特殊权限指令

为了支持user/kernel mode,处理器会有两种操作模式:

  • user mode,当运行在该模式下时,CPU只能执行普通权限的指令
  • kernel mode,当运行在该模式下时,CPU可以执行特定权限的指令

普通权限的指令有,将两个寄存器相加的指令ADD、将两个寄存器相减的指令SUB、跳转指令JRCBRANCH指令等等。所有的应用程序都允许执行这些指令。

特殊权限指令是一些直接操纵硬件的指令和设置保护的指令,例如设置page table寄存器、关闭时钟中断。在处理器上有各种各样的状态,操作系统会使用这些状态,但是只能通过特殊权限指令来变更这些状态。

特殊情况:当一个应用程序尝试执行一条特殊权限指令,因为不允许在user mode执行特殊权限指令,处理器会拒绝执行这条指令。

通常来说,当一个应用程序尝试执行一条特殊权限指令,处理器会拒绝执行这条指令且将控制权限从user mode切换到kernel mode,当操作系统拿到控制权之后,会杀掉进程,因为应用程序执行了不该执行的指令。

检查指令是否在对应的空间内执行:使用处理器中的一个bit位作为标志位:

  • 1的时候是user mode
  • 0时是kernel mode

当处理器在解析指令时,如果指令是特殊权限指令,并且该bit被设置为1,处理器会拒绝执行这条指令

正确的在用户空间发起执行一条特殊权限指令的需求:通过系统调用,用户程序执行系统调用,会通过ECALL触发一个软中断;软中断会查询操作系统预先设定的中断向量表,并执行中断向量表中包含的中断处理程序。中断处理程序在内核中,这样就完成了user mode到kernel mode的切换,并执行用户程序想要执行的特殊权限指令。


page table与虚拟内存

处理器包含page table,而page table将虚拟内存地址与物理内存地址做了对应

每一个进程都有自己独立的page table,且每一个进程只能访问出现在自己page table中的物理内存。这意味着一个进程不能随意编造一个内存地址,然后通过这个内存地址来访问其他进程的物理内存。

操作系统会设置page table,使得每一个进程都有不重合的物理内存,这样一个进程就不能访问其他进程的物理内存,因为其他进程的物理内存都不在它的page table中。

换句话来说,page table定义了进程对于内存的视图,每一个用户进程都有自己对于内存的独立视图。比如,程序A有了一个内存地址0,程序B也有了一个内存地址0。但是操作系统会将这两个程序的内存地址0映射到不同的物理内存地址

综上,实现了内存的隔离性。

user/kernel mode的切换

需要有一种方式能够让应用程序可以将控制权转移给内核。

在RISC-V中,有一个专门的指令用来实现这个功能,叫做ECALL

  1. ECALL接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行ECALL指令,并传入一个数字(这里的数字参数代表了应用程序想要调用的System Call)
  2. ECALL会跳转到内核中一个特定,由内核控制的位置:在xv6中存在一个唯一的系统调用接入点,每一次应用程序执行ECALL指令,应用程序都会通过这个接入点进入到内核中。
  3. xv6有一个位于syscall.c的函数syscall,每一个从应用程序发起的系统调用都会调用到这个syscall函数,syscall函数会检查ECALL的参数,通过这个参数内核可以知道需要调用的是什么系统调用

操作系统检查应用程序是否可以进行系统调用:在内核侧实现对应某个系统调用的位置中可以加入任何的检查,例如检查系统调用的参数,并决定应用程序是否被允许执行该系统调用

决定是否允许执行对应系统调用的规则

在Unix中,任何应用程序都能调用fork;而write的实现需要检查传递给write的地址(需要写入数据的指针)是否属于对应的用户应用程序,若不属于则不允许执行write系统调用


宏内核(Monolithic Kernel):让整个操作系统代码都运行在kernel mode。大多数的Unix操作系统实现都运行在kernel mode,XV6也是这样

微内核(Micro Kernel):内核只有非常少的几个模块,比如一些IPC的实现或者是Message passing;非常少的虚拟内存的支持,可能只支持了page table;以及分时复用CPU的一些支持。

微内核情况下,需要让Shell能与文件系统交互,比如Shell调用了exec,必须有种方式可以接入到文件系统中。通常来说,是通过消息来实现系统调用:

Shell会通过内核中的IPC系统发送一条消息,内核会查看这条消息并发现这是给文件系统的消息,之后内核会把消息发送给文件系统。文件系统会完成它的工作之后会向IPC系统发送一条消息说,这是exec系统调用的结果,之后IPC系统再将这条消息发送给Shell。

  • 微内核对于文件系统的交互,都需要分别完成2次用户空间<->内核空间的跳转
  • 宏内核与文件系统交互,只需要完成1次用户空间<->内核空间的跳转

但宏内核出现Bug的可能性更大,因为内核中的代码量更多

XV6的启动流程

xv6项目结构来看各个部分的作用

image-20251127110340118

  • kernel 文件夹,里面包含了基本上所有的内核文件。因为XV6是一个宏内核结构,这里所有的文件会被编译成一个二进制文件 kernel,然后这个二进制文件会被运行在kernle mode中。
  • user 文件夹,编译的二进制文件基本上是运行在user mode的程序

image-20251127110508710

  • mkfs 文件夹,它会创建一个空的文件镜像,系统代码会将这个镜像存在磁盘上,这样就可以直接使用一个空的文件系统

编译过程

  1. 根目录下的 Makefile 会逐个读取C文件,例如从proc.c开始
  2. 调用gcc编译器,生成一个RISC-V 汇编语言文件 proc.s
  3. 调用汇编解释器,生成汇编语言的二进制格式 proc.o
  4. 系统加载器(Loader)会收集所有编译成功的 .o 文件,将它们链接在一起,生成内核文件

这里生成的内核文件就是在QEMU中运行的文件

Makefile 还另外创建了 kernel.asm,这里包含了内核的完整汇编语言,便于定位Bug


QEMU启动编译

在QEMU的主循环中,重复做的事情:

  1. 读取4字节或者8字节的RISC-V指令
  2. 解析RISC-V指令,并找出对应的操作码(op code)
  3. 在软件中执行相应的指令

QEMU的主循环需要维护寄存器的状态。所以QEMU会有以C语言声明的类似于X0, X1寄存器等等。

当QEMU仿真多个CPU核时,是在实际不同的CPU核上并行运算

make qemu指令执行解析

  • -kernel:这里传递的是内核文件(kernel目录下的kernel文件),这是将在QEMU中运行的程序文件。
  • -m:这里传递的是RISC-V虚拟机将会使用的内存数量
  • -smp:这里传递的是虚拟机可以使用的CPU核数
  • -drive:传递的是虚拟机使用的磁盘驱动,这里传入的是fs.img文件

xv6的启动流程

首先从链接阶段开始,内核空间的配置文件 kernel.ld

OUTPUT_ARCH( "riscv" )
ENTRY( _entry )

...

这表明程序的入口指令将是 _entry

xv6从 entry.s 开始启动,这个时候系统没有内存分页,没有隔离性,运行在 machine mode。xv6会尽可能快的跳转到 kernel mode

_entry 内部指令,在这里初始化栈后,跳转到 start()

_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

start() 函数内部,这将完成 Machine modekernel mode 的转换(设置寄存器bit位),并跳转到 main()函数

// 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");
}
  • w_mepc((uint64)main)main 函数的地址写入了程序计数器
  • 当执行到 asm volatile("mret"),该汇编指令是指从machine mode返回,同时将根据程序计数器中的地址继续执行

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

  • kinit:设置好页表分配器(page allocator)
  • kvminit:设置好虚拟内存
  • kvminithart:打开页表
  • processinit:设置好初始进程或者说设置好进程表单
  • trapinit/trapinithart:设置好user/kernel mode转换代码
  • plicinit/plicinithart:设置好中断控制器PLIC(Platform Level Interrupt Controller),这是用来与磁盘和console交互方式
  • binit:分配buffer cache
  • iinit:初始化inode缓存
  • fileinit:初始化文件系统
  • virtio_disk_init:初始化磁盘
  • userinit:当所有的设置都完成了,操作系统也运行起来了,会通过userinit运行第一个进程

展开看 userinit 函数内容

// a user program that calls exec("/init")
// assembled from ../user/initcode.S
// od -t xC ../user/initcode
uchar initcode[] = {
  0x17, 0x05, 0x00, 0x00, 0x13, 0x05, 0x45, 0x02,
  0x97, 0x05, 0x00, 0x00, 0x93, 0x85, 0x35, 0x02,
  0x93, 0x08, 0x70, 0x00, 0x73, 0x00, 0x00, 0x00,
  0x93, 0x08, 0x20, 0x00, 0x73, 0x00, 0x00, 0x00,
  0xef, 0xf0, 0x9f, 0xff, 0x2f, 0x69, 0x6e, 0x69,
  0x74, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00
};

// Set up first user process.
void userinit(void){
  struct proc *p;

  p = allocproc(); 
  initproc = p;
  
  // allocate one user page and copy initcode's instructions
  // and data into it.
  uvmfirst(p->pagetable, initcode, sizeof(initcode));
  p->sz = PGSIZE;

  // prepare for the very first "return" from kernel to user.
  p->trapframe->epc = 0;      // user program counter
  p->trapframe->sp = PGSIZE;  // user stack pointer

  safestrcpy(p->name, "initcode", sizeof(p->name));
  p->cwd = namei("/");

  p->state = RUNNABLE;

  release(&p->lock);
}

该函数启动了第一个进程 initcode

系统总是需要有一个用户进程在运行,这样才能实现用户与操作系统的交互,所以这里需要一个小程序来初始化第一个用户进程。

initcode 是使用二进制方式保存的代码,汇编代码如下

# 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
  • sys_exec 系统调用将执行传入的 path ,即 /init

sys_exec 系统调用内部会为参数分配空间,然后将参数从用户空间拷贝到内核空间

init 程序内容

// init: The initial user-level program

#include "kernel/types.h"
#include "kernel/stat.h"
#include "kernel/spinlock.h"
#include "kernel/sleeplock.h"
#include "kernel/fs.h"
#include "kernel/file.h"
#include "user/user.h"
#include "kernel/fcntl.h"

char *argv[] = { "sh", 0 };

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 会为用户空间设置好一些东西,比如配置好console,调用 fork,并在fork出的子进程中执行Shell,也就是 exec("sh", argv)

📑
目录