实验思路

一行多命令

实现效果

; 分开同一行内的两条命令,表示依次执行前后两条命令。; 左右的命令都可以为空。

实现

fork 一个子进程执行已解析出的命令,父进程等待子进程执行结束后再继续解析。

1
2
3
4
5
6
7
8
9
10
11
// sh.c parsecmd()
case ';':
r = fork();
if (r == 0) {
return argc;
} else {
wait(r);
*rightpipe = 0;
return parsecmd(argv, rightpipe);
}
break;

后台任务

实现效果

& 分开同一行内的两条命令,表示同时执行前后两条命令。& 左侧的命令应被置于后台执行,Shell 只等待 & 右侧的命令执行完毕,然后继续执行后续语句,此时用户可以输入新的命令,并且可能同时观察到后台任务的输出。& 左侧的命令不能为空。

实现

与上一个类似,只是父进程不需要等待子进程直接继续解析即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//sh.c parsecmd()
case '&':
if (!argc) { // 左侧命令不能为空
debugf("sh: grammar error around &\n");
exit();
}
r = fork();
if (r == 0) {
background = 1;
return argc;
} else {
*rightpipe = 0;
return parsecmd(argv, rightpipe);
}
break;

引号支持

实现效果

将引号(包括单引号和双引号)内的内容看作单个字符串,对于紧挨着引号的内容将其连接视作整体,引号内输入回车也视作普通字符,直到引号匹配。

实现

对于标识符和单词等的解析在 _gettoken,读到一个有效引号(不在引号内的引号)后删除这一字符并更新当前状态(是否在引号内),当在引号内时忽略空格和预留字符的作用。(通过修改输入逻辑保证命令的引号一定匹配)

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
28
29
30
// sh.c _gettoken
int quat = 0;
*p1 = s; // 起始位置
while (*s && (quat || !strchr(WHITESPACE SYMBOLS, *s))) {
if (strchr("\'\"", *s)) {
if (!quat) { // 进入引号
quat = *s; // 设置引号类型
char *t = s;
while (*t) { // 删除该引号
*t = *(t + 1);
t++;
}
} else {
if (quat == *s) { // 找到对应引号
quat = 0; // 清除标志位
char *t = s;
while (*t) { // 删除该引号
*t = *(t + 1);
t++;
}
} else { // 当作普通字符
s++;
}
}
} else {
s++;
}
}
*p2 = s;// 下一次解析起始位置
return 'w';

键入命令时任意位置的修改

实现效果

可以使用左右键移动光标位置,并在该位置进行删除和增添字符,对于修改后的命令及时回显,并且输入回车后可以正常运行修改后的命令。删除键以及左右键到达边界时,保持原样并发出警报。

实现

主要思路是增加变量 len 记录输入命令的总长度,变量 p 为当前读取字符的位置,变量 left 为左边界的位置,变量 quat 为当前引号类型,字符分为程序内部字符数组以及控制台显示字符,p 相当于光标位置,输入字符后保证程序和控制台的一致性即可。

修改 readline 根据输入字符类型分类处理

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
28
29
30
31
32
// myRead.c readline
len = 0;
quat = 0;
left = 0;
for (p = 0; p < n; p++) {
char c;
debugf("\x1b[s"); // 保存光标位置,便于上下键复位
read_char(fd, &c);
if (c == '\r' || c == '\n') {
if (!quat) { // 如果不在引号内
buf[len] = '\0';
if (iscons(fd) && len) { // 对于控制台输入的 shell 以及命令不为空才保存
save_cmd(buf, record);
} else if (!len) {
record->tot_history--;
}
return;
} else {
p = len;
buf[len++] = '\n';
buf[len] = '\0';
left = len;
debugf("> ");
}
} else if (c == 0x1b) { // 控制符
read_control(fd, buf, record);
} else if (c == 0x7f || c == '\b') { // 退格
read_backspace(fd, buf);
} else { // 正常字符
read_normal(fd, buf, c);
}
}

以正常字符为例,先将 p 之后的字符后移,然后更新字符长度并赋值,如果是控制台输入则需要相应的进行回显。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// myRead.c
void read_normal(int fd, char *buf, char c) {
for (int i = len; i > p; i--) {
buf[i] = buf[i - 1];
}
len++;
buf[p] = c;
buf[len] = '\0'; // 向数组中增加字符
if (strchr("\'\"", c)) {
if (!quat) {
quat = c;
} else if (quat == c) { // 找到对应引号才清除标志
quat = 0;
}
}
if (iscons(fd)) { // 如果是控制台输入
debugf("\x1b[s\x1b[D\x1b[K"); // 保存光标位置,光标先向左移动,删除光标之后的所有字符
debugf("%s\x1b[u", &buf[p]); // 更新输出pos及之后的字符,光标回到保存的位置
}
}

程序名称 .b 的省略

实现效果

允许命令省略 .b 后缀,并在当前命令没有该后缀时加上后缀继续尝试打开。

实现

spawn 函数中第一个参数 prog 就是程序名,首先会根据 prog 打开文件,如果此时没找到文件并且没有后缀 .b,那么可以尝试添加 .b 后缀之后再打开看是否可行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// spawn.c spwan
if ((fd = open(prog, O_RDONLY)) < 0) {
int len = strlen(prog);
if (fd == -E_NOT_FOUND && !(prog[len - 2] == '.' && prog[len - 1] == 'b')) {
char buf[4096];
int i = 0;
while (prog[i] != '\0') {
buf[i] = prog[i];
i++;
}
buf[i++] = '.';
buf[i++] = 'b';
buf[i++] = '\0';
fd = open(buf, O_RDONLY);
}
if (fd < 0) {
return fd;
}
}

tree

实现效果

tree [-adfs] [-L n] [path…]

  1. -a,显示隐藏目录和文件(给 ls.b 也加入了该参数)
  2. -d,只显示目录
  3. -f,显示完整的路径名
  4. -s,显示文件或目录的大小
  5. -L n,限制显示层级

实现

参考 ls.b,记录当前目录路径以及输出前缀,读取目录中的文件控制块,对于文件加上前缀输出即可,对于目录输出后需要继续递归处理。

mkdir & touch & 重定向 >

实现效果

mkdir [-pv] dir…

  1. -p,目录已存在不报错,且按需创建父目录
  2. -v,为每一个已创建的目录打印信息

touch [-c] file…

  1. -c,不创建任何文件

当重定向 > 目标路径不存在时,自动创建并写入。

实现

修改 serv_open 通过文件打开模式 O_MKDIR(创建目录),O_CREAT(创建文件),O_EXCL(文件存在时报错) 以及 O_TRUNC(清空文件) 进行操作,如果打开文件不存在并且需要创建文件或者创建目录,那么通过 file_create 进行文件的创建再赋上文件类型即可,如果文件存在并且有 O_EXCL 那么返回文件存在错误,如果有 O_TRUNC 使用 file_truncate 将文件内容清除,如果没有读写需求使用 file_close 关闭文件。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// serv.c
void serve_open(u_int envid, struct Fsreq_open *rq) {
struct File *f;
struct Filefd *ff;
int r;
struct Open *o;

// Find a file id.
if ((r = open_alloc(&o)) < 0) {
ipc_send(envid, r, 0, 0);
return;
}

// Open the file.
r = file_open(rq->req_path, &f);
int o_mode = rq->req_omode;
if (r == -E_NOT_FOUND && ((o_mode & O_CREAT) || (o_mode & O_MKDIR))) {
if ((r = file_create(rq->req_path, &f)) < 0) {
ipc_send(envid, r, 0, 0);
return;
}
f->f_type = (o_mode & O_CREAT) ? FTYPE_REG : FTYPE_DIR;
} else if (r >= 0 && (o_mode & O_EXCL)) {
ipc_send(envid, -E_FILE_EXISTS, 0, 0);
return;
} else if (r < 0) {
ipc_send(envid, r, 0, 0);
return;
}

// Save the file pointer.
o->o_file = f;

// Fill out the Filefd structure
ff = (struct Filefd *)o->o_ff;
ff->f_file = *f;
ff->f_fileid = o->o_fileid;
o->o_mode = rq->req_omode;
ff->f_fd.fd_omode = o->o_mode;
ff->f_fd.fd_dev_id = devfile.dev_id;

if (o_mode & O_TRUNC) {
file_truncate(f, 0);
}
if (!(o_mode & O_ACCMODE)) {
file_close(f);
}

ipc_send(envid, 0, o->o_ff, PTE_D | PTE_LIBRARY);
}

对于 mkdir 使用 open(path, O_MKDIR | O_EXCL),对于 touch 使用 open(file_path, O_CREAT),对于重定向使用 open(t, O_CREAT | O_WRONLY)

历史命令

实现效果

能够通过上下键进行历史命令的切换,能够继续编辑按下回车后能正确执行。对于新输入的命令切换后能暂时保存,可以切换回来继续编辑。对于边界情况保持原样并且发出警报。输入命令后将命令写入 .history 文件中。

history [-c] [n]

  1. -c,清空历史文件
  2. n,输出最近 n 条指令(默认全部输出)

实现

在 shell 初始时使用 open(“.history”, O_TRUNC | O_CREAT) 创建或者清空历史命令文件。

命令的切换

为了方便在 sh.c 中使用结构体变量储存命令相关参数,并且该变量需要对 fork 的子进程共享。

1
2
3
4
5
struct history {
int tot_history; // 总共有多少命令
int now_history; // 当前在第几条命令
int end[1022]; // 每条命令的结尾在文件中的偏移
}

实现一系列函数完成相关功能。读取到上下键之后通过 get_pre_cmd 或者 get_next_cmd 函数进行命令的切换,内部都是以 read_cmd 为核心进行操作的。当一条命令完整读入后对其进行储存。当输入部分命令后进行切换可以对该命令进行临时储存,之后可以切换回该命令继续操作。命令切换后需要对这一行进行清除,并重新输出显示新的命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// myRead.c read_control
if (up) {
debugf("\x1b[u");
buf[len] = '\0';
clear(buf);
get_pre_cmd(buf, record);
print_buf(buf);
}

// get_cmd.c
void read_cmd(int fd, char *buf, int offset, int size);// 从 offset 开始读取 size 长度的字符
int write_cmd(int fd, char *buf, history *record);// 向历史文件末尾写入命令 buf
void get_pre_cmd(char *buf, history *record);// 获取上一条命令
void get_next_cmd(char *buf, history *record);// 获取下一条命令
void save_cmd(char *buf, history *record); // 保存命令
history.b
输出

将整个文件内容读入,记录有效的 \n 的数量以及位置 pos[] 确定命令的情况,接着根据 pos 输出即可。

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
28
29
30
31
32
33
34
35
36
37
38
// history.c
void print(int n) {
int r;
int fd;
if ((fd = open(".history", O_RDONLY)) < 0) {
debugf("open file .history error: %d\n", fd);
exit();
}
if ((r = read(fd, buf, sizeof(buf))) < 0) {
debugf("read file .history error: %d\n", r);
exit();
}
tot = 0;
pos[0] = -1;
int quat = 0;
for (int i = 0; i < r; i++) {
if (buf[i] == '\'' || buf[i] == '\"') {
if (!quat) {
quat = buf[i];
} else if (buf[i] == quat) {
quat = 0;
}
}
if (buf[i] == '\n' && !quat) { // 不在引号内的回车才是命令末尾的回车
pos[++tot] = i;
}
}
if (!tot) {
debugf("no history\n");
exit();
}
for (int i = n > tot ? 1 : tot - n + 1; i <= tot; i++) {
int start = pos[i - 1] + 1;
int end = pos[i];
buf[end] = '\0';
debugf("%-4d %s\n", i, &buf[start]);
}
}

shell 环境变量

实现效果

子 shell 能够继承环境变量并对其做出修改;局部变量对子 shell 不可见。

delcare [-xr] [NAME [=VALUE]]

  1. -x,设置环境变量,否则为局部变量
  2. -r,变量只读,不能被重新赋值或者删除

NAME 不存在则创建,否则重新赋值(如果可以的话)。VALUE 缺省为空字符串。只输入 declare 则输出所有变量,包括环境变量和局部变量。

unset NAME

如果 NAME 不是只读变量,则删除变量(环境变量和局部变量都删除)。

支持在命令中展开变量的值,优先使用环境变量,没有对应的变量则为空字符串。

实现

declare & unset

将一对变量用结构体储存

1
2
3
4
5
6
7
8
9
// EnvVar.h
#define ENV_VAR_FREE 0 // 未使用
#define ENV_VAR_RDONLY 1 // 只读
#define ENV_VAR_RDWR 2 // 可读可写
struct Env_var {
char name[20];
char value[20];
int mode;
};

sh.c 中记录两个变量的数组,分别为环境变量和局部变量,在初始时通过 syscall_mem_map 各自修改 perm 使得环境变量能够在子 shell 中共享,局部变量可以在 fork 出的子进程中共享。

1
2
3
// sh.c
Env_var global_env_var[MAXENVVARS] __attribute__((aligned(BY2PG)));
Env_var local_env_var[MAXENVVARS] __attribute__((aligned(BY2PG)));

unsetdeclare 属于内置命令,不通过加载程序执行,所以需要修改 runcmd 的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// sh.c runcmd
if (strcmp(argv[0], "unset") == 0) {
unset_main(argc, argv, global_env_var, local_env_var);
} else if (strcmp(argv[0], "declare") == 0) {
declare_main(argc, argv, global_env_var, local_env_var);
} else {
int child = spawn(argv[0], argv);
close_all();
if (child >= 0) {
wait(child);
} else {
debugf("spawn %s: %d\n", argv[0], child);
}
if (rightpipe) {
wait(rightpipe);
}
if (argc > 1 && (strcmp(argv[0], "history") == 0 || strcmp(argv[0], "history.b") == 0) &&
strcmp(argv[1], "-c")) { // 清除历史命令
record.tot_history = record.now_history = 0;
}
}

主要实现通过以下函数的调用

1
2
3
4
5
6
// EnvVar.h
Env_var *find_env_var(Env_var *env_var_array, char *name); // 在对应的变量数组中找到 key 为 name 的变量
Env_var *get_free_env_var(Env_var *env_var_array); // 在对应的变量数组中找到一个未使用的变量
int set_env_var(Env_var *env_var_array, char *name, char *value, int mode); // 在对应的变量数组中设置变量
int unset_env_var(Env_var *env_var_array, char *name); // 在对应的变量数组中取消变量
void print_env_var(Env_var *env_var_array, char *name); // 输出变量数组内的键值对
变量展开

增加预留字符 $,将其后面的字符串在变量数组中进行匹配,并指向相应的 VALUE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// sh.c parsecmd
case '$':
if (gettoken(0, &t) != 'w') {
debugf("syntax error: < not followed by word\n");
exit();
}
Env_var *var = find_env_var(global_env_var, t);
if (var == NULL) { // 环境变量中不存在
var = find_env_var(local_env_var, t);
if (var == NULL) { // 局部变量中不存在
t = "";
} else {
t = var->value;
}
} else {
t = var->value;
}
argv[argc++] = t; // 加入参数
break;

测试程序

一行多命令

主要验证是否顺序执行。

1
ls;mkdir test;ls;mkdir test/a;tree test

image-20230613195627033

后台任务

1
sh script & ls

image-20230613203930610

可以看到两个任务交错输出,并且回到 shell 后后台任务仍在运行。

引号支持

检测输入是否正确要求引号匹配以及对引号内回车的处理;引号连接功能的检测;是否将引号内容作为单个字符串

1
2
3
4
5
6
echo abc"def';&
gh"ijk
echo abc'deg";&
fg'ijk
touch 'ab c'
tree
image-20230613203037274

键入命令时任意位置的修改

随机输入命令并进行修改,使得及时回显且能够正确运行,同时检测左右移动以及删除键的边界,具体过程可现场演示。

实现程序名称中 .b 的省略

在其他测试命令中可以体现,可以交错使用包含和不包含 .b 的命令。

tree

1
2
3
4
5
6
7
tree
tree -a
mkdir -p a/b/c
tree -d
tree -fs
tree -L 2
tree a a/b
image-20230613210446145image-20230613210542107image-20230613210619851image-20230613210649014image-20230613210723974image-20230613212018138

mkdir

1
2
3
4
5
6
7
8
9
mkdir test1
ls
mkdir -v test2
ls
mkdir test2
mkdir test3/test4
ls
mkdir -pv test3/test4
tree

image-20230613214735788

touch

1
2
3
4
touch test1 test2
ls
touch -c test4
ls

image-20230613215011609

重定向

1
2
3
4
ls
cat script > test
ls
cat test

image-20230613215323011

历史命令

1
2
3
4
5
6
7
8
9
ls
echo hello os
mkdir test
history
history 3
history 10
cat .history
history -c
cat .history

image-20230613224747157

shell 环境变量

检测能否正常设置(包括缺省);检测可见性是否正确;检测能否重新赋值和删除;检测只读变量;检测是否能展开变量

1
2
3
4
5
6
7
declare hello =world;declare -x os;declare
sh
declare
declare -x os =best;declare
unset os;declare
declare -r os =best;declare os;unset os;declare
echo os is $os

image-20230616210651472

遇到的问题和解决方案

“后台”

因为 shell 等待输入调用的 cons_read 内部会不断调用系统调用 syscall_cgetc,而系统调用内部又是忙等待,所以会导致后台任务在此时其实并不会被调用运行,只有在其它命令输入后并执行的过程中才会调用它,要想真正实现后台运行就需要去掉 sys_cgetc 中的忙等待,直接返回 scancharc() 才能真正实现“后台”执行。

1
2
3
4
//syscall_all.c
int sys_cgetc() {
return scancharc();
}

控制符

关于上下左右键的读取,它们并不是由单个 ASCII 码构成的,而是 CSI(control sequence introducer) 控制序列构成,具体可以使用 man console_codes 查看详细文档。下面列出一些用到的控制符

控制码 效果
\x1b[nA 向上移动n行
\x1b[nB 向下移动n行
\x1b[nC 向右移动n列
\x1b[nD 向左移动n列
\x1b[3~ Del键
\x1b[nP 删除光标右边n个字符,剩下部分左移,光标不动
\x1b[s 保存光标位置
\x1b[u 回到光标保存位置
\x1b[K 删除光标到行尾的内容
\x1b[nm 颜色等设置

.b 的增加

由于参数是连续放置的,所以直接在 prog 后添加后缀的话会影响到第一个参数,将其变成 b,所以需要重新开个数组添加后缀。

tree —— 前缀

经过观察可以发现,tree 命令最后一项和其他项的前缀是有区别的,例如

  1. 最后一项的前面的符号是 └──,而其他项前面的符号是 ├──

  2. 最后一项如果是目录的话,对于其子项前缀加上空格即可,而其他项如果是目录,前缀需要加 “| ”。

    image-20230616210942223

为了实现最后一项的判断,需要先找到一个合法的文件控制块,然后循环读取合法的文件控制块,并对前一个进行输出,最后进行最后一项的输出。

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
28
// tree.c

void print(struct File f, int end, char *path, char *pre, int dep);

// tree(char *path, char *pre, int dep)
struct File f1, f2;
do {
if ((r = readn(fd, &f1, sizeof(f1))) < 0) {
debugf("read file error: %d", r);
exit();
}
if (!r) { // 读取为空
break;
}
} while (!check(f1));
while ((r = readn(fd, &f2, sizeof(f2))) == sizeof(f2)) {
if (check(f2)) {
print(f1, 0, path, pre, dep);
f1 = f2;
}
}
if (r < 0) {
debugf("read file error: %d", r);
exit();
}
if (check(f1)) {
print(f1, 1, path, pre, dep);
}

共享

一开始环境变量和局部变量映射的 perm 分别是 PTE_D | PTE_LIBRARY 和 PTE_D,但这样会出现局部变量设置不成功的情况,这是因为每次执行命令是使用 fork 出的子进程操作,由于写时复制机制使得内容修改对父进程没有影响,所以增加一种 perm —— PTE_FORK 使得只在 fork 的父子进程之间共享,在 duppage 时加入对这种 perm 的判断,本地环境变量的 perm 设为 PTE_D | PTE_FORK。

经过测试设置环境变量后执行其他程序命令会影响环境变量内容,如下图所示

image-20230614183515406

我猜测因为环境变量会进行映射,但是其他程序相应的位置有自己的变量进行修改或者初始化,就使得 shell 进程的环境变量进行了错误的修改。解决方案就是再加一种 perm —— PTE_SH,只在装载 sh.b 程序的时候共享页面,在 spwan 进行映射时加上判断,环境变量的 perm 设为 PTE_D | PTE_FORK | PTE_SH。

除此之外,如果直接定义两个数组由于在映射的时候是页面为单位,两个数组的边界的 perm 就会出现问题,需要使用 __atrribute__((aligned(BY2PG))) 使其以页面开头为起始地址分配空间,对于 record 同理。