Solidity中的Opcode:一份入门教程
Solidity是以太坊智能合约的主要编程语言。当你在Solidity中编写智能合约并部署到以太坊网络时,Solidity编译器会将你的源代码编译成EVM(以太坊虚拟机)能理解的字节码。这个字节码就是由一系列的Opcode(操作码)组成。
Opcode(操作码)是一种低级语言,每一种Opcode都有特定的指令,例如,添加(ADD),乘法(MUL),跳转(JUMP)等等。EVM会按照这些Opcode来执行智能合约的逻辑。
示例:简单的加法操作
让我们从一个非常简单的例子开始,一个加法操作。以下是一个Solidity智能合约的示例,它有一个函数add,接收两个参数并返回它们的和:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract Adder {
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
}
当我们将这个智能合约编译并查看生成的字节码,我们会看到一系列的Opcode,例如:
PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0x10 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH2 0x1E JUMP JUMPDEST STOP JUMPDEST PUSH1 0x2 SLOAD ...
这些Opcode在EVM中的执行会根据它们的指令完成相应的操作。例如,PUSH1操作会将接下来的字节(0x60)推送到栈中,MSTORE操作会将栈顶的两个元素出栈,并将第二个元素存储到由第一个元素指定的内存地址中。
我们的关注点在于实现加法操作的那部分Opcode,它看起来像这样:
PUSH1 0x2 SLOAD PUSH1 0x3 SLOAD ADD
这段Opcode实现了add函数的功能。让我们逐一解释这些操作:
PUSH1 0x2
:将0x2(也就是2)推送到栈中。SLOAD
:从存储位置加载一个字,该位置由栈顶的元素指定,并将结果推送到栈顶。这里,它会加载参数a的值。PUSH1 0x3
:将0x3(也就是3)推送到栈中。SLOAD
:同样的,加载参数b的值。ADD
:从栈顶弹出两个元素,将它们相加,并将结果推送回栈顶。这里,它实现了a和b的加法操作。
理解并利用Solidity中的Opcode可以带来很多好处,比如:
更好的理解合约行为:虽然Solidity是一个相对易于理解和使用的高级语言,但是在某些情况下,直接查看合约的Opcode可以提供更深入的理解。例如,通过查看Opcode,你可以更好地理解合约是如何处理数据和执行操作的,例如数据存储、函数调用和算术运算等。
优化Gas消耗:在以太坊中,每种Opcode都有对应的Gas消耗。通过理解各种Opcode的Gas消耗,你可以更好地优化合约的Gas消耗。例如,你可以选择使用消耗较少Gas的Opcode,或者减少某些不必要的操作。
安全性审计:理解Opcode也对合约的安全性审计非常有帮助。通过查看Opcode,你可以检查合约是否包含任何潜在的安全漏洞,例如重入攻击、整数溢出和未初始化的存储指针等。
调试和故障排查:当智能合约出现问题时,查看和理解Opcode可以帮助你找到问题的根源。虽然Solidity提供了很好的错误处理和调试工具,但是在某些复杂的情况下,直接查看Opcode可能更有帮助。
合约验证:在某些情况下,你可能需要验证一个合约的源代码。通过比较源代码编译出的Opcode和链上合约的Opcode,你可以验证这两者是否一致。
基于以太坊黄皮书和EIP-150,EIP-160,EIP-170等Ethereum Improvement Proposals的内容,我们可以查阅到以下EVM Opcode的Gas消耗:
Opcode | Gas消耗 | 描述 |
---|---|---|
ADD | 3 | 加法运算 |
MUL | 5 | 乘法运算 |
SUB | 3 | 减法运算 |
DIV | 5 | 除法运算 |
SLOAD | 800 | 从存储位置加载一个字 |
SSTORE | 20000/5000 | 向存储位置存储一个字,如果存储位置的原值为0,则消耗20000 Gas,否则消耗5000 Gas |
JUMP | 8 | 无条件跳转 |
JUMPI | 10 | 条件跳转 |
PUSH | 3 | 将一个字节数据推入栈顶 |
POP | 2 | 弹出栈顶的一个字节数据 |
CALL | 700 | 执行消息调用 |
RETURN | 0 | 停止执行并返回数据 |
REVERT | 0 | 停止执行并撤销所有的状态改变 |
SELFDESTRUCT | 5000 | 销毁合约,并将合约的余额发送到指定地址 |
CREATE | 32000 | 创建一个新的合约 |
CREATE2 | 32000 | 使用给定的盐值创建一个新的合约 |
这些都是基础的Gas消耗,实际的Gas消耗可能会因为各种因素而变化。例如,如果一个操作导致了存储的改变,那么它可能会消耗更多的Gas。此外,对于SSTORE操作,如果它是将一个非零值变为零值,那么它还会退回一部分Gas。
为了更好的理解,我们以一个简单的Solidity合约为例,该合约有一个函数store
,用于在合约的存储中存储一个uint256
值:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract SimpleStore {
uint256 public storedData;
function store(uint256 x) public {
storedData = x;
}
}
当我们调用store
函数时,合约需要执行以下操作:
- 接收输入值(x)
- 将这个值存储到合约的存储中(即变量
storedData
)
现在我们来计算这个操作的Gas消耗。在EVM中,每个操作都有对应的Opcode,每个Opcode都有对应的Gas消耗。以下是这个操作对应的Opcode和Gas消耗:
PUSH1 0x0
:将0x0(也就是0)推送到栈中,消耗3 Gas。CALLDATALOAD
:从调用数据加载一个字(32字节),消耗3 Gas。SSTORE
:向存储位置存储一个字,消耗20000 Gas(如果存储位置的原值为0)或5000 Gas(如果存储位置的原值不为0)。
所以,如果我们是第一次调用store
函数(即变量storedData
的原值为0),那么总的Gas消耗为:3
Gas + 3 Gas + 20000 Gas = 20006
Gas。如果我们已经调用过store
函数(即变量storedData
的原值不为0),那么总的Gas消耗为:3
Gas + 3 Gas + 5000 Gas = 5006 Gas。
以上是一个简单具体的例子,实际上,由于Solidity合约可能包含更复杂的逻辑和更多的操作,所以计算Gas消耗可能会更复杂。但是通过理解各种Opcode和它们的Gas消耗,你应该可以对Gas消耗有一个基本的理解,并能进行初步的优化。
### Gas,Gwei,Gas Limit
在以太坊中,执行智能合约的操作需要消耗Gas,Gas是衡量执行操作所需工作量的单位。每种操作,比如加法、数据存储等,都有固定的Gas消耗。但Gas本身并不是一种货币,你不能拥有或转移Gas。当你在以太坊上执行操作时,你需要为这些操作付费,付费的单位就是以太币(ETH)。你需要为每单位Gas支付一定数量的ETH,这就是Gas价格,通常以Gwei为单位。
Gwei是以太币的一个单位,1 ETH等于109 Gwei。在以太坊中,Gas价格通常以Gwei为单位,而不是ETH,这是因为Gwei比ETH更小,更适合表示小额的Gas价格。
Gas Limit是你愿意为一个操作或一笔交易支付的最大Gas数量。如果操作的实际Gas消耗超过了Gas Limit,那么操作会失败,已经消耗的Gas不会退还。如果操作的实际Gas消耗小于Gas Limit,那么剩余的Gas会退还给你。
以太坊上的每个操作都有一个固定的Gas消耗,但Gas价格是变动的,取决于网络的拥堵程度。如果网络很拥堵,那么你可能需要支付更高的Gas价格,以使你的操作得到更快的处理。反之,如果网络不拥堵,那么你可以支付较低的Gas价格。
理解Gas、Gas价格和Gas Limit对于使用以太坊和开发智能合约非常重要,因为它们直接影响到操作的执行速度和成本。
### 简单的优化实践
在Solidity中,有许多基于Opcode的优化策略可以帮助我们减少Gas消耗。以下是一些常用的策略:
- 减少状态变量的存储和读取操作:在EVM中,读取(SLOAD)和写入(SSTORE)状态变量的操作是非常昂贵的,分别消耗800 Gas和5000或20000 Gas。我们可以通过在函数内部使用内存变量,而不是直接操作状态变量,来减少这些操作的次数。
例如,以下代码中的increment
函数直接操作了状态变量counter
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract Counter {
uint256 public counter;
function increment(uint256 value) public {
for (uint256 i = 0; i < value; i++) {
counter++;
}
}
}
在这个函数中,每次循环都会进行一次SSTORE操作,消耗5000或20000 Gas。我们可以通过以下方式优化它:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract Counter {
uint256 public counter;
function increment(uint256 value) public {
uint256 newCounter = counter;
for (uint256 i = 0; i < value; i++) {
newCounter++;
}
counter = newCounter;
}
}
在优化后的函数中,我们只进行了一次SLOAD操作和一次SSTORE操作,无论value
的值是多少。
- 使用位操作进行算术操作:有时,我们可以使用位操作来代替一些算术操作,因为位操作的Gas消耗通常比算术操作低。例如,我们可以使用左移操作(SHL)来代替乘法操作,使用右移操作(SHR)来代替除法操作。
例如,以下代码中的double
函数使用了乘法操作来将输入值乘以2:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract Doubler {
function double(uint256 x) public pure returns (uint256) {
return x * 2;
}
}
我们可以通过以下方式优化它:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract Doubler {
function double(uint256 x) public pure returns (uint256) {
return x << 1;
}
}
在优化后的函数中,我们使用了左移操作来代替乘法操作,这样可以节省Gas。
- 使用汇编代码:在某些情况下,我们可以直接使用汇编代码来进行更底层的优化。但请注意,使用汇编代码需要更高的技能和更多的注意力,因为它可能会增加代码的复杂性和出错的风险。
例如,以下代码中的add
函数使用了Solidity的语法来执行加法操作:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract Adder {
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
}
我们可以通过以下方式优化它:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract Adder {
function add(uint256 a, uint256 b) public pure returns (uint256 result) {
assembly {
result := add(a, b)
}
}
}
在优化后的函数中,我们使用了汇编代码来执行加法操作,这样可以避免一些Solidity的开销。
- 利用存储布局:以太坊的存储是按照256位字(word)组织的,这意味着在一个存储位置可以存储256位的信息。因此,通过精心设计数据结构,可以使多个较小的状态变量共享同一个存储位置,从而减少SSTORE和SLOAD操作的次数。
例如,以下代码中的set
和get
函数每次都需要两次SSTORE或SLOAD操作:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract Pair {
uint128 public x;
uint128 public y;
function set(uint128 _x, uint128 _y) public {
x = _x;
y = _y;
}
function get() public view returns (uint128, uint128) {
return (x, y);
}
}
我们可以通过以下方式优化它:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;
contract Pair {
uint256 public xy;
function set(uint128 _x, uint128 _y) public {
xy = (uint256(_x) << 128) | _y;
}
function get() public view returns (uint128, uint128) {
uint128 x = uint128(xy >> 128);
uint128 y = uint128(xy);
return (x, y);
}
}
在优化后的代码中,x
和y
共享同一个存储位置xy
,set
和get
函数每次只需要一次SSTORE或SLOAD操作。
- 利用固定和动态数组的性质:在Solidity中,固定长度数组和动态长度数组的实现是不同的,了解它们的实现可以帮助我们进行一些优化。例如,动态长度数组的长度是存储在数组的第一个存储位置的,所以如果我们知道数组的长度不会改变,使用固定长度数组可以避免一些不必要的SLOAD操作。