一、操作系统接口
1. 进程和内存
1.1. 通过fork()系统调用来讲解进程和内存
- xv6提供了分时特性(不同进程间的调度),内核将每个进程和一个pid关联起来
- 一个进程可通过系统调用fork()来创建一个子进程,相当于把父进程内存空间的所有数据代码全部复制到子进程的内存空间中。在父进程中,fork()返回值是子进程pid;在子进程中,fork()返回值是0.
- wait()会等待一个子进程退出
1.2. 通过exec()系统调用来讲解进程和内存
- exec会释放本进程的内存,并将参数中的文件调入内存并执行(由于之前的calling process被释放了,因此即使执行完参数文件,也不会返回原来的calling process)
由此,提出了一个疑问,为什么不把fork和exec合并成一个调用?
猜测:一个是保留父进程,一个是终止父进程,肯定不一样啊。
1.3. xv6 shell执行命令的代码逻辑
- 用
getcmd
获取用户输入,若不是cd命令,则: - fork一个子进程,父shell调用wait,子进程exec该命令
从逻辑上可看出这样的代码非常浪费时间和空间,因为fork会将父进程全部拷贝,然后再用exec替换。为了防止这样的浪费,有了copy-on-write(要在4.6进行讨论)
2. I/O和文件描述符
- 文件描述符fd=0表示标准输入,fd=1表示标准输出,fd=2表示standard error
- read系统调用
read(fd1, buf, n)
从fd1
读最多n个字节,将它们拷贝到buf
中,然后返回读出的字节数;write(fd2, buf, n)
写buf
中的n个字节到fd2
并返回实际写出的字节数 cat
的实现逻辑:从fd1
读取,然后再写到f2
中。下面代码中和其类似,而分别将二者设为0和1,代表从标准输入读取,然后再输出到标准输出中(即从键盘读取,再输出到屏幕上)
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){
fprintf(2, "write error\n");
exit();
}
}
close
系统调用释放一个文件描述符,让其以后可以被其他系统调用使用(如open
、pipe
、dup
)- I/O重定向可通过fork和文件描述符交叉使用而实现:
fork
一个进程->关闭当前的标准输入->打开指定文件(默认成为标准输入)->执行新的程序
如下面cat<input.txt
的原理:
char *argv[] = {"cat", nullptr};
pid = fork();
if (pid==0){
close(0); // 子进程关闭文件描述符0后,可以保证接下来的open会使用0作为心打开的文件input.txt的文件描述符,之后cat就会在标准输入指向input.txt的情况下运行
open("input.txt", O_RDONLY);
exec("cat", argv);
}
if (pid!=0){
wait(NULL);
}
- 每一个文件的偏移是父子进程共享的,如子进程写"hello “,父进程先wait(),然后再写"world”,最后输出就是"hello world"。
dup
复制一个已有的文件描述符,返回一个指向同一个输入/输出对象的新描述符。这两个描述符共享同一个文件偏移
文件描述符是一个强大的抽象,因为它们将所连接的细节隐藏起来了:一个进程向描述符1写出,它有可能是写到一份文件、一个设备(如控制台)、或一个管道
3. 管道
- 管道是一个小的内核缓冲区,给进程提供的接口是一对文件描述符(一个用于读操作、一个用于写操作)
- 下图的代码中,子进程的文件描述符表如下:
int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if(fork() == 0) { // 此时子进程的文件描述符表为:0->标准读;1->标准写;2->标准错误;3->p[0];4->p[1]
close(0); // 此时子进程的文件描述符表为:1->标准写;2->标准错误;3->p[0];4->p[1]
dup(p[0]); // 此时子进程的文件描述符表为:0->p[0];1->标准写;2->标准错误;3->p[0];4->p[1]
close(p[0]); // 此时子进程的文件描述符表为:0->p[0];1->标准写;2->标准错误;4->p[1]
close(p[1]); // 此时子进程的文件描述符表为:0->p[0];1->标准写;2->标准错误;
exec("/bin/wc", argv);
} else {
write(p[1], "hello world\n", 12);
close(p[0]);
close(p[1]);
}
即,调用pipe()、fork()后,父子进程都有了指向管道的文件描述符。子进程将管道的读端口拷贝在描述符0上,再关闭p中的描述符(新管道的读写描述符被记录在数组p中),然后执行wc。当wc从标准输入读取时,实际上是从管道读取的。父进程向管道的写端口写入然后关闭它的两个文件描述符
3. 如命令echo hello too | xargs echo bye
,背后的原理是:先执行echo hello too
,导致将"hello too"输出到标准输出,并通过管道“|”传递给xargs
命令。而xargs的实现代码中,有这么一段:
while(read(0, &ReadChar, 1))
{
if(ReadChar != '\n')
{
*Ptr = ReadChar;
++Ptr;
}
else
{
// 一旦遇到结尾,给字符串末尾一个空字符,执行此命令行即可
*Ptr = 0;
execCommand(CmdPath, Argv);
Ptr = Argv[StartIndex];
}
}
就是在之前设置好xargs后面的参数后(存放在Argv中),又读入标准输入中的内容(即通过管道传递过来的之前echo的内容)并存放在Ptr(也就是CmdPath)中