实验总览

本次实验旨在对 MOS 操作系统中现有的 Shell 进行功能扩展,使其从一个基础的命令解释器演进为一个功能更加完善、用户体验更佳的交互式程序。实验内容主要包括:

  1. 路径管理:引入当前工作目录(CWD)的概念,支持相对路径,并实现 cdpwd 内置命令。
  2. 环境变量:实现局部和全局环境变量的管理,包括 declareunset 命令和 $ 变量展开。
  3. 输入与指令优化:实现指令自由输入、不带 .b 后缀指令、快捷键、历史指令、注释功能、
  4. 指令条件执行:实现 Linux shell 中的 &&||
  5. 更多指令:增加 touch, mkdir, rm 等常用文件系统操作命令和 exit 指令。
  6. 追加重定向:实现了追加重定向功能

限于篇幅,本报告并未给出全部修改的代码,仅给出了笔者认为比较关键,语言不能完全描述的部分代码。

实验设计与实现

1. 支持相对路径与内置命令 cdpwd

1.1 设计思路

为了支持相对路径,核心是为每个进程维护一个“当前工作目录”(CWD)的状态。

  1. CWD 的存储
    • 最佳方案是利用共享内存。我分配了一个固定的虚拟地址 CWD_VA (0x7f000000),并将其映射为一个带有 PTE_LIBRARY 标志的共享页面。PTE_LIBRARY 确保了通过 forkspawn 创建的子进程能自动继承这个页面的映射,从而共享 CWD 字符串。
    • 在 Shell 启动时(libmain -> fsutil_init),会检查该页面是否已映射。如果是第一个启动的 Shell,它会负责分配并初始化 CWD 为根目录 /
  2. 路径解析
    • 创建一个 resolve_path 函数,负责将用户输入的任何路径(绝对或相对)转换为一个唯一的、规范化的绝对路径。
    • normalize_path 函数负责处理拼接后的路径,解析 ...,并移除多余的 /,最终得到一个干净的绝对路径。这个绝对路径随后被传递给 openstat 等底层系统调用。
  3. 内置命令实现
    • cd:作为一个内置命令,cd 直接修改共享内存中的 CWD 字符串。它首先使用 resolve_path 解析目标路径,然后通过 stat 检查路径是否存在且为目录,验证通过后,更新 CWD。
    • pwd:同样是内置命令,它直接读取共享内存中的 CWD 字符串并打印到标准输出。

1.2 关键代码

CWD 共享与初始化 (user/lib/fsutil.c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 定义一个固定的虚拟地址用于存储CWD
#define CWD_VA 0x7f000000
static char *current_working_directory;

void fsutil_init(void) {
// 将指针指向我们的固定地址
current_working_directory = (char *)CWD_VA;
// 检查这个页面是否已经被映射。pageref > 0 表示已映射。
// 如果没有被映射,说明当前进程是第一个启动的进程(通常是 sh),
if (pageref(current_working_directory) == 0) {
syscall_mem_alloc(0, (void *)current_working_directory, PTE_D | PTE_LIBRARY);
// 初始化CWD为根目录 "/"
strcpy(current_working_directory, "/");
}
}
  • lib/libos.c 中的 libmain 会在 main 函数执行前调用 fsutil_init,确保 CWD 机制就绪。
  • PTE_LIBRARY 标志是实现父子进程共享 CWD 的关键。

路径解析 (user/lib/fsutil.c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 将相对路径转换为绝对路径
void resolve_path(char *resolved_path, const char *relative_path) {
if (relative_path[0] == '/') {
// 已经是绝对路径
strcpy(resolved_path, relative_path);
} else {
// 是相对路径,与CWD拼接
strcpy(resolved_path, current_working_directory);
// 只有当CWD不是根目录时,才在末尾添加'/'
if (strcmp(current_working_directory, "/") != 0) {
strcat(resolved_path, "/");
}
strcat(resolved_path, relative_path);
}
normalize_path(resolved_path);
}
  • 这个函数统一了路径处理的入口,所有文件操作(如 open, stat, rm 等)都调用它来获取绝对路径。

  • cd 作为一个改变 Shell 自身状态的命令,必须在主 Shell 进程中直接执行,而不是 fork 一个子进程来执行。我的代码在 sh.c 的主循环中正确地处理了这一点。pwd 也可以这样处理,或者像现在一样在子进程中执行,因为它不改变 Shell 状态。

2. 环境变量管理

2.1 设计思路

  1. 数据结构:定义了一个 Var 结构体来存储单个变量,包括名称、值以及 is_envis_readonly 等标志位。所有变量存储在一个全局数组 shell_vars 中。

  2. 命令实现

    • declare:解析 -x, -r 选项和 NAME=VALUE 格式。调用 add_var 函数。该函数会先用 find_var 查找变量。如果存在且非只读,则更新;如果不存在,则在 shell_vars 数组中找一个空位创建。
    • unset:调用 remove_var,该函数会查找变量,如果存在且非只读,则将其从数组中移除。
    • $ 变量展开:在执行命令前,增加一个 expand_vars 阶段。该函数遍历命令行字符串,查找 $ 符号,提取后面的变量名,用 find_var 查找其值,并将 $ 和变量名替换为其值。

2.2 关键代码

变量数据结构与管理 (user/include/var.h, user/lib/var.c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// user/include/var.h
typedef struct {
char name[MAX_VAR_NAME_LEN + 1];
char value[MAX_VAR_VALUE_LEN + 1];
u_int is_env : 1;
u_int is_readonly : 1;
u_int is_used : 1;
} Var;

extern Var shell_vars[MAX_VARS];

// user/lib/var.c
int add_var(const char *name, const char *value, u_int is_env, u_int is_readonly) {
Var *var = find_var(name);
if (var) { // 变量已存在
if (var->is_readonly) { /* ... 错误处理 ... */ }
strcpy(var->value, value ? value : "");
// ... 更新属性 ...
} else { // 变量不存在
if (num_shell_vars >= MAX_VARS) { /* ... 错误处理 ... */ }
// ... 找空位并创建 ...
}
return 0;
}

declare 命令处理 (user/sh.c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int handle_declare(int argc, char **argv) {
// ...
u_int is_env = 0, is_readonly = 0;
// ... 解析 -x, -r 选项 ...

// 解析 NAME[=VALUE]
for (; i < argc; i++) {
char *name = argv[i];
char *value = strchr(name, '=');
if (value) {
*value = '\0'; // 分割 name 和 value
value++;
}
if (add_var(name, value, is_env, is_readonly) != 0) {
return 1;
}
}
return 0;
}

变量展开 (user/sh.c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void expand_vars(char *in_buf, char *out_buf, int out_size) {
char *p_in = in_buf;
char *p_out = out_buf;
// ...
while (*p_in && p_out < end) {
if (*p_in == '$') {
p_in++; // 跳过 '$'
char var_name[MAX_VAR_NAME_LEN + 1];
// ... 提取变量名 ...

Var *v = find_var(var_name);
if (v) {
// ... 将 v->value 拷贝到 out_buf ...
}
} else {
*p_out++ = *p_in++;
}
}
*p_out = '\0';
}

// 在 main 循环中调用
expand_vars(current_chunk_start, expanded_buf, sizeof(expanded_buf));

3. 输入指令优化

3.1 设计思路

为了实现高级的行编辑功能,必须放弃简单的、带回显的 read 系统调用,转而使用更底层的、无回显的字符获取方式,并由 Shell 自己完全控制终端的回显和光标移动。

  1. 指令自由输入

    1. 底层输入:使用 syscall_cgetc() 获取单个字符,该调用不阻塞且无回显。
    2. 状态维护:在 readline 函数中,维护两个核心状态:len(当前输入缓冲区的总长度)和 cursor_pos(光标在缓冲区中的逻辑位置)。
    3. 处理普通字符:接收到可打印字符时,在 cursor_pos 处将其插入缓冲区(需要移动后续字符),然后打印该字符,并使用 ANSI 转义序列重绘光标之后的内容,最后将光标移回正确位置。
  2. 不带 .b 后缀:在 spawn 函数中,当尝试执行一个程序时,如果直接 spawn 失败,并且程序名不以 .b 结尾,就为其拼接上 .b 后缀再尝试一次。

  3. 快捷键

    • 方向键:它们是 ANSI 转义序列(如 \x1b[A)。readline 中有一个状态机来捕获这些序列。
    • 退格键:修改缓冲区和 lencursor_pos,并向终端发送“退格-空格-退格”序列来擦除字符,然后重绘光标后的内容。
    • Ctrl-A/E/K/U/W:每个快捷键都对应一套对缓冲区 buflencursor_pos 的逻辑操作,以及一套相应的终端控制指令(如清屏 \x1b[K,光标移动 \r, \x1b[nC)来更新屏幕显示。
  4. 历史指令:使用一个全局数组 history_lines 存储历史命令。load_historyadd_to_history 函数负责从 /.mos_history 文件中读写历史记录。

  5. **注释 **:我设计了一个辅助函数 find_and_split 。这个函数负责从当前命令指针开始,查找第一个出现的有关字符。其中,# 被视为行尾,它会立即停止对当前命令块的解析。

  6. 反引号替换

    • 在变量展开之后、命令执行之前,增加一个反引号处理阶段。
    • 该阶段遍历命令字符串,查找成对的 `
    • 对于每个 `...` 块,提取其中的子命令。
    • 创建一个管道,子进程执行子命令后通过管道把执行后的结果传给父进程。
  7. 一行多指令:为了支持在一行中通过 ; 分隔执行多个独立的命令,核心思路是将完整的命令行字符串分割成多个独立的命令块,然后按顺序依次执行。

    1. 命令块分割:在 find_and_split 当找到一个 ; 时,它会将该位置的字符替换为 \0,从而有效地将一个长命令字符串“截断”成一个独立的命令块。同时,函数返回下一个命令块的起始位置。
    2. 循环执行:在 sh.cmain 函数中,我设计了一个主 while 循环。这个循环不断地调用 find_and_split 来获取下一个命令块。获取到命令块后,就立即对其进行变量展开、反引号替换,并最终通过 forkruncmd(或直接执行内置命令)来执行它。

    通过这种“分割-执行”的循环模式,Shell 能够处理包含任意多个由 ; 分隔的指令行。

3.2 关键代码

自由输入与快捷键 (user/sh.c: readline)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void readline(char *buf, u_int n) {
int cursor_pos = 0; // 光标在缓冲区中的逻辑位置
int len = 0; // 缓冲区中当前命令的长度
// ...
while (1) {
while ((c = syscall_cgetc()) == 0) { // 无回显获取字符
syscall_yield();
}
if (c == '\b' || c == 0x7f) { // 退格键
// 实现回显擦除
} else if (c == '\x1b') { // ANSI转义序列 (箭头等)
// 根据转义序列判断快捷键类型并执行对应操作
} else { // 普通字符
printf("%c", c); // 手动回显
// ... 逻辑插入并重绘光标后内容 ...
}
}
}

find_and_split函数

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
char *find_and_split(char *s, char *op) {
char *p = s;
while (*p) {
if (p[0] == '#') {
break;
}
if (p[0] == '&' && p[1] == '&') {
*op = '&';
*p = '\0';
return p;
}
if (p[0] == '|' && p[1] == '|') {
*op = 'o';
*p = '\0';
return p;
}
if (p[0] == ';') {
*op = ';';
*p = '\0';
return p;
}
p++;
}
*op = 0;
return p;
}
  • 该函数负责查找命令中的 &&||#;

4. 指令条件执行

4.1 设计思路

我把 Shell 的主循环设计成了一个能处理复杂命令行的状态机。

  1. 命令分隔与执行

    • ;find_and_split 来查找下一个命令分隔符(&&, ||)。它将命令字符串在分隔符处用 \0 截断,形成一个个命令块。
    • &&||:主循环维护一个 last_status (上一个命令的退出码) 和 should_run_next (是否应执行下一个命令) 的状态。
  2. exit 返回状态:我修改了原有的 exit() 函数,使其能够支持获得命令执行的返回值。

5. 更多指令

5.1 设计思路

  1. touch, mkdir, rm:
    • 这些命令都是独立的可执行程序 (touch.c, mkdir.c, rm.c)。
    • 它们通过 main 函数的 argc, argv 解析命令行参数(如 rm -rf)。
    • 内部调用相应的库函数(最终是系统调用)来完成操作:
      • touch: 使用 openO_CREAT 标志。
      • mkdir: 使用新增的 fsipc_mkdir -> serve_mkdir -> file_create_dir 流程。-p 选项通过递归调用 mkdir_p 实现。
      • rm: stat 判断是文件还是目录。对文件调用 remove。对目录,如果带 -r,则递归遍历并删除内容,最后删除自身。
  2. exit:
    • 这是一个内置命令,因为它需要终止当前的 Shell 进程。
    • handle_exit 解析可选的数字参数,然后调用 syscall_exit(code) 退出。

6. 实现追加重定向

6.1 实现思路

追加重定向:

  • _gettoken 中增加对 >> 的识别,返回一个特殊的 token。
  • parsecmd 中,为这个新的 token 增加一个 case。
  • 当遇到 >> 时,使用 openO_WRONLY | O_CREAT | O_APPEND 标志打开文件。O_APPEND 是关键,它告诉文件系统后续的 write 操作都在文件末尾进行。
  • user/lib/fd.cwrite 函数中,也增加了对 O_APPEND 模式的检查,在每次写入前,先 seek 到文件末尾。

总结

本次实验成功地将 MOS 的 Shell 从一个简单的命令执行器,转变为一个具备现代 Shell 诸多核心功能的强大工具。通过实现CWD管理、环境变量、行编辑、历史命令、高级命令流控制等功能,不仅极大地提升了 MOS 的可用性和用户体验,更重要的是,在实践中深入理解了操作系统中进程状态管理、内存共享、IPC、文件系统交互等核心概念的底层实现细节。整个过程充满挑战,但也收获颇丰。