关于区块链:智能合约编写之Solidity运行原理

52次阅读

共计 6369 个字符,预计需要花费 16 分钟才能阅读完成。

引 言

作为一门面向智能合约的语言,Solidity 与其余经典语言既有差别也有相似之处。

一方面,服务于区块链的属性使其与其余语言存在差别。例如,合约的部署与调用均要通过区块链网络确认;执行老本须要被严格控制,以避免恶意代码耗费节点资源。

另一方面,身为编程语言,Solidity 的实现并未脱离经典语言,比方 Solidity 中蕴含相似栈、堆的设计,采纳栈式虚拟机来进行字节码解决。

本系列前几篇文章介绍了如何开发 Solidity 程序,为了让读者知其然更知其所以然,本文将进一步介绍 Solidity 的外部运行原理,聚焦于 Solidity 程序的生命周期和 EVM 工作机制。

Solidity 的生命周期

与其余语言一样,Solidity 的代码生命周期离不开编译、部署、执行、销毁这四个阶段。下图整顿展示了 Solidity 程序的残缺生命周期:

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

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

上面是一个示例程序,咱们通过 remix 摸索它的生命周期。

pragma solidity ^0.4.25;

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

编译

源代码编译完后,能够通过 ByteCode 按钮失去它的二进制:

608060405234801561001057600080fd5b506040516020806100ed83398101806040528101908080519060200190929190505050806000819055505060a4806100496000396000f300608060405260043610603f576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b1146044575b600080fd5b348015604f57600080fd5b50606c60048036038101908080359060200190929190505050606e565b005b80600081905550505600a165627a7a723058204ed906444cc4c9aabd183c52b2d486dfc5dea9801260c337185dad20e11f811b0029

还能够失去对应的字节码(OpCode):

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 函数如何运行。

JUMPDEST DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP POP JUMP STOP

部署

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

部署胜利后,可失去一条交易回执:

点开 input,能够看到具体的交易输出数据:


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

这些都表明,合约部署以交易作为介质。联合区块链交易常识,咱们能够还原出整个部署过程:

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

执行

依据是否带有修饰符 view,咱们可将函数分为两类:调用与交易。因为在编译期就确定了调用不会引起合约状态的变更,故对于这类函数调用,节点间接提供查问即可,无需与其余区块链节点确认。而因为交易可能引起状态变更,故会在网络间确认。

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

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

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

下图展现了运行过程:

这里仅粗略介绍了 set(0xa)是如何运行,下节将进一步开展介绍 EVM 的工作机制以及数据存储机制。

销毁

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

EVM 原理

在前文中,咱们介绍了 Solidity 程序的运行原理。通过交易确认后,最终由 EVM 执行字节码。对 EVM,上文只是一笔带过,这一节将具体介绍其工作机制。

运行原理

EVM 是栈式虚拟机,其外围特色就是所有操作数都会被存储在栈上。上面咱们将通过一段简略的 Solidity 语句代码看看其运行原理:

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

这段代码通过编译后,失去的字节码如下:

PUSH1 0x1
PUSH1 0x2
ADD

为了读者更好理解其概念,这里精简为上述 3 条语句,但理论的字节码可能更简单,且会掺杂 SWAP 和 DUP 之类的语句。

咱们能够看到,在上述代码中,蕴含两个指令:PUSH1 和 ADD,它们的含意如下:

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

这里用半动画的形式解释其执行过程。下图中,sp 示意栈顶指针,pc 示意程序计数器。当执行完 push1 0x1 后,pc 和 sp 均往下移:

相似地,执行 push1 0x2 后,pc 和 sp 状态如下:

最初,当 add 执行完后,栈顶的两个操作数都被弹出作为 add 指令的输出,两者的和则会被压入栈:

存储探索

在开发过程中,咱们常会遇到令人蛊惑的 memory 修饰符;浏览开源代码时,也会看到各种间接针对内存进行的 assembly 操作。不理解存储机制的开发者遇到这些状况就会一头雾水,所以,这节将探索 EVM 的存储原理。

在前文《智能合约编写之 Solidity 的根底个性》中咱们介绍过,一段 Solidity 代码,通常会波及到局部变量、合约状态变量。

而这些变量的存储形式存在差异,上面代码表明了变量与存储形式之间的关系。


contract Demo{
    // 状态存储
    uint private _state;

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

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

例如,在上面这条简略的语句中,变量值 1 会被读出,通过 PUSH 操作压入栈顶:

uint i = 1;

对于这类变量,无奈强行扭转它们的存储形式,如果在它们之前搁置 memory 修饰符,编译会报错。

内存

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

本节中,咱们将以字符串为例,剖析内存如何存储这些对象。

1. 对象存储构造

上面将用 assembly 语句对简单对象的存储形式进行剖析。

assembly 语句用于调用字节码操作。mload 指令将被用于对这些字节码进行调用。mload(p)示意从地址 p 读取 32 字节的数据。开发者可将对象变量看作指针间接传入 mload。

在上面代码中,通过 mload 调用,data 变量保留了字符串 str 在内存中的前 32 字节。

string memory str = "aaa";
bytes32 data;
assembly{data := mload(str)
}  

把握 mload,即可用此剖析 string 变量是如何存储的。上面的代码将揭示字符串数据的存储形式:

function strStorage() public view returns(bytes32, bytes32){
    string memory str = "你好";
    bytes32 data;
    bytes32 data2;
    assembly{data := mload(str)
        data2 := mload(add(str, 0x20))
    }   
    return (data, data2);
}

data 变量示意 str 的 0~31 字节,data2 示意 str 的 32~63 字节。运行 strStorage 函数的后果如下:

0: bytes32: 0x0000000000000000000000000000000000000000000000000000000000000006
1: bytes32: 0xe4bda0e5a5bd0000000000000000000000000000000000000000000000000000

能够看到,第一个数据字失去的值为 6,正好是字符串 ” 你好 ” 经 UTF- 8 编码后的字节数。第二个数据字则保留的是 ” 你好 ” 自身的 UTF- 8 编码。

熟练掌握了字符串的存储格局之后,咱们就能够使用 assembly 批改、拷贝、拼接字符串。读者可搜寻 Solidity 的字符串库,理解如何实现 string 的 concat。

2. 内存调配形式

既然内存用于存储对象,就必然波及到内存调配形式。

memory 的调配形式非常简单,就是程序调配。上面咱们将调配两个对象,并查看它们的地址:

function memAlloc() public view returns(bytes32, bytes32){
    string memory str = "aaa";
    string memory str2 = "bbb";
    bytes32 p1;
    bytes32 p2;
    assembly{
        p1 := str
        p2 := str2
    }   
    return (p1, p2);
}

运行此函数后,返回后果将蕴含两个数据字:

0: bytes32: 0x0000000000000000000000000000000000000000000000000000000000000080
1: bytes32: 0x00000000000000000000000000000000000000000000000000000000000000c0

这阐明,第一个字符串 str1 的起始地址是 0x80,第二个字符串 str2 的起始地址是 0xc0,之间 64 字节,正好是 str1 自身占据的空间。此时的内存布局如下,其中一格示意 32 字节(一个数据字,EVM 采纳 32 字节作为一个数据字,而非 4 字节):

  • 0x40~0x60:闲暇指针,保留可用地址,本例中是 0x100,阐明新的对象将从 0x100 处调配。能够用 mload(0x40)获取到新对象的调配地址。
  • 0x80~0xc0:对象调配的起始地址。这里调配了字符串 aaa
  • 0xc0~0x100:调配了字符串 bbb
  • 0x100~…:因为是程序调配,新的对象将会调配到这里。

    状态存储

顾名思义,状态存储用于存储合约的状态字段。

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

对于 mapping 和数组,存储会更简单,其本身会占据 1 槽,所蕴含数据则会按相应规定占据其余槽,比方 mapping 中,数据项的存储槽位由键值 k、mapping 本身槽位 p 经 keccak 计算得来。

从实现而言,不同的链可能采纳不同实现,比拟经典的是以太坊所采纳的 MPT 树。因为 MPT 树性能、扩展性等问题,FISCO BCOS 放弃了这一构造,而采纳了分布式存储,通过 rocksdb 或 mysql 来存储状态数据,使存储的性能、可扩展性失去进步。

结 语

本文介绍了 Solidity 的运行原理,运行原理总结如下。
首先,Solidity 源码会被编译为字节码,部署时,字节码会以交易为载体在网络间确认,并在节点上造成合约;合约函数调用,如果是交易类型,会通过网络确认,最终由 EVM 执行。
EVM 是栈式虚拟机,它会读取合约的字节码并执行。
在执行过程中,会与栈、内存、合约存储进行交互。其中,栈用于存储一般的局部变量,这些局部变量就是字节码的操作数;内存用于存储对象,采纳 length+body 进行存储,程序调配形式进行内存调配;状态存储用于存储状态变量。
了解 Solidity 的运行形式及其背地原理,是成为 Solidity 编程高手必经之路。

正文完
 0