Linux (11) 进程控制

Linux 进程控制

Linux 进程概念

一、写时拷贝

(1)原理

写实拷贝原理:

在进程当中父进程没有创建子进程,那么代码段是只读的,对应的数据段是读写的,当创建子进程的时候,操作系统就会把数据段改成只读的。当子进程修改数据的时候,发现虚拟地址合法,然后知道你访问的是数据段(因为虚拟地址有区域划分),而对应的数据段是只读的,所以这时候操作系统就会实现写时拷贝。操作系统查找页表的时候,如果对应的物理内存不存在,那么就会发生缺页中断,等待操作系统进行加载内存。其次就是发现物理内存存在但是权限是只读的,那么这时候就会发生写时拷贝

(2)意义

  1. 解决 fork 创建子进程开销大的问题

    fork 传统实现需要把父进程整个虚拟地址空间全部复制一份物理内存。如果父进程内存很大,创建子进程会非常慢、占用大量内存。COW 让 fork只复制页表,不复制物理内存,fork 速度大幅提升。

  2. 大部分内存页面子进程根本不会修改,不需要拷贝

    父进程有大量数据、全局变量、代码段,很多页面子进程只是读取,不会写。COW 做到按需拷贝:只读就共享;只有发生写操作,才单独复制被修改的那一页,节省内存。

二、进程终止

(1)进程退出的场景

1.代码运行完毕,结果正确

2.代码运行完毕,结果错误

3.代码异常终止

思考

针对代码返回的时候,因为返回的有可能是局部变量,所以这个返回值是如何返回的呢?

通过寄存器进行返回


实例:

Main 函数通过不同的返回值代表不同错误

c 复制代码
#include<stdio.h>
int main()
{
		printf("hello world\n");
		FILE* fp=fopen("log.txt","r");
		if(fp==NULL)
		{	
				return 1;
		}
		fclose(fp);
		return 0;
}

在Ubuntu中,无需额外查看最近运行的进程的返回值,这时候就需要用到echo指令

bash 复制代码
echo $?    //打印最近一个程序,退出时的退出码

结果:


(2)获取退出码

1.父进程通过回收子进程,来获取子进程退出情况

当进程退出的时候,进入僵尸状态,这个子进程就会把退出码写到自己的task_struct里面,然后让父进程来进行获取退出信息

2.通过系统函数strerror

Strerror 系统函数可以把错误码转换成字符串(string)来方便我们进行查看

打印出全部错误码对应的描述:

2.errno来进行返回错误码

c 复制代码
#include<stdio.h>
#include<errno.h>
#include<string.h>
int main()
{
	printf("hello world\n");
	FILE *fp=fopen("log.txt","r");//打开文件log.txt
	if(fp==NULL)
	{
		return errno;
	}
	fclose(fp);
	return 0;
}

(3) 进程常见退出方法

1.异常退出

c 复制代码
#include<stdio.h>
#include<errno.h>
#include<string.h>
int main()
{
	int a=10;
	a/=0;
	return 89;
}

执行结果:

程序异常终止,对应的退出码无意义:


2.exit

c 复制代码
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<stdlib.h>
void fun()
{
	printf("fun begin\n");
	exit(42);
	printf("fun end!\n");
}
int main()
{
	fun();
	printf("main!\n");
}

执行结果:

概念:任何地方调用exit,表示进程结束!!并返回给父进程bash,子进程的退出码!!

Exit C语言提供 _Exit 系统提供!!

区别:exit 与 _exit

  • 使用 exit() 退出进程时,会刷新缓冲区,将缓冲区中的数据写入文件后再终止进程;
  • 使用 _exit() 退出进程时,不会刷新缓冲区,缓冲区中的数据会直接丢失。

本质原因:C 语言在用户态封装了一层缓冲区(stdio 缓冲区),只有当缓冲区写满、遇到换行符或进程正常退出(如调用 exit())等条件满足时,才会将数据真正刷新到文件中。而 _exit() 是系统调用,直接终止进程,不经过 C 语言的缓冲区刷新流程。


三、进程等待

(1)为什么要等待

  1. 解决僵尸进程问题:子进程退出后若父进程不及时回收,会一直处于僵尸状态,占用内核资源(如 task_struct),通过等待可以及时回收,避免资源泄漏。
  2. 获取子进程退出信息 :父进程需要通过 wait/waitpid 等系统调用回收子进程资源,并获取子进程的退出码、终止信号等状态信息,以便判断子进程是否正常结束。

(2)等待是什么

1.系统调用

Linux系统下,通过系统调用进行进程等待

c 复制代码
#include<unistd.h>
int main()
{
	pid_t id=fork();
	if(id==0)
	{
		//child
		int cnt=5;
		while(cnt)
		{
			printf("我是一个子进程,pid: %d,ppid: %d\n",getpid(),getppid());
			cnt--;
			sleep(1);
		}
		exit(5);
	}
	//father
	sleep(1000);
	return 0;
}

结果:

状态:Z+代表僵尸进程!!!


c 复制代码
pid_t wait(int* status);

作用: 阻塞等待任意一个子进程退出,并回收其资源。

返回值:

  • 成功:返回被回收的子进程的 PID,父进程可通过 status 参数拿到子进程的退出信息;
  • 失败:返回 -1(例如当前没有子进程可等待)。

说明:wait 是阻塞式系统调用,若子进程尚未退出,父进程会一直挂起等待,直到有子进程终止才返回。

代码:

c 复制代码
//father
pid_t rid=wait(NULL);
if(rid>0)
{
	printf("wait success,rid: %d\n");
}

等待成功:

1:就是回收子进程的僵尸状态

2:父进程回收子进程退出信息


waitpid()

c 复制代码
pid_t waitpid(pid_t pid, int* status, int options);

作用: 等待指定子进程退出,并回收其资源。

返回值:

  • 成功:返回被回收的子进程的 PID;
  • 失败:返回 -1(例如指定的子进程不存在或没有可等待的子进程)。

参数说明:

  • pid:指定要等待的子进程。若为 -1,则等待任意一个子进程退出(与 wait 等价);
  • status:输出型参数,父进程通过它获取子进程的退出信息,根据退出码判断子进程是否正常结束;
  • options:阻塞控制选项。传 0 表示阻塞等待;传 WNOHANG 表示非阻塞,若子进程未退出则立即返回 0。

非阻塞等待(WNOHANG)的实现方式:

当 options 传入 WNOHANG 时,waitpid 采用**轮询(polling)**的方式等待:

  1. 父进程调用 waitpid(pid, &status, WNOHANG) 后,内核会立即检查指定的子进程是否已经退出;
  2. 若子进程尚未退出 ,waitpid 不会挂起 父进程,而是立刻返回 0,父进程可以继续执行后续代码;
  3. 父进程通常会在循环中反复调用 waitpid,每次调用之间可以处理其他任务或短暂休眠(如 sleep),直到某次调用返回子进程的 PID,表示回收成功。
c 复制代码
// 非阻塞等待示例:轮询回收子进程
pid_t rid;
while ((rid = waitpid(id, &status, WNOHANG)) == 0)
{
    // 子进程还没退出,先干点别的事
    printf("子进程还在运行,我先忙别的...\n");
    sleep(1);   // 休眠 1 秒后再来检查
}
// 走到这里说明回收成功
printf("回收成功,子进程 PID: %d\n", rid);

对比:阻塞等待(options=0)时,父进程会一直挂起,直到子进程退出才返回;而非阻塞等待(WNOHANG)则让父进程不被卡住,可以边做其他事情边轮询检查子进程状态,适合需要同时处理多个任务的场景。


通过 status 获取退出信息

wait/waitpid 的 status 参数是输出型参数 ,父进程通过它拿到子进程的退出状态。status 是一个 32 位整数,其低 16 位被划分为两部分:

  • 低 8 位(bit 0~7):存放终止信号。若子进程被信号杀死,这里就是对应的信号编号;
  • 高 8 位(bit 8~15) :存放退出码。若子进程正常退出(如调用 exit(5)),退出码 5 就保存在这里。

注意:status 不能直接当作退出码使用,需要借助宏来解析,否则拿到的数值是「信号 + 退出码」拼在一起的结果,含义不直观。

常用解析宏:

  • WIFEXITED(status):判断子进程是否正常退出 (即通过 exit/return 结束);
  • WEXITSTATUS(status):在正常退出的前提下,取出真正的退出码;
  • WIFSIGNALED(status):判断子进程是否被信号终止;
  • WTERMSIG(status):在被信号终止的前提下,取出终止信号编号。
c 复制代码
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // 子进程:正常退出,退出码为 5
        exit(1);
    }

    // 父进程:阻塞等待子进程退出
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0)
    {
        if (WIFEXITED(status))
        {
            // 正常退出,取出真正的退出码
            printf("子进程正常退出,退出码: %d\n", WEXITSTATUS(status));
        }
        else if (WIFSIGNALED(status))
        {
            // 被信号杀死,取出终止信号
            printf("子进程被信号终止,信号: %d\n", WTERMSIG(status));
        }
    }
    return 0;
}

现象:


(3)怎么做

当子进程退出时,内核会把退出信息写入子进程的 PCB(进程控制块,即 task_struct) 中,父进程通过 wait/waitpid 回收子进程时,就是从 PCB 中读取这些数据。

PCB 中与退出信息相关的两个关键字段:

  • int exit_code:记录子进程的退出码 。若子进程正常退出(如调用 exit(5)),这里保存的就是 5;
  • int exit_signal:记录子进程的终止信号 。若子进程被信号杀死(如 SIGKILL),这里保存的就是对应的信号编号。

也就是说:子进程退出后,退出码和终止信号并不会凭空消失,而是被内核保存在 PCB 中,等待父进程来取。这正是「父进程通过回收子进程来获取退出信息」的底层实现。

在 Linux 内核中 PCB 结构体:

获取流程总结:

  1. 子进程退出,内核将 exit_code 和 exit_signal 写入子进程的 PCB;
  2. 子进程进入僵尸状态(Z+),等待父进程回收;
  3. 父进程调用 wait/waitpid,从 PCB 中读取这两个字段;
  4. 父进程通过 WIFEXITED/WEXITSTATUS/WIFSIGNALED/WTERMSIG 等宏解析出真正的退出码或终止信号;
  5. 回收完成后,子进程的 PCB 被释放,僵尸状态解除。

四、进程的程序替换

(1)程序替换的概念

当我们执行程序时,如果执行到 execl 这个函数,就会把该函数调用的新程序的代码和数据 ,覆盖 到当前进程的代码和数据上。PCB 不变 ,但页表映射关系有可能发生调整 。这种策略叫做进程的程序替换!

核心要点:

  • PCB 不变:进程的标识、PID、父子关系等内核信息保持不变,替换前后仍是同一个进程;
  • 代码和数据被覆盖:当前进程的代码段、数据段被新程序的内容整体替换;
  • 页表映射可能调整:新程序的代码和数据加载到内存后,页表会重新建立虚拟地址到物理地址的映射关系。

(2)程序替换接口

程序替换接口:

(3)进程替换的原理

进程替换的原理-fork:

替换流程:

  1. 父进程调用 fork() 创建子进程,子进程复制父进程的代码和数据(借助写时拷贝,开销很小);
  2. 子进程中调用 execl 等 exec 系列函数,加载新程序;
  3. 内核将新程序的代码和数据加载到内存,覆盖子进程原有的代码和数据;
  4. 子进程的 PCB 保持不变,但页表映射关系被重新调整,指向新程序的代码和数据;
  5. 子进程开始执行新程序的入口函数(如 main),完成程序替换。

注意:exec 系列函数只有失败才返回 -1,成功时不会返回------因为一旦替换成功,原来的代码已经被覆盖,根本没有机会再执行返回语句了。

(4)程序替换接口函数

程序替换接口:

execl 函数:

c 复制代码
int execl(const char* path, const char* arg, ...);

参数说明:

  • 第一个参数 path :要执行程序的路径 + 程序名 ,指定新程序在磁盘上的位置;
  • 第二个参数 arg(可变参数) :以 const char* 形式传入的命令行参数,以 NULL 结尾 ,表示参数列表结束;

代码示例:

c 复制代码
#include <unistd.h>
#include <stdio.h>

int main()
{
    // 子进程执行 ls 命令,参数以 NULL 结尾
    execl("/bin/ls", "ls", "-l", NULL);

    // 只有 exec 失败才会执行到这里
    perror("execl failed");
    return 1;
}

应用场景: 如果我们不想让程序后面的部分继续执行,那么可以创建子进程,让子进程去执行这个替换程序。父进程负责回收子进程,子进程负责执行新程序,两者互不干扰。

加载器

概念:

这个加载器,就是先创建进程,然后让这个进程去替换,执行指令。

为什么 exec 能替换任何程序?

核心原因:只要是一个进程,就能被替换 。exec 系列函数替换的是「进程」这个载体,而不是「某种特定语言写的程序」。无论新程序是 C、Python、Shell 还是其他任何语言写的,对内核来说都一样------它们最终都会被编译/解释成可执行的二进制文件,加载进内存后就是一段「代码 + 数据」。

理解这句话的关键:

  1. 替换的是进程,不是语言 :exec 只关心「当前进程的代码和数据被新程序的代码和数据覆盖」,它不关心新程序是什么语言写的。只要是能运行的程序,内核就能把它加载进来。

  2. 解释型语言(如 Python)的本质 :当我们 execl("/usr/bin/python3", "python3", "script.py", NULL) 时,真正被加载进内存的不是 script.py 的源码,而是 Python 解释器(python3 这个二进制文件) 的代码和数据。替换完成后,进程执行的是解释器的二进制代码。

  3. 解释器再读脚本 :解释器运行起来后,才从磁盘上读取 script.py 的源码,逐行解释执行。也就是说------进程替换的是解释器,脚本文件是解释器运行后自己去读的。

一句话总结:

因为 exec 替换的是「进程」这个通用载体,而任何语言最终都要落到「二进制文件」上才能被 CPU 执行。所以 C 语言可以调用任何语言------本质是 C 程序先 fork 出一个子进程,再用 exec 把子进程替换成目标语言的解释器二进制文件,解释器再去读取并执行对应的脚本文件。

为什么 CPU 需要二进制文件,而不是直接执行内存里的代码?

核心原因:CPU 只认识机器指令(二进制),不认识任何高级语言。CPU 的指令集(如 x86、ARM)规定了一组固定的二进制编码,每条编码对应一个操作(如加法、跳转、读写内存)。CPU 执行时,只是从内存中取出这些二进制指令,解码后执行------它根本不关心这些指令是从哪来的。

execlp 函数:

c 复制代码
int execlp(const char* file, const char* arg, ...);

参数说明:

  • file:程序名 (在环境变量 PATH 中查找的程序名,无需写完整路径);
  • arg:可变参数,依次传入程序名 + 命令行参数列表;
  • 参数列表以 NULL 结尾,表示结束。

特点:execlp 会通过环境变量 PATH 自动查找程序,因此不需要写绝对路径,只需给出程序名即可。


execv 函数:

c 复制代码
int execv(const char* path, char* const argv[]);

参数说明:

  • path:绝对路径,即可执行程序在磁盘上的完整路径;
  • argv:命令行参数数组 ,依次对应 argv[0](程序名)、argv[1](选项1)、argv[2](选项2)......

特点:execv 需要手动指定完整路径 ,参数通过数组形式传入,而不是可变参数。


execvp 函数:

c 复制代码
int execvp(const char* file, char* const argv[]);

参数说明:

  • file:程序名 (在环境变量 PATH 中查找);
  • argv:命令行参数数组 ,依次对应 argv[0](程序名)、argv[1](选项1)、argv[2](选项2)......

特点:execvp 会默认继承父进程的环境变量表 ,从而获取到对应的 env[] 环境变量,同时通过 PATH 自动查找程序。

execvpe 函数:

c 复制代码
int execvpe(const char* file, char* const argv[], char* const envp[]);

参数说明:

  • file:程序名 (在环境变量 PATH 中查找);
  • argv:命令行参数数组 ,依次对应 argv[0](程序名)、argv[1](选项1)、argv[2](选项2)......;
  • envp:自定义环境变量表,用于传入新的环境变量表。

特点:execvpe 相比 execvp 多了一个 envp 参数,可以手动指定环境变量表 ,而不是默认继承父进程的环境变量。这样可以让当前进程获取到最新的环境变量表。

如何新增环境变量:

如果需要新增环境变量,可以使用 putenv 函数:

用environ 之前要进行声明,extern char** environ

c 复制代码
int putenv(char* string);

结果:


相关推荐
猫咪很可爱1 小时前
Oracle归档自动删除任务失败
数据库·oracle
菜菜的院子1 小时前
WSL2 运行 Ubuntu 26.04 完整 GNOME 桌面(WSLg Wayland)
linux·运维·ubuntu·wsl
杨运交1 小时前
[079][消息模块]基于 Redis Stream 的高可靠消息队列实现——Spring Data Redis 实战解析
数据库·redis·spring
暖核1 小时前
从 CentOS 到 Ubuntu24.04 运维迁移手册:命令对照表 + 实操 + 避坑
linux·运维·centos
分布式存储与RustFS1 小时前
训练集放在 RustFS 上,怎么像本地盘一样快?用 Curvine 加一层 POSIX 缓存
运维·云原生·开源·对象存储·分布式存储·s3·性能基准
pride.li1 小时前
第12章 Python 教程-流程控制:循环结构
linux·服务器·python·算法
迷途之人不知返1 小时前
【基础IO】-5-简单理解三个标准流——stdin、stdout、stderr
linux
牛马鸡niumasi1 小时前
【Linux】进程退出
linux
北京中科新远科技1 小时前
高速光链路故障排查实战:从DOM光功率到FEC与BER
运维·服务器·网络