BUAA-OS-Challenge-2024
前言
本文是对BUAA操作系统课程挑战性任务中shell增强的总结,其目的是实现一个功能更强大的shell(与linux更加接近)。
宇宙安全声明:
- 本文行文过程为笔者实现Shell增强的流程,与指导书顺序不同。
- 本文借鉴参考了往年功能要求相似的部分,但由于2024年OS课程对shell增强进行评测,故修改了往年的部分bug
- 本文是在通过测评的基础上,一定会存在未评测的bug。
实现对.b
的忽略
目标:
你需要实现不带 .b
后缀的指令,但仍需兼容带有 .b
后缀的指令,如 ls
与 ls.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这三个外部指令
实现流程:
- 在
user/lib/file.c
中实现创建文件、创建目录、删除文件等一系列函数。 - 在user/目录下创建对应的
.c
文件,并将.b
烧录进入这一目录下的include.mk
文件中 - 在
.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 meow
,ls | 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
按照\n
和his_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
:可能的取值为Running
,Done
)、任务的进程 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,笔者都是当作内置指令进行实现。
思路:
envid
和cmd
已经在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.c
和false.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们