xv6 操作系统(一):从一个 Shell 命令理解操作系统
本文记录学习 MIT 6.S081 Lecture 01 过程中一些理解与思考。
1.操作系统
1.1操作系统是什么
1. 抽象硬件,提供一系列比直接操作硬件更有用的服务
2. 多进程之间共享硬件资源
- 在互不干扰的前提下运行多个程序
- multiplex(多路复用)
3. 不同程序之间有时候需要进行数据共享
4. 给程序提供一种受控的交互方式
- 隔离性
- 安全
- 访问控制1.2 操作系统的基本结构

可以简单分成三层:
用户程序
↓
用户空间
↓
Kernel
↓
硬件其中:
- 硬件资源包括:CPU、内存、磁盘、网卡等
- 用户空间运行:Shell、文本编辑器、C 编译器(CC)等程序
- Kernel(内核):管理计算机资源,可以理解为计算机资源的守护者
内核拥有普通用户程序没有的特殊权限,可以访问 CPU、内存、磁盘等硬件资源。
2.xv6的运行环境
2.1 xv6与Unix
Unix 是一个比较早期的操作系统。
xv6 是一个教学操作系统,它实现了很多与 Unix 类似的设计,但是比真正的 Unix/Linux 简单很多。
xv6 运行在 RISC-V 架构上,我们通过 QEMU 模拟一台 RISC-V 计算机来运行 xv6。
Unix 最早的重要用途之一,就是允许多个用户通过分时的方式共享一台计算机。
用户通常通过 Shell 与操作系统进行交互。2.2 QEMU是什么
- QEMU是一个硬件模拟器
我们可以使用 QEMU 模拟:
CPU
内存
磁盘
其他硬件设备- 然后让 xv6 认为自己运行在一台真正的 RISC-V 计算机上。
2.3 为什么修改程序后需要重新构建 xv6
copy.c 位于 macOS 文件系统中
↓
macOS 将它交叉编译成 RISC-V 程序
↓
Makefile 将程序放入 fs.img
↓
QEMU 将 fs.img 模拟成硬盘
↓
xv6 从自己的文件系统中找到并运行 copy为什么使用 fs.img,而不是直接读取 macOS 文件夹?
QEMU 模拟的是一整台电脑。
这台电脑需要自己的硬盘,
因此使用 fs.img 文件来模拟磁盘。xv6 是一个比较简单的教学操作系统,它本身并不会直接读取 macOS 的文件目录。
如果要让 xv6 直接访问 macOS 的目录,会引入更多复杂的机制。
程序编译好
↓
放入 fs.img
↓
xv6 从 fs.img 中读取并运行3.系统调用
3.1 什么是系统调用
- 普通用户程序不能直接操作很多硬件资源。因此用户程序需要通过,System Call请求内核完成这些操作。
open 打开文件或设备,返回文件描述符(fd)
read 从文件描述符读取数据到缓冲区
write 将缓冲区数据写入文件描述符
fork 复制当前进程,创建子进程(父进程返回子进程 pid,子进程返回 0)
exec 用另一个程序替换当前进程的内存和指令,成功不返回
wait 等待子进程结束,并获取其退出状态
exit 结束当前进程,并将退出状态返回给父进程3.2 普通函数调用和系统调用有什么区别
- 普通函数调用:
用户程序
↓
普通函数
↓
仍然运行在用户权限下- 系统调用:
用户程序
↓
系统调用
↓
进入 Kernel
↓
内核使用特殊权限完成操作区别在与:内核具有访问硬件和系统资源的特殊权限,普通用户程序没有这些权限。
3.3 系统调用底层说怎么样进入内核的
从C语言角度看:
open(...)
write(...)
看起来像普通 C 函数。
但系统调用最终会执行特殊的机器指令。
在 RISC-V 中,这条指令叫:ecall- 流程大致是:
用户程序
↓
open() / write()
↓
执行 ecall
↓
控制权进入 Kernel
↓
Kernel 检查寄存器和参数
↓
执行对应的系统调用
↓
返回用户程序3.4 Python 等高级语言和系统调用是什么关系
- 高级语言更加接近人的表达方式,低级语言更加接近机器指令。
Python 代码
↓
Python 运行时 / 标准库
↓
操作系统接口
↓
系统调用
↓
Kernel所以 Python 并不是不使用系统调用,而是把系统调用进行了封装。
4.基本 I/O 系统调用
4.1 open
`open` 是一个系统调用,调用之后,会进入 Kernel 执行相关代码,最终返回:文件描述符。
常见的文件描述符:
0 = 标准输入
1 = 标准输出
2 = 标准错误输出4.2 read / write
read() 用于读取数据:
write() 用于写入数据:
read(fd, buffer, n);
write(fd, buffer, n);
参数说明:
fd 文件描述符,表示从哪个文件/设备读,或写到哪个文件/设备 (见9文件描述符号)
buffer 内存缓冲区,read 时用来存放读到的数据,write 时存放要写出的数据
n 要读取或写入的字节数
Unix 将很多 I/O 统一抽象成:字节流字节流是什么意思?
- 可以简单理解成:数据是一串连续的字节。
例如一个文件有 300 个字节:
第一次 read → 读取 1~100
第二次 read → 读取 101~200
第三次 read → 读取 201~3004.3 exit
用于结束当前进程
0 通常表示成功
非 0 通常表示发生错误父进程可以通过 wait() 获取子进程的退出状态。
5.Shell 是如何运行一个命令的
- 案例:cat README
用户输入 cat README
↓
Shell 解析命令
↓
Shell 调用 fork()
↓
创建一个子进程
↓
子进程调用 exec()
↓
加载 cat 程序
↓
cat 调用 open / read 读取 README
↓
cat 调用 write 输出内容Shell 是连接用户和操作系统的一种命令行程序,它通过系统调用使用 Kernel 提供的功能。
理解 Shell 的关键,就是理解:fork 、exec、wait 、文件描述符 、I/O Redirect
6. fork:创建进程
6.1 fork 的基本作用
fork();
会创建一个新的子进程。
fork 之后
↓
存在两个进程
↓
父进程
子进程两个进程都会从 fork() 返回的位置继续执行。
区别在于返回值:子进程:fork() 返回 0,父进程:fork() 返回子进程的 PID
6.2 fork 会复制什么
可以简单理解为:fork 会复制当前进程的内存,产生一个新的进程。
编译后的 C 程序最终也是内存中的机器指令和数据,所以 fork 后:
父进程
↓
拥有自己的虚拟内存
子进程
↓
拥有看起来完全一样的虚拟内存之后父子进程可以分别继续运行,在多核环境下,它们甚至可能同时运行,因此输出可能会交错。
6.3 Shell为什么需要fork
例如在shell中运行:ls
Shell
↓
fork()
↓
创建子进程
↓
子进程运行 ls- Shell 自己不能直接被
ls替换,否则执行完ls后,Shell 就不存在了。 所以 Shell 会先创建一个子进程,再让子进程去执行用户输入的命令。
6.4 Copy-on-Write
- 在前面“5. Shell 是如何运行一个命令的”这个流程中,Shell 会先
fork()创建子进程,然后子进程再调用exec()运行新的程序。
如果 `fork()` 真的把父进程的内存完整复制一份:
Shell
↓
fork()
↓
完整复制父进程内存
↓
子进程 exec()
↓
刚刚复制的内存又被新程序替换这样比较浪费,所以现代操作系统都会使用Copy-on-Write
fork()
↓
父子进程先共享内存
↓
某一方需要修改某块内存
↓
操作系统才复制这一块内存写入的时候才进行复制。
7.exec:用另一个程序替换当前程序
7.1 exec的作用
exec()不会创建新进程。- 从某个可执行文件加载新的程序,并用它替换当前进程的程序内容。
// exec.c
#include "kernel/types.h"
#include "user/user.h"
int main()
{
// 0 作为 argv 数组的结束标志
// argv[0] 通常写成程序名只是一个约定
// 真正的命令参数通常从 argv[1] 开始
char *argv[] = {"echo","this","is","echo",0};
// 第一个参数 "echo":告诉内核“去加载哪个可执行程序”
// 参数2:传给新程序的命令行参数数组
exec("echo", argv);
// exec 成功后不会执行到这里,原程序已经被替换
printf("exec failed!\n");
exit(1);
}
当前进程
↓
exec("echo", argv)
↓
内核根据第一个参数 "echo"
找到 echo 可执行文件
↓
加载 echo,替换当前进程原来的程序
↓
把 argv 作为参数交给 echo
↓
开始执行 echo第一个参数决定运行哪个程序;argv[0] 只是传给新程序的第一个参数,通常习惯写成程序名,但不要求必须相同,真正的命令参数通常从 argv[1] 开始
7.2为什么shell不自己直接exec
- 如果shell直接执行exec,shell也就不存在了
- 因此 Unix 中常见的方式是:
Shell
↓
fork()
↓
产生子进程
↓
子进程 exec()
↓
运行用户输入的程序父进程 Shell 仍然存在,子进程复杂运行命令
8.wait:等待子进程
8.1 wait的作用
- 父进程可以调用:wait(&status);等待子进程退出
- 如果子进程没有退出,父进程在这里阻塞,子进程退出,wait立即返回,同时写入status
// forkexec.c
// 先 fork() 创建子进程,然后在子进程里 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");
// exec 失败后执行
// 子进程以状态 1 退出
exit(1);
}else{
printf("parent waiting\n");
// 等待子进程结束
// 子进程结束后,内核将退出状态写入 status
wait(&status);
printf("child exit with status %d\n", status);
}
exit(0);
}8.2 status 是什么时候赋值的
char *argv[] = {"echo","THIS","IS","ECHO",0};
exec("echo", argv);
printf("exec failed\n");
exit(1);情况1:exec执行成功
子进程
↓
exec("echo", argv)
↓
成功加载 echo 程序
↓
原来的子进程程序被 echo 替换(不会继续往下执行)
↓
echo 程序运行结束
↓
echo 自己调用 exit(0)
↓
内核保存退出状态 0
↓
父进程 wait(&status)
↓
status = 0- exec() 成功以后并不是 exec() 返回,而是当前子进程开始运行新的 echo 程序。
情况 2:exec 失败
- 如果系统找不到 echo,或者加载失败:
exec("echo", argv)
↓
exec 返回 -1
↓
继续执行后面的代码
↓
printf("exec failed")
↓
exit(1) 为什么这里是1,子进程退出状态为1
↓
内核保存退出状态 1
↓
父进程 wait(&status)
↓
status = 1status 保存的是子进程最终的退出状态,exec 成功时,由新程序最终的 exit(...) 决定;exec 失败时,会执行后面的 exit(1)。
8.3wait的返回值是什么
int pid = wait(&status);
wait() 返回值 → 退出的子进程 PID
status → 子进程的退出状态8.4 父进程有多个子进程怎么办
如果父进程有两个子进程,并且希望确保两个子进程全部结束:需要调用两次 wait
所以如果父进程有多个子进程,需要调用对应次数的 `wait()`,才能把这些子进程全部等待并回收。8.5 子进程可以等待父进程吗
`wait()` 只能等待:当前进程自己的子进程
所以子进程不能通过 `wait()` 来等待自己的父进程。9.文件描述符
9.1 文件描述符作用
- 进程用来告诉内核“我要操作哪个已经打开的文件、设备或者管道”的编号。
0 → 标准输入
1 → 标准输出
2 → 标准错误输出
write(1, ...) :向文件描述1写入数据
fd 1 → 终端,所以内容回显示到终端可以简单理解成:
程序使用 fd-> 内核根据 fd-> 找到真正的文件 / 管道 / 设备
- 文件描述符本身只是一个编号。
fd 0 → console
fd 1 → console
fd 2 → console
fd 3 → 某个文件
fd 4 → 某个 pipe10.I/O Redirect
10.1 Shell 中的重定向
- Shell 中:
< 数据从文件流向程序
> 数据从程序流向文件
grep X < out : 文件 out->grep 的标准输入
echo hello > output.txt :echo 的标准输出->output.txt10.2 重定向程序
// redirect.c
// 运行命令,并将它的输出重定向到 output.txt
#include "kernel/types.h"
#include "kernel/fcntl.h"
#include "user/user.h"
int main()
{
int pid;
pid = fork();
if(pid == 0){
// 关闭原来的标准输出
close(1);
// open 会返回最小的空闲文件描述符
// 因为刚刚关闭了 1,所以这里会得到 fd = 1
// O_CREATE:不存在就创建 O_WRONLY:只允许写入
open("output.txt", O_CREATE | O_WRONLY);
char *argv[] = {"echo","this","is", "redirect","echo",0};
// echo 仍然向 fd 1 输出
// 但现在 fd 1 已经指向 output.txt
exec("echo", argv);
// exec 失败才会执行到这里
printf("exec failed!\n");
exit(1);
}else{
// 等待子进程结束
wait((int *)0);
}
exit(0);
}close(1) 的作用
- 表示关闭当前进程的文件描述符 1
接下来执行: open("output.txt", O_CREATE | O_WRONLY);
xv6 的 `open()` 会返回:当前进程最小的空闲文件描述符
fd 1 → output.txt
此时再执行,数据就会写进 output.txt10.3 为什么要先 fork 再修改文件描述符
- 因为我们只希望,echo 的输出 → output.txt,而不是Shell 自己的输出 → output.txt
所以 Shell 先:
Shell
↓
fork()
↓
创建子进程
然后只修改子进程:子进程 fd 1 → output.txt,
而父进程 Shell 仍然保持:Shell 的 fd 1 → 终端Unix中的常见的用来重定向指令的输入输出的方法,这种方法同时又不会影响父进程的输入输出。因为我们不会想要重定向Shell的输出,我们只想重定向子进程
版权所有
版权归属:念宇
