4

我把世界上第一个 JS 引擎编译回了 JS

 3 years ago
source link: https://zhuanlan.zhihu.com/p/330586852
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

我把世界上第一个 JS 引擎编译回了 JS

雪碧 | github.com/doodlewind

1995 年,在我刚满周岁的时候,大洋彼岸有个叫 Brendan Eich 的人在十天内创造了一门今天我正以它谋生的编程语言,这就是 JavaScript。

这个快速创造 JavaScript 的故事在程序员群体中广为流传。但对于今天的人们来说,或许已经没有多少人记得(甚至体验过)最早的 JavaScript 是什么样的,更不要说阅读当年的 JS 引擎源码了。

不过在 2020 年,我们迎来了一个了解这段历史的契机。在研究编程语言历史的 HOPL-IV 学术会议上,由 Brendan Eich 和 ES6 首席作者 Allen Wirfs-Brock 联手撰写的《JavaScript 20 年》详细介绍了 JS 诞生和演化的历史。作为这本书的中文版译者,我逐个校订了超过原版中超过 600 条参考文献链接,其中正有一条指向了最早的 JS 引擎源码。这激发了我的好奇心——最早的 JS 引擎代码,今天还能不能编译运行?如果可以的话,能不能更进一步地把它编译回 JavaScript,让它在 Web 上复活呢?因此我进行了这次尝试。

最早的 JS 引擎名为 Mocha(这是 Netscape 内部的网页脚本语言项目代号),由 Brendan Eich 在 1995 年 5 月完成了首个原型。在 1995 年全年和 1996 年的大部分时间里,Eich 都是仅有的全职负责 JavaScript 引擎的开发者。直到 1996 年 8 月发布 Netscape 3.0 时,Mocha 的代码库主要包含的仍然是这个原型中的代码。随 Netscape 3.0 发布的 JS 版本被称为 JavaScript 1.1,这个版本标志着 JavaScript 的初始阶段开发工作得以完成。在此之后,Eich 又花了两周时间重写了 Mocha,获得了一个更强的引擎,这就是今天 Firefox 搭载的 SpiderMonkey。

如果你谷歌搜索「Netscape source code」,大概只能追溯到 1998 年 Mozilla 项目中的 SpiderMonkey 引擎代码。而真正的 Mocha 引擎源码,则位于网络上一份(来路不明的)Netscape 3.0.2 浏览器源码的压缩包中。但 Mocha 的源码早已在 Eich 重写 SpiderMonkey 后被彻底放弃,该怎样复活它呢?

其实想了解任何软件,其手段都无非「自顶向下」和「自底向上」两条路。前者从架构层面入手了解宏观知识,后者从代码层面入手解决微观问题。由于我已经比较熟悉对 QuickJS 等 JS 引擎的使用,因此这里我直接选择了自底向上的实践手段。其基本的理念很简单:渐进地编译出引擎的各个模块,最后把它组合在一起跑起来

原版 Mocha 采用 Makefile 作为构建系统,但它显然已经无法在今天的操作系统中正确工作了——那可是个 MacOS 还在使用 PPC 处理器的时代!但说到底,构建系统只不过是一个自动执行 gccclang 等编译器的辅助工具而已。而 C 语言项目的编译过程,概括说来也无非这么几件事:

  1. gcc -c 命令,逐个将「作为库被使用」的 .c 源码编译为 .o 格式的对象文件。这会把 C 源码中的每个函数都编译成二进制可执行文件中的所谓「符号」,就像是 ES Module 中 export 出来的函数那样。注意在这个时候,每个对象文件中都可以任意调用以 .h 形式引入的其他库的 API。此时编译不会出错,只会在对象文件中记录对外部符号的调用。
  2. ar 命令把这些 .o 对象文件制作成 .a 格式的静态库。这其实只相当于简单的文件拼接组装而已,获得的 .a 文件中会包含项目中所有的符号,类似于 cat *.js >> all.js 的效果。另外我们还可以制作更节约空间的动态库,但相对比较复杂,这里略过。
  3. gcc -l 命令编译出「调用这个库」的 .c 源码,这时编译器会将其产物与 .a 静态库相链接。链接器会把各个对象文件中形同「榫卯结构」式的符号依赖连接起来。这时对于第一步中的每个对象文件,其中所有调用外部 API 的符号都必须能被链接器找到,缺失任何一个符号都会导致链接失败——但只要链接成功,我们就最终获得了以 main 函数为入口的可执行文件。

因此,整个渐进的移植过程是这样的:

  1. 编译出每份 Mocha 内部的(即除了入口之外的).c 源码文件,获得包含其符号的 .o 格式对象文件。
  2. 将包含这些符号的 .o 对象文件拼接起来,打包出 .a 格式的静态库文件,即 libmocha.a
  3. 编译 Mocha 入口的 mo_shell.c 文件,将其与 libmocha.a 静态库相链接,获得最终的可执行文件。

在这个过程中,需要处理一些外部依赖,其中最典型的是对 prxxx.h 的依赖。这是 Netscape 当年开发的 Netscape Portable Runtime 跨平台标准库,其中实现了一些通用的宏定义与类型定义,以及 C 的哈希表、链表等基础数据结构,还有某些数学计算、时间转换等功能。NSPR 的源码也附带在了 Netscape 3 的源码中,但我并没有一次性把它们全部提交进新的移植版 Mocha 代码库。这里的处理方式是仅在遇到缺失的 NSPR 依赖时,才手动将涉及到的 NSPR 头文件和源码递归地引入,从而剥离出一份最小可用的 Mocha 代码树。

整个移植过程中涉及到的源码改动,主要包括这些:

  • 移除掉 prcpucfg.h,直接使用 x86 和 WASM 的小端字节序。
  • 修订 prtypes.h 中的类型定义,用 C99 标准中的 uint16_t 代替 unsigned short 等存在兼容问题的类型,类似的还有 Bool 类型。
  • 补充 MOCHAFILE 宏,强制令 Mocha 进入读取文件的命令行模式,而不是浏览器中所使用的嵌入模式。
  • 补充部分代码中缺失的 include 引用。

最后,我只用一个非常简单的 bash 脚本,就成功编译出了 Mocha 的全部模块。相信只要正经学过几天 C 语言就能搞明白:

function compile_objs() {
    echo "compiling OBJS..."
    $CC -Iinclude src/mo_array.c -c -o out/mo_array.o
    $CC -Iinclude src/mo_atom.c -c -o out/mo_atom.o
    $CC -Iinclude src/mo_bcode.c -c -o out/mo_bcode.o
    $CC -Iinclude src/mo_bool.c -c -o out/mo_bool.o
    $CC -Iinclude src/mo_cntxt.c -c -o out/mo_cntxt.o
    $CC -Iinclude src/mo_date.c -Wno-dangling-else -c -o out/mo_date.o
    $CC -Iinclude src/mo_emit.c -c -o out/mo_emit.o
    $CC -Iinclude src/mo_fun.c -c -o out/mo_fun.o
    $CC -Iinclude src/mo_math.c -c -o out/mo_math.o
    $CC -Iinclude src/mo_num.c -Wno-non-literal-null-conversion -c -o out/mo_num.o
    $CC -Iinclude src/mo_obj.c -c -o out/mo_obj.o
    $CC -Iinclude src/mo_parse.c -c -o out/mo_parse.o
    $CC -Iinclude src/mo_scan.c -c -o out/mo_scan.o
    $CC -Iinclude src/mo_scope.c -c -o out/mo_scope.o
    $CC -Iinclude src/mo_str.c -Wno-non-literal-null-conversion -c -o out/mo_str.o
    $CC -Iinclude src/mocha.c -c -o out/mocha.o
    $CC -Iinclude src/mochaapi.c -Wno-non-literal-null-conversion -c -o out/mochaapi.o
    $CC -Iinclude src/mochalib.c -c -o out/mochalib.o
    $CC -Iinclude src/prmjtime.c -c -o out/prmjtime.o
    $CC -Iinclude src/prtime.c -c -o out/prtime.o
    $CC -Iinclude src/prarena.c -c -o out/prarena.o
    $CC -Iinclude src/prhash.c -c -o out/prhash.o
    $CC -Iinclude src/prprf.c -c -o out/prprf.o
    $CC -Iinclude src/prdtoa.c \
        -Wno-logical-not-parentheses \
        -Wno-shift-op-parentheses \
        -Wno-parentheses \
        -c -o out/prdtoa.o
    $CC -Iinclude src/log2.c -c -o out/log2.o
    $CC -Iinclude src/longlong.c -c -o out/longlong.o
}

当然在这中途抛出的编译器警告中,我也看到了一些不讲武德的代码。比如 mo_date.c 里的这个:

if (i <= st + 1)
    goto syntax;
for (k = (sizeof(wtb)/sizeof(char*)); --k >= 0;)
    if (date_regionMatches(wtb[k], 0, s, st, i-st, 1)) {
        int action = ttb[k];
        if (action != 0)
            if (action == 1) /* pm */
                if (hour > 12 || hour < 0)
                    goto syntax;
                else
                    hour += 12;
            else if (action <= 13) /* month! */
                if (mon < 0)
                    mon = /*byte*/ (action - 2);
                else
                    goto syntax;
            else
                tzoffset = action - 10000;
        break;
    }
if (k < 0)
goto syntax;

也有很多注释提醒着我这个项目的悠久历史,比如 mocha.c 里的这个:

/*
** Mocha virtual machine.
**
** Brendan Eich, 6/20/95
*/

另外我也找到了一些体现 1995 年混沌兼容性问题的代码。它们让我更理解当时的人们为什么会期待「一次编写,到处运行」的 Java 了:

#if defined(AIXV3)
#include "os/aix.h"

#elif defined(BSDI)
#include "os/bsdi.h"

#elif defined(HPUX)
#include "os/hpux.h"

#elif defined(IRIX)
#include "os/irix.h"

#elif defined(LINUX)
#include "os/linux.h"

#elif defined(OSF1)
#include "os/osf1.h"

#elif defined(SCO)
#include "os/scoos.h"

#elif defined(SOLARIS)
#include "os/solaris.h"

#elif defined(SUNOS4)
#include "os/sunos.h"

#elif defined(UNIXWARE)
#include "os/unixware.h"

#elif defined(NEC)
#include "os/nec.h"

#elif defined(SONY)
#include "os/sony.h"

#elif defined(NCR)
#include "os/ncr.h"

#elif defined(SNI)
#include "os/reliantunix.h"
#endif

幸运的是,这些 C 代码都能顺利通过编译。这里为了保留历史遗迹,没有做画蛇添足的多余改动。而在获得全部对象文件后,只要用下面这几行 bash 脚本,就能链接出 Mocha 的可执行文件了!

function compile_native() {
    export CC=clang
    export AR=ar
    compile_objs
    echo "linking..."
    $AR -rcs out/libmocha.a out/*.o
    $CC -Iinclude -Lout -lmocha tests/mo_shell.c -o out/mo_shell
    echo "mocha shell compiled!"
}

获得 Mocha 的原生版本之后,该怎样获得它的 WASM 版本呢?非常简单,只要把原生编译器 gcc(在 macOS 上其实是 clang)换成 WASM 编译器 emcc 就可以了!这个 Emscripten 编译器支持 JavaScript 和 WASM 作为编译后端,切换输出格式不过是改一个编译参数的事情:

function compile_web() {
    export CC=emcc
    export AR=emar
    compile_objs
    echo "linking..."
    $AR -rcs out/libmocha.a out/*.o
    $CC -Iinclude -Lout -lmocha tests/mo_shell.c \
        --shell-file src/shell.html \
        -s NO_EXIT_RUNTIME=0 \
        -s WASM=$1 \
        -O2 \
        -o $2
    echo "mocha shell compiled!"
}

function compile_js() {
    compile_web 0 out/mocha_shell_js.html
}

function compile_wasm() {
    compile_web 1 out/mocha_shell_wasm.html
}

在获得可用的 Mocha 引擎后,我没有重新编写 Makefile。因为我发现这个完全手动实现的 bash 脚本虽然不具备增量编译的能力,但也非常简单易用,可以很方便地构建出不同的编译产物:

$ source build.sh

# build WASM
$ compile_wasm

# build js
$ compile_js

# build native
$ compile_native

不过,Emscripten 编译产物默认的侵入性很强,其输出本身是一个「只要打开页面就会立刻同步执行 WASM 内容」的 HTML。该如何使其接受文本框的用户输入呢?为了简单起见,这里直接将 WASM 引擎页面嵌入了一个 iframe 中。每次点击页面上的 Run 按钮,都会先将输入框内容插入 localStorage,然后重新加载相应的 WASM iframe 页面,在其中同步地读取 localStorage 内的字符串 JS 脚本内容作为(Emscripten 模拟出的)stdin 的标准输入,最后自动启动 Mocha 解释执行。

这个过程很简单,相信任何一个普通的前端开发者都可以轻松地实现出来。这是最后的效果:

这样就大功告成了!我们重新把世界上第一个 JS 引擎安装回了浏览器里!

从开始移植 Mocha 源码到上线 WASM 版本,只花了我不到三天的业余时间。因此个人认为当年的 Mocha 引擎较好地考虑了可移植性和可维护性,具有不错的工程质量。但诸如引用计数等基础设计使其存在固有的性能瓶颈,因此后来需要重写,这就是另一个故事了。

本文写作时,正好处于 JavaScript 正式发布 25 周年之际(1995 年 12 月 4 日,Netscape 与 Sun 召开联合发布会)。而介绍那次事件的新闻稿,也是《JavaScript 20 年》中的一份附件。作为中国的前端开发者,我很高兴能看到这本书在国内获得了不错的反响(个人相关文章共计约 6 万阅读量,GitHub 翻译项目 2.2k star)。有趣的是,JS 之父 Brendan Eich 的推特头像上也写着中文,可惜上面只能看到「無一」两个字,看起来像是在练混元形意太极拳:

不过托

的福,我找到了 Eich 头像的原图。你看这里的汉字并不是玄学,而是一段程序员的心灵鸡汤,写的是「越多人贡献心力,对整个生态系的发展有益无害,开源俨然已成了一种文化」——

今天我们这次小小的实践,也算是这种文化的一种体现吧。

C 语言之父 Dennis Ritchie 说,成功的方式是靠运气——「你要出现在正确的时间和正确的地点,然后让自己被后人所延续。」而 JavaScript 也正是这样的。这门语言已经在 SpaceX 龙飞船上支撑起了人类首个宇宙飞船中的 GUI,甚至即将随着詹姆斯韦伯太空望远镜飞向远方。但当我们回顾这一切的起点时,那个带着不少瑕疵的 1995 年版 Mocha 引擎,无疑出现在了正确的时间和正确的地点——否则我们今天写的大概将会是 VBScript。

在 2020 年结束之际回顾 1995,那真像是个不可思议的时代:WTO 成立,申根协议生效,中国劳动法施行,Windows 95、Java 和 JavaScript 陆续发布。而四分之一个世纪过去后,有些东西进步了,有些东西天翻地覆了,但也有些东西恐怕再也回不来了。

忘记那些糟心事吧。就在今天,让我们为 1995 干杯,为 2020 干杯,为 JavaScript 干杯吧。

传送门:Mocha 1995


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK