【Linux系统编程】进程控制(一)——进程创建与进程终止

发布时间:2026/7/23 20:19:05
【Linux系统编程】进程控制(一)——进程创建与进程终止 文章目录1. 进程创建1.1 fork系统调用1.2 写时拷贝1.3 fork常规用法1.4 fork调用失败的原因2. 进程终止2.1 进程终止的场景2.2 进程常见的退出方法2.2.1 return 退出退出码2.2.2 exitreturn方式 vs 函数exit2.2.3 _exitexit函数 vs 系统调用_exit缓冲区在哪里呢本节我们讲解进程控制依次分为进程创建、进程终止、进程等待和进程程序替换。1. 进程创建关于进程创建其实我们在之前的文章中已经讲解的差不多了这里我们快速回顾一下并进行一些补充1.1 fork系统调用在 linux 中 fork 函数是非常重要的函数它从已存在进程中创建⼀个新进程。新进程为子进程原进程为⽗进程。fork成功的话在父进程中返回子进程的PID在子进程中返回0。失败的话-1在父进程中返回不会创建任何子进程并且正确设置了errno思考为什么fork给子进程返回0给父进程返回子进程的PID首先返回值的不同使得我们可以区分父子进程从而实现分流。其次一个进程可以创建很多个子进程而父进程通过子进程的PID就可以唯一地找到一个子进程对其管理/控制。给子进程返回 0 是一个简洁的标识表明我是子进程我被成功创建了。进程调用fork创建子进程内核做• 分配新的内存块和内核数据结构给子进程• 将父进程部分数据结构内容拷贝给子进程• 添加子进程到系统进程列表当中• fork 返回子进程开始被调度所以fork之前父进程独立执行fork之后父子两个执行流分别执行。注意fork之后谁先执行完全由调度器决定。1.2 写时拷贝一个进程调用fork创建子进程之后父子代码共享父子在不写入时数据也是共享的当任意一方试图写入时便以写时拷贝的方式各自拷贝一份副本。具体过程如下未调用fork之前只有父进程自己它的代码和数据通过页表映射到物理内存的特定区域。代码是只读的数据是可读可写的。然后调用fork创建子进程之后父子进程代码共享数据也先共享未发生修改。但是此时操作系统会把会把父子进程对于数据的权限改为只读注意这里修改的是页表中的权限不会修改vm_area_struct中的权限VMA 权限仍然是可写的回看上一篇文章这样做的目的是什么呢不是为了限制访问而是作为陷阱当任何一方尝试写入时CPU 的 MMU 会触发缺页异常保护异常让内核有机会介入。什么意思呢我们继续往下看后来父子进程中的某一方比如子进程修改了某个数据。但是这时发现页表中的权限是只读那么就会触发异常此时内核介入进行判断处理发现该虚拟地址所在的 vm_area_struct 中是有 VM_WRITE 权限即 VMA 允许写并且内核通过某种方式识别出这个只读的页表项并不是因为权限错误而是因为写时拷贝COW而故意设为只读的。然后就会执行写时拷贝内核分配一个新的物理页。将原物理页的内容复制到新页。修改触发写入的进程子进程的页表项指向新页并恢复可读可写权限。父进程的页表项保持不变仍然指向原来的物理页。仍然为只读因为父进程可能还会和别的子进程共享或直到父进程自身尝试写入该页时才会通过缺页异常处理来升级权限写时拷贝之后写入进程现在有了自己独占的可写副本可以安全修改保证了进程数据层面的独立性因为有写时拷贝技术的存在所以父子进程得以彻底分离完成了进程独立性的技术保证!写时拷贝是一种延时申请技术可以提高整机内存的使用率。问题为什么非要先复制旧内容再修改直接分配新页写入要修改的新值不行嘛因为写入可能不只是像g_val新值这样的修改也有可能是g_val那你不拷贝过去直接在新分配的空间上怎么知道之前是几呢或者是一个数组你只是修改其中某个元素比如arr[2]新值那不拷贝过去其它的元素怎么知道是几呢所以先拷贝再修改1.3 fork常规用法⼀个父进程希望复制自己使父子进程同时执行不同的代码段分流。例如父进程等待客户端请求生成子进程来处理请求。让子进程执行一个全新的程序。后面讲1.4 fork调用失败的原因系统中有太多的进程实际用户的进程数超过了限制2. 进程终止进程终止的本质是释放系统资源就是释放进程申请的相关内核数据结构和对应的数据和代码。2.1 进程终止的场景一个进程终止无非就三种情况代码运行完毕结果正确代码运行完毕结果不正确进程异常终止如果一个进程把代码跑完了那结果对还是不对如果不对出了什么问题呢如何区分它是哪种情况呢通过进程的退出码就可以知悉。那如果代码没有跑完异常终止呢那此时退出码其实就没有意义了。就好比现在有一场考试有个同学考试作弊没考完就被监考老师带走了那此时他的考试成绩还有意义吗还能代表他学习好坏吗当然不能就没有意义了。当然异常终止时退出码也不会被正常设置了。这时候我们应该把注意力放到这个进程为什么会出异常上一旦出现异常操作系统一般就立即知道了然后通常的做法就是直接把这个进程杀掉可以通过给进程发送信号的方式。所以如果知道这个进程收到了什么信号我们就可以推断出它出异常的原因关于信号我们后面有专门的章节来讲解。2.2 进程常见的退出方法2.2.1 return 退出return呢相信大家都不陌生return关键字主要用于函数中使用return进行函数返回值的返回一旦执行return就代表函数调用结束终止不再执行 return 后面的任何代码。返回类型为 void 的函数不返回任何值。其中 return 语句可以省略函数结束时自动返回只不过不给调用者返回具体的返回值不过呢main函数中的return相对比较特殊一点我们以前写C/C程序的时候main函数通常最后我们会写一个return 0比如我们来随便写一个程序那这个return 0是什么意思呢这个0返回给谁了啊上面我们介绍了普通函数中return的作用但是main函数中的return语句稍微要特殊一点main函数一旦执行return语句进程就退出了那退出就退出呗返回一个0是什么意思这个问题其实我们C语言的第一篇文章简单提到过当程序运行结束时main函数中的return语句会将一个整数值进程退出码返回给操作系统最终会被其父进程获取表示程序的执行状态。一般来说返回值为0表示程序执行成功非零值表示程序执行出现错误。main 的返回值先给运行时库再通过系统调用传给内核最终被父进程或 shell 命令行启动的大部分程序的父进程获取并可通过echo $?查看我们来查看一下echo $?打印上一条命令的退出状态码也就是返回值我们也可以修改一下main函数的返回值这下再来查看退出码就也变成了100不过注意进程的退出状态码占8 位0 到 255。超出这个范围的值会被截断只保留 值 % 256 的结果。2026%256234退出码那为什么需要有退出码呢因为我们创建一个进程通常是让他帮我们完成某些任务的。就是帮忙办事的那事办得怎么样我需要知道所以通过进程退出码来反馈事有没有办成功如果没有也要知道出了什么问题。而退出码0就代表执行无误这是进程执行的理想状态。一件事情办成功了一般我们不太关心为什么成功了。就比如你这次考试考了100分你爸爸一般不会问你为什么为什么你这次考了100分但是如果你失败了你考了个不及格20分。你爸爸大概会很生气会问你原因怎么回事啊天天在学校都干啥了为什么考这么低那同样的如果一个进程执行某项任务失败了通常需要知道它为什么执行失败了获取失败的原因。退出码如果是非0通常就代表失败了。非0有很多值所以也对应了失败的原因是很多的不同的退出码代表不同的退出原因。这里正式给出退出码的概念在 Linux 中退出码Exit Code也称为退出状态Exit Status或返回值是一个进程在终止时返回的整数值范围 0–255。它用于告知父进程该进程的执行结果。0 通常表示成功正常结束无错误。非 0表示失败或某种特定状态。那0我们了解了非0的退出码有哪些呢分别代表什么意思呢上面提到退出码的范围是0~255那是不是一共有256种退出码呢如果不是那有效的退出码有几种呢那下面我们可以通过程序来看一下怎么做呢C语言中提供了一个全局的错误变量——errnoC语言的库函数在调用失败时会产生错误码这个错误码会保存到errno中另外C语言还提供了一个库函数——strerror我们之前的文章是讲过这两个东西的它可以将C语言中的错误码转化为对应的错误信息并返回对应错误信息字符串的首地址。接收错误码返回错误码对应的错误信息字符串那现在我们就可以借用这个函数来看一看所有退出码对应的退出信息还不知道具体有几个0~255只是范围试一试我们看到有效的是0~133即134个每一个退出码都对应不同的退出信息。举个例子使用ls命令查看一下不存在的文件我们看到报的错误信息就是No such file or directory对应的错误码是2我们可以验证一下没有问题此外当一个进程退出时它的task_struct中会保存这个退出码等待父进程获取其退出结果在task_struct的定义中我们能找到exit_code这个字段它就用来存储进程的退出码2.2.2 exit除了main函数中return来退出一个进程我们还可以使用C标准库中的一个函数——exit其实在我们之前C/C的文章中是用过这个函数的。exit函数接收一个整形的参数在程序的任何地方调用exit函数都会导致进程立即退出这个参数就是进程的退出码等同于进程的退出码相当于调用了 exit(main的返回值)试一下return方式 vs 函数exit使用return来退出一个进程这个return一定是main函数中进行return。但是如果使用exit在程序中的任何地方调用exit都会导致进程退出不在main函数中也可以下面通过代码体会两者的差异先来看这段代码代码很简单无需解释看结果没有问题符合预期修改一下代码把自定义函数print中的return 10改成exit(10)再看结果没有打印“程序正常结束”因为2.2.3 _exit还有第三种方法使用系统调用——_exit参数和exit函数一样但是从目前的演示来看exit和_exit好像没看出有啥区别啊参数也一样然后都是在任何地方调用都直接退出进程。那他们到底有什么区别呢exit函数 vs 系统调用_exit下面通过代码验证两者的区别首先再来回顾一下这是我们上面验证过的这里我们看不到两者的区别。现在我们把代码改一下首先看exit还是打印一个字符串但是这次没有\n换行看现象先休眠三秒然后打印了hello world因为我们没有加\n所以命令提示符直接跟在后面再看_exit修改代码只把上面的exit改成_exit来看现象还是先休眠三秒然后并没有打印hello world直接打印了命令行提示符下面依次来解释这里大家可能疑惑的地方第一点为什么先休眠再打印回到第一次的代码使用exit是打印了hello world字符串的但是我们是先调用printf再调用sleep的为什么结果是先休眠再打印字符串呢如果这个问题你不知道原因请你去复习我们之前的进度条那篇文章也是Linux系统编程专栏中的不过我们这里还是再给大家说一下原因吧一起复习一下原因在于当进行输入或输出操作时数据先暂时存储在缓冲区中然后再批量地传输到目标位置或从源位置读取出来。这样可以减少对源位置或目标位置的直接读写次数从而提高数据传输效率。我们调用printf函数打印hello world字符串并不是直接就把它打印输出到显示器上了。而是这个字符串先被放到了缓冲区中。缓冲区被用来暂时存储要输出或被读取的数据直到达到一定条件后才会将其发送到目标位置如屏幕、文件、网络等。这个条件通常是缓冲区满了、遇到换行符、或者主动进行缓冲区刷新的操作。如果你加上换行就会发现是先打印字符串然后休眠所以这里为什么先休眠呢因为我们没有加\n换行所以就暂时不会触发缓冲区的刷新因此我们看到先进行了休眠后面才打印了字符串使用exit的时候第二个问题为什么使用exit最后打印了字符串但是使用_exit最后没有打印呢其实原因就很明显了exit在结束进程之前会刷新缓冲区的内容而_exit直接结束进程不会刷新缓冲区上面说了两者的区别那它们有什么联系吗exit是一个C语言的库函数_exit是系统调用那我们之前讲了库函数和系统调用其实是一个上下层的关系所以exit底层也调用了_exit本质就是对_exit进行了一个封装只不过在最终调用_exit之前做了一些诸如刷新缓冲区这样的操作不管我们采用哪种方式来退出进程最终一定要通过系统调用陷入内核由内核完成最后的资源回收和状态更新。exit()是库函数它会在调用 _exit() 系统调用之前先执行用户态的清理工作其中就包括刷新所有标准I/O流的缓冲区。然后再调用 _exit() 陷入内核进行相关的各种操作比如进程相关资源的释放等_exit()是系统调用它直接进入内核不会进行任何用户态的缓冲区刷新。因此如果程序直接调用 _exit() 退出仍在用户态缓冲区中的数据就会丢失。有了上面的理解我们再来提出一个问题缓冲区在哪里呢目前我们还不知道缓冲区到底底层实现是什么东西但是我们能感知到缓冲区底层一定要维护一段内存空间因为它可以把我们要输出的数据暂时保存起来等待刷新。那缓冲区应该在哪里呢或者先回答它一定不在哪里缓冲区一定不在操作系统内核中因为如果缓冲区是在操作系统内部的那exit和_exit都是在上层的那为什么exit可以刷新缓冲区而_exit不刷新呢给出结论缓冲区是C标准库维护当然学到后期大家会明白这里说的缓冲区是用户级缓冲区其实内核也维护了缓冲区那具体是怎么维护的我们来带大家简单理解一下上面说了我们使用printf打印一个字符串并不是直接输出到显示器上的而是先会暂存到缓冲区等待合适的时机刷新到显示器“文件中”大家可能听过一句话——Linux下一切皆文件。其实我们使用scanf从键盘读数据也是一样从键盘输入的数据也是先被放到缓冲区的后续再从缓冲区中拿到这些数据。我们C语言文件操作的文章中提到过对于任何一个C程序只要运行起来就会默认打开3个流stdin——标准输入流键盘stdout——标准输出流显示器stderr——标准错误流显示器而且这三个流的类型都是FILE *文件指针那FILE是什么呢一个结构体VS2013编译环境提供的stdio.h头文件中有对文件类型FILE的申明这个结构体中其实就维护了我们所说的缓冲区!其中char *_base就指向缓冲区的起始地址。缓冲区是一块由 malloc 或静态分配的内存区域。这就是我们所说的C标准库维护的缓冲区暂时理解到这里就可以了下篇文章我们继续讲解进程控制的进程等待、进程程序替换