这里说一下 EVM
中的汇编。
参考资料
汇编相关知识介绍
什么是汇编?
任何以高级语言(例如C
,Go
或Java
)编写的程序,在执行之前都将先编译为“汇编语言”。 但是什么是汇编?
汇编(也称为汇编语言)是指可使用汇编器转换为机器代码的低级编程语言。 汇编语言与物理机或虚拟机绑定,因为它们实现了指令集。 一条指令告诉 CPU
执行一些基本任务,例如将两个数字相加。
英特尔x86
大约有1503
条机器指令。它们是通常称为操作码。
理解虚拟机和栈
以太坊虚拟机EVM有自己的指令集,该指令集中目前包含了 144
个操作码,详情参考 Geth
代码
这些指令被Solidity
语言 抽象之后,用来编写智能合约,但是Solidity
也支持使用内联汇编。例如:
1 | contract Assembler { |
EVM
是一个基于栈的虚拟机(stack machine
),栈这种数据结构只允许两个操作:压入(PUSH
)或弹出(POP
)数据。 最后压入的数据位于栈顶,因此将被第一个弹出,这被称为后进先出 (LIFO:Last In, First Out
):
栈虚拟机将所有的操作数保存在栈上。
stack machine
也是一种处理器,其中所有操作数都存储在栈中。 它仍然具有PC
(程序计数器)和SP
(堆栈指针)的存储器和寄存器,但是所有内容都存储在栈中。
操作码基础
为了能够解决实际问题,基于栈结构机器需要实现一些额外的指令,例如 ADD
、SUBSTRACT
等等。指令执行时通常会先从堆栈弹出一个或多个值作为参数, 再将执行结果压回堆栈。这通常被称为逆波兰表示法(Reverse Polish Notation
):
1 | a + b // 标准表示法Infix |
为什么Solidity中需要用汇编
细粒度控制
编写内联汇编使在以太坊上编写智能合约更加有趣。 为什么呢?
你可以使用操作码直接与EVM进行交互。 这使的可以对程序(=智能合约)要执行的操作进行更精细控制。
汇编提供了更多控制权来执行某些仅靠 Solidity
不可能实现的逻辑。 例如,指向特定的内存插槽。
更少的Gas消耗
在Solidity
中使用汇编语言的主要优点之一是节省了Gas
。
让我们通过创建简单的函数来比较Solidity
和Assembly
之间的Gas
成本:
- 简单地将两个数字
(x + y)
相加,然后 - 返回结果
两个版本:一个仅使用Solidity
,一个仅使用内联Assembly
。
先不用担心代码怎么写,我们稍后会再说:)
1 | function addAssembly(uint x, uint y) public pure returns (uint) { |
从上面的示例中可以看到,使用内联汇编可以节省86的gas。 对于此示例来说,这并不是很多,但这粗略地了解了我们可以为更复杂的合约节省多少gas。
增强功能
有一些事情在EVM汇编中可以做,而没法在 Solidity
中实现。
Solidity 中两种类型的汇编
汇编语言相对而言更接近EVM。
Solidity
定义了两种汇编语言的实现方式:
- 内联汇编(
Inline Assembly
):可以在内部Solidity
源代码中使用。 - 独立汇编(
Standalone Assembly
:可以使用,无需Solidity
。
在本文主要介绍使用内联汇编,因为独立汇编尚未实现。
基本汇编语法
引入汇编
前面已经有个实例,可以在Solidity
中使用assembly{}
来嵌入汇编代码段,这被称为内联汇编:
1 | assembly { |
在assembly
块内的代码开发语言被称为Yul
,为了简化我们称其为 汇编、汇编代码或EVM汇编
。
另一个需要注意的问题时,汇编代码块之间不能通信,也就是说在 一个汇编代码块里定义的变量,在另一个汇编代码块中不可以访问。 例如:
Solidity
文档有这样的解释:
不同的内联汇编块不共享任何名称空间,即不可能调用Yul函数或访问在其他内联汇编块中定义的Yul
变量。
1 | assembly { |
上面注释的代码就是编译时会报的错误。
简单汇编示例
下面的代码使用内联汇编代码计算函数的两个参数的和并返回结果:
1 | function addition(uint x, uint y) public pure returns (uint) { |
让我们重写上面的代码,补充一些更详细的注释,以便说明每个指令在EVM
内部的运行原理。
1 | function addition(uint x, uint y) public pure returns (uint) { |
Solidity汇编中的变量定义与赋值
在Yul
中,使用let
关键字定义变量。使用:=
操作符给变量赋值:
1 | assembly { |
和Solidity
不同,Solidity
只需要用=
, 因此不要忘了 :
.
如果没有使用:=
操作符给变量赋值,那么该变量自动初始化为0
值:
1 | assembly { |
你可以使用复杂的表达式为变量赋值,例如:
1 | assembly { |
汇编中let指令的运行机制
在EVM
的内部,let
指令执行如下任务:
- 创建一个新的堆栈槽位
- 为变量保留该槽位
- 当到达代码块结束时自动销毁该槽位
因此,使用let指令在汇编代码块中定义的变量,在该代码块 外部是无法访问的。
汇编的注释
在Yul
汇编中注释的写法和Solidity
一样,可以使用单行注释//
或多行注释/* */
。例如:
1 | assembly { |
汇编中的字面量
在Solidity
汇编中字面量的写法与Solidity
一致。不过,字符串字面量最多可以包含32
个字符。
1 | assembly { |
汇编中的块和作用域
在Solidity
汇编中,变量的作用范围遵循标准规则。一个块的范围使用由一对大括号标识。
在下面的示例中,y
和z
仅在定义所在块范围内有效。因此y
变量的作用范围是scope 1
,z
变量的作用范围是scope 2
。
1 | assembly { |
作用范围的唯一例外是函数和for
循环,我们将在下面解释。
在Solidity汇编中访问变量
局部变量
在Solidity
汇编中,只需要使用变量名就可以访问局部变量, 无论该变量是定义在汇编块中,还是汇编块之外(Solidity
代码中),不过变量必须是函数的局部变量:
下面是一个示例:
1 | function assembly_local_var_access() public pure { |
内存、Calldata变量、状态变量
以后补充
汇编中的循环
For循环
先看一下Solidity
中for
循环的使用。下面的Solidity
函数代码中 对变量value
乘2
计算 n
次,其中value
和n
是函数的参数:
1 | function for_loop_solidity(uint n, uint value) public pure returns(uint) { |
等效的Solidity
汇编代码如下:
1 | function for_loop_assembly(uint n, uint value) public pure returns (uint) { |
类似于其他开发语言中的for
循环,在汇编中,for
循环也包含 3
个元素:
- 初始化:
let i := 0
- 执行条件:
lt(i, n)
,必须是函数风格表达式 - 迭代后续步骤:
add(i, 1)
注意:for
循环中变量的作用范围略有不同。在初始化部分定义的变量在循环的其他部分都有效。
在 for
循环的其他部分中声明的变量遵守前面介绍的常规语法作用域规则。
汇编中的 while循环
在Solidity
汇编中实际上是没有while
循环关键字的,但是可以使用 for
循环实现同样的功能:只要留空for
循环的初始化部分和后迭代部分即可。
1 | assembly { |
在Solidity汇编的判断语句
使用If
Solidity
内联汇编支持使用if语句来设置代码执行的条件,但是没有其他语言中的else
部分。
1 | assembly { |
if
语句强制要求代码块使用大括号,即使需要保护的代码只有一行, 也需要使用大括号。这和Solidity
有点不同。
1 | function calculate_delivery_charges(uint ether_sent) public pure returns (uint) { |
如果需要在Solidity
内联汇编中检查多种条件,可以考虑使用 switch
语句。
使用switch语句
EVM
汇编中也有switch
语句,它将一个表达式的值于多个常量进行对比,并选择相应的代码分支来执行。switch
语句支持 一个默认分支default
,当表达式的值不匹配任何其他分支条件时,将执行默认分支的代码。
1 | assembly { |
switch
语句在语法上有一些限制:
- 分支列表不需要大括号,但是分支的代码块需要大括号;
- 所有的分支条件值必须:
- 具有相同的类型
- 具有不同的值
- 如果分支条件已经涵盖所有可能的值,那么不允许再出现
default
条件
1 | assembly { |
我个人认为switch
语句是内联汇编语言最有趣的功能之一。原因如下:
Solidity
支持if { ... } else { ... }
语句,while
,do
和for
循环, 但是没有switch
语句。- 当比较的值与一个
case
匹配时,控制流将停止。它不会从一个case
延续到下一个case
。
因此,我们可以认为 EVM
汇编中的 switch
语句可用于不同的目的:
- 作为非常基本版本的
if/else
- 作为
if
语句的扩展版本
Solidity汇编的函数
你也可以在 Solidity
内联汇编中定义底层函数,他们可以包含自己的逻辑,调用这些自定义的函数和使用内置的操作码一样。
下面的汇编函数用来分配指定长度 length
的内存,并返回内存指针pos
:
1 | assembly { |
汇编函数的运行机制很有趣,让跟我们深入看一看。汇编函数运行如下:
- 从栈顶提取参数
- 将结果压入栈
和Solidity
函数不同,不需要指定汇编函数的可见性,例如public
或private
, 因为汇编函数仅在定义所在的汇编代码块内有效。
汇编变量遵循相同作用域规则。最新的 Solidity
文档对此给出了明确的解释:
Yul
允许定义函数。它们不应与 Solidity
中的函数混淆,因为它们从来不是合约外部接口的一部分,并且独立于 Solidity
函数的命名空间。
函数定义
使用 function
关键字,后跟其名称、两个括号()
和一组大括号{...}
,这种标准方式定义汇编函数。
它还可以声明参数。它们的类型不需要像 Solidity
函数一样指定。
如:
1 | assembly { |
函数返回值
像 Rust
一样,返回值用-> 指定会返回一个值.
1 | assembly { |
不需要显式返回语句。为了返回一个值,只需在最终语句中将其分配给返回变量(如前面的示例中my_result
)。
重要提示:EVM
包含了 return
的内置操作代码。如果在汇编函数中编写了return
操作码,它将停止完全执行当前上下文(内部消息调用),而不仅仅是当前汇编函数。
变量访问
在汇编函数内部,无法访问该函数之外声明的变量
退出函数
可以使用特殊语句退出当前函数。
leave
关键字可以放置在汇编函数体的任意位置,以停止其执行流并退出它。它的工作原理与空返回语句完全相同,有一个例外:函数将返回上次复制的变量给返回变量。
注意:leave
关键字只能在函数内使用
操作码(Opcodes)和汇编
我们终于要深入这个主题的核心:操作码(opcodes
)。以太坊虚拟机的指令集!
操作码简介
EVM
操作码可以分为以下几类:
- 算数和比较操作
- 位操作
- 密码学计算,目前仅包含
keccak256
- 环境操作码,主要指与区块链相关的全局信息,例如:
blockhash
或coinbase
- 存储、内存和栈操作
- 交易与合约调用操作
- 停机操作
- 日志操作
有哪些操作码
操作码列表可以查看Solidity
文档,有关这个表的更多详细信息:
操作码始终从堆栈顶部获取参数(在括号中给出)。
标有 -
(第二列)的操作码不会将项推送到栈上。这些操作码的大多数是返回内存中的值。
所有其他操作码会将项(其”返回”值)压入到堆栈上。
标有F
、H
、B
和C
的操作码,表示从相应的版本:Frontier
、Homestead
、Byzantium
和Constantinople
(君士坦丁堡)加入。
译者注: 以太坊经历了哪些升级版本,可以查看:
mem[a...b)
表示从位置 a
开始到(但不包括位置 b
)开始的内存字节。
storage[p]
表示位置 p
处的存储内容。
操作码pushi
和Jumpdest
不能直接使用。 相反,您可以通过输入以十进制或十六进制表示形式的整数常量来使用它们。 将自动生成适当的PUSHi
指令。
汇编中使用操作码
待补充…(不知道还会不会补充,文章原作者是在 Dec 25, 2019 写的,挺久了… )
高级的汇编概念
多个赋值
如果调用一个函数反复多个值,可以将他们赋值给元组(tuple
)
前面我们已经可以在汇编中创建函数,这些函数可以返回多个值。
使用汇编函数可以一次分配多个值。 请参阅代码段:
1 | assembly { |
栈平衡(Stack balancing)
在每个 assembly { ... }
块的末尾,必须平衡堆栈(除非另有要求)。 否则,编译器将生成警告。
Solidity中的独立汇编(Standalone Assembly)
这个内容请直接看参考资料。
内联汇编不能做的事
不同的内联汇编块不共享任何名称空间,即,不可能调用Yul函数或访问在其他内联汇编块中定义的Yul
变量。
编写汇编的缺点
Solidity
文档指出以下内容:
内联汇编是一种以较低级别访问以太坊虚拟机的方法。 这绕过了几个重要的安全功能和Solidity
的检查。 仅当您有信心及必需时才使用它。
实际上,在Solidity
中编写汇编代码并非没有挑战。 我列出了一些重要的点。
汇编中都是uint
EVM
汇编中的算术运算忽略了某些类型可以小于256
位的(例如:uint64
)。
例如,一个地址长20
个字节。 你还可以在Solidity
中指定较小的类型,例如bytes10
或uint128
。
为了提高效率,EVM
汇编将每个值都视为256
位数字。
高阶位仅在必要时才清除(例如:执行比较或写入内存之前)
这意味着,如果您从内联汇编中访问此类变量,则可能必须先手动清除高阶位。编写汇编代码会很快成为负担…