使用分身术变身术创建新进程

723 阅读18分钟

火影忍者相比大家都很熟悉吧,就算没看过也应该都听说过,里面有着各种各样的忍术,加上不同的结印手势,那真是相当的炫酷啊。可这和计算机有什么关系呢?今天我们就要使用两种忍术来创建一个新进程。

没错,就是要用两种忍术来创建一个新进程,分别是分身术 fork,和变身术 exec。前文说过 init 第一个进程的创建,因为是第一个进程,所以必须得“自己手动”的建立进程初始数据。而后续的进程就不用这么麻烦啦,直接一个分身,一个变身就能创建一个新进程。

这两个忍术可得好好领悟认真学,鸣人可是靠着这两种忍术打遍天下无敌手,就算是在最后的BOSS战(面试)里面都起到了重要作用。下面我们就来具体学学这两种术法。

  • 公众号:Rand_cs

一、分身术fork

准确的说应该是影分身,火影里面普通的分身术和影分身的区别知道吧,不知道感兴趣的可以去看看火影,咱这就不解释了,不然变成火影的公众号了。

不过咱们还是要来百科百科影分身,官方解释为:使用查克拉造出有实体的分身,具有独立于本体的意识和一定的抗打击能力,可应用于各种忍术之上,正常解除后分身的记忆和经验会回归本体。

而我们的新进程呢,是使用一定物理空间来创建自己的PCB,页表等结构,它是独立于父进程存在的一个进程,能够被调度上CPU,可运行各种新程序,运行完后退出再由父进程回收。这个过程简直完美契合影分身之术有没有,简直怀疑岸本齐史是不是另有一个计算机兼职。

1. fork 残卷秘籍

下面我们来具体看看影分身fork这个秘籍,但是呢 fork 大家都应该都很熟悉了,就不再做过多的铺垫介绍,简单来说就是根据父进程克隆出一个几乎一模一样的子进程出来。在这儿也不举 fork 那个 if-else 判断 pid 的经典的却老掉牙的例子了,咱们来谈点不一样的。

首先来看看影分身简化版的残卷秘籍(这种方式对于计算机来说是低效的)

fork.png

上述的 fork 只是残卷,主要是想说明 fork 的一种实现过程思路。虽然这种方式在忍术中被列为B级,但是在计算机的世界里,将父进程的资源全部拷贝一份的实现方式是非常低效的,后面我们会讲另一种高效的方式:写时复制。现在先来看看下面几个通用的问题:

2. 常见问题

调用一次,返回两次

这是CSAPP里面的原话,个人认为单独说这么一句总结性的话语是有歧义的,对于初次接触到fork的朋友来说可能很迷惑。一个函数只能有一次返回,是不可能返回两次的。即使我们平时写程序时可能会使用多个 return 语句,但最终肯定只会从一个 return 中返回。那fork函数作何解释呢?

fork 之后一个进程就变成了两个进程,两个进程两个fork 两个返回,而不是说一个 fork 函数就返回两次

父子进程返回的数不一样?

fork 函数有三种返回值:

  • 在父进程中会返回子进程的 pid

  • 子进程中返回0

  • 出错的话返回-1

子进程是克隆出来的,返回值怎么还不一样?看清前面说的,根据父进程克隆出几乎一模一样的子进程来,说明并不是完全相同

那返回值是怎么回事呢?在Linux里面系统调用采用中断门实现,所以调用 fork 时会触发中断,中断就会保存上下文,其中包括了eax寄存器的值

据调用约定,eax 寄存器里面存放的是返回值,所以据上面的残卷可以看出,fork 时会修改子进程中断上下文里的 eax 为0。如此父进程中的 fork 和子进程中的 fork 便会返回不一样的值。

而返回 -1,多数情况是进程数达到上限或者内存不足,这种情况下根本就没有创建新的进程,也谈不上两次返回和返回不同的值。

fork 之后接着 fork 后面的代码运行

这似乎是废话,但是为什么呢?我看到CSDN上有篇博客是这样回答的,大概意思是 fork 函数只是将后面要执行的代码拷贝到新的进程,这篇博客的访问点赞评论都很高。但是私以为这种说法是不对的,至少在我看的一些系统 fork 源码中没有这么实现的。

那为什么 fork 之后父进程子进程都是是接着 fork 后面的代码运行呢?其实很简单,就是中断上下文的保存于恢复。前面说过 fork 系统调用通过中断实现,中断时父进程保存了当前执行流的位置即 cs:eip 的值,然后 fork 函数复制了一份给子进程,所以父进程子进程中断返回时都会继续执行fork后面的代码。

因此fork前是一个进程在执行,fork 后是两个进程在执行同一块儿代码(如果没调用 exec 变身的话)

最后来看低效版的 fork 动态图,实实在在的将父进程的资源复制了一份。

(抱歉放不了动图,可以去我的公众号Rand_cs查看)

二、变身术exec

1. exec 函数

前面我们的分身术 fork 函数只能克隆出来一个与父进程几乎相同的子进程,它们执行的是同一个程序,但经常我们需要的是一个全新的进程,它能运行其他程序。这就需要变身,用到 exec 函数。exec 函数总共有6个,其中execve是内核的系统调用,其他5个execl, execv, execle, execlp, execvp都是在execve之上实现的。

execve函数原型如下:

execve.png

  • const char* filename,可执行文件的完整路径

  • char* const argv[] ,以NULL结束的字符串指针数组的地址,每个字符串表示一个命令行参数

  • char* const envp[],以NULL结束的字符串指针数组的地址,每个字符串以NAME=value的形式表示一个环境变量,通常直接传参NULL。

2. ELF文件

我们要加载的文件叫做可执行目标文件,Linux里面可执行目标文件的格式为ELF,而Windows里面是PE,注意不是 exe,exe 只是后缀名。

ELF格式简介

ELF 指的是 Executable and Linkable Format,可执行可链接格式。从命名中也可以看出它有两种视图:执行和链接两种视图。

源文件到可执行目标文件.png

上面这图大家应该都很熟悉了吧,后面两种目标文件,可重定位目标文件和可执行目标文件就分别对应着ELF格式文件的链接视图和执行视图。

ELF文件格式

细究ELF文件的话,内容还是很多的,我们在这儿捡重点,exec用的上的说:

先来从总体上看看两种视图的结构:

elf文件格式.png 链接视图以节为单位,执行视图以段为单位。这里的段和我们所说的内存分段的段的含义是不同的,要区分开。

实际的ELF文件里面的节和段很多,这里只是列出了比较重要需要了解的一部分,下面简要说明一下:

  • .text:代码部分

  • .rodata: 只读的数据,例如 printf 中的格式串,switch-case 中的跳转表

  • .data:已初始化的全局变量

  • .bss:未初始化的全局变量,局部静态变量

  • .symtab:symbol table,符号表,程序里面的全局变量名和函数名都属于符号,这些符号信息保存到符号表

  • .rel.text,.rel.data:与可重定位相关的信息

  • .debug,调试所用的符号表

  • .init,包含可执行的指令,进程初始化代码的一部分,要在执行main函数之前执行这些代码

ELF Header

ELF Header.png

各元素表示的意思大都已经说明,根据命名应该还是很好记住各元素所代表的意义,下面再重点说几点:

  • e_ident前4位是固定的魔数,e_ident[0] = 0x7f,e_ident[1] = 'E', e_ident[2] = 'L', e_ident[3] = 'F',表明这是一个 ELF 文件

  • e_ident[5]用来指定大端还是小端字节序,1表小端,2表大端,0表非法编码格式

  • e_type,ELF 目标文件类型,如可重定位,可执行,动态共享目标文件

  • e_entry,这个可执行文件的入口地址,exec 加载完程序之后就从这儿开始运行

Program Header

程序头.png

同上简单解释几点:

  1. 程序段的类型有很多,我们只需要了解可装载段,顾名思义,需要装载到内存里面的段,比如代码段,数据段。

  2. 这里涉及了多种段,程序段类型里面的段,数据段代码段等里面的段,还有内存的分段,都是段不要混淆了。

  3. 一般说来 p_filesz <= p_memsz,这是因为bss节的存在,它并不存在与文件中,仅存在与运行时的内存当中。这是因为 bss 节中存放的是未初始化的全局变量,它们的值是无意义的,如果我们在文件中分配空间将这些变量的值存储下来也就无意义。所以我们的目标文件中其实并不需要 bss 的实体,只需要记录bss 的大小位置等相关信息即可。

  4. 虽然在文件中存储变量没有意义,但是人家好歹也是未初始化的全局变量,需要在内存中专门为它们开辟空间存储它们。

3. exec残卷秘籍

从上面的 ELF Header 和 Program Header 中可以看出程序各段的大小位置都已经确定好了了,我们只需要将它们加载到相应位置即可,来看看 exec 变身术的残卷秘籍:

exec.png

同 fork 那本秘籍,主要是想展现 exec 实现的一个大致过程思路,每个步骤写的应该还是比较清晰,照例下面说几点重点:

Load装载映射

装载映射的段都是可装载段,具体的装载过程可以用读取文件 read 和 lseek 系统调用来实现。

read 的作用就是读取文件到内存的一个缓冲区,而 read,lseek 两函数需要的参数在程序头中都有记录,所以理论上来讲实现起来应该是很容易的。

入口地址e_entry

exec 需要修改原进程内核栈中的一些信息,最主要的就是将中断上下文里面的 eip 改为 ELF 文件中的入口地址。

ELF头中的 p_entry 入口地址是什么?是main函数的地址吗?非也。那是什么呢?这要牵扯一个概念,运行库,运行库涉及的知识很多,在这就长话短说,讲讲与本文有关的。

简单说来,运行库就是标准库的扩展,会在 main 函数运行之前准备好环境,运行完之后再进行收尾的工作

本文就只说说准备运行环境的部分,这部分可以看做是一个函数,全局符号为_start,也就是函数名为_start。_start才是我们运行的第一个函数,ELF 头中的入口地址 p_entry 就是它

_start 函数的工作之一就是压入 main 函数的参数

前面我们的伪码中是把实际的命令行参数传到了用户态的线性空间中,但是要清楚 main 函数的参数可不是实际的命令行参数,而是命令行参数的个数和字符串指针数组的地址。这两个参数压栈操作就在_start 中进行。毕竟 main 函数也是一个被调用的函数,在调用之前需要传参

exec返回

exec函数如果发生错误会返回-1,正确则不返回

exec 函数里面还调用了许多其他函数,这些函数出错,exec 没能继续运行下去的话是会直接返回-1的,只是上面伪码没体现出来。

要知道 exec 这个函数就像是推到原进程然后重来,改变了很多信息,中断的执行上下文被大幅度改变,调用 exec 的代码也是不复存在的。从这个角度看exec从未成功返回,取而代之的是执行的新程序被映射大进程的地址空间。

以上来自深入理解 Linux 内核的解释,感觉听抽象模糊?也可以尝试这样理解,来自于操作系统真相还原的一个系统设计。

在这个OS设计里,exec 如果成功运行到最后,直接使用 jmp 语句跳到中断退出点。jmp 语句不像 call,它是有去无回的,所以没有不会再返回到 exec 函数里,而是直接弹出中断上下文的 eip 入口地址去运行新程序了。

再来看看exec的动态图,想要表达的意思很简单,就是在原进程上推到重来:

(抱歉放不了动图,可以去我的公众号Rand_cs查看)

好了,关于分身术和变身术咱们就传授到这,下面我们要来学以致用,推陈出新,创造一门新忍术。

三、写时复制COW

前面说过,影分身之术虽然等级很高但是有弊端的,特别是在施展多重影分身之术时可能会因为查克拉消耗太过剧烈而伤及自身,所以被列为禁术。而同样的,咱们最初版的 fork 因为复制了父进程的全部资源而浪费了太多时间空间,也不再使用。

现在的 fork 都是用了写时复制技术,这项技术可了不得,面试中经常提到。岸本齐史肯定不会这个,不然的话肯定再创一门 S级忍术,没有实体的假分身可以变成真的,真的可以变成假的。真真假假,虚虚实实,补不足而损有余,这样就可以减少不必要的查克拉消耗,还能达到兵者诡道也的效果。

扯远了扯远了,写时复制这项技术可没有那么强大,但也有类似的机制和目的,减少空间时间消耗,高效的完成进程创建任务,来具体看看:

1. 初代版本的fork具体弊端

前面我们的fork是傻瓜性的,真的将父进程的所有资源全部复制了一份,但实际上是不必要的。

如果我们不调用 exec 运行新程序,那么实际上父子俩进程很多的资源是可以共用的,比如代码部分

而如果调用 exec 来执行新程序,exec 要删除掉已存在的用户区域,复制父进程的资源也无意义。所以这样的fork有很大弊端,不适用

2. 具体的COW

顾名思义的简单解释就是,fork 时不会真的分配新的物理页复制资源,子进程直接引用共享父进程的物理空间。只有一个进程要写数据,改变共享内容时,才单独复制一份出来

这样就避免了不必要的资源复制。在面试中肯定不能只回答这么一点内容,那是过不了关的,咱们还需细剖注意几个问题,就直接已干货的形式罗列出来了,如下所示。

  1. 即使利用写时复制技术,fork时也还是为子进程创建一些单独的资源,比如PCB,页表。也就是说要为其分配新的物理空间来存储这些资源,这些东西是不会在物理上共享的。

  2. 父子进程各自拥有一套页表,子进程的页表从父进程哪儿复制过来的,内容是相同,所以父子进程映射到了同一个物理空间。但因为是两套页表,所以父子进程的虚拟地址空间是不同的,只是说两个虚拟地址空间对应的是同一个物理空间。或者按照CSAPP里面的话来说,相同但独立的地址空间。表述的可能不太一样,但实际表达的意思是一样的,能清楚明白是指就好。

  3. 写时复制的实现原理:

  • 将两个进程的页面标记为只读,这是通过设置页表项里面的存取权限位来实现的

  • 将两个进程的区域结构都标记为私有的写时复制,这是通过设置进程的vm_area_struct结构体里面的vm_flags字段来实现的

  • 如果父子进程都是读取相同的物理页,那么父子之间是相安无事的。但是只要有一个写就会起冲突,内核就会把这个页的内容拷贝到一个新分配的物理页,并更新写进程的页表项使其指向新分配的这个物理页。最后再回复页面的可写属性

再来看看动图直观感受一下:

(抱歉放不了动图,可以去我的公众号Rand_cs查看)

所以啊,fork之后,你以为现在是父子两个进程实体,但实际上内存里面只有父进程一个完整的实体。你又以为子进程在内存里面没多少自己单独的资源时,过了一会儿,说不定又因为写操作给分配了。

所以吧,真不是我生搬硬套,这虚虚实实的感觉与我那创造的S级忍术还是有些相似的对吧。到现在这个S级术法还没取名字呢,为了纪念写时复制技术,而且这个术法这么厉害,干脆就叫做牛影分身吧。(为啥叫这名儿能懂吧,没看明白的看看写时复制的英文简写?)

四、fork、vfork、clone

最后这一部分简单谈谈上述三个函数的区别,同样的不多说直接以干货的形式罗列出来:

1. vfork

  • vfork就是为了避免fork时的大量无用复制而设计的。

  • vfork创建进程时连父进程的页表都不会复制,完全使用父进程的资源,运行在父进程的地址空间中。子进程对数据的任何修改也就是对父进程的数据修改

  • vfork会保证子进程先运行,而fork不会,要看调度情况

  • 父进程则一直被阻塞,直到子进程调用exec有了自己的地址空间或者退出时,父进程才会被重新调度

由上可以看出vfork的系统开销很小,似乎很有竞争力,但是由于现在的fork采用了写时复制技术,相比之下vfork的竞争力也不是那么强了,所以现在已经渐渐淡出内核

2. clone

  • clone这个函数功能很齐全,参数也多,使用其他比较复杂。我们可以使用不同的参数组合来选择性的复制父进程的资源。

  • 传统的fork函数还有vfork函数就是依据clone来实现的。

  • clone函数的主要用处还是来创建线程,也就是轻量级进程

关于这部分就先说这么多吧,了解了解即可,最常用的还是fork函数。

五、总结

本文主要介绍了火影里面的分身术和变身术,还自创了一门忍术牛影分身。哦不,是两个函数 fork 和 exec,还有写时复制技术。

本文给出了简化版的秘籍,还是残卷,诸位不要真的去修炼,怕走火入魔。这只是用来给出一种实现的思路,体现运行过程,相当于流程图。诸君看看了解过程就好,不要太过较真。

这也不是为自己的不严谨开脱,而是内核这个东西吧,内容太多也太复杂,想要通俗易懂的表达出来,着实不容易,这是客观的事实。当然与本人的能力水平也有关,诸位还请见谅,如果哪儿有错还请批评指正。

最后在谈谈鸣人吧,漩涡鸣人一直是我比较喜欢的角色,天资并不聪颖的他始终保持着积极乐观的态度,经过坚持不懈的努力终于实现了自己的梦想成为了一代火影。

在这儿也祝愿大家能像鸣人一样始终保持着积极乐观的心态,早日实现自己的人生梦想!!!