今夕是何年。
零 前言
转眼就六月底放假了,大家每天聚在一起玩了三两天,假期生活就又要步入正轨了。那最后的最后残余的OS便是挑战性任务了,我写的是Shell的迭代开发,一路也是波折坎坷,下面请看笔者的Shell实验内容吧!
壹 引言
1.1 项目背景
MOS 操作系统实验基于Lab6的 Shell 环境,但其功能较为简陋,例如不支持相对路径、缺乏环境变量、命令行输入体验不佳等,这在实际使用中造成了诸多不便。我们的挑战性任务旨在克服这些局限,通过对 Shell 的深度改造,实现一个功能丰富、交互友好且行为符合 POSIX 标准的增强型 Shell。
1.2 任务目标
根据指导书要求,本次挑战性任务需完成以下几大模块的开发:
- 路径支持:为进程引入工作目录(CWD)概念,实现
cd、pwd内建指令,并使所有文件操作指令支持相对路径(包括 . 和 ..)。 - 环境变量:实现
declare、unset内建指令,支持局部、导出、只读三种变量属性,并能在子 Shell 中正确继承。 - 输入优化:实现自由光标移动、行编辑快捷键(如 Ctrl-A/E/K/U/W)、历史指令回溯、命令自动补全 .b 后缀、注释功能。
- 高级执行:实现反引号命令替换、分号多指令执行、&& 与 || 条件执行、>> 追加重定向。
- 指令集扩充:实现
touch,mkdir,rm等常用外部指令,并支持相应选项(如 -p, -r, -f)。
1.3 技术选型
鉴于任务的复杂性与指导书的提示,面对涉及
&&、||、|
和反引号等具有嵌套和优先级关系的语法,我们决定放弃原有的边解析边执行的简单模型。我们采用了更为健壮和可扩展的
“词法分析 -> 语法分析(构建AST) -> AST遍历执行”
的经典编译器前端设计模式。该模型将复杂的命令语句转化为结构化的数据(AST),极大地简化了后续的执行逻辑,并能自然地处理复杂的指令嵌套和执行顺序。
贰 总体设计
2.1 核心流程
我们设计的增强型 Shell 的主干执行流程遵循一个清晰的循环模式:
- 读取输入
readline:主进程循环调用readline函数,该函数负责处理所有底层用户交互,包括光标移动、快捷键和历史命令,最终返回一条完整的、用户确认的命令字符串。 - 创建子进程
fork:为保证主 Shell 的稳定,所有命令的解析和执行都在一个 fork 出的子 Shell 进程中完成。主 Shell 仅负责等待子进程结束,并管理历史记录。 - 预处理
preprocess_backquotes:在子进程中,首先对命令字符串进行预处理,主要处理反引号命令替换。 - 解析构建AST
parse_line:将预处理后的字符串交给递归下降解析器parse_line,该解析器经过词法分析和语法分析,最终构建出一棵完整的抽象语法树(AST)。 - 遍历执行AST
run_ast:run_ast函数递归地遍历 AST 的每个节点,根据节点类型(如 EXEC, PIPE, AND, OR 等)执行相应的操作,包括设置重定向、创建管道、管理进程和执行最终的命令。
2.2 AST为核心的解析与执行模型
我们参考指导书建议的 EBNF 文法,设计了AST的节点结构,并以此为基础构建了解析器和执行器。
1. AST 节点定义
我们定义了三种基本的节点结构,所有节点共享一个通用的 type 头部:
struct execcmd: 表示一个简单命令,如ls -l。包含 argv 数组。struct redircmd: 表示一个重定向命令,内含一个被重定向的子命令节点cmd以及重定向信息(文件名、模式、fd)。struct bincmd: 表示一个二元命令,如管道、&&、|| 等。包含左右两个子命令节点left,right。
为了避免在内核中没有实现的
malloc,我们通过静态数组(node_pool,
arg_pool)构建了一个无动态内存分配的节点和参数池,确保了内存管理的简洁与安全。
2. 递归下降解析
我们实现了一套自上而下的递归下降解析器。例如,parse_list
函数负责解析 ;,它会调用 parse_and_or
来解析其操作数;parse_and_or 则调用
parse_pipeline,层层递进,完美地实现了操作符的优先级(|
> &&/|| > ;)。
3. AST 遍历执行
run_ast
函数是执行引擎的核心,部分代码如下。它通过一个大的 switch
语句,根据当前节点的 type 来决定行为:
- EXEC 节点:检查是否为内建指令。若是,则直接调用相应处理函数;若不是,则通过 spawn 创建新进程执行外部命令。
- PIPE 节点:创建管道,fork 两个子进程分别执行左右子树,并通过 dup 将它们的标准输入/输出连接到管道两端。
- REDIR 节点:在执行子命令前,先打开重定向文件并使用 dup 将标准输入/输出重定向。
- AND/OR 节点:先 fork 执行左子树,wait 获取其退出状态码,然后根据状态码和节点类型(&& 或 ||)决定是否继续执行右子树。
1 | // ... |
叁 关键功能实现
3.1 支持相对路径与工作目录
- 工作目录管理:我们在内核的进程控制块(struct Env)中增加了一个字段来存储每个进程的当前工作目录(CWD)。
- 内建指令
cd:cd 是一个特殊的内建指令。它在 fork 出的子 Shell 中被识别,但其核心逻辑是通过一个我们新增的系统调用syscall_set_cwd(parent_envid, path)来修改父进程(主 Shell)的工作目录,从而实现 cd 命令的持久化效果。 - 内建指令
pwd:pwd 通过syscall_get_cwd(0, ...)获取并打印当前进程的 CWD。 - 路径解析:我们实现了一个
normalize_path辅助函数。该函数接收一个任意路径(绝对或相对)和当前工作目录,通过状态机解析路径中的 . 和 ..,最终生成一个规范化的绝对路径,供所有文件操作(open, spawn 等)使用。
3.2 环境变量管理
- 内存模型与继承:我们环境变量的实现利用了 MOS
的内存管理机制。在顶级 Shell 启动时,
init_environs函数会分配一页内存,并将其以 PTE_LIBRARY 标志映射到固定的虚拟地址 ENV_VAR_VA。PTE_LIBRARY 使得子进程在 fork 时与父进程共享此物理内存页,并将其权限降为只读,实现了高效的继承。当子进程试图修改环境变量(写入共享页)时,会触发写时复制CoW,从而获得自己的私有副本,保证了对父 Shell 的隔离。 - declare 与 unset:
declare指令通过解析 -x(导出)和 -r(只读)选项来设置 struct Var 中的标志位。unset 则通过清除变量的 VAR_FLAG_VALID 标志来实现逻辑删除。
3.3 输入指令优化
- 行编辑与历史指令:
readline函数是该模块的核心。它工作在"raw mode",逐字符读取输入。通过一个大的 switch 语句,它响应普通字符(插入)、退格(删除)、方向键(移动光标)和所有 Ctrl 快捷键,并在每次按键后调用 draw_line 重绘整行,实现了流畅的行编辑体验。历史指令通过一个全局数组 history_buf 和一个文件 /.mosh_history 进行管理,readline通过修改历史指针history_inst来实现历史命令的切换。 - 其他优化:
- .b 后缀:在 run_ast 的 EXEC 节点处理中,如果待执行的命令不含 / 且不以 .b 结尾,则自动为其拼接上 / 和 .b 后缀。
- 注释:在 main 函数中,
readline返回后,在 fork 之前,通过 strchr 查找 # 并将其替换为 \0,简单高效地实现了注释功能。
3.4 高级指令执行
- **反引号
`
**:preprocess_backquotes函数通过扫描字符串实现。当遇到一对反引号时,它会截取内部的子命令,**递归地调用parse_line和run_and_capture**。run_and_capture函数通过pipe创建管道,fork` 子进程执行子命令的 AST,并将子进程的标准输出重定向到管道写端;父进程则从管道读端捕获所有输出,并将其替换回原命令字符串中的反引号部分。 - 条件执行 && 与
||:该功能的关键在于进程退出状态码的正确传递。
run_ast在处理 AND/OR 节点时,会 fork-exec 左侧命令,然后调用wait_with_status等待并获取其退出状态码 status。最后,根据 (status == 0)(对于&&)或 (status != 0)(对于||)的布尔结果,决定是否继续执行右侧子树。 - 追加重定向 >>:我们为 REDIR 节点增加了一个
REDIR_APPEND 类型。在 run_ast 中,如果节点是此类型,在 open
文件后,会额外调用
fstat获取文件大小,然后 seek 到文件末尾,再执行后续的 dup 和子命令,从而实现了追加写入。
肆 炒鸡离谱的bug
先说根本原因,笔者没有注意到history指令要输出自身history,导致我在重构完AST语法树后第三个关于历史指令的测试点无法通过。
那你可能会好奇了,难道说我在重构前可以通过第三个测试点吗?你别说,你还真别说,重构前还真过了!🧐于是我debug的思路就是反复对比重构前后的区别是什么?
后来我把bug的范围逐步缩小至run_ast中的EXEC分支,无意间发现我修改了之前的错误写法:
1 | if (ecmd->argv[0] == NULL) { |
这意味着什么呢?我们的内置指令history也会进入外置指令的处理流程中,而由于没有history.c文件,spawn一定会失败!
当我们的指令序列如下:
1 | mkdir dir1 |
我们的history指令输出如下:
1 | mkdir dir1 |
!!!⚠原来评测机使用的是字符串匹配!history被检测到误判成正确的了。
以至于后来我将这个内置指令进入外置指令处理流程的这个bug修复之后过不了第三个测试点的评测!真是一次令人刻骨铭心的记忆!也是成功卡了我两天😭
伍 总结
通过本次挑战性任务,我们成功地将一个功能简陋的 MOS Shell
改造为了一个健壮、功能丰富的现代化命令行工具。我们通过引入
AST
模型,构建了清晰、可扩展的程序架构。在实现相对路径、环境变量、高级输入和复杂流控制等功能的过程中,我们不仅加深了对
Shell
工作原理的理解,更在实践中掌握了进程通信、内存共享、系统调用等核心操作系统概念。特别是history指令报错问题的过程,让我们体会到了git的重要性并且得知了一种新的bug触发方式。最终,我们的
Shell 顺利通过了所有自动化评测,圆满完成了本次挑战。
再见MOS!再见OS!👋
If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. If the images used in the blog infringe your copyright, please contact the author to delete them. Thank you !