引言

最近正在学习 Linux 系统编程的进程处理板块,学习相关知识整理成博客分享给大家。在Linux的世界里,进程是程序执行的基本单位。理解如何创建、管理和控制进程,是系统编程的核心基石。本文将详细解析fork()execve()waitpid()system()等关键系统调用,并深入探讨由此引发的僵尸进程孤儿进程问题,最后理清进程树的概念。希望能帮助大家巩固这块知识!

1. 什么是进程?

1.1 进程基本概念

在开始介绍基本的函数之前,首先需要理解什么是 "进程" 。可以把一个进程想象成一个正在执行的程序。对于在 Linux 世界的进程理解可以参考我之前的这一篇文章:Linux学习篇10——Linux 进程管理深度解析:进程基础、常用指令、服务管理与系统监控-CSDN博客

我们所需要理解的核心是:程序是静态的,是存储在磁盘上的可执行文件;而进程是动态的,是程序被加载到内存中并执行的一个实例。

1.2 进程树、父进程与子进程

在Linux中,进程形成了一个树状结构,即进程树。除了在系统启动时由内核创建的初始进程 init (或 systemd,PID为1) 之外,每个进程都有父进程。你的shell(如bash)就是一个进程,你从shell启动的任何程序都会成为该shell的子进程

2. 进程创建与进程管理函数详解

2.1 命令行参数与环境参数

在深入进程控制函数之前,那我们就需要知道什么是进程的命令行参数列表与环境变量列表两个至关重要的概念,它们分别对应着 char *argv[] 和 char *envp[] 这两个参数结构,是进程执行时不可或缺的上下文信息。

2.1.1 命令行参数 (argcargv)

当你启动一个程序时,经常会附带一些额外的信息,这些信息就是命令行参数

例如

$ ls -lt /home/csapp

在这个命令中: ls 是shell程序名,-lt 与 /home/csapp 都是传入的参数

在C语言的 main 函数中,我们通过两个参数来接收这些信息:

main 函数原型

int main(int argc, char *argv[]);
// 或者
int main(int argc, char **argv); // 两种写法等价

argc (argument count)命令行参数量

  • 一个整数,表示命令行参数的数量(包括程序名本身)。

  • 对于上面的例子,argc 的值是 4

argv (argument vector)命令行参数列表

  • 一个指向字符串数组的指针,或者说是一个字符串数组。

  • 数组中的每个元素 argv[i] 都是一个 char*,指向一个以 \0 结尾的字符串,即一个参数。

  • 这个数组有一个硬性规定:它的最后一个元素必须是 NULL 指针。这为遍历数组提供了终止条件。

  • 对于上面的例子,argv 数组的内容如下:

    • argv[0] -> "ls" (程序名)

    • argv[1] -> "-lt"

    • argv[2] -> "/home/csapp"

    • argv[3] -> "NULL"

我们可以通过

2.1.2 环境变量 (envp)

环境变量是存储在系统中的一个键值对(key-value)集合,它提供了进程运行所需的环境信息。与 argv 命令行参数列表相同的是它的最后一个元素必须是 NULL 指针

也就是说说明当前进程的是处于什么基础环境下运行的,就比如创建与运行当前进程的用户名是什么,该进程的家目录路径是什么,当前的工作目录是什么。

常见的环境变量有:

  • USER:当前用户名

  • HOME:用户的家目录路径

  • PATH:系统查找可执行文件的路径列表

  • PWD:当前工作目录

我们可以通过使用全局变量 environ 访问当前进程的环境变量

#include <stdio.h>
#include <unistd.h> // 声明了 extern char **environ;

extern char **environ; // 显式声明全局变量

int main() {
    printf("Environment variables (via environ):\n");
    for (int i = 0; environ[i] != NULL; i++) {
        printf("%s\n", environ[i]);
    }
    return 0;
}

2.2 system() 函数:简单的“黑盒”调用

system() 函数是一个高级库函数,它允许程序执行一个shell命令,就像在终端中输入命令一样。它会阻塞调用它的进程,直到命令执行完成。

其函数原型如下:

#include <stdlib.h>
int system(const char *command);

参数 command:要执行的shell命令字符串。例如 "ls -l""gcc hello.c"。如果参数为NULL,则检查系统中是否有可用的shell。

返回值

  • 成功:返回命令的终止状态。

  • 失败(如fork失败):返回-1。

该函数的工作流程是内部调用fork() 创建一个新的子进程。该子进程随即调用 execve() 来执行 /bin/sh -c command(bin目录是存放基础指令的目录)。父进程调用 waitpid() 等待子进程(shell)结束。上述各函数我都会在接下来一一讲解。

示例
#include <stdio.h>
#include <stdlib.h>

int main() {
    int ret = system("ls -l ~"); // 终端输出当前用户文件夹内的文件
    if (ret == -1) {             // 检测函数调用是否出现异常
        perror("system failed");
        exit(EXIT_FALURE);
    }
    printf("Command finished, status: %d\n", ret); // 函数调用成功
    return 0;
}

2.3 fork() 函数:一分为二的魔法

fork() 是Linux中创建进程最核心、最基础的系统调用。它的神奇之处在于:调用一次,返回两次

其函数原型如下:

#include <unistd.h>
pid_t fork(void);

关于返回值类型 pid_t :这个类型定义在头文件/usr/include/x86-64_64-linux-gnu/sys/types.h中,该类型定义如下:

// 函数定义
__pid_t fork (void)
// 类型定义
typedef __pid_t pid_t;

而_pid_t是pid_t的别名,后者定义在/usr/include/x86-64_64-linux-gnu/bits/types.h中,相关宏定义如下。

__STD_TYPE __PID_T_TYPE __pid_t;
#define __PID_T_TYPE        __S32_TYPE
#define __S32_TYPE      int
#define __STD_TYPE     typedef

__STD_TYPE预处理后被替换为typedef,__PID_T_TYPE预处理后被替换为int,因此,__pid_t实际上是这样定义的

typedef int __pid_t;

也就是说 最终_pid_t的本质就是int整形,也就是进程号。

由于fork()函数参数为空void,这里就不做介绍,需要对函数留意的是返回值:

  • 在父进程中:返回新创建的子进程的进程ID(PID)(一个大于0的数)。

  • 在子进程中:返回 0

  • 调用失败:返回 -1。

函数的工作流程是调用 fork() 成功后,系统会创建一个与父进程几乎完全相同的新进程,这就是子进程。子进程获得父进程的数据空间、堆、栈副本而不是共享。父进程和子进程并发执行,执行顺序由内核的调度器决定,是不确定的。

与 fork() 函数一同掌握的是获取两个进程号函数。

getpid() 函数

获取当前进程的进程号,其函数原型如下

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

pid_t getpid(void);
getppid() 函数

获取当前进程的父进程号,其函数原型如下

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

pid_t getppid(void);

为了更好的理解三个函数的使用,下面介绍一个示例

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

int main() {
    pid_t pid;              // 用于获取当前进程号

    printf("Parent: Before fork, my PID is %d\n", getpid());

    pid = fork();          // fork()函数创建子进程,fork函数之后父子进程都会执行

    if (pid < 0) {         // 如果 fork() 函数调用失败返回值-1
        perror("fork failed");
        return 1;
    } else if (pid == 0) { // fork()函数调用后pid为0则为子进程
        printf("Child: My PID is %d, my Parent's PID is %d\n", getpid(), getppid());
    } else {               // 该部分是父进程
        
        printf("Parent: My PID is %d, I have a child with PID %d\n", getpid(), pid);
    }

    // 父子进程都会执行的部分
    printf("Process %d says: Hello World!\n", getpid());
    return 0;
}

2.4 execve() 系列函数:改头换面

fork() 创建的是父进程的副本,但通常我们希望子进程执行一个全新的程序。这时就需要 exec 系列函数。它们会用新的程序代码完全替换当前进程的文本、数据、堆和栈段。

函数原型:

#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);
参数介绍
  • 参数 pathname:要执行的可执行文件的完整路径(如 /bin/ls)。

  • 参数 argv:传递给新程序的参数列表(argv[0] 通常是程序名本身),必须以NULL结尾

  • 参数 envp:环境变量列表,必须以NULL结尾

返回值

只在出错时返回 -1如果成功,原进程的代码就被替换了,不会再返回。下面介绍exec开头的一系列函数,这些都是基于execve的包装。

常见 exec 系列函数
函数原型 参数特点 搜索路径
int execl(const char *path, const char *arg, ...) 参数列表(list) 需全路径
int execlp(const char *file, const char *arg, ...) 参数列表 PATH中找
int execle(const char *path, const char *arg, ..., char *const envp[]) 参数列表+环境变量 需全路径
int execv(const char *path, char *const argv[]) 参数数组(vector) 需全路径
int execvp(const char *file, char *const argv[]) 参数数组 PATH中找
int execvpe(const char *file, char *const argv[], char *const envp[]) 参数数组+环境变量 PATH中找

2.5 waitpid() 系列函数:回收与等待

当子进程终止时,内核不会立即彻底清除它。终止的子进程会变成一个僵尸进程(见下文)。父进程需要使用 wait() 或 waitpid() 来等待子进程终止获取其退出状态,同时彻底释放子进程占用的系统资源。这个过程称为“回收”。

其函数原型如下:

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

/** 等待子进程的终止并获取子进程的退出状态
 *    功能简单 没有选择
 */
pid_t wait(int *wstatus);

/**
 * 功能灵活 可以设置不同的模式 可以等待特定的子进程
 * 
 */
pid_t waitpid(pid_t pid, int *wstatus, int options);

功能:等待子进程状态发生变化(终止或停止),并获取其状态信息。

参数解析
  • wstatus:一个整型指针,用于存储子进程的退出状态。可以使用宏来解析这个值。如果不需要详情,可传入NULL

  • pid

    • <-1:等待进程组ID等于pid绝对值的任何子进程。

    • -1:等待任何子进程,等同于wait()

    • 0:等待与调用进程同一进程组的任何子进程。

    • >0:等待进程ID等于pid的特定子进程。

  • options:改变函数行为,常用选项:

    • WNOHANG:非阻塞模式。如果没有子进程退出,立即返回0,而不是挂起等待。

    • WUNTRACED:也返回那些已停止(但未终止)的子进程状态。

示例

下面介绍一个fork() + exec()waitpid()的经典组合

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

int main() {
    int subprocess_status
    pid_t pid = fork(); // 创建子进程

    if (pid < 0) {
        // fork失败处理
        perror("fork failed");
        exit(EXIT_FALIURE);
    } else if (pid == 0) {
        // 子进程代码
        printf("Child process (PID: %d) is running.\n", getpid());
        
        // 准备execve参数
        char *argv[] = {"echo", "Hello from child process!", NULL};
        char *envp[] = {"PATH=/bin", NULL}; // 简单的环境变量
        
        // 执行echo命令
        execve("/bin/echo", argv, envp);
        
        // 如果execve成功,下面的代码不会执行
        perror("execve failed");
        exit(EXIT_FALIURE);
    } else {
        // 父进程代码
        printf("Parent process (PID: %d) created child (PID: %d).\n", getpid(), pid);
        
        // 等待子进程结束
        waitpid(pid, &subprocess_status, 0);
        printf("Parent: Child process has finished.\n");
    }
    
    return 0;
}

3. 特殊进程状态:僵尸与孤儿

3.1 僵尸进程(Zombie process)

定义:一个已经终止(exited)但其父进程尚未调用wait()waitpid()对其进行回收的进程。

成因:子进程先于父进程退出,父进程却忙于其他事务而忽略了回收子进程。

危害:内核会为僵尸进程保留少量的进程表项信息(PID、退出状态等)。如果父进程一直不回收,僵尸进程会一直占用PID这个系统资源。系统中如果有大量僵尸进程,可能导致无法创建新进程。

3.2 孤儿进程(Orphan process)

定义:一个还在运行,但其父进程已经终止的进程。

成因:父进程先于子进程退出。

处理机制:Linux/Unix系统为了解决孤儿进程问题,设计让 init 进程(PID=1)成为所有孤儿进程的新的父进程,或者让孤儿进程成为父进程的父进程也就是爷爷进程的子进程init进程会定期调用wait()来清理这些被它接管的孤儿进程。因此,孤儿进程本质上是无害的,它们最终会被init进程妥善回收。

🎯总结

对于基本的进程处理板块就介绍到此,掌握了基本的进程处理才能队之后的进程通讯、信号、网络通讯有更加深刻的理解。

创作不易,如果觉得本文对你有帮助,欢迎点赞、收藏、评论,关注我获取更多 Linux/C++ 系统编程干货!
👉 你的每一次互动,都是我持续输出的动力!

Logo

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

更多推荐