【LInux】进程程序替换与shell实现:从fork到exec的完整闭环

文章目录

    • 进程程序替换与shell实现:从fork到exec的完整闭环
    • 一、进程程序替换
      • [1.1 为什么需要程序替换](#1.1 为什么需要程序替换)
        • [1.1.1 shell如何执行命令](#1.1.1 shell如何执行命令)
      • [1.2 程序替换的原理](#1.2 程序替换的原理)
      • [1.3 exec函数族详解](#1.3 exec函数族详解)
        • [1.3.1 命名规律](#1.3.1 命名规律)
        • [1.3.2 六个函数的对比](#1.3.2 六个函数的对比)
      • [1.4 exec函数使用示例](#1.4 exec函数使用示例)
        • [1.4.1 基本使用:execl](#1.4.1 基本使用:execl)
        • [1.4.2 使用execlp简化路径](#1.4.2 使用execlp简化路径)
        • [1.4.3 使用execv传递参数数组](#1.4.3 使用execv传递参数数组)
        • [1.4.4 fork + exec:让子进程执行新程序](#1.4.4 fork + exec:让子进程执行新程序)
        • [1.4.5 使用execle传递自定义环境变量](#1.4.5 使用execle传递自定义环境变量)
      • [1.5 exec调用关系图](#1.5 exec调用关系图)
    • 二、实现mini-shell
      • [2.1 shell的工作原理](#2.1 shell的工作原理)
      • [2.2 内建命令 vs 外部命令](#2.2 内建命令 vs 外部命令)
        • [2.2.1 什么是内建命令](#2.2.1 什么是内建命令)
        • [2.2.2 为什么cd必须是内建命令](#2.2.2 为什么cd必须是内建命令)
        • [2.2.3 为什么export必须是内建命令](#2.2.3 为什么export必须是内建命令)
        • [2.2.4 常见的内建命令](#2.2.4 常见的内建命令)
      • [2.3 命令行解析](#2.3 命令行解析)
      • [2.4 完整的mini-shell实现](#2.4 完整的mini-shell实现)
      • [2.5 代码详解](#2.5 代码详解)
        • [2.5.1 命令提示符的生成](#2.5.1 命令提示符的生成)
        • [2.5.2 命令行解析](#2.5.2 命令行解析)
        • [2.5.3 内建命令cd的实现](#2.5.3 内建命令cd的实现)
        • [2.5.4 内建命令export的实现](#2.5.4 内建命令export的实现)
        • [2.5.5 外部命令的执行](#2.5.5 外部命令的执行)
      • [2.6 编译和运行](#2.6 编译和运行)
      • [2.7 与真实shell的差距](#2.7 与真实shell的差距)
    • 三、总结:进程与函数的类比
      • [3.1 call/return vs fork/exec/wait](#3.1 call/return vs fork/exec/wait)
      • [3.2 进程通信的思想](#3.2 进程通信的思想)
      • [3.3 Unix哲学的体现](#3.3 Unix哲学的体现)
    • 四、总结与展望

进程程序替换与shell实现:从fork到exec的完整闭环

💬 欢迎讨论 :这是Linux系统编程系列的第六篇文章。在前五篇中,我们学习了进程的创建(fork)、状态管理和资源回收(wait/waitpid)。但fork出的子进程只能执行父进程的代码副本,如果我们想让子进程执行一个全新的程序,该怎么办?这就是本篇要深入讲解的进程程序替换技术。更重要的是,我们将把fork、exec、wait三大核心技术结合起来,实现一个真正的命令行解释器!

👍 点赞、收藏与分享:这篇文章包含了大量原理分析和一个完整的shell实现,如果对你有帮助,请点赞、收藏并分享!

🚀 循序渐进:建议先学习前五篇文章,理解fork、进程状态和wait机制,这样学习本篇会更轻松。


一、进程程序替换

1.1 为什么需要程序替换

在学习程序替换之前,我们先思考一个问题:fork创建的子进程有什么局限性?

让我们回顾一下fork的行为:

cpp 复制代码
int main()
{
    printf("父进程开始\n");
    
    pid_t id = fork();
    
    if(id == 0) {
        // 子进程执行的还是父进程的代码
        printf("我是子进程\n");
    }
    else {
        printf("我是父进程\n");
    }
    
    return 0;
}

fork后,子进程获得了父进程的代码副本,它执行的仍然是父进程程序的代码 。虽然我们可以通过if-else让父子执行不同的代码分支,但本质上它们运行的是同一个程序的代码

那么问题来了:如果我想让子进程执行一个完全不同的程序,比如执行ls命令,该怎么办?

这时就需要**程序替换(Program Replacement)**技术。

1.1.1 shell如何执行命令

让我们看一个日常操作:

bash 复制代码
$ ls -l
total 64
-rwxr-xr-x 1 user user 8960 Dec 10 10:30 a.out
-rw-r--r-- 1 user user  256 Dec 10 10:25 test.c

当你在shell中输入ls -l时,发生了什么?

  1. shell(bash)是一个进程,它读取你输入的命令
  2. shell调用fork()创建子进程
  3. 子进程调用exec加载ls程序
  4. 子进程开始执行ls的代码,而不是bash的代码
  5. 父进程(shell)调用wait()等待子进程完成

这就是程序替换的典型应用场景。

1.2 程序替换的原理

程序替换的本质是:将磁盘上的一个程序加载到当前进程的地址空间,替换掉原有的代码和数据。

让我们从内存的角度来理解这个过程:

替换前(子进程刚fork出来):

bash 复制代码
子进程地址空间
┌──────────────┐
│   命令行参数  │
├──────────────┤
│   环境变量   │
├──────────────┤
│    栈       │  ← 父进程代码的栈
│      ↓      │
│            │
│      ↑      │
│    堆       │  ← 父进程的堆数据
├──────────────┤
│  未初始化数据 │  ← 父进程的BSS段
├──────────────┤
│  初始化数据   │  ← 父进程的数据段
├──────────────┤
│   代码段     │  ← 父进程的代码
└──────────────┘

调用exec后:

bash 复制代码
子进程地址空间
┌──────────────┐
│   命令行参数  │  ← 新程序的参数
├──────────────┤
│   环境变量   │  ← 可以继承或重新设置
├──────────────┤
│    栈       │  ← 新程序的栈
│      ↓      │
│            │
│      ↑      │
│    堆       │  ← 新程序的堆
├──────────────┤
│  未初始化数据 │  ← 新程序的BSS段
├──────────────┤
│  初始化数据   │  ← 新程序的数据段
├──────────────┤
│   代码段     │  ← 新程序的代码
└──────────────┘

关键点:

  1. 进程ID不变:还是同一个进程
  2. 代码和数据被完全替换:原来父进程的代码不见了
  3. 文件描述符表继承:打开的文件仍然有效(除非设置了FD_CLOEXEC)
  4. 从新程序的main函数开始执行

1.3 exec函数族详解

Linux提供了6个exec系列函数,它们都用于程序替换,但参数形式不同:

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,并设置errno

这6个函数看起来很复杂,但只要掌握了命名规律就很好记。

1.3.1 命名规律

函数名由exec加上1-2个字母后缀组成,每个字母都有特定含义:

l (list):参数列表

参数以列表形式逐个传递,必须以NULL结尾:

cpp 复制代码
execl("/bin/ls", "ls", "-l", "-a", NULL);
//     程序路径    arg0  arg1  arg2  结束标记

v (vector):参数数组

参数放在一个字符指针数组中:

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

p (path):搜索PATH环境变量

不需要写完整路径,会在PATH中搜索:

cpp 复制代码
// 不用写/bin/ls,只写ls即可
execlp("ls", "ls", "-l", NULL);

e (environment):自定义环境变量

可以传递自定义的环境变量表:

cpp 复制代码
char *envp[] = {"PATH=/bin:/usr/bin", "HOME=/home/user", NULL};
execle("/bin/ls", "ls", "-l", NULL, envp);

注意:这里子进程会完全替换父进程的环境变量,只会使用你传入的envp里面的环境变量

记忆技巧:

bash 复制代码
exec + l/v + p + e
      ↓     ↓   ↓
    参数形式 路径 环境
1.3.2 六个函数的对比
函数 路径 参数形式 环境变量
execl 完整路径 列表 继承
execlp 搜索PATH 列表 继承
execle 完整路径 列表 自定义
execv 完整路径 数组 继承
execvp 搜索PATH 数组 继承
execve 完整路径 数组 自定义

注意 :只有execve是真正的系统调用,其他5个都是库函数,最终都会调用execve

1.4 exec函数使用示例

让我们通过实际例子来学习如何使用这些函数。

1.4.1 基本使用:execl
cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("程序开始,PID=%d\n", getpid());
    printf("即将执行ls命令\n");
    
    // 替换当前进程为ls程序
    execl("/bin/ls", "ls", "-l", "-h", NULL);
    
    // 如果exec成功,下面的代码不会执行
    printf("如果你看到这句话,说明exec失败了\n");
    perror("execl");
    
    return 1;
}

运行结果:

bash 复制代码
$ gcc test.c -o test
$ ./test
程序开始,PID=15000
即将执行ls命令
total 64K
-rwxr-xr-x 1 user user 8.8K Dec 10 10:30 test
-rw-r--r-- 1 user user  256 Dec 10 10:25 test.c

关键点:

  1. printf("如果你看到...")没有执行,因为进程已经被替换
  2. PID没变,还是15000
  3. 执行的是ls的代码,不是test的代码了
1.4.2 使用execlp简化路径
cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("使用execlp执行命令\n");
    
    // 不需要写/bin/ls,系统会在PATH中查找
    execlp("ls", "ls", "-l", NULL);
    
    perror("execlp");
    return 1;
}
1.4.3 使用execv传递参数数组
cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    printf("使用execv执行命令\n");
    
    // 参数放在数组中
    char *argv[] = {"ls", "-l", "-a", "-h", NULL};
    
    execv("/bin/ls", argv);
    
    perror("execv");
    return 1;
}
1.4.4 fork + exec:让子进程执行新程序

这是最常见的用法:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main()
{
    printf("父进程[%d]开始\n", getpid());
    
    pid_t id = fork();
    
    if(id < 0) {
        perror("fork");
        return 1;
    }
    else if(id == 0) {
        // 子进程:执行ls命令
        printf("子进程[%d]即将执行ls\n", getpid());
        execlp("ls", "ls", "-l", NULL);
        
        // 如果exec失败才会执行到这里
        perror("execlp");
        exit(1);
    }
    else {
        // 父进程:等待子进程
        printf("父进程[%d]等待子进程[%d]\n", getpid(), id);
        
        int status = 0;
        waitpid(id, &status, 0);
        
        if(WIFEXITED(status)) {
            printf("子进程退出,退出码=%d\n", WEXITSTATUS(status));
        }
        
        printf("父进程继续运行\n");
    }
    
    return 0;
}

运行结果:

bash 复制代码
父进程[15100]开始
父进程[15100]等待子进程[15101]
子进程[15101]即将执行ls
total 64
-rwxr-xr-x 1 user user 9216 Dec 10 11:00 test
-rw-r--r-- 1 user user  512 Dec 10 11:00 test.c
子进程退出,退出码=0
父进程继续运行

这就是shell执行命令的基本模型:fork + exec + wait

1.4.5 使用execle传递自定义环境变量
cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    printf("演示execle传递环境变量\n");
    
    // 自定义环境变量
    char *envp[] = {
        "PATH=/bin:/usr/bin",
        "MY_VAR=hello",
        "USER=testuser",
        NULL
    };
    
    // 创建一个简单的测试程序来接收环境变量
    pid_t id = fork();
    if(id == 0) {
        execle("./printenv", "printenv", NULL, envp);
        perror("execle");
        exit(1);
    }
    else {
        wait(NULL);
    }
    
    return 0;
}

创建接收程序printenv.c

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

int main()
{
    printf("MY_VAR = %s\n", getenv("MY_VAR"));
    printf("USER = %s\n", getenv("USER"));
    printf("PATH = %s\n", getenv("PATH"));
    return 0;
}

编译运行:

bash 复制代码
$ gcc printenv.c -o printenv
$ gcc test.c -o test
$ ./test
演示execle传递环境变量
MY_VAR = hello
USER = testuser
PATH = /bin:/usr/bin

1.5 exec调用关系图

让我们通过一张图来理解这6个函数之间的关系:

bash 复制代码
用户调用
   ↓
┌──────┬──────┬──────┬──────┬───────┐
│execl │execlp│execle│execv │execvp │
└──┬───┴──┬───┴──┬───┴──┬───┴───┬───┘
   │      │      │      │       │
   └──────┴──────┴──────┴───────┘
              ↓
         参数处理/路径搜索
              ↓
          ┌────────┐
          │ execve │  ← 唯一的系统调用
          └────┬───┘
               ↓
          内核加载程序

核心要点:

  • execve是唯一的系统调用
  • 其他5个都是库函数,最终调用execve
  • 库函数做的工作:参数格式转换、PATH搜索、环境变量处理

二、实现mini-shell

现在我们已经掌握了fork、exec、wait三大技术,是时候把它们组合起来,实现一个真正的命令行解释器了!

2.1 shell的工作原理

shell的核心工作流程非常简单:

bash 复制代码
while(true) {
    1. 显示命令提示符
    2. 读取用户输入的命令
    3. 解析命令(分割成程序名和参数)
    4. fork创建子进程
    5. 子进程exec执行命令
    6. 父进程wait等待子进程
}

让我们用伪代码表示:

cpp 复制代码
while(1) {
    // 1. 显示提示符
    printf("[user@host dir]$ ");
    
    // 2. 读取命令
    fgets(command, sizeof(command), stdin);
    
    // 3. 解析命令
    parse(command, argv);
    
    // 4. 创建子进程
    pid_t id = fork();
    if(id == 0) {
        // 5. 子进程执行命令
        execvp(argv[0], argv);
        exit(1);
    }
    else {
        // 6. 父进程等待
        waitpid(id, &status, 0);
    }
}

但实际实现要考虑更多细节,比如:

  • 如何显示美观的命令提示符?
  • 如何处理内建命令(cd、export等)?
  • 如何维护环境变量?

2.2 内建命令 vs 外部命令

在实现shell之前,我们需要理解一个重要概念:内建命令(Built-in Command)

2.2.1 什么是内建命令

Linux命令分为两类:

外部命令:

  • 是独立的可执行文件
  • 如:ls对应/bin/lsps对应/bin/ps
  • shell通过fork+exec执行

内建命令:

  • 是shell程序内部的函数
  • 如:cdexportexit
  • shell直接调用自己的函数执行
2.2.2 为什么cd必须是内建命令

让我们思考一个问题:为什么cd不能做成外部命令?

假设cd是一个外部程序/bin/cd

cpp 复制代码
// shell执行cd命令的流程
pid_t id = fork();  // 创建子进程
if(id == 0) {
    // 子进程
    execl("/bin/cd", "cd", "/home/user", NULL);
    // cd程序调用chdir()改变工作目录
    // 但这只改变了子进程的工作目录!
}
waitpid(id, NULL, 0);
// 父进程(shell)的工作目录没有改变

问题在于:

  • 子进程调用chdir()只改变自己的工作目录
  • 父进程(shell)的工作目录不受影响
  • 子进程退出后,shell还在原来的目录

因此,cd必须由shell自己执行:

cpp 复制代码
// shell内部直接调用
if(strcmp(argv[0], "cd") == 0) {
    chdir(argv[1]);  // shell进程自己改变目录
}
2.2.3 为什么export必须是内建命令

同样的道理:

cpp 复制代码
// 如果export是外部命令
pid_t id = fork();
if(id == 0) {
    // 子进程设置环境变量
    setenv("MY_VAR", "value", 1);
    // 只影响子进程的环境变量表
}
// 父进程(shell)的环境变量表没有改变

环境变量属于进程的私有数据,子进程无法修改父进程的环境变量表。

因此,export也必须由shell自己执行:

cpp 复制代码
if(strcmp(argv[0], "export") == 0) {
    // shell自己添加环境变量
    putenv(argv[1]);
}
2.2.4 常见的内建命令
命令 原因
cd 必须改变shell自己的工作目录
export 必须修改shell自己的环境变量
exit 必须终止shell自己
alias 修改shell的命令别名表
source 在shell进程中执行脚本
jobs 查看shell的作业控制表

2.3 命令行解析

shell需要将用户输入的字符串解析成程序名和参数数组。

输入:

bash 复制代码
"ls -l -a /home/user"

输出:

cpp 复制代码
argv[0] = "ls"
argv[1] = "-l"
argv[2] = "-a"
argv[3] = "/home/user"
argv[4] = NULL

使用strtok()函数可以轻松实现:

cpp 复制代码
void ParseCommandLine(char *cmdline, char **argv)
{
    int argc = 0;
    const char *sep = " \t\n";  // 分隔符
    
    argv[argc++] = strtok(cmdline, sep);
    
    while((argv[argc++] = strtok(NULL, sep)) != NULL);
    
    argc--;  // 最后一个NULL不计入
}

2.4 完整的mini-shell实现

现在让我们来实现一个功能完整的mini-shell!

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 CMD_SIZE = 1024;
const int ARGC_MAX = 64;
const int ENV_MAX = 64;

// 全局变量
char *g_argv[ARGC_MAX];      // 命令参数数组
int g_argc = 0;               // 参数个数
char *g_env[ENV_MAX];         // 环境变量表
int g_last_code = 0;          // 上一个命令的退出码
char g_pwd[CMD_SIZE];         // 当前工作目录
char g_pwd_env[CMD_SIZE];     // PWD环境变量

// 获取用户名
string GetUserName()
{
    const char *name = getenv("USER");
    return name ? name : "unknown";
}

// 获取主机名
string GetHostName()
{
    const char *hostname = getenv("HOSTNAME");
    return hostname ? hostname : "localhost";
}

// 获取当前工作目录
string GetPwd()
{
    if(getcwd(g_pwd, sizeof(g_pwd)) != NULL) {
        // 更新PWD环境变量
        snprintf(g_pwd_env, sizeof(g_pwd_env), "PWD=%s", g_pwd);
        putenv(g_pwd_env);
        return g_pwd;
    }
    return "/";
}

// 获取当前目录的最后一级
string LastDir()
{
    string pwd = GetPwd();
    if(pwd == "/") return "/";
    
    size_t pos = pwd.rfind('/');
    if(pos == string::npos) return pwd;
    
    return pwd.substr(pos + 1);
}

// 生成命令提示符
string MakePrompt()
{
    char prompt[CMD_SIZE];
    snprintf(prompt, sizeof(prompt), "[%s@%s %s]$ ",
             GetUserName().c_str(),
             GetHostName().c_str(),
             LastDir().c_str());
    return prompt;
}

// 显示命令提示符
void PrintPrompt()
{
    printf("%s", MakePrompt().c_str());
    fflush(stdout);
}

// 读取命令行
bool GetCommandLine(char *cmdline, int size)
{
    char *ret = fgets(cmdline, size, stdin);
    if(ret == NULL) {
        return false;
    }
    
    // 去掉换行符
    cmdline[strlen(cmdline) - 1] = '\0';
    
    // 空命令
    if(strlen(cmdline) == 0) {
        return false;
    }
    
    return true;
}

// 解析命令行
void ParseCommandLine(char *cmdline)
{
    memset(g_argv, 0, sizeof(g_argv));
    g_argc = 0;
    
    const char *sep = " \t";
    g_argv[g_argc++] = strtok(cmdline, sep);
    
    while((g_argv[g_argc++] = strtok(NULL, sep)) != NULL);
    
    g_argc--;
}

// 添加环境变量
void AddEnv(const char *item)
{
    int i = 0;
    while(g_env[i] != NULL) i++;
    
    g_env[i] = (char*)malloc(strlen(item) + 1);
    strcpy(g_env[i], item);
    g_env[++i] = NULL;
}

// 内建命令:cd
bool BuiltinCd()
{
    if(strcmp(g_argv[0], "cd") != 0) {
        return false;
    }
    
    if(g_argc == 1) {
        // cd without argument, go to home
        const char *home = getenv("HOME");
        if(home) chdir(home);
    }
    else if(g_argc == 2) {
        if(chdir(g_argv[1]) != 0) {
            perror("cd");
            g_last_code = 1;
        }
        else {
            g_last_code = 0;
        }
    }
    else {
        printf("cd: too many arguments\n");
        g_last_code = 1;
    }
    
    return true;
}

// 内建命令:export
bool BuiltinExport()
{
    if(strcmp(g_argv[0], "export") != 0) {
        return false;
    }
    
    if(g_argc == 2) {
        AddEnv(g_argv[1]);
        g_last_code = 0;
    }
    else {
        printf("Usage: export VAR=VALUE\n");
        g_last_code = 1;
    }
    
    return true;
}

// 内建命令:echo
bool BuiltinEcho()
{
    if(strcmp(g_argv[0], "echo") != 0) {
        return false;
    }
    
    if(g_argc == 2) {
        if(strcmp(g_argv[1], "$?") == 0) {
            printf("%d\n", g_last_code);
        }
        else if(g_argv[1][0] == '$') {
            // echo $VAR
            const char *val = getenv(g_argv[1] + 1);
            if(val) {
                printf("%s\n", val);
            }
        }
        else {
            printf("%s\n", g_argv[1]);
        }
        g_last_code = 0;
    }
    else {
        printf("Usage: echo STRING or echo $VAR\n");
        g_last_code = 1;
    }
    
    return true;
}

// 内建命令:env
bool BuiltinEnv()
{
    if(strcmp(g_argv[0], "env") != 0) {
        return false;
    }
    
    for(int i = 0; g_env[i] != NULL; i++) {
        printf("%s\n", g_env[i]);
    }
    
    g_last_code = 0;
    return true;
}

// 检查并执行内建命令
bool CheckAndExecBuiltin()
{
    return BuiltinCd() || 
           BuiltinExport() ||
           BuiltinEcho() ||
           BuiltinEnv();
}

// 执行外部命令
bool ExecuteCommand()
{
    pid_t id = fork();
    
    if(id < 0) {
        perror("fork");
        return false;
    }
    else if(id == 0) {
        // 子进程:执行命令
        execvpe(g_argv[0], g_argv, g_env);
        
        // 如果execvpe返回,说明执行失败
        perror(g_argv[0]);
        exit(127);  // 命令未找到
    }
    else {
        // 父进程:等待子进程
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        
        if(ret > 0) {
            if(WIFEXITED(status)) {
                g_last_code = WEXITSTATUS(status);
            }
            else {
                g_last_code = 128 + WTERMSIG(status);
            }
            return true;
        }
    }
    
    return false;
}

// 初始化环境变量(从父shell继承)
void InitEnv()
{
    extern char **environ;
    
    int i = 0;
    while(environ[i] != NULL) {
        g_env[i] = (char*)malloc(strlen(environ[i]) + 1);
        strcpy(g_env[i], environ[i]);
        i++;
    }
    g_env[i] = NULL;
}

// 主函数
int main()
{
    // 初始化环境变量
    InitEnv();
    
    char cmdline[CMD_SIZE];
    
    while(true) {
        // 1. 显示命令提示符
        PrintPrompt();
        
        // 2. 读取命令行
        if(!GetCommandLine(cmdline, CMD_SIZE)) {
            continue;
        }
        
        // 3. 解析命令
        ParseCommandLine(cmdline);
        
        // 4. 检查是否是内建命令
        if(CheckAndExecBuiltin()) {
            continue;
        }
        
        // 5. 执行外部命令
        ExecuteCommand();
    }
    
    return 0;
}
        

2.5 代码详解

让我们逐个模块分析这个shell的实现。

2.5.1 命令提示符的生成
cpp 复制代码
string MakePrompt()
{
    char prompt[CMD_SIZE];
    snprintf(prompt, sizeof(prompt), "[%s@%s %s]$ ",
             GetUserName().c_str(),
             GetHostName().c_str(),
             LastDir().c_str());
    return prompt;
}

生成类似bash的提示符:[user@hostname dir]$

关键技术:

  • GetUserName():从环境变量USER获取用户名
  • GetHostName():从环境变量HOSTNAME获取主机名
  • LastDir():提取当前路径的最后一级目录名
2.5.2 命令行解析
cpp 复制代码
void ParseCommandLine(char *cmdline)
{
    memset(g_argv, 0, sizeof(g_argv));
    g_argc = 0;
    
    const char *sep = " \t";
    g_argv[g_argc++] = strtok(cmdline, sep);
    
    while((g_argv[g_argc++] = strtok(NULL, sep)) != NULL);
    
    g_argc--;
}

工作流程:

  1. 使用strtok()按空格和制表符分割字符串
  2. 将分割结果存入g_argv数组
  3. 最后一个元素设为NULL(exec要求)

示例:

bash 复制代码
输入:"ls -l -a"
输出:g_argv = {"ls", "-l", "-a", NULL}
2.5.3 内建命令cd的实现
cpp 复制代码
bool BuiltinCd()
{
    if(strcmp(g_argv[0], "cd") != 0) {
        return false;  // 不是cd命令
    }
    
    if(g_argc == 1) {
        // cd without argument, go to home
        const char *home = getenv("HOME");
        if(home) chdir(home);
    }
    else if(g_argc == 2) {
        if(chdir(g_argv[1]) != 0) {
            perror("cd");
            g_last_code = 1;
        }
        else {
            g_last_code = 0;
        }
    }
    else {
        printf("cd: too many arguments\n");
        g_last_code = 1;
    }
    
    return true;
}

实现要点:

  • shell进程自己调用chdir()改变工作目录
  • 支持cd(回到HOME)和cd 目录两种用法
  • 更新退出码g_last_code
2.5.4 内建命令export的实现
cpp 复制代码
bool BuiltinExport()
{
    if(strcmp(g_argv[0], "export") != 0) {
        return false;
    }
    
    if(g_argc == 2) {
        AddEnv(g_argv[1]);  // 添加到环境变量表
        g_last_code = 0;
    }
    else {
        printf("Usage: export VAR=VALUE\n");
        g_last_code = 1;
    }
    
    return true;
}

void AddEnv(const char *item)
{
    int i = 0;
    while(g_env[i] != NULL) i++;
    
    g_env[i] = (char*)malloc(strlen(item) + 1);
    strcpy(g_env[i], item);
    g_env[++i] = NULL;
}

实现要点:

  • 将新环境变量添加到g_env数组
  • 子进程通过execvpe()的第三个参数获得这些环境变量
2.5.5 外部命令的执行
cpp 复制代码
bool ExecuteCommand()
{
    pid_t id = fork();
    
    if(id < 0) {
        perror("fork");
        return false;
    }
    else if(id == 0) {
        // 子进程:执行命令
        execvpe(g_argv[0], g_argv, g_env);
        
        // 如果execvpe返回,说明执行失败
        perror(g_argv[0]);
        exit(127);  // 命令未找到
    }
    else {
        // 父进程:等待子进程
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        
        if(ret > 0) {
            if(WIFEXITED(status)) {
                g_last_code = WEXITSTATUS(status);
            }
            else {
                g_last_code = 128 + WTERMSIG(status);
            }
            return true;
        }
    }
    
    return false;
}

这是fork + exec + wait的完美结合:

  1. fork():创建子进程

  2. execvpe():子进程加载新程序

    • 自动搜索PATH
    • 传递命令参数
    • 传递环境变量
  3. waitpid():父进程等待子进程,获取退出码

2.6 编译和运行

编译mini-shell:

bash 复制代码
g++ -o myshell myshell.cpp -std=c++11

运行:

bash 复制代码
./myshell

测试示例:

bash 复制代码
[user@localhost test]$ ls -l
total 16
-rwxr-xr-x 1 user user 13824 Dec 10 15:30 myshell
-rw-r--r-- 1 user user  4096 Dec 10 15:25 myshell.cpp

[user@localhost test]$ pwd
/home/user/test

[user@localhost test]$ cd /tmp

[user@localhost tmp]$ pwd
/tmp

[user@localhost tmp]$ export MY_VAR=hello

[user@localhost tmp]$ echo $MY_VAR
hello

[user@localhost tmp]$ echo $?
0

[user@localhost tmp]$ ls /nonexist
ls: cannot access '/nonexist': No such file or directory

[user@localhost tmp]$ echo $?
2

[user@localhost tmp]$ ps
  PID TTY          TIME CMD
15500 pts/0    00:00:00 bash
15600 pts/0    00:00:00 myshell
15601 pts/0    00:00:00 ps

可以看到,我们的mini-shell已经能够:

  • ✅ 显示漂亮的命令提示符
  • ✅ 执行外部命令(ls、pwd、ps等)
  • ✅ 实现内建命令(cd、export、echo)
  • ✅ 维护环境变量
  • ✅ 记录命令退出码

2.7 与真实shell的差距

我们的mini-shell虽然功能完备,但与真实的bash相比还有很多不足:

缺少的功能:

  1. 重定向ls > file.txtcat < input.txt
  2. 管道ps aux | grep myshell
  3. 后台执行sleep 100 &
  4. 信号处理:Ctrl+C不应该终止shell
  5. 命令历史:上下箭头翻历史命令
  6. Tab补全:按Tab自动补全命令
  7. 通配符ls *.txt
  8. 条件执行ls && pwdls || echo failed
  9. 脚本执行source script.sh

这些功能的实现会涉及到更多的系统编程知识,如:

  • 文件描述符重定向(dup2)
  • 管道(pipe)
  • 信号处理(signal)
  • 终端控制(termios)

这些知识我们都会在后续文章中逐渐讲解


三、总结:进程与函数的类比

通过本篇文章的学习,我们完成了从fork到exec再到shell的完整闭环。现在让我们站在更高的层次来理解这些技术。

3.1 call/return vs fork/exec/wait

我们在编程时经常使用函数:

cpp 复制代码
int add(int a, int b) {
    return a + b;
}

int main() {
    int result = add(3, 5);  // 调用函数
    printf("result = %d\n", result);
    return 0;
}

函数调用的特点:

  1. call:调用函数,传递参数
  2. 执行:函数执行自己的代码
  3. return:返回结果给调用者

这个模式与进程的使用非常相似:

cpp 复制代码
int main() {
    pid_t id = fork();         // 创建进程
    if(id == 0) {
        execl("/bin/ls", "ls", "-l", NULL);  // 执行程序,传递参数
        exit(1);               // 返回退出码
    }
    else {
        int status;
        waitpid(id, &status, 0);  // 等待结果
        int code = WEXITSTATUS(status);
        printf("exit code = %d\n", code);
    }
    return 0;
}

进程使用的特点:

  1. fork + exec:创建进程,加载程序,传递参数
  2. 执行:子进程执行自己的代码
  3. exit + wait:子进程返回退出码,父进程获取结果

3.2 进程通信的思想

函数之间通过参数和返回值通信:

bash 复制代码
函数A → 调用函数B(参数) → 函数B → 返回结果 → 函数A

进程之间也通过类似的方式通信:

bash 复制代码
进程A → fork+exec(参数) → 进程B → exit(退出码) → wait(获取结果) → 进程A

这种模式的优势:

  1. 模块化:每个程序专注于一个任务
  2. 复用性:程序可以被多个父进程调用
  3. 隔离性:子进程崩溃不影响父进程
  4. 并发性:多个子进程可以并发执行

3.3 Unix哲学的体现

我们实现的mini-shell体现了Unix的设计哲学:

"Write programs that do one thing and do it well. Write programs to work together."

"编写只做一件事并做好的程序。编写能协同工作的程序。"

具体体现:

  • ls专注于列出文件
  • grep专注于搜索文本
  • sort专注于排序
  • shell负责组合它们:ls | grep test | sort

这种设计让系统变得:

  • 灵活:可以任意组合命令
  • 强大:简单命令组合出复杂功能
  • 可维护:每个程序职责单一,易于理解和修改

四、总结与展望

通过本篇文章,我们系统地学习了进程程序替换和shell的实现原理:

进程程序替换:

  • 理解了exec的作用:将磁盘程序加载到进程地址空间
  • 掌握了exec函数族的使用和命名规律
  • 理解了fork + exec + wait的完整流程

mini-shell实现:

  • 掌握了shell的基本工作原理
  • 理解了内建命令与外部命令的区别
  • 实现了一个功能完整的命令行解释器
  • 理解了为什么cd、export必须是内建命令

核心知识点:

  1. exec替换当前进程,不创建新进程
  2. execve是唯一的系统调用,其他都是库函数
  3. 内建命令修改shell自身状态,外部命令在子进程执行
  4. fork + exec + wait是进程协作的经典模式

至此,我们已经完整学习了Linux进程控制的核心技术。从第一篇的进程概念,到第二篇的进程状态,再到第三篇的调度算法,第四篇的虚拟内存,第五篇的进程等待,以及本篇的程序替换------我们构建了一个完整的进程管理知识体系。

在后续的文章中,我们将学习更高级的主题:

  • 进程间通信(IPC):管道、共享内存、消息队列
  • 信号机制:进程如何响应异步事件
  • 守护进程:后台服务的实现原理
  • 线程编程:多线程与多进程的选择

💡 思考题

  1. 为什么exec函数成功时不返回,只有失败时才返回?
  2. 如果在exec之前打开了一个文件,exec之后文件描述符还有效吗?
  3. 如何在mini-shell中实现管道功能(ls | grep test)?
  4. 如果shell执行一个很慢的命令,如何让shell在等待期间响应Ctrl+C?

以上就是关于进程程序替换与shell实现的全部内容!至此,我们已经掌握了进程控制的完整技术栈,可以开始更高级的系统编程之旅了!

相关推荐
YXWik65 小时前
Linux安装Whisper(C++版)音频解析文本
linux·c++·whisper
橘颂TA5 小时前
【Linux】不允许你还不会——信号保存(3)
linux·服务器·网络·数据库
SETH·XU5 小时前
简记:关于net-snmp中engineid冲突全局etimelist的赋值情况
c语言·网络·net-snmp·snmpv3·engineid冲突
BAOYUCompany5 小时前
暴雨服务器成功中标湖南石油化工职业技术学院
运维·服务器
Neolnfra5 小时前
系统敏感安全文件路径
linux·windows·安全·web安全·网络安全·adb·系统安全
麒qiqi5 小时前
Linux 线程(POSIX)核心教程
linux·算法
再遇当年5 小时前
因为研究平台arm,RK3588交叉编译误把我笔记本X86平台的/x86_64-linux-gnu文件删除,导致联想拯救者笔记本中的ubuntu系统损坏
linux·arm开发·ros·gnu·交叉编译·x86
sdszoe49225 小时前
思科DHCP+OSPF综合实验
运维·服务器·网络·ospf·思科dhcp
2501_927283585 小时前
全程自动化:智慧工厂的物流协奏新篇章
运维·人工智能·自动化·制造·agv