编写Unix-Like Shell
原本是之前打算完成CSAPP的Shell Lab,后来直接写成完整的shell Shell是一个与操作系统结合得比较紧密的东西,所以可移植性上会有较大欠缺。通常会使用一些操作系统相关的函数。
代码实现
实现基于smsh4的版本改出来,首先是对源代码分析一下,最终的版本已放在github
Shell原理
- 从shell中运行程序
- shell内置命令
- 可以IO交互
- 支持脚本
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;
- backspace的实现采用一个比较取巧的方法,直接输入
\b仅仅是与方向键一样移动光标{=tex}(原本这个符号的意思也仅仅是回退,vim里替换模式就采用了这个),所以实现删除功能是通过"回退->输出空字符->回退",这样模拟出回退同时删除的功能。此外,回退时要注意cur_pos的位置变化,以及不要删除前面的prompt。 - 方向键,具体方向键的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需要完成的事情有两件
- 以指定模式(读/写/追加)打开文件,获取其文件描述符
- 调用命令执行
这个函数很简单,直接放在这里
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实现是我认为结构比较清晰的一个实现,并进行了模仿。