「前言」文章的大致内容是进行创建、终止、等待。

一、进程创建

1.1 再谈 fork 函数

Linux中 fork 函数时非常重要的函数,它从已存在进程中创建一个新进程,新进程为子进程,而原进程为父进程,fork 函数在进程概念的篇章已经介绍过了,这里再谈 fork 函数,再次理解 fork函数。

man fork 查看 fork函数详细介绍。

fork 的返回值有两个:

  1. 创建子进程失败返回 -1
  2. 创建成功:a.给父进程返回子进程的PID     b.给子进程返回 0

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

  1. 分配新的内存块和内核数据结构给子进程
  2. 将父进程部分数据结构内容拷贝至子进程(第一点和第二点在初识进程地址空间已经详细解释)
  3. 添加子进程到系统进程列表当中
  4. fork返回,开始调度器调度

fork 之后,父子进程代码共享

测试代码:

#include<stdio.h>    
#include<unistd.h>    
#include<sys/types.h>    
    
int main()    
{    
    printf("before fork pid: %d\n", getpid());    
    pid_t id = fork();    
    if(id == -1)    
    {    
        printf("fork error\n");    
    }    
    
    printf("after fork pid: %d, return val: %d\n", getpid(), id);    
    sleep(1);
    return 0;    
}    

运行结果

这里可以看到,before fork pid 只输出了一次,而 after fork pid 输出了两次。其中,before fork pid 是由父进程打印的,而调用fork函数之后打印的两个 after fork pid,分别由父进程和子进程两个进程执行。也就是说,fork之前父进程独立执行,而 fork之后父子两个执行流分别执行,也就是父子进程代码共享。

虽然子进程是从fork之后执行的,但全部代码都是父子进程共享的

注意:fork之后,父进程和子进程谁先执行完全由调度器决定

小提示:在编写 makefile 的时候,目标文件的依赖方法中,可以用 $@表示要形成的目标文件,即依赖关系中:左边的内容;用$^表示目标文件的依赖文件,即依赖关系中:右边的内容

1.2 fork 函数返回值问题

fork函数为什么要给子进程返回0,给父进程返回子进程的PID

  • 一个子进程永远只有一个父进程,但父进程可以拥有多个子进程。比如,一个孩子只有一个父亲,而父亲可以有多个孩子
  • 进程多了就要有进程的标识符,没有事不行的。就好比一个父亲他有三个孩子,父亲想叫其中的一个孩子,得叫孩子的名字吧,不叫孩子怎么知道叫哪一个孩子,总不能说:孩子,你过来一下。这样叫哪知道是哪一个,同比进程也是如此,得有一个认得出你的标识符。给子进程返回 0,给父进程返回子进程的PID就是类似情况

为什么fork函数有两个返回值? 

  • 因为存在两个进程(父进程和子进程),那么 fork 自然也就会被返回两次,每一个进程都要 return,所以 fork 函数有两个返回值(这里在地址空间也有介绍,这里简单说一下)

1.2 写时拷贝

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

写时拷贝在进程地址空间也有详细介绍

当我们不修改数据时,父子进程的虚拟内存所对应的物理内存都是同一块物理地址(内存),当子进程的数据被修改,那么就会将子进程修改所对应数据的物理内存出进行写时拷贝,在物理内存中拷贝一份放在物理内存的另一块空间,将子进程虚拟内存与这个新的地址通过页表进行关联 

为什么数据要进行写时拷贝?

  • 进程具有独立性,多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程

1.3 fork 常规用法

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

1.4 fork调用失败的原因

  1. 系统中有太多的进程
  2. 实际用户的进程数超过了限制

二、进程终止

2.1 进程退出码

进程有创建,进程也有结束的时候,进程结束我们称为进程终止。在C/C++中,在 main 函数最后基本都会写上 return 0对于这个返回值 0 我们称它为进程退出码

进程退出码有很多,每个进程退出码都有着自己的意义,进程退出码代表了进程为什么会退出,比如进程退出码 0 代表的意义就是进程正常退出,也就是代码正常执行完成

测试代码

#include<stdio.h>     
    
int main()    
{    
    printf("hello world\n");   
    return 0;                    
}      

程序运行完了,怎么查看进程退出码?

当进程执行之完成可以通过一个命令查看具体的进程退出码,? 就是环境变量中的一个名字,$?就是获取相应的环境变量

 echo $?

我们可以修改进程的退出码,进程退出码的意义也可以自己定义,不使用操作系统的那一套进程退出码

#include<stdio.h>      
    
int main()    
{    
    printf("hello world\n");    
        
    return 1;//我们假设进程退出码 1 ,是进程正常退出
    //return 0;
}              

 echo $? 查看进程退出码

echo $? 命令只会记录最近一次的进程退出码(即 main函数的 return 返回值),而下一个为 0的原因就是echo本身也是一个进程,并且正确执行退出,因此显示的是0 

如何设定 main函数的返回值?

  • 如果不关心进程退出码,return 0 就行,如果要关心进程退出码,要返回特定的数据表明进程退出的情况和特定的错误(进程是正常退出还是非正常退出)

进程退出码一般使用0表示成功,!0表示错误,!0具体是多少,就标定特定的错误

进程退出码都是数字,对计算机友好,但是对人不友好,所以退出码都要有对应的退出码的文字描述

strerror 这个函数就是把进程的退出码转换成文字描述

测试代码

#include<stdio.h>    
#include<string.h>    
      
  int main()    
  {    
      int i = 0;    
      for(i; i < 200; ++i)    
      {    
          printf("%d: %s\n", i, strerror(i));
      }                       
      return 0;                       
  }              

 运行结果

如图,只有0代表着success,其他的都对应不同的错误,并且有133个不同的错误,一共有134个进程退出码,就代表有134种不同的进程运行结果 

2.2 进程退出场景

进程退出的场景分三类:

  1. 代码运行完毕,结果正确(进程退出码为 0
  2. 代码运行完毕,结果不正确(进程退出码 !0
  3. 代码没有跑完,异常终止(退出码无意义)

进程如何退出呢?接下来就来解释一下(前两种情况)

2.3 进程如何退出

  1. main 函数的 return 退出,这是最常用的一种方式

  2. 通过 exit 函数退出

man 3 exit 查看一下,exit 是C语言的一个库函数,参数 status 就是当前进程的退出码

测试代码

#include<stdio.h>      
#include<stdlib.h>    
    
int main()    
{    
    printf("hello\n");    
    exit(11);    
    printf("world\n");
    return 0;    
}    

运行结果,到exit语句就会将进程结束,后面的代码也就不会再去执行了

查看退出码

(3)还有第三种,通过 _exit系统调用退出(了解)

man 2 _exit 查看

 测试代码

#include<stdio.h>     
#include<stdlib.h>    
    
int main()    
{    
    printf("hello\n");
    _exit(15);                                       
    //exit(11);  
    printf("world\n");
    
    return 0;                
}                

运行结果

结果发现 _exit() 其也是和 exit() 一样的功能。事实上,_exit 是系统调用的函数,也就是操作系统(OS)提供的,而exit()是库函数,库函数是 OS 之上的函数,exit 底层实际上就是调用 _exit,但二者之间也会有区别

二者的区别在刷新缓冲区上,将换行符去掉进行测试

测试代码

#include<stdio.h>    
#include<unistd.h>    
#include<stdlib.h>    
      
int main()    
{
      printf("hello world");   
      sleep(2);    
      exit(1);
      
      return 0;    
}    

运行结果

进程结束后,会刷新缓冲区,打印的结果暂停2秒也会显示出来,下面看 _exit()

测试代码

#include<stdio.h>    
#include<unistd.h>    
#include<stdlib.h>    
      
int main()    
{    
      printf("hello world");
      sleep(2); 
      _exit(1);   
      //exit(1);    
      
      return 0;    
}    

运行结果

_exit没有打印出结果,也就是说 _exit并没有刷新缓冲区

因此

  1. exit终止进程,主动刷新缓冲区
  2. _exit终止进程,不会刷新缓冲区

_exit() 是系统调用,而库函数 exit() 在系统调用之上, _exit() 不会刷新缓冲区,exit() 会刷新缓冲区,这也直接说明了缓冲区肯定在系统调用之上,也就是用户级缓冲区,缓冲区后序会详细解释

前面的三点都是进程的正常退出,最后一点是异常退出

(4)异常退出:通过 ctrl + c 终止进程,信号终止,如 kill -9

三、进程等待

3.1 进程等待必要性

进程等待的必要性: 

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

总的来说,进程等待的意义就是:回收子进程资源,获取子进程退出信息,即通过进程等待的方式解决僵尸进程的问题

3.2 进程等待的方法

3.2.1 通过 wait 方法回收子进程

man 2 wait 查看 waitwait 是一个系统调用,输出型参数,获取子进程退出状态,不关心则可以设置成为NULL,下面先使用第一个接口

返回值,等待成功返回子进程的PID,失败返回 -1

wait(): on success, returns the process ID of the terminated child; on error, -1 is returned.

测试代码,让子进程处于 Z状态5秒,父进程 10秒后醒来回收子进程

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

int main()
{
    pid_t id = fork();
    if (id == 0) // 子进程
    {
        int cnt = 5;
        while (cnt)
        {
            printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt);
            --cnt;
            sleep(1);
        }
        exit(0); // 退出子进程
    }

    // 父进程
    sleep(10);              // 由于子进程没有被父进程回收会处于 5秒的 Z状态
    pid_t ret = wait(NULL); // ret 用于接收 wait的返回值
    if (id > 0)
    {
        printf("wait success: %d\n", ret);
    }

    sleep(5); // 不让父进程那么快退出,用于查看进程处于的状态

    return 0;
}

监控脚本

while :; do ps axj | head -1 && ps axj | grep mytest | grep -v grep; sleep 1; done

 运行结果

右侧执行脚本,左侧同时运行 mytest,发现当子进程正在执行时,子进程和父进程都处于 S 状态,当子进程执行完毕,没有被父进程回收时的那 5秒,子进程就变成了 Z 状态,当父进程执行时,通过调用 wait 将子进程回收,子进程就结束了,最后的5秒只剩下父进程处于S+状态,这就是父进程通过进程等待回收了僵尸进程(子进程)

3.2.2 通过 waitpid 获取子进程退出信息

man 2 waitpid 查看 waitpidwaitpid 是一个系统调用,下面使用第二个接口进行测试

#include<sys/types.h>
#include<sys/wait.h>

pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
    当正常返回的时候waitpid返回收集到的子进程的进程ID;
    如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
    如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
    pid:
    Pid=-1,等待任一个子进程。与wait等效。
    Pid>0.等待其进程ID与pid相等的子进程。
status:
    WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
    WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
    WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID

 测试代码

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

int main()
{
    pid_t id = fork();
    if (id == 0)//子进程
    {
        int cnt = 5;
        while (cnt)
        {
            printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt);
            --cnt;
            sleep(1);
        }
        exit(10);//退出子进程
    }

    //父进程
    sleep(10);//由于子进程没有被父进程回收会处于 5秒的 Z状态
    int status = 0;
    pid_t ret = waitpid(id, &status, 0);
    if (id > 0)
    {
        printf("wait success: %d, status: %d\n", ret, status);
    }

    sleep(5);//不让父进程那么快退出,用于查看进程处于的状态    


    return 0;
}

运行结果

但是我们发现,status 不是我们想要的信息,所以 status 并不是整体使用的,status 有自己的位图结果,下面解释输出型参数 status 的使用

3.3 获取子进程 status

status 解释:

  1. waitwaitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充
  2. 如果传递NULL,表示不关心子进程的退出状态信息
  3. 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程
  4. status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status16比特位)

对于 32个 bit 位在这里只有16个 bit位是有意义的,进程正常终止 0~7 位返回 0代表正常的终止信号(返回0证明没有出问题),进程正常终止 8~15 位代表子进程对应的退出码

进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志,后面的比特位不再使用,即没有意义

怎么获取这些有用的信息?答案是通过位操作符

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

把上面的代码进行修改

再运行程序,就可以获取子进程的信息了

 (status >> 8) & 0xFFstatus & 0x7F 太难记了,所以系统当中提供了两个宏来获取退出码和退出信号

exitNormal = WIFEXITED(status);  //是否正常退出
exitCode = WEXITSTATUS(status);  //获取退出码

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

 修改代码

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

int main()
{
    pid_t id = fork();
    if (id == 0)//子进程                                                       
    {
        int cnt = 5;
        while (cnt)
        {
            printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt);
            --cnt;
            sleep(1);
        }
        exit(10);//退出子进程                                                                                                       
    }

    //父进程                                                                                                                        
    sleep(10);//由于子进程没有被父进程回收会处于 5秒的 Z状态                                                                        
    int status = 0;
    pid_t ret = waitpid(id, &status, 0);

    //判断子进程是否正常退出,正常退出为真                                                                                          
    if (WIFEXITED(status))
    {
        //获取子进程退出码                                                                                                          
        printf("wait success: %d, exit child code: %d\n", ret, WEXITSTATUS(status));

        // printf("wait success: %d, exit sign: %d, exit child code: %d\n", ret, (status&0x7F), ((status >> 8)&0xFF));    
    }
    else
    {
        printf("wait failed\n");
    }

    sleep(5);//不让父进程那么快退出,用于查看进程处于的状态 

    return 0;
}

运行结果

3.4 再谈进程退出

  • 子进程退出会变成僵尸进程,会把自己的退出结果写入到自己的 PCB 结构体中,在 Linux 下是 task_struct,子进程退出后 task_struct 不会立马释放,task_struct 会等待父进程来取走子进程退出信息
  • wait/waitpid 是一个系统调用,即以OS的身份进行,因此OS也有资格有能力去读取子进程的 task_struct,因此 wait/waitpid 是从子进程的 task_struct 来获取子进程的退出信息的

3.5 进程的阻塞和非阻塞等待

上面的测试代码就是阻塞等待,所谓的阻塞等待就是:当子进程未退出时,父进程都在一直等待子进程退出,在等待期间,父进程不能做任何事情,这种等待叫做阻塞等待,也叫轮询阻塞等待

父进程不做任何事,一直等待子进程的退出,在此期间父进程会一直询问:子进程,你好了没?这种询问会一直询问到子进程忙完,也就是子进程退出,父进程的一直询问这种方式称为轮询检测

而父进程不是一直等到子进程退出,而是间隔一定时间去询问子进程,父进程在子进程未退出时可以做一些自己的事情,当子进程退出时再读取子进程的退出信息,这种等待方式叫做非阻塞等待,也叫非轮询阻塞等待

下面进行非阻塞等待代码测试

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

int main()
{
    pid_t id = fork();
    if (id == 0)//子进程    
    {
        int cnt = 5;
        while (cnt)
        {
            printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt);
            --cnt;
            sleep(1);
        }
        exit(10);//退出子进程    
    }

    //父进程    
    int status = 0;
    while (1)
    {
        pid_t ret = waitpid(id, &status, WNOHANG);//WHOHANG: 非阻塞-> 子进程没有退出,父进程检测的时候,立即返回    
        if (ret == 0)
        {
            //waitpid 调用成功 && 子进程没有退出    
            //子进程没有退出, waitpid 没有等待失败,仅仅是检测到了子进程没有退出    
            //
            //执行父进程的代码

            printf("wait done, but child is running...\n");
            sleep(1);
        }
        else if (ret > 0)
        {
            // waitpid 等待成功 && 子进程退出了
            printf("wait success: %d, exit sign: %d, exit child code: %d\n", ret, (status & 0x7F), ((status >> 8) & 0xFF));
            break;
        }
        else
        {
            // waitpid 失败
            printf("wait failed\n");
        }

    }

    return 0;
}

运行结果

非阻塞等待有什么好处?

  • 非阻塞等待不会占用父进程的所有精力,可以在轮询期间,执行别的代码

四、进程程序替换

4.1 创建子进程的目的

创建子进程的目的:

  1. 想让子进程执行父进程代码的一部分(执行父进程对应磁盘代码中的一部分)

  2. 想让子进程执行一个全新的程序(让子进程想办法加载磁盘是指定的程序,执行新程序的代码和数据,这就是进程的程序替换)

4.2 替换函数

替换函数有六种以exec开头的函数,它们统称为exec函数,这六种都是库函数,这些函数的作用是:将指定的程序加载到内存中,让指定的进程执行

man 3 execl 查看

#include <unistd.h>

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

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

  • 第一个参数是要执行程序的路径
  • 第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾
  • ... 是可变参数列表

(2) int execlp(const char *file, const char *arg, ...)

  • 第一个参数是要执行程序的名字
  • 第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾

(3)int execle(const char *path, const char *arg, ...,char *const envp[])

  • 第一个参数是要执行程序的路径
  • 第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾
  • 第三个参数是你自己设置的环境变量

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

  • 第一个参数是要执行程序的路径
  • 第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾 

(5)int execvp(const char *file, char *const argv[])

  • 第一个参数是要执行程序的名字
  • 第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾 

第六个就不介绍了,都一样,下面这个是系统调用,上面 6 个库函数底层都是调用 execve 这个函数

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

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

  • 第一个参数是要执行程序的路径
  • 第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
  • 第三个参数是你自己设置的环境变量

4.3 替换函数解释

解释:

  1. 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回
  2. 如果调用出错则返回-1
  3. 所以 exec 系列函数只有出错的返回值而没有成功的返回值

4.4 替换函数命名理解

这些函数原型看起来很容易混,但只要掌握了规律就很好记

  • l(list) : 表示参数采用列表
  • v(vector) : 参数用数组
  • p(path) : 有p自动搜索环境变量PATH
  • e(env) : 表示自己维护环境变量

4.5 替换函数测试

4.5.1 execl

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

l(list) : 表示参数采用列表 

  • 第一个参数是要执行程序的路径
  • 第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾,
  • ... 是可变参数列表
  • 这些函数作用是将指定的程序加载到内存中,让指定的进程执行

如何找到程序?

  • 这是由第一个参数决定的,通过环境变量找到指定的程序

如何执行?

  • 这个是由第二个参数决定的,通过相应的命令执行程序

下面假设替换 ls 这个程序,execl 这个函数第一个参数要带路径

测试代码

#include<stdio.h>      
#include<unistd.h>      

int main()
{
    printf("process is running...\n");

    execl("/usr/bin/ls", "ls", NULL);//第一个参数是要执行哪个程序,第二个参数是你想怎么执行,以 NULL 结尾

    printf("process is running...\n");
    return 0;
}

 运行结果

我们发现,程序确实被替换了,执行了 ls 这个程序,而且最后一句打印没有打印出来,对比 ls 命令执行的结果,二者无差异,只不过没有把颜色带上,加上颜色的参数就可以了。

exec 系列的函数为什么没有成功返回值呢?

  • 因为替换成功了,就和接下来的代码无关了,判断毫无意义,exec 系列函数只要返回了,一定是程序替换失败了。

程序执行完成后,最后一句为什么没有被打印?下面解释原理。

4.5.2 程序替换的原理

以上面代码为例,代码执行时,进程地址空间与物理内存与页表就会形成映射关系,当执行原有的代码时,执行第一个printf会照常打印,到了execl函数时,就会发生进程的程序替换,也就是说,我们所编写的代码会被execl函数所调用对应磁盘内部的代码和数据覆盖,即将指定程序的代码和数据覆盖原有的代码和数据,然后执行这个新的代码和数据,所以execl后面的printf没有打印。

当进行进程程序替换时,有没有创建新的进程?

  • 进程程序替换之后,该进程对应的PCB、进程地址空间以及页表等数据结构都没有发生改变,只是进程在物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的 pid 并没有改变

程序替换一般都是用 fork 生成子进程,让子进程进行程序替换,上面的单进程例子是为了方便演示

下面使用子进程进行程序替换(双进程(父子进程)),函数依旧是 execl

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

int main()
{
    printf("process is running...\n");
    pid_t id = fork();
    assert(id != -1);

    //子进程                              
    if (id == 0)
    {
        //类比:命令行怎么写,这里就怎么写      
        sleep(1);
        execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);// --color=auto 是颜色高亮      

        exit(1);//这个代码执行了,就说明 excel 函数返回了,返回就意味程序替换失败了      
    }

    //父进程                              
    int status = 0;
    pid_t ret = waitpid(id, &status, 0);
    if (ret > 0)
    {
        printf("wait success: %d, exit signal: %d, exit child code: %d\n", ret, status & 0x7F, (status >> 8) & 0xFF);
    }
    else
    {
        printf("wait failed\n");
    }

    return 0;
}

 运行结果

结果还是一致的,用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。

当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动开始执行。调用exec并不创建新进程,所以调用exec前后该进程的pid并未改变。

进行程序替换时会发生写时拷贝,保证进程的独立性,不让子进程影响父进程

这就是程序替换的原理

4.5.3  execlp

int execlp(const char *file, const char *arg, ...)

  • l(list) : 表示参数采用列表
  • p(path) : 有p自动搜索环境变量PATH

参数:

  • 第一个参数是要执行程序的名字
  • 第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾

测试代码

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

int main()
{
    printf("process is running...\n");
    pid_t id = fork();
    assert(id != -1);

    //子进程                              
    if (id == 0)
    {
        //类比:命令行怎么写,这里就怎么写      
        sleep(1);
        execlp("ls", "ls", "-a", "-l", "--color=auto", NULL);// --color=auto 是颜色高亮 

        //execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);// --color=auto 是颜色高亮      

        exit(1);//这个代码执行了,就说明 excel 函数返回了,返回就意味程序替换失败了      
    }

    //父进程                              
    int status = 0;
    pid_t ret = waitpid(id, &status, 0);
    if (ret > 0)
    {
        printf("wait success: %d, exit signal: %d, exit child code: %d\n", ret, status & 0x7F, (status >> 8) & 0xFF);
    }
    else
    {
        printf("wait failed\n");
    }

    return 0;
}

运行结果

4.5.4  execlp

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

v(vector) : 参数用数组

参数:

  • 第一个参数是要执行程序的路径
  • 第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾 

改一下代码就可以了

4.5.4 替换自己写的可执行程序

上面的几个调用方式,事实上我们所调用的都是系统程序,接下来就通过exec类的函数调用自己写的程序。

随便创建一个源文件:test.c

#include<stdio.h>    
    
int main()    
{    
    printf("我是另一个C程序!!\n");    
    printf("我是另一个C程序!!\n");    
    printf("我是另一个C程序!!\n");    
    printf("我是另一个C程序!!\n");    
    printf("我是另一个C程序!!\n");    
    printf("我是另一个C程序!!\n");
    
    return 0;    
}    

makefile中也需要改成能够同时生成 myexecmytest 的指令,对于makefile文件,只会生成第一个程序,因此需要修改 makefile 让它们可以同时生成

.PHONY:all    
all: myexec mytest    
    
myexec:exec.c    
    gcc -o $@ $^    
mytest:test.c    
    gcc -o $@ $^    
    
.PHONY:clean    
clean:    
    rm -f myexec mytest     

结果如下

因为自己写的程序不在环境变量里面,所以不能使用p(path) : 有p自动搜索环境变量PATH。

直接使用相对路径即可

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

int main()
{
    printf("process is running...\n");
    pid_t id = fork();
    assert(id != -1);

    //子进程                              
    if (id == 0)
    {
        //类比:命令行怎么写,这里就怎么写      
        sleep(1);

        execl("./mytest", "mytest", NULL);

        exit(1);//这个代码执行了,就说明 excel 函数返回了,返回就意味程序替换失败了      
    }

    //父进程                              
    int status = 0;
    pid_t ret = waitpid(id, &status, 0);
    if (ret > 0)
    {
        printf("wait success: %d, exit signal: %d, exit child code: %d\n", ret, status & 0x7F, (status >> 8) & 0xFF);
    }
    else
    {
        printf("wait failed\n");
    }

    return 0;
}

运行结果

对于这种调用方式,是没有语言之间的隔阂的,即我们可以通过C语言调用C++、Java、Python等等其他类型的语言,当然也可以反过来调。

也就是说程序替换,可以使用程序进行替换,也可以调用任何后端语言对应的可执行程序。

4.5.5  execle

int execle(const char *path, const char *arg, ...,char *const envp[])

  • l(list) : 表示参数采用列表
  • e(env) : 表示自己维护环境变量

参数:

  • 第一个参数是要执行程序的路径
  • 第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾
  • 第三个参数是你自己设置的环境变量

直接使用 4.5.4 上面的代码,修改一下。

test.c

exec.c

运行结果

结果发现,系统内部的环境变量使用不了,我们自定义的就可以使用。这是因为我们的 execle 函数的最后一个参数的原因,最后的一个参数就是传入的环境变量,没有传入就不会使用,因此如果我们在exec.c中将最后一个位置的参数改成 environ(前面添加extern char** environ)的话,就会反过来:我们自定义的环境变量就不会生效,只有系统的才会生效。

但是我们想让两者同时生效,就要使用进程概念前面提到的函数:putenv

man putenv 查看,putenv 是一个库函数,作用是把你自定义的环境变量导入环境变量中,让自定义的环境和系统的环境变量让两者同时生效。

再修改一下 exec.c 的代码

再次运行程序

这样就可以让自定义的环境和系统的环境变量让两者同时生效。

其他 exec 系列的函数不再演示,道理都一样。只有 execve 是真正的系统调用,其它六个函数最终都调用 execve,所以 execve在man手册 第2节,其它函数在man手册第3节。

4.5.6  exec 系列函数与 main 函数的相关问题

对于execle函数和main函数,在进程调用的时候是谁先被调用?

  • exec先被调用。exec系列的函数的功能是将我们的程序加载到内存中!
  • 我们知道一个程序要想运行必须加载到内存中让CPU去执行,那程序是如何加载的?而对于LinuxOS来说,程序加载是通过exec系列的函数加载到内存中的,因此Linux中的exec系列函数也被称为加载器。

程序是先加载呢?还是先执行main呢?

  • 毫无疑问,一定是先加载,所以,也就解释通了对于 exec系列的函数和 main函数,一定是 exec 系列的函数先被调用。

main也作为函数,也需要被传参,exec系列的函数和main函数的参数有什么关联呢?

main 函数本身自带三个参数,不过平时我们都不传参数

int main(int argc, char* argv[], char* env[]);

execle为例,main函数的参数都是exec系列的函数传给 main函数的,他们的参数就是这种一一对应的映射关系!即 main函数被 exec调用

那对于exec系列中不带有 envp[] 参数的那些函数,照样能够拿到默认的环境变量,其实是 environ 通过地址空间的方式让子进程拿到的 

下图exec函数族 一个完整的例子

程序替换中只有一个 execve系统调用,其他都是封装,目的是为了让我们有更多的选择

进程替换到此结束。

五、进程控制应用场景:模拟 shell命令行解释器

5.1 模拟 shell 版本1 

shell也就是命令行解释器,其运行原理就是:当有命令需要执行时,shell创建子进程,让子进程执行命令,而shell只需等待子进程退出即可

其实shell需要执行的逻辑非常简单,其只需循环执行以下步骤:

  1. 获取命令行
  2. 解析命令行
  3. 创建子进程(fork)
  4. 替换子进程(execvp)
  5. 等待子进程退出(wait)

版本1

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#define NUM 1024  //一个命令最大长度    
#define OPT_NUM 64 //一个命令最多选项    
char lineCommend[NUM];
char* myargv[OPT_NUM];

int main()
{
    while (1)
    {
        //打印输出提示符    
        printf("用户名@主机名 当前路径# ");
        //刷新缓冲区    
        fflush(stdout);

        //获取用户输入,自己输入的时候,按回车缓冲区里面会多一个 \n    
        char* s = fgets(lineCommend, sizeof(lineCommend) - 1, stdin);
        if (s == NULL)
        {
            perror("fgets");
            exit(-1);
        }

        //去掉自己输入的回车 \n    
        lineCommend[strlen(lineCommend) - 1] = 0;

        //对输入的命令做字符串切割
        //ps: 输入"ls -a -l -i" -> 切割成 "ls" "-a" "-l" "-i"
        myargv[0] = strtok(lineCommend, " ");
        int i = 1;
        while (myargv[i++] = strtok(NULL, " "));

        //创建子进程执行命令
        pid_t id = fork();
        if (id == -1)
        {
            perror("fork");
            exit(-1);
        }
        else if (id == 0)
        {
            //子进程
            execvp(myargv[0], myargv);
            exit(1);
        }

        //父进程
        waitpid(id, NULL, 0);
    }

    return 0;
}

 运行结果,一个简易的shell就完成了。

但是这个简易的shell命令行解释器还有一个问题:就是返回上一级路径时,路径没有发生变化。

下面就来解决这个问题。

5.2 当前路径

什么是当前路径?

测试代码

执行这个程序并新建窗口进行观察

ls /proc/进程pid

以列表显示

ls /proc/进程pid -al

其中,exe是指当前可执行程序在磁盘中的路径 ,而 cwd (current working directory) 则是指 当前进程的工作目录,它就是我们平时所说的 当前路径

在 Linux 中,我们可以使用 chdir系统调用来改变进程的工作目录

也就是说,当前工作目录可以被改变,chdir的参数是写入你要修改当前工作目录的的路径 。

回到上面,为什么我们自己写的shellcd的时候路径没有变化呢?

  • myshell 是通过创建子进程的方式去执行命令行中的各种指令的,也就是说,cd 命令是由子进程去执行的,那么自然被改变也是子进程的工作目录,父进程的工作目录不受影响
  • 而当我们使用 pwd 指令来查看当前路径时,cd 指令对应的子进程已经执行完毕退出了,此时 myshell 又会给 pwd 创建一个新的子进程,且这个子进程的工作目录和父进程 myshell 相同,所以 pwd 打印出来的路径不变

知道原因后,我们只需要对命令行传入的指令进行判断,如果是 cd 指令,就使用 chdir将父进程的工作目录修改为指定的目录即可 

修改代码

  #include<stdio.h>                   
  #include<stdlib.h>
  #include<string.h>
  #include<unistd.h>
  #include<sys/types.h>
  #include<sys/wait.h>
  
  #define NUM 1024  //一个命令最大长度
  #define OPT_NUM 64 //一个命令最多选项
  char lineCommend[NUM];
  char* myargv[OPT_NUM];
  
  int main()
  {
      while(1)
      {
          //打印输出提示符
          printf("用户名@主机名 当前路径# ");
          //刷新缓冲区
          fflush(stdout);
  
          //获取用户输入,自己输入的时候,按回车缓冲区里面会多一个 \n
          char* s = fgets(lineCommend, sizeof(lineCommend)-1, stdin);
          if(s == NULL)
          {
              perror("fgets");
              exit(-1);
          }
  
          //去掉自己输入的回车 \n
          lineCommend[strlen(lineCommend)-1] = 0;
  
          //对输入的命令做字符串切割
          //ps: 输入"ls -a -l -i" -> 切割成 "ls" "-a" "-l" "-i"                                                                               
          myargv[0] = strtok(lineCommend, " ");
          int i = 1;
          while(myargv[i++] = strtok(NULL, " "));
          
          //如果是 cd 命令,不需要创建子进程,让 shell 自己执行对应的命令,本质就是执行系统接口
          if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0)
          {
              if(myargv[1] != NULL)
                  chdir(myargv[1]);//改变父进程的工作目录
  
              continue;//直接跳过此次循环,不再创建子进程
          }
  
          //创建子进程执行命令
          pid_t id = fork();
          if(id == -1)
          {
              perror("fork");
              exit(-1);
          }
          else if(id == 0)
          {
              //子进程
              execvp(myargv[0], myargv);
              exit(1);
          }
          else if(id == 0)
          {
              //子进程
              execvp(myargv[0], myargv);
              exit(1);
          }
  
          //父进程
          waitpid(id, NULL, 0);
      }
  
      return 0;
  }                                                            

运行结果,可以使用 cd 命令改变路径了

5.3 内建/内置命令

Linux 中的命令一共分为两种 – 内建(内置)命令和外部命令:

  • 内建命令是 shell 程序的一部分,其功能实现在 bash 源代码中,不需要派生子进程来执行,也不需要借助外部程序文件来运行,而是由 shell 进程本身内部的逻辑来完成
  • 外部命令则是通过创建子进程,然后进行进程程序替换,运行外部程序文件等方式来完成

上面的cd 命令就是一个内建命令,echo 也是一个内建命令,我们上面写的 shell 执行这个命令也有问题,也需要像 cd 命令一样去处理 。

--------------- END ---------------

「 作者 」 枫叶先生
「 更新 」 2023.1.9
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
          或有谬误或不准确之处,敬请读者批评指正。