MIT6.S081 Chap1:操作系统接口

270 阅读17分钟

操作系统接口

操作系统的工作是:

  1. 将计算机的资源在多个程序间共享,并且给程序提供一系列比硬件本身更有用的服务。
  2. 管理并抽象底层硬件(eg:word软件不用关心自己使用的是何种硬盘)
  3. 多路复用硬件,使得多个程序可以同时运行(或者看起来是同时运行)
  4. 给程序间提供一种受控的交互方式,使得程序之间可以共享数据、共同工作

O/S内核提供的服务:

  1. 进程
  2. 内存分配
  3. 文件内容
  4. 文件名、目录
  5. 访问控制
  6. 其他:用户、IPC(进程间通信)、网络、时间、终端

xv6操作系统提供Unix操作系统中的基本接口,并且模仿Unix的内部设计。 image-20220321154418008

如上图所示,xv6使用了传统的内核概念,内核就是一个向其他运行中程序提供服务的特殊程序。每一个运行中程序称作进程,进程拥有包含指令、数据、栈的内存空间。指令实现程序的运算,数据是用于运算过程的变量,栈管理了程序的过程调用。

进程通过系统调用使用内核服务。由于系统调用会进入内核,让内核执行服务然后返回,所以进程总是在用户空间和内核空间交替运行。

内核使用了CPU的硬件保护机制来保护用户进程只能访问自己的内存空间(用户空间)。内核拥有实现保护机制所需的硬件权限,而用户程序没有这些权限。当一个用户程序进行一次系统调用时,硬件会提升特权级并且开始执行一些内核中预定义的功能。

内核提供了一系列系统调用,这些就是用户程序可见的操作系统接口,比如:fork()、exit()、wait()等

进程和内存

一个xv6进程由两部分组成:一部分是用户内存空间(指令、数据、栈),另一部分是仅对内核可见的进程状态。xv6提供了分时特性:它在可用CPU之间不断切换,决定哪一个等待中的进程被执行。当一个进程不在执行时,xv6保存它的CPU寄存器,当它们再次被执行时,恢复这些寄存器的值。内核将每个进程和一个 pid 关联起来

通过fork产生一个子进程时,父子进程拥有不同的内存空间和寄存器改变一个进程中的变量不会影响另一个进程

系统调用 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这个程序,这个程序的参数列表为echo hello。大部分程序忽略第一个参数,通常第一个参数是程序的名字。

xv6 shell是一个普通的程序,它接受用户输入的命令并执行它们。它是传统Unix系统中最基本的用户界面。shell是一个普通程序不是内核的一部分

常用系统调用

  • fork:函数原型int fork()。作用是让一个进程生成另外一个和这个进程的内存内容相同的子进程。
  • exit:函数原型int exit(int status)。让调用它的进程停止执行并且将内存等占用的资源全部释放。需要一个整数形式的状态参数,0代表正常状态退出,1代表以非正常状态退出
  • wait:函数原型int wait(int* status)。等待子进程退出,返回子进程PID,子进程的退出状态存储到int* status这个地址中。如果调用者没有子进程,wait将返回-1
  • exec:函数原型int exec(char* file, char* argv[])。加载一个文件,获取执行它的参数,执行。如果执行错误返回-1,执行成功则不会返回,而是开始从文件入口位置执行命令,文件必须是ELF格式

xv6 shell 通过系统调用为用户执行程序。主循环main通过getcmd读取命令行的输入,然后它调用fork生成一个shell进程的副本。父 shell 调用wait,而子进程执行用户命令(在runcmd中调用exec

eg:用户在命令行输入 "echo hello",getcmd会以echo hello为参数调用 runcmd,由runcmd执行实际的命令。对于echo helloruncmd将调用exec。如果exec 成功被调用,子进程就会转而去执行 echo 程序里的指令。在某个时刻 echo 会调用 exit,这会使得其父进程从 wait 返回。

注意:forkexec 并没有被合并成一个调用,由后面的学习可以知道,将创建进程和加载程序分为两个过程是一个很好的设计。这是因为,这种区分可以使得shell在子进程执行指定程序之前对子进程进行修改

xv6 通常隐式地分配用户的内存空间。fork 在子进程需要装入父进程的内存拷贝时分配空间,exec 在需要装入可执行文件时分配空间。一个进程在需要额外内存时可以通过调用 sbrk(n) 来增加 n 字节的数据内存。 sbrk 返回新的内存的地址。

I/O和文件描述符

文件描述符是一个整数,它代表了一个进程可以读写的被内核管理的对象。进程可以通过多种方式获得一个文件描述符,如打开文件、目录、设备,或者创建一个管道,或者复制已经存在的文件描述符。通常把文件描述符指向的对象称为"文件"。文件描述符的接口是对文件、管道、设备等的抽象,这种抽象使得它们看上去就是字节流。

每个进程都有一张表,而xv6内核就以文件描述符作为这张表的索引,所以每个进程都有一个从0开始的文件描述符空间

按照惯例,进程从文件描述符0读入(标准输入),从文件描述符1输出(标准输出),从文件描述符2输出错误(标准错误输出)。

shell正是利用了这种惯例来实现I/O重定向。并且shell保证在任何时候都有3个打开的文件描述符,它们是控制台(console)的默认文件描述符。

 // 保证有3个打开的文件描述符
 while((fd = open("console", O_RDWR)) >= 0) {
     if(fd >= 3) {
         close(fd);
         break;
     }
 }

系统调用 readwrite 从文件描述符所指的文件中读或者写 n 个字节。read(fd, buf, n)fd 读最多 n 个字节(fd 可能没有 n 个字节),将它们拷贝到 buf 中,然后返回读出的字节数。每一个指向文件的文件描述符都和一个偏移关联。read 从当前文件偏移处读取数据,然后把偏移增加读出字节数。紧随其后的 read从新的起点开始读数据。当没有数据可读时,read 就会返回0,这就表示文件结束了。

write(fd, buf, n)buf 中的 n 个字节到 fd 并且返回实际写出的字节数。如果返回值小于 n 那么只可能是发生了错误。就像 read 一样,write 也从当前文件的偏移处开始写,在写的过程中增加这个偏移。

系统调用 close 会释放一个文件描述符,使得它未来可以被 open, pipe, dup 等调用重用。一个新分配的文件描述符永远都是当前进程的最小的未被使用的文件描述符

I/O重定向

I/O 重定向允许我们更改输出地点和输入来源。一般地,输出到屏幕,输入来自键盘, 但是通过 I/O 重定向,我们可以做出改变。

标准输出一般是输出到屏幕的,但是我们使用 I/O 的重定向功能可以将标准输出重定向到其他地方,比如一个文件。重定向需要使用 ">" 重定向符号实现。

特殊符号: < 输入重定向,> 输出重定向,>> 输出重定向,进行追加,不会覆盖之前内容

文件描述符fork的交叉使用可以实现I/O重定向。fork会复制父进程的文件描述符和内存,所以子进程和父进程的文件描述符一模一样。exec替换调用它的进程的内存但是会保留它的文件描述符表。这种行为使得shell可以这样实现重定向:fork一个进程,重新打开指定文件的文件描述符,然后执行新的程序。

eg:简化版的shell执行cat<input.txt的代码:

 char *argv[2];
 argv[0] = "cat";
 ### argv[1] = 0;
 if(fork() == 0) {
     close(0);
     open("input.txt", O_RDONLY);
     exec("cat", argv);
 }

子进程关闭文件描述符0后,open会使用0作为新打开的文件input.txt的文件描述符(因为0是 open 执行时的最小可用文件描述符)。之后cat将会在标准输入指向 input.txt 的情况下运行。

xv6的shell就是如上实现I/O重定位的。在 shell 的代码中, fork 出了子进程,在子进程中 runcmd 会调用 exec 加载新的程序。所以,forkexec是单独的两种系统调用,这种区分使得shell可以在子进程执行指定程序之前对子进程进行修改

不过,虽然fork复制了文件描述符,但每一个文件当前的偏移仍然是在父子进程间共享的:

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

在这段代码的结尾,绑定在文件描述符1上的文件有数据"hello world",父进程的 write 会从子进程 write 结束的地方继续写 (因为 wait ,父进程只在子进程结束之后才运行 write)。这种行为有利于顺序执行的 shell 命令的顺序输出,例如 (echo hello; echo world)>output.txt

dup 复制一个已有的文件描述符,返回一个指向同一个输入/输出对象的新描述符。这两个描述符共享一个文件偏移。正如被 fork 复制的文件描述符一样。这里有另一种打印 “hello world” 的办法:

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

从同一个原初文件描述符通过一系列 forkdup 调用产生的文件描述符都共享同一个文件偏移,而其他情况下产生的文件描述符就不是这样了,即使他们打开的都是同一份文件。比如同时open相同的文件,就不会共享文件的偏移

文件描述符是一个强大的抽象,因为它们将它们所连接的细节隐藏起来了:一个进程向描述符1写出,它有可能是写到一份文件,一个设备(如控制台),或一个管道。

管道

管道是一个小的内核缓冲区,它以文件描述符对的形式提供给进程,一个用于写操作,一个用于读操作。从管道的一端写的数据可以从管道的另一端读取。管道提供了一种进程间交互的方式。

示例代码:

 int p[2];
 char *argv[2];
 argv[0] = "wc";
 argv[1] = 0;
 pipe(p);
 if(fork() == 0) {
     close(0);  // 关掉了0描述符
     dup(p[0]); // dup返回一个新描述符,由于0已经被关掉,所以返回最小的未被使用的描述符时,即返回0,即将标准输入绑定到了管道的读端口
     close(p[0]);
     close(p[1]);
     exec("/bin/wc", argv);
 } else {
     write(p[1], "hello world\n", 12);
     close(p[0]);
     close(p[1]);
 }

这段程序调用pipe,创建一个新的管道并且将读写描述符记录在数组 p 中。在 fork 之后,父进程和子进程都有了指向管道的文件描述符。子进程将管道的读端口拷贝在描述符0上,关闭 p 中的描述符。然后执行程序wc。当 wc 从标准输入读取时,它实际上是从管道读取的。父进程向管道的写端口写入,然后关闭它的两个文件描述符。

如果数据没有准备好,那么对管道执行的read会一直等待,直到有数据了或者其他绑定在这个管道写端口的描述符都已经关闭了。在后一种情况中,read 会返回 0,就像是一份文件读到了最后。读操作会一直阻塞直到不可能再有新数据到来了,这就是为什么我们在执行 wc 之前要关闭子进程的写端口。如果 wc 指向了一个管道的写端口,那么 wc 就永远看不到 eof 了(因为绑定在这个管道写端口的描述符永远不会被全部关闭)。

xv6 shell 对管道的实现(比如 fork sh.c | wc -l)和上面的描述是类似的。子进程创建一个管道连接管道的左右两端。然后它为管道左右两端都调用 runcmd,然后通过两次 wait 等待左右两端结束。管道右端可能也是一个带有管道的指令,如 a | b | c, 它 fork 两个新的子进程(一个 b 一个 c),因此,shell 可能创建出一颗进程树。树的叶子节点是命令中间节点是进程,它们会等待左儿子和右儿子执行结束。理论上,你可以让中间节点都运行在管道的左端,但做的如此精确会使得实现变得复杂。 管道pipe可能看上去和临时文件类似,命令

 echo hello world | wc

可以用无管道的方式实现:

 echo hello world > /tmp/xyz;
 wc < /tmp/xyz

但是,管道和临时文件还是有许多不同的:

  1. 管道会进行自我清扫。而如果采用shell重定向的话,需要在任务完成后删除 /tmp/xyz
  2. 管道可以传输任意长度的数据
  3. 管道允许同步:两个进程可以使用一对管道来进行二者之间的信息传递,每一个读操作都阻塞调用进程,直到另一个进程用write完成数据的发送。

创建管道时,相当于以读方式和写方式打开两次同一个文件,这样就会返回两个不同的文件描述符,一个用来写,一个用来读。

父进程fork出子进程时,子进程会拷贝父进程的文件描述符表,所以子进程中也拥有刚刚打开的文件所对应的两个文件描述符。

由于管道只能单向通信,所以我们可以根据实际需要,确定关闭父进程的读端还是写端,确定关闭子进程的读端还是写端。

为什么父子进程都需要关闭一个文件描述符,曾经还是要打开两个文件描述符呢?

如果父进程只以读的方式打开,那么子进程也只有读的文件描述符,两个读是不能通信的。父进程只以写的方式打开时同理。

为什么父子进程一定要关闭一个文件描述符呢?

虽然可以不关闭,不过还是建议一定要关:

  • 在语义上证明了管道单向通信这样的特性

  • 为了防止误操作

  • 如果不关闭,假如父子进程都需要通过管道读写,那么有可能父进程刚写入管道的数据,又被父进程自己读出

  • 当管道写端的文件描述符全部关闭时,当管道读端读完管道中所有数据后,再次读会返回0,就像读到文件末尾一样。如果管道写端的文件描述符没有全部关闭,当管道读端读完所有数据后,再次读就会阻塞,等到写端向管道中写入数据。

    例子:如果父进程创建管道,并fork出子进程,并向管道写入数据后就退出,而子进程没有关闭写端的文件描述符,那么当子进程读完管道中的数据后,就会阻塞,导致子进程无法正常退出。

文件系统

xv6文件系统提供文件和目录,文件就是一个简单的字节数组,而目录包含指向文件和其他目录的引用。xv6把目录实现为一种特殊的文件。目录是一棵树,它的根节点是一个特殊的目录root/a/b/c指向一个在目录 b 中的文件 c,而 b 本身又是在目录 a 中的,a 又是处在 root 目录下的。

不从 / 开始的目录表示的是相对调用进程当前目录的目录(相对路径),调用进程的当前目录可以通过chdir这个系统调用进行改变。

示例:

 chdir("/a"); // 切换到/a
 chdir("b");  // 切换到 /a/b 由于不从/开始,所以是相对调用进程当前目录的
 open("c", O_RDONLY);
 open("/a/b/c", O_RDONLY);

上述两个open打开的都是同一个文件。

有很多的系统调用可以创建一个新的文件或者目录:mkdir 创建一个新的目录,open 加上 O_CREATE 标志打开一个新的文件,mknod 创建一个新的设备文件mknod 在文件系统中创建一个文件,但是这个文件没有任何内容。相反,这个文件的元信息标志它是一个设备文件,并且记录主设备号辅设备号mknod 的两个参数),这两个设备号唯一确定一个内核设备。当一个进程之后打开这个设备文件的时候,内核会将readwrite的系统调用重新定向到设备上。

fstat可以获取一个文件描述符指向的文件的信息。它填充一个名为 stat的结构体,它在stat.h中定义为:

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

文件名和这个文件本身是有很大的区别。同一个文件(文件本身称为 inode可能有多个名字,称为连接 (links)。系统调用 link 创建另一个文件的名称,它指向同一个 inode。下面的代码创建了一个既叫做 a 又叫做 b 的新文件。

 open("a", O_CREATE|O_WRONGLY);
 link("a", "b");

读写 a 就相当于读写 b。每一个 inode 都由一个唯一inode 号 直接确定。

一个inode存储了文件的元数据,包括该文件的类型(file,directory or device)、大小、文件在硬盘中的存储位置以及指向这个inodelink的个数

在上面这段代码中,我们可以通过 fstat 知道 ab 都指向同样的内容:ab 都会返回同样的 inode 号(ino),并且 nlink 数会设置为2。

fstat:一个系统调用,函数原型int fstat(int fd, struct stat* st),将inode中的相关信息存储到st中

系统调用 unlink 从文件系统移除一个文件名。一个文件的 inode 和磁盘空间只有当它的链接数变为 0 的时候才会被清空,也就是没有一个文件再指向它。

xv6 关于文件系统的操作都被实现为用户程序,诸如 mkdirlnrm 等等。而不是将其放在shell或kernel内,这样可以使用户比较方便地在这些程序上进行扩展

有一个例外,那就是 cd,它是在 shell 中实现的。cd 必须改变 shell 自身的当前工作目录。如果 cd 作为一个普通命令执行,那么 shell 就会 fork 一个子进程,而子进程会运行 cdcd 只会改变子进程的当前工作目录。父进程的工作目录保持原样。