🐯10 异常和存储

[TOC]

异常和存储

异常

Solidity 使用状态恢复异常来处理错误。这种异常将撤消对当前调用(及其所有子调用)中的状态所做的所有更改,并且还向调用者标记错误。

  • 如果异常在子调用发生,那么异常会自动冒泡到顶层(异常会重新抛出)。 但是如果是在 send 和 低级别如:calldelegatecallstaticcall 的调用里发生异常时,他们会返回 false (第一个返回值) 而不是冒泡异常。

  • 根据 EVM 的设计,如果被调用的地址不存在,低级别函数 calldelegatecallstaticcall 第一个返回值同样是 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、没有匹配函数、或者本身抛出一个异常),不包括使用低级别 callsenddelegatecallcallcodestaticcall 的函数调用。低级操作不会抛出异常,而是通过返回 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 开销非常大。合约中可以被所有函数访问的全局变量存储在 storagestorage 是永久的存储,以太坊会把它存到公链环境里的每一个节点上。

  • memory 内存:用于暂存数据,存储的内容会在函数调用结束(包括外部函数)时擦除,燃料开销相对较小。

  • stack EVM 栈:几乎免费但容量有限,用于存放小型的局部变量。为了导入变量和以太坊的机器/汇编指令代码,维护了一个栈,这个栈是EVM的内存工作环境,它的最大为1024个元素。存储超出1024个元素就会出现异常。 对栈的访问仅限于顶端,可以将最顶端的16个元素之一复制到栈顶部,或者将最顶端的元素与下面的16个元素之一交换。所有其他操作从堆栈中获取最顶部的两个(或一个或多个,具体取决于操作)元素,并将结果推送到堆栈中。无须燃料费用,但最多16个变量。

  • 函数参数、函数返回值、局部变量默认是 memory,而合约声明的状态变量默认是 storage

  • 数据位置指定非常重要,不同的数据位置变量赋值产生的结果不同,memorystorage 之间以及它们和状态变量中相互赋值,会创建一个完全不相关的复制。 将一个 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; 数组 arrstorage 中占用 32 个字节(1 个插槽),但在 memory 中占用 128 个字节(4 个元素,每个元素 32 个字节)。

  • 结构体布局差异:以下结构在 storage 中占用 96 个字节(3 个 32 字节的插槽),但在 memory 中占用 128 个字节(4 个成员,每个成员 32 个字节)。

Last updated