【Linux系统】12 进程程序替换 自定义Shell 命令行解释器

1 什么是进程程序替换

前面我们学过:fork () 之后,父子进程各自执行父进程代码的一部分(走不同的代码分支)。但问题来了 ------如果子进程就想去执行一个全新的程序呢?

例:父进程是服务器,fork 出子进程后,子进程想去执行磁盘上的另一个可执行程序(比如执行 ls、执行另一个编译好的二进制程序)。

答案就是进程的程序替换。

程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中。

关键认知:程序替换不是创建新进程,而是把 "当前这个进程" 的用户空间代码和数据整个换掉,换成另一个程序的代码和数据。

2 替换原理:加载全新程序到进程地址空间

2.1 核心机制

  • 用 fork 创建子进程后,子进程执行的是和父进程相同的程序(但可能执行不同的代码分支);
  • 子进程往往要调用一种 exec 函数 来执行另一个程序;
  • 当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换 ,从新程序的**启动例程(startup routine)**开始执行;
  • 调用 exec 并不创建新进程 ,所以调用 exec 前后,该进程的 id 并未改变(PID 不变)。

2.2 示意图理解(加载过程)

cpp 复制代码
  PCB(task_struct)           虚拟内存              页表          物理内存            磁盘
  ┌────────────┐         ┌────────────┐      ┌──────────┐     ┌──────────┐     ┌──────────┐
  │  pid不变    │         │  栈 stack   │      │          │     │  堆 heap   │     │ 要执行的  │
  │  (进程身份   │         │  堆 heap    │ ──映射──▶│          │────▶│  栈 stack  │────▶│  程序     │
  │   保留)     │ ───────▶│ 数据段 data  │      │  页表     │     │ 数据段 data │     │ (ELF)    │
  │             │  mm指针  │  代码段 text │      │          │     │ 代码段 text │     │ 代码/数据 │
  └────────────┘         └────────────┘      └──────────┘     └──────────┘     └──────────┘

执行流程:磁盘上要执行的新程序(ELF 格式可执行文件)的代码和数据,通过页表映射关系,加载 / 替换进调用进程的用户空间(代码段、数据段、堆、栈区域被新程序内容覆盖)。PCB 和 PID 保持不变,变的只是用户空间的代码和数据内容。

一句话记忆:进程程序替换 = 进程 "换芯"(换掉代码和数据),"壳"(PCB、PID、内核身份)不变。

3 六种 exec 替换函数(exec 函数簇)

3.1 函数解释:为什么 exec 只有出错返回值

其实有 六种以 exec 开头的函数 ,统称 exec 函数。头文件都是 <unistd.h>:

cpp 复制代码
#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. 这些函数如果调用成功 ,则加载新的程序,从启动代码开始执行,不再返回(后续代码永远执行不到,进程已经被新程序替换);
  2. 如果调用出错 ,则返回 -1;
  3. 所以 exec 函数只有出错的返回值,而没有成功的返回值。

理解:因为一旦成功,进程代码就被新程序完全替换掉了,exec 后面的代码根本不会执行,自然也不可能有 "成功后的返回值"。只有失败时才会回到原进程继续执行并返回 -1。

3.2 命名理解:l /v/p /e 后缀规律

这些函数原型看起来很容易混,但只要掌握了规律就很好记。

表格

后缀字母 全称 含义
l list 参数采用列表(可变参数形式,一个一个传)
v vector 参数采用数组(指针数组)
p path 有 p 就自动搜索环境变量 PATH(无需写全路径)
e env 表示自己维护环境变量(手动传入环境变量数组)

六个函数完整对照表(面试高频):

表格

函数名 参数格式 是否带路径 是否使用当前环境变量
execl 列表 不是 是
execlp 列表 是 是
execle 列表 不是 不是,须自己组装环境变量
execv 数组 不是 是
execvp 数组 是 是
execve 数组 不是 不是,须自己组装环境变量

3.3 exec 调用举例

cpp 复制代码
#include <unistd.h>

int main()
{
    char *const argv[] = {"ps", "-ef", NULL};                    // v 后缀用的参数数组
    char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL}; // e 后缀用的环境变量

    // l:列表传参,写全路径
    execl("/bin/ps", "ps", "-ef", NULL);

    // 带 p:可以使用环境变量PATH,无需写全路径
    execlp("ps", "ps", "-ef", NULL);

    // 带 e:需要自己组装环境变量
    execle("ps", "ps", "-ef", NULL, envp);

    // v:数组传参,写全路径
    execv("/bin/ps", argv);

    // 带 p:可以使用环境变量PATH,无需写全路径
    execvp("ps", argv);

    // 带 e:需要自己组装环境变量(execve 是真正的系统调用)
    execve("/bin/ps", argv, envp);

    exit(0);
}

记忆口诀:l 是列表 (list)、v 是数组 (vector)、p 是带路径搜索 (PATH)、e 是自己给环境变量 (env)。

3.4 exec 函数簇调用关系:只有 execve 是系统调用(重点)

事实上,只有 execve 是真正的系统调用,其它五个函数最终都调用 execve。 所以:

  • execve 在 man 手册第 2 节(系统调用 section 2);
  • 其它五个函数在 man 手册第 3 节(库函数 section 3)。

函数之间的关系图:

cpp 复制代码
  execlp  ──把可变参数保存到──▶  execvp  ──依次在PATH环境变量指示的各目录中查找该程序──▶
  execl   ──把可变参数保存到──▶  execv   ──使用environ所指向的当前环境变量表───────────▶
  execle  ──把可变参数保存到──▶  execve (系统调用) ◀── 最终都要调用它

即:

  1. execlp / execl / execle 先把 "可变参数" 保存到以 NULL 结尾的指针数组中;
  2. execlp → execvp:execvp 会依次在 PATH 环境变量指示的各目录中查找该程序;
  3. execl → execv:execv 会使用 environ 所指向的当前环境变量表;
  4. execle → execve:execve 是最终的系统调用,所有 exec 函数最后都调它。

课件提示(课堂上会结合时间演示):环境变量如何传入的问题------ 带 e 的函数要自己组装环境变量传入,不带 e 的函数默认继承当前进程的环境表,这一点在后面写 shell 时体现得非常明显。


4 自主 Shell 命令行解释器

学完进程程序替换,我们来亲手实现一个 mini shell,把 fork、exec、wait 三大件串起来,同时真正搞懂 shell 的运行原理。

4.1 目标

  1. 要能处理普通命令(如 ls、ps,交给子进程执行);
  2. 要能处理内建命令(如 cd、export、env、echo,由 shell 自己执行);
  3. 要能帮助我们理解 内建命令 / 本地变量 / 环境变量 这些概念;
  4. 要能帮助我们理解 shell 的运行原理。

4.2 实现原理:shell 与用户的典型互动

先看一个 shell 的典型互动:

cpp 复制代码
[root@localhost epoll]# ls
client.cpp readme.md server.cpp utility.h
[root@localhost epoll]# ps
PID TTY TIME CMD
3451 pts/0 00:00:00 bash
3514 pts/0 00:00:00 ps

用时间轴理解事件发生次序(时间从左向右):

cpp 复制代码
  bash ─读入"ls"─▶ bash ─fork建子进程─▶ [子进程 ls]─运行命令─▶ 退出 ─▶ bash 等待子进程退出
  bash ─读入"ps"─▶ bash ─fork建子进程─▶ [子进程 ps]─运行命令─▶ 退出 ─▶ bash 等待子进程退出
  • shell(用 bash 方块代表)从用户读入字符串 "ls";
  • shell 建立一个新的进程 (fork),然后在那个进程中运行 ls 程序,并等待那个进程结束(wait);
  • 然后 shell 读取新的一行输入,建立一个新的进程,在这个进程中运行 ps 程序并等待这个进程结束。

所以写一个 shell,需要循环以下 5 步过程:

  1. 获取命令行(读取用户输入的一整条命令字符串);
  2. 解析命令行 (把 "ls -a -l -n" 拆成命令名 + 参数数组);
  3. 建立一个子进程(fork);
  4. 替换子进程(execvp /execve,子进程去执行新程序);
  5. 父进程等待子进程退出(wait)。

根据这些思路 + 我们前面学的技术(fork /exec/wait),就可以自己实现一个 shell 了。

4.3 完整源码实现

① 头文件与全局变量
cpp 复制代码
#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 envvnum = 64;      // 环境变量个数上限

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

// 全局的变量
int lastcode = 0;            // 记录上一条命令的退出码(echo $? 用)

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

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

// 去除字符串前导空格
#define TrimSpace(pos) do{\
    while(isspace(*pos)){\
        pos++;\
    }\
}while(0)
② 用户名、主机名、当前路径
cpp 复制代码
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); // PWD=XXX
    return pwd;
}

// 获取当前目录的最后一级名字(用于提示符)
string LastDir()
{
    string curr = GetPwd();
    if(curr == "/" || curr == "None") return curr;
    // /home/whb/XXX
    size_t pos = curr.rfind("/");
    if(pos == std::string::npos) return curr;
    return curr.substr(pos+1);
}
③ 打印命令行提示符(第 1 步)
cpp 复制代码
// 构造形如 [user@host dir]# 的提示符
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() // 1. 命令行提示符
{
    printf("%s", MakeCommandLine().c_str());
    fflush(stdout);
}
④ 获取用户命令(第 2 步)
cpp 复制代码
bool GetCommandLine(char command_buffer[], int size) // 2. 获取用户命令
{
    // 我们认为:要将用户输入的命令行,当做一个完整的字符串
    // 例如 "ls -a -l -n"
    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;
}
⑤ 解析命令行(第 3 步)
cpp 复制代码
void ParseCommandLine(char command_buffer[], int len) // 3. 分析命令
{
    (void)len;
    memset(argv, 0, sizeof(argv));
    gargc = 0;
    // "ls -a -l -n"
    const char *sep = " ";
    argv[gargc++] = strtok(command_buffer, sep);     // 切出第一个单词(命令名)
    // = 是刻意写的:当 strtok 返回 NULL 时,把 NULL 也存进 argv
    while((bool)(argv[gargc++] = strtok(nullptr, sep)));
    gargc--;   // 把多出的那个 NULL 去掉
}

void debug()   // 调试用:打印解析结果
{
    printf("argc: %d\n", gargc);
    for(int i = 0; argv[i]; i++)
    {
        printf("argv[%d]: %s\n", i, argv[i]);
    }
}
⑥ 执行命令(第 4 步:fork + exec + wait)
cpp 复制代码
// 在shell中
// 有些命令,必须由子进程来执行(普通命令)
// 有些命令,不能由子进程执行,要由shell自己执行 ------ 内建命令 built command
bool ExecuteCommand()   // 4. 执行命令(普通外部命令)
{
    // 让子进程进行执行
    pid_t id = fork();
    if(id < 0) return false;

    if(id == 0)
    {
        // 子进程
        // 1. 执行命令:用 execve 替换子进程为用户要执行的程序
        execve(argv[0], argv, genv);
        // 2. 退出:execve 失败才走到这里
        exit(1);
    }

    // 父进程
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);  // 5. 等待子进程退出
    if(rid > 0)
    {
        if(WIFEXITED(status))
        {
            lastcode = WEXITSTATUS(status);  // 记录子进程退出码,供 echo $? 使用
        }
        else
        {
            lastcode = 100;
        }
        return true;
    }
    return false;
}
⑦ 环境变量操作:AddEnv 与 InitEnv
cpp 复制代码
// 往环境变量数组 genv 中添加一项(export 用)
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;
}

// 作为一个shell,获取环境变量应该从系统的配置来
// 我们今天就直接从父shell中获取环境变量
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;
}
⑧ 内建命令处理(第 5 步:shell 自己执行)
cpp 复制代码
// shell自己执行命令,本质是shell调用自己的函数
bool CheckAndExecBuiltCommand()
{
    // 内建命令:cd
    if(strcmp(argv[0], "cd") == 0)
    {
        if(gargc == 2)
        {
            chdir(argv[1]);      // 改变当前工作目录
            lastcode = 0;
        }
        else
        {
            lastcode = 1;
        }
        return true;
    }
    // 内建命令:export
    else if(strcmp(argv[0], "export") == 0)
    {
        if(gargc == 2)
        {
            AddEnv(argv[1]);     // 把变量加入环境变量表
            lastcode = 0;
        }
        else
        {
            lastcode = 2;
        }
        return true;
    }
    // 内建命令:env ------ 打印当前环境变量
    else if(strcmp(argv[0], "env") == 0)
    {
        for(int i = 0; genv[i]; i++)
        {
            printf("%s\n", genv[i]);
        }
        lastcode = 0;
        return true;
    }
    // 内建命令:echo
    else if(strcmp(argv[0], "echo") == 0)
    {
        if(gargc == 2)
        {
            // echo $?   echo $PATH   echo hello
            if(argv[1][0] == '$')
            {
                if(argv[1][1] == '?')       // echo $?:打印上一条命令退出码
                {
                    printf("%d\n", lastcode);
                    lastcode = 0;
                }
            }
            else
            {
                printf("%s\n", argv[1]);    // echo hello:直接打印
                lastcode = 0;
            }
        }
        else
        {
            lastcode = 3;
        }
        return true;
    }
    return false;   // 不是内建命令,返回 false,走普通命令执行流程
}
⑨ 主函数:五步循环
cpp 复制代码
int main()
{
    InitEnv();
    char command_buffer[basesize];
    while(true)
    {
        PrintCommandLine();                 // 1. 命令行提示符
        if( !GetCommandLine(command_buffer, basesize) ) // 2. 获取用户命令
        {
            continue;
        }
        ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令
        if( CheckAndExecBuiltCommand() )    // 5. 先检查内建命令(shell自己执行)
        {
            continue;
        }
        ExecuteCommand();                   // 4. 执行普通外部命令(fork+exec+wait)
    }
    return 0;
}

运行效果(自己动手编译运行):

cpp 复制代码
[whb@localhost myshell]# ls
client.cpp  server.cpp  utility.h  readme.md
[whb@localhost myshell]# env        # 内建命令,打印环境变量
PATH=/usr/local/bin:/usr/bin:/bin
...
[whb@localhost myshell]# echo hello # 内建命令,打印 hello
hello
[whb@localhost myshell]# echo $?    # 打印上一条命令退出码
0

4.4 总结:exec/exit 就像 call/return

在继续学习新知识前,我们来思考函数和进程之间的相似性。

exec/exit 就像 call/return。

一个 C 程序由很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有它的局部变量,不同的函数通过 call/return 进行通信。

这种通过参数和返回值,在拥有私有数据的函数间通信的模式,是结构化程序设计的基础。Linux 鼓励将这种应用于程序之内的模式,扩展到程序之间:

cpp 复制代码
  调用者(函数) ──call/return──▶ 被调用函数(函数内)
  调用者(进程) ──fork,exec───▶ 被调用的程序(新进程)
  调用者(进程) ◀──exit────── 被调用的程序(返回值 n)
  调用者(进程) ◀──wait(&ret)── 拿到返回值

一句话总结整个图: 一个 C 程序可以 fork/exec 另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过 exit(n) 来返回值。调用它的进程可以通过 wait(&ret) 来获取 exit 的返回值。

这就是 Linux 进程间协作的核心范式:fork(创建)→ exec(替换运行)→ exit(返回退出码)→ wait(回收并获取结果)。shell 就是这一范式的典型实现者,而你亲手写的这个 mini shell,就是它最好的例证。


5 面试 & 高频考点总结

Q1 进程程序替换会不会创建新进程?PID 变不变?

不会创建新进程。exec 只是把当前进程的用户空间代码和数据替换为新程序,调用 exec 前后该进程的 id 并未改变(PID 不变),PCB 保留。

Q2 exec 函数为什么只有出错的返回值?

因为 exec 一旦调用成功,进程代码就被新程序完全替换,exec 后面的代码根本不会执行,自然没有 "成功返回值";只有出错时才返回 -1 并继续执行原进程后续代码。

Q3 exec 函数簇中 l /v/p /e 分别代表什么?

l (list) 参数用列表;v (vector) 参数用数组;p (path) 有 p 自动搜索环境变量 PATH,无需写全路径;e (env) 自己维护环境变量,需手动组装环境变量数组。

Q4 六个 exec 函数,哪个是真正的系统调用?它们在 man 手册第几节?

只有 execve 是真正的系统调用,其他五个最终都调用 execve。execve 在 man 手册第 2 节(系统调用),其它五个在 man 手册第 3 节(库函数)。

Q5 带 p 和不带 p 的 exec 有什么区别?

带 p(execlp、execvp):会依次在 PATH 环境变量指示的各目录中查找该程序,因此无需写全路径;不带 p(execl、execv):必须写全路径。

Q6 带 e 和不带 e 的 exec 在环境变量上有什么区别?

不带 e(execl、execlp、execv、execvp):使用当前进程已有的环境变量表(environ);带 e(execle、execve):不使用当前环境变量,必须自己组装环境变量数组 envp 传入。

Q7 自己写一个 shell,核心五步是什么?

①获取命令行 → ②解析命令行 → ③fork 建立子进程 → ④exec 替换子进程 → ⑤父进程 wait 等待子进程退出。循环往复。

Q8 什么是内建命令?为什么 cd 必须是内建命令,不能交给子进程执行?

内建命令(built-in command)由 shell 自己执行,如 cd、export、env、echo。因为普通命令交给子进程执行时,子进程的修改(如 chdir 改变目录)不会影响父进程 shell;而 cd 必须改变 shell 自身的当前目录,所以必须由 shell 自己调用 chdir 执行。

Q9 为什么说 exec/exit 就像 call/return?

函数之间通过 call 传参、被调函数 return 返回值的通信模式,被 Linux 扩展到程序之间:一个程序用 fork/exec 调用另一个程序并传参,被调用程序用 exit (n) 返回值,调用方用 wait (&ret) 获取该返回值。二者结构完全类比。

相关推荐
女神下凡8 分钟前
芯参谋(95):嵌入式文件系统_开发指南
linux·服务器·arm开发·驱动开发·设计规范
@兽血沸腾43 分钟前
Windows系统安装Jenkins部署应用至远程Windows服务器操作指南
运维·服务器·jenkins
且从容.4 小时前
Ubuntu安装flume
linux·ubuntu·flume
承渊政道4 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
讯捷蓝达6 小时前
戴尔R720xd服务器卡死在 “Configuring Memory...Done“,换了内存还是卡?问题根本不在内存
运维·服务器
且从容.7 小时前
ubuntu安装flink
linux·ubuntu·flink
数字新视界8 小时前
动环监控信创适配在智能机房管理中的应用价值与未来展望
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
爱和冰阔落9 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨9 小时前
WSL2的安装与使用
linux·wsl
微小的鱼9 小时前
Linux的渲染与显示系统链路
android·java·linux