编程珠玑番外篇-E. 高级语言怎么来的-2

虚拟机的前世今生

上节我们提到了 LISP 中, 因为 eval 的原因, 发展出了运行时环境这样一个概念。基于这个概念,日后发展出了虚拟机技术。但这段历史并不是平铺直叙的,实际上,这里面还经历了一个非常漫长而曲折的过程, 说起来也是非常有意思的。 这一节我们就着重解释虚拟机的历史。

我们 21 世纪的程序员,凡要是懂一点编程技术的,基本上都知道虚拟机字节码这样两个重要的概念。 所谓的字节码 (bytecode),是一种非常类似于机器码的指令格式。这种指令格式是以二进制字节为单位定义的(不会有一个指令只用到一个字节的前四位),所以叫做字节码。所谓的虚拟机,就是说不是一台真的计算机,而是一个环境,其他程序能在这个环境中运行,而不是在真的机器上运行。现在主流高级语言如 Java, Python, PHP, C#,编译后的代码都是以字节码的形式存在的, 这些字节码程序, 最后都是在虚拟机上运行的。

1. 虚拟机的安全性和跨平台性

虚拟机的好处大家都知道,最容易想到的是安全性和跨平台性。安全性是因为现在可执行程序被放在虚拟机环境中运行,虚拟机可以随时对程序的危险行为,比如缓冲区溢出,数组访问过界等等进行控制。跨平台性是因为只要不同平台上都装上了支持同一个字节码标准的虚拟机,程序就可以在不同的平台上不加修改而运行,因为虚拟机架构在各种不同的平台之上,用虚拟机把下层平台间的差异性给抹平了。我们最熟悉的例子就是 Java 了。Java 语言号称 一次编写,到处运行(Write Once, Run Anywhere),就是因为各个平台上的 Java 虚拟机都统一支持 Java 字节码,所以用户感觉不到虚拟机下层平台的差异。

虚拟机是个好东西,但是它的出现,不是完全由安全性和跨平台性驱使的。

2. 跨平台需求的出现

我们知道,在计算机还是锁在机房里面的昂贵的庞然大物的时候,系统软件都是硬件厂商附送的东西(是比尔盖茨这一代人的出现,才有了和硬件产业分庭抗礼的软件产业),一个系统程序员可能一辈子只和一个产品线的计算机打交道,压根没有跨平台的需求。应用程序员更加不要说了,因为计算机很稀有,写程序都是为某一台计算机专门写的,所以一段时间可能只和一台庞然大物打交道,更加不要说什么跨平台了。 真的有跨平台需求,是从微型计算机开始真的普及开始的。因为只有计算机普及了,各种平台都被广泛采用了,相互又不互相兼容软件,才会有软件跨平台的需求。微机普及的历史,比 PC 普及的历史要早10年,而这段历史,正好和 UNIX 发展史是并行重叠的。

熟悉 UNIX 发展史的读者都知道, UNIX 真正普及开来,是因为其全部都用 C,一个当时绝对能够称为跨平台的语言重写了一次。又因为美国大学和科研机构之间的开源共享文化,C 版本的 UNIX 出生没多久,就迅速从原始的 PDP-11 实现,移植到了 DEC,Intel 等平台上,产生了无数衍生版本。随着跨平台的 UNIX 的普及, 微型计算机也更多的普及开来,因为只需要掌握基本的 UNIX 知识,就可以顺利操作微型计算机了。所以,微机和 UNIX 这两样东西都在 1970年 到 1980 年在美国政府,大学,科研机构,公司,金融机构等各种信息化前沿部门间真正的普及开来了。这些历史都是人所共知耳熟能详的。

既然 UNIX 是跨平台的,那么,UNIX 上的语言也应当是跨平台的 (注: 本节所有的故事都和 Windows 无关,因为 Windows 本身就不是一个跨平台的操作系统)。UNIX 上的主打语言 C 的跨平台性,一般是以各平台厂商提供编译器的方式实现的,而最终编译生成的可执行程序,其实不是跨平台的。所以,跨平台是源代码级别的跨平台,而不是可执行程序层面的。 而除了标准了 C 语言外,UNIX 上有一派生机勃勃的跨平台语言,就是脚本语言。(注:脚本语言和普通的编程语言相比,在能完成的任务上并没有什么的巨大差异。脚本语言往往是针对特定类型的问题提出的,语法更加简单,功能更加高层,常常几百行C语言要做的事情,几行简单的脚本就能完成

3. 解释和执行

脚本语言美妙的地方在于,它们的源代码本身就是可执行程序,所以在两个层面上都是跨平台的。不难看出,脚本语言既要能被直接执行,又要跨平台的话,就必然要有一个“东西”,横亘在语言源代码和平台之间,往上,在源代码层面,分析源代码的语法,结构和逻辑,也就是所谓的“解释”;往下,要隐藏平台差异,使得源代码中的逻辑,能在具体的平台上以正确的方式执行,也就是所谓的“执行”。

虽说我们知道一定要这么一个东西,能够对上“解释”,对下“执行”,但是 “解释” 和 “执行” 两个模块毕竟是相互独立的,因此就很自然的会出现两个流派:把解释和执行设计到一起把解释和执行单独分开来 这样两个设计思路,需要读者注意的是,现在这两个都是跨平台的,安全的设计,而在后者中字节码作为了解释和执行之间的沟通桥梁,前者并没有字节码作为桥梁。

4. 解释和执行在一起的方案

我们先说前者,前者的优点是设计简单,不需要搞什么字节码规范,所以 UNIX 上早期的脚本语言,都是采用前者的设计方法。 我们以 UNIX 上大名鼎鼎的 AWK 和 Perl 两个脚本语言的解释器为例说明。 AWK 和 Perl 都是 UNIX 上极为常用的,图灵完全的语言,其中 AWK, 在任何 UNIX 系统中都是作为标准配置的,甚至入选 IEEE POSIX 标准,是入选 IEEE POSIX 卢浮宫的唯一同类语言品牌,其地位绝对不是 UNIX 下其他脚本语言能够比的。这两个语言是怎么实现解释和运行的呢? 我从 AWK 的标准实现中摘一段代码您一看就清楚了:

int main(int argc, char *argv[]) {
  ...
  syminit();
  compile_time = 1;
  yyparse();
  ...
    if (errorflag == 0) {
      compile_time = 0;
      run(winner);
    }
  ...
}

其中, run 的原型是
run(Node *a)   /* execution of parse tree starts here */

winner 的定义是:
Node    *winner ;    /* root of parse tree */

熟悉 Yacc 的读者应该能够立即看出, AWK 调用了 Yacc 解析源代码,生成了一棵语法树。按照 winner 的定义, winner 是这棵语法树的根节点。 在“解释”没有任何错误之后,AWK 就转入了“执行” (compile_time 变成了 0),将 run 作用到这棵语法树的根节点上。 不难想像,这个 run 函数的逻辑是递归的(事实上也是),在语法树上,从根依次往下,执行每个节点的子节点,然后收集结果。是的,这就是整个 AWK 的基本逻辑: 对于一段源代码, 先用解释器(这里awk 用了 Yacc 解释器),生成一棵语法树,然后,从树的根节点开始,往下用 run 这个函数,遇山开山,遇水搭桥,一路递归下去,最后把整个语法树遍历完,程序就执行完毕了。(这里附送一个小八卦,抽象语法树这个概念是 LISP 先提出的,因为 LISP 是最早像 AWK 这样做的,LISP 实在是属于开天辟地的作品!)Perl 的源代码也是类似的逻辑解释执行的,我就不一一举例了。

5. 三大缺点

现在我们看看这个方法的优缺点。 优点是显而易见的,因为通过抽象语法树在两个模块之间通信,避免了设计复杂的字节码规范,设计简单。但是缺点也非常明显。最核心的缺点就是性能差,需要资源多,具体来说,就是如下三个缺点。

缺点1因为解释和运行放在了一起,每次运行都需要经过解释这个过程。假如我们有一个脚本,写好了就不修改了,只需要重复的运行,那么在一般应用下尚可以忍受每次零点几秒的重复冗余的解释过程,在高性能的场合就不能适用了。

缺点2因为运行是采用递归的方式的,效率会比较低。 我们都知道,因为递归涉及到栈操作和状态保存和恢复等,代价通常比较高,所以能不用递归就不用递归。在高性能的场合使用递归去执行语法树,不值得。

缺点3,因为一切程序的起点都是源代码,而抽象语法树不能作为通用的结构在机器之间互传,所以不得不在所有的机器上都布置一个解释+运行的模块。 在资源充裕的系统上布置一个这样的系统没什么,可在资源受限的系统上就要慎重了,比如嵌入式系统上。 鉴于有些语言本身语法结构复杂,布置一个解释模块的代价是非常高昂的。本来一个递归执行模块就很吃资源了,再加一个解释器,嵌入式系统就没法做了。所以,这种设计在嵌入式系统上是行不通的。

当然,还有一些其他的小缺点,比如有程序员不喜欢开放源代码,但这种设计中,一切都从源代码开始,要发布可执行程序,就等于发布源代码,所以不愿意公布源代码的商业公司很不喜欢这些语言等等。但是上面的三个缺点,是最致命的,这三个缺点,决定了有些场合,就是不能用这种设计。

6. 分开解释和执行

前面的三个主要缺点,恰好全部被第二个设计所克服了。在第二种设计中, 我们可以只解释一次语法结构,生成一个结构更加简单紧凑的字节码文件。这样,以后每次要运行脚本的时候, 只需要把字节码文件送给一个简单的解释字节码的模块就行了。因为字节码比源程序要简单多了,所以解释字节码的模块比原来解释源程序的模块要小很多;同时,脱离了语法树,我们完全可以用更加高性能的方式设计运行时,避免递归遍历语法树这种低效的执行方式;同时,在嵌入式系统上,我们可以只部署运行时,不部署编译器。 这三个解决方案,预示了在运行次数远大于编译次数的场合,或在性能要求高的场合,或在嵌入式系统里,想要跨平台和安全性,就非得用第二种设计,也就是字节码+虚拟机的设计。

讲到了这里,相信对 Java, 对 PHP 或者对 Tcl 历史稍微了解的读者都会一拍脑袋顿悟了: 原来这些牛逼的虚拟机都不是天才拍脑袋想出来的,而是被需求和现实给召唤出来的啊!

我们先以 Java 为例,说说在嵌入式场合的应用。Java 语言原本叫 Oak 语言,最初不是为桌面和服务器应用开发的,而是为机顶盒开发的。SUN 最初开发 Java 的唯一目的,就是为了参加机顶盒项目的竞标。嵌入式系统的资源受限程度不必细说了,自然不会允许上面放一个解释器和一个运行时。所以,不管Java 语言如何,Java 虚拟机设计得直白无比,简单无比,手机上,智能卡上都能放上一个 Java 运行时(当然是精简版本的)。 这就是字节码和虚拟机的威力了。

SUN 无心插柳,等到互联网兴起的时候, Java 正好对绘图支持非常好,在 Flash 一统江湖之前,凭借跨平台性能,以 Applet 的名义一举走红。然后,又因为这种设计先天性的能克服性能问题,在性能上大作文章,凭借 JIT 技术,充分发挥上面说到的优点2,再加上安全性,一举拿下了企业服务器市场的半壁江山,这都是后话了。

再说 PHP。PHP 的历史就包含了从第一种设计转化到第二种设计以用来优化运行时性能的历史。 PHP 是一般用来生成服务器网页的脚本语言。一个大站点上的PHP脚本, 一旦写好了,每天能访问千百万次,所以,如果全靠每次都解释,每次都递归执行,性能上是必然要打折扣的。 所以,从 1999年的 PHP4 开始, Zend 引擎就横空出世,专门管加速解释后的 PHP 脚本, 而对应的 PHP 解释引擎,就开始将 PHP 解释成字节码,以支持这种一次解释,多次运行的框架。 在此之前, PHP 和 Perl, 还有 cgi, 还算平分秋色的样子,基本上服务器上三类网页的数量都差不多,三者语法也很类似,但是到了 PHP4 出现之后,其他两个基于第一种设计方案的页面就慢慢消逝了, 全部让位给 PHP。 你读的我的这个 Wordpress 博客,也是基于 PHP 技术的,底层也是 Zend 引擎的。 著名的 LAMP 里面的那个 P, 原始上也是 PHP,而这个词真的火起来,也是 99年 PHP4 出现之后的事情。

第二种设计的优点正好满足了实际需求的事情,其实不胜枚举。比如说 在 Lua 和 Tcl 等宿主语言上也都表现的淋漓尽致。像这样的小型语言,本来就是让运行时为了嵌入其他语言的,所以运行时越小越好,自然的,就走了和嵌入式系统一样的设计道路。

7. 结语

其实第二种设计也不是铁板一块,里面也有很多流派,各派有很多优缺点,也有很多细致的考量,下一节,如果不出意外,我将介绍我最喜欢的一个内容: 下一代虚拟机:寄存器还是栈。

说了这么多,最后就是一句话,有时候我们看上去觉得一种设计好像是天外飞仙,横空出世,其实其后都有现实,需求等等的诸多考量。虚拟机技术就是这样,在各种需求的引导下,逐渐的演化成了现在的样子。

17 Comments »

  1. 美金 said,

    June 13, 2009 @ 9:26 pm

    “SUN 无心插柳,等到互联网兴起的时候, Java 正好对绘图支持非常好,在 Flash 一统江湖之前,凭借跨平台性能,以 Applet 的名义一举走红。然后,又因为这种设计先天性的能克服性能问题,在性能上大作文章,凭借 JIT 技术,充分发挥上面说到的优点2,再加上安全性,一举拿下了企业服务器市场的半壁江山,这都是后话了。”
    SUN的兴起是由于其sparc CPU和Solaris OS的性能,而JAVA在其占领企业级服务器市场起到的作用微乎其微。

  2. Eric said,

    June 13, 2009 @ 10:46 pm

    美金老师,可能我说的不清楚,我说的是 Java 的兴起。SUN 的兴起的确是你说的,企业级服务器的优势。

  3. partita said,

    June 13, 2009 @ 11:27 pm

    “Windows 本身就不是一个跨平台的操作系统”

    其实不是这样的,NT在最初设计的时候由Dave Cutler操刀设计的硬件抽象层HAL,可移植性相当好。NT本身也出过MIPS/Alpha/PPC的版本,连Xbox 360据说用的都是修改自Windows 2000的内核。

  4. partita said,

    June 13, 2009 @ 11:31 pm

    另外,字节码没有什么神秘的,无非就是一种体积比较小、类似机器码但是与机器无关的二进制码。字节码用栈结构也主要是为了JIT和低端硬件执行的方便。

  5. iamxhu said,

    June 14, 2009 @ 12:54 am

    好文!
    虽然对于虚拟机相关的一些知识点都有所了解,但是这篇文章讲他们串起来后,发现对这些东西的理解更加深刻了。

    期待下一篇

  6. dogstar said,

    June 14, 2009 @ 2:08 am

    基于栈的机器模型,可以设计出更小的指令集,更容易优化.forth体现的淋漓尽致.

  7. bones7456 said,

    June 14, 2009 @ 4:12 am

    才看了一半,就忍不住先留言了,写得很好,太赞了~

  8. James Ge said,

    June 15, 2009 @ 9:03 am

    几点建议:
    1. Java的“Write once, run anywhere“更像是广告词,而不是事实,“Write Once, Debug Everywhere“更确切,世界的复杂性是超越人类形式化的能力的。

    2. “语法树”只是指计算机的存储方式是树(题外话:树是最容易映射到线性内存的层次化结构),如果编译时,我们就拥有了一棵从根到叶子看的一清二楚的树,何来停机问题?,问题就在于那棵“树”里有些节点扮演着指向其他节点的角色,典型如命令式语言中的变量,“语法图”才是更合适的说法,执行也不是“遍历”,而是不断地重写和规约。

    3.几乎所有语言都有中间语言的,而不仅仅是那些“高级“语言,三个主要原因:1.代码在内存中,而指令的操作数大都是寄存器,如果直接编译到机器码,需要考虑从内存读入寄存器,从寄存器写会内存,这种无法分层的架构大概谁都写不下去。2. 不是所有的语言都可以直接映射到机器码的,这个比较复杂,主要在于一些过程不可逆,比如Haskell的Grin中间语言。“虚拟机”,“字节码”这类工业技术是不会像吹的那样“革命性”的,大都是顺水推舟。3. 如果一门语言支持REPL(Read-eval-print loop),调试和学习会方便很多,有个中间语言实现这个会比较便捷。

    4. “下一代虚拟机:寄存器还是栈“的标题有点大,这似乎是上个世纪的技术了… 一方面C#和Java的JIT虚拟机和脚本语言的是不一样的,它们先把部分或全部字节码转成机器码再执行。另一方面,有些脚本语言因为一些独特的语言特性,做这个会得不偿失,典型如Python;有些是没有对速度的强烈需求或技术。一般来说只有实验性的语言才会用基于栈的虚拟机,因为简单一点。

  9. Eric said,

    June 15, 2009 @ 9:22 am

    回答James 老师:
    1. 的确是广告词。 Write Once, Debug Everywhere 其实也是戏言,一般情况下 Java 的跨平台能力还是很好的,我有不少经验。
    2. 语法树不是我发明的名词,乃是 abstract syntax tree 的翻译,此条意见可转发麦卡锡老大。 另外,树的节点并不是非常基本的节点,比如 FOR 循环,就是一个 FOR 树,下面带一个循环体,所以即使是语法树,也不是你想的“看得一清二楚”,停机问题一样有。
    3. 从编译角度说完全同意。从脚本语言角度说,不见得,比如 BASIC, 中间语言是?
    4. 写完您看就是了。 脚本语言在 CLR 和 JVM 上的实现很多的,IronPython, JRuby, Groovy, 都是。你说的是 JIT, 我想 JIT 并不是基于栈的虚拟机的本质。JPython 在某些方面的的速度不见得比 CPython 慢,具体可参考 JPython 作者的文章。 您说“一般来说只有实验性的语言才会用基于栈的虚拟机”应当是不准确的,C# 和 Java 无论如何也不能使 实验性的语言吧。(对了,您知道下一代基于寄存器的 LLVM 和 Parrot 虚拟机和下一代 JVM 的设计么,如果您知道下一代虚拟机的设计,相信就不会说是“上世纪的技术”了)

  10. tdus said,

    June 17, 2009 @ 11:55 pm

    看得很爽,谢谢~!

  11. Ronnie said,

    June 20, 2009 @ 3:10 am

    好文章,学到不少东西

  12. tomheng said,

    June 21, 2009 @ 2:04 am

    需求驱动设计和开发!

  13. ringtail said,

    June 30, 2009 @ 11:22 pm

    “有时候我们看上去觉得一种设计好像是天外飞仙,横空出世,其实其后都有现实,需求等等的诸多考量。虚拟机技术就是这样,在各种需求的引导下,逐渐的演化成了现在的样子。”看到这段话的时候,忽然想起钱穆先生《中国历代政治得失》里的话,大意是任何一个制度都不是凭空出世的,而是由当时的人事所决定的。看来很多东西从大道理上都是差不多的,计算机科学与人文科学同样可以触类旁通。

  14. tdus said,

    July 1, 2009 @ 3:37 am

    续集还没出?

  15. Ai.Freedom said,

    July 3, 2009 @ 7:58 am

    中文斜体的显示太虚了, 我一般用楷体之类的代替了.

  16. Robin said,

    August 5, 2009 @ 10:18 pm

    How to advance PR value for my site?

    http://www.chengduedu.com

    it is 0 now….

  17. iamxhu said,

    August 6, 2009 @ 12:58 am

    关于Java语言的跨平台性我也有过很多经验的。
    目前在公司主要是在Windows下开发java程序,这些编译好的jar文件被部署到AIX,Solaris,HP-UX,Linux等平台上,好像没有发现遇到平台相关的问题。