• 详述进程控制【Linux】


    1. 创建进程

    1.1 认识fork

    进程概念中已经说明fork函数的用法:在已有的进程中使用fork函数,会创建一个子进程,而父进程就是原进程。

    fork函数的位置就是一个分界点,fork之前的代码由父进程执行,之后的代码分别由父子进程执行。

    实际上,这里的父子进程共享所有代码,只是fork函数在语法上限制了子进程执行的语句范围,原因是OS会将fork的位置传给子进程,让子进程从这个位置开始执行。

    进程调用fork函数,当控制转移到内核中的fork代码后,内核会做以下事情:

    • 分配新的内存块和内核数据结构给子进程;
    • 将父进程部分数据结构内容拷贝至子进程;
    • 添加子进程到系统进程列表当中;
    • fork返回,开始调度器调度。
    #include 
    #include 
    #include 
    #include                                                                                          
    int main()
    {
      printf("fork before:PID:%d\n", getpid());
      pid_t id = fork();
      if(id == -1)//错误
      {
        printf("error\n");
      }
      printf("fork after:PID:%d, PPID:%d\n", getpid(), getppid());
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    运行以上代码,查看PID和PPID:

    image-20221011145029255

    可以看到,fork之前的代码只执行了一次,fork之后的代码执行了两次。说明fork之后的代码父子进程都会执行。

    fork函数的返回值

    给子进程返回0;给父进程返回子进程的PID,子进程创建失败则返回-1。

    为什么给子进程返回0,而对父进程返回子进程的PID呢?

    首先要明确创建子进程的目的:指派任务给子进程执行。PID对于进程而言就是一个名字,标识。父进程可以有多个子进程,子进程只有一个父进程,所以父进程必须知道子进程的标识。

    为什么fork有两个返回值?

    fork之后的代码中,包括最后的return语句,由于fork之后的语句父子进程都要执行,return语句也不例外。

    1.2 写时拷贝

    在任意一方未进行写入数据的操作时,父子进程是共享代码和数据的。只要当任意一方写入数据,这时才会拷贝一份,然后修改部分代码和数据,得到属于各自的代码和数据。

    image-20221015224451321

    为什么不在创建子进程的一开始就进行数据拷贝、修改等操作?

    • 为了按需分配内存,高效地使用内存空间。子进程不一定会使用父进程的所有数据和代码,而且子进程在不写入数据的情况下,也没有必要对数据进行拷贝、修改。
    意义

    分离父子进程,保证其独立性。写时拷贝本质是一种延时申请的技术,提高内存使用率。

    1.3 fork的常规用法

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

    1.4 fork调用失败的原因

    • 系统中有太多的进程,内存空间不足,子进程创建失败;

    • 实际用户的进程数超过了限制,子进程创建失败。

    2. 进程终止

    进程终止,本质就是OS释放系统资源,释放进程之前申请的相关内核数据结构和对于的数据和代码。

    2.1 进程退出的情况

    进程退出有三种情况:

    • 代码执行完,结果正确;

    • 代码执行完,结果不正确;

    • 代码未执行完,程序崩溃。

    对于前两种代码执行完的情况,OS是怎么知道结果是正确还是错误?

    • 在学习C语言的初期,我们就知道main函数是程序的入口,但是我们并没有理解它。实际上,每个程序都有一个main函数。那么main函数的返回值的意义就在于此,可以让OS知道程序运行后的情况如何,以便OS调度或提醒用户。

    为什么main函数的return语句总是return 0?它有什么含义吗?return 1、2、3不行吗?

    • main函数的返回值是可以自己设置的,这个返回值叫做「退出码」,程序员或OS以退出码判断运行结果是否正确。return语句的意义就是返回给上一级进程,以批判该进程执行结果(可以忽略)。
    • 非零值有无数个,不同的非零值就可以表示不同的错误原因。返回值(退出码)可以有不同的结果,方便定位错误原因。

    使用指令echo $?查看上一个进程的退出码:

    image-20221011153156008

    对于程序员,我们只看返回码是无法知道是什么错误的,所以每个返回码都有对应的错误表。就像ls指令打印出的错误一样(后面的选项是随便打的)。

    image-20221011165724441

    实际上,退出码都是有映射到各种不同的字符串的,这些字符串就像上面ls的报错一样。

    通过strerror函数可以获取错误码和错误信息字符串的映射关系:

    #include 
    #include 
    int main()
    {
      for(int i = 0; i < 150; i++)
      {
      printf("%d:%s\n", i, strerror(i));
      }
      return 0;                                                                 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    image-20221011165252122

    可以看到,上面ls的错误就是退出码为2映射的字符串。

    退出码映射的字符串都有不同的含义,帮助程序员定位执行失败的原因,这是C语言中的退出码和字符串的映射关系,映射关系是人为规定的,不同情况下会有不同的含义。

    2.2 进程退出的方法

    正常退出:

    • main函数中的return语句;
    • 在任何地方调用exit函数;
    • 在任何地方调用_exit函数。

    return代表函数调用结束,exit是一个接口。

    异常退出:

    • ctrl + c,信号终止。

    return语句退出

    上面演示过,return后可以自定义退出码,通过echo $?指令可以查看验证。

    exit函数退出

    #include 
    #include 
    void show()
    {
      printf("hello world");
      exit(99);
    }
    int main()
    {
      show();
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    image-20221012102514768

    在这段代码中,exit会在进程终止前将缓冲区中的数据刷新出来。

    _exit函数退出

    同样是上面的代码,将exit换成_exit,注意包含头文件

    #include 
    #include 
    #include 
    void show()
    {
      printf("hello world");
      _exit(100);
    }
    int main()
    {
      show();
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    image-20221012102759630

    但是如果在打印语句中加上换行符呢:

    image-20221012103032709

    结果却可以打印,为什么?

    三者的区别

    • return:只有main函数中的return语句才能让进程退出,其他函数中的return语句不能;exit和_exit函数在任何地方都可以让进程退出。
    • exit函数在退出进程前,会执行用户定义的清理函数,冲刷缓冲,关闭流等操作,然后才终止进程;而_exit直接终止进程,不会做任何收尾操作。
    • exit是一个函数,而_exit是一个系统调用。

    「系统调用」,是system calls的直译,可以简单地理解为OS提供给上层的接口,是系统级别的函数。

    image-20221016092213384

    重新回看那个\n的问题,因为exit会冲刷缓冲,所以就算不加\n最后也会打印出来,而没有收尾操作的_exit,就没办法打印。这就说明「缓冲区」一定不在OS内部,而是C标准库为我们维护的。如果是OS维护,_exit也可以将缓冲区中的内容刷新出来。

    三者的联系

    事实上,main函数中的return语句会隐式地调用exit函数。

    而exit函数在执行完毕收尾操作后,会调用_exit函数终止进程。

    也就是说,_exit是最底层的函数,其他两个函数都是由封装而来的。

    3. 进程等待

    3.1 原因

    进程等待是对于父进程而言的,也就是说等待的进程是子进程。

    • 如果子进程退出,父进程不回收,那么子进程会变成僵尸进程;
    • 僵尸进程是无法用kill -9指令杀死的;
    • 父进程创建子进程,其目的是让子进程工作,如果父进程对子进程不管不顾,这就违背了创建子进程的初衷;
    • 父进程需要通过进程等待,回收子进程的资源,获取子进程的退出信息。

    3.2 子进程status参数

    进程的status参数是一个int类型参数,但是它的不同范围的比特位储存着不同的信息(此处只研究低16位)。

    image-20221016093024660

    在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,第8位比特位是core dump标志。

    image-20221016093543146

    在头文件中,提供了一些宏简化位运算操作:

    if (WIFEXITED(status)) {
    	// 正常退出:((status) & 0x7f) == 0
    	// 打印退出码:(status >> 8) & 0xff
    	printf("child return: %d\n", WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
    	// 异常退出:((signed char) (((status) & 0x7f) + 1) >> 1) > 0
    	// 打印异常信号值:(status) & 0x7f
    	printf("child signal: %d\n", WTERMSIG(status));
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    其中,我们需要了解两个宏:

    • WIFEXITED(status):如果进程正常退出,返回的值是非零值。作用是用值的真假判断进程是否正常退出;
    • WEXITSTATUS(status):如果WIFEXITED非零,得到的是进程的退出码。

    这里的status参数是针对进程正常退出而言的,如果进程因为崩溃(或其他不正常的方式)退出,这里的参数也是没有意义的。对于return语句,如果进程在它之前因为崩溃而退出,那么return的退出码也就没有意义了,因为根本没有执行return语句。

    程序异常退出或崩溃,本质上是OS杀掉了进程,这和语言是无关的。OS如何杀掉进程?–发送信号。

    3.3 进程等待的方法

    wait函数

    函数声明和头文件

    #include
    #include
    pid_t wait(int* status);
    
    • 1
    • 2
    • 3

    参数

    指向status参数的指针,如果不需要监视,设置为NULL。

    返回值

    • 成功:返回进程PID;
    • 失败:返回-1。

    作用

    等待任意子进程。

    下面用fork创建一个子进程,然后让子进程工作一段时间,在这段时间中,使用wait函数让父进程等待子进程结束。子进程结束以后父进程读取子进程的信息,然后打印子进程的status参数。

    #include 
    #include 
    #include 
    #include 
    #include 
    int main()
    {
        pid_t id = fork();
        if(id == 0)//子进程创建成功
        {
            int count = 5;
            while(count--)
            {
                printf("子进程:PID:%d, PPID:%d\n", getpid(), getppid());
                sleep(1);
            }
            exit(99);
        }
        //父进程
        int status = 0;//定义一个status参数,等下传入wait中后再提取
        pid_t ret = wait(&status);
        if(ret > 0)//wait子进程退出成功,返回它的pid
        {
            printf("等待子进程退出成功\n");
            if(WIFEXITED(status))//如果是正常退出,条件为真
            {
                printf("子进程退出码:%d\n", WEXITSTATUS(status));
            }
        }
        sleep(3);
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    image-20221013102331096

    在进程运行时,在另一个终端用下面的脚本监控系统进程的情况:

    while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "#####################";sleep 1;done
    
    • 1

    image-20221013102527447

    从监控结果可以看到,子进程结束以后被父进程回收,不会变成僵尸进程。

    waitpid函数

    函数声明和头文件

    #include
    #include
    pid_t waitpid(pid_t pid, int* status, int options);
    
    • 1
    • 2
    • 3

    参数

    • pid:待等待的子进程的PID。如果为-1,表示等待任意子进程;
    • status:同上;
    • options:
      • WNOHANG,如果等待的子进程未结束,则waitpid函数的返回值为0,不再等待;如果正常结束,则返回子进程的PID;
      • WUNTRACED,如果子进程进入暂停执行情况则马上返回,但结束状态不予以理会。

    options的不同选项,实际上是C语言中的宏。为什么是C语言?原因:Linux内核是由C语言写的,而wait和waitpid是系统调用,也就是内核对外开放的接口,也就是C语言函数。

    宏的作用是将抽象的数据赋予意义。

    返回值

    • 等待成功则返回等待进程的PID;
    • 如果设置了选项WNOHANG,而调用中 waitpid函数如果判断没有已退出的子进程的信息,返回0;
    • 如果出错,返回-1,errno会被设置成相应的值以指示错误所在。

    作用

    • 等待指定PID进程或任意进程。

    wait和waitpid的区别

    效果不同:

    wait会令调用者阻塞,直至某个子进程终止。

    waitpid可以设置一个选项(options)设置为非阻塞,另外waitpid并不是等待第一个进程结束而是等待PID指定的进程。

    waitpid有wait没有的三个特性:

    • waitpid使我们可以等待指定的进程;
    • waitpid提供了一个无阻塞的wait;
    • waitpid支持工作控制。

    wait和waitpid作为系统调用,它的执行者是OS,本质上就是OS帮我们拿到进程的信息(task_struct)。

    父进程不等待子进程,会造成僵尸进程,这是系统层面上的内存泄漏,跟我们new或malloc出来的内存空间造成的内存泄漏是不一样的。

    通过status参数,父进程可以知道子进程的状态。

    阻塞等待和非阻塞等待

    阻塞等待

    使用wait会令调用者阻塞。被阻塞的进程对于系统而言,无非两种情况:一是等待被调度,也就是这个进程没有被CPU调度(CPU本来就很忙);二是在阻塞队列中。

    非阻塞等待

    父进程通过waitpid等待子进程,如果子进程没有退出,waitpid直接返回。

    阻塞和唤醒

    一般进程阻塞,伴随着被切换的操作,也就是如果进程不运行了,OS将它的PCB放到排队队列中,在用户层面看来,就好像卡住了一样。将PCB放到运行队列中,就是进程运行起来了。

    阻塞调用和非阻塞调用

    调用的主体是父进程,被调用的是wait和waitpid函数。

    • 阻塞调用:父进程一直等待子进程结束;
    • 非阻塞调用:父进程会每隔一段时间后查询子进程是否结束,在这些间隔内,父进程可以做自己的事情。

    示例

    4. 进程程序替换

    4.1 替换原理

    fork之后,父子进程各自执行父进程代码的一部分,这一部分对于用户而言是重复的,而创建子进程的初衷就是让它去干父进程之外的事情(这一点在接触『进程』后已经提到过不止一次)。虽然可以通过写时拷贝让父子进程拥有属于它们各自的数据,但是代码依然是共享的,也就是说,它们虽然数据不同,但是执行的任务还是一样的。

    **进程程序替换就是让子进程通过特定的接口(exec函数),加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的进程地址空间中。**子进程执行不同的程序,叫做替换。

    当执行进程替换操作后,子进程的代码和数据被新程序的代码和数据替换,并从新程序开始执行。

    子进程进行进程替换,有没有创建一个新的子进程?

    • 没有。进程=内核数据结构(PCB)+代码+数据,因为内核数据结构没有发生改变,所以没有创建新的进程。

    子进程进行进程替换后,会影响父进程的代码和数据吗?

    • 不会。进程替换,实质上是对子进程的数据进行写入操作。一旦父子进程的任何一方发生数据写入操作,写时拷贝技术就会发挥作用,为写入数据的一方另外创建一份代码和数据。所以父子进程的代码和数据是分离的。

    子进程进行程序替换后,环境变量相关数据会被替换吗?

    • 不会。因为每个进程都有自己的环境变量。环境变量以进程为单位,子进程继承父进程的环境变量。关于环境变量,可以参看这篇文章:环境变量的来源、原理与应用

    4.2 exec函数族

    exec函数族提供了一个在进程中启动另一个程序执行的方法。它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段,在执行完之后,原调用进程的内容除了进程号外,其他全部被新的进程替换了。

    头文件

    #include 
    
    • 1

    函数原型

    int execl(const char * path,const char * arg,);
    int execle(const char * path,const char * arg,char * const envp[]);
    int execlp(const char * file,const char * arg,);
    int execv(const char * path,char * const argv[]);
    int execve(const char * path,char * const argv[],char * const envp[]);
    int execvp(const char * file,char * const argv[]);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    参数说明

    • path:要执行的程序路径。可以是绝对路径或者是相对路径。在execv、execve、execl和execle这4个函数中,使用带路径名的文件名作为参数;
    • file:要执行的程序名称。如果该参数中包含“/”字符,则视为路径名直接执行;否则视为单独的文件名,系统将根据PATH环境变量指定的路径顺序搜索指定的文件;
    • argv:命令行参数的数组;
    • envp:带有该参数的exec函数可以在调用时指定一个环境变量数组。其他不带该参数的exec函数则使用调用进程的环境变量;
    • arg:程序的第0个参数,即程序名自身。相当于argv[O]。
    • …:命令行参数列表。调用相应程序时有多少命令行参数,就需要有多少个输入参数项。注意:在使用此类函数时,在所有命令行参数的最后应该增加一个空的参数项(NULL),表明命令行参数结束。

    这些参数类型都是字符指针类型,说明这些参数都是以字符串的形式传入的。

    返回值

    • 一1表明调用exec失败,无返回表明调用成功。即exec函数有返回值则表明调用程序失败。

    4.3 exec函数用例

    execl

    int execl(const char * path,const char * arg,);
    
    • 1

    execl中的l,可以看作list的缩写。

    使用execl函数进行进程替换操作:

    • 第一个参数是要替换的程序的路径,需要包括程序名。下面用常用的ls程序为例,所以先用which指令查看它所在的路径:

    image-20221013155501247

    • 第二个参数实际上也是有顺序的:第一个参数是程序名,中间的是选项,以字符串形式传入,最后以NULL结尾。也就是在命令行是怎么写的,这里就怎么传,下面的示例也是一样的。
    #include 
    #include 
    int main()
    {
        printf("exec函数之前\n");
        execl("/usr/bin/ls", "ls", "-l", "-a", "-i", "-d", NULL);
        printf("exec函数之后\n");
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    image-20221013155807156

    可以看到execl函数确实成功在这个路径下调用成功了ls程序,但是execl后面的打印语句没有执行。

    原因是:一旦exec函数调用成功,即进程替换成功后,所有的数据都被替换了,包括exec前面的语句、return语句等。之所以第一个打印语句能执行,是因为它在exec函数前面。

    为什么execl成功没有返回值,只有失败了才返回呢?

    • 替换成功了,所有数据都被替换了。即使return返回,也没有地方可以接收,因为替换以后新进程和原来的进程两者无关。

    为什么创建子进程?或者说为什么让子进程去进行进程替换操作?

    • 为了不影响父进程,保证父进程工作的付利息。父进程的主要任务是读取数据、解析数据、指派进程执行代码等工作,如果替换父进程,那么就没有进程可以管理数据了。

    execv

    int execv(const char * path,char * const argv[]);
    
    • 1

    execv中的v,可以认为是vector,和第二个参数argv对应,表示字符串参数是存在一个数组中,以数组的形式传入的。

    它和execl功能上没有什么区别,只是传参方式不同。

    下面把参数放到数组中,然后将数组作为参数传入execv:

    #include 
    #include 
    #include 
    #include 
    #define NUM 16
    int main()
    {
    	pid_t id = fork();
    	if(id == 0)//子进程
    	{
    		printf("子进程:PID:%d,PPID:%d\n", getpid(),getppid());
    		sleep(3);
    		char* const _argv[NUM] = {
    			(char*)"ls",
    			(char*)"-a",
    			(char*)"-l",
    			NULL
    		};
    		execv("/usr/bin/ls", _argv);
    		exit(1);
    	}
    	else//父进程
    	{
    		printf("父进程:PID:%d,PPID:%d\n", getpid(),getppid());
    	}
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    把字符串强转为char*,只是为了取消警告(类型匹配)。

    image-20221014103514819

    execlp

    int execlp(const char * file,const char * arg,);
    
    • 1

    结合环境变量部分,如果想要让程序直接执行而不指定它的路径,就需要将这个路径添加到环境变量PATH中。

    execlp中的p和环境变量PATH对应。

    • 第一个参数是要找的程序名;
    • 后面的参数是命令行参数。

    功能

    从环境变量中查找程序,找到然后执行。

    也就是说,execlp可以直接调用环境变量中的程序,而不用传入路径。

    #include 
    #include 
    #include 
    #include 
    int main()
    {
    	pid_t id = fork();
    	if(id == 0)//子进程
    	{
    		printf("子进程:PID:%d,PPID:%d\n", getpid(),getppid());
    		sleep(3);
    		execlp("ls", "ls", "-a", "-l", NULL);
    		exit(1);
    	}
    	else//父进程
    	{
    		printf("父进程:PID:%d,PPID:%d\n", getpid(),getppid());
    	}
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    image-20221014104428416

    效果同上。当然,如果要用它执行自己写的程序,就要将这个程序的路径添加到环境变量PATH中。

    实际上,传入的命令行参数(字符串选项),是由被调用程序中的main函数的一个参数接收的。

    main函数原型

    int main(int argc, char* argv[], char* envp[])

    execle

    int execle(const char * path,const char * arg,char * const envp[]);
    
    • 1

    execle中的e和environment variables(环境变量)对应,所以不带p的接口就要带上路径。

    上面的示例都是调用系统程序比如ls,如何调用自己写的C/C++程序呢?

    • 其实就是设置命令行参数之间的对应关系。

    下面写一个名为mycmd的程序,然后用proc2的子进程调用它:

    //mycmd
    #include 
    #include 
    #include 
    int main(int argc, char* argv[])//命令行参数个数,命令行参数数组
    {
    	if(argc != 2)
    	{
    		printf("无法运行\n");
    		exit(1);
    	}
    	if(strcmp(argv[1], "-a") == 0)
    	{
    		printf("hi -a\n");
    	}
    	else if(strcmp(argv[1], "-b") == 0)
    	{
    		printf("hi -b\n");
    	}
    	else
    	{
    		printf("运行失败\n");
    	}
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    //proc2.c
    #include 
    #include 
    #include 
    #include 
    #define NUM 16
    //绝对路径或相对路径都可以
    const char* myfile = "mycmd";
    
    int main()
    {
    	pid_t id = fork();
    
    	if(id == 0)//子进程
    	{
    		printf("子进程:PID:%d,PPID:%d\n", getpid(),getppid());
    		sleep(3);
    		char* const _argv[NUM] = {
    			(char*)"-a",
    			NULL
    		};
    		execle(myfile, "mycmd", "-a", NULL);
    		exit(1);
    	}
    	else//父进程
    	{
    		printf("父进程:PID:%d,PPID:%d\n", getpid(),getppid());
    	}
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    image-20221014112309733

    可以看到,proc2的子进程成功调用了自己写的mycmd程序。

    【注意】

    • 第一个参数是被调用的程序的路径,相对路径或绝对路径都可以,但是要保证使用相对路径时被调用程序要在当前进程的路径下。

    • 后面的参数是命令行参数。

    上面只用了两个参数,也可以传入环境变量envp[],它是一个指针数组。

    在proc2.c的main函数中定义一个指针数组作为要传入的环境变量:

    #include 
    #include 
    #include 
    #include 
    #define NUM 16
    
    const char* myfile = "mycmd";
    int main()
    {
        //新增
    	char* const _env[NUM] = {
    		(char*)"MY_CMD_VAL=1234567890",
    		NULL
    	};
    	pid_t id = fork();
    
    	if(id == 0)//子进程
    	{
    		printf("子进程:PID:%d,PPID:%d\n", getpid(),getppid());
    		sleep(3);
    		char* const _argv[NUM] = {
    			(char*)"-a",
    			NULL
    		};
    		execle(myfile, "mycmd", "-a", NULL, _env);
    		exit(1);
    	}
    	else//父进程
    	{
    		printf("父进程:PID:%d,PPID:%d\n", getpid(),getppid());
    	}
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    在mycmd.c中,增加查看环境变量的打印语句:

    #include 
    #include 
    #include 
    int main(int argc, char* argv[])//命令行参数个数,命令行参数数组
    {
    	if(argc != 2)
    	{
    		printf("无法运行\n");
    		exit(1);
    	}
    	printf("环境变量:%s\n", getenv("MY_CMD_VAL"));//新增
    	if(strcmp(argv[1], "-a") == 0)
    	{
    		printf("hi -a\n");
    	}
    	else if(strcmp(argv[1], "-b") == 0)
    	{
    		printf("hi -b\n");
    	}
    	else
    	{
    		printf("运行失败\n");
    	}
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    编译运行proc2

    image-20221014113858582

    结果表明,proc2的环境变量传给了mycmd。

    补充

    这就是环境变量具有全局属性,可以被子进程继承的原因。实际上,在子进程内部调用execle函数时,传入main函数接收的环境变量env就可以让子进程继承父进程的环境变量。

    image-20221014131740932

    将mycmd.c的getenv函数的参数改成"PATH":

    image-20221014133422436

    编译运行proc2:

    image-20221014132324930

    这就是main函数接收的系统环境变量PATH。

    execve

    int execve(const char * path,char * const argv[],char * const envp[]);
    
    • 1

    这是一个系统调用,是OS提供的接口。实际上exec函数族都是用它封装的函数。原因是封装不同功能的函数以满足上层不同的需要。

    小结

    命名

    • l(list):表示参数采用列表的形式,一一列出。
    • v(vector):表示参数采用数组的形式。
    • p(path):表示能自动搜索环境变量PATH,进行程序查找。
    • e(env):表示可以传入自己设置的环境变量。
    函数名参数格式是否带路径是否使用当前环境变量
    execl列表
    execlp列表
    execle列表否,需自己组装环境变量
    execv数组
    execvp数组
    execve数组否,需自己组装环境变量

    image-20221016094523442

    5. 自制简易shell

    通过了解进程程序替换的原理后,介绍shell的运行原理。

    5.1 shell运行原理

    通过子进程执行命令,父进程完成等待子进程、解析命令等管理操作即可完成命令行解释器(shell)的工作。

    外壳程序(shell)就像银行的指导人员,OS相当于银行内部,命令行解释器(shell)对用户输入到终端的命令进行解析,调用对应的执行程序。(回忆我们使用命令行输入命令时,shell做的工作)

    image-20221016095719900

    5.2 模拟实现

    1. 首先要明确,shell一定是一个常驻内存的进程(不主动退出),也就是死循环。

    2. 打印出提示信息;

    3. 获取用户从键盘键入的信息(指令和选项);

    4. 解析命令行参数;

    5. fork创建子进程;

    6. TODO,内置命令;(在最后)

    7. 替换子进程;

    8. 等待子进程退出。

    既然是常驻内存的进程,那么下面的操作都是在死循环内进行的。如果想退出这个自制shell,可以按ctrl+c;如果是死循环(打印),连续按几次就可以停下了。

    打印提示信息

    每次输入命令之前,都有这样的提示信息(具体视连接工具和平台而异):image-20221014140842559

    可以通过打印事先写好的字符串达到这种效果:

    while(1)
    {
        printf("[root@localhost myshell]# ");//随便写的
    }
    
    • 1
    • 2
    • 3
    • 4

    这样符号和字符串的组合就是打印给用户看的信息。除此之外,需要注意一些细节:

    • shell并没有在打印提示信息以后换行,但是不加\n的话,这个字符串会滞留在缓冲区中,所以打印提示信息需要搭配fflush使用,参数是stdout(标准输出),将字符串刷新到显示器上。

      while(1)
      {
          printf("[root@localhost myshell]# ");//随便写的
          fflush(stdout);
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5

      还增加需要下面的部件,否则这只是一个死循环打印。

    获取键入的信息

    自己写一个缓冲区

    用一个全局的数组储存命令行参数。因为我们输入命令行参数的形式是一个字符串,就像这样:"ls -a -l",所以这个字符数组存储的是字符串。为了等下方便完整且刚好地截取输入的字符串,在初始化这个数组的时候就将所有元素置为\0

    #include //注意memset的头文件
    #define NUM 1024    //定义缓冲区的长度
    
    char cmd_line[NUM];  //定义缓冲区字符数组
    
    int main()
    {
    	while(1)
    	{
    		//...
    		memset(cmd_line, '\0', sizeof(cmd_line));
    	}
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    写好缓冲区后,用gets函数获取输入的字符串,stdin表示从标准输入中读取数据:

    if(fgets(cmd_line, sizeof(cmd_line), stdin) == NULL)
    {
        continue;
    }
    printf("echo:%s\n", cmd_line);
    
    • 1
    • 2
    • 3
    • 4
    • 5

    测试一下,输入"ls -a -l",回车:

    image-20221014144153287

    但是回显指令后,会多空一行,原因是:

    注意刚刚输入一个字符串后,又按下了「回车」,这就导致缓冲区cmd_line的内容变成这样了:ls -a -l \n\0\0\0...,这就导致刚刚按下的回车输入到了缓冲区,所以要把这个\n去掉。换句话说,我们删除一个尾端的数据,通常将这个元素移除“尾端”的范围内,而\0就是字符串尾端的标志。所以我们对缓冲区读取的字符串做修改,将最后的\n置成\0

    cmd_line[strlen(cmd_line) - 1] = '\0';//除去\n
    
    • 1

    测试一下:

    image-20221014145612943

    现在有模有样的,就差解析命令和程序替换(就是让子进程调用命令的程序)了。

    解析命令

    还记得上面的exec函数族吗?给它们传入的命令参数是一个数组,这个数组的元素是命令或选项,就像这样:“ls”,“-a”,“-l”。但是我们知道,输入的命令行参数是一个字符串,为了使用这个接口,我们需要将这个字符串拆分成若干个命令和选项的小字符串,并且把它们存到一个数组里面,最后传入这个数组到exec函数(至于选哪个,等下再说)中。

    #define SIZE 32
    #define SEP " "     //定义" "为拆散字符串的分隔符
    char cmd_line[NUM];  //定义缓冲区字符数组,保存输入的命令行字符串
    char *g_argv[SIZE];  //保存拆散后的命令行字符串
    
    
    int main()
    {
    	while(1)
    	{
    		//...
    		//解析命令
    		g_argv[0] = strtok(cmd_line, SEP);
    		int index = 1;
            while(g_argv[index++] = strtok(NULL, SEP));
    	}
    	return 0;
    }
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    strtok函数是用来分解字符串的,其原型是: char *strtok(char str[], const char *delim); 其中str是要分解的字符串,delim是字符串中用来分解的字符,该函数返回分解后的字符串的起始位置指针。

    【测试】用一个循环检查一下字符串是否被拆成功了(等下要删掉):

    for(int i = 0; g_argv[i]; i++)
    {
        printf("g_argv[%d]:%s\n", i, g_argv[i]);
    }
    
    • 1
    • 2
    • 3
    • 4

    image-20221014161702599

    删掉它,继续。解析命令行参数的操作完成了,下面就是创建子进程和用子进程调用指定程序了。

    创建子进程

    用fork创建子进程已经轻车熟路:

    pit_t id = fork();
    if(id == 0)//子进程
    {
        printf("子进程开始运行:\n");
        //...
        exit(1);
    }
    else//父进程
    {
        //...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    替换子进程(子进程)

    这部分的操作的主体是子进程。

    个人觉得这个操作叫「进程替换」有点不准确,因为它本质上是让子进程去调用、运行其他程序,「替换」体现在当子进程调用其他程序时,子进程的所有数据都会被这个新的程序代替,实行这个操作以后,才是真正地称为进程替换。

    那么使用哪个exec函数来进行进程替换?

    • 根据需要选择。例如我等下要用ls示例,因为ls是系统程序,它是在环境变量PATH中的,那么可以选择execvp函数,因为第一个参数是file(看最开始的参数说明),默认在环境变量PATH中搜索名为file的程序。
    if(id == 0)//子进程
    {
        printf("子进程开始运行:\n");
        execvp(g_argv[0], g_argv);
        exit(1);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    等待子进程退出(父进程)

    这部分的操作的主体是父进程。

    在前面「进程等待」部分提到,父进程传入一个status变量给子进程,通过这个status参数的低16比特位知晓子进程的状态。

    • 成功:返回进程PID;
    • 失败:返回-1或0。
    else//父进程
    {
        printf("子进程开始运行:\n");
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        if(ret > 0)//退出成功,返回子进程pid
        {
            printf("退出码:%d\n", WEXITSTATUS(status));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    5.3 测试及补充

    测试

    编译运行以下代码:

    #include 
    #include 
    #include 
    #include 
    #include 
    #include //注意memset的头文件
    #define NUM 1024    //定义缓冲区的长度
    #define SIZE 32
    #define SEP " "     //定义" "为拆散字符串的分隔符
    char cmd_line[NUM];  //定义缓冲区字符数组,保存输入的命令行字符串
    char *g_argv[SIZE];  //保存拆散后的命令行字符串
    
    
    int main()
    {
    	//0. 用死循环让程序常驻内存
    	while(1)
    	{
    		//1. 打印提示信息
    		printf("[root@localhost myshell]# ");//随便写的
    		fflush(stdout);//将上面的字符串刷新到屏幕
    		memset(cmd_line, '\0', sizeof(cmd_line));
    		//2. 获取键入信息
    		//输入"ls -a -l"
    		if(fgets(cmd_line, sizeof(cmd_line), stdin) == NULL)
    		{
    			continue;
    		}
    		cmd_line[strlen(cmd_line) - 1] = '\0';//除去\n
    		printf("echo:%s\n", cmd_line);
    		//3. 解析命令
    		g_argv[0] = strtok(cmd_line, SEP);//strtok第一次要传入字符串地址
    		int index = 1;
    		while(g_argv[index++] = strtok(NULL, SEP));//传入NULL,表示继续分割
    		// 测试是否解析成功
    		// for(int i = 0; g_argv[i]; i++)
    		// {
    		// 	printf("g_argv[%d]:%s\n", i, g_argv[i]);
    		// }
    		//4. 创建子进程 
    		pid_t id = fork();
    		if(id == 0)//子进程
    		{
    			printf("子进程开始运行:\n");
    			execvp(g_argv[0], g_argv);// 5. 程序替换
    			exit(1);
    		}
    		else//父进程
    		{
    			int status = 0;
    			pid_t ret = waitpid(id, &status, 0);//6. 等待子进程退出
    			if(ret > 0)//退出成功,返回子进程pid
    			{
    				printf("退出码:%d\n", WEXITSTATUS(status));
    			}
    		}
    	}
    
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60

    image-20221014165453097

    补充

    但是如果试图使用cd指令回退到上级目录呢?

    image-20221014170431611

    上面的程序对cd指令是无效的。

    【原因】

    首先我们要知道,可执行程序(就是编译后的文件)和进程所在的目录是不一样的,可以看这里了解。

    其次我们还要知道,当该命令(cd)执行时,不会为前往另一个目录而创建一个新的进程,而是由外壳代为执行这条命令,ls等其他命令也是这种情况,这些命令叫做「内置指令」。这是因为,创建新的进程时,子进程会继承父进程创建时的目录。而如果cd命令继承了父进程的目录,则它永远也不能达到它的目标。

    因为我们上面的操作对于命令行参数(我们输入的命令)而言,都是子进程执行的,子进程的几乎所有数据都会被替换,那么子进程调用cd程序,对于shell本身(父进程)是没有影响的。

    【解决】

    让父进程调用cd指令。

    在真正的shell程序中,这些内置指令都是要由父进程执行的。

    在fork后的父进程代码中,使用接口chdir,切换工作目录,切换成功就重新循环。添加下面的代码:

    //4. TODO,内置指令
    if(strcmp(g_argv[0], "cd") == 0) 
    {
        if(g_argv[1] != NULL) chdir(g_argv[1]); //cd path, cd ..
    
        continue;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    image-20221014172903429

    当然可以把“子进程开始运行”和“退出码”这两个语句删掉,让它更像shell。

    【优化】

    可以特殊招待以下ls指令,比如像真正的shell上个色?给ls -l起个别名ll?

    if(strcmp(g_argv[0], "ls") == 0)
    {
        g_argv[index++] = "--color=auto";
    }
    if(strcmp(g_argv[0], "ll") == 0)
    {
        g_argv[0] = "ls";
        g_argv[index++] = "-l";
        g_argv[index++] = "--color=auto";
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    image-20221014192649622

    come on,有内味了。

    有一个细节,我的代码中没有过滤fork失败的判断分支,因为篇幅有限且一般情况下不会失败。

    5.4 小结

    运用所学的知识,通过解决各种问题,能更深层次地理解我们平常使用的指令,又理解了一点点“一切皆文件”的Linux了。感觉黑乎乎的shell也不再那么神秘,只要抽丝剥茧,高楼大厦也是砂砾筑之。

    模拟实现shell的源代码在这里

  • 相关阅读:
    [附源码]java毕业设计网上招聘系统
    工厂综合能源管理系统
    国外访问学者/博士后留学人员反诈骗指南
    Kubebuilder实现mysql-operator
    Spring入门——Eclipse实现HelloWorld程序
    进程和线程和协程补充
    【Unity入门计划】Unity2D动画(2)-脚本与混合树实现玩家角色动画过渡
    Java发送mail并更新日历信息及jar包冲突问题
    希尔排序.
    ArcGIS QGIS学习二:图层如何只显示需要的部分几何面数据(附最新坐标边界下载全国省市区县乡镇)
  • 原文地址:https://blog.csdn.net/m0_63312733/article/details/127344288