【Linux】Linux进程控制及程序替换

服务器 0

🍎作者:阿润菜菜
📖专栏:Linux系统编程


进程控制及程序替换

  • 进程创建
    • fork的用法
    • 进程调用fork,内核做了什么
    • fork函数的返回值问题
    • fork创建失败的原因
  • 进程等待
    • 进程等待是什么?为什么要有?
    • 进程等待的方式
      • wait(等待任意的子进程,只能是阻塞状态等待)
      • 认识输出型参数status
      • waitpid(可以等待特定的子进程,可以非阻塞状态等待)
    • 父进程如何获取子进程的退出信息的?
  • 进程终止
    • 终止的三种方式
    • 进程退出码
    • 操作方式
  • 进程的程序替换
    • 为什么要有程序替换?
    • 实现程序替换
      • 单进程程序替换--- 见见猪跑
      • 程序替换原理
      • 多进程程序替换
    • 熟悉程序替换接口 --- execl函数组

进程创建

fork的用法

在linux中fork是一个很重要的函数,它可以已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
fork函数返回两个值,一个是子进程的进程号(pid),另一个是0。父进程可以通过pid来区分自己和子进程,子进程可以通过返回值0来判断自己是子进程 。一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。
下面是一个fork函数的用法示例:

#include <unistd.h>#include <stdio.h>int main() {    int pid = fork(); // 产生两个进程,一个父进程,一个子进程    if (pid == -1) return -1;    if (pid) { // 这里运行父进程代码        printf("I am father, my pid is %d/n", getpid());        return 0;    }    else { // 这里运行子进程代码        printf("I am child, my pid is %d/n", getpid());        return 0;    }}

fork的常见用法有那些?

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

进程调用fork,内核做了什么

在进程调用fork函数之后,当执行的程序代码转移到内核中的fork代码后,内核需要分配新的内存块和内核数据结构给子进程内核数据结构包括PCB、mm_struct和页表,然后构建起映射关系,同时将父进程内核数据结构中的部分内容拷贝到子进程,并且内核还会将子进程添加到系统进程列表当中,最后内核空间中的fork代码执行完毕,操作系统中也就已经创建出来了子进程,最后返回用户空间,父子进程执行程序fork之后的剩余代码
也就是说fork之前父进程独立执行,fork之后,父子进程两个执行流一起执行fork之后的剩余代码。fork之后,父子进程谁先执行,完全由调度器 决定。
参考
在这里插入图片描述
哈希表是一种数据结构,它可以快速地存储和查找数据。哈希表的原理是将数据的键值通过一个哈希函数转换为一个索引,然后将数据存储在对应索引的位置。当需要查找数据时,只需要再次计算键值的哈希值,就可以直接定位到数据的位置。

Linux系统中,每个Shell都有一个哈希表,用来记录执行过的命令及其路径。这样可以避免每次执行命令时都要在PATH路径下搜索命令的位置,提高了命令的执行效率。

当Linux系统创建一个子进程时,它会为子进程分配一个唯一的进程标识符(PID),并将子进程添加到系统进程列表中。系统进程列表实际上是一个哈希表,它以PID为键值,以指向进程控制块(PCB)的指针为数据。PCB是一个结构体,它包含了进程的各种信息和状态。通过这个哈希表,系统可以快速地根据PID找到对应的PCB,并进行相应的操作。

fork函数的返回值问题

fork函数被调用一次,但返回两次,分别在父进程和子进程中返回。这两个返回值的作用是让父进程和子进程能够区分自己的身份,并进行不同的操作。

fork函数的返回值有三种可能:

  • 在子进程中返回0,表示成功创建了子进程。
  • 在父进程中返回子进程的PID(进程标识符),表示成功创建了子进程,并告知父进程其ID。
  • 如果出错,fork函数返回-1,表示创建子进程失败。

示例代码:

 #include <stdio.h>#include <assert.h>#include <unistd.h>int g_value = 100; //全局变量int main(){    // fork在返回的时候,父子都有了,return两次,id是不是pid_t类型定义的变量呢?返回的本质,就是写入!    // 谁先返回,谁就让OS发生写时拷贝    pid_t id = fork();    assert(id >= 0);    if(id == 0)    {        //child        while(1)        {            printf("我是子进程, 我的id是: %d, 我的父进程是: %d, g_value: %d, &g_value : %p/n",/                    getpid(), getppid(), g_value, &g_value);            sleep(1);            g_value=200; // 只有子进程会进行修改        }    }    else    {        //father        while(1)        {            printf("我是父进程, 我的id是: %d, 我的父进程是: %d, g_value: %d, &g_value : %p/n",/                    getpid(), getppid(), g_value, &g_value);            sleep(1);        }    }}

问题1 :那fork返回之后,为什么给父进程返回子进程的pid,而给子进程返回0呢?
我们知道子进程只能有一个父进程。但是父进程可以有多个子进程,那么父进程找子进程是不具有唯一性的,所以就需要fork函数返回子进程的pid,通过子进程的pid来确定和找到具体的子进程。

问题2: 那么一个id变量,怎么能保存两个值,并且if和elseif语句同时执行呢?
在fork之后,父子进程谁先执行代码完全由调度器决定,所以父子进程谁先返回,那么就谁先对id变量进行赋值,后一个执行的进程又会对id变量进行写入,因为进程具有独立性,所以这个时候就会发生写时拷贝
此时id变量打印出来的地址是相同的,但是内容就会不一样了,因为分别在父子进程中各有一个id变量的值了,他们的值是不同的。

参考虚拟地址空间知识

fork创建失败的原因

一般两个原因:
1、物理内存不够了用了;创建子进程也是需要消耗物理内存的!
2、系统中创建了太多的进程。父进程创建子进程的上限到了,OS为了限制用户父进程无限制的创建子进程,通常都会给父进程设置一个"进程上限";

最好最直接的解决办法:重启你的云服务器

进程等待

进程等待是什么?为什么要有?

首先,**OS为什么要有进程等待?**回答这个问题之前,我们先要搞清楚为什么要创造子进程,那当然是需要子进程帮助我们执行特定的任务,既然子进程帮助我们执行了任务,那么我们当然要关心一下子进程的执行结果,所以这个问题的回答就是:
在这里插入图片描述

  1. 避免内存泄漏(目前一定要做的)
  2. 获取子进程执行的结果(按需)

那么什么是进程等待呢?简单说就是:
进程等待(本质) — 通过系统调用,获取子进程退出码或者退出信号的方式(获取子进程退出码以此来知晓父进程交代给子进程的任务子进程完成的怎么样),并顺便释放内存(释放掉子进程的空间)

进程等待的方式

这里我们可以使用系统调用wait()/waitpid();来实现!

wait(等待任意的子进程,只能是阻塞状态等待)

如果子进程退出,父进程不读取子进程退出的信息,那么子进程就会变为僵尸进程,从而导致内存泄露的问题。其实我们可以通过进程等待的方式来解决僵尸进程问题。通过wait函数回收僵尸进程剩余资源。

让父进程通过进程等待的方式,回收子进程剩余资源(PCB,内核栈等),获取子进程退出信息,父进程需要知道子进程的退出码和执行时间等信息,形象化的比喻就是父进程通过进程等待来给僵尸进程收尸。

pid_t wait(int*status);
Linux下包含头文件:sys/types.h和sys/wait.h
参数: 输出型参数用于获取进程退出码和信号(如果程序正常运行结束的话,信号是0);当然如果我们不关心进程的退出码和信号的话,我们可以将其设置为NULL;
返回值: 等待成功:返回等待进程的pid;等待失败(如父进程没有子进程),返回-1;
在这里插入图片描述

当父进程调用wait函数时,父进程也会自动的等待子进程运行完毕!相当于在子进程运行的这段期间,父进程相当于卡在了wait函数内部!这叫做阻塞等待!父进程会被OS放入一个等待队列中进行等待子进程的运行结束!

认识输出型参数status

输出型参数status是一个用于接收函数返回值的变量,通常用于一些进程管理或系统调用的函数中,比如wait或waitpid。这些函数会在子进程结束时返回子进程的退出状态信息,而status参数就是用来存储这些信息的。status参数是一个指针类型,但不是要传递一个指针参数,而是一个由操作系统填充的输出型参数。status参数只使用了低16位,高两个字节没有用到,**其中的前7比特位代表子进程终止信号,后8比特位代表进程退出码。**如果传递NULL,表示不关心子进程的退出状态信息。

示例代码:

  #include <stdio.h>#include <sys/types.h>#include <sys/wait.h>#include <unistd.h>#include <stdlib.h>#include <assert.h>int main(){pid_t id = fork();assert(id!=-1);if(id==0){// child processint cnt=5;while(cnt){printf("child process running,pid:%d,ppid:%d,cnt:%d/n",getpid(),getppid(),cnt--);}exit(10);}int status=0;int ret=waitpid(id,&status,0);if(ret>0){// printf("wait success,exit code:%d,signal number:%d/n",);printf("status:%d/n",status);}return 0;}

结果:
在这里插入图片描述
这里status的输出结果为2560,写成16比特位的形式就是0000 1010 0000 0000,上面说了前7比特位代表子进程终止信号,后8比特位代表进程退出码,所以1010实际上就是10,也就是僵尸进程的退出码,表示什么样的结果错误,这可以取决于我们自己,我们可以自己写个printf语句输出想输出的错误信息,然后可以看到终止信号是0 ,表示僵尸进程正常退出。

我们如何拿出到进程退出码和终止信号呢?
所以我们可以通过位运算获取退出码和退出信号
也就是----我们可以利用(status指向的整形>>8)&0xFF的方式来拿出进程退出码!(status&0x7F)的方式来拿出终止信号;

waitpid(可以等待特定的子进程,可以非阻塞状态等待)

waitpid与wait功能相似,wait是处理最先处于僵尸状态的子进程,waitpid是处理指定pid的子进程

pid_t waitpid(pid_t pid,int*status,int options);
参数: pid:指定等待的子进程;pid=-1时则处理最先处于僵尸状态的子进程;
status:输出型参数;与wait的status功能一样;
option:决定父进程在等待的过程中是否可与去做其他事情—>0:不可以;WNOHANG:可以!
返回值:
option=0:等待成功,返回子进程pid;等待失败,返回-1;
option=WNOHANG:等待成功,返回子进程pid;子进程还没运行结束,返回0;等待失败(子进程不存在),返回-1,

上文了解,当子进程还没有死的时候,也就是没有退出的时候,父进程调用的wait或waitpit需要等待子进程退出,系统调用接口也不返回,这段时间父进程什么都没做,就一直等待子进程退出,这样的等待方式,称之为阻塞式等待。

那有没有非阻塞等待呢?
非阻塞式等待就是,不停的检测子进程状态,每一次检测之后,系统调用立即返回,在waitpid中的第三个参数设置为WNOHANG,即为父进程非阻塞式等待。
如果等待的子进程状态没有发生变化,则waitpid会返回0值。多次非阻塞等待子进程,直到子进程退出,这样的等待方式又称之为轮询。如果等待的进程不是当前父进程的子进程,则waitpid会调用失败。

非阻塞等待有一个好处就是,不会像阻塞式等待一样,父进程什么都做不了,而是在轮询期间,父进程还可以做其他的事情

父进程如何获取子进程的退出信息的?

先来回答父进程在使用wait()/waitpid()的期间在做什么?
上面说了,父进程在wait的时候,如果子进程没有退出,父进程只能一直调用waitpid进行等待,这里的阻塞等待,不是在运行队列等待,而是在阻塞队列等待。
那么进程等待什么,我们说是等待子进程的退出码和退出信号,怎么获取呢?我们可以手动获取或者使用宏获取 ----> 宏WIFEXITED和WEXITSTATUS

宏WIFEXITED(status):若子进程是正常终止,则返回结果为真,用于查看进程是否正常退出。
WEXITSTATUS(status):若进程正常终止,也就是进程终止信号为0,这时候会返回子进程的退出码。

if(ret>0){// 是否正常退出if(WIFEXITED(status)){// 判断子进程退出码是什么printf("child process exit normally,exit code:%d/n",WEXITSTATUS(status));}else{printf("child process don't exit normally/n");}// printf("wait success,exit code:%d,signal number:%d/n",(status>>8)&0xFF,status & 0x7F);}

测试结果:
在这里插入图片描述
那父进程是怎么实现的获取信息? ---- 读取子进程的内核数据结构
在子进程的pcb结构体中,有两个变量:
int exit_code;//用于记录当前进程的退出码;
int exit_signal//用于记录当前进程的终止信号;
当子进程进入僵尸状态,也就是子进程被终止掉了!OS会根据子进程终止时的退出码和终止信号来填充exit_code和exit_signal;当父进程使用wait()/waitpid()系统调用的时候,OS就会将子进程pcb里面的
exit_code和exit_signal存储于status指针指向的int空间中,而status所指向的空间是属于父进程的,父进程也就自然而然的拿到了子进程的退出码和终止信号!
在这里插入图片描述

进程终止

终止的三种方式

1、程序正常运行,最后结果正确;
2、程序正常运行,最后结果错误;
3、程序异常终止!(比如出现除以0、对空指针解引用、kill -9 命令杀掉进程等等情况)

程序崩溃(进程异常)终止的本质是 — 进程因为某些原因,导致进程收到了来着操作系统的信号(如kill -9)

那程序正常运行结束是会有退出码的?怎么查看呢

进程退出码

首先搞清楚进程退出码存在的前提就是程序正常运行完毕!在其他情况下,进程退出码没有意义!

什么是进程退出码呢?

我们知道一个程序是从main函数开始的,在写main函数的时候最会会写一个return 0;但有小伙伴会发现其实不写这个return 0 程序也能跑啊,那这个return 0是什么呢?为什么要写呢?
其实这个0就是进程退出码;这个值会返回给OS,来表明进程的的退出结果是什么样的,比如在系统内部0就是代表正常退出,非0就是代表错误退出,而非0有很多数字,具体的数字有标识为不同的错误信息。
return 0,表示该进程正常运行,且运行结果正确;
return 其他数据,表示该进程正常运行,但是运行结果错误,那么到底是为什么错误呢?作为父进程需要知道原因,我们此时return 的数字就表示这个错误原因,这个数字也是进程退出码!
而且系统内部不同的退出码都必须有相应的文字描述,来确定进程的具体退出原因,这个文字描述我们是可以自己定义的,当然也可以用系统默认的映射关系输出错误码的描述!

默认的C库错误信息表:
在这里插入图片描述

如何查看最近的退出码?
$? : 只会保留最近一次进程的退出码
我们使用 echo $?即可查看

操作方式

上面提到了main函数的return,那其它函数return呢?这其实仅仅代表该函数内部返回–>进程执行,本质其实是main执行流执行!

如果我们在main函数以外的地方,遇到了不合理的地方想要提前终止进程(比如利用malloc开辟空间失败,我们就可以提前终止进程),我们就可以利用exit()/_exit()函数,来提前终止掉该进程!exit()/_exit()//的参数就相当于main函数的返回值 ----> main return XXX!0:表示正常运行结束,结果运行正确;其他数字表示正常运行结束,结果运行错误!

我们看到exit()/与_exit()看起来长得很像,那么他们有区别吗?

其实exit()是C语言库函数里面的函数接口,而_exit()不属于C语言,_exit()是系统调用;exit()内部也是通过调用_exit()系统调用来终止进程的,只不过exit()内部更加丰富,在结束进程之前,会做一些工作,比如:执行用户定义的清理函数、刷新缓冲区、关闭流等;但_exit()没有这么多前戏,就是纯粹的终止进程!它不会刷新缓存区,因此我们在调用_exit()结束进程的时候,没有刷新缓冲区,直接就结束掉进程了,如果我们要打印字符串就会一直停留在缓冲区,没有机会输出到屏幕上!-
也就是exit终止进程,会主动刷新缓冲区。_exit终止进程,不会刷新缓冲区。

  1 #include <stdio.h>  2 #include <unistd.h>  3 #include <stdlib.h>  4 int main()  5 {  6   7     printf("hello Linux/n");  8       9     exit(111); 10      11   while(1) sleep(1); 12 }

结果:
在这里插入图片描述
如果缓冲区在操作系统里面,那么exit和_exit都会刷新缓冲区,因为这两个接口终止进程的工作最终都是要依靠操作系统来终止的,所以操作系统更加的底层,缓冲区如果在OS的话,这两个接口都应该刷新缓冲区,但是我们看到的现象并不是这样的,所以就说明缓冲区不在OS,他其实是用户级的缓冲区 --C库

所以如何理解进程退出?
其实就是OS内少了一个进程,OS要释放进程对应的内核数据结构+代码和数据(如果有独立的)

进程的程序替换

为什么要有程序替换?

我们先来回答父进程创建子进程的目的是什么?

创建子进程一般有两个目的:

1.让子进程执行父进程代码的一部分,也就是执行父进程对应的磁盘上的代码和数据的一部分。
2.让子进程加载磁盘上指定的程序到内存中,使其执行新的程序的代码和数据,这就是进程的程序替换

那么如何实现呢?

实现程序替换

Linux给我们提供了7个接口来帮住我们实现:

#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[]);int execvpe(const char *file, char *const argv[], char *const envp[]);int execve(const char *path, char *const argv[], char *const envp[]);

这些函数叫做exec函数组,我们通过调用这7个函数中的任意一个就可以完成程序替换。每个函数都有自己的特性:

单进程程序替换— 见见猪跑

int execl(const char* path,const char* arg,...);  一个程序替换函数

首先回答这个小知识— 参数列表里的(…) 是什么?
在这里插入图片描述
其实函数参数中的…是可变参数列表,可以给C语言函数传递不同个数的参数

替换函数怎么用呢?我们要执行一个程序,首先就是找到这个程序,然后在执行这个程序,执行程序的时候,也拥有不同的执行方式,通过执行选项的不同便可以使得程序以多种不同的方式执行(可变参数作用)
execl函数第一个参数path,表示我们要替换的程序在哪里,第二个参数及后面arg表示我们想用怎么样的方式运行我们的程序
注意:结尾必须以NULL结尾,表示参数输入完了

#include <stdio.h>#include <unistd.h>int main(){// .c --> .exe --> load into memory --> process --> runningprintf("The process is running.../n");execl("/usr/bin/ls","ls","-a","-l","--color=auto",NULL);// 传参以NULL结尾,来表示传参结束printf("The process finishes running.../n");return 0;}

结果:
在这里插入图片描述
我们可以看到有一个现象就是第三句printf代码没有被执行,那它为什么没有被执行?

程序替换原理

首先根据上面我们写的代码,我们自己写的代码(比如:打印runing的语句)在运行起来的时候本身就已经是一个进程,那么这时候该进程就已经有了自己的内核数据结构了,比如:pcb、进程地址空间、页表等!我们知道我们这些进程的父进程都是bash,就是说一个进程是可以调用其他进程的
现在当我们的进程运行到ececl语句的时候,会发生程序替换:一个进程去运行另一个新的程序

在这里插入图片描述当我们自己写的程序运行到execl()语句时,就会根据ececl()的path(地址)参数,将ls命令从磁盘加载进物理内存!加载到物理内存的那个地方呢?加载到我们自己写的程序对应的物理内存上的位置!
也就是说用ls命令的数据和代码,替换原来老的程序的代码和数据,物理空间还是原来的物理空间,页表的映射关系也基本不变,如果ls命令数据和代码太多了,os会在页表增加一些映射关系!然后该进程开始重新运行一段新的程序!
所以将磁盘中指定程序的代码和数据直接覆盖掉物理内存中原来正在运行的进程的代码和数据,以达到程序替换的效果,这就是程序替换的本质。

在进程替换的时候是没有创建新进程的,而是在原有进程基础上,将指定程序的代码和数据覆盖到原来的代码和数据里。

当父进程派生的子进程发生程序替换时,防止父子进程原先共享的代码段和数据段被修改,操作系统会进行写时拷贝,将代码段和数据段重新复制一份给子进程,让子进程程序替换之后,不会影响父进程。这就是进程之间的独立性。
通过之前学习我们知道虚拟地址空间和页表可以保证进程之间的独立性,一旦有执行流要改变代码或数据,就会发生写时拷贝
所以不是只有数据可能发生写入,代码也是有可能发生写入的,这两种情况都会发生写时拷贝。

进程替换的特点:

  • 程序替换是整体替换,不是局部替换
  • 替换进程只会影响调用进程,进程具有独立性(写时拷贝)
    因为进程具有独立性,那么我们可不可以让子进程去做程序替换,来帮住我们去做特定的任务?
    所以我们可以来看看多进程程序替换怎么实现的:

多进程程序替换

我们可以让子进程去执行一段与父进程完全不一样的代码:

#include <stdio.h>#include <unistd.h>#include <stdlib.h>#include <assert.h>#include <sys/types.h>#include <sys/wait.h>int main()  //HUE{printf("The process is running.../n");pid_t id = fork();assert(id!=-1);if(id==0){//child processsleep(1);execl("/usr/bin/ls","ls","-a","-l",NULL);exit(1);// 如果调用失败,直接让子进程退出}int status = 0;pid_t ret=waitpid(id,&status,0);if(ret == id){printf("wait success, exit code:%d , signal number:%d/n",(status>>8)&0xFF,status&0x7F);}}

在这里插入图片描述
这就是最简单的多进程程序替换,通过上述那么我们可以解释一下shell的运行原理:首先shell从命令行接受到我们的命令后会创建一个子进程来执行我们的命令,然后在让该子进程调用execl函数来进程程序替换,替换掉子进程从Shell哪里继承下来的代码和数据!然后让子进程开始运行这段程序!

熟悉程序替换接口 — 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[]);int execvpe(const char *file, char *const argv[], char *const envp[]);int execve(const char *path, char *const argv[], char *const envp[]);

execl为例:
int execl(const char *path, const char *arg, …); 各参数代表:
1. —你想执行谁?需要带路径— path
2. 加载执行它 — arg
3. 怎么执行— …
4. ----最后必须以NULL结束执行

int execlp(const char *file, const char *arg, …); : 第一个参数不再是传递路径,而是只需要指定程序名即可,系统会自动在环境变量PATH中进行查找。

int execv(const char *path, char *const argv[]); : 第二个参数改为以数组的方式传参—怎么执行
在这里插入图片描述
int execvp(const char *file, char *const argv[]); :就是前面两个函数的集成,指定程序传参–执行谁?和vector数组传递怎么执行

int execve(const char *path, char *const argv[], char *const envp[]); :这个函数比前面多了一个指针数组的参数,env指针数组就是自定义环境变量,也就意味着,程序替换的时候,不用系统环境变量,用自己定义的环境变量。

注意:这里的自定义环境变量是覆盖式传入的,也就是说当你传入自定义环境变量时,原来子进程从父进程继承的环境变量就会被覆盖,那这引出了一个问题:我们知道环境变量具有全局属性,可以被子进程继承下去,那这是为啥?其实int execve(const char *path, char *const argv[], char *const envp[]); 这个函数时真正的系统调用,我们知道main函数也是有参数的,它也需要被调用,它的参数其实就是来自于execle的。在bash创建子进程的时候就会调用将环境变量继承给子进程!而上面的六个函数都是对这个int execve() 系统调用的封装!
在这里插入图片描述
那怎么实现传入环境变量时不覆盖原来的环境变量呢?
我们可以利用putenv函数将指定的自定义环境变量导入到环境变量表里面,然后将environ作为参数传给替换程序,这样替换程序就可以获得自己定义的和系统默认的环境变量
在这里插入图片描述
其他的两个execl函数依此类推就可以知晓其意义。

真正执行程序替换的其实只有execve这一个系统调用接口,其他的6个都是在execve的基础上封装得来的。只有execve在man2号手册,其他都在3号手册。


最后我们可以利用上述所学知识,实现一个自己的简易版本Myshell玩一玩!💨💨💨

也许您对下面的内容还感兴趣: