ILRuntime 的实现原理

优质
小牛编辑
135浏览
2023-12-01

ILRuntime的实现原理

ILRuntime借助Mono.Cecil库来读取DLL的PE信息,以及当中类型的所有信息,最终得到方法的IL汇编码,然后通过内置的IL解译执行虚拟机来执行DLL中的代码。

IL托管栈和托管对象栈

为了高性能进行运算,尤其是栈上的基础类型运算,如int,float,long之类类型的运算,直接借助C#的Stack类实现IL托管栈肯定是个非常糟糕的做法。因为这意味着每次读取和写入这些基础类型的值,都需要将他们进行装箱和拆箱操作,这个过程会非常耗时并且会产生巨量的GC Alloc,使得整个运行时执行效率非常低下。

因此ILRuntime使用unsafe代码以及非托管内存,实现了自己的IL托管栈。

ILRuntime中的所有对象都是以StackObject类来表示的,他的定义如下:

struct StackObject
{
public ObjectTypes ObjectType;
public int Value; //高32位
public int ValueLow; //低32位
}
enum ObjectTypes
{
Null,//null
Integer,
Long,
Float,
Double,
StackObjectReference,//引用指针,Value = 指针地址,
StaticFieldReference,//静态变量引用,Value = 类型Hash, ValueLow= Field的Index
Object,//托管对象,Value = 对象Index
FieldReference,//类成员变量引用,Value = 对象Index, ValueLow = Field的Index
ArrayReference,//数组引用,Value = 对象Index, ValueLow = 元素的Index
}

通过StackObject这个值类型,我们可以表达C#当中所有的基础类型,因为所有基础类型都可以表达为8位到64位的integer。对于非基础类型而言,我们额外需要一个List来储存他的object引用对象,而Value则可以存储这个对象在List中的Index。由此我们就可以表达C#中所有的类型了。

托管调用栈

ILRuntime在进行方法调用时,需要将方法的参数先压入托管栈,然后执行完毕后需要将栈还原,并把方法返回值压入栈。

具体过程如下图所示

调用前:                                调用完成后:
|---------------| |---------------|
| 参数1 | |-------------->| [返回值] |
|---------------| | |---------------|
| ... | | | NULL |
|---------------| | |---------------|
| 参数N | | | ... |
|---------------| |
| 局部变量1 | |
|---------------| |
| ... | |
|---------------| |
| 局部变量1 | |
|---------------| |
| 方法栈基址 | |
|---------------| |
| [返回值] |------
|---------------|

函数调用进入目标方法体后,栈指针(后面我们简称为ESP)会被指向方法栈基址那个位置,可以通过ESP-X获取到该方法的参数和方法内部申明的局部变量,在方法执行完毕后,如果有返回值,则把返回值写在方法栈基址位置即可(上图因为空间原因写在了基址后面)。

当方法体执行完毕后,ILRuntime会自动平衡托管栈,释放所有方法体占用的栈内存,然后把返回值复制到参数1的位置,这样后续代码直接取栈顶部就可以取到上次方法调用的返回值了。