进程创建

进程的创建通常就是使用fork()函数进行进程的创建。

进程调⽤ fork ,当控制转移到内核中的 fork 代码后,内核做:

• 分配新的内存块和内核数据结构给⼦进程

• 将⽗进程部分数据结构内容拷⻉⾄⼦进程

• 添加⼦进程到系统进程列表当中

• fork 返回,开始调度器调度

fork()函数的返回值

• ⼦进程返回0

• ⽗进程返回的是⼦进程的pid。

写时拷贝

1. fork 创建子进程,拷贝虚拟地址空间与页表

  1. 内核新建子进程 PCB,完整复制父进程的虚拟地址空间布局、页表;
  2. 子进程拥有和父进程完全一致的虚拟地址,&var 打印值一模一样;
  3. 不拷贝物理内存,父子进程暂时共享父进程原本的所有物理页。

2. 关键一步:修改两个进程页表权限

内核把父子进程中所有可读写的共享物理页,统一标记为 只读(W=0)。

  • 只读段 .rodata 本来就是只读,不受影响;
  • 栈、堆、data、bss 这些原本可写的页面,临时变成只读。

目的:靠 MMU 硬件拦截写操作,监测谁要修改内存。

内存的映射关系:

父进程虚拟A地址 ──┐
                   └──→ 同一块物理内存页
子进程虚拟A地址 ──┘
页表权限:R=1 W=0

3. 任意进程执行「写操作」(变量赋值)触发缺页异常

父进程 / 子进程只要修改共享页面里的变量:

  1. CPU MMU 检测到:当前页表无写权限;
  2. 触发 缺页异常 Page Fault,陷入内核态;
  3. 内核识别:这是 COW 共享页,不是真的权限错误。

4. 内核执行复制操作(真正拷贝内存)

  1. 分配一块全新的物理内存页;
  2. 把旧物理页的数据完整拷贝到新页面;
  3. 修改执行写入操作的那个进程的页表: 同一个虚拟地址,重新映射到新物理页,页表权限恢复为可写 (W=1);
  4. 另一个进程页表保持不变,依旧指向原始物理页。

修改后的内存映射:

父进程虚拟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;
}

输出特征:

  1. 父子 &num 虚拟地址完全相同;
  2. 变量值互不干扰; 底层:子进程写 num 触发异常,单独复制一页物理内存。

特殊页面不参与 COW

  1. .rodata 只读常量页:本身永久只读,不会共享可写,写直接段错误,不走 COW;
  2. 内核空间页面:用户进程无法修改,不参与 COW;
  3. 执行 exec 后:子进程抛弃原有虚拟地址空间,加载新程序,所有共享关系直接失效。

COW 带来的核心优势

  1. fork 极速完成 创建子进程只复制页表(很小的数据结构),不拷贝物理内存,瞬间完成;
  2. 内存占用极低 父子大量页面共享同一块物理内存,不会双倍占用内存;
  3. 完美适配 fork + exec 场景 子进程 fork 后马上 exec 加载新程序,全程不会修改任何共享页,完全不需要拷贝任何物理内存,性能拉满。

进程终止

进程终止就是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。

正常终止(主动、预期退出)

  1. main 函数 return main 返回等价调用 exit(),刷新缓冲区、执行退出清理。
  2. 调用库函数 exit(int status)
    • 先执行 atexit() 注册的钩子函数;
    • 刷新 stdio 缓冲区;
    • 调用系统调用 _exit() 进入内核。
  3. 系统调用 _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) 完整优雅退出

执行步骤固定:

  1. 倒序运行所有 atexit 注册的清理函数
  2. 刷新全部 IO 缓冲区,把屏幕 / 文件没写完的数据落盘
  3. 清除临时文件
  4. 调用 _exit () 通知内核终止进程

适用场景:普通程序正常结束,需要完整释放资源。

 _exit (int status) 暴力内核退出

直接调用操作系统内核接口,跳过上面所有步骤:

  • 不执行 atexit 清理函数
  • 不刷新 IO 缓冲区(cout/printf 缓存数据直接丢失)
  • 不处理临时文件
  • 直接销毁进程,把退出码传给父进程

适用场景:fork() 创建的子进程退出(避免重复执行父进程 atexit 逻辑)。

进程等待

为什么需要进程等待?

1. 僵尸进程问题

fork() 创建子进程后: 子进程先结束、父进程还在运行,子进程资源不会自动释放,PCB 留在系统中,变成僵尸进程。 子进程退出状态会一直保存,等待父进程读取。

2. 进程等待的作用
  1. 父进程回收子进程资源,消灭僵尸进程;
  2. 获取子进程的退出码(判断子进程是正常结束 / 异常崩溃)。
3. 两种等待接口
  • wait():阻塞等待任意一个子进程退出
  • waitpid():精准控制,可指定等待某个子进程、非阻塞轮询

获取⼦进程status

• wait和waitpid,都有⼀个status参数,该参数是⼀个输出型参数,由操作系统填充。

• 如果传递NULL,表⽰不关⼼⼦进程的退出状态信息。

• 否则,操作系统会根据该参数,将⼦进程的退出信息反馈给⽗进程。

本质:int 整型变量,存储子进程退出全部信息

wait(&status) 会把子进程结束的完整状态数据写到 status 内存里,它不是单纯存退出码,是一个按位封装的整数,里面包含两类信息:

  1. 子进程是正常 exit () 退出,还是被信号杀死;
  2. 如果正常退出:exit (数字) 里的退出码;
  3. 如果被信号杀死:是几号信号干掉的进程。

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 —— 等待模式选项(阻塞 / 非阻塞等)

选项可以多个值按位或 |组合。最常用常量:

  1. 0 默认行为:阻塞等待。父进程卡住休眠,直到目标子进程退出,函数才返回。

  2. WNOHANG (No Hang,非阻塞)

    核心!不会阻塞父进程。 如果目标子进程还没有退出,waitpid立刻返回0,父进程可以继续干别的事。 一般配合循环轮询检测子进程状态。

  3. WUNTRACED 等待收到暂停信号(如 SIGSTOP)而被挂起的子进程。

  4. 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。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐