《从零入门Linux系统篇(二十七):进程篇·十一——手写Shell实战:从普通命令执行到内建命令实现》

这是Linux进程篇的收官之战。为了真正看懂命令行背后那套运转逻辑,我们要把前面进程篇一到十一积攒的全部家伙什儿都翻出来,从零开始,手写一个简易Shell。按照深入程度,自定义Shell的编写可以拆成四个阶段。本文先带大家啃下前三个阶段,把骨架搭起来、把流程跑通。至于第四阶段,我们留到Linux文件篇再接着攻坚。好的,不废话,我们开始。

目录

一、准备工作——从基础接口到Shell框架

1.1 常用接口回顾

1.1.1 rfind

1.1.2 fgets

1.1.3 strtok

1.1.4 snprintf

1.2 Shell基础架构设计

二、第一阶段:让Shell运行普通命令

2.1 Shell核心代码实现

三、第二阶段:让Shell支持内建命令

3.1 什么是内建命令?

四、第三阶段:加入环境变量支持

4.1 第二、第三阶段合并版——Shell完整实现

4.2 Shell实现中的几个关键问题

4.2.1 为什么读取命令不用scanf而用fgets?

4.2.2 为什么使用字符数组,而不是std::string?

4.2.3 思考一个悖论:我们手写的Shell能执行su -吗?

4.2.4 为什么需要内建命令?——普通命令与特殊内建命令的区别

4.2.5 chdir()的小细节——只改变工作目录,不改变环境变量


一、准备工作——从基础接口到Shell框架

1.1 常用接口回顾

正式开始前,我们得先把几个几乎被遗忘的接口从记忆深处捞回来,它们马上就会在Shell里派上大用场。

1.1.1 rfind
size_t rfind(const string& str, size_t pos = npos);
  • str:要查找的目标子串或字符。
  • pos:从哪个位置开始逆向搜索。默认值是npos,也就是从字符串末尾一路往前找。
  • 返回值:目标子串最后一次出现时,首字符所在的下标;如果压根没找到,返回 std::string::npos。

这个函数就是帮我们从后往前,找到某个字符或字符串最后一次露面的位置。后面在解析命令行时,它会派上关键用场。

1.1.2 fgets
char* fgets(char* str, int n, FILE* stream);

这个函数,就是从输入流里“抓一行”的标准工具。自定义Shell里,就是靠它把用户敲进来的命令读出来的。

  • str:缓冲区指针。读到的字符串,就存进这块地方。
  • n:最多能读多少个字符。注意它有点“抠门”,实际最多只读n-1个,因为最后一位要留给自动补上的\0结束符。
  • stream:从哪儿读。Shell里我们固定传stdin,也就是标准输入,老老实实等着用户敲键盘。

返回值:成功时,返回的就是str本身,相当于把缓冲区又原样递回你手里。如果还没读到任何字符就撞上了EOF,或者读取过程中出了错,那就返回NULL。

1.1.3 strtok
char* strtok(char* str, const char* delimiters);

这个函数,就是用来“切串”的。给它一根长字符串,再给它一把“分隔符”,它就能把长串一段段切开,每次吐出一小段。

  • str:待切分的原始字符串。这里有个非常反直觉的规矩:第一次调用时,要把原字符串传进去;之后再想拿剩下的段,这个位置必须传NULL。 函数自己会记住上次切到哪儿了,你只要不停喊它就行。
  • delimiters:分隔符集合。比如传一个空格" ",它就会按空格切;传" \t",它就连空格带Tab一起当刀使。

返回值:当前切出来的那一小段字符串的起始指针。如果没有东西可切了,返回NULL,表示“已经切完了”。

strtok切串的时候,会直接修改原字符串,把分隔符位置改成\0。所以传进去的字符串得是可写的,不能是只读的字符串常量。这个细节,用过一次就忘不掉。

1.1.4 snprintf
int snprintf(char* s, size_t n, const char* format, ...);

这个函数,你可以把它理解成“安全版的sprintf”。它干的事很简单:把一堆零散的数据,按照你给的格式,拼成一句完整的字符串。但比sprintf强的地方在于,它永远不会写爆你的缓冲区。

参数逐个看:

  • s:目标缓冲区。拼好的字符串,最终就躺在这块数组里。
  • n:缓冲区的大小。这是它的安全底线:函数最多写入n-1个有效字符,剩下一个位置强制填\0。所以哪怕你给的数据再多,它也绝不越界。
  • format:格式化模板。比如"%s@%s",决定了后面那些参数怎么排、以什么顺序插入。
  • ...:可变参数列表。模板里挖了几个坑,你就得填几个对应的变量进去。

返回值:它返回的是“假设缓冲区无限大时,完整输出本来需要多少字符”,不包含结尾的\0。如果这个返回值大于等于n,就说明缓冲区给小了,输出被截断了。这个特性很实用,你可以先用返回值判断空间够不够,不够再扩。

1.2 Shell基础架构设计

动手写代码之前,先把整条执行流在脑子里过一遍。核心逻辑说白了就四步,一个循环走到底:

打印提示符 → 获取输入 → 切分字符串 → 识别并执行

第一步,打印提示符,告诉用户“该你说话了”;

第二步,获取输入,把用户敲的那一整行命令读进来;

第三步,切分字符串,用空格当刀,把命令和参数拆成一个个独立片段;

第四步,识别并执行,判断这到底是什么命令,该内置的内置处理,该外部程序的交给fork + exec去跑。

跑完一圈,回到第一步,继续打印提示符,继续等下一句。Shell的一生,就是这四步的无限循环。

二、第一阶段:让Shell运行普通命令

这是Shell的婴儿学步阶段。目标很朴素:能执行ls -a -l这种外部命令就足够了。怎么跑?父进程负责解析命令,子进程负责被execvp替换,各司其职。

2.1 Shell核心代码实现

先把整个代码完整贴出来,然后我们再一块一块拆开看。

#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
#include <cstdio>
using namespace std;

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

int g_argc = 0;
char* g_argv[128] = {0};

const char* GetUserName()
{
    const char* name = getenv("USER"); // 获取当前用户名
    return name == nullptr ? "None" : name;
}

const char* GetHostName()
{
    const char* host = getenv("HOSTNAME"); // 获取主机名
    return host == nullptr ? "None" : host;
}

string GetDirName(const char* pwd)
{
#define SEP "/"
    string str = pwd;
    if (str == SEP) return "/";
    auto e = str.rfind(SEP); // 从后往前查找路径分隔符
    if (e == string::npos) return "BUG?";
    return str.substr(e + 1); // 提取当前最后一级目录名
}

string GetCwd()
{
    const char* pwd = getenv("PWD"); // 获取当前绝对路径
    if (pwd == NULL) return "None";
    return GetDirName(pwd);
}

void MakeCommandLine(char* commandline, int size)
{
    // 拼接用户名、主机名和当前目录,格式化输出至目标缓冲区
    snprintf(commandline, size, COMMANDLINE,
             GetUserName(), GetHostName(), GetCwd().c_str());
}

void PrintCommandLine()
{
    char CommandLine[COMMAND_SIZE] = {0};
    MakeCommandLine(CommandLine, sizeof(CommandLine)); // 构建命令提示符
    printf("%s", CommandLine);
    fflush(stdout); // 刷新标准输出缓冲区
}

bool GetCommandLine(char* cml, int size)
{
    char* c = fgets(cml, size, stdin); // 从标准输入读取一行
    if (c == NULL) return false;
    if (strlen(cml) - 1 == 0) return false;
    cml[strlen(cml) - 1] = 0; // 剔除最后的换行符 '\n'
    return true;
}

void CommandParse(char* cml)
{
#define SPL " "
    g_argc = 0;
    g_argv[g_argc++] = strtok(cml, SPL); // 首次切分获取程序名
    while ((bool)(g_argv[g_argc++] = strtok(nullptr, SPL))); // 循环切分获取参数
    g_argc--;
}

void Execute()
{
    pid_t _id = fork(); // 创建子进程
    if (_id == 0)
    {
        execvp(g_argv[0], g_argv); // 子进程执行程序替换
        exit(0);
    }
    pid_t rid = waitpid(_id, nullptr, 0); // 父进程阻塞等待子进程退出
    (void)rid;
    return;
}

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

        char commandline[COMMAND_SIZE] = {0};
        if (!GetCommandLine(commandline, sizeof(commandline))) // 2. 获取输入
            continue;

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

        Execute(); // 4. 创建子进程执行
    }
    return 0;
}

三、第二阶段:让Shell支持内建命令

第一阶段的Shell只能跑跑外部命令,但遇到cd这种命令就露怯了。为什么?因为cd改变的是当前工作目录,而这件事必须由Shell亲自来做,交给子进程去改,改的是子进程自己的目录,父进程纹丝不动,等于没改。

像cd、echo、export这类必须由父进程亲自执行的命令,在Shell里有个专门的名字,内建命令

3.1 什么是内建命令?

内建命令的处理逻辑,跟外部命令走了完全不同的路。父进程在fork之前,得先拦住命令看一眼:这是不是我该亲自办的?如果是,就别派子进程了,直接在自己的进程内部调用对应的系统接口,比如cd就调chdir(),原地把目录切了。所以,内建命令和外部命令的分界线就在这里:

  • 外部命令:fork + exec,子进程去跑,父进程只负责等。
  • 内建命令:不fork,父进程直接调用系统接口亲手执行。

这条线一旦划清楚,Shell的行为就变得合理多了。你敲cd /home的时候,改变的是Shell自己的工作目录,这样下次执行ls时,ls继承的才是新目录下的环境。如果cd交给子进程去干,子进程退出了,目录就改了个寂寞。这就是内建命令存在的根本原因。

四、第三阶段:加入环境变量支持

环境变量具有全局属性,子进程可以继承。Shell不能总是指望系统的environ,它得自己维护一张环境变量表,在启动时把系统的environ导入进来,然后在这张表上做文章。这个阶段,我们要让Shell拥有自己的“环境变量管家”。

4.1 第二、第三阶段合并版——Shell完整实现

下面这段代码,就是把第二阶段的“内建命令”和第三阶段的“环境变量管理”合并在一起的结果。你把它跑起来,就能得到一个能执行普通命令、能处理cd和echo、还能维护自己环境变量表的迷你Shell。

#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;

const int PROMPT_SIZE = 128;
#define PROMPT "[%s@%s %s ]#"

#define COMMAND_SIZE 1024
int g_argc = 0;
char* g_argv[COMMAND_SIZE] = {0};

size_t ExitCode = 0; // 记录最近一次子进程的退出码

const int ENV_SIZE = 100;
int g_envs = 0;
char* g_env[ENV_SIZE] = {0}; // Shell 自己维护的环境变量表

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

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

const char* GetHome()
{
    const char* home = getenv("HOME");
    return home == NULL ? "None" : home;
}

string GetDirectory(char* _cwd)
{
    string str = _cwd;
    size_t pos = str.rfind("/");
    if (pos == string::npos) return "None";
    string ret = str.substr(pos + 1);
    return ret;
}

string GetCwd()
{
    char* cwd = getenv("PWD");
    return GetDirectory(cwd);
}

void MakePrompt(char* prompt, int size)
{
    snprintf(prompt, size, PROMPT, GetUserName(), GetHostName(), GetCwd().c_str());
}

void PrintCommandPrompt()
{
    char CommandPrompt[PROMPT_SIZE] = {0};
    MakePrompt(CommandPrompt, sizeof(CommandPrompt));
    printf("%s", CommandPrompt);
}

bool GetCommandLine(char* _cmd, int size)
{
    char* ptr = fgets(_cmd, size, stdin);
    if (ptr == NULL) return false;
    _cmd[strlen(_cmd) - 1] = 0; // 剥离末尾换行符
    if (strlen(_cmd) == 0) return false;
    return true;
}

bool AnalyseCommandLine(char* _cmd)
{
#define ESC " "
    g_argc = 0;
    g_argv[g_argc++] = strtok(_cmd, ESC);
    while ((bool)(g_argv[g_argc++] = strtok(NULL, ESC)));
    g_argc--;
    return g_argc > 0 ? true : false;
}

void Execute()
{
    pid_t _id = fork();
    if (_id == 0)
    {
        execvp(g_argv[0], g_argv);
        exit(0);
    }
    int statue = 0;
    pid_t rid = waitpid(_id, &statue, 0);
    if (rid > 0)
        ExitCode = WEXITSTATUS(statue); // 获取并记录子进程退出码
    return;
}

void CommandCd()
{
    if (g_argc == 1)
    {
        chdir(GetHome()); // 无参数默认切换到 HOME 路径
    }
    else if (g_argc == 2)
    {
        if (strcmp(g_argv[1], "-") == 0)
            chdir(getenv("OLDPWD")); // 切换到上一次所在目录
        else if (strcmp(g_argv[1], "~") == 0)
            chdir(getenv("HOME")); // 切换到家目录
        else
            chdir(g_argv[1]); // 切换到用户指定的路径
    }
    else
        cout << "command not found" << endl;
}

void CommandEcho()
{
    string What = g_argv[1];
    if (g_argc == 2)
    {
        if (What == "$?")
        {
            cout << ExitCode << endl; // 打印最近一次退出码,然后重置
            ExitCode = 0;
        }
        else if (What[0] == '$')
        {
            string sub = What.substr(1);
            printf("%s\n", getenv(sub.c_str())); // 打印指定环境变量的值
        }
        else
        {
            cout << What << endl;
        }
    }
    else
        cout << "command not found" << endl;
}

bool CheckBuild_inCommand()
{
    string _cmd = g_argv[0];
    if (_cmd == "cd")
    {
        CommandCd(); // 拦截并执行内建 cd
        return true;
    }
    else if (_cmd == "echo")
    {
        CommandEcho(); // 拦截并执行内建 echo
        return true;
    }
    return false;
}

void Initenv()
{
    extern char** environ;
    memset(g_env, 0, sizeof(g_env));
    g_envs = 0;

    // 1. 把系统原生环境变量深拷贝进我们自己的表
    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*)"TEST=1234567890";
    g_env[g_envs] = NULL;

    // 3. 把表里的变量全部导入当前进程环境
    for (int i = 0; g_env[i]; i++)
    {
        putenv(g_env[i]);
    }

    // 4. 让全局 environ 指向我们自己的表
    environ = g_env;
}

int main()
{
    Initenv(); // 初始化环境变量表

    while (1)
    {
        PrintCommandPrompt(); // 1. 打印提示符

        char CommandLine[COMMAND_SIZE] = {0};
        if (!GetCommandLine(CommandLine, sizeof(CommandLine))) // 2. 获取输入
            continue;

        if (!AnalyseCommandLine(CommandLine)) // 3. 解析命令
            continue;

        if (CheckBuild_inCommand()) // 4. 如果是内建命令,就在父进程里执行
            continue;

        Execute(); // 5. 外部命令,交给子进程去跑
    }
    return 0;
}

关键逻辑拆解

这段代码相比前两阶段,多了两个核心变化:内建命令拦截和环境变量自主维护。

CheckBuild_inCommand就是那个拦截器。它一看命令是cd或echo,就截下来不往Execute送了,直接在父进程里调CommandCd或CommandEcho搞定。这就是前面说的,内建命令绝不能交给子进程,因为它们要改的就是父进程自己的状态。

Initenv是环境变量管家的初始化。它先把系统的environ整张表深拷贝进我们自己的g_env数组,再追加一条TEST=1234567890,然后把数组里的变量一条条putenv回当前进程,最后把全局environ指针指到我们自己的表上。至此,Shell的环境变量就完全接管过来了,以后想增删改查,直接对g_env动手就行。

echo命令也被玩出了花。敲echo hello就打印hello;敲echo $PATH就解析出PATH的值;敲echo $?就打印上一次命令的退出码。一个小内建命令,把环境变量、退出码、普通输出三种能力全串了起来。

现在,这个迷你Shell已经有模有样了。它能跑外部命令,能切目录,能看环境变量,甚至还能查退出码。下一阶段,我们就要往里面加更硬核的东西,管道、重定向、后台运行。不过那属于文件篇的活了,先把眼前三个阶段啃透,地基才牢。

4.2 Shell实现中的几个关键问题

前三个阶段写完,不妨先停下来,问自己几个“为什么”。这些问题看着不起眼,但想通了,就能把从应用层字符串处理到内核进程管理这条链路彻底打通。

4.2.1 为什么读取命令不用scanf而用fgets?
  • scanf会被空格截断。scanf读字符串的时候,默认把空格、制表符、换行符都当成“到此为止”的结束标志。你让它读一句话,它往往只读第一个词就撂挑子了。
  • 读不到完整命令。可命令行动不动就带参数,ls -a -l这种是家常便饭。用scanf去读,它只把ls拿进来,后面的-a -l全留在输入缓冲区里,Shell解析出来的命令缺胳膊少腿,根本跑不对。
  • fgets才是正确姿势。fgets会一口气读满一整行,直到撞上换行符\n才罢休。它能把包含空格的完整命令原原本本地搬到你的字符数组里,一点不落。这正是Shell最需要的能力。

选fgets不是个人喜好,而是从命令行输入这个场景倒推出来的必然选择。scanf适合读单个单词或数字,但想“听完整句话”,还得靠fgets出马。

4.2.2 为什么使用字符数组,而不是std::string?

说白了,就是为了迁就exec*那一大家子。系统调用和C标准库的底层接口,比如execvp、strtok全是吃着char*[]这碗饭长大的,它们压根不认std::string这种现代货。

如果你硬要上std::string或者vector<string>,每回调用底层接口之前,都得手动调.c_str()来回倒腾,又麻烦又低效。这还不算完,频繁的指针转换会打乱你对内存的掌控,稍不留神就埋下悬空指针、野指针的雷。维护起来,不是省心,是添堵。

所以,在Shell这个紧贴底层的场景里,用最朴素的字符数组才是最稳的选择。它跟系统接口无缝对接,不用中间商赚差价,代码写起来也少了许多花花肠子。等到以后需要更复杂的字符串操作时,再局部使用std::string也来得及,但主心骨还是得落在char*上。

4.2.3 思考一个悖论:我们手写的Shell能执行su -吗?

结论很干脆:不能,至少不能正常运行。 就算勉强执行了,状态也维持不住。为什么?

因为su -的本质,不是一次简单的程序替换,而是一次彻底的“身份切换”。它需要创建新的会话,把当前进程的权限凭证、环境变量、工作目录等家底全部推倒重来,相当于给进程换一套全新的“身份档案”。

而我们手写的这个Shell,本质上只是一个跑在用户态的普通子进程,手里并没有操作系统核心的管理权限。当子进程尝试执行su -时,它确实会完成程序替换,但替换之后呢?父进程,也就是我们的Shell,根本没有维护会话切换的完整机制。子进程生命周期一结束,或者环境发生冲突,整棵权限树和会话环境就会瞬间崩塌。

打个比方:这就像让一个普通员工临时披上老板的外套,衣服是穿上了,可董事会、印章、财务系统全都不在他手里。外套一脱,戏就演不下去了。

所以,su -这种需要动到用户身份根基的命令,不是一个用户态小程序能接得住的。我们的迷你 Shell,到这里就触碰到了它能力的天花板。这也恰恰说明,真正的Shell为什么会设计成会话领导者,并且和终端、内核协同得那么紧密,因为有些坑,不是靠fork + exec就能填平的。

4.2.4 为什么需要内建命令?——普通命令与特殊内建命令的区别

要真正理解内建命令存在的意义,光知道“它在父进程里执行”还不够。我们把视角拉高一点,从三个维度拆开看,你就能明白这玩意儿为什么不是可有可无的。

1. 改变 Shell 自身状态的绝对必要性

有些命令,天生就是冲着Shell自己来的。最典型的就是cd。你要是把它交给子进程去执行,子进程倒是开开心心把目录切了,可它一退出,父进程的工作目录还是老样子,切了个寂寞。这类命令必须由Shell进程亲自上阵,直接调用chdir()这类系统接口,把状态改在自己身上。所以,内建命令的第一层意义就是:有些事,别人替不了你。

2. 保证命令在任何情况下都可用

外部命令有个致命软肋,它强依赖$PATH和磁盘上实实在在的文件。一旦这些依赖出了问题,外部命令就集体哑火。

想象一个极端场景:你手滑把/usr/bin目录删了,或者磁盘故障导致/bin挂载失败。这时候,几乎所有的外部命令都跑不了了,Shell是不是就成废物了?并不会。因为cd、pwd、echo这些内建命令还活着。它们不靠PATH,不靠磁盘文件,就住在Shell自己身体里。管理员可以靠着这几个内建命令,在崩溃的环境里艰难地挪动、查看、自救,比如cd到备份目录去恢复系统。如果连cd都是外置的,那路径一坏,你连挪个窝都做不到。

3. 兼容性与POSIX标准

POSIX 标准规定了不少必须存在的工具,比如test、[、kill。按照标准,这些命令必须在磁盘上有一份独立的、可执行的二进制文件,这样任何调用方,比如C语言的system()函数,或者不带 Shell的环境都能找到它们。

但Shell也有自己的算盘。为了效率和特性支持,比如 kill 要操作作业控制,Shell又必须在内部自己实现一份。于是,就出现了“内建版”和“外置版”并存的局面。你在终端敲kill,走的是Shell的内建版本;但如果你在某个不依赖 Shell 的环境里调 kill,磁盘上的外置版本照样能顶上。两边不冲突,各有各的用途。

所以,内建命令不是某些命令“住”在Shell里这么简单。它们是Shell的自救工具、状态操纵杆,也是 POSIX 标准下的一种兼容性妥协。理解了这三层,你才算真正明白为什么Shell要“脚踏两条船”,内建命令和外部命令,各有各的主场,缺一不可。

4.2.5 chdir()的小细节——只改变工作目录,不改变环境变量

当你敲下cd ..的时候,底层真正动起来的是chdir("..")。这个系统调用干得挺利索,但也藏了一个特别容易踩的坑。

先看内核到底做了什么:chdir成功之后,内核已经把进程PCB里的cwd(当前工作目录)指针改掉了。从物理意义上讲,进程的落脚点确实变了,它已经真的“站”在了上一级目录里。

但诡异的地方来了:它不会顺手更新环境变量表里那个叫PWD的字符串。

PWD还是老样子,还指着你进来之前的那个路径。这就坏事了。你的GetCwd()也好,getenv("PWD")也好,拿到的都是旧路径。结果就是:人已经走了,身份证上的地址还没改,提示符上显示的目录跟实际所在目录对不上号,出现了“假移动”的诡异现象。你明明已经cd出去了,提示符却还赖在原地。

所以,一个真正完善的Shell,在调用chdir成功后,绝不能拍拍屁股就走。它必须手动再补一刀,用putenv或setenv把PWD环境变量同步更新过来。这样提示符才能跟实际路径步调一致,不会出现“人在新家,提示符还留在旧宅”的错位。这也是为什么系统里的正版Shell从不出这种差错因为它们在chdir之后,都默默做完了这套“改完路径顺手改变量”的收尾动作。我们手写的迷你Shell如果忽略了这一步,迟早会在某个cd之后露馅。

3.2.6 命令加不加 -f 的区别

在Linux的命令行生态里,-f(--force)这个选项随处可见rm -f、mkdir -f,它代表的是“强制”。但你有没有想过,加不加这一个小字母,底下的逻辑差别能有多大?我们直接看表。

场景不加 -f加 -f
文件存在正常删除正常删除
文件不存在报错:“No such file or directory”静默失败,不吭一声
只读文件(无确认)会问你一句,等你敲y直接删,不废话
返回码(文件不存在时)非0,代表失败0,居然算成功

看明白了吗?这一个小-f,骨子里做的是两件事:把“没成功”伪装成“成功了”,把“该问的”全咽回肚子里。

拿rm举例最直观。删除一个不存在的文件时,不带-f的rm会老老实实告诉你:“No such file or directory”,返回码也不是0,因为它确实没干成。可一旦加了-f,它连个响都没有,失败了也当成功处理,返回码直接是0。

对于只读文件也一样。不带-f,它会停下来问你:“这是个只读文件,确定要删吗?”你得亲手敲个y它才动手。带上-f,这层确认直接跳过,说删就删,哪怕那是你昨晚刚写完的毕业论文。

所以-f的本质是什么?它是一把“强行推进”的开关。它把“该报错的”压成无声,把“该确认的”变成默认同意。这在写Shell脚本时非常实用,脚本无人值守,不能指望谁会来敲y,所以需要-f来保证流程畅通。但也正因如此,rm -rf才会成为人人谈之色变的“自杀指令”。加不加-f,天差地别。


到这里,Linux进程篇就正式收官了。从fork到exec,从僵尸到孤儿,从优先级到虚拟地址空间,再到今天手写的迷你Shell,我们一步一个脚印,把Linux进程管理的整张版图拼了起来。而这款只有三个阶段的小Shell,恰恰是最好的毕业作品:它把前面攒下的所有家伙,全用上了。

最后再回味几个要点:

  • Shell的一生,就是四步循环:打印提示符、获取输入、切分字符串、识别执行。看似简单,但所有命令行工具都逃不出这个骨架。
  • 内建命令必须在父进程里跑,否则cd改了子进程的目录,父进程纹丝不动,等于白切。
  • 环境变量表要自己维护,启动时从系统environ导入,以后增删改查才随心所欲。
  • chdir 之后,PWD不会自动更新,得手动putenv同步,否则提示符会跟实际路径“闹分居”。
  • 一个小小的-f,背后是“把失败当成功”和“跳过一切确认”的双重逻辑。它是脚本自动化的利器,也是危险操作的源头。

这个迷你Shell虽然只完成了前三个阶段,但它已经把命令行交互最核心的秘密抖搂得七七八八了。至于第四阶段,管道、重定向、后台运行那些功能需要文件描述符的底子来撑,所以我们把它安排到Linux文件篇再继续攻坚。

进程篇到此告一段落。下一篇,我们将踏入文件篇的疆域,继续把这套Linux知识体系往下铺。

感谢一路看到这里的你。如果这个系列对你有帮助,欢迎点赞、收藏、关注三连支持。你的每一次正反馈,都是我继续肝下一篇的最大动力。我们文件篇见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值