YangYuchen
xv6学习,Lab2-System calls

xv6学习,Lab2-System calls

二、Lab2: system calls

在完成实验前,先阅读Chapter 2,Sections 4.3 以及 4.4 of Chapter 4,这些章节主要说明操作系统的三个必备要求,即多路复用、隔离和交互(multiplexing, isolation, and interaction.)。下面对其中的一些重要内容进行总结:

  1. 抽象计算机的硬件资源

    由于应用程序之间的彼此不信任,由应用程序直接调用计算机底层硬件不现实,所以需要通过借助操作系统来调用底层硬件。比如,Unix应用程序仅通过文件系统的打开、读、写和关闭系统调用与存储器进行交互,而不是直接读取和写磁盘。这为应用程序提供了路径名的便利性,并允许操作系统(作为接口的实现者)来管理磁盘。Unix进程使用exec来构建它们的内存映像,而不是直接与物理内存交互。

  2. 用户模式、监督模式和系统调用(User mode, supervisor mode, and system calls)

    因为应用程序之间可能会相互影响,所以操作系统必须安排应用程序不能修改(甚至读取)操作系统的数据结构和指令,并且应用程序不能访问其他进程的内存,来使应用程序之间相互隔离。cpu为强隔离提供了硬件支持。例如,RISC-V有三种模式可以执行指令:机器模式、监督模式和用户模式。在机器模式下执行的指令具有完全的权限;在监督模式下,CPU被允许执行特权指令:例如,启用和禁用中断,读取和写保存页表地址的寄存器,等等。如果用户模式下的应用程序试图执行特权指令,那么CPU不会执行指令,而是切换到监督模式,以便监督模式代码可以终止应用程序。想要调用内核函数(例如,xv6中的system call)的应用程序必须转换到内核。CPU提供了一个特殊的指令,它将CPU从用户模式切换到监督模式,并在内核指定的入口点进入内核。

  3. 内核组织( Kernel organization)

    • 宏内核(monolithic kernel):整个操作系统都驻留在内核中,因此所有系统调用的实现都以监督模式运行。整个操作系统以完整的硬件特权运行。操作系统设计者不需要决定操作系统的哪一部分不需要完整的硬件特权。此外,操作系统的不同部分也更容易进行合作。例如,操作系统可能具有一个可以由文件系统和虚拟内存系统共享的缓冲区缓存。
    • 微内核(microkernel):为了减少内核中出错的风险,操作系统设计人员可以最小化在监督模式下运行的操作系统代码的数量,并在用户模式下执行大部分操作系统,这就是微内核。在微内核中,内核接口由一些低级功能组成,可用于启动应用程序、发送消息、访问设备硬件等。
  4. 进程概述( Process overview)

    • xv6中的隔离单元是一个进程process,进程抽象(process abstraction)可以防止一个进程破坏或监视另一个进程的内存、CPU、文件描述符等。它还可以防止进程破坏内核本身,因此进程就不能破坏内核的隔离机制。进程为程序提供了专用内存系统或地址空间,其他进程无法读写。一个进程还为程序提供了它自己的CPU来执行程序的指令。Xv6使用页表(由硬件实现)给每个进程提供自己的地址空间。RISC-V页表将映射虚拟地址(RISC-V指令操作的地址)为物理地址(CPU芯片发送到主存储器的地址)。

    • 官方文档用图片描述了进程的地址空间,一个地址空间从虚拟地址0开始,首先是用户的指令,全局变量首先是指令,然后是用户栈空间user stack,最后是一个“堆”区,malloc可以根据需要在这个堆区域中进行扩展。在地址空间的顶部,xv6预留了页面空间,将在第4章中解释。

    • xv6内核为每个进程维护许多状态片段,并将这些状态片段存在位于kernel/proc.h:86的结构体中,我在原有注释基础上查询资料做了补充,如下。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      enum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

      struct proc {
      struct spinlock lock; // 进程锁,用于保护进程的关键数据结构

      // p->lock must be held when using these:
      enum procstate state; // 进程控制块的状态,枚举类在上面
      void *chan; // 如果不为空,表示进程正在等待的通道
      int killed; // 如果为非零,表示进程已被杀死
      int xstate; // 退出状态,将返回给父进程的wait
      int pid; // 进程 ID

      // wait_lock must be held when using this:
      struct proc *parent; // 父进程指针,用于表示进程的父进程

      // these are private to the process, so p->lock need not be held.
      uint64 kstack; // 内核堆栈,进程从用户态进入内核态后使用,存储用户态的断点
      uint64 sz; // 进程内存大小(以字节为单位),从0开始
      pagetable_t pagetable; // 用户页表,用于管理进程的内存映射,进程和进程用户空间完全独立,用不同的页表映射到不同的物理内存上去,进程切换时页表也要切换
      struct trapframe *trapframe; // 陷阱帧,用于处理异常和中断,记录进程从用户态进入时的陷阱帧
      struct context context; // 进程上下文,在内核态下,用于切换进程时保存和恢复进程的状态,及存储原先进程的相关信息
      struct file *ofile[NOFILE]; // 进程打开的资源
      struct inode *cwd; // 当前进程的工作目录
      char name[16]; // 进程名称,用于调试和识别进程
      };

      简而言之,每个进程都有一个执行线程(简称线程),用来执行进程的指令。一个线程可以被挂起,然后再恢复。线程的大部分状态(局部变量、函数调用返回地址)都存储在线程的栈中。每个进程都有两个栈:一个用户栈和一个内核栈(p->kstack)。进程的线程将根据不同的指令和功能在用户堆栈和内核堆栈之间进行切换。

  5. 如何启动xv6的第一个进程?

    • 当RISC-V计算机开机时,它会初始化自己并运行一个存储在只读存储器中的引导加载器。引导加载程序将xv6内核加载到内存中。然后,在机器模式下,CPU从(kernel/entry.S:6)开始执行xv6。开始时禁用了分页硬件:虚拟地址直接映射到物理地址。

    • xv6系统是如何编译的?首先,Makefile会读取系统的每一个c语言文件,通过gcc编译器生成拓展名为s的riscv汇编语言文件,之后使用汇编语言编译器,生成拓展名为o的汇编语言二进制格式。最后,系统加载器loader会收集所有拓展名为o的汇编文件并将它们链接在一起。kernel文件夹下的文件就对应内核模式,user文件夹下的就对应用户模式。Makrfile还会创建kernel.asm,它将包含内核完整的汇编语言。

    • xv6在entry.S中的代码设置了栈 (这部分内容为汇编语言)这样就可以运行C代码 。文件start.c(kernel/start.c:11)中声明了初始栈的空间,即下方的代码块。在_entry处的代码加载栈指针寄存器 sp,地址为 stack0+4096,也就是栈的顶部,因为 RISC-V 的栈是向下扩张的。现在内核就拥有了栈,entry 调用start(kernel/start.c:21)并执行。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      # entry.S needs one stack per CPU.
      __attribute__ ((aligned (16))) char stack0[4096 * NCPU];

      # kernel/entry.S
      # 这部分为汇编语言,自带的注释说明了功能
      # 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
    • 函数 start 执行一些只有在机器模式下才允许的配置,然后切换到监督者模式。为进入监督者模式,RISC-V 提供了指令 mret

      This instruction is most often used to return from a previous call from supervisor mode to machine mode. start isn’t returning from such a call, and instead sets things up as if there had been one: it sets the previous privilege mode to supervisor in the register mstatus, it sets the return address to main by writing main’s address into the register mepc, disables virtual address translation in supervisor mode by writing 0 into the page-table register satp, and delegates all interrupts and exceptions to supervisor mode.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      // kernel/start.c中的start函数
      // entry.S在stack0上以机器模式跳转到此处
      // 这部分代码的注释不全面也并不准确
      void
      start()
      {
      // 这段代码读取当前机器状态寄存器 mstatus 的值,并将其与MSTATUS_MPP_MASK取反后进行与操作,得到新的 mstatus 值。 // 将新的 mstatus 值写入机器状态寄存器,将处理器模式设置为 Supervisor 模式
      unsigned long x = r_mstatus();
      x &= ~MSTATUS_MPP_MASK;
      x |= MSTATUS_MPP_S;
      w_mstatus(x);
      w_mepc((uint64)main);
      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();
      int id = r_mhartid();
      w_tp(id);
      // 切换到supervisor模式并跳转到main函数
      asm volatile("mret");
      }
    • 从start.c跳转至main.c,在main函数中,xv6对内存模块,进程模块以及文件系统等进行了初始化,之后进入scheduler函数,调度用户进程。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      // kernel/main.c中的main函数
      // start() jumps here in supervisor mode on all CPUs.
      void
      main()
      {
      // 通过cpuid函数获取当前CPU核的ID,如果当前CPU核ID为0(主CPU核),则执行以下初始化操作
      if(cpuid() == 0){
      consoleinit(); // 初始化控制台
      printfinit(); // 初始化print语句
      // 这是在shell中看到的打印的语句
      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;
      }
      // 当前CPU核的ID不为0的情况
      else {
      while(started == 0) // 如果started还是0,就一直循环,直到被主核设置为1
      ;
      __sync_synchronize();
      printf("hart %d starting\n", cpuid());
      kvminithart(); // turn on paging
      trapinithart(); // install kernel trap vector
      plicinithart(); // ask PLIC for device interrupts
      }
      //调用 scheduler 函数来启动进程调度。
      scheduler();
      }
    • main还调用了用户内部化程序(kernel/proc.c:233)来创建第一个进程,具体代码如下。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      // kernel/proc.c:233
      // 设置第一个用户进程
      void
      userinit(void)
      {
      struct proc *p; // 定义一个进程结构体指针p并分配
      p = allocproc();
      initproc = p; // 将分配的进程结构体指针赋值给initproc
      // 分配一个用户页面,并将 initcode 的指令和数据复制到其中
      uvmfirst(p->pagetable, initcode, sizeof(initcode));
      p->sz = PGSIZE; // 设置进程的大小为 PGSIZE(页面大小)
      // 为从内核返回用户的第一次“返回”做准备
      p->trapframe->epc = 0; //设置进程的trapframe中的epc(程序计数器)为 0
      p->trapframe->sp = PGSIZE; // 设置进程的trapframe中的sp(栈指针)为 PGSIZE
      safestrcpy(p->name, "initcode", sizeof(p->name)); // 安全地复制字符串initcode到进程的name字段
      p->cwd = namei("/"); // 设置进程的工作目录为根目录
      p->state = RUNNABLE; // 设置进程的状态为 RUNNABLE(可运行)
      release(&p->lock); // 释放进程的锁
      }
    • 第一个进程执行一个用 RISC-V 汇编编写的程序initcode.S(user/initcode.S:1),它通过调用 exec 系统调用重新进入内核。正如我们在第一章中所看到的,exec 用一个新的程序(本例中是/init)替换当前进程的内存和寄存器。一旦内核完成 exec,它就会在/init 进程中返回到用户空间。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      # user/initcode.S,主要实现了用户空间的初始进程/init的执行。它设置了程序的名称和参数,执行exec系统调用,将控制权传递给内核来启动新程序
      #include "syscall.h"

      exec(init, argv)
      .globl start
      start:
      la a0, init
      la a1, argv
      li a7, SYS_exec
      ecall
      # 将/init的地址加载到 a0 寄存器
      # 将argv的地址加载到 a1 寄存器
      # 将系统调用号SYS_exec加载到 a7 寄存器
      # 执行系统调用

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

      # 将系统调用号 SYS_exit 加载到 a7 寄存器
      # 执行系统调用
      # 跳转到 exit 标签继续执行

      # char init[] = "/init\0";
      init:
      .string "/init\0"
      # 定义字符数组 init

      # char *argv[] = { init, 0 };
      .p2align 2
      argv:
      .long init
      .long 0
      # 定义字符指针数组 argv,包含 init 和 0
      # 将 init 的地址加载到第一个 long 型元素
      # 将 0 加载到第二个 long 型元素,作为结束标志
    • 最后,init (user/init.c:15)在需要时会创建一个新的控制台设备文件,然后以文件描述符 0、1 和 2 的形式打开它。然后它在控制台上启动一个 shell。这样系统就启动了,在控制台中看到了熟悉的init: starting sh

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      // user/init.c:15
      char *argv[] = { "sh", 0 }; //存储启动 shell 程序的命令行参数

      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(;;){ // 循环执行,不断创建新的进程来执行 shell 程序
      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.
      }
      }
      }
      }
    • 一个进程可以通过执行RISC-V ecall指令来进行系统调用。此指令提高了硬件特权级别,并将程序计数器更改为一个由内核定义的入口点。入口点上的代码切换到一个内核堆栈,并执行实现系统调用的内核指令。当系统调用完成时,内核切换回用户堆栈,并通过调用sret指令返回到用户空间,这降低了硬件特权级别,并在系统调用指令之后立即恢复执行用户指令。进程的线程可以在内核中“阻塞”以等待I/O,并在I/O完成后恢复到它停止的位置。p->state 指示进程是否已分配、准备运行、运行、等待I/O或退出。p->pagetable 保存为RISC-V硬件期望的格式。总的来说,通过分配地址空间让进程有了自己的“内存”,通过线程让进程有了自己的“CPU”,以实现隔离。

  6. 安全模型(Security Model)

    操作系统必须假定进程的用户级代码将尽力破坏内核或其他进程,试图做出一些超出其权限的操作。内核的目标是限制每个用户进程,以便它所能做的就是读/写/执行它自己的用户内存,使用32个通用的RISC-V寄存器,并以系统调用所允许的方式影响内核和其他进程。内核必须阻止任何其他操作,这通常是内核设计中的一个绝对要求。我们希望内核代码是没有漏洞的,当然也不包含任何恶意的内容。当然实际中无法达到这样的理想情况,通常不可能编写无漏洞的代码或设计无漏洞的硬件。因此,值得在内核中设计保护措施,以尽可能防止存在bug。

  7. 实际情况(Real world)

    大多数操作系统都采用了进程的概念,而且大多数进程都看起来与xv6中的相似。然而,现代操作系统支持一个进程中的多个线程,以允许单个进程利用多个cpu。在一个进程中支持多个线程涉及到xv6没有的大量机制,包括潜在的接口更改(例如,Linux的克隆,fork的变体),以控制进程线程共享的哪些方面。

  8. 系统调用(4.3 Code: Calling system calls)

    exec调用如何实现?

    在riscv版本的xv6操作系统中,如果一个应用程序需要调用内核模式中的操作,需要使用ecall+系统调用号。内核模式的这一部分内容位于syscall.c中,它从a0中读取到系统调用号,并根据不同的系统调用号执行不同的操作。这样就实现了用户模式和内核模式的强隔离。

    initcode.S将exec的参数放在寄存器a0和a1中,并将系统调用号放在a7中。系统调用号与syscall数组中的条目匹配(一个函数指针表,位于kernel/syscall.c:107)。ecall 指令进入内核,执行uservec、usertrap,然后执行syscall。

    syscall(kernel/syscall.c:132)从陷阱帧 (trapframe)中保存的a7中检索系统调用编号(system call number),并保存它的index到syscalls中。

    当sys_exec返回时,syscall记录其返回值到p->trapframe->a0中。这会导致原始的用户空间执行调用(exec)返回该值,因为 C 语言中 RISC-V 的调用规范将返回值放在 a0 中,也就是在栈上传递返回值。如果系统调用的编号无效,系统调用会打印一个错误并返回−1。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // kernel/syscall.h中部分代码
    // System call numbers
    #define SYS_fork 1
    #define SYS_exit 2
    #define SYS_wait 3
    #define SYS_pipe 4
    #define SYS_read 5
    #define SYS_kill 6
    #define SYS_exec 7
    #define SYS_fstat 8
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    // kernel/syscall.c
    // 从当前进程的地址处获取一个uint64类型的数据
    int fetchaddr(uint64 addr, uint64 *ip)
    {
    // 获取当前进程结构体指针
    struct proc *p = myproc();
    // 检查地址是否超出进程空间大小或溢出
    if (addr >= p->sz || addr + sizeof(uint64) > p->sz) {
    return -1;
    }
    // 使用 copyin 函数将进程页表中的数据复制到指定地址
    if (copyin(p->pagetable, (char *)ip, addr, sizeof(*ip))!= 0) {
    return -1;
    }
    return 0;
    }

    // 从当前进程的地址处获取一个以空字符结尾的字符串
    int fetchstr(uint64 addr, char *buf, int max)
    {
    // 获取当前进程结构体指针
    struct proc *p = myproc();
    // 使用 copyinstr 函数将进程页表中的字符串复制到指定缓冲区
    if (copyinstr(p->pagetable, buf, addr, max) < 0) {
    return -1;
    }
    // 返回字符串的长度(不包括空字符)
    return strlen(buf);
    }

    // 获取系统调用的第n个参数
    static uint64 argraw(int n)
    {
    // 获取当前进程结构体指针
    struct proc *p = myproc();
    // 通过 switch 语句根据参数索引获取trapframe中的相应参数值
    switch (n) {
    case 0:
    return p->trapframe->a0;
    case 1:
    return p->trapframe->a1;
    case 2:
    return p->trapframe->a2;
    case 3:
    return p->trapframe->a3;
    case 4:
    return p->trapframe->a4;
    case 5:
    return p->trapframe->a5;
    default:
    // 如果参数索引超出范围,则触发 panic
    panic("argraw");
    return -1;
    }
    }
    // 将系统调用的第n个参数作为 32 位整数进行获取
    void argint(int n, int *ip)
    {
    // 获取原始参数值并将其存储在指定的指针位置
    *ip = argraw(n);
    }
    // 将系统调用的第n个参数作为指针进行获取
    void argaddr(int n, uint64 *ip)
    {
    // 获取原始参数值并将其存储在指定的指针位置
    *ip = argraw(n);
    }

    // 将系统调用的第n个参数作为以空字符结尾的字符串进行获取
    int argstr(int n, char *buf, int max)
    {
    uint64 addr;
    // 获取原始参数值作为地址
    argaddr(n, &addr);
    // 使用获取的地址获取字符串
    return fetchstr(addr, buf, max);
    }

    // 用于处理系统调用的函数原型声明(部分)
    extern uint64 sys_fork(void);
    extern uint64 sys_exit(void);
    extern uint64 sys_wait(void);
    extern uint64 sys_pipe(void);
    extern uint64 sys_read(void);

    // 系统调用函数指针数组(部分)
    static uint64 (*syscalls[])(void) = {
    [SYS_fork] sys_fork,
    [SYS_exit] sys_exit,
    [SYS_wait] sys_wait,
    [SYS_pipe] sys_pipe,
    [SYS_read] sys_read,
    };

    // 系统调用入口函数
    void syscall(void)
    {
    int num;
    struct proc *p = myproc();
    // 获取系统调用号
    num = p->trapframe->a7;
    // 如果系统调用号有效,且对应函数存在,则执行相应函数
    if (num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    // 使用系统调用号查找并执行对应的系统调用函数
    p->trapframe->a0 = syscalls[num]();
    } else {
    // 打印错误信息,表示未知的系统调用号
    printf("%d %s: unknown sys call %d\n",
    p->pid, p->name, num);
    p->trapframe->a0 = -1;
    }
    }
  9. 系统调用参数(Chapter4.4 Code: System call arguments)

    内核中的系统调用实现需要查找从用户空间传递的参数。用户代码执行系统调用包装函数,其参数是由 RISC-V调用约定放置在寄存器中的。内核代码将用户寄存器保存到当前进程的陷阱帧中。内核函数 argint、argaddr 和 argfd 从陷阱帧中检索第 n 个系统调用参数,并将其转换为整数、指针或文件描述符。它们都调用 argraw 来检索相应的已保存的用户寄存器(kernel/syscall.c:34)。

    有些系统调用将指针作为参数传递进来,内核必须使用这些指针来读写用户内存。例如,exec 系统调用会给内核传递一个指针数组,该数组指向用户空间中的字符串参数。这些指针带来了两个问题,第一,用户程序可能是有缺陷的或恶意的,它可能会给内核传递一个无效的指针或试图诱使内核访问内核内存而不是用户内存的指针。第二,xv6 内核的页表映射与用户的页表映射不同,因此内核无法使用普通指令从用户提供的地址加载或存储数据。

    内核实现了一些函数,用于安全地在用户提供的地址之间传输数据。fetchstr 就是一个例子(见 kernel/syscall.c:25)。像 exec 这样的文件系统调用会使用 fetchstr 从用户空间获取字符串文件名参数。fetchstr 会调用 copyinstr 来完成这项艰巨的任务。

    copyinstr(位于 kernel/vm.c 中的第 403 行)会从用户页表pagetable中虚拟地址srcva开始,最多将max字节的数据复制到dst。因为pagetable不是当前页表,所以copyinstr使用walkaddr(它会调用walk)在pagetable中查询srcva,从而得到物理地址pa0。内核会将每个物理内存地址映射到对应的内核虚拟地址,所以copyinstr可以直接将字符串字节从pa0复制到dstwalkaddr(位于 kernel/vm.c 中的第 109 行)会检查用户提供的虚拟地址是否属于该进程的用户地址空间,这样程序就无法欺骗内核去读取其他内存了。类似的函数copyout则是将数据从内核复制到用户提供的地址。

1. Using gdb ()

2. System call tracing ()

In this assignment you will add a system call tracing feature that may help you when debugging later labs. You’ll create a new trace system call that will control tracing. It should take one argument, an integer “mask”, whose bits specify which system calls to trace. For example, to trace the fork system call, a program calls trace(1 << SYS_fork), where SYS_fork is a syscall number from kernel/syscall.h. You have to modify the xv6 kernel to print out a line when each system call is about to return, if the system call’s number is set in the mask. The line should contain the process id, the name of the system call and the return value; you don’t need to print the system call arguments. The trace system call should enable tracing for the process that calls it and any children that it subsequently forks, but should not affect other processes.

简而言之,就是完成一个系统调用trace来追踪系统调用,并打印需要追踪调用的信息(进程编号,名称及返回值)。它接收一个整数”mask”,代表了要追踪的系统调用。比如题目中样例给的trace 32 grep hello README ,就是跟踪grep hello README执行时的read。以下为根据提示进行实验的详细步骤。

  • Makefile中添加$U/_trace

  • user/user.h 中为系统调用添加原型(prototype),在 user/usys.pl 中添加stub,在 kernel/syscall.h 中添加系统调用编号。

    1
    2
    3
    4
    5
    6
    7
    8
    // user/user.h
    int trace(int);

    // user/usys.pl
    entry("trace");

    // kernel/syscall.h
    #define SYS_trace 22
  • 构建系统也会调用 user/usys.pl 这个 Perl 脚本,它会生成 user/usys.S,用于提供具体的系统调用存根实现,此实现会使用 RISC-V 架构的 ecall 指令来完成到内核的切换。在完成了这些编译问题的修正后,运行 trace 32 grep hello README 命令,这个命令将会运行失败,因为尚未在内核中实现这个系统调用。

    image.png
  • 从用户空间检索系统调用参数的函数位于 kernel/syscall.c 中, 以下为它们的使用示例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    // kernel/sysproc.c 中的部分代码示例,省略了头文件
    // 这些系统调用在内核空间执行,执行完毕后再返回用户空间

    // 退出进程的系统调用
    uint64
    sys_exit(void)
    {
    int n;
    // 从p->trapframe->a0获取用户提供的要退出进程
    argint(0, &n);
    // 调用 exit 函数结束指定进程
    exit(n);
    return 0;
    }

    // 等待子进程结束的系统调用
    uint64
    sys_wait(void)
    {
    uint64 p;
    // 从p->trapframe->a0获取用户空间保存的子进程的标识符
    argaddr(0, &p);
    return wait(p);
    }

    // 调整进程空间大小的系统调用
    uint64
    sys_sbrk(void)
    {
    int n;
    // 解析用户传入的参数
    argint(0, &n);
    // 获取当前进程的空间地址
    uint64 addr = myproc()->sz;
    // 尝试动态调整空间大小,如果调整失败则返回错误码
    if(growproc(n) < 0)
    return -1;
    return addr;
    }
  • 在 proc 结构(kernel/proc.h)中定义新变量中记住其参数来实现trace系统调用。

    1
    2
    // kernel/proc.h
    uint mask; // Trace mask
  • 根据上文kernel/sysproc.c 中的代码示例,添加一个 sys_trace()函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // kernel/sysproc.c

    uint
    sys_trace(void) {
    int tracemask;
    argint(0, &tracemask);
    struct proc* p = myproc();
    p->mask = tracemask;
    return 0;
    }
  • 修改 fork()函数( kernel/proc.c),以将追踪掩码mask从父进程复制到子进程。

    1
    2
    3
    4
    // kernel/proc.C

    // 在使用fork创建子进程后,子进程获取mask
    np->mask = p->mask;
  • 修改kernel/syscall.c 中的 syscall() 函数,以打印跟踪输出。除此之外还需要添加一个系统调用名称的数组来进行索引。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    // kernel/syscall.c
    extern uint64 sys_trace(void);

    static uint64 (*syscalls[])(void) = {
    // 省略
    [SYS_close] sys_close,
    [SYS_trace] sys_trace,
    };

    // 这只是一个系统调用名的数组,用于打印系统调用的名称
    char *syscallnames[23] = {
    // 省略
    "close",
    "trace"
    };


    void
    syscall(void)
    {
    int num;
    struct proc *p = myproc();

    num = p->trapframe->a7;
    if(num > 0 && num < NELEM(syscalls) && syscalls[num]) { // 系统调用编号正确
    p->trapframe->a0 = syscalls[num](); // 调用并将返回值存到用户进程的 a0 寄存器中
    // 对该编号系统调用的进行trace
    if((p->mask >> num) & 1) {
    // 打印对应的pid、系统调用名称和返回值
    printf("%d: syscall %s -> %d\n",p->pid, syscallnames[num], p->trapframe->a0);
    }
    } else {
    printf("%d %s: unknown sys call %d\n",
    p->pid, p->name, num);
    p->trapframe->a0 = -1;
    }
    }
  • image.png

3. Sysinfo ()

In this assignment you will add a system call, sysinfo, that collects information about the running system. The system call takes one argument: a pointer to a struct sysinfo (see kernel/sysinfo.h). The kernel should fill out the fields of this struct: the freemem field should be set to the number of bytes of free memory, and the nproc field should be set to the number of processes whose state is not UNUSED. We provide a test program sysinfotest; you pass this assignment if it prints “sysinfotest: OK”.

新增一个系统调用sysinfo用来收集运行系统的信息。此系统调用接受一个参数:指向 struct sysinfo 的指针(kernel/sysinfo.h)。内核应该填写此结构体的字段:freemem 字段应该设为系统空闲内存的字节数,nproc 字段应该设为进程状态不为 UNUSED 的进程数量。题目规定,在执行此 sysinfo 系统调用之后,获取到当前系统的两项状态信息,其一为剩余可用空间的数量,其二为当前标记为 UNUSED 状态的进程数量。这两项状态信息需通过一个预先设定好的结构体来进行传递。根据提示,按照以下步骤完成实验。

  • 在Makefile中添加$U/_sysinfotest

  • 运行 make qemuuser/sysinfotest.c 文件将无法编译。按照之前的任务一样添加系统调用 sysinfo。在 user/user.h 中声明 sysinfo() 的原型时,需要预声明 struct sysinfo 的存在。同时像完成Trace一样,进行声明和定义。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // user/user.h

    struct sysinfo;
    int sysinfo(struct sysinfo *);

    // user/usys.pl
    entry("sysinfo");

    // kernel/syscall.h
    #define SYS_sysinfo 23

    // 除此之外还要更改syscall.c的数组
  • sysinfo 需要将一个 struct sysinfo 结构体复制回用户空间;可以参考 sys_fstat()kernel/sysfile.c)和 filestat()kernel/file.c),了解如何使用 copyout() 进行复制。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // kernel/file.c

    // 定义一个函数 filestat,用于获取文件的元数据
    // addr 是用户虚拟地址,指向一个 struct stat
    int
    filestat(struct file *f, uint64 addr)
    {
    // 获取当前进程的指针 p
    struct proc *p = myproc();
    struct stat st;
    // 如果文件的类型是 FD_INODE 或 FD_DEVICE
    if (f->type == FD_INODE || f->type == FD_DEVICE) {
    // 调用 ilock 函数锁定文件的 inode
    ilock(f->ip);
    // 调用 stati 函数获取文件的状态信息,并将结果存储在 st 中
    stati(f->ip, &st);
    // 调用 iunlock 函数解锁文件的 inode
    iunlock(f->ip);
    // 通过 copyout 函数将 st 的内容复制到用户空间的 addr 地址
    if (copyout(p->pagetable, addr, (char *)&st, sizeof(st)) < 0)
    return -1;
    return 0;
    }
    return -1;
    }
  • 为了收集空闲内存的数量,在 kernel/kalloc.c 中添加一个函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    // kernel/kalloc.c

    void *
    kalloc(void) // 用于分配内核内存
    {
    struct run *r; // run是一个单链表
    acquire(&kmem.lock); // 申请并获取 kmem.lock
    r = kmem.freelist; // 将 kmem.freelist 的值赋给 r,表示获取空闲内存列表的头部
    if(r) // 如果 r 不为 NULL,表示成功获取到内核内存
    kmem.freelist = r->next; // 将 kmem.freelist 的头指针指向下一个空闲内存块
    release(&kmem.lock); // 释放 kmem.lock 信号量
    if(r) // 如果成功申请到了内核内存
    memset((char*)r, 5, PGSIZE); // 将申请到的内核内存填充为 5
    return (void*)r; // 返回申请到的内核内存的指针
    }

    // 参考kalloc函数完成,返回空闲内存大小
    uint64
    freesize(void){
    struct run * n;
    int num = 0;
    // 申请并获取 kmem.lock
    acquire(&kmem.lock);
    n = kmem.freelist;
    while(n){
    n = n -> next;
    num++;
    }
    // 释放 kmem.lock 信号量
    release(&kmem.lock);
    // 计算空闲内存大小
    int totalSize = num * PGSIZE;
    return totalSize;
    }
  • 为了收集进程的数量,在 kernel/proc.c 中添加一个函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // kernel/proc.c
    // 参考proc.c中的procdump函数

    uint64
    freenum(void){
    struct proc *p;
    int procnum = 0;
    // 遍历进程,对进程状态为UNUSED的进行计数
    for(p = proc; p < &proc[NPROC]; p++){
    if(p->state == UNUSED){
    procnum++;
    }
    }
    return procnum;
    }
  • kernel/sysproc.c 中完成 sysinfo 的系统调用函数。根据提示,需要调用上面添加的两个函数以对sysinfo结构体进行赋值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // kernel/sysproc.c

    // 补充头文件 获取sysinfo结构体
    #include "sysinfo.h"

    uint64
    sys_sysinfo(void)
    {
    uint64 addr;
    struct sysinfo sysInfo;
    struct proc *p = myproc();
    argaddr(0,&addr);
    // 调用上面完成的两个方法获取空闲内存大小和进程数量
    sysInfo.freemem = freesize();
    sysInfo.nproc = freenum();
    // 参考 kernel/file.c 的filestat方法,返回结构体到用户空间
    if(copyout(p->pagetable,addr,(char *)&sysInfo,sizeof(sysInfo))<0)
    return -1;
    return 0;
    }
  • 各种报错,第一次由于忘了把函数在defs.h中定义,第二次由于p->state == UNUSED,应该是不为UNUSED。修改后成功通过批阅。

    image.png
  • 总的来说,本章学习了操作系统实现强隔离的原因及方式,了解了xv6操作系统的用户空间和内核空间,阅读了xv6系统kernel和user中有关第一个进程启动的相关源码,了解了系统的启动过程,以及在用户空间中如何完成系统调用。实验中完成了两个系统调用,第一章的实验是在用户空间中实现,意在了解学习操作系统的功能,本章实验在内核空间实现,学习了内核空间实现系统调用的具体过程。

本文作者:YangYuchen
本文链接:https://www.littlewhite.site/xv6学习,Lab2-240711/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可