【Linux学习】进程控制

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

🐱作者一只大喵咪1201
🐱专栏《Linux学习》
🔥格言你只管努力剩下的交给时间
请添加图片描述

在前面我们学习了进程的相关概念在这里本喵会给大家介绍如何控制进程。

进程控制

一、进程创建

  • 函数pid_t fork(void);
  • 返回值
    -1子进程没有创建成功。
    0该值传给子进程。
    pid值该值传给父进程。
  • 作用创建一个子进程如果创建成功返回两个值。

之前我们都是直接在用这个函数它的原理到底是什么样我们并不清楚现在本喵来回答三个问题。

如何理解fork有两个返回值

这个问题是不是一直都在困扰着你一个函数居然有两个返回值这是怎么做到的呢在学习了进程地址空间以后就可以回答这个问题了。

在一段代码中执行完fork函数以后就会创建出子进程此时就同时存在两个进程。

fork函数是一个系统调用但是它仍然是一个函数只是执行者是操作系统而已既然有返回值所以该函数的最后一条语句肯定是return。

图
fork函数中做的事情大概如上图这里仅是一个感性的介绍可以看到在return之前该函数的核心逻辑已经做完了就和我们平时写的函数一样return的时候核心逻辑肯定是执行完的。

换句话说在执行return的时候子进程已经被创建成功了。

此时父进程和子进程都会执行return语句都会返回pid值所以表现出来的就是fork有两个返回值。

如何理解fork之后给父进程返回子进程的pid给子进程返回0

现在已经知道父子进程都会执行return语句所以会有两个返回的pid值但又是怎么做到给父进程和子进程返回的值是不同的呢

在这里本喵正式介绍一下写时拷贝。

写时拷贝

图
上图描述了子进程创建的过程。

在父进程创建子进程之前它有自己的PCB还有自己的进程地址空间并且通过页表的映射在物理内存中也有相应的内存空间。

当执行完fork以后子进程诞生了它同样具有自己的PCB以及进程地址空间和页表。

但是此时子进程的数据段和代码段等内容经过页表的映射也指向了父进程在物理内存中的空间。

  • 子进程会继承父进程的资源。

也就是说子进程在刚创建的时候它和父进程共用同一块物理内存。

图
当子进程尝试这对数据段中的内容做修改的时候就会发生写时拷贝。

所谓写时拷贝就是在物理内存中将被修改空间中的内容先拷贝到另一个物理空间中并且修改对应的页表映射关系再修改拷贝后新空间中的内容。

pid_t id这个用来接受返回值的变量在子进程刚创建的时候在物理内存中是只有一个的但是在返回的时候父子进程return的值不一样也就是物理内存中的id值被修改了为了保证进程的独立性所以就会发生写时拷贝。

  • 父进程和子进程谁先返回谁就发生写时拷贝并且使用新物理空间至于它俩是谁先返回的这个是不一定的。

正是由于写时拷贝的存在所以父进程和子进程能够接收到不同的返回值。

如何理解父子进程让if和else if同时执行

根据上面的介绍我们知道由于发生了写时拷贝所以父进程和子进程都有一个id值并且是不同的。

图
fork之后的代码父子进程是共享的。

也就是说在fork之后的代码父子进程是共同执行的并且父子进程使用的是同一块物理空间中的代码。

但是各自的id值是不同的所以会父子进程会进入不同的条件判断中并且执行不同的代码。

二、进程终止

进程终止指的就是程序执行结束了进程终止退出的场景有三种

  • 代码运行完毕且结果正确
  • 代码运行完毕但结果不正确
  • 代码异常终止

程序执行完毕

我们在写代码的时候main函数最后总会有一个return 0其中0就是进程终止的退出码。

退出码用来标识程序的运行结果。

在C语言的库中官方提供了很多的退出码

图

图
将官方提供的退出码全部打印出来可以看到一共有134个退出码其中退出码为0表示成功退出码是其他的时候都代表着不同的意义。

  • 0只有一个所以用它来标识成功其他数字有无数个所以用非0数字表述程序执行的其他情况。

我们可以根据程序的退出码来判断程序执行的情况在shell中也有专门的变量来查看程序的退出码

图
写一个打印hello world的程序return的值是0也就是程序的退出码是0。在shell中使用本地变量来查看退出码。

  • 这是一个本地变量用了存放最近一个进程的退出码

上面的程序中退出码都是0标识的是成功同样我们可以根据不同的情况返回不同的退出码。

图
上面程序是从1加到100如果结果正确返回0结果不正确返回1.

图
可以看到退出码是1表示结果不正确此时我们是自己定义的退出码是1标识结果不正确。并没有使用官方库中提供的退出码。

return 后面的数字是进程的退出码用echo查看的退出码是main函数中的return值为什么不是加法函数中的return值呢

只有main函数中的return值才能作为进程的退出码其他自己实现的函数中的return值不能够作为退出码。

如果就我们就想在子函数中退出并且有相应的退出码呢

图

在加法函数中如果结果正确退出码是0如果结果不正确退出码是10.

图
可以看到此时的退出码是10。

  • exit(int num)函数的作用是终止进程并且进程的退出码就是它的参数。

exit()和_exit()的区别

图
此时使用_exit函数来终止进程。

图
可以看到退出码仍然是10和使用exit()的结果是一样的。

那么exit和_exit到底有什么区别呢

图
使用exit()来终止进程。
图
可以看到字符串正常打印了出来。

图
使用_exit()来终止进程。
图
运行结果中并没有字符串的打印这是什么原因呢

图

  • 程序是属于用户层的exit和_exit等函数都是在程序中的。
  • 调用exit()之后还会再执行一些程序比如刷新缓存区等操作然后再调用_exit()终止进程exit的_exit的封装。
  • 调用_exit()之后会直接终止进程不会执行刷新缓存区等操作。

区别也就是说_exit()是系统调用而exit()是库函数。

所以在使用_exit的时候缓冲区还没有来得及刷新进程就被终止了所以字符串就没被打印出来。

结论程序执行完毕的情况下只有main函数中的return和exit或者_exit的才能终止进程并且返回退出码。

程序异常终止

异常终止就是程序执行到一半不再执行了因为发生了异常。

图
上图中的程序发生了除0操作所以肯定是异常如果不异常的话会打印一个语句再终止进程。

图
可以看到打印的语句并不是DIV执行成功而是打印了异常信息告诉我们浮点数除0了。

此时程序就没有执行完毕但是异常终止了。

三、进程等待

图
进程等待主要使用上面的俩个系统调用等待成功返回被等待进程的pid值。

进程等待的作用

  • 回收子进程资源
  • 获取子进程退出信息

回收子进程资源

先来看看现象
图
子进程在执行了10秒钟之后终止了退出码是10.

图
此时子进程成了僵尸进程如上图中红色框中所示的Z+。

图
此时就是连kill -9 都无法杀死这个僵尸进程因为已经挂了的进程是无法再杀死的所以它仍然是僵尸进程。

图
在子进程终止的10秒钟后使用waitpid函数来等待子进程。

图
在子进程终止后的10秒钟内子进程的状态是僵尸状态。

图
在进程等待成功以后处于僵尸状态的子进程(pid值为26718)的进程就没有了如上图中绿色框所示此时子进程的资源也被回收了。

进程等待可以回收子进程资源。

获取退出信息

再看进程等待的系统调用

  • pid_t waitpid(pid_t pid, int* status, int options);

该函数的三个参数中第二参数status是一个输出型参数进程退出的信息就放在这个变量里。

图
这个变量是一个32为的int类型的变量在存放进程退出状态的时候只用到了16位。

  • [15:8]这8位放的是进程的退出码。
  • [6:0]这7位放的是进程异常退出时的终止信号。
    *[7]这一位放的是一个标志位以后本喵再详细介绍这一位。

图
通过移位操作获取到status的次低8位和低7位也就是进程退出码和终止信号信息并且打印出来。

图
可以看到在子进程终止以后父进程进行等待等待成功以后打印出了子进程的退出码和终止信息。

  • 我们在子进程中使用exit(10)来终止进程。

图
此时我们是使用kill -9来终止的子进程所以打印出来的终止进行就是kill -9。进程异常终止的情况下程序的退出码就没有任何意义了。

图
不仅可以使用kill -9来终止进程还可以使用其他信号来终止进程。

图
此时是使用kill -4终止的进程所以终止信号就是4.

一个进程退出以后会变成僵尸并且会把自己的退出信息写入自己的PCB中的task_struct中如下图所示的变量中。
图
wait和waitpid是系统调用所以操作系统是有资格去读取子进程的task_struct的读取到以后会放在status变量中。

阻塞和非阻塞等待

waitpid的第三个参数就是用了决定进程等待的方式的。

我们上面演示的就是阻塞式等待此时第三个参数是0。所谓阻塞等待就是父进程什么也不干就在等子进程终止终止以后父进程继续执行来回收子进程资源并且获取它的退出信息。

非阻塞等待是和阻塞等待相反的在等待子进程终止期间父进程还可以干它自己的事情此时第三个参数是WNOHANG。

  • waitpid的返回值
    -1等待失败例如给它传入一个不存在的pid值就会等待失败
    pid_t等待成功以后会返回被等待进程的pid值
    0在非阻塞等待中如果子进程没有终止那么返回0如果终止且等待成功返回子进程的pid值。

图
将等待方式设定为非阻塞等待。
图
可以看到虽然使用了进程等待但是子进程和父进在同时执行。

这里有一个问题是进行了非阻塞等待但是子进程终止了父进程也没有进行资源回收和退出信息获取啊也就是对子进程只询问了一次发现子进程没有结束父进程就干自己的事了之后也没有再询问。

这样的逻辑执行一次就被叫做非阻塞等待。

而将这部分代码放在一个while循环中就会进行多次非阻塞等待也会对子进程进行多次询问此时叫做轮询。

图
图
此时在子进程执行的过程中父进程在干自己的事之前都会等待一次子进程当子进程终止以后父进程在干完自己手头的事情发现子进程终止了然后获取到了子进程的退出信息。

非阻塞等待的好处就是不会占用父进程的所有精力父进程可以在轮询期间干自己的事情。

四、进程程序替换

首先我们要知道创建子进程的目的是什么无非就是两种目的

  • 想让子进程执行父进程代码的一部分。
  • 想让子进程执行一个全新的代码。

我们之前所写的程序子进程都是在执行父进程代码的一部分而要想让子进程执行全新的代码就需要进行进程程序替换。

图
这是程序替换用到的系统调用本喵会挨个给大家介绍。

先来看看进程程序替换是什么
图
在子进程中用ls -al来替换程序并且执行。

图
此时使用我们自己的程序同样可以实现ls -al的功能因为子进程执行的就是ls -a -l程序。因为程序替换成功了所以返回ls程序的退出码如果替换失败就会执行exit(1)。

程序替换原理

图
在子进程刚创建的时候子进程和父进程通过页表映射到物理内存中空间是同一块空间父子进程的代码段数据段堆栈等区域都同一个。

图
当子进程中执行exec*()函数的时候会发生写时拷贝将原本物理内存中的数据段和代码段拷贝一份放在新的物理内存中。

将磁盘中要替换的可执行程序覆盖到新的物理内存中并且改变子进程原本的页表映射关系。

仅程序发生了替换(数据段和代码段)子进程的PCB中的task_struct仍然不变。

而且写时拷贝不仅在数据段发生在代码段也可以发生写时拷贝的目的同样是为了保证进程的独立性。程序替换之后子进程执行的代码也不再是原本父进程中的代码而是全新的代码比如上诉例子中的ls程序。

替换函数

  1. int execl(const char* path, const char* arg,…);
  • 第一个参数是要替换程序所在路径比如/usr/bin/ls就是可执行程序ls所在的路径。
  • 除了第一个参数以外后面的参数是可变参数也就是参数的个数是可以变的。
  • 返回值如果替换失败了就返回-1替换成功了什么都不返回因为程序已经被替换了有没有返回值也没有意义。

可变参数我们在C语言中见过很多比如printf
图
这里的可变参数其实就是main函数的命令行参数中的char* argvargv[0]是程序名argv[1]及后面的是程序的选项最后要有NULL结尾。

上面演示就是使用的execl()函数本喵就不再演示了。

  1. int execlp(const char* file,const char* arg,…);
  • 第一个参数是要替换程序的程序名字操作系统会自动去环境变量PATH中寻找该程序名。
  • 后面的参数和返回值是和execl一样的。

图
使用execlp的时候只写了要替换的程序名ls后面的可变参数也只写了ls。

图

由于替换ls的时候没有加任何选项所以现实的内容如上图中的红色框所示。

  1. int execle(const char* path,const char* arg,…,char* const envp[]);
  • 第一个参数和execl一样也是要替换程序的路径。
  • 第二个开始直到倒数第一个之前都是可变参数和execl还有execlp一样。
  • 最后一个参数是环境变量。

替换的程序也可以是我们自己写的程序。

图
自己写一个获取环境变量的程序并且打印出来。

  • 自定义环境变量
    图
    原本子进程和父进程都是使用的系统的环境变量但是在子进程进行程序替换的时候使用execle将自定义的环境变量传了过去此时子进程在进行程序替换的同时将原本系统的环境变量也进行了替换替换成了自定义的环境变量。

图
可以看到子进程打印出来的环境变量只有自己定义的3个环境变量。

  • 系统环境变量

如果想将自己定义的环境变量也加到系统环境变量中呢
图
使用putenv()系统调用将自定义的环境变量增加到系统的环境变量中在给execle传参的时候第三个参数传environ。
在这图里插入图片描述
可以看到子进程打印出来的环境变量不仅有系统的还有我们自己定义的。

对于系统的环境变量即使不传参子进程也是能获取到的因为子进程会继承父进程的一切。

  1. int execv(const char* path, char* const argv[]);
  • 第一个参数同样是需要替换程序的路径和execl一样。
  • 第二个参数不再是可变参数而是将原本的可变参数放在一个指针数组中。

第二个参数的指针数组和mian命令函数中的char* argv[]一样argv[0]是程序名argv[1]等之后的是选项最后一个是NULL。
图
将ls -a -l 并且带颜色高亮的程序加选项以字符串的形式放在指针数组中传给execv()函数。
图
结果和execl()一样。

  1. int execvp(const char* file, char* const argv[]);
  • 第一个参数是需要替换的程序名同样不需要路径系统回去环境变量PATH中寻找和execlp一样。
  • 第二个参数和execv一样不再讲解。

图
图

  1. int execvpe(const char* file, char* const argv[], char* const envp[]);

参数本喵都已经讲解过了这里无非就是做了一些组合。

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

图

参数类型本喵就不在这里继续讲解了相信大家可以自己进行组合。

  • 该函数是在手册2中的其他6个程序替换函数是在手册3中的。

图
图
前面的六个程序替换函数都是execve()函数的封装。

封装了六个不同函数的目的就是方便我们用户根据不同的使用情况去调用。

替换不同类型的程序

使用上诉7个程序替换函数并不是父进程是C程序替换的程序也必须是C程序而是任何类型的程序都可以因为任何类型的程序最后都是二进制机器码。

图
在while循环中将cpppythonshell三种不同类型的可执行程序分别替换一次并且执行。

图
可以看到这三种类型的可执行程序都可以去替换。

进程程序替换可以替换任何类型的可执行程序。

五、shell的简单实现

现在我们就可以更加清楚shell的运行机制了bash是一个父进程每输入一个指令就会创建一个子进程并且进行相应的程序替换。

现在我们就来简单的模拟实现一下
图
使用fgets函数获取输入的一行字符串并且将这一行字符串进行切割比如“ls -a -l”切割为“ls”“-a”“-l”并且将切割后的字符串放入myargv指针数组中。

图
通过打印可以看到分割成功了。

图
字符串分割成功以后就要创建子进程在子进程中进行进程程序替换因为替换的程序都是指令池中的所以使用execvp()即可。

为了能够让我们自己的shell一直运行这部分逻辑全部放到while(1)循环中。

图
在我们对shell运行起来以后输入ls指令可以成功显示文件但是没有发生文件类型的高亮。

图
在将字符串中的程序名分割出来后判断一下程序名是不是ls如果是对话给它加高亮选项。

图
此时我们自己的shell中使用ls指令以后显示的文件名同样有颜色高亮。

图
但是又出现了问题更换了路径但是使用pwd后的现实的路径却没有发生改变。

内建/内置命令

首先需要明白当前目录是什么

图

  • /proc中放的当前内存中存在的进程176178是我们的shell的pid值查看myshell进程文件的情况。
  • 最重要的信息有两条
    橘黄色线cwd就是表示当前路径该路径就是当前进程的可执行程序所在的目录。
    红色线当前进程的路径。

所以说当前路径是相对于进程来说的不同进程对应的当前路径也是不同的。

我们的shell中使用cd改变了路径但是使用pwd以后现实的路径没有发生改变原因是

  • cd是子进程替换的程序所以子进程执行的时候确实改变了路径但是当它终止后它就退出了所对应的cwd(当前路径也没有了)。
  • 再使用pwd指令查看的是myshell的cwd(当前路径)而myshell的当前路径是不会发生改变的。

找到原因再找办法解决有一个系统调用chdir可以改变当前路径
图
更改成功返回0更改失败返回-1。

图
在将字符串分割完以后在创建子进程之前需要判断一下是否是cd指令如果是cd指令的话需要更改当前目录这个操作父进程就可以完成不需要创建子进程。

图
此时更换路径就不再存在问题了。

同样的echo也是一个内建命令只需要父进程就可以完成不需要创建子进程。

图
在我们对shell程序中使用echo打印退出码时结果并不是预期那样。

图
当指令echo的时候单独处理打印的内容并且不需要创建子进程父进程就可以完成。

图
出来内建指令其他子进程终止后父进程获取它们的退出信息。

图
此时echo指令就可以正常使用了。

内建/内置指令的本质就是不用创建子进程去完成任务父进程就足够了。

myshell.c的全部代码

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

#define NUM 1024
#define OPT_NUM 64

char lineCommand[NUM];
char *myargv[OPT_NUM]; //指针数组
int  lastCode = 0;
int  lastSig = 0;

int main()
{
    while(1)
    {
        // 输出提示符
        printf("[用户名@主机名 当前路径]# ");
        fflush(stdout);

        // 获取用户输入, 输入的时候输入\n
        char *s = fgets(lineCommand, sizeof(lineCommand)-1, stdin);
        assert(s != NULL);
        (void)s;
        // 清除最后一个\n , abcd\n
        lineCommand[strlen(lineCommand)-1] = 0; // ?
        //printf("test : %s\n", lineCommand);
        
        // "ls -a -l -i" -> "ls" "-a" "-l" "-i" -> 1->n
        // 字符串切割
        myargv[0] = strtok(lineCommand, " ");
        int i = 1;
        if(myargv[0] != NULL && strcmp(myargv[0], "ls") == 0)
        {
            myargv[i++] = (char*)"--color=auto";
        }

        // 如果没有子串了strtok->NULL, myargv[end] = NULL
        while(myargv[i++] = strtok(NULL, " "));

        // 如果是cd命令不需要创建子进程,让shell自己执行对应的命令本质就是执行系统接口
        // 像这种不需要让我们的子进程来执行而是让shell自己执行的命令 --- 内建/内置命令
        if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0)
        {
            if(myargv[1] != NULL) chdir(myargv[1]);
            continue;
        }
        if(myargv[0] != NULL && myargv[1] != NULL && strcmp(myargv[0], "echo") == 0)
        {
            if(strcmp(myargv[1], "$?") == 0)
            {
                printf("%d, %d\n", lastCode, lastSig);
            }
            else
            {
                printf("%s\n", myargv[1]);
            }
            continue;
        }
        // 测试是否成功, 条件编译
#ifdef DEBUG
        for(int i = 0 ; myargv[i]; i++)
        {
            printf("myargv[%d]: %s\n", i, myargv[i]);
        }
#endif
        // 内建命令 --> echo

        // 执行命令
        pid_t id = fork();
        assert(id != -1);

        if(id == 0)
        {
            execvp(myargv[0], myargv);
            exit(1);
        }
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        assert(ret > 0);
        (void)ret;
        lastCode = ((status>>8) & 0xFF);
        lastSig = (status & 0x7F);
    }
}

代码如上全部都是本喵讲解到的知识点本喵不再做详细解释。

图
可以看到我们自己实现的shell同样可以实现系统shell的部分功能比如lscdpwd等指令因为在子进程中都是使用的指令池中的程序来替换的。

总结

进程控制内容是对前面进程学习内容的一种检验很重要尤其是进程程序替换此时我们心中曾经的疑惑能够解开不少。希望对各位有所帮助。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: linux

“【Linux学习】进程控制” 的相关文章