在Linux系统编程中,进程控制是最基础也是最重要的部分之一。本文将系统梳理进程控制的核心知识点,从缓冲区机制到虚拟内存,从fork函数到僵尸进程回收,帮助你全面掌握Linux进程编程的关键内容。

一、标准I/O缓冲区与进程退出

1.1 缓冲区类型

标准I/O库为了提高效率引入了缓冲区机制,数据不会立即写入设备,而是先存入缓冲区。主要有三种类型:
  • 全缓冲:缓冲区满、调用fflush或程序正常退出时刷新。磁盘文件默认使用全缓冲。
  • 行缓冲:遇到换行符\n、缓冲区满或调用fflush时刷新。终端标准输出stdout默认行缓冲。
  • 无缓冲:数据立即写入设备。标准错误输出stderr默认无缓冲,保证错误信息及时显示。

1.2 exit() vs _exit()

函数对比

特性
exit()
_exit()
类型
C 标准库函数
系统调用
头文件
<stdlib.h>
<unistd>
刷新缓冲区
✅ 会
❌ 不会
执行清理函数
✅ 会
❌ 不会
关闭文件流
✅ 会
❌ 不会

执行流程

exit() 执行流程:
用户态:执行 atexit() 注册的函数 → 刷新所有缓冲区 → 关闭文件流
内核态:调用 _exit() 回收进程资源
 
_exit() 执行流程:
直接进入内核态 → 回收进程资源(PCB、文件描述符)→ 返回退出状态给父进程

二、虚拟内存与地址空间

2.1 逻辑地址vs物理地址

  • 逻辑地址(虚拟地址):进程运行时操作系统分配的地址,仅在本进程内有效。32位系统每个进程有4GB独立虚拟地址空间。让每个进程都感觉自己独占整个完整的内存地址空间,互不干扰
  • 物理地址:内存硬件的实际地址,由内存控制器和CPU使用。
早期直接使用物理地址存在三大问题:内存冲突、内存碎片浪费、安全性低。虚拟地址空间通过地址隔离动态映射完美解决了这些问题。

2.2 MMU内存管理单元

MMU是硬件单元,负责:
  • 虚拟地址到物理地址的映射
  • 内存访问权限检查
每个进程有独立的页表,因此不同进程的相同虚拟地址会映射到不同的物理地址,实现了进程间的地址隔离。

三、进程基础概念

3.1 程序vs进程

  • 程序:存储在磁盘上的二进制文件,不占用系统资源,无生命周期。
  • 进程:运行中的程序,是操作系统分配资源的基本单位,有独立的地址空间和生命周期。
关系:一个程序可以对应多个进程,一个进程只能对应一个程序。

3.2 常用进程操作命令

  • ps aux:查看系统中所有进程的详细信息
  • ps ajx:查看进程树,显示进程间的父子关系
  • kill -9 pid:发送SIGKILL信号,强制杀死进程,不清理资源
  • kill -15 pid:发送SIGTERM信号,优雅终止进程,让进程正常退出,清理资源
  • ulimit -a:查看进程资源限制(如最大打开文件数、最大进程数等)

3.3 环境变量

使用getenv(const char *name)函数获取环境变量的值,例如:
#include <stdio.h>
#include <stdlib.h>

int main() {
    printf("HOME: %s\n", getenv("HOME"));
    return 0;
}

四、fork函数详解

#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

4.1 fork的返回值

fork()函数用于创建一个新的子进程,它有两个返回值:
  • 失败:返回-1
  • 成功:父进程返回子进程的PID,子进程返回0
注意fork之后,父子进程从fork调用处开始执行各自的代码。
 
在循环中或代码的不同分支中调用fork函数,会形成 “进程树” 结构:父进程会创建新的子进程,而已存在的子进程也可能创建自己的 “孙子进程”。

4.2 写时复制(COW)机制

fork创建子进程时,不会立即复制父进程的整个地址空间,而是让父子进程共享相同的物理内存页。只有当任一进程尝试修改内存时,才会为修改的页创建独立副本。
 
这就是为什么父子进程的全局变量修改互不影响:
 
int var = 100;

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        var = 1000; // 触发写时复制,子进程有了自己的var副本
        printf("Child var: %d\n", var); // 输出1000
    } else {
        sleep(1); // 等待子进程修改
        printf("Parent var: %d\n", var); // 输出100
    }
    return 0;
}

根本原因就是:进程拥有独立的虚拟地址空间 + 写时复制(COW)机制。

坑点:在父子进程中分别进行var变量地址打印时,会发现两个打印的地址值相同。但是明明每个进程拥有独立的虚拟地址,为什么变量的地址会相同?

 

每个进程都"认为"自己独占整个地址空间,所以编译器为所有进程生成相同的地址布局。但是内核会为每个进程维护独立的页表,所以相同的虚拟地址映射到不同的物理内存。

eg:

        进程A的虚拟地址 0x1234  →  物理内存地址 0xABCD
        进程B的虚拟地址 0x1234  →  物理内存地址 0x5678

虽然两个进程都看到 0x1234,但它们指向的完全是不同的物理内存单元。

一句话总结:虚拟地址是进程私有的"门牌号",相同的门牌号指向不同房子里的房间。

4.3 父子进程资源共享

fork后,父子进程共享和不共享的资源如下:
资源类型
共享情况
说明
代码段
共享
只读,无需复制
数据段/堆/栈
写时复制
初始共享,修改后独立
文件描述符表
共享
指向同一文件表项,共享文件偏移量
工作目录/根目录
继承(可独立修改)
子进程可通过chdir切换
UID/GID/权限
继承(可独立修改)
可通过setuid变更
信号处理方式
继承(可独立修改)
初始一致,后续修改互不影响
PID/PPID
不共享
子进程有独立PID
文件锁
不共享
子进程需重新申请

五、孤儿进程与僵尸进程

5.1 孤儿进程

  • 定义:父进程先于子进程退出,子进程成为孤儿进程。
  • 处理:孤儿进程会被init进程(或systemd --user进程)领养,由init进程负责回收其资源。
  • 特点孤儿进程不会造成系统资源泄漏。

5.2 僵尸进程

  • 定义:子进程先于父进程退出,但父进程没有调用wait()或waitpid()回收子进程的资源,子进程的PCB仍残留在系统中。
  • 危害僵尸进程会占用系统资源(主要是PID),如果大量产生会导致系统无法创建新进程。
  • 产生原因:子进程退出时,内核会向父进程发送SIGCHLD信号,但父进程默认忽略该信号,不会主动回收资源。

僵尸进程中,为什么父进程还活着,却不能回收已死的子进程的资源?

 

 

子进程结束时,内核会向其父进程发送 SIGCHLD信号,并将子进程的状态信息保留在进程表项中,此时子进程进入僵尸状态。

内核只负责记录状态,不会自动清理子进程资源。因为退出状态可能对父进程有用,必须由父进程主动调用 wait()或 waitpid()来获取状态并回收资源。

 

 

默认情况下,父进程忽略 SIGCHLD 信号,如果父进程没有显式设置 SIGCHLD 信号的处理函数,那么即便子进程结束并发送了 SIGCHLD 信号,父进程收到了 SIGCHLD 信号,但因为默认是忽略处理,所以不会执行回收子进程的代码,也不会主动调用 wait()/waitpid() 等回收函数。因此即使收到信号也不会回收子进程。

 

 

即便父进程设置了信号处理函数,若函数中未调用 wait() / waitpid(),依然无法回收资源。

六、子进程资源回收

6.1 wait函数

#include <sys/wait.h> 
pid_t wait(int *wstatus);
  • 功能阻塞等待任意一个子进程退出,回收其资源。
  • 参数:wstatus是传出参数,用于获取子进程的退出状态。
  • 返回值:成功返回终止子进程的PID,失败返回-1。
判断子进程死亡原因
 
  • WIFEXITED(wstatus):如果子进程正常退出,返回真。此时用WEXITSTATUS(wstatus)获取退出状态码。
  • WIFSIGNALED(wstatus):如果子进程被信号杀死,返回真。此时用WTERMSIG(wstatus)获取信号编号。

6.2 waitpid函数

pid_t waitpid(pid_t pid, int *wstatus, int options);
比wait更灵活,可以指定等待的子进程和等待方式。
 
参数说明
  • pid:
    • pid > 0:等待指定PID的子进程
    • pid = -1:等待任意子进程(同wait)
    • pid = 0:等待同一进程组的所有子进程
    • pid < -1:等待进程组ID为|pid|的任意子进程
  • options:
    • 0:阻塞等待(同wait)
    • WNOHANG:非阻塞等待,如果没有子进程退出立即返回0
返回值
  • 成功:返回退出子进程的PID
  • 设置WNOHANG且无退出子进程:返回0,表示还有子进程在运行,但是暂时没有子进程退出
  • 失败:返回-1,本质是系统调用出错,错误码是 ECHILD ,含义就是:当前父进程已经没有任何子进程了,所以直接返回-1结束循环

6.3 回收多个子进程

使用wait回收
// 创建5个子进程
for (int i = 0; i < 5; i++) {
    if (fork() == 0) {
        printf("Child %d\n", getpid());
        exit(0);
    }
}

// 父进程回收所有子进程
for (int i = 0; i < 5; i++) {
    pid_t wpid = wait(NULL);
    printf("Reaped: %d\n", wpid);
}
使用waitpid非阻塞回收
while (1) {
    pid_t wpid = waitpid(-1, NULL, WNOHANG);
    if (wpid == -1) {
        // 没有子进程了,退出循环
        break;
    } else if (wpid > 0) {
        printf("Reaped: %d\n", wpid);
    }
    // 没有子进程退出,做其他事情
    sleep(1);
}

七、exec族函数(进程替换)

7.1 核心作用(必须先懂)

调用exec后,当前进程的代码、数据等被新程序覆盖,PID 不变,但进程的 “内容” 完全变成新程序。从exec族函数开始,原程序下面的代码都不会被执行

返回值与错误处理
  • 成功时不会返回(因为进程已被替换)。
  • 失败时返回 -1,并设置 errno(如文件不存在、权限不足等)。因此,exec 后的代码仅在失败时执行,通常用于处理错误。

7.2 最常用的两个exec函数

1. execlp(列表传参,自动找命令)

#include <unistd.h>
int execlp(const char *file,const char arg,.../*(char *) NULL */);
  • file: 要执行的程序
  • arg:传递给新程序的第一个参数,一般来说,这个参数通常是要执行的程序名本身
  • ...:可变参数列表,用于传递其他参数,参数以 NULL 结尾来标识参数列表的结束。
//替换原进程的内容,执行ls -l命令
execlp("ls", "ls", "-l", NULL);

2. execvp(数组传参,自动找命令)

int execvp(const char *file, char *const argv[]);
  • 第一个参数:要执行的命令
    • 直接写:"ls" "pwd" "cat"
    • 不用写绝对路径!
  • 第二个参数:命令参数数组
    • 格式:{命令名, 参数1, 参数2, ..., NULL}
    • 必须以 NULL 结尾!
//替换原进程的内容,执行ls -l命令
char *argv[] = {"ls", "-l", NULL};
execvp("ls", argv);

总结

本文详细讲解了Linux进程控制的核心知识,包括:
  1. 标准I/O缓冲区机制和exit/_exit的区别
  2. 虚拟内存和写时复制机制
  3. 程序与进程的区别及常用操作命令
  4. fork函数的工作原理和父子进程资源共享
  5. 孤儿进程和僵尸进程的产生原因
  6. wait和waitpid函数回收子进程资源
  7. exec族函数替换进程内容
掌握这些知识是进行Linux系统编程的基础,后续我们还会深入学习进程间通信、信号处理等高级内容。
 

 

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐