• 【Linux】进程控制 (万字详解)—— 进程创建 | 进程退出 | 进程等待 | 程序替换 | 实现简易shell


    🌈欢迎来到Linux专栏~~进程控制


    • (꒪ꇴ꒪(꒪ꇴ꒪ )🐣,我是Scort🎓
    • 🌍博客主页:张小姐的猫~江湖背景
    • 快上车🚘,握好方向盘跟我有一起打天下嘞!
    • 送给自己的一句鸡汤🤔:
    • 🔥真正的大师永远怀着一颗学徒的心
    • 作者水平很有限,如果发现错误,可在评论区指正,感谢🙏
    • 🎉🎉欢迎持续关注!
      在这里插入图片描述

    请添加图片描述

    请添加图片描述

    一. 进程创建

    🌍回忆fork

    在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进 程,而原进程为父进程。📌fork不懂的可以去这篇博客fork初始看看

    #include 
    pid_t fork(void);
    //返回值:子进程返回0,父进程返回子进程id;创建失败返回-1
    

    ⚡面试题:请你描述一下,fork创建子进程,操作系统都做了什么

    • 1️⃣系统多了一个进程,此进程分配有对应的PCB结构体、地址空间、页表
    • 2️⃣并将自己进程的代码和数据(从父进程中拷贝)加载到内存中,构建映射关系
    • 3️⃣将该进程的PCB放入运行队列里,等待调度
    • 4️⃣一旦开始调度,通过虚拟地址空间➕页表找到相关代码按照顺序语句等执行

    在这里插入图片描述
    所以,fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意,fork之后,谁先执行完全由调度器决定

    fork之后,代码共享是after之后的还是全部代码共享?

    • 虽然子进程是从after之后往后,但全部代码都是共享的

    ⚡那么为什么子进程是从fork之后开始执行,而不是before开始?

    • 因为进程随时可能被中断,下次回来,还必须从之前位置继续运行,就要求CPU必须随时记录下,当前进程执行的位置,所以CPU内有对应的寄存器EIP,用来记录当前进程的执行位置!
    • 寄存器在CPU内,只有一份,寄存器的数据是可以有多份的 —— 上下文数据
    • 🌍虽然父子进程各自调度,各自会修改EIP,但是因为子进程已经认为自己的EIP起始值就是fork之后的代码!

    在这里插入图片描述

    所以子进程是从after开始跑,但并不代表之前的代码看不到!

    创建子进程,给子进程分配对应的内核结构,必须子进程自己独有,因为进程具有独立性!理论上子进程也要有自己的代码和数据!可是一般而言,我们没有加载的过程,也就是说,子进程没有自己的代码和数据!!所以,子进程只能“使用”父进程的代码和数据!

    • 代码:都不可以被写,只能读取,所以父子共享
    • 数据:可能被修改,必须分离!

    🌍为什么OS选用写时拷贝 ?

    那么数据在创建进程时候就直接拷贝分离吗?

    • 可能拷贝子进程根本用不上的数据,即便用得上也只是读取 ———— 空间浪费

    举个例子:

    const char *str = "aaa";
    const char *str2 = "aaa";
    
    printf("%p\n", str);
    printf("%p\n", str2);
    

    打印出来的是同一块地址!编译器在编译程序时候都知道节省空间,你觉得OS不会吗?

    OS为何选择了写时拷贝,来将父子进程的数据进行分离?

    1. 一般而言即便是OS,也无法提前知道哪些空间可能会被写入!
    2. 用的时候,再给你分配,是一种延时申请技术,可以提高整机内存的使用率

    ps:string,深浅拷贝底层也是写实拷贝实现的

    在这里插入图片描述
    父/子修改数据时,会发生缺页中断:OS再开辟一段空间,把数据拷贝过来(写时拷贝),重新建立映射关系;父子分开,更改读写权限。这时候再进行写操作。这样保证了父子进程的独立性。

    🌍fork的用法 & 调用失败的原因

    ⚡fork用法

    • 父子进程执行不同代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
    • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数(下面详说哦)

    ⚡fork 调用失败的原因

    • 系统中有太多进程时,资源不足
    • 用户创建的进程数超出了限制,为了防止某些用户恶意创建

    二. 进程终止

    💦进程退出场景

    • 1️⃣代码运行完毕,结果正确
    • 2️⃣代码运行完毕,结果不正确
    • 3️⃣代码异常终止, 崩溃了

    思考:为什么main函数总会return 0,意义何在?

    并不是总是0, main函数的return的值就是进程退出码,返回给上一级进程,用来评判该进程执行结果

    ❗查看最近一次进程退出时的退出码 ——来衡量代码跑完对不对的

    echo $?  查看最近一个程序的退出码 
    ————————————————————————————————————————————————————————————————————————————————————————————————
    代码运行完毕,结果正确    - 0:   success
    代码运行完毕,结果不正确  - !0:  failed 
    代码异常终止			- 程序崩溃 → 退出码没有意义,return都不会跑(可以通过某种方式获得原因,进程等待详谈)
    

    在这里插入图片描述

    1. 代码运行完毕,结果正确:返回0
    2. 代码运行完毕,结果不正确:返回非0

    返回非0值,这是因为结果错误有多种可能,通过错误码获得对应错误信息字符串,比如我们可以用strerror来查看 ——

    在这里插入图片描述

     #include
     #include
     int main()
     {
       int i=0;
       for(i=0;i< 150;i++)
       {
         printf("%d:%s\n",i,strerror(i));                                                                  
       }                                                                                  
       return 0;                                                                          
      }  
    

    运行结果如下——

    在这里插入图片描述以上的退出码是系统给我们提供的,我们可以使用这些退出码,但是如果想自己定义,也可以自己设计一套退出方案!

    在这里插入图片描述这个没有错,自定义设为1了

    3️⃣程序崩溃
    程序运行出错,崩溃 —— 存在野指针

    #include
    int main()
    {
       printf("hello world\n");
       printf("hello world\n");
       printf("hello world\n");
       int *p =NULL;
       *p=1234;//野指针
       printf("hello world2\n");
       printf("hello world2\n");
       printf("hello world2\n");
       return 0;
    }
    

    程序崩溃时退出码是没有意义的,(好比你作弊了,老师还会在意你的分数吗?),一般而言退出码对应的return语句,没有被执行

    在这里插入图片描述

    💦退出进程方法

    🌈return 退出

    main函数内的return返回代表进程退出;非main函数return代表函数返回

    return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数

    🌈exit

    📌 exit在任意地方调用,都代表终止进程,参数是退出码。

    #include 
    void exit(int status);
    

    在这里插入图片描述

    🌈_exit

    在之前的进度条代码,我们就知道显示器是行刷新的,即\n进行刷新
    在这里插入图片描述

    🌈exit 和 _exit 区别

    我们发现_exit直接终止进程exit函数会执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再终止进程

    在这里插入图片描述

    🌈进程异常退出

    1️⃣向进程发生信号导致进程异常退出:

    • 发生kill -9信号使得进程异常退出,或是使用Ctrl+C使得进程异常退出等

    2️⃣代码错误导致进程运行时异常退出:

    • 代码当中存在野指针问题使得进程运行时异常退出,或是出现除0的情况使得进程运行时异常退出等。

    三. 进程等待

    ⚡进程等待的必要性

    1. 子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏
    2. 进程一旦变成僵尸进程,那么就算是kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程
    3. 对于一个进程来说,最关心自己的就是其父进程,因为父进程需要知道自己派给子进程的任务完成的如何

    所以父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息

    ⚡进程等待的方法

    ➰wait

    在这里插入图片描述

    #include
    #include
    
    pid_t wait(int*status);
    
    • 返回值: 等待成功,返回被等待进程pid;等待失败,返回-1

    下面写一段代码来验证:回收僵尸进程的问题

    #include 
    #include 
    #include 
    #include 
    #include 
    int main()
    {
    	pid_t id = fork();
    	if(id < 0)
    	{
    	  perror("fork");
    	  exit(-1);//表示进程运行完毕,结果不正确
    	}
    	if(id == 0){
    		//子进程
    		int count = 5;
    		while(count--){
    			printf("cnt: %d, 我是子进程,pid:%d,ppid:%d\n",cnt, getpid(), getppid());
    			sleep(1);
    		}
    		exit(0);
    	}
    	//父进程
    	sleep(7);
    	pid_t ret = wait(NULL);//阻塞式的等待!
        if(ret > 0)
        {
          printf("等待子进程成功,ret:%d\n",ret);
        }
    	return 0;
    }
    

    我们可以使用以下监控脚本对进程进行实时监控:

    while :; do ps ajx | head -1 && ps ajx | grep myproc | grep -v grep;sleep 1; echo "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"; done
    

    wait回收了僵尸进程

    在这里插入图片描述

    waitpid
    #include
    #include
    
    pid_t waitpid(pid_t pid, int *status, int options);
    

    返回值: 等待成功,返回被等待进程pid;等待失败,返回-1

    • pid:待等待子进程的pid,若设置为-1,则等待任意子进程
    • status:输出型参数,获取子进程的退出状态,不关心可设置为NULL
    • options:默认为0,表示阻塞等待
      在这里插入图片描述

    ⚡通过status获取子进程退出信息

    🥑位操作

    status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只研究status低16比特位):

    在这里插入图片描述
    由此我们可以通过此来对status进行位操作来获取异常信号退出码

    exitCode = (status >> 8) & 0xFF; //退出码
    exitSignal = status & 0x7F;      //退出信号
    

    在这里插入图片描述

    在这里插入图片描述

    🔸 对于代码异常终止的:

    1. 除0错误异常终止
      在这里插入图片描述

    2. 我们给子进程发送2号信号,把子进程提前干掉,此时可以看到退出码是无效的,退出信号即是我们发送的信号 ——在这里插入图片描述

    🥑宏

    我们也可以通过一组不用进行位操作的宏来获取退出码、判断有无异常信号

     WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
     WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
    

    在这里插入图片描述

    运行结果如下——(正常退出 vs 异常终止)

    在这里插入图片描述

    在这里插入图片描述

    🥑细节小问题

    1️⃣为什么要用wait/waitpid函数呢??直接用全局变量不行吗??

    • 进程具有独立性,那么数据就要发生写时拷贝,父进程无法拿到,更何况信号呢?

    2️⃣既然进程具有独立性,进程退出码不也是子进程的数据吗?,父进程为什么能拿得到呢??wait/waitpid究竟干了什么

    • 这要从僵尸进程:至少要保留该进程的PCB信息!task_struct里面保留了任何进程退出时的退出结果信息!!所以wait本质就是读取了子进程的task_struct结构
    🥑理解waitpid

    在这里插入图片描述

    ⚡options

    pid_ t waitpid(pid_t pid, int *status, int options);
    

    waitpid的第三个参数options,用来设置等待方式

    • 0:默认阻塞等待
    • WNOHANG:非阻塞等待

    若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID

    小故事:快要期末考了,我这个学期没有上过课,我给学霸张三打电话,问他要C语言的考试重点,他说他在楼上有事情让我等30min。我说:等你完全没有问题,电话别挂,你不下来,我就不挂,我就一直等着,这就是阻塞状态,一个月后,我再次找张三要复习资料,这次不同我每隔5mins 给张三打一次电话,询问他好了没有,这样每一次的打电话过程:非阻塞调用——基于非阻塞调用的轮询检测方案

    在这里插入图片描述

    🔥阻塞状态

    阻塞的本质:意味着进程的PCB被放入等待队列中,并将进程状态由R改为S状态
    返回的本质:子进程退出,父进程的PCB从等待队列中拿回,继续执行没执行完的代码,可以被CPU调度了

    🔥非阻塞状态

    我们看到OS或者某些应用,长时间卡住不动,这种情况我们叫做应用或者程序HANG住了。那么,WNOHANG表示设置等待方式为非阻塞

    父进程在等待子进程返回结果,情况有如下:

    • 等待成功,子进程退出
    • 等待成功,子进程还未退出
    • 等待失败
     #include                                                                                       
     #include
     #include
     #include
     
      int main()
      {
        pid_t id =fork();
        if(id == 0)
        {
          //子进程
          int cnt =5;
          while(cnt)
          {
            printf("我是子进程:%d\n",cnt--);
            sleep(1);
          }
          exit(105);//105 仅仅用来测试
        }
        else{
          int quit =0;
          while(!quit)
          {
            int status =0;
            pid_t result = waitpid(-1, &status, WNOHANG);
            if(result > 0)
            {
              //等待成功 && 子进程退出
              printf("等待子进程退出成功,退出码:%d\n",WEXITSTATUS(status));
              break;
            }
            else if(result == 0)
            {
              //等待成功 && 子进程未退出                                                                                                                                                       
              printf("子进程还在运行,暂时退出不了,你待会再来吧\n");
            }
            else
            {
              //等待失败
              printf("wait失败\n");
              break;
            }
          }
    }
    

    这就叫做基于非阻塞等待的轮询方案

    在这里插入图片描述

    四 . 进程替换

    众所周知,fork之后,父子各自执行父进程代码的一部分,父子代码共享,数据写时拷贝各自私有一份,如果子进程就想执行一个全新的程序呢?那就要通过进程替换实现

    💢概念和原理

    程序替换,是通过特定的接口,加载磁盘上的一个权限的程序(代码和数据),加载到调用进程的地址空间中!仅仅替换当前进程的代码和数据的技术,并没有创建新的进程

    在这里插入图片描述

    程序替换本质就是把程序的代码+数据,加载到特定进程的上下文中。C/C++程序要运行,必须要先加载内存中,如何加载呢?是通过加载器,加载器的底层原理就是一系列的exec*程序替换函数

    在这里插入图片描述

    上面我们发现,函数替换后,结束语句并没有打印

    注:execl是程序替换,调用函数成功之后,会将当前进程的所以代码和数据都进行替换!包括已经执行的和未执行的!(甚至把自己都干掉了,所以没有返回值

    execl一旦调用成功,后续所有代码,全部都不会执行!exec*函数成功是不需要进行返回值检测;只要返回了,就一定是因为调用失败了,直接退出程序即可。

    💚小细节
    在加载新程序之前,父子的数据和代码的关系?代码共享,数据写时拷贝。
    当加载新程序的时候,不就是一种“写入吗”?代码为了保证独立性,必须分离,所以会发生写时拷贝,所以父子进程在代码和数据上就彻底分离了

    💢替换函数

    #include `
    
    int execl(const char *path, const char *arg, ...);
    int execv(const char *path, char *const argv[]);
    int execlp(const char *file, const char *arg, ...);
    int execvp(const char *file, char *const argv[]);
    int execle(const char *path, const char *arg, ...,char *const envp[]);
    int execve(const char *path, char *const argv[], char *const envp[]);
    

    这些函数名看起来容易混淆,但只要理解其命名含义就很好记忆

    替换函数接口
    l(list)参数采用列表方式
    v(vctor)参数采用数组方式
    p(path)自动搜索环境变量path ,无需写全路径
    e(env)需要自己定义环境变量

    下面我来一一探究:
    🌍execl

    int execl(const char *path, const char *arg, ...)

    在这里插入图片描述

    🌍execv
    l即参数用列表传递;v即参数用数组传递

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

    在环境变量中我们提到过,main是可以带有参数的。argv是一个指针数组,指针指向命令行参数字符串。我们可以理解为,通过exec函数,把argv喂给了ls程序的main函数。

    在这里插入图片描述

    🌍execlp && execvp

    p:我会自己在环境变量PATH中查找,告诉我程序名即可

      execlp("ls", "ls", "-a", "-l", NULL);                                                                                                             
      char* argv[] = { "ls", "-a", "-l", NULL};   
      execvp("ls", argv);	
    

    ps:Makefile默认只生成第一个目标文件,那么如何在一个Makefile文件中一次形成两个可执行文件呢?
    在这里插入图片描述

    所有的接口,看起来没有很大差别,只是调用参数的不同。这么多的接口,是为了满足不同的调用场景

    操作系统只提供了一个系统调用接口execve(2),其他库函数(3)都是对系统调用的简单封装。

    在这里插入图片描述

    💢程序替换运行其他语言程序

    其中bash是解释器,test.sh是我们写的脚本,作为参数的形式给bash读取到,在bash内部执行的,执行对应的功能

    在这里插入图片描述

    五. 实现一个简易的shell

    💫 写一个shell 命令行解释器,需要循环以下过程

    • 打印提示行
    • 获取和解析命令
    • fork创建子进程;替换子进程
    • 父进程等待

    各个阶段都有很多细节要注意:

    🔥 1. 打印提示行
    由于提示行本就是写死的,对于理解Linux意义不大我们就直接打印:[ljj@localhost myshell]#
    另外在之前的进度条我们就知道,显示器的刷新策略就是行刷新,所以不想加\n,可以调用fflush(stdout);

    🔥 2. 获取命令行

    定义一个缓冲区cmd_line[NUM],并初始化。用fgets函数获取,打印的时候我们发现多换了一次行,这是因为我们把回车也读取到了,需要把\n处置0

    cmd_line[strlen(cmd_line)-1] = '\0'; //strlen不包括'\0'
    

    在这里插入图片描述

    🔥 3.解析命令行
    解析字符串,要分割命令行,用strtok。把一个字符串打散成多个子串吗?

    #include
    
    char *strtok(char *str, const char *delim);
    

    strtok细节:

    • 第一次调用,要传入原始字符串
    • 第二次调用,如果还要解析原始字符串,传入NULL

    🔥 4. fork创建子进程;替换子进程
    不能用当前进程直接替换,会把前面的解析代码覆盖掉,因此要创建子进程。同时,父进程需要等待子进程退出,并返回结果

    那么选择哪个进程替换函数呢?execvp

    bash是一个进程;会获取用户输入、对命令行做解析,帮用户和内核打交道;还会创建子进程帮我们执行命令,就算子进程崩了,也不会影响到父进程(王婆和实习生)

    🔥5. 内建命令
    在运行我们的shell发现,cd.. cd path等代码路径并没有回退,cd 等命令不能移动myshell的位置是因为子进程会退出,并非是父进程bash。
    对于cd,我们以内建命令方式运行(即不创建子进程,让父进程shell自己执行),实际上相当于调用了自己的一个函数。更改当前进程路径,有一个系统调用接口chdir ——

    在这里插入图片描述

    代码实现——迷你shell

    #include                                                                                                                                                                                                                     
    #include
    #include
    #include
    #include
    #include
    
    #define NUM 1024
    #define SIZE 32
    #define SEP " "
    
    //保存打散之后的字符串
    char *g_argv[SIZE];
    //保存完整的命令行字符串
    char cmd_line[NUM];
        
    // shell 运行原理 :通过让子进程执行命令,父进程等待&&解析命令
    int main()
     {
        //0. 命令行解释器,一定是一个常用内存的进程,也即是不退出
         while(1)
          {
            //1. 打印出提示信息
            //[whb@localhost myshell]#
            printf("[ljj@localhost myshell]# ");
            fflush(stdout);
            sleep(10);
            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';
            //"ls -a -l\n\0" 这里把最后的\n都输入进去了
            //printf("echo:%s\n", cmd_line);
            //3.解析命令行字符串:"ls -a -l" -> "ls" "-a" "-i"
            g_argv[0] = strtok(cmd_line, SEP); //第一次调用,要传入原始字符串
            int index = 1;
            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";  
            }
             while(g_argv[index++] = strtok(NULL, SEP)); // 第二次调用,如果还要解析原始字符串,传入NULL
            
            //for :debug
            //for(index =0; g_argv[index]; index++)
            //    printf("g_argv[%d]:%s\n", index, g_argv[index]);
            
            //4.todo:内置命令:让父进程(shell)自己执行的命令,叫做内置命令
            //内建命令本质其实就是shell中的一个函数调用
            if(strcmp(g_argv[0], "cd") == 0) //不想让子进程执行
            {
               if(g_argv[1]!= NULL) chdir(g_argv[1]);  //cd path, cd .. 
        
               continue;
            }
            //5.fork()
            pid_t id = fork();
            if(id == 0) //子进程
            {
               printf("下面功能让子进程执行\n");
               //cd 等命令不能移动myshell的位置,因为子进程会退出
               execvp(g_argv[0],g_argv);// ls -a -l
               exit(1);
            }
            //父进程
            int status =0;
            pid_t ret = waitpid(id, &status, 0);
            if(ret > 0)
            {
              printf("退出码:%d\n", WEXITSTATUS(status));                                                                                                                                     
            }
          }
          return 0;
     }
    

    在这里插入图片描述

    📢写在最后

    • 能看到这里的都是棒棒哒🙌!
    • 想必进程控制也算是Linux中重要🔥的部分了,如果认真看完以上部分,肯定有所收获。
    • 接下来我还会继续写关于📚《基础IO》等…
    • 💯如有错误可以尽管指出💯
    • 🥇想学吗?我教你啊🥇
    • 🎉🎉觉得博主写的还不错的可以`一键三连撒🎉🎉
    • 在这里插入图片描述
  • 相关阅读:
    Alibaba商品详情API接口
    数据库连接池连接超时报错
    web前端——简单的网页布局案列
    Dockerfile定制Ubuntu的docker镜像
    Adobe是什么?
    maya 设置半径 获取时长,设置时长
    深度解析:抢单模式,逆向的商业模式策略
    Scala数据结构
    (附源码)python办公数据分析系统 毕业设计 021836
    2022最新版-李宏毅机器学习深度学习课程-P49 GPT的野望
  • 原文地址:https://blog.csdn.net/qq_42996461/article/details/126906878