初识Linux · 自主Shell编写

[复制链接]
发表于 2026-10-5 19:45:02 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×

所谓 shell,就是外壳解释程序。我们日常看到的命令行提示符、远程登录的 SSH,本质都是 shell。本文带你在 CentOS 上从零写一个简化版 shell,模拟 bash 的核心行为,目标是搞清楚命令行是怎么被一行行读进来、拆开、 fork 出去执行的。过程中会用到进程替换、进程等待、进程退出码这些前面的基础知识,它们是写 shell 的铺垫而非主体。

一、命令行提示符部分

先看一眼平时看到的提示符长什么样:_lazy@VM-12-14-centos ~。开头的 _lazy 是当前用户名,@ 后面是主机名,波浪号代表当前所处目录。我们的目标就是把这副样子原样复刻出来。

从哪儿拿用户名、主机名和当前目录

环境变量这时候就该登场了。输入 env,能看到一长串环境变量,其中 HOSTNAME、PWD、USER 分别对应主机名、当前路径和当前用户名。

取这三种途径:environ 全局环境变量表、命令行参数表、getenv。这里用 getenv,比二级指针 environ 更好读。三个小函数这样写:

#define SIZE 512

char* GetUser()
{
    char* user = getenv("USER");
    if (user == NULL) return NULL;
    return user;
}

char* Gethost()
{
    char* host = getenv("HOSTNAME");
    if (host == NULL) return NULL;
    return host;
}

char* Getcwd()
{
    char* cwd = getenv("PWD");
    if (cwd == NULL) return NULL;
    return cwd;
}

用 snprintf 拼出一整串

由于提示符后面还要接输入,不能拿换行当结束,用 snprintf 先把三段内容拼进一个字符数组,再打印这个数组。完整输出函数:

void OutputBash()
{
    char line[SIZE];

    char* username = GetUser();
    char* hostname = Gethost();
    char* cwd = Getcwd();

    snprintf(line, sizeof(line), "[%s@%s %s]>", username, hostname, cwd);
    printf("%s", line);
    fflush(stdout);
}

这里的 fflush(stdout) 不能省:提示符要等命令执行完才换行,不强制刷新,屏幕就会卡住不动。

如果 man 手册不全,补一下即可:sudo yum install man-pages。

只显示当前目录名

上面把整个路径都打出来了,不够像。理想效果只显示最后一段目录名。办法是把指针移到最后一个斜杠处,用宏比用函数省事(函数要传二级指针):

#define SkipPath(p) do { p += (strlen(p) - 1); while (*p != '/') p--; } while (0)

但要兼容根目录的单个斜杠,条件判断写成这样更稳:

snprintf(line, sizeof(line), "[%s@%s %s]>", username, hostname, strlen(cwd) == 1 ? "/" : cwd + 1);

二、获取用户输入的命令行

接下来要接用户敲的命令。用 scanf 行不行?想想 ls -l -n -a 这种带参数的——scanf 遇空格或换行就断,只能拿到 ls 一个词。所以这里改用 fgets(gets 也行,但后面要接文件操作,fgets 更适合当缓冲区):

int GetUserCommand(char* usercommand, size_t n)
{
    char* s = fgets(usercommand, n, stdin);
    if (s == NULL) return -1;

    return strlen(s);
}

这段代码有个隐患,第四部分会讲到。

三、拆分命令行参数

拿到整行命令不能直接执行。C 库函数 strtok 负责切分:第一个参数是待切字符串,第二个是分隔符集,切一次之后把第一个参数置 NULL 就能继续切。切完的结果存进数组,供后面的进程替换使用。

#define SEP " "
char* gArgv[SIZE];

void SplitCommand(char* usercommand)
{
    gArgv[0] = strtok(usercommand, SEP);
    int index = 1;
    while ((gArgv[index++] = strtok(NULL, SEP)));
}

两个值得留意的细节:分隔符是双引号包起来的空格字符串,注意它是 const char* 而不是单个字符,别写成单引号;另外 strtok 切完返回 NULL 正好可以当数组结束标志,循环写得很省。

四、执行命令

先不管内建命令,直接执行试试。核心是进程替换:

void ExcuteCommand()
{
    pid_t id = fork();

    if (id < 0) Die();
    else if (id == 0)
    {
        // child
        execvp(gArgv[0], gArgv);
        exit(1);
    }
    else
    {
        // father
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        if (rid > 0)
        {
            lastcode = WEXITSTATUS(status);
            if (lastcode != 0) printf("%s:%s:%d\n", gArgv[0], strerror(lastcode), lastcode);
        }
    }
}

子进程 execvp 找不到命令就 exit(1);父进程 waitpid 收尸,用 WEXITSTATUS 取退出码存进 lastcode,非 0 就报一行错。代码里的比较运算符是小于与大于两个符号,写在 HTML 里必须转义成 &lt; 与 &gt;,否则浏览器会把它当成标签。

到此还跑不起来,因为 fgets 会把敲的那一下回车也读进来,命令末尾带着换行,execvp 自然失败。把末尾回车清掉就行,宏定义 ZERO 完成这一步。

回头看第二步 fgets 的隐患:如果用户输入超过缓冲区长度,剩下的内容会滞留在 stdin,下一次读取就会拼上一段脏数据。真要做得稳,得处理"读满但未读完"的情况。

五、判断内建命令

cd、echo 这类内建命令只能在父进程里做,不能 fork 子进程去执行。判断逻辑简单粗暴,strcmp 比字符串即可:

void Cd()
{
    const char* path = gArgv[1];
    if (path == NULL) path = Gethost();
    // path 一定存在
    chdir(path);

    // 刷新环境变量
    char temp[SIZE * 2];
    getcwd(temp, sizeof(temp));
    snprintf(cwd, sizeof(cwd), "PWD=%s", temp);
    putenv(cwd);
}

int IsInorder()
{
    int yes = 0;
    const char* enter_cmd = gArgv[0];
    if (strcmp(enter_cmd, "cd") == 0)
    {
        yes = 1;
        Cd();
    }
    else if (strcmp(enter_cmd, "echo") == 0 && strcmp(gArgv[1], "$?") == 0)
    {
        yes = 1;
        printf("%d\n", lastcode);
        lastcode = 0;
    }
    return yes;
}

以 cd 为例:判断命中内建之后,在 Cd 里用 chdir 切当前工作目录,再用 getcwd 拿到新路径,拼成 PWD=... 后 putenv 写回环境变量。这样下一次提示符才能显示正确路径。

echo $? 是另一类内建:它要读父进程自己存的 lastcode,子进程里读不到父进程的上次退出码,所以也只能父进程执行。执行完顺手把 lastcode 清零,下一次才准。

六、一个完整 shell 该补齐什么

到这里,一个能显示提示符、能读命令、能切参数、能执行外部程序、能处理 cd 与 echo $? 的迷你 shell 就成型了。往真实 bash 再走,还差几块:

  • 历史与行编辑:上下键翻历史、Tab 补全,需要 readline 库。
  • 管道与重定向:解析 |、>、<,配合 dup2 做重定向。
  • 环境变量展开:$VAR 的替换与展开。
  • 信号处理:Ctrl+C 不该让 shell 自己死掉,要交给子进程,用 signal 加 job control。
  • 读满残留:处理第二步提到的输入缓冲问题。

每补一块,都对应一类系统调用。写 shell 最大的收获不是造出一个 bash,而是把"一条命令从敲下到跑起来"这件事彻底拆开看了一遍。

qidao123.com企服评测-应用市场 编辑提示

本文按命令行提示符、读取用户输入、拆分参数、进程替换执行、内建命令判断五个步骤,还原一个简化版 Linux shell 的实现过程,并说明 fgets 残留输入、回车未清、cd 与 echo 星号必须父进程执行等具体问题处。文中代码为教学示例,仅建议在自有测试环境的实验中运行,勿在生产机器上覆盖系统 shell;涉及 chdir、putenv 这类改动进程状态的调用,请先理解其作用范围再动手。

回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表