作者:jtriley.ethjtriley.eth
编译:0x11,Foresight News
以太坊虚拟机 (EVM) 是一个 256 位、基于堆栈、全球可访问的图灵机。由于架构与其他虚拟机和物理机的明显不同,EVM 需要领域特定语言 DSL(注:领域特定语言指的是专注于某个应用程序领域的计算机语言)。
在本文中,我们将研究 EVM DSL 设计的最新技术,介绍六种语言 Solidity、Vyper、Fe、Huff、Yul 和 ETK。
语言版本
1、Solidity: 0.8.19
2、Vyper: 0.3.7
3、Fe: 0.21.0
4、Huff: 0.3.1
5、ETK: 0.2.1
6、Yul: 0.8.19
阅读本文,需要你对 EVM、堆栈和编程有基本的了解。
以太坊虚拟机概述
EVM 是一个基于 256 位堆栈的图灵机。然而,在深入研究它的编译器之前,应该介绍一些功能特性。
由于 EVM 是「图灵完备」的,它会受到「停机问题」的困扰。简而言之,在程序执行之前,没有办法确定它未来是否会终止。EVM 解决这个问题的方法是通过「Gas」计量计算单位,一般来说,这与执行指令所需的物理资源成比例。每个交易的 Gas 量是有限制的,交易的发起者必须支付与交易消耗的 Gas 成比例的 ETH。这个策略的影响之一是,如果有两个功能上相同的智能合约,消耗更少 Gas 的合约将被更多采用。这导致协议竞争极端的 Gas 效率,工程师努力最小化特定任务的 Gas 消耗。
此外,当调用一个合约时,它会创建一个执行上下文。在这个上下文中,合约有一个堆栈用于操作和处理,一个线性内存实例用于读写,一个本地持久性存储用于合约读写,并且附加到调用的数据「calldata」可以被读取但不能被写入。
关于内存的一个重要说明是,虽然它的大小没有确定的「上限」,但仍然是有限的。扩展内存的 Gas 成本是动态:一旦达到阈值,扩展内存的成本将呈二次方增长,也就是说 Gas 成本与额外内存分配的平方成正比。
合约也可以使用一些不同的指令来调用其他合约。 「call」指令将数据和可选的 ETH 发送到目标合约,然后创建自己的执行上下文,直到目标合约的执行停止。 「staticcall」指令与 「call」相同,但增加了一个检查,即在静态调用完成之前,断言全局状态的任何部分都未被更新。最后, 「delegatecall」指令的行为类似于 「call」,只是它会保留先前上下文的一些环境信息。这通常用于外部库和代理合约。
为什么语言设计很重要
在与非典型架构交互时,特定领域语言(DSL)是必要的。虽然存在诸如 LLVM 之类的编译器工具链,但是依赖它们来处理智能合约,在程序正确性和计算效率至关重要的情况下,不太理想。
程序正确性非常重要,因为智能合约默认是不可变的,并且鉴于区块链虚拟机(VM)的属性,智能合约是金融应用程序的热门选择。虽然存在针对 EVM 的升级性解决方案,但它充其量只是一个补丁,最坏的情况是任意代码执行漏洞。
计算效率也非常关键,因为最小化计算具有经济优势,但不能以安全为代价。
简而言之,EVM DSL 必须平衡程序正确性和 Gas 效率,在不牺牲太多灵活性的情况下通过做出不同的取舍来实现其中之一。
语言概览
对于每种语言,我们将描述它们的显着特性和设计选择,并包括一个简单的计数功能智能合约。言语流行度是根据 Defi Llama 上的总锁定价值 (TVL) 数据确定的。
Solidity
Solidity 是一种高级语言,其语法类似于 C、Java 和 Javascript。它是按 TVL 计算最受欢迎的语言,其 TVL 是第二名的十倍。为了代码重用,它使用面向对象模式,智能合约被视为类对象,利用了多重继承。编译器采用 C++ 编写,计划在将来迁移到 Rust。
可变的合约字段存储在持久性存储中,除非它们的值在编译时(常量)或部署时(不可变)已知。合约内声明的方法可以声明为 pure、view、payable,或默认情况下是 non-payable 但状态可修改。pure 方法不会从执行环境中读取数据,也不能读取或写入持久性存储;也就是说,给定相同的输入,pure 方法将始终返回相同的输出,它们不会产生副作用。
view 方法可以从持久性存储或执行环境中读取数据,但它们不能写入持久性存储,也不能创建副作用,例如附加事务日志。payable 方法可以读写持久性存储,从执行环境中读取数据,产生副作用,并且可以接收附加在调用中的 ETH。non-payable 方法与 payable 方法相同,但具有运行时检查,以断言当前执行上下文中没有附加 ETH。
注意:将 ETH 附加到交易中与支付 Gas 费用是分开的,附加的 ETH 由合约接收,可以通过恢复上下文选择接受或拒绝它。
在合约的范围内声明时,方法可以指定以下四种可见性修饰符:private、internal、public 或 external。private 方法可以通过当前合约内的「jump」指令在内部访问。任何继承的合约都不能直接访问 private 方法。internal 方法也可以通过「jump」指令在内部访问,但继承的合约可以直接使用内部方法。public 方法可以通过「call」指令由外部合约访问,创建一个新的执行上下文,并在直接调用方法时通过跳转进行内部访问。public 方法也可以通过在方法调用前加上「this.」来在新的执行上下文中从同一合约中访问。external 方法只能通过「call」指令访问,无论是来自不同的合约还是在同一合约内,都需要在方法调用前加上「this.」。
注意:「jump」指令操作程序计数器,「call」指令为目标合约的执行期间创建一个新的执行上下文。在可能的情况下,使用「jump」而不是「call」更加节约 Gas。
Solidity 还提供了三种定义库的方式。第一种是外部库,它是一个无状态的合约,单独部署到链上,在调用合约时动态链接,并通过「delegatecall」指令访问。这是最不常见的方法,因为外部库的工具支持不足,「delegatecall」很昂贵,它必须从持久存储中加载额外的代码,并且需要多个事务进行部署。内部库的定义方式与外部库相同,只是每个方法必须定义为内部方法。
在编译时,内部库被嵌入到最终合约中,并且在死代码分析阶段,库中未使用的方法将被删除。第三种方式与内部库类似,但不是在库内定义数据结构和功能,而是在文件级别定义,并且可以直接导入和在最终合约中使用。第三种方法提供了更好的人机交互性,可以使用自定义数据结构,将函数应用于全局作用域中,并一定限程度上将别名运算符应用于某些函数。
编译器提供两个优化通道。第一个是指令级优化器,对最终的字节码执行优化操作。第二个是近期增加使用 Yul 语言(稍后详细介绍)作为编译过程中的中间表示(IR),然后对生成的 Yul 代码进行优化操作。
为了与合约中的公共和外部方法交互,Solidity 规定了一种应用程序二进制接口(ABI)标准来与其合约交互。目前,Solidity ABI 被视为 EVM DSL 的事实标准。指定外部接口的以太坊 ERC 标准都按照 Solidity 的 ABI 规范和风格指南来执行。其他语言也遵循 Solidity 的 ABI 规范,很少出现偏差。
Solidity 还提供了内联 Yul 块,允许对 EVM 指令集进行低级别访问。Yul 块包含 Yul 功能的子集,详细信息请参见 Yul 部分。这通常用于进行 Gas 优化,利用高级语法不支持的功能,并自定义存储、内存和 calldata。
由于 Solidity 的流行,开发人员工具非常成熟且设计精良,Foundry 是在这方面突出的代表。
以下是用 Solidity 编写的一个简单合约:
Vyper
Vyper 是一种语法类似于 Python 的高级语言。它几乎是 Python 的一个子集,只有一些小的不同。它是第二受欢迎的 EVM DSL。Vyper 针对安全性、可读性、审计能力和 Gas 效率进行了优化。它不采用面向对象模式、内联汇编,并且不支持代码重用。它的编译器是用 Python 编写的。
存储在持久性存储器中的变量是在文件级别声明的。如果它们的值在编译时已知,可以将它们声明为「constant(常量)」;如果它们的值在部署时已知,则可以将它们声明为「immutable(不变量)」;如果它们被标记为 public,则最终合约将为该变量公开一个只读函数。常量和不变量的值通过它们的名称在内部访问,但是持久性存储器中的可变量可以通过在名称前面添加「self.」来访问。这对于防止存储变量、函数参数和局部变量之间的命名空间冲突非常有用。
和 Solidity 类似,Vyper 也使用函数属性来表示函数的可见性和可变性。被标记为「@external」的函数可以通过「call」指令从外部合约访问。被标记为「@internal」的函数只能在同一合约中访问,并且必须以「self.」为前缀。被标记为「@pure」的函数不能从执行环境或持久存储中读取数据,也不能写入持久存储或创建任何副作用。
被标记为「@view」的函数可以从执行环境或持久存储中读取数据,但不能写入持久存储或创建副作用。被标记为「@payable」的函数可以读取或写入持久存储,创建副作用,接受收 ETH。没有声明这个可变性属性的函数默认为 non-payable,也就是说,它们和 payable 函数一样,但不能接收 ETH。
Vyper 编译器还选择将局部变量存储在内存中而不是堆栈上。这使得合约更加简单和高效,并解决了其他高级语言中常见的「堆栈过深」的问题。但是,这也带来了一些折衷。
另外,由于内存布局必须在编译时知道,因此动态类型的最大容量也必须在编译时知道,这是一个限制。此外,分配大量内存会导致非线性的 Gas 消耗,正如 EVM 概述部分中提到的。但是,对于许多用例来说,这个 Gas 成本可以忽略不计。
虽然 Vyper 不支持内联汇编,但它提供了更多内置函数,以确保几乎每个 Solidity 和 Yul 中的功能在 Vyper 中也可以实现。通过内置函数可以访问低级位运算、外部调用和代理合约操作,通过编译时提供覆盖文件可以实现自定义存储布局。
Vyper 没有丰富的的开发工具套件,但它有更紧密集成的工具,并且也可以插入到 Solidity 开发工具中。值得关注的 Vyper 工具包括 Titanaboa 解释器,它具有许多与 EVM 和 Vyper 相关的内置工具,可用于实验和开发,以及 Dasy,一种基于 Vyper 的 Lisp,具有编译时代码执行功能。
下面是用 Vyper 编写的一个简单合约:
Fe
Fe 是一种类似 Rust 的高级语言,目前正在积极开发中,大部分功能尚未推出。它的编译器主要用 Rust 编写,但使用 Yul 作为其中间表示形式(IR),依赖于用 C++ 编写的 Yul 优化器。随着 Rust 原生后端 Sonatina 的加入,这一点有望改变。Fe 使用模块进行代码共享,因此不使用面向对象的模式,而是通过基于模块的系统重用代码,在模块内声明变量、类型和函数,可以以类似于 Rust 的方式进行导入。
持久存储变量在合约级别声明,如果没有手动定义的 getter 函数则不可公开访问。常量可以在文件或模块级别声明,并且可以在合约内部访问。当前不支持不可变的部署时变量。
方法可以在模块级别或合约内声明,默认是 pure 和 private。要使合约方法公开,必须在定义前加上「pub」关键字,这使得它可以在外部访问。要从持久化存储变量中读取,方法的第一个参数必须是「self」,在变量名前加上「self.」,使该方法具有只读访问本地存储变量的权限。要读取和写入持久化存储,第一个参数必须是「mut self」。「mut」关键字表示合约的存储在方法执行期间是可变的。访问环境变量是通过将「Context」参数传递给方法来完成的,通常命名为「ctx」。
函数和自定义类型可以在模块级别声明。默认情况下,模块项都是私有的,除非加上「pub」关键字才能访问。但是,不要和合约级别的「pub」关键字混淆。模块的公共成员只能在最终合约或其他模块内部访问。
Fe 暂时不支持内联汇编,相反,指令由编译器内部函数或在编译时解析为指令的特殊函数包装。
Fe 遵循 Rust 的语法和类型系统,支持类型别名、带有子类型的枚举、特征和泛型。目前这方面的支持还有限,但正在进行中。特征可以针对不同类型进行定义和实现,但不支持泛型,也不支持特征约束。枚举支持子类型,并可以在其上实现方法,但不能在外部函数中对其进行编码。尽管 Fe 的类型系统仍在发展中,但它在为开发人员编写更安全、编译时检查的代码方面显示出了很大的潜力。
下面是用 Fe 编写的一个简单的合约:
Huff
Huff 是一种汇编语言,具有手动堆栈控制和对 EVM 指令集的最小化抽象。通过「#include」指令,编译时可以解析任何包含的 Huff 文件,从而实现代码重用。最初由 Aztec 团队编写用于极度优化的椭圆曲线算法,编译器后来被用 TypeScript 重写,然后又被用 Rust 重写。
常量必须在编译时定义,目前不支持不可变量,并且语言中没有显式定义持久性存储变量。由于命名存储变量是高级抽象,因此在 Huff 中写入持久性存储是通过操作码 「sstore」 写入和 「sload」读取。自定义存储布局可以由用户定义,也可以按照惯例从零开始并且每个变量递增使用编译器内在的「FREE_STORAGE_POINTER」。使存储变量外部可访问需要手动定义一个可以读取并返回变量给调用者的代码路径。
外部函数也是高级语言引入的抽象,因此在 Huff 中没有外部函数的概念。但是,大多数项目在不同程度上遵循其他高级语言的 ABI 规范,最常见的是 Solidity。一个常见的模式是定义一个「调度程序」,加载原始调用数据并使用它来检查是否匹配函数选择器。如果匹配,则执行其后续代码。由于调度程序是用户定义的,因此它们可能遵循不同的调度模式。
Solidity 按名称字母顺序对其调度程序中的选择器进行排序,Vyper 按数字顺序排序并在运行时执行二进制搜索,大多数 Huff 调度程序按预期的函数使用频率排序,很少使用跳转表。目前,跳转表在 EVM 中不被原生支持,因此需要使用类似「codecopy」的内省指令才能实现。
内部函数使用「#define fn」指令定义,可以接受模板参数以提高灵活性,并指定函数开始和结束时的预期堆栈深度。由于这些函数是内部的,因此无法从外部访问,在内部访问需要使用「jump」指令。
其他控制流程,例如条件语句和循环语句可以使用跳转目标定义。跳转目标是由标识符后跟冒号定义的。可以通过将标识符压入堆栈并执行跳转指令来跳转到这些目标。这在编译时解析为字节码偏移量。
宏由「#define macro」定义,其他方面与内部函数相同。关键区别在于宏不会在编译时生成「jump」指令,而是将宏的主体直接复制到文件中的每个调用中。
这种设计权衡了减少任意跳转与运行时 Gas 成本之间的关系,代价是调用多次时代码的大小增加。「MAIN」 宏被视为合约的入口,并且其主体中的第一条指令将成为运行时字节码中的第一条指令。
编译器内置的其他特性还包括为日志记录生成事件哈希、为调度生成函数选择器、为错误处理生成错误选择器以及内部函数和宏的代码大小检查器等。
注意:「// [count]」之类的堆栈注释不是必需的,它们只是用于指示该行执行结束时的堆栈状态。
下面是用 Huff 编写的一个简单合约:
ETK
EVM 工具包(ETK)是一种具有手动堆栈管理和最小化抽象的汇编语言。代码可以通过「%include」和「%import」指令进行重用,编译器是用 Rust 编写的。
Huff 和 ETK 之间的一个显着区别是,Huff 为 initcode 添加了轻微的抽象,也称为构造函数代码,这些代码可以通过定义特殊的「CONSTRUCTOR」宏来覆盖。在 ETK 中,这些不会被抽象化,initcode 和运行时代码必须一起定义。
与 Huff 类似,ETK 通过「sload」和「sstore」指令读写持久性存储。然而,没有常量或不可变关键字,但是可以使用 ETK 中的两种宏之一来模拟常量,即表达式宏。表达式宏不会解析为指令,而是生成可用于其他指令中的数字值。例如,它可能不会完全生成「push」指令,但可能会生成一个数字以包含在「push」指令中。
如前所述,外部函数是高级语言概念,因此在外部公开代码路径需要创建函数选择器调度程序。
内部函数不像其他语言那样可以显式定义,而是可以为跳转目标指定用户定义的别名,并通过其名称跳转到它们。这也允许其他控制流,例如循环和条件语句。
ETK 支持两种宏。第一种是表达式宏,可以接受任意数量的参数并返回可用于其他指令的数字值。表达式宏不生成指令,而是生成立即值或常量。然而,指令宏接受任意数量的参数,并在编译时生成任意数量的指令。ETK 中的指令宏类似于 Huff 宏。
下面是 ETK 用编写的一个简单合约:
Yul
Yul 是一种具有高级控制流和大量抽象的汇编语言。它是 Solidity 工具链的一部分,并可以选择在 Solidity 编译通道中使用。 Yul 不支持代码重用,因为它旨在成为编译目标而不是独立语言。它的编译器是用 C++ 编写的,计划将其与 Solidity 通道的其余部分一起迁移到 Rust。
在 Yul 中,代码被分成对象,这些对象可以包含代码、数据和嵌套对象。因此,Yul 中没有常量或外部函数。需要定义函数选择器调度程序才能将代码路径公开到外部。
除了堆栈和控制流指令外,大多数指令在 Yul 中都作为函数公开。指令可以嵌套以缩短代码长度,也可以分配给临时变量,然后传递给其他指令使用。条件分支可以使用「if」块,如果值为非零,则执行该块,但没有「else」块,因此处理多个代码路径需要使用「switch」处理任意数量的情况和「default」后备选项。循环可以使用「for」循环执行;虽然其语法与其他高级语言不同,但提供了相同的基本功能。可以使用「function」关键字定义内部函数,并且与高级语言的函数定义类似。
Yul 中的大多数功能在 Solidity 中使用内联汇编块公开。这允许开发人员打破抽象,编写自定义功能或在高级语法中不可用的功能中使用 Yul。但是,使用此功能需要深入了解 Solidity 在 calldata、memory 和 storage 方面的行为。
还有一些独特的函数。 「datasize」,「dataoffset」和「datacopy」函数通过其字符串别名操作 Yul 对象。 「setimmutable」和「loadimmutable」函数允许在构造函数中设置和加载不可变参数,尽管它们的使用受到限制。 「memoryguard」函数表示只分配给定的内存范围,从而使编译器可以使用超出保护范围的内存进行附加优化。最后,「verbatim」允许使用 Yul 编译器不知道的指令。
下面是用 Yul 编写的一个简单合约:
优秀 EVM DSL 的特性
一个优秀的 EVM DSL 应该从这里列出的每种语言的优缺点中学习,还应该包括几乎所有现代语言中的基础,如条件语句、模式匹配、循环、函数等等。代码应该是明确的,为了代码美观或可读性而添加最少的隐式抽象。在高风险、正确性至关重要的环境中,每行代码都应该是明确可解释的。此外,一个定义良好的模块系统应该是任何伟大语言的核心。它应该清楚地说明哪些项定义在哪个作用域中,以及哪些可以访问。默认情况下,模块中的每个项都应该是私有的,只有显式公共项才能在外部公开访问。
在 EVM 这样的资源受限环境中,效率很重要。效率通常通过提供低成本的抽象来实现,如通过宏进行编译时代码执行,丰富的类型系统来创建设计良好的可重用库以及常见的链上交互包装器。宏在编译时生成代码,这对于减少常见操作的样板代码非常有用,在像 Huff 这样的情况下,它可用于在代码大小与运行时效率之间进行权衡。
丰富的类型系统允许更具表现力的代码、更多的编译时检查以在运行时之前捕获错误,并且当与类型检查的编译器内部函数结合使用时,可能会消除大部分内联汇编的需求。泛型还允许可空值(例如外部代码)被包装在「选项」类型中,或者易出错的操作(例如外部调用)被包装在「结果」类型中。这两种类型是库编写者如何通过定义代码路径或恢复失败结果的事务来强制开发人员处理每个结果的示例。然而,请记住,这些是编译时抽象,会在运行时解析为简单的条件跳转。强制开发人员在编译时处理每个结果会增加初始开发时间,但好处是运行时的意外情况要少得多。
灵活性对于开发人员也很重要,因此,虽然复杂操作的默认情况应该是安全且可能不那么高效的路线,但有时需要使用更高效的代码路径或不支持的功能。为此,应该向开发人员开放内联汇编,而且没有护栏。Solidity 的内联汇编为了简单和更好的优化器传递设置了一些护栏,但是当开发人员需要完全控制执行环境时,他们应该被授予这些权利。
一些可能有用的功能包括可以在编译时操作函数和其他项的属性。例如,「inline」属性可以将简单函数的主体复制到每个调用中,而不是为了效率创建更多的跳转。而「abi」属性可以允许手动覆盖给定外部函数生成的 ABI,以适应不同代码风格的语言。此外,还可以定义一个可选的函数调度器,允许在高级语言内进行定制,以便对预期更常用的代码路径进行额外的优化。例如,在执行「name」之前检查选择器是否为「transfer」或「transferFrom」。
结论
EVM DSL 设计任重而道远。每种语言都有自己独特的设计决策,我期待看到它们在未来如何发展。作为开发人员,学习尽可能多的语言符合我们的最大利益。首先,学习多种语言并了解它们的不同之处和相似之处将加深我们对编程和底层机器体系结构的理解。
其次,语言具有深远的网络效应和强大的保留特性。毫无疑问,大型参与者都在构建自己的编程语言,从 C#、Swift 和 Kotlin 到 Solidity、Sway 和 Cairo。学习在这些语言之间无缝切换为软件工程职业提供了无与伦比的灵活性。最后,重要的是要了解每一种语言背后都需要付出大量的工作。没有人是完美的,但无数有才华的人付出了大量努力,为像我们这样的开发者创造安全愉快的体验。