文章目录
- 前言
- 一、进程创建
- 1、fork函数
- 2、写时拷贝
- 3、子进程从哪里开始执行父进程代码
- 二、进程终止
- 1、进程终止时,操作系统做了什么
- 2、进程终止的常见方式
- 2.1 main函数退出码
- 3、在代码中终止进程
- 3.1 使用return语句终止进程
- 3.2 使用exit函数终止进程
- 3.3 使用_exit系统调用终止进程
- 3.4 exit函数与_exit系统调用的区别
- 4、进程等待
- 4.1 进程等待的必要性
- 4.2 wait系统调用
- 4.3 waitpid系统调用
- 4.4 wait和waitpid的第二个参数int* status
- 4.5 为什么要使用wait和waitpid系统调用
- 4.6 wait和waitpid的第三个参数
- 三、进程替换
- 1、进程替换的概念和原理
- 2、进程替换操作
- 2.1 execl函数使用
- 2.2 execv函数使用
- 2.3 execlp函数使用
- 2.4 execvp函数使用
- 2.5 使用exec*系列函数执行c、c++程序
- 2.6 使用exec*系列函数执行其它语言写的程序
- 2.7 execle函数使用
- 2.8 execvpe函数使用
- 2.9 execve系统调用
- 3、实现一个简单的shell脚本
前言
一、进程创建
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非零,提取子进程退出码。(查看进程的退出码)
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可执行程序。
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函数功能相同。我们可以看到在test02程序中打印的TEST_VAL的值为null,说明没有TEST_VAL这个环境变量。
然后我们在test中创建一个指针数组,里面存储TEST_VAL环境变量和它的值,然后将这个指针数组当作execle函数的第三个参数传递过去,此时TEST_VAL这个环境变量就会被传递到test02这个程序中。我们看到此时test02程序就可以找到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中。
当我们将父进程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系统调用来更改父进程的目录。