Nook in the Lunar Mare

编写Unix-Like Shell

Jan 1, 2019


原本是之前打算完成CSAPP的Shell Lab,后来直接写成完整的shell Shell是一个与操作系统结合得比较紧密的东西,所以可移植性上会有较大欠缺。通常会使用一些操作系统相关的函数。

代码实现

实现基于smsh4的版本改出来,首先是对源代码分析一下,最终的版本已放在github

Shell原理

  1. 从shell中运行程序
  2. shell内置命令
  3. 可以IO交互
  4. 支持脚本

shell的主体

shell大概就是一个循环执行,不断派生进程,然后进程退出的一个周而复始的过程。这个过程可以用伪代码表示出来。

shell_main:
    loop:
        cur_cmd = next_cmd()
        cmd, args = parse_cmd(cur_cmd)
        start_proc(exec(cmd, args))
    loop_end;

shell设计

前面提到可以通过exec执行完成shell内的功能运行,但是这样做会退出,所以通常使用fork新开一个子进程执行程序,主进程放我们的main。这样解决了完成命令后继续执行shell的功能。随之而来的问题是我们常使用的信号量,当按下Ctrl-C,SIGINT信号将发给所有与终端连接的进程,因此shell设计需要处理这方面问题,这里我记得应用程序应该可以忽略信号量。最后最后,shell要支持清空,不然用起来实在难受(sqliteShell和adb,说的就是你们)。

特别的功能点

清屏(implement clear screen)

参考Bash。这个功能涉及到对屏幕的操作,如果可以引入库的话直接用curses实现,但我对UNIX是否提供自带的命令行屏幕操作接口不清楚,所以想参考有没有人不借助curses实现。我首先寻找了bash中的signal处理部分,一开始我以为ctrl-l会发出信号,然而实际上,ctrl-l仅仅是一个特殊的ASCII字符(0xC)。所以最终定位到清屏实现的方法是搜索screen,代码在display.c这个文件内,对应的text.c文件中也有清屏,只是其实际上是对display内方法的封装。这边把这个东西抄过来。

void
_rl_clear_screen (void)
{
#if defined (__DJGPP__)         // windows 编译
  ScreenClear ();
  ScreenSetCursor (0, 0);
#else
  if (_rl_term_clrpag)          // 借助terminfo的操作
    tputs (_rl_term_clrpag, 1, _rl_output_character_function);
  else
    rl_crlf ();
#endif /* __DJGPP__ */
}

tputs应该是调用了tput操作,来自于termcap库(使用strace看clear也能捕获类似信息)。我们的shell实现取巧一些,调用tput就好,接着要处理的就是如何捕获ctrl-L。这边需要注意的就是getc的缓冲问题。这边有一个linux上利用terminos的解决方案。照猫画虎配置utils中的termios,使之能捕获ctrl-L

int main()
{
    struct termios old_tio, new_tio;
    unsigned char c;

    /* get the terminal settings for stdin */
    tcgetattr(STDIN_FILENO,&old_tio);

    /* we want to keep the old setting to restore them a the end */
    new_tio=old_tio;

    /* disable canonical mode (buffered i/o) and local echo */
    new_tio.c_lflag &=(~ICANON & ~ECHO);

    /* set the new settings immediately */
    tcsetattr(STDIN_FILENO,TCSANOW,&new_tio);

    do {
         c=getchar();
         printf("%d ",c);
    } while(c!='q');
    
    /* restore the former settings */
    tcsetattr(STDIN_FILENO,TCSANOW,&old_tio);

    return 0;
}

目前可以实现clear了,按下ctrl-l会打印出clear,代表我们可以达到这个路径。接下来就是在ctrl-L的case内调用tput clear。

处理特殊ASCII字符

接下来由于我们直接捕获了所有键盘输入,不再存在缓冲区的概念,因此对于不可打印的特殊ASCII字符需要我们手动操作。这是因为原本带缓冲区的版本帮我们完成了诸如删除,中断,EOF,回显字符等功能,需要我们捕获每个ASCII字符,分别处理,这边选择switch处理这些事件。

    while(True){
        /* Handle some ctrl char */
        switch (ch)
        {
        case CTRL_L:
            printf("Clear");
            break;
        case '\n':
            /* read done */
            /* just break out */
            putc(ch, stdout);   //echo
            goto cmd_end;
            break;
        case '\b':
            // reach the $>> prompt
            if(cur_pos == 0)
                break;
            putc(ch, stdout);
            putc(' ', stdout);
            putc(ch, stdout);
            cur_pos -= 1;
            break;
        case CTRL_D:
            /* Deal with Ctrl-D */
            if(cur_pos == 0)
                printf("Exit Shell\n");
                return NULL;
            break;
        default:
            /* continue to read command char */
            buf[cur_pos++] = ch;
            putc(ch, stdout);   //echo
            break;
        }
    }

cmd_end:
        /* pack cmd, just set a NUL at the end */
        buf[cur_pos] = '\0';
        return buf;
  1. backspace的实现采用一个比较取巧的方法,直接输入\b仅仅是与方向键一样移动光标{=tex}(原本这个符号的意思也仅仅是回退,vim里替换模式就采用了这个),所以实现删除功能是通过"回退->输出空字符->回退",这样模拟出回退同时删除的功能。此外,回退时要注意cur_pos的位置变化,以及不要删除前面的prompt。
  2. 方向键,具体方向键的ASCII映射应该是与keyboard driver相关的,底下的参考链接4给了一个网站,可以用于查看上下键的ascii码.这边暂定为方向键up和down操控历史记录,左右键光标移动。糟糕的是这个网址展示的上下键ascii码并不适合我的情况,这是因为UNIX终端中使用特殊的方法表示方向键(参考链接5),所以实际上我需要的是处理如下情况,并不是单个的ascii字符。
<esc>[A,上
<esc>[B,下
<esc>[C,右
<esc>[D,左

相应的你可能见过终端有时候抽风,显示这样的字符 ^[[A ^[[B ^[[C ^[[D,其中ctrl-[也就是ESC是以^[表示的。总之要重新改代码了。再次测试即可捕获方向键。注意左和右的数值,第一次编译没检查这个错误,导致非常奇怪的bug以及segment fault。
        case ESC:
            if((ch = getc(fp)) == '[')
            {
                ch = getc(fp);
                switch (ch)
                {
                // up
                case 'A':        
                    his_string = previous_history();
                    printf("%s", his_string);
                    sprintf(buf, "%s", his_string);
                    cur_pos = strlen(his_string) - 1;
                    break;
                
                case 'B':
                    his_string = next_history();
                    printf("%s", his_string);
                    sprintf(buf, "%s", his_string);
                    cur_pos = strlen(his_string) - 1;
                    break;

                default:
                    printf("^[");
                    putc(ch, stdout);
                    break;
                }
            }
            break;

Tab补全

其实这也算一个特殊字符处理,只是比较重要就放在这了。Tab是ASCII表中的Horizental Tab, 也就是ctrl-I,其代码为0x9.有些补全的实现是采用shell脚本完成的,centos的/etc/bash_complete.d下有不少这类脚本。如果要实现最简单的版本,思路是获取当前已输入的字符,然后得出长度len,在已知的补全表中匹配长度len的相同字符作为备选项。想想有点麻烦,这边采用UNIX的工具complete实现这个功能。发现compelet和compgen的功能是builtin,因此换一个思路,使用find命令寻找指定路径,仅提示而不进行补全。

History实现

History可以将每个命令不论错误或是正确存入文件里,文件中最多保存MAX_HISTORY条命令,最新一条命令的cursor为0,当敲下up键变动cursor(cursor++),直至cursor达到,同理,向下直至cursor为0.
history的思路就是这样,具体实现时我设置MAX_HISTORY为1000,可以视为一个char*数组,每个元素都是一个字符串指针,这些指针都指向由malloc分配的内存区域,所以最终是需要一个free操作释放这些字符串,因此History文件被我实现在一个单一的文件中,提供init方法和free的方法,在主程序的初始化过程和销毁过程中调用。

------------------------------------------
|   |   |   |   |   |   |   |   |   | ....
------------------------------------------

这样做的一个好处是实现简单,cursor操作即可,然而对于超过1000条的历史怎么办呢?UNIX日志对超出上限的记录采用循环覆写的方式,顶替最早的记录,所以历史文件中记载命令的顺序最好是从旧到新,但cursor模式在这里就有局限性,要保证循环可用的话编程时脑子里的复杂度就会升高,所以我选择通过函数使得访问这个数组就如访问queue。新增历史记录则入队,当新增的记录已经达到队尾则队首出队,当更新历史记录文件可以以队首到队尾的顺序写入。在这个模式下,cursor是相对队尾的偏移量,0代表最新的命令,为了简洁我直接让最新命令返回NULL,即清空当前的命令行。

------------------------------------------------------
|   |   |   |   |   |   |   |   |   |   |   | ....
-----------------------------------------------------
  ^                       ^               ^
queue_hdr           queue_cursor=4      queue_tail

队列额外维护一个queue_len,可以用于定位当前cursor对应的实际命令,使用的公式是这样的 > cur_history_locate_index = (queue_hdr + queue_len - queue_cursor) % MAX_HISTORY

最后的工作就是处理历史命令写入文件中,这边找不到很好的方法在文件的首行插入,所以都是将整个历史文件数组记录的命令重新写入,性能可能不够好。

插入模式

即支持光标移动后,输入的字符会被插入在光标之前,而非进行字符替换。这一块主要是一些繁琐的工作,我分为两个部分实现,一是视觉效果,二是实际的命令。首先思考下面这个场景,我们输入了部分的命令,发现需要在前面插入一个字符:

buf:
    -------------------------------------------------------------------------------
    | 'p' | 'i' | 'g' | ' ' | 'w' | 'w' | 'w' | '.' | 'a' | '.' | 'c' | 'n' |
    -------------------------------------------------------------------------------
                                                                               ^
                                                                            cur_pos/col_pos
其中cur_pos是当前命令在buffer中的结尾位置,新建一个col_pos指示光标位置。需要把'pig'改成'ping',则先移动光标

buf:
    -------------------------------------------------------------------------------
    | 'p' | 'i' | 'g' | ' ' | 'w' | 'w' | 'w' | '.' | 'a' | '.' | 'c' | 'n' |
    -------------------------------------------------------------------------------
                         ^                                                      ^
                       col_pos                                               cur_pos

之后进行一个循环的复制操作即可。
buf:
    -----------------------------------------------------------------------------------
    | 'p' | 'i' | 'n' | 'g' | ' ' | 'w' | 'w' | 'w' | '.' | 'a' | '.' | 'c' | 'n' | 
    ------------------------------------------------------------------------------------
                   ^                                                                 ^
                 col_pos                                                           cur_pos

至此实现的是命令部分,视觉效果上依旧是替换,我采取的是比较苯的方法,重新将col_pos之后的字符打印,实验表明肉眼察觉不到这个变化。

Redirect实现

重定向的原理在了解之后并不是特别困难,主要是利用UNIX的文件描述符机制和dup2()这个函数修改了文件描述符指向的对象。如果我们将屏幕设备视为一个文件,stdout原本指向这个文件的写端,重定向干的事情就是令程序内的stdout指向我们指定的文件,这边推荐看一下链接中的dup2介绍,这个系统函数帮我们实现了这个操作。
为了减少复杂性,实现的重定向功能仅仅支持两个对象,也就是类似’echo xxx > a.txt’这样单个重定向符的情况。这个情景下,redirect需要完成的事情有两件

  1. 以指定模式(读/写/追加)打开文件,获取其文件描述符
  2. 调用命令执行

这个函数很简单,直接放在这里

int start_redirect(char **arglist)
{
    char **left;
    char *mode;
    char *right;

    // count
    int argc = 0;
    if(arglist == NULL || arglist[0] == NULL){
        perror("error parse in redirect");
        return -1;
    }

    while(arglist[++argc] != NULL);

    if(argc < 3){
        perror("too less to redirect");
        return -1;
    }

    left = arglist;
    mode = arglist[argc - 2];
    right = arglist[argc - 1];

    if(strstr("> >> <", mode) == NULL){
        perror("Not support redirect");
        return -1;
    }

    init_redirect();

    if(strcmp(">", mode) == 0){
        redirect_write_to(right);
        left[argc - 2] = NULL;
        execute(left);
    }
    else if(strcmp(">>", mode) == 0){
        redirect_append_to(right);
        left[argc - 2] = NULL;
        execute(left);
    }
    else{
        redirect_input_from(right);
        left[argc - 2] = NULL;
        execute(left);
    }

    end_redirect();
    return 0;
}

由于我是在原本的进程中对STDOUT_FILENO进行修改,之后再调用execute执行命令,我的文件描述符会被拷贝到子进程,最终完成重定向,因此最后需要恢复主进程的STDOUT_FILENO,所以redirect_init包含了对原始的stdout描述符的保存。

Pipe实现

Pipe也是类似的,通过修改文件描述符指向的对象,使得cmd1的输出能够成为cmd2的输入,这样形成一个链条。cmd1与cmd2通常是两个进程,进程之间如何通信呢?UNIX提供了管道机制,可以进行父子进程之间的通信,Pipe就好像父子进程之间的管道,管道两端都支持读写,我们可以将父进程的stdout连接到父进程管道的写入端,另一边子进程管道的读端连接子进程的stdin,反复这个操作,进程之间就被连接起来了。
确定使用管道机制,下一步就是如何生成多个父子进程?我实现方式是通过传递一个fork_level变量递归地进行fork,直至fork_level为0停止fork。
听起来好像很容易,实际上有一些地方还是有些绕的,出了奇奇怪怪的bug,我建议整个过程可以在草稿纸上推演一下,调试时学习一下gdb的多进程调试功能,跟踪子进程流程,可以方便定位出错误。参考连接的6,8,9,10都是不错的文章,有助于理解pipe。其中有一个python的pipe实现是我认为结构比较清晰的一个实现,并进行了模仿。

补充资料

参考

  1. ctrl-L
  2. non buffered Getch
  3. ASCII Table
  4. key up and down监测
  5. UNIX终端中的方向键
  6. PIPE
  7. Bash源码解读重定向
  8. GDB调试多进程
  9. PIPE管道
  10. SIGCHILD SIGPIPE . GNU project . Bash Again