Linux(九) 进程管理完全指南:从入门到实战

前言

进程管理是 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。调用后有三种返回值:

  1. 父进程中返回 >0 的整数:这个值就是刚创建的子进程的 PID
  2. 子进程中返回 0:子进程通过返回值 0 识别自己的身份
  3. 出错返回 -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 的本质是「复制当前时刻的完整进程上下文」,而不是重新加载程序:

  1. 父进程执行到 fork() 这一行时,CPU 的**程序计数器(RIP寄存器)**已经指向了 fork 之后的下一条指令地址
  2. 子进程复制父进程的寄存器上下文时,会原样复制这个程序计数器的值
  3. 当子进程被调度器调度上 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. 父子进程的变量值不一样:子进程修改不影响父进程,证明内存独立
  2. 变量的虚拟地址完全一样:因为是复制的虚拟地址空间,但映射到了不同的物理页

1.7 新手必知的注意事项

  1. 执行顺序不确定:fork 之后父子谁先跑,完全由操作系统调度器决定,不要假设父进程先执行。如果需要控制顺序,要用后面讲的等待、信号等机制。
  2. 文件偏移量共享:如果 fork 前打开了一个文件,父子进程写同一个文件描述符,内容会交替写入,文件偏移量会互相影响(因为内核里的文件表项是共享的)。
  3. 子进程不要忘记退出:如果在循环里 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);
参数详解
  1. pid:指定等待哪个进程

    • pid > 0:等待 PID 等于这个值的指定子进程(最常用)
    • pid == -1:等待任意子进程,和 wait() 功能完全一样
    • pid == 0:等待和当前进程同组的任意子进程
    • pid < -1:等待进程组 ID 等于 -pid 的任意子进程
  2. 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位):记录「正常退出」的退出码

两种场景拆解
  1. 子进程正常退出(return / exit / _exit

    低 8 位全部为 0次低 8 位 存放进程退出码(范围 0~255)。

    所以要用 WEXITSTATUS(status) 取出次低8位的值。

  2. 子进程被信号杀死(异常终止)

    次低 8 位全部为 0低 8 位 存放终止它的信号编号。

    所以要用 WTERMSIG(status) 取出低8位的值。

  3. 额外标记:第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,还有两种常用方式:

  1. 忽略 SIGCHLD 信号

    c 复制代码
    signal(SIGCHLD, SIG_IGN);

    告诉内核:我不关心子进程的退出状态,子进程退出后内核自动回收,不会变成僵尸。简单粗暴,适合不需要获取退出码的场景。

  2. 双 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);

这是最常用的退出方式,执行流程:

  1. 逆序执行所有用 atexit() / on_exit() 注册的退出处理函数
  2. 刷新所有标准 IO 缓冲区,关闭所有打开的文件流
  3. 删除临时文件
  4. 最终调用 _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 信号基础

  • 信号用宏定义标识,比如 SIGINTSIGKILL,本质是正整数编号
  • 每个信号都有默认动作:终止进程、忽略、暂停进程、恢复运行
  • 进程可以修改大部分信号的处理方式:
    1. 默认处理:执行系统默认动作
    2. 忽略信号 :收到信号当没发生(SIG_IGN
    3. 捕获信号:注册自定义处理函数,收到信号就执行这个函数

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[]);

适合执行系统命令,比如 lsps,不用写全路径。

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 注意事项

  1. 成功不返回,失败才返回:不要写 exec 成功后的逻辑,永远执行不到
  2. 失败后用 _exit 退出:不要用 exit,避免刷新父进程的缓冲区
  3. 参数列表必须以 NULL 结尾 :不管是 l 类型的可变参数,还是 v 类型的数组,末尾都必须是 (char*)NULL,否则会报错
  4. 第一个参数 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 新手常见误区

  1. ❌ fork 之后子进程从 main 开头重新执行
    ✅ 从 fork 调用的下一行开始执行,程序计数器会被复制
  2. ❌ 僵尸进程占大量内存
    ✅ 只占 PCB 和 PID,几乎不占内存,但会耗光 PID 号
  3. ❌ exec 会创建新进程
    ✅ 不创建新进程,PID 不变,只是替换内存内容
  4. ❌ 所有信号都能捕获
    ✅ SIGKILL 和 SIGSTOP 不能捕获、不能忽略
  5. ❌ 循环 fork 子进程不用退出
    ✅ 必须 exit,否则会创建孙子进程,进程数指数爆炸
  6. ❌ 直接打印 status 当作退出码/信号号
    ✅ status 是组合型整数,高16位无意义,低16位分信号和退出码,必须通过位运算或系统宏解析

掌握了这些内容,你就吃透了 Linux 进程管理的核心知识,后续学习进程间通信、多线程、服务器开发都会轻松很多。

相关推荐
江华森1 小时前
Linux 操作命令完全指南
linux·运维
rjszcb2 小时前
Linux,sensor调试笔记1,修改帧率,以及曝光上不去问题
linux
源图客2 小时前
【AI向量数据库】Weaviate介绍与部署
运维·docker·容器
用什么都重名2 小时前
Git分支合并与远程服务器同步实战:保留关键配置文件
运维·服务器·git
C++ 老炮儿的技术栈2 小时前
Ubuntu root账号自动登陆
linux·运维·服务器·c语言·c++·ubuntu·visual studio
2301_780789662 小时前
零信任架构中,身份感知防火墙(IAFW)的部署要点与最佳实践
linux·运维·服务器·人工智能·tcp/ip·架构
2401_868534782 小时前
2025下半年网络规划设计师真题(选择题、案例分析)
运维·服务器·网络
Urbano2 小时前
22 道工序、核心难点与自动化升级方案
运维·自动化
Urbano2 小时前
工装裤与外套缝制自动化对比:真实设备选型与工艺适配指南
运维·自动化