Loading...

chapter0:操作系统接口

操作系统的工作:

  • 将计算机的资源在多个程序间共享,并且给程序提供一系列比硬件本身更有用的服务。
  • 管理并抽象底层硬件,例如我们在使用word的时候不用去关心自己使用的是何种硬盘
  • 多路复用硬件,使得多个程序可以同时运行
  • 给程序间提供一种受控的交互方式,使得程序间可以共享数据

好的接口:

  • 简单而精准
  • 复杂的功能

xv6操作系统:

  • 提供Unix操作系统中的基本接口
  • 模仿Unix的内部设计


进程:每一个运行中程序,他们都拥有包含指令、数据、栈的内存空间。

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


内核:一个向其他运行中程序提供服务的特殊程序

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


指令:程序的运算
数据:用于运算构成的变量
:管理了程序的过程调用

内核提供的一系列系统调用就是用户程序可见的操作系统接口,xv6 内核提供了 Unix 传统系统调用的一部分,他们是:


shell: 一个普通的程序,它接受用户输入的命令并且执行它们,它也是传统 Unix 系统中最基本的用户界面。shell 作为一个普通程序,而不是内核的一部分,充分说明了系统调用接口的强大:shell 并不是一个特别的用户程序。这也意味着 shell 是很容易被替代的,实际上这导致了现代 Unix 系统有着各种各样的 shell,每一个都有着自己的用户界面和脚本特性。xv6 shell 本质上是一个 Unix Bourne shell 的简单实现。它的实现在第 7850 行

进程和内存

xv6的进程组成:

  • 用户内存空间:指令,数据,栈
  • 仅对内核可见的进程

xv6的分时性:它在可用 CPU 之间不断切换,决定哪一个等待中的进程被执行。当一个进程不在执行时,xv6 保存它的 CPU 寄存器,当他们再次被执行时恢复这些寄存器的值。

内核将每个进程和一个 pid (process identifier进程身份标识) 关联起来。

fork:一个进程可以通过系统调用 fork 来创建一个新的进程。fork 创建的新进程被称为子进程,子进程的内存内容同创建它的进程(父进程)一样。fork 函数在父进程、子进程中都返回(一次调用两次返回)。对于父进程它返回子进程的 pid,对于子进程它返回 0。
🌟:父子进程拥有不同的内存空间和寄存器,改变一个进程中的变量不会影响另一个进程

考虑下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
int pid;
pid = fork();
if(pid > 0){//父进程
printf("parent: child=%d\n", pid);
pid = wait();
printf("child %d is done\n", pid);
} else if(pid == 0){//子进程
printf("child: exiting\n");
exit();
} else {
printf("fork error\n");
}

演示流程:

1
2
3
4
5
6
7
8
9
10
11
在父进程中:

fork()返回子进程的PID (一个大于0的值)。
执行printf("parent: child=%d\n", pid);,输出类似“parent: child=12345”,其中12345是子进程的PID。
父进程执行wait(),等待子进程完成。
当子进程完成后,printf("child %d is done\n", pid);被执行,输出类似“child 12345 is done”。
在子进程中:

fork()返回0
执行printf("child: exiting\n");,输出“child: exiting”。
调用exit(),子进程结束。

exit:导致调用它的进程停止运行,并且释放诸如内存和打开文件在内的资源。
wait:返回一个当前进程已退出的子进程,如果没有子进程退出,wait 会等候直到有一个子进程退出


exec:将从某个文件(通常是可执行文件)里读取内存镜像,并将其替换到调用它的进程的内存空间。说白了就是想要执行新程序,并用新程序来替换当前进程。
代码示例:

1
2
3
4
5
6
7
8
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
//arg1;新程序的路径名
//arg2: 新程序的命令行参数数组
printf("exec error\n");

这段代码将调用程序替换为 /bin/echo 这个程序,这个程序的参数列表为echo hello。大部分的程序都忽略第一个参数,这个参数惯例上是程序的名字(此例是 echo)

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

xv6 没有用户这个概念当然更没有不同用户间的保护隔离措施。按照 Unix 的术语来说,所有的 xv6 进程都以 root 用户执行。

I/O和文件描述符

文件描述符:一个整数,代表了一个进程可以读写的被内核管理的对象。

如何获得文件描述符:

  • 打开文件、目录、设备,或者创建一个管道(pipe)
  • 复制已经存在的文件描述符

文件:文件描述符指向的对象

字节流:处理单元为1个字节(byte),操作字节和字节数组,存储的是二进制文件,如果是音频文件、图片、歌曲

每个进程都有一张表,而 xv6 内核就以文件描述符作为这张表的索引,所以每个进程都有一个从0开始的文件描述符空间。按照惯例,进程从文件描述符0读入(标准输入),从文件描述符1输出(标准输出),从文件描述符2输出错误(标准错误输出)。我们会看到 shell 正是利用了这种惯例来实现 I/O 重定向。shell 保证在任何时候都有3个打开的文件描述符(8007),他们是控制台(console)的默认文件描述符。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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();
}
if(write(1, buf, n) != n){
//就算if条件中是不成立的,那么if中的函数也会执行,那是因为只有执行才知道这个if条件是不是对的
fprintf(2, "write error\n");
exit();
}
}

这段代码中值得一提的是 cat 并不知道它是从文件、控制台或者管道中读取数据的。同样地 cat 也不知道它是写到文件、控制台或者别的什么地方。文件描述符的使用和一些惯例(如0是标准输入,1是标准输出)使得我们可以轻松实现 cat


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

文件描述符和 fork 的交叉使用使得 I/O 重定向能够轻易实现。fork 会复制父进程的文件描述符和内存,所以子进程和父进程的文件描述符一模一样。exec 会替换调用它的进程的内存但是会保留它的文件描述符表。这种行为使得 shell 可以这样实现重定向:fork 一个进程,重新打开指定文件的文件描述符,然后执行新的程序。下面是一个简化版的 shell 执行 cat<input.txt 的代码:

1
2
3
4
5
6
7
8
char *argv[2];
argv[0] = "cat";
### argv[1] = 0;
if(fork() == 0) {//确保是子进程
close(0);//关闭输入文件描述符
open("input.txt", O_RDONLY);//重新定向标准输入打开input文件,文件描述符0指向input
exec("cat", argv);
}

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


fork和exec为什么是两种单独的system call:在 shell 的代码中,记得这时 fork 出了子进程,在子进程中 runcmd 会调用 exec 加载新的程序。fork 用于创建新的进程,而 exec 用于在进程内加载新的程序。 这种区分使得 shell 可以在子进程执行指定程序之前对子进程进行修改。

虽然 fork 复制了文件描述符,但每一个文件当前的偏移仍然是在父子进程之间共享的,考虑下面这个例子:

1
2
3
4
5
6
7
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” 的办法:

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

从同一个原初文件描述符通过一系列 fork 和 dup 调用产生的文件描述符都共享同一个文件偏移,而其他情况下产生的文件描述符就不是这样了,即使他们打开的都是同一份文件。dup 允许 shell 像这样实现命令:ls existing-file non-exsiting-file > tmp1 2>&1. 2>&1 告诉 shell 给这条命令一个复制描述符1的描述符2。这样 existing-file 的名字和 non-exsiting-file 的错误输出都将出现在 tmp1 中。xv6 shell 并未实现标准错误输出的重定向,但现在你知道该怎么去实现它。

管道

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

接下来的示例代码运行了程序 wc,它的标准输出绑定到了一个管道的读端口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int p[2];
char *argv[2];
argv[0] = "wc"; //命令名称
argv[1] = 0;
pipe(p); //创建新管道并将读写描述符记录在数组p中。
if(fork() == 0) { //子进程
close(0); //关闭标准输入
dup(p[0]); //复制pipe读端
close(p[0]);//关闭pipe读端,因为已经复制了
close(p[1]); //关闭pipe写端,因为子进程不需要写入任何数据
exec("/bin/wc", argv);
} else { //父进程
write(p[1], "hello world\n", 12);//向管道写端写入字符串
close(p[0]); //关闭管道读端
close(p[1]); //关闭pipe写端
}

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

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

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

pipe 可能看上去和临时文件没有什么两样:命令

1
echo hello world | wc

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

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

管道和临时文件起码有三个关键的不同点:

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

文件系统

xv6 文件系统提供文件和目录,文件就是一个简单的字节数组,而目录包含指向文件和其他目录的引用。xv6 把目录实现为一种特殊的文件。目录是一棵树,它的根节点是一个特殊的目录 root。/a/b/c 指向一个在目录 b 中的文件 c,而 b 本身又是在目录 a 中的,a 又是处在 root 目录下的。不从 / 开始的目录表示的是相对调用进程当前目录的目录,调用进程的当前目录可以通过 chdir 这个系统调用进行改变。下面的这些代码都打开同一个文件(假设所有涉及到的目录都是存在的)。

1
2
3
4
5
chdir("/a"); //当前工作目录改为/a
chdir("b"); //当前工作目录再改为/a/b
open("c", O_RDONLY); //以只读方式打开/a/b目录下的c文件(相对路径)

open("/a/b/c", O_RDONLY); //以只读方式打开/a/b目录下的c文件(绝对路径)

有很多的系统调用可以创建一个新的文件或者目录:mkdir 创建一个新的目录,open 加上 O_CREATE 标志打开一个新的文件,mknod 创建一个新的设备文件。下面这个例子说明了这3种调用:

1
2
3
4
mkdir("/dir"); //创建一个目录/dir
fd = open("/dir/file", O_CREATE|O_WRONGLY); //打开一个新的文件
close(fd); //关闭文件描述符
mknod("/console", 1, 1); //创建一个名为/console的设备文件

mknod 在文件系统中创建一个文件,但是这个文件没有任何内容。相反,这个文件的元信息标志它是一个设备文件,并且记录主设备号和辅设备号(mknod 的两个参数),这两个设备号唯一确定一个内核设备。当一个进程之后打开这个文件的时候,内核将读、写的系统调用转发到内核设备的实现上,而不是传递给文件系统。


可以获取一个文件描述符指向的文件的信息。它填充一个名为 ```stat```的结构体,它在 ```stat.h```中定义为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
```cpp
#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 的新文件。

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

读写 a 就相当于读写 b。每一个 inode 都由一个唯一的 inode 号 直接确定。在上面这段代码中,我们可以通过 fstat 知道 a 和 b 都指向同样的内容:a 和 b 都会返回同样的 inode 号(ino),并且 nlink 数会设置为2。


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

1
unlink("a"),

我们同样可以通过b访问到它

另外:

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

是创建一个临时 inode 的最佳方式,这个 inode 会在进程关闭 fd 或者退出的时候被清空。


xv6 关于文件系统的操作都被实现为用户程序,诸如 mkdir,ln,rm 等等。这种设计允许任何人都可以通过用户命令拓展 shell 。现在看起来这种设计是很显然的,但是 Unix 时代的其他系统的设计都将这样的命令内置在了 shell 中,而 shell 又是内置在内核中的。

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