从零实现一个迷你Shell——深入理解Linux命令行解释器

目录

[1. 引言](#1. 引言)

[2. Shell 的工作流程(主循环)](#2. Shell 的工作流程(主循环))

[3. 全局数据结构设计](#3. 全局数据结构设计)

[4. 命令行提示符的生成](#4. 命令行提示符的生成)

[4.1 获取用户名、主机名](#4.1 获取用户名、主机名)

[4.2 获取当前工作目录(并更新环境变量 PWD)](#4.2 获取当前工作目录(并更新环境变量 PWD))

[4.3 提取最后一级目录名](#4.3 提取最后一级目录名)

[4.4 组装提示符](#4.4 组装提示符)

[5. 命令读取与解析](#5. 命令读取与解析)

[5.1 读取一行输入](#5.1 读取一行输入)

[5.2 解析命令行(拆分为参数数组)](#5.2 解析命令行(拆分为参数数组))

[6. 内建命令详解](#6. 内建命令详解)

[6.1 cd 命令(支持 cd、cd ~、cd -、cd 路径)](#6.1 cd 命令(支持 cd、cd ~、cd -、cd 路径))

[6.2 echo 命令(支持普通字符串、?、环境变量)](#6.2 echo 命令(支持普通字符串、?、环境变量))

[6.3 其他内建命令(扩展点)](#6.3 其他内建命令(扩展点))

[6.4 内建命令的调度](#6.4 内建命令的调度)

[7. 外部命令的执行(fork + execvp + waitpid)](#7. 外部命令的执行(fork + execvp + waitpid))

[8. 环境变量的初始化](#8. 环境变量的初始化)

[9. 完整运行示例](#9. 完整运行示例)

[10. 总结与扩展思考](#10. 总结与扩展思考)

[10.1 已实现的核心功能](#10.1 已实现的核心功能)

[10.2 可进一步扩展的功能](#10.2 可进一步扩展的功能)

[10.3 知识巩固](#10.3 知识巩固)


1. 引言

Shell 是用户与Linux内核之间的桥梁。它不断读取用户输入的命令,解析后执行------普通命令通过创建子进程运行,内建命令则由Shell自身处理。理解Shell的工作原理,是掌握进程创建、进程等待、程序替换、环境变量管理等核心概念的最佳实践。

本文将基于您提供的完整代码,逐行剖析一个功能完备的迷你Shell,包括:

  • 命令行提示符的自定义(支持 [user@hostname current_dir]# 格式)

  • 命令的读取、解析、参数拆分

  • 内建命令的实现:cd(含 cd -cd ~)、echo $?echo $变量

  • 外部命令的执行(fork + execvp + waitpid

  • 退出码 $? 的维护

  • 环境变量的初始化与继承

  • 程序整体结构与执行流程

该Shell虽然简洁,但已具备最基本的交互能力,可以作为理解bash等复杂Shell的起点。


2. Shell 的工作流程(主循环)

一个Shell的核心是无限循环,每一步处理一个命令行:

c

复制代码
int main()
{
    InitEnv();                     // 初始化环境变量
    while (1) {
        PrintCommandPrompt();      // 1. 显示提示符
        GetCommandLine();          // 2. 获取用户输入
        CommandParse();            // 3. 解析命令(拆分为argc/argv)
        if (CheckAndExecBuiltin()) // 4. 内建命令处理
            continue;
        Execute();                 // 5. 外部命令执行(fork + exec)
    }
    return 0;
}

以下将逐一深入每个模块。


3. 全局数据结构设计

c

复制代码
#define COMMAND_SIZE 1024
#define MAXARGC 128
char* g_argv[MAXARGC];
int g_argc = 0;

#define MAX_ENVS 100
char* g_env[MAX_ENVS];
int g_envs = 0;

int lastcode = 0;                 // 保存上一条命令的退出码(对应 $?)
char LAST_PATH[PATH_MAX];        // 用于 cd - 保存上一个路径
  • g_argv / g_argc:解析命令行后得到的参数数组和参数个数,供 execvp 使用。

  • g_env:Shell自身维护的环境变量表(从父Shell继承,可扩展)。

  • lastcode:每次执行外部命令后,通过 WEXITSTATUS 获取子进程退出码,供 echo $? 使用。

  • LAST_PATH:实现 cd - 时需要记住切换前的路径。


4. 命令行提示符的生成

我们希望提示符格式为 [user@hostname current_dir]#,其中 current_dir 只显示路径的最后一个目录名(如 /home/user/project 显示为 project)。

4.1 获取用户名、主机名

c

复制代码
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;
}

4.2 获取当前工作目录(并更新环境变量 PWD)

c

复制代码
const char* GetPwd() {
    const char* pwd = getcwd(cwd, sizeof(cwd));   // 系统调用,获取绝对路径
    if (pwd != NULL) {
        snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
        putenv(cwdenv);                            // 更新环境变量 PWD
    }
    return pwd == NULL ? "None" : pwd;
}

注意:chdir 后必须同步更新 PWD,否则子进程会得到错误的当前目录。这里通过 putenv 直接修改环境变量。

4.3 提取最后一级目录名

c

复制代码
std::string DirName(const char* pwd) {
    std::string dir = pwd;
    if (dir == "/") return "/";
    auto pos = dir.rfind('/');
    if (pos == std::string::npos) return "BUG?";
    return dir.substr(pos + 1);
}

4.4 组装提示符

c

复制代码
void MakeCommandLine(char cmd_prompt[], int size) {
    snprintf(cmd_prompt, size, "[%s@%s %s]# ",
             GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
}

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

5. 命令读取与解析

5.1 读取一行输入

c

复制代码
bool GetCommandLine(char* commandline, int size) {
    char* c = fgets(commandline, size, stdin);
    if (c == NULL) return false;          // EOF 或错误
    commandline[strlen(commandline) - 1] = 0; // 去掉末尾换行符
    if (strlen(commandline) == 0) return false;
    return true;
}

5.2 解析命令行(拆分为参数数组)

使用 strtok 按空格分隔,填充 g_argv,并维护 g_argc

c

复制代码
bool CommandParse(char* commandline) {
    g_argc = 0;
    g_argv[g_argc++] = strtok(commandline, " ");
    while ((bool)(g_argv[g_argc++] = strtok(nullptr, " ")));
    g_argc--;   // 最后一个 NULL 不计入 argc
    return g_argc > 0;
}

strtok 会修改原字符串,因此不能直接传入只读字符串。这里每次读取到 commandline 数组后即可安全拆分。


6. 内建命令详解

内建命令必须由Shell自身执行,不能通过子进程(否则无法改变Shell自身状态,如 cd 无效)。

6.1 cd 命令(支持 cdcd ~cd -cd 路径

c

复制代码
bool Cd() {
    if (g_argc == 1) {    // 无参数 -> 回到 HOME
        std::string home = GetHome();
        if (home.empty()) return true;
        strcpy(LAST_PATH, GetPwd());
        chdir(home.c_str());
    } else {
        std::string where = g_argv[1];
        if (where == "-") {
            char NOW_PATH[PATH_MAX];
            strcpy(NOW_PATH, GetPwd());
            chdir(LAST_PATH);
            strcpy(LAST_PATH, NOW_PATH);
        } else if (where == "~") {
            std::string home = GetHome();
            if (home.empty()) return true;
            strcpy(LAST_PATH, GetPwd());
            chdir(home.c_str());
        } else {
            char NOW_PATH[PATH_MAX];
            strcpy(NOW_PATH, GetPwd());
            if (chdir(where.c_str()) == 0) {
                strcpy(LAST_PATH, NOW_PATH);
            }
        }
    }
    return true;
}

关键点:

  • 切换目录使用 chdir 系统调用,它影响的是Shell自身(父进程)的当前目录。

  • cd - 需要在切换前保存当前路径到 LAST_PATH,然后切换到旧的 LAST_PATH,再更新 LAST_PATH 为之前的当前路径。

  • cd ~cd 无参数时回到 HOME 目录。

  • 每次成功 chdir 后,下次调用 GetPwd 会通过 getcwd 重新获取并更新 PWD 环境变量,因此 cd 后提示符中的目录会自动变化。

6.2 echo 命令(支持普通字符串、$?$环境变量

c

复制代码
void Echo() {
    if (g_argc == 2) {
        std::string opt = g_argv[1];
        if (opt == "$?") {
            std::cout << lastcode << std::endl;
            lastcode = 0;
        } else if (opt[0] == '$') {
            std::string env_name = opt.substr(1);
            const char* env_value = getenv(env_name.c_str());
            if (env_value) std::cout << env_value << std::endl;
        } else {
            std::cout << opt << std::endl;
        }
    }
}

解释:

  • $? 输出上一条命令的退出码,然后清空 lastcode(实际bash中不会清空,但这里简化)。

  • $PATH 之类:提取变量名,用 getenv 获取环境变量的值并输出。

  • 普通字符串直接输出。

6.3 其他内建命令(扩展点)

代码中预留了 exportalias 的框架,但未完整实现。一个完整的Shell还应支持:

  • export VAR=value:向 g_env 添加变量,并调用 putenv 使其生效。

  • alias:命令别名。

6.4 内建命令的调度

c

复制代码
bool CheckAndExecBuiltin() {
    std::string cmd = g_argv[0];
    if (cmd == "cd")    { Cd(); return true; }
    if (cmd == "echo")  { Echo(); return true; }
    // if (cmd == "export") { ... return true; }
    return false;
}

如果匹配到内建命令,返回 true,主循环中 continue 跳过后续的 Execute;否则进入外部命令执行。


7. 外部命令的执行(fork + execvp + waitpid)

这是普通命令(如 lspsgrep)的执行方式:

c

复制代码
int Execute() {
    pid_t id = fork();
    if (id == 0) {
        // 子进程
        execvp(g_argv[0], g_argv);
        exit(1);   // exec 失败才会走到这里
    }
    // 父进程阻塞等待
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0) {
        lastcode = WEXITSTATUS(status);   // 保存退出码
    }
    return 0;
}

注意:

  • execvp 会根据 PATH 环境变量自动搜索可执行文件,无需写全路径。

  • 子进程 exec 成功后不会返回,失败则调用 exit(1),父进程通过 waitpid 获取其退出码,存储到 lastcode

  • 父进程必须等待,否则子进程会变成僵尸进程。


8. 环境变量的初始化

Shell启动时需要继承父Shell(如bash)的环境变量,以便后续子进程能够获得正确的环境。

c

复制代码
void InitEnv() {
    extern char** environ;   // 全局环境变量数组,由启动时设置
    memset(g_env, 0, sizeof(g_env));
    g_envs = 0;

    // 1. 复制父Shell的环境变量
    for (int i = 0; environ[i]; i++) {
        g_env[i] = (char*)malloc(strlen(environ[i]) + 1);
        strcpy(g_env[i], environ[i]);
        g_envs++;
    }
    // 2. 添加一个测试变量(可选)
    g_env[g_envs++] = (char*)"HAHA=for_test";
    g_env[g_envs] = NULL;

    // 3. 将 g_env 中的变量全部导入到当前进程的环境
    for (int i = 0; g_env[i]; i++) {
        putenv(g_env[i]);
    }
    // 4. 可选:修改全局 environ 指向,但 putenv 已经生效
    // environ = g_env;
}

关键点:

  • extern char **environ 是程序启动时从父进程继承的完整环境变量数组。

  • 先复制到 g_env,再逐个 putenv,确保子进程 exec 后能获得这些环境变量。

  • 如果后续实现 export 内建命令,则需要修改 g_env 并再次调用 putenv 或直接操作 environ


9. 完整运行示例

编译运行该Shell后,交互效果如下(假设可执行文件名为 myshell):

bash

复制代码
$ ./myshell
[user@host project]# ls -l
total 8
-rwxr-xr-x 1 user user 12345 Dec 1 10:00 myshell
[user@host project]# cd ..
[user@host home]# pwd
/home/user
[user@host home]# cd -
[user@host project]# pwd
/home/user/project
[user@host project]# echo $?
0
[user@host project]# export MYVAR=hello   # 暂未实现,但可扩展
[user@host project]# echo $MYVAR
hello
[user@host project]# ls notexist
ls: cannot access 'notexist': No such file or directory
[user@host project]# echo $?
2

10. 总结与扩展思考

10.1 已实现的核心功能

  • 提示符动态显示用户名、主机名、当前目录名

  • 命令行读取、解析、参数拆分

  • 内建命令 cd(含 cd -cd ~)和 echo(含 $?, $VAR

  • 外部命令通过 fork+execvp+waitpid 执行

  • 退出码维护

  • 环境变量继承

10.2 可进一步扩展的功能

  • 管道 | :需要连接多个子进程的输入输出,涉及 pipedup2

  • 输入/输出重定向 > < >> :在子进程 exec 前使用 open + dup2

  • 后台运行 & :父进程不调用 wait,而是将子进程放入后台列表。

  • 作业控制jobsfgbg、信号处理。

  • 完整的 exportalias 实现。

  • 命令历史记录 (通过 readline 库或自己维护数组)。

10.3 知识巩固

该迷你Shell完整地应用了:

  • 进程控制(fork / exec / wait

  • 环境变量管理(getenv / putenv / environ

  • 字符串处理(strtok, snprintf

  • 文件路径操作(chdir / getcwd / rfind

通过亲手实现这样一个Shell,您将彻底理解命令行解释器的底层奥秘,为后续学习系统编程、网络编程、并发服务器等打下坚实基础。


附录:完整代码(已整理)

您提供的代码已经非常完整,可以直接编译运行。使用时注意:

  • 编译命令:g++ -o myshell myshell.cpp -std=c++11

  • 运行:./myshell

代码中 export 部分尚未实现,读者可自行补全作为练习。

本博客完

相关推荐
拙慕JULY1 小时前
小程序返回 base64 文件报错
开发语言·javascript·小程序
月疯1 小时前
torch:expand和repeate的区别
开发语言·python·深度学习
阿标在干嘛1 小时前
政策平台的推送系统:消息队列、定时任务、AB测试的工程实践
服务器·数据库·ab测试
Drone_xjw1 小时前
qt配置项目样式表
开发语言·qt
devilnumber1 小时前
静态代理 & 动态代理:实战运用 + 场景区别 + 怎么选
java·开发语言·代理模式
fpcc1 小时前
工具使用——CMake中的函数和宏
c++·cmake
Adorable老犀牛2 小时前
nginx_exporter:Prometheus 监控 Nginx 基础指标
运维·nginx·prometheus
山里幽默的程序员2 小时前
DevOps 必备:盘点2026 年最强RESTful API 接口测试方案
运维·restful·devops·api开发·api开发工具
happymaker06262 小时前
Linux常见命令总结
linux·运维·服务器