Zjut-OS-02 Shell 记录

这个对应学校课设是实验二,实现一个shell这个部分

需要注意的是,pintos这个项目是不包括这个实验的
这个实验是CS162课程的一个作业,不过我们学校是作为课设的一部分的
项目代码在这里

学校用的代码基疑似用的是23年的,这里参考的文档是24年的文档
(之前的文档只有Berkeley本校的才能看了)

<0x01> 任务说明

这个实验主要是去构建一个shell,这个shell可以让用户运行与管理程序
通过构建自己的shell,你将会对这些接口更加熟悉并且更了解其他的shell
(下面是我自己翻译的,用词会比较随便)

添加cd和pwd的支持

每个shell支持一系列的内置指令,实际上就是shell内部的函数
比方说,exit这个指令就是shell自己实现的
目前只有两个内置指令,help和exit

添加新的内置指令pwd,可以打印出当前的工作目录
再添加新的内置指令cd,有一个目标目录的参数,让工作目录切换到目标目录

执行程序

目前如果你尝试输入什么不是内置指令的命令运行
shell会返回说它不知道怎么去执行外部程序

你需要修改你的shell让它可以执行外部程序
执行的第一个词是程序名,后面跟着的是程序执行的参数

对于这一步,你可以假设第一个词是程序的完整路径
比如假设我们要运行wc,实际应该运行的是/usr/bin/wc
在下一步才需要实现像wc这样的简单的文件名
如果只实现这步,在自动测试平台中也是可以过一些测试的

你需要使用定义在tokenizer.c里面的函数来把输入的命令按空格分开
不需要支持任何转换的特性
一旦实现这步,你可以按下面的格式执行程序了

/usr/bin/wc shell.c

当你的shell需要执行程序时,应该fork一个子进程
父进程应当等待子进程直到完成,然后继续监听其他的命令

在PATH中寻找程序

如果每次执行程序都要输入完整路径,那太痛苦了
我们可以定义一系列的环境变量,这是一个string为键与值的哈希表
其中一个环境变量就是PATH
里面有一系列按:分割的路径
当shell执行一个程序,比如说wc,shell会在PATH的每个路径中寻找有没有wc

修改你的shell,让它可以使用PATH环境变量
当然,输入完整路径执行的功能也应当要支持
不要使用execvp()函数,如果使用的话,测试平台将不评分
(这个函数本身就差不多实现了这步的功能)
使用execv()函数实现这个功能

输入输出重定向

当运行一个程序时,从什么文件中输入或者将输出导入什么文件都是非常有用的
语法[进程] > [文件]告诉shell将某进程的标准输出重定向到一个文件中
类似的[进程] < [文件]告诉shell用某文件作为某进程的标准输入

修改你的shell,让它支持这样的功能
你不需要为内置指令提供这个功能的支持
也不需要支持标准错误输出的重定向
你可以假设输入时<>两边都是有空格的
需要注意的时,> [文件]不是要传给程序的参数

管道处理

这一步的任务类似输入输出重定向
但需要做的是让许多程序的输入输出可以串起来
实现这样的语法

[process A] | [process B] | [process C]

A进程的输出会成为进程B的输入,进程B的输出会成为进程C的输入

为了实现这部分的功能,你需要使用pipe这个系统调用
从历史来看,这部分的内容对于大部分的学生来说是最难的部分
建议搞明白pipe是什么,怎么用,再动手实现
(文档里面介绍pipe的部分摸了,找其他文档也差不多)

信号处理与终端控制

绝大部分的shell可以让你用快捷键来终止或暂停进程
比如说Ctrl-CCtrl-Z
这会给shell的子进程发送一个信号
前者给的是SIGINT信号,也即终止当前的程序
后者给的是SIGTSTP信号,表示让当前程序切换到后台
如果直接使用这些快捷键,这种信号也会发送到shell本身
导致shell本身也被终止了
这不是我们所期待的,我们希望是终止后只终止子进程

任务是确保每个程序都在自己的进程组中
当你启动一个进程,这个进程组应当放在前台
停止信号应该只影响前台程序,而不是后台的shell

在实现前需要理解下面的操作系统概念

进程组

每个进程有一个唯一的ID,也就是PID
每个进程也有一个进程组号,也就是PGID
默认情况下,PGID跟父进程是一致的
进程可以通过getpgidsetgpidgetpgrpsetpgrp控制PGID

需要注意的是,当你启动新的程序时,这个程序可能需求多个进程
这些进程应当继承同样的PGID
将每个shell的子进程作为单独的进程组是一个不错的想法
每个shell子进程的PGID应当与PID相同

前台终端

每个终端都关联着一个前台进程组ID
当你按下Ctrl-C的时候,终端会给前台进程组ID的进程发终止信号
你可以改变哪个进程组在前台,通过tcsetpgrp(int fd, pid_t pgrp)
fd应该是0,以代表标准输入

信号一览

信号是各进程间的同步信息
下面展示一些常见的信号

  • SIGINT
    • 默认情况下,应该终止这个程序
  • SIGQUIT
    • 这个默认也是终止程序,但程序更重视这个终止信号
    • 这个信号也用于尝试导出内存快照时
  • SIGKILL
    • 强制终止程序,并且程序不能重写这个信号的行为
  • SIGTERM
    • 类似SIGTERM
  • SIGTSTP
    • 默认暂停程序
  • SIGCONT
    • 恢复程序
  • SIGTTIN
    • 让后台程序可以获取键盘输入
    • 实际上是让程序暂时恢复前台状态,获取完就又回到后台暂停
  • SIGTTOU
    • 让后台程序可以在终端输出
    • 默认情况下行为跟SIGTTIN一致

在你的shell中,你可以使用kill -XXX PID给进程发信号
这里的-XXX是指信号的代号,比如kill -TERM PID会发送SIGTERM信号

在C中,你可以使用signal指定如何处理信号
shell应该忽略大部分信号,而子进程应该响应这些信号
需要注意的是,fork出的子进程会继承父进程的信号处理方式

可选:后台处理

目前,shell在执行下一句之前会等待前一个程序执行完毕
很多shell允许运行指令到后台,只要在命令行的最后加上&
当后台程序启动后,shell允许启动其他进程而不用等待后台进程完成

修改shell,让它可以完成上面的事情
你只需要支持&语法即可,而不是作为内置指令
实现这个特性的同时,还要加上新的内置指令wait
这个会等待所有后台任务处理完毕,然后才会返回
可以假设&会有空格包裹,并且只出现在指令最后

可选:前后台切换

绝大部分的shell允许运行的进程在前后台切换
这需要加上两个新的内置指令

fg [PID]将进程移到前台,这个进程如果被暂停,那么应该恢复
如果PID没有指定,那么就将最近启动的进程移到前台

bg [PID]恢复一个后台的暂停状态的进程
如果PID没有指定,那么应该恢复最近启动的进程

<0x02> 代码实现

这部分主要讲解思路,代码不会展示得很详细

前置:将代码分离

我的习惯是把一些相关的代码分离开来,这样会好找一些

// shell_cmd.h
#pragma once
#include "tokenizer.h"

typedef int cmd_fun_t(struct tokens *tokens);

typedef struct fun_desc
{
    cmd_fun_t *fun;
    char *cmd;
    char *doc;
} fun_desc_t;

extern fun_desc_t cmd_table[];

int cmd_get_size();

int cmd_exit(struct tokens *tokens);
int cmd_help(struct tokens *tokens);
// shell_cmp.c
#include "shell_cmd.h"
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define unused __attribute__((unused))
#define ARRAY_SIZE(ARR) (sizeof(ARR) / sizeof((ARR)[0]))

fun_desc_t cmd_table[] = {
    {cmd_help, "?", "show this help menu"},
    {cmd_pwd, "pwd", "show current working path"},
    {cmd_cd, "cd", "change current working path"},
    {cmd_exit, "exit", "exit the command shell"},
};

int cmd_get_size()
{
    return ARRAY_SIZE(cmd_table);
}

int cmd_help(struct tokens *tokens)
{
    for (unsigned int i = 0; i < ARRAY_SIZE(cmd_table); i++)
    {
        printf("%s - %s\n", cmd_table[i].cmd, cmd_table[i].doc);
    }
    return 1;
}

int cmd_exit(struct tokens *tokens)
{
    exit(0);
}

然后在shell.c中做一点小修改,然后在makefile中加入shell_cmp.c即可
后面其他的东西我也会这样单独分离开来,具体操作就不详细写出了

有一点需要注意,最后提交的代码还是要合起来的,文件结构要跟原来的一样
我也不知道为啥,反正如果不合并直接交的话会有奇怪问题

添加cd和pwd的支持

//shell_cmd.h
// ...
int cmd_pwd(struct tokens *tokens);
int cmd_cd(struct tokens *tokens);
// ...
//shell_cmd.c
// ...
// 记得添加到内置命令表中
fun_desc_t cmd_table[] = {
    {cmd_help, "?", "show this help menu"},
    {cmd_pwd, "pwd", "show current working path"},
    {cmd_cd, "cd", "change current working path"},
    {cmd_exit, "exit", "exit the command shell"},
};
// ...
int cmd_pwd(struct tokens *tokens)
{
    char cwd[PATH_MAX];
    // 通过系统调用getcwd来获取当前目录
    if (getcwd(cwd, sizeof(cwd)) != NULL)
    {
        printf("%s\n", cwd);
    }
    else
    {
        fprintf(stderr, "show work path failed");
        return 1;
    }
    return 0;
}

int cmd_cd(struct tokens *tokens)
{
	// 通过系统调用chdir来改变工作目录
    if (chdir(tokens_get_token(tokens, 1)) == 0)
    {
        return 0;
    }
    else
    {
        fprintf(stderr, "change work path failed");
        return -1;
    }
}
// ...

这样就可以了

执行程序

既然PATH部分要通过execv来实现
那这步就直接用execv实现得了

execv是一个系统调用,写全是这样得execv(const char* path, char* const* argv)
const char*是常量字符串
char* const*是常量数组,数组内为char*,也即字符串
(C里面没有string这个东西,只有char*模拟的字符串)
假设我们要执行一个程序,路径为/usr/bin/example,要传入的参数有111 222 333
如果在命令行里面,这个很简单,这样运行就可以

/usr/bin/example 111 222 333

如果转换为execv系统调用,就是这样的

const char* path = "/usr/bin/example"
char argv[5][4096];
argv[0] = "/usr/bin/example";
argv[1] = "111";
argv[2] = "222";
argv[3] = "333";
argv[4] = NULL;
execv(path, argv);

传入的argv数组最开始是运行的路径,最后要有NULL结尾

知道这些,那就可以开始实现这部分功能了

// shell.c
// ...
while (fgets(line, 4096, stdin))
{
    struct tokens *tokens = tokenize(line);
    int fundex = cmd_lookup(tokens_get_token(tokens, 0));
    if (fundex >= 0)
    {
        cmd_table[fundex].fun(tokens);
    }
    else
    {
	    // 假设最多64个参数,肯定是够大了
        char *argv[64];
	    argv[0] = tokens_get_token(tokens, 0);
	    // 因为第一个参数肯定是自己的路径,已经算一个了
	    int argv_count = 1;
	    // 循环遍历tokens
	    for (int i = 1; i < tokens_get_length(tokens); i++)
	    {
			// 把获取到的token通过strdup复制一份,避免重复销毁
	        argv[i] = strdup(tokens_get_token(tokens, i));
	        argv_count++;
	    }
	    // 最后一个填NULL
	    argv[argv_count] = NULL;
	    // 传参运行
	    execv(argv[0], argv);
    }
    if (shell_is_interactive)
    {
        fprintf(stdout, "%d: ", ++line_num);
    }
    tokens_destroy(tokens);
}
// ...

(这里仅讲解原理,在我的实现里,我已经将整个程序运行封装成一个函数了)

在PATH中寻找程序

有了前面的基础,我们只需要添加PATH中寻找的逻辑就可以了
这里就直接展示我的实现了

// shell_program.c
// ...
// 传入的可以是相对路径也可以是完整路径
char *program_get_full_path(const char *path)
{
    char cwd[PATH_MAX];
    getcwd(cwd, sizeof(cwd));
    strcat(cwd, "/");
    // 判断完整路径
    if (access(path, F_OK) == 0)
    {
        return strdup(path);
    }
    // 判断path
    else
    {
        char *env_path = getenv("PATH");
        char full_path[PATH_MAX];
        if (env_path == NULL)
        {
            fprintf(stderr, "there is no PATH\n");
            return NULL;
        }
        char *path_copy = strdup(env_path);
        char *token = strtok(path_copy, ":");
        while (token != NULL)
        {
            full_path[0] = '\0';
            strcat(full_path, token);
            strcat(full_path, "/");
            if (access(strcat(full_path, path), F_OK) == 0)
            {
                return strdup(full_path);
            }
            token = strtok(NULL, ":");
        }
    }
    return NULL;
}
// ...

这里我通过access系统调用判断文件是否可访问来判断有没有这个文件
如果access能找到,那execv也是认这个路径的

通过getenv系统调用来获取PATH的内容
通过strtok将PATH按:分隔
然后就是构建完整路径,通过access判断文件是否存在

有这个函数后,在前面输入的路径加上这句就可以了

// ...
// 尝试获取文件的完整路径
argv[0] = program_get_full_path(tokens_get_token(tokens, 0));
// ...

输入输出重定向

这里要提到Unix的一个概念:万物皆文件
无论是键盘输入还是打印机输出,对于系统内核来说这些都是对文件的读写

对应的,标准输入和标准输出都有对应的“文件”表示
每个进程会有一个文件描述表,里面列出了这个进程所有控制着的文件
一般来说,标准输入是第0号,标准输出是第1号

所以,输入输出重定向,本质上就是将标准输入输出的“文件”定向到真正的文件上
通过dup2这个系统调用,我们可以将标准输入输出重定向到需要的文件上
dup2需要两个fd,一个是文件的fd,一个是目的地的fd
文件的fd可以用open系统调用获得,目的地fd其实就是标准输入输出
简单写的话可以直接写0或1
当然为了增强可读性,可以用unistd.h中的STDIN_FILENOSTDOUT_FILENO表示标准输入输出

具体实现可以看这个例子

// 通过解析token知道是否要重定向和重定向的文件路径
// 这里就不写这步的过程了
bool is_input_redirect;
bool is_output_redirect;
char input_file_path[PATH_MAX];
char output_file_path[PATH_MAX];
if (is_input_redirect)
{
	// 获取文件的fd
    int fd = open(input_file_path, O_RDONLY);
    if (fd < 0)
    {
        fprintf(stderr, "open input failed\n");
        return EXIT_FAILURE;
    }
    // 重定向,本质是复制一份fd作为标准输入
    dup2(fd, STDIN_FILENO);
    // 因为上面复制了一份,所以把这里创建的fd关闭即可
    close(fd);
}
// 下面的原理一样
if (is_output_redirect)
{
    int fd = open(output_file_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0)
    {
        fprintf(stderr, "open output failed\n");
        return EXIT_FAILURE;
    }
    dup2(fd, STDOUT_FILENO);
    close(fd);
}

管道处理

这个确实比较头疼
其实可以想象一堆进程通过单向的管道串起来
这个任务的关键其实是处理一个进程的输入是上一个进程的输出
也就是这个进程在管道读的这头,上个进程在管道写的这头

上个进程 >-> 写 >---> 读 >-> 这个进程 >-> 写 >---> 读 >-> 下个进程

为了进程管理方便,这里要使用fork来创建子进程
fork这个系统调用很有意思,这个函数有两个返回值
乍一听,挺反常的,实际上这两个返回值一个给父进程,一个给子进程
给父进程的是子进程的pid,给子进程的是0值
如果fork失败,返回的是负数

举个例子

pid_t pid = fork();
if(pid == 0)
{
	printf("child\n");
}
else
{
	printf("parent\n");
}

在程序执行到fork时,会立刻创建一个子进程
通过下面的if可以写出子进程和父进程不同的处理逻辑

下面先讲我的实现思路

// 就是有几个 | 符号
int pipe_count;
// 管道们
int pipes[pipe_count][2];
// 初始化管道
for(int i=0; i<pipe_count; i++)
{
	pipe(pipes[i]);
}
pid_t child_pid;
// 表示执行到第几个pipe
int pipe_index;
// 子进程创建循环
for(...)
{
	child_pid = fork();
	if(child_pid == 0)
	{
		// ...
		if (pipe_index > 0)
        {
	        // 将自己的标准输入替换为上一个的输出
            dup2(pipes[pipe_index - 1][0], STDIN_FILENO);
	    }
        if (pipe_index < pipe_count)
        {
	        // 将自己的标准输出替换为下一个的输入
	        dup2(pipes[pipe_index][1], STDOUT_FILENO);
        }
        // 然后关闭所有的pipe,确保pipe里面干净
        program_close_all_pipe(pipes, pipe_count);
        // 下面会执行程序,然后子进程会退出
		// ...
	}
	//... 
	pipe_index++;
}
// 最后再关闭一次
program_close_all_pipe(pipes, pipe_count);
// 最后等待所有子进程完毕
waitpid(-child_pid, NULL, 0);

其实就是用管道把进程串起来的过程

信号处理与终端控制

这个任务的关键是让shell本身不执行Ctrl-C,但子进程要能执行
这里可以使用signal系统调用来注册信号处理函数
当然这里也不用自己写,因为signal的处理是有默认实现的

比如说signal(SIGINT, SIG_IGN);表示在收到SIGINT后忽略
signal(SIGINT, SIG_DFL);表示收到SIGINT后使用默认的实现,也就是退出进程
需要注意的是,子进程默认会继承父进程的信号处理方式
这里的shell需要忽略SIGINT之类的信号,但子进程不能
所以需要在子进程创建后重新注册信号处理,让子进程使用默认实现

// 主进程信号初始化
void signal_main_init(void)
{
    signal(SIGINT, SIG_IGN);
    signal(SIGTSTP, SIG_IGN);
    signal(SIGTTOU, SIG_IGN);
}

// 子进程信号初始化
void signal_child_init(void)
{
    signal(SIGINT, SIG_DFL);
    signal(SIGTSTP, SIG_DFL);
    signal(SIGTTOU, SIG_DFL);
}

主要需要处理的是这三个信号,放到合适的位置即可

然后就是进程组管理
因为有些程序自己会创建很多子进程,我们希望一个SIGINT可以把整个程序杀掉
所以最好就是子进程跟shell不在一个进程组里并且子进程是自己进程组的组长
这样可以擒贼先擒王,子进程退出后,子进程下面的进程也就退出了
通过setpgrp系统调用,可以将自己单独分出进程组并且自己是组长
对于管道处理的情况,这一堆程序应当是一个进程组的

// 子进程创建循环
for(...)
{
	child_pid = fork();
	if(child_pid == 0)
	{
		// ...
        // 下面会执行程序,然后子进程会退出
		// ...
	}
	// 父进程设置子进程的进程组
	if (pipe_index == 0)
    {
        setpgid(child_pid, child_pid);
        pgid = child_pid;
    }
    else
    {
        setpgid(child_pid, pgid);
    }
    // ...
}

后台处理

这个部分比较神秘,涉及到前后台这个概念
我的理解就是,前台程序拥有终端的输入输出,后台只能有终端的输出
后台的程序还可能被暂停
前台一般给到的是一个进程组

通过tcsetpgrp这个系统调用,可以将终端前台权转让给一个进程组
需要注意的是,使用tcsetpgrp转让前台权时,这个进程组要有前台权
tcsetpgrp的第一个参数表示要转让的fd,一般转让标准输入
第二个参数表示转让给的进程组ID

bool is_background;
for(...)
{
	child_pid = fork();
	if(child_pid == 0)
	{
		// ...
		// 这里的部分不重要
        // 下面会执行程序,然后子进程会退出
		// ...
	}
	// 父进程获取子进程组
	if (!is_background)
    {
        // 前台给到子进程组
        tcsetpgrp(STDIN_FILENO, pgid);
    }
    // ...
}

前后台切换

这里需要添加新的类型,用来保存所有前后台的任务
这个列表也用来切换进程的前后台状态

// 添加job类型
typedef struct
{
	// 记录pid
    pid_t pid;
    // 记录是否是后台任务
    bool is_background;
    // 保存当前终端设置
    struct termios termios;
} job;

然后添加一系列函数用来控制job列表

fg的任务是把一个后台任务拉到前台来

// fg的核心函数
bool job_to_forground(job *job)
{
	// 实现无PID输入拉起最近的任务
    job = job == NULL ? recent_job : job;
    if (job->is_background == false)
    {
        return false;
    }
    // 转让前台
    tcsetpgrp(STDIN_FILENO, job->pid);
    // 恢复termios
    tcsetattr(STDIN_FILENO, TCSADRAIN, &job->termios);
    // 传递信号
    kill(job->pid, SIGCONT);
    job->is_background = false;
    // 等待这个子进程结束
    waitpid(job->pid, NULL, WUNTRACED);
    // 前台回到shell
    tcsetpgrp(STDIN_FILENO, getpgrp());
    return true;
}

bg的任务是将后台任务从暂停中恢复

// bg的核心函数
bool job_resume(job *job)
{
    job = job == NULL ? recent_job : job;
    // 传递信号
    kill(job->pid, SIGCONT);
    job->is_background = true;
    return true;
}
// 本身就是后台任务,也就没必要等待了

<0x03> 后记

首先感谢你能看到这里,写得还是挺啰嗦挺碎的

整个的代码我折腾了两天时间
其实实现这个东西还是挺有成就感的
有一说一,通过写一个shell来入门系统调用确实是不错的选择
shell可以说大部分功能就是靠系统调用实现的
而且也大多是常用的系统调用
如果没这项目,我还真不知道写什么可以很好入门系统调用

shell的大部分功能跟sh比较过,行为上基本是一致的
(就除了几个回显消息不同,不过这个项目没规定回显消息格式)
但学校的测试机不知道有什么问题,我最高跑到了9/10
很神秘,甚至我直接在GitHub上找了个代码直接贴上去也是9/10
(那份代码完成到后台处理,但测试机也判定后台处理不通过)
疑似是测试机的问题

经过学长验证,这个测试的最高分疑似就是9
而且拿到9分都不用写这么多,只要写到文件重定向就能拿到了
那我不就很小丑吗,我的实现在Berkeley都可以拿额外学分了
可惜工地终究不是它
这也验证了学校的测试机是仿制的