Linux - 第4节 - Linux进程控制

目录

1.进程创建

1.1.fork函数

1.2.写时拷贝

1.3.fork常规用法

1.4.fork调用失败的原因

2.进程终止

2.1.进程终止的正确认识

2.2.进程终止的常见做法

2.3.Linux内核对于进程终止的操作

3.进程等待

3.1.进程等待的原因

3.2.进程等待的常见做法

3.3.进程退出信息的构成

3.4.阻塞等待和非阻塞等待

4.进程程序替换

4.1.进程程序替换的概念和原理

4.2.进程程序替换的常见做法

5.简易shell的实现


1.进程创建

1.1.fork函数

在linux fork 函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void);
返回值:子进程中返回0,父进程返回子进程id,出错返回-1
进程调用 fork ,当控制转移到内核中的 fork 代码后,内核做:
\bullet 分配新的内存块和内核数据结构给子进程  
\bullet 将父进程部分数据结构内容拷贝至子进程
\bullet 添加子进程到系统进程列表当中
\bullet fork返回,开始调度器调度

当一个进程调用 fork之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程。
代码如下图一所示,运行结果如下图二所示,这里看到了三行输出,第一行是执行的fork函数之前父进程的打印代码,第二行是执行的fork函数之后父进程的打印代码,第三行是执行的fork函数之后子进程的打印代码。
可以得出结论, fork 之前父进程独立执行, fork 之后,父子两个执行流分别执行,如下图三所示。注意, fork 之后,谁先执行完全由调度器决定。

问题:对于父子进程的代码部分,是否只有fork之后的代码是被父子进程共享的?

答:我们前面提到进程具有独立性,也就是说进程的代码和数据必须独立,数据的独立我们上一个博客讲过是依靠写时拷贝实现的,代码部分因为是只读的,所以可以默认代码是独立的。一般情况下,fork之后父子共享所有的代码,子进程执行的后续代码不等于共享的所有代码,只不过子进程只能从这里开始执行。

CPU的寄存器中有一个eip寄存器,该寄存器一般被称为程序计数器或pc指针,其功能是保存当前正在执行指令的下一条指令。fork之后,eip程序计数器会拷贝给子进程,子进程便从该eip所指向的代码处开始执行。

问题:fork之后,操作系统做了什么?

答:进程=内核的进程数据结构+进程的代码和数据,fork之后操作系统创建子进程的内核数据结构(struct task_struct + struct mm_struct + 页表) + 代码继承父进程以共享的方式,数据以写时拷贝的方式,操作系统通过这些操作保证了不同进程之间的独立性。

1.2.写时拷贝

通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:

注:

1.父子进程的页表都是只读的,当子进程修改内容之后,数据段所对应的父子进程的页表部分会将只读属性去掉,对子进程数据段对应页表部分进行修改。

2.写时拷贝是由操作系统的内存管理模块完成的。
问题:为什么要写时拷贝?创建子进程的时候就把数据分开不行吗?
原因一:父进程的数据,子进程不一定全用,即便使用也不一定全部写入,因此创建子进程的时候就把数据分开会有浪费空间的嫌疑。
原因二:最理想的情况,会被父子修改的数据提前进行分离拷贝,不需要修改的共享即可,但是从技术角度实现复杂,因为代码不跑很难知道要修改哪些变量。
原因三:如果fork的时候就无脑拷贝数据给子进程,会增加fork的成本(内存和时间)
根据以上三个原因,所以最终采用写时拷贝,写时拷贝只会拷贝父子修改的,其实就是拷贝数据的最小成本,并且写时拷贝本质是一种延迟拷贝策略,只有真正使用的时候才开空间进行拷贝,变相的提高了内存的使用率。

1.3.fork常规用法

\bullet 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
\bullet 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

1.4.fork调用失败的原因

\bullet 系统中有太多的进程。
\bullet 实际用户的进程数超过了限制。
子进程创建失败的例子:
代码如下图一所示,利用for循环不停的让父进程fork创建子进程,对于父进程来说,如果id小于0说明子进程创建失败退出循环,如果id大于0则说明子进程创建成功循环继续。对于子进程来说,id等于0子进程创建成功,使用sleep函数和exit函数使得子进程持续两秒就退出,因此子进程不会循环fork。如下图二所示,在输出结果中有子进程创建失败的情况。


2.进程终止

2.1.进程终止的正确认识

问题:在c/c++中,main函数可以认为是入口函数,函数最后都要return或return 0,那么return和return 0是给谁return?一定要return数值0吗,其他值可以吗?

答:常见进程退出有三种情况,第一种代码跑完结果正确,第二种代码跑完结果不正确,第三种代码没跑完程序异常了。main函数的return返回值一般叫做进程退出码,如果return返回值为0代表代码跑完结果正确,如果return返回值为非0代表代码跑完结果不正确,使用不同的非0值代表不同的错误原因。

进程退出码表征了进程退出的信息,前面我们讲过进程退出要进入僵尸状态,僵尸状态的进程必须要被父进程或操作系统回收,读取其退出信息之后进程才会进入x状态释放,因此进程退出码很重要,其是会被父进程或操作系统读取的。因此return返回是给父进程或操作系统return的。

代码如下图一所示,运行该代码,该代码对应进程的父进程是bash进程,此时连续两次使用echo $?命令,如 下图二所示,可以看到第一次运行结果打印123,第二次运行结果打印0。echo $?的功能是打印bash进程中最近一次子进程执行完毕时对应进程的退出码,因此第一次echo $?打印的是下图一代码的退出码,第二次echo $?打印的是第一次echo $?命令的退出码。

系统中的命令如果执行失败,那么该命令的退出码也是非0的,如下图所示。

问题:一般而言,失败的非零值应该如何设置呢?各种非零值默认表达的含义是什么?

答:我们之前学过strerror函数,其功能是将一个错误码转为错误码描述,使用下图一所示的代码,打印1-100错误码对应的错误码描述,打印结果如下图二所示。

从打印结果可以看到0代表成功,1代表权限不允许等等。要注意这里打印的是c语言规定自己的错误码标准,其他语言或系统不一定遵守(Linux操作系统就没有遵守)。

2.2.进程终止的常见做法

方法一:

在main函数中return,进程退出。

注:只有在main函数中return才是进程退出,在其他函数中return不代表进程退出,非main函数return代表函数调用结束。

方法二:

在代码的任意地点中调用exit函数,进程退出。exit函数后面括号中的内容就是退出码,对应main函数return的值。

注:

1.main函数和非main函数调用exit都可以让进程退出。

2.使用exit需要包含<stdlib.h>头文件。

代码如下图一所示,运行该代码,然后使用echo $?命令,打印的结果为111,如下图二所示。退出码为111说明该进程代码没有执行完,在函数fun中就将该进程终止了。

exit和_exit:

_exit和exit功能基本相同,二者的关系是调用和被调用的关系,exit的实现中调用了_exit。

_exit和exit唯一的区别是:exit中止进程并且刷新缓冲区,_exit只中止进程没有任何刷新操作。

exit测试代码如下图一所示,运行结果如下图二所示,运行结果为先停顿一秒然后显示hello word。_exit测试代码如下图三所示,运行结果如下图四所示,运行结果仅为停顿一秒。因此可以看出exit函数刷新了缓冲区而_exit函数没有刷新缓冲区。

注:

1._exit是系统函数,exit是c语言的函数。

2.使用_exit需要包含<unistd>头文件。

2.3.Linux内核对于进程终止的操作

进程=内核结构(task_struct、mm_struct、page table)+进程代码和数据

当一个进程终止时,进程进入z状态,父进程或操作系统读取其退出码等信息,然后将该进程设置为x状态,等待释放其内核结构以及代码和数据。

实际上在进程释放的时候,操作系统一定会将进程的代码和数据部分释放掉,进程的内核结构部分(task_struct、mm_struct、page table)不一定会被释放。创建对象首先要开辟空间,然后要进行初始化,这两步都要花时间,因此重新创建一个进程内核结构部分要花费时间,在Linux中会维护一个废弃的数据结构链表,将要释放的内核结构挂在链表中,当需要创建内核结构对象时,在该链表中拿出来一个然后进行初始化工作即可,节省了开辟空间的开销。这里提到的废弃的数据结构链表就是内核数据结构缓冲池或slab分派器。


3.进程等待

3.1.进程等待的原因

问题:为什么要进程等待?

原因一:解决僵尸进程的内存泄漏问题
子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题, 进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程,这样就会造成内存泄漏。
原因二:获取子进程的退出状态
父进程派给子进程的任务完成的如何我们需要知道。子进程运行完成,结果对还是不对,或者是否正常退出,父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息(退出码等)。

3.2.进程等待的常见做法

方法一:wait方案

wait函数用来等待任意一个退出的子进程,父进程中调用wait函数,获取子进程退出信息,并将其子进程由僵尸状态转为释放状态。

wait函数如果等待成功则返回等待子进程的pid(等待子进程的pid大于0),如果等待失败则返回-1。

wait函数参数status是一个指针参数,其为输出型参数(通过调用该函数,从函数内部拿出来特定的数据),子进程退出的时候会将自己的退出信息写入task_struct然后进入僵尸状态,wait函数是系统函数,因此会去操作系统中找到对应子进程的task_struct,提取对应子进程的退出信息(退出码等),wait函数参数指针status指向内容的就是子进程的退出信息。

代码如下图一所示,运行代码的同时使用while :; do ps ajx | head -1 && ps ajx | grep myproc | grep -v grep; echo "-----------------------------------------------------------------"; sleep 1; done命令作为监控脚本,监控进程状态,代码运行情况如下图二所示。

父进程运行后等待40秒,在这40秒内子进程本应一直处于S运行状态,但在这40秒内我们使用kill -9 6984命令将子进程杀掉,子进程由S运行状态变为Z僵尸状态,40秒之后父进程调用wait函数获取子进程退出信息并将子进程由z僵尸状态转为释放状态进行释放,此时只剩下父进程,父进程打印等待成功和wait函数返回的等待的子进程pid,20秒后父进程退出。

注:wait函数是一个系统调用接口,需要包含<sys/types.h>和<sys/wait.h>头文件。

方法二:waitpid方案

waitpid函数用来等待特定的退出的子进程,父进程中调用waitpid函数,获取对应子进程退出信息,并将该子进程由僵尸状态转为释放状态。

waitpid函数如果等待成功子进程退出则返回等待子进程的pid(等待子进程的pid大于0),如果等待失败则返回-1,如果等待成功子进程没有退出则返回0(非阻塞等待的情况)。

waitpid函数第一个参数pid如果大于0,则为等待对应pid的子进程,第一个参数pid如果等于-1,则为等待任意进程(waitpid函数将第一个参数pid设为-1等价于直接调用wait函数);waitpid函数第二个参数status是一个指针参数,其为输出型参数(通过调用该函数,从函数内部拿出来特定的数据),子进程退出的时候会将自己的退出信息写入task_struct然后进入僵尸状态,waitpid函数是系统函数,因此会去操作系统中找到对应子进程的task_struct,提取对应子进程的退出信息(退出码等),waitpid函数第二个参数指针status指向内容的就是子进程的退出信息;waitpid函数第三个参数options如果等于0,则为阻塞等待,阻塞等待就是父进程保持阻塞状态来等待子进程被杀死变为僵尸状态,waitpid函数第三个参数options如果等于WNOHANG,则为非阻塞等待,非阻塞等待就是只等待一次,如果子进程没有结束父进程继续执行自己的代码,不予以等待。

注:waitpid函数是一个系统调用接口,需要包含<sys/types.h>和<sys/wait.h>头文件。

3.3.进程退出信息的构成

进程退出信息的构成(wait函数和waitpid函数参数status指向的内容):

wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息,否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status参数指向内容不能简单的当作整形来看待,应该当作位图来看待,具体细节如下图(只研究status指向整型的低16比特位),status参数指向整形的次低八位(8-15比特位)可以得到子进程的退出码,如果子进程异常退出,那么子进程会收到特定的信号,status参数指向整形的最低七位(0-6比特位)用来表示子进程的退出信号。
注:status参数指向整形的第八个比特位是core dump标志,与信号有关,我们后面再进行讲解。

代码如下图一所示,使用(status>>8)&0xFF代码可以得到status参数指向整形次低八位表示的退出码,运行得到的结果如下图二所示,可以看到打印的退出码和子进程return的值相同。

代码如下图一所示,运行该代码子进程会死循环运行,父进程一直处于阻塞等待状态等待子进程挂掉,使用kill -9 命令杀死子进程,代码中status&0x7F可以得到status参数指向整形最低七位表示的退出信号,退出信号打印结果为9,如下图二所示,与kill -9 命令中的9相对应,这里子进程的退出码已经不重要了,因为子进程代码没有跑完,子进程收到信号已经异常退出了。使用kill -l命令可以看到Linux系统中所有的信号,如下图三所示。

举一个程序代码错误导致程序崩溃异常的例子,代码如下图一所示,代码int a=10/0导致子进程崩溃,运行结果如下图二所示,错误信号为8,从上图三可以看到信号8表示浮点数错误。

从这里我们可以知道,以前我们写的c/c++代码程序崩溃退出,其本质就是程序异常,操作系统通过发送信号的方式将该进程终止。

前面我们使用位操作从status参数指向的整形中得到了子进程的退出码和退出信号,实际上Linux中给我们提供了两个宏可以供我们调用,如下图一所示,WIFEXITED用来检测进程是否正常退出,也就是检测是否收到异常信号,WEXITSTATUS用来提取进程的退出码。两个宏的使用代码如下图一所示,运行结果如下图二所示。

问题:子进程的退出码和退出信号应该先看哪一个?

答:前面讲过常见进程退出有三种情况,第一种代码跑完结果正确,第二种代码跑完结果不正确,第三种代码没跑完程序异常了。进程退出码对应前两种情况,进程退出码为0对应代码跑完结果正确,进程退出码为非0对应代码跑完结果不正确,进程退出信号对应代码没跑完程序异常了。因此我们应该先看进程的退出信号,一旦进程出现异常,只关心退出信号,退出码没有任何意义。

3.4.阻塞等待和非阻塞等待

阻塞等待和非阻塞等待介绍(waitpid函数的options参数):

阻塞等待:在父进程等待子进程中,子进程没有结束返回PID时,父进程不进行任何操作。

补充:当我们调用某些函数的时候,因为条件不就绪(条件可能是任意的软硬件条件),需要我们阻塞等待,本质就是当前进程自己变成阻塞状态,等条件就绪的时候再被唤醒。

非阻塞等待:在父进程等待子进程中,子进程没有结束返回PID时,父进程会接收0来代表子进程还没有退出,继续执行父进程代码。可以理解为只等待一次,子进程没有结束waitpid()函数返回0,不予以等待。

补充:waitpid进行非阻塞等待一般需要多次调用,直到子进程退出,多次调用非阻塞接口一般称为轮询检测。

选择阻塞等待或非阻塞等待的方法:

如果让父进程进行阻塞等待,那么就将父进程的waitpid函数中options参数设为0。如果让父进程进行非阻塞等待,那么就将父进程的waitpid函数中options参数设为WNOHANG。

问题:如何理解父进程阻塞等待?

答:从上层表现来看父进程阻塞等待就是进程卡住了。父进程阻塞等待本质就是操作系统将父进程的状态由R变为S,将父进程的task_struct从CPU的运行队列投入到子进程的等待队列中,等待子进程退出。子进程退出,操作系统将父进程的task_struct从子进程的等待队列投入到CPU的运行队列中,并将父进程的状态由S变为R,父进程继续被CPU运行。

非阻塞等待的轮询等待代码如下图一所示,代码运行后子进程死循环一直运行,因此父进程轮询等待子进程退出,我们使用kill -9 杀死子进程,子进程退出父进程轮询等待停止,运行结果如下图二所示。

创建一个myproc.cpp文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,因为我们的myproc.cpp文件中使用了c++11的语法,因此要在形成可执行文件的依赖方法命令后面加上-std=c++11。使用make命令生成可执行程序,然后使用./myproc执行该程序,如下图三所示。

myproc.cpp文件中,类型重定义了一个函数指针类型handler_t,定义了一个内容为handler_t类型的vector方法集,定义fun1、fun2两个方法,定义Load函数将两个方法加载到方法集中。如果非阻塞等待父进程等待子进程成功子进程没有退出,那么父进程回调处理对应的任务。


4.进程程序替换

4.1.进程程序替换的概念和原理

进程程序替换的概念:

子进程执行的是父进程的代码片段,如果我们想让创建出来的子进程执行全新的的程序代码就需要使用进程程序替换。

补充:我们一般在服务器设计(Linux编程)的时候,往往需要子进程做两种事情:(1)让子进程执行父进程的代码片段(服务器代码)(2)让子进程执行磁盘中一个全新的程序,也就是说让客户端执行对应的程序,通过我们的进程来执行其他人写的可以是不同语言的进程代码。

进程程序替换的原理:

进程进行程序替换,将磁盘中的程序加载到内存结构中,重新建立进程的页表映射。当子进程的程序替换的时候,代码和数据都发生了写时拷贝,完成父子进程的分离。

效果:父进程和子进程彻底分离,子进程执行一个全新的程序。

注:

1.进程程序替换的过程没有创建新的进程,只是改变了该进程页表的映射关系。

2.进程进行程序替换,该进程的PID不会改变。

4.2.进程程序替换的常见做法

进程程序替换通过execl系列接口得以实现,exec系列接口有execl、execlp、execle、execv、execvp、execve,如下图所示。

注:

1.使用execl系列函数需要包含<unistd.h>头文件。

2.exec系列接口是系统接口,因此可以调用任意的可执行程序(包括Java、python等语言写出的可执行程序)

问题:如果我们想执行一个全新的程序,我们需要做几件事情?

答:首先,先找到这个程序的位置。其次,程序可能携带选项进行执行(也可以不携带),通过选项明确告诉操作系统要怎么执行这个程序。

execl接口:(记忆:exec+list链表)

path参数是执行程序的路径。execl接口是可变参数的,arg及后面的参数类似命令的选项(会将这些参数传给execl程序的main函数参数),对于arg及后面的参数,最后一个参数必须是NULL,标识【如何执行程序】的参数传递完毕。

创建一个myexec.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,得到了myexec进程,在myexec进程中我们调用了execl接口执行ls命令,如下图三所示。

通过下面的运行结果,我们发现myexec.c文件中execl接口后面的printf代码没有执行,这是因为一旦执行execl接口替换成功,是将当前进程的代码和数据全部替换了,因此execl接口后面原本的代码已经不存在了。

注:execl接口是有返回值的,但其实execl接口不用判断返回值,因为execl接口执行后一旦替换成功,不会再执行返回语句了没有返回值,execl接口执行后如果失败,必然会继续向后执行。execl接口的返回值仅可以让用户知道是什么原因导致的替换失败。

创建一个myexec.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,得到了myexec进程,在myexec进程中我们调用了execl接口执行ls命令,如下图三所示。

调用了execl接口执行ls命令时,我们故意写错一些信息使得替换失败,可以观察到execl接口的返回值为-1。

创建一个myexec.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,如下图三所示。

这里的myexec.c代码就是父进程创建一个子进程来执行一个全新的程序(以前是执行父进程的代码片段)。这里如果id=0就是子进程,进入子进程的if语句块部分,子进程调用了execl接口,如果替换成功不会执行execl接口后面的代码(包括if语句块后面的语句),如果替换失败exit退出,因此只可能父进程执行if语句块后面的语句。

注:子进程执行程序替换,不会影响父进程,进程具有独立性。

execv接口:(记忆:exec+vector向量)

path参数是执行程序的路径。arg参数是一个指针数组,数组里面的内容是命令选项的指针(会将这些参数传给execv程序的main函数参数),arg指针数组中的内容,最后一个命令选项的指针后面必须是NULL。 

注:execv和execl只有传参方式的区别。

创建一个myexec.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,如下图三所示。

execlp接口:(记忆:exec+list链表+path路径)

file参数是执行程序的程序名,这里的程序必须在默认搜索路径下(环境变量path的路径中)。execl接口是可变参数的,arg及后面的参数类似命令的选项(会将这些参数传给execl程序的main函数参数),对于arg及后面的参数,最后一个参数必须是NULL,标识【如何执行程序】的参数传递完毕。

创建一个myexec.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,如下图三所示。

execvp接口:(记忆:exec+vector向量+path路径)

file参数是执行程序的程序名,这里的程序必须在默认搜索路径下(环境变量path的路径中)。arg参数是一个指针数组,数组里面的内容是命令选项的指针(会将这些参数传给execv程序的main函数参数),arg指针数组中的内容,最后一个命令选项的指针后面必须是NULL。 

创建一个myexec.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,如下图三所示。

补充1:使用make/makefile一次make生成myexec、mycmd两个可执行程序,其中myexec调用了mycmd。

makefile文件内容如果如下图一所示,那么得到的结果为只能生成一个可执行文件,mycmd和myexec哪个在makefile文件中靠前就生成哪个,如下图二所示。

如下图三所示,我们利用关键词.PHONY,在最前面使用命令.PHONY:all和all:myexec mycmd即可一次make生成两个可执行程序。.PHONY:all是告诉编译器要生成可执行程序all,all:myexec mycmd是告诉编译器可执行程序all依赖myexec和mycmd,all只有依赖关系没有依赖方法,因此编译器会根据后面的依赖关系和依赖方法自动的推导出all所依赖的两个可执行程序myexec和mycmd,运行结果如下图四所示。

补充2:在myexec进程中创建子进程,在子进程中利用execl函数调用了mycmd程序。

创建一个myexec.c文件写入下图一所示的代码,创建一个mycmd.cpp文件写入下图二所示的代码,创建一个Makefile文件写入下图三所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,如下图四所示。

注:myexec.c文件中execl("/home/dxf/test2023_2_24/mycmd","mycmd",NULL)是使用绝对路径,这里也可以使用相对路径,代码为execl("./mycmd","mycmd",NULL)。

execle接口:(记忆:exec+list链表+env环境变量)

path参数是执行程序的路径。execl接口是可变参数的,arg及后面的参数类似命令的选项(会将这些参数传给execl程序的main函数参数),对于arg及后面的参数,最后一个参数必须是NULL,标识【如何执行程序】的参数传递完毕。envp参数是一个指针数组,里面是自己的自定义环境变量,其功能是通过execle接口进行程序替换,将自定义的环境变量手动导入到新的程序中。

注:execle添加环境变量给目标进程是覆盖式的,新添加的环境变量会将进程原本的全部环境变量进行覆盖。

创建一个myexec.c文件写入下图一所示的代码,创建一个mycmd.cpp文件写入下图二所示的代码,创建一个Makefile文件写入下图三所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,如下图四所示。

这里在父进程myexec中定义了自定义环境变量env_并传给新程序mycmd,那么新程序中有自定义环境变量mypath却没有原本其他的环境变量(因为被自定义环境变量覆盖了)。这里因为mycmd没有原本其他的环境变量因此程序在getenv("PATH")处挂掉了,后面的代码都没有执行。

创建一个myexec.c文件写入下图一所示的代码,创建一个mycmd.cpp文件写入下图二所示的代码,创建一个Makefile文件写入下图三所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,如下图四所示。

这里在父进程myexec中定义了自定义环境变量env_并传给新程序mycmd,那么新程序中有自定义环境变量mypath却没有原本其他的环境变量(因为被自定义环境变量覆盖了)。这里mycmd有自定义环境变量mypath,因此mypath环境变量被打印了出来。

创建一个myexec.c文件写入下图一所示的代码,创建一个mycmd.cpp文件写入下图二所示的代码,创建一个Makefile文件写入下图三所示的代码。使用make命令生成可执行程序,然后使用./myexec执行该程序,如下图四所示。

这里在父进程myexec中声明了全部已有的环境变量environ并传给新程序mycmd,那么新程序中有环境变量path却没有自定义环境变量mypath,因此./myexec执行后mycmd程序在getenv("MYPATH")处挂掉了,此时我们使用代码export MYPATH="YouCanSeeMe"创建MYPATH环境变量,再./myexec执行可以看到PATH和MYPATH都可以打印出来。

execve接口(记忆:exec+vector向量+env环境变量):

filename参数其实就是path参数是执行程序的路径。arg参数是一个指针数组,数组里面的内容是命令选项的指针(会将这些参数传给execv程序的main函数参数),arg指针数组中的内容,最后一个命令选项的指针后面必须是NULL。 envp参数是一个指针数组,里面是自己的自定义环境变量,其功能是通过execle接口进行程序替换,将自定义的环境变量手动导入到新的程序中。

execvpe接口(记忆:exec+vector向量+path路径+env环境变量)

file参数是执行程序的程序名,这里的程序必须在默认搜索路径下(环境变量path的路径中)。arg参数是一个指针数组,数组里面的内容是命令选项的指针(会将这些参数传给execv程序的main函数参数),arg指针数组中的内容,最后一个命令选项的指针后面必须是NULL。 envp参数是一个指针数组,里面是自己的自定义环境变量,其功能是通过execle接口进行程序替换,将自定义的环境变量手动导入到新的程序中。

问题:为什么execl系列会有这么多接口?

答:为了适配不同的应用场景。

问题:为什么execve是单独的,如下图所示?

答:严格意义上来说只有execve是系统接口,execl系列的其他接口严格意义上来讲其实并不是系统接口,它们是基于系统接口execve之上做的封装。


5.简易shell的实现

第一步:显示提示符

shell本质上就是一个死循环,shell提示符的组成如下图一所示,为用户名+@+主机名+当前所在目录,这些字段都可以通过系统接口获取,我们暂时不关心获取这些属性的接口。

我们在printf函数的时候不能带\n,\n虽然能够刷新缓冲区,但是其会另起一行,而我们想要在shell提示符的后面输入内容,因此需要使用fflush函数。

第二步:获取用户输入

定义一个command_line数组来获取用户的输入内容。

我们在输入命令的时候如果有选项是要带空格的,因此不能使用scanf或cin,我们这里使用fgets函数,fgets函数声明如下图所示,fgets函数从特定的流中获取对应长度的内容保存在s字符数组中,如果成功则返回保存字符数组的地址(即参数s),如果获取失败则返回NULL。fgets函数是一个c语言函数,获取到的是c风格的字符串,也就是说获取的字符串会默认以\0结尾。

注:我们在输入的时候最后要敲Enter键,该键会输入一个\n然后刷新输入缓冲区。command_line数组里面保存的命令应该只有命令本身不应该有后面加的\n,因此需要专门将\n去掉。

第三步:从command_line数组中提取各部分指令字符串(字符串切分)

定义一个command_args指针数组来获取strtok函数拆分后的子串地址。

对字符串字符串进行切分,我们使用strtok函数,strtok函数声明如下图所示,strtok函数会将一个字符串按照特定分隔符delim依次切分成不同的子串,如果切分成功返回字符串起始地址,如果切分失败返回NULL。第一次切分strtok函数的str参数要传待切分的字符串,后面继续对该字符串切分则传NULL即可。

注:Linux下有一个alias命令,其功能是起别名,如下图所示,使用命令alias my_cmd='ls -a -l -i',那么后面可以使用my_cmd来代替ls -a -l -i。

我们使用which ls命令可以看到,我们平时使用的ls其实是被重命名过的,真正ls执行的功能其实是ls --color=auto。

因此这里command_line数组在切分的时候,第一次切分如果切分后的字符串为ls(数组command_args下标0对应的字符串为ls),就在数组command_args下标为1的地方追加字符串--color=auto,然后从数组command_args下标为2的地方往后再追加command_line数组中提取的其他指令字符串。

第四步:内建命令修正

补充知识:环境变量的数据在进程的上下文中。环境变量会被子进程继承下去,所以环境变量具有全局属性。当我们进行程序替换的时候,当前进程的环境变量不会被替换,其环境变量是继承父进程的。

如果直接exec创建子进程执行cd,最多只是让子进程进行路径切换,子进程是一运行就完毕的进程,我们在shell中更希望父进程shell的路径发生变化。创建环境变量时,应该在父进程shell中创建环境变量,如果在子进程中创建环境变量,那么只有该子进程及其子孙进程拥有该新创建的环境变量,父进程shell和其他子进程中没有该环境变量,无法保证环境变量的全局属性。

如果有些行为是必须让父进程shell执行的,不想让子进程执行,那么就不能创建子进程(子进程去执行和父进程无关),只能是父进程自己实现对应的代码。像这样由shell自己执行的命令,我们称之为内建(内置/bind-in)命令,内建命令相当于shell内部的函数。

Linux中有一个chdir函数,其用来切换到对应的目录下,chdir声明如下图所示,path参数就是对应要切换的目录路径。我们封装chdir函数来实现自己的ChangeDir函数,如果数组command_args下标0对应的字符串为cd,那么执行ChangeDir函数切换路径,然后使用continue跳过后面的创建子进程部分。

Linux中有一个putenv函数,将对应变量导入到本进程的上下文环境变量中,putenv声明如下图所示,string参数就是对应要导入的变量名。我们封装putenv函数来实现自己的PutEnvInMyShell函数,如果数组command_args下标0对应的字符串为export,那么执行PutEnvInMyShell函数将对应变量导入本进程的上下文环境变量中,然后使用continue跳过后面的创建子进程部分。

注:export新增的环境变量会增加到环境变量列表中,环境变量列表只保存环境变量名并不保存环境变量的内容,环境变量列表中每一个环境变量都有对应的一个指针指向自己的内容部分。在myshell中使用命令export dxf=123将dxf变量导入环境变量时,export dxf=123首先会进入command_line数组,然后拆分到command_args指针数组中并执行该命令,本次循环结束进入下一次循环,循环开始会memset清空command_line数组,而我们的环境变量dxf的内容是保存在command_line数组中的,memset清空command_line数组那么环境变量内容也就没有了。这里我们可以使用env_buffer数组来保存环境变量的内容,这样即使下一次循环开始memset清空command_line数组,环境变量dxf的内容在env_buffer数组中不会受影响。

系统中常见的内建命令有: cd命令、export命令、echo命令等。

第五步:创建子进程

执行对应指令需要程序替换,程序替换会直接将我们进程的代码替换掉,那么该指令执行完之后不会再弹出提示符,无法再继续执行指令,因此这里要创建子进程,子进程程序替换执行对应的指令。

第六步:子进程程序替换执行对应指令

我们使用execvp函数进程程序替换,execvp函数第一个参数是文件名,而command_args下标0对应的字符串刚好就是调用指令的名字。

注:在子进程中使用的是execvp函数,没有使用execle或execvpe手动传自己的环境变量,那么子进程进行程序替换后其环境变量仍然是默认继承的父进程myshell的环境变量,而父进程myshell的环境变量是继承bash进程的,这就证实了环境变量具有全局属性。

创建一个myexec.c文件写入下图一所示的代码,创建一个Makefile文件写入下图二所示的代码,使用make命令创建myshell可执行程序,./myshell运行程序即可运行我们自己的简易shell。

myshell.c文件代码:

#include <stdio.h>
#include <string.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

#define SEP " "
#define NUM 1024
#define SIZE 128

char command_line[NUM];
char *command_args[SIZE];

char env_buffer[NUM]; //for test

extern char**environ;

//对应上层的内建命令
int ChangeDir(const char * new_path)
{
    chdir(new_path);

    return 0; // 调用成功
}

void PutEnvInMyShell(char * new_env)
{
    putenv(new_env);
}

int main()
{
    //shell 本质上就是一个死循环
    while(1)
    {
        //不关心获取这些属性的接口, 搜索一下
        //1. 显示提示符
        printf("[张三@我的主机名 当前目录]# ");
        fflush(stdout);

        //2. 获取用户输入
        memset(command_line, '\0', sizeof(command_line)*sizeof(char));
        fgets(command_line, NUM, stdin); //键盘,标准输入,stdin, 获取到的是c风格的字符串, '\0'
        command_line[strlen(command_line) - 1] = '\0';// 清空\n

        //3. "ls -a -l -i" -> "ls" "-a" "-l" "-i" 字符串切分
        command_args[0] = strtok(command_line, SEP);
        int index = 1;
        // 给ls命令添加颜色
        if(strcmp(command_args[0]/*程序名*/, "ls") == 0 ) 
            command_args[index++] = (char*)"--color=auto";
        // = 是故意这么写的
        // strtok 截取成功,返回字符串其实地址
        // 截取失败,返回NULL
        while(command_args[index++] = strtok(NULL, SEP));   
    
        // 4. TODO, 编写后面的逻辑, 内建命令
        if(strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL)
        {
            ChangeDir(command_args[1]); //让调用方进行路径切换, 父进程
            continue;
        }
        if(strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
        {
            // 目前,环境变量信息在command_line,会被清空
            // 此处我们需要自己保存一下环境变量内容
            strcpy(env_buffer, command_args[1]);
            PutEnvInMyShell(env_buffer); //export myval=100, BUG?
            continue;
        }

        // 5. 创建进程,执行
        pid_t id = fork();
        if(id == 0)
        {
            //child
            // 6. 程序替换
            execvp(command_args[0]/*不就是保存的是我们要执行的程序名字吗?*/, command_args);
            exit(1); //执行到这里,子进程一定替换失败
        }
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        if(ret > 0)
        {
            printf("等待子进程成功: sig: %d, code: %d\n", status&0x7F, (status>>8)&0xFF);
        }
    }// end while
}

makefile文件代码:

myshell:myshell.c
	gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
	rm -f myshell

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

随风张幔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值