设计一个shell程序,在/userdata_linux用什么软件写程序

设计一个shell程序,在/userdata_linux用什么软件写程序介绍一下如何用C编写一个极简的LinuxShell程序,文末附有源码仓库

welcome

0.什么是 Shell?

Shell 是操作系统中的一个用户界面,它负责提供交互式访问系统内核的接口,用户可以通过它执行命令、运行程序、管理文件系统等。

在 Linux 系统中,常见的 shell 包括 bashshzsh 等;在 Windows 系统中,常见的 shell 包括 cmdpowershell

当然某个比较有名的 shell 并不一定只能在某个操作系统上使用。
就好像在 Linux 上也可以使用 powershell 一样。

受益于 Linux 系统的模块化设计,默认 shell(如 sh)能做的事情我们都可以通过调用系统库文件、或访问 /bin 下的可执行文件实现。因此手动编写一个 mini-shell 并不是一件困难的事情。

一般来说,一个强大的 shell 程序都会有一个相对完备的脚本语言,这也是我们能编写 .bat.sh 等脚本文件并在必要的时候执行的原因。

编写一个 Shell 程序大概是操作系统课程中的一项必备实验,这里仅介绍如何利用 C 语言编写一个最简单的 Shell 程序,并在文末附上 GitHub 仓库。

由于程序源码文件被分为了不同模块的几个文件,因此实际使用时需要配合 make 工具才能编译。

本文的伪代码格式参照 Python 的缩进语法,但函数调用风格参照 C。

1.需求分析

1.1 命令提示符

通过介绍可以得知,shell 程序的主要功能是接受用户的指令,经过合法性检查后由解释器调用操作系统提供的系统调用,最后再提交给内核执行。

如果你在使用 shell 时比较细心,会很容易发现每一行前面都有一个意义不明的字符串,这个字符串也被称为命令提示符。

命令提示符的作用是给出包括但不限于当前 shell 的活跃用户名、shell 所属的主机名、当前工作目录,以及用户权限的信息。

bash_style

图中给出的是 bash 的命令提示符。不同的 shell 也会对提示符做不同的字符样式处理工作,比如这里的 bash 就将重要信息加粗染色了。

这里可以看出 bash 的提示符格式是:用户名@主机名:工作目录$ (末尾有一个空格)。

1.2 指令类别

原则上,在处理用户指令时,shell 会将不同类别的指令划分为外部指令与内部指令。

内部指令可以视为是 shell 程序的一部分,这类命令不需要启动新的进程来执行;例如切换目录 cd、输出字符串 echo 等。相对的,外部指令就是需要使用子进程执行的命令;例如执行一个可执行文件。

内部指令可以很简单地通过为程序添加功能函数实现,但是外部指令就需要使用操作系统,或者是库文件提供的接口执行(对于 Linux 而言一般是 execvp)。

用户并不会关心输入的某条指令到底是内部还是外部的,并且无论是内部还是外部指令执行起来都几乎没有差别。但从实现上考虑,如果我们区分开内外部指令,一方面可以提升程序的运行速度(因为每次调用 execvp 实际上都唤起了一次系统调用,而系统调用需要切换一堆状态量),另一方面可以提高 shell 的可移植性(虽然我们不需要这个东西)。

不区分内外部指令(即全部视作外部指令)的话,相当于把所有功能实现都交付给操作系统本身,我们只负责完成 shell 脚本的前端解析工作。

这样一来,总体的命令处理过程就可以细分为:当用户需要执行一条命令时,首先检查是否是内部指令;如果不是,再交给子进程去执行。如果都失败了再返回错误。

error_example

上图给出了 bash 遇到错误指令的处理提示(左侧的红点是 vscode 的终端提示,与 bash 无关)。

1.3 结论

综上,不难得知若希望编写得到一个简单的 shell 程序,我们需要实现以下几个基本功能:

  • 命令提示符:输出当前用户的基本信息;
  • 常用的内、外部指令:cd、ls、cat 等;
  • 能够调用系统中存在的可执行文件:例如使用 ./helloworld 执行当前目录下的 helloworld 文件;
  • 错误命令提示;
  • 一些额外的命令,如管道功能等。

2.代码设计

2.1 伪码框架

我们很容易就能想到,一个 shell 的工作流程应该是这样的:

Created with Raphaël 2.3.0 输出命令提示符 读取命令 命令有效? 解释执行 抛出错误 yes no

为了持续监听用户输入,这个工作流程应当是一个头尾相连的死循环,否则在接受一次用户输入后整个 shell 就会退出。故我们可以得到一个伪码框架,如下:

while True:
	print_prompt()
	listen_command()
	if command_parse() is vaild:
		command_process()
	else:
		throw_error()

这个框架只是在整体上看来比较完善,但是很显然还是过于抽象了一点,它甚至没有提示我们如何组织并管理 shell 运行时所必须的数据结构。

bash 的命令提示符告诉我们:提示符总与当前活跃用户(也可以称为「登录用户」)相关。那么我们的 mini shell 就需要去记录一个被称为「用户信息」的数据结构;此外,每次调用一个 Shell 程序时,命令提示符总是会记录着不同 shell 的当前工作目录,所以我们也同样需要一个数据实体用于保存一些环境信息。

C 中的 struct 天然就适合完成这个工作:思路是将上述的过程视作一个「面向对象」的过程,使用 struct 设计我们的目标类结构。所以就可以得到下面这个相对「完善」一点的伪码框架:

User usr = new User()
Command cmd = new Command(usr) // 登录的用户是 usr

while True:
	cmd.print_prompt()
	cmd.listening()
	if cmd.parse() is vaild:
		cmd.process()
	else:
		throw_error()
	cmd.update_prompt()

2.2 使用到的库

因为 glibc 提供的输入函数是不包含行编辑功能的,而且也没有提供记录输入历史的函数,所以如果想补全这部分内容,就可以引入第三方库。

最初设计 shell 时并没有考虑过以上功能,只是简单的添加了一个字符着色功能。有兴趣的读者可以自己在 github 上找找满足需求的库。

3.代码实现

3.1 基本数据结构

前文提及,为了降低程序复杂度,我们会将两类数据:用户数据和 shell 运行时环境数据抽象并封装为两个类。前者参照 Linux 中 passwd 结构体的实现,简化掉一些无关数据项,可以得到如下的设计:

// src/user_info.h
extern const uint32_t uid_len;     // long 类型十六进制数的字符串长度
extern const uint32_t name_limit;  // 用户名上限
// 在头文件中定义变量会导致重定义错误,故仅使用 extern 暴露名称

typedef struct user_info_t { 
   
  char *name; // 用户名,也是家目录名
  // 设计时不考虑家目录的位置变动,一律视作在 `./` 文件夹下
  long uid;

  void (*destructor)(struct user_info_t* const);
} usr_info;

usr_info* create_usr(usr_info* const this); // 构造函数
void release_usr(usr_info* const this);     // 析构函数

上面的代码中有一点需要注意:为了和面向对象语言保持一致,本文中提及的类的成员方法的对象指针变量名都叫 this

这是因为设计上我们的 shell 使用的语言被限制在纯 C,而习惯用法中类方法内调用对象本身一般都会通过名叫 this 的指针实现,所以函数参数中的对象指针就被命名为了 this

但在 C++ 中 this 是一个语言关键字,因此我不保证不修改代码的情况下代码能够通过 C++ 编译器的编译,因此请不要使用 g++clang++ 等 C++ 编译器编译本项目;否则请自行承担后果。

正如最开始提到的,我们的 shell 要区分内外部指令,所以我们还需要根据需求分析的结果、以及我们希望实现的功能,为每种类型的命令编写标签;标签使用了 enum 实现。

// src/shell_runtime.h
typedef uint32_t ExprLenT; // 定义表达式长度值的底层数据类型
extern const ExprLenT read_limit;

// 划分不同表达式的类型
typedef enum expression_t { 
   
  /* 每行首元素用于区分命令类型 */
  nil,
  external, externCopy, externMkdir, externTouch, externLook, externLs,
  buildin, buildinExit, buildinEcho, buildinHelp, buildinPwd, buildinCd, buildinCls
} expr_type;

shell 运行时的环境数据比较复杂,从命令提示符的内容中我们可以知道,我们需要记录:用户名、主机名和当前工作目录。前面两个元素可以通过持有一个指向 usr_info 结构的指针实现,工作目录就需要我们自己使用字符数组实现了。

另外,我们的 shell 是允许更改当前工作目录的,所以我们需要知道什么时候更改了工作目录、然后去更新我们的字符数组,所以我们还需要在运行时数据中记录上一条命令的表达式类型。

// src/shell_runtime.h
typedef struct shell_runtime_t { 
   
// private:
  char *_origianl_expr;  // 原始命令串
  char *_processed_expr; // 经过裁切处理后的命令
  ExprLenT _expr_len;
// public:
  usr_info* active_usr;   // 当前使用该 shell 的用户
  char* cwd; // 当前工作目录

  int argc;
  char **args; // 命令串需要被拆分为不同 tokens,所以要有一个字符指针数组存储所有拆解得到的 tokens
  expr_type prev_exprT;

  void (*destructor)(struct shell_runtime_t* const);
  // 根据编译原理,一个解释器需要完成语句扫描、解释执行,所以这里先给出一个推测的函数指针
  // 从 FILE 中读取语句
  struct shell_runtime_t* (*scanner)(struct shell_runtime_t* const, FILE *);
  void (*interpreter)(struct shell_runtime_t* const);
} shell_rt;

shell_rt* create_shell(shell_rt* const this, usr_info* logged_usr); // 构造函数
void release_shell(shell_rt* const this);                           // 析构函数

这里解释一下 args 的作用:我们都知道在编译原理中,程序语言的最小单位是 token。因此我们需要将读取得到的原始表达式 _original_expr 拆解为数个 tokens 后解释执行,所以我们就需要有一个东西(args)去记录我们拆解得到的所有 tokens,并用一个变量(argc)记录拆解得到的 token 数量。

token 从哪来?当然是从 _processed_expr 中得到。也就是说 args 不需要实际拷贝字符串,它只需要记录 _processed_expr 中已存在的非空字符串。

一个类都有几个特殊的函数,比如构造函数、析构函数。我们还需要补充上面两个类的构造和析构函数。

// src/user_info.c
usr_info* create_usr(usr_info* const this)
{ 
   
  this->uid = 0;
  this->name = (char*)malloc((name_limit + 1) * sizeof(char));
  if (this->name == NULL)
    return NULL; // 发生异常情况不应该继续执行

  this->destructor = &release_usr;
  /// TODO: 部分成员变量需要等后续完成后补充

  return this;
}

有一些数据成员如何实现我们还没有什么头绪,那么就推迟到我们完成了其他部分的内容再来补充。

// src/shell_runtime.c
shell_rt* create_shell(shell_rt* const this, usr_info* logged_usr)
{ 
   
  this->_origianl_expr = (char*)calloc(sizeof(char), read_limit + 1);
  if (this->_origianl_expr == NULL)
    return NULL;
  this->_processed_expr = (char*)calloc(sizeof(char), read_limit + 1);
  if (this->_processed_expr == NULL) { 
   
    /// TODO: 要回收前面已分配的资源,保证异常安全
    return NULL;
  }
  this->_expr_len = 0;

  /// TODO: 初始化成员变量
  if (this->cwd == NULL) { 
   
    /// TODO: 同上
    return NULL;
  }
  this->args = NULL; this->argc = 0;
  this->prev_exprT = nil;

  this->destructor = &release_shell;

  return this;
}

析构函数比较简单,把所有动态分配的指针全部释放掉就行,这里就不给出源码;详情可以参考文末仓库内的源码文件。

3.2 错误处理

从上面的构思中我们已经可以看出一点:错误处理机制的缺省使得我们需要在代码块中大量留白。所以在一切开始之前,我们需要完成首个功能函数的构思:错误处理。

错误处理是后续实现的重要组成部分,毕竟代码中有错误但没法处理的情况总是会阻塞我们的思路。

受限于 C 语言不存在异常机制,我们只能使用错误码完成我们的错误处理工作。设计时我考虑了几种错误情况(简单易用):

  1. 一般性成功;
  2. 一般性失败;
  3. 构造函数错误;
  4. 初始化工作失败;
  5. 接收到 EOF 符(在 io 函数中)。

总结一下,可以用一个 enum 概括以上情况:

// util/error_handle.h
typedef enum error_code_t { 
   
  success, failed,
  constructorError, initError,
  receivedEOF
} error_code;

错误码只是用于识别函数执行过程中是否发生了错误,我们还需要有一个用于输出错误信息的函数。发生了错误时,我们关注的信息主要有两点:

  1. 在哪发生的;
  2. 是什么导致的。

简单来说就是 where 和 what。格式上参考 bash 的错误信息提示,可以得到一个用于抛出错误信息的函数:

// util/error_handle.h
/// @brief 将错误原因抛出到 `stderr` 中
/// @param where 错误位置
/// @param what 错误原因,不要带换行符
void throw_error(const char* where, const char* what)
{ 
   
  const char* error_fmt = "%s: %s\n";
  fprintf(stderr, error_fmt, where, what);
}

此外,在错误发生时我们还需要回收一些资源,就是将指针释放、然后置空。释放和置空一共是两个语句,比较繁琐,所以我使用了宏函数简化一下:

// util/constant_def.h <= 注意一下,仓库中的宏函数不在头文件 error_handle.h 中
#define release_ptr(ptr) do { 
      free(ptr); ptr = NULL; } while (0) // 很经典的宏函数结构

那么现在就可以回去补充构造函数中的错误处理代码了。

一般来说,C 语言可以使用 longjmpsetjmp 宏配合实现全局的异常抛出-捕获效果,并且这种方式的异常处理要比错误码更加直观和强大。不过很显然这个东西属于一种比较进阶一点的程序设计技巧,本文实现的 shell 程序主旨是简单为主,故不会使用这种方式完成异常处理。
有兴趣的读者可以参阅这份资料。

3.3 主程序流

为了接近我们先前分析得到的伪码框架,我们需要将所有必要的、且功能单一的部分统统封装为一个可供调用的函数,并将不同功能之间的协调运作转换为函数之间互相的调用与返回(其实这部分有点像是一个状态机)。

参考以上思路,我们可以尝试性地写出一个 main 函数:

// src/mainProg.c
int main()
{ 
   
  usr_info user;
  shell_rt shell;
  if (make_usr_info(&user) == NULL) { 
   
    throw_error("login", "create user failure");
    /// TODO: 构造错误,可以直接 exit 终止程序
  }
  if (create_shell(&shell, &user) == NULL) { 
   
    throw_error("login", "create shell failure");
    user.destructor(&user); // 析构掉已构造的对象
    /// TODO: 构造错误
  }
  const char *prompt_fmt = "%s@%x:%s$ "; // 提示符的格式串
  const size_t basic_len = name_limit + uid_len + strlen(prompt_fmt);

  size_t cwd_len = strlen(shell.cwd);
  size_t prompt_len = basic_len + cwd_len;

  // 命令提示符必然是动态拼接的,因为当前工作目录是可变动的
  char *prompt = (char*)calloc(prompt_len + 1, sizeof(char));
  if (prompt == NULL) { 
   
    shell.destructor(&shell);
    user.destructor(&user);
    throw_error("prompt", "create prompt string failure");
    /// TODO: prompt 分配失败,可以 exit 退出,因为后面无法执行了
  }
  sprintf(prompt, prompt_fmt, shell.active_usr->name, shell.active_usr->uid, shell.cwd); // 拼接提示符

  while (1) { 
    // 死循环
    shell.scanner(&shell, stdin, prompt)->interpreter(&shell);;

    if (shell.prev_exprT == buildinCd) { 
   
      cwd_len = strlen(shell.cwd);
      /* 保证 prompt 始终能够容纳下 cwd */
      if (prompt_len < basic_len + cwd_len) { 
   
        /* 容纳不下时触发一次手动扩容 */
        prompt_len = basic_len + (cwd_len * 2);
        release_ptr(prompt); // 扩容因子为 2
        prompt = (char*)calloc(prompt_len + 1, sizeof(char));
        if (prompt == NULL) { 
   
          shell.destructor(&shell);
          user.destructor(&user);
          throw_error("prompt", "reallocate prompt string failure");
          /// TODO: 扩容失败,一样可以 exit
        }
      }
      sprintf(prompt, prompt_fmt, shell.active_usr->name, shell.active_usr->uid, shell.cwd);
    }
  }

  release_ptr(prompt); // 释放动态分配的资源
  shell.destructor(&shell);
  user.destructor(&user);

  return 0;
}

3.4 输出命令提示符

此时我们的 shell 程序只是空有一个 main 函数的架子,实际上编译运行后是没有任何内容输出的(当然也可能无法通过编译,这很正常)。

我们已经知道,命令提示符可以被总结为一个简单的格式串:用户名@主机名:工作目录$ ,其中末尾的特殊字符 $ 通常用来区分用户权限。为了能够获取 shell 运行时的工作目录、并将这个目录拼凑为提示符输出,我们需要以下几个函数:

get_cwd(); // 获取当前工作目录,在 src/functional_func.c 中
get_uid(); // 得到用户的 uid 或是其他身份信息,可参考 src/user_info.c 中的 `generate_uid`
get_id();  // 获取用户名,可参考 src/user_info.c 中的 `make_usr_info`

这里的主机名、用户 uid 之类的可以从 stdin 中获取,也就是向用户申请一个;或者去读取 /etc/hostname 文件获取物理主机名。

现在万事俱备,只差一个额外的工作:将提示符中的家目录替换为 ~;也就是将类似于 /home/username/something 的结构变为 ~/something

很显然这里有一堆字符处理工作要做,所以我们还需要将这部分功能封装为新的函数:

// util/string_util.h
/// @brief 从 `pos` 开始匹配 `substr`
/// @param mainstr 主串
/// @param substr 子串
/// @param pos 起始偏移量
/// @return `substr` 在 `mainstr` 中的起始位置,不存在则返回 `NULL`
char* match_str(const char* mainstr, const char* substr, const size_t pos);

/// @brief 将 `str` 中的 `target` 替换为 `~`,并前移字符串,
/// @brief 其实就是替换目录字符串中的家目录名称
/// @param str 待处理的字符串
/// @param target 目标字符串,默认该字符串满足格式 `/%s/` 或 `/%s`
void format_str(char* str, const char* target);

format_str 函数的目的是用 ~ 字符替代目录串中的家目录名,并把家目录后的字符向前移动到串首。这个过程很简单,就是在主串中尝试匹配正确的「家目录结构」,并且仅在匹配成功的情况下移动字符串。

匹配的关键点已经在函数注释中指出(假设家目录username):

  1. 必须满足格式 /username
  2. 如果匹配得到的子串不在 str 的末尾,那么必须满足格式:/username/
  3. 否则,必须满足格式:/username^,其中 ^ 表示空字符 \0

整体流程相当简单,但是要注意:移动字符串的时候可能会在主串串尾留下一些非空字符,我们还需要将这些非空字符清空,以避免后续使用时出现奇怪的问题。

// util/string_util.c
void format_str(char* str, const char* target)
{ 
   
  const char sep = '/';
  const char* terminus = str;
  while (*terminus++ != NIL_CHAR);
  // NIL_CHAR 被定义于 util/constant_def.h,实际上是空字符 '\0'
  const size_t sub_len = strlen(target);
  size_t pos = 0;
  do { 
   
    char *substr = match_str(str, target, pos);
    pos += sub_len;

    if (substr == NULL || substr == str) break;
    if (substr != str && *(substr - 1) != sep)
      continue; // 不满足 `/%s`
    // 检查是否满足 `/%s/` 或 `/%s`,后一种情况仅限 substr 在 str 末尾
    if (*(substr + 1) != NIL_CHAR && *(substr + sub_len) != sep)
      if (*(substr + sub_len) != NIL_CHAR)
        continue;

    str[0] = '~';
    strcpy(str + 1, substr + sub_len);

    char *end = str;
    while (*end++ != NIL_CHAR);
    memset(end, NIL_CHAR, terminus - end);
    break;
  } while (pos < terminus - str);
}

由于这时候 shell 的功能还不全,所以我们需要手动创建一个和预先给定的用户 id 同名的文件夹,然后把我们的 shell 程序放进去运行,这样才能检测命令提示符的功能:

prompt_test

3.5 命令读取

通常来说,一个完整的 shell 会拥有一套功能相对强大的脚本语言,并按照完整的编译前端解析流程分析语句表达式。我们可以借助 YACC 等工具帮我们编写一个属于我们自己的语法分析器,但显然这个工作所需要的精力与背景知识不在本文介绍的范围内,故对于我们将要得到的这个简单的 shell 程序而言,仅使用简单的字符串匹配替代并实现了上述几个过程。

由于我们没有完整的词法分析器帮我们实现命令的 token 识别,因此对于任何用户输入的字符串,我们需要有一系列的字符串处理函数帮我们处理:字符串存入、字符串清洗、token 的识别与拆解三个步骤。当然这并不困难,只是很不巧的是 C 并没有提供一个 string 类型为我们做这些事。

作为一个需要接收用户输入的程序,我们显然不能完全信任任何传入的字符串;因为用户可能将任何东西传输给我们的程序。考虑到空格作为空白字符是没有实际语义的,所以实现时我们可以基于空白字符来处理我们的输入串。

类似 python 中清洗字符串的函数一样,我们也需要有一个自己的 trim 函数用于清除字符串首尾的空白字符,同时为了统一函数的输入功能调用,我们还需要把获取输入的功能抽象封装进一个 input_str 函数中;而且注意到:命令提示符的输出通常在等待用户输入时,因此我们还需要将输出提示符的任务交给 input_str 函数。

// util/io_util.h
void input_str(FILE* infile, char* dest, int char_limit, const char* prompt);

// util/string_util.h
/// @brief 裁切字符串指定范围内的首尾空白字符
/// @param str 待处理字符串
/// @param terminus 和参数 `str` 构成一个左闭右开的区间 `[str, str + terminus)`
/// @return 指向第一个非空字符的指针,参数非法时返回 NULL
char* trim_str(char* const str, const size_t terminus);

这两个函数的实现都不困难,唯一需要注意的是在 input_str 函数中,每次获取输入前都需要使用 fflush 或其他任何你喜欢的方式刷新输入缓冲区,以避免字符污染。

顺便一提,实现时大多数 IO 函数都使用的是与文件交互的几个函数:fgetfputs 等,这只是因为可以通过 FILE 指针简单的重定向程序的输出位置,并且这几个函数能够要求提供最大读取的字符数。

现在我们拥有的功能函数已经相当多了。为了进一步完善用户注册、登录功能,我们额外添加一个工厂函数 make_usr_info,用于输出提示信息并获取用户名。

// src/user_info.h
// 工厂函数,创建一个新的 usr 对象,失败返回 NULL 指针
usr_info* make_usr_info(usr_info* const this);

这个函数的作用是根据输入的有效用户名(不允许包含空白字符),在当前目录下创建一个新文件夹,然后将工作目录切换到新文件夹中。

make_user_info

此时由于其余的指令处理功能都没有补全,所以我们的 shell 在接受任何输入时都只会接受 -> 换行 -> 重新输出提示符。

3.6 命令解析与执行

到了这一步,我们的 shell 已经能够支持基本的输入输出了,也能够通过命令提示符反馈一些环境信息。接下来我们需要为 shell 实装上一些核心的功能。

我们都知道 glibc 提供了一个非常万能的函数 system,这个函数可以快速实现指令的执行。但是 system 本身不会对输入串做任何检查,并且它的工作细节也相对复杂,所以这里对命令进行解析后执行更为可靠。

前面我们已经给出了 shell_rt 的成员函数类型(就是结构体里的指针),现在我们要给出实际上的函数定义。

考虑到命令提示符的输出总在 shell 获取输入的时候,所以需要把命令提示符传递给获取输入的函数。

// src/shell_runtime.h
// 从 `infile` 中获取输入并做词法分析,这个函数会调用 `read_expr` 和 `analyse_expr`
shell_rt* get_expr(shell_rt* const this, FILE* infile, const char* prompt);
// 解释执行处理后的表达式
void interprete_epxr(shell_rt* const this);

// 下面的函数可以视作是私有方法

/// @brief 从指定的文件标识符中读取命令表达式
/// @param infile 指定的文件标识符,一般是 stdin
/// @param prompt 需要输出的命令提示符
shell_rt* read_expr(shell_rt* const this, FILE* infile, const char* prompt);

/// @brief 对原始表达式进行词法分析
/// @param delims 词法分析时用于拆分 token 的字符集合
shell_rt* analyse_expr(shell_rt* const this);

具体的词法分析(解析 token)的工作在 read_expr 函数中执行。按惯例,大部分程序语言的 token 划分都是按照空格进行的,所以我们也使用空格切分得到的表达式。

string.h 中的函数 strtok 恰好提供了这样一个 token 拆分的功能。这个函数接受两个参数:第一个是需要处理的字符串指针,第二个是由分隔符构成的字符串;函数会将出现在第一个参数中的所有分隔符替换为空字符 \0。我们可以用一个 for 循环简单地处理已经经过清洗的表达式:

for (char *str = strtok(this->_processed_expr, " "); str != NULL;
     str = strtok(NULL, " "), this->argc++);

这个被直接拆分后的字符串中夹杂了大量的空字符,直接使用的话既需要控制越界,又需要检测空字符;所以我们还需要另一个数组单独存储所有已被拆分的 token,这就是前文提及的 args 的作用。

// src/shell_runtime.c
/* 找出所有 token 在原字符串中的地址,并在另一个数组中保存这些地址 */
for (uint32_t i = 1, backtrack = 1, num_args_saved = 0;
     i <= (this->_expr_len) && num_args_saved < this->argc; ++i, ++backtrack) { 
   
  if (this->_processed_expr[i] != NIL_CHAR && this->_processed_expr[i - 1] == NIL_CHAR)
    backtrack = 0; // backtrack is used to backtrack to the head of each token
  else if (this->_processed_expr[i] == NIL_CHAR && this->_processed_expr[i - 1] != NIL_CHAR) { 
   
    this->args[num_args_saved++] = &this->_processed_expr[i - backtrack];
    backtrack = 0;
  } /* 由于 linux 中 exec 函数族要求传给它们的命令参数数组的最后一个元素值为 NULL * 所以这里需要手动添加多一个元素,并赋值为 NULL * 这部分事项没有在这部分代码中体现 * 详见文件 src/shell_runtime.c 的函数 `analyse_expr` */
}

由于我们的 shell 支持的命令少、同名命令不多,所以我们可以把每条命令的第一个字符传给 switch 语句,根据首字符的不同快速判断这条命令是内部指令还是外部指令,这个操作能够极大地减少按照字符串匹配结果来判断命令类型所带来的时间开销。就像这样:

// src/shell_runtime.c
expr_type categorize_epxr(char** args, int argc)
{ 
    /* 用 const 约束 args 是没有意义的,这是 C 的语言缺陷 */
  switch (args[0][0]) { 
   
  case 'e': /* exit */
    if (strcmp(args[0], "exit") == 0)
      return buildinExit;
    else if (strcmp(args[0], "echo") == 0)
      return buildinEcho;
    break;
  case 'h': /* help */
    if (strcmp(args[0], "help") == 0)
      return buildinHelp;
    break;
  // ...
}

对表达式进行简单分类后,就可以根据解析结果调用不同的函数;我们稍后再完善相关的命令支持函数,这里先用一些简单的 printf 以检测我们的解析功能是否正常:

commend_test

看来有效的命令被成功响应,并且无效指令也抛出了对应的错误信息。

3.7 功能实现

命令解析的前端部分已经完成了,接下来是可选的功能命令支持部分(因为这里使用 system 一把梭也能完成)。仓库中的 shell 实现了以下几个命令功能:

  • cd、cp、ls 等;
  • 管道功能;
  • 外部文件调用;
  • 其他的如 help 提示、cat、pwd 等。

其中的 help、pwd、cd 和 cp 等指令只需要由 shell 自己提供、或对系统调用:如 chdir 做一个简单的封装即可,故不在这里做过多介绍。

这里补充一下对于不同命令而言需要用到的系统调用函数。

命令 系统调用函数
cd chdir
pwd getcwd
cp 创建文件使用 creat,还有验证文件存在和其访问权限时需要使用 access,其余的文件复制可以用 glibc 提供的函数完成,直接用系统调用 readwrite 也可以
touch creataccess
mkdir mkdir
cat 这个自己用文件读写把文件内容以文本形式写入 stdout 就行
ls 这里需要访问 linux 提供的用于描述目录结构的结构体 struct dirent,写起来比较复杂,详细可以参考其他人的文档

详细可以查看 src/functional_func.c 文件中的内容。接下来主要介绍如何实现调用外部可执行文件,以及如何实现管道功能。

3.7.1 调用外部可执行文件

如果我们希望在一个 shell 运行的时候能够接受类似 ./hello 的指令执行另一个程序,那么我们实际上做的是一个「启用另一个进程执行指定的命令」的工作。

这个时候我们就需要使用到 Linux 提供的 exec 族函数,它们位于头文件 unistd.h 中:

函数名 功能
execl(const char *path, const char *arg, ...) 执行指定路径的可执行文件,使用可变参数列表传递命令行参数
execv(const char *path, char *const argv[]) 同上,但使用参数数组传递命令行参数
execle(const char *path, const char *arg, ..., char *const envp[]) 使用参数列表和环境变量数组执行新程序
execve(const char *path, char *const argv[], char *const envp[]) 同上,但是是参数数组
execvp(const char *file, char *const argv[]) 在参数 file 和 PATH 环境变量中搜索可执行文件并使用参数数组执行

以上函数的返回值类型均为 int,且仅在执行失败时返回,返回值是 -1.

观察这几个函数的参数需求,我们可以发现 execvp 既可以在 PATH 中搜索可执行文件,也可以将指定文件路径直接传递给参数 file,故我们可以选择这个函数作为我们的普通命令执行函数。

不过即使这里有许多函数,但是最终都会调用函数 execve(系统调用)。查询一下函数参数要求,我们还需要注意一点:所有参数列表或参数数组都需要有一个 NULL 元素或指针作为结尾标记,所以我们还需要对前面的命令解析部分做一点小小的修改。

另外还需要特别注意一点:exec 函数调用后会启用另一个进程顶替当前进程,并且 exec 函数只有在执行失败时才会返回;所以在这部分我们必须使用 fork 切出子进程,再在子进程中调用 exec

由于这里我们需要使用多进程的方式运行程序,所以我们需要一个用于在父子进程之间通信的手段,保证当指令执行失败时我们有办法返回错误信息。这个通信方式既可以是临时创建一个文件,也可以向系统申请一块共享内存;最终实现时我使用的是 Linux 的匿名管道。

匿名管道是一种用于进程间通信的机制。它可以在父进程和子进程之间创建一个共享且单向的流式管道。这个管道可以通过系统调用 pipe(int[2]) 创建,此函数会将传入的数组设置为两个文件描述符,其中首元素用于读取数据,尾元素用于写入数据。这两个文件描述符可以用于在父进程和子进程之间进行通信。

函数的流程并不复杂,只是需要注意的地方不少,简单整理思路后我们就可以得到如下的一个实现:

int fd[2], status = success;
if (pipe(fd) == -1) { 
   
  throw_error("execute", "cannot create pipe");
  return failed;
}
/* 由于默认创建的管道读取和写入都是堵塞式的,但 exec 成功执行后不会返回 * 所以这里要将管道设置为非阻塞式读取 */
fcntl(fd[0], F_SETFL, fcntl(fd[0], F_GETFL)|O_NONBLOCK);

pid_t pid = fork();
if (pid < 0) { 
   
  throw_error("execute", "cannot fork");
  return failed;
}
if (pid == 0) { 
   
  close(fd[0]);
  execvp(args[0], args+1);
  /* 只有失败了才会进入下面的代码块 */
  int message = failed;
  write(fd[1], &message, sizeof(int));
  exit(failed); // 强制子进程退出,不然就有另外一个 Shell 程序在等待监听了
} else { 
   
  waitpid(pid, NULL, 0); // 指定等待子进程死亡
  read(fd[0], &status, sizeof(int));
}

插入实现的代码段后,我们简单的编写一个 hello.c,编译后让 shell 执行:

execute_test

我们甚至可以尝试让 shell 运行自己:

execute_itself

3.7.2 管道功能的实现

这里要实现的管道有些类似于上文提及的、用于进程间通信的匿名管道。不过这里的管道是指一种特殊的运算符(一般都是 |),用于将一个命令的输出连接到另一个命令的输入,实现两个或多个命令之间的数据传递。

这个东西有点像重定向功能,也是将一个进程的输出重定向到另一个地方;但是之所以是「像」而不是「就是」,是因为管道实际上是重定向了 stdout 的文件描述符,将前一个命令连接的 stdout 重定向到后一个命令的 stdin 中。

在 Linux 中,每个进程都拥有自己独立的 stdoutstdinstderr 流,通常默认情况下这些流会分别连接到终端和键盘上;并且由于 Linux 将这些流抽象为了一个文件接口,所以我们可以借助操纵文件描述符,进而将这些流重定向到别的文件中。

而重定向则是简单粗暴地将一个进程的 stdout 流写入指定文件中。

通常来说,Linux 中 stdinstdout 的默认文件描述符分别为 0 和 1;并且我们能通过 unistd.h 中的 dup2(int fd, int fd2) 函数重定向指定的文件描述符,所以我们要做的只有找出所有需要调用管道功能的指令就行了。

但是这里需要注意,管道操作符两端的指令都有可能是调用可执行文件的指令,而一个成功执行的 exec 函数是不会返回的;所以我们必须同时 fork 出两个子进程分别执行两个指令,并且至少要保证左侧指令输出完成后才执行右侧指令。

识别出某条指令是否是管道指令很简单,我们只需要扫描一遍命令字符串,查看其中是否有特定的管道操作符 | 即可。这里给出伪代码:

execute_pipe(args, argc):
	if argc is not enough:
		return // 检查参数是否满足要求

	status_pipe[2] = pipe() // 返回执行情况的管道
	stream_pipe[2] = pipe() // 用于实现管道功能的管道

	for counter in range(2): // 一共就两条指令,直接用循环写
		pid = fork()
		if pid < 0:
			throw_error("pipe", "fork failed")
		else if pid == 0:
			if counter == 0: // 左侧的指令
				dup2(fd[1], stdout) // 重定向 stdout
				exec(args.command_left)
				write(status_pipe[1], FAILED) // 执行失败了
				exit(failed)
			else: // 右侧的指令
				dup2(fd[0], stdin) // 重定向 stdin
				exec(args.command_right)
				write(status_pipe[1], FAILED) // 同上
				exit(failed)

		waitpid(pid, NULL, 0); // 需要保证前一个进程输出彻底完成

可见这部分的实现还是相对复杂和繁琐一点的。剩下就根据已经写好的工具函数填充为实际的 C 代码即可。详见仓库 src/shell_runtime.c 中的 execute_pipe

之后我们将这段代码插入回程序中,这里额外编写一个将 fgets() 接受的内容原封不动抛给 fputs() 输出的 C 程序,并与前文提到的 hello 结合进行测试:

pipe_test

其中的 throw 源码如下:

#include <stdio.h>
int main()
{ 
   
  char str[1025];
  fputs(fgets(&str[0], 1024, stdin), stdout);
  return 0;
}

需要注意的是,由于我们的 shell 在语句解析上的羸弱,本文中的所有指令都是无法递归调用执行的。为了防止输入递归调用的指令导致 shell 出错,我们可以提前确定命令串中是否有多个不同的命令、或是出现了两个以上的管道符1,进而阻止这条指令执行。

若希望实现命令的递归式调用,则可以将解析所得的 token 串重新组织为波兰式或直接建立语法树,然后解释执行。当然这部分内容不在本文探讨之内。

3.7.3 重定向功能的实现

既然已经实现了管道,那么就顺手将类似的重定向功能也一并实现了。

同样的,重定向功能也是由一个重定向操作符 > 控制,将操作符左侧的输出重定向到右侧的文件中;因此简单地扫描一边字符串就可以知道这条命令是否是重定向命令了。

实现时重定向与管道不同,重定向需要使用 freopen 函数将某个文件绑定在 stdout 上,再向 stdout 正常输出信息。

重定向部分的功能相对简单很多,我们只需要验证目标文件是否存在,然后使用系统调用获取目标文件描述符,最后使用 freopen 重新绑定子进程的 stdout 即可。这里只给出伪码框架,详见仓库 src/shell_runtime.c 中的 execute_redirect

execute_redirect(args, argc):
	if args.target_file is inexistence:
		return

	status_pipe[2] = pipe()
	pid = fork()
	if pid < 0:
		throw_error("redircect", "fork failed")
	if pid == 0:
		target_fd= freopen(args.target_file, "w", stdout)
		exc(args.expr)
		write(status_pipe[1], failed)
		exit(failed)
	waitpid(pid, NULL, 0)

3.9 补充和完善

3.9.1 字符样式更改

前文给出的一些演示图中,bash 的命令提示符总是带有绿色加粗效果,我也想把这玩意染成绿的。

实际上,在命令行界面中更改输出的字体样式相当简单,只需要使用数个 ASCII 转义序列即可。以下是几个常用的 ASCII 转义序列:

// util/font_style.h
#define FNT_RESET "\033[0m" // 重置所有效果
#define FNT_BLACK "\033[30m" // 黑
#define FNT_RED "\033[31m" // 红
#define FNT_GREEN "\033[32m" // 绿
// ...
#define FNT_BOLD_TEXT "\033[1m" // 粗体
#define FNT_ITALIC_TEXT "\033[3m" // 斜体
// ...
#define FNT_CLS "\033[2J" // 清屏
#define FNT_RESET_CURSOR "\033[H" // 重置光标位置

这里提一个比较有用的小知识:C 语言的预处理器可以将两个用空格隔开的字面量字符串在编译期拼接在一起(说编译期其实不太严谨,在预处理阶段这个字符串拼接工作就已经完成了),没有任何额外开销。

// 也就是说这样写:
const char* str1 = "HelloWorld";
// 等价于这样写:
const char* str2 = "Hello" "World";

这时候利用上面预定义的一堆宏,我们可以玩一个比较花的花活:

#define STYLIZE(STR, COL) COL STR FNT_RESET // 只接受字面量字符串

这个宏函数可以为一个字面量字符串添加字符样式更改效果,例如为一个字符串加粗并染成绿色:

const char* bold_green_str = STYLIZE("hello", FNT_GREEN FNT_BOLD_TEXT);

那么把这个字体样式加到 shell 的工具函数中,给 throw_error 的格式串、以及命令提示符的格式串染个色,我们可以得到这样的效果:

font_test

我从没觉得写代码这么高兴过。

详细的操作方法可以看一看仓库里的文件。

3.9.2 信号处理

经常使用命令行就知道,组合键 Ctrl+C 能够强制终止一个进程,但这个组合键不能终止终端本身。

这是因为组合键 Ctrl+C 会向操作系统发出一个中断 (SIGINT) 信号,在没有注册 signal_handler 的情况下,这个中断信号一般都会强制终止当前运行的程序。

所以,如果希望得到的 shell 能像正常终端一样屏蔽 Ctrl+C 信号,就需要在程序内将该信号屏蔽掉。这个过程很简单,在程序内任意一个地方注册一个 signal_handler 即可:

#include <signal.h> // 要求操作系统支持 POSIX 标准
int main()
{ 
   
  signal(SIGINT, SIG_IGN); // 屏蔽 `Ctrl+C`
  // ...
}

然后程序就可以无视 Ctrl+C 信号:

ignore_signal

当然通过键盘关闭程序是一个 CLI 程序必不可少的部分,即使 Ctrl+C 不可用,我们也需要提供另外一个接口用于关闭程序本身。

通常来说我们可以显式支持一个 exit 指令用于关闭程序,不过我们也可以使用文件尾标识符 EOF(因为 fgets 接收到 EOF 时会有副作用,所以可以设计为接收到 EOF 即关闭程序):

// 源码见 util/io_util.c
do { 
    // 持续获取输入,直到获取到非空输入
  if (prompt != NULL && dest[0] == NIL_CHAR)
    fputs(prompt, stdout); // 输出提示符
  if (fgets(dest, char_limit, infile) == NULL) { 
   
    puts("\nexit"); // 接收到 EOF,终止进程
    exit(receivedEOF);
  }
  trimmed = trim_str(dest, strlen(dest) + 1);
while (trimmed[0] == NIL_CHAR);

Linux 下输入 EOF 的方式是使用 Ctrl+D,Windows 则是 Ctrl+Z

EOF_test

4.总结

前面几部分基本上已经实现了我们这个简单的 shell 程序的主要功能,对于其他丰富功能其实只需要提供满足调用参数需求的函数,然后在对应的解释执行函数中添加相应的调用语句即可。

正如本文开头提及的,若希望提供更丰富的行编辑功能、命令历史回顾功能,或者是简化文中字符串处理部分的函数,都可以引入第三方库加以解决。(当然自己手搓轮子也不是不可以)

help_details

以上就是本程序中支持的所有命令。

4.1 Reference

本文的实现与撰写主要参考了以下博客:
myShell:Linux Shell 的简单实现

4.2 仓库

文中涉及的 Shell 程序源码文件已打包至 GitHub 仓库 中。

4.3 跨平台性声明

源码文件中存在平台依赖的代码集中在文件 src/mainProg.csrc/functional_func.csrc/shell_runtime.csrc/user_info.c 中,有兴趣的读者可以分析并修改这部分代码,使得编译得到的程序可以在 Linux 系统之外的环境运行。

如果目标操作系统环境明确支持 POSIX,那么实际上只有文件 src/functional_func.csrc/shell_runtime.c 中存在平台相关代码。这些代码分别来自头文件 sys/wait.hsys/stat.hsys/types.h


  1. 实际实现的 shell 并没有阻止两个以上的管道符出现,这应该被视为是一个 UB(undefined behavior)。 ↩︎

今天的文章设计一个shell程序,在/userdata_linux用什么软件写程序分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/82407.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注