Shell

本文目标

  • 学习 Shell 的基本操作
  • Shell 的执行流程
  • Shell 的实作

进入正题

Shell 是一套能够监听使用者命令、解析命令再告知作业系统核心完成命令 (System call) 的工具。
因为这套工具是利用 OS kernel 提供的系统呼叫完成作业的,所以可以把它想像成 OS kernel 的外壳。

Shell 的基本操作

一般来说,Shell 的运作流程如下:

  1. Prompt ($)
  2. 使用者输入命令
  3. 依照路径寻找命令的位置
  4. 产生 Child process 完成命令
  5. 输出结果或是错误运行
  6. 再次出现 Prompt

万用字元

通常来说,系统都会支援以下万用字元

  1. *: 表示不限长度的任何字元
cat *.html
  1. ?: 表示任一字元
cat in?ex.html
  1. []: 表示中括号内的其中一个字元
cat index[12].html

特殊字元

  1. \: 取消後面字元的意义
    假设我希望印出 * 字元的内容,就不能直接使用 *,因为该符号已经是万用字元了。这时候,可以这样做:
echo \* star \*
  1. ': 取消字串中的特殊意义
  2. ": 取消字串中除了 $'\ 的特殊意义
  3. `: 执行字串中的命令

file descriptor

请直接参考本系列的档案系统篇,该文已经探讨过什麽是 File descriptor

pipe

pipe 可以让使用者连结多个命令,参考以下命令:

cat file_1 | sort

在不使用 pipe | 时,cat 会将 file_1 的内容输出到终端机上。使用 pipe 後,cat 的输出会作为 sort 程序的输入,等到排序完成後才做输出。

redirection

我们可以利用转向符号 >< 改变终端机输出与输入的对象。

  1. 输出转向
cat file_1 > file_2

将 file_1 的内容输出到 file_2。

2. 输出附加转向

cat file_1 >> file_2

如果 file_2 原先就有内容且使用者需要保留该资料,可以使用 >> 让 file_1 的内容接续到 file_2 的内容後面。
3. 输入转向

cat < file_1 > file_2 

输入转向会将原来由键盘输入的资料改为使用者指定的周边、档案替代。
上面的命令会将 file_1 作为输入并输出到 file_2 上面。

更多 cli 工具

碍於篇幅问题,笔者仅罗列几个常见工具并大概说明其用途,详细的使用方法请服用连结内的文章。

  • cat

cat (concatenate) 工具能够连结并显示文字。

cat index.html
  • grep

强大的档案搜寻工具。

cat index.html | grep head
  • less

分页工具,在读取内容巨大的档案时不会一次载入全部的内容,可以加快载入的速度。

cat index.html | less

查找行程开启的档案

lsof -c chrome

查看目前执行中的 Process。

ps -A

专案观摩: Picoshell

该专案为成功大学的 Linux 核心设计小考题目,题目中提供了一个未完成的迷你 shell 程序,让学员透过考试检验 C 语言程序设计的认知:

  • [ ] fork, wait, exec
  • [ ] stdin, stdout, stderr
  • [ ] pointer

运作流程

  1. 首先看到 main():

    int main()
    {
        while (1) {
            prompt();
            char buf[512] = {0}; /* input buffer */
            char *c = buf;
            if (!fgets(c + 1, sizeof(buf) - 1, stdin))
                exit(0);
            for (; *++c;) /* skip to end of line */
                ;
            run(c, 0);
        }
        return 0;
    }
    

    我们可以得知 main() 会重复执行 while loop 的内容:

    • 执行 prompt()

      /*
       *  印出 $
       */ 
      static void prompt()
      {
          write(2, "$ ", 2);
      }
      
    • 设置 buffer 大小并将 stdin 的内容读入

    • 利用 for loop 将 c 指标指到 buffer 的最後一个字元
      这边要注意的是 fgets(c + 1, sizeof(buf) - 1, stdin),这个行为保留了 buffer 的第一个位置,等等看到 run() 时就会明白为什麽要这麽做。

  2. 运行 run()

  • 在查看 run() 的原始码之前,我们先看到其他定义好的 static 函式,这些函式会被 run() 呼叫,帮助 shell 判断特殊的 token 以及印出错误讯息:

    /* Display error message, optionally - exit */
    static void fatal(int retval, int leave)
    {
        if (retval >= 0)
            return;
        write(2, "?\n", 2);
        if (leave)
            exit(1);
    }
    
    /* Helper functions to detect token class */
    static inline int is_delim(int c)
    {
        return c == 0 || c == '|';
    }
    
    static inline int is_redir(int c)
    {
        return c == '>' || c == '<';
    }
    
    static inline int is_blank(int c)
    {
        return c == ' ' || c == '\t' || c == '\n';
    }
    
    static int is_special(int c)
    {
        return is_delim(c) || is_redir(c) || is_blank(c);
    }
    
    • run() 中的字串处理
    size_t length;
    char *redir_stdin = NULL, *redir_stdout = NULL;
    int pipefds[2] = {0, 0}, outfd = 0;
    char *v[99] = {0};
    char **u = &v[98]; /* end of words */
    for (;;) {
        c--;
        if (is_delim(*c)) /* if NULL (start of string) or pipe: break */
            break;
        if (!is_special(*c)) {
            /* Copy word of regular chars into previous u */
            length = 0;
            while(!is_special(*c)){
                length++;
                c--;
            }
            u--;
            c++;
            strncpy(*u, c, length);
            u[length] = '\0';
        }
        if (is_redir(*c)) { /* If < or > */
            if (*c == '<')
                redir_stdin = *u;
            else
                redir_stdout = *u;
            if ((u - v) != 98)
                u++;
        }
    }
    if ((u - v) == 98) /* empty input */
        return;
    
    if (!strcmp(*u, "cd")) { /* built-in command: cd */
        fatal(chdir(u[1]), 0);
        return; /* actually, should run() again */
    }
    
    1. run() 中的 v 变数为指向 char pointer 的 array,也就代表他是用来存放多个字串的,可以让我们在解析 command 後把内容存进去。
    2. u 变数则是指向 char pointer 的 pointer,在这边被用来指向 v 存放的最後一个字串。
    3. 每次执行 for loop 都会将指标往前指,做到 parse command 的作用。
    4. 下面的判断式可以让我们知道 command 已经到头了 (也就是刚刚提到的 fgets(c + 1, sizeof(buf) - 1, stdin)) 或是 遇到 pipe
      if (is_delim(*c)) /* if NULL (start of string) or pipe: break */
      break;
      
    5. 接着这边是小考的作答区,主要是做一些字串的处理,!is_special(*c) 会在字元为一般字母时成立。当条件成立以後,笔者让指标持续移动直到遇到特定字元,这时候我们就可以确定指标之後的 length 个字元就是我们要的命令:
    length = 0;
        while(!is_special(*c)){
            length++;
            c--;
        }
        u--;
        c++;
        strncpy(*u, c, length);
        u[length] = '\0';
    
    • 处理 pipe 与执行
      剩下的部分就是做 pipe 以及 redir 的後续处理,最後在使用 execvp 执行我们一开始输入的 command:
        if (*c) {
            pipe(pipefds);
            outfd = pipefds[1]; /* write end of the pipe */
        }
    
        pid_t pid = fork();
        if (pid) { /* Parent or error */
            fatal(pid, 1);
            if (outfd) {
                run(c, outfd);     /* parse the rest of the cmdline */
                close(outfd);      /* close output fd */
                close(pipefds[0]); /* close read end of the pipe */
            }
            wait(0);
            return;
        }
    
        if (outfd) {
            dup2(pipefds[0], 0); /* dup read fd to stdin */
            close(pipefds[0]);   /* close read fd */
            close(outfd);        /* close output */
        }
    
        if (redir_stdin) {
            close(0); /* replace stdin with redir_stdin */
            fatal(open(redir_stdin, 0), 1);
        }
    
        if (t) {
            dup2(t, 1); /* replace stdout with t */
            close(t);
        }
    
        if (redir_stdout) {
            close(1);
            fatal(creat(redir_stdout, 438), 1); /* replace stdout with redir */
        }
        fatal(execvp(*u, u), 1);
    }
    

总结

Jserv 老师在题目设计上的用心不言而喻,之前在修大学部的作业系统时,shell 跟 pipe ... 等观念都只有在恐龙书上看到,所以笔者在看到这个作业时就把他拿来玩看看了,虽然正解只需要补齐大约 10 行程序码,但要补出这短短的程序码需要掌握 C 语言技巧并且理解 shell 的行为、如何使用 fork()exec()
然後笔者在本文附上的个人解答其实不够精简,建议可以自行去 Trace 老师提供的正解

此外,可能会有读者想要问我为何不在 mini-riscv-os 中加入 Shell 的实作,主要是因为 mini-riscv-os 缺乏了这些要素:

  • File System (目前仅实作 Disk Driver,要实作档案系统的话很多架构都需要重新考虑)
  • System call (目前有人提交 PR,但还不支援 exec 系统呼叫)

当然,如果有读者兴趣提交相关 PR 的话,我绝对举双手赞成 XD

Reference


<<:  找LeetCode上简单的题目来撑过30天啦(DAY23)

>>:  Day23-Kubernetes 那些事 - CronJob

Day 18:501. Find Mode in Binary Search Tree

今日题目 题目连结:501. Find Mode in Binary Search Tree 题目主...

[Cmoney 菁英软件工程师战斗营] IOS APP 菜鸟开发笔记(7)----自定义弹出视窗

前言 因为UI和UX方面的需求,这几天上网搜寻了如何自定义下一页的弹出大小,弹出位置和动画,发现有蛮...

【Day23】I2C Master(Write)的实现

上一篇我们设计了 I2C Master 的状态机,那麽我们今天要来引用上次完成的状态机模块来实现 I...

Day24-Go Json处理

前言 上两篇中我们在介绍 Go 网路的操作中,有稍微提到 json 格式,那这篇将介绍有关何为 js...

Day 13 Flask Route

首先,作为一个 Web 的框架,主要就是当作网页的 Server 在运作的,网页中必不可少的就是网址...