MIT6.s081/6.828 lectrue2:OS design 以及 Lab2 心得

发布时间 2023-08-10 17:19:20作者: byFMH

这节课主要介绍 OS 的顶层设计以及 OS 启动流程和系统调用流程

前置知识:要求阅读 xv6 book chapter2 和 xv6 源码:

kernel/proc.h, kernel/defs.h, kernel/entry.S, kernel/main.c, user/initcode.S, user/init.c, and skim kernel/proc.c and kernel/exec.c

一、课程内容

课程主要讨论4个话题

  • Isolation。隔离性是设计操作系统组织结构的驱动力。

  • Kernel mode 和 User mode。这两种模式用来隔离操作系统内核用户程序

  • System calls。系统调用是应用程序能够转换到内核执行的基本方法,利用系统调用,用户态应用程序才能使用内核服务

  • 最后我们会看到所有的这些是如何以一种简单的方式在XV6中实现

1 操作系统隔离性(isolation)

隔离性体现在两个方面

  1. 不同的应用程序之间有强隔离性。我们在用户空间有多个应用程序,例如Shell,echo,find。但是,如果你通过Shell运行你的 Primes 代码(lab1中的作业)时,假设你的代码出现了问题,不应该影响到其他的应用程序

  2. 应用程序和操作系统之间有强隔离性,操作系统某种程度上为所有的应用程序服务。当你的应用程序出现问题时,你会希望操作系统不会因此而崩溃。

这里我们可以这样想,如果没有操作系统会怎样?

  1. 应用程序的角度:失去 OS,应用程序会直接与硬件交互。比如,应用程序可以直接看到CPU的多个核,看到磁盘,内存。应用程序和硬件资源之间没有一个额外的抽象层
  2. CPU 角度:失去 OS,无法分时复用使用操作系统的一个重要目的是为了同时运行多个应用程序,所以时不时的,CPU会从一个应用程序切换到另一个应用程序。我们假设硬件资源里只有一个CPU核,并且我们现在在这个CPU核上运行Shell。但是现在我们没有操作系统来帮我们完成切换,所以Shell就需要时不时的释放CPU资源。如果说Shell中的某个函数有一个死循环,那么Shell永远也不会释放CPU,进而其他的应用程序也不能够运行,甚至都不能运行一个第三方的程序来停止或者杀死Shell程序
  3. 内存角度:失去 OS,无法内存隔离。从内存的角度来说,如果应用程序直接运行在硬件资源之上,那么每个应用程序的文本,代码和数据都直接保存在物理内存中。物理内存中的一部分被Shell使用,另一部分被echo使用。即使在这么简单的例子中,因为两个应用程序的内存之间没有边界,如果echo程序将数据存储在属于Shell的一个内存地址中,那么就echo就会覆盖Shell程序内存中的内容。

使用操作系统的一个原因,甚至可以说是主要原因就是为了实现 CPU复用 和 内存隔离。

有意思的知识:实时操作系统

将操作系统设计成一个库,比如要使用 OS 的功能,就 import os,这并不是一种常见的设计。你或许可以在一些实时操作系统中看到这样的设计,因为在这些实时操作系统中,应用程序之间彼此相互信任。但是在大部分的其他操作系统中,都会强制实现硬件资源的隔离。

醍醐灌顶的观点:

  1. 进程抽象了物理上的 CPU (的核),操作系统不是直接将CPU提供给应用程序,而是向应用程序提供“进程”,操作系统内核会完成不同进程在CPU上的切换。OS 就是进程池(管理进程状态,创建和销毁进程)
  2. exec 抽象了物理上的内存。当我们在执行exec系统调用的时候,我们会传入一个文件名,而这个文件名对应了一个应用程序的内存镜像。内存镜像里面包括了程序对应的指令,全局的数据。应用程序可以逐渐扩展自己的内存,但是应用程序并没有直接访问物理内存的权限
  3. files 来说抽象了物理上的磁盘。应用程序不会直接读写挂在计算机上的磁盘本身,并且在Unix中这也是不被允许的。在Unix中,与存储系统交互的唯一方式就是通过files。Files提供了非常方便的磁盘抽象,你可以对文件命名,读写文件等等。

2 如何实现这两种隔离性

通常来说,需要通过硬件来实现强隔离性

这里的硬件支持包括了两部分:

  1. 为了实现 OS 和用户程序的隔离,需要 user / kernel mode,kernel mode在RISC-V中被称为Supervisor mode但是其实是同一个东西;这需要 CPU 的一个 mode bit 支持

  2. 为了实现用户程序之间的隔离,需要虚拟内存(Virtual Memory)。这需要 CPU 的 MMU 硬件支持

但总的来说,虚拟内存 和 user / kernel mode 都是操作系统和CPU硬件的合作结果

一个处理器如果需要运行能够支持多个应用程序操作系统需要同时支持 user/kernle mode 和虚拟内存实现强隔离性。我们在这门课中使用的RISC-V处理器(模拟器 qemu)就支持了这些功能。

3 详解 CPU 的硬件隔离

3.1 CPU 的用户态/内核态

CPU 会有两种操作模式,第一种是user mode,第二种是 kernel mode。CPU 里面有一个flag(mode bit),占处理器的一个bit,当它为 1 的时候是user mode,当它为 0 时是kernel mode。

  1. 当运行在 kernel mode 时,CPU 可以运行特定权限的指令(privileged instructions);操作系统内核在 kernel mode 下运行
  2. 当运行在 user mode 时,CPU 只能运行普通权限的指令(unprivileged instructions);用户应用程序在 user mode 下运行

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

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

额外小知识:

BIOS是一段计算机自带的代码,它会先启动,之后它再启动操作系统,所以BIOS需要是一段可被信任的代码,它最好是正确的,且不是恶意的。

系统调用时发生的控制转移:

第一节已经讲过 Shell 程序会调用fork或者exec系统调用,所以 OS 需要提供一种方式能够让应用程序可以将控制权转移给内核(Entering Kernel)。

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

ECALL接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行ECALL指令,并传入一个数字。这里的数字参数代表了应用程序想要调用的System Call。

举例:不论是Shell还是其他的应用程序,当它在用户空间执行 fork 这个系统调用时,它并不是直接调用操作系统中实现 fork 功能的代码,fork 系统调用函数会执行ECALL指令,并将fork对应的数字作为参数传给ECALL。之后再通过ECALL跳转到内核。

在xv6 中,有一个位于syscall.c的函数syscall,每一个从应用程序发起的系统调用都会调用到这个syscall函数,syscall函数会检查ECALL的参数,通过这个参数内核可以知道需要调用的是fork

进程从用户模式变成内核模式的唯一方法是通过终端、故障或者系统调用

​ -----摘自 CSAPP

3.2 虚拟内存(会在后面的章节着重讲解,这里先一笔带过了)

详细讲解:

MMU是CPU中的一个硬件单元,MMU 使用 page table 完成 虚拟地址 和 物理地址 之间的转换。

每一个进程都会有自己独立的page table,这样的话,每一个进程只能访问出现在自己 page table 中的物理内存操作系统负责维护page table,使得每一个进程都有不重合的物理内存

4 宏内核 vs 微内核

整个操作系统代码都运行在kernel mode。大多数的Unix操作系统实现都运行在kernel mode。比如,XV6中,所有的操作系统服务都在kernel mode中,这种形式被称为Monolithic Kernel Design(宏内核)。

减少内核中的代码,它被称为Micro Kernel Design(微内核)。在这种模式下,希望在kernel mode中运行尽可能少的代码。所以这种设计下还是有内核,但是内核只有非常少的几个模块,例如,内核通常会有一些IPC的实现或者是Message passing;非常少的虚拟内存的支持,可能只支持了page table;以及分时复用CPU的一些支持。

5 QEMU 不只是 CPU 模拟器

QEMU表现的就像一个真正的计算机一样。当你想到QEMU时,你不应该认为它是一个C程序,你应该把它想成是下图,一个真正的主板。(有 CPU,还有内存IO 等各种外设)

image-20230724183503164

图中是一个 RISC-V主板,它可以启动一个XV6。当你通过QEMU来运行你的 OS 时,你应该认为你的 OS 是运行在这样一个主板之上。

直观来看,QEMU是一个大型的开源C程序,你可以下载或者git clone它。但是在内部,在QEMU的主循环中,只在做一件事情:

  • 读取4字节或者8字节的RISC-V指令。

  • 解析RISC-V指令,并找出对应的操作码(op code)。我们之前在看kernel.asm的时候,看过一些操作码的二进制版本。通过解析,或许可以知道这是一个ADD指令,或者是一个SUB指令。

  • 之后,在软件中执行相应的指令

这基本上就是QEMU的全部工作了,对于每个CPU核,QEMU都会运行这么一个循环

6 本节重头戏:xv6启动流程

RISC-V有三种CPU执行指令的模式:机器模式、内核模式、用户模式

RISC-V计算机启动流程:

   1.  主板上电,启动位于 ROM 上的 BIOS
   2.  BIOS 完成硬件自检程序POST(Power-On Self Test)
   3.  BIOS 读取MBR(主引导记录), 并执行其中的 **boot loader** 代码。BIOS将**启动控制权移交给boot loader**。
   4.  boot loader **把 xv6 kernel 加载到内存中**的 0x8000000 处(0x80000000 此时是物理地址,因为RISC-V是在禁用分页硬件的情况下开始的)(**BIOS完成硬件初始化后,boot loader完成软件加载**)
   5.  OS 启动,计算机可用

下面会详解,第 4 步完成之后,第 5 步 OS 是如何启动的?

首先在机器模式下执行了以下4件事(如下图):

  1. 加载内核代码到 ox80000000处,CPU 会从这个约定好的地址处取指令
  2. 为每个 CPU 核分配栈空间,以便后续执行 C 代码
  3. 执行一些只能在机器模式下进行的配置(比如一个关键配置是把“上一个状态”设置为内核态,返回地址设置为内核态的 main()函数,这里上一个之所以加引号是因为这是 OS 首次启动,当然没有所谓的“上一个状态”,这是是为了 mret 指令做铺垫)
  4. 使用 mret 指令切换到内核模式,执行内核代码

image-20230809202916263

跳转到内核模式后,做了以下 2 件事:

  1. 初始化内存和文件系统(如内存分页、设置内核页表等)
  2. 创建OS 的第一个进程,在进程中执行 initcode 代码(这是一段写好的固定代码,嵌入到了内核程序中)

image-20230809204556104

执行 initcode 后会跳转到用户态,用户态执行用户程序:exec("/init", argc),exec 上一节已经讲过:exec系统调用,这个系统调用会从指定的文件(第一个参数)中读取并加载指令,并替代当前调用进程的指令。

所以会执行 user/init.c 中的 main 函数,这个 main 函数中创建了一个子进程,这个子进程就是 shell,于是,亲爱的 shell,终于见面了,可以愉快地与用户进行 REPL(Read-Eval-Print Loop,SICP 中提到了此概念),OS 启动完成!

image-20230809205808657

二、Lab2: system calls 回顾

我的GitHub实现

上一个 lab 是要我们使用系统调用做一些有趣的工作,但为了更深一步理解系统调用,这个 lab 是我们创造一些系统调用,即向 xv6 添加一些新的系统调用

在开始编码之前,请阅读xv6的第2章、第4章的4.3和4.4节以及相关的源文件:
* 将用户空间的系统调用路由到内核的用户空间“存根(跳板)”位于user/usys.S,它是由用 user/usys.pl在运行make命令时生成的。声明在user/user.h中
* 将系统调用路由到实现它的内核函数的内核空间代码位于 kernel/syscall.c和kernel/syscall.h中。
* 与进程相关的代码是kernel/proc.h和kernel/proc.c。

开始 lab 前,先切换到 syscall 分支:

  $ git fetch
  $ git checkout syscall
  $ make clean

任务一:使用gdb(简单)

首先运行 make qemu-gdb 命令,然后在另一个窗口中启动gdb ( 使用命令 gdb-multiarch )。打开两个窗口后,在gdb窗口输入:

(gdb) b syscall
Breakpoint 1 at 0x80002142: file kernel/syscall.c, line 243.
(gdb) c
Continuing.
[Switching to Thread 1.2]

Thread 2 hit Breakpoint 1, syscall () at kernel/syscall.c:243
243     {
(gdb) layout src
(gdb) backtrace

layout 命令将窗口一分为二,显示当前在源代码中的位置。backtrace 命令输出堆栈反向跟踪。有关有用的GDB命令,请参见 Using the GNU Debugge 获取更多GDB 命令

回答以下问题(将答案存储在answers-syscall.txt中)

问题 1

查看 backtrace 的输出,哪个函数调用了 syscall?

回答:

(gdb) backtrace
#0  syscall () at kernel/syscall.c:133
#1  0x0000000080001d14 in usertrap () at kernel/trap.c:67
#2  0x0505050505050505 in ?? ()

backtrace 打印出了函数的调用栈,可以知道是 kernel/trap.c:67 中的 usertrap 函数调用了syscall

补充知识:backtrace命令也可以输入简化bt命令,用来打印当前线程的栈帧信息,一行一帧,从当前执行的帧(帧0)开始,然后是它的调用者(帧1),一直向上到栈底。

问题 2

多次键入 n 以步进到 struct proc *p = myproc(); 一旦执行了这个语句,输入p /x *p,它将以十六进制打印当前进程的 proc 结构体(参见kernel/proc.h>)

p->trapframe->a7的值是什么?这个值代表什么?(提示:查看user/initcode.S,这是xv6启动的第一个用户程序。)

回答:

(gdb) p /x *p
$6 = {
  lock = {
    locked = 0,
    name = 0x80008178 "proc",
    cpu = 0x0
  },
  state = RUNNING,
  chan = 0x0,
  killed = 0,
  xstate = 0,
  pid = 1,
  parent = 0x0,
  kstack = 274877894656,
  sz = 4096,
  pagetable = 0x87f73000,
  trapframe = 0x87f74000,//这一行,这里没有打印出 trapframe 结构体的具体内容,而是 trapframe 的地址,所以看不出 a7 的值
  context = {
    ra = 2147488870,
    sp = 274877898368,
    s0 = 274877898416,
    s1 = 2147519792,
    s2 = 2147518720,
    s3 = 1,
    s4 = 0,
    s5 = 3,
    s6 = 2147588560,
    s7 = 8,
    s8 = 2147588856,
    s9 = 4,
    s10 = 1,
    s11 = 0
  },
  ofile = {0x0 <repeats 16 times>},
  cwd = 0x80016e40 <itable+24>,
  name = "initcode\000\000\000\000\000\000\000"
}

有2 个方法可以知道 a7 的值。

方法 1:明白 a7 的含义:

有了前面的 xv6 系统启动流程,我们就知道 struct proc *p = myproc();这句代码是用户第一个进程启动的时候,用来创建进程的,所以这是一个exec 系统调用, inicode.S中有如下代码,故 a7 是SYS_exec代表的数字

start:
        la a0, init
        la a1, argv
        li a7, SYS_exec //将 SYS_exec 的写入到a7中
        ecall

查看 syscall.h 可知:

#define SYS_exec    7

方法 2:啥都不懂,直接看 a7 里存的到底是啥值

可以看到 trapframe = 0x87f74000,并没有a7相关,可以使用命令 info reg 查看所有寄存器的值,a7是7,这是因为SYS_exe是7,这个值代表 ecall的参数

补充知识:

当使用 print 查看程序运行时的数据时,你的每一个输出都会被 GDB 标记一个唯一值。GDB会以$1$2$3...这样的方式为你每一个 print 命令编上号。下次查看历史内容就可以直接 p $1 这样子了~

问题 3

处理器在内核模式下运行时,我们可以打印 特殊寄存器,如 sstatus (参见RISC-V特殊指令的描述) RISC-V privileged instructions

(gdb) p/t $sstatus
$7 = 100010
 CPU之前的模式是什么?(用户模式?机器模式?内核模式?)

回答

GDB 命令 p/t 是用于打印一个变量的二进制表示。在这里,p/t $sstatus 会输出 $sstatus 寄存器的二进制表示

首先查看手册知道 sstatus 命令的作用,查看官方文档得到下图:
image-20230809221634185

The SPP bit indicates the privilege level at which a hart was executing before entering supervisor mode. When a trap is taken, SPP is set to 0 if the trap originated from user mode, or 1 otherwise. When an SRET instruction (see Section 3.3.2) is executed to return from the trap handler, the privilege level is set to user mode if the SPP bit is 0, or supervisor mode if the SPP bit is 1; SPP is then set to 0.

SPP位表示hart在进入kernel mode 之前执行的 CPU 特权级别。当捕获 trap 时,如果 trap 源自 user mode ,则SPP设置为0,否则设置为1。当执行SRET指令(参见第3.3.2节)以从 trap 理程序返回时,如果SPP位为0,则特权级别设置为 user mode ,如果SPP位为1,则为 kernel mode;然后SPP设置为0。

sstatus 的二进制值为 100010SPP 是第 8 位,是 0,那么在执行系统调用陷入内核之前的特权级别就是 user mode.

其实这个问题的答案从 XV6 的启动流程中也可以得知,因为user mode 调用了 exec 系统调用,导致陷入 kernel mode

问题 4

在本 lab 的后续部分(或在接下来的 lab 中),可能会出现编程错误导致 xv6 内核崩溃的情况。例如,在系统调用开始时,将语句 num = p->trapframe->a7 替换为 num = * (int *) 0,运行 make qemu,您将看到:

xv6 kernel is booting

hart 2 starting
hart 1 starting
scause 0x000000000000000d
sepc=0x000000008000215a stval=0x0000000000000000
panic: kerneltrap

qume退出了

要查找内核页缺失的bug,请在 kernel/kernel.asm 文件中搜索为您刚刚看到的 sepc 值,这个文件包含已编译内核的汇编代码。

写下内核崩溃时正在执行的汇编指令。哪个寄存器存储了变量num?

回答

输出中的 sepc 就是内核发生 panic 的代码地址。在 kernel/kernel.asm 中查看编译后的完整内核汇编代码,在其中搜索这个地址,就可以找到使内核 panic 的代码。

// 摘自 kernel/kernel.asm ,这是整个 kernel 编译后的汇编代码,可以看到是 a3寄存器存储了变量 num
num = * (int *) 0;
    8000215a:	00002683          	lw	a3,0(zero) # 0 <_entry-0x80000000>
        

右边是代码对应的汇编,表示将地址 0 开始的 2byte 数据加载到 a3 寄存器中

问题 5

要在发生故障的指令处检查处理器和内核的状态,启动gdb,并在发生故障的 sepc 处设置断点,如下所示:

(gdb) b *0x000000008000215a
Breakpoint 1 at 0x8000215a: file kernel/syscall.c, line 247.
(gdb) layout asm
(gdb) c
Continuing.
[Switching to Thread 1.3]

Thread 3 hit Breakpoint 1, syscall () at kernel/syscall.c:247

确认出错的汇编指令与上一问你找到的指令相同。

为什么内核会崩溃?提示:查看正文中的图3-3;地址0是否映射到内核地址空间?上面的 scause 值是否确认了这一点?(参见RISC-V特权指令中的原因说明)


回答

内核崩溃时打印出来的 scause 是 0x000000000000000d

查看图3-3如下:可以看到物理地址0没有映射到虚拟内核地址空间中

image-20221111151645530

scause寄存器的官方解释:When a trap is taken into S-mode, scause is written with a code indicating the event that caused the trap.当一个trap进入s模式时,scause会用一个代码来表示引起该trap的事件。我们刚刚看到 scause 0x000000000000000d,对照下表,d代表13-Load page fault;证实了内核崩溃的原因,因为我们修改:num = * (int *) 0;即把0看做一个指针,然后去0地址处取值,而0地址没有映射到内核地址空间,所以 Load page fault

image-20221111152309161

问题 6

上面内核崩溃打印出了 scause 的值,但查找导致崩溃的原因还需要查看额外的信息。例如,要找出内核崩溃时哪个用户进程正在运行,你可以使用以下命令打印出进程的名称:

 (gdb) p p->name
内核崩溃时正在运行的二进制文件的名称是什么?它的进程id (pid)是什么?

回答

(gdb) p p->name
$1 = "initcode\000\000\000\000\000\000\000"
(gdb) p p->pid 
$2 = 1

正在运行的二进制文件是inicode,进程id是1

补充知识:单步调试方法

make CPUS=1 qemu-gdb
另一个窗口 gdb-multiarch
-------
因为你修改了 syscall.c 中 syscall的代码,所以直接打断点: b syscall
(gdb) b syscall
Breakpoint 1 at 0x80001fe0: file kernel/syscall.c, line 133.
--------
c 执行
layout split 直观展示
si 和 ni 是针对汇编
s 和 n 是针对c代码
n是一次一步,s会跳进去一次一步
发现执行这一句汇编时,内核会报错: 80001ff4:	00002683          	lw	a3,0(zero)

System call tracing (moderate)

在本作业中,您将添加一个<系统调用跟踪>功能,它可以在调试以后的lab时帮助您。您将创建一个名叫 trace 的新系统调用来控制跟踪,它应该接受一个参数,一个整数“mask”,其位指定要跟踪的系统调用,例如,要跟踪fork系统调用,程序调用trace(1 << SYS_fork);如果在mask中设置了系统调用的编号,则必须修改xv6内核,以便在每个系统调用即将返回时打印一行。这一行应该包含进程id、系统调用的名称和返回值;您不需要打印系统调用参数;trace系统调用应该支持对调用它的进程和它随后分叉的任何子进程的跟踪,但不应该影响其他进程

解答:

mask这个参数的位指定了要跟踪哪些系统调用,比如mask是 1010 0000,第 5bit 和第 7bit 是1,而syscall.h中定义了

#define SYS_read    5
#define SYS_kill    6
#define SYS_exec    7

所以如果以这个 1010 0000 作为参数的话,trace只跟踪 read 和 exec

这道题目的思路:要想跟踪系统调用,我们必须知道系统调用的路径,在第一节 XV6 启动流程中,可知系统调用都是使用 ecall 指令,进入内核态的 syscall() 函数,即所有系统调用都要进入syscall()函数,这个函数很简单,来分析一下

void
syscall(void)
{
  int num;
  struct proc *p = myproc();//有关进程的一些信息都保存在这个结构体中

  num = p->trapframe->a7;//从a7寄存器中取的参数,用来指示进哪个系统调用
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    // Use num to lookup the system call function for num, call it,
    // and store its return value in p->trapframe->a0
    p->trapframe->a0 = syscalls[num]();//执行系统调用,结果保存在a0寄存器中

  } else {
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

既然每个系统调用都要进入这个函数,我们只需要在这个函数中埋点就可以了,要跟踪指定的系统调用,比如跟踪系统调用号为num的某种系统调用,那么mask就是1<<num,进入syscall()函数后,要检查mask的第num位是不是1,是的话就进行打印相关信息,所以埋点方式很明确了:

if((mask >> num) & 1 == 1) {
    //题目要求打印 pid、系统调用的名称 和 返回值;
	printf("%d: syscall %s -> %d\n",p->pid, syscall_names[num], p->trapframe->a0);
}

但是如何将 mask 函数传入 syscall 中呢?mask是属于 trace 的参数,是不能直接传递到syscall()这个公共位置的,题目提示可以把mask作为proc的一个字段保存到proc中,这一步需要在 sys_trace()函数中执行:

// kernel/sysproc.c
uint64
sys_trace(void)
{
  int mask;

  if(argint(0, &mask) < 0) // 从a0寄存器获取参数, 赋值 mask
    return -1;
  
  myproc()->syscall_trace = mask; // 将 mask 存到 proc 结构体中
  return 0;
}

剩下一些工作比较零碎了,在官网给出的提示中都有,比如修改 fork,将trace mask 从父进程复制到子进程。修改 user 文件,在用户空间中添加跳板函数等,具体见我的 github,切换到相应分支即可:

这里顺便把官网提示附上:

  • 将$U/_trace添加到Makefile中的UPROGS中

  • 运行make qemu,你会看到编译器无法编译user/trace.c,因为系统调用的用户空间跳板函数还不存在;在user/user.h添加一个系统调用的原型(声明),在user/usys.pl添加一个跳板函数,为kernel/ sycall .h添加一个系统调用号。Makefile调用perl脚本user/usys.pl,该脚本编译生成user/usys.S,这是实际的系统调用存根,它使用RISC-V的ecall指令转换到内核。修复编译问题后,运行trace 32 grep hello README;它将失败,因为您还没有在内核中实现系统调用。

  • 在kernel/sysproc.c中添加一个sys_trace()函数,它通过在proc结构中的添加一个新变量来保存它的参数来实现新的系统调用(参见kernel/proc.h)。从用户空间获取系统调用参数的函数在kernel/syscal .c中,您可以在kernel/sysproc.c中看到它们的使用示例。

    // in kernel/syscal .c
    int n;
    argint(0, &n);
    
  • 修改fork()(参见kernel/proc.c),将trace mask 从父进程复制到子进程。

  • 修改kernel/syscall.c中的syscall()函数以打印trace输出。您将需要新建一个数组,数组元素为 系统调用名称,以便对其进行索引。

  • 如果在qemu中直接运行测试用例时通过,但使用make grade运行测试时出现超时,请尝试在Athena上测试您的实现。这个实验室中的一些测试对于您的本地机器来说可能计算量太大了(特别是如果您使用WSL)。

Sysinfo (moderate)

在这个任务中,您将添加一个系统调用:sysinfo,它收集关于正在运行的系统的信息。这个系统调用有一个参数:指向结构体的指针-sysinfo(参见kernel/sysinfo.h)。内核负责填写这个结构体:其中 freemem 字段为空闲的内存字节数,nproc字段为状态不是UNUSED的进程的数量。我们提供了一个名叫 sysinfotest 的测试程序;如果输出“sysinfotest: OK”,则此作业通过。

说实话,这个任务简单,也不简单。

简单是因为实现后会发现统计空闲内存字节数和状态不是UNUSED的进程的数量都很简单,因为 OS 会维护这些状态,具体来说,OS 会维护 free-list(内存空闲链表),也会维护所有进程的状态,所以统计的话只需要遍历 OS 维护的数据,进行简单的统计就好

不简单是因为要想彻底理解这个任务,需要阅读很多代码才能完全理解 OS 是如何维护这些状态的,比如 OS 是怎么维护 free-list 以及各进程状态的? copyout 的原理究竟是什么?而且 copyout 的原理大概率需要学完虚拟内存和页表的知识才能完全理解。所以这个任务对于小白还是有难度的,我也是回头来看才发现这一点的。

copyout 函数的解释

我画了这幅图帮助理解 copyout,所谓的copyout实现数据从内核空间复制到用户空间,是因为两个空间的页表不一样,内核空间只认内核空间的地址 src ,用户空间只认用户空间的地址 dst-va,所以需要一个函数,将数据从 src 复制到 dst-va

image-20230810163136216

为什么要复制到用户空间

为什么要把这些系统数据复制到用户空间?我认为这是 api 设计的考虑,系统调用的本质就是为 OS 的用户提供服务,所以 sysinfo 得到的统计数据应该以某种方式提供给用户,而统计数据刚得到时是在内核空间,由于 OS 的隔离性,用户无法看到、也无法使用这些数据,所以需要把这些数据传到用户空间。

实现

理解了以上两个问题,实现起来就很简单了

// in kernel/sysproc.c
uint64
sys_sysinfo(void)
{
  uint64 addr;
  argaddr(0, &addr);

  struct sysinfo sinfo;
  sinfo.freemem = count_free_mem(); // kernel/kalloc.c
  sinfo.nproc = count_live_process(); // kernel/proc.c 
  
  // 使用 copyout,结合当前进程的页表,获得进程传进来的指针(逻辑地址)对应的物理地址
  // 然后将 &sinfo 中的数据复制到该指针所指位置,供用户进程使用。
  if(copyout(myproc()->pagetable, addr, (char *)&sinfo, sizeof(sinfo)) < 0)
    return -1;
  return 0;
}

获取空闲字节数

// kernel/kalloc.c
uint64
count_free_mem(void)
{
  //锁内存管理结构,防止统计时有进程访问 free-list
  acquire(&kmem.lock); 
  //统计空闲页数,乘上页大小 PGSIZE 就是空闲的内存字节数
  uint64 mem_bytes = 0;
  //xv6中,空闲内存页的记录方式是将空闲内存页本身直接作为链表的节点,形成一个空闲页链表,每次需要分配,就把链表根部对应的页分配出去。
  struct run *r = kmem.freelist;
  // 遍历 free-list
  while(r) {
    mem_bytes += PGSIZE;
    r = r ->next;
  }
  release(&kmem.lock);

  return mem_bytes;
}

获取运行中的进程数

//kernel/proc.c 
uint64
count_live_process(void)
{
  uint64 process_num = 0;
  for(struct proc *p = proc; p < &proc[NPROC]; p++) {

    if(p->state != UNUSED) {
      process_num++;
    }

  }
  return process_num;
}

其余实现比较琐碎,比如在用户空间为系统调用添加 "stub"(即 ecall 指令所在的程序)等,具体见我的GitHub实现