QuickJS 介绍
QuickJS 介绍
来源 https://ming1016.github.io/2021/02/21/deeply-analyse-quickjs/
参考 https://github.com/bellard/quickjs
参考 https://sciter.com/ https://github.com/c-smile/sciter-sdk
介绍
最近在做 JavaScript 和 Native 打交道的工作,虽然6年前服务端和前端包括 JavaScript 经验也有些,不过如今前端标准和前端引擎也发展了很多,这里做个记录吧。本文会着重介绍 QuickJS,其中会针对 js 语言的一些特性来看这些特性在 QuickJS 是如何解释执行和优化的,能够加深对 js 语言的理解。QuickJS 是在 MIT 许可下发的一个轻量 js 引擎包含 js 的编译器和解释器,支持最新 TC39 的 ECMA-262 标准。QuickJS 和其它 js 引擎的性能对比,可以参看 QuickJS 的 benchmark 对比结果页,从结果看,JerryScript 内存和体积小于 QuickJS,但各项性能均低于 QuickJS,Hermes 体积和内存大于 QuickJS,性能和 QuickJS 差不多,但 Hermes 对于 TC39 的标准支持并没 QuickJS 全。Frida 在14.0版本引入了 QuickJS,经作者验证内存使用只有 V8的五分之一,于是设为默认引擎。14.3版本将默认引擎切回 V8,主要是因为某些场景 V8表现和调试功能更好,对于调试已经在 Fabrice bellard 的规划里了,未来可期。
在详细介绍和剖析 QuickJS 之前,我先跟你聊聊 JavaScript 的背景和 QuickJS 作者的背景吧。我觉得这样更有助于理解 QuickJS。
先说说 JavaScript。
JavaScript 背景
第一个图文浏览器是1993年的 Mosaic,由 Marc Andreessen 开发。后替代 Mosaic 的是 Netscape 的 Netscape Navigator 浏览器。Brendan Eich 给 Netscape 开发 Java 辅助语言 Mocha(后更名为 JavaScript),耗时10天出原型(包含了eval 函数),集成到 Netscape 2预览版里,Mocha 基于对象而非 Java 那样基于类。Mocha 采用源码解析生成字节码解释执行方式而非直接使用字节码的原因是 Netscape 公司希望 Mocha 代码简单易用,能够直接在网页代码中编写。
直到现在各 js 引擎都还不直接使用字节码,这是由于不同的 js 引擎都有自己的字节码规范,不像 js 代码规范有统一一套,而且字节码会有捆绑版本和验证难的问题。 TC39 有份中间代码的提案,叫做 Binary AST,提案在这 Binary AST Proposal Overview。Binary AST 这份提案还会去设计解决变量的绑定机制对解析性能影响,还有延缓 Early Error 语义到最近的 enclosing 函数或全局执行脚本的时候来提升解析性能,解决区分列表表达式和箭头函数参数列表的区别需要做回溯等可能会存在字符层面歧义,还有单字节和双字节编码检查的问题,字符串和标识符使用 UTF-8,不用转义码。Binary AST 借鉴 WebAssembly 对解析后得到的 AST 使用基本 primitives(字符串、数字、元组)来进行简单二进制编码,再压缩,每个文件都会有个 header 来保证向前和向后的兼容,节点种类通过 header 的索引来引用,避免使用名称可能带来的版本问题,为减少大小会支持 presets。
下面回到90年代,接着说代号是 Mocha 的 JavaScript 发展历程。
Mocha 的动态的对象模型,使用原型链的机制能够更好实现,对象有属性键和对应的值,属性的值可以是多种类型包括函数、对象和基本数据类型等,找不到属性和未初始化的变量都会返回 undefined,对象本身找不到时返回 null,null 本身也是对象,表示没有对象的对象,因此 typeof null 会返回 object。基础类型中字符串还没支持 Unicode,有8位字符编码不可变序列组成,数字类型由 IEEE 754 双精度二进制64位浮点值组成。新的属性也可以动态的创建,使用键值赋值方式。
Brendan Eich 喜欢 Lisp,来 Netscape 本打算是将 lisp 引入浏览器的。因此 Mocha 里有着很多的 lisp 影子,比如函数一等公民和 lambda,函数是一等公民的具体表现就是,函数可以作为函数的参数,函数返回值可以是函数,也可将函数赋值给变量。Brendan Eich 的自己关于当时开发的回顾文章可以看他博客这篇,还有这篇《New JavaScript Engine Module Owner》,访谈稿《Bending over backward to make JavaScript work on 14 platforms》。
Netscape 2 正式版将 Mocha 更名为 JavaScript,后简称 js。1.0 版本 js 语法大量借鉴 C 语言。行末可不加分号,一开始 js 就是支持的。1.1版 js 支持隐式类型转换,可以把任意对象转成数字和字符串。1.0版 js 对象不能继承,1.1 加入对象的 prototype 属性,prototype 的属性和实例的对象共享。为了加到 ISO 标准中,Netscape 找到了 Ecma,因为 ISO 对 Ecma 这个组织是认可的。接着 Ecma 组建了 TC39 技术委员会负责创建和维护 js 的规范。期间,微软为了能够兼容 js,组建了团队开发 JScript,过程比较痛苦,因为 js 的规范当时还没有,微软只能自己摸索,并写了份规范 The JScript Language Specification, version 0.1 提交给 TC39,微软还将 VBScript 引入 IE。96年 Netscape 为 JavaScript 1.1 写了规范 Javascript 1.1 Specification in Winword format 作为 TC39 标准化 js 的基础。97年 TC39 发布了 ECMA-262 第一版规范。
Netscape 3 发布后,Brendan Eich 重构了 js 引擎核心,加了嵌套函数、lambda、正则表达式、伪属性(动态访问修改对象)、对象和数组的字面量、基于标记和清除的 GC。lambda 的函数名为可选,运行时会创建闭包,闭包可递归引用自己作为参数。新 js 引擎叫 SpiderMonkey。js 语言增加新特性,从更多语言那做了借鉴,比如 Python 和 Perl 的数组相关方法,Perl 对于字符串和正则的处理,Java 的 break / continue 标签语句以及 switch。语言升级为 JavaScript 1.2(特性详细介绍),和 SpiderMonkey 一起集成到 Netscape 4.0。ES3 结合了 js 1.2 和 JScript 3.0,I18N 小组为 ES3 加入了可选 Unicode 库支持。开始兼容 ES3的浏览器是运行微软 JScript 5.5的 IE 5.5,运行 js 1.5 的 Netscape 6。
ES3 标准坚持了10年。
这10年对 js 设计的抱怨,也可以说 js 不让人满意的地方,在这个地方 wtfjs - a little code blog about that language we love despite giving us so much to hat 有汇总。当然也有人会站出来为 js 正名,比如 Douglas Crockford,可以看看他的这篇文章 JavaScript:The World’s Most Misunderstood Programming Language,里面提到很多书都很差,比如用法和特性的遗漏等,只认可了 JavaScript: The Definitive Guide 这本书。并且 Douglas Crockford 自己还写了本书 JavaScript: The Good Parts,中文版叫《JavaScript语言精粹》。另外 Douglas Crockford 做出的最大贡献是利用 js 对象和数组字面量形式实现了独立于语言的数据格式 JavaScript Object Notation,并加入 TC39 的标准中,JavaScript Object Notation 也就是我们如今应用最多的数据交换格式 JSON 的全称。
ES4 初期目标是希望能够支持类、模块、库、package、Decimal、线程安全等。2000年微软 Andrew Clinick 主导的.NET Framework 开始支持 JScript。微软希望通过 ES4 标准能够将 JScript 应用到服务端,并和以往标准不兼容。浏览器大战以 Netscape 失败结束后,微软取得了浏览器统治地位,对 TC39 标准失去了兴趣,希望用自家标准取而代之,以至 TC39 之后几年没有任何实质进展。这个期间流行起来的 Flash 使用的脚本语言 ActionScript 也只是基于 ES3 的,2003年 ActionScript 2.0 开始支持 ES4 的提案语法以简化语义,为了提升性能 Flash 花了3年,在2007年开发出 AVM2虚机支持静态类型的 ActionScript 3.0。那时我所在的创业公司使用的就是 flash 开发的图形社交网站 xcity,网站现在已经不在了,只能在 xcity贴吧找到当年用户写的故事截的图。
Brendan Eich 代表 Mozilla 在2004年开始参与 ES4 的规划,2006年 Opera 的 Lars Thomas Hansen 也加入进来。Flash 将 AVM2 给了 Mozilla,命名为 Tamarin。起初微软没怎么参与 ES4 的工作,后来有20多年 Smalltalk 经验的 Allen Wirfs-Brock 加入微软后,发现 TC39正在设计中 ES4 是基于静态类型的 ActionScript 3.0,于是提出动态语言加静态类型不容易成,而且会有很大的兼容问题,还有个因素是担心 Flash 会影响到微软的产品,希望能够夺回标准主动权。Allen Wirfs-Brock 的想法得到在雅虎的 Douglas Crockford 的支持,他们一起提出新的标准提案,提案偏保守,只是对 ES3 做补丁。
AJAX,以及支持 AJAX 和解决浏览器兼容性问题的库 jQuery 库(同期还有 Dojo 和 Prototype 这样 polyfill 的库)带来了 Web2.0时代。polyfill 这类库一般使用命名空间对象和 IIFE 方式解决库之间的命名冲突问题。我就是这个时期开始使用 js 来开发自己的网站 www.starming.com,当时给网站做了很多小的应用比如图片收藏、GTD、博客系统、记单词、RSS订阅,还有一个三国演义小说游戏,记得那会通过犀牛书(也就是 Douglas Crockford 推荐的 JavaScript: The Definitive Guide ,中文版叫《JavaScript 权威指南》)学了 js 后,就弄了个兼容多浏览器的 js 库,然后用这个库做了个可拖拽生成网站的系统。2013年之后 starming 已经做成了一个个人博客,现在基于 hexo 的静态博客网站。2013年之前的 starming 网站内容依然可以通过 https://web.archive.org/ 网站查看到,点击这个地址。
2006年 Google 的 Lars Bak 开始了 V8 引擎的开发,2008 基于 V8 的 Chrome 浏览器发布,性能比 SpiderMonkey 快了10倍。V8 出来后,各大浏览器公司开始专门组建团队投入 js 引擎的性能角逐中。当时 js 引擎有苹果公司的 SquirrelFish Extreme、微软 IE9 的 Chakra 和 Mozilla 的 TraceMonkey(解释器用的是 SpiderMonkey)。
随着 IBM、苹果和 Google 开始介入标准委员会,更多的声音带来了更真实的诉求,TC39 确定出 ES4 要和 ES3 兼容,同时加入大应用所需的语言特性,比如类、接口、命名空间、package,还有可选静态类型检查等等。部分特性被建议往后延,比如尾调用、迭代器生成器、类型自省等。现在看起来 ES4 10年设计非常波折,激进的加入静态类型,不兼容老标准,把标准当研究而忽略了标准实际上是需要形成共识的,而不是要有风险的。在 TC39 成员达成共识后,ES4 还剔除了 package 和命名空间等特性,由于 Decimal 的设计还不成熟也没能加入。由于 ES4 很多特性无法通过,而没法通过标准,因此同步设计了10年多的 ES 3.1 最终改名为 ES5,也就是 ECMA-262 第 5 版。ES5 主要特性包括严格模式,对象元操作,比如setter、getter、create等,另外添加了些实用的内置函数,比如 JSON 相关解析和字符串互转函数,数组增加了高阶函数等等。ES5 的测试套件有微软 Pratap Lakshman 的,这里可以下载看到,谷歌有 Sputnik,有5000多个测试,基于他们,TC39 最后统一维护著名的 Test262 测试套件。
ES5 之后 TC39 开始了 Harmony 项目,Harmony 开始的提案包括类、const、lambda、词法作用域、类型等。从 Brenda Eich 的 《Harmony Of My Dreams》这篇文章可以 js 之父对于 Harmony 的期望,例如文章中提到的 # 语法,用来隐藏 return 和 this 词法作用域绑定,最终被 ES2015 的箭头函数替代,但 # 用来表示不可变数据结构没有被支持,模块和迭代器均获得了支持。Harmony 设计了 Promise 的基础 Realm 规范抽象、内部方法规范 MOP、Proxy 对象、WeakMap、箭头函数、完整的 Unicode 支持、属性 Symbol 值、尾调用、类型数组等特性。对于类的设计,TC39 将使用 lambda 函数、类似 Scheme 的词法捕获技术、构造函数、原型继承对象、实例对象、扩展对象字面量语法来满足类所需要的多次实例化特性。模块的设计思路是通过 module 和 import 两种方式导入模块,import 可以用模块声明和字符串字面量的方式导入模块。模块导入背后是模块加载器的设计,设计的加载流程是,先处理加载标识的规范,再处理预处理,处理模块间的依赖,关联上导入和导出,最后再进行依赖模块的初始化工作。整个加载过程可以看这个 js 实现的原型,后来这个加载器没有被加入规范,而由浏览器去实现。CommonJS 作者 Kevin Dangoor 的文章《CommonJS: the First Year》写下做 CommonJS 的初衷和目标,标志着 js 开始在服务端领域活跃起来。CommonJS 的思路就是将函数当作模块,和其他模块交互是通过导出局部变量作为属性提供值,但是属性能被动态改变和生成,所以对于模块使用者,这是不稳定的。Ryan Dahl 2009 开发的 Node.js (介绍参看作者jsconf演讲)就是用 CommonJS 的模块加载器。Node.js 链接了 POSIX API,网络和文件操作,有个自己的 Event Loop,有些基础的 C 模块,还包含了 V8 引擎。
2010 年开始出现其他语言源码转 js 源码这种转译器的风潮,最有代表的是 CoffeeScript,CoffeeScript 某种程度上是对 js 开发提供了更优雅更先进的开发语言辅助。从 CoffeeScript 一些特性在开发者的反馈,能够更好的帮助 TC39 对 js 特性是否进入标准提供参考。后来还有以优化性能为目的的 Emscripten 和 asm.js 翻译成高效 js 代码的转译器。转义器对于 Harmony 甚至是后面的 ES2015 来说有着更重要的意义。当时的用户没有主动进行软件升级的习惯,特别是由系统绑定的浏览器。适配 IE6的痛苦,相信老一辈的前端开发者会有很深的体会。大量浏览器低版本的存在对于新的标准推广造成了很大的阻碍,因此使用新标准编写代码转移成可兼容低版本浏览器的代码能够解决兼容问题。在 Harmony 项目开发过程中除了 Mozilla 使用 SpiderMonkey 引擎开发的 Narcissus 转译器外,还有直到目前还在使用的 Babel 和 TypeScript 语言的转译器。另外还有使用 rust 写的 js 编译器 swc,主打速度,打算来替代 babel。
2015年,ECMAScript 2015发布。ECMAScript 2015 之后,由于各个浏览器都开始更快的迭代更新, TC39 开始配合更新的节奏,开始每年更新标准,以年作为标准的版本号。
ES2016 增加了 async/await 异步语法特性,纵观 js 的异步历程,从最开始的 Callback方式到 Promise/then,js 解决了回调地狱的问题,但缺少能够暂停函数和恢复执行的方法,因此在 ES2015 加入了生成器,其实现核心思想就是协程,协程可以看作是运行中线程上的可暂停和恢复执行的任务,这些任务都是可通过程序控制的。在 ES2016 加入简洁的 async/await 语法来更好的使用协程。js 异步编程历程如下图:
ECMAScript 2016 开始 js 进入了框架百家争鸣的时代。
js 框架方面,早期 js 的框架是以兼容和接口优雅为基准比较胜出的 PrototypeJS 和 jQuery。MVC 流行起来的框架是 Backbone,MVVM 时代是 AngularJS 为代表的数据双向绑定只用关注 Model 框架新星崛起。Vue 在 MVVM 基础上增加了用来替代 Options API 的 Composition API 比拟 React 的 Hooks。React(React 的创作者是 Jord Walke,在 facebook,不过现在已经离开了 facebook,创立了自己的公司)后来居上,以函数式编程概念拿下头牌,这也是因为 React 核心团队成员喜欢 OCaml 这样的函数式编程语言的原因。。React 的组件有阿里的 Ant Design 和 Fusion Design 可用。React 对于逻辑复用使用的是更优雅的 Hooks,接口少,以声明式方式供使用,很多开发者会将自己开发的逻辑抽出来做成 hooks,出现了很多基于 hooks 状态管理公用代码。对于状态的缓存维护由 React 的内核来维护,这能够解决一个组件树渲染没完成又开始另一个组件树并发渲染状态值管理问题,开发者能够专注写函数组件,和传统 class 组件的区别可以看 Dan Abramov 的这篇文章《How Are Function Components Different from Classes?》。js 框架的演进如下图:
为了使 js 能够应用于更大的工程化中,出现了静态类型 js 框架。静态类型的 js 框架有微软的 TypeScript 和 Facebook 的 Flow。TypeScript 的作者是当年我大学时做项目使用的 IDE Delphi 的作者 Anders Hejlsberg,当时的 Delphi 开发体验非常棒,我用它做过不少项目,改善了大学生活品质。
对于 React 的开发,现需要了解脚手架 create-react-app,一行命令能够在 macOS 和 Windows 上不用配置直接创建 React 应用。然后是使用 JSX 模版语法创建组件,组件是独立可重用的代码,组件一般只需要处理单一事情,数据通过参数和上下文共享,上下文共享数据适用场景类似于 UI 主题所需的数据共享。为了确保属性可用,可以使用 defaultProps 来定义默认值,使用 propTypes 在测试时进行属性类型设置和检查。在组件里使用状态用的是 Hooks,最常见的 Hooks 是 setState 和 useEffect,项目复杂后,需要维护的状态就会很复杂,React 本身有个简单使用的状态管理库 React Query 数据请求的库,作用类似 Redux,但没有模版代码,更轻量和易用,还可用 Hooks。React Router 是声明式路由,通过 URL 可以渲染出不同的组件。react 跑在 QuickJS 上的方法可以参看 QuickJS 邮件列表里这封邮件。
React 框架对应移动端开发的是 React Native。
React Native 使用了类似客户端和服务器之间通讯的模式,通过 JSON 格式进行桥接数据传递。React Native 中有大量 js 不适合编写的功能和业务逻辑,比如线程性能相应方面要求高的媒体、IO、渲染、动画、大量计算等,还有系统平台相关功能特性的功能业务代码。
这样的代码以前都是使用的原生代码和 C++ 代码编写,C++ 代码通过静态编译方式集成到工程中,也能实现部分平台通用,但是 C++ 编写代码在并发情况下非常容易产生难查的内存安全和并发问题,对于一些比较大的工程,开启 Monkey 测试由于插桩导致内存会增大好几倍,从而无法正常启动,查问题更加困难。当然 Rust 也许是另一种选择,rust 语言层面对 FFI 有支持,使用 extern 就可以实现 rust 与其他编程语言的交互。rust 对内存安全和并发的问题都能够在编译时发现,而不用担心会在运行时发现,这样开发体验和效率都会提高很多,特别是在重构时不会担心引入未知内存和并发问题。使用 rust 编译 iOS 架构的产物也很简单,先安装 Rustup,然后在 rustup 里添加 iOS 相关架构,rust 的包管理工具是 cargo,类似于 cocoapods,cargo 的子命令 cargo-lipo 可以生成 iOS 库,创建一个 C 的桥接文件,再集成到工程中。对于 Android 平台,rust 有专门的 android-ndk-rs 库。rust 的 ffi 在多语言处理中需要一个中间的通信层处理数据,性能和安全性都不高,可以使用 flatbuffers 。关于序列化方案性能比较可以参看 JSON vs Protocol Buffers vs FlatBuffers 这篇文章。
React Native 本身也在往前走。以前 React Native 有三个线程,分别是执行 js 代码的 JS Thread,负责原生渲染和调用原生能力的 UI Thread,模拟虚拟 DOM 将 Flexbox 布局转原生布局的 Shadow Thread。三个线程工作方式是 JS Thread 会先对 React 代码进行序列化,通过 Bridge 发给 Shadow Thread,Shadow Thread 会进行反序列化,形成虚拟 DOM 交由 Yogo 转成原生布局,Shadow Thread 再通过 Bridge 传给 UI Thread,UI Thread 获取消息先反序列化,再按布局信息进行绘制,可以看出三个线程交互复杂,而且消息队列都是异步,使得事件难保处理,序列化都是用的 JSON 性能和 Protocol Buffers 还有 FlatBuffers 相比差很多。新架构会从线程模型做改进,高优先级线程会直接同步调用 js,低优先级更新 UI 的任务不在主线程工作。同时新架构的核心 JSI 方案简化 js 和原生调用,改造成更轻量更高效的调用方式,用来替换 Bridge。JSI 使得以前的三个线程通信不用都通过 Bridge 这种依赖消息序列化异步通信方式,而是直接同步通信,消除异步通信会出现的拥塞问题,具体使用例子可以看这篇文章《React Native JSI Challenge》。另外 React Native 新架构的 JSI 是一个轻量的 C++桥接框架,通信对接的 js 引擎比如 JSC、Hermes、V8、QuickJS、JerryScript 可以很方便的替换。关于 JSI 的详情和进展可以参考其提案地址。
2019 年出现的 Svelte。Svelte 的特点是构建出的代码小,使用时可以直接使用构建出带有少量仅会用到逻辑的运行时的组件,不需要专门的框架代码作为运行时使用,不会浪费。Svelte 没有 diff 和 patch 操作,也能够减少代码,减少内存占用,性能会有提升。当然 Svelte 的优点在项目大了后可能不会像小项目那么明显。
CSS 框架有 Bootstrap、Bulma、Tailwind CSS,其中认可度最高的是 Tailwind CSS,近年来 Bootstrap 持续降低,Tailwind CSS 和 Bootstrap 最大的不同就是 Tailwind CSS 没有必要的内置组件,因此非常轻量,还提供了 utility class 集合和等价于 Less、Sass 样式声明的 DSL。浏览器对 CSS 样式和 DOM 结合进行渲染的原理可以参看我以前《深入剖析 WebKit》这篇文章。
在浏览器之外领域最成功的框架要数 Node.js 了。
Ryan Dahl 基于 V8 开发了 Node.js,提供标准库让 js 能够建立 HTTP 服务端应用。比如下面的 js 代码:
const http = require('http');
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('hi');
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
上面代码运行后会起了个服务应用,访问后会输出 hi。Node.js 会涉及到一些系统级的开发,比如 I/O、网络、内存等。
Node.js 作者 Ryan Dahl 后来弄了 Deno,针对 Node.js 做了改进。基于 Node.js 开发应用服务的框架还有 Next.js,Next.js 是一个 React 框架,包括了各种服务应用的功能,比如 SSR、路由和打包构建,Netflix 也在用 Next.js。最近前端流行全栈无服务器 Web 应用框架,包含 React、GraphQL、Prisma、Babel、Webpack 的 Redwood 框架表现特别突出,能够给开发的人提供开发 Web 应用程序的完整体验。
Node.js 后的热点就是开发工具,工程构建,最近就是 FaaS 开发。工程开发工具有依赖包管理的 npm 和 yarn,webpack、Snowpack、Vite、esbuild 和 rollup 用来打包。其中 esbuild 的构建速度最快。
测试框架有 Karma、Mocha、Jest,React 测试库 Enzyme。其中 Jest 非常简单易用,可进行快照测试,可用于 React、Typescript 和 Babel 里,Jest 目前无疑是测试框架中最火的。另外还有个 Testing Library 可用来进行 DOM 元素级别测试框架,接口易用,值得关注。
最近比较热门的是低代码,低代码做的比较好的有 iMove 等,他们通过拖拽可视化操作方式编辑业务事件逻辑。这里有份低代码的 awesome,收录了各厂和一些开源的低代码资源。
2020 年 Stack Overflow 开发者调查显示超过半数的开发者会使用 js,这得益于多年来不断更新累积的实用框架和库,还有生态社区的繁荣,还有由各大知名公司大量语言大师专门为 js 组成的技术委员会 TC39,不断打磨 js。
js 背景说完了,接下来咱们来聊聊 QuickJS 的作者吧。
作者
QuickJS 作者 Fabrice Bellard 是一个传奇人物,速度最快的TCC,还有功能强大,使用最广的视频处理库 FFmpeg 和 Android 模拟器 QEMU 都是出自他手。
一个人在一个项目或者一个方向上取得很大成果就很厉害了,但是 Fabrice Bellard 却在C语言、数据压缩、数值方法、信号处理、媒体格式、解析器方面弄了很多的实用明星项目,涉及编译、虚拟机、操作系统、图形学、数学等领域。还能后续维护和管足文档,比如 QuickJS 就会配套的对新 ECMAScript 特性、运行时新 API进行支持更新。各项都在性能、可移植核灵活性上做到极致,非常牛了。我觉得 Fabrice Bellard 和 John D. Carmack II 一样,属于一人可抵一个军团的天才,关于 John D. Carmack II 的介绍可以参看《DOOM启世录》这本书。下面是他项目的介绍:
- 1989:LZEXE 高中时候写的 DOS 上很有名的可执行压缩程序。那时 Fabrice Bellar 使用 Amstrad PC1512 编程,由于 Amstrad PC1512磁盘空间非常有限,于是他使用8086汇编重写了 LZSS 压缩算法,并优化了代码结构,新的压缩程序就是 LZEXE,LZEXE 解压速度非常快,充分显示了他超高的编程天赋。
- 1996:Harissa Java 虚拟机。
- 1997: Pi 计算世界纪录保持者。Fabrice Bellard 的计算圆周率的公式 Bellard 公式,是 BBP 公式的变体,计算中使用优化的查德诺夫斯基方程算法计算,复杂度从O(n3)降到了O(n2)。用 BBP 公式来验证结果。Bellard 公式比 Bailey-Borwein-Plouffe 公式快43%,在圆周率竞赛中取胜成为最快的计算圆周率算法,创造了Pi 计算世界纪录,直到2010年8月被余智恒和近藤茂打破,他们完成了五万亿位的 Pi 计算,使用的是可以充分利用超过4核多线程达到12核24超线程的分布式大型服务器级机器计算的 y-cruncher 来完成的计算。不过 Fabrice Bellard 使用的机器配置要低很多,使用了116天提案算出了2700亿位。
- 1999:Linmodem
- 2000:FFmpeg 主要是做音视频编解码和转换,在苹果没有开放硬件解码接口时候,在 iOS 上各种视频 App 都会用 FFmpeg 来解码视频,OpenGL ES 渲染。常见的视频特效、贴纸都用到了 FFmpeg。如今苹果开放了 Metal 硬件解码接口,AVFoundation 可以直接使用硬件解码,这样就可以不用 FFmpeg 了,不过 FFmpeg 现在依然应用在各专业领域,比如滤镜,性能效率依然强劲。FFmpeg 就像一篇博士论文,并且比其他论文要好很多。FFmpeg 可以作为库在应用中使用,也可以直接使用里面的工具。FFmpeg 由音视频编解码库 libavcodec 模块和负责流到输出互转过程的 Libavformat 模块组成,两模块提供解析和不同格式转换的能力,并且灵活易扩展,很多媒体工具和播放器都集成了他们。音视频数据不同格式有不同算法,编码就是写数据,解码就是读数据,编码解码由 libavcodec 模块负责。一个媒体数据会有多个流,比如视频流和音频流,多个流混合单输出叫 multiplexing,demultiplexing 是将单输出返回成多个流。multiplexing 和 demultiplexing 依靠的就是 Libavformat 模块。
- 2000:计算当时已知最大素数26972593-1
- 2001:TinyCC(Tiny C Compiler) 是、是GNU/Linux环境下最小的 ANSI C 语言编译器,目前编译速度是最快的。
- 2002:TinyGL 来自他在1998年 VReng 虚拟现实引擎分布式3D 应用,是一个非常快速、紧凑的OpenGL子集。TinyGL 比 Mesa(后被 VMWare 收购) 还要快要小,占用更少的资源。
- 2002:QEmacs,Bellard 为他的 QEmacs 写了个包括 HTML/XML/CSS2/DocBook 渲染引擎,如果配合 QuickJS 组装成一个浏览器一点问题也没有。Emacs 处理大文件很拿手,一个文件也能多开独立窗口,我觉得 QuickJS 5万行的核心代码是 Bellard 在自己的 QEmacs 里写的,因为 QEmacs 有个显著的特性是使用高度优化的内部表示法和对文件进行 mmaping,这样带来的好处是上百兆的文件编辑起来也不会慢。
- 2003:QEMU 在一台物理机器上管理数百个不同计算环境。QEMU 中 Fabrice Bellard 声明了两百多个版权声明,占了近1/3。QEMU 不是一次翻译一条指令,会将多个指令翻译后一次记到 chunk 里,如果这多个指令会执行多次,那么这个翻译过程就会被省掉,从而提高执行速度。VirtualBox、Linux Kernel-based 虚拟机(KVM)、Android 模拟器都是基于 QEMU。
- 2004:TinyCC Boot Loader 可在15秒内从代码编译到启动 Linux 系统。
- 2005:DVB-T computer-hosted transmitter 用普通 PC 外加 VGA 卡,产生VHF信号,充当模拟数字电视系统。
- 2011:JSLinux 用 Javascript 开发的 PC 模拟器,可以在浏览器里运行 Linux。
- 2012:LTEENB 是 PC 软件实现4G LTE/5G NR基站。能跑在一个普通4核 CPU 的 PC 上。据说是 Fabrice Bellard 一个人10个月做出来的,有人调侃说10个月自己可能协议都读不完。目前 LTEENB 用在他创办的 Amarisoft 公司里。
- 2014:BPG 基于HEVC的新图像格式,使用 JavaScript 解码器。相比较JPEG,BPG 有着更高的压缩算法,相同质量体积少半。
- 2017:TinyEMU 被开发出来,TinyEMU可以模拟128位精简指令集 RISC-V(基于 RISC 开源指令集架构) 和 x86 CPU 指令集的小型模拟器,另外 TinyEMU 还有 js 版,可以运行 Linux 和 Windows 2000。
- 2019:QuickJS 本篇主角 JavaScript 引擎。
更详细的介绍可以看 Fabrice Bellard 的无样式个人网站 以及网站上列出他的各个项目代码。
Decimal
QuickJS 在2020-01-05版本加入–bignum flag 用来开启 Decimal 科学计算,依靠他以前写的 LibBF 来处理 BigInt、BigFloat 和 BigDecimal 数字。LibBF可以处理任意精度浮点数的库,使用渐进最优算法,基本算术运算接近线性运行时间。使用的 IEEE 754语义,操作都是按 IEEE 754标准来进行四舍五入。基本加减乘除和平方根算术运算都具有接近线性的运行时间,乘法使用 SIMD 优化的 Number Theoretic Transform 来运算,SIMD(Single instruction, multiple data)是单指令多数据的意思,表示某时刻一个指令能够并行计算,适合任务包括调音量和调图形对比度等多媒体操作。能够支持sin、cos、tan这样的函数。
TC39这些年来一直在考虑加入高精度的小数类型 Decimal。
这些都是什么数字呢?
Int 的最大值是2的31次方减1,十进制就是2147483647,共31位,如果需要更大位数就需要用于科学计算的 Decimal。Decimal128是128位的高精度精确小数类型。
为什么要使用 Decimal 这种类型呢?
decimal 分数通常不能用二进制浮点精确表示,当建立十进制字符串表示量相互作用模型时,二进制浮点精度效果较差,所以涉及财务计算都不用二进制浮点数字,还有些小数不能用二进制表示,尾数部分会一直循环,所以会截断,这样精度就会有影响,比如0.1 + 0.2 == 0.3就是 false,0.2 + 0.2 == 0.4就是 true。出现这个情况的原因是二进制唯一的质因数是2,因此以2作为质因数表示分母的分数没有问题,而以5或10作为分母的分数是重复的小数,0.1的是分母为10的分数1/10,0.2是分母为5的分数1/5,这些重复小数进行运算然后转换为十进制时就会出现问题。这里有个网站0.30000000000000004.com专门收集了各种语言 0.1 + 0.2 的结果,也包含了 C、C++、Kotlin、Objective-C、Swift 和 JavaScript,同时也列出了如何使用语言支持方法来得到 0.3 的准确结果,比如 swift 已经有了 Decimal 函数,通过 Decimal(0.1) + Decimal(0.2) 就能够得到 0.3。The Floating-Point Guide - What Every Programmer Should Know About Floating-Point Arithmetic这个网站包含了 Decimal 方方面面,还有各种语言的处理范例。
Decimal 函数的实现在 swift 源码 stdlib/public/Darwin/Foundation/Decimal.swift 路径下,Java BigDecimal的实现在这里,js 也有个人使用 js 实现的更高精度的浮点运算库,比如 Michael M 的 GitHub 上就有 bignumber.js、decimal.js 和 big.js。QuickJS 采用的方式就是使用数字加m字面量,表现起来就是 0.1m + 0.2m,结果是0.3m。浮点运算中使用很多的数字进行相加,会产生渐进的累加误差,特别是在财务计算这种对精度要求高的应用上,这些误差就会成为大事故。先前做包大小优化时,模块很多,发现四舍五入位数少取一位时偏差还蛮大。因此提高浮点表示的精度,就可以减少中间计算造成的累积舍入误差。
当需要更高精度时,浮点运算可以用可变长度的比如指数来实现,大小根据需要来,这就是任意精度浮点运算。更高精度就需要浮点硬件,叫浮点扩展,比如 double-double arithmetric,用在 C 的 long double 类型。就算精度再高吧,有些有理数比如1/3,也没法全用二进制浮点数表示,如果要精确表现这些有理数,需要有理算术软件包,软件包对每个整数使用 bignumber 运算。在计算机的代数系统(比如Maple)里,可以评估无理数比如 π 直接处理底层数学,而不需要给每个中间计算使用近似值。
再看看对应的标准和各编程语言的实现情况。
那什么是IEEE 754-2008标准?为什么要有这个标准?
以前很早的时候大概在七八十年代,计算机制造商的浮点标准比如字大小、表示方法还有四舍五入等都不一样,不同系统浮点的兼容就是个很大的问题,因此当时英特尔和摩托罗拉都提出了浮点标准的诉求。
85年IEEE 754-2008 decimal 浮点标准出来,IBM大型机进行了支持。标准在计算机硬件和编程语言中应用非常广,其基本格式有单精度,双精度和扩展精度,单精度在 C 里是 float 类型,7位小数。双精度在 C 里是 double 类型,占8个字节,64位双精度的范围是2 × 10的负308次方到2 × 10的308次方。扩展精度在 C99和 C11 标准的附件IEC 60559 浮点运算里定义了 long double 类型。四精度,34位小数。decimal32、decimal64和 decimal128都是用于执行十进制进位。
IEEE 754-2008标准还定义了32位、64位和128位 decimal 浮点表示法。规定了一些特殊值的表示方法,比如正无穷大 +∞ ,负无穷大 -∞,不是一个数字表示为 NaNs 等。定义了浮点单元(FPU),也可以叫数学协处理器,专门用来执行浮点运算。
IBM在1998年在大型机里引入了 IEEE 兼容的二进制浮点运算,05年加了 IEEE 兼容的decimal 浮点类型。
除了IEEE 754-2008标准外,还有其他浮点格式标准吗?
现在对机器学习模型训练而言,范围比精度更有用,因此有了 Bfloat16标准,Bfloat16标准和 IEEE 754半精度格式的内存量一样,而指数要更多,IEEE 754是5位,Bfloat16是8位。很多机器学习硬件加速器都提供了支持Bfloat16支持,Nvidia 甚至还支持了 TensorFloat-32 格式标准,指数位数更多,达到10位。
如今 TC39正在努力将 Decimal 加到标准中,BigInt 提案已接受。TC39提案用 BigDecimal/Decimal128 语法是类似 1.23m 这样。Swift 已经有了支持,对应的是 Decimal。python也有,文档。数据库MongoDB的说明。C 和 C++语言对于32、64和128位 IEEE 754 decimal 类型还是一个提案,只是GCC编译器实现了一个,Clang不支持decimal floating point types,所以 QuickJS 专门实现了 BigInt 和 BigDecimal 的处理。BigInt 可以使 JavaScript 有任意大小的整数。QuickJS BigInt 性能上这里有个 js 程序 pi_bigint.js 算10万位的 pi,相同机器上 V8 是2.3s,QuickJS 是0.26s。BigDecimal 相对于 BigInt 多了小数位,用做高精度小数计算。BigDecimal 使用十进制而不是二进制 BigInt 表示非小数部分,然后用 scale 来表示小数位置。这样的表示就不会有精度问题,使用 BigDecimal 计算是在 BigInt 之间进行运算,scale进行小数点位置更新。Fabrice Bellard用BigDecimal写了这段代码来计算pi的位数,代码在这里。
QuickJS
QuickJS 只有210KB,体积小,启动快,解释执行速度快,支持最新 ECMAScript 标准(ECMA-262)。
ECMAScript 标准最大的变化是发生在 ES2015。类、箭头函数、静态类型数组、let关键字、maps、sets、promise等特性都是在 ES2015(ES6)增加的,ECMAScript 2016 主要增加了 await/async 关键字,ECMAScript 2017 主要增加了 rest/spread 运算符,ECMAScript 2020 主要增加了 BigInt。CMAScript 标准通读比较枯燥,最好在碰到坑时查阅。最新的 ECMAScript 标准在这里 ecma-262。QuickJS包含了 ecma-262 标准测试套件 Test262,将 test262 测试套件安装到 QuickJS test262 目录下,QuickJS 运行测试套件的程序源文件是 run-test262.c,test262.conf 包含各种测试的选项,test262_error.txt 是记录当前显示错误信息的列表。到今年 Test262里面有已包含了三万多个单独测试 ECMAScript 规范的测试,而 QuickJS 几乎全部通过了测试,和 V8差不多,比 JavaScriptCore 强。Test262 是 ECMAScript 测试套件,在 Test262 Report 网站上可以看到各个 js 引擎对 ECMAScript 标准支持情况,最新情况如下:
如上图所示,在语法、内置对象上 QuickJS 和 V8 都不相上下,附加特性上做的最好。
QuickJS 发布以来功能的更新都会发布在 QuickJS 的 changelog 里 上,20年重要的更新就是 TC39 BigDecimal 提案的支持还有更新了下 TC39的 Operator overloading 提案,更新的commit在这里,并修改了运算符重载语义使之更接近于TC39的提案。os和std模块做了更新和新增,包括 os.realpath、os.getcwd、os.mkdir、os.stat、os.lstat、os.readlink、os.readdir、os.utimes、os.exec、os.chdir、std.popen、std.loadFile、std.strerror、std.FILE.prototype.tello、std.parseExtJSON、std.setenv、std.unsetenv、std.getenviron。加了官方 Github 镜像。
下面我们通过安装 QuickJS 来小试下吧。
安装
QuickJS 的编译和咱们通过 Xcode 工程配置编译的方式不同,使用的是 makefile 来配置编译和安装的,和一些开源 C/C++ 工程编译使用 cmake 方式也有些不同,以前我们写些简单 c/c++ 的 demo 后,会简单的通过 clang 命令加参数进行编译和链接,但如果需要编译和链接的文件多了,编译配置复杂了,每次手工编写就太过复杂,因此就会用到 makefile 或者 cmake 来帮助减少复杂的操作提高效率。那什么是 makefile? 和 cmake 有什么关系呢?
我先介绍下什么是 makefile 吧。
makefile
makefile 是在目录下叫 Makefile 文件,由 make 这个命令工具进行解释执行。把源代码编译生成的中间目标文件 .o 文件,这个阶段只检测语法,如果源文件比较多,Object File 也就会多,再明确的把这些 Object File 指出来,链接合成一个执行文件就会比较繁琐,期间还会检查寻找函数是否有实现。为了能够提高编译速度,需要对没有编译过的或者更新了的源文件进行编译,其他的直接链接中间目标文件。而且当头文件更改了,需要重新编译引用了更改的头文件的文件。上面所说的过程只需要 make 命令和编写的 makefile 就能完成。
简单说,makefile 就是一个纯手动的 IDE,通过手动编写编译规则和依赖来配合 make 命令来提高编译工作效率。make 会先读入所有 include 的 makefile,将各文件中的变量做初始化,分析语法规则,创建依赖关系链,依据此关系链来定所需要生成的文件。
那么 makefile 的语法规则是怎样的呢?
makefile 的语法规则如下:
target ... : prerequisites ...
command
...
...
其中的 target 可以是一个目标文件,也可以是一个可执行的文件,还可以是一个label。prerequisites 表示是 target 所依赖的文件或者是 target。prerequisites 的文件或 target 只要有一个更新了,对应的后面的 command 就会执行。command 就是这个 target 要执行的 shell 命令。
举个例子,我们先写个 main.c
#include <stdio.h>
#include "foo.h"
int main() {
printf("Hi! \n");
sayHey();
return 0;
}
再写个 foo.c
#include "foo.h"
void sayHey() {
printf("Hey! \n");
}
再写个 makefile
hi: main.o foo.o
cc -o hi main.o foo.o
main.o: main.c foo.h
cc -c main.c
foo.o: foo.c foo.h
cc -c foo.c
clean:
rm hi main.o foo.o
在该目录下直接输 make 就能生成 hi 可执行文件,如果想要清掉生成的可执行文件和中间目标文件,只要执行 make clean 就可以了。
上面代码中冒号后的 .c 和 .h 文件就是表示依赖的 prerequisites。你会发现.o 文件的字符串重复了两次,如果是这种重复多次的应该如何简化呢,类似 C 语言中的变量,实际上在 makefile 里是可以有类似变量的语法,在文件开始使用 = 号来定义就行。写法如下:
objects = main.o foo.o
使用这个变量的语法是 $(objects),使用变量语法后 makefile 就变成下面的样子:
objects = main.o foo.o
hi: $(objects)
cc -o hi $(objects)
main.o: main.c foo.h
cc -c main.c
foo.o: foo.c foo.h
cc -c foo.c
clean:
rm hi $(objects)
makefile 具有自动推导的能力,比如 target 如果是一个 .o 文件,那么 makefile 就会自动将 .c 加入 prerequisites,而不用手动写,并且 cc -c xxx.c 也会被推导出,利用了自动推导的 makefile 如下:
objects = main.o foo.o
hi: $(objects)
cc -o hi $(objects)
main.o: foo.h
foo.o:
clean:
rm hi $(objects)
make 中通配符和 shell 一样,~/js 表示是 $HOME 目录下的 js 目录,*.c 表示所有后缀是c的文件,比如 QuickJS 的 makefile 里为了能够随时保持纯净源码环境会使用 make clean 清理中间目标文件和生成文件,其中 makefile 的 clean 部分代码如下:
clean:
rm -f repl.c qjscalc.c out.c
rm -f *.a *.o *.d *~ unicode_gen regexp_test $(PROGS)
rm -f hello.c test_fib.c
rm -f examples/*.so tests/*.so
rm -rf $(OBJDIR)/ *.dSYM/ qjs-debug
rm -rf run-test262-debug run-test262-32
上面的 repl.c、qjscalc.c 和 out.c 是生成的 QuickJS 字节码文件,*.a、*.o、*.d 表示所有后缀是a、o、d的文件。
如果要简化到编译并链接所有的 .c 和 .o 文件,可以按照下面的写法来写:
objects := $(patsubst %.c,%.o,$(wildcard *.c))
foo : $(objects)
cc -o foo $(objects)
上面代码中的 patsubst 是模式字符串替换函数,%表示任意长度字符串,$加括号表示要执行 makefile 的函数,wildcard 的作用是扩展通配符,因为在变量定义和函数引用时,通配符会失效,因此这里 wildcard 的作用是获取目录下所有后缀是 .c 的文件。patsubst的语法如下:
$(patsubst <pattern>,<replacement>,<text>)
根据此语法,上例里,就是将目录下所有 .c 后缀文件返回成同名 .o 的文件。在 patsubst 和 wildcard 在 QuickJS 的 makefile 里广泛使用,比如下面这段:
ifdef CONFIG_LTO
libquickjs.a: $(patsubst %.o, %.nolto.o, $(QJS_LIB_OBJS))
$(AR) rcs $@ $^
endif # CONFIG_LTO
上面这段表示在配置打开 lto 后,会把 QJS_LIB_OBJS 这个变量定义的那些中间目标 .o 文件后缀缓存 .nolto.o 后缀。
QuickJS 的 makefile 中使用的函数除了patsubst 和 wildcard 还有 shell。shell 的作用就是可以直接调用系统的 shell 函数,比如 QuickJS 里的 $(shell uname -s),如果在 macOS 上运行会返回 Darwin,使用此方法可以判断当前用户使用的操作系统,从而进行不同的后续操作。比如 QuickJS 的 makefile 是这么做的:
ifeq ($(shell uname -s),Darwin)
CONFIG_DARWIN=y
endif
上面代码可以看出,通过判断 shell 函数返回值来确定是否是 Darwin内核,将结果记录在变量 CONFIG_DARWIN 变量中。通过这个结果后续配置编译器为 clang。
ifdef CONFIG_DARWIN
# use clang instead of gcc
CONFIG_CLANG=y
CONFIG_DEFAULT_AR=y
endif
在编写 makefile 时有些 prerequisites 和 target 会变,这样在命令中就不能写具体的文件名,在 makefile 里有种自动产生变量的规则,叫做自动化变量可以解决这样的问题,自动化变量还能解决 makefile 冗余问题,因为自动化变量用简短的语法替代重复编写 target 和 prerequisites,自动化变量有$@、$%、$<、$?、$+等,比如$@表示的是target,$^表示 prerequisites,GNU make 里在自动化变量里加入 D 或者 F 会变成变种自动化变量,能够代表更多意思,比如$(@F)表示在路径中取出文件名部分。自动化变量在 QuickJS 的 makefile 里使用很多。比如下面的代码:
$(OBJDIR)/%.o: %.c | $(OBJDIR)
$(CC) $(CFLAGS_OPT) -c -o $@ $<
上面这段代码的作用是当.o文件依赖的编译中间产物或 c 源文件有更新时,会重新编译生成.o文件 $@ 表示的是 $(OBJDIR)/%.o,$< 表示的是 %.c | $(OBJDIR),简化了代码,$@ 就像数组那样会依次取出 target,然后执行。
依赖关系里会有.h 头文件,你一定奇怪在 QuickJS 的 makefile 里那些头文件为什么就没有出现在 prerequisites 中了,这是为什么呢?
这是因为有办法让 makefile 自动生成依赖关系。如果没有这办法自动生成依赖关系的话,在大型工程中,你就需要对每个 c 文件包含了那些头文件了解清楚,并在 makefile 里写好,当修改 c 文件时还需要手动的维护 makefile,因此这种工作不光重复而且一不小心还会错。
那有办法能解决重复易错的问题么?
编译器,比如 clang 有个选项 -MMD -MF 可以生成依赖关系,生成为同名的 .d 文件,.d 文件里有相应 .c 的所依赖的文件。因此可以在 makefile 里利用编译器这个特性,使用变种自动化变量$(@F)来设置编译配置,自动设置同名文件名的.d文件。在 QuickJS 中是这么配置编译标识的:
ifdef CONFIG_CLANG
HOST_CC=clang
CC=$(CROSS_PREFIX)clang
CFLAGS=-g -Wall -MMD -MF $(OBJDIR)/$(@F).d
CFLAGS += -Wextra
CFLAGS += -Wno-sign-compare
CFLAGS += -Wno-missing-field-initializers
CFLAGS += -Wundef -Wuninitialized
CFLAGS += -Wunused -Wno-unused-parameter
CFLAGS += -Wwrite-strings
CFLAGS += -Wchar-subscripts -funsigned-char
CFLAGS += -MMD -MF $(OBJDIR)/$(@F).d
上面代码中的$(@F).d 表示会根据 target 的文件名生成对应的包含依赖关系的 .d 文件。
生成完 .d 文件后,需要用 include 命令把这些规则加到 makefile 里,看下 QuickJS 的做法:
-include $(wildcard $(OBJDIR)/*.d)
makefile 还有些隐含的规则,比如把源文件编译成中间目标文件这一步可以省略不写,make 会自动的推导生成中间目标文件,对应命令是 $(CC) –c $(CPPFLAGS) $(CFLAGS),链接目标文件是通过运行编译器的ld来生成,也可以省略,对应的命令是$(CC) $(LDFLAGS) .o $(LOADLIBES) $(LDLIBS)。隐含规则使用的变量包括命令变量和命令参数变量,隐含规则命令变量有 AR,默认命令是 ar 用来对函数库打包,AS 默认是 as,CXX 默认是 g++,CC 默认命令是 cc,是 C 语言的编译程序,QuickJS 的 CC 会根据系统进行区分设置,如果是 macOS 这种 Darwin 系统,会使用clang,其他的用 gcc。如果不是 Darwin 系统同时开启 lto,那么 AS 会设置为 llvm-ar 命令,对应 QuickJS 的 makefile 代码如下:
ifdef CONFIG_DEFAULT_AR
AR=$(CROSS_PREFIX)ar
else
ifdef CONFIG_LTO
AR=$(CROSS_PREFIX)llvm-ar
else
AR=$(CROSS_PREFIX)ar
endif
endif
上面 CROSS_PREFIX 变量实际上已经没用了,以前是因为要兼容在 Linux 下运行 Windows 所需要添加 mingw32 的前缀,目前这段变量定义已经被注释掉了。 隐含规则命令参数有编译器参数 CFLAGS 和链接器参数 LDFLAGS 等,这些变量可以根据条件判断或者平台区分,配置不同参数。
cmake
由于 GNU 的 make 和其他工具,比如微软的 nmake 还有 BSD 的 pmake 的 makefile 语法规则标准有不同,因此如果想为多个平台和工具编写可编译的 makefile 需要写多份 makefile 文件。
为了应对这样重复繁琐的工作,cmake 出现了。
我们可以编写 CMakeList.txt 这样的文件来定制编译流程,cmake 会将其转换成平台和工具相应的 makefile 文件和对应的工程文件(比如 Xcode 工程或Visual Studio工程)。比如你所熟悉的 LLVM 就是用的 cmake,源码各个目录下都有对应的 CMakeList.txt 文件。具体可以参看官方教程。
使用 qjsc -e 生成的 C 代码,通过编写如下的 CMakeLists.txt 配置:
cmake_minimum_required(VERSION 3.10)
project(runtime)
add_executable(runtime
# 如果有多个 C 文件,在这里加
src/main.c)
# 头文件和库文件
include_directories(/usr/local/include)
add_library(quickjs STATIC IMPORTED)
set_target_properties(quickjs
PROPERTIES IMPORTED_LOCATION
"/usr/local/lib/quickjs/libquickjs.a")
# 链接到 runtime
target_link_libraries(runtime
quickjs)
按照上面代码编写,可以编译出可执行的文件了。
github 上有个 QuickJS 工程的 cmake 脚本,可以用来下载编译 QuickJS 库。
Xcode 来编译安装和调试 QuickJS 源码
由于 QuickJS 使用的是 makefile 管理,而 makefile 不能直接转成 Xcode 的工程文件,因此需要使用 Xcode 的 External Build System,创建工程 QuickJSXcode 后,给工程添加 QuickJS 源码文件,直接导入到工程即可,使用 ⌘ + B 构建工程,在 Product/Scheme/Edit Scheme 里选择 Run/Info,在 Executable 里选择刚才构建生成的 qjs 可执行文件。如下图:
然后添加命令行工具 Target 辅助,在 Target 的 Dependencies 里添加 QuickJSXcode,在 Link Binary With Libraries 里添加编译出来的 libquickjs.a。最后在 Build Settings 的 Search Paths 里,将 QuickJS 代码路径添加到 Header Search Paths 和 Library Search Paths 里。完成后现在就可以使用 Xcode 进行 QuickJS 源码断点调试、自动补全和跳转了。如下图所示:
通过断点调试,更加方便阅读理解源码。可调试工程我放到了Github上,在这里。
QuickJS 在 VSCode 的调试的话,先 fork 一份 Koushik Dutta 的 QuickJS 修改版本,代码在这,VSCode 扩展的 github 地址,VSCode 扩展市场链接,对 QuickJS 修改的详细 Koushik Dutta 在邮件里有说明,邮件地址在这,Fabrice Bellard 看了也回邮件表示以后会增加调试接口。
使用方法
按照官方手册写的方式使用 makefile 安装后,命令行工具会被安装到 /usr/local/bin 目录下,此目录下会有 JS 解释器 qjs,有编译器 qjsc(QuickJS compiler) 编译 js 文件为可执行文件(QuickJS引擎 + js 文件打包,qjs 解释执行目标 js 文件),还有一个可以对任意长度数字计算的 qjscalc。编译的库会放到 /usr/local/lib/quickjs/ 目录下,有静态库 libquickjs.a,可以生成更小和速度更快的库 libquickjs.lto.a,lto(Link-Time-Optimization)需要在编译时加上 -flto 标识。
qjsc 还可以把 js 文件编译成 QuickJS 虚拟机的字节码,比如编写下面的一段 javascript 代码,保存为 helloworld.js
let myString1 = "Hello";
let myString2 = "World";
console.log(myString1 + " " + myString2 + "!");
使用
qjsc -o hello helloworld.js
就能够输出一个可执行文件 hello 可执行文件,运行后输出 hello world !。把参数改成-e 可以输出.c文件。
qjsc -e -o helloworld.c helloworld.js
文件内容如下:
/* File generated automatically by the QuickJS compiler. */
#include "quickjs-libc.h"
const uint32_t qjsc_helloworld_size = 173;
const uint8_t qjsc_helloworld[173] = {
0x02, 0x09, 0x12, 0x6d, 0x79, 0x53, 0x74, 0x72,
0x69, 0x6e, 0x67, 0x31, 0x12, 0x6d, 0x79, 0x53,
0x74, 0x72, 0x69, 0x6e, 0x67, 0x32, 0x0a, 0x48,
0x65, 0x6c, 0x6c, 0x6f, 0x0a, 0x57, 0x6f, 0x72,
0x6c, 0x64, 0x0e, 0x63, 0x6f, 0x6e, 0x73, 0x6f,
0x6c, 0x65, 0x06, 0x6c, 0x6f, 0x67, 0x02, 0x20,
0x02, 0x21, 0x1a, 0x68, 0x65, 0x6c, 0x6c, 0x6f,
0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2e, 0x6a, 0x73,
0x0e, 0x00, 0x06, 0x00, 0xa0, 0x01, 0x00, 0x01,
0x00, 0x04, 0x00, 0x00, 0x52, 0x01, 0xa2, 0x01,
0x00, 0x00, 0x00, 0x3f, 0xe1, 0x00, 0x00, 0x00,
0x80, 0x3f, 0xe2, 0x00, 0x00, 0x00, 0x80, 0x3e,
0xe1, 0x00, 0x00, 0x00, 0x82, 0x3e, 0xe2, 0x00,
0x00, 0x00, 0x82, 0x04, 0xe3, 0x00, 0x00, 0x00,
0x3a, 0xe1
