前言

本文是对BUAA操作系统课程挑战性任务中shell增强的总结,其目的是实现一个功能更强大的shell(与linux更加接近)。

宇宙安全声明

  1. 本文行文过程为笔者实现Shell增强的流程,与指导书顺序不同。
  2. 本文借鉴参考了往年功能要求相似的部分,但由于2024年OS课程对shell增强进行评测,故修改了往年的部分bug
  3. 本文是在通过测评的基础上,一定会存在未评测的bug。

实现对.b的忽略

目标:

你需要实现不带 .b 后缀的指令,但仍需兼容带有 .b 后缀的指令,如 lsls.b 都应能够正确列出当前目录下的文件。

思路:

对于ls等指令,我们在lab6中实现了带有.b格式的指令,其判别方式为作为一个token传入spawn函数进行解析。因此,为实现对.b的忽略,需要修改spawn函数。

实现方法:

spawn代码片段如下(往年这部分实现已经很多)

int fd;
if ((fd = open(prog, O_RDONLY)) < 0) {       // 检测是否以.b结尾,如果是,则直接打开
        int len = strlen(prog);
        if(len < 2 || prog[len-1] != 'b' || prog[len-2] != '.'){
            char tmp[MAXPATHLEN];
            strcpy(tmp, prog);
            tmp[len] = '.';
            tmp[len + 1] = 'b';
            tmp[len + 2] = '\0';
            if((fd = open(tmp, O_RDONLY)) < 0){
                    return fd;
            }
        } else {
            return fd;
        }
}

实现touch,mkdir,rm这三个外部指令

实现流程:

  1. user/lib/file.c中实现创建文件、创建目录、删除文件等一系列函数。
  2. 在user/目录下创建对应的.c文件,并将.b烧录进入这一目录下的include.mk文件中
  3. .c文件中,调用已经建立好的函数,实现对应功能。同时,需要支持对于额外参数的识别。

touch实现

由于在lab5中实现的文件系统不支持创建文件这一功能,因此需要提供在文件系统中创建文件的接口:

void serve_create(u_int envid, struct Fsreq_create *rq);    //路径 /fs/serv.c

int fsipc_create(const char* path, int type); //路径 /user/lib/fsipc.c

int create(const char* path, int type); //路径 /user/lib/file.c
int createDir(const char* path); //路径 /user/lib/file.c (实际上可以与create共用)

对于create的实现,调用fsipc_create即可。

touch.c文件的实现中,只需要依次遍历传入的参数,并将type设置为REG即可(fd用于检测异常):

fd = create(argv[i], FTYPE_REG)

mkdir实现

简单的mkdir可以参考往年博客,与上面touch的实现相似,这里主要介绍一下-p参数的实现:

int len = strlen(argv[i]);
for(int j = 0; j < len; j++) {
    if(argv[i][j] == '/') {
        if(stat(temp, &stat_buf) < 0) {
            r = createDir(temp);
        }
    }
    temp[j] = argv[i][j];
}
r = createDir(argv[i]);

实现的代码片段如上,这里由于需要递归创建,于是每次读到'\'就进行一次创建即可(此时-p忽略了错误,即部分根目录存在也可)。

rm实现

这是2024年新增加的指令,但在file.c文件中已经存在了对remove的支持接口。在rm.c中,本人的实现仅仅是调用remove,并给remove函数增加一个是否允许删除目录(-r参数)的标记。

在原来的remove函数中仅有一行fsipc_remove(path)。因此,需要对remove进行修改:

int r;
if((r = open(path, O_EXCL)) != -10) {
    if((r = open(path, O_MKDIR)) == 3) {
        if(allowRemoveDir) {
            r = 4;
            fsipc_remove(path);
        }
    } else {
        fsipc_remove(path);
    }       
}
    //3 表示dir已经存在->是dir 无法移除
    //4 表示成功移除dir
    //-3 表示成功移除reg
    //-10表示无法移除(未找到)
return r;

这里的实现确实有些潦草(有偷奸耍滑之嫌),这段代码的意思是尝试创建一个文件,如果存在(创建失败)则证明已有文件,那么将其移除;如果不存在(创建成功),则rm无效,然后为防止文件多出来,于是删除。(但这里存在dirty的debugf输出,本人认为由于已经删除这个文件了,dirty什么的已经无所谓了,于是无视了,但仔细思考下还是存在问题,一定有更好的实现)

反引号的实现

目标:

将反引号内指令执行的所有标准输出替换为 echo 的参数,即输出反引号内指令执行的结果。

思路:

笔者在实现了单一的echo(不添加重定位)后提交了评测,发现通过了(发现评测很弱)就放在了那里,这里的实现是弱版本的反引号。具体的思路是,在_gettoken中读出反引号后,直接将匹配的反引号(不考虑反引号的嵌套)之间的内容全部读出,然后扔到runcmd中。(实现与双引号类似,这里暂且省略)

追加重定向的实现

目标:

实现对文件的追加写入

思路:

对于'>',我们在lab6中已经实现了覆盖重定向写入。所以对于‘>>‘的实现,可以依托于这个。即读到一个'>'后,判断后一个字符是不是'>',是的话则设置标记,并将s++越过这两个,返回'>'。

对于追加写,笔者实现了O_APPEND,即写入前,将偏移量改为文件大小(此时O_APPEND同时需要有写的功能,即有O_APPEND&O_WONLY == O_WONLY成立,这样才能写入)

实现:

user/lib/file.c文件中,对于open函数,增加如下:

#define O_APPEND 0x1001 //append challenge
if ((mode & O_APPEND) == O_APPEND) {
        fd->fd_offset = size;
}

user/sh.c中,可以仿照lab 6的实现。

注意,这里的O_APPEND和O_WONLY需要 ‘|’ 一个O_CREAT,表示文件不存在则创建(这个可能根据需要会改动)

注释的实现

目标:

使用 # 实现注释功能,例如 ls | cat # this is a comment meowls | cat 会被正确执行,而后面的注释则会被抛弃。

思路及实现:

在函数_gettoken中,一旦读到#s++到结束,然后返回0:

if(*s == '#'){
        while(*s) {
            s++;
        }
        return 0;
}

一行多命令的实现

目标:

实现使用 ; 将多条指令隔开从而从左至右依顺序执行每条指令的功能。

实现思路:

每读到一个分号,则fork一个子进程去执行,父进程等待子进程完成(同时需要支持重定向,避免读写不一)

实现方法:

在重定向时,由于读入子进程的token时候,是处于父进程,因此可以直接设置一个标志位表示子进程是否有重定向。

forktemp = fork();
if (forktemp == 0) {
    return argc;
} else if (forktemp > 0) {
    if(re_alloc == 0){                  //如果前一条命令出现了重定向,那么再重定向回来
            dup(1, 0);
    } else if(re_alloc == 1) {
            dup(0, 1);
    }
    ipc_recv(&flagOfIpc, 0 ,0);         //此处的recv是指令条件执行所用
    wait(forktemp);
    *rightpipe = 0;
    return parsecmd(argv, rightpipe);
}
break;

实现引号支持

同反引号,只需要读入两个引号之间的内容即可(保证了不存在引号嵌套,但嵌套也没什么)。

if(*s == '\"') {
        *s = 0;
        s++;
        *p1 = s;
        while(*s && (*s != '\"')){
            s++;
        }
        *s++ = 0;
        *p2 = s;
        return 'w';
}

实现历史指令

目标:

你需要实现 shell 中保存历史指令的功能,可以通过 上键 和 下键 选择所保存的指令并执行。你需要将历史指令保存到根目录的 .mosh_history 文件中(一条指令一行),为了评测的方便,我们设定 $HISTFILESIZE=20(bash 中默认为 500),即在 .mosh_history 中至多保存最近的 20 条指令。你还需要支持通过 history 命令输出 .mosh_history 文件中的内容。

思路:

每次读入一个指令,就将其追加写入.mosh_history中。然后读这个文件的时候,笔者从后往前遍历(read可以返回字符数),每次读到一个\n就计数一次count++,并将内容倒写入一个字符串数组out,当超过20条或者不足20条时候,则停止写入out。最后,从后向前遍历out(注意这里history与往年实现不同,是内置指令,即不允许创建一个history.c文件)

对于上下指令,需要复制当前指令数量到一个his_now变量中,全部读入后,操作his_now上键-1,下键+1(但不得越界),字符串数组tmp按照\nhis_now的数量进行读入输出。同时,需要维护光标的位置。(这位同学,你也不想你光标乱跑的事情被别人知道吧)

实现:

这里仅列出需要实现的函数(需要具体代码可留言):

int hsty_num;
int hsty_now;
char cmdbuf[1024];
void init_history() ;
void savecmd(char *s);
void loadcmd(int *p_cursor, char *buf, int no) ;
void loadcmd_from_buf(int *p_cursor, char *dst, char *from) ;
void loadcmd(int *p_cursor, char *buf, int no) ;
int insert_char(char *buf, int i, char ch);
void remove_char(char *buf, int i);
int readline(int fd, char *buf, int n);

以及对history的判别:

if(strlen(buf) >= 7) {
            if(buf[0] == 'h' && buf[1] == 'i' && buf[2] == 's' && buf[3] == 't' && buf[4] == 'o' && buf[5] == 'r' && buf[6] == 'y') {
                if((fd = open("/.mosh_history", O_RDONLY| O_CREAT)) < 0) {
                    user_panic("open .mosh_history, fd = %d", fd);
                }
                count = 0;
                outlen = 0; 
                while((r= read(fd, buffer,4096)) >0) {
                    buf[r]='\0';
                    for(int i = r - 1; i >= 0; i --) {
                        if(buffer[i] == '\n') {
                            count++;
                            if(count > 20) {
                                break;
                            }
                        }
                        out[outlen++] = buffer[i];
                    }
                }
                for(int i= outlen-1; i > 0;i--) {
                    printf("%c",out[i]);
                }
                //printf("\n");
                close(fd);
                continue;
            }
        }

对于光标维护,需要知道上下的具体表现方式(两个ASCII码):

case 0x1b: // read \e
            read(0, &ch, 1); // read [
            read(0, &ch, 1); // read A B C D for arrow keys
            switch(ch) {
            case 'A': 
                printf("%c[B", 27); 
                while(cursor > 0) {
                    remove_char(buf, cursor-1);
                    cursor--;
                }
                if (hsty_now == hsty_num) {
                    strcpy(cmdbuf, buf);
                }
                hsty_now = hsty_now > 0 ? hsty_now - 1 : 0;
                loadcmd(&cursor, buf, hsty_now);
                break;
            case 'B':
                while(cursor>0) {
                    remove_char(buf, cursor-1);
                    cursor--;
                }
                hsty_now = hsty_now < hsty_num ? hsty_now + 1 : hsty_num;
                if (hsty_now == hsty_num) {
                    loadcmd_from_buf(&cursor, buf, cmdbuf);
                } else {
                    loadcmd(&cursor, buf, hsty_now);
                }
                break;
            //......
            default:
                break;
            }
            break;

这里还额外实现了对删除backspace的支持(未做要求,只是笔者实在受不了原来的insert修正了~(我受够了那些繁文缛节了)~):

case 0x7f:  
            if (cursor > 0) {
                remove_char(buf, cursor - 1);
                cursor--;
            }
            break;

实现&后缀的shell后台任务

(这个与往年实现有很大不同)

目标:

  • 支持 mosh 运行后台进程,当命令的末尾添加上 & 符号时,该命令应该在后台执行。
  • 实现 jobs 指令列出当前 shell 中所有后台任务的状态。你需要为任务创建 ID(每次启动 mosh 时,任务从 1 开始编号,每个新增任务编号应加 1),并且通过 jobs 指令输出包括:任务 ID(job_id)、任务的运行状态(status:可能的取值为 RunningDone)、任务的进程 ID(env_id)与运行任务时输入的指令(cmd)。请以 printf("[%d] %-10s 0x%08x %s", job_id, status, env_id, cmd) 的格式进行输出。
  • 实现 fg 将后台任务带回前台继续运行,用户通过 fg 的方式将对应任务带回前台。
  • 实现 kill 指令,用户通过 kill 来实现结束后台任务。

创建后台指令:

即对以&结尾的命令,创建进程,扔到后台运行(保持当前界面仍然是父进程)。

实现:

为存储这些进程,笔者创建了一个结构体:

struct Jobs {
    char cmd[MAXCMDLEN];            //MAXCMDLEN为自定义宏
    int envid;
    int id;
} jobs[300];

同时,对main函数runcmd位置进行重构:

if (r == 0) {
            runcmd(buf);
            exit();
        } else {
            if(flag) {                          //如果是后台指令,则存入jobs,然后直接能够读入下一条指令
                len = strlen(buf);
                for(int i = 0; i < len; i++) {
                    jobs[jobs_n].cmd[i] = buf[i];
                }
                jobs[jobs_n].cmd[len] = '\0'; 
                jobs[jobs_n].id = jobs_n + 1;
                jobs[jobs_n].envid = r;
                jobs_n++;
            } else {    
                wait(r);                        //如果不是后台指令,则需要等待上一条执行完毕才能读入下一条指令
            }
    }

jobs的实现

对于jobs,kill和fg,笔者都是当作内置指令进行实现。

思路:

envidcmd已经在jobs中存入,所以我们需要考虑的仅仅是状态(status)。因此,为获得进程的运行状态env status,我们需要一个系统调用。这个系统调用实现非常简单,只需要传入envid,然后在内核态使用envid2env获得进程控制块(无需考虑free后返回-2的问题,此时就是Done的状态了)。

实现:

系统调用部分的实现按照套路即可。

if(buf[0] == 'j' && buf[1] == 'o' && buf[2] == 'b' && buf[3] == 's') {
                for(int i = 0; i < jobs_n; i++) {
                     printf("[%d] %-10s 0x%08x %s\n", jobs[i].id,
                        getStatus(jobs[i].envid) == 1 ? "Running" : "Done",
                        jobs[i].envid, jobs[i].cmd);
                }
                continue;   
            }

kill的实现

kill的实现需要将进程状态设置为NOT_RUNNABLE(2),设置进程状态的系统调用已经实现过了,所以kill的实现十分简单(可能是课程组的失误,输出时候仍然是fg:xxxxxxx,导致笔者卡了一下午不知道为什么,直到看到pyq......)。

fg的实现

实际上,fg的实现就是需要继续执行对应进程的指令,那么怎么才能接手呢?欸对,wait就好了,所以每次读入fg后,如果有效则直接wait就好,这样运行结束后还是父进程。

指令条件执行

(这个是笔者感觉最难的部分了......)

首先,需要导入true.cfalse.c用于测试。实际上,这个是考虑对ipc的一个应用,即需要将子进程运行的返回值发送给父进程。官方推荐的做法是实现子进程退出exit返回一个值(不能简单粗暴直接void改int啊),笔者在同学指引下,对进程控制块增加一个env_res作为该进程结束后的返回值。1表示运行失败,0表示运行成功,默认为1,当且仅当进程正常退出后才设为0。

因此首先需要两个系统调用:获得env_res和设置env_res,这个和status的获取和设置不能说十分相似,只能说一模一样。然后,在user/lib/libos.c中,修改添加如下:

int r = main(argc, argv);
syscall_set_res(r);         //仅有正常返回时候才会执行这一条

对于"||"和“&&”的解析,完全可以仿照">>"的解析(见上文)。

然后是最关键的ipc部分,我们为什么不能直接使用envs[ENVX(child)].env_res取出呢?答:这时候这个值已经变成1了(无论是否正确返回),因此我们需要在运行后、销毁前的阶段发送消息。同时,父进程需要在wait子进程完成前,设置好准备接收的状态。

//runcmd 
    int to = envs[ENVX(syscall_getenvid())].env_parent_id;
    if(to != mainEnvid) {
        ipc_send(to , res, 0, 0);
    }
//parsecmd: case '|' and case '&'
    if(forktemp == 0) {
        return argc;
    } else if (forktemp > 0) {
        res = ipc_recv(&flagOfIpc, 0 ,0);
        wait(forktemp);
        //other logical operation   
        //.....
        return parsecmd(argv,rightpipe);
     }

mainEnvid存储的是当前父进程的父进程(因为我们不需要给父进程的父进程任何消息,即最后一个指令),所以进行特判。这一值在main里面直接设定好。

最后就是一堆逻辑问题......

虽然很短,但是感觉想清楚这一套还是挺复杂的,再次感谢rygg!

其它问题

忙等:

实现完后台指令后,发现那个readline会在sys_cgetc里面忙等导致其他进程没法调动:

int sys_cgetc(void) {
    int ch;
    while ((ch = scancharc()) == 0) {
    }
    return ch;
}

直接将while改掉就好。

致谢:

https://b1fang.github.io/2023/06/16/OS-Challenge/

https://github.com/wokron/BUAA-OS-2023

以及所有讨论区、微信群的uu们