[Linux系统编程——Lesson10.进程控制:创建与终止]

目录

前言

本节重点:

一、进程创建

1-1 🔥fork函数初识🔥

1-2 🔥fork函数返回值🔥

1-3 🔥写时拷⻉🔥

1️⃣写时拷贝的工作原理

2️⃣问题延深

1-4 🔥fork常规⽤法🔥

1-5 🔥fork调⽤失败的原因🧐

总结

二、进程终止

2-1 💧进程退出场景💧

总结

2-1-1 💧代码运行完毕,结果正确(正常退出,预期内成功)💧

2-1-2 💧代码运行完毕,结果不正确(正常退出,预期内失败)💧

2-1-3 💧代码异常终止(非正常退出,意外中断)💧

🔑总结:三类退出场景的核心差异🔑

2-2 进程常⻅退出⽅法🧐

2-2-1 🔥退出码🔥

1️⃣正常终止(可通过 echo $? 查看退出码)

2-2-2 💧从 main 函数返回(return 语句)💧

2-2-3 🔥调用 exit() 函数(标准库函数)🔥

2-2-4 🔥调用 _exit() 函数(系统调用)🔥

2-2-5 🔥exit函数与_exit函数的区别🔥

2️⃣异常终止(信号触发,无主动退出码)

总结

🧐进程创建与终止核心知识总结🔑

1️⃣进程创建

2️⃣进程终止



前言

        在现代操作系统的世界里,进程无疑是最为核心的概念之一。它就像一个个忙碌的 “工作单元”,支撑着计算机完成各项复杂任务,从我们日常使用的软件启动,到后台服务的稳定运行,背后都离不开进程的调度与管理。想要深入理解操作系统的运行机制,掌握进程的创建、终止、等待以及程序替换等关键操作,就如同拿到了打开系统内核大门的一把钥匙。

如果对于进程概念同学们不熟悉的话可以回顾下我的这一篇博客[Linux系统编程——Lesson3.进程概念 ]

本节重点:

  • 学习进程创建,fork
  • 学习到进程终⽌,认识$?

一、进程创建

1-1 🔥fork函数初识🔥

🧐在之前的内容中我们讨论了父子进程,你们进程是如何创建的呢❓

在Linux中,fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

#include <unistd.h>
pid_t fork(void);
返回值:自进程中返回0,父进程返回子进程id,出错返回-1

这里我们又衍生出三个问题

  • 1. 为什么要给⼦进程返回0,⽗进程返回⼦进程pid?
  • 2. 为什么⼀个函数fork会有两个返回值?
  • 3. 为什么⼀个id即等于0,⼜⼤于0

这些问题先放在这里,我们后面会一一解答✍️

首先我们要先学习进程调⽤ fork ,当控制转移到内核中的 fork 代码后,内核做什么
  • 分配新的内存块和内核数据结构给⼦进程
  • 将⽗进程部分数据结构内容拷⻉⾄⼦进程
  • 添加⼦进程到系统进程列表当中
  • fork 返回,开始调度器调度

要理解 fork() 系统调用的内核执行流程,需先明确其核心目的创建一个与父进程几乎完全相同的子进程二者共享代码段,但拥有独立的数据段、堆栈和进程控制块(PCB)。以下将从内核视角,分阶段详解每个操作的目标、细节及背后的设计逻辑,并补充关键延伸知识,形成完整认知。

fork() 内核执行流程详解

当用户进程调用 fork() 后,CPU 会从 “用户态” 切换到 “内核态”,执行内核中 fork() 相关的核心逻辑,共分为 4 个关键步骤,且每个步骤环环相扣:

1️⃣分配新的内存块和内核数据结构给子进程

目标:为子进程 “创建独立的身份标识” 和 “存储空间”,避免与父进程资源冲突。内核需分配两类核心资源:

  • 内核数据结构:最关键的是 进程控制块(PCB)task_struct 结构体),它是进程的 “身份证”,包含进程 ID(PID)、状态(就绪 / 运行 / 阻塞)、优先级、打开的文件描述符表、信号处理表、内存映射表等核心信息。内核会为子进程分配一个唯一的 PID(确保与系统中所有进程不重复),并初始化 task_struct 的基础字段(如状态设为 “就绪”,父进程 ID(PPID)设为调用 fork() 的父进程 PID)。
  • 内存块:子进程需要独立的 “数据段”(存储全局变量、静态变量)和 “堆栈段”(存储局部变量、函数调用栈)—— 代码段(存储可执行指令)无需分配新内存,因父子进程代码完全相同,可通过 “只读共享” 节省资源。早期内核会直接拷贝父进程的数据段和堆栈段到新内存块,但现代内核(如 Linux)采用 写时复制(Copy-On-Write, COW) 优化:仅为子进程创建 “内存映射表”,指向父进程的内存页,暂不实际拷贝数据;只有当父子进程任一试图修改某内存页时,才会为该页分配新内存并拷贝内容,大幅减少创建子进程的时间和内存开销。

2️⃣将父进程部分数据结构内容拷贝至子进程

目标:保证子进程 “继承父进程的运行环境”,但仅继承 “必要且可独立的资源”。并非父进程所有数据结构都拷贝,内核会筛选 “需共享” 和 “需独立” 的资源,核心拷贝内容包括:

  • 进程控制块(PCB)的部分字段:除了 PID、PPID 等需重新初始化的字段,子进程会拷贝父进程的优先级(确保初始调度公平性)、信号屏蔽字(继承父进程对信号的屏蔽规则)、当前工作目录(继承父进程的文件操作路径)等。
  • 文件描述符表:父进程打开的文件(如标准输入 0、标准输出 1、标准错误 2 或自定义文件),子进程会通过 “引用计数” 方式继承:子进程的文件描述符表指向与父进程相同的 “文件对象”(包含文件偏移量、打开模式等),此时文件对象的引用计数加 1。只有当任一进程调用 close() 时,引用计数减 1,直至为 0 才真正关闭文件 —— 这就是 “父子进程共享打开文件” 的原理。
  • 内存映射表(COW 机制下):如步骤 1 所述,子进程的内存映射表会拷贝父进程的映射关系(指向相同的物理内存页),但会将这些内存页的权限设为 “只读”。一旦父子进程任一试图修改,触发 “页错误”(Page Fault),内核再执行实际的内存拷贝。

不拷贝的关键资源:

  • 代码段:父子进程共享同一段只读代码(因指令无需修改,拷贝会浪费内存);
  • 进程 ID(PID):子进程必须有唯一 PID,由内核分配而非拷贝;
  • 挂起的信号:父进程未处理的信号仅属于父进程,子进程不会继承(避免信号处理混乱)。

3️⃣ 添加子进程到系统进程列表当中

目标:让子进程 “被操作系统调度器识别”,具备获得 CPU 运行的资格。内核维护一个全局的 “进程列表”(Linux 中为 task_list 链表,所有进程的 task_struct 都挂载于此),此步骤的核心操作是:

  • 将子进程的 task_struct 节点插入到 task_list 链表中;
  • 同时将子进程添加到对应的 “就绪队列”(调度器管理的队列,按进程优先级分组)—— 此时子进程状态为 “TASK_RUNNING”(就绪态),等待调度器分配 CPU 时间片。

这一步是子进程从 “内核创建完成” 到 “可被调度运行” 的关键过渡:若不加入进程列表,调度器无法感知子进程存在,子进程将永远无法执行。

4️⃣fork() 返回,开始调度器调度

目标:完成子进程创建,恢复父子进程的执行,并决定 “谁先获得 CPU”。这是 fork() 最特殊的一步:fork() 会返回两次—— 一次在父进程中,一次在子进程中,且返回值不同,内核通过以下逻辑实现:

  1. 内核层面的返回准备:内核会为父子进程分别设置 “返回值”:

    • 父进程:返回子进程的 PID(父进程可通过此 PID 管理子进程,如 wait() 等待子进程结束、kill() 向子进程发送信号);
    • 子进程:返回 0(子进程可通过 getppid() 获取父进程 PID,返回 0 是为了让子进程明确自身身份)。
  2. 调度器介入:当内核完成 fork() 的核心逻辑后,会触发 “调度器调度”(即 “进程上下文切换” 的决策)。此时有两种可能:

    • 子进程先运行:调度器可能优先将 CPU 分配给子进程(新创建的进程通常有较短的运行时间,符合 “短作业优先” 调度思想),子进程从内核态切换回用户态,执行 fork() 之后的代码(以返回值 0 进入逻辑);
    • 父进程先运行:若父进程优先级更高或系统采用 “父进程优先” 策略,父进程先切换回用户态,执行 fork() 之后的代码(以返回值 “子进程 PID” 进入逻辑)。

无论谁先运行,父子进程的执行逻辑从此完全独立:共享代码段,但因 fork() 返回值不同,可通过 if-else 分支实现不同行为(如父进程等待子进程,子进程执行新任务)。

关键结论:

当⼀个进程调⽤fork之后,就有两个⼆进制代码相同的进程。⽽且它们都运⾏到相同的地⽅。但每个进 程都将可以开始它们⾃⼰的旅程,
  • 看如下程序。

运行结果如下:

这⾥看到了三⾏输出,⼀⾏before,两⾏after。进程43676先打印before消息,然后它有打印after。 另⼀个after消息有43677打印的。注意到进程43677没有打印before,为什么呢?
如下图所⽰

所以,fork之前⽗进程独⽴执⾏,fork之后,⽗⼦两个执⾏流分别执⾏。注意,fork之后,谁先执⾏完 全由调度器决定。

1-2 🔥fork函数返回值🔥

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

这里想必同学们立马就会想起我们一开始提到三个问题的前两个?

  • 1. 为什么要给⼦进程返回0,⽗进程返回⼦进程pid
  • 2. 为什么⼀个函数fork会有两个返回值

这两个问题都与 fork() 系统调用的特殊设计有关,本质上是由 “创建子进程后父子进程并行执行” 的特性决定的。

1️⃣为什么子进程返回 0,父进程返回子进程 PID

这是操作系统为了让父子进程明确自身身份并实现分工而设计的规则,核心原因有两点:

  • 身份区分的需求fork() 会创建与父进程几乎完全相同的子进程,二者共享代码段。为了让父子进程执行不同的逻辑(例如父进程继续管理其他任务,子进程处理新任务),需要通过返回值区分 “谁是父进程,谁是子进程”。子进程返回 0 是一种约定,表示 “我是子进程”;父进程返回子进程的 PID(进程唯一标识),表示 “我是父进程,且创建的子进程 ID 是 XXX”。

  • 实际功能的需要:父进程通常需要管理子进程(如通过 wait() 等待子进程结束、通过 kill() 向子进程发送信号),因此必须知道子进程的 PID—— 这就是父进程返回子进程 PID 的直接原因。而子进程若需要与父进程交互,可通过 getppid() 系统调用获取父进程 PID,无需通过 fork() 返回值传递,因此用 0 作为子进程的返回值既简洁又明确。

2️⃣为什么一个函数 fork() 会有两个返回值

这是因为 fork() 执行过程中发生了进程复制,导致函数在 “两个不同的进程” 中分别完成了返回,本质上是 “一个函数调用在两个独立进程中各自结束” 的结果

  • fork() 的执行逻辑:当父进程调用 fork() 时,内核会先创建子进程(复制资源、分配 PID 等),此时系统中出现两个几乎完全相同的进程(父进程和子进程)。这两个进程会同时从内核态返回到用户态,继续执行 fork() 函数调用之后的代码。

  • 两个返回值的本质:fork() 函数的 “返回” 动作在父子进程中各执行了一次

    • 父进程中,fork() 完成子进程创建后正常返回,返回值为子进程 PID;
    • 子进程中,由于复制了父进程的执行上下文(包括函数调用栈),会 “继承” fork() 调用的状态,继续执行到返回点,返回值为 0

因此,从用户视角看,“一个 fork() 调用” 似乎产生了两个返回值,但本质是两个进程分别执行了一次返回操作。

😶‍🌫️简言之,fork() 的两个返回值和特殊的返回值规则,是操作系统为了让父子进程明确身份、实现分工而设计的核心机制,体现了 “进程复制后并行执行” 的本质特性。

1-3 🔥写时拷⻉🔥

        写时拷贝(Copy-On-Write,COW)是操作系统在处理进程创建(如 fork() 系统调用)时采用的一种高效内存管理技术,其核心思想 “延迟拷贝”—— 避免不必要的内存复制,仅在真正需要修改数据时才执行拷贝操作,从而显著提升性能。

1️⃣写时拷贝的工作原理

1.创建子进程时:共享而非拷贝

父进程调用 fork() 时,内核不会立即复制父进程的整个地址空间(数据段、堆栈段等),而是让父子进程共享同一份物理内存页,并通过以下方式实现:

  • 父子进程的页表(虚拟地址到物理地址的映射表)指向相同的物理内存页;
  • 内核将这些共享的物理内存页标记为 “只读”(即使原内存页是可写的);
  • 代码段本身就是只读的,自然可以安全共享,无需额外处理。

此时,子进程看似拥有了与父进程相同的内存数据,实则并未分配新的物理内存,仅共享父进程的资源,创建子进程的速度极快(只需创建页表和进程控制块等少量数据)。

2.修改数据时:触发拷贝

当父进程或子进程试图修改某块共享内存时(如修改一个全局变量或局部变量),CPU 会检测到 “对只读内存页执行写操作” 的异常,触发页错误中断(Page Fault),内核介入处理:

  • 内核为修改操作分配一块新的物理内存页;
  • 将原共享内存页的内容复制到新页中;
  • 更新触发修改操作的进程的页表,使其指向新的物理内存页;
  • 将新内存页标记为 “可写”,允许进程完成修改操作。

此时,只有被修改的内存页会被拷贝,未修改的内存页仍保持共享,最大程度减少了内存复制的开销。

后续操作独立访问拷贝完成后,父子进程对该内存页的访问将完全独立:修改方使用新拷贝的内存页,另一方仍使用原内存页(若未修改),两者互不干扰。


2️⃣问题延深

        要理解 “数据写时拷贝” 的核心逻辑,需从进程独立性需求、内存资源效率、代码与数据的差异特性三个维度展开,结合 “为什么需要”“为什么不提前拷贝”“代码是否适用” 三个问题,形成完整的技术逻辑链:

 ① 核心前提:进程独立性决定了 “数据必须隔离”

  • 进程是操作系统进行资源分配的基本单位,其核心特性之一是独立性—— 即多个进程运行时互不干扰,子进程对数据的修改绝对不能影响父进程(反之亦然)。
  • 例如:父进程有一个变量 a=10,若子进程将 a 改为 20,父进程的 a 仍需保持 10。这就要求 “父进程与子进程的数据必须在修改时实现物理隔离”,而写时拷贝(Copy-On-Write, COW) 是满足这一需求的高效方案。

② 问题 1:为什么数据要进行写时拷贝?—— 平衡 “独立性” 与 “效率”

        写时拷贝的本质是 “延迟拷贝策略”,其设计目标在保证进程独立性的同时,避免 “无意义的内存浪费”,核心逻辑如下:

  1. 满足进程独立性的底层需求
    若不做任何隔离,父进程与子进程共享同一块内存数据,子进程修改数据会直接覆盖父进程数据,破坏独立性(这是绝对不允许的)。而写时拷贝通过 “修改时才拷贝” 的机制,确保修改后的数据只属于当前进程,天然满足隔离要求。

  2. 避免 “预拷贝” 的资源浪费
    若子进程创建后立即拷贝父进程所有数据,会面临两个关键问题:

    • 子进程可能根本不使用父进程的部分数据(例如父进程有 1GB 内存数据,但子进程只执行一个简单的 printf,无需访问大部分数据);
    • 子进程可能只读取不修改父进程数据(例如子进程仅打印父进程的配置信息,不做任何修改)。此时 “预拷贝” 会浪费大量内存空间(拷贝 1GB 数据却只用 1KB),而写时拷贝仅在 “真正修改” 时才分配新内存,完美规避了这种浪费。

③ 问题 2:为什么不在创建子进程时就进行数据拷贝?—— 按需分配,提升内存利用率

        这一问题的核心“延迟分配(Lazy Allocation)” 思想在内存管理中的应用,具体可从 “资源效率” 和 “创建性能” 两方面分析:

维度预拷贝(创建时拷贝)写时拷贝(修改时拷贝)
内存利用率无论子进程是否使用 / 修改,都占用双倍内存,浪费严重仅在修改时分配新内存,未修改数据共享,利用率极高
进程创建速度拷贝大量数据耗时久,创建效率低仅复制 “进程控制块(PCB)” 等元数据,不拷贝数据,创建速度极快
实际场景适配不适配 “子进程只读数据”“子进程仅用部分数据” 的高频场景完美适配绝大多数场景(如 fork() 后 exec() 替换进程,子进程仅短暂共享父进程数据)

典型场景:Linux 中 fork() 创建子进程后,常立即调用 exec() 替换进程镜像(加载新程序代码和数据)。若 fork() 时预拷贝父进程数据,拷贝完成后立即被 exec() 覆盖,完全是 “无用功”;而写时拷贝仅共享父进程数据,exec() 直接替换,无任何内存浪费

④ 问题 3:代码会不会进行写时拷贝?——90% 不适用,特殊场景需适配

要回答这个问题,需先明确代码与数据的本质差异

  • 数据:进程运行中可能被修改(如变量赋值、文件写入缓冲区),需要隔离;
  • 代码:默认是只读的(进程运行时仅读取代码指令,不修改代码),因此无需隔离 —— 父进程与子进程共享同一块只读代码内存即可,无需拷贝。

基于此,代码是否需要写时拷贝,取决于 “代码是否会被修改”,代码的写时拷贝不是 “常规操作”,而是 “应对特殊可写代码场景的兜底机制”,因此 90% 以上的情况不适用。

最终总结:写时拷贝的核心逻辑链

  1. 需求起点:进程独立性要求 “数据修改不跨进程影响”,必须隔离;
  2. 效率优化:预拷贝浪费内存和时间,因此采用 “延迟拷贝”—— 写时拷贝,按需分配;
  3. 数据 vs 代码:数据默认可修改,需写时拷贝;代码默认只读,无需拷贝,仅特殊可写场景需适配写时拷贝。

🔑写时拷贝是操作系统 “兼顾正确性与效率” 的经典设计,也是理解进程内存管理、fork()/exec() 机制的核心知识点。

1-4 🔥fork常规⽤法🔥

  fork() 是 Unix/Linux 系统中创建新进程的核心系统调用,其常规用法可归纳为两类典型场景,均围绕 “进程复制与程序替换” 的核心能力展开:

1️⃣父子进程并行执行不同代码段(任务分工)

核心逻辑:父进程通过 fork() 复制自身,生成的子进程与父进程共享初始代码和数据,但后续执行不同的代码分支,实现 “并行处理不同任务” 的效果。

典型场景:网络服务端程序(如 Web 服务器、数据库服务器)

  • 父进程专注于监听客户端连接请求(如通过 accept() 阻塞等待),不处理具体业务逻辑。
  • 子进程当父进程接收到新请求时,通过 fork() 创建子进程,由子进程专门处理该客户端的后续交互(如数据读写、逻辑计算),父进程则继续返回监听新请求。

优势:

  • 实现 “一父多子” 的并发处理模式,父进程负责调度,子进程负责具体任务,分工明确。
  • 子进程崩溃或异常退出不会直接影响父进程,提高系统稳定性(进程独立性保障)

代码示例思路:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    while (1) {
        // 父进程:等待客户端连接(伪代码)
        int client = accept_connection();  // 阻塞等待新连接
        
        pid_t pid = fork();
        if (pid == 0) {
            // 子进程:处理当前客户端请求
            handle_client(client);  // 子进程专属逻辑
            close(client);
            return 0;  // 处理完毕后退出
        } else if (pid > 0) {
            // 父进程:继续等待新连接,同时回收子进程资源
            close(client);  // 父进程无需持有客户端连接
            waitpid(-1, NULL, WNOHANG);  // 非阻塞回收僵尸进程
        }
    }
    return 0;
}

2️⃣子进程通过 exec 执行全新程序(程序替换)

核心逻辑:fork() 生成的子进程先复制父进程的内存空间,随后通过 exec 系列函数(如 execlexecvp 等)彻底替换自身的代码、数据和堆栈,执行一个全新的程序。这是 “创建新进程执行其他程序” 的标准流程。

典型场景命令行解释器(Shell)

  • 当用户在 Shell 中输入命令(如 lsgcc)时,Shell 进程(父进程)通过 fork() 复制自身生成子进程。
  • 子进程立即调用 exec 函数,加载并执行命令对应的程序(如 /bin/ls),完全替换子进程的原有代码。
  • 父进程则等待子进程执行完毕,再继续接收用户输入。

优势:

  • 结合 fork() + exec(),既利用了 fork() 快速创建进程的特性(写时拷贝减少开销),又能灵活执行任意程序,是 Unix 系统 “进程创建与程序执行分离” 设计哲学的体现。

代码示例思路:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:替换为新程序(如执行ls命令)
        execl("/bin/ls", "ls", "-l", NULL);  // 成功则不会返回
        perror("exec failed");  // 若exec失败,才会执行此句
        return 1;
    } else if (pid > 0) {
        // 父进程:等待子进程执行完毕
        waitpid(pid, NULL, 0);
        printf("子进程执行完成\n");
    }
    return 0;
}

总结:fork() 常规用法的本质

  • 用法一的核心是 “复用父进程资源,实现并行任务分工”,依赖 fork() 后父子进程的代码分支分离。
  • 用法二的核心是 “以父进程为模板创建新进程,再执行全新程序”,依赖 fork() + exec() 的组合(fork 负责创建进程壳,exec 负责填充新内容)。

这两种用法共同构成了 Unix 系统进程管理的基础,也是理解多进程编程、服务端架构的关键。

1-5 🔥fork调⽤失败的原因🧐

   fork() 系统调用并非总能成功创建新进程,其失败主要与系统资源限制和进程数量管控有关,具体原因可归纳为以下两类:

1️⃣系统中进程数量过多,超出内核限制

操作系统对全局进程总数存在硬性限制(由内核参数控制,如 Linux 中的 pid_max),当系统中进程数量达到这一上限时,fork() 会失败。

  • 限制本质:内核需为每个进程分配唯一的进程 ID(PID)和进程控制块(PCB)等元数据,而 PID 是有限的整数资源(如 32 位系统通常上限为 32768,可通过 sysctl kernel.pid_max 查看 / 修改)。
  • 触发场景:短时间内创建大量进程(如恶意程序的 “进程爆炸” 攻击、程序逻辑错误导致的无限创建进程循环),耗尽所有可用 PID。
  • 失败表现fork() 返回 -1,并设置 errno 为 EAGAIN(表示暂时无法创建,资源暂时不足)。

2️⃣用户级进程数超过配额限制

操作系统会为每个用户(或用户组)设置进程数量上限,防止单个用户占用过多系统资源,当用户已创建的进程数达到此限制时,fork() 会失败。

  • 限制机制:通过资源限制(rlimit)系统实现,具体由 RLIMIT_NPROC 参数控制(可通过 ulimit -u 查看当前用户的进程数上限)。
  • 设计目的:保证系统资源的公平分配,避免单个用户创建过多进程导致其他用户无法正常使用系统。
  • 触发场景:单个用户在其配额内已创建大量进程(如运行多个并行任务的脚本,且未合理控制数量)。
  • 失败表现:fork() 返回 -1errno 设为 EAGAIN 或 ENOMEM(取决于具体系统实现)。

总结

   fork() 失败的核心原因“资源不足或超出限制”,其中 “进程数量超限” 是最常见的场景。在实际编程中,需通过检查 fork() 返回值(是否为 -1)来处理失败情况,并根据 errno 判断具体原因,必要时通过重试(针对暂时性资源不足)或优化进程创建逻辑(如使用进程池限制数量)来规避。


二、进程终止

2-1 💧进程退出场景💧

        📖理解进程终止,首先需明确其核心本质操作系统回收该进程占用的所有系统资源,包括进程控制块(PCB)、内存地址空间(代码段、数据段、堆栈)、打开的文件描述符、网络连接等,将这些资源归还给系统供其他进程使用。而进程退出的场景,本质是 “进程停止运行” 的不同触发条件,可细分为以下三类:

  • 代码运⾏完毕,结果正确
  • 代码运⾏完毕,结果不正确
  • 代码异常终⽌

🧐为什么以0表示代码执行成功,以非0表示代码执行错误

  • 退出码为0,代表success,表示代码运行完毕,且进程运行结果正确
  • 退出码为非0,代表failed,表示代码运行正常,但进程运行结果不正确,进程在运行时出现了错误,出现错误我们就需要知道错误的原因,退出码可以为1、2、3、4…,这些不同的数字可以表示不同的原因,纯数字可以表示出错原因,但是不方便人阅读,C语言内置的strerror函数能够将退出码转化为退出原因。

  • 我们使用一些错误的指令来看看退出码和退出信息是否和上面一样对应,通过下图我们可以发现,有的退出码和退出信息与上面对应,而有的却不对应,这是因为如果我们不想使用C语言内置的退出码和退出信息对应关系,我们可以自定义退出码和退出信息对应关系。

结论:main函数的退出码可以被父进程获取到,并且这个退出码可以用来判断子进程的运行结果。


  • 在C语言的库中定义了一个全局变量错误码error,当一个库函数或系统调用失败时,会将error自动设置,当有多个函数调用失败时,error只会记录最后一个失败函数设置的值。

退出码🆚错误码

        退出码(Exit Code)错误码(Error Code)是程序开发中用于标识 “异常状态” 的重要机制,二者既有关联又有明确区别,理解它们的差异与联系有助于更规范地处理程序中的错误场景。

维度退出码(Exit Code)错误码(Error Code)
作用范围用于标识整个进程的退出状态(进程级)用于标识单个函数 / 系统调用的执行结果(函数级)
产生时机进程终止时产生(如 main 返回、exit() 调用)函数 / 系统调用执行失败时产生(如 open() 打开文件失败)
传递方式由进程返回给父进程(通过 wait() 系列函数获取)由函数返回给调用者(直接作为函数返回值或通过指针传出)
典型场景Shell 中通过 echo $? 查看上一个进程的退出结果调用 open("/file", O_RDONLY) 失败时,返回 -1 并设置 errno
  • 退出码表示 “整个进程的最终执行结果”,是进程给父进程的 “最终报告”

    • 0 表示 “进程正常执行并完成预期任务”;
    • 非 0 值表示 “进程执行异常或未完成预期任务”,具体数值可自定义(如 1 表示 “参数错误”,2 表示 “文件不存在”)。例如:ls 命令成功列出目录时退出码为 0,若目录不存在则退出码为非 0。
  • 错误码表示 “单个函数调用的失败原因”,是函数给调用者的 “即时反馈”

  • 系统调用(如 readwrite)和库函数(如 fopen)执行失败时,通常返回特定值(如 -1),并通过全局变量 errno(C 语言)记录错误码(如 ENOENT 表示 “文件不存在”,EACCES 表示 “权限不足”)。例如:fopen("nonexist.txt", "r") 失败时返回 NULLerrno 被设置为 ENOENT(错误码)。

📖共性与关联

  1. 核心作用一致均用于 “标识异常原因”,帮助开发者或父进程定位问题(“为什么失败”)。

  2. 存在联动关系进程退出码常基于函数错误码设置 —— 当函数调用失败(产生错误码)时,进程可将该错误码作为退出码返回,实现 “错误原因的传递”。

例如:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

int main() {
    FILE* f = fopen("test.txt", "r");
    if (f == NULL) {
        // 函数错误码为errno(如ENOENT),将其作为进程退出码
        exit(errno);  // 错误码 -> 退出码
    }
    // ... 其他逻辑 ...
    fclose(f);
    return 0;
}

此时若文件不存在,errno 为 ENOENT(通常是 2),进程退出码也会是 2,通过 echo $? 可直接看到错误原因对应的数值。

总结

  • 退出码是 “进程的最终状态报告”,面向进程间通信(父进程判断子进程结果);
  • 错误码是 “函数的即时错误反馈”,面向函数调用者(定位具体操作失败原因);
  • 二者通过 “错误码→退出码” 的传递形成联动,实践中可根据需求选择 “保持一致” 或 “直接输出错误信息”,核心目标是清晰标识问题原因。

2-1-1 💧代码运行完毕,结果正确(正常退出,预期内成功)💧

如上一小节内容所述,当退出码为0,代表success,表示代码运行完毕,且进程运行结果正确

这是进程最理想的退出场景:进程按预设逻辑执行完所有代码,达成预期目标,无任何错误或异常。

  • 核心特征:程序流程完整走完(如从 main 函数开始,执行到 return 0),计算结果、输出内容、文件操作等均符合设计预期。
  • 典型案例:
    1. 执行 ls -l 命令,程序成功遍历目录、格式化输出文件列表后退出;
    2. 一个计算 “1+1” 的简单程序,执行完加法逻辑、打印结果 2 后退出;
  • 退出状态码:通常返回 0(如 main 函数 return 0),用于告知父进程 “执行成功”(父进程可通过 wait()/waitpid() 获取该状态码)。

2-1-2 💧代码运行完毕,结果不正确(正常退出,预期内失败)💧

        退出码为!0,代表failed,表示代码运行正常,但进程运行结果不正确,进程在运行时出现了错误,出现错误我们就需要知道错误的原因,退出码可以为1、2、3、4…,些不同的数字可以表示不同的原因,纯数字可以表示出错原因,但是不方便人阅读,C语言内置的strerror函数能够将退出码转化为退出原因

        进程虽完整执行完所有代码(无崩溃、无异常中断),但未达成预期目标(如计算错误、输入无效),属于 “逻辑上的失败”,而非 “程序异常”。

  • 核心特征:程序流程正常结束,但结果不符合设计要求;退出时会主动返回非 0 的状态码,用于标识 “失败类型”。
  • 典型案例:
    1. 一个接收用户输入、计算 “a/b” 的程序,用户输入 b=0,程序检测到 “除数为 0” 的非法输入,打印错误提示后主动退出(返回状态码 1);
    2. 执行 cp src dest 命令时,src 文件不存在,cp 程序检测到 “源文件缺失”,打印错误信息后退出(返回非 0 状态码);
  • 退出状态码:返回非 0 的整数(如 12255 等),不同数值可代表不同的失败原因(父进程可通过状态码判断子进程 “为何失败”)。

2-1-3 💧代码异常终止(非正常退出,意外中断)💧

        进程未执行完预设代码,因 “意外错误” 被迫停止运行,属于 “程序级别的异常”,通常由操作系统或信号机制触发。

下图就是Linux操作系统中的信号大全,上图出现的两个错误分别对应的下面的8号信号和11号信号。

这类场景的核心是 “进程无法自主控制退出时机”,常见触发原因可分为两类:

①程序自身错误导致的异常(操作系统强制终止)

进程执行了非法操作,触发操作系统的 “错误检查机制”,操作系统向进程发送终止信号(如 SIGSEGVSIGFPE),强制进程退出。

  • 典型案例:
    • 空指针访问:程序试图读写地址为 0x0 的内存(非法内存操作),操作系统发送 SIGSEGV(段错误信号),进程立即终止;
    • 整数除零:程序执行 1/0 的计算(算术错误),操作系统发送 SIGFPE(浮点异常信号,虽名为 “浮点”,实则覆盖所有算术错误),进程终止;
    • 栈溢出:递归调用无终止条件,导致堆栈空间耗尽,操作系统发送 SIGSEGV,进程终止。

②外部信号导致的异常(其他进程 / 用户强制终止)

进程被其他进程或用户通过 “信号” 强制中断,无需执行完自身代码。

  • 典型案例:

    • 用户在终端执行 ./a.out 后,按下 Ctrl+C,终端向进程发送 SIGINT(中断信号),进程终止;
    • 父进程通过 kill(pid, SIGKILL) 向子进程发送 SIGKILL(强制终止信号),子进程立即终止(无法忽略该信号);
    • 系统因 “内存耗尽” 触发 OOM(Out Of Memory) killer,选中该进程并发送 SIGKILL,强制回收其内存。
  • 退出状态码:异常终止的进程无 “主动返回的状态码”,父进程通过 wait() 获取的状态码会包含 “信号类型”(如通过 WIFSIGNALED(status) 判断是否因信号终止,WTERMSIG(status) 获取终止信号编号)。

🔑总结:三类退出场景的核心差异🔑

        三类情况我们可以分为代码运行完毕代码没跑完两种情况,而代码运行完毕,又可以细分为结果正确结果不正确两种情况,

退出场景执行完整性触发原因退出状态码特征典型案例
代码跑完,结果正确完整逻辑正常结束(预期成功)返回 0ls 成功列出目录
代码跑完,结果不正确完整逻辑正常结束(预期失败)返回非 0 整数cp 源文件不存在
代码异常终止不完整非法操作 / 外部信号(意外)无主动返回,含信号信息空指针访问、Ctrl+C 中断

小结

  • 一个进程是否出异常,我们只要看有没有收到信号即可
  • 一个进程结果是否正常,我们只要看返回码即可。

无论哪种场景,进程终止的最终目的都是释放资源—— 确保系统资源不被 “僵尸进程”(虽终止但未回收资源的进程)占用,维持操作系统的资源管理效率。

2-2 进程常⻅退出⽅法🧐

        进程的退出方法可以分为正常终止异常终止两大类,不同方法在资源处理和使用场景上各有特点

1️⃣正常终⽌(可以通过 echo $? 查看进程退出码):

1. 从main返回

2. 调⽤exit

3. _exit

2️⃣异常退出:
  • ctrl + c,信号终⽌

2-2-1 🔥退出码🔥

再讲退出方法前,我们先介绍一下退出码

        退出码(退出状态)可以告诉我们最后⼀次执⾏的命令的状态。在命令结束以后,我们可以知道命令 是成功完成的还是以错误结束的。其基本思想是,程序返回退出代码 0 时表⽰执⾏成功,没有问题。 代码 1 0 以外的任何代码都被视为不成功。

Linux Shell 中的主要退出码

  • 退出码 0 表⽰命令执⾏⽆误,这是完成命令的理想状态。
  • 退出码 1 我们也可以将其解释为 “不被允许的操作”。例如在没有 sudo 权限的情况下使⽤
  • yum;再例如除以 0 等操作也会返回错误码 1 ,对应的命令为 let a=1/0
  • 130 SIGINT ^C )和 143 SIGTERM )等终⽌信号是⾮常典型的,它们属于
  • 128+n 信号,其中 n 代表终⽌码。
  • 可以使⽤ strerror 函数来获取退出码对应的描述。

1️⃣正常终止(可通过 echo $? 查看退出码)

正常终止是进程主动结束运行的方式,会生成一个 “退出码”(0 表示成功,非 0 表示失败),父进程或 Shell 可通过 echo $? 获取该代码以判断执行结果。

2-2-2 💧从 main 函数返回(return 语句)💧

这是用户程序最自然的终止方式,仅适用于 main 函数(其他函数的 return 仅结束函数,不终止进程)。

  • 原理 main 函数执行到 return 时,返回值会作为进程的 “退出码”,随后触发进程终止和资源回收。
#include <stdio.h>
int main() {
    printf("程序执行完毕\n");
    return 0;  // 退出码为0(表示成功)
}
  • 特点
    • 若 main 无显式 return,默认返回 0(C 标准规定)。
    • 退出码范围为 0~255,超出则自动取模 256(如 return -1 等价于退出码 255)。

2-2-3 🔥调用 exit() 函数(标准库函数)🔥

   exit() C 标准库函数(需包含 <stdlib.h>),可在程序任意位置调用(如子函数),触发进程终止并执行必要的清理操作。

void exit(int status);
  • 原理:调用 exit(退出码) 后,进程会先完成以下操作,再终止:
    1. 执行通过 atexit() 注册的 “退出清理函数”(按注册逆序执行);
    2. 刷新所有标准 I/O 缓冲区(如 printf 未手动刷新的内容);
    3. 关闭打开的标准 I/O 流(如 stdout);
    4. 传递退出码给父进程,由内核回收资源。
  • 示例:

  • 输出:

2-2-4 🔥调用 _exit() 函数(系统调用)🔥

  _exit() 是 Linux 系统调用需包含 <unistd.h>,同样可在任意位置调用,但不执行用户态清理操作,直接通知内核终止进程。

void _exit(int status);
  • 原理:调用 _exit(退出码) 后,进程跳过缓冲区刷新和清理函数执行直接释放内核资源(如内存、文件描述符),适合无需清理的场景。
  • 示例:

  • 输出:(无内容,缓冲区未刷新)

2-2-5 🔥exit函数与_exit函数的区别🔥

exit 函数_exit 函数主要有以下区别:

  • 所属类型与头文件:
    • exit 函数:C 标准库函数,定义在<stdlib.h>头文件中。
    • _exit 函数:是系统调用函数,定义在<unistd.h>头文件中。
  • 清理工作执行情况:
    • exit 函数:调用时会执行一系列清理工作。它会先调用所有已注册的 atexit () 函数,这些函数可用于在程序退出时执行清理工作,如释放资源等;接着刷新所有打开的 stdio 流,确保缓冲的数据被写入文件或输出;然后关闭所有打开的文件描述符;最后调用_exit () 来真正终止进程。
    • _exit 函数:直接终止进程,不进行任何清理工作。它不会调用 atexit () 注册的函数,也不会刷新 stdio 缓冲区或关闭文件描述符。
  • 使用场景
    • exit 函数:适合用于进程正常运行结束,需要在退出前进行标准清理工作的场景。例如程序需要确保所有缓冲区都被正确刷新、所有文件都被关闭,或执行一些退出时的清理函数(如释放动态分配的内存、写入日志等),此时应使用 exit 函数。
    • _exit 函数:常用于需要快速、直接退出的场景,尤其是在多进程编程中。例如,在 fork () 创建子进程后,子进程在执行 exec () 前检测到错误,需要立即退出时,应使用_exit () 而非 exit (),以避免父进程资源的不必要继承,防止影响文件缓冲区、打开的文件描述符等。
特性exit 函数_exit 函数
所属类型与头文件C 标准库函数,头文件 <stdlib.h>系统调用函数,头文件 <unistd.h>
清理工作

执行完整清理:

1. 调用 atexit () 注册的函数

2. 刷新所有 stdio 流缓冲区

3. 关闭所有打开的文件描述符

4. 最终调用 _exit () 终止进程

不执行任何清理工作,直接终止进程
适用场景程序正常结束,需确保资源正确释放、缓冲区刷新等场景需快速退出的场景,如多进程中子进程出错退出,避免影响父进程资源

代码示例:

  • 下面这张图片是分别测试两个代码得到的,第一个代码和第二个代码的区别就是输出语句中是否带有\n',再调用exit函数查看现象,我们可以发现两个程序都输出了,但是第一个代码是程序一运行就打印出字符串,而第二个代码是程序结束后才打印出字符串。
#include <stdio.h>                                                                                                           
#include <unistd.h>      
#include <stdlib.h>      
      
int main()      
{      
    printf("I am a code\n");      
      
    sleep(3);    
                                                                     
    exit(0);                                                         
} 
#include <stdio.h>                                                                                                           
#include <unistd.h>      
#include <stdlib.h>      
      
int main()      
{      
    printf("I am a code");      
      
    sleep(3);    
                                                                     
    exit(0);                                                         
} 

  • 下面这张图片是分别测试两个代码得到的,第一个代码和第二个代码的区别就是输出语句中是否带有\n',再调用_exit函数查看现象,我们可以发现第一个代码是程序一运行就打印出字符串,而第二个代码是程序结束后也没有打印出字符串。
#include <stdio.h>                                                                                                           
#include <unistd.h>      
#include <stdlib.h>      
      
int main()      
{      
    printf("I am a code\n");      
      
    sleep(3);    
                                                                     
    _exit(0);                                                         
} 
#include <stdio.h>                                                                                                           
#include <unistd.h>      
#include <stdlib.h>      
      
int main()      
{      
    printf("I am a code");      
      
    sleep(3);    
                                                                     
    _exit(0);                                                         
} 

小结:
exit函数库函数_exit函数系统调用,并且通过上面代码的测试我们发现,exit函数能够在进程结束后强制刷新缓冲区,而_exit函数在进程结束后不能够刷新缓冲区,exit函数本质上在底层封装的就是_exit函数,目前我们能够得到的结论就是缓冲区不在操作系统的内部。

2️⃣异常终止(信号触发,无主动退出码)

        异常终止是进程被动结束的方式,通常由外部信号或程序错误触发,进程无法自主控制,且不会生成 “主动退出码”(父进程需通过信号判断原因)。

最常见场景:Ctrl + C 终止

  • 原理:在终端中按下 Ctrl + C 时,终端会向当前前台进程发送 SIGINT 信号(信号编号 2),进程默认会立即终止。
  • 特点:
    • 不执行 exit() 的清理步骤(如缓冲区不刷新、清理函数不执行)。
    • 父进程可通过 wait() 获取信号编号,判断进程是否被信号终止。
  • 示例:

  • 操作与结果:运行后按 Ctrl + C,进程立即终止,无额外清理操作。

总结

  • 正常终止:main 返回、exit()_exit() 均为主动退出,核心区别在于是否执行用户态清理(exit() 清理最完整,_exit() 最直接)。
  • 异常终止:Ctrl + C 等信号触发被动退出,适用于强制终止进程的场景。

选择哪种方式取决于是否需要清理资源主进程通常用 exit() 或 main 返回子进程常用 _exit();异常终止则用于紧急停止的场景。


🧐进程创建与终止核心知识总结🔑

        本文围绕进程的两大关键生命周期阶段 —— 创建与终止展开,系统梳理了核心概念、原理、常见问题及实践用法,具体内容如下:        

1️⃣进程创建

1. fork 函数核心认知

  • 内核执行流程fork 创建子进程时,内核需完成四步关键操作 —— 为子进程分配新内存块与内核数据结构、拷贝父进程部分数据结构内容、将子进程加入系统进程列表、返回后交由调度器调度。
  • 返回值特性:存在 “一个函数两个返回值” 的特殊现象,子进程返回 0、父进程返回子进程 PID,这是因父进程需管理多个子进程(需 PID 标识),而子进程可通过其他方式找到父进程,无需 PID 返回。
  • 写时拷贝机制:是平衡进程独立性与内存效率的关键设计,核心基于 “进程数据必须隔离” 的前提。创建子进程时不直接拷贝数据,仅当父子进程有一方要修改数据时才触发拷贝;不初始拷贝是为按需分配内存、提升利用率;代码段因通常只读,90% 场景下无需写时拷贝,仅特殊适配场景可能涉及,其逻辑链围绕 “隔离需求” 与 “效率优化” 展开。
  • 常规用法:主要有两类,一是让父子进程并行执行不同代码段,实现任务分工;二是子进程通过 exec 执行全新程序,本质是基于 fork 创建的进程 “载体”,实现程序的切换与执行。
  • 调用失败原因:分为系统级与用户级限制,系统中进程数量超内核上限,或用户级进程数超过配额限制,都会导致 fork 调用失败。

2️⃣进程终止

1. 进程退出场景

进程退出分三大类,核心差异在于 “是否正常运行完毕” 与 “结果是否符合预期”:

  • 正常退出(结果正确):代码运行完毕,且执行结果符合预期。
  • 正常退出(结果不正确):代码虽运行完毕,但执行结果不符合预期,仍属于正常终止范畴。
  • 异常终止:代码未正常运行完毕,因意外中断终止,又分两类 —— 程序自身错误(如越界访问)导致操作系统强制终止,或外部信号(如用户执行 kill 命令)导致进程强制终止。

2. 常见退出方法

  • 正常终止方式:可通过退出码标识结果(echo $? 可查看),包括从 main 函数返回(return 语句)、调用标准库函数 exit ()、调用系统调用_exit ();其中 exit () 与_exit () 存在区别,exit () 会先执行用户级清理操作(如刷新缓冲区)再终止,_exit () 直接进入内核终止进程。
  • 异常终止方式:由信号触发,进程无主动退出码,因是意外中断,非进程主动执行退出操作。

结束语

以下就是我对【Linux系统编程】进程控制:创建与终止的理解

感谢你的三连支持!!!

评论 6
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值