深入了解linux系统—— 进程控制

进程创建

fork函数

Linux操作系统中,我们可以通过fork函数来创建一个子进程;

这是一个系统调用,创建子进程成功时,返回0给子进程,返回子进程的pid给父进程;创建子进程失败则返回-1给父进程。

我们就可以通过返回值来对父子进程进行分流:

c 复制代码
#include <stdio.h>
#include <unistd.h>
int main()
{
    int id = fork();
    if(id < 0){
        //创建子进程失败
        perror("fork");
        return 1;
    }
    else if(id == 0){
        //子进程
        printf("子进程, pid : %d\n",getpid());
    }
    else{
        //父进程
        printf("父进程, pid : %d\n",getpid());
    }
	return 0;
}

使用fork函数创建子进程,这里简单复习一下就OK了;

我们来看通过fork创建子进程,操作系统内核做了什么:

  1. 首先就是分配新的内存块和内核数据结构给子进程
  2. 然后就是将父进程部分数据结构内容拷贝给子进程
  3. 将子进程添加到系统进程列表当中
  4. fork返回,调度器开始调度

这里fork之前我们的父进程独立执行,fork创建子进程之后,父子进程两个执行流就分别执行。

写时拷贝

我们知道,当创建子进程之后,父子进程的代码和数据是共享的,而当有一个进程(父进程/子进程)要进行数据修改时,我们的操作系统就会进行写时拷贝,重新开辟一块空间给这个进程,然后修改它的页表(虚拟地址和物理地址)的对应关系;这样就完成了写时拷贝。

那操作系统是如何知道一段代码和数据现在是共享的呢?

这个问题就比较简单了,在我们的页表中不仅存放了虚拟地址和物理地址的对应关系,还存在对这一块内存的权限(rw等)

当我们通过fork创建子进程之后,我们的父子进程共享代码和数据,操作系统就会将我们父子进程对代码和数据的权限修改为只读;

当我们应该进程想要修改数据时,操作系统通过页表发现进程对数据的权限为只读,操作系统就会报错,然后给我们进程重新开辟一块空间,然后将数据拷贝过来,再修改我们进程页表的地址映射关系;

而写时拷贝之后,操作系统就会将我们父子进程对代码和数据的权限修改为可读可写。这样我们进程之间就不会相互影响了。

写时拷贝:是一种延时申请技术,可以提高整机内存的使用率

fork函数的常规使用

我们之前使用fork来创建子进程,然后让父子进程分流执行不同的代码,这是fork常规使用的一种,也就是:父子进程执行不同的代码段。

除此之外呢,我们还可以通过fork创建进程,然后通过调用exec系列函数,让子进程执行不同的程序。

  • 创建子进程,让子进程执行不同的代码段
  • 创建子进程,让子进程执行不同的程序

fork失败的原因

我们知道fork创建子进程成功,返回0给子进程,返回子进程的pid给父进程;而创建子进程失败则返回-1给父进程。

fork为什么会失败呢?

简单来说就是:

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

进程终止

进程退出

在我们执行一个程序时,这个程序可能代码运行完了,且结果符合我们的预期;也可能代码运行完了,但是结果不符合我们的预期;当然也可能我们的程序代码根本就没有运行完,而是中途退出了。

所以进程退出,也就存在三种可能:

  • 代码运行完,结果正确;
  • 代码运行完,结果不正确;
  • 代码异常终止;

进程常见退出方法

那我们知道了进程退出有三种可能;那我们进程如何退出呢?

  1. main函数返回
  2. exit函数
  3. _exit

当然我们使用Ctrl + C杀死一个进程属于进程的异常退出;通过信号让进程终止也属于异常退出。

exit函数

通过查看手册,可以发现exit函数是3号手册,也就是C标准库的库函数;

那它的作用就是,退出一个进程,然后并返回退出码;像我们之前在C中执行的exit(1)就是退出程序并返回1

_exit系统调用

通过查看手册,我们发现_exit位于2号手册,也就是系统调用;

它的作用也是退出进程,并且返回退出码。

exit_exit的区别

那这两个函数都是退出一个进程,那有什么不同之处呢?

首先,_exit是操作系统提供的进程退出的函数,而exit是库函数,exit可以说是对_exit的封装,在进程退出之前做了一些其他的事情。

这里直白一点,直接说了:exit底层也会调用_exit函数,但是在调用_exit函数之前,还做了:

  • 执行用户通过atexit/on_exit定义的清理函数;
  • 关闭所有打开的流,所有缓存数据均被写入。(简单理解就是刷新缓冲区)
  • 然后再调用_exit

这里我们不懂atexit/on_exit这些,但是我们好像知道缓冲区 ,现在来看下面代码,通过exit_exit退出的区别:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
int main()
{
	printf("hello linux");
	exit(1);
	//_exit(1);
	return 0;
}

我们知道,在输出时,缓冲区是按行刷新的,这里我们输出hello linux没有换行,数据就在缓冲区当中;

return退出和exit退出的区别

我们知道在main函数中,return也是退出进程,exit也可以退出进程,那它们有什么区别呢?

main函数中,没有什么区别,都是进程退出;

在其他函数中,return是退出当前函数,而exit是直接退出进程。

退出码

退出码,它可以告诉我们最后一次执行的命令(程序)的状态;

我们可以使用echo $?来查看最近一次程序退出的退出码:

c 复制代码
#include <stdio.h>
int main()
{
	return 1;
}

当程序退出时,退出码为0通常表示程序执行成功,没有问题;

如果退出码不是0就认为程序运行不成功。

这里也要记住一个点:当程序异常终止时,程序的退出码是没有意义的。

进程等待

为什么要等待

  1. 在进程状态中,我们了解到了僵尸进程 ,但是我们只是知道子进程比父进程先退出,会造成子进程僵尸状态,但是我们并不知道如何去解决僵尸进程;(这里我们需要让父进程等待,去解决僵尸进程问题,且获取子进程退出时的退出信息
  2. 此外,当一个进程进入僵尸状态,我们是无法杀死一个僵尸进程的;因为这个进程已经退出了,只不过保留了task_struct等待父进程获取退出信息。
  3. 创建子进程是让子进程去执行父进程派给子进程的任务,在子进程退出后,无论是正常退出还是异常退出,都要让父进程知道子进程的运行结果。

所以我们要让父进程通过进程等待,来回收子进程的资源,获取子进程的退出信息。

如何等待

那我们如何让父进程等待呢?

通过系统调用waitwaitpid

wait方法

wait只有一个参数,这个status是一个输出型参数,简单来说就是:我们想要让父进程获得子进程的退出信息,就传递一个int* 指针,这样在函数调用结束后,父进程就拿到了子进程的退出信息;(如果不需要获取子进程退出信息,可以传NULL)。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
	int id = fork();
	if(id < 0)
		return 1;
	else if(id == 0){
		//子进程
		int cnt = 3;
		while(cnt--){
			printf("子进程, pid : %d, ppid : %d\n",getpid(),getppid());
			sleep(1);
		}
		exit(1);
	}
	//父进程
	sleep(5);
	wait(NULL);
    while(1){ }
	return 0;
}

看上述代码,我们让子进程循环进行三次,每次打印一句话,然后sleep1秒钟;循环运行结束直接退出;

父进程先seep5秒钟,然后再进程等待wait这里我们先不获取子进程退出信息,传NULL)。

我们预期的结果是:子进程运行三秒,然后子进程退出,而我们的父进程先睡眠5秒,所以子进程有两秒是处于僵尸状态的;在父进程睡眠结束时,进入等待,然后子进程结束僵尸状态。

这里为了方便我们查看,父进程等到结束之后,我们让父进程进入死循环。

这里对于返回值,如果wait执行成功则返回子进程的pid给父进程,失败则返回-1给父进程。

waitpid方法

waitpid进程等待的方法,与wait不同的是:waitpid多了两个参数:pidoption

首先是pid

这里我们主要就看-1<0的部分:

-1:当我们传-1时,就表示我们当前父进程要等待任意个子进程结束。

>0:我们父进程等待某一个子进程时,我们可以传子进程的pid,让父进程等待某一个子进程

现在来看waitpid的第三个参数:options

options参数默认为0,它表示阻塞等待;

什么意思呢?简答来说,就是父进程在调用waitpid时,如果要等待在子进程还没有结束,那父进程就在waitpid中阻塞掉,直到子进程退出,waitpid再返回。

而我们也可以传WNOHANG,当我们传WNOHANG时,如果要等待的子进程还没有结束,waitpid就直接0,我们的父进程不会在waitpid中阻塞掉;如果子进程结束,那就返回子进程的pid

这里举个例子:

最近周末,你要和女朋友出去旅游,现在要出发了,你已经准备好了,你来到了女朋友的宿舍楼下,要等你女朋友下楼;

你给她打了电话过去,询问她好了没有,她说没有,然后你就挂了;拿起手机玩起来了游戏了;玩了一局游戏,你又给你女朋友打了过去,然后她说还没有,然后你又挂了,又开了一局游戏。打完又给你女朋友打去了电话询问她好没好,她是快了马上,说让你不用挂电话,她准备好了给你说。

然后你没有挂电话,就拿着手机等待你女朋友给你说她准备好了。

这里你打一次电话询问你女朋友好了没有,没有然后你就挂了电话;这本质上不就是一次非阻塞等待吗。(你女朋友没有好,然后你就挂了电话)

而当你打电话询问你女朋友好了没有,知道了没有你并没有挂断电话,而是拿着手机等待你女朋友准备好。这不就是一次阻塞等待吗。

而体现到我们父进程等待上面就是,父进程调用waitpid,如果子进程没有结束,waitpid没有返回,而是等待子进程退出后再返回,再次期间父进程就阻塞在了waitpid中;这不就是阻塞等待吗。

而父进程调用waitpid,如果子进程没有结束,函数直接返回0,父进程可以去做自己的事情,这不就是非阻塞等待吗。

阻塞等待这里就不演示了,现在看一下非阻塞等待:

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
    int id = fork();
    if(id == 0){
        //子进程
        int cnt = 3;
        while(cnt--){
            printf("子进程, pid : %d, ppid : %d\n",getpid(),getppid());
            sleep(1);
        }
        exit(1);
    }
    //父进程
    printf("wait begin\n");
    while(waitpid(-1,NULL,WNOHANG) == 0)
    {
        printf("父进程: pid : %d\n",getpid());
        sleep(1);
    }
    printf("wait end\n");
    sleep(10);    
    return 0;
}

一般情况下,我们使用非阻塞调用要轮循调用waitpid,再等待过程中,父进程可以执行自己的代码(比如要完成一个或多个任务)。

获取子进程的status

在上述中,我们进程等待解决了僵尸进程的问题;但是我们进程等待不止可以解决子进程的僵尸问题,还可以让父进程获取子进程的退出信息。

在上述中,我们没有获取子进程的退出信息,所以传的参数是NULL,现在我们来获取一下子进程的退出信息。

我们先来看以下代码:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	int id = fork();
	if(id < 0)
		return 1;
	else if(id == 0){
		//子进程
		printf("子进程, pid : %d, ppid : %d\n",getpid(),getppid());
		exit(1);
	}
	//父进程
    int status = 0;
    wait(&status);
    //阻塞等待
    //waitpid(-1,&status,0);
    printf("child status : %d\n",status);
	return 0;
}

我们发现,我们子进程的退出码是1,但是我们拿到的status256;这是为何?

这是因为,虽然status是类型是int;但是我们不能将它当做一个整形来看待,而是当做一个位图;

int类型4字节,也就是32bit位;我们要将这32bit位划分成以下三部分:

其中,高16位没有使用,我们不考虑

而使用到的低16位中的高8位表示进程退出时的退出码;低8位表示进程的退出信号

退出码:

先来看退出码的区域:

退出信号:

现在来看退出信号部分,在退出信号部分中,还存在着一个标识位core dump

这里注意:如果进程是异常退出(被信号杀死),那它的退出码就没有任何意义了。

就好比考试作弊被发现了,考试成绩就没有意义了。

进程异常退出,程序都没有执行完,那退出码就没有什么意义了。

我们了解了status,那我们如果想要通过status获得进程的退出码或者退出信号呢?

这里就要涉及到位操作了;

在获取退出码和退出信号之前,我们要先判断一下进程是否是被信号杀死的。

我们只需判断status按位与上ox7F0111 1111),判断退出信号是否为0即可(因为没有0号信号)。

首先获取退出码:

我们只需让status>>8然后再按位与&0xFF1111 1111)即可获得退出码。

获取退出信号:

在上述中其实已经描述了,status按位与&0x7F0111 1111)即可获得进程退出信号。

如果进程退出信号为0那就表示进程是正常退出的,因为不存在0号信号。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	int id = fork();
	if(id < 0)
		return 1;
	else if(id == 0){
		//子进程
		printf("子进程, pid : %d, ppid : %d\n",getpid(),getppid());
		exit(1);
	}
	//父进程
    int status = 0;
    wait(&status);
    printf("child status : %d\n",status);
    printf("status exit code : %d\n",(status)&0xFF);
    printf("status exit signal : %d\n",status&0x7F);
	return 0;
}

可以看到,这样我们的确获得了进程的退出码和退出信息。

当然在操作系统中还存在一些宏,我们可以直接使用这些宏来获取退出码和退出信息:

退出码:

  • WIFEXITED(status):判断程序是否正常退出;

    返回true就表示程序正常退出;

  • WEXITSTATUE(status):当进程正常退出时,可以通过WEXITSTATUE获取进程的退出码。

退出信号:

  • WTERMSIG(status):当进程异常退出时,可以使用WTERMSIG获取当前进程的退出信号。
c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	int id = fork();
	if(id < 0)
		return 1;
	else if(id == 0){
		//子进程
		printf("子进程, pid : %d, ppid : %d\n",getpid(),getppid());
		exit(1);
	}
	//父进程
    int status = 0;
    pid_t rid = wait(&status);
    if(WIFEXITED(status)){
    	//进程正常退出
    	printf("wait succes,rid : %d, exit code : %d\n",rid,WEXITSTATUS(status));
    }
    else{                                                                                                                	//进程异常退出
    	printf("status exit signal : %d\n",WTERMSIG(status));
    }    
	return 0;
}

进程切换

在我们之前创建子进程时,我们创建的子进程执行的代码,都是父进程代码的一部分;

如果我们想要让我们子进程执行不同的代码,执行新的程序呢?

此时,我们就要一个进程切换,让子进程执行新的程序;

先来看一段进程切换的简单代码:

c 复制代码
#include <stdio.h>
#include <unistd.h>
int main()
{
	printf("进程切换 begin\n");
	execl("/usr/bin/ls","ls","-a","-l",NULL);
	printf("进程切换 end\n");
	return 0;
}

原理

程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据)然后让进程执行全新的程序。

听起来进程替换好高级,但是它是如何实现的呢?

当我们的一个进程想要进行进程切换时,原理非常简单,就是将磁盘中全新的程序(代码和数据)覆盖式的加载到当前程序代码和数据的位置,然后修改页表即可。

而我们如果是子进程想要进行进程切换

我们知道当我们父进程通过fork创建子进程时,操作系统会给子进程分配新的内存块和内核数据结构给子进程,然后将父进程的部分数据结构内容拷贝到子进程中,其中就包含进程地址空间,以及页表;这样我们父子进程就指向同一个代码和数据;

那我们现在要进行进程替换,也就是修改子进程指向的代码;

此时操作系统就会报错,发生写时拷贝,给子进程重新开辟一块空间,然后将新的程序的(代码和数据)加载到这块空间内,然后让子进程指向这一块新的空间,并修改页表;

这样就完成了子进程的程序替换。

替换函数

理解了进程切换的原理,我们现在来看进程切换exec系列函数。

exec系列函数一共有6个,记起来非常麻烦;但是这些命名都是有规律的。

注意:exec系列函数如果替换成功是没有返回值的,因为替换成功之后,我们原来代码后面的部分就不会被执行了。

如果替换失败,则返回-1

命名规律

  1. l(list):表示参数使用列表的形式(可变参数列表)
  2. v(vector):表示参数使用数组的形式
  3. p(path):表示可以自动去PATH环境变量的路径中找对应指令(简单来说就去执行系统命令不需要带路径)。
  4. e(env):表示环境变量表;

现在来依次看一下这些函数的简单使用:

execl

execl函数参数存在两个

  1. 一个表示要执行新的程序所在的路径
  2. 另一个则是参数列表,表示要怎们执行这个新的程序

execl函数命名只有l表示以参数列表的形式调用,执行系统指令时也要带上路径。

注意:参数列表要以NULL结尾;(命令行参数表以NULL结尾)

执行系统命令

c 复制代码
#include <stdio.h>
#include <unistd.h>
int main()
{
	printf("replace begin\n");
	execl("/usr/bin/ls","ls","-a","-l",NULL);
	printf("replaxe end\n");
	return 0;
}

执行自己写的程序(这里我们自己写一个程序,输出一下命令行参数)

c 复制代码
//test.c
#include <stdio.h>
int main(int argc, char* argv[])
{
    for(int i = 0;i < argc;i++)
    {
        printf("argv[%d] : %s\n", i, argv[i]);
    }
	return 0;
}
c 复制代码
//code.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types>
#include <sys/wait.h>
int main()
{
	printf("replace begin\n");
	execl("./test","./test","-a","-b","-c",NULL);
	printf("replaxe end\n");
	return 0;
}

execlp

这个函数就非常简单了,和execl相比唯一的不同就是,执行系统指令时不需要带路径(会通过PATH环境变量去寻找)

c 复制代码
#include <stdio.h>
#include <unistd.h>
int main()
{
	execl("ps","ps","-a","-l",NULL);
	return 0;
}

当然使用execlp也可以执行自己写的程序,不过要带上路径。

execle

这个函数就存在三个参数了:

  1. 新的程序所在的路径(系统指令也要带路径)
  2. 参数列表,以NULL结尾
  3. 环境变量表。

我们知道全局变量environ它执行环境变量表,所以我们如果不使用我们自己的环境变量表,传environ即可。

这里我们让test输出一下环境变量表

c 复制代码
//test.c
#include <stdio.h>
int main(int argc, char* argv[], char* env[])
{
	for(int i = 0; env[i];i++)
	{
		printf("env[%d] : %s\n",i,env[i]);
	}
	return 0;
}
c 复制代码
#include <stdio.h>
#include <unistd.h>
int main()
{
	extern char** environ;
	execle("./test","./test",NULL,environ);
	return 0;
}

execv

execv第一个参数和execl相同,这里就不描述了;

看第二个参数:char* const argv[],简单来说就是指针数组;也就是命令行参数表;

(注意:这里的命令行参数列表要以NULL结尾)

c 复制代码
#include <stdio.h>
#include <unistd.h>
int main()
{
	char* const argv[] = {
		(char* const)"ls",
		(char* const)"-a",
		(char* const)"-l",
         NULL
	};
	execv("/usr/bin/ls",argv);
	return 0;
}

execvp

execvpexecv的区别就是,在执行系统命令时,我们可以不带路径;execvp会在环境变量PATH中找指定程序。

c 复制代码
#include <stdio.h>
#include <unistd.h>
int main()
{
	char* const argv[] = {
		(char* const)"ls",
		(char* const)"-a",
		(char* const)"-l",
         NULL
	};
	execpv("ls",argv);
	return 0;
}

execvpe

execvpe存在三个参数,fileargvenv

file:第一个参数,和上面一样,指的是程序所在的路径。

argv:第二个参数指的是命令行参数表。

env:第三个参数指的是环境变量表。

这里我们还是使用test来测试,让test输出命令行参数表和环境变量表;

c 复制代码
//test.c
#include <stdio.h>
int main(int argc, char* argv[], char* env[])
{
    for(int i = 0;i < argc;i++)
    {
        printf("argv[%d] : %s\n", i, argv[i]);
    }
    printf("\n");
	for(int i = 0; env[i];i++)
	{
		printf("env[%d] : %s\n",i,env[i]);
	}
	return 0;
}
c 复制代码
//code.c
#include <stdio.h>
#include <unistd.h>
int main()
{
	char* const argv[] = {
        (char* const)"./test",
        (char* const)"-a",
        (char* const)"-b",
        (char* const)"-c",
        NULL
	};
    //使用自己的环境变量表
    char* const env[] = {
        (char* const)"I=LXB",
        (char* const)"MYVAL=666",
        NULL
    };
    execvpe("./test",argv,env);
    //extern char** environ;
    //execvpe("ls",argv,environ);
	return 0;
}

这里也可以执行系统指令,不需要带路径

这里简单总结一下这些函数:

函数名 路径 参数格式 环境变量表
execl 需要带路径 列表 使用当前环境变量表
execlp 系统命令不需要带路径 列表 使用当前环境变量表
execle 需要带路径 列表 可以使用自己的环境变量表
execv 需要带路径 数组 使用当前环境变量表
execvp 系统命令不需要带路径 数组 使用当前环境变量表
execvpe 系统命令不需要带路径 数组 可以使用自己的环境变量表

execve函数

与上面的exec系列的函数不同,execve是一个系统调用

这里简单来说上面的exec系列是库函数,而execve是操作系统提供的系统调用;

exec系列函数对系统调用做了封装;

那也就是说,我们使用execlpexecvp执行系统命令不带路径时,最后也会调用execve时也会带上路径吗?

我们在使用execlexecv等,不传递环境变量表时,最后调用execve也会传递当前环境变量表吗?

我我们在使用execl系列时,传递的参数列表,也会被转化为参数数组,然后传递给execve吗?

对的,当我们使用execlp执行系统命令不带路径时,execl会根据环境变量PATH找到对应程序的路径,然后调用execve传程序的路径。

当我们使用execlexecv等没有传环境变量表时,exec系列在调用execve系统调用时会传当前环境变量表environ

当我们使用execl系列,传递的参数列表,都会被转化成参数数组,然后再将参数值数组传递给execve

到这里,本篇文章内容就结束了,干货满满!!!

简单总结:

  • 进程创建fork

  • 进程退出,退出时的退出码

  • 进程等待

    解决僵尸进程的问题;

    获取子进程退出时的退出信息

    退出信息status

  • 进程切换:原理

    exec系列函数

感谢各位的支持!

相关推荐
熬夜学编程的小王37 分钟前
【Linux篇】多线程编程中的互斥与同步:深入理解锁与条件变量的应用
linux·条件变量·线程同步·线程互斥
芯辰则吉--模拟芯片1 小时前
模拟Sch LVS Sch 方法
服务器·数据库·lvs
音视频牛哥2 小时前
把Android设备变成“国标摄像头”:GB28181移动终端实战接入指南
android·音视频·大牛直播sdk·gb28181安卓端·gb28181对接·gb28181平台对接·gb28181监控
tangweiguo030519872 小时前
Jetpack Compose 响应式布局实战:BoxWithConstraints 完全指南
android
難釋懷2 小时前
Android开发-视图基础
android
Chat_zhanggong3453 小时前
AI训练服务器概述
运维·服务器·人工智能
伊织code3 小时前
AWS MCP Servers
服务器·python·ai·云计算·aws·mcp
cnbestec3 小时前
从人体姿态到机械臂轨迹:基于深度学习的Kinova远程操控系统架构解析
服务器·人工智能·机器人
QX_hao4 小时前
【firewall-cmd】--的作用以及使用方法
服务器·网络·windows
独行soc4 小时前
2025年渗透测试面试题总结-网络安全、Web安全、渗透测试笔试总结(一)(附回答)(题目+回答)
linux·运维·服务器·安全·web安全·面试·职场和发展