EOS智能合约和虚拟机分析
EOS虚拟机同经典的EVM,是EOS中运行智能合约的容器,但是从设计上讲它与EOS.IO是分离的。进
一步脚本语言和虚拟机的技术设计与EOS.IO分离。从宏观来讲任何语言或者虚拟机,只要满足条件适
合沙盒模式运行,同时满足一定的运行效率,都可以通过满足EOS.IO提供的API来加入到EOS.IO的消
息传递过程中。以下为github上官方的说明:
The EOS.IO software will be first and foremost a platform for coordinating
the delivery of authenticated messages (called Actions) to accounts. The details
of scripting language and virtual machine are implementation specific details
that are mostly independent from the design of the EOS.IO technology. Any
language or virtual machine that is deterministic and properly sandboxed with
sufficient performance can be integrated with the EOS.IO software API.
本文就EOSIO中的智能合约和虚拟机进行分析来从更加全面的角度来看EOS是如何构建和实现。
目录
相关背景知识
LLVM相关内容
LLVM相关技术的理解对于我们深入理解EOS虚拟机的运行机制至关重要,所以必要的LLVM的相关知
识在这里是需要的。同时LLVM作为一个成熟的编译器后端实现,无论从架构还是相关设计思想以及相
关的工具的实现都是值得学习的。
LLVM架构概述
概括来讲LLVM项目是一系列分模块、可重用的编译工具链。它提供了一种代码良好的中间表示(IR),
LLVM实现上可以作为多种语言的后端,还可以提供与语言无关的优化和针对多种CPU的代码生成功能。
最初UIUC的Chris Lattner主持开发了一套称为LLVM(Low Level Virtual Machine)的编译器工具库套
件,但是后来随着LLVM的范围的不断扩大,则这个简写并不代表底层虚拟机的含义,而作为整个项目
的正式名称使用,并一直延续至今。所以现在的LLVM并不代表Low Level Virtual Machine。
The LLVM Project is a collection of modular and reusable compiler and toolchain
technologies. Despite its name, LLVM has little to do with traditional virtual machines.
The name "LLVM" itself is not an acronym; it is the full name of the project.
LLVM不同于传统的我们熟知的编译器。传统的静态编译器(如gcc)通常将编译分为三个阶段,分别
由三个组件来完成具体工作,分别为前端、优化器和后端,如下图所示。
LLVM项目在整体上也分为三个部分,同传统编译器一致,如下图所示,不同的语言的前端,统一的
优化器,以及针对不同平台的机器码生成。从图2我们也可以得到启发,如果想实现一门自定义的
语言,目前主要的工作可以集中在如何实现一个LLVM的前端上来。
LLVM的架构相对于传统编译器更加的灵活,有其他编译器不具备的优势,从LLVM整体的流程中我
们就可以看到这一点,如下图所示为LLVM整体的流程,编译前端将源码编译成LLVM中间格式的文
件,然后使用LLVM Linker进行链接。Linker执行大量的链接时优化,特别是过程间优化。链接得
到的LLVM code最终会被翻译成特定平台的机器码,另外LLVM支持JIT。本地代码生成器会在代码
生成过程中插入一些轻量级的操作指令来收集运行时的一些信息,例如识别hot region。运行时收
集到的信息可以用于离线优化,执行一些更为激进的profile-driven的优化策略,调整native code
以适应特定的架构。
从图中我们也可以得出LLVM突出的几个优势:
- 持续的程序信息,每个阶段都可以获得程序的信息内容
- 离线代码生成,产生较高的可执行程序
- 便捷profiling及优化,方便优化的实施
- 透明的运行时模型
- 统一,全程序编译
LLVM IR介绍与分析
根据编译原理可知,编译器不是直接将源语言翻译为目标语言,而是翻译为一种“中间语言”,即
"IR"。之后再由中间语言,利用后端程序翻译为目标平台的汇编语言。由于中间语言相当于一款编
译器前端和后端的“桥梁”,不同编译器的中间语言IR是不一样的,IR语言的设计直接会影响到编
译器后端的优化工作。LLVM IR官方介绍见:http://llvm.org/docs/LangRef.html
LLVM IR格式
The LLVM code representation is designed to be used in three different forms: as an
in-memory compiler IR, as an on-disk bitcode representation (suitable for fast loading
by a Just-In-Time compiler), and as a human readable assembly language representation.
This allows LLVM to provide a powerful intermediate representation for efficient compiler
transformations and analysis, while providing a natural means to debug and visualize the
transformations.
由上诉的引用得知目前LLVM IR提供三种格式,分别是内存里面的IR模型,存储在磁盘上的二进制
格式,存储在磁盘上的文本可读格式。三者本质上没有区别,其中二进制格式以bc为文件扩展名,
文本格式以ll为文件扩展名。除了以上两个格式文件外,和IR相关的文件格式还有s和out文件,这
两种一个是由IR生成汇编的格式文件,一个是生成的可执行文件格式(linux下如ELF格式),
- bc结尾,LLVM IR文件,二进制格式,可以通过lli执行
- ll结尾,LLVM IR文件,文本格式,可以通过lli执行
- s结尾,本地汇编文件
- out, 本地可执行文件
以上几种不同文件的转化图如下所示,整体上我们可以看一下这几种格式的转化关系,同时从中
我们也可以看出工具clang、llvm-dis、llvm-as等工具的作用和使用。
中间语言IR的表示,一般是按照如下的结构进行组织的由外到内分别是:
- 模块(Module)
- 函数(Function)
- 代码块(BasicBlock)
- 指令(Instruction)
模块包含了函数,函数又包含了代码块,后者又是由指令组成。除了模块以外,所有结构都是从
值产生而来的。如下为一个ll文件的片段,从中可以简单的看出这种组织关系。
; ModuleID = 'main.ll'
source_filename = "main.c"
target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"
; Function Attrs: noinline nounwind uwtable
define i32 @add(i32, i32) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%5 = load i32, i32* %3, align 4
%6 = load i32, i32* %4, align 4
%7 = add nsw i32 %5, %6
ret i32 %7
}
LLVM IR指令集
指令集的分类大致可以分为基于栈的,基于运算器的还有基于寄存器的,基于栈的和基于寄存器
的虚拟机目前是比较常见的,两种不同之处主要在运行效率,指令集大小和性能三个方面。LLVM
IR采用的是基于寄存器的满足RISC架构以及load/store模式,也就是说只能通过将load和store
指令来进行CPU和内存间的数据交换。LLVM IR指令集拥有普通CPU一些关键的操作,屏蔽掉了
一些和机器相关的一些约束。LLVM提供了足够多的寄存器来存储基本类型值,寄存器是为SSA形
式(静态单态赋值),这种形式的UD链(use-define chain, 赋值代表define, 使用变量代表use)
便于优化。LLVM指令集仅包含31条操作码。LLVM中的内存地址没有使用SSA形式,因为内存地
址有可能会存在别名或指针指向,这样就很难构造出来一个紧凑可靠的SSA表示。在LLVM中一个
function就是一组基本块的组合,一个基本块就是一组连续执行的指令并以中指指令结束
(包括branch, return, unwind, 或者invoke等),中止指令指明了欲跳转的目的地址。
LLVM IR类型系统
LLVM的类型系统为语言无关。每一个SSA寄存器或者显示的内存对象都有其对应的类型。这些类
型和操作码一起表明这个操作的语义,这些类型信息让LLVM能够在低层次code的基础上进行一
些高层次的分析与转换,LLVM IR包含了一些语言共有的基本类型,并给他们一些预定义的大小,
从8bytes到64bytes不等,基本类型的定义保证了LLVM IR的移植性。同时LLVM又包含了四种复杂
类型,pointer,arrays, structures和functions。这四种类型足够表示现有的所有语言类型。为
了支持类型转换,LLVM提供了一个cast操作来实现类型的转换,同时为了支持地址运算,LLVM
提供了getelementptr的命令。LLVM中的许多优化都是基于地址做的(后续的总结再分析)。
LLVM IR内存模型
LLVM提供特定类型的内存分配,可以使用malloc指令在堆上分配一个或多个同一类型的内存对象,
free指令用来释放malloc分配的内存(和C语言中的内存分配类似)。另外提供了alloca指令用于
在栈上分配内存对象,该内存对象在通常在函数结尾会被释放。统一内存模型,所有能够取地址的
对象都必须显示分配。局部变量也要使用alloca来显示分配,没有隐式地手段来获取内存地址,这就
简化了关于内存的分析。
LLVM IR函数调用
LLVM中对普通函数调用,LLVM提供了call指令来调用附带类型信息的函数指针。这种抽象屏蔽了
机器相关的调用惯例。还有一个不能忽略的就是异常处理,在LLVM中,LLVM提供了invoke和
unwind指令。invoke指令指定在栈展开的过程中必须要执行的代码,例如栈展开的时候需要析构
局部对象等。而unwind指令用于抛出异常并执行栈展开的操作。栈展开的过程会被invoke指令停
下来,执行catch块中的行为或者执行在跳出当前活动记录之前需的操作。执行完成后继续代码执
行或者继续栈展开操作。注意像C++的RTTI则由C++自己的库处理,LLVM并不负责。
LLVM IR示例
下面我们编写一个简短的程序并编译成LLVM IR的形式来看LLVM的IR的具体格式和结构如下为一
段程序,保存为main.c
#include <stdio.h>
int add(int a, int b)
{
return (a + b);
}
int main(int argc, char** argv)
{
add(3, 5);
return 0;
}
我们使用命令clang -o0 -emit-llvm main.c -S -o main.ll编译生成ll文件,ll文件为文本可见
文件,内容如下:
; ModuleID = 'main.c'
source_filename = "main.c"
target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"
//函数特征如inline
; Function Attrs: noinline nounwind uwtable
define i32 @add(i32, i32) #0 { //@代表是全局属性 i32为数据类型
%3 = alloca i32, align 4 //申请空间存放变量,%为局部属性
%4 = alloca i32, align 4 //3,4用来存放传入的参数,aling为位宽
store i32 %0, i32* %3, align 4 //将传入的参数放到是对应的存储位置
store i32 %1, i32* %4, align 4
%5 = load i32, i32* %3, align 4 //将参数存到待运算的临时变量中
%6 = load i32, i32* %4, align 4
%7 = add nsw i32 %5, %6 //执行具体的相加操作
ret i32 %7 //最后返回结果
}
; Function Attrs: noinline nounwind uwtable
define i32 @main(i32, i8**) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i8**, align 8
store i32 0, i32* %3, align 4
store i32 %0, i32* %4, align 4
store i8** %1, i8*** %5, align 8
%6 = call i32 @add(i32 3, i32 5)
ret i32 0
}
以上代码不难发现函数add的展开中有部分临时变量的浪费,更为简洁的表达可以如下,当然
际的优化到什么程度要看后续的具体的实现。
%3 = add nsw i32 %1, %0
ret i32 %3
LLVM JIT介绍与分析
JIT技术Just-In-Time Compiler,是一种动态编译中间代码的方式,根据需要,在程序中编
译并执行生成的机器码,能够大幅提升动态语言的执行速度。LLVM设计上考虑了解释执行
的功能,这使它的IR可以跨平台去使用,代码可以方便地跨平台运行,同时又具有编译型语言
的优势,非常的方便。像Java语言,.NET平台等,广泛使用JIT技术,使得程序达到了非常
高的执行效率,逐渐接近原生机器语言代码的性能。
LLVM JIT实现原理
JIT引擎的工作原理并没有那么复杂,本质上是将原来编译器要生成机器码的部分要直接写
入到当前的内存中,然后通过函数指针的转换,找到对应的机器码并进行执行。实际编写
过程中往往需要处理例如内存的管理,符号的重定向,处理外部符号等问题。实现一个LLVM
的字节码(bc)的解释器其实并不复杂最好的实例就是LLVM自身的解释器lli,其总共不超过
800行代码实现了一个LLVM的字节码解释器,其源代码的github地址为:
https://github.com/llvm-mirror/llvm/blob/master/tools/lli/lli.cpp
LLVM JIT代码示例
下面就以LLVM源代码中的例子来解释LLVM-JIT是如何使用和运行的,在这之前,我们需
要明确llvm中常用的语句表达结构为module-->function-->basicblock-->instruction
-->operator
我们主要分析源代码example/HowToUseJIT部分的代码,主要代码片段如下:
该例子中在内存中创建了一个LLVM的module,这个module包含如下两个function:
int add1(int x) {
return x+1;
}
int foo() {
return add1(10);
}
针对以上两个函数,创建LLVM内存中IR中间格式的代码如下:
//首先包含llvm JIT需要的相关头文件
#include "llvm/ADT/STLExtras.h"
#include "llvm/ExecutionEngine/ExecutionEngine.h"
#include "llvm/ExecutionEngine/GenericValue.h"
...............
...............
#include "llvm/Support/raw_ostream.h"
#include <algorithm>
#include <cassert>
#include <memory>
#include <vector>
using namespace llvm;
int main() {
InitializeNativeTarget(); //初始化本地执行环境,和具体的机器相关
LLVMContext Context; //定义一个LLVM的上下文变量
//创建一个module对象,以便后续我们可以把function放入其中
//这里这个module对象的名字是text,关联的上下文为上面声明
std::unique_ptr<Module> Owner = make_unique<Module>("test", Context);
Module* M = Owner.get();
//创建add1函数对象,并把该对象加入到module中,
Function* Add1F = cast<Function>(M->getOrInsertFunction(
"add1", //函数的名字为add1
Type::getInt32Ty(Context),//函数的参数为int32
Type::getInt32Ty(Context))); //函数的返回值为int32
//创建一个块,并把块关联到add1函数上,注意函数的最后一个参数
BasicBlock* BB = BasicBlock::Create(Context, "EntryBlock", Add1F);
//创建一个basic block的builder,这个builder的工作就是将instructions添加到
//basic block中去
IRBuilder<> builder(BB);
//获得一个指向常量数字1的指针
Value* One = builder.getInt32(1);
//获得指向函数add1第一个参数的指针
assert(Add1F->arg_begin() != Add1F->arg_end()); // 确保有参数
Argument* ArgX = &* Add1F->arg_begin(); // 获得参数指针
ArgX->setName("AnArg");
// 设置参数名称,便于后续的查找
//创建加1的指令,并把指令放入到块的尾部
Value* Add = builder.CreateAdd(One, ArgX);
//创建返回指令, 至此add1的函数已经创建完毕
builder.CreateRet(Add);
//创建函数foo
Function* FooF = cast<Function>(M->getOrInsertFunction(
"foo", Type::getInt32Ty(Context)));
BB = BasicBlock::Create(Context, "EntryBlock", FooF);
//通知builder关联到一个新的block上
builder.SetInsertPoint(BB);
Value* Ten = builder.getInt32(10);
//创建一个函数的调用,并把参数传递进去
CallInst* Add1CallRes = builder.CreateCall(Add1F, Ten);
Add1CallRes->setTailCall(true);
//创建返回结果
builder.CreateRet(Add1CallRes);
// 创建JIT引擎,创建参数为上下文
ExecutionEngine* EE = EngineBuilder(std::move(Owner)).create();
outs() << "We just constructed this LLVM module:\n\n" << * M;
outs() << "\n\nRunning foo: ";
outs().flush();
//调用函数foo
std::vector<GenericValue> noargs;
GenericValue gv = EE->runFunction(FooF, noargs);
//获得函数返回值
outs() << "Result: " << gv.IntVal << "\n";
delete EE;
//关闭LLVM虚拟机
llvm_shutdown();
return 0;
}
以上代码在内存中创建了LLVM IR,并调用LLVM JIT的执行引擎运行代码,从中我们得到启
发是如果我们借助LLVM JIT运行我们的合约代码,我们就需要将合约代码最终转化为LLVM
能识别的中间代码IR上,下面将一步一步的分析EOS中是如何利用LLVM-JIT技术实现的虚
拟机运行。
WebAssembly相关内容
WebAssembly概述
WASM在浏览器中运行的效果和Java语言在浏览器上的表现几近相同的时候,但是WASM
不是一种语言,确切的说WASM是一种技术方案,该技术方案允许应用诸如C、C++这种
编程语言编写运行在web浏览其中的程序。更加细节的去讲,WASM是一种新的字节码格
式,是一种全新的底层二进制语法。突出的特点就是精简,加载时间短以及高速的执行模
型。还有一点比较重要,那就是它设计为web多语言编程的目标文件格式。具体可见官网
相关介绍:https://webassembly.org/
WebAssembly格式介绍与分析
WebAssembly同LLVM的IR类似,提供两种格式,分别为可读的文本格式wast和二进
制格式wasm,两者最终是等价的,可以通过工具wast2wasm完成wast到wasm的格式转
而工具wasm2wast则执行这一过程的返作用。
WebAssembly WAST格式介绍
为了能够让人阅读和编辑WebAssembly,wasm二进制格式提供了相应的文本表示。这
是一种用来在文本编辑器、浏览器开发者工具等工具中显示的中间形式。下面将用基本
语法的方式解释了这种文本表示是如何工作的以及它是如何与它表示的底层字节码。
无论是二进制还是文本格式,WebAssembly代码中的基本单元是一个模块。在文本格式
中,一个模块被表示为一个S-表达式。S-表达式是一个非常古老和非常简单的用来表示树
的文本格式。具体介绍:https://en.wikipedia.org/wiki/S-expression 因此,我们可以
把一个模块想象为一棵由描述了模块结构和代码的节点组成的树。与编程语言的抽象语
法树不同的是,WebAssembly的树是平坦的,也就是大部分包含了指令列表。树上的
每个一个节点都有一对括号包围。括号内的第一个标签表示该节点的类型,其后跟随的
是由空格分隔的属性或孩子节点列表。因此WebAssembly的S表达式结构大概如下所示:
(module (memory 1) (func))
上面的表达式的含义是模块module包含两个孩子节点,分别是属性为1的内存节点,和
函数func节点。从上面我们知道一个空的模块定义为module,那将一个空的模块转化为
wasm将是什么格式,如下所示:
0000000: 0061 736d ; WASM_BINARY_MAGIC
0000004: 0d00 0000 ; WASM_BINARY_VERSION
WebAssembly模块中的所有代码都是包含函数里面。函数的结构如下所示:
( func [signature] [locals] [body] )
- signature 函数的签名声明函数的参数和返回值
- local 局部变量,声明了具体的类型
- body 为函数体,一个低级的的指令的线性列表
关于数据类型这里简单说明一下,wasm目前有四种可用的数据类型,分别为i32 i64 f32 f64
关于签名我们来看一个签名的具体例子,如下所示表示函数需要两个参数,均为i32类型,
返回值是一个f64类型,参数可以看成是函数调用过程中传递过来的实参初始化后的局部变量。
(func (param i32) (param i32) (result f64) ... )
关于局部变量这里需要注意两个操作:get_local和set_local,先看下面的例子:
(func (param i32) (param f32) (local f64) get_local 0 get_local 1 get_local 2)
- get_local 0会得到i32类型的参数
- get_local 1会得到f32类型的参数
- get_local 2会得到f64类型的局部变量
为了便于识记,可以定义变量名的方式来取代索引的方式,具体如下:
(func (param $p1 i32) (param $p2 f32) (local $loc i32) …)
关于函数体,在具体介绍函数体之前,我们要明确的一点是,虽然wasm被设计成高效执行
的代码,但是最后wasm的执行依然是一个栈式机器定义的,下面我们参考如下代码:
(func (param $p i32) ..get_local $p get_local $p i32.add)
上面函数的功能概括为i+i,即计算表达是$p+$p的结果,结果将放在最后运行的栈的顶部。
现在我们完整的写出一个module,该module就包含上述的功能,具体的S表达式如下:
(module
(func (param $lhs i32) (param $rhs i32) (result i32)
get_local $lhs
get_local $rhs
i32.ad
)
)
上面的描述似乎缺少了什么,那就我们如何才能使用这个函数,于是涉及到函数的导出和调用。
wasm中是通过export来完成导出的,通过call关键字来完成函数调用的,如下一个更加复杂
的例子:
(module
(func $getNum (result i32)
i32.const 42)
(func (export "getPlus") (result i32)
call $getNum
i32.const 1
i32.add
)
)
函数运行最后的结果在栈顶保存43这个元素,注意其中的(export "getPlus")也可以通过如下的
方式(export "getPlus" (func $getPlus))的方式导出。最后一个问题wasm如何导入函数?
下面我们看一个具体的例子 :
(module</br>
(import "console" "log" (func $log (param i32)))
(func (export "logIt")
i32.const 13
call $log))
WebAssembly使用了两级命名空间,这里的导入语句是说我们要求从console模块导入log函
数。导出的logIt函数使用call指令调用了导入的函数。
小结: 到目前为止我们熟悉了wast的具体格式,关于wast中的外部内存使用,表格等高级内容
可以单独去了解。
WebAssembly WASM格式介绍
wasm为WebAssembly的二进制格式,可以通过工具wast2wasm将wast转化为wasm格式,下
面将如下wast转化为wasm, 命令为wat2wasm simple.wast -o simple.wasm
上述工具的地址为:https://github.com/WebAssembly/wabt/
(module
(func $getNum (result i32)
i32.const 42)
(func (export "getPlus") (result i32)
call $getNum
i32.const 1
i32.add
)
)
虽然编译好的二进制文件没有办法进行直观的读取,但是可以借助wat2wasm工具进行查看其
verbose的输出,命令为:./wat2wasm test.wat -v输出结果为如下,通过对如下字节流的理
我们可以清晰看到wasm的二进制流格式是什么样的,以及它是如何运行的。基于以下的代码我
可以自己构建一个wasm的解析引擎,引擎需要使用寄存器的设计加上栈的运行控制。
0000000: 0061 736d ; WASM_BINARY_MAGIC
0000004: 0100 0000 ; WASM_BINARY_VERSION
; section "Type" (1)
0000008: 01 ; section code
0000009: 00 ; section size (guess)
000000a: 01 ; num types
; type 0
000000b: 60 ; func
000000c: 00 ; num params
000000d: 01 ; num results
000000e: 7f ; i32
0000009: 05 ; FIXUP section size
; section "Function" (3)
000000f: 03 ; section code
0000010: 00 ; section size (guess)
0000011: 02 ; num functions
0000012: 00 ; function 0 signature index
0000013: 00 ; function 1 signature index
0000010: 03 ; FIXUP section size
; section "Export" (7)
0000014: 07 ; section code
0000015: 00