深入了解以太坊虚拟机
朋友一起走
发表于 2022-12-23 04:07:17
218
0
0
string, bytes32, byte[], bytes之间的区别是什么? P; p5 \2 a( ^2 Z5 _: Z% K: p( N* F
该在什么地方使用哪个类型?& n0 n" N j7 N; G' R; B' Y$ a4 X
将 string 转换成bytes时会怎么样?可以转换成byte[]吗?0 I' Y& t3 B& C
它们的存储成本是多少?) ]" k6 F8 ]& B/ a6 B
EVM是如何存储映射( mappings)的?
" {) z' [7 e1 M$ C0 F ^! m% J
为什么不能删除一个映射?
可以有映射的映射吗?(可以,但是怎样映射?)
为什么存在存储映射,但是却没有内存映射?
编译的合约在EVM看来是什么样子的?: _% R7 v V8 D8 ^* h1 E- M* e
合约是如何创建的?, Q6 k$ [0 n. w5 I, T
# [( W" [' C, |( X- n! \1 o7 l
到底什么是构造器?6 ?, {: z/ p) I% ^
8 p" H8 p5 b% L6 m& q. ^2 s3 G$ l: ^
什么是 fallback 函数?
) P1 Y F" c. s/ B0 N7 G
我觉得学习在以太坊虚拟机(EVM)上运行的类似Solidity 高级语言是一种很好的投资,有几个原因:
Solidity不是最后一种语言。更好的EVM语言正在到来。(拜托?)EVM是一个数据库引擎。要理解智能合约是如何以任意EVM语言来工作的,就必须要明白数据是如何被组织的,被存储的,以及如何被操作的。知道如何成为贡献者。以太坊的工具链还处于早期,理解EVM可以帮助你实现一个超棒的工具给自己和其他人使用。智力的挑战。EVM可以让你有个很好的理由在密码学、数据结构、编程语言设计的交集之间进行翱翔。在这个系列的文章中,我会拆开一个简单的Solidity合约,来让大家明白它是如何以EVM字节码(bytecode)来运行的。
我希望能够学习以及会书写的文章大纲:
EVM字节码的基础认识不同类型(映射,数组)是如何表示的当一个新合约创建之后会发生什么当一个方法被调用时会发生什么ABI如何桥接不同的EVM语言我的最终目标是整体的理解一个编译的Solidity合约。让我们从阅读一些基本的EVM字节码开始。
) Z; K% Z0 d5 |. C0 o
EVM指令集将是一个比较有帮助的参考。. \4 f+ ~1 S1 V0 A& C
一个简单的合约
我们的第一个合约有一个构造器和一个状态变量:
// c1.sol
pragma solidity ^0.4.11;% x$ r. S( L! L! Q) j
contract C {
uint256 a;, _' f; n; @6 D/ v* z
function C() {4 ]; }2 T) t$ F7 p
a = 1;
}
}& q* \3 r# Q5 \& r: f! ~9 @& {
用solc来编译此合约:
$ solc --bin --asm c1.sol
======= c1.sol:C =======
EVM assembly:
/* "c1.sol":26:94 contract C {... */
mstore(0x40, 0x60)
/* "c1.sol":59:92 function C() {... */9 K6 _4 |' K; U2 {% J! x: o( |
jumpi(tag_1, iszero(callvalue))
0x0
dup1" q F* S9 I4 A
revert* I' ~4 x% s, @+ W6 C
tag_1:
tag_2:
/* "c1.sol":84:85 1 */
0x1
/* "c1.sol":80:81 a */$ x* \9 C& B- p* r
0x0
/* "c1.sol":80:85 a = 1 */
dup2
swap1! [* [2 m: r2 Z9 N; w8 f8 w0 W
sstore0 H( Z5 X/ c% F* T& Z+ i( z$ I% s
pop
/* "c1.sol":59:92 function C() {... */
tag_3:
/* "c1.sol":26:94 contract C {... */0 M0 I4 _4 H: O: B2 I
tag_4:- ?$ D; ?/ }2 q: T+ T
dataSize(sub_0)
dup1
dataOffset(sub_0)5 P( \4 O/ f7 h/ ^6 G1 C5 S
0x0
codecopy# }) V1 F- x$ Z" M& {, P# L
0x03 N( \2 \- x+ a* _4 K/ ?- P) b
return! x) X# D: ^# W! g
stop2 Q8 V1 N+ v7 U& L
sub_0: assembly {
/* "c1.sol":26:94 contract C {... */; a. A- ?" @5 Q3 t8 v0 D+ J6 V- G
mstore(0x40, 0x60)% I6 O. L0 n# h, t. R& r) s
tag_1:
0x02 I2 l- `* ^! P. Y B
dup1. S+ W* \: N3 F( U4 ?
revert' g+ v J: c" e" e) [1 t" A
auxdata: 0xa165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb770029
}
Binary:
60606040523415600e57600080fd5b5b60016000819055505b5b60368060266000396000f30060606040525b600080fd00a165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb7700292 _9 |1 ]$ v) }1 B- Z2 I" P
6060604052...这串数字就是EVM实际运行的字节码。4 c* g- e% i" V7 o0 E
一小步一小步的来' O" p0 `) \/ ?1 {4 @$ F
上面一半的编译汇编是大多数Solidity程序中都会存在的样板语句。我们稍后再来看这些。现在,我们来看看合约中独特的部分,简单的存储变量赋值:( G z9 j6 X. Z, {/ ?/ W
a = 1
代表这个赋值的字节码是6001600081905550。我们把它拆成一行一条指令:+ }" ^0 { d/ H& N% [
60 01
60 004 j, {) ^. A8 \8 Z2 j
81
904 [( k+ c; }& [* z
55, W0 q6 j, N+ H+ f R3 P
507 @: V7 m q0 I# V( o# O
EVM本质上就是一个循环,从上到下的执行每一条命令。让我们用相应的字节码来注释汇编代码(缩进到标签tag_2下),来更好的看看他们之间的关联:. }8 W" P2 F7 U3 {8 `" e
tag_2:/ ?2 V- O' |: Q2 U B! n
// 60 01+ R/ C2 ?7 p6 O* K; z. F' G
0x1) \( Z+ Y' y$ J' K, S! _
// 60 00
0x02 ~! d: B4 _8 [. f3 @- `3 a
// 81( q* I6 d0 E' _8 D
dup2+ p! {$ _3 r1 N% Q9 u2 N
// 90
swap17 B* p) Y9 C! o1 s
// 556 H, [ O- l# n1 Q6 `! G5 r
sstore: r4 s+ P" q1 W/ U m
// 50$ v, Q4 t, r" v W U+ {/ R' l
pop
注意0x1在汇编代码中实际上是push(0x1)的速记。这条指令将数值1压入栈中。
只是盯着它依然很难明白到底发生了什么,不过不用担心,一行一行的模拟EVM是比较简单的。
模拟EVM
EVM是个堆栈机器。指令可能会使用栈上的数值作为参数,也会将值作为结果压入栈中。让我们来思考一下add操作。; m8 D5 O+ A( }% A6 @2 @. t' A
假设栈上有两个值:
[1 2]- Y5 L: M& t4 W' P5 M) A
当EVM看见了add,它会将栈顶的2项相加,然后将答案压入栈中,结果是:
[3]7 d+ B9 P3 l. F& j# [# h' C. X/ c9 ^1 J
接下来,我们用[]符号来标识栈:$ q, J, u' n$ h) }! M
// 空栈+ Y* Y$ i# S' F7 K; x1 Y: R
stack: []( R8 g+ X6 W+ [4 k; j
// 有3个数据的栈,栈顶项为3,栈底项为1% j0 s ^ u% v4 n! l
stack: [3 2 1]
用{}符号来标识合约存储器:- i6 N2 K$ o3 c. L/ m, X, Q
// 空存储5 @; Q k1 n {, \! E* q, e
store: {}0 U3 y: J4 o4 N. J3 C
// 数值0x1被保存在0x0的位置上
store: { 0x0 => 0x1 }% x; F- z" ]6 H! g1 V( j
现在让我们来看看真正的字节码。我们将会像EVM那样来模拟6001600081905550字节序列,并打印出每条指令的机器状态:
// 60 01:将1压入栈中
0x1' c+ f, g0 X- W
stack: [0x1]5 a9 q* q, p# s7 h2 } ]
// 60 00: 将0压入栈中
0x07 Q( r4 R' U- `/ U: L
stack: [0x0 0x1]9 ]$ |: ~! b3 ^% @. g6 D9 ]4 |, L
// 81: 复制栈中的第二项
dup2
stack: [0x1 0x0 0x1]
// 90: 交换栈顶的两项数据
swap1
stack: [0x0 0x1 0x1]) G$ z% P9 k' ]$ B) L$ a
// 55: 将数值0x01存储在0x0的位置上
// 这个操作会消耗栈顶两项数据- X' Y, H: O7 C# Z! T
sstore
stack: [0x1]
store: { 0x0 => 0x1 }. X2 f) }$ I& }3 b: A( [
// 50: pop (丢弃栈顶数据)1 K! u$ n$ @$ {- b% h
pop
stack: []; j. L6 K% p& [$ `- S3 X/ C. @
store: { 0x0 => 0x1 }
最后,栈就为空栈,而存储器里面有一项数据。) v3 a% c* r( U
值得注意的是Solidity已经决定将状态变量uint256 a保存在0x0的位置上。其他语言完全可以选择将状态变量存储在其他的任何位置上。
6001600081905550字节序列在本质上用EVM的操作伪代码来表示就是:
// a = 1
sstore(0x0, 0x1)
仔细观察,你就会发现dup2,swap1,pop都是多余的,汇编代码可以更简单一些:
0x1
0x0) g$ U+ j# @2 A) U! b0 ?
sstore; G# |( c1 g# [- u
你可以模拟上面的3条指令,然后会发现他们的机器状态结果都是一样的:
stack: []
store: { 0x0 => 0x1 }4 A: e, Y" K3 q u* P* Z/ T) F. i4 j H; P
两个存储变量, l2 u" b7 b3 `! |# X0 Y
让我们再额外的增加一个相同类型的存储变量:) X6 t* ]+ ?% ^! Y) [
// c2.sol! n6 h: e( l0 y: d8 s" L1 r
pragma solidity ^0.4.11;
contract C {4 g- K! ^/ I. Y
uint256 a;
uint256 b;2 G" c6 [0 b5 I( i# F e
function C() {
a = 1;
b = 2;& A! {5 `/ L) v6 S* o5 s
}
}
编译之后,主要来看tag_2:. |! I" N( M# R# E
$ solc --bin --asm c2.sol
//前面的代码忽略了
tag_2:
/* "c2.sol":99:100 1 */& U I, _* x- p4 l
0x1
/* "c2.sol":95:96 a */* W z d2 ^) ^7 D
0x0
/* "c2.sol":95:100 a = 1 */
dup2+ t i, I. s4 D/ {# o
swap1
sstore
pop7 J1 z: p; |2 b: @: K
/* "c2.sol":112:113 2 */
0x2
/* "c2.sol":108:109 b */
0x11 m$ s3 V" [4 T8 m8 P# l% ~. ^
/* "c2.sol":108:113 b = 2 */
dup2
swap1
sstore9 H: o0 q; x1 h" e% J
pop
汇编的伪代码:
// a = 1
sstore(0x0, 0x1)) H8 W+ k$ H; L/ j7 F4 \1 ^5 S
// b = 2
sstore(0x1, 0x2)
我们可以看到两个存储变量的存储位置是依次排列的,a在0x0的位置而b在0x1的位置。
存储打包0 o8 ~/ C: m# ` e
每个存储槽都可以存储32个字节。如果一个变量只需要16个字节但是使用全部的32个字节会很浪费。Solidity为了高效存储,提供了一个优化方案:如果可以的话,就将两个小一点的数据类型进行打包然后存储在一个存储槽中。. c& k0 {, K' A2 {$ e/ Z
我们将a和b修改成16字节的变量:
pragma solidity ^0.4.11;
contract C {
uint128 a;1 _* [! {: c) W4 X$ f
uint128 b;1 i- H& J: Q+ {# N' Q2 W9 k. D
function C() {2 g3 J9 k$ V/ T* {/ n2 F5 L
a = 1;
b = 2;
}: e1 o/ K! C: C( Q
}8 x' w8 \7 _5 A2 R
编译此合约:" F/ r, B: Q1 S& P1 H# W2 R8 x& [
$ solc --bin --asm c3.sol
产生的汇编代码现在更加的复杂一些:# y) p* i2 M" n6 b& M5 \6 p
tag_2:
// a = 1, L3 H2 Y1 {% C; |, o
0x1' U' e6 |+ Z4 y5 b! v+ k, [: d
0x04 |" r; l+ V7 d2 {2 t u. p$ f. n
dup1
0x100
exp
dup29 G6 m$ `% K% Z, x6 M
sload
dup26 N1 t: W) z9 v3 T7 N
0xffffffffffffffffffffffffffffffff I, Q0 g: }+ n/ [, d; N
mul
not
and
swap1: y$ v: w+ X# m: P$ V$ T
dup40 J! k3 u* @1 t" g7 p
0xffffffffffffffffffffffffffffffff1 L7 t/ r6 C2 P' Z
and
mul5 u- q- l' i9 C: v. B- `
or
swap1
sstore6 C! W' |: w: W% N- J" h
pop2 S/ M) P8 ]8 [! v5 O# {' \
// b = 2
0x2: P8 B0 {9 d* W; y
0x0
0x10/ j: ~3 q$ b( u$ a+ k$ E
0x100
exp
dup2
sload# a) u/ j# z G' w- d1 u8 _: O) E# T
dup2
0xffffffffffffffffffffffffffffffff
mul7 ?2 G* w' _& o$ g7 H$ g
not
and, V) B0 v/ q/ k, X
swap1
dup4
0xffffffffffffffffffffffffffffffff- v& w: Q* D; G# v U2 a
and: x, w6 a T( l4 T/ b: z
mul
or6 w( x/ H+ U9 {5 K' x
swap1; K1 p8 d& h7 m. [+ I) U q2 Q
sstore5 h' w5 W, T u! u& M
pop
面的汇编代码将这两个变量打包放在一个存储位置(0x0)上,就像这样:. p4 V& j2 i( _: _ i
[ b ][ a ]
[16 bytes / 128 bits][16 bytes / 128 bits]* ]6 }* C9 n6 D& l3 P
进行打包的原因是因为目前最昂贵的操作就是存储的使用:0 f* Y. X8 j+ i* e
sstore指令第一次写入一个新位置需要花费20000 gassstore指令后续写入一个已存在的位置需要花费5000 gassload指令的成本是500 gas大多数的指令成本是3~10 gas
通过使用相同的存储位置,Solidity为存储第二个变量支付5000 gas,而不是20000 gas,节约了15000 gas。7 j' O! ?/ D f
更多优化1 @( `1 d! w$ M; N; S+ C
应该可以将两个128位的数打包成一个数放入内存中,然后使用一个sstore指令进行存储操作,而不是使用两个单独的sstore命令来存储变量a和b,这样就额外的又省了5000 gas。
你可以通过添加optimize选项来让Solidity实现上面的优化:
$ solc --bin --asm --optimize c3.sol
这样产生的汇编代码只有一个sload指令和一个sstore指令:( ^! S' D$ I6 D6 n( U
tag_2:
/* "c3.sol":95:96 a */
0x0
/* "c3.sol":95:100 a = 1 */
dup1
sload; C2 H% }% A9 x* E
/* "c3.sol":108:113 b = 2 */
0x200000000000000000000000000000000/ N3 Y. {' J/ l* l/ l6 [
not(sub(exp(0x2, 0x80), 0x1))- A1 b% ~. D0 A# Z) _* ]
/* "c3.sol":95:100 a = 1 */
swap1- t4 }% l: W9 t5 k
swap2$ N! i. i/ T$ b$ }/ Q- T/ e' U
and
/* "c3.sol":99:100 1 */ o, t# Z2 t, w* C8 r9 h
0x1
/* "c3.sol":95:100 a = 1 */% U. E; E# w( M) a- i" `$ [0 d% k
or7 @% V- T4 y/ m1 l4 o7 ~- Y0 M
sub(exp(0x2, 0x80), 0x1)3 N5 N6 T' E9 Q8 p
/* "c3.sol":108:113 b = 2 */ @: v1 `. U4 F2 w, _ `8 B
and
or9 R) t! {3 y' u4 i9 A; k! A1 E
swap1% B# U2 u5 L8 w. C
sstore
字节码是:
600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055
将字节码解析成一行一指令:. z, G# P% q' m' R4 U+ G2 r! |
// push 0x0
60 00
// dup1
80
// sload
540 K1 R Q7 w' @) t- V0 r
// push17 将下面17个字节作为一个32个字的数值压入栈中. K+ H& J, W0 ~6 P' y' G( w" }
70 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 001 s0 X6 b: M; k' g8 w5 a7 ?
/* not(sub(exp(0x2, 0x80), 0x1)) */
// push 0x10 |% q8 J1 O8 M7 f5 @- C2 a3 v3 D
60 01. Y. B1 G/ T. \# q4 V. `, O6 y
// push 0x80 (32)
60 80
// push 0x80 (2)
60 020 z# l/ O% _' v* F) n# @
// exp
0a! Q0 ]" q2 Z. O' V. @) }" t
// sub
034 e8 ^, U9 X W5 Q( X
// not
19
// swap1
90
// swap2( q9 l, A& W5 t4 z
91
// and0 o7 b$ v5 f4 y+ g/ w& q @, O
16
// push 0x1$ I& P# A' N$ ~1 _
60 015 g: A$ S8 v6 u5 k0 n% ]0 U
// or0 v% [- T: A+ W+ B& a% D3 a
17
/* sub(exp(0x2, 0x80), 0x1) */
// push 0x1
60 01$ A" p. _8 f5 w! W4 Z
// push 0x80 s# d& A2 F. I3 |* I' Z: ~5 U4 X
60 806 C: ^. h4 X5 @" t) U
// push 0x02 e6 w0 @8 K$ I* J1 ~) r
60 02
// exp
0a8 Q" y" `* O$ k$ y* w
// sub
03+ P+ W. O2 ^7 H6 [* ?
// and; D$ ~1 I% X8 _* W5 l
16& y' _* C0 C; y1 M0 j
// or
17' C* _4 P: u# o( [2 `. V
// swap1
90 t; j5 p% `4 d2 G
// sstore
555 T1 Y) {5 \; j* E# V! t
上面的汇编代码中使用了4个神奇的数值:
0x1(16字节),使用低16字节
// 在字节码中表示为0x01
16:32 0x00000000000000000000000000000000
00:16 0x00000000000000000000000000000001
0x2(16字节),使用高16字节
//在字节码中表示为0x200000000000000000000000000000000
16:32 0x00000000000000000000000000000002
00:16 0x00000000000000000000000000000000
not(sub(exp(0x2, 0x80), 0x1)) l) V2 L6 W( @4 A
// 高16字节的掩码& R ]* f) n0 u' e3 c& t: c
16:32 0x00000000000000000000000000000000 : C5 Y4 }, ?/ k; {
00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) j- `4 Q! g$ x
sub(exp(0x2, 0x80), 0x1)' K0 N! ]) ~0 a& Y, w
// 低16字节的掩码 \/ p+ E! n# ~3 m D; w D
16:32 0x00000000000000000000000000000000 + c* ]% l* P: e) ^ h
00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
代码将这些数值进行了一些位的转换来达到想要的结果:
16:32 0x00000000000000000000000000000002
00:16 0x00000000000000000000000000000001- G0 r6 g$ A* D+ e# Q/ {$ B
最后,该32字节的数值被保存在了0x0的位置上。
Gas 的使用$ v+ z4 ?: R. T0 ^, |' x1 c
600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055
注意0x200000000000000000000000000000000被嵌入到了字节码中。但是编译器也可能选择使用exp(0x2, 0x81)指令来计算数值,这会导致更短的字节码序列。8 M9 y" k0 T" Y& x* f
但结果是0x200000000000000000000000000000000比exp(0x2, 0x81)更便宜。让我们看看与gas费用相关的信息:
一笔交易的每个零字节的数据或代码费用为 4 gas
一笔交易的每个非零字节的数据或代码的费用为 68 gas( M, K0 A" v, i) X# w
来计算下两个表示方式所花费的gas成本:# `8 S, V- [& }7 G) I
0x200000000000000000000000000000000字节码包含了很多的0,更加的便宜。2 ^9 p1 p* ?3 O) J/ }
(1 68) + (32 4) = 196; [" h4 v* C& }% P+ s
608160020a字节码更短,但是没有0。
5 * 68 = 340
更长的字节码序列有很多的0,所以实际上更加的便宜!
总结6 D5 v! c' A' A! N4 m6 d
EVM的编译器实际上不会为字节码的大小、速度或内存高效性进行优化。相反,它会为gas的使用进行优化,这间接鼓励了计算的排序,让以太坊区块链可以更高效一点。6 |% i' k+ R" h% D% \$ L! m
我们也看到了EVM一些奇特的地方:
EVM是一个256位的机器。以32字节来处理数据是最自然的持久存储是相当昂贵的Solidity编译器会为了减少gas的使用而做出相应的优化选择+ E6 [% ^( O+ a2 N- h2 R
Gas成本的设置有一点武断,也许未来会改变。当成本改变的时候,编译器也会做出不同的优化选择。, n8 i# v$ a) k, q- @, G; E7 U8 j
成为第一个吐槽的人