CS162_HW2: Shell
作业要求:
- 实现
cd
、pwd
指令运行 - Program Execution
- 解析路径
- 输入输出重定向
Optional:
- 管道
- 信号处理和停止控制
- 前台、后台切换
Get Started
作业提供了Shell的骨架代码,包括一个分词工具tokenizer.c
和shell的初始化、一个最简单的exit
内建命令
tokenizer.c
的用法:
/* A struct that represents a list of words. */
struct tokens;
/* Turn a string into a list of words. */
struct tokens* tokenize(const char* line);
/* How many words are there? */
size_t tokens_get_length(struct tokens* tokens);
/* Get me the Nth word (zero-indexed) */
char* tokens_get_token(struct tokens* tokens, size_t n);
/* Free the memory */
void tokens_destroy(struct tokens* tokens);
这部分代码将在其后提供
cd、pwd
实现shell的内建命令cd
、pwd
要实现该命令,我们需要先清楚这个两个命令的行为
使用man
来获取详细信息:man cd
、man pwd
cd
有两种情况,一种是带参的,另一种是不带参数的
带参数的cd
会打开对应路径,且只能有一个参数
不带参数的cd
会打开环境变量HOME
里存储的路径,若该变量未定义,则不做处理
pwd
直接打印当前所在工作目录
注意到题目提供的代码骨架中已经有了help
和exit
内建命令的实例了,我们照着它的写法,在cmd_table
里添加对应的记录,然后实现cmd_cd
与cmd_pwd
函数即可
/* Built-in command struct and lookup table */
typedef struct fun_desc {
cmd_fun_t *fun;
char *cmd;
char *doc;
} fun_desc_t;
fun_desc_t cmd_table[] = {
{ cmd_help, "?", "show this help menu" },
{ cmd_exit, "exit", "exit the command shell" },
{ cmd_cd, "cd", "change the working directory" },
{ cmd_pwd, "pwd", "print name of current/working directory" }
};
cd
命令中,当没有路径作为参数传入的时候,需要获取环境变量HOME
的值
使用库函数getenv
来完成。这个函数定义在unistd.h
中
使用库函数chdir()
来更改当前程序的工作目录,同样定义在unistd.h
中
cmd_cd
如下:
/* change working directory */
int cmd_cd(unused struct tokens *tokens)
{
char *dst = NULL;
int res = -1;
switch (tokens_get_length(tokens)) {
case 1: /* no directory operand is given, if HOME is given, cd $HOME */
dst = getenv("HOME");
break;
case 2:
dst = tokens_get_token(tokens, 1);
break;
default:
shell_msg("too many argument\n");
}
if (dst == NULL)
return -1;
res = chdir(dst);
if (res == -1)
shell_msg("No such file or directory\n");
return res;
}
此处的shell_msg()
是一个宏函数,它展开为
#define shell_msg(FORMAT, ...) \
do {\
if (shell_is_interactive) { \
fprintf(stdout, FORMAT, ##__VA_ARGS__);\
} \
} while(0)
它的作用是,只有当shell作为交互式程序启动的时候,才会输出警告或者错误信息
pwd
更简单,可以直接使用getcwd
库函数来实现
/* get current full path */
int cmd_pwd(unused struct tokens *tokens)
{
char *path = getcwd(NULL, 0);
if (path == NULL) {
shell_msg("%s\n", strerror(errno));
return -1;
}
printf("%s\n", path);
free(path);
return 0;
}
strerror
的作用是输出errno
对应的错误信息
执行程序
shell执行程序的流程如下:
- 执行fork调用,拷贝一个当前进程的副本
- 子进程执行exec系列的系统调用,将当前子进程替换成要执行的程序
- shell等待子进程返回或者让子进程在后台运行(如果有添加
&
参数)
因为我们暂时没有展开可执行文件的路径,因此,目前,只能输入完整的文件路径
题目限制条件:不允许使用execvp
调用
exec
族的调用有
exec execle execv execveat execvpe
execl execlp execve execvp
等,其中execv
和execvp
的区别是,execv
不会自动展开路径,而execvp
会自动展开可执行文件的路径
具体操作可以分为以下几步:
- 解析参数
- 使用
fork
调用 - 子进程使用
execv
调用 - 父进程等待子进程结束
- 解析参数
我们创建这样子的一个结构体:
struct ch_process {
int tokens_len;
int next_token;
char **args;
};
然后,使用函数void parse_args(struct ch_process *ch, struct tokens *tokens);
来解析参数
该函数的定义如下:
void parse_args(struct ch_process *ch, struct tokens *tokens)
{
char *token;
while (ch->next_token < ch->tokens_len) {
token = tokens_get_token(tokens, ch->next_token);
ch->args[ch->next_token++] = token;
}
ch->args[ch->next_token] = NULL;
}
fork
调用有两个返回值,子进程返回0,父进程返回子进程的进程号
整个函数如下
/* start a child process to execute program */
int run_program(struct tokens *tokens)
{
int tokens_len = tokens_get_length(tokens);
if (tokens_len == 0) /* no input */
exit(0);
char *args[tokens_len + 1];
struct ch_process child = { 0 };
child.tokens_len = tokens_len;
child.next_token = 0;
child.args = args;
parse_args(&child, tokens);
pid_t chpid = fork();
if (chpid < 0) { /* fork error */
shell_msg("fork : %s\n", strerror(errno));
return -1;
} else if (chpid == 0) {
execv(path, args);
}
if (wait(NULL) == -1) { /* wait until child process done */
shell_msg("wait: %s\n", strerror(errno));
return -1;
}
return 0;
}
解析路径
我们在实际使用shell的时候,并不需要每次都输入可执行文件的完整路径
shell对此的处理流程是这样子的:
优先在本目录下进行搜索,判断其是否是本目录下的可执行文件;
如未找到,则在环境变量PATH
的值中记录的目录下进行查找PATH
下有很多目录,这些目录用一个字符串表示,使用:
作为分隔符
例如,在我的电脑上,运行echo $PATH
,结果如下:
$ echo $PATH
/home/yingmanwumen/.cargo/bin:/home/yingmanwumen/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
因此,我们设计了char *get_fullpath(char *name)
函数来完成这个工作
首先判断用户输入的路径是不是已经是完整路径:
strcpy(path, name);
if (access(path, X_OK) == 0)
return path;
access
系统调用用来判断某个文件或者目录是否具有某种属性,例如X_OK
就是判断其是否可执行
接下来,使用getenv
,获取$PATH
然后,解析其下的每一条路径,在每个路径下查找是否存在对应的可执行文件
完整代码如下
char *get_fullpath(char *name)
{
char *val = getenv("PATH");
int i, j, len;
char *path = (char *)malloc(BUFSIZ);
/* if name is already full path */
strcpy(path, name);
if (access(path, X_OK) == 0)
return path;
/* enumerate $PATH and search reachable path */
len = strlen(val);
i = 0;
while (i < len) {
j = i;
while (j < len && val[j] != ':')
j++;
int k = j - i;
memset(path, 0, BUFSIZ);
strncpy(path, val + i, k);
path[k] = '/';
strcpy(path + k + 1, name);
if (access(path, X_OK) == 0)
return path;
i = j + 1;
}
free(path);
return NULL;
}
输入输出重定向
使用<
重定向标准输入,使用>
重定向标准输出,使用>>
则不覆盖标准输出对应的文件,而是使用append
的方式写入
题目对多个<
、>
、>>
存在的情况说得比较含糊
经过自己的测试,得出如下规律:
- 多个
<
存在的情况下,只有最后一个<
才是有效的 - 多个
>
或者多个>>
存在的情况下,同样也只有最后一个参数是有效的,但是,前面那些文件也会被打开。
例如,ls -l >> a > b > c < d < e
中,shell首先打开文件a
、b
、c
,若不存在则创建,然后打开文件d
,发现其后还有一个到e
的重定向,则关闭文件d
、打开文件e
,因此,当d
存在时,最终的结果是在c
中输出ls -l
的结果,而b
中原有的数据被清除、a
中的数据没有影响
我们扩展之前的struct ch_process
,改为:
struct ch_process {
int tokens_len;
int next_token;
char **args;
int in_fd;
int out_fd;
int out_attr;
};
然后构建函数void parse_redirection(struct ch_process *ch, struct tokens *tokens);
注意,它一定只能在解析完参数后执行
同时,我们更改解析参数函数:
void parse_args(struct ch_process *ch, struct tokens *tokens)
{
char *token;
int finish = 0;
while (ch->next_token < ch->tokens_len && !finish) {
token = tokens_get_token(tokens, ch->next_token);
/* if first char of token is < or >, break */
finish = (token[0] == '<' || token[0] == '>');
/* if not finish, !finish 1, then args[next_token] = token, then next_token inccrease
else if finish, args[next_token] = NULL, and next_token refer to the first < or > or >> */
/* This line may be hard to understand, but it can avoid IF branch */
ch->args[ch->next_token] = (char *)((!finish) * (int64_t)(void*)(token));
ch->next_token += !finish;
}
ch->args[ch->next_token] = NULL;
}
void parse_redirection(struct ch_process *ch, struct tokens *tokens)
函数的核心部分如下:
switch(arrow[0]) {
case '<':
/* redirect standard input.
If there are multiple '<' in command line, such as `prog < foo1 < foo2`,
the last one would be active */
if (access(path, R_OK) == 0) {
if (ch->in_fd != 0) {
close(ch->in_fd);
}
ch->in_fd = open(path, O_RDONLY);
} else {
shell_msg("%s is not exsist or readable\n", path);
return;
}
break;
case '>':
/* The only diff between > and >> is that << have the attrs of O_APPEND and O_TRUNC */
attr = O_WRONLY | O_CREAT;
if (arrow[1] == '>') {
attr |= O_APPEND;
} else {
attr |= O_TRUNC;
}
ch->out_attr = attr;
if (ch->out_fd != 1) {
close(ch->out_fd);
}
ch->out_fd = open(path, attr, 0664); /* -rw-rw-r-- */
}
arrow
记录<
、>
与>>
所在的token,path
则是记录arrow
后面跟着的文件名:
/* next_tocken start from the first < or > or >>
for example, if `program > foo`, then arrow = >, path = foo */
arrow = tokens_get_token(tokens, ch->next_token++);
if (ch->next_token >= ch->tokens_len) {
/* next_token is out of range, no filename next to < or > or >> */
shell_msg("No file next to '%s'\n", arrow);
return;
}
path = tokens_get_token(tokens, ch->next_token++);
在run_program
这个函数中,也需要做一定的变更,例如在execv
前增加重定向有关的代码:
/* redirect */
if (child.in_fd != 0) {
dup2(child.in_fd, 0);
}
if (child.out_fd != 1) {
dup2(child.out_fd, 1);
}
因为fork
的程序的文件描述符都是完全复制自父进程的,它们指向同一个文件,因此,可以直接使用
这里使用了一个新的系统调用dup2
,与之对应的还有dup
调用
它的作用是复制文件描述符
例如,dup2(old, new)
就会把old
对应的文件复制到new
对应的文件中(old
、new
都是文件描述符)
若new
对应一个已经打开的文件,则dup2
会先关闭该文件,然后再将文件信息复制过去
资源
暂时懒得放