🐯10 异常和存储
[TOC]
异常和存储
异常
Solidity 使用状态恢复异常来处理错误。这种异常将撤消对当前调用(及其所有子调用)中的状态所做的所有更改,并且还向调用者标记错误。
如果异常在子调用发生,那么异常会自动冒泡到顶层(异常会重新抛出)。 但是如果是在
send和 低级别如:call,delegatecall和staticcall的调用里发生异常时,他们会返回 false (第一个返回值) 而不是冒泡异常。根据 EVM 的设计,如果被调用的地址不存在,低级别函数
call,delegatecall和staticcall第一个返回值同样是 true,调用之前应检查账号的存在性。外部调用的异常可以被 try/catch 捕获。
异常可以包含传递回调用者的数据。该数据由一个 4 字节选择器和随后的 ABI 编码数据组成。选择器的计算方式与函数选择器相同,即函数签名的 keccak256-hash 的前四个字节。
目前,Solidity 支持两种错误签名:
Error(string)和Panic(uint256)。error用于“常规”错误条件,而panic用于不应该出现在无错误代码中的错误。
异常回退操作
在内部, Solidity 对异常执行回退操作(指令 0xfd ),从而让 EVM 回退对状态所做的所有更改。回退的原因是不能继续安全地执行,因为没有实现预期的效果。 因为我们想要保持交易的原子性,最安全的动作是回退所有的更改,并让整个交易(或至少调用)没有任何新影响。
在Panic和Error的情况下,都可以使用 try/catch 来应对,但是调用者中的更改将始终被还原。
在0.8.0 之前,Panic 异常使用
invalid指令,它会消耗了所有可用的 gas。 Error 异常,在 Metropolis(2017年分叉的版本) 版本之前会消耗所有的 gas。
assert 异常 Panic
assert(条件)检查异常 Panic,检查条件并在条件不满足时抛出异常。
assert函数会创建一个Panic(uint256)类型的错误。
assert函数只能用于测试内部错误,正常的函数代码永远不会产生 Panic , 甚至是基于一个无效的外部输入时。如果发生了 Panic ,那就是一个需要修复的 bug。如果使用得当,语言分析工具可以识别出那些会导致 Panic 的assert条件和函数调用。
下列情况将会产生一个Panic异常,提供的错误码编号,用来指示Panic的类型
0x01: 调用 assert 的参数(表达式)结果为 false 。
0x11: 在
unchecked { … }外,算术运算结果向上或向下溢出。0x12; 用零当除数做除法或模运算(例如 5 / 0 或 23 % 0 )。
0x21: 将一个太大的数或负数值转换为一个枚举类型。
0x22: 访问一个没有正确编码的存储byte数组.
0x31: 在空数组上 .pop() 。
0x32: 访问 bytesN 数组(或切片)的索引太大或为负数。(例如: x[i] 而 i >= x.length 或 i < 0).
0x41: 分配了太多的内内存或创建了太大的数组。
0x51: 调用了零初始化内部函数类型变量。
require 错误 Error
require(条件,string 错误说明)检查错误 Error,检查条件并在条件不满足时抛出异常,但是要注意string 错误说明不可以为中文。
require函数要么创建一个Error(string)类型的错误,或者没有错误数据的错误并且require函数应该用于确认条件有效性,例如输入变量,或合约状态变量是否满足条件,或验证外部合约调用返回的值。
require是一个像其他函数一样可被执行的函数,所有的参数在函数被执行之前就都会被执行。 尤其,在require(condition, f())里,函数 f 会被执行,即便 condition 为 true .
下列情况将会产生一个 Error(string) 的错误:
调用 require 的参数(表达式)最终结果为 false 。
在不包含代码的合约上执行外部函数调用。
通过合约接收以太币,而又没有 payable 修饰符的公有函数(包括构造函数和 fallback 函数)。
合约通过公有 getter 函数接收 Ether 。
以下情况,来自外部调用的错误数据被转发,Error 或 Panic 都有可能触发
.transfer() 失败。
通过消息调用调用某个函数,但该函数没有正确结束(例如, 它耗尽了 gas、没有匹配函数、或者本身抛出一个异常),不包括使用低级别
call、send、delegatecall、callcode或staticcall的函数调用。低级操作不会抛出异常,而是通过返回 false 来指示失败。使用 new 关键字创建合约,但合约创建没有正确结束。
可以给
require提供一个消息字符串,而assert不行,如果没有为 require 提供字符串参数,它返回空错误数据,甚至不包括错误选择器。
revert 错误 Error
revert(string 错误说明)函数是另一个可以在代码块中处理异常的方法, 可以用来标记错误并回退当前的调用,同require一样,revert的错误说明也不能是中文。
revert调用中还可以包含有关错误信息的参数,这个信息会被返回给调用者,并且产生一个Error(string)错误。
revert(string)相当于require(条件,string)的简化版,使用if做条件判断后执行revert(string)等同于require(条件,string)。Solidity 在 0.5.0 之前
revert(string)有一个同样用法throw,在0.4.13版本弃用,0.5.0 版本移除。
try catch
try 关键字后面必须跟一个表示外部函数调用或合约创建的表达式(new ContractName())。
表达式内部的错误不会被捕获(例如,如果它是一个还涉及内部函数调用的复杂表达式),只会在外部调用本身内部发生还原。后面的返回部分声明了与外部调用返回的类型匹配的返回变量。如果没有错误,则分配这些变量,并且合约的执行在第一个成功块内继续。如果到达成功块的末尾,则在 catch 块之后继续执行。
Solidity 支持不同类型的 catch 块,具体取决于错误的类型。如果错误是由
revert("reasonString")或require(false, "reasonString")(或导致此类异常的内部错误)引起的,则将执行catch Error(string memory reason)类型的 catch 子句。如果错误签名与任何其它子句不匹配,在解码错误消息时出现错误,或者没有随异常提供错误数据,则执行子句
catch (bytes memory lowLevelData)。在这种情况下,声明的变量提供对低级错误数据的访问。不需要错误数据,可以使用
catch { ... }(即使作为唯一的 catch 子句)。为了捕获所有错误情况,必须至少有子句
catch { ...}或子句catch (bytes memory lowLevelData) { ... }。如果在
try/catch语句中的返回数据解码过程中发生错误,这会导致当前执行的合约出现异常,因此不会在 catch 子句中捕获。如果在catch Error(string memory reason)的解码过程中出现错误,并且有一个低级的catch子句,这个错误就会被捕获到那里。如果执行到一个
catch-block,则外部调用的状态改变效果已经恢复。如果执行到达成功块,则效果不会恢复。如果效果已恢复,则在 catch 块中继续执行或try/catch语句本身的执行恢复(例如,由于上述解码失败或由于未提供低级 catch 子句)。调用失败背后的原因可能是多方面的。不要假设错误消息直接来自被调用的合约:错误可能发生在调用链的更深处,而被调用的合约只是转发了它。此外,这可能是由于气体不足的情况,而不是故意的错误情况:调用者总是在调用中保留 63/64 的气体,因此即使被调用的合约用完了气体,调用者仍然有剩下一些气体。
存储
以太坊虚拟机的三类存储区域
以太坊虚拟机 EVM 有三类存储区域,storage、memory 和 stack EVM 栈,合约中的每个变量都有一个对应的数据存储区域。
storage 存储:虚拟机会为每份合约分别划出一片独立的存储
storage区域,并在函数相互调用时持久存在,所以它使用 gas 开销非常大。合约中可以被所有函数访问的全局变量存储在storage,storage是永久的存储,以太坊会把它存到公链环境里的每一个节点上。memory 内存:用于暂存数据,存储的内容会在函数调用结束(包括外部函数)时擦除,燃料开销相对较小。
stack EVM 栈:几乎免费但容量有限,用于存放小型的局部变量。为了导入变量和以太坊的机器/汇编指令代码,维护了一个栈,这个栈是EVM的内存工作环境,它的最大为1024个元素。存储超出1024个元素就会出现异常。 对栈的访问仅限于顶端,可以将最顶端的16个元素之一复制到栈顶部,或者将最顶端的元素与下面的16个元素之一交换。所有其他操作从堆栈中获取最顶部的两个(或一个或多个,具体取决于操作)元素,并将结果推送到堆栈中。无须燃料费用,但最多16个变量。
函数参数、函数返回值、局部变量默认是
memory,而合约声明的状态变量默认是storage。数据位置指定非常重要,不同的数据位置变量赋值产生的结果不同,
memory和storage之间以及它们和状态变量中相互赋值,会创建一个完全不相关的复制。 将一个storage的状态变量赋值给一个storage的局部变量是通过引用传递,所以对于局部变量的修改,将同时修改关联的状态变量。另一方面将一个memory的引用类型赋值给另一个memory的引用,则不会创建另一个赋值。
不同数据类型的变量会有各自默认的存储地点:
状态变量总是会存储在
storage中。函数参数默认存放在
memory中。结构、数组或映射类型的局部变量,默认会放在存储
storage中。除结构、数组及映射类型之外的局部变量,会储存在栈中。
Call Data 布局
假定函数调用的输入参数数据采用 ABI 规范定义的格式。 ABI 规范要求将参数填充为32字节的倍数。 内部函数调用则使用不同的约定。
合约构造函数的参数直接附加在合约代码的末尾,也采用ABI编码。
构造函数将通过硬编码偏移量而不是通过使用 codesize 操作码来访问它们,因为在将数据追加到代码时,它就会改变。
calldata 是所有函数调用的数据包括函数参数的保存位置,是不可修改的内存位置。计算需要增加 calldata 里非零字节数*68 的Gas费用。
清理变量
当一个值短于256位时,在某些情况下,剩余位必须被清理。 Solidity 编译器在设计时,会在操作数据之前清理这些剩余位,以避免剩余位中潜在垃圾数据在操作产生任何不利影响。
例如,在将一个值写入存储器之前,需要清除剩余的位,因为存储器的内容可以用于计算哈希值或作为消息调用的数据发送。同样,在将一个值存储到存储器中之前,也需要清除剩余的位,不清理会有可能存在垃圾数据。
另一方面,如果紧接着的操作不受影响,就不清理位。 例如,由于任何非零值都会被 JUMPI 指令认为是 true ,所以在布尔值被用作条件判断之前,不需要清理它们。
除了上述设计原则外,Solidity 编译器也会在将输入数据(input data)加载到堆栈时,会对其进行清理。
不同的类型有不同的清理无效值的规则:
enum
0 到 n - 1
例外
bool
0 或 1
1
int
符号扩展词
目前默默地换行;将来会抛出异常
uint
高位归零
目前默默地换行;将来会抛出异常
内存布局
Solidity 保留了四个32字节的插槽,字节范围(包括端点)特定用途如下:
0x00 - 0x3f (64 字节): 用于哈希方法的暂存空间(临时空间)。
0x40 - 0x5f (32 字节): 当前分配的内存大小(也作为空闲内存指针)。
0x60 - 0x7f (32 字节): 零位插槽。
暂存空间可以在语句之间使用 (例如在内联汇编中)。 零位插槽用作动态内存数组的初始值,并且永远不应写入(空闲内存指针最初指向
0x80).Solidity 总是将新对象放在空闲内存指针上,并且内存永远不会被释放(将来可能会改变)。
Solidity 中的内存数组中的元素始终占据32字节的倍数(对于 byte[] 总是这样,但对于 bytes 和 string 而言则不是)。
多维内存数组是指向内存数组的指针,动态数组的长度存储在数组的第一个插槽中,然后是数组元素。
Solidity 中有一些需要临时存储区的操作需要大于64个字节, 因此无法放入暂存空间。 它们将被放置在空闲内存指向的位置,但是由于使用寿命短,指针不会更新。 内存可以归零,也可以不归零。 因此,不应指望空闲内存指针指向归零内存区域。
尽管使用
msize可以到达绝对归零的内存区域,但使用此类非临时指针而不更新空闲内存指针可能会产生意外结果。
存储布局的差异
memory中的布局与storage中的布局不同。数组差异:
uint8[4] arr;数组arr在storage中占用 32 个字节(1 个插槽),但在memory中占用 128 个字节(4 个元素,每个元素 32 个字节)。结构体布局差异:以下结构在
storage中占用 96 个字节(3 个 32 字节的插槽),但在memory中占用 128 个字节(4 个成员,每个成员 32 个字节)。
Last updated