实验一 实现带参数的简单Shell Unix环境高级编程

大数据学习路线图

问题描述

实验一利用课本第9页程序1-5的框架,实现允许输入命令带参数的简单shell。原来的实现是不能够带参数的。输入命令所能带的参数个数,只受到系统键盘输入缓冲区长度(以及shell输入缓冲区长度)的限制,该缓冲区的缺省长度是4096个字节。

实现时要解决的主要问题有:

1. 正确确理解并使用系统调用fork(),execve()和waitpid(),特别是execve()函数。fork()函数创建一个新的进程。新进程就是所谓的子进程,它是执行fork()函数的进程(父进程)的“克隆”,也就是说,子进程执行的程序与父进程的完全一样。当fork()函数返回值为0时表示处于子进程中;而返回值大于0时表示处于父进程中,此时的返回值是子进程的进程id。因此,fork()的返回值可以用来划分仅仅适合父进程和子进程执行的程序段。fork()函数返回值为-1时表示出错。

如果子进程只是运行与父进程完全一样的程序,那用处是很有限的。要让子进程运行不同于父进程的程序,就必须调用execve函数,它是所有其他exec函数的基础。execve函数把调用它的进程的程序,替换成execve函数的参数所指定的程序。运行execve函数成功后,进程将开始运行新的程序,也就是execve函数的参数所指定的程序。

execve函数原型:int execve(const char *path, const char *argv[],const char *envp[]);

其中:

  • path:要执行的程序路径名,比如“/bin/ls”,“cd”,“/usr/bin/gcc”等等。
  • argv:参数表,比如ls命令中可带的命令行参数-l,-a等。注意,argv的第一个元素必须是要执行的程序(命令)的路径名。
  • envp:环境变量表,供要执行的命令使用。实参数用NULL或系统环境变量environ均可。注意,因为environ由系统提供,属于外部变量,所以说明时必须用“extern”修饰。

例子:

char *argv[] = {“gcc”, “-g”, “-c”, “hello.c”, NULL};
char *argv1[] = {“/bin/ls”, “-l”, “-a”, NULL};
execve(“/usr/bin/gcc”, argv, environ);  // 编译程序“hello.c”
execve(“/bin/ls”, argv1, NULL);   // 执行命令“ls –l –a”
execve(“/usr/ls”, argv1, NULL);   // 出错,因为目录/usr/下没有ls程序。
// 注意,在argv1 的第一个字符串“/bin/ls”中,只有ls是有用的。

系统调用waitpid()用于等待子进程结束、获取子进程的运行状态,详细说明在第八章。本实验仅仅用它使父进程等待子进程结束,因此维持程序1-5的用法即可。

2. 根据简单shell的输入,构造execve函数的参数。
根据程序1-5,数组buf保存用户的输入,包括命令和参数。由于shell命令的命令名和各参数之间是用空格分开,因此可以用空格作为分界符。通过一个循环可以把buf数组中的命令和各个参数依次分离开来,并赋给数组argv的各元素适当的指针值。argv数组的最后一个指针必须是NULL。接着就可以调用execve(argv[0],argv, environ)来执行用户输入的命令。

提示:argv数组中各指针所指向的字符串,可以直接利用buf的存储空间,不需要另外分配内存。

3. 正确编译程序。
由于书中例子用到了本书第一版中作者自己定义的error.c文件,因此编译时要记得把error.c文件和源代码文件一起编译,error.c文件可以到cs8的/home/luwei/common目录下复制。可以用如下命令编译:
gcc <源文件名> error.c –o<可执行文件名>

问题解决

概括来说,解决的核心问题就是如何将用户的输入 /bin/ls -l -h{“/bin/ls”, “-l”, “-a”, NULL} 的形式存储到 argv1[]中,也就是将一个字符串分成多个数组元素。

要在空格处断开,使用到的技巧是将空格变为 \0,因为字符串是靠 \0 (等价于 0)来确定结束位置的,如:

char buf[] = "/bin/ls -l";
char *argv;
argv = buf;
printf("%s\n", argv); /* 输出为 "/bin/ls -l" */
buf[7] = 0; /* 将空格转换为 '\0' */
printf("%s\n", argv); /* 这时输出的是 "/bin/ls" */

理解了上面这段代码,整个问题就不难理解了。代码如下:

/* Unix 带参数的简单Shell, author: 给力星 */
#include "apue.h"

int main(void) {
  char buf[MAXLINE];
  pid_t pid;
  int status;

  printf("%% ");
  while(fgets(buf, MAXLINE, stdin) != NULL) {
    if (buf[strlen(buf) -1] == '\n')
      buf[strlen(buf) -1] = 0;

    if ((pid = fork()) < 0) {
      err_sys("fork error");
    } else if (pid == 0) { /* child */
      //execlp(buf, buf, (char *)0);

      char *argv[100];  /* 利用buf的存储空间,分配一个字符指针数组即可 */

      /* 处理用户输入的命令,简单的实现 */
      int i, len, index = 0;
      argv[index++] = buf;
      for (i = 0, len = strlen(buf); i < len; i++) {
        if (buf[i] == ' ') {
          buf[i] = 0;
          if (buf[i+1] != ' ') {
            argv[index++] = &buf[i+1];
          }
        }
      }
      argv[index] = NULL; /* argv最后一个为NULL */
      /* 处理完毕 */

      execve(argv[0],argv, NULL);

      err_ret("couldn't execute: %s", buf);
      exit(127);
    }

    if ((pid = waitpid(pid, &status, 0)) < 0)
      err_sys("waitpid error");
    printf("%% ");
  }
  exit(0);
}

这个是简单的做法,能处理 "/bin/ls -l -a" 这样的输入。不过处理用户输入的命令这边的做法比较弱,不能处理有多余空格的情况。比如 "     /bin/ls    -l -a  " 这样含有多余的空格的命令,上面的程序会出错,而在系统的shell里是可以正确运行的。

纠正这个问题的处理代码如下:

      /* 处理用户输入的命令,能处理多余空格 */
      int i = 0, len, index = 0;
      while(buf[i] == ' ') { /* 处理开头的空格*/
        i++;
      }
      argv[index++] = buf+i;  /* 或 &buf[i],第一个非空格的位置 */
      for (len = strlen(buf); i < len; i++) {
        if (buf[i] == ' ') {
          buf[i] = 0;
        } else {
          /* 需防止越界,非空格字符的前一个字符为\0,才是参数的起始位置 */
          if ( (i-1 >= 0) && (buf[i-1] == 0) ) {
            argv[index++] = buf+i;
          }
        }
      }
      argv[index] = NULL; /* argv最后一个为NULL */
      /* 处理完毕 */

其实更简单的是利用 strtok() 函数,可以完美的实现上述拆分功能,具体用法请自行谷歌~ 也可以在下面的进阶部分看到其用法。

进阶

这样就好了吗?还可以更进一步!(注:本科生跟研究生课程的实验要求不一样,本科生的实验不要求使用PATH变量。)

书上的程序中,execlp(buf, buf, (char *)0) 是可以处理 ls -l 这样的命令的,因为 execlp() 会根据系统中的PATH路径(通过命令 echo $PATH 可以看到)来查找程序,也就是最终会查找到 /bin/ls 并执行。

execve() 不具备这样的功能,如果要实现类似 execlp() 的功能,则需要自己去遍历PATH路径,在每个PAT目录下尝试运行。将 execve(argv[0],argv, NULL); 修改为如下代码:

      // execve(argv[0],argv, NULL);
      char *pathEnv;
      pathEnv = getenv("PATH"); /* 获取PATH系统变量 */
      // printf("PATH =: %s\n", pathEnv);
      if (NULL == pathEnv) {
        err_sys("couldn't get PATH variable");
        exit(0);
      }

      if (NULL != strstr(argv[0], "/")) { /* 命令带目录,直接执行 */
        execve(argv[0], argv, NULL);
      } else { /* 命令不带目录,需自行遍历 */
        char *pathItem, path[100];
        pathItem = strtok(pathEnv, ":"); /* 使用strtok拆分 */
        while (NULL != pathItem) { /* 对每个PATH路径尝试运行 */
          strcpy(path, pathItem);
          strcat(path, "/");
          strcat(path, argv[0]); /* 合成完整路径 */
          // printf("pathItem: %s\n", pathItem);
          // printf("path: %s\n", path);
          execve(path, argv, NULL);
          pathItem = strtok(NULL, ":");
        }
      }

这样子就可以直接运行 ls -l -a 这样的命令了。

另外简单shell也不是神马命令都能执行,如 cd,这个貌似是系统命令?而不是程序。

关于 environ

关于 execve(argv[0], argv, environ) 中的 environ,这个environ是用于向子进程传输系统的环境变量的。前面说到用 NULL 就行,如果要传入 environ,首先应声明 extern char **environ; ,有兴趣的可以把这个变量的内容数出来看看你就懂了。

实际上 main() 中可以有三个参数: main(int argc, char **arg, char *envp[]),其中的 envp 就等价于 extern char **environ;。可以输出看下,内容是一样的。

如果我们执行 execve(argv[0], argv, NULL),那么子进程中的 envp 就是空的,也就是说不能通过 getenv("PATH") 获取到PATH内容。可以运行上面进阶的代码编译后的程序,如 ./shell ,然后在简单shell中再次执行 ./shell,就会提示couldn't get PATH variable。

避免这种情况,就得传入 environ

int main(void) {
  extern char **environ;
  ...
  execve(path, argv, environ);
  ...
}

友情提示: 代码要看懂并理解,否则 copy 去应付作业是没有用的,考试时不会也白搭~