目录

一、进程控制概述
二、进程终止
    2.1 进程终止的常见场景
    2.2 进程终止的三种方式
        2.2.1 正常终止:main函数return
        2.2.2 正常终止:exit与_exit系统调用
        2.2.3 异常终止:信号触发
    2.3 进程终止的核心流程
三、进程等待
    3.1 进程等待的必要性
    3.2 进程等待的核心函数
        3.2.1 基础等待函数:wait
        3.2.2 灵活等待函数:waitpid
    3.3 子进程退出状态解析
    3.4 非阻塞等待的实现
四、进程程序替换
    4.1 程序替换的本质
    4.2 exec系列替换函数
        4.2.1 函数原型与参数解析
        4.2.2 函数使用场景对比
    4.3 程序替换的底层原理
五、进程控制实战:迷你Shell实现
六、总结


一、进程控制概述

进程控制包括创建、终止、等待、程序替换四大核心操作。在之前的文章中,我们已详细讲解了进程创建的核心接口fork():其通过“写时拷贝”机制生成与父进程几乎一致的子进程,实现“一次调用、两次返回”的独特行为。而本文将聚焦进程控制的另外三个关键环节:进程终止(如何让进程正常或异常退出)、进程等待(如何安全回收子进程资源)、进程程序替换(如何让进程执行新的程序代码),这些操作共同构成了进程生命周期管理的完整闭环。

二、进程终止

进程终止是进程生命周期的终点,指进程从运行状态转为退出状态(X状态),并释放占用的资源。理解进程终止的场景、方式与流程,是确保系统资源不泄漏的基础。

2.1 进程终止的常见场景

在实际开发中,进程终止主要源于以下三类场景:

  1. 正常完成任务:进程按预期执行完所有代码(如main函数执行到return);
  2. 异常错误终止:进程执行过程中遇到不可恢复的错误(如除零错误、访问空指针);
  3. 外部信号终止:进程接收到外部发送的终止信号(如用户按下Ctrl+C发送SIGINT信号,或通过kill命令发送SIGKILL信号)。

2.2 进程终止的三种方式

Linux提供了多种进程终止方式,可分为“正常终止”和“异常终止”两类,不同方式的核心区别在于是否会生成“退出状态码”(用于告知父进程终止结果)。
在这里插入图片描述

2.2.1 正常终止:main函数return

main函数的return语句是用户进程最常见的正常终止方式,其本质是通过return返回一个“退出状态码”,告知操作系统进程的执行结果。

  • 退出状态码return n中的n即为退出状态码,0表示进程正常终止,非0表示异常(具体数值可自定义,用于区分不同错误类型);
  • 底层关联main函数的return最终会调用exit系统调用,将退出状态码传递给内核,由内核记录到子进程的task_struct(PCB)中,等待父进程读取。

2.2.2 正常终止:exit与_exit系统调用

若进程需要在非main函数中终止(如函数执行出错时直接退出),可使用exit_exit系统调用,二者的核心区别在于是否刷新用户层缓冲区

函数 头文件 功能说明 缓冲区处理
exit(int status) <stdlib.h> 正常终止进程,将status作为退出状态码,会执行用户层清理操作(如刷新缓冲区) 刷新用户层缓冲区(如printf缓冲区)
_exit(int status) <unistd.h> 直接终止进程,仅将status传递给内核,不执行用户层清理 不刷新缓冲区,直接丢弃

2.2.3 异常终止:信号触发

当进程遇到非法操作(如除零、段错误)或接收到外部终止信号时,会触发“异常终止”。此时进程不会生成自定义的退出状态码,而是由内核记录“信号编号”,告知父进程终止原因。

常见的触发异常终止的信号:

  • SIGINT(2号信号):用户按下Ctrl+C,进程被中断;
  • SIGSEGV(11号信号):进程访问非法内存地址(如空指针、数组越界),触发段错误;
  • SIGFPE(8号信号):进程执行非法算术运算(如除零);
  • SIGKILL(9号信号):外部通过kill -9 PID发送的强制终止信号,进程无法忽略。

2.3 进程终止的核心流程

无论通过哪种方式终止,进程最终都会进入内核态,由内核完成以下核心操作:

  1. 释放资源:回收进程占用的内存资源(如堆、栈、共享区)、文件描述符、网络连接等;
  2. 更新PCB状态:将task_struct中的进程状态从“运行/睡眠”等状态改为“死亡状态(X)”或“僵尸状态(Z)”;
  3. 保存终止信息:将“退出状态码”(正常终止)或“信号编号”(异常终止)存入task_struct,等待父进程通过wait系列函数读取;
  4. 通知父进程:向父进程发送SIGCHLD信号,告知父进程“子进程已终止,请回收资源”。

三、进程等待

进程等待是父进程通过wait系列系统调用,读取子进程终止信息并回收子进程PCB的操作。若父进程不执行等待,子进程会成为“僵尸进程”,持续占用内存资源,引发内存泄漏。

3.1 进程等待的必要性

为什么必须执行进程等待?核心原因有两点:

  1. 避免僵尸进程:子进程终止后,其PCB不会立即释放(需保留终止信息),若父进程不读取这些信息,子进程会一直处于“僵尸状态(Z)”,占用PID和内存资源;
  2. 获取终止结果:父进程需要通过等待函数读取子进程的“退出状态码”或“终止信号”,判断子进程是正常完成任务还是异常终止(如是否因段错误崩溃)。

3.2 进程等待的核心函数

Linux提供waitwaitpid两个核心等待函数,其中waitpid功能更灵活,支持指定等待的子进程、非阻塞等待等。

3.2.1 基础等待函数:wait

wait是最简单的等待函数,功能是“阻塞等待任意一个子进程终止,并回收其资源”。

函数原型

#include <sys/wait.h>
#include <sys/types.h>

pid_t wait(int* status);
  • 参数status:输出型参数,用于存储子进程的终止信息(退出状态码或信号编号),若不需要该信息,可传入NULL
  • 返回值:成功时返回终止子进程的PID;失败时返回-1(如无子进程可等待)。

使用示例(回收任意子进程):

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

int main() {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) { // 子进程
        printf("Child PID: %d, will exit after 3s\n", getpid());
        sleep(3);
        exit(10); // 子进程正常终止,退出状态码10
    } else { // 父进程
        int status;
        pid_t ret = wait(&status); // 阻塞等待子进程终止
        if (ret > 0) {
            printf("Parent wait success, Child PID: %d\n", ret);
        }
    }
    return 0;
}

在这里插入图片描述

3.2.2 灵活等待函数:waitpid

waitpidwait的增强版,支持“指定子进程PID”“非阻塞等待”等功能,是实际开发中更常用的等待函数。

函数原型

pid_t waitpid(pid_t pid, int* status, int options);
  • 参数pid:指定等待的子进程PID,有三种取值:
    • pid > 0:等待PID等于该值的子进程;
    • pid == -1:等待任意一个子进程(功能与wait一致);
    • pid == 0:等待与父进程同属一个进程组的子进程;
  • 参数status:与wait一致,存储子进程终止信息;
  • 参数options:等待选项,常用WNOHANG(非阻塞等待),若子进程未终止,函数立即返回0,不阻塞;
  • 返回值
    • 成功:返回终止子进程的PID(阻塞等待)或0(非阻塞等待时子进程未终止);
    • 失败:返回-1(如无子进程、被信号中断)。

使用示例(指定PID+非阻塞等待):

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

int main() {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) { // 子进程
        printf("Child PID: %d, sleeping 5s\n", getpid());
        sleep(5);
        exit(20);
    } else { // 父进程:非阻塞等待子进程
        int status;
        while (1) {
            pid_t ret = waitpid(pid, &status, WNOHANG); // 非阻塞
            if (ret == 0) {
                // 子进程未终止,父进程可执行其他任务
                printf("Parent: Child is still running, do other things...\n");
                sleep(1);
            } else if (ret > 0) {
                // 子进程终止,回收成功
                printf("Parent wait success, Child PID: %d\n", ret);
                break;
            } else {
                // 等待失败
                perror("waitpid");
                break;
            }
        }
    }
    return 0;
}

在这里插入图片描述

3.3 子进程退出状态解析

waitwaitpidstatus参数存储了子进程的终止信息,但并非直接存储“退出状态码”或“信号编号”,而是通过位运算封装的复合信息。

status参数的位结构(32位系统)

status的低16位用于存储终止信息,具体划分如下:

  • 低7位:存储“终止信号编号”,若该值非0,表示子进程因信号异常终止;
  • 第8位:存储“退出状态码的高8位”(实际退出状态码为8位,此处直接取该位即可),若低7位为0,表示子进程正常终止,该位即为退出状态码。
    在这里插入图片描述

3.4 非阻塞等待的实现

非阻塞等待的核心是通过waitpid(pid, &status, WNOHANG)实现“轮询检测子进程状态”,父进程在等待期间可执行其他任务,避免阻塞。

应用场景:父进程需要同时管理多个子进程,或等待期间需处理其他事件(如网络请求、用户输入)。

多子进程非阻塞等待示例

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

int main() {
    // 创建3个子进程
    pid_t pids[3];
    for (int i = 0; i < 3; i++) {
        pids[i] = fork();
        if (pids[i] < 0) {
            perror("fork");
            return 1;
        } else if (pids[i] == 0) {
            printf("Child %d (PID: %d) will exit after %ds\n", i+1, getpid(), (i+1)*2);
            sleep((i+1)*2);
            exit(i+1); // 子进程1退出码1,子进程2退出码2...
        }
    }

    // 父进程:非阻塞等待所有子进程
    int wait_count = 0;
    while (wait_count < 3) {
        for (int i = 0; i < 3; i++) {
            if (pids[i] == 0) continue; // 已回收的子进程,跳过

            int status;
            pid_t ret = waitpid(pids[i], &status, WNOHANG);
            if (ret > 0) {
                // 回收成功
                printf("Parent wait Child %d (PID: %d) success, exit code: %d\n", 
                       i+1, ret, WEXITSTATUS(status));
                pids[i] = 0; // 标记为已回收
                wait_count++;
            }
        }
        // 等待期间执行其他任务
        printf("Parent: waiting for children...\n");
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

四、进程程序替换

进程程序替换是指通过exec系列系统调用,将进程当前的代码段、数据段替换为新程序的代码段、数据段,让进程执行新程序的操作。替换后进程的PID不变,但执行逻辑完全改变。

4.1 程序替换的本质

程序替换的核心是“替换内存映像,保留进程身份”:

  • 保留的资源:进程的PIDPPIDPCB、打开的文件描述符、信号屏蔽字等;
  • 替换的资源:进程的代码段(正文区)、数据段(初始化/未初始化数据区)、堆、栈,以及虚拟地址空间的映射关系(更新页表,指向新程序的物理内存)。

简单来说:程序替换后,“进程还是原来的进程(PID不变),但干的活变成了新程序的活”。

4.2 exec系列替换函数

Linux提供6个exec系列函数,均以exec开头,核心区别在于参数传递方式是否自动搜索PATH路径

4.2.1 函数原型与参数解析

6个exec函数的原型如下,可通过函数名的后缀记忆功能:

  • l(list):参数以列表形式传递,需手动传入所有参数,最后以NULL结尾;
  • v(vector):参数以字符串数组形式传递,数组最后一个元素需为NULL
  • p(path):自动搜索PATH环境变量中的路径,无需指定新程序的完整路径;
  • e(environment):自定义环境变量,传入新的环境变量数组,默认使用当前进程的环境变量。
#include <unistd.h>

// 1. execl:列表传参,需指定完整路径
int execl(const char *path, const char *arg, ...);
// 2. execlp:列表传参,自动搜索PATH
int execlp(const char *file, const char *arg, ...);
// 3. execv:数组传参,需指定完整路径
int execv(const char *path, char *const argv[]);
// 4. execvp:数组传参,自动搜索PATH
int execvp(const char *file, char *const argv[]);
// 5. execle:列表传参+自定义环境变量
int execle(const char *path, const char *arg, ..., char *const envp[]);
// 6. execve:数组传参+自定义环境变量(系统调用,其他为库函数封装)
int execve(const char *path, char *const argv[], char *const envp[]);

返回值:所有exec函数均“成功无返回,失败返回-1”——因为成功后进程代码段已替换,原函数的返回逻辑不再执行;若返回-1,说明替换失败(如程序路径错误、权限不足)。

4.2.2 函数使用场景对比

不同exec函数的使用场景不同,以下为常见场景示例:

函数 示例(执行ls -l命令) 适用场景
execl execl("/bin/ls", "ls", "-l", NULL); 参数少,知道程序完整路径
execlp execlp("ls", "ls", "-l", NULL); 参数少,不知道完整路径(依赖PATH)
execvp char* argv[] = {"ls", "-l", NULL}; execvp("ls", argv); 参数多,用数组管理
execve char* envp[] = {"PATH=/bin", NULL}; execve("/bin/ls", argv, envp); 需自定义环境变量

示例代码(execlp执行ls命令):

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

int main() {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // 子进程:替换为ls -l命令
        printf("Child will exec ls -l\n");
        execlp("ls", "ls", "-l", NULL); // 成功则不返回
        // 若执行到这里,说明替换失败
        perror("execlp");
        _exit(1);
    } else {
        // 父进程等待子进程
        wait(NULL);
        printf("Parent: Child exec done\n");
    }
    return 0;
}

在这里插入图片描述

4.3 程序替换的底层原理

程序替换的底层依赖“虚拟地址空间”和“页表映射”机制,具体流程如下:

  1. 读取新程序exec函数根据路径找到新程序的可执行文件(如/bin/ls),将文件内容加载到内存;
  2. 清空旧映射:内核清空当前进程虚拟地址空间中“代码段、数据段、堆、栈”的页表映射,释放对应的物理内存(若其他进程未共享);
  3. 建立新映射:将新程序的代码段、数据段映射到进程的虚拟地址空间(如代码段映射到0x400000开始的地址),更新页表;
  4. 重置程序计数器:将CPU的程序计数器(PC)指向新程序的入口地址(通常是代码段的起始地址),进程开始执行新程序。
    在这里插入图片描述

五、进程控制实战:迷你Shell实现

结合进程创建(fork)、进程等待(waitpid)、进程程序替换(execvp),可实现一个简易的Shell(命令行解释器),核心逻辑如下:

  1. 读取命令:从标准输入读取用户输入的命令(如ls -l);
  2. 解析命令:将命令拆分为“程序名”和“参数数组”;
  3. 创建子进程:通过fork创建子进程,避免父进程被替换;
  4. 程序替换:子进程通过execvp替换为目标程序;
  5. 父进程等待:父进程通过waitpid等待子进程终止,准备接收下一个命令。

实现代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

#define MAX_CMD_LEN 1024
#define MAX_ARG_NUM 32

// 解析命令:将输入的cmd_str拆分为argv数组
void parse_cmd(char *cmd_str, char *argv[]) {
    int argc = 0;
    // 按空格分割字符串
    char *token = strtok(cmd_str, " ");
    while (token != NULL && argc < MAX_ARG_NUM - 1) {
        argv[argc++] = token;
        token = strtok(NULL, " ");
    }
    argv[argc] = NULL; // 数组末尾加NULL
}

int main() {
    char cmd_str[MAX_CMD_LEN];
    char *argv[MAX_ARG_NUM];

    while (1) {
        // 1. 打印提示符
        printf("[minishell]$ ");
        fflush(stdout); // 刷新提示符(避免缓冲区问题)

        // 2. 读取命令
        if (fgets(cmd_str, MAX_CMD_LEN, stdin) == NULL) {
            perror("fgets");
            continue;
        }
        // 去除fgets读取的换行符(\n)
        cmd_str[strcspn(cmd_str, "\n")] = '\0';

        // 3. 解析命令
        parse_cmd(cmd_str, argv);
        if (argv[0] == NULL) continue; // 空命令,跳过

        // 4. 内置命令:exit(退出Shell)
        if (strcmp(argv[0], "exit") == 0) {
            printf("minishell exit\n");
            exit(0);
        }

        // 5. 创建子进程执行命令
        pid_t pid = fork();
        if (pid < 0) {
            perror("fork");
            continue;
        } else if (pid == 0) {
            // 子进程:程序替换
            execvp(argv[0], argv);
            // 替换失败才执行
            perror("execvp");
            exit(1);
        } else {
            // 父进程:等待子进程
            waitpid(pid, NULL, 0);
        }
    }

    return 0;
}

运行效果
在这里插入图片描述

六、总结

进程控制核心逻辑围绕“进程生命周期管理”展开:

  1. 创建:通过fork生成子进程,依赖“写时拷贝”实现资源高效复用;
  2. 终止:通过return/exit/信号实现,内核负责释放资源并保存终止信息;
  3. 等待:通过wait/waitpid回收子进程,避免僵尸进程,获取终止结果;
  4. 替换:通过exec系列函数替换程序,让进程执行新任务,保留进程身份。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐