本文主题内容

本文讲解 Linux 进程控制,主要内容包括:

  • 进程创建,掌握 fork 和 vfork
  • 进程终止,认识 $? 和退出码
  • 进程等待
  • 进程程序替换
  • 通过微型 shell 重新认识 shell 的运行原理

Linux中很多看似简单的操作,背后都离不开进程控制。一个命令如何被执行,子进程如何创建,程序如何替换,退出状态如何返回给父进程,这些内容是系统编程的基础,也是学习 shell、服务端开发和并发编程绕不开的部分。本文从最基础的 fork 讲起,逐步把进程创建、终止、等待、替换和 shell 执行流程串成一条线,帮助把零散知识点连成完整体系。

一、进程创建

1.1 fork函数初识

在 Linux 中,fork 是非常重要的系统调用。它会从一个已经存在的进程中创建一个新进程。新创建的进程叫子进程,原来的进程叫父进程。

函数原型如下:

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

fork 的返回值规则很重要:

  • 子进程中返回 0
  • 父进程中返回子进程的进程号
  • 出错返回 -1

这里最有问题的是:为什么一个 fork 会有两个返回值。原因很简单,fork 调用后,父子进程都会从 fork 之后的位置继续执行,只不过它们的返回值不同,内核会根据当前执行的是父进程还是子进程分别设置返回值。

进程调用 fork 后,内核会做几件事:

  • 分配新的内存块和内核数据结构给子进程
  • 将父进程部分数据结构内容拷贝到子进程
  • 把子进程加入系统进程列表
  • fork 返回后,由调度器决定谁先运行

在这里插入图片描述
在这里插入图片描述

1.2 fork函数返回值

fork 的返回值之所以设计成父进程返回子进程 PID、子进程返回 0,是为了让父子进程能够很方便地通过分支语句区分自己的身份。

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

int main()
{
    pid_t pid;
    printf("Before: pid is %d\n", getpid());	// 获取当前进程的 ID(PID)
    if ((pid = fork()) == -1)			// 创建失败
        perror("fork()"), exit(1);	// 报错并返回
    printf("After:pid is %d, fork return %d\n", getpid(), pid);	// fork() 会返回两次
    sleep(1);
    return 0;
}

运行后通常会看到三行输出:
在这里插入图片描述

父进程先打印 Before,然后 fork 后父子进程分别继续执行。子进程不会重新打印 fork 之前的内容,因为 fork 之后两个进程是从同一个位置继续向下运行,但之前的代码已经执行完了。

fork 之后,谁先执行完全由调度器决定,不能依赖代码顺序强行判断。

fork 之后,父子进程分别拥有独立的执行流

1.3 写时拷贝

在 fork() 之后,父子进程看起来像复制了一份,但实际并不是简单地把所有数据完整复制一遍。Linux 使用了写时拷贝机制。

写时拷贝的核心思想是:

  • 父子进程开始时共享数据页
  • 只有当任意一方尝试写入时,系统才真正复制一份新的副本
  • 这样可以减少无意义的内存拷贝,提高效率

也就是说,fork 后父子进程的代码通常是共享的,数据在没有写入时也是共享的;一旦写入,就会变成各自独立的数据页。

这就是进程独立性能成立的重要技术基础。

在这里插入图片描述

fork 的常规用途

fork 常用于两类场景:

  1. 父进程复制自己,让父子进程执行不同逻辑
  2. 子进程在 fork 后调用 exec 执行全新的程序

例如,服务器程序里常见的做法就是:

  • 父进程负责接收客户端连接
  • 子进程负责处理请求

1.4 fork常规用法

fork 的典型使用方式是先创建子进程,再通过 if 分流。

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

int main()
{
    int ret = fork();
    if(ret < 0)
    {
        perror("fork");
        return 1;
    }
    else if(ret == 0)
    {
        printf("I am child : %d!, ret: %d\n", getpid(), ret);
    }
    else
    {
        printf("I am father : %d!, ret: %d\n", getpid(), ret);
    }
    sleep(1);
    return 0;
}

逻辑:

  • ret < 0:创建失败
  • ret == 0:当前是子进程
  • ret > 0:当前是父进程

注意,fork 之后的父子进程都有自己的执行路径,不能再把它们当成一个进程理解。

1.5 fork调用失败的原因

fork 失败通常有两个主要原因:

  • 系统中进程数量太多
  • 当前用户的进程数超过系统限制

这在资源紧张或者限制比较严格的环境中比较常见。

二、进程终止

进程终止的本质是释放系统资源。这里的资源不仅仅是进程使用过的内存,还包括进程对应的内核数据结构、代码和数据相关信息。

2.1 进程退出场景

进程退出通常有三种情况:

  • 代码运行完毕,结果正确
  • 代码运行完毕,结果不正确
  • 代码异常终止

2.2 进程常见退出方法

进程的退出方式主要分为两类:

正常终止

可以通过 echo $? 查看进程退出码。

常见正常退出方式有:

  1. 从 main 返回
  2. 调用 exit
  3. 调用 _exit

异常退出

例如:

  • ctrl + c
  • 信号终止

2.3 退出码

退出码,也叫退出状态,用来告诉父进程或 Shell,程序最后运行得怎么样。

一般来说:

  • 退出码 0 表示执行成功
  • 非 0 表示执行失败或异常结束

常见退出码含义

在这里插入图片描述

Linux Shell 中,很多终止信号对应的退出码可以理解为 128 + 信号编号。

可以使用 strerror 获取某些错误码对应的描述信息。

2.4 _exit函数

函数原型如下:

#include <unistd.h>
void _exit(int status);

参数 status 表示进程终止状态,父进程可以通过 wait 获取它。

status 虽然是 int,但真正被父进程使用的只有低 8 位
所以 _exit(-1) 后,在 Shell 中看到的 $? 往往是 255。

_exit 的特点是:

  • 直接终止进程
  • 不做额外清理
  • 不刷新标准 I/O 缓冲区

2.5 exit函数

函数原型如下:

#include <unistd.h>
void exit(int status);

exit 最后也会调用 _exit,但在调用 _exit 之前,还会做一些额外工作:

  1. 执行通过 atexiton_exit 注册的清理函数
  2. 关闭所有打开的流
  3. 将缓存数据刷新到外部
    在这里插入图片描述

exit 和 _exit 的区别

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    printf("hello");
    exit(0);
}

运行结果通常会输出:

hello

因为 exit 会刷新缓冲区。

如果改成:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    printf("hello");
    _exit(0);
}

那么 hello 可能不会立刻输出,因为 _exit 不会做缓冲区刷新。

注意,如果程序中有标准输出缓存,exit_exit 的表现是不同的。

2.6 return退出

在 main 函数里使用 return n,本质上等价于 exit(n)
这是因为 main 的运行时环境会把返回值当作 exit 的参数。

所以在普通程序中:

  • return 0 等价于正常结束
  • return 非0 表示异常或失败

三、进程等待

3.1 进程等待必要性

进程等待非常重要,原因有三个:

  1. 子进程退出后,如果父进程不管不顾,会形成僵尸进程
  2. 僵尸进程无法被 kill -9 杀掉,因为它已经死了,只是信息还在
  3. 父进程需要知道子进程执行结果如何

所以父进程要通过等待机制回收子进程资源,并获取子进程退出信息。

3.2 wait方法

函数原型如下:

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);

返回值:

  • 成功返回被等待进程的 PID
  • 失败返回 -1

参数:

  • status 是输出型参数
  • 如果不关心子进程退出状态,可以传 NULL

wait 的作用是:等待任意一个子进程退出,并回收它。

3.3 waitpid方法

函数原型如下:

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

返回值:

  • 正常返回时,返回收集到的子进程 PID
  • 如果设置了 WNOHANG,而当前没有已退出的子进程可收集,则返回 0
  • 出错返回 -1,并设置 errno

参数说明:

  • pid = -1:等待任意子进程
  • pid > 0:等待指定 PID 的子进程
  • status:输出型参数
  • options = 0:阻塞等待
  • WNOHANG:非阻塞等待

常用状态判断宏:

  • WIFEXITED(status):判断是否正常退出
  • WEXITSTATUS(status):提取退出码
    在这里插入图片描述

3.4 获取子进程status

status 不能简单当作整型使用,它本质上可以看成一个位图,只研究低 16 位就够了。
在这里插入图片描述

根据 status 判断子进程是正常退出还是异常退出:

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

int main(void)
{
    pid_t pid;
    if ((pid = fork()) == -1)
        perror("fork"), exit(1);

    if (pid == 0)
    {
        sleep(20);
        exit(10);
    }
    else
    {
        int st;
        int ret = wait(&st);
        if (ret > 0 && (st & 0X7F) == 0)	// 正常退出
        {
            printf("child exit code:%d\n", (st >> 8) & 0XFF);
        }
        else if (ret > 0)	// 异常退出
        {
            printf("sig code : %d\n", st & 0X7F);
        }
    }
}

测试时:

  • 子进程正常退出,会打印退出码
  • 子进程被信号杀掉,会打印信号编号

3.5 阻塞与非阻塞等待

阻塞等待

阻塞等待就是父进程一直等,直到子进程退出。

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

int main()
{
    pid_t pid;
    pid = fork();
    if(pid < 0)
    {
        printf("%s fork error\n", __FUNCTION__);
        return 1;
    }
    else if(pid == 0)
    {
        printf("child is run, pid is : %d\n",getpid());
        sleep(5);
        exit(257);
    }
    else
    {
        int status = 0;
        pid_t ret = waitpid(-1, &status, 0);
        printf("this is test for wait\n");
        if(WIFEXITED(status) && ret == pid)
        {
            printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));
        }
        else
        {
            printf("wait child failed, return.\n");
            return 1;
        }
    }
    return 0;
}

这里有一个常见现象:exit(257) 最终看到的退出码可能是 1,因为退出状态只保留低 8 位。

非阻塞等待

非阻塞等待的特点是:

  • 子进程没退出时,父进程不傻等
  • 父进程可以做别的事
  • 过一会儿再来检查子进程是否退出
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>

typedef void (*handler_t)();
std::vector<handler_t> handlers;

void fun_one()
{
    printf("这是一个临时任务1\n");
}

void fun_two()
{
    printf("这是一个临时任务2\n");
}

void Load()
{
    handlers.push_back(fun_one);
    handlers.push_back(fun_two);
}

void handler()
{
    if (handlers.empty())
        Load();
    for (auto iter : handlers)
        iter();
}

int main()
{
    pid_t pid;
    pid = fork();
    if (pid < 0)
    {
        printf("%s fork error\n", __FUNCTION__);
        return 1;
    }
    else if (pid == 0)
    {
        printf("child is run, pid is : %d\n", getpid());
        sleep(5);
        exit(1);
    }
    else
    {
        int status = 0;
        pid_t ret = 0;
        do
        {
            ret = waitpid(-1, &status, WNOHANG);
            if (ret == 0)
            {
                printf("child is running\n");
            }
            handler();
        } while (ret == 0);

        if (WIFEXITED(status) && ret == pid)
        {
            printf("wait child 5s success, child return code is :%d.\n", WEXITSTATUS(status));
        }
        else
        {
            printf("wait child failed, return.\n");
            return 1;
        }
    }
    return 0;
}

阻塞等待适合必须等结果的场景,非阻塞等待适合父进程还要继续做其他事情的场景

四、进程程序替换

fork 之后,父子进程执行的是同一份程序代码。如果子进程想执行一个全新的程序,就要用程序替换。

4.1 替换原理

程序替换是通过特定接口,把磁盘上的一个新程序加载到当前进程的地址空间中。
在这里插入图片描述

替换发生后:

  • 进程 ID 不变
  • 用户空间代码和数据被新程序替换
  • 从新程序的启动入口开始执行

也就是说,exec 不会创建新进程,只是让当前进程换了一套程序。

4.2 替换函数

exec 函数族共有六个:

#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);

函数说明

这些函数的共同特点是:

  • 成功后不返回
  • 失败返回 -1

所以 exec 函数只有失败返回值,没有成功返回值。

4.3 命名规律

exec 族函数名字虽然多,但规律很好记:

  • l (list):参数列表形式
  • v (vector):参数数组形式
  • p(path) :会自动搜索 PATH
  • e(env):由用户自己传环境变量
    在这里插入图片描述

使用示例

#include <unistd.h>

int main()
{
    char *const argv[] = {"ps", "-ef", NULL};
    char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};

    execl("/bin/ps", "ps", "-ef", NULL);
    execlp("ps", "ps", "-ef", NULL);
    execle("ps", "ps", "-ef", NULL, envp);
    execv("/bin/ps", argv);
    execvp("ps", argv);
    execve("/bin/ps", argv, envp);
}

注意:

  • 真正的系统调用只有 execve
  • 其他函数最终都会调用 execve

所以 execve 在 man 手册第 2 节,其余函数通常在第 3 节。
在这里插入图片描述

4.4 程序替换和环境变量

exec 的工作和环境变量关系很大。
如果带 p,就会自动根据 PATH 找命令;如果带 e,就说明需要调用者自己提供环境表。

这也是 shell 执行命令时非常关键的一部分。

五、自主Shell命令行解释器

5.1 目标

制作一个 shell 需要能够:

  • 处理普通命令
  • 处理内建命令
  • 帮助理解内建命令、本地变量、环境变量
  • 帮助理解 shell 的运行原理

5.2 实现原理

shell 的典型运行流程可以概括成以下步骤:

  1. 获取命令行
  2. 解析命令行
  3. fork 创建子进程
  4. 子进程 exec 执行命令
  5. 父进程 wait 等待子进程退出

也就是说,shell 本质上是一个循环:

  • 读命令
  • 解析命令
  • 判断是否是内建命令
  • 如果不是,就 fork + exec
  • 父进程等待结果

在这里插入图片描述

5.3 源码

头文件和全局变量

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>

using namespace std;

const int basesize = 1024;
const int argvnum = 64;
const int envnum = 64;

//全局的命令行参数表
char *gargv[argvnum];
int gargc = 0;

//全局的变量
int lastcode = 0;

// 我的系统的环境变量
char *genv[envnum];

//全局的当前shell工作路径
char pwd[basesize];
char pwdenv[basesize];

去空白宏

#define TrimSpace(pos) do{\
while(isspace(*pos)){\
pos++;\
}\
}while(0)

获取用户名、主机名和当前路径

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

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

string GetPwd()
{
    if(nullptr == getcwd(pwd, sizeof(pwd)))
        return "None";
    snprintf(pwdenv, sizeof(pwdenv), "PWD=%s", pwd);
    putenv(pwdenv);
    return pwd;
}

处理当前目录显示

string LastDir()
{
    string curr = GetPwd();
    if(curr == "/" || curr == "None")
        return curr;

    size_t pos = curr.rfind("/");
    if(pos == std::string::npos)
        return curr;
    return curr.substr(pos + 1);
}

string MakeCommandLine()
{
    char command_line[basesize];
    snprintf(command_line, basesize, "[%s@%s %s]# ",
             GetUserName().c_str(),
             GetHostName().c_str(),
             LastDir().c_str());
    return command_line;
}

void PrintCommandLine()
{
    printf("%s", MakeCommandLine().c_str());
    fflush(stdout);
}

获取用户输入

bool GetCommandLine(char command_buffer[], int size)
{
    char *result = fgets(command_buffer, size, stdin);
    if(!result)
    {
        return false;
    }

    command_buffer[strlen(command_buffer) - 1] = 0;
    if(strlen(command_buffer) == 0)
        return false;

    return true;
}

解析命令行

void ParseCommandLine(char command_buffer[], int len)
{
    (void)len;
    memset(gargv, 0, sizeof(gargv));
    gargc = 0;

    const char *sep = " ";
    gargv[gargc++] = strtok(command_buffer, sep);
    while((bool)(gargv[gargc++] = strtok(nullptr, sep)));
    gargc--;
}

调试输出

void debug()
{
    printf("argc: %d\n", gargc);
    for(int i = 0; gargv[i]; i++)
    {
        printf("argv[%d]: %s\n", i, gargv[i]);
    }
}

执行外部命令

bool ExecuteCommand()
{
    pid_t id = fork();
    if(id < 0)
        return false;

    if(id == 0)
    {
        execvpe(gargv[0], gargv, genv);
        exit(1);
    }

    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0)
    {
        if(WIFEXITED(status))
        {
            lastcode = WEXITSTATUS(status);
        }
        else
        {
            lastcode = 100;
        }
        return true;
    }

    return false;
}

添加环境变量

void AddEnv(const char *item)
{
    int index = 0;
    while(genv[index])
    {
        index++;
    }

    genv[index] = (char*)malloc(strlen(item) + 1);
    strncpy(genv[index], item, strlen(item) + 1);
    genv[++index] = nullptr;
}

处理内建命令

bool CheckAndExecBuiltCommand()
{
    if(strcmp(gargv[0], "cd") == 0)
    {
        if(gargc == 2)
        {
            chdir(gargv[1]);
            lastcode = 0;
        }
        else
        {
            lastcode = 1;
        }
        return true;
    }
    else if(strcmp(gargv[0], "export") == 0)
    {
        if(gargc == 2)
        {
            AddEnv(gargv[1]);
            lastcode = 0;
        }
        else
        {
            lastcode = 2;
        }
        return true;
    }
    else if(strcmp(gargv[0], "env") == 0)
    {
        for(int i = 0; genv[i]; i++)
        {
            printf("%s\n", genv[i]);
        }
        lastcode = 0;
        return true;
    }
    else if(strcmp(gargv[0], "echo") == 0)
    {
        if(gargc == 2)
        {
            if(gargv[1][0] == '$')
            {
                if(gargv[1][1] == '?')
                {
                    printf("%d\n", lastcode);
                    lastcode = 0;
                }
            }
            else
            {
                printf("%s\n", gargv[1]);
                lastcode = 0;
            }
        }
        else
        {
            lastcode = 3;
        }
        return true;
    }

    return false;
}

初始化环境变量

void InitEnv()
{
    extern char **environ;
    int index = 0;
    while(environ[index])
    {
        genv[index] = (char*)malloc(strlen(environ[index]) + 1);
        strncpy(genv[index], environ[index], strlen(environ[index]) + 1);
        index++;
    }
    genv[index] = nullptr;
}

main 主循环

int main()
{
    InitEnv();
    char command_buffer[basesize];

    while(true)
    {
        PrintCommandLine();
        if(!GetCommandLine(command_buffer, basesize))
        {
            continue;
        }

        ParseCommandLine(command_buffer, strlen(command_buffer));
        if(CheckAndExecBuiltCommand())
        {
            continue;
        }

        ExecuteCommand();
    }

    return 0;
}

5.4 简易 shell 总结

  • 普通命令:fork + exec + wait
  • 内建命令:shell 自己直接处理
  • 环境变量:从父 shell 继承并维护
  • $?:记录上一个命令的退出状态

shell 的本质,就是不断地解析命令并决定由谁执行


六、总结

  • fork 负责创建进程
  • 写时拷贝保证父子进程初始共享、写入后分离
  • exit 和 _exit 负责终止进程
  • wait 和 waitpid 负责等待和回收子进程
  • exec 族负责程序替换
  • shell 通过 fork、exec、wait 组合完成命令执行
  • 环境变量在 shell 和进程控制中起着重要作用
    在这里插入图片描述

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐