本篇博客主要从操作系统的角度来介绍shell以及如何实现一个简单的shell。
操作系统是用于提供用户态和内核态交互的一种特殊的程序,当用户态的进程想要访问内核服务时,必须要通过操作系统提供的系统调用接口,示意图如下:
在类unix中有一个特殊的程序 - shell,实际上shell并不属于内核,只是一个用户态的普通程序,但它的特殊性在于它是类unix系统中用户主要的交互接口,它对内核的系统调用进行了一层封装,从而为用户提供方便的命令行操作。
操作系统提供的服务有:进程、内存、文件描述符、管道、文件系统等,下面就从这几个方面来介绍shell是如何对其进行封装和实现的。
进程和内存
一个进程是由用户空间内存(指令、数据)和由内核管理的进程状态信息(PCB)。
在进程相关操作中, 最主要的系统调用有:fork, exit, wait, exec。
- fork: 创建一个子进程,该子进程与自身共享内存空间,一旦某一方要去修改内存,则会copy一份新的内存以供修改(即copy-on-write)
- exit: 使得当前进程停止执行,并释放内存、文件等资源
- wait: 一直等待,直到当前进程的一个子进程退出
- exec: 用一个从文件中加载得到的新内存镜像来代替当前进程
类unix中的shell就是使用上述系统调用来运行用户传入的指令:
- 在main函数中循环调用一个自定义的函数getcmd(),目的就是从命令行读取用户传入的输入
- 对于每条命令,通过fork创建子进程去处理命令——定义函数runcmd()来实现;而此时父进程通过wait等待子进程执行结束
- runcmd():通过exec加载实际的目标程序(如"echo hello")来替代了当前runcmd进程,当执行结束后目标程序(如"echo")会自己调用exit,从而退回至main函数的wait处
I/O和文件描述符
在unix中所有进程可以I/O操作的对象都可以当作“文件”,从而都会使用一个文件描述符来对其进行访问,包括打开文件、目录、设备、创建一个管道,即都可以看作字节流来访问。
shell默认会一直打开3个文件描述符:0(stdin), 1(stdout), 2(stderr)。文件描述符其实就是一个与访问对象绑定的整数,从0开始依次加1;新分配的文件描述符总是当前进程未被使用的最小描述符。
通过文件描述符和fork可以实现shell中的I/O重定向。fork出来的子进程会与父进程共享文件描述符表(也是在内存中),所以父进程打开了哪些文件,子进程就可以直接去访问那些文件;而exec虽然会替换掉当前进程空间,但是它会将文件描述符表保存下来。因此,shell可以通过“fork+打开重定向的文件+exec”来实现I/O重定向了。下面是 cat < input.txt 的例子,cat默认会从0描述符中读,往1描述符中写:
- close(0)关闭0描述符
- open打开需重定向的文件,返回当前可用的最小描述符,即0,因此0描述符便和input.txt文件绑定
- exec("cat",argv)执行cat命令,由于exec会将文件描述表保存下来,因此cat会从0描述符读取,就相当于从input.txt文件中读取,然后往1(stdout)描述符中写,从而实现了读取重定向
- 输出重定向也是类似的,通过close(1)和open(),将重定向文件与1描述符绑定
需要说明的是,fork会在父子进程间共享文件描述符表,但是一旦某一方对其进行修改了则会copy一份,然而文件偏移则是一直共享的,即一方写完后另一方再写则是连续下去写的,看下面一个例子:
输出结果:
除了fork是会共享文件偏移的,dup系统调用复制的一个新文件描述符也与原描述符共享文件偏移(dup返回的描述符也是当前可用的最小描述符):
输出结果也是:hello world
管道Pipes
管道是一段内核缓冲区,有一对文件描述符对其进行操作,即一个写一个读,可用于进程间通信。下面看一个例子:
- 通过pipe系统调用创建一个管道,并将其读写文件描述符保存在 数组p中(p[0]:read, p[1]:write)
- 通过fork创建子进程,父子进程共享p[0]和p[1]
- 子进程:通过dup将p[0]与0(stdin)绑定,之后执行/bin/wc程序时即会从p[0]描述符中读取输入
- 父进程:直接往p[1]中写,即此时管道中有数据了,然后子进程便会读取数据进行操作;当然父进程中也可以通过exec执行命令自动往p[1]中写入数据 —— 即通过close(1); dup(p[1]);将p[1]描述符与1(stdout)绑定
shell中的管道也可以像上面这么实现,如 ls | wc -l
- 创建1个管道
- 调用2次fork,创建2个子进程,分别调用runcmd()来处理管道左边和右边的命令
- 左边负责往管道写 — close(1); dup(p[1]);
- 右边负责从管道读 — close(0); dup(p[0]);
- 父进程调用2次wait,等待2个子进程结束再返回
文件系统
文件系统包括文件和目录,其中目录是一种树形的结构,根结点为root目录。
在文件系统中涉及到的系统调用有:
- chdir 切换目录,mkdir 创建目录
- mknod 创建设备文件 - mknod(设备文件名,主设备号, 次设备号)
- open/read/write 操作文件
- fstat 获取文件描述符指向文件对象的信息
- link 创建(硬)链接,即有多个文件名指向相同的索引节点inode,因此当把其中一个删除了,其他文件的内容仍然保留
- symlink 创建软链接文件,该文件指向新的inode,并记录了指向原文件inode的路径信息,当把原文件删除了,该链接文件的内容也不存在了
- unlink 删除一个文件并减少它的链接数,只有当这个文件对应的inode节点的链接数为0且没有指向它的文件描述符时,才会释放对应的磁盘空间
类unix可以将文件系统的操作实现为用户级别的程序(如: mkdir, ln, rm ...),而不是嵌入在shell中,因此在shell中只需要通过exec来加载对应的可执行文件(如: /bin/mkdir),即可实现用户想执行的命令行功能。但有一个例外:cd命令,因为shell执行命令是通过fork创建子进程来执行的,这样的话对于cd命令就只是子进程切换了目录,父进程并没有,从而用户输入下一条命令时还是从父进程处fork一份去执行,因此目录始终是原始目录。所以,cd命令需要在shell中实现(直接调用chdir syscall),而不是通过在一个用户程序中实现,然后在shell中调用exec("/bin/cd")来执行。
总结
上述的shell主要实现了单条命令执行、文件重定向、和管道这三种类型。实际的shell中还可支持括号包围的指令作为整体执行,命令间用分号分割等,这些更多的是功能层面上的,可以花样百出,但涉及到与操作系统的交互主要还是上述的三个基本功能。