xv6(RISC-V)操作系统源码分析第一节——操作系统接口

一、内核与进程

xv6使用传统形式内核(一个向其他运行中的程序提供服务的特殊程序)。

每一个正在运行的程序称为进程,进程拥有自己的指令数据的内存空间。它们的功能如下:

  1. 指令:实现程序的运算
  2. 数据:用于运算过程的变量
  3. 栈:管理程序的过程调用

一个计算机通常有许多进程,但只有一个内核。

二、进程调用内核服务的方式——系统调用

进程通过调用系统调用来调用内核的服务。

系统调用是操作系统接口中的一个调用。系统调用会进入内核,让内核执行服务后返回。而进程主体运行在用户空间。所以,进程在内核空间和用户空间间交替运行。如下图:

xv6(RISC-V)操作系统源码分析第一节——操作系统接口_第1张图片

内核使用CPU提供的硬件保护机制来确保在用户空间中执行的每个进程只能访问自己的内存。

内核具有硬件特权,能够访问上面这些受保护的资源,但进程不行。

当用户程序调用系统调用时,硬件提高特权级别并开始执行内核中预定义的函数。

内核提供的系统调用集合就是用户程序可见的接口。

xv6提供了传统Unix内核所提供的服务和系统调用的一个子集。如下图:

系统调用 描述
int fork() 创建一个进程,返回子进程的PID。
int exit(int status) 终止当前进程,并将status传递给wait()。不会返回。
int wait(int *status) 等待子进程结束,并将status接收到参数*status中,返回其PID。
int kill(int pid) 终止给定PID的进程,成功返回0,失败返回-1。
int getpid() 返回当前进程的PID。
int sleep(int n) 睡眠n个时钟周期。
int exec(char *file, char *argv[]) 通过给定参数加载并执行一个文件;只在错误时返回。
char *sbrk(int n) 使进程内存增加n字节,返回新内存的起始地址。
int open(char *file, int flags) 打开一个文件,flags表示读或写,返回fd(文件描述符)。
int write(int fd, char *buf, int n) 将buf中n字节写入到文件描述符中;返回n。
int read(int fd, char *buf, int n) 从文件描述符中读取n字节到buf;返回读取字节数,文件结束返回0。
int close(int fd) 释放文件描述符fd。
int dup(int fd) 返回一个新文件描述符,其引用与fd相同的文件。
int pipe(int p[]) 创建管道,将读/写文件描述符放置在p[0]和p[1]。
int chdir(char *dir) 改变当前目录。
int mkdir(char *dir) 创建新目录。
int mknod(char *file, int, int) 创建新设备文件。
int fstat(int fd, struct stat *st) 将打开的文件的信息放置在*st中。
int stat(char *file, struct stat *st) 将命名文件信息放置在*st中。
int link(char *file1, char * file2) 为文件file1创建一个新的名称file2。
int unlink(char *file) 移除一个文件。

上图中的系统调用,若无特殊说明,调用成功返回1,否则返回0。

三、xv6实现功能的概况

xv6实现了进程、内存、文件描述符、管道、文件系统和shell。

(一)进程和内存

一个xv6进程由用户空间内存(指令、数据和堆栈)和内核私有的进程状态组成。

xv6可以透明地切换当前CPU正在执行的进程。当一个进程暂时不使用CPU时,xv6保存该进程的CPU环境(具体地讲,就是CPU内部的寄存器),在下次运行该进程时恢复它们。内核为每个进程关联一个独一无二的进程标识符PID)。

创建新进程的系统调用——int fork()

  • fork创建的新进程称为子进程,其内存内容(指令、数据和堆栈)与调用的原进程(父进程)完全相同。在父进程与子进程中,fork都会返回。在父进程中,fork返回子进程的PID,在子进程中,fork返回0。

进程的退出——int exit(int status)

  • 调用该系统调用的进程会退出并释放资源,该进程就消失了。

等待子进程退出,并返回其PID——int wait(int *status)

  • 若调用者无子进程,则返回-1。
int* pid = fork();
if (pid > 0)
{
   printf("parent: child=%d\n", pid);
   pid = wait((*int* *)0);
   printf("child %d is done\n", pid);
}
else if (pid == 0)
{
   printf("child: exiting\n");
   exit(0);
}
else
{
   printf("fork error\n");
}

 上面代码的执行结果有两种可能:

可能1:

parent: child=1234

child: exiting

child 1234 is done

可能2:

child: exiting

parent: child=1234

child 1234 is done

注意:子进程与父进程拥有相同的内存内容,但进程是在不同内存和寄存器中执行,即改变其中一个进程的变量不影响另一个进程。

使用新的内存映像来替换进程的内存——int exec(char *file, char *argv[])

  • exec需要两个参数:可执行文件的文件名与一个字符串参数数组。
  • 新内存映像从文件系统中的文件中进行读取。
  • 为了读取,该文件有特殊的文件格式,这个格式规定了文件中哪部分存放指令,哪部分是数据,从哪条指令开始等等。xv6中的格式使用ELF格式。
  • 当exec执行成功时,不返回到调用程序,而是从文件中加载的指令在ELF头声明的入口点开始执行。

exec使用案例:

char* *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");

上面程序会执行/bin/echo程序,并将argv数组作为参数。

(二)I/O与文件描述符

文件描述符是一个小整数,代表一个可由进程读取或写入的内核管理对象。

新分配的文件描述符总是当前进程中最小的未使用描述符。

文件描述符的获取方式:

  1. 打开一个文件或目录
  2. 打开一个设备
  3. 创建一个管道
  4. 复制一个现有的描述符

通常把文件描述符所指向的对象称为文件,文件描述符实现了抽象。因为我们可以由上看见,文件描述符将文件、管道和设备之间的差异抽象化,使它们看起来都像字节流。

打开一个文件并返回其文件描述符——int open(char *file, int flags)

  • open的第二个参数及对应功能如下表:
O_RDONLY
O_WRONLY
O_RDWR 读和写
O_CREATE 如果文件不存在则创建文件
O_TRUNC 将文件长度截断为0

xv6内核为每一个进程单独维护一个以文件描述符为索引的表,因此每个进程都有一个从0开始的文件描述符私有空间。按照约定:

  1. 一个进程从文件描述符0(标准输入)读取数据
  2. 一个进程向文件描述符1(标准输出)写入输出
  3. 一个进程向文件描述符2(标准错误)写入错误信息

向/从文件描述符指向的文件读/写数据——

int read(int fd, char *buf, int n)

/int write(int fd, char *buf, int n)

  • int read(int fd, char *buf, int n):从文件描述符fd中读取不超过n个字节的数据,并将其复制到buf中,返回读取的字节数。
  • int write(int fd, char *buf, int n):将buf中的n个字节的数据写入文件描述符fd中,并返回写入的字节数,若写入的字节数小于n则该次写入发生错误。

怎么实现继续读写呢?

实现继续读写的机制就是在引用文件的文件描述符中设置一个偏移量

read从当前文件偏移量中读取数据,然后按读取的字节数推进偏移量,随后的read将返回上次读取之后的数据,当没有更多的字节可读时,read返回0,表示文件结束。

write在当前文件偏移量处写入数据,然后按写入的字节数推进偏移量,每次write都从上一次写入的地方开始。

char buf[512];
int n;
for (;;)
{
    n = read(0, buf, sizeof buf);
    if (n == 0)
        break;
    if (n < 0)
    {
        fprintf(2, "read error\n");
        exit(1);
    }
    if (write(1, buf, n) != n)
    {
        fprintf(2, "write error\n");
        exit(1);
	}
}

上面程序实现的功能:将数据从其标准输入复制到标准输出,若出错,则它向标准错误写入一条消息。我们可以通过改变fd来使上面的程序从xxx读取,并向xxx写入。

释放文件描述符——int close(int fd)

  • 释放的目的,意味着该文件描述符所指向的文件关闭读写,同时,释放后的文件描述符可以被其他系统调用重用

返回引用文件与原文件描述符相同但不同值的新文件描述符——int dup(int fd)

  • 两个文件描述符共享一个偏移量。

(三)管道

管道是一个小的内核缓冲区,为进程提供了一种通信方式(管道作为一对文件描述符提供给进程,一个用于读,一个用于写,从管道一端写入数据,并从另一端读取数据)。

int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if (fork() == 0)
{
    close(0);  // 释放文件描述符0
    dup(p[0]); // 复制一个p[0](管道读端),此时文件描述符0(标准输入)也引用管道读端,故改变了标准输入。
    close(p[0]);
    close(p[1]);
    exec("/bin/wc", argv); // wc 从标准输入读取数据,并写入到参数中的每一个文件
}
else
{
    close(p[0]);
    write(p[1], "hello world\n", 12);
    close(p[1]);
}

程序解析:

程序调用pipe来创建一个新管道。读文件描述符记录在p[0]中,写文件描述符记录在p[1]中。

子进程实现功能:

        通过close与dup使文件描述符0引用管道的读端(实现了I/O重定向),释放管道的文件描述符。调用exec运行wc程序时,当wc从其标准输入端读取时,将从管道中读取。

父进程实现功能:

        关闭管道读端,并向管道写入,然后关闭写端。

注意:管道的读端会等待数据被写入,或者等待所有指向写端的文件描述符被关闭,在后一种情况下,读将返回0。也就是说,若无数据写入,读会被无限阻塞,直到写端被关闭。

(四)文件系统 

xv6文件系统包含了数据文件(拥有字节数组)和目录(拥有对数据文件和其他目录的命名引用)。这些目录形成了一颗树,从一个被称为根目录的特殊目录开始。

例如/a/b/c,该路径指:根目录/中的a目录中的b目录中的名为c的文件或目录。

不以/开头的路径是相对于调用进程的当前目录进行计算其绝对位置的。

改变进程的当前目录——int chdir(char *dir)

  • chdir("/a");
    chdir("b");
    open("c", O_RDONLY);
    open("/a/b/c", O_RDONLY);
  • 上面两个open打开的是同一个文件。

创建新目录——int mkdir(char *dir)

创建新设备文件——nt mknod(char *file, int, int)

  • mkdir("/dir");
    fd = open("/dir/file", O_CREATE | O_WRONLY);
    close(fd);
    
    mknod("/console", 1, 1);
  • 其中,open加上O_CREATE标志是创建并打开一个新的数据文件。
  • mknod创建了一个引用设备的特殊文件,与设备文件相关联的是主要设备号和次要设备号,它们唯一地标识一个内核设备。当一个进程打开设备文件后,内核会将系统的读写调用转移到内核设备上实现,而非将它们传递给文件系统。

文件名称文件是两码事。底层文件(非磁盘上的文件)被称为inode,一个inode可以有多个名称,称为链接。每个链接由目录中的一个项组成(一个项包含一个文件名和对inode的引用)。

inode中存储着一个文件的元数据,包含它的类型(文件或目录或设备)、长度、文件内容在磁盘上的位置以及文件的链接数量。

从文件描述符引用的inode中检索信息——int fstat(int fd, struct stat *st)

#define T_DIR 1    	// Directory
#define T_FILE 2   	// File
#define T_DEVICE 3 	// Device
struct stat
{
    int dev;     	// File system’s disk device
    uint ino;    	// Inode number
    short type;  	// Type of file
    short nlink; 	// Number of links to file
    uint64 size; 	// Size of file in bytes
};

创建一个引用同一个inode的文件——int link(char *file1, char * file2)

  • open("a", O_CREATE | O_WRONLY);
    link("a", "b");
  •  读写a和读写b是一样的。

每个inode都有一个唯一的inode号来标识。可以通过fstat的结果来确定n个文件名是否指向同一个底层内容,若是,则stat中的ino将相同,nlink将为n。

删除文件名——int unlink(char *file)

  • unlink会从文件系统中删除一个文件名。只有当文件的链接数为0且没有文件描述符引用它时,文件的inode和存放其内容的磁盘空间才会被释放。
  • unlink("a");

创建临时文件的方法:

fd = open("/tmp/xyz", O_CREATE | O_RDWR);
unlink("/tmp/xyz");

当进程关闭fd或者退出时会删除文件。

(五)shell

shell,Unix的命令行用户接口,是第一种脚本语言。

shell是一个用户程序,不是内核的一部分。xv6的shell是Unix Bourne shell的简单实现。

1.shell与进程和内存

shell使用fork、exit和exec在用户空间运行程序。

shell的主程序很简单,如下:

int main(void)
{
  static char buf[100];
  int fd;

  // Ensure that three file descriptors are open.
  while((fd = open("console", O_RDWR)) >= 0){
    if(fd >= 3){
      close(fd);
      break;
    }
  }

  //读并运行输入命令
  while(getcmd(buf, sizeof(buf)) >= 0){
    if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
      // Chdir 只能在父进程中被调用,子进程不行
      buf[strlen(buf)-1] = 0;  // chop \n
      if(chdir(buf+3) < 0)
        fprintf(2, "cannot cd %s\n", buf+3);
      continue;
    }
    if(fork1() == 0)
      //子进程中运行命令
      runcmd(parsecmd(buf));
    wait(0);
  }
  exit(0);
}

主循环用getcmd读取用户的一行输入,然后调用fork创建shell副本。父进程调用wait等待,而子进程则运行命令。

比如,用户向shell输入echo MCQupupup,那么shell调用runcmd,参数为echo MCQupupup。runcmd运行实际的命令。对于echo MCQupupup,它会调用exec,若exec成功,那么子进程将执行echo程序的指令。在某些时候,echo会调用exit来达到返回到父进程的目的。

parsecmd程序:

parsecmd(char *s)
{
  char *es;
  struct cmd *cmd;

  es = s + strlen(s);
  cmd = parseline(&s, es);
  peek(&s, es, "");
  if(s != es){
    fprintf(2, "leftovers: %s\n", s);
    panic("syntax");
  }
  nulterminate(cmd);
  return cmd;
}

runcmd程序:

void runcmd(struct cmd *cmd)
{
  int p[2];
  struct backcmd *bcmd;
  struct execcmd *ecmd;
  struct listcmd *lcmd;
  struct pipecmd *pcmd;
  struct redircmd *rcmd;

  if(cmd == 0)
    exit(1);

  switch(cmd->type){
  default:
    panic("runcmd");

  case EXEC:
    ecmd = (struct execcmd*)cmd;
    if(ecmd->argv[0] == 0)
      exit(1);
    exec(ecmd->argv[0], ecmd->argv);
    fprintf(2, "exec %s failed\n", ecmd->argv[0]);
    break;

  case REDIR:
    rcmd = (struct redircmd*)cmd;
    close(rcmd->fd);
    if(open(rcmd->file, rcmd->mode) < 0){
      fprintf(2, "open %s failed\n", rcmd->file);
      exit(1);
    }
    runcmd(rcmd->cmd);
    break;

  case LIST:
    lcmd = (struct listcmd*)cmd;
    if(fork1() == 0)
      runcmd(lcmd->left);
    wait(0);
    runcmd(lcmd->right);
    break;

  case PIPE:
    pcmd = (struct pipecmd*)cmd;
    if(pipe(p) < 0)
      panic("pipe");
    if(fork1() == 0){
      close(1);
      dup(p[1]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->left);
    }
    if(fork1() == 0){
      close(0);
      dup(p[0]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->right);
    }
    close(p[0]);
    close(p[1]);
    wait(0);
    wait(0);
    break;

  case BACK:
    bcmd = (struct backcmd*)cmd;
    if(fork1() == 0)
      runcmd(bcmd->cmd);
    break;
  }
  exit(0);
}

exit程序:

// Exit the current process.  Does not return.
// An exited process remains in the zombie state
// until its parent calls wait().
void exit(int status)
{
  struct proc *p = myproc();

  if(p == initproc)
    panic("init exiting");

  // Close all open files.
  for(int fd = 0; fd < NOFILE; fd++){
    if(p->ofile[fd]){
      struct file *f = p->ofile[fd];
      fileclose(f);
      p->ofile[fd] = 0;
    }
  }

  begin_op();
  iput(p->cwd);
  end_op();
  p->cwd = 0;

  acquire(&wait_lock);

  // Give any children to init.
  reparent(p);

  // Parent might be sleeping in wait().
  wakeup(p->parent);
  
  acquire(&p->lock);

  p->xstate = status;
  p->state = ZOMBIE;

  release(&wait_lock);

  // Jump into the scheduler, never to return.
  sched();
  panic("zombie exit");
}

exec程序:

int exec(char *path, char **argv)
{
  char *s, *last;
  int i, off;
  uint64 argc, sz = 0, sp, ustack[MAXARG], stackbase;
  struct elfhdr elf;
  struct inode *ip;
  struct proghdr ph;
  pagetable_t pagetable = 0, oldpagetable;
  struct proc *p = myproc();

  begin_op();

  if((ip = namei(path)) == 0){
    end_op();
    return -1;
  }
  ilock(ip);

  // Check ELF header
  if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))
    goto bad;

  if(elf.magic != ELF_MAGIC)
    goto bad;

  if((pagetable = proc_pagetable(p)) == 0)
    goto bad;

  // Load program into memory.
  for(i=0, off=elf.phoff; isz;

  // Allocate two pages at the next page boundary.
  // Make the first inaccessible as a stack guard.
  // Use the second as the user stack.
  sz = PGROUNDUP(sz);
  uint64 sz1;
  if((sz1 = uvmalloc(pagetable, sz, sz + 2*PGSIZE, PTE_W)) == 0)
    goto bad;
  sz = sz1;
  uvmclear(pagetable, sz-2*PGSIZE);
  sp = sz;
  stackbase = sp - PGSIZE;

  // Push argument strings, prepare rest of stack in ustack.
  for(argc = 0; argv[argc]; argc++) {
    if(argc >= MAXARG)
      goto bad;
    sp -= strlen(argv[argc]) + 1;
    sp -= sp % 16; // riscv sp must be 16-byte aligned
    if(sp < stackbase)
      goto bad;
    if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
      goto bad;
    ustack[argc] = sp;
  }
  ustack[argc] = 0;

  // push the array of argv[] pointers.
  sp -= (argc+1) * sizeof(uint64);
  sp -= sp % 16;
  if(sp < stackbase)
    goto bad;
  if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0)
    goto bad;

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

  return argc; // this ends up in a0, the first argument to main(argc, argv)

 bad:
  if(pagetable)
    proc_freepagetable(pagetable, sz);
  if(ip){
    iunlockput(ip);
    end_op();
  }
  return -1;
}

xv6隐式分配大部分用户空间内存:

        fork复制父进程的内存到子进程,exec分配足够的内存来容纳可执行文件。一个进程在运行时若需要更多的内存(比如该进程里面使用了malloc),该进程可以调用sbrk(n)将其数据内存增长n个字节,sbrk返回新内存的位置。

2.shell与I/O

shell利用文件描述符的约定(比如,文件描述符为0,其源就是标准输入),实现I/O重定向和管道。shell确保自己总是有3个文件描述符打开,这些文件描述符默认是控制台的文件描述符。

文件描述符与fork配合使用,实现I/O重定向:

fork将父进程的文件描述符表和它的内存一起复制,这样会使子进程打开的文件和父进程完全一样。系统调用exec替换调用进程中的内存,但保留文件描述符表。如下代码:

char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if (fork() == 0)
{
    close(0);  // 释放标准输入的文件描述符
    open("input.txt", O_RDONLY);  // 这时input.txt的文件描述符为0
    // 即标准输入为input.txt
    exec("cat", argv);  // cat从0读取,并输出到1
}

close和open的配合使用,使得文件描述符0会指向input.txt。

上面程序实际上就是shell调用cat

注意:通过上面的程序可以理解为什么xv6将fork与exec分开调用。

将这两个调用分开,shell将可以在这个间隔内重定向子进程的I/O,但不干扰父进程的I/O。

虽然fork复制了文件描述符表,但每个底层文件的偏移量是父子共享的。

 if (fork() == 0)
 {
     write(1, "hello ", 6);
     exit(0);
 }
else
{
    wait(0);
    write(1, "world\n", 6);
}

上面程序的结果是:

文件描述符1所引用的文件中包含数据:hello world 

 这种行为有助于从shell命令的序列中产生有序的输出。比如:

(echo hello; echo world)>output.txt

 上面程序的另一种写法是:

fd = dup(1);
write(1, "hello ", 6);
write(fd, "world\n", 6);

注意:通过fork与dup衍生出来的文件描述符与原文件描述符共享同一个偏移量。 除此之外,其余操作衍生的文件描述符不共享同一个偏移量,即使它们是由同一个文件的打开调用产生的。

shell调用实现错误文件描述符的I/O重定向:

 ls existing-file non-existing-file > tmp1 2>&1

2>&1相当于dup(1),即重定向错误信息到标准输出。

上面的命令意为:已存在文件的名称与不存在文件的错误信息都会显示在文件tmp1中。

3.shell与管道

  case PIPE:
    pcmd = (struct pipecmd*)cmd;
    if(pipe(p) < 0)
      panic("pipe");
    if(fork1() == 0){
      close(1);
      dup(p[1]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->left);
    }
    if(fork1() == 0){
      close(0);
      dup(p[0]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->right);
    }
    close(p[0]);
    close(p[1]);
    wait(0);
    wait(0);
    break;

p[0]是管道的读端,p[1]是管道的写端。两个子进程分别创建管道的读端与写端,并分别对应命令的右端与左端。所以我们可以得到如下命令:

echo hello world | wc

将 hello world 写入 wc。

我们也可以不使用管道,而是通过临时文件(文件重定向)去实现这个功能:

echo hello world >/tmp/xyz; wc 

但使用管道比使用临时文件更有优势,至少有四个:

  1. 管道会自动清理自己,如果是文件重定向,则shell在完成后必须删除/tmp/xyz。
  2. 管道可以传递任意长的数据流,而文件重定向需要磁盘上有足够的空间存储所有数据。
  3. 管道可以分阶段并行执行,而文件重定向需要顺序执行。
  4. 实现进程间通信,管道的阻塞读写比文件的非阻塞语义更有效率。

4.shell与文件系统

这里强调cd命令。

看shell如何实现cd的调用:

  while(getcmd(buf, sizeof(buf)) >= 0){
    if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
      // Chdir 只能在父进程中被调用,子进程不行
      buf[strlen(buf)-1] = 0;  // chop \n
      if(chdir(buf+3) < 0)
        fprintf(2, "cannot cd %s\n", buf+3);
      continue;
    }
    if(fork1() == 0)
      //子进程中运行命令
      runcmd(parsecmd(buf));
    wait(0);
  }

cd的功能是改变shell自身的当前工作目录,如果把cd作为一个普通命令执行,那么shell就会fork一个子进程,子进程中运行的cd只会改变子进程的当前工作目录,父进程(即shell)的当前工作目录则不变。 

总结:

第一节通过重点讲解xv6的系统调用来讲述xv6操作系统的概貌。

参考资料:

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

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

你可能感兴趣的:(risc-v,unix,汇编,vscode,c语言)