第一个进程

回顾:进程 = 状态机

编译器:C 状态机 → 汇编状态机的翻译器

  • 特殊的指令/函数:syscall
  • 我们同样可以建模 (模拟) 进程的执行
1
2
3
4
5
6
7
8
9
10
def StateMachine():
b = sys_read()

if b == 0:
sys_write('I got a zero.')
else:
sys_write('I got a one.')

def main():
sys_spawn(StateMachine)

操作系统的启动

Firmware 阶段

  • CPU Reset 后,Firmware 代码开始执行
  • 加载操作系统

操作系统初始化阶段

  • 操作系统扫描系统中的硬件、初始化数据结构……
  • 加载第一个进程 (状态机)

操作系统执行阶段

  • 状态机在 CPU 上执行
  • 允许执行 syscall 进入操作系统代码

进程的创建

我们能控制这个行为吗?

  • 计算机系统没有魔法
  • 你能想到的事就能实现

人工智能就能帮你实现

  • 我希望用 QEMU 在给定的 Linux 内核完成初始化后,直接执行我自己编写的一个 hello 二进制文件。我应该怎么做?
  • 在这个过程中,发散出很多概念 → 知识体系的快速建立

恰恰是 UNIX “干净” 的设计 (完成初始化后将控制权移交给第一个进程) 使得 Linus 可以在可控的工程代价下实现 (相当完善的) POSIX 兼容,从而掀起一场操作系统的革命。时至今日,实现接口级的兼容已经是一件极为困难的工程问题,典型的例子是微软的工程师最终抛弃了 API 行为兼容的 Windows Subsystem for Linux 1.0,进而转向了虚拟机上运行的 Linux 内核。

整个 Linux 的 “世界” 都是从这个进程开始,并通过一系列实现进程管理的操作系统 API 创建的。

创建新进程

创建状态机

1
pid_t fork(void);

现在我们已经有 “一个状态机” 了

  • 只需要 “创建状态机” 的 API 即可
  • UNIX 的答案: fork
    • 做一份状态机完整的复制 (内存、寄存器现场)

fork() 的行为

立即复制状态机

  • 包括所有信息的完整拷贝
    • 每一个字节的内存
    • 打开的文件 (共享)
    • ……
    • 复制失败返回 -1
      • errno 会返回错误原因 (man fork)

如何区分两个状态机?

  • 新创建进程返回 0
  • 执行 fork 的进程返回子进程的进程号

Fork Bomb

新建状态机需要资源

  • 只要不停地创建进程,系统还是会挂掉的

代码解析: Fork Bomb

1
2
3
4
5
6
7
8
9
10
:(){:|:&};:   # 刚才的一行版本

:() { # 格式化一下
: | : &
}; :

f() { # bash: 允许冒号作为 identifier
f | f &
}
f
  • 类比原子弹:一个重原子核 (U-235/Pu-239) 被中子击中后分裂成两个较轻的原子核,同时释放出能量和更多的中子
    • “自我增殖”

进程总有 “被创建” 的关系

  • 因此总能找到 “父子关系”
  • 因此有了进程树 (pstree)
1
2
3
4
5
6
7
8
9
10
systemd-+-ModemManager---2*[{ModemManager}]
|-NetworkManager---2*[{NetworkManager}]
|-accounts-daemon---2*[{accounts-daemon}]
|-at-spi-bus-laun-+-dbus-daemon
| `-3*[{at-spi-bus-laun}]
|-at-spi2-registr---2*[{at-spi2-registr}]
|-atd
|-avahi-daemon---avahi-daemon
|-colord---2*[{colord}]
...

运行可执行文件

如何运行可执行程序?

1
2
int execve(const char *filename, 
char * const argv[], char * const envp[]);

UNIX 的答案: execve (重置状态机)

  • 将当前进程重置成一个可执行文件描述状态机的初始状态

execve 行为

  • 执行名为 filename 的程序
  • 允许对新状态机设置参数 argv (v) 和环境变量 envp (e)
    • 刚好对应了 main() 的参数!
  • execve 是唯一能够 “执行程序” 的系统调用
    • 因此也是一切进程 strace 的第一个系统调用

fork() + execve()

UNIX 中实现 “创建新状态机” 的方式

  • Spawn = fork + execve
1
2
3
4
5
6
7
8
9
10
11
int pid = fork();
if (pid == -1) {
perror("fork"); goto fail;
} else if (pid == 0) {
// Child
execve(...);
perror("execve"); goto fail;
} else {
// Parent
...
}

环境变量

“应用程序执行的环境”

  • 使用 env 命令查看
    • PATH: 可执行文件搜索路径
    • PWD: 当前路径
    • HOME: home 目录
    • DISPLAY: 图形输出
    • PS1: shell 的提示符
  • export: 告诉 shell 在创建子进程时设置环境变量
    • 小技巧:export ARCH=x86_64-qemu 或 export ARCH=native

环境变量:PATH

可执行文件搜索路径

  • 还记得 gcc 的 strace 结果吗?
1
2
3
4
[pid 28369] execve("/usr/local/sbin/as", ["as", "--64", ...
[pid 28369] execve("/usr/local/bin/as", ["as", "--64", ...
[pid 28369] execve("/usr/sbin/as", ["as", "--64", ...
[pid 28369] execve("/usr/bin/as", ["as", "--64", ...
  • 这个搜索顺序恰好是 PATH 里指定的顺序
1
2
3
$ PATH="" /usr/bin/gcc a.c
gcc: error trying to exec 'as': execvp: No such file or directory
$ PATH="/usr/bin/" gcc a.c

计算机系统里没有魔法。机器永远是对的。

退出程序

状态机管理:销毁状态机

1
void _exit(int status); 

fork + exec = 自由执行任何程序

  • 还差一个销毁状态机的函数就完整了
  • UNIX 的答案: _exit
    • 立即摧毁状态机,允许有一个返回值
  • 子进程终止会通知父进程 (后续课程解释)

这个简单……

  • 但问题来了:多线程程序怎么办?

结束程序执行的三种方法

exit 的几种写法 (它们是不同)

  • exit(0)
    • 会调用 atexit
  • _exit(0)
    • 执行 “exit_group” 系统调用终止整个进程 (所有线程)
    • 会调用 atexit 吗?
  • syscall(SYS_exit, 0)
    • 执行 “exit” 系统调用终止当前线程
    • 会调用 atexit 吗?

因为 “程序 = 状态机”,操作系统上进程 (运行的程序) 管理的 API 很自然地就是状态机的管理。在 UNIX/Linux 世界中,以下三个系统调用创建了整个 “进程世界”,不论是我们常用的 IDE 和浏览器,还是编译时在后台调用的 gcc。其中,fork 对当前状态机状态进行完整复制,execve 将当前状态机状态重置为某个可执行文件描述的状态机,exit: 销毁当前状态机。在对这个概念有了绝对正确且绝对严谨的理解后,操作系统也就显得不那么神秘了。