欢迎光临
我们一直在努力

Re:Linux 系统篇(二十一):手写迷你 Shell —— 吃透 fork+exec 本质,还原命令行解释器核心原理

在这里插入图片描述

观众老爷们大家好 这里是邪修KING的独家频道

本文属于系列Linux系统篇 ——操作指令

一起学Linux的小伙伴可订阅专栏: Linux系统篇

上一篇我们讲透了 exec 全家桶 6 个函数的用法和原理。而 fork + exec 最经典、最贴近我们日常使用的应用,就是 Shell 命令行解释器。 我们每天敲的 bash、zsh,底层核心逻辑其实非常朴素:打印提示符 → 读命令 → 拆参数 → fork 子进程 → exec 替换执行 → 父进程等待回收,循环往复。 本篇我们就通过一份完整的 C++ 迷你 Shell 代码,逐模块拆解每个函数的作用,重点讲清楚 exec 在其中的角色,彻底搞懂 Shell 到底是怎么跑起来的。


一、迷你 Shell 的整体工作流程

在看代码之前,先把核心流程印在脑子里,整个 Shell 就是一个无限循环,每一轮做 4 件事:

  • 打印提示符:显示 用户名@主机名:当前目录$ 样式的提示,告诉用户可以输入了
  • 读取并解析命令:读取用户输入的一整行,按空格拆成 命令 + 参数 的数组
  • 创建子进程执行命令:fork 创建子进程,子进程调用 execvp 把自己替换成目标命令程序
  • 父进程等待回收:父进程阻塞等待子进程执行完毕,回收僵尸进程,然后回到第 1 步
  • 💡 核心本质: Shell 本身从来不执行具体命令,它只做 “解析 + 调度”。真正跑命令的,是 fork 出来的子进程,通过 exec 替换成系统里的真实程序(ls、cat、gcc 等)。


    二、代码逐模块教学拆解

    2.1 头文件与全局定义

    #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]# " // 提示符格式
    #define MAXARG 128 // 最大参数个数

    char* g_argv[MAXARG]; // 全局:解析后的参数数组
    int g_argc = 0; // 全局:参数个数

    各部分作用

    表格

    头文件作用
    unistd.h 提供 fork、execvp、getpid 等系统调用
    sys/wait.h 提供 waitpid 进程回收函数
    sys/types.h 提供 pid_t 等进程类型定义
    • g_argv / g_argc:全局变量,用来存放解析后的命令参数,直接传给 exec 使用。

    2.2 辅助信息函数:拼装提示符用

    这一组函数都是为了生成和 bash 类似的提示符,从环境变量和系统信息里取出用户名、主机名、当前目录。

    获取用户名

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

    • 作用:从环境变量 USER 里取出当前登录用户名
    • 对应知识点:getenv 获取环境变量,就是上一篇讲的标准获取方式
    获取主机名

    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;
    }

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

    • 作用:取出当前工作目录,并且只截取最后一级目录名,提示符更简洁。
    • 对应知识点:进程的工作目录记录在环境变量 PWD 中。

    2.3 生成并打印提示符

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

    void PrintCommandPrompt()
    {
    char prompt[COMMAND_SIZE];
    MakeCommandLine(prompt, sizeof(prompt));
    printf("%s", prompt);
    fflush(stdout); // 强制刷新输出,确保提示符立刻显示
    }

    • 作用:按照格式组装成 [user@host dir]# 样式的提示符,打印到屏幕上。
    • 细节:fflush(stdout) 很重要,否则提示符可能不会立刻显示出来。

    2.4 读取用户输入

    bool GetCommandLine(char *out, int size)
    {
    char *c = fgets(out, size, stdin);
    if(c == NULL) return false;

    // 去掉末尾的换行符
    if(out[strlen(out)–1] == '\\n') out[strlen(out)–1] = 0;
    if(strlen(out) == 0) return false;
    return true;
    }

    • 作用:从标准输入读取一整行用户命令
    • 细节处理:去掉 fgets 带进来的末尾换行符;空行直接返回不处理。

    2.5 命令解析:把一行拆成参数数组

    这一步是为 exec 做准备 ——exec 需要的是 argv[] 参数数组,所以要把一整行字符串按空格切开。

    #define SEP " "

    bool CommandParse(char *commandline)
    {
    g_argc = 0;
    g_argv[g_argc++] = strtok(commandline, SEP);

    while( (g_argv[g_argc++] = strtok(NULL, SEP)) );

    g_argc—;
    return true;
    }

    • 作用:用 strtok 按空格切割字符串,填充全局 g_argv 数组。
    • 结果:比如输入 ls -l -a,会被切成 g_argv[0]="ls"、g_argv[1]="-l"、g_argv[2]="-a"、结尾 NULL。
    • 为什么要拆成数组?因为 exec 系列的 v 版本(execvp、execv),就是接收数组形式的参数,刚好完美匹配。

    2.6 核心执行函数:fork + execvp(重点)

    这是整个 Shell 最核心的部分,直接对应我们上一篇讲的 fork 创建进程 + exec 程序替换 + wait 回收 完整流程。

    int Execute()
    {
    pid_t id = fork();

    if(id == 0)
    {
    // ========== 子进程:程序替换 ==========
    execvp(g_argv[0], g_argv);

    // 能走到这里,说明 exec 失败了
    exit(1);
    }
    else
    {
    // ========== 父进程:等待子进程退出 ==========
    pid_t rid = waitpid(id, nullptr, 0);
    (void)rid;
    }
    return 0;
    }

    逐行讲透
    ① 为什么用 fork?

    Shell 本身不能被替换。如果直接在主进程 exec,命令跑完 Shell 就没了。所以必须 fork 出子进程,让子进程去替换执行,父进程保留,继续等待下一条命令。

    ② 为什么选 execvp?对应上一篇的知识点

    我们上一篇讲了 6 个 exec 函数,这里选 execvp 是最贴合 Shell 场景的:

    • v 后缀:参数用数组传递。我们已经把命令解析成 g_argv 数组了,直接传进去就行,非常方便。
    • p 后缀:自动在 PATH 环境变量里搜索可执行文件。用户输入 ls 不用写 /usr/bin/ls,自动能找到,和真实 Shell 行为完全一致。
    ③ exec 成功不返回的特性

    execvp 下面的 exit(1),只有 exec 失败的时候才会执行。 只要替换成功,子进程就开始跑新程序的代码,原来的代码彻底消失,永远走不到这一行。这完全呼应上一篇讲的 exec 返回值特性。

    ④ 父进程 waitpid

    父进程阻塞等待子进程执行完毕,回收子进程的 PCB,避免产生僵尸进程。对应我们之前讲的僵尸进程回收知识点。


    2.7 主循环:Shell 的永生循环

    int main()
    {
    while(true)
    {
    // 1. 打印命令提示符
    PrintCommandPrompt();

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

    // 3. 命令行解析
    CommandParse(commandline);

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

    • 整个 Shell 就是一个死循环,一轮接一轮地 “提示 → 读 → 解析 → 执行 → 等”。
    • 这就是为什么 Shell 不会跑完一条命令就退出,它会一直等你输入下一条。

    三、编译运行与实际效果

    3.1 配套 Makefile

    g++ -o myshell myshell.cc -std=c++11

    .PHONY:clean
    clean:
    rm -f myshell

    3.2 编译运行

    make
    ./myshell

    3.3 运行效果

    启动后会显示提示符,输入命令和正常 bash 几乎一样:

    [ubuntu@VM-0-16-ubuntu lesson17]# ls -l
    总用量 xx
    -rw-rw-r– ... Makefile
    -rwxrwxr-x ... myshell
    -rw-rw-r– ... myshell.cc

    [ubuntu@VM-0-16-ubuntu lesson17]# pwd
    /home/ubuntu/113/lesson17

    [ubuntu@VM-0-16-ubuntu lesson17]# clear

    所有系统命令都能正常执行,本质就是:子进程被替换成了系统里对应的真实程序。


    四、原理总结与拓展说明

    4.1 核心结论

  • Shell 本身不执行命令,它只做解析和调度,真正执行命令的是 fork 出来的子进程。
  • exec 是核心:子进程通过 execvp 完成程序替换,跑起目标命令。
  • 完整链路:fork 创建子进程 → execvp 替换程序 → waitpid 回收,这就是 Shell 的本质。
  • 4.2 这个迷你 Shell 的简化点

    这是教学演示版本,重点突出进程替换原理,省略了很多高级功能:

    • ❌ 不支持管道 |、重定向 >
    • ❌ 没有内置命令(比如 cd、exit 这些需要 Shell 自己执行,不能 fork 执行)
    • ❌ 没有信号处理、Tab 补全、历史命令
    • ✅ 完整保留了 Shell 最核心的 fork + exec + wait 机制

    4.3 对应前面的知识点串联

    表格

    代码里的实现对应之前的知识点
    fork() 创建子进程 进程创建、写时拷贝
    execvp() 执行替换 exec 全家桶、v 数组传参、p 自动搜 PATH
    exec 成功不返回 exec 返回值特性:失败才返回
    waitpid() 回收 僵尸进程、进程等待回收
    getenv("USER") 环境变量获取

    全文总结

    手写迷你 Shell 是对「进程创建 + 程序替换 + 进程等待」三个核心知识点的最好综合实践。 看似复杂的命令行解释器,底层其实就是不断重复 fork → exec → wait 的循环。理解了这个,你就彻底懂了 Shell 的本质,也把前面三篇的进程知识点串成了完整的体系。

    完整代码

    完整可运行代码:myshell.cc(每接口带作用备注)

    // ==============================================
    // 头文件引入
    // ==============================================
    #include <iostream>
    #include <cstdio> // C标准IO:printf、fgets、snprintf
    #include <cstring> // C字符串:strtok、strlen
    #include <cstdlib> // 标准库:getenv、exit
    #include <unistd.h> // 系统调用:fork、execvp、getpid
    #include <sys/types.h> // 系统类型:pid_t
    #include <sys/wait.h> // 进程等待:waitpid

    // ==============================================
    // 宏定义
    // ==============================================
    #define COMMAND_SIZE 1024 // 命令行最大输入长度
    #define FORMAT "[%s@%s %s]# " // 提示符格式:[用户名@主机名 目录]#
    #define MAXARG 128 // 命令参数最大个数
    #define SEP " " // 命令参数分隔符:空格

    // ==============================================
    // 全局变量
    // 作用:存放解析后的命令参数数组,直接供 execvp 使用
    // ==============================================
    char* g_argv[MAXARG]; // 参数指针数组,结尾以NULL结束
    int g_argc = 0; // 参数个数

    // ==============================================
    // 函数:GetUserName
    // 作用:从环境变量中获取当前登录用户名
    // 备注:使用标准库 getenv 读取 USER 环境变量
    // ==============================================
    const char* GetUserName()
    {
    const char *name = getenv("USER");
    return name == NULL ? "None" : name;
    }

    // ==============================================
    // 函数:GetHostName
    // 作用:从环境变量中获取当前主机名
    // 备注:读取 HOSTNAME 环境变量
    // ==============================================
    const char* GetHostName()
    {
    const char *hostname = getenv("HOSTNAME");
    return hostname == NULL ? "None" : hostname;
    }

    // ==============================================
    // 函数:GetPwd
    // 作用:从环境变量中获取当前工作目录完整路径
    // 备注:读取 PWD 环境变量,进程工作目录由内核维护并同步到环境变量
    // ==============================================
    const char* GetPwd()
    {
    const char *pwd = getenv("PWD");
    return pwd == NULL ? "None" : pwd;
    }

    // ==============================================
    // 函数:DirName
    // 作用:截取完整路径的最后一级目录名
    // 备注:让提示符更简洁,只显示当前所在目录名而非全路径
    // ==============================================
    std::string DirName(const char *pwd)
    {
    std::string dir = pwd;
    // 从右往左找第一个斜杠
    auto pos = dir.rfind("/");
    if(pos == std::string::npos) return "BUG";
    // 截取斜杠后面的部分
    return dir.substr(pos+1);
    }

    // ==============================================
    // 函数:MakeCommandLine
    // 作用:按照格式拼装命令提示符字符串
    // 备注:将用户名、主机名、当前目录填入格式模板
    // ==============================================
    void MakeCommandLine(char cmd_prompt[], int size)
    {
    snprintf(cmd_prompt, size, FORMAT,
    GetUserName(),
    GetHostName(),
    DirName(GetPwd()).c_str());
    }

    // ==============================================
    // 函数:PrintCommandPrompt
    // 作用:打印命令提示符到屏幕
    // 备注:必须 fflush 刷新输出缓冲区,否则提示符可能延迟显示
    // ==============================================
    void PrintCommandPrompt()
    {
    char prompt[COMMAND_SIZE];
    MakeCommandLine(prompt, sizeof(prompt));
    printf("%s", prompt);
    fflush(stdout); // 强制刷新标准输出
    }

    // ==============================================
    // 函数:GetCommandLine
    // 作用:从标准输入读取用户输入的一整行命令
    // 返回:读取成功返回true,失败/空行返回false
    // 备注:去掉fgets自带的末尾换行符
    // ==============================================
    bool GetCommandLine(char *out, int size)
    {
    char *c = fgets(out, size, stdin);
    if(c == NULL) return false;

    // 去掉末尾的换行符
    if(out[strlen(out)–1] == '\\n')
    out[strlen(out)–1] = '\\0';

    // 空行不处理
    if(strlen(out) == 0) return false;
    return true;
    }

    // ==============================================
    // 函数:CommandParse
    // 作用:命令行解析,按空格拆分字符串,填充全局参数数组 g_argv
    // 备注:拆分后的格式完全匹配 execvp 的参数要求,结尾自动带NULL
    // ==============================================
    bool CommandParse(char *commandline)
    {
    g_argc = 0;
    // 第一次拆分,取出第一个参数(命令名)
    g_argv[g_argc++] = strtok(commandline, SEP);

    // 循环拆分后续所有参数,直到返回NULL
    while( (g_argv[g_argc++] = strtok(NULL, SEP)) );

    g_argc—; // 减去最后一次NULL导致的计数+1
    return true;
    }

    // ==============================================
    // 函数:Execute【核心函数】
    // 作用:fork创建子进程 + execvp程序替换 + waitpid父进程回收
    // 核心流程:
    // 1. 父进程fork出子进程
    // 2. 子进程调用execvp,把自己替换成用户输入的命令程序
    // 3. 父进程阻塞等待子进程执行完毕,回收PCB避免僵尸进程
    // ==============================================
    int Execute()
    {
    // 创建子进程
    pid_t id = fork();

    if(id == 0)
    {
    // ========== 子进程分支 ==========
    // 作用:执行程序替换,用目标命令程序覆盖当前子进程的代码数据
    // 参数1:命令名(自动去PATH环境变量搜索)
    // 参数2:解析好的参数数组
    // 备注:execvp成功则永不返回,直接跑新程序;只有失败才会返回-1
    execvp(g_argv[0], g_argv);

    // ===== 能走到这里,说明exec执行失败 =====
    perror("命令执行失败");
    exit(1);
    }
    else if(id > 0)
    {
    // ========== 父进程分支 ==========
    // 作用:阻塞等待指定子进程退出,回收子进程PCB,防止僵尸进程
    // 参数1:等待的子进程PID
    // 参数2:接收退出状态,这里传nullptr不关心
    pid_t rid = waitpid(id, nullptr, 0);
    (void)rid; // 避免未使用变量警告
    }
    else
    {
    // fork失败
    perror("fork失败");
    return –1;
    }
    return 0;
    }

    // ==============================================
    // 主函数:Shell 主循环
    // 整体流程:打印提示符 → 读命令 → 解析 → 执行 → 等待 → 循环
    // ==============================================
    int main()
    {
    // Shell 是死循环,不断等待用户输入命令
    while(true)
    {
    // 1. 打印命令提示符
    PrintCommandPrompt();

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

    // 3. 命令行解析,拆分出参数数组
    CommandParse(commandline);

    // 4. 执行命令(fork+exec+wait)
    Execute();
    }
    return 0;
    }


    配套 Makefile

    # 编译器
    CC = g++
    # 编译选项:开启警告、C++11标准
    CFLAGS = -Wall -std=c++11

    # 目标可执行文件
    TARGET = myshell
    # 源文件
    SRC = myshell.cc

    # 默认目标:编译生成
    all: $(TARGET)

    $(TARGET): $(SRC)
    $(CC) $(CFLAGS) $< -o $@

    # 清理编译产物
    .PHONY: clean
    clean:
    rm -f $(TARGET)


    编译运行

    # 编译
    make

    # 启动迷你Shell
    ./myshell

    在这里插入图片描述

    赞(0)
    未经允许不得转载:171主机测评 » Re:Linux 系统篇(二十一):手写迷你 Shell —— 吃透 fork+exec 本质,还原命令行解释器核心原理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址