前言
进程管理是 Linux 系统编程的核心骨架,也是后端、嵌入式、运维岗位的必学知识。很多初学者觉得进程抽象难懂:为什么 fork() 调用一次会返回两次?僵尸进程到底是什么?exit 和 _exit 有啥区别?信号又是怎么一回事?
本文将沿着「认识进程 → 创建进程 → 等待回收 → 终止退出 → 错误处理 → 信号交互 → 程序替换 → 多进程实战」的完整学习路径,用通俗的比喻、可直接运行的代码、可复现的实验和直观的原理示意图,把所有知识点讲透。哪怕你只有基础的 C 语言功底,也能一步步吃透 Linux 进程管理的全部核心内容。
0. 前置铺垫:到底什么是进程?
在正式讲解之前,我们先把最基础的概念讲清楚,避免后面越听越懵。
0.1 程序 vs 进程
- 程序 :就是存放在磁盘上的可执行文件(比如
ls、你编译出来的a.out),是一堆静态的指令和数据,不占 CPU、不占运行内存。 - 进程 :是运行起来的程序,是操作系统分配资源(CPU、内存、文件)的基本单位。同一个程序可以同时运行多个进程(比如开两个终端,就是两个 bash 进程)。
可以简单理解:程序是菜谱,进程是按照菜谱炒菜的过程;菜谱是静态的,炒菜是动态的,会占用灶台、食材、人力。
0.2 进程的身份证:PCB 与 PID
Linux 内核用一个叫 task_struct(进程控制块,简称 PCB)的结构体来描述一个进程的全部信息,相当于进程的「档案袋」,里面记录了:
- 进程 ID(PID):进程的唯一身份证号,是一个正整数
- 进程状态(运行、等待、停止、僵尸等)
- 虚拟地址空间信息
- 文件描述符表
- 优先级、调度信息
- 信号处理配置
- ......
我们平时操作进程,本质都是通过 PID 找到对应的 PCB,再修改或读取里面的信息。
0.3 查看进程的基础命令
bash
ps aux # 查看系统所有进程
ps ajx # 查看进程的父子关系(PPID 是父进程ID)
top / htop # 动态查看进程状态
kill -9 PID # 强制杀死指定 PID 的进程
一、进程创建:fork() 的分身术
fork() 是 Linux 创建进程的核心系统调用,它的特点是:调用一次,返回两次,就像细胞分裂------一个细胞分裂成两个完全一样的细胞,各自独立生活。
1.1 函数原型与返回值
c
#include <unistd.h> // 头文件
pid_t fork(void);
pid_t 本质就是整数类型,专门用来存进程 ID。调用后有三种返回值:
- 父进程中返回 >0 的整数:这个值就是刚创建的子进程的 PID
- 子进程中返回 0:子进程通过返回值 0 识别自己的身份
- 出错返回 -1 :比如系统进程数达到上限、内存不足,同时设置
errno标记错误原因
通俗理解:爸爸生了儿子,爸爸知道儿子的身份证号(返回子PID),儿子自己的身份标记是0,这样父子俩就能走不同的代码分支。
1.2 第一个 fork 程序
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
printf("进程创建前,我是父进程,PID=%d\n", getpid());
pid_t pid = fork(); // 调用fork,从这里开始父子进程分道扬镳
if (pid == -1) {
perror("fork失败");
return 1;
} else if (pid == 0) {
// 子进程执行的代码
printf("【子进程】PID=%d, 父进程PPID=%d, fork返回值=%d\n",
getpid(), getppid(), pid);
} else {
// 父进程执行的代码
printf("【父进程】PID=%d, 我创建的子进程PID=%d\n", getpid(), pid);
}
// 父子进程都会执行到这里
printf("PID=%d 程序结束\n", getpid());
return 0;
}
编译运行:
bash
gcc fork_demo.c -o fork_demo
./fork_demo
预期输出(顺序可能不一样,因为父子执行顺序由调度器决定):
进程创建前,我是父进程,PID=1234
【父进程】PID=1234, 我创建的子进程PID=1235
PID=1234 程序结束
【子进程】PID=1235, 父进程PPID=1234, fork返回值=0
PID=1235 程序结束
1.3 核心误区澄清:子进程从哪里开始执行?
❌ 新手最高频错误认知
很多初学者会误以为:fork 创建子进程后,子进程会从 main 函数的第一行从头重新执行一遍。这是完全错误的。
✅ 正确结论
fork() 调用一次,父子进程都会从 fork 的下一行代码开始继续执行,不会回头重新运行 main 开头的代码。
执行流程示意图

在这里插入图片描述
底层原理:为什么不会从头执行?
fork 的本质是「复制当前时刻的完整进程上下文」,而不是重新加载程序:
- 父进程执行到
fork()这一行时,CPU 的**程序计数器(RIP寄存器)**已经指向了fork之后的下一条指令地址 - 子进程复制父进程的寄存器上下文时,会原样复制这个程序计数器的值
- 当子进程被调度器调度上 CPU 运行时,会直接从 RIP 指向的地址(也就是 fork 之后的代码)开始执行
通俗比喻:就像你看书看到第10页中间,复印了一本一模一样的书。复印出来的新书也是翻开在第10页中间,而不是从第1页重新开始读。
用代码一眼验证
回到上面的示例程序,运行后你会发现「进程创建前」这句话只打印了一次------因为它在 fork 之前,只有父进程执行过;子进程从 fork 之后开始,永远不会执行到这条 printf。
1.4 子进程继承了什么?独立性体现在哪里?
fork() 之后,子进程是父进程的「几乎完整副本」,但两者完全独立,就像克隆人------长得一模一样,但各过各的生活。
✅ 子进程会继承父进程的内容
- 整个虚拟地址空间(代码、数据、堆、栈)
- 文件描述符表(重点:共享文件偏移量)
- 环境变量、工作目录、信号处理方式
- 优先级、调度属性
❌ 子进程独有的内容
- 自己的 PID、PPID
- 独立的进程控制块 PCB
- 未决信号、定时器会被清空(不会继承父进程待处理的信号)
1.5 核心优化:写时复制(Copy-on-Write, COW)
很多初学者会问:fork 把整个内存都复制一遍,岂不是很慢?
内核早就做了优化:fork 之后,父子进程先共享同一份物理内存,页表标记为只读。只有当某一方尝试修改内存时,内核才会为修改的那一页复制一份独立副本。
写时复制原理示意图

- 只读的代码段:永远共享,不复制,节省内存
- 数据段、堆、栈:谁改谁复制,不改就共用
- 好处:大大降低 fork 的开销,尤其是 fork 后马上执行新程序的场景,几乎没有多余拷贝
通俗比喻:你和室友共用一份课本,不做笔记的时候就一起看;如果你要在书上写字,就自己去复印一页,没写字的页面继续共用。
1.6 动手验证:地址空间独立
c
//我们写个代码,验证父子进程修改变量互不影响:
#include <stdio.h>
#include <unistd.h>
int g_val = 100; // 全局变量
int main() {
int l_val = 200; // 局部变量
pid_t pid = fork();
if (pid == 0) {
// 子进程修改变量
g_val = 999;
l_val = 888;
printf("子进程:g_val=%d, 地址=%p | l_val=%d, 地址=%p\n",
g_val, &g_val, l_val, &l_val);
return 0;
} else {
sleep(1); // 等子进程先修改完
printf("父进程:g_val=%d, 地址=%p | l_val=%d, 地址=%p\n",
g_val, &g_val, l_val, &l_val);
return 0;
}
}
现象:
- 父子进程的变量值不一样:子进程修改不影响父进程,证明内存独立
- 变量的虚拟地址完全一样:因为是复制的虚拟地址空间,但映射到了不同的物理页
1.7 新手必知的注意事项
- 执行顺序不确定:fork 之后父子谁先跑,完全由操作系统调度器决定,不要假设父进程先执行。如果需要控制顺序,要用后面讲的等待、信号等机制。
- 文件偏移量共享:如果 fork 前打开了一个文件,父子进程写同一个文件描述符,内容会交替写入,文件偏移量会互相影响(因为内核里的文件表项是共享的)。
- 子进程不要忘记退出:如果在循环里 fork,子进程执行完任务必须调用 exit/_exit 退出,否则子进程会继续循环,再创建孙子进程,导致进程数指数爆炸(后面实战会重点讲)。
二、进程等待:回收子进程,避免僵尸进程
子进程退出后,不会立刻释放全部资源,内核会保留它的 PCB 和退出状态,等父进程来读取。如果父进程一直不读,这个进程就会变成僵尸进程。
2.1 什么是僵尸进程?有什么危害?
- 产生原因 :子进程已经终止,但父进程没有调用
wait/waitpid回收它的退出状态 - 状态标识 :用
ps命令看,状态标记为Z(Zombie) - 危害 :僵尸进程已经释放了内存、文件等大部分资源,但PID 和 PCB 还占着。如果僵尸太多,会把系统的 PID 号耗尽,导致无法创建新进程。
通俗理解:员工离职了,但人事档案还没销毁,占着工号。工号用完了,新人就没法入职了。
僵尸进程产生流程图
#mermaid-svg-AbDM4FH0HUqmwM3B{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AbDM4FH0HUqmwM3B .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AbDM4FH0HUqmwM3B .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AbDM4FH0HUqmwM3B .error-icon{fill:#552222;}#mermaid-svg-AbDM4FH0HUqmwM3B .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AbDM4FH0HUqmwM3B .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AbDM4FH0HUqmwM3B .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AbDM4FH0HUqmwM3B .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AbDM4FH0HUqmwM3B .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AbDM4FH0HUqmwM3B .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AbDM4FH0HUqmwM3B .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AbDM4FH0HUqmwM3B .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AbDM4FH0HUqmwM3B .marker.cross{stroke:#333333;}#mermaid-svg-AbDM4FH0HUqmwM3B svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AbDM4FH0HUqmwM3B p{margin:0;}#mermaid-svg-AbDM4FH0HUqmwM3B .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AbDM4FH0HUqmwM3B .cluster-label text{fill:#333;}#mermaid-svg-AbDM4FH0HUqmwM3B .cluster-label span{color:#333;}#mermaid-svg-AbDM4FH0HUqmwM3B .cluster-label span p{background-color:transparent;}#mermaid-svg-AbDM4FH0HUqmwM3B .label text,#mermaid-svg-AbDM4FH0HUqmwM3B span{fill:#333;color:#333;}#mermaid-svg-AbDM4FH0HUqmwM3B .node rect,#mermaid-svg-AbDM4FH0HUqmwM3B .node circle,#mermaid-svg-AbDM4FH0HUqmwM3B .node ellipse,#mermaid-svg-AbDM4FH0HUqmwM3B .node polygon,#mermaid-svg-AbDM4FH0HUqmwM3B .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AbDM4FH0HUqmwM3B .rough-node .label text,#mermaid-svg-AbDM4FH0HUqmwM3B .node .label text,#mermaid-svg-AbDM4FH0HUqmwM3B .image-shape .label,#mermaid-svg-AbDM4FH0HUqmwM3B .icon-shape .label{text-anchor:middle;}#mermaid-svg-AbDM4FH0HUqmwM3B .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AbDM4FH0HUqmwM3B .rough-node .label,#mermaid-svg-AbDM4FH0HUqmwM3B .node .label,#mermaid-svg-AbDM4FH0HUqmwM3B .image-shape .label,#mermaid-svg-AbDM4FH0HUqmwM3B .icon-shape .label{text-align:center;}#mermaid-svg-AbDM4FH0HUqmwM3B .node.clickable{cursor:pointer;}#mermaid-svg-AbDM4FH0HUqmwM3B .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AbDM4FH0HUqmwM3B .arrowheadPath{fill:#333333;}#mermaid-svg-AbDM4FH0HUqmwM3B .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AbDM4FH0HUqmwM3B .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AbDM4FH0HUqmwM3B .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AbDM4FH0HUqmwM3B .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AbDM4FH0HUqmwM3B .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AbDM4FH0HUqmwM3B .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AbDM4FH0HUqmwM3B .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AbDM4FH0HUqmwM3B .cluster text{fill:#333;}#mermaid-svg-AbDM4FH0HUqmwM3B .cluster span{color:#333;}#mermaid-svg-AbDM4FH0HUqmwM3B div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AbDM4FH0HUqmwM3B .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AbDM4FH0HUqmwM3B rect.text{fill:none;stroke-width:0;}#mermaid-svg-AbDM4FH0HUqmwM3B .icon-shape,#mermaid-svg-AbDM4FH0HUqmwM3B .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AbDM4FH0HUqmwM3B .icon-shape p,#mermaid-svg-AbDM4FH0HUqmwM3B .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AbDM4FH0HUqmwM3B .icon-shape .label rect,#mermaid-svg-AbDM4FH0HUqmwM3B .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AbDM4FH0HUqmwM3B .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AbDM4FH0HUqmwM3B .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AbDM4FH0HUqmwM3B :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
子进程执行完毕
内核释放内存、文件等大部分资源
保留 PCB + 退出状态,进入僵尸状态 Z
父进程调用 wait/waitpid?
读取退出状态,彻底回收 PCB 和 PID
一直保持僵尸状态
2.2 wait() 函数:阻塞等待任意子进程
c
#include <sys/wait.h>
pid_t wait(int *status);
功能
父进程调用后会阻塞,直到有任意一个子进程退出,就回收它的资源并返回。如果已经有子进程退出了,调用 wait 会立刻返回,不会阻塞。
参数
status:输出型参数,用来存子进程的退出状态。如果不关心退出原因,可以传NULL。
返回值
- 成功:返回被回收的子进程的 PID
- 失败:返回 -1(比如当前进程根本没有子进程)
2.3 waitpid() 函数:精准等待指定子进程
wait 只能等任意子进程,waitpid 功能更强,可以指定等待某个进程,还支持非阻塞模式。
c
pid_t waitpid(pid_t pid, int *status, int options);
参数详解
-
pid:指定等待哪个进程
pid > 0:等待 PID 等于这个值的指定子进程(最常用)pid == -1:等待任意子进程,和wait()功能完全一样pid == 0:等待和当前进程同组的任意子进程pid < -1:等待进程组 ID 等于-pid的任意子进程
-
options:等待选项
0:阻塞等待,和 wait 行为一致WNOHANG:非阻塞模式。如果指定的子进程还没退出,立刻返回 0,不卡住父进程。适合父进程还要干别的活,轮询检查子进程状态的场景。WUNTRACED:子进程被暂停(比如收到 SIGSTOP)也会返回WCONTINUED:子进程从暂停恢复运行也会返回
返回值
- 成功回收:返回子进程 PID
- 设置 WNOHANG 且子进程未退出:返回 0
- 出错:返回 -1
2.4 解析退出状态宏
status 不是直接的退出码,而是一个打包了多种信息的整数,必须用系统提供的宏来拆解。
2.4.1 status 底层存储结构
wait/waitpid 传出的 status 是一个 32 位整型 ,Linux 下实际只使用低 16 位来存放子进程退出信息,高 16 位保留未使用。
低 16 位又被划分为 3 个区域,分工明确:
32 位 status 整数(仅低 16 位有效)
┌───────────────────┬────────┬──────────────────┐
│ 高 16 位 │ 第8位 │ 低 8 位 │
│ 保留未使用 │ core标记│ 信号编号 │
│ │ 1bit │ 8bit │
└───────────────────┴────────┴──────────────────┘
▲
└── 第 9~15 位:正常退出码(7bit,等效 0~255)
简化记忆(最常用划分方式):
低 8 位 :记录「异常终止」的信号编号
次低 8 位(第8~15位):记录「正常退出」的退出码
两种场景拆解
-
子进程正常退出(
return/exit/_exit)低 8 位全部为
0,次低 8 位 存放进程退出码(范围0~255)。所以要用
WEXITSTATUS(status)取出次低8位的值。 -
子进程被信号杀死(异常终止)
次低 8 位全部为
0,低 8 位 存放终止它的信号编号。所以要用
WTERMSIG(status)取出低8位的值。 -
额外标记:第8位
若进程崩溃并生成
core调试文件,该位会置 1,对应宏WCOREDUMP(status)。
2.4.2 手动位运算模拟(不用宏,直观理解)
我们可以用位运算 直接拆解 status,帮你看懂系统宏的底层原理:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t pid = fork();
if (pid == 0)
{
return 42; // 正常退出,退出码 42
}
int status;
waitpid(pid, &status, 0);
// 1. 取出 低8位:信号编号
int sig = status & 0xFF;
// 2. 取出 次低8位(右移8位后再取低8位):退出码
int exit_code = (status >> 8) & 0xFF;
printf("低8位(信号):%d\n", sig);
printf("次低8位(退出码):%d\n", exit_code);
return 0;
}
运行结果:
低8位(信号):0
次低8位(退出码):42
2.4.3 结合信号终止测试
修改代码,手动发送 SIGINT(2) 信号终止子进程:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
int main()
{
pid_t pid = fork();
if (pid == 0)
{
while(1) sleep(1); // 子进程死循环
}
sleep(1);
kill(pid, SIGINT); // 发送 2 号信号杀死子进程
int status;
waitpid(pid, &status, 0);
int sig = status & 0xFF;
int exit_code = (status >> 8) & 0xFF;
printf("低8位(信号):%d\n", sig);
printf("次低8位(退出码):%d\n", exit_code);
return 0;
}
运行结果:
低8位(信号):2
次低8位(退出码):0
2.4.4 系统宏的本质
现在你就能明白,之前的判断宏只是封装好的位运算:
| 系统宏 | 等价位运算 | 作用 |
|---|---|---|
WIFEXITED(status) |
(status & 0xFF) == 0 |
判断是否正常退出 |
WEXITSTATUS(status) |
(status >> 8) & 0xFF |
获取正常退出码 |
WIFSIGNALED(status) |
(status & 0xFF) > 0 |
判断是否被信号终止 |
WTERMSIG(status) |
status & 0xFF |
获取终止信号编号 |
总结口诀:
正常退出看高半字节(次低8位),异常终止看低半字节(低8位)
完整示例:回收并打印退出信息
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("子进程运行,PID=%d\n", getpid());
sleep(2);
return 66; // 正常退出,退出码66
}
int status;
pid_t ret = waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("子进程 %d 正常退出,退出码:%d\n", ret, WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("子进程 %d 被信号杀死,信号编号:%d\n", ret, WTERMSIG(status));
}
return 0;
}
你可以试试在子进程运行时用 kill -9 杀死它,看看输出的信号编号是不是 9。
2.5 其他避免僵尸进程的方法
除了主动 wait,还有两种常用方式:
-
忽略 SIGCHLD 信号
csignal(SIGCHLD, SIG_IGN);告诉内核:我不关心子进程的退出状态,子进程退出后内核自动回收,不会变成僵尸。简单粗暴,适合不需要获取退出码的场景。
-
双 fork 技巧
父进程 fork 出子进程,子进程再 fork 出孙子进程,然后子进程立刻退出。孙子进程会被 init 进程(PID=1)收养,自动回收。适合后台守护进程场景。
三、进程终止:正常退出与异常终止
Linux 进程终止分两大类:正常终止 和异常终止。
3.1 正常终止的三种方式
方式1:main 函数里 return n
只有在 main 函数里的 return 才会触发进程退出,等价于调用 exit(n)。普通函数里的 return 只是函数返回,不会终止进程。
方式2:exit(n) ------ C 标准库函数
c
#include <stdlib.h>
void exit(int status);
这是最常用的退出方式,执行流程:
- 逆序执行所有用
atexit()/on_exit()注册的退出处理函数 - 刷新所有标准 IO 缓冲区,关闭所有打开的文件流
- 删除临时文件
- 最终调用
_exit(status)进入内核释放资源
注意:status 只有低 8 位有效,所以退出码范围是 0~255,超过会截断。
方式3:_exit(n) / _Exit(n) ------ 系统调用
c
#include <unistd.h>
void _exit(int status);
直接陷入内核,释放进程资源,不做任何用户态清理:不刷新缓冲区、不执行退出函数、不关文件流。
经典实验:缓冲区的区别
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
printf("Hello World"); // 注意:没有换行符,内容还在缓冲区里
// exit(0); // 打开这行:会输出 Hello World,因为exit会刷缓冲区
_exit(0); // 打开这行:什么都不输出,直接进内核,缓冲区丢了
}
什么时候用 _exit?
fork 之后的子进程里,如果 exec 失败了,要用 _exit 退出,避免重复刷新父进程的缓冲区、执行父进程注册的退出函数,造成混乱。
3.2 异常终止
进程收到了无法处理的信号,执行默认的终止动作,就会异常终止。常见场景:
- 按
Ctrl+C→ 触发SIGINT信号 → 终止进程 - 访问空指针、越界、修改只读内存 → 触发
SIGSEGV(段错误)→ 终止+生成core - 执行
kill -9 PID→ 触发SIGKILL→ 强制杀死,不可捕获 - 调用
abort()函数 → 给自己发SIGABRT→ 异常终止
小知识:
SIGKILL(9号)和SIGSTOP(19号)是两个特殊信号,不能被捕获、不能被忽略、不能被阻塞,是内核的终极杀招。
四、错误处理:errno 与错误信息打印
Linux 系统调用失败时,不会直接返回错误原因,而是通过全局变量 errno 传递错误码。
4.1 errno 是什么?
errno是一个线程局部变量(每个线程/进程有独立一份),定义在<errno.h>- 系统调用失败时,内核会把它设置为对应的错误编号(正整数)
- 系统调用成功时,不会修改 errno 的值,所以不能用 errno 非零来判断失败,必须先看函数返回值
4.2 常见错误码对照表
| 错误码 | 含义 | 常见场景 |
|---|---|---|
EACCES |
权限不足 | 打开没有读权限的文件 |
ENOENT |
文件/目录不存在 | 打开一个不存在的路径 |
EINTR |
系统调用被信号中断 | wait 过程中收到信号 |
EAGAIN |
资源暂时不可用 | 非阻塞读没数据、fork进程数超限 |
ENOMEM |
内存不足 | malloc 分配失败、fork 内存不够 |
4.3 打印错误的两个工具
1. perror() ------ 最简单直接
c
#include <stdio.h>
void perror(const char *s);
自动读取当前 errno,翻译成文字,格式为:你传的字符串: 错误描述,自动换行。
示例:
c
FILE *fp = fopen("不存在的文件.txt", "r");
if (fp == NULL) {
perror("打开文件失败");
// 输出:打开文件失败: No such file or directory
}
2. strerror() ------ 把错误码转成字符串
c
#include <string.h>
char *strerror(int errnum);
传入错误码,返回对应的描述字符串。适合需要把错误信息写到日志里的场景。
示例:
c
#include <errno.h>
#include <string.h>
printf("错误原因:%s\n", strerror(errno));
注意:
strerror线程不安全,多线程环境推荐用strerror_r。
五、信号处理:异步通知机制
信号是 Linux 里的「软件中断」,是一种异步事件通知机制。就像你正在写代码,外卖到了,门铃响了------你停下手里的活,去取外卖,再回来继续写代码。
5.1 信号基础
- 信号用宏定义标识,比如
SIGINT、SIGKILL,本质是正整数编号 - 每个信号都有默认动作:终止进程、忽略、暂停进程、恢复运行
- 进程可以修改大部分信号的处理方式:
- 默认处理:执行系统默认动作
- 忽略信号 :收到信号当没发生(
SIG_IGN) - 捕获信号:注册自定义处理函数,收到信号就执行这个函数
5.2 常用信号速查表
| 编号 | 信号名 | 默认动作 | 触发场景 |
|---|---|---|---|
| 2 | SIGINT |
终止 | 终端按 Ctrl+C |
| 3 | SIGQUIT |
终止+core | 终端按 Ctrl+\ |
| 9 | SIGKILL |
强制终止 | kill -9,不可捕获/忽略 |
| 11 | SIGSEGV |
终止+core | 非法内存访问(段错误) |
| 14 | SIGALRM |
终止 | alarm 定时器到期 |
| 15 | SIGTERM |
终止 | kill 默认信号,请求优雅退出 |
| 17 | SIGCHLD |
忽略 | 子进程退出时通知父进程 |
| 19 | SIGSTOP |
暂停 | 暂停进程,不可捕获/忽略 |
5.3 signal() 函数(简单但不推荐)
c
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
设置信号的处理函数。handler 可以是:
- 自定义函数地址:捕获信号
SIG_IGN:忽略信号SIG_DFL:恢复默认处理
缺点 :不同 Unix 系统行为不一致,属于历史遗留函数,生产代码推荐用 sigaction。
5.4 sigaction() 函数(生产级标准)
功能更全、行为可移植,是设置信号处理的标准方式。
c
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
核心结构体
c
struct sigaction {
void (*sa_handler)(int); // 普通处理函数
void (*sa_sigaction)(int, siginfo_t *, void *); // 带详细信息的处理函数
sigset_t sa_mask; // 处理信号期间,额外屏蔽的信号
int sa_flags; // 行为标志位
void (*sa_restorer)(void); // 废弃,不用管
};
关键参数说明
sa_mask:执行信号处理函数期间,暂时屏蔽的信号集合。默认会自动屏蔽当前正在处理的信号,防止嵌套处理导致混乱。sa_flags常用选项:SA_RESTART:被信号中断的系统调用自动重启(比如 read 被打断,自动重试)SA_SIGINFO:使用sa_sigaction处理函数,可以拿到信号的详细信息(谁发的、为什么发)SA_NOCLDWAIT:给 SIGCHLD 设置这个标志,子进程退出自动回收,不产生僵尸
示例:捕获 Ctrl+C
c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handle_int(int sig) {
printf("\n收到信号 %d,我知道你想退出,但我先不退出\n", sig);
}
int main() {
struct sigaction sa;
sa.sa_handler = handle_int;
sigemptyset(&sa.sa_mask); // 清空屏蔽集
sa.sa_flags = SA_RESTART; // 自动重启被中断的系统调用
sigaction(SIGINT, &sa, NULL); // 设置 SIGINT 的处理函数
while (1) {
printf("程序运行中...\n");
sleep(1);
}
return 0;
}
运行后按 Ctrl+C 不会退出,想退出可以按 Ctrl+\ 或者开另一个终端 kill 掉。
5.5 信号集操作函数
sigset_t 是信号集合类型,用下面的函数操作:
c
#include <signal.h>
int sigemptyset(sigset_t *set); // 清空集合,所有信号都不包含
int sigfillset(sigset_t *set); // 把所有信号加入集合
int sigaddset(sigset_t *set, int signum); // 添加一个信号
int sigdelset(sigset_t *set, int signum); // 删除一个信号
int sigismember(const sigset_t *set, int signum); // 判断信号是否在集合里
5.6 信号屏蔽与未决信号
- 信号屏蔽(阻塞):告诉内核「这些信号先别递送给我,先存着」,相当于手机开勿扰模式
- 未决信号:已经产生但因为被阻塞,还没递送给进程处理的信号,相当于通知栏里没读的消息
设置信号屏蔽:sigprocmask()
c
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how参数:SIG_BLOCK:把 set 里的信号加入屏蔽集(新增屏蔽)SIG_UNBLOCK:把 set 里的信号解除屏蔽SIG_SETMASK:直接把屏蔽集替换成 set
获取未决信号集:sigpending()
c
int sigpending(sigset_t *set);
获取当前进程的未决信号集合,也就是哪些信号已经来了但还没处理。
5.7 发送信号
c
// 给指定进程发信号
int kill(pid_t pid, int sig);
// 给自己发信号,等价于 kill(getpid(), sig)
int raise(int sig);
六、程序替换:exec 家族
fork 出来的子进程和父进程代码完全一样,但实际开发中,我们通常希望子进程去执行另一个全新的程序(比如执行 ls、执行别的脚本),这就要用到程序替换。
6.1 什么是程序替换?
调用 exec 系列函数,会用新程序的代码、数据、堆栈完全覆盖当前进程的内存映像 ,但进程 PID 不变。相当于把房子里的家具全部换掉,重新装修,但门牌号不变。
程序替换原理示意图

6.2 六个 exec 函数的命名规律
所有函数都在 <unistd.h> 里,名字里的字母对应不同功能,记住规律就不用死记硬背:
| 字母 | 含义 | 功能 |
|---|---|---|
l (list) |
列表传参 | 命令行参数一个个列出来,最后以 (char*)NULL 结尾 |
v (vector) |
数组传参 | 命令行参数用字符串数组传递,数组末尾为 NULL |
p (path) |
自动搜 PATH | 文件名不带路径时,自动去 PATH 环境变量里找可执行文件 |
e (env) |
自定义环境 | 可以自己传环境变量数组,不继承父进程的 |
6.3 函数详解
1. 带 p 的:自动搜索 PATH
c
int execlp(const char *file, const char *arg0, ... /* (char*)NULL */);
int execvp(const char *file, char *const argv[]);
适合执行系统命令,比如 ls、ps,不用写全路径。
2. 不带 p 的:必须写完整路径
c
int execl(const char *path, const char *arg0, ... /* (char*)NULL */);
int execv(const char *path, char *const argv[]);
执行自己写的程序、确定路径的程序用这个。
3. 带 e 的:自定义环境变量
c
int execle(const char *path, const char *arg0, ... /* (char*)NULL, char *const envp[] */);
int execve(const char *path, char *const argv[], char *const envp[]);
可以自己构造环境变量数组传给新程序。
底层说明:六个函数里,只有
execve是真正的系统调用,其他五个都是 C 库函数,最终都会调用execve。
6.4 经典示例:fork + exec 执行 ls
这是 Linux 最经典的组合用法:父进程 fork 子进程,子进程用 exec 替换成新程序,父进程等待回收。
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:替换成 ls -l 命令
printf("子进程开始执行 ls\n");
execlp("ls", "ls", "-l", (char*)NULL);
// 只有失败才会走到这里
perror("execlp 失败");
_exit(1); // 必须用 _exit
} else {
wait(NULL); // 父进程等子进程跑完
printf("父进程:子进程执行完毕\n");
}
return 0;
}
6.5 注意事项
- 成功不返回,失败才返回:不要写 exec 成功后的逻辑,永远执行不到
- 失败后用
_exit退出:不要用 exit,避免刷新父进程的缓冲区 - 参数列表必须以 NULL 结尾 :不管是 l 类型的可变参数,还是 v 类型的数组,末尾都必须是
(char*)NULL,否则会报错 - 第一个参数 arg0 是程序名:通常和可执行文件名一致,是 argv0 的值
七、综合实战:多进程批量创建与回收
学会了单个进程创建,我们来看批量创建多进程的场景------这也是工作中最常用的模式,比如并发处理任务、服务器处理多个客户端。
7.1 循环创建多进程的标准模板
新手写循环 fork 最容易踩的坑:子进程没有退出,继续执行循环,创建孙子进程,导致进程数爆炸。
✅ 正确模板:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
#define PROC_NUM 5 // 创建5个子进程
int main() {
pid_t pids[PROC_NUM]; // 存所有子进程的PID
for (int i = 0; i < PROC_NUM; i++) {
pid_t pid = fork();
if (pid == 0) {
// ========== 子进程执行任务 ==========
printf("第%d个子进程启动,PID=%d\n", i+1, getpid());
sleep(1); // 模拟干活
printf("第%d个子进程退出\n", i+1);
exit(0); // ✅ 必须退出!否则子进程会继续循环fork
} else if (pid > 0) {
// ========== 父进程记录PID ==========
pids[i] = pid;
} else {
perror("fork失败");
exit(1);
}
}
// 父进程:逐个回收所有子进程
for (int i = 0; i < PROC_NUM; i++) {
int status;
pid_t ret = waitpid(pids[i], &status, 0);
if (WIFEXITED(status)) {
printf("回收子进程 %d,退出码:%d\n", ret, WEXITSTATUS(status));
}
}
printf("所有子进程回收完毕,父进程退出\n");
return 0;
}
7.2 代码常见问题分析
很多初学者写的代码会有这些小问题,我们逐一修正:
问题1:打印信号终止状态用错宏
很多人直接打印 WIFSIGNALED(status),但它返回的是「是不是信号终止」的布尔值(0或1),不是信号编号。
✅ 正确写法:
c
if (WIFSIGNALED(status)) {
printf("被信号终止,信号号:%d\n", WTERMSIG(status));
}
问题2:子进程用 exit 不用 _exit
如果子进程没有特殊清理需求,推荐用 _exit,避免执行父进程注册的退出函数、刷新缓冲区,更安全。
问题3:没有处理 fork 失败的情况
系统资源不足时 fork 会失败,必须判断返回值 -1,否则后面逻辑会出错。
7.3 进阶:非阻塞轮询回收
如果父进程不能一直阻塞等子进程,要一边干活一边检查,就用 WNOHANG 非阻塞模式:
c
// 轮询回收
int done = 0;
while (!done) {
done = 1;
for (int i = 0; i < PROC_NUM; i++) {
if (pids[i] <= 0) continue;
int status;
pid_t ret = waitpid(pids[i], &status, WNOHANG);
if (ret == 0) {
done = 0; // 还有没退出的
} else if (ret > 0) {
printf("子进程 %d 退出了\n", ret);
pids[i] = 0; // 标记已回收
}
}
if (!done) {
// 父进程干点别的活
printf("父进程正在处理其他事务...\n");
usleep(500000); // 500ms
}
}
八、全文总结与速查表
8.1 核心函数速查表
| 功能 | 函数 | 核心要点 |
|---|---|---|
| 创建进程 | fork() |
一次调用两次返回;写时复制优化;父子地址空间独立,文件偏移共享 |
| 等待子进程 | wait() |
阻塞等待任意子进程,回收资源,避免僵尸 |
| 精准等待 | waitpid() |
可指定PID、支持非阻塞WNOHANG;用宏解析status退出状态 |
| 正常退出 | exit() |
库函数,刷缓冲区、执行退出函数,最终调用_exit |
| 直接退出 | _exit() |
系统调用,直接进内核释放资源,不做用户态清理 |
| 错误处理 | errno/perror/strerror |
系统调用失败设置errno;perror直接打印错误描述 |
| 设置信号 | sigaction() |
生产级标准,可设置屏蔽集、重启系统调用、捕获信号 |
| 程序替换 | exec* 家族 |
替换进程映像,PID不变;成功不返回,失败返回-1;fork+exec是经典组合 |
8.2 新手常见误区
- ❌ fork 之后子进程从 main 开头重新执行
✅ 从 fork 调用的下一行开始执行,程序计数器会被复制 - ❌ 僵尸进程占大量内存
✅ 只占 PCB 和 PID,几乎不占内存,但会耗光 PID 号 - ❌ exec 会创建新进程
✅ 不创建新进程,PID 不变,只是替换内存内容 - ❌ 所有信号都能捕获
✅ SIGKILL 和 SIGSTOP 不能捕获、不能忽略 - ❌ 循环 fork 子进程不用退出
✅ 必须 exit,否则会创建孙子进程,进程数指数爆炸 - ❌ 直接打印 status 当作退出码/信号号
✅ status 是组合型整数,高16位无意义,低16位分信号和退出码,必须通过位运算或系统宏解析
掌握了这些内容,你就吃透了 Linux 进程管理的核心知识,后续学习进程间通信、多线程、服务器开发都会轻松很多。