WAVM源码解析 —— WASI接口定义、内部实例初始化及实例链接

前言

从前面两篇文章中,我们可以窥探,WAVM执行一个wasm程序,主要包括一下步骤:

  1. 加载wasm二进制文件到内存,解析生成 IR::Module
    在这一步主要是解析wasm的各个Segment,保存到我们自定义的数据结构IR::Module中
  2. 编译生成本地可执行的二进制代码
    这一步是实现JIT的关键,是区别于wasm解释器的地方,这样我们就可以通过wasm程序中各个函数的地址,来调用执行,同我们执行普通的C函数一样,这有点类似于加载动态链接库中的函数(见 dlsym(3))
  3. 生成内部实例
    这一步是实现外部函数接口的关键,比如wasi定义的接口,这些接口都将在内部实例中实现
  4. 链接
    链接的主要工作就是根据需要导入的Module和导入项的名字,在对应的实例中查找,以获取对应的内容,如导入的函数,需要获取其入口地址。
    wasi定义的接口,就需要在链接的过程中,从内部实例中获取对应接口函数的地址
  5. 实例化
    生成实例,其实生成内部实例的过程也是实例化,只不过内部实例不基于任何IR::Module,也无需链接任何内容
    所谓实例化,主要内容就是为内存段、表段等申请空间,记录所有函数(自定义的函数和导入的函数)的入口地址,然后将所有的信息记录到一个统一的数据结构
  6. 执行
    根据提供的入口函数,比如”_start”,通过实例化后的实例中的函数信息列表,找到其入口地址,然后调用

本文将重点关注上述3、4、5三个部分

生成内部实例

一、调用接口

1
2
3
4
5
6
7
8
9
10
11
12
Instance* Intrinsics::instantiateModule(
Compartment* compartment,
const std::initializer_list<const Intrinsics::Module*>& moduleRefs,
std::string&& debugName)

Instance* wasi_snapshot_preview1
= Intrinsics::instantiateModule(compartment,
{WAVM_INTRINSIC_MODULE_REF(wasi),
WAVM_INTRINSIC_MODULE_REF(wasiArgsEnvs),
WAVM_INTRINSIC_MODULE_REF(wasiClocks),
WAVM_INTRINSIC_MODULE_REF(wasiFile)},
"wasi_snapshot_preview1");

函数,最重要的参数是moduleRefs,这是一个Intrinsics::Module类型的列表,因为内部实例不是基于wasm程序的,其只需要考虑导入导出段所关注的内容,因此定义了Intrinsics::Module类,其类似与IR::Module,但是仅仅包含Function、Global、Table、Memory等内容,其结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct ModuleImpl
{
HashMap<std::string, Intrinsics::Function*> functionMap;
HashMap<std::string, Intrinsics::Table*> tableMap;
HashMap<std::string, Intrinsics::Memory*> memoryMap;
HashMap<std::string, Intrinsics::Global*> globalMap;
};
struct Module
{
ModuleImpl* impl = nullptr;
WAVM_API ~Module();
};

WAVM_INTRINSIC_MODULE_REF(wasi)是一个宏,将其替换可得getIntrinsicModule_wasi(),查看其定义:

1
2
3
4
5
WAVM::Intrinsics::Module* getIntrinsicModule_wasi()
{
static WAVM::Intrinsics::Module module;
return &module;
}

也就是说,每一个WAVM_INTRINSIC_MODULE_REF()都会返回一个Intrinsics::Module对象,在WAVM中,定义了4个Intrinsics::Module对象,他们是按实现的功能类别分类的,但是实际上实现的都是wasi标准定义的接口。

那么如何初始化Intrinsics::Module对象呢?

二、Intrinsics::Module的构建

构建Intrinsics::Module,需要依赖于一个宏函数WAVM_DEFINE_INTRINSIC_FUNCTION,定义如下:

1
2
3
4
5
6
7
8
#define WAVM_DEFINE_INTRINSIC_FUNCTION(module, nameString, Result, cName, ...)                     \
static Result cName(WAVM::Runtime::ContextRuntimeData* contextRuntimeData, ##__VA_ARGS__); \
static WAVM::Intrinsics::Function cName##Intrinsic( \
getIntrinsicModule_##module(), \
nameString, \
(void*)&cName, \
WAVM::Intrinsics::inferIntrinsicFunctionType(&cName)); \
static Result cName(WAVM::Runtime::ContextRuntimeData* contextRuntimeData, ##__VA_ARGS__)

WAVM_DEFINE_INTRINSIC_FUNCTION宏,定义了一个接口,同时将接口赋值给具体的Intrinsics::Module对象,我们以一个简单的sched_yield为例:

宏定义

1
2
3
4
5
6
7
8
9
WAVM_DEFINE_INTRINSIC_FUNCTION(wasi, 
"sched_yield",
__wasi_errno_return_t,
wasi_sched_yield)
{
TRACE_SYSCALL("sched_yield", "()");
Platform::yieldToAnotherThread();
return TRACE_SYSCALL_RETURN(__WASI_ESUCCESS);
}

转化后

1
2
3
4
5
6
7
8
9
10
11
12
13
static __wasi_errno_return_t wasi_sched_yield(
WAVM::Runtime::ContextRuntimeData* contextRuntimeData);
static WAVM::Intrinsics::Function wasi_sched_yieldIntrinsic(
getIntrinsicModule_wasi(),
"sched_yield",
(void*)&wasi_sched_yield,
WAVM::Intrinsics::inferIntrinsicFunctionType(&wasi_sched_yield));
static __wasi_errno_return_t wasi_sched_yield(WAVM::Runtime::ContextRuntimeData* contextRuntimeData)
{
TRACE_SYSCALL("sched_yield", "()");
Platform::yieldToAnotherThread();
return TRACE_SYSCALL_RETURN(__WASI_ESUCCESS);
}

在这里,定一个三方面内容,分别是:

  • 接口的函数声明
  • Intrinsics::Function类型的对象
  • 接口的定义

而这里最关键的就是第二部分,构建静态的Intrinsics::Function对象,我们看一下其构造函数

1
2
3
4
5
6
7
8
9
10
11
12
Intrinsics::Function::Function(Intrinsics::Module* moduleRef,
const char* inName,
void* inNativeFunction,
FunctionType inType)
: name(inName), type(inType), nativeFunction(inNativeFunction)
{
initializeModule(moduleRef);

if(moduleRef->impl->functionMap.contains(name))
{ Errors::fatalf("Intrinsic function already registered: %s", name); }
moduleRef->impl->functionMap.set(name, this);
}

可以看到,构造函数的一个重要作用就是,将自己赋值给Intrinsics::Module对象,换言之,每当使用WAVM_DEFINE_INTRINSIC_FUNCTION()宏定义一个接口函数,静态的Intrinsics::Function对象会自动调用构造函数,从而将自己赋值到Intrinsics::Module中。

三、Intrinsics::instantiateModule()执行

1
2
3
4
5
Instance* Intrinsics::instantiateModule(
Compartment* compartment,
const std::initializer_list<const Intrinsics::Module*>& moduleRefs,
std::string&& debugName)
{。。。}

此函数,主要的步骤是:

1. 将moduleRefs转化为IR::Module
2. 编译上一步生成的IR::Module
3. 调用实例化接口函数,生成内部实例

整个过程是非常清晰的。

3.1 将moduleRefs转化为IR::Module

moduleRefs引用的是一个Intrinsics::Module列表,而Intrinsics::Module仅仅关注Function、Table、Memory、Globa四项内容,我们仅在这里分析最重要的Function部分。
首先我们看看Intrinsics::Function的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
struct Function
{
WAVM_API Function(Intrinsics::Module* moduleRef,
const char* inName,
void* inNativeFunction,
IR::FunctionType type);

private:
const char* name;
IR::FunctionType type;
void* nativeFunction;
};

主要包括,函数名、函数的签名、以及其函数指针,始终注意,所谓接口函数就是本地的CPP的函数,就是我们使用WAVM_DEFINE_INTRINSIC_FUNCTION()宏定义的,与之相互对的是wasm的内部函数,由WASM的类型段、函数段、代码段等组成,我们看一下WAVM给wasm的函数结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct Module
{
...
IndexSpace<FunctionDef, IndexedFunctionType> functions;
...
}
template<typename Definition, typename Type> struct IndexSpace
{
std::vector<Import<Type>> imports;
std::vector<Definition> defs;
...
}
template<typename Type> struct Import
{
Type type;
std::string moduleName;
std::string exportName;
};
struct FunctionDef
{
// 函数类型的索引,继承自原WASM的函数段
IndexedFunctionType type;
// 函数局部变量的信息,也就是每个局部变量的值类型,继承自原WASM的代码段
std::vector<ValueType> nonParameterLocalTypes;
// 函数的字节码,继承自原WASM的代码段
std::vector<U8> code;
std::vector<std::vector<Uptr>> branchTables;
};

在IR::Module中,定义了一个functions字段,来存储函数的信息,其类型为IndexSpace,我们查看IndexSpace的定义,发现其包含了两部分组成:

  • 导入的,即std::vector<Import<Type>> imports;
  • 自定义的,即std::vector<Definition> defs;

对于导入的部分,其信息主要包括了类型、导入包的名字和导入导入项的名字,对于Functiong而言,类型是IndexedFunctionType,这其实是wasm类型段的索引。

对于自定义的部分,其定义就是FunctionDef内容包括了函数类型、局部变量表以及具体的代码段的内容。

OK,来看一下具体的转化代码,依然只关注函数的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
for(const Intrinsics::Module* moduleRef : moduleRefs)
{
if(moduleRef->impl)
{
for(const auto& pair : moduleRef->impl->functionMap)
{
functionImportBindings.emplace_back(pair.value->getNativeFunction());
const Uptr typeIndex = irModule.types.size();
const Uptr functionIndex = irModule.functions.size();
irModule.types.push_back(pair.value->getType());
irModule.functions.imports.push_back({{typeIndex}, "", pair.value->getName()});
irModule.imports.push_back({ExternKind::function, functionIndex});
}

过程是:

  1. 将接口函数的地址,写入到functionImportBindings中
  2. 将接口函数的类型,”依次”添加到irModule的type字段中,
  3. 将从第二步中获取的 {类型索引 、空包名“ ” 、函数名},写入irModule的functions字段的import
  4. 将函数索引,写入irModule的imports字段
  • 这里需要注意,导入包名、导入项名其实应该是irModule的imports字段的信息,在实现山将这部分信息给到了template<typename Type> struct Import结构;
  • 其实根据wasm的定义,irModule的type字段中是不应该包含重复项的,但是显然这个过程是不能保证的,但是这个不重要;
  • 我们将外部接口函数放到了imports字段,但是显然链接过程是需要链接export字段的,这是因为能导出的函数,一定是自定义,而不能是导入的,这是合理的,我总不能把我导入的包再导出吧,因此外部接口是以导入项的形式存在的,显然这个导入项是不能从其他包导入的,所以导入包的名称是空,我们也不需要导入,因为外部接口函数的地址我们是知道的,就保存在functionImportBindings中;
  • 其实链接的过程,就是获取导入函数的地址的过程

因此接下来我们要为每一个外部接口函数生成一个wasm格式的thunks函数,然后将thunks导出。

3.2 创建thunks函数

主要过程如下;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
for(Uptr functionImportIndex = 0; functionImportIndex < irModule.functions.imports.size();
++functionImportIndex)
{
const FunctionImport& functionImport = irModule.functions.imports[functionImportIndex];
const FunctionType intrinsicFunctionType = irModule.types[functionImport.type.index];
const FunctionType wasmFunctionType(
intrinsicFunctionType.results(), intrinsicFunctionType.params(), CallingConvention::wasm);

const Uptr wasmFunctionTypeIndex = irModule.types.size();
irModule.types.push_back(wasmFunctionType);

// 下面操作是将加载参数和调用函数,封装到codeStream,可以认为是WASM的调用函数命令
// WASM的函数调用,在调用函数的时候会先把参数放到操作数栈
// call指令会先将参数加载到自己的局部变量表中,然后调用local_get将其放到操作数栈进行操作
// 在这里,我们先执行了local_get,然后再进行call,然后将其放入codeStream中
Serialization::ArrayOutputStream codeStream;
OperatorEncoderStream opEncoder(codeStream);
for(Uptr paramIndex = 0; paramIndex < intrinsicFunctionType.params().size(); ++paramIndex)
{
opEncoder.local_get({paramIndex});
}
opEncoder.call({functionImportIndex});
opEncoder.end();
// 将自定义的函数再写入functions.def和export中,其实函数的字节码为codeStream
// 从这里可以看到,我们在WASM中用内部函数封装了一层外部的本地函数,内部函数所用就是执行call指令
// 而前面的local_get就是为了将参数放到操作数栈
const Uptr wasmFunctionIndex = irModule.functions.size();
irModule.functions.defs.push_back({{wasmFunctionTypeIndex}, {}, codeStream.getBytes(), {}});
irModule.exports.push_back(
{functionImport.exportName, ExternKind::function, wasmFunctionIndex});
}
  1. 获取thunks函数的函数类型,填入到irModule.types
  2. 创建thunks函数,主要操作见注释
  3. 将thunks函数写入irModule.functions.defs,写入的内容包括了thunks函数的字节码,其与wasm的代码段的字节码是一样的
  4. 填充导出段

最后将执行实例化操作,创建出内部实例,内部实例和普通实例其实是相同的,区别在于导入段所需要内容的获取方式不同。

以函数为例,实例化的过程就是:

  • 1. 将导入函数的地址写到指定的位置,这样在执行是就能找到对应的函数
  • 2. 同时提供一个接口,可以让链接器找到本实例导出函数的地址。

以Memory为例,实例化的过程就是:

  • 1. 分配内存空间、初始化内存的数据
  • 2. 同上1
  • 3. 同上2

在执行实例化函数之前,我们必须将导入项的内容准备好,对于函数而言,需要的就是导入函数的地址,对于内部实例,函数地址我们是直接写在
functionImportBindings中的,而对于普通实例,其导入函数的地址,需要通过链接器进行链接操作获取!那么链接器是如何工作的呢?

链接

说实话链接应该是最简单的了:

  1. 循环遍历自己的导入项
  2. 通过moduleNameToInstanceMap.get(moduleName);获取对应的项
  3. 如果没有找到,则记录未找到的导入包名、导入项以及导入类型等信息
  4. 如果不存在未找到的导入项,那么宣告链接成功

前面我们说过,实例化的一大作用就是为链接提供查询导出项的接口!

核心实现,实在很简单,就懒得解析了。

关于实例化的内容,见下一篇!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
struct LinkResult
{
struct MissingImport
{
std::string moduleName;
std::string exportName;
IR::ExternType type;
};
// 真正起作用的是resolvedImports,如果success的话,missingImports应该是空的,他描述的是没找到的导入项
std::vector<MissingImport> missingImports;
ImportBindings resolvedImports;
bool success{false};
};

LinkResult Runtime::linkModule(const IR::Module& module, Resolver& resolver)
{
LinkResult linkResult;
for(const auto& kindIndex : module.imports)
{
switch(kindIndex.kind)
{
case ExternKind::function: {
const auto& functionImport = module.functions.imports[kindIndex.index];
linkImport(module,
functionImport,
module.types[functionImport.type.index],
resolver,
linkResult);
break;
case ExternKind::table:
case ExternKind::memory:
case ExternKind::global:
default: WAVM_UNREACHABLE();
}
};
}

linkResult.success = linkResult.missingImports.size() == 0;
return linkResult;
}

static void linkImport(const IR::Module& module,
const Import<Type>& import,
ResolvedType resolvedType,
Resolver& resolver,
LinkResult& linkResult)
{
Object* importValue;
if(resolver.resolve(import.moduleName, import.exportName, resolvedType, importValue))
{
linkResult.resolvedImports.push_back(importValue);
}
else
{
linkResult.missingImports.push_back({import.moduleName, import.exportName, resolvedType});
linkResult.resolvedImports.push_back(nullptr);
}
}

bool ProcessResolver::resolve(const std::string& moduleName,
const std::string& exportName,
ExternType type,
Object*& outObject)
{
const auto& namedInstance = moduleNameToInstanceMap.get(moduleName);
return namedInstance != nullptr;
}