首先列出EVM虚拟机汇编指令集:https://gist.github.com/hayeah/bd37a123c02fecffbe629bf98a8391df
常用汇编指令: https://blog.csdn.net/qq_33733970/article/details/78572733 https://blog.csdn.net/baishuiniyaonulia/article/details/78504758
简单合约实例
编写一个简单合约
pragma solidity ^0.4.11;
contract C {
uint256 a;
function C( ) {
a = 1;
}
}
使用remix查看汇编代码(点击detail查看):
可以查看BYTECODE、ABI、WEB3DEPLOY、RUNTIME BYTECODE 、ASSEMBLY等信息。
汇编分析
先看BYTECODE 。其中的object就是编译后的汇编指令。可以对照EVM虚拟机汇编指令集:https://gist.github.com/hayeah/bd37a123c02fecffbe629bf98a8391df 查看。下面的opcodes就是替换为对应指令后的结果。
再看WEB3DEPLOY,其中部署时data内容就是合约编译后的汇编指令。
接下来,我们重点看一下ASSEMBLY部分。从这部分,可以看到合约具体的执行过程。
我们先来回顾一下栈的操作过程(纯粹是按照过去的知识储备,与上面案例无关)。
假设有一个add操作,a=1+2。我们用[]符号来标识栈;用{}符号来标识合约存储器
往栈中压入值1 :[1]
王栈中压入值2:[2,1]
遇到add操作符
1和2出栈并且执行add计算,得到结果3
将3入栈 : [3]
将栈顶数据保存早0x0位置上, 清空栈:
栈:[]
存储:{0x0 → 3}
此时我们回到上面的案例,只看一下核心的 a=1 这个操作。
下图我将汇编和数字编码对应起来。5b6001600081905550 就是函数c的汇编代码:
具体执行过程:
//tag1 就是a=1的具体执行代码。
tag 1 function C() {\n a = 1;...
//跳转到方法C [5b]
JUMPDEST function C() {\n a = 1;...
//将1压入栈中 [60 01]
//执行结果:stack: [0x1]
PUSH 1 1
//将0压入栈中(这里是给a占个位置) [60 00]
//执行结果:stack: [0x0 0x1]
PUSH 0 a
// 复制栈中的第二项 [81]
//执行结果:stack: [0x1 0x0 0x1]
DUP2 a = 1
// 交换栈顶的两项数据 [90]
//执行结果:stack: [0x0 0x1 0x1]
SWAP1 a = 1
// 55: 将数值0x01存储在0x0的位置上. 这个操作会消耗栈顶两项数据。 [55]
//执行结果:stack: [0x1]
// store: { 0x0 => 0x1 }
SSTORE a = 1
//丢弃栈顶数据 [50]
//执行结果:stack: []
// store: { 0x0 => 0x1 }
POP a = 1
假设有两个变量:
pragma solidity ^0.4.11;
contract C {
uint256 a;
uint256 b;
function C( ) {
a = 1;
b = 2;
}
}
汇编代码:
ASSEMBLY:
可以看到他的执行过程就是按照参数顺序,依次执行的。
虚拟机的优化
将多个小字节的数据,存储到一个存储位置(32字节)中。
我们写这么一个合约
pragma solidity ^0.4.11;
contract C {
uint128 a;
uint128 b;
function C() {
a = 1;
b = 2;
}
}
编译之后的代码为:
.code
PUSH 60 contract C {\n uint128 a;...
PUSH 40 contract C {\n uint128 a;...
MSTORE contract C {\n uint128 a;...
CALLVALUE function C() {\n a = 1;...
ISZERO function C() {\n a = 1;...
PUSH [tag] 1 function C() {\n a = 1;...
JUMPI function C() {\n a = 1;...
PUSH 0 function C() {\n a = 1;...
DUP1 function C() {\n a = 1;...
REVERT function C() {\n a = 1;...
tag 1 function C() {\n a = 1;...
JUMPDEST function C() {\n a = 1;...
PUSH 1 1
PUSH 0 a
DUP1 a
PUSH 100 a = 1
EXP a = 1
DUP2 a = 1
SLOAD a = 1
DUP2 a = 1
PUSH FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF a = 1
MUL a = 1
NOT a = 1
AND a = 1
SWAP1 a = 1
DUP4 a = 1
PUSH FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF a = 1
AND a = 1
MUL a = 1
OR a = 1
SWAP1 a = 1
SSTORE a = 1
POP a = 1
PUSH 2 2
PUSH 0 b
PUSH 10 b
PUSH 100 b = 2
EXP b = 2
DUP2 b = 2
SLOAD b = 2
DUP2 b = 2
PUSH FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF b = 2
MUL b = 2
NOT b = 2
AND b = 2
SWAP1 b = 2
DUP4 b = 2
PUSH FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF b = 2
AND b = 2
MUL b = 2
OR b = 2
SWAP1 b = 2
SSTORE b = 2
POP b = 2
PUSH #[$] 0000000000000000000000000000000000000000000000000000000000000000 contract C {\n uint128 a;...
DUP1 contract C {\n uint128 a;...
PUSH [$] 0000000000000000000000000000000000000000000000000000000000000000 contract C {\n uint128 a;...
PUSH 0 contract C {\n uint128 a;...
CODECOPY contract C {\n uint128 a;...
PUSH 0 contract C {\n uint128 a;...
RETURN contract C {\n uint128 a;...
.data
0:
.code
PUSH 60 contract C {\n uint128 a;...
PUSH 40 contract C {\n uint128 a;...
MSTORE contract C {\n uint128 a;...
PUSH 0 contract C {\n uint128 a;...
DUP1 contract C {\n uint128 a;...
REVERT contract C {\n uint128 a;...
.data
上述代码执行结果就是讲a和b两个变量存储在一个存储位置上(32字节):
进行打包的原因是因为目前最昂贵的操作就是存储的使用:
sstore指令第一次写入一个新位置需要花费20000 gas
sstore指令后续写入一个已存在的位置需要花费5000 gas
sload指令的成本是500 gas
大多数的指令成本是3~10 gas
通过使用相同的存储位置,Solidity为存储第二个变量支付5000 gas,而不是20000 gas,节约了15000 gas。
字节码优化
上面已经将存储优化了。还可以将编译后的字节码优化一下。
在remix中启用优化:
优化后的字节码为(只列出tag1的部分):
tag 1 function C() {\n a = 1;...
JUMPDEST function C() {\n a = 1;...
PUSH 0 a
DUP1 a = 1
SLOAD a = 1
PUSH 200000000000000000000000000000000 b = 2
PUSH 1
PUSH 80
PUSH 2
EXP
SUB
NOT
SWAP1 a = 1
SWAP2 a = 1
AND a = 1
PUSH 1 1
OR a = 1
PUSH 1
PUSH 80
PUSH 2
EXP
SUB
AND b = 2
OR b = 2
SWAP1 b = 2
SSTORE b = 2
可见,只有一次sstore命令。
不要小看这一次sstore指令的执行。在以太坊EVM执行中是需要消耗gas的。这样少一个sstore命令的执行,就节省了5000gas。
Gas 的使用
为什么ABI将方法选择器截断到4个字节?如果我们不使用sha256的整个32字节,会不会不幸的碰到不同方法发生冲突的情况? 如果这个截断是为了节省成本,那么为什么在用更多的0来进行填充时,而仅仅只为了节省方法选择器中的28字节而截断呢?
这种设计看起来互相矛盾…直到我们考虑到一个交易的gas成本。
每笔交易需要支付 21000 gas
每笔交易的0字节或代码需要支付 4 gas
每笔交易的非0字节或代码需要支付 68 gas
0要便宜17倍,0填充现在看起来没有那么不合理了。
方法选择器是一个加密哈希值,是个伪随机。一个随机的字符串倾向于拥有很多的非0字节,因为每个字节只有0.3%(1/255)的概率是0。
0x1填充到32字节成本是192 gas
431 (0字节) + 68 (1个非0字节)
sha256可能有32个非0字节,成本大概2176 gas
32 * 68
sha256截断到4字节,成本大概272 gas
324
ABI展示了另外一个底层设计的奇特例子,通过gas成本结构进行激励。
总结
EVM的编译器实际上不会为字节码的大小、速度或内存高效性进行优化。相反,它会为gas的使用进行优化,这间接鼓励了计算的排序,让以太坊区块链可以更高效一点。
我们也看到了EVM一些奇特的地方:
EVM是一个256位的机器。以32字节来处理数据是最自然的
持久存储是相当昂贵的
Solidity编译器会为了减少gas的使用而做出相应的优化选择
Gas成本的设置有一点武断,也许未来会改变。当成本改变的时候,编译器也会做出不同的优化选择。