操作系统上的进程
操作系统上的进程
有关状态机、并发和中断的讨论给我们真正理解操作系统奠定了基础,现在我们正式进入操作系统和应用程序的 “边界” 了。让我们把视角回到单线程应用程序,即 “执行计算指令和系统调用指令的状态机”,开始对操作系统和进程的讨论。
这部分的内容
线程、进程和操作系统,操作系统上的第一个进程
UNIX/Linux 进程管理 API:fork, execve, exit
前面讲了很多并发,特别强调了,程序就是状态机。单线程状态机、多线程状态机。到这里,我们还要用状态机视角来帮助理解操作系统上的进程以及 API。
状态包含:
编译出来的汇编指令在代码区,read only,运行时不应该被任何人修改。
此外还有数据区,可读可写,这些是共享的。
此外还有每个线程自己的状态,如线程栈,自己的线程信息,比如线程名是个指针,指向data中的字符串。
寄存器
然后每条指令执行,或者发生中断,都会把状态往前推一步。上面就是理论。如何把这个理论变成实际的东西呢?前面的上下文切换代码就是。这里有很多的细节。
1. kernel 运行第一个进程
进程也是状态机
从并发开始,有了状态机的概念,逐步深入。程序就是状态机。
C 语言的状态机视角,汇编语言的状态机的视角。编译器就是做两个状态机的转换。特殊的指令,syscall,状态机停下,然后把所有的状态完整的交给操作系统。
操作系统的启动,计算机系统也是个状态机,初始状态就是 CPU reset 的状态,PC 是一个特定的值,状态机就开始执行了,取指令,执行。此处的指令就是 固件,扫描系统中的硬件、初始化数据结构。加载第一个进程(状态机)。
操作系统也是一段代码,最终有一个时刻,会把一个程序的初始状态加载执行。当然操作系统会保留一些自己的状态。
控制加载的第一个程序
比如,前面有一个 minimal.s,能否让 linux 启动后就加载这个程序。
这个事情应该是可以做的,在没有 gpt 之前,想做这件事会遇到特别多的问题,查手册看书的效率是很低的。
一个想法:我们能不能控制 kernel 加载的第一个状态机。
成功实现后,会看到 hello world,用了几行脚本,正确的启动了我们自己的第一个进程。然后退出了,操作系统里没有任何进程了,然后内核报错:Kernel panic - not syncing: Attempted to kill init!
也即第一个系统调用输出了文字,第二个系统调用推出了 init。内核比较慌,😂。系统里没进程了。kernel panic 是一个预期的行为。
既然可以控制启动程序,那正常来说,第一个启动的程序应该是什么?一个练习,编译 linux 内核,启动后加载 busybox,实现一个比较完整的、可玩性比较高的 linux。
扩展:嵌入式系统里的 init
busybox 是个万能的程序,是个状态机,和 minimal 本质一样,静态链接的可执行文件,但是功能更强大,可以根据传入的参数变成任何东西,这是一个所有程序的集合的打包。unix 里重要的工具都有了。
我们把一个脚本作为 init ,这个脚本用 busybox 来执行。脚本里可以 echo ,也可以直接 /bin/toybox sh 启动成 shell,这就有点意思了。但是如果在这个终端里 ls 会提示找不到命令。很正常,没有这个可执行文件,没这个东西。但是我们可以 /bin/busybox ls 这就有了。
如何操作一下,让他更像一个真实的能用的更好玩的 bash 呢?肯定也是有办法的。
所有在 busybox 里的命令,按照其命令的名字复制一份。这时候就可以用一些命令来做一些事情了,然后创建一些目录,挂载一些东西。最后把终端从调试串口移动到真实的机器上去。
这时候我们得到了一个近乎完整的 linux 体验,因此许多嵌入式系统里就是用 busybox 。这里如果我们查看进程树 pstree 看到根进程名为 init。
所有的这些指令都是 busybox,那么 busybox 如何知道该执行什么呢?用 strace 看看,任何的指令都有参数列表,参数的第一个是文件名,busybox 用了这个参数。
这些好玩的东西补上了理论和实践的 gap,纯实践也挺痛苦,纯理论有点空洞。
还可以继续追问:如果我希望用 QEMU 启动我给定的 Linux 内核二进制文件 vmlinuz 和初始内存文件系统 initramfs,应该使用怎样的 QEMU 参数使得 initramfs 中的 /bin/hello 作为第一个执行的程序?
对于初次接触 linux 的人来说,自己定制可能有点难度,但是有了这些基础的了解,再去看相关教程也不会那么困难了。
有了这些了解,就可以去真正看看文档了。
某种程度上,linux 成功是偶然的。作为任何一个后来者,想实现一个完全兼容的东西是不可能的。
为什么我们国家不投很多人力物力做一个操作系统呢?如果想做到和 linux 兼容是不可能的,在 linus 那个时代和 POSIX 兼容是做得到的,今天再去做操作系统不是一个好主意。
微软想去做一个 WSL,把 linux 的进程在 Windows上启动起来,程序执行系统调用时用 windows 用自己的方法实现。这件事情最后证明行不通,api 不多,但是 linux 里的对象太多了,有太多太多历史上积累的东西在里面。
现在在写操作系统,作为一个爱好可以,真去做,也不太可能成为像 linus 那样的人了。
可以研究一下 busybox 的代码,没多少行,而且很好读,是个大的 switch case,是个状态机。从这些成熟的,维护的很好的代码看看代码应该怎么写,代码格式、语法、好的用法之类的。
当我们看过足够多的代码后,就会明白怎么样写是好的,什么时候该用什么样的写法。看过了资深程序员写的代码,再回过头对比自己的代码,就会有进步。
2. fork() 创建新进程
fork() 创建新进程一个容易想到的创建新进程的方法,比如 create("/bin/init") ,但是 unix 里实现了一个比较有趣的系统调用。
创建新状态机
unix 设计了很有意思的机制,不像 CreateProcess 一样直接给个新状态机给初始状态。unix 创建新状态机的系统调用:
含义为做一份当前状态机的完整复制,无传入参数,父进程返回子进程的进程号,子进程返回 0。fork 含义是叉子,一个执行流,经过 fork 就分叉了。C 程序 = 状态机,fork 做的事情就是把状态机完整的复制一份。
每一个直接的内存
打开的文件
寄存器完全一样
除了 fork 的返回值了新的 PID。每个进程有个编号。初此之外,两个进程没有任何区别。x86 C 函数的返回值放在 rax 里。
复制完成后,如果电脑有两个 CPU 的话,就可以在两个 CPU 上并行执行,如果只有一个 CPU ,那么就在一个 CPU 上轮转执行。
到这里操作系统的执行模型就变了,变成一个并发程序。和多线程一样,这里操作系统想执行一步的话,可以选择不同的进程来执行。操作系统就是一个状态机的管理者。虚拟化就是操作系统里可以管理好多个状态机,每次操作系统可以选一个进程执行。操作系统里容纳了很多个状态机,但是每次只选一个执行,这就是操作系统的功能。
当然还需要花点时间来理解。
先看一个有趣的例子:Fork Bomb 无限制的创建状态机。这是一段 bash 脚本,一行代码。
格式化一下
bash 可以把 : 用作函数名。递归创建新进程。
这个程序的含义,先创建一个管道,在左边执行一个 f,右边执行一个 f。一直重复下去。直到资源耗尽。
因为状态机是复制的,所以总是能找到“父子关系”,这就是进程树 pstree,fork 是 unix 里创建进程的唯一方法。
为什么这么设计呢?
这样设计可以共享父子进程的状态,可以配合实现一些工作。比如父进程预处理,fork 10 个子进程做并行计算。这里只是简单讲讲,后面会更具体的
一些面试题 or 习题
理解 fork:入门
这段代码总共创建了多少个进程?printf 执行了多少次?
理解完整复制当前状态的含义。这段程序的输出值,这就带来了并发。
理解 fork 的最精确的语义,
理解 fork:buf
这个程序的输出结果
每次 fork 完会打印一个 hello,画个图浅看一下,应该会有 6 个。直接编译执行后确实也是。
但是,如果 ./a.out | wc --lines 话,结果是 8。当然可以认为有空白字符,但是管道到 vim 里发现真的有8行 hello。还可以 ./a.out | cat 发现真的有 8 个。
机器永远是对的。出了问题,肯定是对机器不够了解。很有意思,出现 bug 可能是哪个环节呢?如何调试呢?看看指令序列发生了什么,对比 system call trace。
通过 strace 查看 write 的系统调用,发现次数不一样。(调试的方法)
如果我们对 fork 的理解是正确的,那么一定就有我们未完全掌握的东西。如果 fork 没错,那么说明让我们出现理解偏差的是 printf。libc 里隐藏了一些细节,出于性能的考虑的设计 man sendbuf ,缓冲模式,。
程序会根据输出到终端还是管道做出不同的行为。立即输出还是输出到缓冲区稍后输出。没有立即输出的内容也被复制了一份,这符合我们对 fork 的理解。
基于状态机模型,我们对 printf 隐含了一些假设:当一个进程执行到printf,立马打印一个出来。但是这个函数的行为并不是这样的。
题外话:更深入的理解标准库的缓冲区,看这些例子:
printf 后执行一个非法操作,程序会崩,但是 hello 也没了。
但是,多了 \n ,居然又出来了。
如果把这个程序的标准输出管道给另一个程序,./a.out | cat会发现又没了。
fork 是无情的复制机器,会把所有的东西都拷贝一遍,包括内部的库函数,以及状态。如果需要正确的打印出来,需要一个 fflush(stdout); ,它会执行一个系统调用,真正的把字符输出到文件里。当然库函数为了更快,执行系统调用需要时间,他会把字符留在缓冲区里,根据输出对象的不同,有不同的缓冲区。如果输出在终端,那么就是个 line buffer,如果是管道或文件,就是 full buffer。
line buffer 的含义是看到 \n 就把缓冲区都写出来。full buffer 会攒够 4096 Byte(页面) 一起丢给操作系统输出出去,除非显示调用fflush();
因此,前面的例子就是把没输出的缓冲区也复制了,攒到一起一并输出。
理解 fork:多线程*
多线程程序的某个线程执行 fork 会发生什么?
创造 fork 的人并没有考虑线程。这是个好玩的问题。unix 的设计者在实现 fork 的时候没考虑这个问题,以至于后来 posix 引入线程后在 fork 和线程的交互还要打上一些补丁。无论怎么设计都有一些小麻烦,后面说。
我们可能作出以下设计:
仅有执行 fork 的线程被复制,其他线程 “卡死”
仅有执行 fork 的线程被复制,其他线程退出
所有的线程都被复制并继续执行
这三种设计分别会带来什么问题
如果不仅仅想创建 init 的副本,还想创建别的程序呢?除了 fork 还应该有个系统调用实现这个功能。
3. execve() 运行可执行文件
execve() 运行可执行文件fork 完全一样的复制了一个状态机,为了得到我们看到的花哨的,多彩的操作系统。还需要一个系统调用,重置初始状态的调用。重置状态机的时候,可以给其传参数。系统调用声明:
把当前进程重置成一个可执行文件描述状态机的初始状态。
状态机还在,但是把东西都扔掉了。变成了一个新的东西。什么是可执行文件?这是一个状态机的初始状态的描述。执行名为 filename 的程序,后面两个刚好对应了 main() 的参数:argv 就是 main 的参数,envp 为环境变量。所有的资源还在,进程号不变,但是状态都被重置了。
整个计算机系统如何建立联系的呢?
操作系统里面有很多对象是可执行文件,如 a.out,a.out 在接收到参数后做参数指定的事情,如 ls -l,ssh xxx,execve 的行为是把一个 ELF 文件搬到内存里,把状态机重置成 ELF 描述的初始状态。每个可执行文件就描述一个状态机。execve就是重置状态机并且给 C 语言的 main 传递两个参数。
设计的干净又简单。**execve 是唯一的能够执行程序的系统调用。**所以现在要启动一个新应用的方式,先 fork 一个自己,然后 execve 替换整个状态机。
所有的进程的 strace 的第一个系统调用都是 execve,执行可执行文件的唯一方式。
举个例子,/bin/bash -c env 会输出环境变量,手动写代码启动这个过程
这个程序执行 /bin/bash ,-c 代表执行,env 打印出当前的环境变量,以 NULL 结尾,这是手册规定的。
当前 sh 的环境变量 bash -c env,运行上面的程序,如果成功执行,会看到状态机被重置为 /bin/bash 这个程序,并给后面的参数。而且环境变量被改掉了。重要的是,printf没有被执行(理解重置的含义,所有的状态都没有了)。这个代码输出的环境变了很少。
strace 这个代码,有好几次 execve。
关于 $PATH 的讨论,一个可执行文件,如果直接 a.out 是不认的,必须要 ./a.out ,搜索路径的问题。如果
这时候 a.out 就可以了,但是 其他命令全坏了,找不到了。彻底改掉了路径搜索的行为。这种设计可以让父子进程之间可以传递参数。
关于环境变量的讨论
完整的main函数,参数个数(count),参数向量(vector),环境变量。
什么是环境变量呢?操作系统在运行时,有进程的父子关系,比如说有很多的 shell,如远程的,本地的,在桌面 shell 里执行 gedit aaa.c ,这个图形界面程序为何能在当前屏幕画出东西?如果有两个屏幕,应该显示在那个屏幕呢?如果远程 ssh 到服务器上,然后 gedit.c 会在自己的电脑上弹出窗口,shell 执行在服务器上。这也是很有意思的事情。
每个运行的程序,除了命令行参数意外,还给了一个环境。当前程序运行的环境,用 env 可以查看环境变量。这个参数也是由 execve 这个系统调用传进去的,作为第 3 个参数。
execv 系列函数里,会继承父进程的环境变量。export 可以告诉 shell 在创建子进程时设置环境变量,然后启动子进程,把环境就带给子进程了。
一个好玩的东西,bash,有个环境变量 PS1,shell 的提示符。如果 export PS1=':' ,提示符就会变。可以换成任何值。在继续启动子进程也是这个提示符。
还有个好玩的。PATH 这个环境变量。
前面有 gcc 的 strace 结果。我们知道 execve pathname 必须是个文件,如果要执行 as,会拼接所有的 PATH,直到成功
一路往下找,直到成功找到。这个搜索顺序是 PATH 里指定的顺序。
我们可以 hack 这个行为。PATH="" /usr/bin/gcc a.c 这就会报错。即用我们指定的空路径,我们用绝对路径制定了 gcc,但是后面找不到其它可执行文件了。
到这里,就真的创建了整个计算机系统里所有的东西了。
4. 退出程序 _exit()
现在,还缺一些东西。程序被创建出来了,还需要被销毁。应该还要有一个系统调用,执行这个系统调用的状态机从操作系统里消失。
至于为什么是 _exit 而不是 exit,因为 exit 是 C 标准库的函数。
销毁当前状态机,并允许有一个返回值
子进程终止会通知父进程 (后续课程解释)
问题又来了:多线程程序,删除所有线程,还是删除一个线程?
这就有许多种情况了。
exit(0);libc的函数,c 库函数,平缓的结束_exit(0);绝对粗暴的退出,直接抹掉状态机
5. 总结:线程、进程和操作系统
这部分的内容
操作系统如何管理程序 (进程)?
fork, execve, exit: 状态机的复制、重置、销毁
理论上就可以实现 “各种功能” 了!
所有的这些,状态机管理。
线程操作系统的状态机模型,即共享内存+寄存器+线程栈。
进程是什么呢?对于进程的理解参考 minimal.S,这就是进程。
linux 操作系统下的进程模型和线程模型有什么区别呢?区别是每个进程有自己的内存,有自己的堆栈,但是呢,不共享内存。每个进程有自己的独立内存,这就是进程模型。
计算机有个重要机制,虚拟内存,带上了 VR 眼镜,并且是强制的。每个“线程”只能看到自己的内存。
因此理解了线程,再去理解进程是比较容易的。
什么是操作系统呢?操作系统是状态机的管理者。而且操作系统自己本身也是个状态机。
操作系统自己有私有的内存,剩下的内存分配给状态机,初始的时候,操作系统的代码完成了整个操作系统的初始化后,会加载一个进程(初始状态),只有一个 init 进程被启动,从此以后,操作系统内核就化身为了一个事件驱动的程序、状态机的管理者,仅在中断和系统调用发生时开始执行。我们看到的其他的所有进程,完全是由第一个 init 进程创建出来的。
做了那么多铺垫以后,终于看到了一个事实:所有的进程都和 minimal.S 一样,要么是 syscall 要么是普通指令。
为了从第一个 init 进程创建其他进程,我们需要的系统调用的 api 是状态机的管理。即创建状态机、销毁状态机···
这个 api 随意设计,如
CreateProcess(exec_file)
TerminateProcess()
windows就是这么设计的。
5.1 程序和进程
(八股)程序 vs 进程
程序是状态机的静态描述,描述了所有可能的程序状态,程序动态运行起来,就成了进程。
代码描述了所有的状态迁移,。程序包含了丰富的行为,非常多的状态分支,很难通过测试验证每一个状态路径的正确性。
程序真正运行起来才会向操作系统要系统调用,要分配内存,。
之前要强化的一个概念:程序是一个状态机。进程的状态,gdb 里可以看到就是寄存器和内存。
更具体的,在任务管理器看到的每一个进程的编号,这个毫无疑问也是进程状态的一部分,进程在创建时会给一个 pid,进程消亡时 pid 回收。那么一个问题,进程号存储在哪里呢?有可能的是内存的某个地方,进程的内存里 or 操作系统的内存里?
unix 中,pid 是操作系统管理的状态,但是也是属于进程的,进程是没有办法直接访问的,因为操作系统虚拟化的设计,进程看起来是独占整个计算机的。如果想要知道进程的编号,逻辑也很自然,如果进程编号是操作系统管的,那么就问操作系统要,那么就是用系统调用。所以操作系统里有很多系统调用是帮助进程读取一些状态的。
如 getpid(),通过这个系统调用,就能知道自己的编号。
基于合适的理解正确的向 AI 提问:写一个 linux c 程序,可以获取到哪些关于当前进程的信息,尽可能的全面。
一些进程的状态 pid ppid uid name command line
./a.out 和 ./a.out 1 2 3 会影响 command line 的
在重复启动的时候,pid 是地增的,这个整数是有限的。通过 a.out | head -n 2 看前两行。
可以继续去问 gpt 关于 pid 的细节:我发现随着进程的创建,进程的 pid 是递增的;而 pid 是有限的 (32-bit 整数),这是否意味着会循环导致 pid 重用?
然后就可以使用这些系统调用做一些简单的事情了,开始写代码。
关于开始编程以后的东西,代码质量。写代码的公理
机器永远是对的
没测试过的代码永远是错的
那么如何测试一个代码是正确的?
java python 是自带测试框架的,因为太重要了。教科书会有一章讲这个。C 语言历史悠久,软件工程刚起步,还不知道测试的重要性。
C 语言测试框架,关于用法
只需要 include testkit.h 就获得了写测试用例的能力。这个测试框架对程序没有侵入性。
如果 make 的时候有一个环境变量为 TK_VERBOSE=1 ,那么就会去执行所有测试用例。
进程管理系统调用
操作系统 = 状态机的管理者,进程管理 = 状态机管理
shell 或者 图形界面都是一个应用程序,做纯计算的操作,那么要新建一个状态机,看起来还是需要操作系统帮忙,也即使用系统调用。
如果作为一个操作系统的设计者,也会想到去设计一个创建和销毁状态机的 API。
create(path, argv)
destroy()
windows 就是这么设计的,问 AI 看看如何在 windows 里创建和销毁一个状态机
windows 的代码,类型也不太一样,命名法,参数里带了类型,很有代表性。
unix 给出的答案
复制状态机 fork()
重置状态机 execve()
fork 复制,汇编、寄存器级别的复制,唯一的区别是 fork 返回的 pid 不同。
fork 后面的代码是同时执行的,在两个进程中。
最后更新于