一个Solidity智能合约的定义:

运行在区块链上的一个服务,代码可见,大家都可以调用执行其中的方法,完成信息读写,转账等功能。

一个简单的Solidity智能合约的构成:

其语法类似 JS,也是运行在虚拟机上的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pragma solidity ^0.5.1;		// EVM编译器版本

contract MyContract{ // 一个合约 类比Java中的一个类
string value; // 定义一个变量,默认保存在区块链上

constructor() public{ // 初始化函数
value = "MyContract";
}

// 一个get方法
// public说明这个一个公开可调用的方法
// view说明这个方法只读不可写
// returns说明这个函数的回传值类型
function get() public view returns(string memory){
return value;
}

// 重点在memory关键字 说明这个变量只存在于内存中,不保存于区块链中
function set(string memory _value) public {
value = _value;
}
}

需要注意的是智能合约中各个变量的生命周期由关键字把握,由于每一步操作都是会消耗gas的,所以尽量要求操作简单直接,不要出现多余的浪费。

简单代码的编写调试运行:

本文使用的Remix直接编写运行,Remix提供各个版本的编译环境和模拟区块链环境提供账户以供测试。

  1. 编写代码

  1. 编译

  1. 部署

image-20210620112330856

当用户调用set方法时:

调用get方法时:

智能合约的生命周期:

Solidity 的代码生命周期离不开编译、部署、执行、销毁这四个阶段。

img

经编译后,Solidity 文件会生成字节码。这是一种类似 jvm 字节码的代码。部署时,字节码与构造参数会被构建成交易,这笔交易会被打包到区块中,经由网络共识过程,最后在各区块链节点上构建合约,并将合约地址返还用户。

当用户准备调用该合约上的函数时,调用请求同样也会经历交易、区块、共识的过程,最终在各节点上由 EVM 虚拟机来执行。

比如下述代码的运行流程:

1
2
3
4
5
6
7
8
9
10
11
pragma solidity ^0.4.25;

contract Demo{
uint private _state;
constructor(uint state){
_state = state;
}
function set(uint state) public {
_state = state;
}
}
  1. 编译

    源代码编译完后,可以通过 ByteCode 按钮得到它的二进制:

    1
    608060405234801561001057600080fd5b506040516020806100ed83398101806040528101908080519060200190929190505050806000819055505060a4806100496000396000f300608060405260043610603f576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b1146044575b600080fd5b348015604f57600080fd5b50606c60048036038101908080359060200190929190505050606e565b005b80600081905550505600a165627a7a723058204ed906444cc4c9aabd183c52b2d486dfc5dea9801260c337185dad20e11f811b0029

    还可以得到对应的字节码(OpCode):

    1
    PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0x10 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x40 MLOAD PUSH1 0x20 DUP1 PUSH2 0xED DUP4 CODECOPY DUP2 ADD DUP1 PUSH1 0x40 MSTORE DUP2 ADD SWAP1 DUP1 DUP1 MLOAD SWAP1 PUSH1 0x20 ADD SWAP1 SWAP3 SWAP2 SWAP1 POP POP POP DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP POP PUSH1 0xA4 DUP1 PUSH2 0x49 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x4 CALLDATASIZE LT PUSH1 0x3F JUMPI PUSH1 0x0 CALLDATALOAD PUSH29 0x100000000000000000000000000000000000000000000000000000000 SWAP1 DIV PUSH4 0xFFFFFFFF AND DUP1 PUSH4 0x60FE47B1 EQ PUSH1 0x44 JUMPI JUMPDEST PUSH1 0x0 DUP1 REVERT JUMPDEST CALLVALUE DUP1 ISZERO PUSH1 0x4F JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x6C PUSH1 0x4 DUP1 CALLDATASIZE SUB DUP2 ADD SWAP1 DUP1 DUP1 CALLDATALOAD SWAP1 PUSH1 0x20 ADD SWAP1 SWAP3 SWAP2 SWAP1 POP POP POP PUSH1 0x6E JUMP JUMPDEST STOP JUMPDEST DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP POP JUMP STOP LOG1 PUSH6 0x627A7A723058 KECCAK256 0x4e 0xd9 MOD DIFFICULTY 0x4c 0xc4 0xc9 0xaa 0xbd XOR EXTCODECOPY MSTORE 0xb2 0xd4 DUP7 0xdf 0xc5 0xde 0xa9 DUP1 SLT PUSH1 0xC3 CALLDATACOPY XOR 0x5d 0xad KECCAK256 0xe1 0x1f DUP2 SHL STOP 0x29 

    其中下述指令集为 set 函数对应的代码,后面会解释 set 函数如何运行。

    1
    JUMPDEST DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP POP JUMP STOP
  1. 部署

编译完后,即可在 remix 上对代码进行部署,构造参数传入 0x123:

img

部署成功后,可得到一条交易回执:

img

点开 input,可以看到具体的交易输入数据:

img

上面这段数据中,标黄的部分正好是前文中的合约二进制;而标紫的部分,则对应了传入的构造参数 0x123。

总结部署全过程:

  • 客户端将部署请求( 合约二进制,构造参数 )作为交易的输入数据,以此构造出一笔交易
  • 交易经过 RIP 编码,然后由发送者进行私钥签名
  • 已签名的交易被推送到区块链上的节点
  • 区块链节点验证交易后,存入交易池
  • 轮到该节点出块时,打包交易构建区块,广播给其他节点
  • 其他节点验证区块并取得共识。不同区块链可能采用不同共识算法,FISCO BCOS 中采用 PBFT 取得共识,这要求经历三阶段提交(pre-prepare,prepare, commit)
  • 节点执行交易,结果就是智能合约 Demo 被创建,状态字段_state 的存储空间被分配,并被初始化为 0x123
  1. 执行

根据是否带有修饰符 view,我们可将函数分为两类:调用与交易。

由于在编译期就确定了view调用不会引起合约状态的变更,故对于这类函数调用,节点直接提供查询即可,无需与其他区块链节点确认。而由于交易可能引起状态变更,故会在网络间确认。

下面将以用户调用了 set(0x10)为假设,看看具体的运行过程。

首先,函数 set 没有配置 view/pure 修饰符,这意味着其可能更改合约状态。所以这个调用信息会被放入一笔交易,经由交易编码、交易签名、交易推送、交易池缓存、打包出块、网络共识等过程,最终被交由各节点的 EVM 执行。

在 EVM 中,由 SSTORE 字节码将参数 0xa 存储到合约字段_state 中。该字节码先从栈上拿到状态字段_state 的地址与新值 0xa,随后完成实际存储。

img

  1. 销毁

由于合约上链后就无法篡改,所以合约生命可持续到底层区块链被彻底关停。若要手动销毁合约,可通过字节码 selfdestruct。销毁合约也需要进行交易确认,在此不多作赘述。

EVM原理

EVM 是栈式虚拟机,其核心特征就是所有操作数都会被存储在栈上。

如代码:

1
2
3
uint a = 1;
uint b = 2;
uint c = a + b;

这段代码经过编译后,得到的字节码如下:(此为精简代码)

1
2
3
PUSH1 0x1
PUSH1 0x2
ADD

我们可以看到,在上述代码中,包含两个指令:PUSH1 和 ADD,它们的含义如下:

  • PUSH1:将数据压入栈顶。
  • ADD:POP 两个栈顶元素,将它们相加,并压回栈顶。

后面的内容就参照微机原理和编译系统理解了。

存储深究

这些变量的存储方式存在差别,下面代码表明了变量与存储方式之间的关系。

1
2
3
4
5
6
7
8
9
10
11
contract Demo{
//状态存储
uint private _state;

function set(uint state) public {
//栈存储
uint i = 0;
//内存存储
string memory str = "aaa";
}
}

栈用于存储字节码指令的操作数。在 Solidity 中,局部变量若是整型、定长字节数组等类型,就会随着指令的运行入栈、出栈。

例如: uint i = 1; 中的变量1会被push到栈顶

  1. 内存

内存类似 java 中的堆,它用于储存”对象”。在 Solidity 编程中,如果一个局部变量属于变长字节数组、字符串、结构体等类型,其通常会被 memory 修饰符修饰,以表明存储在内存中。EVM是顺序分配存储空间。

  1. 状态存储

状态存储用于存储合约的状态字段。

从模型而言,存储由多个 32 字节的存储槽构成。在前文中,我们介绍了 Demo 合约的 set 函数,里面 0x0 表示的是状态变量_state 的存储槽。所有固定长度变量会依序放到这组存储槽中。

对于 mapping 和数组,存储会更复杂,其自身会占据 1 槽,所包含数据则会按相应规则占据其他槽,比如 mapping 中,数据项的存储槽位由键值 k、mapping 自身槽位 p 经 keccak 计算得来。

从实现而言,不同的链可能采用不同实现,比较经典的是以太坊所采用的 MPT 树。由于 MPT 树性能、扩展性等问题,FISCO BCOS 放弃了这一结构,而采用了分布式存储,通过 rocksdb 或 mysql 来存储状态数据,使存储的性能、可扩展性得到提高。

总结:

  1. Solidity 源码会被编译为字节码
  2. 部署时,字节码会以交易为载体在网络间确认,并在节点上形成合约;
  3. 合约函数调用,如果是交易类型,会经过网络确认,最终由 EVM 执行。

其中,EVM 是栈式虚拟机,它会读取合约的字节码并执行。

在执行过程中,会与栈、内存、合约存储进行交互。其中,栈用于存储普通的局部变量,这些局部变量就是字节码的操作数;内存用于存储对象,采用 length+body 进行存储,顺序分配方式进行内存分配;状态存储用于存储状态变量。

参考资料:

智能合约编写之 Solidity 运行原理

solidity官方文档