操作系统经典的组织架构,一般最底层包括了计算机的一些硬件资源,包括CPU、内存、磁盘、网卡等。而在这个架构的最上层则运行着各种各样的应用程序,这些程序都运行在同一个空间中,这个空间被称为用户空间(Usersapce)

区别于运行在用户空间里的程序,有一个特殊的程序总是在运行,他称作Kernel

当打开计算机时,Kernel总是第一个启动,Kernel 程序只有一个,它维护数据来管理每一个用户空间进程,同时还维护了大量的数据结构来管理底层的硬件资源,以供用户空间的程序使用。因此,Kernel的核心功能就是: 管理硬件设备,供应用程序使用

我们通常会比较关心Kernel中的服务,一个是文件系统,另一个是进程管理系统

每一个用户空间程序都被称为一个进程,他们有自己的内存和共享的CPU时间。
同时, Kernel会管理内存的分配,不同的进程需要不同数量的内存,Kernel会复用内存、划分内存,并为所有的进程分配内存。

文件系统通常会有一些逻辑分区,我们可以认为文件系统的作用是管理文件内容并找出文件具体在磁盘中的哪个位置。文件系统还维护了一个独立的命名空间,其中每个文件都有文件名,并且命名空间中有一个层级的目录,每个目录包含了一些文件。所有的这些都被文件系统所管理。

Kernel的Access Control机制

当一个进程想要使用某些资源时,比如读取磁盘中的数据,使用某些内存,Kernel中的Access Control 机制会决定是否允许这样的操作。

Kernel的系统调用(System Call)

我们同时也会对应用程序如何与Kernel交互,它们之间的接口长什么样感兴趣,这些通常称为Kernel的API,它们决定了应用程序如何访问Kernel。通常来说,这是通过 系统调用(System Call) 来实现的。

系统调用于程序中的函数调用看起来是一样的,区别在于系统调用会实际运行到系统内核中,并执行内核中对于系统调用的实现

第一个例子是如果应用程序要打开一个文件,它会调用名为 open 的系统调用,并且把文件名作为参数传给 open。比如我要打开一个名为 “out” 的文件,那么 “out” 将会作为参数传人,同时还希望写入数据,那么还会有一个额外的参数 1 , 表明我想要写入参数,就是

1
2
API-Kernel
fd = open("out", 1)

这里看起来就像是一个函数调用,但是 open 是一个系统调用,它会跳到Kernel, Kernel可以获取到 open 的参数,接下来执行实现 open 的 Kernel 代码,最后返回一个文件描述符对象,之后,应用程序可以使用这个文件描述符作为 handle , 来表示打开相应的文件。

如果想要向文件写入数据,相应的系统调用则是 write .

1
2
fd = open("out", 1)
write(fd, "Hello\n", 6)

  1. 第一个参数是由 open 返回的文件描述符
  2. 第二个参数是指向要写入数据的指针,通常是 char 型。实际上是内存中的地址
  3. 第三个参数是写入字符的数量

除此之外还可能用到的系统调用是 fork, 它会创建一个与调用进程一模一样的进程,并返回新进程的 process ID/pid。

系统调用跳到内核与标准的函数调用跳到另一个函数相比,区别是什么?
A: Kernel会有特殊的权限能够直接访问到底层硬件,而普通的用户程序是没有办法直接访问这些硬件的。

系统调用-copy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//copy.c: copy input to output.

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

int main() {
char buf[64];

while(1) {
int n = read(0, buf, sizeof(buf));
if(n <= 0) {
break;
}
write(1, buf, n);
}
exit(0);
}

这个程序执行了三个系统调用:read、write、exit

系统调用-read

它接收三个参数:

  1. 第一个参数是文件描述符,指向一个之前打开的文件。Shell 会确保在默认情况下,当一个程序启动时,文件描述符0链接到console的输入,文件描述符1链接到console的输出,所以可以通过这个程序看到console打印我的输入。当然,这里的程序会预期文件描述符已经被Shell打开并设置好。这里的0,1文件描述符是非常普遍的Unix风格,许多的Unix系统都会从文件描述符0读取数据,然后向文件描述符1写入数据。
  2. 第二个参数是指向某段内存的指针,程序可以通过指针对应的地址读取内存中的数据。
  3. 第三个参数是代码想读取的最大长度。

read 的返回值是读到的字节数,如果读到文件结尾没有更多内容了read会返回0,如果发生了一些错误比如文件描述符不存在则会返回-1。

系统调用-open

前面 copy 代码假设文件描述符已经设置好了,但是一般情况下,我们需要创建文件描述符,最直接的创建文件描述符的方法就是使用open系统调用。

1
2
3
4
5
6
7
8
9
10
11
//open.c: create a file, write to it.
#include "kernel/types"
#include "user/user.h"
#include "kernel/fcntl.h"

int main() {
int fd = open("output.txt", O_WRONLY | O_CREATE);
write(fd, "ooo\n", 4);

exit(0);
}

这个程序会创建一个叫做 output.txt 的新文件,并向它写入一些数据,最后退出。我们看不到任何输出,因为它只是向打开的文件中写入数据。

  1. 第一个参数是文件名
  2. 第二个参数是一些标志位,用来告诉 open 系统调用在内核中的实现:我们将要创建并写入一个文件。

open 系统调用会返回一个新分配的文件描述符,通常是一个比较小的数字,2、3、4等。

之后这个文件描述符作为第一个参数传入 write,数据被写入到了文件描述符对应的文件中。

文件描述符

文件描述符本质上对应了一个内核中的表单数据。内核维护了每个运行程序的状态,内核会为每一个运行进程保存一个表单,表单的 key 是文件描述符。这个表单让内核知道,每个文件描述符对应的实际内容是什么。这里比较关键的是,每个进程都有自己独立的文件描述符空间,所以如果运行了两个不同的程序,对应两个不同的进程,如果他们都打开一个文件,他们或许可以得到相同数字的文件描述符,但是因为内核为每个进程都维护了一个独立的文件描述符空间,这里相同数字的文件描述符可能会对应到不同的文件。

文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,用于指代被打开的文件,对文件所有 I/O 操作相关的系统调用都需要通过文件描述符。Linux中”万物皆可文件”

Shell

Shell 通常就是人们说的命令行接口。它提供了很多工具来管理文件,编写程序,编写脚本。通常来说,当你输入内容时,其实是在告诉Shell运行相应的程序,比如输入 ls 时,实际意义就是要求 Shell 运行名为 ls 的程序,文件系统中有一个文件名为 ls, 这个文件中包含一些计算机指令。

除了运行程序以外,Shell还可以做其他事情,比如重定向IO

1
$ ls > out

要求Shell运行ls程序并将输出重定向到一个叫做out的文件中,然后可以通过 cat 命令来读取 out 中的数据。也可以运行一个叫做 grep 的程序。grep x会搜索输入中包含x的行,我可以告诉shell将输入重定向到文件out,这样我们就可以查看out中的x。
1
$ grep x < out

系统调用-fork

下面是一个简单用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// fork.c: create a new process
#include "kernel/types.h"
#include "user/user.h"

int main() {
int pid;
pid = fork();
printf("fork() returned %d", pid);
if(pid == 0) {
printf("child\n");
} else {
printf("parent\n");
}
exit(0);
}

调用 fork 会拷贝当前进程的内存并且创建一个新的进程,这里的内存包含了进程的指令和数据,之后就有了两个拥有完全一样内存的进程。fork系统调用在两个进程中都会返回,在原始的进程中,fork系统调用会返回大于0的整数,这个是新创建进程的ID。而在新创建的进程中,fork系统调用会返回0。所以即使两个进程的内存是完全一样的,我们还是可以通过fork的返回值区分旧进程和新进程。(完全相同的内存,但是是不同的内存地址,此外,文件描述符也会被拷贝)

系统调用-exec, wait

echo是一个非常简单的命令,它接收任何你传递给它的输入,并将输入写到输出。

下面是一个 exec 系统调用的简单使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// exec.c: replace a process with an executable file

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

int main() {
// 指针0是一个NULL指针,它只表明结束
char *argv[] = {"echo", "this", "is", "echo", 0};

exec("echo", argv);

printf("exec failed!\n");

exit(0);
}

代码会执行 exec 系统调用,这个系统调用会从指定的文件中读取并加载命令,并替代当前调用进程的指令。从某种程度上来说相当于丢弃了调用进程的内存,并开始执行新加载的指令。所以上面的代码执行 exec 命令后会有这样的效果:操作系统从名为 echo 的文件中加载指令到当前的进程中,并替代了当前进程的内存,之后开始执行这些新加载的指令。同时,你可以传入命令行参数,exec允许传入一个命令行参数的数组,这里是一个C语言中的指针数组,这里的字符指针本质就是一个字符串。

所以这里等价于运行 echo 命令,并且带上 “this is echo” 这三个参数。我们可以看到如下输出:

1
this is echo

即我运行了exec程序,exec程序实际上会调用exec系统调用,并用echo指令来代替自己,所以这里是echo命令在产生输出。

exec命令用于调用并执行指定的命令。exec命令通常用在shell脚本程序中,可以调用其他的命令。如果在当前终端中使用命令,则当指定的命令执行完毕后会立即退出终端。系统调用 exec 是以新的进程去代替原来的进程,但进程的PID保持不变。因此,可以这样认为,exec系统调用并没有创建新的进程,只是替换了原来进程上下文的内容。原进程的代码段,数据段,堆栈段被新的进程所代替。

除此之外,还有一些值得注意的地方:

  1. exec系统调用会保留当前的文件描述符表单。所以任何在exec系统调用之前的文件描述符,例如0,1,2等,它们在新的程序中表示相同的东西。
  2. 通常来说exec系统调用不会返回,因为exec会完全替换当前进程的内存,相当于当前进程不复存在了,所以exec系统调用已经没有地方能返回了。exec系统调用只会当出错时才会返回,例如程序文件根本不存在,因为exec系统调用不能找到文件,exec会返回-1。

如果不想原进程被替代的话,可以先 fork 一个子进程,然后在子进程里调用exec系统调用。下面是一个应用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// forkexec.c: fork then exec

#include "user/user.h"

int main() {
int pid, status;
pid = fork();
if(pid == 0) {
char *argv[] = {"echo", "THIS", "IS", "ECHO", 0};
exec("echo", argv);
printf("exec failed!\n");
exit(1);
} else {
printf("parent waiting\n");
wait(&status);
printf("the child exited with status %d\n", status);
}
exit(0);
}

在这个程序中先调用了fork,在子进程中与前一个程序一样调用exec。子进程会用echo命令来代替自己,echo执行完成之后就退出。之后父进程重新获得了控制。fork会在父进程中返回大于0的值。

Unix提供了一个wait系统调用,如第20行所示。wait会等待之前创建的子进程退出。当我在命令行执行一个指令时,我们一般会希望Shell等待指令执行完成。所以wait系统调用,使得父进程可以等待任何一个子进程返回。这里wait的参数status,是一种让退出的子进程以一个整数(32bit的数据)的格式与等待的父进程通信方式。所以在第17行,exit的参数是1,操作系统会将1从退出的子进程传递到第20行,也就是等待的父进程处。&status,是将status对应的地址传递给内核,内核会向这个地址写入子进程向exit传入的参数。wait返回子进程的进程号。

Unix中的风格是,如果一个程序成功的退出了,那么exit的参数会是0,如果出现了错误,那么就会像第17行一样,会向exit传递1。

IO重定向(Redirect)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// redirect.c: run a command with output redirected

int main() {
int pid;

pid = fork();
if(pid == 0) {
close(1);
open("output.txt", O_WRONLY|O_CREATE);

char *argv[] = {"echo", "this", "is", "redirect", "echo", 0};
exec("echo", argv);
printf("exec failed!\n");
exit(1);
} else {
wait((int *) 0);
}
exit(0);
}

shell提供了方便的IO重定向工具,echo hello > out 会将 echo 的输出重定向到 out 文件中,然后运行 cat < out 就可以看到输出。

Shell之所以有这样的能力,是因为Shell首先会先fork一个子进程,然后再子进程里,Shell改变了文件描述符。文件描述符1通常是进程用来作为输出的(也就是console的输出文件符),Shell会先将文件描述符1改为output文件,之后再运行你的指令。同时,父进程的文件描述符1并没有改变。所以这里先fork,再更改子进程的文件描述符,是Unix中常见的重定向指令的输入输出的方法。

运行上面的代码,没有看到任何的输出,但实际上 redirect 程序里运行了 echo,并且把 echo 的输出重定向到 output.txt。在output.txt里可以看到 “this is redirect echo”。代码里 close(1) 的意义是我们希望文件描述符1指向一个其他的位置,也就是说,在子进程里,我们不想使用原本指向console的文件描述符1。后面的 open 语句一定会返回1,因为 open 会返回当前进程未使用的最小文件描述符。因为刚刚关闭了文件描述符1,而文件描述符0又对应着console的输入,所以open一定会返回1。再之后,文件描述符1就和output.txt关联起来了。

管道-PIPE

管道是一种通信机制,通常用于进程间的通信,将前一个进程的输出(stdout)作为下一下进程的输入(stdin)。管道命令仅能处理standard output,也就是说类似 lessheadtail等可以接受标准输入的命令,而管道又可以分为匿名管道和有名管道。

匿名管道用 | 来表示,用完即销毁,例如 ls -al /etc | less 。而有名管道又被称为先进先出队列(FIFO),可以通过 mkfifo 命令来显式地常见,例如

1
mkfifo hello

会在当前文件夹下创建一个管道文件 hello ,然后可以执行 echo "hello world" > hello 往管道里写入 hello world ,然后因为管道是同步的,所以在当前终端下会发生阻塞,需要新打开一个终端,然后执行 cat < hello 命令就可以将管道中的数据读取出来并打印,然后之前的终端阻塞也停止了。

需要注意的是,当进程对命名管道的使用结束后,命名管道依然存在于文件系统中,使用 ls 命令可以看到该文件,需要手动删除。

管道的创建是系统调用 pipe() ,该函数创建了一个管道 pipe 并且返回两个文件描述符用来表示管道的两端,读取端描述符是 fd[0] ,写入端描述符是 fd[1]

而我们之所以能够使用类似 ls -al /etc | less 这样的命令来进行两个进程之间的通信,主要是进行了如下实现:

  1. 首先在当前进程下创建一个管道 fd[2] ,然后使用 fork 创建一个子进程,此时 fd 数组同样会被复制,但是由于它们指向了同一个文件(管道),所以就实现了两个进程通过 fd 数组对同一个管道进行跨进程读写操作。
  2. 禁用父进程的读取端,禁用子进程的写入端,就实现了从父进程写入由子进程读取的单向操作。

下面这段程序运行了 wc ,并将它的标准输出绑定到了一个管道的读端口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int fd[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(fd);
if(fork() == 0) {
close(0);
dup(fd[0]);
close(fd[0]);
close(fd[1]);
exec("/bin/wc", argv);
} else {
write(fd[1], "hello world\n", 12);
close(fd[0]);
close(fd[1]);
}

这段程序首先创建了一个管道,然后 fork 出一个子进程,这样父子进程都指向同一个管道。

在子进程中,首先关闭了标准输入(文件描述符为0),然后使用 dup(fd[0]) 复制了管道的读端,由于复制操作会使用最小可用的文件描述符,所以管道的读端成为了新的标准输入。接着,关闭了管道的两个端口,因为复制后就不再需要原来的文件描述符。最后,使用 exec 函数执行了 wc 命令,wc命令会从标准输入(现在是管道的读端)读取数据。

在父进程中,使用write函数向管道的写端写入了字符串”hello world\n”。然后,关闭了管道的两个端口。总的来说就是这里实现了父进程向子进程传输 “hello world\n” ,然后子进程调用 wc 命令处理这个字符串,输出行数、单词数和字节数,在这里应该是输出 1 2 12

如果数据没有准备好,那么对管道执行的 read 会一直等待,直到有数据了或者其他绑定在这个管道写端口的描述符都已经关闭了。后者 read 会返回 0,这也是为什么执行 wc 之前要关闭子进程的写端口 —— 防止堵塞。