你好!本文章为该博客的第十二篇文章!

本文章包含北航2025操作系统shell挑战性任务的实现指导,期望通过重述题目描述和给出一种可行的设计思路来帮助大家更好的完成此挑战性任务。

需要注意的是,本文仅完全适用于北航2025OS课程,对于之前和之后的课程,课程部分内容可能会发生变动,发生冲突时请以当前课程为准!

以及,如果本文章涉及学术诚信问题,请第一时间和我联系,我将及时做出修改,谢谢支持!

本文章尚未完成,如有需要请大力催更!

北航2025 OS shell挑战性任务实现指导

前言

本文为北航2025《操作系统》课程的shell挑战性任务(基于lab6的MOS架构)的实现指导,主要聚焦于给出一种可行的设计思路,以及基于该设计的具体实现。

任务回顾

本挑战性任务要求完成一个类似Linux中Bash的shell

基于此(可能是题目设计时候的)原则,接下来的大部分特性如无具体说明,将与其保持一致。

本挑战性任务要求设计的新功能如下(与原指导书顺序相比有调整,内容有删改):

实现输入指令的优化

本部分任务如下:

  • 运行外部指令时,无需输入指令结尾的 .b。(举例:lsls.b 均为有效可运行的指令)

  • 实现以下快捷键:

    快捷键 行为
    left-arrow 光标尝试向左移动 1 列,如果可以移动则移动
    right-arrow 光标尝试向右移动 1 列,如果可以移动则移动
    backspace 删除光标左侧 1 个字符并将光标向左移动 1 列;若已在行首则无动作
    Ctrl-E 光标跳至最后
    Ctrl-A 光标跳至最前
    Ctrl-K 删除从当前光标处到最后的文本
    Ctrl-U 删除从最开始到光标前的文本
    Ctrl-W 向左删除最近一个 word(即,先越过空白(如果有),再删除连续非空白字符)
  • 实现历史指令功能。至多保存最近运行的20条指令,将其存入 /.mos_history 中。

    需要在每次指令输入完毕之后,运行之前,对 /.mos_history 进行写入。

    允许使用 up-arrowdown-arrow 实现历史指令和当前指令间的切换。

    新增内建指令 history,其效果为输出 /.mos_history 中的内容。

支持内建指令和外部指令传递返回值到shell中

即,支持父进程获取子进程 main 函数的返回值。

支持内建指令 exit

支持内建指令 exit,执行后退出当前shell。

支持更多标识符

本部分任务如下:

  • 支持使用 >> 将标准输出重定向至文件,进行追加输出。
  • 支持使用 ; 分割多个指令,使得一行内的多个指令从前到后按顺序执行。
  • 支持使用 &&|| 实现指令的条件执行。对于 command1 && command2command2 被执行当且仅当 command1 返回 0;对于 command1 || command2command2 被执行当且仅当 command1 返回非 0 值。二标识符的优先级相同,按从左到右的方式执行。

完成此部分任务后,指令解析的EBNF文法如下:

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
# 指令输入
line ::= list

# 列表
list ::= and_or ( ( ";" ) and_or )*

# 逻辑与/或: 支持 && 和 ||
and_or ::= pipeline ( ( "&&" | "||" ) pipeline )*

# 管道: 支持 |
pipeline ::= command ( "|" command )*

# 简单命令:WORD 串 + 重定向
command ::= WORD ( WORD | redirect )*

# 重定向
redirect ::= "<" WORD
| ">" WORD
| ">>" WORD

# 词法单元
TOKEN ::=
WORD (* 普通字或变量展开后的字符串 *)
| T_PIPE (* "|" *)
| T_SEMI (* ";" *)
| T_AMP (* "&" *)
| T_AND (* "&&" *)
| T_OR (* "||" *)
| T_REDIR_IN (* "<" *)
| T_REDIR_OUT (* ">" *)
| T_REDIR_APP (* ">>" *)
| T_EOL (* 换行 或是 # *)
| T_EOF (* 输入结束 *)

支持对指令的预处理

本部分任务如下:

  • 使用 # 实现注释功能,# 之后的内容将被无视。
  • 使用反引号实现指令替换,将反引号内指令执行的所有标准输出代替原有指令中的反引号内容。

添加当前工作目录到shell中,并添加相关指令

本部分任务如下:

  • 为进程添加“当前工作目录”字段,子进程在创建时继承父进程的当前工作目录,无父进程的进程的当前工作目录初始为根目录。

  • 支持相对路径的解析,修改文件系统相关函数与当前指令的实现,使之支持相对路径。

  • 支持内建指令 cd

    输入 行为 输出 返回值
    cd 切换工作目录到 / 0
    cd <abspath> 若绝对路径 <abspath> 存在且为目录,切换到该目录 0
    cd <relpath> 根据当前工作路径,对相对路径<relpath>,若存在且为目录,切换到该目录 0
    cd <noexist_path> 路径不存在 cd: The directory '原始输入' does not exist\n 1
    cd <filepath> 路径存在但不是目录 cd: '原始输入' is not a directory\n 1
    cd <arg1>..<argn> 输入多于 1 个参数 Too many args for cd command\n 1
    cd . 解析为当前目录 /dir1/dir2,无变化 0
    cd .. 解析为 /dir1,若存在且为目录,切换到该目录 0
  • 支持内建指令 pwd

    输入 行为 输出 返回值
    pwd 输出当前工作目录 /dir1/dir2\n 0
    pwd arg1 ... argn 参数数量错误 pwd: expected 0 arguments; got n\n 2
  • 支持外部指令 touchmkdirrm,要求与Bash一致(但只需支持输入路径个数为0或1的情况)。

增加对变量的支持

在该shell中,变量为具有可读写属性和继承属性的键值对,具体描述如下:

  • key最大长度不超过16,名称与C语言变量命名要求相同。
  • value最大长度不超过16。
  • 按读写属性分类,可分为只读变量和可读写变量。前者只可读不可写,后者可读可写。
  • 按继承属性分类,可分为环境变量和局部变量。在创建子进程时,子进程仅从父进程处复制所有的环境变量,而不复制局部变量,且此后父子进程对变量的操作互不影响。

本部分任务如下:

  • 支持内建指令 declare [-xr] [NAME[=VALUE]],其中:
    • -x 表示变量 NAME 为环境变量,否则为局部变量。
    • -r 表示将变量 NAME 设为只读。只读变量不能被 declare 重新赋值或被 unset 删除。
    • 如果变量 NAME 不存在,需要创建该变量;如果变量 NAME 存在,将该变量赋值为 VALUE
    • 其中 VALUE 为可选参数,缺省时将该变量赋值为空字符串即可。
    • VALUE 存在,则 NAME=VALUE 之间不存在任何空格
    • 如果没有 [-xr][NAME[=VALUE]] 部分,即只输入 declare,则输出当前 shell 的所有变量,包括局部变量和环境变量。
  • 支持内建指令 unset NAME 命令,若变量 NAME 不是只读变量,则在当前进程中删除变量 NAME
  • 支持在命令解析时展开变量的值,如使用 echo.b $NAME 打印变量 NAME 的值。

由此,我们记录下了所有需要完成的任务。

设计思路与特性总结

实际上shell挑战性任务是思维难度最小,对OS的知识储备要求最小的一个挑战性任务。

但是,笔者完成该任务总计增删代码量为 2211 行。

这样一个工作量,可以预见的是,如果一开始的架构很糟糕,那么接下来是寸步难行的。

(很遗憾,lab6完成的shell的架构相对于该任务就很糟糕。)

(当然,我的室友@wrongization 就基于原shell架构完成了整个任务,同时他也在OO课程的第一单元用纯纯的正则表达式解析处理掉了词法分析和语法分析,在此我向本宿舍最大赤石仙人致敬。)

因此,我们根据以上调整顺序后的任务回顾进行提炼总结,给出以下完成顺序,以期在保留原shell部分特性的同时进行迭代式重构:

  1. spawn 处增加输入省去后缀的优化。
  2. 重写 readline,将其扩展为一个管理输入缓冲与屏幕输出的模块,在此完成输入指令优化任务。
  3. 重写指令解析与执行部分,将IO管理之后的部分移除,换为用递归下降法解析指令,之后在抽象语法树(AST)上进行运行。
  4. 调整原shell中的子进程创建时机,以支持内建指令。
  5. 修正原shell中对重定向的处理,以修正内建指令与重定向时的输入输出错误。
  6. 增加指令 exit
  7. 修改进程控制块,增加进程返回值字段,并调整系统调用实现父子进程间返回值的传递。修正所有 exit() 的参数传递。
  8. 添加更多标识符。
  9. 修改IO管理部分,增加对输入指令的预处理。
  10. 修改进程控制块,增加当前工作目录字段,并修改进程管理函数,增加系统调用,实现对工作目录的读取、修改和继承。
  11. 修改用户态文件管理接口函数,在接口中增加对相对路径的支持,以及目录的创建。
  12. 增加路径相关的内建指令和外部指令。
  13. 修改进程控制块,增加环境变量列表字段,并修改进程管理函数,增加系统调用,实现对环境变量的增删读写。
  14. 增加对环境变量的管理指令。
  15. 修改指令的执行阶段,使之可以在运行前替换所有变量。
  16. 提交,享受暑假生活。

步骤很多,但是应该覆盖全面了。(毕竟我在读其他同学的分步较少的博客的时候,很多设计实际上还是需要我自己摸索,并且很多博客都是基于原架构实现的,而我刚刚才骂过原架构……)

(以及,为了保证快速上手,建议在遇到一些不会写的代码的时候学会抄MOS的已有内容

为了保证下面叙述顺利,我在这里给出几个该任务中shell和进程应该具有的特性。

(以及,如果你想试着不看下面的实现过程,想试试自己的工程能力的话,那么也请你至少读完这些特性再动手,毕竟这些坑实在是太难想了,我在实现过程中发了纠正帖,以及反复问了LLM,才踩完这些坑……)

  1. 在shell中,当 pipeline 中包含多于一个 command 时,被管道连接的各个 command 应在不同的子进程中运行。当 pipeline 仅包含一个 command 时,该指令应在shell进程本身被处理(即,不应该通过fork出子进程后再执行的方式处理)。

  2. 子进程在被创建时,应当继承父进程的当前工作目录信息和环境变量信息,并采用深拷贝的方式继承。(即子进程被创建后,父子进程的对有关字段的操作相互独立,互不影响)。

  3. 在处理历史指令相关输入优化时,.mos_history 的写入应在指令读入完成之后,被执行之前。.mos_history 仅存放实际被执行的指令(即使该指令不合法),因此在用户输入时所进行的所有的指令修改中,仅被执行指令的指令追加至 .mos_history 的最后,其余修改仅保留至内存中,不影响 .mos_history 的内容。

    这个描述可能有点奇怪……如果让我举个例子的话就是,你现在可以去打开Linux的Bash,然后随便运行几条指令,接下来通过上下键修改某几条已运行的指令的内容,并运行其中一个修改后的指令。接下来,你会发现内存中保存的历史指令保持着被修改后的样子,而history指令的输出中除最后一个被回车运行的指令外,其他修改则未被保存。

  4. ls 输出的是每个文件的绝对路径。

  5. 使用 &&|| 时,判断 and_or 中的某条 pipeline 是否运行的唯一判据是上一条被执行的指令的返回值是否为0。

  6. pipeline 的返回值为最后一条 command 的返回值,不论之前的 command 的执行结果; and_or 的返回值为最后一条被执行的 pipeline 的返回值。

  7. 反引号不存在嵌套,且子指令的输出无需被二次解析。

  8. 环境变量替换的结果无需被二次替换。

  9. 文件系统中的 remove 函数移除的目标文件若为一目录,则其一定会进行递归的文件删除。

  10. (哦对了,虽然这不属于一个完整的特性,但是你可以注意一下调整完子进程创建时机之后重定向带来的问题。)

如果我有什么遗漏,请一定在评论里告诉我!

实现过程

文件架构

整体来说,我所实现的文件架构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
mos
└─ user
├─ include
| └─ shell.h
├─ lib
| ├─ shellio.c
| ├─ lexer.c
| ├─ parser.c
| └─ executor.c
├─ mkdir.c
├─ touch.c
├─ rm.c
└─ sh.c

其中,shell.h 负责将 sh.c 中的繁杂功能剥离开,其功能实现交由 shellio.clexer.cparser.cexecutor.c 实现。

由此其实也可以看出来,整体上shell处理指令的顺序就是“IO管理-词法分析-语法分析-执行”,这与 user/lib/ 中新增的文件是一一对应的。

与MOS交互的关键路径和文件

此部分内容主要在于给出一套实现功能时的规范化的修改流程。

具体来说,shell和MOS各模块的交互可以分为以下几种:

  • 文件读写,此部分与文件系统相关,其路径为“shell-用户态文件操作接口-发送文件操作请求—文件系统接收请求-交由具体函数实现”。

    与之相关紧密的关键文件按路径顺序排列如下:user/lib/file.cuser/lib/fsipc.cfs/serv.cfs/fs.c

  • 读写进程块信息,此部分与进程管理相关,其路径为“shell-用户态系统调用接口-内核处理系统调用-进程管理函数”。

    与之相关紧密的关键文件按路径顺序排列如下:user/lib/syscall_lib.ckern/syscall_all.ckern/env.cinclude/env.h

后续几乎所有需要改动内核的部分,基本上都是按照这两条路径依次修改文件。

实现细节

如果把全部细节展开描述的话,效果可能甚至不如直接贴代码……所以这里主要是从大处着笔,写到实现思路彻底展明为止,着重描述一些关键细节。

IO管理与回显

实际上这里没有什么太大的难度,主要就是维护一个字符串和一个光标位置即可。可能很多同学会卡在控制台的回显特性带来的显示错误上(就是你按下去一个按键,无论这个按键是什么,控制台都会立刻把这个按键对应的字符或者控制效果显示出来)。解决这个问题的方法有两个,一个是去 user/lib/console.c 把回显有关的代码给改掉,然后在shell上自行实现一个回显(这是我的朋友之前采用的方法,但是我自己并不喜欢),另一个就是每次在回显之后,都让shell做一个把该操作撤销的控制台输出(如果当前的标准输入输出都是控制台的话)。

另一个比较卡人的地方就是如何识别按键输入和实际读取的内容的联系了。(比如说,你可能会花一段时间去找上下左右对应的是“哪个”字符,而最后你会查出来这里面每个按键实际上会往标准输入里塞三个字符)

这里我列个本次任务中会用到的按键输入对应表,方便大家对照。

按键 输入
up-arrow \x1b[A
down-arrow \x1b[B
left-arrow \x1b[D
right-arrow \x1b[C
backspace \b / \x7f
Ctrl-E \x05
Ctrl-A \x01
Ctrl-K \x0b
Ctrl-U \x15
Ctrl-W \x17

以及,不论你是手写一个回显还是输出一个操作相反的控制字符,你也应该需要其他的能控制控制台行为的输出,所以我接下来再给出一个我自己使用的表(当然,这里建议你询问大模型获取更多控制效果):

输出 控制效果
"\x1b[%dA" 光标向上移动 %d 列(如无该参数则为向上移动1列)
"\x1b[%dB" 光标向下移动 %d 列(如无该参数则为向下移动1列)
"\x1b[%dD" 光标向左移动 %d 列(如无该参数则为向左移动1列)
"\x1b[%dC" 光标向右移动 %d 列(如无该参数则为向右移动1列)
"\x1b[K" 清除光标后的一整行内容

另外,在测试的时候可能会遇到 Ctrl-ACtrl-W 的测试相关的按键冲突。对于 Ctrl-A 的冲突,其实际上是qemu的快捷键的问题(有点类似于tmux里 Ctrl-B 的效果),按两遍就行;对于 Ctrl-W 的冲突,大概率是浏览器里关闭标签页的快捷键和我们要实现的快捷键撞了,这个可以选择在测试的时候随便换一组快捷键。

历史指令管理

把历史指令的实现单拿出来的原因是,其实现难度约等于其他输入优化工作的总和(虽然都是一样的低),而且确实有一个不太好理解的细节。

我对历史指令的存储实际上是分了两部分的:内存里的暂存区和 /.mos_history。二者内容实际上有一些差别,具体来说:

  • 暂存区是一块 21*1024 的二维数组,其作用相当于一个存储最近20条被执行的指令和当前输入指令的缓存;/.mos_history 记录的是最近20条实际被运行的指令。
  • 在shell初始化时,若 /.mos_history 存在,则将其内容填充至暂存区中,否则暂存区为空。
  • 存在一个指针指向当前被修改的缓存,在每次指令输入开始前,其指向暂存区已保存的最后一条指令的下一块内存。
  • 在指令输入时,若用户输入了上下键,则上述指针根据用户的按键改变指针指向位置,并把光标移动至改变指向位置后的指令末尾,并使控制台显示切换后的指令。
  • 在指令输入完毕前,对暂存区的任何修改都将被保留在暂存区中,修改效果不会因为指针的移动而还原(举个例子就是,你在指令输入完毕前切换到上一条指令做了些修改,那之后再次切到这条指令的时候其修改效果仍然留在暂存区里),但 /.mos_history 内不应记录下此时的任何修改(在指令输入完毕后,其修改仍然不应被保存)。
  • 在指令输入完毕后,指令运行开始前,应该将当前指针指向的指令被存入 /.mos_history 中,当前指针指向的指令也被存入暂存区末尾。若 /.mos_history 此时存储的指令超过20条,则应移除最开头的一条。若暂存区此时存储的指令超过20条,也应移除最开头的一条。

重构后的架构展开

在进行重构之后,一条指令经过IO管理和预处理后,其会被解析成一抽象语法树。该语法树为一多叉树,且深度严格为4或5。具体来说,其结构如下:

  • line 节点(由 ; 分割的各段指令)
    • expr 节点(由 &&|| 分割的各段指令)
      • pipeline 节点(由 | 分割的各段指令)
        • command 节点(可被执行的最小单元指令)
          • word 节点 / redirect 节点

至于节点所需的内存申请的问题,我选择照搬 envs 的实现,预先开个大数组开出所有节点所占用的空间,同时记录每个节点的被使用状态,通过申请和释放节点的函数把这个大数组包装起来。

在解析完成后,尝试根据该抽象语法树运行指令,按照自顶向下的顺序依次尝试执行。

子进程创建时机调整

在我们现在设计的shell中,考虑到满足某些条件的内建指令必须在shell进程本身被处理,故我们需要对原shell中无脑开子进程处理的行为做一些修正。

具体来说(其实这里就是复述一遍特性总结):

  • 对于 pipeline 节点内只有一个 command 的情况(即不存在管道传递时),该指令应在shell进程本身中被处理。
  • pipeline 中包含多于一个 command 时,被管道连接的各个 command 应在不同的子进程中运行。
  • 对于所有内建指令,其运行时不应额外开出子进程;对于所有外部指令,其均通过spawn-wait进行处理。

具体的实现方法为:在运行 pipeline 节点的函数内,若 pipeline 内只有一个子节点,则直接调用运行 command 的函数;否则,创建一个子进程,之后:

  • 子进程遍历该节点除最后一个子节点外的所有子节点,每次循环中创建一个管道和一个孙子进程,孙子进程负责接下来的遍历过程,而子进程则执行当前节点对应的指令,之后关闭自己的所有文件标识符,等待孙子进程结束,之后结束自己。

  • 容易发现,上述过程中最后会剩下一个孙子进程未执行任何指令,所以让其执行最后一个子节点对应的指令,之后结束自己。

  • 父进程等待该子进程结束,函数正常返回。

至此,子进程创建时机修正结束。

重定向修正

由于上述对子进程创建时机的调整,之前shell中对重定向的处理方法也跟着出现了问题。具体来说,对于一条有重定向信息的内建指令运行,其在运行后无法自行恢复原有的标准输入输出方向。

这也就意味着,你需要在 command 节点运行前保存一份当前标准输入输出的文件标识符的备份。

我的做法是:在开始处理重定向前,用 fd_alloc() 申请一个空的输入文件标识符,将当前标准输入标识符dup到这个空的标识符中,再对当前标准输出如法炮制。在当前 command 节点运行结束后,将上述申请的标识符再dup回标准输入输出,最后用 close() 释放上述申请的标识符。

需要注意的是,运行 pipeline 的时候无需为管道操作执行这样的备份,因为 pipeline 内如果存在多于一个的子节点,则各子节点都将在子进程中运行,子进程运行指令结束后即会被销毁,无需恢复输入输出。

返回值机制实现

简单来说,由于原MOS中,在父进程创建完子进程后,父子进程实际上不存在什么直接信息传递的渠道,故子进程的 main 函数返回值无法传递给父进程。

解决这个问题的方法有两种,一种是让子进程在被销毁前尝试和父进程进行通信,把返回值传递出去,另一种就是让父进程负责子进程的销毁,在销毁途中获取子进程的返回值。我采用的是后者。

具体来说,我们从 user/lib/libos.c 里的 exit() 函数和 user/lib/wait.c 里的 wait(u_int) 函数开始考虑,看看如何通过 exit() 发出子进程的返回值信息,再从 wait() 获取这个信息。

exit() 函数实际上就干了两件事:

  1. 关闭自己的所有文件标识符;
  2. 调用系统调用销毁自己。

但是如果直接让子进程销毁了自己,那么父进程不仅连通信的机会都没有,还没有寻找子进程PCB的机会了(如果在父进程寻找到子进程之前有新的进程使用了原子进程的PCB,那么就完蛋了)。所以,父进程不能等到子进程销毁后才去寻找其返回值。

反过来说,就是子进程不能让自己被销毁,只能让自己被永久暂停,即把自己标记为运行结束的状态

所以,我们在进程状态里新增一个 ENV_END 状态,再新增一个用于进程结束的系统调用(我们暂且记为 syscall_env_exit)。其系统调用的主要功能有两个:

  1. 将自己的状态设置为 ENV_END,并使自己不能被调度(即从调度链表和阻塞链表里被移除);
  2. 将自己的返回值记录到PCB里。

至此,我们就完成了子进程的返回值发送部分。

接下来考虑 wait() 函数,它实际上就干了一件事:

  • 如果子进程还存在并且未运行结束,则放弃该进程剩余的时间片,否则该函数结束。

所以我们要做的修改就是:

  1. 如果子进程还存在并且未运行结束,则放弃该进程剩余的时间片;
  2. 否则,如果该子进程处于结束态,则读取子进程返回值,销毁子进程,之后函数结束。
  3. 否则,函数结束。

(写完才发现好像只是变相的实现了一遍MOS里的消息传递……不管了,写都写了)

完全结束了吗?不是的。

我们刚刚做的操作里是修改了两个已经存在的函数的:给 wait() 添加了返回值,给 exit() 添加了参数。前者其实没啥影响,但是后者做完之后,你还要修改所有调用了 exit() 的地方,给其填一个合适的参数,否则对该函数的调用就是错误的。所幸,我们现在的MOS里并没有特别多调用 exit() 的地方,诸葛修改即可。(不用修改 test/ 文件夹里的该函数,反正里面的测试点你也用不上了)

指令预处理

这个其实没啥难度。

对于 # 标记的注释,你可以把 # 在词法分析上识别成一行的结束符,从而无需进行任何额外处理。

对于反引号的处理,我们选择开个缓冲区,开个管道,再开个子进程,把子进程的输出用管道连接到缓冲区里,再把缓冲区的内容塞到指令里面。

(之所以写这一段,是因为有相当多的人问过这一段)

相对路径相关

首先添加当前工作路径到进程里。

我们注意到,当前工作路径是有继承性的,而且不同进程的当前工作路径在进程创建后是相互独立的,所以修改还有点多。

首先我们要往PCB里丢垃圾,在PCB里开个长数组存放当前工作路径。

然后,对于没有父进程的进程,其当前工作路径在被创建时应该被设定为根目录;对于有父进程的进程,其当前工作路径在被创建时应该被设定为父进程所在目录。(这一块需要通过修改 env_createenv_exofork 来实现)

最后,写两个系统调用,分别负责把当前工作目录copy到指定地址,以及把指定地址的字符串copy到当前工作目录。

(由于内核态没有文件系统的概念,所以我们不在此处做路径合法性的检查)

接下来就是支持相对路径了。

首先,可以预见的是接下来会有非常多的相对路径转绝对路径的操作,所以我建议你先实现一个相对路径转绝对路径的函数,其声明最好是塞到 user/lib.h 里,这样方便随处调用。

然后,就是修改一下文件系统的接口函数,在接口函数里实现相对路径转绝对路径。具体来说,你需要修改 openstatremove 三个函数,使其在识别到传入参数为绝对路径时,将其转为绝对路径,再将转换后的绝对路径扔给文件系统进程即可。

最后就是添加和修改使用了路径的指令执行程序/函数。具体有这样几个注意的点:

  • 在样例中,ls 的输出一定是绝对路径。
  • mkdir 需要一个可以创建文件夹的函数,且需要支持递归创建文件夹的函数。
  • 实际上,原有的文件系统中似乎不存在递归删除文件夹内所有文件的函数。

对于后两个问题,你需要在文件系统中加入对应的函数,或者通过修改已有函数实现其效果,或者在用户程序中做一些处理。

变量相关

注意到环境变量和当前工作目录其实非常像,所以我们可以采用类似的维护方法:在进程块里面开一块存储若干环境变量的空间,写几个系统调用负责读写环境变量(以及读出全部环境变量),最后在 declare 指令中调用这些系统调用即可。

当然,为了使PCB变得不那么大,另一种替代方案是开一片全局数组,并给环境变量添加一个“所在进程id”的信息,(或者添加用于组成链表的 Entry,与之匹配的就是在PCB中存储一个链表结构体),在进程创建和销毁时完成环境变量的继承和销毁操作,其余操作和上述方法一致。

对于局部变量,其实际上可以直接在shell进程内被维护。当然,你也可以选择将其和环境变量混在一起。

最后就是,如何识别一般指令中的变量并进行替换。我的做法是在执行 command 节点的时候进行处理,即每在执行中发现一个 word 节点,就尝试找出其中所有的 $,并将 $ 后直到非字母,数字,下划线的部分视作一个 key,优先在局部变量,其次在环境变量中试图寻找该 key 并替换。最后,把替换后的结果存储回原 word 节点,再进行执行操作。

一些可能出现的Q&A

这个解决方案有哪些潜在的bug?

我没找过。主要是测试点太好过了。

但是我倒是确实知道几个问题:

  • 在我最后一版实现里,实际上并未对僵尸进程做任何处理。这在子进程过多且不写wait的情况下很容易会爆,虽然课程组没有也很难测试这一点。
  • 我对大多数指令参数的处理都是在执行command 节点的时候被处理的,包括后面环境变量的识别和替换,但是这样做其实多少有点问题,毕竟在Linux的Bash上,当环境变量的值包含空格时,其实际上会被在传参时识别成两个参数,而非这种实现里的一个参数。一个比较好的解决方案是,把这个处理过程扔到预处理那里。(当然,由于课程组并未要求实现单引号,所以实际上这事课程组测不了)
  • 在我最后一版实现里,我误以为原有的 remove 函数可以递归删除,从而未对递归删除做特殊处理。

如果你的任务指导书里包含“实现后台任务”?

2025年的shell挑战性任务里不包含这个任务。

尽管确实没有,但是考虑到接下来的某一届也许有可能出现这样的任务,遂也记录一下基于这个架构做扩展的方法。

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

左侧命令不可为空

实际上也没啥困难的,词法分析加一种token,语法分析加一层,然后模仿处理管道的部分即可,其区别主要在于:

  • 管道需要进行输入输出的传递,而后台任务不需要;
  • 管道的设计保证了其需要等待所有子进程执行完毕,而后台任务只需要等待最后一个子任务。

同时需要注意的是,这样的要求实际上对进程管理的要求更完备一点,需要更加严格的防范僵尸进程的产生。

如果你希望让大模型进行辅助?

写到一半突然意识到,这都2025年了,肯定会有人想用大模型做一些辅助,省去大量的人工写代码的过程……但是比较遗憾的是,我目前见到的大模型辅助实现的错误率很高,而且集中在和MOS做对接上。

尽管我本人并未使用大模型辅助实现,我仍然希望能够在这方面给出一些指导,一方面是防止大模型的修改错误导致回正变得更加困难,另一方面也是希望大家能够“省力”,把注意力放在架构设计和进程管理上。

其实也没什么特别深刻的指导,我的建议就是把只属于shell和外部指令的实现丢给LLM进行实现,这一部分大概率LLM不会做错。子进程创建的时机除外,最好人工实现这一段,虽然这也是纯用户层软件的过程,但是其准确度要求较高,实现错误的话很可能内建指令运行结果不对,并且你很有可能不知道怎么在大模型生成的代码基础上二次修改。

同时,一个更为重要的经验是,用大模型查询资料比直接做实现往往效果更好,比如说关于按键和输入的对应关系实际上就是我通过直接询问大模型得到的。

文末碎碎念

啊,写完了。写了九千多个字,字数都快赶上今年的lab5-extra了。

最初写这篇文章的原因是找我问问题的人太多了,想着写个一劳永逸的东西,结果在完成任务的过程中稀奇古怪的问题越来越多,似乎这篇文章也没有覆盖到多少。

总之,如果你觉得这个文章有啥讲的不清楚的问题,仍然欢迎直接问我(或者发发评论,如果评论区还在的话)。

另外,回头把OO的几次博客作业也会发上来,虽然大概率没人看(毕竟我觉得写的极其没有营养)。

祝各位暑假愉快!

参考资料

本文的任务回顾部分主要参考2025 OS实验指导书和Linux Bash的实现。

本文的Q&A部分中,对实现后台任务的要求简述部分摘自 CookedBear的这篇文章,在此向前人表示深深的感谢。