进程的控制
进程创建
进程的创建通常就是使用fork()函数进行进程的创建。
进程调⽤ fork ,当控制转移到内核中的 fork 代码后,内核做:
• 分配新的内存块和内核数据结构给⼦进程
• 将⽗进程部分数据结构内容拷⻉⾄⼦进程
• 添加⼦进程到系统进程列表当中
• fork 返回,开始调度器调度
fork()函数的返回值
• ⼦进程返回0
• ⽗进程返回的是⼦进程的pid。
写时拷贝
1. fork 创建子进程,拷贝虚拟地址空间与页表
- 内核新建子进程 PCB,完整复制父进程的虚拟地址空间布局、页表;
- 子进程拥有和父进程完全一致的虚拟地址,
&var打印值一模一样; - 不拷贝物理内存,父子进程暂时共享父进程原本的所有物理页。
2. 关键一步:修改两个进程页表权限
内核把父子进程中所有可读写的共享物理页,统一标记为 只读(W=0)。
- 只读段
.rodata本来就是只读,不受影响; - 栈、堆、data、bss 这些原本可写的页面,临时变成只读。
目的:靠 MMU 硬件拦截写操作,监测谁要修改内存。
内存的映射关系:
父进程虚拟A地址 ──┐
└──→ 同一块物理内存页
子进程虚拟A地址 ──┘
页表权限:R=1 W=0
3. 任意进程执行「写操作」(变量赋值)触发缺页异常
父进程 / 子进程只要修改共享页面里的变量:
- CPU MMU 检测到:当前页表无写权限;
- 触发 缺页异常 Page Fault,陷入内核态;
- 内核识别:这是 COW 共享页,不是真的权限错误。
4. 内核执行复制操作(真正拷贝内存)
- 分配一块全新的物理内存页;
- 把旧物理页的数据完整拷贝到新页面;
- 修改执行写入操作的那个进程的页表: 同一个虚拟地址,重新映射到新物理页,页表权限恢复为可写 (W=1);
- 另一个进程页表保持不变,依旧指向原始物理页。
修改后的内存映射:
父进程虚拟A地址 → 旧物理页(原值不变,只读)
子进程虚拟A地址 → 新物理页(已修改,可写)
5. 仅修改的页面复制,其余页面继续共享
COW 是页粒度复制:只拷贝发生写入的 4K/2M 页。
如果子进程只修改一个 int 变量,只会复制该变量所在的一页,其他上千页栈、堆仍共享物理内存,极大节省开销。
#include <stdio.h>
#include <unistd.h>
int main() {
int num = 10; // 栈上变量,可读写页
pid_t pid = fork();
if (pid == 0) {
// 子进程
num = 20; // 写操作,触发COW
printf("子进程 num=%d, &num=%p\n", num, &num);
} else {
sleep(1);
printf("父进程 num=%d, &num=%p\n", num, &num);
}
return 0;
}
输出特征:
- 父子
&num虚拟地址完全相同; - 变量值互不干扰; 底层:子进程写 num 触发异常,单独复制一页物理内存。
特殊页面不参与 COW
.rodata只读常量页:本身永久只读,不会共享可写,写直接段错误,不走 COW;- 内核空间页面:用户进程无法修改,不参与 COW;
- 执行 exec 后:子进程抛弃原有虚拟地址空间,加载新程序,所有共享关系直接失效。
COW 带来的核心优势
- fork 极速完成 创建子进程只复制页表(很小的数据结构),不拷贝物理内存,瞬间完成;
- 内存占用极低 父子大量页面共享同一块物理内存,不会双倍占用内存;
- 完美适配 fork + exec 场景 子进程 fork 后马上 exec 加载新程序,全程不会修改任何共享页,完全不需要拷贝任何物理内存,性能拉满。
进程终止
进程终止就是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
正常终止(主动、预期退出)
main函数returnmain 返回等价调用exit(),刷新缓冲区、执行退出清理。- 调用库函数
exit(int status)- 先执行
atexit()注册的钩子函数; - 刷新 stdio 缓冲区;
- 调用系统调用
_exit()进入内核。
- 先执行
- 系统调用
_exit(int status)/_Exit()直接交给内核,不执行用户层清理(不刷缓冲区、不跑 atexit),fork 子进程常用。


atexit函数
int atexit(void (*func)(void));
atexit确实是登记 / 记录清理函数的工具,调用atexit(clean)只是把 clean 存进一张退出函数表,不会立刻运行 clean;main执行完return 0属于正常退出,会自动执行所有atexit登记的函数,打印 clean 里的内容。-
两种场景都会自动执行登记的 clean
- 场景 1:main 函数跑完,执行
return 0(底层等价调用exit(0)) - 场景 2:代码任意位置手动写
exit(23)
exit和_exit函数
exit (int status) 完整优雅退出
执行步骤固定:
- 倒序运行所有 atexit 注册的清理函数
- 刷新全部 IO 缓冲区,把屏幕 / 文件没写完的数据落盘
- 清除临时文件
- 调用 _exit () 通知内核终止进程
适用场景:普通程序正常结束,需要完整释放资源。
_exit (int status) 暴力内核退出
直接调用操作系统内核接口,跳过上面所有步骤:
- 不执行 atexit 清理函数
- 不刷新 IO 缓冲区(cout/printf 缓存数据直接丢失)
- 不处理临时文件
- 直接销毁进程,把退出码传给父进程
适用场景:fork() 创建的子进程退出(避免重复执行父进程 atexit 逻辑)。
进程等待
为什么需要进程等待?
1. 僵尸进程问题
fork() 创建子进程后: 子进程先结束、父进程还在运行,子进程资源不会自动释放,PCB 留在系统中,变成僵尸进程。 子进程退出状态会一直保存,等待父进程读取。
2. 进程等待的作用
- 父进程回收子进程资源,消灭僵尸进程;
- 获取子进程的退出码(判断子进程是正常结束 / 异常崩溃)。
3. 两种等待接口
wait():阻塞等待任意一个子进程退出waitpid():精准控制,可指定等待某个子进程、非阻塞轮询

获取⼦进程status
• wait和waitpid,都有⼀个status参数,该参数是⼀个输出型参数,由操作系统填充。
• 如果传递NULL,表⽰不关⼼⼦进程的退出状态信息。
• 否则,操作系统会根据该参数,将⼦进程的退出信息反馈给⽗进程。
本质:int 整型变量,存储子进程退出全部信息
wait(&status) 会把子进程结束的完整状态数据写到 status 内存里,它不是单纯存退出码,是一个按位封装的整数,里面包含两类信息:
- 子进程是正常 exit () 退出,还是被信号杀死;
- 如果正常退出:exit (数字) 里的退出码;
- 如果被信号杀死:是几号信号干掉的进程。
waitpid函数
pid_t waitpid(pid_t pid, int *status, int options);
| pid 值 | 含义 | ||||
| pid > 0 | 精准等待:等待 PID 严格等于这个数值的子进程 | ||||
| pid == -1 | 等待任意一个子进程,等价于 wait() | ||||
| pid == 0 | 等待当前进程组中的任意一个子进程 | ||||
| pid < -1 | 等待进程组 ID 等于 abs(pid) 的任意子进程 | ||||
status获取子进程的退出状态
| 宏 | 作用 | ||||
| WIFEXITED(status) | 判断子进程是否正常退出。为真 (非 0) 代表正常结束 | ||||
| WEXITSTATUS(status) | 获取子进程退出码,只有正常退出时才有效 | ||||
| WIFSIGNALED(status) | 判断子进程是否被信号杀死(异常终止) | ||||
| WTERMSIG(status) | 获取终止子进程的信号编号 | ||||
int options —— 等待模式选项(阻塞 / 非阻塞等)
选项可以多个值按位或 |组合。最常用常量:
-
0默认行为:阻塞等待。父进程卡住休眠,直到目标子进程退出,函数才返回。 -
WNOHANG(No Hang,非阻塞)核心!不会阻塞父进程。 如果目标子进程还没有退出,
waitpid立刻返回0,父进程可以继续干别的事。 一般配合循环轮询检测子进程状态。 -
WUNTRACED等待收到暂停信号(如 SIGSTOP)而被挂起的子进程。 -
WCONTINUED等待收到继续信号 SIGCONT 恢复运行的子进程。
阻塞等待

非阻塞等待

进程程序替换
概念
- 程序:存放在磁盘上的可执行文件(ELF 文件),静态文件。
- 进程:程序加载到内存运行后的实例,操作系统为它分配 PCB、地址空间等资源。
- 进程程序替换(exec):不创建新进程,将当前进程用户空间的代码、数据完全替换成另一个新程序,然后执行新程序。

使用fork()创建的子进程执行和父进程相同的程序,如果想和父进程执行的程序不相同,子进程就调用exec函数可以执行另外程序,当进程执行exec调用另外一个程序,那么该进程的用户地址空间和代码数据就会被替换,从新程序启动进程,调用exec函数的程序并不会创建进程,所以进程的pid是不变的。
// l:参数列表传参
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[]);
// v:字符串数组传参
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[]);


证明了没有创建新的进程,俩个文件的进程pid始终是相同的
示例

execl
execl("/bin/ls","ls","-l","-a",NULL);
- l:列表传参,一个个写
- 必须写完整路径
/bin/ls - 使用父进程默认环境变量 environ
execv
char* argv[]={"ls","-l","-a",NULL};
execv("/bin/ls",argv);
‑ v:数组传参
‑ 必须写完整路径
‑ 使用父进程默认环境
execlp
execlp("ls","ls","-l","-a",NULL);
l:列表传参
‑ p:自动去 PATH 找程序,不用写/bin/
‑ 使用父进程默认环境
execvp
char* argv[]={"ls","-l","-a",NULL};
execvp("ls",argv);
‑ v:数组传参
‑ p:PATH 搜索,只写程序名即可
‑ 使用父进程默认环境
execvpe
char* argv[]={"./other",NULL};
char* env[]={"HELLO=world",NULL};
execve("./other",argv,env);
‑ v:数组
‑ p:PATH 搜索
‑ e:自定义环境
‑ 不是标准 POSIX 函数,Ubuntu/gcc 需要 #define _GNU_SOURCE 消除警告
| 函数 | 传参方式 | PATH 搜索 (p) | 自定义环境 (e) | |
| execl | 列表 l | ❌ | ❌ | |
| execv | 数组 v | ❌ | ❌ | |
| execlp | 列表 l | ✅ | ❌ | |
| execvp | 数组 v | ✅ | ❌ | |
| execve | 数组 v | ❌ | ✅ | |
| execvpe | 数组 v | ✅ | ✅ | |
putenv
作用:修改当前进程(父进程)自己的环境变量,fork 出来的子进程默认就会继承这份环境。
对比 execve/execvpe:
execve(...,env):给子进程单独传一套全新环境,不和父进程共享putenv():修改父进程自身环境,子进程继承父进程环境
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
extern char **environ;
int main()
{
//设置环境变量
putenv("HELLO=world");
pid_t id = fork();
if(id == 0)
{
//子进程继承父进程putenv设置好的环境
execl("./other","./other",NULL);
perror("execl");
exit(1);
}
else
{
waitpid(id,NULL,0);
}
return 0;
}
other.c打印environ,就能看到HELLO=world。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)