Linux 进程程序替换与自定义 Shell:从 exec 函数族到命令行解释器

写在前面

fork() 解决的是"复制一个执行流",exec 函数族解决的是"让这个执行流改去运行另一个程序",waitpid() 则让父进程等待并回收子进程。把这三件事连起来,就得到了 Shell 执行外部命令的核心骨架。

这篇文章先讲清进程程序替换的原理、exec 函数族的命名规律、命令行参数与环境变量怎样交给新程序,再亲手搭出一个可以执行 lspwdtouch 等外部命令的第一版 Shell。

本篇严格停在"外部命令执行"这一进度:cdexportenvecho $? 等内建命令,以及更完整的环境管理,留到后续实现。

知识框架

下面这张图先把全文主线串起来。阅读时只要始终抓住一句话:Shell 负责组织参数、创建子进程并等待;子进程负责通过 exec 换成目标程序。

目录

  1. 为什么创建子进程之后还要"换程序"
  2. exec 到底替换了什么
  3. exec 函数族的命名规律
  4. 命令行参数和环境变量从哪里来
  5. C/C++ 程序与脚本怎样被启动
  6. proc.c:分阶段验证替换、参数与环境表
  7. 自定义 Shell 的四步闭环
  8. myshell.cc:第一版完整实现
  9. 当前实现的边界与常见误区
  10. 总结

一、为什么创建子进程之后还要"换程序"

1. fork() 只复制,不会自动换成别的程序

父进程调用 fork() 后,系统得到父、子两个执行流。子进程会从 fork() 返回处继续执行,最初看到的代码与数据和父进程相同。

但现实需求往往不是"让子进程再跑一遍父进程的程序",而是:

  • 父进程继续承担控制工作;
  • 子进程去运行 lspython3 或用户编写的其他程序;
  • 父进程等待子进程结束并回收资源。

这就需要进程程序替换(process program replacement):让已经存在的进程加载另一份程序。

2. 为什么通常是 fork() 后让子进程调用 exec

如果父进程直接调用 exec,父进程自己的代码也会被新程序覆盖,原来的控制逻辑无法继续。

更常见的组合是:

cpp 复制代码
pid_t id = fork();
if (id == 0)
{
    // 子进程换成目标程序
    execvp(...);
    exit(1); // 能走到这里,说明替换失败
}

// 父进程保留原程序,等待子进程
waitpid(id, nullptr, 0);

这样既保留了父进程,又让子进程拥有了新的任务。Shell 执行外部命令用的正是这个基本模型。

二、exec 到底替换了什么

1. 换的是程序映像,不是进程身份

调用成功后,新程序的代码和数据被装入调用进程的用户地址空间,旧程序的用户态代码、数据、堆和栈随之被替换,并从新程序的启动入口开始执行。

但调用前后仍是同一个进程,因此它的 PID(Process ID,进程标识符)不变。可以把它概括为:

换程序,不换进程。

严格地说,不能把这个过程简单理解成"又创建了一个进程"。exec 会重建用户地址空间中的程序映射,但进程身份仍然延续。fork() 后的私有可写页通常采用写时拷贝(copy-on-write);只读代码页常可直接共享。调用 exec 时,旧映射被新程序映射替换,这与"代码页也先做一次普通内存复制"不是一回事。

2. 为什么 exec 成功后没有返回值

exec 函数的返回规律很特殊:

  • 成功:旧代码已经不存在,不会回到原调用点;
  • 失败:返回 -1,并设置 errnoerrno 用来记录最近一次失败对应的错误编号,可以配合 perror() 查看可读原因。

因此下面这种判断没有成功分支:

cpp 复制代码
execvp(g_argv[0], g_argv);
// 只有失败才会执行到这里
exit(1);

⚠️ 易错点:不能把"成功返回 0"套到 exec 上。只要 exec 返回了,就意味着失败。

3. PID 不变怎样验证

可以在替换前的子进程里打印一次 getpid(),再让目标程序启动后打印一次 getpid()。如果两次 PID 相同,就说明代码换了,进程身份没有换。

4. 它为什么像一个加载器

一个磁盘文件要真正运行,最终必须有代码和数据进入进程地址空间。exec 完成的正是"把指定程序加载进当前进程并开始执行"的关键动作。

这也解释了为什么它不只会启动系统命令:只要目标最终能以 Linux 进程的形式运行,就可以被 exec 启动。

三、exec 函数族的命名规律

1. 六个常用封装与一个底层入口

常见接口如下:

c 复制代码
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[]);

GNU/Linux 的 glibc 还提供:

c 复制代码
int execvpe(const char *file, char *const argv[], char *const envp[]);

其中 execve 是 Linux 系统调用入口,其他常用形式是 C 库为了方便不同传参场景提供的封装。

在 Linux 终端中可执行:

bash 复制代码
man 2 execve
man 3 exec

第一条查看系统调用,第二条查看 C 库封装。成功打开手册后,可以重点寻找 SYNOPSIS、返回值和各字母的含义。

2. 四个字母记住全部规律

  • l = list:参数一个接一个地写在可变参数列表里;
  • v = vector:参数先放进 argv 指针数组,再整体传入;
  • p = PATH:只给文件名,由函数按 PATH 搜索;
  • e = environment:调用者显式提供新的环境变量表。

例如:

c 复制代码
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);

这里第一部分回答"执行谁",后面的字符串回答"怎样执行"。最后必须用 NULL 结束可变参数列表。

等价的数组风格可以写成:

c 复制代码
char *const argv[] = {
    (char *const)"ls",
    (char *const)"-l",
    (char *const)"-a",
    NULL
};

execv("/usr/bin/ls", argv);

3. "执行谁"和 argv[0] 为什么看起来重复

下面两个 "ls" 的职责不同:

c 复制代码
execlp("ls", "ls", "-l", NULL);
  • 第一个 "ls" 告诉 execlp 去找哪个可执行文件;
  • 第二个 "ls" 会成为新程序收到的 argv[0]

按照惯例,argv[0] 放程序名,但内核并不会替你验证它是否真的等于文件名。写规范代码时仍应保持两者语义清楚。

4. p 的含义:按 PATH 搜索

没有 p 时,通常要提供绝对路径或相对路径:

c 复制代码
execv("/usr/bin/ls", argv);

p 时可以只写文件名:

c 复制代码
execvp("ls", argv);

execvp 会按当前进程环境中的 PATH 查找可执行文件。若传入的字符串本身含 /,例如 "./other",就按给定路径执行,不再做普通的 PATH 搜索。

5. 用一张图读懂 l/v/p/e

把接口名称拆开后,选择函数就不再需要死记:先判断参数是逐个传还是数组传,再判断是否需要 PATH 搜索和显式环境表。所有常用封装最终都围绕 execve 完成程序替换。

四、命令行参数和环境变量从哪里来

1. main 的参数不是凭空出现的

目标程序常见入口是:

cpp 复制代码
int main(int argc, char *argv[], char *env[])
  • argc:命令行参数数量;
  • argv:以空指针结尾的命令行参数表;
  • env:以空指针结尾的环境变量表。

新程序收到的 argv,正是调用方执行 exec* 时交给它的那张表。Shell 先把用户输入拆成 argv,再通过 execvp 交给子进程中的新程序。

第三个 main 参数是许多 Unix/Linux 实现支持的扩展写法,不属于最可移植的 C/C++ 入口形式;需要可移植地读取环境时,可以使用 getenv()extern char **environ

2. 不显式传环境表时发生什么

execlexecvexecvp 等不带 e 的封装会沿用调用进程当前的环境。C/C++ 程序还可以通过:

c 复制代码
extern char **environ;

取得当前环境表的入口。

如果使用带 e 的接口并显式传入 envp,新程序使用的是调用者提供的这张环境表,而不是自动把旧表与新表合并。

例如:

c 复制代码
char *const envp[] = {
    (char *const)"MYVAL=123456789",
    NULL
};

execve("./other", argv, envp);

目标程序通常只会看到这张显式提供的表。若希望保留旧环境并增加新变量,可以先更新当前进程环境,再把 environ 传入。

3. putenv() 的作用方向

putenv() 修改的是调用它的进程的环境。父进程看不到子进程后来新增的变量;子进程却可以继承父进程已有的环境。

如果进程关系是 A → B → C,而 B 调用 putenv()

  • A 看不到 B 新增的变量;
  • B 自己能看到;
  • B 之后创建的 C 可以继承它。

⚠️ 易错点:putenv() 通常直接把传入指针放进环境表,并不保证复制字符串,因此那块字符存储必须继续有效。局部数组离开作用域后再被环境表引用会产生悬空指针。需要更直观的"复制键和值"语义时,可以考虑 setenv()

4. execvpe() 的平台边界

execvpe() 是 GNU 扩展,不是 POSIX(可移植操作系统接口标准)规定的接口。在 glibc(GNU/Linux 常用的 C 标准库实现)环境下,通常需要在包含头文件前定义 _GNU_SOURCE,编译器才能看到完整声明。

本篇保留原始代码与原始 Makefile,不擅自加宏。如果严格编译模式提示 execvpe 未声明,原因是功能测试宏,而不是程序替换原理失效。

还有一个容易忽略的 glibc 细节:execvpe() 搜索文件时读取的是调用进程当前环境中的 PATH,不是传入 envp 里的 PATH。本例传的是 "./other",字符串里已经含 /,因此不依赖这一步搜索。

五、C/C++ 程序与脚本怎样被启动

1. 用一个目标程序观察 argv 与环境表

文件名:other.cc

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

int main(int argc, char *argv[], char *env[])
{
    std::cout << "hello C++, My Pid Is: " << getpid() << std::endl;

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

    printf("\n");

    for(int i = 0; env[i]; i++)
    {
        printf("env[%d]: %s\n", i, env[i]);
    }

    return 0;
}

这个程序没有复杂业务,只做三件事:

  1. 打印自己的 PID;
  2. 逐项打印 argv
  3. 逐项打印环境变量表。

在目标目录中执行:

bash 复制代码
g++ other.cc -o other

这条命令把 other.cc 编译成名为 other 的可执行文件。成功后,目录中应出现 other,它供后面的 proc 通过 "./other" 启动。

2. 脚本实际启动的是解释器

文件名:other.py

python 复制代码
#!/usr/bin/python3

print ("hello python")

文件名:other.sh

bash 复制代码
#!/usr/bin/bash


echo "hello shell!"

Python、Shell 这类脚本需要解释器。可以把调用关系理解为:新进程映像来自解释器二进制,脚本路径则作为参数交给解释器。

例如,要通过显式解释器运行脚本,命令形态是:

bash 复制代码
/usr/bin/python3 test/other.py
/usr/bin/bash test/other.sh

脚本首行的 #! 称为 shebang。直接执行具有权限的脚本时,系统会据此选择解释器。

⚠️ 易错点:当前目录里的脚本已经位于 test/ 子目录,而 proc.c 中相关调用只是注释下来的演示历史。若直接照注释使用 "other.py""other.sh",当前工作目录与文件路径必须匹配。

六、proc.c:分阶段验证替换、参数与环境表

1. 构建文件

文件名:Makefile

makefile 复制代码
proc:proc.c
	gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
	rm -f proc

在目标目录执行:

bash 复制代码
make

$@ 代表目标名 proc$^ 代表全部依赖文件 proc.c。成功后会生成 proc

注意:这个 Makefile 只构建 proc,不会自动构建 other。因此完整实验顺序是先执行:

bash 复制代码
g++ other.cc -o other
make
./proc

2. 主程序的演进:先替换,再传参数,最后传环境

proc.c 同时保留了多次实验的调用记录。如果把生效代码和所有历史调用整块当成一个"最终版本",反而会混淆每一步解决的问题。我按接口演进拆开来看;下面都是阶段片段,不能单独编译。

第一版只验证"子进程换程序,父进程继续运行"。列表风格的调用点依次尝试了系统命令、自己编译的程序和脚本解释器:

c 复制代码
//execlp("/usr/bin/ls", "ls", "-ln", "-a", NULL);
//child
//execl("/usr/bin/ls", "/usr/bin/ls", "-ln", "-a", NULL);
//execl("./other", "other", NULL);
//execl("/usr/bin/python3", "python", "other.py", NULL);
//execl("/usr/bin/bash", "bash", "other.sh", NULL);

第二版把命令行参数组织成以 NULL 结尾的 argv 数组,开始使用 v 风格接口:

c 复制代码
char *const argv[] = {
    (char*const)"other",
    (char*const)"-a",
    (char*const)"-b",
    (char*const)"-c",
    (char*const)"-d",
    NULL
};

//execvp("./other", argv);
//execv("/usr/bin/ls", argv);

最后一版增加三条环境变量,再把当前环境表交给 execvpe()

c 复制代码
char *const addenv[] = {
    (char *const)"MYVAL=123456789",
    (char *const)"MYVAL1=123456789",
    (char *const)"MYVAL2=123456789",
    NULL
};

for(int i = 0; addenv[i]; i++)
{
    putenv(addenv[i]);
}
extern char **environ;
execvpe("./other", argv, environ);

无论换哪一种接口,控制骨架都不变:子进程负责替换,父进程负责等待;exec 一旦返回,子进程就按失败路径退出。

讲解片段(注释为博客补充,不是完整程序)

c 复制代码
if(fork() == 0)
{
    // 这里准备 argv、环境表并调用 exec*
    exit(1);
}

waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");

3. 顺着执行路径读代码

程序入口先打印"我的程序要运行了",随后调用 fork()

  • 子进程进入 if
  • 父进程跳过 if,在 waitpid(-1, NULL, 0) 处阻塞等待任意一个子进程。

子进程先打印 PID,再准备 argv

other -a -b -c -d

然后循环调用 putenv(),把三条 MYVAL* 变量增量加入子进程当前环境。接着把 environ 传给 execvpe(),因此新程序既能看到原环境,也能看到新增变量。

execvpe("./other", argv, environ) 成功后,子进程开始运行 other,PID 不变,exit(1) 不会执行。若失败,才会落到 exit(1)

父进程等到子进程结束后打印"我的程序运行完毕了"。

4. 当前代码的限制

  • 没有检查 fork() 失败;
  • 没有通过 perror() 打印 execvpe() 失败原因;
  • waitpid() 的退出信息参数传了 NULL,父进程不知道子进程的退出码;
  • putenv() 接收的是强制转换后的字符串字面量。它们具有静态存储期,所以生命周期足够长,但不应尝试修改;
  • 在某些严格编译环境中,execvpe() 需要 _GNU_SOURCE 才有声明。

这些限制不会改变本例要观察的主线:参数表和环境表由调用方准备,子进程通过 exec 换成目标程序,父进程等待。

七、自定义 Shell 的四步闭环

1. 第一步:打印提示符

一个最小提示符可以由用户名、主机名、当前目录和提示字符组成,例如 [用户名@主机名 当前目录]#

代码通过 getenv("USER")getenv("HOSTNAME")getenv("PWD") 读取现有环境变量,再用 snprintf() 安全地拼进固定大小的缓冲区。

因为提示符末尾没有换行,打印后要调用:

cpp 复制代码
fflush(stdout);

其中 stdout 是标准输出流。若不主动刷新,缓冲机制可能让提示符不能立即显示。

2. 第二步:读取整行输入

scanf("%s", ...) 会把空格当分隔符,不适合一次读入:

bash 复制代码
ls -a -l

因此使用 fgets() 从标准输入流 stdin 读取整行。fgets() 会保留换行符,所以代码再把末尾的 '\n' 改成 '\0'

3. 第三步:把字符串切成 argv

用户输入是一整段字符:

bash 复制代码
ls -a -l

execvp() 需要:

cpp 复制代码
char *argv[] = {
    (char *)"ls",
    (char *)"-a",
    (char *)"-l",
    nullptr
};

strtok() 会把分隔符位置改写成 '\0',并返回每个子串的起始地址。第一次传原字符串,之后传 nullptr,直到返回空指针。

这里的 g_argv 只保存指向 commandline 内部的指针,没有复制各个单词。因此 commandline 必须在 execvp() 使用完这些指针之前一直有效。当前代码在同一次循环内完成解析、fork 和执行,生命周期满足要求。

4. 第四步:fork + execvp + waitpid

解析完成后:

  • Shell 父进程调用 fork()
  • 子进程调用 execvp(g_argv[0], g_argv)
  • 父进程调用 waitpid()
  • 回收完成后进入下一轮,再次打印提示符。

八、myshell.cc:第一版完整实现

1. 构建文件

文件名:Makefile

makefile 复制代码
myshell:myshell.cc
	g++ -o $@ $^ -std=c++11 #-std=c99
.PHONY:clean
clean:
	rm -f myshell

进入 myshell 目录执行:

bash 复制代码
make
./myshell

make 成功后生成 myshell,第二条命令启动它。可以测试 ls -lpwdtouch demo.txt 等外部命令;按 Ctrl+C 结束当前 Shell。

2. 完整源码

文件名:myshell.cc

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s %s]# "

// 下面是shell定义的全局数据

#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0; 

const char *GetUserName()
{
    const char *name = getenv("USER");
    return name == NULL ? "None" : name;
}

const char *GetHostName()
{
    const char *hostname = getenv("HOSTNAME");
    return hostname == NULL ? "None" : hostname;
}

const char *GetPwd()
{
    const char *pwd = getenv("PWD");
    return pwd == NULL ? "None" : pwd;
}


void MakeCommandLine(char cmd_prompt[], int size)
{
    snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}

void PrintCommandPrompt()
{
    char prompt[COMMAND_SIZE];
    MakeCommandLine(prompt, sizeof(prompt));
    printf("%s", prompt);
    fflush(stdout);
}

bool GetCommandLine(char *out, int size)
{
    // ls -a -l => "ls -a -l\n" 字符串
    char *c = fgets(out, size, stdin);
    if(c == NULL) return false;
    out[strlen(out)-1] = 0; // 清理\n
    if(strlen(out) == 0) return false;
    return true;
}

// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
bool CommandParse(char *commandline)
{
#define SEP " "
    g_argc = 0;
    // 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
    g_argv[g_argc++] = strtok(commandline, SEP);
    while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));
    g_argc--;
    return true;
}

void PrintArgv()
{
    for(int i = 0; g_argv[i]; i++)
    {
        printf("argv[%d]->%s\n", i, g_argv[i]);
    }
    printf("argc: %d\n", g_argc);
}

int Execute()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        execvp(g_argv[0], g_argv);
        exit(1);
    }

    // father
    pid_t rid = waitpid(id, nullptr, 0);
    (void)rid; // rid使用一下
    return 0;
}

int main()
{
    while(true)
    {
        // 1. 输出命令行提示符
        PrintCommandPrompt();

        // 2. 获取用户输入的命令
        char commandline[COMMAND_SIZE];
        if(!GetCommandLine(commandline, sizeof(commandline)))
            continue;

        // 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
        CommandParse(commandline);
        //PrintArgv();

        // 4. 执行命令
        Execute();
    }
    return 0;
}

一个小插曲:为什么 getenv("HOSTNAME") 获取不到主机名

在编写自定义 Shell 的命令行提示符时,我最初通过下面的代码获取主机名:

cpp 复制代码
const char *GetHostName()
{
    const char *hostname = getenv("HOSTNAME");
    return hostname == nullptr ? "None" : hostname;
}

运行程序后,提示符中的主机名显示为 None。但在 Linux 终端中执行:

bash 复制代码
echo "$HOSTNAME"

却可以正常打印主机名。

问题排查

继续使用下面的命令检查:

bash 复制代码
printenv HOSTNAME

发现没有任何输出。

这说明 HOSTNAME 虽然是当前 Bash 能够识别的 Shell 变量,但它没有被导出到环境变量表中。

echo "$HOSTNAME" 是由 Bash 先完成变量展开,再把结果交给 echo,因此即使变量没有导出,也可以正常显示。

而 C/C++ 中的 getenv() 只能读取当前进程继承到的环境变量,不能读取 Bash 内部尚未导出的普通 Shell 变量。因此:

cpp 复制代码
getenv("HOSTNAME");

返回了空指针。

临时解决方法

可以在终端中导出 HOSTNAME

bash 复制代码
export HOSTNAME

或者明确使用系统主机名赋值:

bash 复制代码
export HOSTNAME="$(hostname)"

然后再次检查:

bash 复制代码
printenv HOSTNAME

此时子进程便可以通过 getenv("HOSTNAME") 读取主机名。

但是,这种方案依赖外部环境。如果换一个终端、调试器或运行方式,HOSTNAME 仍然可能不存在。

推荐方案:使用 gethostname()

主机名属于系统信息,更合理的做法是直接使用 Linux 提供的 gethostname() 接口,而不是依赖环境变量。

myshell.cc 已经包含:

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

因此可以把 GetHostName() 修改为:

cpp 复制代码
const char *GetHostName()
{
    // 使用 static,保证函数返回后字符数组依然存在
    static char hostname[256] = {0};

    // 预留一个位置存放字符串结束符 '\0'
    if(gethostname(hostname, sizeof(hostname) - 1) == -1)
    {
        return "None";
    }

    // 防止主机名过长时没有自动添加结束符
    hostname[sizeof(hostname) - 1] = '\0';

    return hostname;
}

这段代码的作用

gethostname() 直接向系统获取当前主机名:

  • 成功时返回 0
  • 失败时返回 -1
  • 主机名会被写入 hostname 数组;
  • 使用 static 可以保证函数结束后数组仍然有效;
  • 最后手动补上 '\0',避免字符串没有正确结束。

修改后,命令行提示符的生成方式保持不变:

cpp 复制代码
void MakeCommandLine(char cmd_prompt[], int size)
{
    snprintf(
        cmd_prompt,
        size,
        FORMAT,
        GetUserName(),
        GetHostName(),
        GetPwd()
    );
}

最终结论

这次问题并不在 snprintf(),而在主机名的数据来源。

  • echo "$HOSTNAME" 可以读取 Bash 内部的 Shell 变量;
  • getenv("HOSTNAME") 只能读取已经导出的环境变量;
  • HOSTNAME 没有导出时,getenv() 会返回空指针;
  • 主机名属于系统信息,推荐使用 gethostname()
  • 当前工作目录属于进程状态,推荐使用 getcwd()

因此,自定义 Shell 获取提示符信息时,更稳妥的方案是:

text 复制代码
用户名:getenv("USER")
主机名:gethostname()
当前工作目录:getcwd()

3. 从 main() 看整体节奏

main() 只有一个无限循环,循环体恰好对应 Shell 的四个阶段:

  1. PrintCommandPrompt():打印提示符;
  2. GetCommandLine():读取一整行;
  3. CommandParse():建立 g_argv
  4. Execute():创建子进程、替换、等待。

这就是第一版命令行解释器的骨架。

4. 提示符相关函数

GetUserName()GetHostName()GetPwd() 都通过 getenv() 读取环境。若找不到变量就返回 "None",避免把空指针直接交给 %s

5. 输入函数的边界

当前实现默认 fgets() 读到的最后一个字符一定是换行符:

cpp 复制代码
out[strlen(out)-1] = 0;

正常交互输入通常成立,但若输入超过缓冲区、文件结束前没有换行,最后一个有效字符可能被误删。更稳健的实现应先检查末尾是否真是 '\n',但这里不修改原代码,只明确当前边界。

6. 参数解析的边界

strtok(commandline, " ") 只能处理最基本的空格切分,它暂不理解:

  • 引号与转义;
  • 制表符和更复杂的空白;
  • 管道 |
  • 输入输出重定向;
  • 后台任务 &
  • 通配符展开;
  • 超过 MAXARGC 的大量参数。

因此它是帮助理解 Shell 主路径的最小模型,不是 Bash 的完整替代品。

7. 执行函数的资源路径

Execute() 中只有子进程调用 execvp()。父进程执行 waitpid(id, nullptr, 0),阻塞到这个子进程结束。

当前实现忽略了子进程退出状态,也没有处理:

  • fork() 返回 -1
  • execvp() 失败原因;
  • waitpid() 被信号中断;
  • 子进程退出码。

这些都是后续增强点,但资源关系已经正确:父进程等待并回收自己创建的子进程。

九、当前实现的边界与常见误区

1. cd 为什么不能照搬外部命令路径

当前 Shell 把所有命令都交给子进程。即使子进程成功调用 chdir(),改变的也只是子进程自己的当前工作目录;子进程结束后,Shell 父进程仍在原目录。

cd 必须由 Shell 自己执行,因为它要修改 Shell 的持久状态。这类命令称为内建命令(built-in command)。本篇只识别这个边界,不提前实现。

2. PWD 不一定等于内核记录的真实当前目录

当前提示符从环境变量 PWD 取值,而不是调用 getcwd()。如果程序改变了当前目录却没有同步更新 PWD,显示值可能过期。

3. exec 成功不返回,失败也不等于进程自动结束

exec 失败时只是返回 -1。调用者必须决定如何报告错误、设置退出码和结束子进程。当前源码选择直接 exit(1)

4. argv 必须以空指针结尾

无论手工构造还是由 strtok() 生成,参数表最后都要有 NULL。否则 exec 无法确定数组在哪里结束。

5. strtok() 会修改原字符串

它会把分隔符改写成 '\0'。因此不能把只读字符串直接交给它,也不能指望解析后仍保留原始命令行的完整字面内容。

6. 第一版 Shell 不是 Bash

它只能解释最简单的"命令 + 空格分隔参数",尚未支持引号、转义、管道、重定向、通配符、变量展开、作业控制和信号转发。理解主流程之后,再逐项补这些能力,结构才不会乱。

总结

进程程序替换的核心是:exec 把新程序装进已有进程,PID 不变,成功后旧代码不再继续执行。l/v/p/e 分别描述参数列表、参数数组、PATH 搜索和显式环境表,最终都围绕"执行谁、怎样执行、带什么环境"展开。

第一版 Shell 则把前面的知识连成了闭环:打印提示符,读取整行,切分出 argvfork() 创建子进程,子进程 execvp() 执行外部命令,父进程 waitpid() 等待回收,再回到下一轮。至此,命令行为什么能启动程序、main 的参数从哪里来、Shell 为什么要维护参数表,都有了可以落到代码上的答案。

相关推荐
北极糊的狐14 小时前
阿里云服务器-命令2-Linux 系统实时资源监视器 top 命令详解(进程级实时资源监控)
linux·运维·服务器
三言老师15 小时前
clear与history历史命令管理实操
linux·运维·服务器·网络·centos
味悲16 小时前
Linux 环境下 DNS 服务器搭建
linux·运维·服务器
greenbbLV16 小时前
中小公司积分商城选型:SaaS与私有化优劣对比分析
大数据·运维·人工智能
dok1216 小时前
Johannes 《Linux内核模块与设备驱动开发:编写Linux驱动程序》 (7)open,release (8) write read
linux
皮卡狮16 小时前
进程间通信:认识匿名管道和进程池实现
linux
feasibility.17 小时前
wsl安装Ubuntu方法(含网络不稳定处理)
linux·运维·windows·ubuntu
w678200717 小时前
,攻击者通过构造特殊的XML使其包含恶意外部实体。外部实体可以为服务器敏感文件,也可以为网络请求等,之后利用方式类似于文件包含和SS ...
xml·服务器·网络
‎ദ്ദിᵔ.˛.ᵔ₎17 小时前
linux进程
linux