译文:Static TypeScript,TypeScript 的一种静态编译器实现
译者声明:本译文获得原作者授权翻译,原论文请点击查看原文;由 Allen 审校。
-
Thomas Ball,微软研究院,Redmond,[email protected]
-
Peli de Halleux,微软研究院,Redmond,[email protected]
-
Michał Moskal,微软研究院,Redmond,[email protected]
综述
基于单片机的嵌入式设备通常使用 C 语言进行编程。这类设备如今进入了计算机科学教学的课堂,甚至一些中学也开办了相关的课程。于是,用于单片机编程的脚本语言(如 JavaScript 和 Python)使用也逐渐增加。
我们研发了 Static TypeScript(STS),它是 TypeScript 的一种子集(而 TypeScript 本身是 JavaScript 的超集),还研发了相关的编译/链接工具链,它们全部使用 TypeScript 进行开发并且在浏览器中运行。STS 为实践开发而设计(特别是实践教学),适合针对小型设备的静态编译。用户开发的 STS 程序将在浏览器中被编译成机器码,并链接预编译的 C++ 运行时,生成比普通的嵌入式解释器更高效的可执行文件,从而延长电池寿命并可以在 RAM 低达 16kB 的设备(例如 BBC micro:bit)上运行。本论文主要对实现 STS 系统和开发适用于课堂教学的嵌入式编程平台的技术挑战进行综述。
关键词:JavaScript,TypeScript,编译器,解释器,单片机,虚拟机
1 简介
近年来,课堂上计算机的实物教学不断地发展,以鼓励孩子们构建自己的简单的交互嵌入式系统。例如,图 1(a)展示了 BBC micro:bit[1],它是一种受 Arduino 启发的小型可编程单片机,带有集成的 5X5 LED 显示点阵、几个传感器和低功耗蓝牙(BLE)无线传输。该设备于 2015 年首次向英国所有 7 年级学生推出(10 至 11 岁),随后走向全球,迄今为止 micro:bit 教育基金会( https://microbit.org )已经发放了四百万个设备。图 1(b)则展示了另一个以 RGB LED 为特点的教育设备 Adafruit’s Circuit Playground Express (CPX)。
研究表明在计算机科学教育中使用这样的设备会增加对孩子们的吸引力,尤其是对于女生,还可以增加孩子们和老师们的自信,让课堂教学更加生动[2,16]。
为了控制课堂教学的成本,这些设备一般使用 16 到 256kB 的 32 bit ARM Cortex-M 单片机,使用额外的计算机(通常是笔记本电脑或台式机)进行编程。在课堂上用这类设备进行编程教学有一系列技术挑战:
选择/设置合适该年龄段学生的编程语言和开发环境 教室的计算机使用的操作系统通常是过时的,互联网连接也不稳定或速度慢,并且可能被学校的 IT 管理员限制访问外部网络,这使得安装原生应用程序有种种困难 学生开发的程序要从计算机转移到电池供电的嵌入式设备上(正如很多项目都在实验中提供这种设备,若没有提供也可以“改造”。)
面对这些挑战,有许多流行脚本语言的嵌入式解释器应运而生,例如 JavaScript 的 JerryScript[8,15]、Duktape[22]、Espruino[23]、mJS[20]、MuJS[19],Python 的 MicroPython[9]及其分支 CircuitPython[12]。这些解释器直接在微控制器上运行,仅依赖从计算机向嵌入式设备传输程序文本;但它们也舍弃了深度优化的 JIT 编译器(如 V8)的一些优势,这类编译器运行需要的内存比单片机的内存要大两个数量级。
图 2 三款有 160x120 分辨率彩色屏幕的基于单片机的游戏机,这些开发板使用 ARM 的 Cortex-M4F 核心:ATSAMD51G19(192kB RAM,以 120Mhz 主频运行)和 STM32F401RE(96kB RAM,以 84Mhz 主频运行)。
很不幸的是,这些嵌入式解释器都比 V8 慢几个数量级(详细对比见第 4 节),影响响应速度和电池寿命。更重要的是,由于内存中的对象表示为动态键值映射(dynamic key-value mappings),因此它们的内存占用量可能是实现同样功能的 C 程序的几倍,这一点严重限制了程序在更低内存的机器上(如 16kB RAM 的 micro:bit 和 32kB RAM 的 CPX)运行的可能性。
1.1 Static TypeScript
作为上述嵌入式解释器的替代,我们开发了 Static TypeScript(STS),它是 TypeScript 的语法子集[3],由一个用 TypeScript 编写的编译器支持,该编译器可以生成在 16-256kB RAM 的微控制器上高效运行的机器码。STS 及其编译器和运行时的设计主要着力于解决前文提到的三个挑战。确切来说:
-
STS 削除了 JavaScript 大部分的「糟粕」;受 StrongScript 影响[14],对于静态声明的类,STS 使用名义类型(nominal type),并支持用虚拟函数表的经典技术对类进行高效编译。 -
STS 工具链是离线运行的,一旦加载入浏览器,就不再需要 C/C++ 编译器。它们用 TypeScript 实现,将 STS 编译为 ARM Thumb 机器码并在浏览器中将其与预编译好的 C++ 运行时链接————浏览器或许在大多数时候是课堂中唯一可用的运行环境了。 -
令人惊喜的是,STS 编译器生成的机器码高效而紧凑,使得我们解锁了一系列应用领域,例如图 2 中所示的低配置设备的游戏编程,它们能够运行全都得益于 STS 提供的能力。
将 STS 用户程序部署到嵌入式设备不需要安装特别的应用或设备驱动,只需要进入浏览器即可。完成编译的程序以文件下载的形式显示,然后用户将文件传输到显示为 USB 存储器的设备中即可(或者直接通过 WebUSB 协议传输,它是一种即将推出的将网站与物理设备连接的协议)。
STS 简单编译的编译方案(将在第 3 节详述)在一系列小型 JavaScript 基准测试中取得了惊人的优秀表现,性能可以和 V8 等先进前沿的 JIT 编译器媲美,而所需的内存则比他们低几个数量级(详情见第 4 节);同时,也比嵌入式解释器快至少一个数量级。评估的一个特别之处是对处理类、接口和动态映射(dynamic maps)的字段/方法查找的不同策略的比较。
1.2 MakeCode:为教学而生的简单嵌入式开发
STS 是 MakeCode 框架支持的核心语言(详情见 https://makecode.com ;该框架及诸多编辑器已按 MIT 协议开源,请见 https://github.com/microsoft/pxt )。MakeCode 支持为单片机设备创造自定义的嵌入式编程实验。每个 MakeCode 实验(我们一般称其为编辑器(editors),虽然它们也包含了模拟器、API、教程、文档等)通过 STS 针对特定设备或设备类型进行编程。
图 3 MakeCode Arcade 编辑器。左边的面板是 Arcade 设备的模拟器;中间的面板是编辑器中可用的 API 的目录;右边的面板是 Monaco 编辑器,可用在其中用 STS 代码为平台游戏编程( https://makecode.com/85409-23773-98992-33605 )。位于顶部的开关用来在模块可视化编程和 Static TypeScript 编程面板之间切换(由于市场因素,被标记为 JavaScript)。
大多数 MakeCode 编辑器主要以 Web 应用的形式部署,其中包含了用以开发 STS 程序的功能齐全的文本编辑器,它基于 Monaco(VS Code 使用的编辑器组件);还包含了基于 Google Blockly 框架的图形化编程界面(注释中的 STS 元数据定义了 STS 的 API 到 Blockly 的映射,MakeCode 会在 Blockly 和 STS 之间进行交互)。
MakeCode 编辑器和原先 BBC micro:bit 和 Adafruit CPX(详情见 https://makecode.microbit.org/ 和 https://makecode.adafruit.com/ )的编程实验至今已经覆盖了全球的数百万学生和教师。
STS 支持包(package)的概念,即 STS、C++、汇编代码文件的集合,并支持把其他的包当做依赖。第三方开发者已经利用这样的能力对 MakeCode 编辑器进行扩展,使之可以支持各种开发板的外接设备(micro:bit 的相关示例见 https://makecode.microbit.org/extensions )。值得注意的是,大多数包完全用 STS 编写从而避免了不安全 C/C++ 的陷阱,这主要得益于高效的 STS 编译器以及通过数/模针脚(GPIO、PWM、servos)和一些协议(I2C 和 SPI)访问硬件实现的底层 STS API。
图 3 展示了用来为图 2 中的手持游戏设备进行编程的 MakeCode Arcade 编辑器(事实上,图中编辑器里的 STS 程序就是在三个单片机设备中运行的游戏之一,它是一个简单的平台游戏)。MakeCode Arcade 包含了一个大部分由 STS 编写的游戏引擎,因此对代码运行效率提出了很高的要求,因为要在高帧率下实现令人快活的视觉效果。该游戏引擎包含游戏循环逻辑、事件上下文栈、物理引擎、文字线条绘制等模块以及用于特定游戏的框架(比如,为图中的平台游戏(译者注:platformer games,这是一种游戏类型)),游戏引擎一共由一万行 STS 代码和少数最基础的 C++ 图像模糊函数组成。该游戏用 Arcade 构建,在浏览器(桌面或移动端)运行,或在不同型号但符合配置要求的单片机上运行(160*120 像素 16 色屏幕和 100MHz 左右主频、100kB RAM 左右的微控制器)。
本文的主要目的是详述了该广泛应用的系统和解决上述课堂教学难题的方法。
2 Static TypeScript(STS)
TypeScript[3]是 JavaScript 的渐进式[18]超集。这意味着所有 JavaScript 程序都是 TypeScript 程序并且其类型是可选的、按需添加的,这些类型能让 IDE 提供更好的支持,也能让大型 JavaScript 程序有更好的错误检测(error checking)。对象类型提供了映射(maps)、函数和类的统一形式。对象类型之间的结构子类型(structural subtyping)定义了可替换性(substitutability)和兼容性检查。经过类型擦除(及较小的语法转换)后生成原始的 JavaScript 程序。
STS 是 TypeScript 的子集,TypeScript 继承了 JavaScript 的一些高度动态化的语法:with
语句、eval
表达式、基于原型(prototype)的继承、类之外的this
指针、arguments
关键字、apply
方法。STS 保留了诸如动态映射(dynamic maps)的特性,但将它们与名义类抽象分开。这样的限制是可以接受的,因为大多数初学者编写的程序都非常简单,而且嵌入式领域的 JavaScript 库或 TypeScript 库非常少,所以使用这些被限制的 JavaScript 特性(如猴子补丁,monkey patching)的机会很少。
我们的目标并不是让 STS 支持 TypeScript 的全部功能,而是务实地针对用户需求,按嵌入式编程所需添加语言特性。
与所有对象类型都是属性包的 TypeScript 相比,STS 在运行时拥有四种不相关的对象类型:
-
动态映射类型,它具有命名属性(即字符串索引),能保存任何类型的值 -
函数(闭包)类型 -
类,用以描述类的示例,通过访问每个字段/方法进行高效的运行时子类型检查,对它们进行名义上的处理,后文中会详述 -
数组(集合)类型
某种程度上,STS 与 Java 或 C# 的风味更接近,它们都把类型视作「抽象的守护神」(protectors of abstractions),而不像 JavaScript 那样允许更自由地处理对象。在 3.4 节的讨论中,运行时类型标签(type tags)用于区分上面列出的不同种类的内置对象类型(以及诸如装箱数字(boxed number)和字符串之类的语言基础构件)。
与 TypeScript 中一样,类型转换不会生成任何代码且不会失败。相反,STS 在字段/方法访问点保护名义上的类抽象。当x == null
时,x.f
在 JavaScript 中会导致运行时错误。如果T
是具有字段f
的类,并且x
的动态类型不是T
的名义子类型,则(x as T).f
在 STS 中将导致运行时错误。如果T
是接口、any
或某种复杂类型(例如,union 或 intersection),则与x
的动态类型无关,而是根据名称查找该字段。
其他抽象在运行时中也受到保护,正如在 JavaScript 中那样:例如,对非函数类型的对象进行函数调用。现在,将属性动态添加到除map
类型之外的任何其他类型是错误的。我们将来可能会取消对动态 JavaScript 语义的限制,这取决于用户的反馈。迄今为止,在我们的用户社区(教育工作者和开发人员)中还没有收到任何关于这些限制的负面反馈。
STS 基本类型根据 JavaScript 语义进行处理。特别要强调的是,所有的数字类型在理论上都是 IEEE 64 位浮点类型,但也有可能实际使用 31 位带符号标记的整数类型。运算符的实现(例如加法或比较)基于动态值的分支,以遵循 JavaScript 语义,并在汇编中手动实现了整数类型的快速实现。
STS 既是 TypeScript 的语法子集又是语义子集,这意味着,如果程序在 STS 中成功编译,它将具有与等效 TypeScript 程序相同的语义,否则该程序会在运行时崩溃(在上述情况下)。
2.1 与 C++ 进行交互操作
STS 程序运行在单片机上由 C++、C、汇编实现的运行时里。该运行时实现了语言的基础构件(操作符、集合、类的支持、动态映射等),同样也支持对底层硬件的访问。该运行时能用包进行拓展,相关详情见 2.2 节。
STS 支持调用 C++ 函数,反之,亦可以从 C++ 调用 STS 函数。为了简化这个过程,STS 使用了一直能够简单的代码生成策略,即在 C++ 函数中使用特殊的注释(//%
)指定那些代码需要导出为 STS,这样的策略还用于导出为 Blockly 代码块(使用注释//% block
)。构建中有一个步骤是解析 C++代码获取这些特殊的注释,还会收集这些函数的原型(签名),从而在调用它们时可以生成正确的转换。举个转换的例子:
// C++源码:
namespace control {
/** Register an event handler */
//% block
void onEvent(int eventType, Action handler) {
// arrange for pxt::runAction0(handler)
// to be called when eventType is triggered
}
}
// 生成的TypeScript
declare namespace control {
/** Register an event handler */
//% block shim=control::onEvent
function onEvent(eventType: number, handler: () => void): void
}
C++ 的命名空间和函数名直接映射到 STS 对等的代码上,原先的代码文档注释也会对应复制。注释//% block
指定函数暴露为 Blockly 图形代码块,它也会被复制。还有许多其它可能的注释可以控制等效图形的外观。STS 函数还会有一个附加的shim
注释,标注对应的 C++ 函数名(某些情况下,STS 声明是用用户编写的,这时候 C++ 函数名不必与 STS 函数名对应)。C++ 的类型会映射到 STS 的类型,由于在 STS 中所有数字类型理论上都是双精度的(),所以 C ++ 的int
类型会映射到 STS 的number
类型。当 C++ 函数被调用时,STS 编译器会确保被传递的值被转换为整数。其它的 C++ 整数类型(例如uint16_t
)也用相似的方法被支持。C++的Action
类型代表对一个闭包的引用,它将和pxt::runAction0()
一起被调用。
类方法还没有被直接支持,常规的函数能用于实现对象,例如:
typedef BoxedBuffer *Buffer;
namespace BufferMethods {
//%
int getByte(Buffer self, uint32_t i) {
return i < self->len ? self->data[i] : 0;
}
}
interface Buffer {
//% shim=BufferMethods::getByte
getByte(i: number)
}
所有在BufferMethods
命名空间中的函数都必须将Buffer
作为第一个参数,并在 STS 这边作为Buffer
类的成员。当这些成员被调用时,STS 编译器会确认第一个参数不是null
并且是Foo
的子类型。这些接口在概念上可以理解为具有不透明表示形式的不可扩展类,即它们不能由常规类实现,并且成员解析是静态的(static)。选择接口语法是因为 TypeScript 允许用新方法在文件之间扩展接口。如果新方法具有上述的shim = ...
注释,或者指定了使用 TypeScript 而不是 C ++替换函数的模拟注释,那么我们允许此类添加。这通常只在高级 C++ 包的开发中涉及(见下文)。
2.2 包(Packages)
STS 支持多种输入文件,也支持 TypeScript 的namespace
语法用以区分作用域。文件不会引入作用域,并且当前不支持 JavaScript 模块。输入文件可以来自一个或多个包。其中一个包为主包,可以列出其他包作为依赖关系,而后者又可以列出其他的依赖关系。有多种方法可以指定包的版本,包括内置包、用命令行进行操作时指定文件路径、GitHub 仓库的 URL。使用包时只能用一个版本(否则可能会发生重定义错误)。
MakeCode 编辑器的构建者通常会决定打包一些随编辑器提供的内置包,这些扩展可以通过 GitHub 中的包进一步扩展。MakeCode Web 应用程序支持将包发布到 GitHub。因为命名空间独立于文件,所以包很容易扩展现有的命名空间。目前,STS 不对命名空间名称执行检查。
MakeCode 附带了许多编辑器构建器可以附带的包(通用包),它们用以支持各种硬件功能(引脚、按钮、蜂鸣器、屏幕等),以及诸如处理 Sprite 的游戏库之类的高级概念。其中一些包是变体,共享界面但具有不同的实现方式(例如,用于不同屏幕的驱动程序)。
外部(GitHub)包通常为其他硬件拓展设备提供支持。用户通常不会一次使用太多外部包,因此我们感到由于没有让命名空间强制不同名而导致名称冲突的风险很低,并且它允许将新的 API 自然地融入现有的名称空间。
3 编译器和运行时
STS 的编译器和工具链(链接器等)完全使用 TypeScript 编写。现在尚不支持单独编译 STS 代码文件,STS 是一个完整的程序编译器(支持缓存预编译的包,其中包含了 C++ 运行时)。STS 的设备运行时主要是由 C++ 编写的,包含定制的垃圾回收器。正如前文提到的,STS 并不计划支持 JavaScript 的全部功能。
3.1 编译工具链
TypeScript 源程序由常规 TypeScript 编译器处理,执行包括类型检查在内的语法和语义分析;这个过程产出带有类型注释的抽象语法树(AST),然后检查是否有 STS 范围之外的语言构件(类似eval
和arguments
等)。抽象语法树随后会转化为具有语言构件的自定义 IR 用以调用运行时函数。这种 IR 之后会被转换为下列的三种形式之一:
-
继续传递 JavaScript 运行到浏览器中(在单独的 iframe “模拟器”里)。
-
与预编译的 C++ 运行时链接的 ARM Thumb 机器码,用以在裸机(译者注:指没有配置操作系统和其他软件的计算机)和操作系统内部运行。
-
自定义虚拟机的字节码,用于无法加载或生成动态语言的平台(例如 XBox 和 iOS)。
ARM Thumb 机器码和自定义的字节码全都被生成为汇编代码,再由定制的汇编器转换为机器码。在本节中我们主要讨论原生 32-bit ARM Thumb 的转化过程(我们会在 4.2 节对比虚拟机的性能)。
本段提到的常规的 TypeScript 编译器、STS 代码生成器、汇编器(assembler)、链接器(linker)均由 TypeScript 实现并且全部运行在浏览器或命令行中。
3.2 链接
生成的机器码将与一个预编译的 C++ 运行时链接。C++ 的编译在云上运行,编译生成的运行时缓存在 CDN 和浏览器中(可以选择使用所有 C++源码的强哈希算法进行缓存)。通常来说,用户编写他们的程序时,C++ 运行时不会更改,从而让离线操作成为可能(尽管我们可以使用 Emscripten 或类似的技术在本地对 C++ 进行编译,但编译工具链、头文件和相关的库可能需要数十兆的下载请求,导致浏览器的离线缓存空间紧张)。
包可能还要包含从运行时中继承出来的 C++ 代码。包含 C++ 的包组合都必须分别进行编译和缓存,这些也都是在云上运行的。到目前为止,这些可能是天量数字的包组合方式还没有出现大问题,因为学生一般不会一次使用很多外部的包,而且我们的经验是,为 MakeCode 编写的包很少使用原生 C++。
3.3 值的表示
范围为 的数字 由 32 位的值 表示,所以最低位是确定的。其它的数字由常规对象特别是装箱的 64 位双精度数字类型表示。
特殊的常量,例如true
、false
和null
由特定的 32 位值表示,最低位是确定的,最低位的下一位也是确定的(有
个这样的值的编码空间,但目前仅使用了几个位置)。JavaScript 的undefined
由0
表示(对应 C++ 的NULL
),因为它是内存分配以及 JavaScript 字段和变量的默认值。
所有其他值均表示为指向常量内存分配的对象或堆上分配的对象的指针。所有指针都是字对齐的(32 位),因此最低两位也是确定的。对于由 STS 运行时创建的对象,第一个字由指向虚函数表表的指针占用。
3.4 虚函数表布局(Virtual table layout)
与 Java 或 C# 相似的是,STS 类的编译具有单继承和静态内存布局(static memory layout)的特点。对象的第一个字(word)指向一个虚函数表,随后的字被分配给字段(如果有基类的字段,会放在前面)。虚函数表包含对象的空间、静态分配的类的序号(包括 STS 支持的内置类型和用户定义的类型的标记)、一个指向接口表和接口哈希表的指针(见下文),该指针之后是指向方法的指针。
最开始的四个方法插槽(slots)是为与对象标记和释放相关的运行时和 GC 相关的功能预先分配的,这些函数遵循 C++ 调用的约定。而其余的函数则遵循 STS 的调用约定。第一个 STS 方法指针是toString()
,前提是它已经被定义了。(注 7:toString()
和valueOf()
在 JavaScript 运行时语义转换中扮演了特殊的角色,我们暂时还不支持valueOf
)而之后是基类定义的方法,再之后是现有的类的方法。如果一个方法从未被动态调度(dynamic dispatch)所调用(例如,从未被重写或从未调用),则该方法不会被加入虚函数表(在 MakeCode Arcade 有引擎中,只有 13%的方法使用动态调度)。
接口表按之前定义的顺序包含类的所有字段和方法的描述符。每个描述符都包含成员索引(分配给成员的名称)和一个函数指针。字段描述符还包含字段的偏移量。该描述符用于成员的查找和对象属性的迭代(如使用Object.keys()
)。描述符用一个简单的哈希表索引,它通过成员索引乘以从虚拟表中获取的接口哈希乘数(在编译时按类计算)来计算键名。
STS 使用三种成员查找方法:
-
当接收方为静态的类
C
时,编译器将针对 C 进行运行时子类型检查(有可能不成功,因为动态类型可能不是C
的子类型),然后在虚函数表中用直接偏移量查找方法,或者在对象中使用直接偏移量进行字段访问。 -
如果接收方的动态类型是一个类(静态类型可以是接口、
any
或更复杂的结构类型),则使用成员索引和接口哈希键名在接口表中查找成员描述符。 -
对于在 C ++运行时中实现的对象,使用特定于类型的函数,尤其是在编译对象字面量或
new Object()
时使用的动态映射。4.3 节会对比这三种方法的性能。
3.5 算术运算符
一些算术运算符被编写为快速整数路径的汇编提高运行速度;以下是运算符+
的实现:
_numops_adds:
ands r2, r0, r1 ; r2 := r0 & r1
ands r2, #1 ; r2 &= 1
beq .boxed ; last bit clear?
subs r2, r1, #1 ; r2 := r1 - 1
adds r2, r0, r2 ; r2 := r0 + r2
bvs .boxed ; overflow?
mov r0, r2 ; r0 := r2
bx lr ; return
.boxed:
mov r4, lr ; save return address
bl numops::adds ; call into runtime
bx r4 ; return
其它有特殊实现的算术运算符有-
、|
、&
、^
和整数转换(调用 C++ 运行时函数时使用)。这些特别的汇编程序比始终调用 C++ 函数要快约两倍。我们在 4.3 节对乘法的性能做了比较。除此之外的运算符则用 C++ 实现为使用整数、限制范围的数值和其它类型的抽象值的函数。
3.6 内置对象(built-in)的表示
数组(Arrays) 和 C++ 标准向量(vector)类似,但有相对保守的增长策略并且不支持稀疏数组(sparse array)。包括范围检查在内的简单数组访问操作用用汇编实现。涉及索引转换和数组增长的情况用 C++ 运行时处理。缓冲区只是连续的内存块,可以通过汇编字节访问器和几个用 C++ 写的工具函数来访问。
字符串(Strings) 会有四种不同的表示,它们的开头都会有一个虚函数表(v-table)指针。所有的字符串当前被限制为最大占用 65,535 字节的空间。ASCII 字符串(所有在 0-127 范围内的字符)用长度前缀加 NUL 中止的字符数据表示(其内部仍然可以有 NUL,只是为了方便 C++ 函数使用所以添加最后的 NUL)。较短的 Unicode 字符串使用 UTF-8 的可变长度编码,它们拥有不同的虚函数表。索引方法可以即时解码 UTF-8。
更长的 Unicode 字符串表示为字符串的长度(以字符为单位)、字符串的大小(以字节为单位)以及指向数据的指针,该指针包含 UTF-8 中的实际字符数据(再次以 NUL 终止)和跳过列表,包含每个可被 16 整除的字符偏移量的字节偏移量。索引方法从最接近的前一个跳过列表偏移量开始,然后从那里开始对 UTF-8 数据进行解码。此编码用于代替标准 UTF-16,以节省空间。它还可以确保所有字符串(以下所述的临时字符串除外)都包含有效的 UTF-8 NUL 终止的字符串,从而使 C++ 运行时函数无需附加转换即可轻松处理它们。
最后,当两个长字符串连接在一起时分配 cons-string。它们由两个指向字符串的指针组成(它们本身可以是 cons-string)。当 cons-string 被索引时,它就地转换为一个跳过列表字符串(它们都是 12 个字节)。这使得字符串连接(在 JavaScript 中非常常见)成为了恒定时间操作。这是一种已经广泛使用的优化技术[4],所有主要的 JavaScript 引擎都支持这项技术。
前三种字符串是由编译器在闪存中进行的,而所有四种字符串都可以动态构造。
闭包(Closures) 用指向函数静态代码的指针表示,这些代码包含从外部作用域能获取的只读本地变量(不包含最外层的全局变量)。如果一个变量被获取后可以写入,它将转换为只想保存其值的堆对象的指针(在任何作用域中)。在闭包执行期间,会静态分配一个特定的寄存器来保存指向闭包对象的指针。
函数会像值一样被使用,但不会获取任何东西,在闪存中以上述的格式静态分配。
动态映射(Dynamic maps) 是简单的两个向量,其中一个为键名,另一个为值。查找是线性的。
3.7 窥孔优化(Peep-hole optimizations)
在完成代码生成之后,生成的汇编代码会经历一次简单的窥孔优化。我们会用对生成的代码运行脚本,识别要被优化的特定指令序列,该脚本会识别到 2-3 条指令序列,并按它们出现的次数进行排序,然后对最常出现的指令序列检视是否能进行简化。典型的窥孔优化规则如下:
push {lr}; push {X, ...} -> push {lr, X, ...}
pop {X, ...}; pop {pc} -> pop {X, ..., pc}
push {rX}; pop {rX} -> nothing
push {rX}; pop {rY} -> mov rY, rX
pop {rX}; push {rX} -> ldr rX, [sp, #0]
push {rX}; ldr rX, [sp, #0] -> push {rX}
push {rX}; movs rY, #V; pop {rX} ->
movs rY, #V (when X != Y)
对于分支,编译器始终会生成一个完全通用但繁琐的指令序列,然后由窥孔优化器简化。以X
在短跳范围举例:
beq .skip; b X; .skip: -> bne X
事实上,b
本身也有着范围的限制并且有时候需要使用bl
来完成。相关的评估请参阅 4.3 节。
3.8 垃圾回收器
STS 运行时拥有一个定制、简洁、精确、紧凑、标记清除(译者注:Mark and Sweep Algorithm,即标记清除算法)的垃圾回收器。运行时会追踪所有执行线程关于 STS 的堆栈。STS 堆栈仅包含上述的统一格式的值。在收集它们时,这些堆栈、全局变量以及 C++ 运行时动态注册的指针都被视作活动指针,还会对它们进行递归扫描,查找其它活动指针。所有对象都从一个指向虚函数表的指针开始,虚函数表具有确定对象大小的方法。垃圾回收不会产生额外的空间开销,虚函数表的最低位用于标记可访问对象,而大小算法(在 Cortex-M4 上,间接调用开销仅为 4-8 个时间周期)用于在扫描阶段标记堆。
当一个 STS 函数首次调用时(会有一个 C++ 栈对应在下面),一个 per-thread 对象记录了 STS 栈指针,栈指针在 C++ 函数被调用前被储存。如果 C++ 函数又一次调用 STS(这种情况很少见),则会将链接列表片段(linked list segment)添加到每个 pre-thread 对象中。这些 per-thread 对象由线程调度器保留。
为了避免堆内存变得碎片化,我们触发垃圾回收的次数比严格必要触发次数要多。特别要提到的是,在每次垃圾回收之后,我们将第一个周期的可用内存标记为可分配。当无法在此处分配对象时,将触发新的垃圾回收,可能会将“好”区的位置移动。之后,无论对象能否装入(新的)“好”区,我们都会对其进行分配。这有可能触发太多次回收,但是这可以限制堆内存碎片化,当在执行过程中稍后需要分配更大的对象(例如屏幕缓冲区)时,这是至关重要的。此外,由于目标单片机的内存相对较小且处理器速度较快,垃圾回收的代价相当低廉。
C++ 代码可以分配常规的可用于 GC 的对象,然后在 STS 端使用,也可以将传统的malloc()/free()
方法用于其他用途。这种malloc
块使用特殊的编码,包括代替虚函数表指针的空间,并且直到将其释放后才进行回收。这种堆内存的共享可以避免提前将内存拆分为 C++ 和 GC 堆。我们仍然保留一个小的 C++ 堆在中断服务程序中使用,因为 GC 不能在用在那里。
除了寄存器之外,从 STS 调用的 C++ 函数的参数也放置在 STS 堆栈上,因此在函数运行时它们不会进行回收处理。任何分配都可以触发垃圾回收,因此在 C++ 函数中分配的中间对象必须在返回给 STS 之前临时向垃圾回收器注册。
引用计数(Reference-counting) 我们曾使用引用计数进行内存管理,在执行期间增加对 C++ 函数的所有参数的引用计数。与上述垃圾回收过程相比,会相对产生巨大的时间开销(请参见图 7)。
4 性能评估
我们在一系列著名的小型性能密集型测试基准中评估了 STS 编译生成的机器码的性能(同时也评估了 STS 的虚拟机后端),并与以下的竞争对手们做了对比:
-
一种用 gcc 编译的纯 C 实现,用来做对比实验的基准(baseline) -
Duktape 2.3,一种嵌入式 JavaScript 解释器 -
IoT.js 1.0,JerryScript 的嵌入式 JavaScript 解释器 -
Python 3.6,常见且成熟的 Python 解释器 -
Node.js 11.0,包含了 V8 这一前沿的 JIT 引擎的运行时 -
MicroPython 1.9.4,一种嵌入式 Python 解释器
我们使用三种不同的基于 ARM 的商用嵌入式系统进行测试:
-
GHI Brainpad,使用 STM32F401RE 芯片, 拥有 96kB RAM、512kB 闪存和主频为 84MHz 的 ARM Cortex-M4F 核心。 -
Adafruit Pybadge,使用 ATSAMD51G19 芯片,拥有 192kB RAM、512kB 闪存和主频为 120MHz 的 ARM Cortex-M4F 核心。 -
树莓派 Zero(Raspberry Pi Zero),使用 BCM2835 芯片,拥有 512kB RAM 和恒定主频为 700MHz (关闭动态 CPU 主频调整)的 ARM11 核心。
树莓派可以运行 Node.js,所以它的硬件条件超出了我们设定的内存范围,但我们使用它来作为 V8 的性能参考。通常来说,ARM11 核心的内存访问速度比 M4F 核心(整体 RAM 在性能上与 L1 缓存类似)的访问速度要慢。M4F 核心上的浮点运算器是单精度的,所以没有用于基准测试,我们转而使用了 ARM11 上的浮点运算器。
Duktape 使用 BCM 上的默认配置进行编译。在 STM 上,使用默认配置会立刻耗尽内存,所以我们选用了适用于低内存的配置文件(还启用了 fast integer 选项)。我们用的是官方的 ARMv6 Node.js 二进制文件、PiCore Linux 附带的 Python 环境、STM32 的官方 MicroPython 二进制文件。
4.1 基准测试
我们使用以下的一系列基准测试。我们尝试使用在 TypeScript/JavaScript 和 Python 中等效的代码,然后对比运行时间而不是语言。(更具体地说,Python fann 使用的算法不同于 C 或 JavaScript 版本,我们对其进行了修改,使它们使用相同的算法且直接进行数组访问;Python 二进制文件更改为使用类而不是元组。之所以进行这些更改,是因为我们想测试数组访问和类实现的性能。我们没有改变 richards 或 nbody 的实现。)由于 C 程序内存不安全且使用静态的整数表示形式,所以它们不一定具有可比性。
**Richards:**Martin Richard's 基准测试模拟操作系统的调度器队列。我们从 JetStream 基准套件[11]中获取 JavaScript 代码,并将原型初始化替换为等效的类代码,在需要的时候添加类型信息,从而将它们转换为 TypeScript 代码。该基准测试使用许多类以及用于不同调度程序任务的派生方法,用以测试对象属性访问性能。我们还使用了该基准测试的 C 和 Python 版本,它们使用单一结构以及 switch 语句和函数指针执行任务。我们开发了等效的 TypeScript 代码(标记为 richards2),但是基于类的版本(richards)似乎可以更好地反映典型的 JavaScript 编码模式。该基准测试的参数是数千次调度迭代的具体次数。
**Binary trees:**该程序来自“计算机语言基准游戏”[10]。在循环内,它构建一个指定深度的完整二叉树,对其执行简单的计算,然后回收内存分配。该基准测试用以测试内存分配子系统的性能。C、Python 和 TypeScript 版本均来自“计算机语言基准游戏”。为了更好地对齐 TypeScript 版本,我们将 Python 版本修改为使用类。
**Fankuch redux:**这是基准测试游戏中的另一个程序,该程序计算特定排列的数量并测试数组访问和整数性能。我们修改了 Python 程序,以便于使用含显式的单元素数组访问的等效算法,与 C 和 TypeScript 版本对齐。
**N-body:**这个程序是对太阳系中行星的物理模拟,也来自“计算机语言基准游戏”,用于测量浮点性能。
4.2 STS 和 VM 的性能
图 4 对比了 STS 和其他运行时的性能。图中将 C 程序的运行耗时直接用毫秒单位列出,其他的耗时则以比 C 程序慢多少的差值列出,以表达对 C 的敬意。
在高频属性访问(Binary 和 richards)基准测试中,STS 比解释器们要快一个数量级,比 Node.js 慢不到两倍,而且解释器的内存耗尽要比二进制上的 STS 快得多。对于直接组合计算(fann),STS 比解释器快大约一个数量级,但比 Node.js(在 V8 中使用了相当先进的优化器)慢几倍。在浮点计算(nbody)中,STS 仍比解释器快得多,但比 Node.js 慢 20 倍,后者更好地利用了 FPU。
一般来说,运行时间越长的基准测试越能表明性能。我们还列出了迭代次数较少的结果,以便能够在同一程序上直接比较 BCM 和 STM 的性能差异。在此类程序上,JIT 预热时间在 Node.js 结果中占很大一部分。
STS VM 比 ARM Thumb STS 慢 5-6 倍,除了高频的浮点 nbody 基准测试外,该基准测试的大部分运算都是在 C++ 运行时函数中执行的。但是,STS VM 仍然比解释器快得多, 这表明类的静态内存布局对 STS 的整体性能大有裨益。
STM32 和 SAMD 上的实验结果差异很小(约 0.1%)。在 BCM 核心上,我们运行了 10 次基准测试,并选择了最快的结果作为实验数据。
4.3 成员访问(member access)的性能
图 5 记录了各种形式的成员访问(请参阅 3.4 节)的耗时测算,表格的前四行分别是循环进行对this
下的字段访问、具有动态子类型检查的访问、通过接口查找的访问、对动态映射对象访问的耗时。
而对于函数,本图列举了直接调用、对非虚拟类方法(non-virtual)的调用、对虚拟类方法的调用、对接口的调用的耗时情况。我们也区分了是否在this
上调用,因为这样调用不需要进行动态类型检查,其他的非接口调用也是如此。
子类型检查(subtype check)决定了变量的虚拟和非虚拟调用,它的执行方式有细微的不同,而最终对应的性能几乎一致。接口查找不需要类型检查(因为该方法是从调用它的对象的虚函数表中查找的),这让它的速度并没有落后常规调用很多。动态映射查找取决于对象字段的数量,但其它的耗时大多和输入没有关系。
图 7 显示了在完整的基准测试中不同的访问成员方式的性能影响。第一行(即基准测试)列出了默认模式下的运行时间,在该模式下,类除了具有较慢的接口表外,还具有类似于 Java 的虚拟表,并且类的字段通过内存查找以静态计算的偏移量进行访问。
第二和第三行显示了通过接口表查找方法和字段时发生的速度下降。第四行显示了将对象表示为具有线性查找的动态映射后的减慢速度,基准测试允许以非侵入方式启用该功能,而字段访问的减慢速度相当惊人,约慢 2 倍。
再下一行显示了访问this
的成员时删除动态子类型检查的效果(因为在输入方法时已经检查了其类型),禁用该优化将导致对方法调用也执行动态查找。
再下一行将简单的标记清除(mark-and-sweep)垃圾收集器的性能与引用计数进行了比较(详情请参阅 3.8 节)。
再之后是将参数转换放到辅助函数中的影响。虽然影响很小,但是节省了 Arcade 代码大小的 4%。下一行的数据展示了窥孔优化器的效果,该优化器在 Arcade 上节省了 14%的代码大小,并将性能提高了百分之几。
最后一行测量了用来保护类抽象的所有动态子类型检查(在基准测试中启用)的影响。这是为了遵循 TypeScript 语义而付出的代价,其中强制转换始终是成功的,但成员访问可能会失败。
4.4 语言基础构造的性能
图 6 展示了各种语言基础构造的循环耗时测算。与图 5 的策略一样,我们对每种基础构造操作一百万次,减去空循环所消耗的时间,然后将时间转换为主时钟周期进行比较。
两个 M4 内核的性能测试结果非常相似,但也有些不同——在这两种情况下,程序都是在闪存中运行的,这比 RAM 慢得多,而且 MCU 缓存的方式也不同。BCM 的数组处理性能更差,可能是因为主内存访问需要更多的耗时,并且缓存也不能完全解决这个问题。对于内存分配(包括 GC 的摊销成本),这个问题尤其明显,对浮点运算也有很大影响。另外,ARM11 上没有硬件整数除法。
整数除法和乘法目前在汇编中没有高速实现的方法(因为 ARM 单片机之间的差异),因此它们比加、减、位运算要慢。
4.5 MakeCode Arcade 的性能
图 2 中展示的 MakeCode Arcade 掌上游戏机比上世纪八十年代的原版 Arcade 游戏机快 100 倍。但是,当年的游戏是用汇编开发的,并且大量使用了图形加速(sprite 等)。另一方面,MakeCode Arcade 使用自动垃圾回收语言,并且提供了高级封装且对初学者友好的 API。而更复杂的游戏(如图 3 中的游戏)可以以 30 fps 运行;根据我们的基准测算,使用嵌入式解释器的运行速度则为 1-5 fps。
我们还评估了 Arcade 游戏的代码大小。在 STS 编译器中,每个输入(JavaScript)语句会生成大约 37 个字节的 ARM Thumb 机器码,每个输入代码行大约生成 20 个字节的 ARM Thumb 机器码。需要注意的是,未使用的代码部分将被删除,因此生成的二进制文件中永远不会存在完整的游戏引擎。相比之下,使用专用 16/32 位编码的 STS 虚拟机可获得大约两倍的代码密度。
C ++运行时占用了约 125kB 的闪存,而引导加载程序和内部文件系统的空间又占用了 64kB,因此程序和游戏资源还剩下约 320kB 的可用空间,这意味着用户应用程序(游戏引擎以外的程序)限制为 5-10k 行代码,就目前来看,这并不是一个很大的限制。
5 相关的工作
Safe TypeScript[13]和 StrongScript[14]都通过运行时类型检查支持了完整的 TypeScript 的类型系统。我们的工作最接近 StrongScript,因为 STS 使用类的名义解释(nominal interpretation)进行代码生成,且 STS 运行时会区分动态映射对象和类对象,分别生成 JavaScript 的{x=...}
语法和new C(...)
语法。
STS 和 StrongScript 也有一些区别,首先,StrongScript 的类型系统保证在不存在类型向下转换的情况下,具体类C
的变量引用名义上为C
的子类型的对象或为null
。而 STS 使用 TypeScript 的类型推断和类型检查,没有做这方面的修改。在 STS 中,类C
的变量会被动态检查(通过查找字段/方法),确定其值是否为类C
的名义子类型。其次,STS 会删除强制类型转换,StrongScript 会在运行时对其进行检查,而如上所述,STS 会在解引用时(成员/字段查找)执行检查。Strong 允许使用额外的属性拓展类对象,而 STS 目前不允许进行这样的拓展。
Hop.js[17]是一个拥有高级类型接口的静态编译器,它会将 JavaScript 程序转换为 Scheme,之后再使用 Bigloo 进行编译。据称在组合基准测试(例如fann)测出的性能要好于 STS,而在Richards上则稍差一些(测算结果依赖的平台是 x86 架构的处理器,因为在树莓派上进行测试,我们根据相对于 V8 的数字进行估算)。
SJS[5,6]是一个相似的系统,但它使用 Java 实现,同时生成的代码是 C 语言而不是 Scheme。同样,在组合基准测试上,它的表现似乎比 STS 更好,而在 Richards 上,表现稍差。这些编译器都不适合在 Web 浏览器中运行。通常来说,它们在可以推断静态类型时似乎能生成非常高效的代码,而在无法编译时,它们会使用慢得多的运行时路径。另一方面,STS 表现出相当平滑且可预测的性能,今后也许会对它们的处理措施进行结合。
除了我们所比较的嵌入式解释器外,还有其他的一些解释器,例如 XS7 [21]或 Espruino[23]。它们具有相近的性能。
6 总结
Static TypeScript(STS)填补了嵌入式编译器中有趣的空白。STS 的工具链完全由 TypeScript 实现,使之可以在浏览器运行并能为覆盖大部分 TypeScript 功能的子集生成 ARM(Thumb)机器码。STS 利用类的名义类型解释(nominal interpretation)提高编译代码的效率。得益于 MakeCode 项目的流行,STS 现已广泛运行在许多低 RAM 的设备上。一组对 STS 的小型基准测试表明,STS 生成的代码比各种脚本语言的嵌入式解释器的运行速度要快得多。迄今为止最大的 STS 应用程序是 MakeCode Arcade,其游戏引擎有超过 10,000 行 STS 代码。
致谢 我们要感谢 MakeCode 团队的成员和前成员:Abhijith Chatra、Sam El-Husseini、Caitlin Hennessy、Steve Hodges、Guillaume Jenkins、Shannon Kao、Richard Knoll、Jacqueline Russell、Daryl Zuniga。我们还要感谢 Lancaster 大学的 James Devine 和 Joe Finney,他们开发的 CODAL 是 STS 的 C++运行时的一部分。最后,我们还要感谢匿名审稿人的评论,并感谢 Edd Barrett 帮忙润色本文的最终版本。
[1] Jonny Austin, Howard Baker, Thomas Ball, James Devine, Joe Finney,Peli de Halleux, Steve Hodges, Michal Moskal, and Gareth Stockdale. 2019. The BBC micro:bit – from the UK to the World. Commun. ACM(to appear) (2019).
[2] BBC. 2017. BBC micro:bit celebrates huge impact in first year, with 90% of students saying it helped show that anyone can code. https://www.bbc.co.uk/mediacentre/latestnews/2017/microbit-first-year.
[3] Gavin M. Bierman, Martín Abadi, and Mads Torgersen. 2014. Understanding TypeScript. In ECOOP 2014 - Object-Oriented Programming - 28th European Conference, Uppsala, Sweden, July 28 - August 1, 2014.Proceedings. 257–281. https://doi.org/10.1007/978-3-662-44202-9_11
[5] Satish Chandra, Colin S. Gordon, Jean-Baptiste Jeannin, Cole Schlesinger, Manu Sridharan, Frank Tip, and Young-Il Choi. 2016.Type inference for static compilation of JavaScript. In Proceedings of the 2016 ACM SIGPLAN International Conference on Object-Oriented Programming, Systems, Languages, and Applications, OOPSLA 2016, part of SPLASH 2016, Amsterdam, The Netherlands, October 30 - November 4,2016. 410–429. https://doi.org/10.1145/2983990.2984017
[6] Wontae Choi, Satish Chandra, George Necula, and Koushik Sen. 2015.SJS: A type system for JavaScript with fixed object layout. In International Static Analysis Symposium. Springer, 181–198.
[7] James Devine, Joe Finney, Peli de Halleux, Michal Moskal, Thomas Ball,and Steve Hodges. 2018. MakeCode and CODAL: intuitive and efficient embedded systems programming for education. In Proceedings of the 19th ACM SIGPLAN/SIGBED International Conference on Languages,Compilers, and Tools for Embedded Systems, LCTES 2018, Philadelphia,PA, USA, June 19-20, 2018. 19–30.
[8] Evgeny Gavrin, Sung-Jae Lee, Ruben Ayrapetyan, and Andrey Shitov.2015. Ultra Lightweight JavaScript Engine for Internet of Things. In SPLASH Companion 2015. 19–20.
[9] Damien George. 2018. MicroPython. http://www.micropython.org.
[10] Isaac Gouy. 2018. The Computer Language Benchmarks Game. https://benchmarksgame-team.pages.debian.net/benchmarksgame/.
[11] Apple Inc. 2018. JetStream Benchmarks 1.1. https://www.browserbench.org/JetStream/in-depth.html.
[12] Adafruit Industries. 2018. CircuitPython. https://github.com/adafruit/circuitpython.
[13] A. Rastogi, N. Swamy, C. Fournet, G. M. Bierman, and P. Vekris. 2015.Safe & Efficient Gradual Typing for TypeScript. In Proceedings of the 42nd Annual ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages. 167–180. http://doi.acm.org/10.1145/2676726.2676971
[14] G. Richards, F. Z. Nardelli, and J. Vitek. 2015. Concrete Types for TypeScript. In 29th European Conference on Object-Oriented Programming,ECOOP 2015. 76–100. https://doi.org/10.4230/LIPIcs.ECOOP.2015.76
[15] Samsung. 2018. JerryScript. http://jerryscript.org
[16] Sue Sentance, Jane Waite, Steve Hodges, Emily MacLeod, and Lucy Yeomans. 2017. "Creating Cool Stuff": Pupils’ Experience of the BBC Micro:Bit. In Proceedings of the 2017 ACM SIGCSE Technical Symposium on Computer Science Education (SIGCSE ’17). ACM, 531–536. https://doi.org/10.1145/3017680.3017749
[17] Manuel Serrano. 2018. JavaScript AOT compilation. In Proceedings of the 14th ACM SIGPLAN International Symposium on Dynamic Languages. ACM, 50–63.
[18] Jeremy G. Siek and Walid Taha. 2007. Gradual Typing for Objects. In ECOOP 2007 - Object-Oriented Programming, 21st European Conference, Berlin, Germany, July 30 - August 3, 2007, Proceedings. 2–27. https://doi.org/10.1007/978-3-540-73589-2_2
[19] Artifex Software. 2018. MuJS. https://mujs.com/.
[20] Cesanta Software. 2018. mJS. https://github.com/cesanta/mjs.
[21] Patrick Soquet. 2017. XS7. https://www.moddable.com/XS7-TC-39.
[22] Sami Vaarala. 2018. DukTape. https://duktape.org/.
[23] Gordon Williams. 2017. Making Things Smart: Easy Embedded JavaScript Programming for Making Everyday Objects into Intelligent Machines. Maker Media.