Linux 高级编程:进程管理入门与常用系统调用详解
前言
在 Linux 学习中,进程管理是一个非常重要的部分。很多初学者在刚接触时,往往会觉得 fork()、exec()、wait() 这些函数名字多、概念杂、调用流程也不直观。实际上,只要把它们放到“进程是如何创建、运行、替换和回收”的主线上去理解,整个知识体系就会清晰很多。
这篇文章结合课堂笔记,对 Linux 中的进程管理基础知识做一次系统整理,尽量用通俗的方式讲清楚这些常见概念和系统调用的作用与联系。

图 1:程序是静态文件,进程是程序运行后的动态实体。
一、什么是进程
1. 进程的基本概念
进程可以理解为“正在运行中的程序”。
程序本身只是存放在磁盘中的一组指令和数据,是静态的;而进程是程序被加载到内存后,由操作系统分配资源并执行起来的动态实体。
简单来说:
- 程序是静态文件
- 进程是程序的一次运行过程
例如,我们写了一个 test.c,编译后得到一个可执行文件 test。这个 test 只是程序;当你在终端执行 ./test 时,系统才真正创建出一个进程。
2. 并发的直观理解
在操作系统中,单核 CPU 在某一个时刻通常只能真正执行一个进程。但为什么我们感觉很多程序像是在同时运行?
原因在于:
- 操作系统会在多个进程之间快速切换
- 每个进程分到很短的时间片
- 切换速度非常快,人眼感知不到
所以我们才会感觉多个程序在“同时执行”,这就是并发执行的直观效果。
二、程序和进程的存储结构
理解进程管理,必须先理解程序在磁盘上的结构,以及进程在内存中的布局。
1. 可执行文件的基本结构
一个可执行程序通常会包含以下几个主要部分:
text段:代码区,存放可执行指令data段:已初始化全局变量和已初始化静态变量bss段:未初始化全局变量和未初始化静态变量
这几个区域共同构成了程序文件的重要内容。
其中:
- 代码区中除了机器指令,通常还会涉及只读常量
data段中存放已经赋初值的数据bss段中存放未赋初值的数据
2. 进程的内存结构
程序运行起来后,进程在内存中的结构会比可执行文件更完整,通常包括:
- 代码段(
text) - 已初始化数据区(
data) - 未初始化数据区(
bss) - 堆(heap)
- 栈(stack)
它们各自的作用如下:
代码段 text
用于存放程序的机器指令,通常是只读的。
已初始化数据区 data
存放已经初始化的全局变量和静态变量。
未初始化数据区 bss
存放未初始化的全局变量和静态变量。
堆 heap
用于动态内存分配,例如 malloc() 申请的空间就来自堆区。
堆的特点是:
- 由程序员手动申请和释放
- 如果不释放,可能导致内存泄漏
- 一般向高地址方向增长
栈 stack
用于存放:
- 局部变量
- 函数参数
- 返回地址
栈的特点是:
- 由编译器自动管理
- 函数调用结束后自动回收
- 一般向低地址方向增长
3. 一个常见对应关系
可以这样记忆进程各区域存放的内容:
- 栈:局部变量、函数参数、返回值相关信息
- 堆:动态分配的内存
bss:未初始化的全局变量和静态变量data:已初始化的全局变量和静态变量text:可执行代码
这部分内容看似偏理论,但它和后面 fork() 的行为密切相关,因为创建子进程时,本质上要考虑这些内存空间如何被继承。

图 2:程序运行后,除了原有的 text、data、bss,还会在内存中形成堆和栈,组成完整的进程地址空间。
三、使用 fork 创建子进程
1. fork 的作用
在 Linux 中,创建新进程最常用的系统调用就是:
pid_t fork(void);
调用 fork() 后,当前进程会复制出一个新的进程,这个新进程称为子进程,原来的进程称为父进程。
2. fork 的返回值
fork() 的返回值非常重要:
- 返回 -1:创建失败
- 返回 0:当前代码运行在子进程中
- 返回一个正数:当前代码运行在父进程中,返回值是子进程的 PID
因此,fork() 后最常见的写法就是根据返回值判断父子进程的执行分支。
例如:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
} else if (pid == 0) {
printf("我是子进程,pid = %d,父进程 pid = %d\n", getpid(), getppid());
} else {
printf("我是父进程,pid = %d,子进程 pid = %d\n", getpid(), pid);
}
return 0;
}
3. fork 之后的执行特点
fork() 调用成功后,会出现两个几乎一样的执行流:
- 父进程继续向下执行
- 子进程也从
fork()返回后的位置继续向下执行
这意味着:
fork()前面的代码执行一次fork()后面的代码父子进程都会各执行一次
这也是为什么很多初学者会看到输出出现两次。
4. fork 复制了什么
从逻辑上看,子进程会继承父进程的大部分内容,例如:
- 代码段
- 数据段
- 堆
- 栈
- 文件描述符表
- 环境变量
- 当前工作目录
但要注意,父子进程虽然初始内容相似,却是两个独立的进程。后续各自修改自己的变量时,互不影响。

图 3:fork() 之后会产生两个执行流,父子进程都从 fork() 的下一条语句开始执行。
四、vfork 与 fork 的区别
1. vfork 的作用
vfork() 也是创建子进程的系统调用,它的设计初衷是提高效率。
pid_t vfork(void);
2. 为什么会有 vfork
如果子进程创建出来以后,马上就要调用 exec() 去执行另一个程序,那么先完整复制一份父进程的地址空间其实有些浪费。
因此,vfork() 的设计思想是:
- 子进程创建后,暂时共享父进程的地址空间
- 通常用于子进程紧接着调用
exec()的场景 - 减少不必要的复制开销
3. 使用 vfork 时要注意什么
vfork() 和 fork() 最大的区别在于:
fork()创建后,父子进程拥有相对独立的地址空间vfork()创建后,子进程暂时和父进程共享地址空间
因此,使用 vfork() 时要格外小心:
- 子进程不要随意修改变量
- 子进程不要轻易返回父函数
- 一般应该尽快调用
exec()或_exit()
否则可能破坏父进程的数据,带来不可预期的问题。
五、exec 函数族:让进程执行另一个程序
1. exec 的核心作用
创建子进程后,很多时候我们并不是想让它继续执行和父进程一样的代码,而是希望它去执行一个全新的程序。
这时候就要用到 exec 函数族。
它的本质作用是:
用一个新的程序替换当前进程的代码和数据。
注意这里的关键词是“替换”:
- 调用
exec()后,并不会创建新进程 - 进程的 PID 通常不变
- 变的是这个进程内部执行的程序内容
2. 常见的 exec 函数族
常见成员包括:
execlexeclpexecvexecvpexecve
其中最核心、最底层的是:
int execve(const char *pathname, char *const argv[], char *const envp[]);
参数含义:
pathname:要执行的程序路径argv[]:传给新程序的参数列表envp[]:传给新程序的环境变量
3. exec 的典型使用场景
最常见的流程是:
- 父进程调用
fork() - 创建子进程
- 子进程调用
exec()执行另一个程序 - 父进程继续负责等待或其他控制逻辑
例如:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
} else if (pid == 0) {
char *argv[] = {"ls", "-l", "-h", NULL};
execv("/bin/ls", argv);
perror("execv");
exit(1);
} else {
printf("父进程继续执行,子进程 pid = %d\n", pid);
}
return 0;
}
4. exec 调用成功后会怎样
如果 exec() 调用成功:
- 原来的程序代码不会继续执行
exec()后面的语句也不会再运行
只有调用失败时,exec() 才会返回,通常返回 -1,并设置错误信息。
所以在实际编程中,exec() 后面一般要紧跟错误处理代码。

图 4:多数多进程程序都遵循“fork -> exec -> wait/waitpid”这条主线。
六、孤儿进程与僵尸进程
这是进程管理里非常经典、也非常容易混淆的两个概念。
1. 孤儿进程
如果父进程先于子进程结束,子进程还在运行,那么这个子进程就会变成孤儿进程。
这时系统会把它交给 init 或其他系统进程接管,由新的“养父进程”负责后续管理和回收。
孤儿进程本身并不是错误,它只是失去了原来的父进程,但仍然可以继续正常运行。
2. 僵尸进程
如果子进程已经结束运行,但父进程没有及时回收它的退出信息,那么子进程的 PCB 等内核资源不会立即释放,这种状态下的子进程就称为僵尸进程。
可以简单理解为:
- 子进程“人已经结束了”
- 但“户口信息”还留在系统里
- 父进程必须调用
wait()或waitpid()去回收
如果父进程长期不回收,系统中就可能累积大量僵尸进程,浪费进程表资源。
3. 二者的区别
可以这样记:
- 父进程先死,子进程还活着:孤儿进程
- 子进程先死,父进程不回收:僵尸进程
这个区别一定要分清。

图 5:孤儿进程是“子进程还活着但父进程没了”,僵尸进程是“子进程已经结束但父进程还没回收”。
七、wait:阻塞方式回收子进程
1. 为什么要回收子进程
当子进程结束时,操作系统会保留它的一些退出信息,以便父进程获取子进程的运行结果。
如果父进程不去取这些信息,子进程就会进入僵尸状态。因此,父进程通常需要显式回收子进程。
2. wait 的基本用法
最常用的回收函数之一是:
pid_t wait(int *status);
它的作用是:
- 阻塞父进程
- 直到某个子进程结束
- 回收该子进程资源
- 返回被回收子进程的 PID
如果不关心退出状态,也可以写成:
wait(NULL);
3. wait 的特点
wait() 的主要特点是:
- 阻塞调用
- 只能等待任意一个子进程结束
- 适合子进程数量不多、控制要求不高的场景
例如:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("子进程开始运行\n");
sleep(2);
printf("子进程结束\n");
exit(0);
} else if (pid > 0) {
wait(NULL);
printf("父进程完成回收\n");
}
return 0;
}
八、waitpid:更灵活的进程回收方式
1. waitpid 的基本原型
相比 wait(),waitpid() 更灵活:
pid_t waitpid(pid_t pid, int *status, int options);
参数含义如下:
pid:指定等待哪个子进程status:保存子进程退出状态options:控制等待方式,例如是否非阻塞
2. pid 参数的常见取值
pid 可以有不同含义:
pid > 0:等待指定 PID 的子进程pid = -1:等待任意子进程,效果类似wait()pid = 0或其他负值:可用于进程组控制
初学阶段重点掌握前两个即可。
3. 非阻塞回收
waitpid() 的一个非常常见的选项是:
WNOHANG
表示:
- 如果当前没有子进程退出,不阻塞
- 立即返回
这对一些需要“父进程继续做别的事,同时顺便检查子进程是否结束”的场景非常有用。
例如:
pid_t ret = waitpid(-1, NULL, WNOHANG);
如果返回值为:
> 0:成功回收了某个子进程0:当前没有已退出的子进程-1:调用失败或没有子进程可回收
4. waitpid 比 wait 更适合什么场景
当你需要:
- 指定等待某个子进程
- 同时管理多个子进程
- 采用非阻塞方式检查子进程状态
就更适合使用 waitpid()。

图 6:wait() 更简单,waitpid() 更灵活,尤其适合多个子进程或非阻塞回收场景。
九、如何获取子进程的退出状态
仅仅知道子进程结束了还不够,很多时候我们还想知道:
- 它是正常退出还是异常终止
- 它的退出码是多少
- 它是不是被信号杀死的
这就需要分析 status。
1. 判断是否正常退出
WIFEXITED(status)
如果结果为真,表示子进程是正常结束的。
2. 获取正常退出码
WEXITSTATUS(status)
只有在 WIFEXITED(status) 为真时,这个值才有意义。
3. 判断是否被信号终止
WIFSIGNALED(status)
如果结果为真,表示子进程是因为信号而异常终止的。
4. 获取终止它的信号编号
WTERMSIG(status)
只有在 WIFSIGNALED(status) 为真时,这个值才有意义。
5. 一个简单示例
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
exit(5);
} else if (pid > 0) {
int status;
wait(&status);
if (WIFEXITED(status)) {
printf("子进程正常退出,退出码 = %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("子进程异常终止,信号编号 = %d\n", WTERMSIG(status));
}
}
return 0;
}
十、这些系统调用之间的关系
把这几个函数连起来看,Linux 中最常见的进程控制流程其实非常清楚:
- 父进程通过
fork()创建子进程 - 子进程根据需要调用
exec()执行新的程序 - 父进程通过
wait()或waitpid()回收子进程 - 如果父进程不回收,就可能产生僵尸进程
- 如果父进程提前结束,子进程可能成为孤儿进程
因此可以把这一章归纳成一句话:
进程管理的核心,就是“创建进程、组织进程、替换进程、回收进程”。
十一、学习这一章时最容易混淆的点
1. fork() 会不会执行两次整个程序?
不会。
fork()前面的代码执行一次fork()后面的代码父子进程各执行一次
2. exec() 会不会创建新进程?
不会。
exec() 只是用新程序替换当前进程的内容,并不会额外创建一个新进程。
3. wait() 和 waitpid() 的区别是什么?
wait()更简单,但一般是阻塞式等待任意子进程waitpid()更灵活,可以指定子进程,还可以设置非阻塞模式
4. 孤儿进程和僵尸进程哪个更危险?
通常僵尸进程更需要警惕,因为它会占用系统中的进程表资源。孤儿进程则会被系统接管,一般不会无限堆积。
十二、总结
这一章表面上看函数很多,但主线并不复杂。
我们先从“什么是进程”出发,理解程序和进程的区别;然后通过进程内存结构,了解一个进程运行时大致长什么样;接着学习 fork() 和 vfork() 如何创建子进程,再理解 exec() 如何让进程执行新的程序;最后通过 wait() 和 waitpid() 完成子进程的回收,并区分孤儿进程与僵尸进程这两个经典概念。
如果把这些知识点串起来,你会发现 Linux 进程管理其实就是一个完整的生命周期问题:
- 创建
- 运行
- 替换
- 结束
- 回收
掌握这条主线后,后续再去学习进程间通信、信号、守护进程、多进程服务器模型,就会顺畅很多。
参考建议
如果你刚学完这一章,建议至少亲手练习下面几个小程序:
- 一个最基础的
fork()示例 - 一个
fork() + exec()示例 - 一个
wait()回收子进程的示例 - 一个
waitpid(..., WNOHANG)的非阻塞回收示例 - 一个演示僵尸进程现象的小程序
只有把这些例子自己敲一遍,才会真正理解父子进程的执行顺序和系统调用的作用。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)