【Linux我做主】进程退出和终止详解

发布于:2025-09-03 ⋅ 阅读:(25) ⋅ 点赞:(0)

进程退出和终止

github地址

有梦想的电信狗

0. 前言

​ 在Linux系统编程中,进程的创建、执行和终止构成了操作系统最核心的生命周期管理。当我们使用fork()创建子进程时,操作系统通过写时拷贝(Copy-on-Write) 机制高效地共享内存;当进程完成任务后,它如何向父进程传递执行结果?当程序意外崩溃时,操作系统又是如何捕获并处理异常?这些机制不仅关系到程序的正确性,更直接影响着系统的稳定性和可靠性。

本文将深入探讨以下核心问题:

  1. 写时拷贝的实现细节:操作系统如何通过页表权限控制实现高效内存共享
  2. 进程终止的完整流程:从正常退出的返回码到异常终止的信号机制
  3. 父子进程间状态传递:父进程如何获取子进程的执行结果和终止状态
  4. 系统调用与库函数的差异exit()_exit()在缓冲区处理上的关键区别

1. 写时拷贝的细节

问题引入

  • 操作系统层面,他是如何知道,父子进程共享的这部分数据,是需要发生写时拷贝的呢

写时拷贝的触发机制

写时拷贝(Copy-on-Write)是操作系统在 fork 系统调用中提升效率的一种机制:

  • 父进程在创建子进程时,不会立即复制整个进程的数据段和堆栈段。
  • 父子进程先共享相同的物理内存页,只在需要写入时再进行真正的复制。

结合图片说明:

在这里插入图片描述


1. fork 之后(修改内容之前)
  • 父进程和子进程的 虚拟内存空间 看起来各自独立,但它们的 页表项 都指向相同的物理内存页。
  • 为了保证写时拷贝的正确触发,操作系统在 fork 完成后,会将 父子进程中所有可写的页表项临时标记为只读
  • 因此:
    • 父进程的数据段页表项:只读
    • 子进程继承父进程页表,也同样是只读

👉 这样做的目的是:不论父进程还是子进程**,一旦尝试写入共享数据**,就会触发一次 页保护异常(Page Fault)

(对应图片左边的情况)


2. 写入保护触发时

当父进程或子进程尝试写入共享数据时:

  1. CPU 访问内存发现页表项标记为只读触发缺页异常(Page Fault)
  2. 操作系统检查:这块内存页在历史上原本是可写的,只是因为写时拷贝机制被临时设置为只读
  3. 发生此类缺页异常,操作系统并不做异常处理,而是执行写时拷贝
    • 给当前写入的进程分配一块新的物理内存页。
    • 将旧的内容拷贝到新的物理页中。
    • 更新当前进程的页表项,映射到新的物理页,并重新设置为可写
  4. 这样,父子进程就不再共享这一块物理内存,各自拥有独立的副本。

👉 谁先写,谁就会得到一份新的独立拷贝。

(对应图片右边的情况)


3. 总结:写时拷贝的触发规则
  • fork 时
    父进程的所有可写页表项被临时改为只读,子进程继承相同的只读页表。
  • 写入时
    父子进程中的任意一方,只要尝试写入共享数据,就会触发页表权限错误(缺页异常)。
  • 操作系统处理方式
    不直接报错,而是进行 拷贝-更新-恢复可写 的流程。谁先写,谁就会得到一份新的独立拷贝
  • 最终效果
    读操作仍然共享物理内存,写操作才会真正复制,极大地提升了 fork 的效率。

以上规则仅适用于数据段,关于代码区,如果尝试对代码区进行修改,不会触发写时拷贝,具体的原理请读者自行研究


2. 循环创建多个子进程

我们可以利用循环创建多个子进程

  • 父进程for循环创建子进程,创建完成后不做任何事,接着创建
  • if (id == 0)id == 0时为子进程,子进程执行特定的任务,执行结束后exit(0)正常退出
#define N 5

void runChild() {
    int cnt = 10;
    while (cnt--) {
        printf("I am child: pid: %d, ppid: %d\n", getpid(), getppid());
        sleep(1);
    }
}
// 如何创建 5个子进程
int main() {
    for (int i = 0; i < N; ++i) {
        pid_t id = fork();
        if (id == 0) {
            // 子进程
            runChild();
            exit(0);  // 父进程没有等待,退出后,子进程会变成僵尸进程
        }
        // if 之后是父进程,什么都没做,接着执行循环
    }
    // 父进程等待 1000秒
    sleep(1000);
    return 0;
}

在这里插入图片描述

  • 当调用 fork() 创建子进程后,父子进程会被同时放入操作系统的就绪队列中等待执行。它们的运行顺序由操作系统的进程调度器决定,用户无法预测或干预。这是操作系统的核心设计原则之一:调度器尽可能保证公平性,而非确定性。因此fork()后父子进程谁先运行,无法确定,取决于调度器

3. 进程终止

int main() {
    // ... 
    return 0;
}
  • 为什么main函数总是会return 0,返回1, 2可以吗,这个返回值给了谁? 为什么要返回这个值

0. 进程终止的原因/情景

  • 代码运行完毕,结果正确

  • 代码运行完毕,结果不正确

  • 代码异常终止

通常代码运行完毕,结果正确时,我们不会关注代码结果为什么正确。

我们最关注的是,代码运行完毕,为什么结果会不正确。或者代码异常终止出现异常的原因是什么

1. 为什么要有返回值

int main() {
    printf("模拟一个逻辑的实现\n");
    return 0;
}

为什么 main 函数要返回 0

  • 这里的 0 称为进程的退出码,用于表示进程的运行结果是否正确
    • 0 通常表示运行 success
  • 为什么 main 函数要返回 0? 因为想通过return 0告诉其他人,当前的程序:
    1. 程序的代码运行完毕
    2. 运行结果正确
  • 这里的返回值,是返回给了当前进程的父进程,告诉父进程子进程的运行结果如何。我们这里的父进程是bash,因此 bash 中可以获取到子进程的退出码
  • 在这里插入图片描述

进程的运行,我们不会关心进程为什么运行正确。我们关心的是,为什么进程运行不正确

那么谁会关心一个进程的运行情况呢?

答案是

  • 父进程要关心子进程的运行结果,并且更多地要关心子进程运行结果不正确的原因

如何表示子进程的运行结果呢?

可以用 return 返回不同的数字,表示不同的出错原因

即,进程不同的退出码,代表了不同的运行结果

  • main函数的返回值,本质表示:

    • 进程运行完成时,是否是正确的结果

    • 如果不是,用返回不同的数字,表示不同的出错原因

2. 获取程序的返回值

  • 这里将main函数的返回值手动设置为11
int main() {
    printf("模拟一个逻辑的实现\n");
    return 11;
}
  • 命令行中,echo $?,用于获取最近一次执行的进程退出时的退出码

在这里插入图片描述

  • bash中,$?用于表示命令行中,最近一次执行的进程退出时的退出码
  • ./myproc运行完后,退出码就被保存到了bash中的?变量中
  • 多次执行echo $?时,结果变成了0,这是因为最近一次执行的程序为echo $?echo正确执行,返回值为0

4. 退出码向错误信息的转换

我们之前写的大多数是数据结构的代码,运行的结果我们可以通过printfcout,在终端中人工判断执行结果是否正确。但是,并不是所有的程序都会向终端中打印,因此,进程退出码的存在是很有必要的

库函数由错误码得到错误信息

尽管不同的错误需要返回不同的退出码,但我们仅仅通过退出码,无法快速地得知到底出现了什么错误

因此,C语言为我们提供相应的接口,用于将进程的退出码翻译成相应的错误信息

man strerror	# 查看错误码转换的接口

在这里插入图片描述

  • 查看不同的退出码对应的错误信息
int main() {
    for (int i = 0; i < 150; ++i) {
        printf("%d: %s\n", i, strerror(i));
    }
    return 0;  // 进程的退出码 表征进程的运行结果是否正确 0->success
}

在这里插入图片描述

  • 后面的数字,没有对应的错误信息,说明C语言提供的错误信息是有限的

在这里插入图片描述

  • ls命令退出码的演示:用 ls 显示一个不存在的文件

在这里插入图片描述

  • ls查看一个不存在的文件,执行错误。终端打印提示消息 No such file or directory,细心的同学可以发现,这里的信息其实就是上述错误信息中的2号错误
  • 发生错误后,使用echo $?查看上个进程的退出码,为2符合预期
  • 再次执行ls,执行正确后,echo $?结果为0,符合预期

自定义退出码体系

C语言提供的错误信息是有限的,系统提供的错误码和错误码描述是有对应关系的。

​ 如果我们不满意系统提供的错误码和错误信息,我们可以自定义出一套错误码体系。实现时只需将相关的错误信息存在一个字符串数组中即可,以下给出示意

  • 可以自定义不同的下标对应的错误信息。用下标表示退出码,下标对应的字符串表示错误信息
// 自定义错误码体系
const char* errorString[] = {
    "success", 	// 0表示成功
    "error1",	// 可以自定义不同的下标对应的错误信息
    "error2",
    "error3",
    "error4",
    "error5"
}

5. 父进程为什么要关心子进程的退出码

在这里插入图片描述

这里ls发生错误时,父进程bash确实拿到了错误码,也就是进程ls的退出码

  • 真正关心错误码的,本质是用户
  • 子进程是用户创建的,用户需要关注子进程是否执行成功用户可以通过父进程,获取子进程执行的退出信息(退出码),方便用户根据子进程的退出信息(执行结果),做下一阶段的执行决策

综上代码运行的结果是否正确,统一使用进程的退出码进行表示!!

6. errno

观察以下代码的运行结果

int main() {
    int ret = 0;
    char* p = (char*) malloc(1000 * 1000 * 1000 * 4);
    if (p == NULL) {
        printf("malloc error\n");
        ret = 1;
    } else {
        // 使用内存的逻辑 ...
        printf("malloc success\n");
    }
    return ret;
}

在这里插入图片描述

  • 上述的运行结果,我们调用C语言的库函数malloc函数出现错误,通过echo $?,我们获取到进程的退出码为1,正是我们设置的malloc出错时ret的值

除了进程的退出码C语言会为我们提供一个全局变量errno

  • 我们调用C语言提供的库函数,如果库函数内部调用失败了,函数内会默认将全局变量errno设置成对应的数字,这个数字表示调用该函数时出错的错误码

在这里插入图片描述

  • 全局变量errno中保存的是最近一次库函数出错时对应的错误码,多次出错,错误码会被覆盖。因此每次出错后,我们应该及时进行处理

    • 通过printfstrerror函数,配合全局变量errno获取malloc错误码及其错误信息的写法

    •   printf("malloc error: %d, %s\n", errno, strerror(errno));
      
  • 代码及运行结果展示

int main() {
    int ret = 0;
    char* p = (char*) malloc(1000 * 1000 * 1000 * 4);
    if (p == NULL) {
        // 这么写,既能知道错误码,还能知道错误信息
        printf("malloc error: %d, %s\n", errno, strerror(errno));
        ret = errno;  // 还能将错误码转换成进程的退出码,让父进程也知道出错了
    } else {
        // 使用内存的逻辑
        printf("malloc success\n");
    }
    return ret;
}

在这里插入图片描述

综上

  • malloc或其他库函数调用出现问题时,我们都可以通过**errno配合strerror函数获取到相应的错误码和错误信息**,同时通过进程的返回值,向父进程中返回相应的错误码

7. 进程异常终止

异常终止后退出码无意义

思考,如果代码异常退出,进程的退出码还有意义吗

  1. 正常情况:main 函数的返回值

    • 语言层面上,main 函数执行 return 语句时,会返回一个值。

    • 操作系统系统层面上,当 main 函数返回时,C 运行时库会调用 exit(),从而结束进程,并将 main 的返回值作为 进程退出码

    • 因此,正常情况下,进程的退出码是有意义的,能够反映程序的返回状态。

  2. 异常情况:程序异常终止

    • 如果程序在执行过程中发生了错误(如非法访问内存、除零错误、接收到致命信号等),进程可能会 提前终止

    • 在这种情况下,进程可能根本没有执行到 main 函数的最后一行 return,甚至可能 main 已经返回了,但在随后的代码中又触发了异常。

    • 结果就是:

      • 进程并不是通过 main 的返回值退出的;
      • 系统会根据异常情况(信号)来终止进程;
      • 此时我们看到的 退出状态 只说明进程是被信号杀死的,而不再携带有意义的退出码
  3. 我们能否知道进程是否执行了 return

    • 答案是:无法确定

    • 原因在于:

      • 进程的异常终止是由操作系统在信号机制下完成的;
      • 操作系统不会告诉我们进程是否执行过 main中的 return
      • 我们只能通过 wait/waitpid 提供的宏(如 WIFSIGNALEDWTERMSIG)来判断进程是否因信号终止,但无法得知异常发生的具体代码位置。
  • 结论
    • 当进程 正常退出 时,退出码反映的是 main 的返回值,具有实际意义。
    • 当进程 异常终止 时,进程的退出码就无意义了,我们不再关心进程的退出码,而是关注进程 异常终止的信号

但对于异常退出的进程,我们不关心退出码,如何知道进程是正常运行结束,还是异常退出终止呢?

我们如何知道进程发生了什么异常,以及发生异常的原因呢?

因此进程退出时,我们要

  • 先关注进程有没有出现异常
  • 如果没有异常,再看进程运行的结果是否正确(再关注退出码)

异常终止时的场景

  • 对空指针解引用的异常
// 对空指针解引用的异常
int main() {
    int* p = NULL;
    *p = 10;
    return 0;
}

在这里插入图片描述

  • 这里对空指针进行解引用,本质上是要访问虚拟地址,但该虚拟地址并没有在页表中建立和物理内存的映射关系,或者该虚拟地址在页表中的权限位被设置为只读

  • 除0异常

int main() {
    int a = 10;
    a /= 0;
    return 0;
}

在这里插入图片描述

进程出现异常的本质

这里给出结论

  • 不论进程出现什么异常,本质都是进程收到了对应的信号
  • 在这里插入图片描述

用发送信号模拟进程出现异常

  • 死循环程序,向该进程发送信号使其中止
int main() {
    while (1) {
        printf("hello Linux: pid: %d\n", getpid());
        sleep(1);
    }
    return 0;
}
  • 向进程发送8号信号

  •   kill -8 757104
    
  • 发送完8号信号后,进程出现了浮点数异常

在这里插入图片描述

  • 向进程发送11号信号

  •   kill -11 757113
    
  • 发送完11号信号后,进程出现了段错误异常

在这里插入图片描述

结论

  • 不论进程出现什么异常,本质都是进程收到了对应的信号
  • 判断子进程退出时有没有异常,只需要判断子进程退出时,有没有收到信号

8. exit终止进程

库函数exit的使用

  • man手册中exit的描述

在这里插入图片描述

// exit 退出进程 
int main() {
    printf("hello Linux\n");
    exit(12);
}

在这里插入图片描述

可以看到,调用exit后,该进程退出,并向**父进程(bash进程)**返回了exit()中的数字

总结

  • 在程序当中调用exit(int status)时,传入的数字,会作为进程退出的退出码

  • exit(12)return 12main函数中是等价

exit和return的区别

  • show()函数中exit(13),同时在main函数中return 12;
void show() {
    printf("hello show 1\n");
    printf("hello show 2\n");
    printf("hello show 3\n");
    printf("hello show 4\n");
    exit(13);
    return;
}
int main() {
    show();
    printf("hello Linux\n");
    return 12;
}

运行结果如下:获取的退出码为13,且main函数中的printf("hello Linux\n");没有被执行

在这里插入图片描述

  • show()函数中仅return,只在main函数中return 12;
void show() {
    printf("hello show 1\n");
    printf("hello show 2\n");
    printf("hello show 3\n");
    printf("hello show 4\n");
    // exit(13);
    return;
}
int main() {
    show();
    printf("hello Linux\n");
    return 12;
}

运行结果如下:获取的退出码为12,main函数中的printf("hello Linux\n");正确执行

在这里插入图片描述

总结exit和return的区别

  • return

    • 在其他函数中return,代表函数结束,返回到调用者,控制的是**“当前函数结束**”

    • main函数中return,等价于调用 exit,代表进程退出,返回值作为退出码

  • exitexit任意地方调用,都代表进程退出,控制的是“进程结束”

系统调用 _exit

在这里插入图片描述

现象对比

  • _exit是系统调用,和exit都有终止进程的作用,那exit_exit有什么区别呢,我们看如下代码示例

exit示例

int main() {
    printf("You can see me    ");
    sleep(1);
    exit(11);
    // _exit(11);
}

运行结果如下

在这里插入图片描述

  • 我们的进程正常退出,字符串"You can see me "被正常打印到显示器中。
  • 我们在进度条的实现中知道,printf()函数向显示器中打印时,是先把数据写入到缓冲区,合适的时候再把数据刷新出来
  • 因此这里是,exit()退出进程时,缓冲区中的数据被刷新了出来

_exit示例

int main() {
    printf("You can see me");
    sleep(1);
    // exit(11);
    _exit(11);
}

在这里插入图片描述

  • 调用_exit,字符串并没有被正常打印,也就是缓冲区中的内容没有被刷新出来,因为我们没有写可以刷新缓冲区的return\n

结论

  • _exit()的头文件是<unistd.h>,属于系统调用。系统调用_exit直接在内核层面终止当前进程

  • exit()的头文件是<stdlib.h>,属于库函数;exit()执行时,会先执行用户定义的清理函数,再重刷缓冲区,关闭流等,最终再调用系统调用_exit()终止进程

在这里插入图片描述

  • 推断一下缓冲区所处的位置:缓冲区一定不在内核空间中,而是处于进程地址空间的用户空间中
    • 调用 exit 时的现象,会先执行 fflush() → 刷新标准 I/O 缓冲区 → 写到内核。
    • 调用 _exit 时的现象,不做刷新 ,直接在内核层面终止进程→ 缓冲区内容丢失。

如果缓冲区在内核空间,二者结果应该一致,不会因为 _exit 跳过用户态步骤而丢失数据。

9. 结语

通过本文的探讨,我们对Linux进程的退出和终止机制有了全面深入的理解:

  1. 写时拷贝的精密控制

    操作系统通过临时设置共享页为只读,在首次写入时触发缺页异常完成物理拷贝,实现了高效的内存共享。这种机制在fork()调用中显著提升了性能。

  2. 进程退出的双通道信令

    • 正常退出:通过返回值传递退出码(0表示成功,非0表示错误类型)
    • 异常终止:由信号机制触发(如SIGSEGV/SIGFPE),此时退出码失去意义
  3. 终止调用的层次差异

    调用方式 缓冲区处理 执行位置 使用场景
    exit() 刷新用户空间缓冲区 库函数 需要清理资源的场景
    _exit() 不刷新缓冲区 系统调用 立即终止的紧急场景

关键收获

  • 程序应通过返回有意义的退出码帮助父进程诊断问题
  • 异常处理时优先检查errno获取详细错误信息
  • 在信号处理函数中必须使用_exit()防止缓冲区操作冲突

以上就是本文的所有内容了,如果觉得文章对你有帮助,欢迎 点赞⭐收藏 支持!如有疑问或建议,请在评论区留言交流,我们一起进步

分享到此结束啦
一键三连,好运连连!

你的每一次互动,都是对作者最大的鼓励!


征程尚未结束,让我们在广阔的世界里继续前行! 🚀


网站公告

今日签到

点亮在社区的每一天
去签到