深入理解Linux进程管理:从创建到替换的完整指南

1. 进程创建

1.1 fork函数

在之前的文章已经谈过fork函数,这里不做过多讲述。该函数从已存在进程中创建一个新进程,新进程为子进程,原进程为父进程。

C 复制代码
// 子进程返回0,父进程返回子进程pid,创建失败返回-1
#include <unistd.h>
pid_t fork(void);

当进程调用fork后内核会:

  • 分配新的内存块和内核数据结构给子进程。
  • 将父进程部分数据结构内容拷贝给子进程。
  • 添加子进程到系统进程列表。
  • fork返回,调度器开始调度。

2. 进程终止

进程终止是为了释放系统资源,也就是释放进程申请的相关内核数据结构和对应代码和数据。进程退出有三种情况:

  1. 代码运行完毕,结果正确。

  2. 代码运行完毕,结果不正确。

  3. 代码异常终止。
    而这三种场景是通过退出码来判断。下面是三种方法:

  4. main函数进行return nn表示该进程的退出码。

  5. 调用exit(n)n表示该进程的退出码。

  6. 直接调用_eixt(n)

2.1 return vs exit

  • return表示函数调用结束,在其他函数中return 只会从当前函数退出,返回到上一级调用函数,而不会导致整个程序结束。
  • exit()表示的是进程结束,在代码中无论任何地方调用都会导致进程退出。

2.2 exit vs _exit

  • exit()终止进程会主动刷新缓冲区。
  • _exit()是系统调用,会直接终止进程,并且不会主动刷新缓冲区。
C 复制代码
#include <stdio.h>
#include <unistd.h> // 包含 _exit()

int main() 
{
    printf("这行文字不会被显示出来"); // 注意:这里没有换行符\n,所以字符串在缓冲区中
    // exit(0);   // 如果用这个,缓冲区会被刷新,文字会显示
    _exit(0);     // 用这个,缓冲区不会被刷新,程序立即终止,文字丢失
}

3. 进程等待

3.1 进程等待必要性

  • 前文说过,子进程退出,父进程如果不管就会造成"僵尸进程"问题,从而造成内存泄漏问题。
  • 进程一旦变成僵尸状态,即使是使用kill -9信号也无能为力。
  • 父进程需要知道交代给子进程的任务完成的如何,如子进程结果对或不对,或者是否正常退出。
  • 父进程通过进程等待的方式回收子进程资源,获取子进程退出信息。

3.2 进程等待方法

3.2.1 wait方法

C 复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);

参数:
    status:输出型参数,如果不关心退出状态,可以传入NULL。
返回值:
    成功返回子进程pid,失败返回-1。

wait()只要有任何子进程终止,它就会立即回收其中一个。并且它无法指定要等待哪个子进程,只能按系统调度,谁先终止就回收谁。

3.2.2 waitpid方法

C 复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);

参数:
    pid:
        pid=-1:等待任意一个子进程,与 wait 等效。 
        pid>0:等待进程ID与pid相等的子进程。
    status:输出型参数,如果不关心退出状态,可以传入NULL。
    options:
        0:表示阻塞等待。
        WNOHANG:非阻塞等待。如果没有子进程退出,就立即返回0,而不是阻塞父进程。
返回值:
    成功:返回状态发生变化的子进程的pid。如果使用了 WNOHANG 选项且没有子进程退出,则返回0。
    失败:返回-1,并设置errno为相应的错误值。

waitpid() 提供了比 wait() 更精确的控制。它可以等待特定的子进程,或者一组子进程,并且可以非阻塞地检查子进程状态。

3.2.3 子进程status

  • waitwaitpid都有一个status参数,该参数是一个输出型参数,由操作系统填入。
  • 如果传递NULL表示不关心子进程退出信息。
  • 否则系统会根据该参数,将子进程退出信息反馈给父进程。
  • status不可只当作整形看待,可以当作位图来看待(如下图,只研究低16位)。
  • 在status的低16位中的次低8位(8~15),表示了子进程的退出状态(退出码),取值范围为[0,255]。
  • 如果进程是直接崩溃掉或是异常终止,则不关心退出状态(退出码),而是关心低7位的终止信号。

下面这段代码通过fork函数创建子进程,并且将子进程退出码设置为1,父进程等待成功后打印退出码,但为什么打印出来是256呢?这是因为进程正常退出,所以在退出状态这一部分的最低位(也就是status的第8位)设置为了1,并且其他比特位都为0,所以打印出来就是256。

C 复制代码
int main()
{
    pid_t id = fork();

    if (id == 0)
    {
        printf("i am child process\n");
        exit(1);
    }

    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0)
    {
        printf("status:%d\n", status);
    }

    return 0;
}

如果想直观的知道退出码是多少,可以像下面代码这样:

C 复制代码
int main()
{
    pid_t id = fork();

    if (id == 0)
    {
        printf("i am child process\n");
        exit(1);
    }

    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0)
    {
        printf("status:%d\n", (status >> 8) & 0xFF);
    }

    return 0;
}

那么如果代码出现异常了呢?造成异常的原因有很多种,例如:代码中/0、野指针、或者溢出等。当出现异常就会导致OS给我们的进程发送信号从而中止进程(下图为异常信号及原因),如果想拿到异常信号可以在代码中使用(status & 0x7F)

当然判断进程的最佳实践还是使用WIFEXITED这个宏来判断为好。WIFEXITED(status) 是一个定义在 <sys/wait.h> 头文件中的宏。它的唯一作用是判断一个子进程是否正常结束 。它是一个条件判断宏,返回一个布尔值:

  • 如果返回 真(!0) ,表示子进程是正常终止的。
  • 如果返回 假(0) ,表示子进程是非正常终止的(例如,被信号杀死)。
C 复制代码
#include <sys/wait.h>

int main()
{
    pid_t id = fork();

    if (id == 0)
    {
        printf("i am child process\n");
        exit(1);
    }

    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0)
    {
        if (WIFEXITED(status))
        {
            printf("进程正常退出:%d\n", WEXITSTATUS(status));
        }
        else
        {
            printf("进程异常\n");
        }
    }

    return 0;
}

所以如何判断进程是否正常结束,就需要看他的退出码以及退出信号(如下图)。

在进程的task_struct中,有着两个整型:exit_codeexit_signal。当进程退出后就会把退出码和退出信号写入到当前进程的task_struct的两个整型中。

父进程通过waitpid函数来获取子进程的task_struct中的属性,将这两个整型合并为一个status参数从而带出给父进程。并且调用完毕后也会让OS释放目标task_struct

所以为什么子进程退出后变为Z状态无法释放task_struct,是因为子进程task_struct中的exit_codeexit_signal还没有被带出。

3.3 阻塞与非阻塞等待

什么是阻塞与非阻塞等待?想象一下一个父亲和他的孩子:

  • 阻塞等待:父亲对孩子说:"你去后院玩,玩完了立刻来书房告诉我一声,在这之前我就在书房门口等着,什么都不干。"
  • 非阻塞等待:父亲对孩子说:"你去后院玩,我自己在书房工作。我会每隔5分钟去后院门口看一眼你玩完了没有。"

这两种区别在于:在等待子进程结束的这段时间里,父进程自身是否能够继续执行其他任务

在之前演示的代码大多数为阻塞等待,下面这段代码创建了一个子进程,子进程运行10秒(每秒打印一条消息),父进程则使用非阻塞轮询的方式检查子进程状态,而不是一直阻塞等待。

C 复制代码
int main()
{
    // 1. 创建子进程
    pid_t id = fork();
    
    // 2. 子进程代码块
    if (id == 0)
    {
        // 子进程
        int cnt = 10;
        while (cnt--) // 循环10次
        {
            printf("子进程运行中:%d\n", cnt); // 打印当前计数
            sleep(1); // 休眠1秒
        }
        exit(0); // 子进程正常退出
    }

    // 3. 父进程代码块 - 非阻塞轮询
    while (1) // 无限循环,直到子进程结束
    {
        // 使用非阻塞方式检查子进程状态
        pid_t rid = waitpid(id, NULL, WNOHANG);
        
        if (rid == id) // 情况1:成功回收了指定子进程
        {
            printf("wait child success\n");
            break; // 跳出循环
        }
        else if (rid == 0) // 情况2:子进程还未结束
        {
            printf("child not quit\n");
            // 这里可以添加父进程的其他工作
            sleep(1); // 休眠1秒,避免忙等待
        }
        else if (rid < 0) // 情况3:出错
        {
            printf("wait error\n");
            break; // 跳出循环
        }
    }

    return 0;
}

4. 进程程序替换

fork()之后父子进程各自执行父进程代码的一部分,如果子进程想执行一个全新的程序就需要进行程序替换。通过特定的接口加载磁盘上一个全新的程序(代码和数据)加载到调用进程的地址空间中。

4.1 程序替换函数

exec 系列函数的作用是:将当前正在运行的进程的代码和数据完全替换为一个新的程序的代码和数据 ,并从新程序的 main 函数开始执行。exec不会创建一个新的进程。它只是在当前进程的上下文中,将原有的程序"扔掉了",把一个新的程序"装进来"并执行。进程的PID保持不变。

如果exec系列函数调用成功,它永远不会返回 ,因为调用它的原程序已经被替换掉了。如果它返回了,那一定意味着调用失败了(返回 -1)。所有exec函数都定义在<unistd.h>头文件中。

exec系列函数共有六个,这六个函数功能完全相同------执行一个新程序 。它们的区别仅在于如何指定这个新程序 以及如何给它传递参数,以适应不同的调用场景。

它们主要在三个方面有区别:

  1. 指定程序路径的方式 :是提供程序的完整路径 ,还是只提供程序名 (让系统去PATH环境变量指定的目录里查找)。
  2. 传递参数的方式 :是像 execl一样以列表 的形式一个一个传,还是像execv一样预先准备好一个数组传进去?
  3. 是否传递环境变量 :是使用当前进程的默认环境变量 ,还是允许用户自定义一组新的环境变量

它们的命名规则也体现了这些区别:

  • l (list):参数以可变参数列表 的形式传递,最后需要一个NULL结尾。
  • v (vector):参数以一个argv[]字符串数组的形式传递。
  • p (path):函数名带p的,可以使用程序名 (如ls),系统会自动在PATH环境变量中的目录里搜索这个程序。不带p的,必须提供程序的绝对路径或相对路径 (如/bin/ls)。
  • e (environment):函数名带e的,允许用户自定义环境变量 ,传入一个环境变量数组。不带e的,则新程序继承当前进程的所有环境变量。
C 复制代码
int execl(const char *path, const char *arg0, ..., /* (char *)0 */);
int execlp(const char *file, const char *arg0, ..., /* (char *)0 */);
int execle(const char *path, const char *arg0, ..., /* (char *)0, char *const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]); // GNU扩展

1. 路径/文件名参数(path/file)
    1.1 对于不带p的函数(execl,execle,execv):
            第一个参数是const char *path
            必须指定程序的完整路径(绝对路径或相对路径)
            例如:"/bin/ls","./myprogram"
    1.2 对于带p的函数(execlp,execvp,execvpe):
            第一个参数是const char *file
            可以只指定程序名,系统会在PATH环境变量指定的目录中搜索该程序
            例如:"ls","gcc"

2. 参数列表(arg0,arg1,...,argn)
    2.1 对于带l的函数(execl,execlp,execle):
            参数以可变参数列表形式传递
            第一个参数arg0通常是程序名
            后续参数是传递给程序的命令行参数
            参数列表必须以NULL指针结束
    2.2 对于带v的函数(execv,execvp,execvpe):
            参数以字符串数组形式传递
            数组的第一个元素argv[0]通常是程序名
            数组的最后一个元素必须是NULL指针

3. 环境变量参数 (envp)
    3.1 对于带e的函数(execle,execvpe):
        最后一个参数是char *const envp[]
        这是一个字符串数组,表示要传递给新程序的环境变量
        每个字符串的格式应为"NAME=value"
        数组必须以NULL指针结束
        如果为NULL,新程序将没有任何环境变量

所有exec系列函数有一个共同且非常重要的返回值特性:
    成功时:
        没有返回值
        如果exec函数调用成功,它将永远不会返回到调用它的程序中
        因为调用进程的代码段和数据段已经被新程序完全替换
    失败时:
        返回-1
        只有当调用失败时,exec函数才会返回
        同时会设置全局变量errno来指示具体的错误原因

使用示例:

C 复制代码
#include <unistd.h>
#include <stdio.h>

int main() 
{
    pid_t pid = fork();
    
    if (pid == 0) 
    {
        // 子进程
        
        // 方法1: 使用 execl (需要完整路径)
        execl("/bin/ls", "ls", "-l", NULL);
        
        // 方法2: 使用 execlp (只需要程序名)
        // execlp("ls", "ls", "-l", NULL);
        
        // 方法3: 使用 execv (参数以数组形式传递)
        // char *argv[] = {"ls", "-l", NULL};
        // execv("/bin/ls", argv);
        
        // 方法4: 使用 execvp (参数以数组形式传递,只需要程序名)
        // char *argv[] = {"ls", "-l", NULL};
        // execvp("ls", argv);
        
        // 方法5: 使用 execle (自定义环境变量)
        // char *envp[] = {"PATH=/bin", "MY_VAR=hello", NULL};
        // execle("/bin/ls", "ls", "-l", NULL, envp);
        
        // 如果任何 exec 调用成功,下面的代码不会执行
        perror("exec failed");
        exit(1); // 子进程退出
    } 
    
    pid_t rid = waitpid(id, NULL, 0);
    if (rid > 0)
    {
        printf("wait:%d success\n", id);
    }
    
    return 0;
}

5. 实现简单shell命令行解释器

主要思路和工作流程:

  1. 初始化环境:设置必要的全局变量和环境
  2. 显示提示符 :显示类似 [username@ directory]# 的提示符
  3. 获取用户输入:读取用户输入的命令
  4. 解析命令:将命令字符串分解为命令和参数
  5. 处理内建命令:如果是内建命令(如 cd、echo),直接执行
  6. 执行外部命令:如果不是内建命令,创建子进程并执行
  7. 循环执行:重复上述过程,直到程序终止

5.1 myshell.h

  • 定义了程序使用的常量和函数声明
  • 包含了必要的标准库头文件
  • 声明了全局变量和所有功能函数的原型
cpp 复制代码
#ifndef __MYSHELL_H__
#define __MYSHELL_H__
#include <stdio.h>

#define SIZE 1024
#define ARGS 64

void InitGlobal();
void PrintCommandPrompt();
bool GetCommandString(char cmd_str_buff[], int len);
bool ParseCommandString(char cmd[]);
void ForkAndExec();
bool BuiltInCommandExec();
#endif

5.2 myshell.cpp

  • 实现了 Shell 的核心功能
  • 包含命令提示符显示、命令解析、进程创建和执行等关键功能
  • 实现了内建命令的处理逻辑
cpp 复制代码
#include "myshell.h"
#include <iostream>
#include <cstdio>
#include <cstring>
#include <string.h>
#include <stdlib.h>
#include <string>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

// 命令行参数表
char *gargv[ARGS] = {NULL};
int gargc = 0;
char pwd[SIZE]; // 保存当前shell进程工作路径
int lastcode = 0;

static std::string GetHomePath()
{
    std::string home = getenv("HOME");
    return home.empty() ? "/" : home;
}

static std::string GetUserName()
{
    std::string username = getenv("USER");
    return username.empty() ? "None" : username;
}

static std::string GetHostName()
{
    std::string hostname = getenv("HOSTNAME");
    return hostname.empty() ? "None" : hostname;
}

static std::string GetPwd()
{
    // std::string pwd = getenv("PWD");
    // return pwd.empty() ? "None" : pwd;
    char temp[1024];
    // 更新shell环境变量
    getcwd(temp, sizeof(temp));
    snprintf(pwd, sizeof(pwd), "PWD=%s", temp);
    putenv(pwd);
    // 让shell只显示当前路径
    std::string pwd_lable = temp;
    const std::string path_sep = "/";
    auto pos = pwd_lable.rfind(path_sep);
    if (pos == std::string::npos)
        return "None";
    pwd_lable = pwd_lable.substr(pos + path_sep.size());
    return pwd_lable.empty() ? "/" : pwd_lable;
}

// 输出提示符
void PrintCommandPrompt()
{
    std::string user = GetUserName();
    // std::string hostname = GetHostName();
    std::string pwd = GetPwd();
    printf("[%s@ %s]# ", user.c_str(), pwd.c_str());
}

// 获取输入
bool GetCommandString(char cmd_str_buff[], int len)
{
    if (cmd_str_buff == NULL || len <= 0)
        return false;
    char *res = fgets(cmd_str_buff, len, stdin);
    if (res == NULL)
        return false;
    cmd_str_buff[strlen(cmd_str_buff) - 1] = 0;
    return strlen(cmd_str_buff) == 0 ? false : true;
}

// 命令解析
bool ParseCommandString(char cmd[])
{
    if (cmd == NULL)
        return false;
#define SEP " "
    gargv[gargc++] = strtok(cmd, SEP);
    while ((bool)(gargv[gargc++] = strtok(NULL, SEP)))
        ;
    gargc--;
// #define DEBUG
#ifdef DEBUG
    printf("gargc:%d\n", gargc);
    printf("____________________________\n");
    for (int i = 0; i < gargc; i++)
    {
        printf("gargv[%d]:%s\n", i, gargv[i]);
    }
    printf("____________________________\n");
    for (int i = 0; gargv[i]; i++)
    {
        printf("gargv[%d]:%s\n", i, gargv[i]);
    }
#endif
    return true;
}

void InitGlobal()
{
    gargc = 0;
    memset(gargv, 0, sizeof(gargv));
}

void ForkAndExec()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        return;
    }
    else if (id == 0)
    {
        // 子进程
        execvp(gargv[0], gargv);
        exit(0);
    }
    else
    {
        // 父进程
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        if (rid > 0)
        {
            lastcode = WEXITSTATUS(status);
        }
    }
}

// 检查命令,内建命令让父进程执行
bool BuiltInCommandExec()
{
    std::string cmd = gargv[0];
    bool ret = false;
    if (cmd == "cd")
    {
        if (gargc == 2)
        {
            std::string target = gargv[1];
            if (target == "~")
            {
                ret = true;
                chdir(GetHomePath().c_str());
            }
            else
            {
                ret = true;
                chdir(gargv[1]);
            }
        }
        else if (gargc == 1)
        {
            ret = true;
            chdir(GetHomePath().c_str());
        }
        else
        {
            // BUG
        }
    }
    else if (cmd == "echo")
    {
        if (gargc == 2)
        {
            std::string args = gargv[1];
            if (args[0] == '$')
            {
                if (args[1] == '?')
                {
                    printf("lastcode:%d\n", lastcode);
                    lastcode = 0;
                    ret = true;
                }
                else
                {
                    const char *name = &args[1];
                    printf("%s\n", getenv(name));
                    lastcode = 0;
                    ret = true;
                }
            }
            else
            {
                printf("%s\n", args.c_str());
                ret = true;
            }
        }
    }
    return ret;
}

5.3 main.cpp

  • 包含程序的主循环
  • 协调各个功能模块的执行流程
cpp 复制代码
#include "myshell.h"

int main()
{
    char commandstr[SIZE];
    while (true)
    {
        // 0.初始化
        InitGlobal();
        // 1.输出命令行提示符
        PrintCommandPrompt();
        // 2.获取用户输入的命令
        if (!GetCommandString(commandstr, SIZE))
            continue;
        // 3.解析命令字符串->命令函参数表
        ParseCommandString(commandstr);
        // 4. 检查命令,内建命令让父进程执行
        if (BuiltInCommandExec())
            continue;
        // 5.让子进程执行命令
        ForkAndExec();
    }
    return 0;
}
相关推荐
Sadsvit2 分钟前
源码编译安装LAMP架构并部署WordPress(CentOS 7)
linux·运维·服务器·架构·centos
xiaok2 分钟前
为什么 lsof 显示多个 nginx 都在 “使用 443”?
linux
苦学编程的谢40 分钟前
Linux
linux·运维·服务器
G_H_S_3_1 小时前
【网络运维】Linux 文本处理利器:sed 命令
linux·运维·网络·操作文本
Linux运维技术栈1 小时前
多系统 Node.js 环境自动化部署脚本:从 Ubuntu 到 CentOS,再到版本自由定制
linux·ubuntu·centos·node.js·自动化
拾心211 小时前
【运维进阶】Linux 正则表达式
linux·运维·正则表达式
Gss7772 小时前
源代码编译安装lamp
linux·运维·服务器
JulyYu2 小时前
Android系统保存重名文件后引发的异常解决
android·操作系统·源码
G_H_S_3_2 小时前
【网络运维】Linux:正则表达式
linux·运维·网络·正则表达式