Linux进程控制


前言


一、进程创建

1、fork函数

在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
在这里插入图片描述
在这里插入图片描述

2、写时拷贝

那么fork创建子进程,操作系统都做了什么。
创建一个子进程,就说明系统中已经多了一个进程。进程 = 内核数据结构 + 进程代码和数据。创建子进程后,操作系统会给子进程分配对应的内核结构,并且这个子进程的内核结构里面的一些数据一般是拷贝的父进程的内核结构里面的一些数据,但是此时子进程和父进程已经是两个独立的进程了。理论上,子进程也要有自己的代码和数据,可是一般而言,子进程并没有加载的过程,也就是说,子进程没有自己的代码和数据。所以,子进程只能使用父进程的代码和数据,但是子进程可能要修改这些数据,所以子进程和父进程的数据必须要分离。那么操作系统是在创建子进程的时候就给子进程将数据分离出去吗?
答案是否,因为如果在创建子进程后就将父进程的数据拷贝一份给子进程的话,当这些数据没有被子进程访问或修改的话,那么这片空间就浪费了。但是如果操作系统不将这些数据拷贝一份给子进程的话,那么子进程可能修改父进程的数据,所以操作系统采用了写时拷贝技术,即当子进程修改数据的时候再给子进程将数据分离一份。

可以看到操作系统为子进程创建了task_struct和mm_struct内核数据结构,并且子进程的task_struct和mm_struct里面的内容大多数是拷贝的父进程的task_struct和mm_struct的数据。我们还可以看到子进程不但共享了父进程的代码段的内容,还共享了父进程的数据段的内容,这是因为操作系统采用了写时拷贝的技术,因为如果子进程创建出来后就将子进程的数据段和父进程的数据段分离开,那么就需要为子进程在物理内存中开辟一片新空间,然后将父进程的数据段拷贝到这片新空间。但是如果操作系统做完了这一系列操作后,子进程只是对这片空间的数据进行了读取,并没有进行修改,甚至有时候子进程就没有访问这片空间的数据,那么这片空间就被浪费了,而且操作系统给子进程分配空间、拷贝数据的操作也白做了。那么如果操作系统只拷贝那些子进程以后会修改的数据可以吗?那也是不行的,因为操作系统也无法知道那些数据会被修改。所以操作系统采用了写时拷贝技术。当子进程只是读取物理内存中的数据时,操作系统不会将父进程和子进程的数据段分离。
在这里插入图片描述
但是当父进程或者子进程要修改数据段的数据时,此时操作系统就会给子进程重新分配一片空间,然后将父进程的数据段拷贝到这片空间,然后将子进程的页表中的物理内存地址更新为映射到这片新的空间地址。此时父进程和子进程修改数据就不会相互影响了。这就是写时拷贝技术。即当用的时候再给子进程分配空间,这样可以更高效的实验内存。
在这里插入图片描述

3、子进程从哪里开始执行父进程代码

我们知道在程序中执行完fork()函数后,就会创建一个子进程,我们知道子进程中的内核数据结构的数据大部分都是拷贝的父进程的,那么子进程的代码是只共享父进程after的代码,还是共享父进程全部的代码?其实子进程被创建后是共享父进程的全部的代码,因为通过上面的分析我们可以看到子进程共享父进程的数据段和代码段。那么当子进程创建后,子进程的代码是从哪里开始执行呢?

在这里插入图片描述
答案是从after开始执行。那么为什么子进程会从after开始执行呢,前面不是分析了子进程共享父进程所有的代码,那么为什么子进程会从after开始执行,而不是从before开始执行代码。这是因为子进程的内核数据结构的数据拷贝了父进程的内核数据结构的数据原因。我们知道CUP中有很多不同作用的寄存器,而CPU进行取指令、分析指令、执行指令就需要这些寄存器的帮助。其中有一个寄存器为EIP(即PC:程序计数器),该寄存器的作用就是保存CPU下一个要执行的指令的地址。我们在前面学过代码经过汇编之后会有很多行代码,而且每行代码加载到内存后都有对应的地址。因为进程随时可能被中断(可能并没有执行完,但是时间片到了),下次该进程再次上CPU执行时不可能从头再执行,那么就需要CPU必须随时记录下来当前进程执行的位置,所以CPU内才有对应的寄存器,用来记录当前进程的执行位置,而**寄存器在CPU内只有一份,但是寄存器内的数据可以有多份,即每个进程都有一份属于自己的寄存器数据,这些寄存器数据就是该进程的上下文数据。**当该进程下CPU时就会将自己的进程上下文数据保存到自己的内核数据结构中,以便下一次上CPU时继续向下执行自己的指令。那么进程在保存自己的进程上下文数据时,EIP寄存器内的数据也会被保存到自己的内核数据结构中,那么当父进程创建子进程时,子进程也会将父进程的EIP寄存器的数据拷贝过来,即将父进程的进程上下文数据拷贝到自己的内核数据结构中。而父进程执行完fork()后,子进程就被创建出来了,此时父进程的进程上下文数据中保存的就是执行after代码的相关数据,又因为子进程会拷贝父进程的进程上下文数据,所以子进程也会从after代码开始执行了。

在这里插入图片描述

二、进程终止

1、进程终止时,操作系统做了什么

在进程终止时,操作系统要释放当前进程申请的相关内核数据结构和对应的数据和代码,即释放该进程占用的系统资源。

2、进程终止的常见方式

2.1 main函数退出码

我们在写c语言和c++程序时,在main函数的最后都会写一个return 0;语句,那么为什么要在main函数中返回一个0?main函数返回值的意义是什么?
我们知道一个进程的运行结果有三种状态:

  • 代码跑完,结果正确。
  • 代码跑完,结果不正确。
  • 代码没有跑完,程序崩溃了。
    当我们想要知道一个进程的运行结果时,就可以通过这个进程的退出码来判断,而main函数中return 的 0 就是main函数的退出码。退出码返回给上一级进程,是用来评判该进程执行结果用的。而且进程的退出码有很多,通常0:表示成功运行,即第一种状态,非0:表示运行的结果不正确。非0值有很多个,不同的非0值就标识了不同的错误原因。这样退出码就可以方便定位程序的错误原因。

我们可以使用echo $?来查看最近一个进程执行完毕的退出码。我们可以看到在代码中我们将main函数的返回值设为10,当使用echo $?查看时,main函数的退出码就是10。

echo $?

在这里插入图片描述
在这里插入图片描述
我们可以通过退出码来判断进程运行的结果正确不正确。例如下面的代码,如果sum函数计算的结果正确,则main函数的退出码就为0,如果sum函数计算的结果不正确,那么mian函数的退出码就为1。这样我们就可以通过退出码来判断该程序的结果是否正确。退出码的意义就是可以根据退出码的不同来定义程序不同的出错原因。
在这里插入图片描述
在这里插入图片描述
我们可以使用c语言库里面的strerror函数来查看不同的退出码对应的错误信息。可以看到向strerror函数中传入一个退出码,strerror函数就会返回该退出码对应的错误信息。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

我们可以看到有很多中退出码,并且每个退出码都对应不同的错误信息。例如我们使用ls 命令来查看一个不存在的文件信息,可以看到会返回No such file or directory的错误,然后查看这个进程的退出码,可以看到退出码为2,我们再对比退出码对应的错误信息,可以看到退出码2对应的错误信息就是No such file or directory。这样我们就可以使用退出码来判断一些进程的错误原因。
在这里插入图片描述
并且我们不只是可以使用这些系统定义的退出码和含义,还可以自己定义然后设计一套退出方案。例如下面我们使用kill -9 命令杀掉一个不存在的进程,然后发现出现了 No such process的错误信息,然后我们查看这个进程的退出码发现该进程退出码为1,然后我们对比系统定义的退出码和错误信息发现这个进程的退出码和错误信息与系统中的不一致。这就是因为kill程序重新定义了一套退出码和错误信息。
在这里插入图片描述
在这里插入图片描述
我们在上面说到过,程序运行的结果有三种情况,第一种情况和第二种情况可以靠退出码来判断,那么第三种情况程序没跑完崩溃时,程序还会有退出码吗?
我们可以看下面程序的运行结果。可以看到该程序在第9句时会出现对空指针解引用的错误,然后程序就会崩溃,然后错误信息是段错误,此时查看程序的退出码为139,而139没有对应的错误信息。
其实当程序崩溃的时候,退出码就没有意义了,一般而言退出码对应的是return语句,而程序崩溃后,后面的return语句就不会被执行,所以退出码就没有意义了。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

3、在代码中终止进程

3.1 使用return语句终止进程

在main函数中,return语句就是终止进程的,return 退出码就是将main函数的进程终止,然后返回该进程的退出码。
可以看到只有main函数中的return语句才会终止进程,而sum函数中的return语句只是返回该函数的返回值。并且在main函数中return语句终止进程后,return语句后面的代码就不会再执行了。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

3.2 使用exit函数终止进程

exit函数也可以用来终止进程。
在这里插入图片描述
通过下面的演示可以看到该进程是在sum函数中执行完exit后就被终止了。return语句与exit函数终止进程的区别就是exit在任何地方调用都可以直接终止进程。其实执行return n等同于执行exit(n),因为调用main运行时,函数会将main的返回值当做 exit的参数。
在这里插入图片描述
在这里插入图片描述

3.3 使用_exit系统调用终止进程

_exit为一个系统调用,也可以用来终止进程。
在这里插入图片描述
通过下面的演示可以看到exit函数和_exit系统调用的作用类似。
在这里插入图片描述
在这里插入图片描述

3.4 exit函数与_exit系统调用的区别

上面的演示我们感觉exit函数和_exit系统调用都有终止进程的作用,但是我们再看下面的演示。
我们知道当printf中加了’\n’换行符,则在打印时会先将缓冲区的数据打印出来,然后程序再睡眠3秒,但是当不加’\n’换行符时,程序会先睡眠3秒,然后才打印缓冲区里面的数据,这时因为当没有’\n’换行符时,缓冲区的数据不会被刷新出来,只有当exit函数执行后,才会将缓冲区里面的数据进行打印。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
然后我们将exit函数换为_exit系统调用,然后我们执行程序后,发现_exit结束后并没有将缓冲区里面的数据刷新出来。这说明_exit为系统调用。
在这里插入图片描述
在这里插入图片描述

因为_exit为系统调用,所以_exit是直接转到kernel(内核)的,而exit函数在执行后,会先进行一系列操作,然后再调用_exit系统调用,所以exit库函数其实底层也是调用的_exit系统调用,不过在exit在调用_exit系统调用之前又被封装了一些其他的作用,即先执行用户定义的清理函数,将程序申请的空间都释放,然后冲刷缓冲,关闭流等操作,最后在调用_exit系统调用。而直接在代码中使用_exit系统调用的话,就不会执行用户定义的清理函数、将程序申请的空间都释放、然后冲刷缓冲、关闭流等操作,所以使用_exit不会将缓冲区中的数据打印出来。
那么我们会不会有个疑问,printf中的数据都在缓冲区中,那么这个缓冲区在哪里呢?又是谁来维护这个缓冲区呢?
我们首先要排除的是缓冲区肯定不在操作系统内部,因为如果是操作系统维护的话,那么_exit也能将缓冲区的数据刷新出来了。其实这个缓冲区是c标准库给我们维护的。

在这里插入图片描述

4、进程等待

4.1 进程等待的必要性

  • 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。另外,进程一旦变成僵尸状态,kill -9 也不能将该子进程杀掉,因为僵尸进程是已经死掉的进程,使用kill -9 没有办法杀死一个已经死掉的进程。
  • 父进程创建子进程是要让子进程处理数据的,那么父进程派给子进程的任务完成的如何,父进程是需要知道的。如,子进程运行完成,结果对还是不对,或者是否正常退出。
    所以才有了进程等待这个步骤,父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
    在这里插入图片描述
    我们可以看到当子进程运行5秒结束后,子进程的进程状态变为了Z+状态,即此时该子进程为僵尸状态。
    并且此时使用kill -9 已经无法杀掉这个僵尸进程,只能通过杀掉该进程的父进程,然后结束掉这个僵尸进程。

在这里插入图片描述

4.2 wait系统调用

wait系统调用就是进程等待的方法,当被等待进程运行成功后该系统调用返回被等待进程的pid,当被等待进程运行失败后,返回-1。
在这里插入图片描述
下面的程序中,我们先让子进程运行5秒,然后子进程退出,此时子进程变为僵尸进程,但是我们在父进程中使用了wait系统调用来等待子进程执行完,所以我们看到父进程是阻塞式的等待,即只有等子进程执行完,即等待wait返回值后,然后父进程才能执行下面的代码。
在这里插入图片描述
在这里插入图片描述

4.3 waitpid系统调用

waitpid系统调用也是等待进程,只不过waitpid有多个参数。
waitpid的第一个参数为要等待的子进程的pid,如果为-1则表示等待任意一个子进程,与wait等效;如果大于0则表示等待其进程ID与pid相等的子进程。
第二个参数status是一个输出型参数。
第三个参数默认为0,当为0时表示阻塞等待。
所以waitpid(-1,NULL,0)就等价于wait。
在这里插入图片描述
可以看到使用waitpid(id,NULL,0)等价于wait(NULL)。
在这里插入图片描述
在这里插入图片描述

4.4 wait和waitpid的第二个参数int* status

wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
wait和waitpid的第二个参数int* status是一个输出型参数,即我们可以在父进程中定义一个status变量,然后将该变量的地址传入wait或waitpid中,当wait或waitpid执行完后,就会将这个等待的子进程的退出码放入到status的地址中。下面我们将子进程的退出码设置为100,然后父进程使用watipid系统调用等待子进程执行完成,然后我们打印status的值看是否为子进程的退出码。
在这里插入图片描述
在这里插入图片描述
我们看了上面的结果发现父进程中status的值并没有和我们分析的一样为子进程的退出码100。这是因为status不能简单的当作整形来看待,因为status不是按照整数来整体使用的,而是按照比特位的方式,将32个比特位进行划分,用来保存不同的信息。我们现阶段只需先学习低16位即可。通过下面的图我们可以看出只有8-15位是保存子进程退出码的,那么当我们要查看子进程退出码时,就不能直接打印status了,而要查看status的8-15位表示的数据。我们可以(status>>8)&0xFF来得到status的8-15位表示的值。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
那么status最低8位表示什么呢,这就要和信号有关联了。当进程异常退出或者崩溃时,本质是操作系统杀掉了该进程,那么操作系统是如何杀掉该进程的呢,本质就是通过发送信号的方式。
在这里插入图片描述
我们可以通过kill -l来查看信号。

kill -l

在这里插入图片描述
下面我们来查看status中保存的子进程收到的信号编号,可以使用(status & 0x7F)来查看status最低7位的数据。
可以看到下面的子进程收到的信号编号为0,说明该子进程是正常结束的。
在这里插入图片描述
在这里插入图片描述
然后我们让子进程出现异常,再来观察子进程收到的信号。
此时可以看到子进程收到的信号编号为8,然后我们查看信号编号为8的信号为SIGFPE,SIGFPE是当一个进程执行了一个错误的算术操作时发送给它的信号。
并且因为此时的子进程出现了异常,所以此时子进程的退出码无意义。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
然后我们再将子进程中设置空指针解引用异常,看看这时子进程收到的信号编号为多少。
我们可以看到空指针解引用异常的信号编号为11,对应的信号为SIGSEGV,SIGSEGV是当一个进程执行了一个无效的内存引用,或发生段错误时发送给它的信号。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

程序异常有时候不光光是内部代码有问题,有时候也可能是外力直接杀掉了这个进程,而此时如果这个进程的代码还没有跑完就会出现异常。
我们看到当子进程一直运行时,此时直接使用kill -9 杀掉子进程,然后因为此时子进程并没有执行完代码,所以子进程出现异常,子进程收到的信号编号为9,9对应的信号为SIGKILL,SIGKILL是发送给一个进程来导致它立即终止的信号。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
其实当我们想要查看status变量对应的退出码和是否正常退出时,不需要通过(status>>8)&0xFF和status&0x7F来查看,因为在<sys/wait.h>中定义了两个宏。
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
WIFSIGNALED(status): 若子进程因为接收到终止信号而终止,那么就返回true。(查看进程是否收到终止信号)
WTERMSIG(status): 返回子进程收到的终止信号。
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

当我们将子进程中的异常代码注释掉后,看到的打印信息如下。
在这里插入图片描述

4.5 为什么要使用wait和waitpid系统调用

父进程通过wait或waitpid可以拿到子进程的退出结果,那么为什么要使用wait或者waitpid函数呢?直接使用一个全局变量不可以吗?
下面我们演示使用全局变量来替代wait或waitpid函数,我们设置一个全局变量code,然后我们在子进程中修改code的值为15,然后去父进程打印code的值。
在这里插入图片描述
在这里插入图片描述
但是我们发现父进程中code的值并没有变为子进程中修改的15,而还是原来的值0。这是因为进程具有独立性,当子进程修改数据时,就会发生写时拷贝,此时子进程和父进程的数据不在同一个物理内存中,所以子进程修改code的值并不会影响父进程中code的值。
在这里插入图片描述
那么我们又会有疑问了,既然进程是具有独立性的,那么子进程的退出码不也是子进程的数据吗,父进程为什么可以拿到呢?
这是因为子进程的这些数据保存到了自己的内核数据结构中,我们知道一个僵尸进程是已经死掉的进程,但是操作系统中还保存了该进程的PCB信息,即内核数据信息task_struct,这里面保留了该进程退出时的退出结果信息,而父进程调用wait或waitpid的过程其实就是去读取子进程的task_struct的退出结果信息了,因为wait或waitpid是系统调用,所以它有权限去查看每个进程的task_struct内核数据结构。即当子进程退出时,会将自己的退出码和信号写入到自己的task_struct内,然后父进程调用wait/waitpid的时候其实就是操作系统去访问子进程的task_struct的exit_code和exit _signal,然后将这两个内容按二进制位的方式写入到status变量中。这样父进程才能得到子进程的退出码和信号。因为子进程的数据是独立的,父进程是无法得到的,所以父进程只能通过上述使用wait/waitpid系统调用,让操作系统去得到子进程的退出码和信号。
我们可以看到在Linux源码中,在task_struck内核数据结构中就定义了exit_state来保存进程的状态,定义了exit_code来保存进程的退出码,定义了exit_signal来保存进程收到的信号编号。

在这里插入图片描述

4.6 wait和waitpid的第三个参数

在前面我们在父进程中调用wait或waitpid时,父进程都是阻塞式等待,即只有当子进程执行完后,父进程才能继续执行。而waitpid的第三个参数options就是控制父进程是否要阻塞式等待。当调用waitpid时,第三个参数为WNOHANG(Wait No HANG)则表示父进程非阻塞式等待。当我们第三个参数传0时就表示父进程阻塞式等待。其实WNOHANG也为一个宏,并且表示1。因为在调用waitpid时传入0或1会让用户不知道是什么意思,所以一般都将这些数字定义为宏,这样就能表达这个值得意思。而那些不知道其含义的数字通常被称为魔术数字或魔鬼数字,因为不知道这些数字表示什么。
在这里插入图片描述
我们知道一个进程被阻塞了就是这个进程的PCB被放到了阻塞队列中,而下面就是父进程调用waitpid阻塞等待的原因。
如果我们将flag设为WNOHANG,则父进程就会进入else if(flag == WNOHANG)分支中,然后就不会被阻塞了。
在这里插入图片描述
下面我们来看使用waitpid时父进程非阻塞等待。我们可以看到当父进程使用waitpid等待子进程运行时并没有直接停在waitpid那里等待子进程运行完才执行下面的代码,而是继续运行下面的代码了,这就是非阻塞式等待。当子进程在执行时,父进程也可以处理其它的事情,只要定时去获取子进程的执行结果,如果子进程退出了就执行子进程退出后的代码,而如果子进程没有退出,父进程就去做其他的事情。

在这里插入图片描述
在这里插入图片描述
下面再通过一个例子来感受非阻塞等待的好处。可以看到通过下面的写法后父进程也可以做一些其他的事情。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

三、进程替换

1、进程替换的概念和原理

我们在学习fork的用法时知道了fork的常规用法有两种。
(1). 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
(2). 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动进程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
即fork()之后父子进程可以各自执行父进程代码中的一部分,那么如果子进程在fork()之后向执行一个全新的程序时,就需要用到进程的程序替换来完成这个功能,本来在fork之后,父子进程代码共享,数据写时拷贝各自的一份数据,而程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中,这样就能让子进程执行其它程序。
一个进程在CPU上执行经过这样的过程。
在这里插入图片描述
当发生进程替换时,就是将新的代码和数据加载到内存中,然后页表重新建立映射。
在这里插入图片描述

2、进程替换操作

我们在man手册中查看exec函数,可以看到下面的exec系列的函数。
在这里插入图片描述
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量PATH
e(env) : 表示自己维护环境变量

2.1 execl函数使用

我们先来演示execl函数的使用。
在这里插入图片描述
我们在下面的代码中使用execl来进行进程替换,即将进程替换为/usr/bin/目录下的ls进程。我们可以看到程序执行的结果,只打印了第一个printf,而没有打印第二个printf的内容。这是因为execl是程序替换,调用该函数成功之后,会将当前进程的所有的代码和数据都进行替换,包括已经执行和没有执行的,所以一旦execl调用成功,原来进程的所有代码都不会执行了。
那么fork和wait调用之后都有返回值,execl为什么调用成功之后没有返回值呢?因为execl根本不需要进行函数返回值判断,当execl调用成功后就执行新的程序了,而原来的程序已经不会再执行了,所以就算execl有返回值也无法进行返回值判定。
在这里插入图片描述
在这里插入图片描述
当我们使用execl替换一个没有的程序时,就会替换失败,此时会继续执行以前的代码。execl函数调用失败时会返回-1。
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
execl替换成功没有返回值。替换失败后返回-1,然后继续执行下面的代码。所以一般都在execl后加exit(1),只要execl没有替换成功,就会执行exit(1)语句终止进程。
在这里插入图片描述
上面的演示中我们是直接在父进程中替换进程,当execl替换成功后,父进程下面的代码就不会执行了。下面我们演示在父进程中创建一个子进程,然后在子进程中调用execl来替换进程。让子进程来执行任务。
当我们不想让进程替换影响父进程,我们想让父进程聚集在读取数据,解析数据,指派进程执行代码的功能时,我们就需要创建子进程来进行进程替换,然后让子进程来执行一些进程。这样进程替换就不会影响到父进程了。
当创建子进程后,父子进程共享父进程的代码,然后子进程的数据在写时进行拷贝,当子进程执行了execl函数加载新程序的时候,其实就是写入代码,这时子进程的代码和数据都会进行写时拷贝,此时父子进程的代码和数据就彻底分开了。
在这里插入图片描述
在这里插入图片描述

2.2 execv函数使用

execl中的l表示list,即表示execl中的第二个参数以链表的形式传入。
execv中的v表示vector,即表示execv中的第二个参数以数组的形式传入。
在这里插入图片描述
我们在代码中定义了一个_argv指针数组,里面存的都是命令的字符串,然后直接将这个指针数组当作实参传入execv函数中。
在这里插入图片描述
在这里插入图片描述

2.3 execlp函数使用

execlp中的p表示该方法不用传入执行的程序的绝对路径,该方法只要知道目标文件名即可,会自动在环境变量PATH的路径下查找目标文件。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.4 execvp函数使用

execvp函数和execv函数类似,因为execvp函数带了p,所以第一个参数可以直接传入目标文件名即可。
在这里插入图片描述
在这里插入图片描述

2.5 使用exec*系列函数执行c、c++程序

我们可以使用exec系列的函数来执行ls、pwd这样的系统命令,那么我们也可以使用exec系列的函数来执行我们写的c、c++程序。我们用c语言写一个下面的程序,然后在test01.c中创建一个子进程来执行这个程序。
在这里插入图片描述
我们使用execl在子进程中进行进程替换,然后将可执行进程test02的绝对路径传入execl。
在这里插入图片描述

我们之前写的makefile文件执行make时,只能生成一个test可执行程序,当我们想让makefile一次生成多个可执行程序时,可以像下面这样写。
在这里插入图片描述
然后我们运行test可执行程序时,可以看到在test可执行程序中创建了一个子进程来使用进程替换执行test02可执行程序。
在这里插入图片描述
我们也可以在execl中的第一个参数中传入相对位置来找到test02可执行程序。不管传入相对位置还是绝对位置,在第一个参数中都要指定目标文件名。而因为第一个参数中有了目标文件名和目标文件所在的路径了,所以第二个参数执行c或c++程序时,就不需要再使用./test02来执行了,当然这样也不会错。
在这里插入图片描述
在这里插入图片描述

2.6 使用exec*系列函数执行其它语言写的程序

使用exec系列函数可以替换c、c++可执行程序,也可以替换其它语言写的程序。
我们可以写一个python程序和一个shell程序,然后在test程序中创建子进程来执行这两个程序。
下面的命令可以查看python的版本。
在这里插入图片描述
然后我们创建一个以.py结尾的文件test.py,用来写python程序。然后创建一个.sh结尾的文件test.sh,用来写shell程序。
在这里插入图片描述
我们在test.py中写入如下的代码,然后执行这个程序,可以看到这个程序可以正常执行并打印内容。
在这里插入图片描述
在这里插入图片描述
然后我们在test.sh中写入如下的代码,然后执行这个程序,可以看到这个程序可以也正常执行并打印内容。
在这里插入图片描述
在这里插入图片描述
接下来我们在test程序中创建子进程,并在子进程中使用execlp函数来进行进程替换执行test.py这个程序。我们可以看到使用python语言写的test.py程序被成功执行。
在这里插入图片描述
在这里插入图片描述
然后我们再执行test.sh程序,在test程序中创建子进程,并且子进程中使用execlp函数来进行进程替换执行test.sh这个程序。我们可以看到使用shell语言写的test.sh程序被成功执行。
在这里插入图片描述
在这里插入图片描述
我们看到创建的test.py和test.sh默认都没有执行权限,当我们给这两个文件加上执行权限后,此时就可以使用./test.py和./test.sh来执行这两个程序了。我们原来执行test.py和test.sh需要调用在/usr/bin目录下的python和bash程序解释器,而当我们将这两个文件的权限加上可执行后,会自动使用相应的解释器来执行这两个文件。
在这里插入图片描述
在这里插入图片描述
此时我们在test程序中,调用execlp时就可以这样传入第一个参数。
在这里插入图片描述
在这里插入图片描述

2.7 execle函数使用

execle函数比execl函数多了第三个参数,第三个参数用来传递环境变量。
下面我们演示execle不传递第三个参数时,也可以使用,此时execle函数和execl函数功能相同。我们可以看到在mypro程序中打印的TEST_VAL的值为null,说明没有TEST_VAL这个环境变量。然后我们也打印环境变量PATH的值,可以看到打印的也为null,说明也没有PATH这个环境变量。而当我们使用execl函数时,子进程替换的程序中就可以打印出来PATH环境变量的值,这说明当我们使用execle函数不传第三个参数时,其实会默认第三个参数为NULL,即存放环境变量的指针数组为NULL。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
当我们使用execl函数时,可以看到在子进程中替换的程序可以打印出PATH环境变量的值。
在这里插入图片描述
在这里插入图片描述

然后我们在test中创建一个指针数组,里面存储TEST_VAL环境变量和它的值,然后将这个指针数组当作execle函数的第三个参数传递过去,此时TEST_VAL这个环境变量就会被传递到mypro这个程序中。我们看到此时mypro程序就可以找到TEST_VAL环境变量的值并打印出来,而这个环境变量就是父进程test传过去的。
在这里插入图片描述
在这里插入图片描述

2.8 execvpe函数使用

execvpe函数和execvp函数的功能类似,就是execvpe函数多了一个参数用来传递环境变量。
我们在test02.c中写一个语句用来打印PATH这个环境变量,当单独执行test02程序时,此时该进程的父进程是bash,并且该进程会继承bash进程的环境变量,所以可以找到PATH环境变量。
在这里插入图片描述
在这里插入图片描述
而当在test程序中创建子进程调用execvpe函数来执行test02进程时,如果给execvpe函数传入第三个参数_env,则test02程序中就找不到PATH环境变量,因为_env中没有存储PATH环境变量。并且因为execvpe函数中有p,所以会去PATH环境变量存的路径中找test02程序,而我们的test02所在的路径不在PATH中。所以我们要先将test02的路径加入到PATH中。如果不想这样将test02的路径添加到PATH环境变量中,我们也可以在传路径时使用./test02,这样就会使用相对路径了。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
当我们将父进程test的env传给子进程的execvpe函数的第三个参数时,此时在test02中就可以查到PATH环境变量了,并且test02的环境变量都是继承的父进程test的。
在这里插入图片描述
在这里插入图片描述

2.9 execve系统调用

除了上面我们介绍的exec * 系列的函数之外,操作系统还提供了一个execve系统调用,而我们之前介绍的exec * 系列的函数的底层其实都是调用execve系统调用实现的。上面的exec * 系列函数都是操作系统提供的基本封装,用来满足不同的调用场景。
在这里插入图片描述

3、实现一个简单的shell脚本

当我们想要将makefile文件中的一个字符串统一改为另一个字符串时,可以在底行模式下使用%s/旧字符串/新字符串/g来实现。例如下面将makefile文件中的所有test01改为myshell。
在这里插入图片描述
下面我们来自己写一个简单的shell程序来分析命令行的命令,类似于bash的一个分析命令的shell程序。我们通过使用bash命令行解释器可以知道命令行解释器一定是一个常驻内存的进程,所以我们使用while(1)循环来让该程序一直执行。
我们使用cmd_line[NUM]这个数组来存储输入的命令和选项。通过fgets来得到用户的键盘输入,但是当我们打印cmd_line想要查看输入的命令和选项时,发现有一个换行符也被打印了。这是因为当我们输入了命令和选项后,按了一个回车键来结束输入,所以这个换行符’\n’也被fgets读取了。
例如当输入ls -a -l时,实际读取的是ls -a -l\n,所以我们需要将cmd_line后面的换行符变为’\0’。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
但是此时cmd_line中存储的是整个命令和选项,我们需要将这个字符串里面的命令和选项都分开存储。所以我们又创建了一个指针数组g_argv,用来存储打散之后的命令行字符串。然后我们使用strtok函数来将cmd_line字符串以空格为分隔符进行分离,将分离的子串都存到g_argv指针数组中。然后我们打印g_argv中的内容,可以看到cmd_line字符串已经成功被分割为命令和各个选项。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

当我们将用户输入的命令和选项都分开后,我们就可以创建一个子进程来执行用户输入的命令了。

在这里插入图片描述
在这里插入图片描述
我们发现使用myshell程序执行 ls -a -l 时,没有颜色显示。这是因为在bash中执行的ls其实是一个别名,真正的完整命令是 ls --color=auto。我们可以在myshell.c中加一个判断,当输入ls命令时,我们将后面加上一个
–color=auto选项。并且在bash中ll也是一个别名,我们查看ll命令,发现ll命令执行的完整命令是ls -l --color=auto,所以我们也可以在myshell.c中加一个判断,当输入ll命令时,执行ls -l --color=auto命令。

在这里插入图片描述
在这里插入图片描述
我们在代码中加入下面的判断,然后执行时可以看到该程序执行ls命令时就有颜色了,并且还可以识别ll命令了。
在这里插入图片描述
在这里插入图片描述
但是我们又发现一个问题,当执行cd …回到上级目录时,我们发现目录并没有改变。
这是因为我们是使用子进程来执行ls pwd cd …等命令,所以显示的路径和回退的路径也是以子进程的路径为准,而当我们执行cd …时,其实是将子进程回到上级目录了,但是子进程执行完cd …命令后就终止了,而当执行pwd命令时,又创建了一个新的子进程来执行pwd命令,所以我们看到的目录才没有回退。
在这里插入图片描述
所以当执行cd … 等命令时,我们不应该使用子进程执行修改子进程的目录,而应该修改父进程的目录。我们通过chdir系统调用来更改父进程的目录。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在bash中执行export命令可以添加一个新的环境变量,而我们自己写的myshell程序不可以添加环境变量,所以下面我们实现这个功能。我们通过putenv这个方法来实现添加环境变量。
在这里插入图片描述
我们判断如果输入的命令是export就调用putenv函数,然后将export后面的环境变量添加到父进程的环境变量中,然后就跳过这次while循环。
在这里插入图片描述
然后我们在mytest程序中打印MYVAL环境变量的值,如果我们单独运行mytest的话,会显示MYVAL为null。但是当我们先执行myshell程序后,然后执行export命令添加MYVAL环境变量,然后再执行mytest程序,此时相当于myshell创建了一个子进程来执行mytest程序,所以mytest程序中的环境变量继承父进程myshell的环境变量,所以mytest会打印出MYVAL的值。
在这里插入图片描述
但是我们发现mytest中并没有找到MYVAL环境变量。
在这里插入图片描述
接下来我们来查找为什么上面的代码没有输出正确的结果,我们先测试输入export命令时,环境变量MYVAL是否成功添加到了父进程中。我们可以看到putenv函数是有一个返回值的,我们接收putenv的返回值,看MYVAL环境变量是否添加成功,如果成功就打印出来。并且我们在父进程创建子进程后,在子进程中打印MYVAL环境变量,看子进程是否继承了MYVAL环境变量。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
我们看到父进程中添加MYVAL环境变量成功了,但是在子进程中还是找不到MYVAL环境变量。
在这里插入图片描述
这其实是因为下面的原因,我们在将cmd_line字符串里面的内容分割然后存入g_argv指针数组中时,其实就是将g_argv中的每一个元素都存储了cmd_line中对应的字符串的地址。当我们执行完export MYVAL=1000命令时,此时g_argv[0]中存了export字符串的地址,g_argv[1]中存了MYVAL=1000字符串的地址。然后调用putenv函数将g_argv[1]传进去,其实就是将MYVAL=1000的地址传进去。此时export MYVAL=1000命令执行完毕。
在这里插入图片描述
在这里插入图片描述
然后当执行下一条命令时,会将cmd_line里面的内容都清空,然后cmd_line中存入新的命令。但是我们上一次添加的环境变量的数据存在没有清空的cmd_line中,将cmd_line的内容都清空就相当于将MYVAL=1000环境变量的数据也清空了,然后getenv去putenv时的地址中去找该环境变量的值时,发现此时该地址中并没有MYVAL=1000的数据,所以才会查找不到MYVAL环境变量的值,因为此时cmd_line中存的是这一次的命令。
在这里插入图片描述
在这里插入图片描述
我们需要再创建一个数组g_myval,用来保存export添加的环境变量,然后将g_argv[1]的内容拷贝到g_myval中,当调用putenv函数时,将g_myval的地址传入。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
此时可以看到,子进程中正确打印出来了父进程中新加的MYVAL环境变量的值。
在这里插入图片描述
但是此时我们又会发现,当向父进程中添加两个环境变量时,会有一个失效,因为strcpy函数会覆盖式的拷贝,即将上一个环境变量的数据给覆盖了。
在这里插入图片描述
我们使用一个二维数组来存储父进程新加的环境变量的数据,此时我们就可以看到父进程可以多次添加环境变量。并且通过上面的分析我们也发现了,进程替换时,环境变量相关的数据不会被替换。因为环境变量是操作系统层面的数据,而进程替换只会替换该进程的代码和数据,并不会替换环境变量相关的数据。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
但是当我们使用exec*系列函数中带e的函数时,如果传入了第三个参数,则该参数会覆盖子进程从父进程中继承的环境变量。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
而当不使用exec*系列函数中带e的函数来进行进程替换时,则不会覆盖子进程从父进程中继承的环境变量。
在这里插入图片描述
在这里插入图片描述
经过上面的编写代码我们进一步明白了子进程继承父进程的环境变量,那么在Linux中,我们执行的命令行程序的父进程都是bash程序,所以这些命令行程序的环境变量都是继承bash程序的,那么bash程序的环境变量是从哪里来的呢?
其实bash的环境变量是写在配置文件中的,shell程序启动的时候,通过读取配置文件获得起始的环境变量,这就是bash程序获得环境变量的过程。
下面为Linux系统中每一个用户的环境变量文件。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在/etc/bashrc文件中,包含了所有的起始环境变量。
在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值