虚拟化¶
约 2152 个字 58 行代码 预计阅读时间 11 分钟
虚拟化
把物理计算机抽象成虚拟计算机,每个程序都好像独占计算机运行。
程序与进程¶
程序是状态机的静态描述,描述了所有可能的程序状态。程序(动态)运行起来,就成为进程。
进程管理 API¶
操作系统 API:非必要不提供,避免代码臃肿
- 进程管理: fork, execve, exit, waitpid
- 内存管理: mmap
- 文件(对象)管理: open, read, write, dup, close, pipe
既然如此,我们就需要操作系统提供进程管理的 API。
一个直观的想法是使用 spawn(path, argv)
创建进程(状态机),用 _exit()
销毁进程。这个想法在 Windows 中得到应用,分别对应 API 为 CreateProcess()
和 TerminateProcess()
。
而在 Unix 中,它使用复制和复位来代替创建和销毁:
pid_t fork(void);
完整复制一份状态机,包括内存、寄存器
- 执行
fork
的进程返回子进程的进程号,它们是“父子”关系 - 子进程则返回 0;如果返回 -1 则说明复制失败,通过
errno
返回错误原因
进程创建的关系形成了进程树,子进程结束时需要发送 SIGCHILD
信号给父进程。但是要是父进程此时已经终止了怎么办?UNIX 的托孤机制会将子进程的 ppid
设置为 1,并且 1 号进程收到该孤儿进程的信号不会做任何事。
实际上,OS 也可能会其托孤至 systemd --usr
上,这个进程的 pid
不为 1
addition:测试框架
- jyy 的测试框架中,父进程调用一个完全复制自己状态的子进程来
run_test
- 父进程等待子进程结束
waitpid
,根据子进程返回的结果判断测试是否通过
int execve(const char * filename, char * const argv[], char * const envp[]);
将当前进程重置成一个可执行文件描述状态机的初始状态。
filename
可执行文件的文件名argv[]
程序的参数envp[]
环境变量
只复位了用户可见的状态,OS 内部维护的状态没变,例如进程号、目录、打开的文件等
execve
是唯一能够“执行程序”的系统调用,因此当我们执行任何可执行文件时,第一个调用的肯定时 execve
,我们可以用 strace
命令进行验证:
在 C 程序中执行 execve
后,如果该系统调用没有 error,则后面的代码都不会被执行,因为这个程序的状态机直接被重置了。例如我们对 gcc 进行 strace
,观察这一部分代码:
这里在环境变量 $PATH
中寻找汇编器 as
,并遍历调用 execve
。从上到下当有一个 execve
可以正确执行后,下面的指令就不会再被执行。
void _exit(int status);
立即摧毁状态机,允许有一个返回值,可被父进程获取。
通过这三个系统调用就实现了整个 UNIX 世界中的所有进程调用。
进程地址空间¶
进程 execve
后的初始状态在 ABI 中得到规定,但是不同操作系统的规定并不相同,例如你可以查看 System-V ABI 的 3.4 节 Process Initializaiton。进程的地址空间中,大部分段都是不可读的,访问它们会出现 segment fault
。
Application Binary Interface: 二进制文件和底层系统的接口
进程的初始状态只有 ELF 文件中声明的内存和一些操作系统分配的内存,其它任何指针的访问都是非法的。但是进程的地址空间不是固定的,一定存在一个系统调用可以改变进程的地址空间:
addr
: 指定映射起始地址;对于mmap
,通常设为NULL
,由系统自动选择合适位置prot
: 指定内存保护方式PROT_READ
可读PROT_WRITE
可写
flags
: 控制映射行为MAP_SHARED
共享映射MAP_PRIVATE
私有映射MAP_ANONYMOUS
匿名申请内存
fd
: 把文件 “搬到” 进程地址空间中 (例子:加载器)offset
: 文件中的偏移量,表示从文件中的哪个位置开始映射
可以用 pmap
命令查看进程的地址空间,当然也可以看 /proc/[pid]/map
操作系统对象¶
文件¶
Everything is a File
文件是带有“名字”的数据对象,广义上可以包含字节流(终端、urandom
等)和字节序列(普通文件);而文件描述符是指向操作系统对象的指针,通过指针可以访问一切。
在 Unix 系统中,操作系统对象(如文件、设备、管道等)的访问都需要通过文件描述符(指针)。常见的文件描述符操作包括:
open()
- 打开/创建对象,会在地址空间中分配一个未分配的最小文件描述符p = malloc(sizeof(FileDescriptor));
close()
- 关闭对象delete(p);
read()/write()
- 读写对象,属于解引用*(p.data++);
lseek()
- 改变读写位置p.data += offset;
dup()
- 复制文件描述符q = p
文件描述符 0,1,2 初始默认占用,它们都指向同一个对象,一般为终端
文件描述符是进程状态的一部分,那么我们在执行 fork
复制进程的时候对于该块地址空间会发生什么?
对于字节流,我们可以没有直接将指针的值复制到子进程中,相当于浅拷贝;对于字节序列,文件描述符指向的是该文件的一个 offset
,因此我们复制的指针值实际上指向的为该 offset
,这个操作和 dup()
相同。
可以尝试以下代码:
Windows 系统中的文件描述符比 Unix 更像一个指针,称为 Handle(句柄)。和 Unix 相反的是,Windows 中默认 handle 是不继承的,这符合面向工程的最小权限原则。
除了上述用来访问系统中对象的系统调用,UNIX 还提供了 mount
, pipe
, mkfifo
等能够“创建”操作系统对象的系统调用。
UNIX 管道(pipe)是一个特殊的流式文件,它常用作进程间通信机制,允许数据在不同进程之间单向流动。其中一个进程将数据写入管道的一端,而另一个进程从管道另一端读取数据。
那么我们平常在终端使用管道 |
传递输出时,OS 的机制会创建一个管道对象,将前一个程序指向终端(stdout
)的文件描述符转移到管道的写口,后一个程序指向终端(stdin
)的文件描述符转移到管道的读口。
UNIX Shell¶
Session(会话) 是一组进程的集合,通常由一个登录会话(login, ssh 等)创建。每个 Session 包含一个或多个 Process Group(进程组),并关联到一个 Controlling Terminal(控制终端)。
由 fork
命令创建出来的进程属于同一个进程组,例如管道命令等。
Controlling Terminal 记录当前正在前台的 Process Group ID。用户在 Shell 中输入 Ctrl+C
(中断)、Ctrl+Z
(停止)、Ctrl+\
(退出) 等字符时实际上是发送给 OS,由 OS 向位于前台的进程组中的所有进程发送对应 signal:
Ctrl+Z
相当于最小化,可以通过 jobs
查看暂停的程序;通过 fg
指令继续执行暂停程序
可执行文件¶
LIBC¶
在操作系统 API 上,为了服务应用程序,有必要设计一套“好用”的库函数。虽然 libc
今天已经谈不上“好用”,但它成就了 C 语言今天的地位,以及以 ISO 标准的形式支撑了操作系统生态上的万千应用。
libc
大部分代码可以通过 C 语言本身实现,少部分需要一些底层支持,例如体系结构相关的内联汇编语句。
链接和加载¶
链接、虚拟内存部分在别的课学过了,此处不再记录
应用生态¶
操作系统仅有两个机制:初始状态+系统调用
UNIX 启动时初始状态:
<1> initramfs
中的对象,包括解压并挂载根文件系统 -> <2> /dev/console
,是用于 IO 的关键设备 -> <3> 查找并加载 init
脚本,此时才会挂载真实根文件系统,例如 /dev/sda1
,通过 pivot_root
或 switch_root
等系统调用切换到真实根文件系统,然后再执行 /sbin/init
全面初始化。
现代 Linux 执行 \sbin\init
会定向到 systemd