首页 > 代码库 > 学习理解shell的好办法--编写自己的shell 之一

学习理解shell的好办法--编写自己的shell 之一

本文参考自《Unix/Linux编程实践教程》, 这是一本讲解unix系统编程的书,注重实践,理解难度不大,推荐大家阅读,敲完本书后,对于理解unix系统如何运作会有更深的视角,回过头再学习别的 Linux相关的东西时,感受非常不一样,这是一本可以提高“内功”的书。自己加了些很菜的解释,以便其他小白理解,大牛直接飘过吧,错误之处希望指正。

shell是一个管理进程和运行程序的程序,用来人和机器交互 


常用的shell如sh,bash,zsh,csh,ksh等都有三个主要功能:
1. 运行程序
date, ls, who都是用C写的实用程序, shell负责将它们装入内存运行, 因此shell可以看成一个程序启动器 


2. 管理输入输出
利用重定向符号<, >,管道符号 | , 可以告诉shell将输入输出定向到文件或其他进程,也可以从文件定向到标准输入输出。尤其是管道,感觉非常酷!通过组合那些基本命令,实现很多功能 


3. 可编程
即带有变量和控制。其实变量是缓冲思想的在最小处的一个应用,先暂存到一个地方,一会儿再用。控制即if, while啥的,控制执行过程。有了变量和控制,单独执行的那些程序便可以放到一个文件中,即所谓的脚本,这样就能一次运行多个命令,也可以保存供以后使 用。其他脚本语言也是类似的原理。


本篇先讲解shell如何运行程序,写一个不带变量和控制的shell,老子曰:“千里之行,始于足下”。 shell的工作看起来是这样的:开一个终端后,打印提示符,一般就是那个"$"或"#", 愚蠢的人类输入命令,命令执行完了,又出现提示符,无尽的循环......直到退出终端,比如输入exit,这是通过命令退出;或提示符后按ctrl + d,这产生一个文件结束符;或图形终端模拟器中鼠标点了窗口的关闭,这是由窗口管理器处理。其实这三个都是用来结束那个无尽的循环,退出shell自己 的。

shell的主体是这样的:

while(!end_of_input) {
    等待人类输入命令;
    执行命令;
    等待命令结束;
}

那个end_of_input由前面提到的三种退出方法产生。有一个情形是这样的,在shell里再运行一个shell,然后在shell里运行的 shell那个shell里再运行一个shell,然后在......你可以买个俄罗斯套娃玩了 :P .一般的程序都是干完自己的活就退出了(命令行界面下常用的程序都是这样的,但图形界面程序为了交互大都需要人类自己去关闭),但因为shell是运行其 他程序的程序,因此它的退出需要另外干预。

为了写一个shell,要知道:
1. 在程序中运行一个程序(相当于创建一个进程);
2. 等待程序中那个新程序的退出


关于进程:运行中的程序。或者说就是在内存中的程序和一些设置,比如状态、时间、进程号等,ps -x命令的输出中,每一行就是一个进程的信息。top命令可以查看实时的进程信息。我们小白初学编程时,写的都是些单进程的程序,一下子到底,比如打印个"hello"。但要把程序执行两遍,只能你再输入一遍,让它再执行一遍,而这可以让程序自己完成,那就是用多进程。这个思路可以用C语言中函数调用来类比。你可以把所有要做的事写道main里,有重复的工作时,一般是建立一个子函数,然后多次调用,而不是复制代码。

execvp调用: execvp(program,arglist). program为调用的程序名,arglist为参数列表,用它来从程序中运行程序,它会利用环境变量查找program,就是ls,who之类。

fork调用:fork(). 创建新进程,它干的活就是把原来运行的程序复制一份,这样,内存中就有了两个一样的程序。这两个程序不再叫程序了,就叫他们进程吧。fork原始意思就是分叉,一条道变成两条道,分道扬镳之后,就走自己的路了。

wait调用:wait(&status). 等待子进程结束。等待分为阻塞和非阻塞,比如要喝一壶茶这个进程。你就是shell。先创建一个烧水的进程,你可以选择阻塞,就是i蹲在旁边看着壶冒热气,也可以非阻塞,水开了壶会有鸣叫,这就属于信号了,另外壶也可以把它的状态存进status里。shell是最初的父进程,它一般执行一个程序是都是阻塞的,不过你看不到,因为机器太快。而后台进程就是非阻塞的,就是命令后边加个"&".


下面开工! 

1.只能运行一个程序的shell

有一组系统调用exec完成“在程序中运行另一个程序”的工作,具体怎么完成的细节先不深究,那又属于另一个编程层次了,这里只是为了写个小shell,只会用这调用就行了,就当成是调用自己的main程序之外的一个函数吧。
这里用到的是execvp.下面是只能运行一个程序的“残疾”shell的代码,因为这货运行完你输入的第一个程序后自己也退出了.

/* egg_sh.c
 * 你认为是先有蛋呢还是鸡呢,这个连鸡和蛋自己都不知道的问题困扰了愚蠢的人类很长时间,姑且认为先有蛋吧,此残疾shell被命名为egg_sh
 * by the way, 使用大写字母开头分隔程序名是很丑陋的,比如EggSh, 真正的程序员用"_"分隔程序名
 */

#include <stdio.h>
#include <signal.h>
#include <string.h>

#define MAXARGS 20      /* 参数的最大个数 */
#define ARGLEN  100     /* 参数缓冲区长度 */ 

char * makestring(char *buf);
int execute(char *arglist[]);



int main()
{
    char *arglist[MAXARGS+1];    /* 参数数组 */
    int numargs = 0;             /* 参数数组索引 */
    char argbuf[ARGLEN];         /* 存放读入内容的缓冲区 */

    while( numargs < MAXARGS ) {
        printf("arg[%d]? ", numargs);   /* 打印提示符 */
        if( fgets(argbuf, ARGLEN, stdin) && *argbuf != ‘\n‘ )
            arglist[numargs++] = makestring(argbuf);
        else{
            if( numargs > 0 ){
                arglist[numargs] = NULL;
                execute(arglist);
                numargs = 0;
            }
        }
    }
    return 0;
}

int execute(char *arglist[])
{
    execvp(arglist[0], arglist);    /* 此处即开始执行程序中的程序, arglist[0]为新程序的名称,arglist为参数列表 */
    perror("execvp failed");
    exit(1);
}

char *makestring(char * buf)
/*
 * 去掉每个参数最后位置的换行,改成‘\0‘,即C语言的字符串结束符
 * 并为每个参数分配内存,以便存放它们
 */
{
    char *cp;

    buf[strlen(buf)-1] = ‘\0‘;    /* 将‘\n‘改为‘\0‘ */
    cp = malloc(strlen(buf)+1);
    if( cp == NULL ){
        fprintf(stderr, "no memory\n");        /* 从开始学编程到现在,内存不足这个情况我从来没碰到过=_=! */
        exit(1);
    }
    strcpy(cp, buf);    /* 把参数缓冲区里的内容复制到刚分配的地方 */
    return cp;        /* 返回参数所在位置的指针 */
}



wc -l egg_sh.c 查看一下,才60多行代码,没错,一个可以成为shell的程序就这么点,只是现在还是个“蛋”。编译运行大概是这样的:
hotea@tmp? ./a.out 
arg[0]? ls
arg[1]? -l
arg[2]? -a
arg[3]? 
总用量 32
drwxrwxrwt  4 root  root  4096  7月 29 12:11 .
drwxr-xr-x 23 root  root  4096  7月 10 02:39 ..
-rwxr-xr-x  1 hotea hotea 6251  7月 29 12:05 a.out
-rw-r--r--  1 hotea hotea 1788  7月 29 12:05 egg_sh.c
drwxrwxrwt  2 root  root  4096  7月 29 08:36 .ICE-unix
-r--r--r--  1 root  root    11  7月 29  2014 .X0-lock
drwxrwxrwt  2 root  root  4096  7月 29  2014 .X11-unix
hotea@tmp?



你可以用它运行别的程序试试,空行回车表示命令输入结束。egg_sh退出的原因是execvp用ls的程序覆盖了egg_sh的程序,结束后egg_sh就没了。要想像真正的shell那样运行完一个程序后继续等待命令,就需要把execvp放在新进程里执行,ls所在的进程退出不会影响egg_sh的进程

2.可以运行多个程序的shell

之前的蛋shell只用了exec,所以只能执行一个程序,现在加上fork调用,可以运行多个程序,把exec放到fork之后的叉路上,它退出了,shell也不会退出。fork执行后,由于分身为两个,为了区分,子进程中fork返回0, 父进程中fork返回子进程的pid。

这样一来执行流程是这样的:

1.提示符  ->  2.取得命令  ->  3.建立新进程  -> 4.父进程 等待.....................  得到子进程状态   -> 回到提示符

                                           |                                                     |

                                         子进程   -> exec运行新程序 ->  结束退出  -> 退出状态 


只需更改execute函数, 这个能运行多个程序的shell已经可以完成最基本的工作了,只是用起来还是不舒服,像蛋shell那样得一次一行输入内容

int execute(char *arglist[])
/* 使用fork()和execvp(), 用wait()等待子进程
 */
{
	int pid,exitstatus;	/* 子进程的进程号和退出状态 */

	pid = fork();		/* 创建子进程 */
	switch( pid ){
		case -1:
			perror("fork failed");
			exit(1);
		case 0:
			execvp(arglist[0], arglist); /* 执行在shell中输入的程序 */
			perror("execvp failed");
			exit(1);
		default:
			while(wait(&exitstatus) != pid)
				;
			printf("child exited with status %d, %d\n",exitstatus>>8, exitstatus&0377);	/* 退出信息 */
	}
}
fork之后,上面这段代码在父子进程中是一样的,不过由于pid不同,才导致执行的部分不同,如果fork不出错的话,子进程会执行case 0后面部分,因为它的pid为0,这样由于调用了exit,子进程也就退出了;父进程执行default后部分,得到子进程的退出状态信息,这信息保存在exitstatus中,可以用,也可以扔掉,这里把它打印出来了,exitstatus>>8是退出值,后面和0377按位与得到信号的号,我们先不用这些。

执行情况类似下面这样

hotea@tmp? ./a.out 
arg[0]? ls
arg[1]? 
a.out  big_egg_sh.c  egg_sh.c
child exited with status 0, 0
arg[0]? ps
arg[1]? 
  PID TTY          TIME CMD
 3708 pts/0    00:00:00 bash
 5266 pts/0    00:00:00 a.out
 5268 pts/0    00:00:00 ps
child exited with status 0, 0
arg[0]? 按ctrl+D
arg[0]? arg[0]? exit
arg[1]? 
execvp failed: No such file or directory
child exited with status 1, 0
arg[0]? ^C
hotea@tmp?
运行多个程序可以了,但^D不管用了,exit也不好使了,原因简单解释一下,子进程调用execvp(exit,NULL),这里把exit当成了新程序,而我们可以用type exit产看exit是shell内嵌的,也就是在环境变量PATH里是找不到的,像ls,who这些多在/bin,/usr/bin这些目录,可以找到,而cd,exit这些内嵌命令,它就会提示no such file or directory. 另外,要退出这个big_egg_sh, 只能通过ctrl+C信号杀死他了,而我们系统用的shell用ctrl+C是杀不死的,而要用ctrl+D退出。为了使big_egg_sh不被^C杀死,可以在其main函数中加入这一句,表示忽略^C产生的信号

signal(SIGINT,SIG_IGN)



至此,一个相当粗糙的shell算是完成了,但这终究是个蛋而已,下一篇让我们把这蛋进化成chicken!(source code at git)