OS-做一个简单的shell
发表于:2023-06-18 | 分类: BUAA操作系统
字数统计: 4.5k | 阅读时长: 16分钟 | 阅读量:

BUAA-OS课程的挑战性任务,完成一个功能更加完善的shell。
MOS Volca Shell实现的功能有要求中的常规功能,以及三个附加功能。
相对路径,环境变量,Tab自动补全。
本文聚焦于讲解具体的实现思路。
代码开源在github上,欢迎大家交流。

lab6-C_log

pre

  • 做完lab6之后感觉意犹未尽,我想实现一个属于我自己的shell,让它能够像unix的shell一样功能完备。
  • 所以我选择完成lab6的challenge。

读懂lab6的sh.c运行

  • 函数调用关系
  • main函数中
  • shell的参数预处理,使用ARGBEGIN ARGEND宏定义完成。
  • 然后循环读入buf
    • readline函数,这个函数是用来处理输入的,对于输入的特别字符我们需要做处理,比如上下左右键等等,将会成为我们后续工作量的主要。
  • 然后用子进程runcmd
    • 先用gettokenparsecmd来获取解析指令
      • 遇到<>重定向
      • 在parsecmd中遇到|进行fork()
    • 然后用spawn运行子程序,注意spawn中也开了进程了
    • 父进程在这里既需要等待spawn的child执行完,又需要等待rightpipe执行完。
  • 这就是shell一条指令的整个处理流程,我们在此基础上完善shell

实现功能清单

具体实现

前置准备操作

  • 为了美观,关闭mos原本的destroying iamkilling等进程信息
  • 然后考虑到我们要优化shell的输入,由于键盘键入的东西我们实在是难以预料。
    • 对于ascii中的可见字符,shell肯定都要支持。
    • 但是大多数不可见字符回显在屏幕上会极度影响shell的体验。
    • 所以我们选择关闭console的读入回显,只选择我们可控的字符进行处理输出其余不输出
  • 在user/lib/console.c中, cons_read函数有下列代码段,它会把输入的东西输出到控制台上。
    • 但是我们不能关之以一劳永逸,因为除了shell之外的其他进程需要用到回显功能
    • 所以我们加个flag,只在shell进程中将其置为true,其余进程不会受到影响
1
2
3
4
5
if (c != '\r') {
debugf("%c", c);
} else {
debugf("\n");
}
  • 接下来我们开始按部就班实现功能

一行多指令 & 后台任务

  • 这两个都可以放在一块说,因为结合我们已经实现的’|’管道,这两个都和它的实现差不多。
  • 一行多指令,就是没有实现两进程之间的通信的管道指令罢了。
  • 而后台任务,就是不需要等待子进程结束的一行多指令罢了。

支持引号

  • 这个需要在_gettoken中修改
  • 可以简单的在_gettoken的里面检测到”一直读入到下个引号停下来。
    • 我并没有实现shell引号里的转义字符了,主要原因还是时间来不及了(

键入位置的修改

  • 这个是工作量最大的之一,主要是对于显示的动态维护移动光标时和退格删除时也需要动态回显
    • 所以关闭自动回显!!!!
  • 基本原理是动态维护两个数组,一个表示在光标前的,一个是在光标后的,光标后的是倒序,这样很方便移动光标的时候的改变。
  • 然后维护两个指针,分别指向两个数组的末尾

光标移动

  • 所以左右移光标的时候,只需要像下面一样:
1
2
3
4
5
6
7
if (direction == RIGHT) {
printRight; //由于关闭回显,所以需要手动移动光标。
beforeCur[beforeLen++] = afterCur[--afterLen];
} else if (direction == LEFT) {
printLeft; //由于关闭回显,所以需要手动移动光标。
afterCur[afterLen++] = beforeCur[--beforeLen];
}
  • 怎么移动光标?
    • 光标其实就是由一些特殊的字符组成的字符串,由显示的外设检测并进行移动光标的操作。
    • 尝试读入之后将字符诶个输出,发现是下面这样的,我用宏定义实现。
1
2
3
4
#define printUp printf("%c%c%c", 27, 91, 65);
#define printDown printf("%c%c%c", 27, 91, 66);
#define printRight printf("%c%c%c", 27, 91, 67);
#define printLeft printf("%c%c%c", 27, 91, 68);
  • 然后注意对于左右移动的限制就好了,如下:
    • 至于上下键的键入,下面要用作实现history,请待下文
1
2
if (beforeLen == inputLen && direction == RIGHT) return;
if (beforeLen == 0 && direction == LEFT) return;

退格与删除操作

  • 对于退格(backspace)删除(delete)的实现,维护数组很简单,分别beforeLen--afterLen--就行。
  • 但是显示上就要花大功夫了,因为退格一位删除一位意味着将后续的字符串整体前移
    • 有一个简单的办法是每一次输入都刷新整个显示串,像下面的操作一样(伪代码)。
1
2
3
4
5
6
7
8
9
10
11
while(...)
printf("\b"); //退到最开始
while(...)
printf(" "); //用空格清空显示
while(...)
printf("\b"); //退到最开始
//打印所有字符
for i in (0, beforeLen-1)
printf(光标前的字符);
for i in (afterLen-1, 0)
printf(光标后的字符);
  • 但是我这么实现出来之后发现,每次移动都能够清楚的看见光标跳来跳去,每次删除都能够看见白花花的一片,十分的丑陋
  • 所以刷新回显的算法是个精细活,我的做法是,退格删除操作光标后面的绝对要刷新,但是前面的可以不动,必要的时候再动。
    • 怎么判定该不该动?怎么动?
    • 我只需要判断先后状态就行了,即存下来之前的beforeLen和afterLen,就能够定位光标(cursor)的位置。
      • 然后进行对应的更改时,就不一定要清空所有前面已经输出的字符,只需要处理cursor到beforeLen之间的字符就行。
    • 然后有的时候前面的字符也是会变的,所以需要刷新所有
      • 这个时候也可以不要空格清空操作,你可以\b退格到最开始之后,开始输出字符。
        • 比原字符串长的话,就覆盖掉了,最好。
        • 比原字符串短的话,就清空后面超出的一段就好。
  • 说了这么多,具体实现请看下面的伪代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void update(int flushAll) {
if (flushAll) {
printBack(cursor);
distance = inputLen - beforeLen - afterLen;
printf(beforeCursor);
printf(afterCursor);
printSpace(distance);
printBack(distance + afterLen);
} else {
distance = inputLen - beforeLen - afterLen;
if (distance == 0)
cursor = beforeLen;
return;
if (cursor > beforeLen)
printBack(cursor - beforeLen);
else
printf(beforeCur+cursor);
printf(afterCursor);
printSpace(distance);
printBack(distance + afterLen);
}
// 最后更新一下光标位置
inputLen = beforeLen + afterLen;
cursor = beforeLen;
}
  • 当然还有在这一部分实现的一个重要功能,即输入tab实现自动补全功能,因为这个与文件系统高度关联,所以得等到实现完相对路径之后,前面的功能以及高度封装,后面不需要再回来修改了。
  • tab自动补全,请看下文

对.b的省略

  • 教程中已经说的很清楚了,就是要在尝试用该名称打开文件失败再添加.b后缀尝试一遍打开。
  • 这个工作在spawn函数中完成,在最开始open失败的时候添加后缀操作。

实现更多的指令

  • 首先创建c文件,然后在用户include.mk文件中加入可执行文件的名称
  • 然后再依葫芦画瓢实现就好啦。

touch & mkdir

  • 这两个指令的实现需要加入到文件系统的IPC通信中。
    • 将文件的open方式设置为O_CREAT或者O_MKDIR
    • 然后file.c的open调用了fsipc.c中的open通信,我们把这个omode传入req->req_omode。
    • 然后在serv.c文件中打开文件失败后检测指令创建文件
  • 但是问题来了,如果我想创建文件重复的时候有报错信息怎么办?
    • 一个方式是取得ipc传回的不同消息来判断是否创建成功。
    • 另一个很简单,就是你在O_CREAT之前O_RDONLY一下,能打开就是已存在的。
  • 对于>的修改就很简单了,open的时候传入O_CREAT|O_WRONLY参数。
    • 在另一边判断的时候也用(perm & omode)这种权限位的判断方式就行。

tree

  • tree我本来以为不是必做的来着(因为我原本想着它不属于unix的内部指令

    • 但是我最后一天突然看到,然后就赶忙实现了
  • 其实最难搞的还是格式问题(虽然我觉得这是c语言程序设计的题

  • 树状图可以采取递归的实现,这个文件递归的深度决定了要输出多少前缀空格
    • 其实也就是dfs了一下文件系统的树状结构。

history历史指令

  • history的历史指令也是工作量最高的之一
  • 但实现其实已经说的比较的清楚了,创建.history文件,然后历史指令都得写入到.history里面。
  • 遍历的时候遍历文件即可,这里参考了zhgg的实现
    • 他把一条指令以< len >< string >< len >作为一个Entry的方式写入文件中,这样可以做到双向的遍历,十分的巧妙。
    • 当然需要一个专门的lib\history.c文件来实现和包括初始化的一系列的操作,以及存下来当前的读写指针
  • 然后在键入上下键的时候可以获取到上一条下一条历史指令,如下:
    • 关键是update是要清空前面的update
1
2
3
4
5
6
7
8
9
10
11
12
// 读取历史指令
if (direction == UP)
len = history_next(beforeCur, UP);
else if (direction == DOWN)
len = history_next(beforeCur, DOWN);
// 更新显示
if (len >= 0) {
beforeLen = len;
afterLen = 0;
}
// 传入参数1;
update(1);

一些面子工程-美化操作

  • 添加规范的注释help指令选项
    • ls与tree默认隐藏‘.’前缀文件
    • 修改指令,增加各种参数选项
  • 花花绿绿的shell
    • 目录文件夹蓝色
    • 表示大小绿色
    • c语言怎么输出颜色可以上网搜,在printf中添加特殊格式符

挑战部分

  • 接下来是挑战部分,实现了三个功能,分别是课程组环境变量规定的相对路径;以及我认为极为影响体验的tab自动补全

环境变量

  • 环境变量的实现确实不算很难,我们选择将所有的变量储存内核态以完成共享的目的。
  • 创建了五个系统调用,与一个Var结构体,存下来键值对shellId读写权限
    • syscall_alloc_shell_id();
    • syscall_declare_var();
    • syscall_unset_var();
    • syscall_get_var();
    • syscall_get_all_var();
  • 这五个系统调用看字面都能看出来意思,这里主要说一下第一个系统调用的考虑。
    • 加入一个shellId属性(不为0,为了判断全局变量考虑)在进程的env中,
    • fork的时候把env_shellId继承下来,子进程创建开始会系统调用alloc_shellId重新分配shellId。
    • get_var的时候通过进程的shellIdVar结构体的shellId进行配对来判断是否可见。
      • 如果是全局变量就把shellId设置为0

支持cd & 相对路径

cd的实现

  • cd的实现随之而来的是进程的工作目录的改变。
  • 先将工作目录添加进PCB即env结构体。
    • 我这里首先确定下来的是我对于工作目录的处理是以字符串的方式来处理的,这个方式比较的简单,而且修改open函数的时候并不需要修改太多。
  • 然后实现两个系统调用,分别是syscall_get_env_pathsyscall_change_dir,用来获取和改变当前的路径,但在内核中执行的其实都是些strcpy的工作,所以大部分的工作需要在用户态完成
    • 这是否也是一种微内核的设计。
  • 而且改变路径的系统调用要能够改变父进程的工作路径,因为cd命令是通过shell的子进程来完成的,改了cd进程的目录没改shell的目录。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int sys_change_dir(char* env_path, int changeParent) {
if (changeParent == 1) {
struct Env *parent = curenv;
while (parent->env_parent_id > 0) {
int r;
r = envid2env((u_int)parent->env_parent_id, &parent, 0);
if (r < 0) {
return r;
}
strcpy(parent->env_path, env_path);
}
}
strcpy(curenv->env_path, env_path);
return 0;
}
  • 而对于change的路径的检查,需要在用户态进行,具体其实也就是open文件看一看是TYPE_DIR就行。
    • 所以接下来是open的工作,我该怎么支持相对路径呢?

相对路径的实现

  • unix判断是相对路径还是绝对路径的方式十分的简单粗暴,最开始是’/‘就是绝对路径,所以原本的open我们改名为openAP(absolute path),然后在open函数里碰到’/‘就调openAP
1
2
3
4
5
6
if (path[0] == '/') {
return openAP(path, mode);
} else {
syscall_get_env_path(0, nowPath);
return openatThis(nowPath, path, mode);
}
  • 那么openatThis是什么呢,就是在nowPath目录以相对路径打开
  • 这个函数其实也可以采取递归的实现,因为其实相对路径是一层一层的目录解析的。
    • 例如当前在/volca/vo目录下,你需要找相对路径../ca/arrive.
    • 过程是:
      • 解析..,进入/volca,然后就是要找ca/arrive,注意了,这一步起是无记忆性的!也就是说前一步是什么我可以不管了
      • 所以你可以递归的调用openatThis(),结束条件是要找的最后一层。
  • 所以openatThis的实现如下:(伪代码)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int openatThis(char *nowPath, const char *path, int mode) {
char dir[MAXNAMELEN];
char nextPath[MAXPATHLEN];
splitPath(dir, nextPath, path);
if (strcmp(dir, ".") == 0) {
// nothing
} else if (strcmp(dir, "..") == 0) {
toParentPath(nowPath);
} else {
catPath(nowPath, dir);
}

if (nextPath[0] == 0) {
return openAP(nowPath, mode);
} else {
return openatThis(nowPath, nextPath, mode);
}
}

tab自动补全

  • tab自动补全的工作量不小,依托于相对路径的实现,可以实现对已经输入的路径的遍历,查找匹配的文件。
    • 我们的工作是实现将路径对于光标前的字符串与光标后的字符串的分离,让它tab的时候不影响到其他单词,我们通过空格来进行区分,所以其实这也说明不支持文件名中有空格的路径的分离
    • 分离出待补全的单词后,再分离出已经输入的路径和待补全的文件名
    • 然后打开这个路径(绝对路径与相对路径都可,如果没有那就默认在当前目录下了),在路径中查找文件,找出之后填补到光标前的字符串中
    • 最后,删除光标后面的部分,到第一个空格。(这主要是为了后面循环匹配准备的
    • 最后的最后update(0);(前面的字符不需要更新了
  • 流程有点复杂,大致伪码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
char comPath[MAXPATHLEN] = {0};
char tobeCom[MAXPATHLEN] = {0};
// 解析分离已输入路径与待补全文件名
getTbCom(comPath, tobeCom);
if (tobeCom[0] == 0) {
return;
}
// 打开路径
if (comPath[0] == 0) {
syscall_get_env_path(0, nowPath);
fd = open(nowPath, O_RDONLY);
} else {
fd = open(comPath, O_RDONLY);
}
if (fd < 0) {
return;
}

//遍历打开的目录下的文件,找到符合要求的保存下来
while (read目录) {
if (f.f_name[0]) {
if (strContain(...)) {
//保存操作
}
}
}
close(fd);
  • 补全层面就是一些字符串操作了,这里就不放出来了。

  • tab循环匹配机制:但是我们要实现连续多次tab的循环匹配,或者像unix那样的显示补全选项。

    • 我们就需要用标志标识多次连续的tab
    • 然后对于路径的分离与匹配不应该重复进行。
    • 所以我们抽象出tabFlushDirtabComplete两个操作,前一个不需要频繁执行
1
2
3
4
5
6
7
void dealTab() {
if (!tabAlreadyFlush) {
tabFlushDir();
tabAlreadyFlush = 1;
}
tabComplete();
}
  • 经过测试,tab自动补全应用场景广泛,演示的时候各种情况都可以来一下hhhh
    • 最开始tab
    • 最后tab
    • 中间tab
    • 已有目录嵌套的tab

总结

  • 实现自己的一个shell还是很裤的一件事情,最开始嫌弃mos shell各种功能不完备,最后实现完这么一大堆东西之后,打开电脑时会情不自禁打开这个shell,敲着一些没有意义的代码,欣赏着自己完成的作品,担心着随时可能出现的bug。
    • 每一次tree之后看到美丽的树状图,都会很兴奋。
    • 每一次touch和mkdir之后都要ls一下看看文件是不是创建成功。
    • 每一次都得装模做样的—help一下看看指令的注释。
    • 每一次cd都在提心吊胆会不会跳错地方。
    • 每一次tab都在坐过山车,经历一个tab之前担心,tab之后开心的过程。
  • 时间很紧又很松,在ddl交付和军理等等考试高度重合的这段时间里中,我打开电脑后还是会兴致冲冲地打开vscode,和这个作品相遇,期待每一次。
  • 这么紧的时间里确实也没有时间支持我去继续完善这个作品了,所以还是留下了很多遗憾,比如ls和tree对文件名的字典序排列,ln文件链接的实现,还有许许多多诸如rm、mv、cp这种unix下的必须指令。
  • 这个世界上确实不会有完美无瑕的作品,多方面因素导致的最后只能是个折中的结果。但是我自己实现的东西,总归还是最裤的。
下一篇:
BUAA-OO-JML-SocialContact