这是基于unity mono代码阅读的第八篇。
上文已经大致分析了封装、继承、重载的底层实现,根据上文我们可以知道,CIL的封装,继承,重载都是通过MetaData里面的各种table实现。简单点说可以看做一个一个的查表操作。
今天我们来看一下重写这个操作。
本文需要前置知识,如果没有看过本文前面第七篇的读者需要翻阅一下第七篇。
并且对于CIL Opcode里面的Call和VirtCall的区别,也需要读者提前阅读一下陈嘉栋大佬的大作
陈嘉栋:用MSIL写程序:从“call vs callvirt”看方法调用zhuanlan.zhihu.com好了,那么我们现在开始。
using System;
using System.Collections.Generic;
using System.Text;
namespace TestCPlus
{
class A
{
virtual public void Say()
{
System.Console.WriteLine("I Am A.");
}
}
class B:A
{
override public void Say()
{
System.Console.WriteLine("I Am B.");
}
}
class Program
{
static void Main()
{
A a= new B();
a.Say();
}
}
}
继续上次的测试代码,上面的Main函数内,类型Class A的实例a 是Class A的继承类 Class B new出来的。在调用a.Say()的时候就涉及到底层的重写操作。生成的il代码是这样
如果读者已经阅读过上文推荐的陈嘉栋大佬写的的“用MSIL写程序:从“call vs callvirt”看方法调用”。那么有一段黑体字一定再温习一遍。
因此,使用callvirt时,它关心的并不是变量定义时的类型是什么,而是变量最后是什么类的引用。也就是说callvirt关心的是变量的运行时类型,是变量真正指向的类型。
上文生成的il重写代码,恰恰是使用的CallVirt来调用的。那么这只是在il层面的调用,真正底层的实现是怎样的呢?今天我们就来一探究竟。
如果读者有看过笔者的前面几篇文章,可以知道CLR的Call和vircall调用都是懒调用,也就是生成代码的时候用一个mono_magic_trampoline来替换,真正被调用的时候会在mono_magic_trampoline生成需要跳转的函数,并进行跳转。
我们就在mono_magic_trampoline打个断点看一下。中间会被中断多次,我们只看方法为我们需要的方法的时候进行单步调试看调用逻辑。希望读者可以手动调试一下看看。
经过几次调试后,对于CallVirt这个操作,在mono_magic_trampoline是这样的处理的。
/**
* mono_magic_trampoline:
*
* This trampoline handles calls from JITted code.
*/
gpointer
mono_magic_trampoline (mgreg_t *regs, guint8 *code, gpointer arg, guint8* tramp)
{
gpointer addr, compiled_method;
gpointer *vtable_slot;
gboolean generic_shared = FALSE;
MonoMethod *m;
MonoMethod *declaring = NULL;
MonoMethod *generic_virtual = NULL;
int context_used;
gboolean proxy = FALSE;
gboolean need_rgctx_tramp = FALSE;
m = arg;
if (m == MONO_FAKE_VTABLE_METHOD)
{
int displacement;
MonoVTable *vt = mono_arch_get_vcall_slot (code, regs, &displacement);
if (!vt)
{
...
}
if (displacement > 0)
{
displacement -= G_STRUCT_OFFSET (MonoVTable, vtable);
g_assert (displacement >= 0);
displacement /= sizeof (gpointer);
...
m = mono_class_get_vtable_entry (vt->klass, displacement);
if (mono_method_needs_static_rgctx_invoke (m, FALSE))
need_rgctx_tramp = TRUE;
}
else
{
/* We got here from an interface method: redirect to IMT handling */
m = MONO_FAKE_IMT_METHOD;
/*g_print ("vtable with disp %d at %pn", displacement, code);*/
}
}
addr = compiled_method = mono_compile_method (m);
return addr;
}
就是对于callVirt的调用,mono_magic_trampoline并不能直接拿到要编译的方法,而只能拿到一个MONO_FAKE_VTABLE_METHOD这个标记位。然后骚操作就来了。
mono通过函数mono_arch_get_vcall_slot拿到了一套MonoVTable。笔者在一开始调试的时候,始终没看懂mono_arch_get_vcall_slot在做什么。毕竟,这套代码是长的下面这样的
gpointer
mono_arch_get_vcall_slot (guint8 *code, mgreg_t *regs, int *displacement)
{
guint8 buf [8];
guint8 reg = 0;
gint32 disp = 0;
mono_breakpoint_clean_code (NULL, code, 8, buf, sizeof (buf));
code = buf + 8;
*displacement = 0;
code -= 6;
/*
* A given byte sequence can match more than case here, so we have to be
* really careful about the ordering of the cases. Longer sequences
* come first.
* There are two types of calls:
* - direct calls: 0xff address_byte 8/32 bits displacement
* - indirect calls: nop nop nop <call>
* The nops make sure we don't confuse the instruction preceeding an indirect
* call with a direct call.
*/
if ((code [1] != 0xe8) && (code [3] == 0xff) && ((code [4] & 0x18) == 0x10) && ((code [4] >> 6) == 1)) {
reg = code [4] & 0x07;
disp = (signed char)code [5];
}
else if
((code [0] == 0xff) && ((code [1] & 0x18) == 0x10) && ((code [1] >> 6) == 2)) {
reg = code [1] & 0x07;
disp = *((gint32*)(code + 2));
} else if ((code [1] == 0xe8)) {
return NULL;
} else if ((code [4] == 0xff) && (((code [5] >> 6) & 0x3) == 0) && (((code [5] >> 3) & 0x7) == 2)) {
/*
* This is a interface call
* 8b 40 30 mov 0x30(%eax),%eax
* ff 10 call *(%eax)
*/
disp = 0;
reg = code [5] & 0x07;
}
else
return NULL;
*displacement = disp;
return (gpointer)regs [reg];
}
说实话,这套代码,就算经常写硬编码的笔者也有点懵逼。而且这样的代码还会随着平台的变化而变化。。。x86和arm完全不一样。虽然有注释,但是注释对于一脸懵逼的我们实在是没啥卵用。
要是我是unity我也不想在其他新的平台再编写一套这样的东西是吧,这样看起来还是il2吹cpp是正道。。
这时候笔者的建议就是,不要着急不要着急,调调看,实在调不下去了,就掀桌子吧。多大的事。
笔者把这段代码调试了好多遍。。大致理清楚了前后思路,这里面涉及到一些x86指令集的相关知识,如果不想看啰嗦的部分可以直接跳过。。
如果从代码上看,这个函数的主要逻辑是从code 这个数组中进行一些校验后,去除对应的数值,用来填充displacement 和 regs。经过调试,我们可以大致确认,code里面存放的是jit后的调用当前callvirt代码,在经过mono_breakpoint_clean_code处理后的code从反汇编来看是这样的
前后被int 3调试中断包围,然后是二进制的 90 90 90 ff 50 38二进制机器码,对应到汇编就是一个简单地call eax+38h
mono_arch_get_vcall_slot所做的事情就是围绕上面的机器码进行的。回头再看看注释
* There are two types of calls:
* - direct calls: 0xff address_byte 8/32 bits displacement
* - indirect calls: nop nop nop <call>
看起来我们遇到了第二种nop nop nop <call>类型的call。当然代码里面这个顺序需要掉个个。实际上第一个if处理的是注释里面的第二种情况
if ((code [1] != 0xe8) && (code [3] == 0xff) && ((code [4] & 0x18) == 0x10) && ((code [4] >> 6) == 1)) {
reg = code [4] & 0x07;
disp = (signed char)code [5];
}
然后 code[3] ff code [4] 50 code [5] 38,通过这一通操作mono_arch_get_vcall_slot获取到了reg和disp
disp = 0n56, reg = 0;有人说,这个disp是什么呢?disp =0n65 = 0x38
reg最后被regs 安排了。假设regs是寄存器的话,那么0号寄存器是哪个呢?在x86上我们可以认为0号寄存器为eax。
return (gpointer)regs [reg];
那么disp = 0x38 reg 0 = eax最后就跟汇编码里面的call eax+38h 对上了。
再回头看看,一下子就简单了。mono_arch_get_vcall_slot这个函数只是想从机器码里面取出当前MonoVTable *vt 和 displacement罢了。说实话,我是真的不喜欢这样的平台相关代码。。
回到mono_magic_trampoline后,我们继续往下看,对于重写的函数,我们获取到了当前的调用的 MonoVTable 和偏移后我们可以干什么
if (displacement > 0)
{
displacement -= G_STRUCT_OFFSET (MonoVTable, vtable);
g_assert (displacement >= 0);
displacement /= sizeof (gpointer);
...
m = mono_class_get_vtable_entry (vt->klass, displacement);
if (mono_method_needs_static_rgctx_invoke (m, FALSE))
need_rgctx_tramp = TRUE;
}
关键函数mono_class_get_vtable_entry,我们可以从displacement和vt带的klass里面获取到当前需要调用的多态方法。
这里要多插一句的是,MonoVTable 我们以为是类似class的虚函数表这样的东西。但是我们仔细查过代码的话,就发现,虚函数表其实是这样的。。
/* Generic vtable. Initialized by a call to mono_class_setup_vtable () */
MonoMethod **vtable;
class里面我们认为的虚表实际上真的是一个MonoMethod 数组。而且MonoVTable 这个东西实际上主要是用来描述一个类的相关信息的,跟虚函数表没啥关系。
/* This struct collects the info needed for the runtime use of a class,
* like the vtables for a domain, the GC descriptor, etc.
*/
typedef struct {
guint16 max_domain;
/* domain_vtables is indexed by the domain id and the size is max_domain + 1 */
MonoVTable *domain_vtables [MONO_ZERO_LEN_ARRAY];
} MonoClassRuntimeInfo;
/* the interface_offsets array is stored in memory before this struct */
struct MonoVTable {
MonoClass *klass;
/*
* According to comments in gc_gcj.h, this should be the second word in
* the vtable.
*/
void *gc_descr;
MonoDomain *domain; /* each object/vtable belongs to exactly one domain */
gpointer data; /* to store static class data */
gpointer type; /* System.Type type for klass */
guint8 *interface_bitmap;
guint16 max_interface_id;
guint8 rank;
USE_UINT8_BIT_FIELD(guint, remote : 1); /* class is remotely activated */
USE_UINT8_BIT_FIELD(guint, initialized : 1); /* cctor has been run */
USE_UINT8_BIT_FIELD(guint, init_failed : 1); /* cctor execution failed */
guint32 imt_collisions_bitmap;
MonoRuntimeGenericContext *runtime_generic_context;
/* do not add any fields after vtable, the structure is dynamically extended */
gpointer vtable [MONO_ZERO_LEN_ARRAY];
};
扯远了,我们继续回来看mono_class_get_vtable_entry
/*
* mono_class_get_vtable_entry:
*
* Returns class->vtable [offset], computing it if neccesary.
* LOCKING: Acquires the loader lock.
*/
MonoMethod*
mono_class_get_vtable_entry (MonoClass *class, int offset)
{
MonoMethod *m;
if (class->rank == 1)
{
....
}
if (class->generic_class)
{
...
}
else
{
mono_class_setup_vtable (class);
m = class->vtable [offset];
}
return m;
}
逻辑比较简单,初始化类的虚函数表,然后用我们上面得出的偏移来获取到对应的虚函数,进行调用。我们看一下class的信息。
0:000:x86> dt class
Local var @ 0xaff8b8 Type _MonoClass*
0x00e38eb8
+0x000 element_class : 0x00e38eb8 _MonoClass
+0x004 cast_class : 0x00e38eb8 _MonoClass
+0x008 supertypes : 0x00e38f70 -> 0x00dc2000 _MonoClass
....
+0x024 parent : 0x00e38dc8 _MonoClass
+0x028 nested_in : (null)
+0x02c image : 0x00e33e28 _MonoImage
+0x030 name : 0x00c50527 "B"
+0x034 name_space : 0x00c50504 "TestCPlus"
+0x038 type_token : 0x2000003
+0x0ac vtable : 0x00e39000 -> 0x00def530 _MonoMethod
可见,从上述一堆魔法操作后,我们从机器码里面拿到了正确的运行时类型class B,对应我们原来的代码
static void Main()
{
A a= new B();
a.Say();
}
然后就是class的虚函数表了。我们简单看一下
0:000:x86> dt _MonoMethod 00def530
mono!_MonoMethod
+0x000 flags : 0x1c6
+0x002 iflags : 0
+0x004 token : 0x6000008
+0x008 klass : 0x00dc2000 _MonoClass
+0x00c signature : 0x00dfc720 _MonoMethodSignature
+0x010 name : 0x02d1cb3e "ToString"
...
+0x014 slot : 0y00000000000000000 (0)
0:000:x86> dt _MonoMethod 00def510
mono!_MonoMethod
+0x000 flags : 0x1c6
+0x002 iflags : 0
+0x004 token : 0x6000005
+0x008 klass : 0x00dc2000 _MonoClass
+0x00c signature : 0x00dfcd00 _MonoMethodSignature
+0x010 name : 0x02d14a13 "GetHashCode"
...
+0x014 slot : 0y00000000000000001 (0x1)
0:000:x86> dt _MonoMethod 00def4f0
mono!_MonoMethod
+0x000 flags : 0xc4
+0x002 iflags : 0
+0x004 token : 0x6000004
+0x008 klass : 0x00dc2000 _MonoClass
+0x00c signature : 0x00def560 _MonoMethodSignature
+0x010 name : 0x02d1cb1d "Finalize"
...
+0x014 slot : 0y00000000000000010 (0x2)
0:000:x86> dt _MonoMethod 00def4d0
mono!_MonoMethod
+0x000 flags : 0x1c6
+0x002 iflags : 0
+0x004 token : 0x6000002
+0x008 klass : 0x00dc2000 _MonoClass
+0x00c signature : 0x00dfcce8 _MonoMethodSignature
+0x010 name : 0x02d14822 "Equals"
...
+0x014 slot : 0y00000000000000011 (0x3)
0:000:x86> dt _MonoMethod 00e38fe0
mono!_MonoMethod
+0x000 flags : 0xc6
+0x002 iflags : 0
+0x004 token : 0x6000004
+0x008 klass : 0x00e38eb8 _MonoClass
+0x00c signature : 0x00e38f88 _MonoMethodSignature
+0x010 name : 0x00c50531 "Say"
...
+0x014 slot : 0y00000000000000100 (0x4)
我们可以看到class B的续表有5个slot,索引分别为0-4。对应5个函数ToString,GetHashCode,Finalize,四个从system.object继承来的函数和一个自己的index 4 函数Say。
至此我们已经理清了大概的思路。
mono_magic_trampoline()
->MonoVTbale vt = mono_arch_get_vcall_slot(&displacement)
MonoMethod m = mono_class_get_vtable_entry (vt->klass, displacement);
addr = compiled_method = mono_compile_method (m);
return addr
在重写的Say方法被调用的时候,在mono CLR内部会会先给这个重写的方法跳转到mono_magic_trampoline。
然后通过mono_arch_get_vcall_slot从调用过来的代码中取到当前运行时信息,比如说MonoVTbale 和对应的虚函数偏移。
在获取到MonoVTbale 和对应的虚函数偏移后CLR通过mono_class_get_vtable_entry获取到当前运行时的类 Class B的虚函数表,并且通过上文提取出来的slot也就是偏移,找到对应的调用函数B::Say()。然后通过mono_compile_method编译B::Say()并返回编译的地址完成调用。至此,我们对于重写这个逻辑基本上搞清楚了。
不过认真看到这里的同学可能会问,这里最重要的MonoVTbale vt 结构在mono_magic_trampoline被调用的时候已经出来了,并且slot也就是偏移也已经算好了,这个偏移和对应的MonoVTbale vt 是怎么出来的?
至于MonoVTbale vt ,是在一开始
A a= new B();
的时候已经生成,然后被放在寄存器eax里面,变成了上面我们看到的汇编码里面的call eax+0x38里面的eax,而代码里面的0x38是mono根据MetaData里面的Class A的信息计算出来的。
就是il代码里面的 void TestCPlus.A::Say()。A的Say函数所在的Slot在编译的时候便已经确定。而Class B继承于Class A,所以无论当前eax寄存器里面存放的是class A还是class B的实例,只要是调用A里面有的函数,对应的虚函数(vtable)的偏移也就是上面的0x38都是一样的,在偏移0x38处都是Say方法。
对这块实现有兴趣的同学可以看一下mono_class_setup_vtable代码,这边就不再赘述了。
/*
* mono_class_setup_vtable:
*
* Creates the generic vtable of CLASS.
* Initializes the following fields in MonoClass:
* - vtable
* - vtable_size
* Plus all the fields initialized by setup_interface_offsets ().
* If there is an error during vtable construction, class->exception_type is set.
*
* LOCKING: Acquires the loader lock.
*/
void
mono_class_setup_vtable (MonoClass *class)
好吧,这块实在是有点绕。希望大家还是动手调试调试,这样对代码有更深入的了解。
我们下次再见。