教材中阐述的操作系统的职责:
(简单了解即可,从后面的知识学习中才能对操作系统的内涵有更深刻的理解)
- 操作系统的任务是在多个程序之间共享一台计算机,并提供一组比单独的硬件支持更有用的服务
- 操作系统管理和抽象底层硬件,例如,一个文字程序不需要关心使用哪种类型的磁盘硬件
- 操作系统为程序提供可控制的交互方式,以便它们可以分享数据和一起工作
xv6
:是本课程中使用的一个类unix
的操作系统,运行在RISC-V
指令集处理器上,本课程中将使用QEMU
模拟器代替。xv6
使用传统的内核组织形式:如上图所示,
- 用户空间(user space):其内的进程即为用户进程(user process),可以有多个;
- 内核空间(kernel space):内核(Kernel)只有一个;
- 系统调用(system call):操作系统中的一个接口;
内核使用 CPU 提供的硬件保护机制来确保在用户空间中执行的每个进程只能访问自己的内存。
当用户进程需要调用内核服务时,实际上会先调用一个系统调用,然后由系统调用进入内核,内核执行服务后返回。
- shell:从用户读取命令并执行它们。(事实上
shell
只是一个用户进程,并不是内核的一部分)
xv6 中的实现是一个简单版本,具体实现可查看(
user/sh.c
)内核如何保证用户空间的进程只访问自己的内存?
1.1 进程和内存
xv6
进程由用户空间内存(指令、数据和堆栈)和内核私有的每个进程状态组成。内核把进程和进程标识符(PID)联系起来。@TODO 到这里不明白的点:
fork
进程使用
fork
这个系统调用来创建新进程(子进程,与父进程具有相同的内存内容):int pid = fork();
// 拿到返回值后,父进程和子进程都会从这里继续执行
if (pid > 0) {
// 父进程...
} else {
// 子进程...
}
- 对于父进程,返回子进程的
PID
- 对于子进程,返回
0
Question:
- 子进程与父进程内存内容相同?是拷贝嘛?还是指向同一个地址? 回答:使用不同的内存和寄存器;改变其中一个,对另一个没有影响。
- 怎么返回两个?啥意思?
回答:父进程调用
fork
这个系统调用之后,在父进程中会返回子进程的pid
,同时还会创建子进程,返回给子进程的pid = 0
exit
使调用进程停止执行并释放资源(例如内存和已经打开的文件)
- 入参:
- 成功,传入
0
- 失败,传入
1
wait
- 子进程 exited/killed,返回
PID of the child of process
- 没有子进程,返回
1
- 没有子进程退出,继续等待
exec
int exec(char *file, char *argv[])
打开一个
ELF
格式的文件,加载后替换当前执行的进程。成功的话,会执行文件内的进程。exec
执行时会替换调用进程的内存但保留其文件表。(也就是说exec
前打开的文件,exec
中也可以使用,后面提到的I/O重定向也利用了这个特点。)1.2 I/O 和文件描述符
文件描述符(
file descriptor
),形式上是一个整数,表示进程可以读取或写入的内核管理对象。(所以我们常说的文件,实际上指代的是文件描述符的对象)获取来源:进程通过打开文件、文件夹、设备、创建管道、或者复制存在的描述符的方式来获取
文件描述符接口抽象了文件、管道和设备之间的差异,使它们看起来都像字节流。
在内部,xv6 内核使用文件描述符作为每个进程表的索引,因此每个进程都有一个从零开始的文件描述符的私有空间。
按照惯例,文件描述符一般:
值 | 含义 |
0 | 标准输入 |
1 | 标准输出 |
2 | 标准错误 |
read
int read(int fd, char* buf, int n);
- 入参:文件描述符、从
fd
中最多读n
个字节放到buf
上
- 返回值:读取的字节数,如果返回 0,说明文件已经读完了
每个文件描述符都有个 offset 标记现在读取到哪了,所以多次读取是会续上的,而不是每次都从头读。
write
int write(int fd, char* buf, int n);
- 入参:文件描述符,从
buf
向fd
中写入最多n
个字节
- 返回值:写入的字节数,如果该数小于
n
,说明写入出错了
与
read
类似,write
也会根据offset
进行移动写入,不会每次都从头写。Tips: cat不会知道具体的文件描述符来源是file、console还是pipe。
close
int close(int fd);
- 入参:文件描述符,将该文件描述符释放,以便该
fd
后续再次被open
/pipe
/dup
- 返回值:
0
正常,1
则错误
Tips: 新分配的文件描述符始终是当前进程未使用的最低编号的描述符
系统调用exec中就利用了这一特性:
// cat < input.txt
if (fork() == 0) {
close(0); // 关闭最小的文件描述符
open("input.txt", O_RDONLY); // 便于将 input.txt 的内容读取到 0 文件上(同时也是标准输入)
exec("cat", argv); // 因为前面的输入文件描述符还在,因此这里才能把输入导到 cat 里面
}
Question:
- 为什么
fork
和exec
要分开? 回答:shell
有机会在两者之间去重定向子进程的I/O而不影响主进程(shell
)的I/O。实际的linux
中,vfork
提供了两者合并的功能。 - 在调用前先修改主shell 的I/O,在执行完子进程后再修改回来;
- 或者,将 I/O 重定向指令作为参数(@TODO,这里不太明白)
- 又或者(最不推荐的做法),像
cat
这样的每个程序都可以做自己的 I/O 重定向
如果两者结合在一起,
shell
要对子进程去重定向I/O的话,需要:- 为什么
2 > &1
是指把标准错误重定向标准输出,如何理解?
1.3 管道(Pipes)
管道:是一个小的内核缓冲区,作为一对文件描述符暴露给进程,一个用于读取,一个用于写入。
// pipe 的定义
int pipe(int p[]);
// 使用样例
int p[2];
pipe(p);
管道相比临时文件有以下四个优点:
- 管道可以自动清理;而使用文件重定向,shell 必须非常小心地在完成后删除临时文件;
- 管道可以传递任意长的数据流,而文件重定向需要在磁盘上有足够的可用空间;
- 管道允许并行执行流水线,而文件方法要求第一个程序在第二个开始之前完成;
- 如果是进程间通信,管道的阻塞读写比非阻塞语义的文件更加高效;
Question:
pipe
之后fork
,为什么父子进程都可以close(p[0])
,这不是重复关闭了嘛?
- xv-book pdf:p16中,为什么子进程需要先在执行
wc
之前关闭管道的写端? 回答:也就是close(p[1])
,因为如果管道的写端没关闭,那么从管道中read
就不会停止。
1.4 文件系统
xv6的文件系统,提供了包含未解释字节数组、目录的数据文件。其中,目录包含了对数据文件和其他目录的命名引用。
/a/b/c
:代表根目录/
下有一个目录a
,其中有一个目录b
,而b
中又有一个目录或文件c
。// 代码1
chdir("/a"); // 将当前目录切换到 /a
chdir("b");
open("c", O_RDONLY);
// 代码2,与代码1效果一样,都是打开文件c
open("/a/b/c", O_RDONLY);
操作文件和目录的系统调用:
mkdir("/dir"); // 创建新目录
fd = open("/dir/file", O_CREATE|O_WRONLY); // 创建新文件
close(fd);
mknod("/console", 1, 1); // 创建新设备
1.? Lab: Xv6 and Unix utilities
sleep
问题:实现一个
sleep
,根据用户提供的时间进行睡眠。根据提示,我们可以得到几个要点:
- 首先可以参考其他文件(e.g.
echo.c
),知道整体的代码架构
- 如果没有传入时间参数,需要报错并结束程序(不然你不知道要睡眠多久)
- 传进来的时间参数是字符串,我们使用时要用
atoi()
将它转为整型
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int main(int argc, char* argv[]) {
// 如果没有传递参数
if (argc <= 1) {
fprintf(2, "usage: sleep time...\\n");
exit(1);
}
if (sleep(atoi(argv[1])) < 0) {
fprintf(2, "sleep: %s failed.\\n", argv[1]);
exit(1);
}
exit(0);
}
Loading Comments...