Unix考古记:一个“遗失”的shell

谨以此文记念伟大的计算机科学巨匠Ken ThompsonDennis Ritchie,并同时向其余全部为Unix发展作出贡献的黑客致敬。html

历史的尘埃

Unix做为一个闻名中外的操做系统已有40余年的历史,围绕着这个古老的操做系统的发展又衍生出了一系列外围软件生态群,其中一个很是重要的组件就是shell。它是操做系统最外层的接口,负责直接面向用户交互并提供内核服务,包括命令行接口(CLI)或图形界面接口(GUI)两种形式。以CLI为例,它提供一套命令规范,是一种解释性语言,将用户输入通过解释器(interpreter)输出使其转化成真正的系统调用,实现人机交互的功能。前端

和操做系统同样,shell也经历了一个漫长的演变史。现在大部分资料讲述最古老的shell都是从1977年的Bourne Shell提及的,它最初移植到Unix V7上,被追认整个shell家族成员的鼻祖,后来的种群都是从其身上分支出来的。linux

Linux shells since 1977

对于1977年以前的历史不少资料大多一笔带过或略过不提。事实上,第一个移植到Unix上的shell却不是Steve Bourne写的,早在1975年5月,贝尔实验室就对外发布了第一个普遍传播的Unix版本——Unix V6(以前开发的版本只供内部研究之用),其根目录下的/bin/sh是第一个Unix自带的shell,由Ken Thompson写的,所以也被称为Thompson Shell。甚至,更早能够追溯到1971年的时候,Thompson Shell就做为一个独立于内核的应用程序而实现了,只不过从1975年正式问世到1977年被取代,短短两年的寿命使得它不多为大多数人所认识。程序员

关于Thompson Shell被取代的缘由在后文中会给出说明,这里着重介绍一下该shell自己的一些技术细节。坦白讲,关于Thompson Shell的资料有点稀缺,但至少还能从网上找到源代码在线文档。Thompson Shell自己是由一个不足900行代码的解释器和一些外部命令工具组件(utilities)构成,用K&R C写成,下面给出各个组件的相关源码和文档连接。正则表达式

  • 解释器sh:解析各类shell命令,包括内置命令和外部命令;源码sh.c;安装路径/bin/sh;手册sh(1)

下面是外部命令:算法

  • exit命令:退出一个文件;源码exit.c;安装路径/bin/exit;手册exit(1)
  • goto命令:在一个文件内跳转shell控制流程;源码goto.c;安装路径/bin/goto;手册goto(1)
  • if命令:条件判断表达式,是test命令的前身;源码if.c;安装路径/bin/if), 手册if(1)
  • glob命令:扩展命令参数通配符;源码glob.c;安装路径/etc/glob;手册glob(8)

命令结构和规范

尽管后来遭“埋汰”,Thompson Shell仍有着不容否定的历史地位,其最大的价值在于它奠基了shell命令语言结构和规范的基础,并且其解释器具备跨平台的可移植性,并影响到了后来包括Bourne Shell在内的各类脚本语言设计实现。下面咱们就以其中5个特性重温一些你们已经耳熟能详的命令规范,你也能够经过sh(1)手册查看原始资料。shell

  • 过滤器/管道线(filter/pipeline)。这绝对是要载入Unix史册的发明,创立者是Douglas McIlroy,Thompson Shell引入并实现了这个伟大的概念——一个或多个命令组成一根过滤器的链条,由’|’或’^’符号分隔。除最后一个命令以外,每一个命令的标准输出都被做为下一个命令的标准输入。这样每一个命令都做为一个独立的进程来运行,并经过管道与邻近的进程相链接。圆括弧内的命令序列总体上能够替代单个命令做为过滤器实现,好比用户能够输入”(A;B)|C”。
  • 命令序列和后台进程。分号’;’指示多个命令序列化执行。’&’符号指示该命令在后台异步执行,使得前面的管道线没必要等待其终止,仅仅报告一个进程id,这样用户之后能够经过kill命令与它通讯。有益于进程管理。
  • I/O重定向。它利用了Unix设计上的一个重要特性——一切皆文件,用三个符号表示:”重定向输出,若是文件不存在则建立它,若是文件存在则截断它;’>>’追加模式重定向输出,若是文件不存在则建立它,若是文件存在则追加输出至末尾处。
  • 通配符扩展(globbing)。通配符的概念源自于正则表达式,使得解释器智能地处理用户不彻底输入,好比记不清文件名、一次性输入多个文件等。’?’匹配任意单一字符;’*’匹配任意字符串(包括空串);成对'[‘和’]’定义了字符集合一个类,可匹配方括号内任意成员,用’-‘两端可指定一系列连续字符匹配范围。
  • 参数传递。这里主要引入了位置参数和选项参数的概念:’$n’指示shell调用的第n个参数替代;还定义了两个选项参数’-t’和’-c’,前者用于交互,致使shell从标准输入中读入一行做为用户执行的系统命令,后者指示shell将附带的下一个参数做为命令执行(可正确处理换行符),是对’-t’的补充,特别是调用者已经读取了命令其中某些字符的状况下。若是不带选项参数则直接读取文件名

解释器的原理与实现

接下来立刻要进入核心部分了,为了搞懂shell解释器原理,咱们要对其整个工做流程作个描述(这里给出一份带注解的sh.c源码剖析)。读过《编译原理》的同窗知道,解释器的实现跟编译器差很少,只不过省略了生成目标代码这一步,直接将用户输入(shell命令)转化成输出(系统调用)。软件前端是一致的,包括预处理、词法扫描、语法分析和语义分析,最后还要附加一个进程管理。固然相较于现代编译器,Thompson Shell解释器在算法和规模上都要简单得多,不过原理上是相通的,况且年代上要比Lex & Yacc还要早。麻雀虽小,五脏俱全,对于初学者来讲,从Thompson Shell去入手编译原理或许不失为一种好选择。express

预处理(preprocessor)

同C预处理器须要事先将源代码中包含的宏和头文件展开同样,Thompson Shell首先须要处理命令中的选项参数位置参数。选项参数有两种’-t’和’-c’,决定了shell从标准输入仍是参数缓存中读取字符(见sh(1))。此外字符序列中还要处理反斜杠’\’,判断是转义字符仍是行接续符,前者对下一个字符设置引用标识,代表作普通字符处理,后者将紧邻其后换行符过滤掉。编程

位置参数是美圆符号’$’打头的,后带一个数字,如’$n’,预处理器对shell命令参数从头开始计数,返回数字n指定的参数位置。若是赶上double’$$’,则表示当前的进程标识,调用getpid()获取。数组

注意到预处理器须要一次读取多个字符,这样就会多读一个没必要要的字符。对此解释器提供了一种预读(peek)方式,即每次从输入流读取一个字符时,放入一个预读缓存里(只有一个int大小的堆栈),也叫回退(push back)。此后先从预读缓存中读取,若是缓存被读完,则从输入流中读取。

词法扫描(lexical scanning)

通过预处理后的字符序列将被切割成为一系列词法记号(token),安置在token列表中,扫描器将对如下几类字符作以下处理。

  • 空格和tab:简单过滤。
  • 引号:须要成对出现,字符自己被过滤,一对引号之间全部字符都被设置引用标识,做为一个token。
  • 元字符:如’&’,’|’等,字符自己做为一个单独token。
  • 其余字符:一概填充token,直到碰上以上字符分隔为止。

举一个例子,当咱们输入命令”(ls; cat tail) >junk”,那么token列表映像将是这样的:

语法分析(syntax parser)

语法分析就是将token列表中的元素做为表达式(expression)并以节点为单位构建语法树,简单命令是一个表达式,而复合命令以及命令序列是多个表达式的组合。Thompson Shell中以简单数组做为语法树的容器,实际上这是结构体的一种变形,只不过每一个成员字段大小都同样(都是sizeof int)而已。一个语法树节点最多有6个字段(大小根据类型可变),分别是

  • DTYP(节点类型):每一个节点都有惟一的类型,又分为四种——TCOM(简单命令)、TPAR(复合命令)、TFIL(过滤器/管道线)、TLST(命令序列)。
  • DLEF(左子树节点):至关于链表指针,根据DTYP定义有所不一样。如过滤器类型左子树节点为前一个命令的输出重定向文件,右子树节点为后一个命令的输入重定向文件。
  • DRIG(右子树节点):同上。
  • DFLG(节点属性):这是个标志位(flag),决定该节点包含命令的属性以及以什么样的状态执行。
  • DSPR(子命令):两重含义,对于简单命令,该字段为空;对于复合命令,该字段指向子语法树节点。
  • DCOM(命令字符):引用命令字符序列。

语法树节点生成顺序根据token列表中每一个元素的优先级(priority)而定,首先遍历整个列表,找到优先级最高的token做为根节点,再分别生成左右子树,这是一种最简单的自顶向下(top-down)解决方案。各个token优先级视DTYP字段而定

优先级

Token

DTYP

第一级

‘&’  ‘;’  ‘\n’

TLST

第二级

‘|’  ‘^’

TFIL

第三级

 ‘(‘  ‘)’

TPAR

第四级

其它字符

TCOM

语法树的构建过程当中还使用了一种基于“有限状态机(finite-state machine)”的动态规划算法,其实现是将整个逻辑流程划分为四个状态:syntax、syn一、syn二、syn3,对应于上面token优先级,程序在每一个状态下都生成一个相应类型的节点,同时还生成四种策略,以决议下一步将转移到何种状态(根据优先级搜索对应的token)。这个四种策略分别是

  • 生成左子树:左边token列表递进到下层状态。
  • 生成右子树:右边token列表并回溯到上层状态或递归调用。
  • 找不到对应token:保持原有token列表递进到下层状态。
  • 生成节点:直接返回节点。

当咱们遍历完整个token列表后,程序老是能返回最初的调用点,即根节点上,从而生成一棵完整的语法树。这种算法的好处是程序员没必要关注具体实现的每一个细枝末节,只要关注相应的状态并制定对应的转移策略便可。还值得一提的是每一个转移策略都是发生在赋值语句或返回语句上,并使用函数实参保存临时变量,这样就避免了调用次数过多致使堆栈溢出。

依旧举两个个例子,好比命令”A & ; B | C”对应的语法树

命令”(A ; B) | C”对应的语法树:

语义分析(Semantic Analyzer)

语法分析仅仅停留在token表达式合法性层面上,它并不知道该表达式是否有意义,好比哪些命令是要后台运行,哪些命令的I/O被重定向到管道线上,通配符该如何扩展等等,这时候要靠语义分析了。这里的“语义”体如今对特殊字符的动态处理以及语法树节点的字段设置,根据上下文(context)而定。好比对于元字符’>’,咱们要判断输出重定向到哪一个文件,是截断仍是追加。对于通配符’?’、’*’和'[…]’,咱们要决定对哪些字符进行扩展,这些在/etc/glob中专门处理。对于语法树节点,除了自身固有属性以外,还须要继承上层节点的属性,以及下推属性到下层子树节点,下面列了一张表格说明。

DTYP

DLEF/DRIG

DFLG

DSPR

TLST

能够为空,也能够是其它节点,类型能够是TLST/TFIL/TCOM 自身属性为0;若是带’&’,则下推属性FINT|FAND|FPRS到左右子树(忽略信号、后台异步,打印pid)

TFIL

必须同时存在、,类型只能是TCOM或TPAR 自身属性继承自上层TLST;下推FPIN到左子树节点;下推FPOU到右子树节点。

TPAR

继承上层的TLST和TFIL;若是是追加模式重定向输出,加上FCAT;若是是复合命令中最后一个子命令,加上FPAR, 将不会fork子进程。 子命令

TCOM

左子树节点为输入重定向文件,右子树为节点输出重定向文件。

执行命令(Executor)

当前面一系列步骤以后,若是错误计数为0,则解释器从语法树的根节点开始,深度优先遍历全部节点,并根据前面语法和语义分析获得的类型和属性,一一执行所包含的命令,以生成最后的系统调用。

对于命令序列(TLST)节点,从左至右顺序执行子树节点命令。

对于过滤器(TFIL)节点,建立管道文件句柄,做为左右子树的重定向文件。

对于简单命令(TCOM)和复合命令(TPAR)节点,首先筛选出系统内置命令(built-in),对于剩下的外部命令则fork一个子进程执行它。若是是复合命令中最后一个子命令,那么仍在原来的进程上执行而没必要建立新进程。可执行文件路径按前后顺序搜索:①本地路径;②/bin;③/usr/bin。

多进程环境下,特别要注意文件句柄管理。命令间共享标准输入输出设备以外,还会重定向到管道线,而父进程在fork以后子进程会获取一份文件句柄拷贝,因此父进程必须在fork以后当即关闭闲置的管道线句柄(若是有的话)以避免形成资源泄漏,子进程也将在重定向以后关闭管道线句柄。

对于后台命令须要打印pid,但不须要响应中断信号,父进程也没必要等待子进程终止。其他进程命令执行中可捕获中断信号,并转入相应的处理函数。

解释器用内置的errno全局变量保存进程终止状态,并生成终止报告(termination report),系统调用wait()用于返回终止进程的pid并输出报告消息索引。

孰优孰劣

尽管Thompson Shell是一款优秀的命令解释器,还产生了多项历史创举,但遗憾的是依然得不到命运女神的垂青,这要归咎于其自身的缺陷——功能单1、命令分散、控制流过于简单,尚没法用来编写脚本(script)。随着Unix日益壮大,它已经没法应付趋于繁杂的编程项目了。那时还出现了一个叫John Mashey的人写的PWB Shell(又叫作Mashey Shell),基于Thompson Shell作了些改进,扩展了命令集,增长了shell变量,还增长了if-then-else-endif,for,while等控制逻辑。不幸的是它比Thompson Shell更短命,由于1977年它赶上了一个强劲的对手。

没错,那就是Bourne Shell,它的主要优势是真正实现告终构化脚本编程,比以前的shell实现得都要好,更要命的是它与前两个shell都不兼容,因而一场标准化的论战开始了。在David G. Kornksh做者)写的“ksh – An Extensible High Level Language”一文中说起,Steve Bourne和John Mashey在三次连续的Unix用户组集会上争论他们各自的理由。在这些集会之间,各自增进他们的shell来拥有对方的功能。还设立了一个委员会来选择标准shell,最终仍是选择了Bourne shell做为标准。

因而从Unix V7开始就有了前面所说的”Bourne Shell Family”。然而历史上没有完美的技术,随着8、九十年代操做系统迅猛发展,针对Bourne Shell的诟病也愈来愈多了。在解释器自己实现上,我看到网上一个对其评价是“universally considered to be one of the most horrible C code ever written”,至于缘由去看一下mac.h就知道了,包括基本运算符、关键字在内的大量宏定义使得整个代码看上去简直不是C写的,也许Bourne是想把解释器打形成本身独特的风格吧,也难怪后来的bash以“born again”命名就是对其祖先的戏谑性调侃。另外内存管理上的一些毛病带来平台可移植性问题,至于其中的技术细节有点高级,超出本文范畴。

Thompson Again Shell?

虽然历史没有给Thompson Shell一个机会,但它并不是就此同Unix V6那样一同沦为开源博物馆上的古老“化石”。做为出自顶级黑客之手的做品,做为伴随Unix那样伟大操做系统一同曾经流行计算机的产物,至今仍受国内外程序员的缅怀,或将其改写,或为其做注。好比国外一个站点v6shell.org上就实现了一个免费开源的可移植性shell,它兼容并扩充原来的Thompson Shell而且可用来作脚本编程。再好比中国程序员寒蝉退士在其我的博客上发布了一个注解版,并对原版作了一些改写,主要是将K&R C转为ANSI C,而且符合POSIX规范,使本来晦涩难懂的源码变得清晰易读起来。正是由于接触到他的版本激起了我对老Unix的考古兴趣,才有了这篇“考古笔记”。我在想不知从此会不会像bash那样,出一个tash来呢?

一些感想

原本全文应该就此结束了,但此时此刻不由想多说几句。这篇笔记当初并不是有意而为之,在hacking源码的过程当中感想积累多了也就逐渐成章了。看代码、做注解、查资料、写此文,先后历经四个多礼拜,是在繁杂的工做中“挤乳沟”挤出来的零散时间片拼凑起来的,虽然文字不长但也算耗费了一番心血,酸甜苦辣心中自明,体会到踏上社会以后潜下心作研究之艰难。现在面对这样一份不到900行写成的,没有一行多余的代码,简洁(clarity)、干净(clean)、快速(fast),这就是Pure C的魅力,我深为这种厚重的编程功力所折服,正所谓“大道至简”吧。虽然要彻底弄懂它须要不少时间,但我相信这种代价倒是值得的。

最后再八卦一下,2011年Dennis Ritchie去世了,有人生前问过他“学C须要多久才能成为熟练开发者并写出重要产品代码?”,Ritchie回答“我不知道,我从没去学过C。”(I don’t know. I never had to learn C.)其实这里已经给出了答案——那就是没有比去阅读Unix源代码更好的选择了,某种意义上C语言就是为Unix而生的。

Dennis Mac Ritchie

参考资料

The Unix Heritage Society:Unix社区遗产,上面有v6和v7以及其它一些衍生版本的操做系统源代码。

The Traditional Bourne Shell Family:Bourne Shell家族简史。

v6shell:osh,一个基于Thompson Shell的开源可移植性old shell。

寒蝉退士的博客:Thompson Shell的一个注解版。

Evolution of shells in Linux:简述Linux Shell演变史。

附录一个中文注释的 shell源码

相关文章
相关标签/搜索