EVM原理及其功能扩展

EVM原理及其功能扩展

EVM运行机制概述

EVM即以太坊虚拟机,用于执行智能合约。智能合约可用高级开发语言Solidity进行开发,合约源代码经过编译得到可在EVM中运行的字节码。在部署合约、与合约交互的时候,字节码都是以16进制字符串形式传递和展现。

EVM运行过程中,其本身并不是一个独立的协程、线程更不是进程,它只是交易处理的一部分,在交易处理过程中以函数方式被调用。

调用路径为:StateProcessor.Process --> core.ApplyTransaction(初始化evm对象) --> StateTransition.TransitionDb() --> 根据交易类型执行 evm.Create 或 evm.Call。

在evm.Create中会执行相关验证、转账、初始化Contract对象并调用 run 函数开始执行合约代码,执行成功后获得返回值也就是要存储到链上的合约代码,将返回值存储到链上。

而evm.Call既是调用入口,同时它本身也是一个可递归的函数,在合约字节码中指令 0xf1 即代表CALL操作,在CALL操作中会递归调用evm.Call。在evm.Call中会执行验证及转账、从数据库获取合约代码初始化Contract对象、并调用 run 函数开始执行合约代码这些处理。

重要:上述"从数据库获取合约代码初始化Contract对象"意味着,给合约账户进行转账时,合约账户所关联的合约代码将会被执行。
在合约代码中,会固定包含检查转账金额的逻辑,如果金额大于0,则会执行合约的fallback函数。
如果合约没有fallback函数,或者fallback函数没有payable修饰符,则代码执行会抛异常,从而交易失败。

EVM是基于栈的虚拟机,另外会有一个内存空间用于临时存储数据,而最终大部分的指令都是对栈中的数据进行操作。

EVM数据存储概述

合约状态值(状态变量、状态常量,即需要持久化的内容)在底层数据库中的存储方式,可用几条规则概括:

  1. 参照磁带存储原理,可以认为一个合约对应了一条无限长的磁带,磁带上以32字节为单位,拥有无数个存储槽;每个存储槽的位置就是它的key,也是用32字节表示。
  2. 对于简单的,大小在32字节以内的变量,以定义变量的顺序作为它的key来存储变量值。即第一个变量的key为0,第二个变量的key为1,……
  3. 结构体和定长数组也是顺序存储(只要每个值都是32字节以内的),比如结构体变量定义在位置1,结构体内部要两个成员,则这两个成员的key依序为 1和2。数组类似,只是在处理数组时编译器会多加一些边界检查的代码。
  4. 连续的若干个小的值,可能被优化为存储的同一个位置,比如:合约中前四个状态变量都是uint64类型的,则四个状态变量的值会被打包成一个32字节的值存储在0位置。
  5. map中内容的存储,如果map中的value在32字节以内,则会按以下公式得到数据库中的key:keccak256(bytes32(map中的key)+bytes32(map变量的位置)); 例如,一个map变量在合约中最先定义,map中一个key为"abc",则其在数据库中的存储位置为:keccak256(bytes32("abc")+bytes32(0))
  6. 如果map中的value是一个复杂类型,存储需求超过32字节,则会按上述公式得到第一个存储位置,然后依序加1得到后续数据的存储位置;
  7. 可变长度数组,与map类似,但更复杂点:以数组变量所在位置为key,存储数组的长度。然后从keccak256(bytes32(position))开始存储数组中的元素;
  8. 可变长度字节数组和字符串一样:如果长度小于等于31字节,则直接在变量位置处存储字符串值,并用值的最后一个字节存储字符串的编码长度。编码长度 = 字符数 * 2 。比如,"abc"的存储值为 "0x6162630000000000000000000000000000000000000000000000000000000006"
  9. 当可变长度字节数组或字符串长度大于31字节时,变量位置存储的是 编码长度,而此时编码长度公式变为 编码长度 = 字符数 * 2 + 1 。 然后,从 keccak256(bytes32(position))位置,使用连续的若干个存储槽存储字符串值。 从而,对于字符串,如果编码长度是奇数,则代表的是长字符串,如果是偶数则代表不超过31字节的字符串。

EVM代码结构

evm的代码都在core包里面,除了入口相关的一些代码,具体运行合约的代码都在core/vm包下

代码文件或结构体 说明
evm.go 定义了EVM运行环境结构体,并实现 转账处理 这些比较高级的,跟交易本身有关的功能
vm/evm.go 定义了EVM结构体,提供Create和Call方法,作为虚拟机的入口,分别对应创建合约和执行合约代码
vm/interpreter.go 虚拟机的调度器,开始真正的解析执行合约代码
vm/opcodes.go 定义了虚拟机指令码(操作码)
vm/instructions.go 绝大部分的操作码对应的实现都在这里
vm/gas_table.go 绝大部分操作码所需的gas都在这里计算
vm/jump_table.go 定义了operation,就是将opcode和gas计算函数、具体实现函数等关联起来
vm/stack.go evm所需要的栈
vm/memory.go evm的内存结构
vm/intpool.go *big.Int的池子,主要是性能上的考虑,跟业务逻辑无关

evm的opcode,大体上可以粗略地分为两类,一类是基础操作,如压栈、出栈、加减乘除等数学运算、逻辑比较、hash等等;另一类是跟交易业务密切相关的,可以称为业务指令,比如BALANCE、ADDRESS、CALLER、CALL等等,这些指令有些对应了Solidity中的全局函数或属性。

通过Solidity编写的合约,需要进行编译,编译后变成可供虚拟机执行的二进制码,这些二进制码实际上在所有地方都按16进制字节数组或字符串表示。

编译后二进制码的结构

以一个最简单的无实际功能无构造器的合约为例,看看编译后代码的结构。 合约代码:

pragma solidity ^0.4.11;
contract C {
}

编译后得到这样一串数据:60606040523415600e57600080fd5b5b603680601c6000396000f30060606040525b600080fd00a165627a7a723058209747525da0f525f1132dde30c8276ec70c4786d4b08a798eda3c8314bf796cc30029 这串数据需要作为16进制格式显示的字节数组对待,这就是evm需要执行的代码。

上面这串代码可分为三部分,即:部署代码、合约代码、Auxdata。

  1. 部署代码 在创建合约的时候,evm.Create会先创建合约账户,然后运行部署代码,运行完成后,它会将 合约代码+Auxdata 作为返回值返回,然后evm.Create函数中就会将返回值跟合约账户关联起来存储在区块链上,这样就完成了合约的部署。 上述代码中,部署代码为前面的 60606040523415600e57600080fd5b5b603680601c6000396000f300

  2. 合约代码 在这个合约中,合约代码只有11字节:60606040525b600080fd00 这部分的代码就是存储在链上,供后续调用的代码。

  3. Auxdata 每个合约最后面的43字节,就是Auxdata,会跟在合约代码后面被存储起来。即a165627a7a723058209747525da0f525f1132dde30c8276ec70c4786d4b08a798eda3c8314bf796cc30029 由于后续跟合约交互,是需要知道合约的ABI(应用二进制接口,也就是合约接口的描述信息)的,而在链上却没有存储ABI信息, 所以,要么就只能在部署合约时,用户自己保存好ABI以及合约地址(以太坊钱包就有这样的功能,帮我们保存了这些信息),但这样的话就只有自己能调用这个合约,别人不知道ABI就不能调用合约。 想要让别人也能调用我们部署的合约,在以太坊中提供了两种机制,一个就是用户直接将相关信息上传到etherscan.io这个网站跟合约关联起来,另一个就是以太坊的swarm网络。 而这里的Auxdata,就是给swarm网络使用的,可以认为就是swarm网络的地址,也就是以太坊希望后续自动将合约的相关信息包含ABI存储到swarm中,这样任何一个人从区块上查询到合约代码后,就可以通过auxdata到swarm网络中下载合约的信息。 Auxdata的固定格式为:0xa1 0x65 'b' 'z' 'z' 'r' '0' 0x58 0x20 <32 bytes swarm hash> 0x00 0x29

  4. 构造函数参数 如果合约有构造函数参数,则创建合约时还需要跟上编码之后的参数,代码就变成如下结构:部署代码+合约代码+Auxdata+构造参数 构造参数的编码方式,就是将参数值按顺序编码成32字节的数据,连接起来。不同的类型有不同的规则,具体见Solidity文档。

功能扩展

evm是以太坊合约的执行部分,想要从合约编程语言层面扩展功能,就需要同时在Solidity上和evm上实现扩展。Solidity上实现功能扩展,最重要的就是弄清楚它的编译过程。 Solidity是面向用户的高级编程语言,其中的一句代码,可能编译后就对应了一堆字节码。 真正要扩展功能,主要涉及以下几点:

  1. 增加opcode,从而与已有的opcode做功能上的区分,也就是扩展了指令
  2. 需要在Solidity语言中,提供供用户调用的接口,比如对Solidity中的address对象,增加address.balanceOf(bytes10 symbol)函数;
  3. 在Solidity编译器中,支持上述扩展的接口的识别,并编译成一段正确的指令组合。这段指令组合从逻辑上可以认为由三部分组成:新扩展指令所需参数的准备阶段+新扩展的指令+对结果的处理
  4. 在EVM中增加新扩展的指令的功能支持。

以下几点,有助于理解如何进行功能扩展:

  1. evm是基于栈的,用一个字节值表示指令。因此evm中指令的个数最多只有0x0~0xff共256个。因此任何一个指令,都有如下一些属性:(1)指令码;(2)该指令需要消耗栈顶多少个元素;(3)该指令执行完后,会往栈里压入几个元素;(4)其他一些属性,如针对PUSH指令,它所额外需要的memory中的元素数量,以及它是否除了对栈的操作外,还产生其他的影响等。
  2. 需要定义清楚指令所需栈顶的若干个元素的顺序,栈顶值代表什么,第二个值代表什么,在执行到这个指令之前,所有数据需要在栈中就绪;也就是,编译过程不能将高级语言的一条语句,对应成底层的一个指令,而应该是一堆指令。

在evm中扩展功能,相对比较简单,具体就是:

  1. 在opcode中定义指令码
  2. 在instructions中实现指令的处理,处理就是按照指令定义从栈顶取需要的数据,然后将结果压入栈顶。
  3. 在gas_table中提供gas函数
  4. 在jump_table中增加由上述内容组合成的operation

参考

深入了解以太坊虚拟机


你可能感兴趣的:(区块链-以太坊,以太坊,EVM,功能扩展)