Linux进程万字详解(五) | 进程控制全解析:创建、终止、等待与程序替换

发布时间:2026/11/4 7:08:00
Linux进程万字详解(五) | 进程控制全解析:创建、终止、等待与程序替换

目录

引言:

一、进程创建

1.1 fork函数回顾

1.2 写时拷贝的底层实现

1.3 fork的常规用法与失败原因

1.4 进程创建与虚拟机隔离

二、进程终止

2.1 进程终止的本质

2.2 进程退出码

2.3 错误码与错误描述

2.4 异常终止与信号

2.5 进程退出方法

方法1:main函数return

方法2:exit函数

方法3:_exit系统调用

三、进程等待

3.1 进程等待的必要性

3.2 wait接口

3.3 waitpid接口

3.4 status位图解析

3.5 宏封装

3.6 阻塞等待 vs 非阻塞等待

阻塞等待(options = 0)

非阻塞等待(options = WNOHANG)

四、进程程序替换

4.1 替换原理

4.2 exec系列函数详解

execl:列表传参

execlp:自动搜索PATH

execv:向量传参

execvp:PATH + 向量

execvpe / execle:自定义环境变量

4.3 execve:真正的系统调用

4.4 程序替换的本质:加载器

结语


引言:

进程是操作系统资源分配的基本单位,而进程控制则是操作系统最核心的功能之一。从进程的创建到终止,从父进程的等待到程序的执行替换,这一系列机制共同构成了Linux进程管理的完整生命周期。理解进程控制,不仅能让我们洞察操作系统如何调度与管理程序执行,更能为后续学习Shell原理、进程间通信、信号机制等高级主题打下坚实基础。

本文将围绕进程控制的四大核心话题展开:进程创建、进程终止、进程等待、进程程序替换。我们将深入剖析fork的写时拷贝原理,解读进程退出码的设计哲学,揭示wait/waitpid的状态位图结构,详解exec系列函数的加载机制。从内核数据结构到用户接口调用,从原理分析到代码实战,带你完整掌握Linux进程由生到死的全过程。


一、进程创建

1.1 fork函数回顾

在Linux中,fork函数是最重要的进程创建接口。它从已存在进程中创建一个新进程,原进程为父进程,新进程为子进程。

#include <unistd.h> pid_t fork(void); // 返回值:子进程中返回0,父进程返回子进程PID,出错返回-1

围绕fork有三个经典问题:

  1. 为什么给子进程返回0,父进程返回子进程PID?
  2. 为什么一个fork函数会有两个返回值?
  3. 为什么同一个变量既等于0又大于0?

结合前文所学,这些问题现在可以深入理解了。创建进程时,内核会:

  • 分配新的内存块和内核数据结构给子进程
  • 将父进程大部分数据结构内容拷贝至子进程
  • 添加子进程到系统进程列表
  • fork返回,开始调度器调度

子进程拥有独立的task_struct、mm_struct和页表。默认情况下,父子进程通过页表映射到同一物理内存,共享代码和数据。当任一进程尝试修改数据时,触发写时拷贝(Copy-On-Write),在物理内存开辟新空间,保证进程独立性。


1.2 写时拷贝的底层实现

权限陷阱机制:

父进程创建子进程后,内核会将父子进程的代码段和数据段页表权限都设为只读。当子进程试图写入数据时:

  1. CPU检查发现页表权限为只读
  2. 触发缺页异常(Page Fault)
  3. 操作系统介入,识别这是合法的写时拷贝场景
  4. 开辟新物理内存,拷贝原数据
  5. 更新子进程页表映射(虚拟地址不变,物理地址指向新空间)
  6. 恢复数据段读写权限,重新执行写入操作

为什么需要写时拷贝?

优势说明
减少创建时间避免一次性完整拷贝所有内存
减少内存浪费子进程大概率不会修改所有变量
提高内存利用率按需分配,延迟拷贝

写时拷贝是一种精细的内存控制技术,属于延迟申请策略,能显著提高整机内存使用效率。


1.3 fork的常规用法与失败原因

常规用法:

  • 代码分流:父子进程执行不同代码段(如父进程监听请求,子进程处理请求)
  • 程序替换:子进程调用exec函数执行全新程序

调用失败原因:

  • 系统进程过多,内存空间不足(PCB、虚拟地址空间、页表分配失败)
  • 用户进程数超过系统限制

1.4 进程创建与虚拟机隔离

如果一个进程要执行另一款操作系统的代码呢?这正是内核级虚拟机(KVM)的原理。Linux下可以运行多个操作系统实例,每个实例都是一个独立进程。虚拟机之间的隔离性正是基于进程的独立性——每个虚拟机拥有独立的PCB、虚拟地址空间和页表,通过页表映射到不同物理内存,实现硬件级别的隔离。


二、进程终止

2.1 进程终止的本质

进程终止的本质是释放系统资源:销毁内核数据结构(task_struct、mm_struct、页表等),回收物理内存和打开的文件描述符。

进程退出的三种场景:

场景说明
代码运行完毕,结果正确正常退出,退出码为0
代码运行完毕,结果不正确正常退出,退出码非0
代码异常终止被信号中断,退出码无意义

子进程的执行结果需要告知父进程,父进程据此进行决策。因此,进程退出是为了给父进程一个交代。


2.2 进程退出码

C/C++程序中main函数的返回值就是进程退出码。返回0表示成功,非0表示不同的错误原因。

查看退出码:

echo $? # 打印最近一个进程的退出码

示例:

#include <iostream> #include <unistd.h> #include <stdio.h> using namespace std; int main() { FILE* pr = fopen("xixi.txt", "r"); if(pr == NULL) return 1; // 文件打开失败,返回错误码1 return 0; // 成功 }
[qyh@VM-0-2-centos linux-sys-prog]$ ./test1 [qyh@VM-0-2-centos linux-sys-prog]$ echo $? 1

./test1的父进程是Bash,Bash通过wait获取子进程的退出码,存储在task_struct中。echo $?就是读取这个值。

注意:再次执行echo $?会显示0,因为这次打印的是上一次echo命令自身的退出码。


2.3 错误码与错误描述

系统提供了一套错误码与错误描述的映射(C标准库实现),通过strerror函数转换:

#include <iostream> #include <unistd.h> #include <stdio.h> #include <errno.h> #include <cstring> using namespace std; int main() { FILE* pr = fopen("xixi.txt", "r"); if(pr == NULL) { cout << errno << " -> " << strerror(errno) << endl; return errno; } return 0; }
[qyh@VM-0-2-centos linux-sys-prog]$ ./test1 2 -> No such file or directory [qyh@VM-0-2-centos linux-sys-prog]$ echo $? 2

指令同样遵循此机制:

[qyh@VM-0-2-centos linux-sys-prog]$ ls -l xixi.txt ls: cannot access xixi.txt: No such file or directory [qyh@VM-0-2-centos linux-sys-prog]$ echo $? 2

ls也是进程,其退出码与我们的程序一致。当然,也可以自定义退出码规则,不遵循C标准库的约定。


2.4 异常终止与信号

一旦进程出现异常,退出码失去意义。异常通常由信号导致:

#include <iostream> #include <unistd.h> using namespace std; int main() { int a = 10; a /= 0; // 除零错误 return 66; }
[qyh@VM-0-2-centos linux-sys-prog]$ ./test1 Floating point exception [qyh@VM-0-2-centos linux-sys-prog]$ echo $? 136

进程因浮点异常被信号终止,返回的136不是退出码,而是包含信号信息的编码。异常退出的详细机制将在信号章节深入讲解。


2.5 进程退出方法

方法1:main函数return

main函数的return表示进程结束。其他函数的return仅表示函数调用完成。

方法2:exit函数

在代码任何位置调用exit(status),立即终止进程并返回退出码给父进程。其内部执行:

  1. 执行用户通过atexit或on_exit注册的清理函数
  2. 刷新所有打开流的缓冲区
  3. 调用_exit系统调用

#include <iostream> #include <stdlib.h> using namespace std; int main() { exit(1); // 进程立即终止,不会执行后续代码 cout << "xixi" << endl; // 不会执行 return 66; }

方法3:_exit系统调用

_exit是系统级接口,直接终止调用进程,不刷新缓冲区:

#include <iostream> #include <unistd.h> using namespace std; int main() { cout << "xixi"; // 注意无换行,数据在缓冲区 _exit(1); // 直接终止,缓冲区不刷新 return 0; }
[qyh@VM-0-2-centos linux-sys-prog]$ ./test1 [qyh@VM-0-2-centos linux-sys-prog]$ echo $? 1

对比:

特性exit_exit
层级C库函数系统调用
缓冲区刷新✅ 刷新❌ 不刷新
清理函数执行✅ 执行❌ 不执行
关系封装了_exit底层接口

关键结论:之前讨论的"缓冲区"是C语言库缓冲区,不是系统级缓冲区。因为_exit不刷新缓冲区,而exit会刷新,说明缓冲区存在于用户空间,由C标准库维护。


三、进程等待

3.1 进程等待的必要性

  • 防止僵尸进程:子进程退出,父进程不回收,造成内存泄漏
  • 僵尸进程刀枪不入:kill -9无法杀死已死去的进程
  • 获取子进程执行结果:判断任务完成质量(结果对错、是否正常退出)
  • 回收资源:这是进程等待最重要的功能

3.2 wait接口

#include <sys/types.h> #include <sys/wait.h> pid_t wait(int* status);
  • status:输出型参数,获取子进程退出状态(不关心可传NULL)
  • 返回值:成功返回回收的子进程PID,失败返回-1
  • 特性:阻塞等待任意一个子进程退出

示例:

#include <iostream> #include <unistd.h> #include <sys/wait.h> using namespace std; int main() { pid_t id = fork(); if(id == 0) { int cnt = 5; while(cnt--) { cout << "子进程:pid" << getpid() << endl; sleep(1); } exit(0); } pid_t rid = wait(NULL); // 阻塞等待子进程 if(rid > 0) { cout << "wait success, rid:" << rid << endl; } return 0; }
子进程:pid5446 子进程:pid5446 子进程:pid5446 子进程:pid5446 子进程:pid5446 wait success, rid:5446

父进程创建子进程后,在wait处阻塞,直到子进程退出才继续执行。


3.3 waitpid接口

pid_t waitpid(pid_t pid, int* status, int options);
参数说明
pid>0:等待指定PID子进程;=0:等待同进程组任一子进程;=-1:等待任一子进程(同wait);<-1:等待指定进程组任一子进程
status输出型参数,获取详细退出信息
options0:阻塞等待;WNOHANG:非阻塞等待

waitpid失败情况:

  • 父进程没有子进程
  • 指定的pid子进程不存在

3.4 status位图解析

status不是整存整取的整数,而是位图结构:

高16位(不考虑) ├─ 低16位 ├─ 高8位(第8-15位):退出码(正常退出时有效) └─ 低8位 ├─ 第7位:Core Dump标志 └─ 低7位(第0-6位):终止信号(异常退出时有效)

验证:

#include <iostream> #include <unistd.h> #include <sys/wait.h> using namespace std; int main() { pid_t id = fork(); if(id == 0) { // 子进程 sleep(1); exit(1); // 退出码设为1 } int _status = 0; pid_t rid = waitpid(id, &_status, 0); if(rid > 0) { cout << "wait success, rid:" << rid << " _status:" << _status << endl; } return 0; }
wait success, rid:4633 _status:256

退出码1对应status为256(即1 << 8),验证了位图结构。

判定规则:

  • 低7位为0:正常退出,读取高8位退出码
  • 低7位非0:异常退出,退出码无意义,读取信号编号

这就是为什么信号没有0号信号——0被保留用于表示"无异常"。


3.5 宏封装

手动位操作繁琐,系统提供宏函数:

宏功能
WIFEXITED(status)判断是否正常退出(真/假)
WEXITSTATUS(status)提取退出码(需先判断WIFEXITED为真)

3.6 阻塞等待 vs 非阻塞等待

阻塞等待(options = 0)

父进程调用waitpid后,若子进程未结束,父进程挂起等待,直到子进程退出。

非阻塞等待(options = WNOHANG)

WNOHANG= Wait No Hang,即"等待但不挂起"。若子进程未结束,立即返回0,父进程可以继续执行其他任务。

现实类比:

  • 阻塞等待:张三在李四楼下等,一直打电话直到李四下来(电话不挂)
  • 非阻塞轮询:张三隔段时间打电话问"下来了吗",没下来就挂电话做别的事,反复询问直到李四下来

非阻塞轮询示例:

#include <iostream> #include <unistd.h> #include <sys/wait.h> #include <vector> using namespace std; typedef void (*handler_t)(); vector<handler_t> handlers; void fun_one() { cout << "临时任务1:整理文件" << endl; } void fun_two() { cout << "临时任务2:发送心跳包" << endl; } void Load() { handlers.push_back(fun_one); handlers.push_back(fun_two); } void handler() { if(handlers.empty()) Load(); for(auto iter : handlers) iter(); } int main() { pid_t pid = fork(); if(pid < 0) { return 1; } else if(pid == 0) { cout << "子进程运行中,pid:" << getpid() << endl; sleep(5); exit(1); } else { int status = 0; pid_t ret = 0; do { ret = waitpid(-1, &status, WNOHANG); // 非阻塞等待 if(ret == 0) { cout << "子进程仍在运行,父进程执行临时任务..." << endl; handler(); sleep(1); } } while(ret == 0); if(WIFEXITED(status) && ret == pid) { cout << "等待成功,退出码:" << WEXITSTATUS(status) << endl; } } return 0; }

waitpid返回值:

  • >0:子进程结束,返回PID
  • =0:调用成功,但子进程未结束(非阻塞模式)
  • <0:调用失败

非阻塞等待让父进程并行处理多项任务,提高整体效率。


四、进程程序替换

4.1 替换原理

fork创建子进程后,父子进程执行相同程序的不同分支。如果子进程想执行全新的程序呢?这就需要进程程序替换。

核心机制:通过特定接口,加载磁盘上的新程序(代码和数据)到调用进程的地址空间中,覆盖原有内容。

关键特性:

  • 不创建新进程(PID不变)
  • 替换代码段和数据段
  • 更新页表映射
  • PCB和虚拟地址空间保持不变

执行特点:exec*函数只有替换失败时才有返回值。一旦成功,原代码后半部分已不存在,无法返回。因此无需判断成功返回值,只需处理失败情况。


4.2 exec系列函数详解

#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* filename, char* const argv[], char* const envp[]);

execl:列表传参

int execl(const char* path, const char* arg, ...); // path:可执行文件路径(我要执行谁) // arg...:命令行参数(我怎么执行它),必须以NULL结尾

示例:

#include <iostream> #include <unistd.h> #include <sys/wait.h> using namespace std; int main() { cout << "begin..." << endl; if(fork() == 0) { execl("/usr/bin/ls", "ls", "-al", NULL); exit(1); // 只有execl失败才会执行到这里 } waitpid(-1, NULL, 0); cout << "end..." << endl; return 0; }
begin... total 68 drwxrwxr-x 3 qyh qyh 4096 Oct 30 15:34 . ... end...

为什么不直接替换当前进程?因为替换后原程序后续代码丢失。创建子进程后,子进程被替换,父进程不受影响——这正是进程独立性的体现。

execlp:自动搜索PATH

int execlp(const char* file, const char* arg, ...); // p = PATH,自动在环境变量PATH中查找可执行文件
execlp("ls", "ls", "-al", NULL); // 无需完整路径

execv:向量传参

int execv(const char* path, char* const argv[]); // v = vector,通过指针数组传递参数
char* const argv[] = {(char*const)"ls", (char*const)"-al", NULL}; execv("/usr/bin/ls", argv);

命令行参数的传递:我们之前讲main函数的argc、argv时提到,程序的命令行参数由父进程通过execv这类函数传递。所有程序都是其他进程的子进程,命令行参数就是这样层层传递的。

execvp:PATH + 向量

execvp("ls", argv); // 自动搜索PATH + 向量传参

execvpe / execle:自定义环境变量

int execvpe(const char* file, char* const argv[], char* const envp[]); int execle(const char* path, const char* arg, ..., char* const envp[]); // e = environment,传递自定义环境变量表

示例:

char* const argv[] = {(char*const)"ls", (char*const)"-al", NULL}; char* const envp[] = {(char*const)"XIXI=10086", NULL}; execvpe("./test2", argv, envp);

注意:传递envp意味着使用全新的环境变量列表,老的环境变量不再保留。

保留原环境变量并新增:

// 方法1:使用putenv(推荐) putenv("XIXI=10086"); // 添加到当前进程环境变量 // 子进程自动继承 // 方法2:通过environ传递 extern char** environ; // 当前进程完整环境变量表 execvpe("./test2", argv, environ); // 传递包含新增变量的完整表

4.3 execve:真正的系统调用

上述6个函数都是C库函数封装,底层都调用唯一的系统调用:

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

设计原因:

  • 库函数提供多种参数风格,方便用户调用
  • 系统调用只需一个统一接口
  • 未显式传递环境变量时,库函数自动传入environ指向的默认环境变量表

这就是为什么不传环境变量子进程仍有环境变量,传了则老环境变量消失的根本原因。


4.4 程序替换的本质:加载器

Shell执行命令的原理:

  1. Bash(父进程)创建子进程
  2. 子进程调用exec*加载新程序
  3. 新程序执行完毕,子进程退出
  4. Bash通过wait回收子进程,获取退出码

我们每天在Bash中执行的每个指令,都是这个流程的实例。exec*系列函数本质上就是加载器(Loader)的实现。

跨语言调用:进程程序替换是系统级概念,与编程语言无关。只要最终能在系统中运行的程序(无论C、C++、Python、Java编译后的字节码),都可以被替换执行。

验证PID不变:

begin... pid:13676 // 子进程原PID 替换了:pid13676 // 替换后PID相同 end...

结语

通过本文的系统讲解,我们完整梳理了Linux进程控制的四大核心机制:从fork的写时拷贝到进程终止的资源回收,从wait的状态位图解析到exec的程序替换原理。这些机制环环相扣,共同构成了进程由生到死的完整生命周期。

特别值得深入理解的是写时拷贝的权限陷阱设计——通过将页表权限设为只读,利用缺页异常触发拷贝,这种"以退为进"的设计思想体现了操作系统工程的精妙。进程等待的状态位图同样令人叹服:用16个比特位同时编码退出码、终止信号和Core Dump标志,信息密度极高。exec系列函数的加载器本质则揭示了Shell命令执行的底层真相——我们每天使用的每个命令,都是进程创建与替换的实例。

进程控制不仅是操作系统理论的核心,更是实际系统编程的基石。理解这些机制后,我们已经具备了手写简易Shell的能力,也为后续学习进程间通信、信号机制、线程控制等高级主题奠定了坚实基础。操作系统的设计之美,在于它将复杂的资源管理隐藏在简洁的接口背后;而进程控制,正是这种设计哲学的最佳体现之一。