XV6实验(2020)

XV6实验记录(2020)

环境搭建

参考连接

Lab guidance (mit.edu)

6.S081 / Fall 2020 (mit.edu)

xv6 book中文版

Lab1:Xv6 and Unix utilities

实现几个unix实用工具,熟悉xv6的开发环境以及系统调用

Boot xv6


就是准备环境,克隆仓库,编译。

git clone git://g.csail.mit.edu/xv6-labs-2020
cd xv6-labs-2020
git checkout util //切换分支
make qemu //build and run xv6

编译通过后会进一个类似shell的界面,退出是Ctrl-a x

sleep

官方要求:Implement the UNIX program sleep for xv6; your sleep should pause for a user-specified number of ticks. A tick is a notion of time defined by the xv6 kernel, namely the time between two interrupts from the timer chip. Your solution should be in the file user/sleep.c.

似乎是为了熟悉一些系统调用,需要了解xv6 book的第一章的前置知识。

xv6 book chapter1

参考一个大佬的博客:MIT 6.S081 Lecture Notes | Xiao Fan (樊潇) (fanxiao.tech)

  1. 进程和内存

每个进程都拥有自己的用户空间内存以及内核空间状态,当进程不再执行时,xv6会存储和这些进程有关的CPU寄存器到下一次运行这些进程。kernel中一个进程有唯一的PID

常用的syscall

  • fork:原型是int fork()。作用是让一个进程生成另一个和这个进程的内存内容相同的子进程。在父进程中,fork的返回子进程的PID,在子进程中,返回值时0

  • exit:原型int exit(int status)。作用是让调用它的进程停止执行并且将内存等占用的资源全部释放。status是状态参数,0代表正常退出,1代表非正常退出

  • wait:原型int wait(int *status)。等待子进程退出,返回子进程PID,子进程的退出状态存储到*status地址中。如果没有调用子进程,wait返回-1。

  • exec:原型int exec(char *file, char *argv[]).作用是加载 一个文件,获取执行它的参数,执行。执行错误返回-1,执行成功则不会返回,而开始从文件入口位置开始执行命令,文件格式必须是ELF格式。

  1. IO 和 文件描述符
  • file descriptor:文件描述符,一个被内核管理的、可以被进程读、写的对象的一个整数,通过打开文件、目录、设备等方式获得。一个文件被打开的越早,文件描述符越小。每个进程都有自己独立的文件描述符列表,0是标准输入,1是标准输出,2是标准错误。shell保证总是3个文件描述符是可用的,在给的源码中的sh.c中有这样一段代码“

    int fd;
    
      // Ensure that three file descriptors are open.
      while((fd = open("console", O_RDWR)) >= 0){
        if(fd >= 3){
          close(fd);
          break;
        }
      }
    
  • readwrite:原型int write(int fd, char *buf, int n)int read(int fd, char *buf, int n)。实现从/向文件描述符fd中写n字节buf内容,返回值时读取/写入的字节数。每个文件描述符有一个offset,read会从这个offset开始读取内容,读完n个字节后将offset后移n个字节,下一个read从新的offset开始读取字节。write类似。

  • close:原型int close(int fd),作用是将打开的文件fd释放,使该文件描述符可以被后面的系统调用使用。

    父进程的fd table不会被子进程的变化硬性,但文件中的offset共享。

  • dup:原型int dup(int fd),复制一个新的fd指向的I/O对象,返回这个新的fd值,两个I/O对象的offset相同。

  1. 管道Pipes

管道是暴露给进程的一对文件描述符,一个文件描述符用来读,另一个文件描述符用来 写,将数据从管道的一端写入,将使其能够被从管道的另一端读出。

pipe也是一个系统调用,原型是int pipe(int p[])p[0]为读取的文件描述符,p[1]为写入的文件描述符。

  1. 文件系统

xv6文件系统包含了文件(byte arrays)和目录(对其他文件和目录的引用)。目录生成了一个树,树从根目录/开始。对于不以/开头的路径,认为是是相对路径

相关系统调用:

  • mknod:创建设备文件,一个设备文件有一个major device #和一个minor device #用来唯一确定这个设备。当一个进程打开了这个设备文件是,内核会将readwrite系统调用重新定向到设备上。

  • 一个文件的名称和文件本身是不一样的,文件本身,也叫inode,可以有多个名字,也叫link,每个link包括了一个文件名和一个对inode的引用。一个inode存储了文件的元数据,包括该文件的类型(file, directory or device)、大小、文件在硬盘中的存储位置以及指向这个inode的link的个数

  • fstat:原型为int fstat(int fd, struct stat *st),作用是将inode中的相关信息存储到st中。

  • link:创建一个指向同一个inode的文件名,unlink则是将一个文件名从文件系统中移除,只有当指向这个inode的文件名的数量为0时这个inode以及其存储的文件内容才会被从硬盘上移除

学习完提供的系统调用,就得实现sleep函数了。

实现如下:

#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

int main(int argc, char *argv[])
{
    if(argc < 2)
    {
        printf("usage: sleep \n");
    }
    sleep(atoi(argv[1]));
    exit(0);
    return 0;
}

pingpong

官方要求:

Write a program that uses UNIX system calls to ‘‘ping-pong’’ a byte between two processes over a pair of pipes, one for each direction. The parent should send a byte to the child; the child should print “: received ping”, where is its process ID, write the byte on the pipe to the parent, and exit; the parent should read the byte from the child, print “: received pong”, and exit. Your solution should be in the file user/pingpong.c.

就是用两个管道实现父进程和子进程之间通信

#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

int main(int argc, char *argv[])
{
    int pid;
    int p1[2], p2[2]; // p1: parent  --> child, p2: child --> parent; 0: read fd, 1: write fd
    char buf[1];
    pipe(p1);
    pipe(p2);
    pid = fork();
    if(pid < 0)
    {
        printf("fork error\n");
        exit(1);
    }
    else if(pid == 0) // child process
    {
        close(p1[1]); // 子进程收信息,发信息,关闭p1的write,防止read的时候阻塞
        close(p2[0]); // 关闭子进程到父进程管道的read,防止子进程写的时候阻塞,父进程中同理
        read(p1[0], buf, 1);
        printf("%d: received ping\n", getpid());
        write(p2[1], " ", 1);
        close(p1[0]);
        close(p2[1]);
        exit(0);
    }
    else // parent process
    {
        close(p1[0]);
        close(p2[1]);
        write(p1[1], " ", 1);
        read(p2[0], buf, 1);
        printf("%d: received pong\n", getpid());
        
        close(p1[1]);
        close(p2[0]);
        exit(0);
    }
    return 0;
}

primes

Write a concurrent version of prime sieve using pipes. This idea is due to Doug McIlroy, inventor of Unix pipes. The picture halfway down this page and the surrounding text explain how to do it. Your solution should be in the file user/primes.c.

XV6实验(2020)_第1张图片

就是通过管道实现筛选素数的倍数。采用递归实现,每个进程中读取管道中的第一个数,就是一个素数,然后创建一个新的管道,将筛后的素数传到管道里,传给子进程。

#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

void child(int *pl)
{
    int pr[2];
    int n;
    close(pl[1]);

    int resd_size = read(pl[0], &n, sizeof(int));
    if(resd_size == 0)
    {
        exit(0);
    }
    pipe(pr);
    
    if(fork() == 0)
    {
        child(pr);
    }
    else
    {
        printf("prime %d\n", n);
        close(pr[0]);
        int t = n;
        while(read(pl[0], &n, sizeof(int)) != 0)
        {
            if(n % t != 0) write(pr[1], &n, sizeof(int));
        }
        close(pl[0]);
        close(pr[1]);
        wait(0);
        exit(0);
    }
}

int main(int argc, char *argv[])
{
    int p[2];
    pipe(p);
    if(fork() == 0)
    {
        child(p);
    }
    else
    {
        close(p[0]);
        for(int i=2;i<=35;i++)
            write(p[1], &i, sizeof(int));
        close(p[1]);
        wait(0); // 等待子进程完成
        exit(0);
    }
    return 0;
}

find

Write a simple version of the UNIX find program: find all the files in a directory tree with a specific name. Your solution should be in the file user/find.c.

就是实现在给定路径下出发,查找所有路径中给定文件名的路径,输出所有文件的路径,可以根据ls.c改造。代码如下

#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "kernel/fs.h"

void find(char *path, char *target)
{
  char buf[512], *p;
  int fd;
  struct dirent de;
  struct stat st; // 存储打开的inode中的相关信息。

  if((fd = open(path, 0)) < 0){
    fprintf(2, "find: cannot open %s\n", path);
    return;
  }

  if(fstat(fd, &st) < 0){ // 将inode中的信息存到结构体st中
    fprintf(2, "find: cannot stat %s\n", path);
    close(fd);
    return;
  }

  switch(st.type){
  case T_FILE:
    if(strcmp(path+strlen(path) - strlen(target), target) == 0)// 比较路径结尾是不是和target相等。
    {
        printf("%s\n", path);
    }
    break;

  case T_DIR:
    if(strlen(path) + 1 + DIRSIZ + 1 > sizeof buf){
      printf("find: path too long\n");
      break;
    }
    strcpy(buf, path);
    p = buf+strlen(buf);
    *p++ = '/';
    while(read(fd, &de, sizeof(de)) == sizeof(de)){
      if(de.inum == 0)
        continue;
      memmove(p, de.name, DIRSIZ);
      p[DIRSIZ] = 0;
      if(stat(buf, &st) < 0){
        printf("find: cannot stat %s\n", buf);
        continue;
      }
      if(strcmp(buf+strlen(buf) - 2, "/.") != 0 && strcmp(buf+strlen(buf)-3, "/..") != 0)// 递归新打开的路径中的文件
      {
        find(buf, target);
      }
    }
    break;
  }
  close(fd);
}

int main(int argc, char *argv[])
{
    if(argc < 3)
    {
        printf("error please input: find  \n");
        exit(0);
    }
    char target[512];
    target[0] = '/';
    strcpy(target+1, argv[2]);
    find(argv[1], target);
    exit(0);
}

xargs

Write a simple version of the UNIX xargs program: read lines from the standard input and run a command for each line, supplying the line as arguments to the command. Your solution should be in the file user/xargs.c.

首先得弄懂xargs的功能,以及一些概念。

命令行参数与标准化输入

命令行参数是在shell中输入命令时跟在命令后边的参数,例如mkdir a b ca, b, c就是mkdir接收的命令行参数。

标准化输入是程序执行时,在shell中输入的东西,程序所等待的就是标准化输入。

标准化输出: 命令返回结果就是一个标准化输出

管道符 |

管道符的作用是前一个命令的输出会作为后一个命令的输入

例如

cmdA | cmdB

cmdA的输出会作为cmdB的输入

xagrs

xargs与管道符搭配使用,前一个命令的输出会作为后一个命令的命令行参数。

了解了这些知识后,我还是不太会写,不得不找教程了,看了B站大佬的视频,真通透

MIT6.S081操作系统实验-Lab1-实现简易版unix的xargs_哔哩哔哩_bilibili

实现代码如下:

#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "kernel/param.h"
#include "kernel/fs.h"

#define MSGSIZE 16

int main(int argc, char *argv[])
{
    char buf[MSGSIZE];
    /*****************
    xv6 book 有这样一段描述
    每个进程都有一张表,而 xv6 内核就以文件描述符作为这张表的索引,所以每个进程都有一个从0	 开始的文件描述符空间。按照惯例,进程从文件描述符0读入(标准输入),从文件描述符1输出(标	 准输出),从文件描述符2输出错误(标准错误输出)。我们会看到 shell 正是利用了这种惯例来    实现 I/O 重定向。shell 保证在任何时候都有3个打开的文件描述符(8007),他们是控制台(console)的默认文件描述符。
    **********************/
    // 所以从fd 0读入xargs的标准输入,通过字符串处理,用exec传递命令行参数
    read(0, buf, MSGSIZE);
    // printf("%s\n", buf);
    int n = read(0, buf, MSGSIZE);
    int buf_idx = 0;
    while(n>0)
    {
        buf_idx += n;
        n = read(0, &buf[buf_idx], MSGSIZE);
    }

    char *xargv[MAXARG];
    int xargc = 0;
    for(int i=1;i<argc;i++)
    {
        xargv[xargc] = argv[i];
        xargc++;
    }
    
    char *p = buf;
    for(int i=0;i<MAXARG;i++)
    {
        if(buf[i] == '\n')
        {
            int pid = fork();
            if(pid > 0)
            {
                p = &buf[i+1];
                wait(0);
            }
            else
            {
                buf[i] = 0;
                xargv[xargc] = p;
                xargc++;
                xargv[xargc] = 0;
                exec(xargv[0], xargv);
                exit(0);
            }
        }
    }
    exit(0);
}

至此,Lab1:Xv6 and Unix utilities必做实验部分结束,感觉好难啊。

Lab2: system calls

前置知识(对Xv6的了解)

操作系统必须满足三个要求:多路复用、隔离和交互,Xv6 bool第二章就是介绍相关知识的。

1. 用户态,核心态,以及系统调用

RISC-V有三种CPU可以执行的模式: 机器模式、用户模式和管理模式,机器模式是cpu启动时的状态,主要用于配置计算机,然后更改为管理模式。在管理模式下,CPU被允许执行特权指令。

想要调用内核函数的应用程序必须过渡到内核,CPU提供一个特殊的指令,将CPU从用户模式切换到管理模式,并在内核指定的入口点进入内核(RISC-V为此提供ecall指令)。一旦CPU切换到管理模式,内核就可以验证系统调用的参数,决定是否允许应用程序执行请求的操作,然后拒绝它或执行它。

2. 内核组织

宏内核:整个操作系统都驻留在内核中,所有的系统调用的实现都以管理模式运行。

微内核:操作系统设计者可以最大限度地减少在管理模式下运行的操作系统代码量,并在用户模式下执行大部分操作系统。这种内核组织被称为微内核(microkernel)

XV6实验(2020)_第2张图片

图2.1说明了这种微内核设计。在图中,文件系统作为用户级进程运行。作为进程运行的操作系统服务被称为服务器。为了允许应用程序与文件服务器交互,内核提供了允许从一个用户态进程向另一个用户态进程发送消息的进程间通信机制。例如,如果像shell这样的应用程序想要读取或写入文件,它会向文件服务器发送消息并等待响应。

3. XV6架构

XV6的源代码位于***kernel/***子目录中,源代码按照模块化的概念划分为多个文件,图2.2列出了这些文件,模块间的接口都被定义在了def.h(kernel/defs.h)。

文件 描述
bio.c 文件系统的磁盘块缓存
console.c 连接到用户的键盘和屏幕
entry.S 首次启动指令
exec.c exec()系统调用
file.c 文件描述符支持
fs.c 文件系统
kalloc.c 物理页面分配器
kernelvec.S 处理来自内核的陷入指令以及计时器中断
log.c 文件系统日志记录以及崩溃修复
main.c 在启动过程中控制其他模块初始化
pipe.c 管道
plic.c RISC-V中断控制器
printf.c 格式化输出到控制台
proc.c 进程和调度
sleeplock.c Locks that yield the CPU
spinlock.c Locks that don’t yield the CPU.
start.c 早期机器模式启动代码
string.c 字符串和字节数组库
swtch.c 线程切换
syscall.c Dispatch system calls to handling function.
sysfile.c 文件相关的系统调用
sysproc.c 进程相关的系统调用
trampoline.S 用于在用户和内核之间切换的汇编代码
trap.c 对陷入指令和中断进行处理并返回的C代码
uart.c 串口控制台设备驱动程序
virtio_disk.c 磁盘设备驱动程序
vm.c 管理页表和地址空间
4. 进程概述

Xv6(和其他Unix操作系统一样)中的隔离单位是一个进程。进程抽象防止一个进程破坏或监视另一个进程的内存、CPU、文件描述符等。它还防止一个进程破坏内核本身,这样一个进程就不能破坏内核的隔离机制。

内核用来实现进程的机制包括用户/管理模式标志、地址空间和线程的时间切片。

XV6实验(2020)_第3张图片

Xv6为每个进程维护一个单独的页表,定义了该进程的地址空间。如图2.3所示,以虚拟内存地址0开始的进程的用户内存地址空间。首先是指令,然后是全局变量,然后是栈区,最后是一个堆区域(用于malloc)以供进程根据需要进行扩展。

每个进程都有一个执行线程(或简称线程)来执行进程的指令。一个线程可以挂起并且稍后再恢复。为了透明地在进程之间切换,内核挂起当前运行的线程,并恢复另一个进程的线程。线程的大部分状态(本地变量、函数调用返回地址)存储在线程的栈区上。每个进程有两个栈区:一个用户栈区和一个内核栈区(p->kstack)。当进程执行用户指令时,只有它的用户栈在使用,它的内核栈是空的。当进程进入内核(由于系统调用或中断)时,内核代码在进程的内核堆栈上执行;当一个进程在内核中时,它的用户堆栈仍然包含保存的数据,只是不处于活动状态。进程的线程在主动使用它的用户栈和内核栈之间交替。内核栈是独立的(并且不受用户代码的保护),因此即使一个进程破坏了它的用户栈,内核依然可以正常运行。

一个进程可以通过执行RISC-V的ecall指令进行系统调用,该指令提升硬件特权级别,并将程序计数器(PC)更改为内核定义的入口点,入口点的代码切换到内核栈,执行实现系统调用的内核指令,当系统调用完成时,内核切换回用户栈,并通过调用sret指令返回用户空间,该指令降低了硬件特权级别,并在系统调用指令刚结束时恢复执行用户指令。

5. 启动XV6和第一个进程

当RISC-V计算机上电时,它会初始化自己并运行一个存储在只读内存中的引导加载程序。引导加载程序将xv6内核加载到内存中。然后,在机器模式下,中央处理器从_entry (kernel/entry.S:6)开始运行xv6。Xv6启动时页式硬件(paging hardware)处于禁用模式:也就是说虚拟地址将直接映射到物理地址。

加载程序将xv6内核加载到物理地址为0x80000000的内存中。它将内核放在0x80000000而不是0x0的原因是地址范围0x0:0x80000000包含I/O设备。

_entry的指令设置了一个栈区,这样xv6就可以运行C代码。Xv6在start. c (kernel/start.c:11)文件中为初始栈stack0声明了空间。由于RISC-V上的栈是向下扩展的,所以_entry的代码将栈顶地址stack0+4096加载到栈顶指针寄存器sp中。现在内核有了栈区,_entry便调用C代码start(kernel/start.c:21)。

函数start执行一些仅在机器模式下允许的配置,然后切换到管理模式。RISC-V提供指令mret以进入管理模式,该指令最常用于将管理模式切换到机器模式的调用中返回。而start并非从这样的调用返回,而是执行以下操作:它在寄存器mstatus中将先前的运行模式改为管理模式,它通过将main函数的地址写入寄存器mepc将返回地址设为main,它通过向页表寄存器satp写入0来在管理模式下禁用虚拟地址转换,并将所有的中断和异常委托给管理模式。

在进入管理模式之前,start还要执行另一项任务:对时钟芯片进行编程以产生计时器中断。清理完这些“家务”后,start通过调用mret“返回”到管理模式。这将导致程序计数器(PC)的值更改为main(kernel/main.c:11)函数地址。

main(kernel/main.c:11)初始化几个设备和子系统后,便通过调用userinit (kernel/proc.c:212)创建第一个进程,第一个进程执行一个用RISC-V程序集写的小型程序:initcode. S (***user/initcode.S:***1),它通过调用exec系统调用重新进入内核。正如我们在第1章中看到的,exec用一个新程序(本例中为 /init)替换当前进程的内存和寄存器。一旦内核完成exec,它就返回/init进程中的用户空间。如果需要,init(user/init.c:15)将创建一个新的控制台设备文件,然后以文件描述符0、1和2打开它。然后它在控制台上启动一个shell。系统就这样启动了。

trace

看完了这些基础知识,开始做实验,打开文档,懵逼了,不会,读都读不懂。。。

开始找资料,终于看了B站的视频,有点头绪了,关键是看官方给的hint,一步一步的创建,然后在创建的函数里实现功能。

官方hint步骤如下:

  • Add $U/_trace to UPROGS in Makefile
  • Run make qemu and you will see that the compiler cannot compile user/trace.c, because the user-space stubs for the system call don’t exist yet: add a prototype for the system call to user/user.h, a stub to user/usys.pl, and a syscall number to kernel/syscall.h. The Makefile invokes the perl script user/usys.pl, which produces user/usys.S, the actual system call stubs, which use the RISC-V ecall instruction to transition to the kernel. Once you fix the compilation issues, run trace 32 grep hello README; it will fail because you haven’t implemented the system call in the kernel yet.
  • Add a sys_trace() function in kernel/sysproc.c that implements the new system call by remembering its argument in a new variable in the proc structure (see kernel/proc.h). The functions to retrieve system call arguments from user space are in kernel/syscall.c, and you can see examples of their use in kernel/sysproc.c.
  • Modify fork() (see kernel/proc.c) to copy the trace mask from the parent to the child process.
  • Modify the syscall() function in kernel/syscall.c to print the trace output. You will need to add an array of syscall names to index into.

第一个hint就是添加编译,第二和第三个hint是创建syscall的一个步骤,涉及到了syscall调用的流程,

user/user.h:		用户态程序,调用函数 trace()
user/usys.S:		跳板函数 trace() 使用 CPU 提供的 ecall 指令,调用到内核态
kernel/syscall.c	到达内核态统一系统调用处理函数 syscall(),所有系统调用都会跳到这里来处理。
kernel/syscall.c	syscall() 根据跳板传进来的系统调用编号,查询 syscalls[] 表,找到对应的内核函数并调用。
kernel/sysproc.c	到达 sys_trace() 函数,执行具体内核操作

因此根据hint2和hint3在user/user.h中添加函数声明,如下:

// 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); // 添加的trace函数声明

user/usys.pl中有样学样的添加entry("trace")如下:

#!/usr/bin/perl -w
	
entry("fork");
entry("exit");
entry("wait");
entry("pipe");
entry("read");
entry("write");
entry("close");
entry("kill");
entry("exec");
entry("open");
entry("mknod");
entry("unlink");
entry("fstat");
entry("link");
entry("mkdir");
entry("chdir");
entry("dup");
entry("getpid");
entry("sbrk");
entry("sleep");
entry("uptime");
entry("trace");// 添加的内容

这个脚本在运行后会生成 usys.S 汇编文件,里面定义了每个 system call 的用户态跳板函数

kernel/sysproc.c中添加sys_trace()函数,同样有样学样的添加:

uint64
sys_trace(void)
{
  int mask;
  struct proc *p = myproc();
  if(argint(0, &mask) < 0)
    return -1;
  p->trace_mask = mask;
  return 0;
}

添加完这些东西,运行make qemu就能通过了,剩下就是功能的实现,但是其中参数传递又成了一个难题。

这里sys_trace()如何获得用户态传来的参数mask需要解决掉,hint里提到了proc,这是个啥,打开一看,原来是存储进程中有关信息的结构体,根据这个提示,再看了b站大佬的视频终于知道了需要在进程结构体中添加一个mask的变量,就能实现了。

在上边工作的基础上,只需稍加修改,就能实现了,其中syscall函数如下:

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]();
    if((p->trace_mask >> num) & 1)
      printf("%d: syscall %s -> %d\n", p->pid, syscall_names[num-1], p->trapframe->a0);// a0是个寄存器,具体也不太理解。
  } else {
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

此外,还需要在freeproc中将mask值清除掉,在fork中将mask复制给子进程。

至此,trace实验就完成了,没弄懂syscall的流程确实很难理解,好难啊。

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”.

这个实验的过程和上个实验的流程基本是一致的,首先要根据上个实验的提示,在相关文件中添加内容,实现创建系统调用的功能。然后根据提示进行推进实验。

提示内容为:

  • Add $U/_sysinfotest to UPROGS in Makefile

  • Run make qemu; user/sysinfotest.c will fail to compile. Add the system call sysinfo, following the same steps as in the previous assignment. To declare the prototype for sysinfo() in user/user.h you need predeclare the existence of struct sysinfo:

        struct sysinfo;
        int sysinfo(struct sysinfo *);
    

    Once you fix the compilation issues, run

    sysinfotest

    ; it will fail because you haven’t implemented the system call in the kernel yet.

  • sysinfo needs to copy a struct sysinfo back to user space; see sys_fstat() (kernel/sysfile.c) and filestat() (kernel/file.c) for examples of how to do that using copyout().

  • To collect the amount of free memory, add a function to kernel/kalloc.c

  • To collect the number of processes, add a function to kernel/proc.c

第一条提示是添加编译,第二条提示是在in user/user.h里面添加声明,第三条提示是让进入相关文件进行学习如何copy一个sysinfo到用户态,好的进去看看。

kernel/sysfile.c中的sys_fstat()实现如下:

sys_fstat(void)
{
  struct file *f;
  uint64 st; // user pointer to struct stat

  if(argfd(0, 0, &f) < 0 || argaddr(1, &st) < 0) // 主要就是这句,argaddr实现了将内容存到用户态的st指针的缓冲区
    return -1;
  return filestat(f, st);
}

kernel/file.c中的filestat()实现如下:

filestat(struct file *f, uint64 addr)
{
  struct proc *p = myproc();
  struct stat st;
  
  if(f->type == FD_INODE || f->type == FD_DEVICE){
    ilock(f->ip);
    stati(f->ip, &st);
    iunlock(f->ip);
    // 使用 copyout,结合当前进程的页表,获得进程传进来的指针(逻辑地址)对应的物理地址
    // 并将&st中的内容复制到addr中,供用户使用。
    if(copyout(p->pagetable, addr, (char *)&st, sizeof(st)) < 0)
      return -1;
    return 0;
  }
  return -1;
}

kernel/sysproc.c中有样学样添加一个新的函数,实现系统调用的功能

uint64 sys_info(void)
{
  struct proc *p = myproc();
  uint64 addr; // 用于存放copy到用户态的sysinfo的结构体信息。
  struct sysinfo info;
  info.freemem = acquire_sysmem(); // 获取空闲内存的函数
  info.nproc = acquire_nproc(); // 获取创建进程数量
  if(argaddr(0, &addr) < 0)
    return -1;
  
  if(copyout(p->pagetable, addr, (char *)&info, sizeof(info)) < 0)
    return -1;
  // printf("sys_info say Hi!\n");
  return 0;
}

kernel/kalloc.c中实现获取空闲内存的函数,这里涉及到了系统中空闲内存的管理方式,大概了解了一下这里是用一个链表连接了所有的空闲区,空闲区管理以页为单位,一个页大小是4096B

uint64 acquire_sysmem(void)
{
  struct run *r;
  acquire(&kmem.lock);
  r = kmem.freelist;
  uint64 cnt = 0;
  while(r)
  {
    cnt += PGSIZE;
    r = r->next;
  }
  release(&kmem.lock);
  return cnt;
}

kernel/proc.c中实现获取创建进程的函数,这里用到的一些东西只是模仿的,还没有理解。

uint64 acquire_nproc(void)
{
  struct proc *p;
  uint64 cnt = 0;
  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state != UNUSED) {
      cnt++;
    }
    release(&p->lock);
  }
  return cnt;
}

至此,lab2的必做实验完成了,还是有点不理解,只是了解了一点进程从用户态到用户态的流程,对这个xv6又了解了那么一点。

Lab3: page tables

基础知识

页表是操作系统为每个进程提供私有地址空间和内存的机制。基于页表,XV6 隔离不同进程的地址空间,并将它们复用到单个物理内存上。页表还提供了一层抽象(a level of indirection),这允许xv6执行一些特殊操作:映射相同的内存到不同的地址空间中(a trampoline page),并用一个未映射的页面保护内核和用户栈区。

1. 页式硬件

XV6实验(2020)_第4张图片
XV6基于Sv39RISC-V运行,这意味着它只使用64位虚拟地址的低39位,而高25位不使用。

页表在逻辑上右一个 2 27 2^{27} 227个页表条目(Page Table Entries/PTE)组成的数组,每个PTE包含一个44位的物理页码(Physical Page Number/PPN)和一些标志。虚拟地址的前27位作为虚页号,后12位作为页内偏移,也就是说XV6页大小为4096B。
XV6实验(2020)_第5张图片
上图是一个三级页表,地址转换是类似的。

因为 CPU 在执行转换时会在硬件中遍历三级结构,所以缺点是 CPU 必须从内存中加载三个 PTE 以将虚拟地址转换为物理地址。为了减少从物理内存加载 PTE 的开销,RISC-V CPU 将页表条目缓存在 Translation Look-aside Buffer (TLB) 中。

每个PTE包含标志位,这些标志位告诉分页硬件允许如何使用关联的虚拟地址。PTE_V指示PTE是否存在:如果它没有被设置,对页面的引用会导致异常(即不允许)。PTE_R控制是否允许指令读取到页面。PTE_W控制是否允许指令写入到页面。PTE_X控制CPU是否可以将页面内容解释为指令并执行它们。PTE_U控制用户模式下的指令是否被允许访问页面;如果没有设置PTE_U,PTE只能在管理模式下使用。

为了告诉硬件使用页表,内核必须将根页表页的物理地址写入到satp寄存器中(satp的作用是存放根页表页在物理内存中的地址)。每个CPU都有自己的satp,一个CPU将使用自己的satp指向的页表转换后续指令生成的所有地址。

2. 内核地址空间
Xv6为每个进程维护了一个页表,用以描述每个进程的用户地址空间,外加一个单独描述内核地址空间的页表。内核配置其地址空间的布局,以允许自己可以预测的虚拟地址访问内存和各种硬件资源。

XV6实验(2020)_第6张图片
这个图显示了如何将内核虚拟地址映射到物理地址。内核使用“直接映射”获取内存和内存映射设备寄存器;也就是说,将资源映射到等于物理地址的虚拟地址。
3. 代码

用于操作地址空间和页表的代码大部分在vm.c (kernel/vm.c:1) 中。其核心数据结构是pagetable_t,它实际上是指向RISC-V根页表页的指针;一个pagetable_t可以是内核页表,也可以是一个进程页表。最核心的函数是walkmappages,前者为虚拟地址找到PTE,后者为新映射装载PTE。copyoutcopyin复制数据到用户虚拟地址或从用户虚拟地址复制数据,这些虚拟地址作为系统调用参数提供。

启动时,main调用kvminit使用kvmake创建内核的页表,这个页表的地址直接引用物理内存。 kvmmake 首先分配一个物理内存页来保存根页表页。然后它调用kvmmap来装载内核需要的转换。转换包括内核的指令和数据、物理内存的上限到 PHYSTOP,并包括实际上是设备的内存Proc_mapstacks (kernel/proc.c:33) 为每个进程分配一个内核堆栈。它调用 kvmmap 将每个堆栈映射到由 KSTACK 生成的虚拟地址,从而为无效的堆栈保护页面留出空间。

5. 进程的地址空间

每个进程都有一个单独的页表,在进程切换时,也会更改页表。一个进程的用户内存从虚拟地址零开始,可以增长到MAXVA,最大为256G

XV6实验(2020)_第7张图片

当进程向xv6请求更多的用户内存时,xv6首先使用kalloc来分配物理页面。然后,它将PTE添加到进程的页表中,指向新的物理页面。Xv6在这些PTE中设置PTE_WPTE_XPTE_RPTE_UPTE_V标志。大多数进程不使用整个用户地址空间;xv6在未使用的PTE中留空PTE_V

首先,不同进程的页表将用户地址转换为物理内存的不同页面,这样每个进程都拥有私有内存。第二,每个进程看到的自己的内存空间都是以0地址起始的连续虚拟地址,而进程的物理内存可以是非连续的。第三,内核在用户地址空间的顶部映射一个带有蹦床(trampoline)代码的页面,这样在所有地址空间都可以看到一个单独的物理内存页面。

在xv6中,栈是一个单独的页面,显示由exec创建后的初始内容。包含命令行参数的字符串以及指向它们的指针数组位于栈的最顶部。再往下是允许程序在main处开始启动的值(即main的地址、argcargv),这些值产生的效果就像刚刚调用了main(argc, argv)一样。

XV6实验(2020)_第8张图片

在栈的下方有一个无效的保护页(guard page)。如果用户栈溢出并且进程试图使用栈下方的地址,那么由于映射无效(PTE_V为0)硬件将生成一个页面故障异常。实际操作时可能会分配更多的物理空间。

sbrk是一个用于进程减少或增长其内存的系统调用。这个系统调用由函数growproc实现(kernel/proc.c)。growproc根据n是正的还是负的调用uvmallocuvmdeallocuvmalloc(kernel/vm.c:229)用kalloc分配物理内存,并用mappages将PTE添加到用户页表中。uvmdealloc调用uvmunmap(kernel/vm.c:174),uvmunmap使用walk来查找对应的PTE,并使用kfree来释放PTE引用的物理内存。

Print a page table (easy)

Define a function called vmprint(). It should take a pagetable_t argument, and print that pagetable in the format described below. Insert if(p->pid==1) vmprint(p->pagetable) in exec.c just before the return argc, to print the first process’s page table. You receive full credit for this assignment if you pass the pte printout test of make grade.

打印一个页表的内容,代码如下:

char buf[3][12] = {"..", ".. ..", ".. .. .."};

void vmprint(pagetable_t pagetable, int depth)
{
  if(depth > 2) return;
  char *str;
  str = buf[depth];
  if(depth == 0)
    printf("page table %p\n", pagetable);
  for(int i = 0; i < 512; i++){
    pte_t pte = pagetable[i];
    if(pte & PTE_V){
      uint64 child = PTE2PA(pte);
      printf("%s%d: pte %p pa %p\n", str, i, pte, child);
      vmprint((pagetable_t)child, depth+1);
    }
  }

}

A kernel page table per process (hard)

Xv6的内核页表只有一个内核页表,该表在内核中执行使用。内核页表直接映射到物理地址。每个进程的用户地址空间有一个单独的页表,包含该进程的用户内存映射,从虚拟地址零开始。由于内核页表不包含这些映射,因此用户地址在内核中无效。 因此,当内核需要使用在系统调用中传递的用户指针,本节和下一节的目标是允许内核直接取消引用用户指针。也就是当用户进程陷入内核态后,每个进程有独立的内核页表。

提示:

  • Add a field to struct proc for the process’s kernel page table.
  • A reasonable way to produce a kernel page table for a new process is to implement a modified version of kvminit that makes a new page table instead of modifying kernel_pagetable. You’ll want to call this function from allocproc.
  • Make sure that each process’s kernel page table has a mapping for that process’s kernel stack. In unmodified xv6, all the kernel stacks are set up in procinit. You will need to move some or all of this functionality to allocproc.
  • Modify scheduler() to load the process’s kernel page table into the core’s satp register (see kvminithart for inspiration). Don’t forget to call sfence_vma() after calling w_satp().
  • scheduler() should use kernel_pagetable when no process is running.
  • Free a process’s kernel page table in freeproc.
  • You’ll need a way to free a page table without also freeing the leaf physical memory pages.
  • vmprint may come in handy to debug page tables.
  • It’s OK to modify xv6 functions or add new functions; you’ll probably need to do this in at least kernel/vm.c and kernel/proc.c. (But, don’t modify kernel/vmcopyin.c, kernel/stats.c, user/usertests.c, and user/stats.c.)
  • A missing page table mapping will likely cause the kernel to encounter a page fault. It will print an error that includes sepc=0x00000000XXXXXXXX. You can find out where the fault occurred by searching for XXXXXXXX in kernel/kernel.asm.

首先根据第一条提示,在进程控制块的结构体中添加一个kernel_pagatable

// proc.h
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)
  pagetable_t kernel_pagetable; // kernel pagetable,新添加的内容
};

第二条提示说的是对于每一个进程,陷入内核后好的办法是创建一个新的kernel_pagetable而不是修改原来共享的kernel_pagetable,就是参考kvminit进行修改

原来的kvminit函数内容创建了一个直接映射的共享的kernel_pagetable。内核需要依赖内核页表内一些固定的映射的存在才能正常工作,例如 UART 控制、硬盘界面、中断控制等。而 kvminit 原本只为全局内核页表 kernel_pagetable 添加这些映射。

void kvminit()
{
  kernel_pagetable = (pagetable_t) kalloc();
  memset(kernel_pagetable, 0, PGSIZE);

  // uart registers
  kvmmap(UART0, UART0, PGSIZE, PTE_R | PTE_W);

  // virtio mmio disk interface
  kvmmap(VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

  // CLINT
  kvmmap(CLINT, CLINT, 0x10000, PTE_R | PTE_W);

  // PLIC
  kvmmap(PLIC, PLIC, 0x400000, PTE_R | PTE_W);

  // map kernel text executable and read-only.
  kvmmap(KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

  // map kernel data and the physical RAM we'll make use of.
  kvmmap((uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);

  // map the trampoline for trap entry/exit to
  // the highest virtual address in the kernel.
  kvmmap(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

由于每个新的进程陷入内核后都需要创建一个新的kernel_pagetable,在创建新的kernel_pagetable时,同样需要完成相关固定的映射,模仿这个函数,进行实现

void kvm_map_pagetable(pagetable_t pgtbl)
{

  // uart registers
  kvmmap(pgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);

  // virtio mmio disk interface
  kvmmap(pgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

  // CLINT
  kvmmap(pgtbl, CLINT, CLINT, 0x10000, PTE_R | PTE_W);

  // PLIC
  kvmmap(pgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);

  // map kernel text executable and read-only.
  kvmmap(pgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

  // map kernel data and the physical RAM we'll make use of.
  kvmmap(pgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);

  // map the trampoline for trap entry/exit to
  // the highest virtual address in the kernel.
  kvmmap(pgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

// 同时需要修改kvmmap函数
// 这个函数的功能是实现映射到kernel_pagetable
void kvmmap(pagetable_t pgtbl, uint64 va, uint64 pa, uint64 sz, int perm)
{
  if(mappages(pgtbl, va, sz, pa, perm) != 0)
    panic("kvmmap");
}

在陷入内核时,需要为进程分配新的页面,存放内核页表

// 创建新的内核页表
pagetable_t kvminit_newpgtbl()
{
  pagetable_t pgtbl = (pagetable_t) kalloc();
  memset(pgtbl, 0, PGSIZE);
  kvm_map_pagetable(pgtbl);
  return pgtbl;
}

同时,由于修改了原来对共享kernel_pagetable的初始化等操作,需要对共享的kernel_pagetable进行初始化

void kvminit()
{
  kernel_pagetable = kvminit_newpgtbl();
}

第三条提示表示每个进程独立的内核页表需要有独立的内核栈,在原来的xv6设计中,所有处于内核态的进程都共享了同一个页表,就意味着共享同一个地址空间。提示说明修改 procinit中的内容,然后,将部分内容实现到分配进程时的函数allocproc

// initialize the proc table at boot time.
// 在启动时初始化进程页表
void procinit(void)
{
  struct proc *p;
  
  initlock(&pid_lock, "nextpid");
  for(p = proc; p < &proc[NPROC]; p++) {
      initlock(&p->lock, "proc");

      // Allocate a page for the process's kernel stack.
      // Map it high in memory, followed by an invalid
      // guard page.
      // 这一部分删去了,原来是共享内核页表的,启动时分配一个内核页表就行了
      // 现在需要在进程进入内核创建独立内核页表时进行分配,移到allocproc中实现
      
  }
  kvminithart();
}

// Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
static struct proc* allocproc(void)
{
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state == UNUSED) {
      goto found;
    } else {
      release(&p->lock);
    }
  }
  return 0;

found:
  p->pid = allocpid();

  // Allocate a trapframe page.
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    release(&p->lock);
    return 0;
  }

  // An empty user page table.
  p->pagetable = proc_pagetable(p);
  if(p->pagetable == 0){
    freeproc(p);
    release(&p->lock);
    return 0;
  }
  // 创建独立的内核页表
  p->kernel_pagetable = kvminit_newpgtbl();
  char *pa = kalloc();
  if(pa == 0)
   panic("kalloc");
  uint64 va = KSTACK((int)0); // 内核栈地址
  // 将内核栈映射到内核页表
  kvmmap(p->kernel_pagetable, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
  p->kstack = va;
  // Set up new context to start executing at forkret,
  // which returns to user space.
  memset(&p->context, 0, sizeof(p->context));
  p->context.ra = (uint64)forkret;
  p->context.sp = p->kstack + PGSIZE;

  return p;
}

第四条提示是让实现,在进程进入内核时,切换到内核页表

// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// Scheduler never returns.  It loops, doing:
//  - choose a process to run.
//  - swtch to start running that process.
//  - eventually that process transfers control
//    via swtch back to the scheduler.
void scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();
  
  c->proc = 0;
  for(;;){
    // Avoid deadlock by ensuring that devices can interrupt.
    intr_on();
    
    int found = 0;
    for(p = proc; p < &proc[NPROC]; p++) {
      acquire(&p->lock);
      if(p->state == RUNNABLE) {
        // Switch to chosen process.  It is the process's job
        // to release its lock and then reacquire it
        // before jumping back to us.
        p->state = RUNNING;
        c->proc = p;
		// 切换到内核页表
        w_satp(MAKE_SATP(p->kernel_pagetable));
        sfence_vma();
        swtch(&c->context, &p->context);
        kvminithart();
        // Process is done running for now.
        // It should have changed its p->state before coming back.
        c->proc = 0;

        found = 1;
      }
      release(&p->lock);
    }
#if !defined (LAB_FS)
    if(found == 0) {
      intr_on();
      asm volatile("wfi");
    }
#else
    ;
#endif
  }
}

最后,进程从内核态退出后,需要释放独立的内核页表,这里模仿freewalk函数实现清空页表。

// 清空内核页表
void kvm_free_kernelpgtbl(pagetable_t pgtbl)
{
  for(int i = 0; i < 512; i++){
    pte_t pte = pgtbl[i];
    if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){
      // this PTE points to a lower-level page table.
      uint64 child = PTE2PA(pte);
      kvm_free_kernelpgtbl((pagetable_t)child);
      pgtbl[i] = 0;
    }
  }
  kfree((void*)pgtbl);
}

// 释放
static void
freeproc(struct proc *p)
{
  if(p->trapframe)
    kfree((void*)p->trapframe);
  p->trapframe = 0;
  if(p->pagetable)
    proc_freepagetable(p->pagetable, p->sz);
  p->pagetable = 0;
  p->sz = 0;
  p->pid = 0;
  p->parent = 0;
  p->name[0] = 0;
  p->chan = 0;
  p->killed = 0;
  p->xstate = 0;
  void *kstack_pa = (void *)kvmpa(p->kernel_pagetable, p->kstack);
  kfree(kstack_pa);
  p->kstack = 0;
  kvm_free_kernelpgtbl(p->kernel_pagetable);
  p->kernel_pagetable = 0;
  p->state = UNUSED;
}

由于修改了kvmpa(实现虚拟地址到物理地址的转化),需要修改virtio_disk.c中调用这个函数的地方


Simplify copyin/copyinstr (hard)

The kernel’s copyin function reads memory pointed to by user pointers. It does this by translating them to physical addresses, which the kernel can directly dereference. It performs this translation by walking the process page-table in software. Your job in this part of the lab is to add user mappings to each process’s kernel page table (created in the previous section) that allow copyin (and the related string function copyinstr) to directly dereference user pointers.

Replace the body of copyin in kernel/vm.c with a call to copyin_new (defined in kernel/vmcopyin.c); do the same for copyinstr and copyinstr_new. Add mappings for user addresses to each process’s kernel page table so that copyin_new and copyinstr_new work. You pass this assignment if usertests runs correctly and all the make grade tests pass.

提示:

  • Replace copyin() with a call to copyin_new first, and make it work, before moving on to copyinstr.
  • At each point where the kernel changes a process’s user mappings, change the process’s kernel page table in the same way. Such points include fork(), exec(), and sbrk().
  • Don’t forget that to include the first process’s user page table in its kernel page table in userinit.
  • What permissions do the PTEs for user addresses need in a process’s kernel page table? (A page with PTE_U set cannot be accessed in kernel mode.)
  • Don’t forget about the above-mentioned PLIC limit.

在上一个实验中,每一个进程都拥有了独立的内核页表。这个实验的目的是在内核页表中维护一个用户态页表的副本,使内核态可以对用户态传进来的指针进行解引用。原来的copyin是通过软件模拟访问页表的过程获取物理地址的,在内核页表映射副本后,可以利用CPU的硬件寻址功能进行寻址。

为了实现将用户态页表映射到内核态页表,需要实现一个将用户态页表项复制到内核态页表的函数,实现如下:

// 将src页表的映射关系拷贝到dst页表中,只拷贝页表项,不拷贝实际的物理内存
int user_kernel_cpoy(pagetable_t src, pagetable_t dst, uint64 start, uint64 sz)
{
  pte_t *pte;
  uint64 pa, i;
  uint flags;

  for(i = PGROUNDUP(start); i < start + sz; i += PGSIZE){
    if((pte = walk(src, i, 0)) == 0)
      panic("user_kernel_copy: pte should exist");
    if((*pte & PTE_V) == 0)
      panic("user_kernel_copy: page not present");
    pa = PTE2PA(*pte);
    flags = PTE_FLAGS(*pte) & (~PTE_U); // 将该页的权限设置为非用户页,在RISC-V中内核无法直接访问用户页
    if(mappages(dst, i, PGSIZE, pa, flags) != 0){
      goto err;
    }
  }
  return 0;

 err:
  uvmunmap(dst, 0, i / PGSIZE, 1);
  return -1;
}
// 实现将内从从oldsz缩减到newsz,和uvmdealloc类似
// uvmunmap功能是移除相关的映射,官方注释如下
// Remove npages of mappings starting from va. va must be
// page-aligned. The mappings must exist.
// Optionally free the physical memory.
uint64 kvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz)
{
  if(newsz >= oldsz)
    return oldsz;

  if(PGROUNDUP(newsz) < PGROUNDUP(oldsz)){
    int npages = (PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE;
    uvmunmap(pagetable, PGROUNDUP(newsz), npages, 0);
  }

  return newsz;
}

内核启动后,能够用于映射内存的地址范围是[0, PLIC),因此在实现内存映射到用户态页表时,需要在这个范围内映射,且不能和其他映射发生冲突。

但是在内核启动时,有一个CLINT(核心本地中断器)的映射,用户进程在内核态中的操作不会用到该映射,因此需要修改实现映射的函数,以及全局内核态页表初始化的函数

void kvm_map_pagetable(pagetable_t pgtbl)
{

  // uart registers
  kvmmap(pgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);

  // virtio mmio disk interface
  kvmmap(pgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);

  // CLINT,注释掉
  // kvmmap(pgtbl, CLINT, CLINT, 0x10000, PTE_R | PTE_W);

  // PLIC
  kvmmap(pgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);

  // map kernel text executable and read-only.
  kvmmap(pgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);

  // map kernel data and the physical RAM we'll make use of.
  kvmmap(pgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);

  // map the trampoline for trap entry/exit to
  // the highest virtual address in the kernel.
  kvmmap(pgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}

// 全局内核页表初始化
void kvminit()
{
  kernel_pagetable = kvminit_newpgtbl();
  // 需要实现这个映射
  kvmmap(kernel_pagetable, CLINT, CLINT, 0x10000, PTE_R | PTE_W);
}

根据提示内容,需要确保进程执行时不能超过映射的内存范围PLIC,在exec.c中加入相关检验的功能

// exec.c, exec函数中加入
if(sz1 >= PLIC)
      goto bad;

第二条提示是说明需要在fork()、exec()、growproc()中实现同步映射到内核页表

fork

// Create a new process, copying the parent.
// Sets up child kernel stack to return as if from fork() system call.
int
fork(void)
{
  int i, pid;
  struct proc *np;
  struct proc *p = myproc();

  // Allocate process.
  if((np = allocproc()) == 0){
    return -1;
  }

  // Copy user memory from parent to child.
  // fork创建了一个新的进程,需要将新进程中用户态内容同步到对应的内核态页表中
  if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0 || user_kernel_cpoy(np->pagetable, np->kernel_pagetable, 0, p->sz) < 0){
    freeproc(np);
    release(&np->lock);
    return -1;
  }
  np->sz = p->sz;

  np->parent = p;

  // copy saved user registers.
  *(np->trapframe) = *(p->trapframe);

  // Cause fork to return 0 in the child.
  np->trapframe->a0 = 0;

  // increment reference counts on open file descriptors.
  for(i = 0; i < NOFILE; i++)
    if(p->ofile[i])
      np->ofile[i] = filedup(p->ofile[i]);
  np->cwd = idup(p->cwd);

  safestrcpy(np->name, p->name, sizeof(p->name));

  pid = np->pid;

  np->state = RUNNABLE;

  release(&np->lock);

  return pid;
}

exec

int
exec(char *path, char **argv)
{
    // ...
    // arguments to user main(argc, argv)
  // argc is returned via the system call return
  // value, which goes in a0.
  p->trapframe->a1 = sp;

  // Save program name for debugging.
  for(last=s=path; *s; s++)
    if(*s == '/')
      last = s+1;
  safestrcpy(p->name, last, sizeof(p->name));
  uvmunmap(p->kernel_pagetable, 0, PGROUNDUP(oldsz) / PGSIZE, 0);
  user_kernel_cpoy(pagetable, p->kernel_pagetable, 0, sz);
    
  // Commit to the user image.
  oldpagetable = p->pagetable;
  p->pagetable = pagetable;
  p->sz = sz;
  p->trapframe->epc = elf.entry;  // initial program counter = main
  p->trapframe->sp = sp; // initial stack pointer
  proc_freepagetable(oldpagetable, oldsz);
  // ...
}

growproc()

// Grow or shrink user memory by n bytes.
// Return 0 on success, -1 on failure.
int
growproc(int n)
{
  uint sz;
  struct proc *p = myproc();

  sz = p->sz;
  if(n > 0){
    uint64 newsz;
    if(PGROUNDUP(sz + n) >= PLIC)
      return -1;
    if((newsz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
      return -1;
    }
    // 内核页表中的映射也要增大或缩小
    if(user_kernel_cpoy(p->pagetable, p->kernel_pagetable, sz, n) != 0){
      uvmdealloc(p->pagetable, newsz, sz);
      return -1;
    }
    sz = newsz;
  } else if(n < 0){
    uvmdealloc(p->pagetable, sz, sz + n);
    sz = kvmdealloc(p->kernel_pagetable, sz, sz + n);
  }
  p->sz = sz;
  return 0;
}

第三条提示是说第一个用户进程同样需要将用户态页表同步映射到内核态页表,需要在userinit中实现

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

  p = allocproc();
  initproc = p;
  
  // allocate one user page and copy init's instructions
  // and data into it.
  uvminit(p->pagetable, initcode, sizeof(initcode));
  p->sz = PGSIZE;
  // 将第一个用户进程同步映射到内核态页表
  user_kernel_cpoy(p->pagetable, p->kernel_pagetable, 0, p->sz);

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

最后按照提示,修改copyincopyinstr就行了。

至此,lab3页表结束了,震撼啊。

Lab4: traps

基础知识

系统调用、异常和中断

有三种事件会导致CPU搁置普通指令的执行,强制将控制权转移给处理该事件的特殊代码。一种情况是系统调用,当用户程序执行ecall指令要求内核为其做某事时。另一种情况是异常:一条指令(用户或内核)做了一些非法的事情,如除以零或使用无效的虚拟地址。第三种情况是设备中断,当一个设备发出需要注意的信号时,例如当磁盘硬件完成一个读写请求时。

xv6 book使用trap作为这些情况的通用术语。通常,代码在执行时发生trap,之后都会被恢复,而且不需要意识到发生了什么特殊的事情。也就是说,我们通常希望trap是透明的;这一点对于中断来说尤其重要,被中断的代码通常不会意识到会发生trap。通常的顺序是:trap迫使控制权转移到内核;内核保存寄存器和其他状态,以便恢复执行;内核执行适当的处理程序代码(例如,系统调用实现或设备驱动程序);内核恢复保存的状态,并从trap中返回;代码从原来的地方恢复执行。

Xv6 trap 处理分为四个阶段:RISC-V CPU采取的硬件行为,为内核C代码准备的汇编入口,处理trap的C 处理程序,以及系统调用或设备驱动服务。


RISC-V trap machinery

每个RISC-V CPU都有一组控制寄存器,内核写入这些寄存器来告诉CPU如何处理trap,内核可以通过读取这些寄存器来发现已经发生的trap。RISC-V文档包含了完整的叙述[1]。riscv.h(kernel/riscv.h:1)包含了xv6使用的定义。这里是最重要的寄存器的概述。

  • stvec:内核在这里写下trap处理程序的地址;RISC-V跳转到这里来处理trap。
  • sepc:当trap发生时,RISC-V会将程序计数器保存在这里(因为PC会被stvec覆盖)。sret(从trap中返回)指令将sepc复制到pc中。内核可以写sepc来控制sret的返回到哪里。
  • scause:RISC -V在这里放了一个数字,描述了trap的原因。
  • sscratch:内核在这里放置了一个值,在trap处理程序开始时可以方便地使用。
  • sstatussstatus中的SIE位控制设备中断是否被启用,如果内核清除SIE,RISC-V将推迟设备中断,直到内核设置SIESPP位表示trap是来自用户模式还是supervisor模式,并控制sret返回到什么模式。

当需要执行trap时,RISC-V硬件对所有的trap类型(除定时器中断外)进行以下操作:

  1. 如果该trap是设备中断,且sstatus SIE位为0,则不执行以下任何操作。
  2. 通过清除SIE来禁用中断。
  3. 复制pcsepc
  4. 将当前模式(用户态或特权态)保存在sstatusSPP位。
  5. scause设置该次trap的原因。
  6. 将模式转换为特权态。
  7. stvec复制到pc
  8. 从新的pc开始执行。

注意,CPU不会切换到内核页表,不会切换到内核中的栈,也不会保存pc以外的任何寄存器。内核软件必须执行这些任务。


Traps from user space

在用户空间执行时,如果用户程序进行了系统调用(ecall指令),或者做了一些非法的事情,或者设备中断,都可能发生trap。来自用户空间的trap的处理路径是uservec(kernel/trampoline.S:16),然后是usertrap(kernel/trap.c:37);返回时是usertrapret(kernel/trap.c:90),然后是userret(kernel/trampoline.S:16)。

RISC-V硬件在trap过程中不切换页表,所以用户页表必须包含uservec的映射,即stvec指向的trap处理程序地址。uservec必须切换satp,使其指向内核页表;为了在切换后继续执行指令,uservec必须被映射到内核页表与用户页表相同的地址。

Xv6用一个包含uservec的trampoline页来满足这些条件。Xv6在内核页表和每个用户页表中的同一个虚拟地址上映射了trampoline页。这个虚拟地址就是TRAMPOLINE(如我们在图2.3和图3.3中看到的)。trampoline.S中包含trampoline的内容,(执行用户代码时)stvec设置为uservec(kernel/trampoline.S:16)。

uservec启动时,所有32个寄存器都包含被中断的代码所拥有的值。但是uservec需要能够修改一些寄存器,以便设置satp和生成保存寄存器的地址。RISC-V通过sscratch寄存器提供了帮助。uservec开始时的csrrw指令将a0sscratch的内容互换。现在用户代码的a0被保存了;uservec有一个寄存器(a0)可以使用;a0包含了内核之前放在sscratch中的值。

uservec的下一个任务是保存用户寄存器。在进入用户空间之前,内核先设置sscratch指向该进程的trapframe,这个trapframe可以保存所有用户寄存器(kernel/proc.h:44)。因为satp仍然是指用户页表,所以uservec需要将trapframe映射到用户地址空间中。当创建每个进程时,xv6为进程的trapframe分配一页内存,并将它映射在用户虚拟地址TRAPFRAME,也就是TRAMPOLINE的下面。进程的p->trapframe也指向trapframe,不过是指向它的物理地址[1],这样内核可以通过内核页表来使用它。

因此,在交换a0sscratch后,a0将指向当前进程的trapframeuservec将在trapframe保存全部的寄存器,包括从sscratch读取的a0

trapframe包含指向当前进程的内核栈、当前CPU的hartid、usertrap的地址和内核页表的地址的指针,uservec将这些值设置到相应的寄存器中,并将satp切换到内核页表和刷新TLB,然后调用usertrap

usertrap的作用是确定trap的原因,处理它,然后返回(kernel/ trap.c:37)。如上所述,它首先改变stvec,这样在内核中发生的trap将由kernelvec处理。它保存了sepc(用户PC),这也是因为usertrap中可能会有一个进程切换,导致sepc被覆盖。如果trap是系统调用,syscall会处理它;如果是设备中断,devintr会处理;否则就是异常,内核会杀死故障进程。usertrap会把用户pc加4,因为RISC-V在执行系统调用时,会留下指向ecall指令的程序指针[2]。在退出时,usertrap检查进程是否已经被杀死或应该让出CPU(如果这个trap是一个定时器中断)。

回到用户空间的第一步是调用usertrapret(kernel/trap.c:90)。这个函数设置RISC-V控制寄存器,为以后用户空间trap做准备。这包括改变stvec来引用uservec,准备uservec所依赖的trapframe字段,并将sepc设置为先前保存的用户程序计数器。最后,usertrapret在用户页表和内核页表中映射的trampoline页上调用userret,因为userret中的汇编代码会切换页表。

usertrapretuserret的调用传递了参数a0a1a0指向TRAPFRAMEa1指向用户进程页表(kernel/trampoline.S:88),userretsatp切换到进程的用户页表。回想一下,用户页表同时映射了trampoline页和TRAPFRAME,但没有映射内核的其他内容。同样,事实上,在用户页表和内核页表中,trampoline页被映射在相同的虚拟地址上,这也是允许uservec在改变satp后继续执行的原因。userrettrapframe中保存的用户a0复制到sscratch中,为以后与TRAPFRAME交换做准备。从这时开始,userret能使用的数据只有寄存器内容和trapframe的内容。接下来userret从trapframe中恢复保存的用户寄存器,对a0sscratch做最后的交换,恢复用户a0并保存TRAPFRAME,为下一次trap做准备,并使用sret返回用户空间。

我的理解就是用户态和内核态共享了两个页,分别是trapoline和trapfram,前者实现trap时对指令的访问,后者实现trap时保存相关参数,实现保护作用。


Code: System call arguments

内核的系统调用实现需要找到用户代码传递的参数。因为用户代码调用系统调用的包装函数,参数首先会存放在寄存器中,这是C语言存放参数的惯例位置。内核trap代码将用户寄存器保存到当前进程的trap frame中,内核代码可以在那里找到它们。函数argintargaddrargfd从trap frame中以整数、指针或文件描述符的形式检索第n个系统调用参数。它们都调用argraw来获取保存的用户寄存器(kernel/syscall.c:35)。

内核实现了安全地将数据复制到用户提供的地址或从用户提供的地址复制数据的函数。例如fetchstr(kernel/syscall.c:25)。文件系统调用,如exec,使用fetchstr从用户空间中检索字符串文件名参数。fetchstr调用copyinstr来做这些困难的工作。

copyinstr(kernel/vm.c:406)将用户页表pagetable中的虚拟地址srcva复制到dst,需指定最大复制字节数。它使用walkaddr(调用walk函数)在软件中模拟分页硬件的操作,以确定srcva的物理地址pa0walkaddr(kernel/vm.c:95)检查用户提供的虚拟地址是否是进程用户地址空间的一部分,所以程序不能欺骗内核读取其他内存。类似的函数copyout,可以将数据从内核复制到用户提供的地址。


Traps from kernel space

Xv6根据用户还是内核代码正在执行,对CPU陷阱寄存器的配置略有不同行为。当内核在CPU上执行时,内核将stvec指向kernelvec上的汇编代码(kernel/kernelvec.S:10)。由于xv6已经在内核中,kernelvec可以使用satp,将其设置为内核页表,以及引用有效内核的堆栈指针。kernelvec保存所有寄存器,以便中断的代码最后可以在没有中断的情况下恢复。

kernelvec将寄存器保存在中断内核线程的堆栈上,因为寄存器值属于该线程,这是合理的。如果trap导致切换到另一个线程—在这种情况下,trap将实际返回到新线程的栈上,将中断线程保存的寄存器安全地保留在其堆栈上。

kernelvec在保存寄存器后跳转到kerneltrap(kernel/trap.c:134)。kerneltrap是为两种类型的陷阱准备的:设备中断和异常。它调用devintr(kernel/trap.c:177)来检查和处理前者。如果trap不是设备中断,那么它必须是异常,如果它发生在xv6内核中,则一定是一个致命错误;内核调用panic并停止执行。

**如果由于计时器中断而调用了kerneltrap,并且进程的内核线程正在运行(而不是调度程序线程),kerneltrap调用yield让出CPU,允许其他线程运行。在某个时刻,其中一个线程将退出,并让我们的线程及其kerneltrap恢复。**第7章解释了线程让出CPU控制权。

kerneltrap的工作完成时,它需要返回到被中断的代码。因为yield可能破坏保存的sepc和在sstatus中保存的之前的模式。kerneltrap在启动时保存它们。它现在恢复那些控制寄存器并返回到kernelvec(kernel/kernelvec.S:48)。kernelvec从堆栈恢复保存的寄存器并执行sretsretsepc复制到pc并恢复中断的内核代码。

可以思考一下,如果因为时间中断,kerneltrap调用了yield,trap return是如何发生的。

当CPU从用户空间进入内核时,Xv6将CPU的stvec设置为kernelvec;可以在usertrap(kernel/trap.c:29)中看到这一点。内核运行但stvec被设置为uservec时,这期间有一个时间窗口,在这个窗口期,禁用设备中断是至关重要的。幸运的是,RISC-V总是在开始使用trap时禁用中断,xv6在设置stvec之前不会再次启用它们。


Page-fault exceptions

Xv6对异常的响应是相当固定:如果一个异常发生在用户空间,内核就会杀死故障进程。如果一个异常发生在内核中,内核就会panic。真正的操作系统通常会以更有趣的方式进行响应。

许多内核使用页面故障来实现*写时复制(copy-on-write,cow)*fork。要解释写时复制fork,可以想一想xv6的fork,在第3章中介绍过。fork通过调用uvmcopy(kernel/vm.c:309)为子进程分配物理内存,并将父进程的内存复制到子程序中,使子进程拥有与父进程相同的内存内容。如果子进程和父进程能够共享父进程的物理内存,效率会更高。然而,直接实现这种方法是行不通的,因为父进程和子进程对共享栈和堆的写入会中断彼此的执行。

通过使用写时复制fork,可以让父进程和子进程安全地共享物理内存,通过页面故障来实现。当CPU不能将虚拟地址翻译成物理地址时,CPU会产生一个页面故障异常(page-fault exception)。 RISC-V有三种不同的页故障:load页故障(当加载指令不能翻译其虚拟地址时)、stote页故障(当存储指令不能翻译其虚拟地址时)和指令页故障(当指令的地址不能翻译时)。scause寄存器中的值表示页面故障的类型,stval寄存器中包含无法翻译的地址。

COW fork中的基本设计是父进程和子进程最初共享所有的物理页面,但将它们映射设置为只读。因此,当子进程或父进程执行store指令时,RISC-V CPU会引发一个页面故障异常。作为对这个异常的响应,内核会拷贝一份包含故障地址的页。然后将一个副本的读/写映射在子进程地址空间,另一个副本的读/写映射在父进程地址空间。更新页表后,内核在引起故障的指令处恢复故障处理。因为内核已经更新了相关的PTE,允许写入,所以现在故障指令将正常执行。

这个COW设计对fork很有效,因为往往子程序在fork后立即调用exec,用新的地址空间替换其地址空间。在这种常见的情况下,子程序只会遇到一些页面故障,而内核可以避免进行完整的复制。此外,COW fork是透明的:不需要对应用程序进行修改,应用程序就能受益。

页表和页故障的结合,将会有更多种有趣的可能性的应用。另一个被广泛使用的特性叫做懒分配 (lazy allocation),它有两个部分。首先,当一个应用程序调用sbrk时,内核会增长地址空间,但在页表中把新的地址标记为无效。第二,当这些新地址中的一个出现页面故障时,内核分配物理内存并将其映射到页表中。由于应用程序经常要求获得比他们需要的更多的内存,所以懒分配是一个胜利:内核只在应用程序实际使用时才分配内存。像COW fork一样,内核可以对应用程序透明地实现这个功能。

另一个被广泛使用的利用页面故障的功能是从磁盘上分页(paging from disk)。如果应用程序需要的内存超过了可用的物理RAM,内核可以交换出一些页:将它们写入一个存储设备,比如磁盘,并将其PTE标记为无效。如果一个应用程序读取或写入一个被换出到磁盘的页,CPU将遇到一个页面故障。内核就可以检查故障地址。如果该地址属于磁盘上的页面,内核就会分配一个物理内存的页面,从磁盘上读取页面到该内存,更新PTE为有效并引用该内存,然后恢复应用程序。为了给该页腾出空间,内核可能要交换另一个页。这个特性不需要对应用程序进行任何修改,如果应用程序具有引用的位置性(即它们在任何时候都只使用其内存的一个子集),这个特性就能很好地发挥作用。


RISC-V assembly (easy)

这个实验不用写代码,目的是让我们熟悉RISC-V的一些汇编指令、寄存器等。然后回答给的问题。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-97RCbViu-1678767207987)(杂货铺/image-20230313173632226.png)]

Q: Which registers contain arguments to functions? For example, which register holds 13 in main’s call to printf?

A: a0-a7, a2

Q: Where is the call to function f in the assembly code for main? Where is the call to g? (Hint: the compiler may inline functions.)

A: 没有这样的代码。 g(x) 被内链到 f(x) 中,然后 f(x) 又被进一步内链到 main() 中

Q: At what address is the function printf located?

A: 0x0000000000000628, main 中使用 pc 相对寻址来计算得到这个地址。

Q: What value is in the register ra just after the jalr to printf in main?

A: 0x0000000000000038, jalr 指令的下一条汇编指令的地址。

Q: Run the following code.

​ unsigned int i = 0x00646c72;

​ printf(“H%x Wo%s”, 57616, &i);

What is the output? If the RISC-V were instead big-endian what would you set i to in order to yield the same output? Would you need to change 57616 to a different value?

A: “He110 World”; 0x726c6400; 不需要,57616 的十六进制是 110,无论端序(十六进制和内存中的表示不是同个概念)

Q: In the following code, what is going to be printed after ‘y=’? (note: the answer is not a specific value.) Why does this happen?

​ printf(“x=%d y=%d”, 3);

A: 输出的是一个受调用前的代码影响的“随机”的值。因为 printf 尝试读的参数数量比提供的参数数量多。 第二个参数 3 通过 a1 传递,而第三个参数对应的寄存器 a2 在调用前不会被设置为任何具体的值,而是会 包含调用发生前的任何已经在里面的值。

Backtrace (moderate)

For debugging it is often useful to have a backtrace: a list of the function calls on the stack above the point at which the error occurred.

Implement a backtrace() function in kernel/printf.c. Insert a call to this function in sys_sleep, and then run , which calls sys_sleep. Your output should be as follows: bttest

backtrace:
0x0000000080002cda
0x0000000080002bb6
0x0000000080002898

After bttest exit qemu. In your terminal: the addresses may be slightly different but if you run addr2line -e kernel/kernel (or riscv64-unknown-elf-addr2line -e kernel/kernel) and cut-and-paste the above addresses as follows: You should see something like this:

 $ addr2line -e kernel/kernel
 0x0000000080002de2
 0x0000000080002f4a
 0x0000000080002bfc
 Ctrl-D

 kernel/sysproc.c:74
 kernel/syscall.c:224
 kernel/trap.c:85

提示:

  • Add the prototype for backtrace to kernel/defs.h so that you can invoke backtrace in sys_sleep.

  • The GCC compiler stores the frame pointer of the currently executing function in the register s0. Add the following function to kernel/riscv.h:

    static inline uint64
    r_fp()
    {
      uint64 x;
      asm volatile("mv %0, s0" : "=r" (x) );
      return x;
    }
    

    and call this function in backtrace to read the current frame pointer. This function uses

    in-line assembly to read s0.

  • These lecture notes have a picture of the layout of stack frames. Note that the return address lives at a fixed offset (-8) from the frame pointer of a stackframe, and that the saved frame pointer lives at fixed offset (-16) from the frame pointer.

  • Xv6 allocates one page for each stack in the xv6 kernel at PAGE-aligned address. You can compute the top and bottom address of the stack page by using PGROUNDDOWN(fp) and PGROUNDUP(fp) (see kernel/riscv.h. These number are helpful for backtrace to terminate its loop.

Once your backtrace is working, call it from panic in kernel/printf.c so that you see the kernel’s backtrace when it panics.

这个实验的目的是让我们了解程序调用的过程,对进程的栈的结构有所了解。

提示中给了一个栈的示意图:

XV6实验(2020)_第9张图片

fp指向当前栈帧的开始地址,sp指向当前栈帧的结束地址,栈从高地址向低地址增长。

栈帧中从高地址到低地址第一个8字节fp-8是return address, 当前调用层的返回地址

栈帧中从高地址到低地址第二个8字节fp-16是previous adress, 即调用当前函数的上一层栈帧的fp开始地址。(这也是第三条提示的内容)

剩下的为保存的寄存器、局部变量等。一个栈帧的大小不固定,但是至少 16 字节。
在 xv6 中,使用一个页来存储栈,如果 fp 已经到达栈页的上界,则说明已经到达栈底

因此这个实验的目的就是追踪当前执行栈的上一层。打印对应的返回地址。

第二条提示说明了如何获取当前栈帧的fp, 当前执行程序栈帧地址在寄存器s0中。

static inline uint64
r_fp()
{
  uint64 x;
  asm volatile("mv %0, s0" : "=r" (x) );
  return x;
}

最后一条提示说明了每个栈会分配一个页面,可以通过 PGROUNDDOWN(fp)PGROUNDUP(fp)获取fp页面的最高和最低地址,只有当fp在这个范围内时才是合法的。以此可以实现循环返回调用的上一层。

backtrace实现如下:

void backtrace(void)
{
  uint64 fp = r_fp();
  while(fp != PGROUNDUP(fp))
  {
    uint64 ra = *(uint64 *)(fp - 8); // 当前执行的栈帧中的返回地址
    printf("%p\n", ra);
    fp = *(uint64 *)(fp - 16); // 进入上一层栈
  }
}

Alarm (hard)

In this exercise you’ll add a feature to xv6 that periodically alerts a process as it uses CPU time. This might be useful for compute-bound processes that want to limit how much CPU time they chew up, or for processes that want to compute but also want to take some periodic action. More generally, you’ll be implementing a primitive form of user-level interrupt/fault handlers; you could use something similar to handle page faults in the application, for example. Your solution is correct if it passes alarmtest and usertests.

这个实验目的是利用时钟中断计时,根据sigalarm中的参数,每隔一定时间输出alarm。

XV6实验(2020)_第10张图片

基础知识中已经对trap机制有了大概的了解,现在再深入了解一下trap机制。

在trap过程中有很多特殊的寄存器,如下边几种:

  • 在硬件中还有一个寄存器叫做程序计数器(Program Counter Register)。

  • 表明当前mode的标志位,这个标志位表明了当前是supervisor mode还是user mode。当我们在运行Shell的时候,自然是在user mode。

  • 还有一堆控制CPU工作方式的寄存器,比如SATP(Supervisor Address Translation and Protection)寄存器,它包含了指向page table的物理内存地址(详见4.3)。

  • 还有一些对于今天讨论非常重要的寄存器,比如STVEC(Supervisor Trap Vector Base Address Register)寄存器,它指向了内核中处理trap的指令的起始地址。

  • SEPC(Supervisor Exception Program Counter)寄存器,在trap的过程中保存程序计数器的值,保存当发生trap时的程序计数器。

  • SSRATCH(Supervisor Scratch Register)寄存器

以官方讲稿的write调用为例:

write通过执行ECALL指令来执行系统调用。ECALL指令会切换到具有supervisor mode的内核中。在这个过程中,内核中执行的第一个指令是一个由汇编语言写的函数,叫做uservec。这个函数是内核代码trampoline.s文件的一部分。所以执行的第一个代码就是这个uservec汇编函数。

之后,在这个汇编函数中,代码执行跳转到了由C语言实现的函数usertrap中,这个函数在trap.c中。

在usertrap这个C函数中,我们执行了一个叫做syscall的函数。

syscall函数会在一个表单中,根据传入的代表系统调用的数字进行查找,并在内核中执行具体实现了系统调用功能的函数。对于我们来说,这个函数就是sys_write。

sys_write会将要显示数据输出到console上,当它完成了之后,它会返回给syscall函数。

在syscall函数中,会调用一个函数叫做usertrapret,它也位于trap.c中,这个函数完成了部分方便在C代码中实现的返回到用户空间的工作。

XV6实验(2020)_第11张图片

ecall指令

涉及到的寄存器有stvec、sepc、scause、sstatus

执行操作有:

  1. 如果陷阱是设备中断,并且状态SIE位被清空,则不执行以下任何操作。
  2. 清除SIE以禁用中断。
  3. User mode -> supervisor mode
  4. Let sepc = pc
  5. Let pc = stvec
  6. Jump to pc
  7. 设置scause以反映产生陷阱的原因。
  8. 将当前模式(用户或管理)保存在状态的SPP位中。

uservec函数

  1. 保存现场
  2. 把内核的page table、内核的stack、当前执行的进程的CPU号装载到寄存器里
  3. 跳转到usertrap执行

usertrap函数

usertrap某种程度上存储并恢复硬件状态,但是它也需要检查触发trap的原因,以确定相应的处理方式

修改stvec的值,还可能修改sepc的值,这些寄存器的值会通过trapframe保存

usertrapret函数

填入trapframe的内容,下一次从用户空间转换到内核空间时可以用到这些数据。

恢复stvec、sepc的值,由于要修改这几个特殊寄存器的值,首先关闭了中断。

userret函数

恢复现场

把用户空间的page table、用户空间的stack装载到寄存器中

执行sret指令

sret指令

切换回user mode

sepc寄存器的数值拷贝回pc寄存器

重新打开中断

然后alarm的代码实现是比较简单的。

首先就是根据提示,添加响应的声明和系统调用注册表,和lab1一样的。

然后根据相关提示一步一步的写,实现如下:

首先在proc中添加有用的字段:

enum procstate { UNUSED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

// 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 ticks; // 记录系统时钟数
  uint64 handler; // 中断回调函数句柄
  int ticks_cnt; // 记录发生中断时钟数
  struct trapframe *tricks_trapframe; // 保存现场
  int handler_runing; // 防止重复调用中断处理函数
};
uint64 sys_sigalarm(void)
{
  int ticks;
  uint64 handler;
  argint(0, &ticks);
  argaddr(1, &handler);
  struct proc *p = myproc();
  p->ticks = ticks;
  p->handler = handler;
  p->ticks_cnt = 0;
  return 0;
}
// 实现恢复现场
uint64 sys_sigreturn(void)
{
  struct proc *p = myproc();
  *p->trapframe = *p->tricks_trapframe;
  p->handler_runing = 0;
  
  return 0;
}

功能是在下面这个函数中实现的

void
usertrap(void)
{
  int which_dev = 0;

  ......

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2)
  {
    if(p->ticks > 0)
    {
      p->ticks_cnt++;
      if(p->handler_runing == 0 && p->ticks_cnt > p->ticks)
      {
        p->ticks_cnt = 0;
        *p->tricks_trapframe = *p->trapframe;
        p->handler_runing = 1;
        p->trapframe->epc = p->handler;
      }
    }
    yield();
  }

这部分不想写了,有点简陋。

你可能感兴趣的:(linux,运维,服务器)