问题的提出,是源自Justin提出的一个case里面的一个问题,讨论了n久没得到一个答案,昨天justin周一早上一起来就又回忆起了这个问题,看来一直把这个问题放在脑子里面没有放下,佩服啊佩服 ^_^ 遂决定深入研究一番,下面是问题的提出:
Boxed value type
In C#, the value type instance having pure user data is resided at stack without any type pointer. In some case, the value need be boxed, then a new boxed object is created from heap. My questions are:
l Assume the value type overrides an virtual function, such as ToString(). When call the function using value instance, the this points to the pure user data part of value instance in stack, however, if calling function using boxed value object, the this should point to the beginning of heap object (the type pointer, four bytes before pure user data). How does the ToString() implementation of value type distinguish between the two cases?
From debug of assembly instruction, I find the ToString() function always gets this pointing to the pure user data part even if it’s called from boxed object. But I have no idea which code move the this pointer forward four bytes?
首先,对于什么是值类型啥是引用类型,及其区别,以及派生结构层次,不属于这里的话题,探讨主要围绕针对上面的这个问题的提出展开。
可以定义一个Struct的类型,来实现对ValueType的继承。这个从Struct继承出来的ValueType,还可以重写从基类继承的方法,一共只有三个方法可供重写:Equals(),
GetHashCode(),ToString()。
好吧,看看上面的问题,首先解释下上面的问题提出之前的一段文字:
Assume the value type overrides an virtual function, such as ToString(). When call the function using value instance, the this points to the pure user data part of value instance in stack, however, if calling function using boxed value object, the this should point to the beginning of heap object (the type pointer, four bytes before pure user data).
厄,首先明确一下,这段话是没有任何问题的。我也曾怀疑过这句话里面的细微的地方的观点,但是事实证明我是错误的…..
来构造一个所提出问题的案例先:
class Program
{
static void Main(string[] args)
{
TestValueType testValueType = new TestValueType(1214926);
Console.WriteLine(testValueType.ToString());
Object o = testValueType;
Console.WriteLine(o.ToString());
int i = 12345;
Console.WriteLine(i.ToString());
Console.ReadLine();
}
internal struct TestValueType
{
private int i;
public TestValueType(int initValue)
{
i = initValue;
}
//Can only override 3 Methods:Equals(),GetHashCode(),ToString()
public override string ToString()
{
return "Valuetype virtual method tostring() Override.";
}
}
}
对于提出的第一个问题:
How does the ToString() implementation of value type distinguish between the two cases?
我想,这个地方不仅仅是two case,而是有四种case:
1. 一个自定义的值类型没有重写tostring()方法的时候,对tostring的调用是如何实现的?tostring()方法的实现是存储在什么地方的?
2. 一个自定义的值类型override了virtual的base class的tostring方法之后,对tostring的调用是如何进行的?这个tostring的实现是存放在什么地方的?也就是调用的哪个实现,如何调用的问题。
3. 一个boxed了的自定义的值类型的boxed状态下,没有重写tostring方法的时候,这个tostring是如何调用的?
4. 上面的一个问题,一个重写了tostring方法的自定义valuetype在boxed状态下面,tostring的调用是如何实现的,存放在哪儿。
Ok,这个是回答他的问题的序言,当然,不是全部,还有Managed Pointer,Instance Pointer,this指针的关系callvirt的具体细节和几个il指令背着我做的小动作问题..
好吧,解决这些问题,先从il语言入手,下面是Main方法的反编译之后的il代码:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// Code size 78 (0x4e)
.maxstack 2
.locals init ([0] valuetype ValutTypeTest.Program/TestValueType
testValueType,
[1] object o,
[2] int32 i)
IL_0000: nop
IL_0001: ldloca.s testValueType
IL_0003: ldc.i4 0x1289ce
IL_0008: call instance void
ValutTypeTest.Program/TestValueType::.ctor(int32)
IL_000d: nop
IL_000e: ldloca.s testValueType
IL_0010: constrained. ValutTypeTest.Program/TestValueType
IL_0016: callvirt instance string [mscorlib]System.Object::ToString()
IL_001b: call void [mscorlib]System.Console::WriteLine(string)
IL_0020: nop
IL_0021: ldloc.0
IL_0022: box ValutTypeTest.Program/TestValueType
IL_0027: stloc.1
IL_0028: ldloc.1
IL_0029: callvirt instance string [mscorlib]System.Object::ToString()
IL_002e: call void [mscorlib]System.Console::WriteLine(string)
IL_0033: nop
IL_0034: ldc.i4 0x3039
IL_0039: stloc.2
IL_003a: ldloca.s i
IL_003c: call instance string [mscorlib]System.Int32::ToString()
IL_0041: call void [mscorlib]System.Console::WriteLine(string)
IL_0046: nop
IL_0047: call string [mscorlib]System.Console::ReadLine()
IL_004c: pop
IL_004d: ret
} // end of method Program::Main
首先说说几个pointer的关系。
对于一个放在计算堆栈里面的value type或者是自定义value type来说,如果想要使用这个类型里面的fields或者是members,需要提供Managed Pointer。也就是&valuetypename的值。在C++里面,没有类似的Dotnet的值类型之内的概念,所以,这里的Managed Pointer就相当于一个object的instance Pointer,或者说是instance reference。对于Value Type的Managed Pointer,指向的是计算stack上面的data part。而对于一个对象来说,this pointer(instance Pointer)指向的是Obj ref,一个四个字节的地方。这个地方存放的数据,指向的是MethodTable。这四个字节的上面,是控制同步的一个块和一个Object Header,这四个字节的下面,就是instance fields,包括一些fields和一些member方法的实现。
This指针在对value type的时候,指向的是stack上面的数据部分。
对于il的一个call方法指令,不管是value type,还是ref type,有的时候是需要指针的,有的时候不需要this指针。Metadata里面并不包含this指针,但是方法的签名,使用一个叫做HASTHIS的bit位来标识是否需要this指针:“Signature: If the method is static, then the HASTHIS (0x20) bit in the calling convention shall be 0”。具体说来,如果一个方法的实现,是保持在type相关的内存中的,就不要这个标识位,如果每个type的instance保存一份实现,就用这个标识。区别这两种情况,可以用C#里面的Stact关键字。
对于:
Object o = testValueType;
Console.WriteLine(o.ToString());
Il代码是:
IL_000e: ldloca.s testValueType
IL_0010: constrained. ValutTypeTest.Program/TestValueType
IL_0016: callvirt instance string [mscorlib]System.Object::ToString()
IL_001b: call void [mscorlib]System.Console::WriteLine(string)
Constrained前缀修饰符,是一个非常有意思的修饰符,他必须和callvirt关键字一起使用,首先看起Stack转换:
constrained. thisType(这里是ValutTypeTest.Program/TestValueType)
Stack Transition:
之前:…, ptr, arg1, … argN 之后:…, ptr, arg1, … argN
Ms没有什么变换,但是Ptr(Managed Pointer)的内容,却是有一个转换逻辑:
l If thisType is a reference type (as opposed to a value type) then,ptr is dereferenced and passed as the ‘this’ pointer to the callvirt of method
l If thisType is a value type and thisType implements method then,ptr is passed unmodified as the ‘this’ pointer to a call of method implemented by thisType
l If thisType is a value type and thisType does not implement method then,ptr is dereferenced, boxed, and passed as the ‘this’ pointer to the callvirt of method
这样,就得到了问题的回答:
对于一个value type(或者自定义的value type),仍然可以调用由类型继承或者是从重写的虚方法。比如Equals,GetHashCode,或者Tostring,因为CLR正好可以以非虚的方式调用这些方法。
如果重写了一个自定义的value type类型的tostring方法,首先,this pointer(managed Pointer)指向的是stack上面的这个变量的data field,数据开始部分。然后在调用tostring方法的时候,首先让constrained来检验下,接着发现了这个自定义的value type实现了tostring这个方法,好,这个时候不执行boxing的动作,直接采用il指令里面的call指令,然后直接调用stack上面的这个value type的data filed之后的tostring方法。
这种情况下,是没有MethodTable,不和MethodTable进行交互。很重要的一点,值类型隐式为sealed类型,所以不可将一个值类型做为另外一个类型的基类使用。
如果自定义的value type类型,没有实现了tostring方法,这个时候,constrained前缀的处理逻辑是,根据“ldloca.s testValueType”刚刚放到stack上面的value type的managed pointer,先把这个value type boxing,装箱,然后把在heap上面新创建的object的ref,替换掉这个ptr(value type的managed pointer),(很重要的一点:这个时候,this指针指向的是obj ref)。这个时候,就换成了使用callvirt指令调用。这个时候,ptr上面的内容,就是object ref,heap上面的value type的instace fields的前四个字节的地方。这个obj ref里面的内容,指向的就是MethodTable。这个时候调用virtual方法,需要通过Vtable map,来具体定位到使用那个基类的方法。
寻找的流程是先看看当前的instance里面实现了相同签名的方法没,如果没有,就找基类或者父类里面的相同签名的。
如果,calling function using boxed value object,这个时候是如何实现的呢?还是上面的C#代码,当如下面操作的时候:
Object o = testValueType;
Console.WriteLine(o.ToString());
不管这个时候testValueType实现没实现tostring方法,这个时候,想当于直接调用一个Object Type的某个方法,是通过走MethodTable来寻找其实现的。
这种情况下,无论如何,在刚刚准备执行这个方法之前,this指针的内容,是一个Ref Type的instance fields的开始的部分。而具体的使用哪个方法,则是根据MethodTable来的。
另外一点需要了解,CLR的特性里面,提供可以以非虚的方式调用从类型或者基类继承的方法。而且System.ValueType重写了这些虚方法,由struct定义的自定义value type没重写这些方法。
了解了这点,如何还有疑惑的话,可以看下下面这个有助于理解的sample:
.class public value XXX
{
.method public void YYY( )
{
...
}
.method public virtual void ZZZ( )
{
...
}
}
.method public static void Exec( )
{
.entrypoint
.locals init(valuetype XXX xxx) // Variable xxx is an Instance of XXX
ldloca xxx // Load managed ptr to xxx
call instance void XXX::YYY( ) // Legal: access to value
// type member
// by managed ptr
ldloca xxx
callvirt instance void XXX::ZZZ( ) // Illegal: virtual call of
// methods possible only
// by object reference.
ldloca xxx
call instance void XXX::ZZZ( ) // Legal: nonvirtual call, access to value type member
// by managed ptr.
ldloc xxx // Load instance of XXX.
box valuetype XXX // Convert it to object reference.
callvirt instance void XXX::ZZZ( ) // Legal
...
}
这时,就涉及到文章最开始提出的第二个问题:
From debug of assembly instruction, I find the ToString() function always gets this pointing to the pure user data part even if it’s called from boxed object. But I have no idea which code move the this pointer forward four bytes?
这里,有一个比较重要的特性,也是callvirt指令来实现的,在具体调用每个方法开始的时候之前,callvirt实现了一个justin和问题的提出者,叫做“this指针偏移”的功能。当然,文章到此为止,还没有证明这一点。
这个解释逻辑,也是参考了大量的资料之后得到的一个假设吧。Callvirt,在执行的时候,有很多种不同的情况下都可以调用callvirt,譬如interface,虚方法,多态等等,callvirt会进行一个判断,来判断具体是哪种情况。如果是我们上面的对boxed value type的情况,就有一个this指针偏移的处理逻辑。
为了证明这点,咱可以参考Rotor是如何实现callvirt方法的:
首先查看Fjit.cpp的实现,这个页面有万把行,实现了大部分IL指令具体做了些啥。
switch (opcode)
{
case CEE_CALLVIRT:
JitResult = compileCEE_CALLVIRT();
break;
}
好吧,查看compileCEE_CALLVIRT的实现:
FJitResult FJit::compileHelperCEE_CALLVIRT(unsigned int token,
bool isReadOnly /* = false */)
{
jitInfo->getCallInfo(methodInfo->ftn,
tokenScope,
token,
0, // constraintToken -
tokenContext,
CORINFO_CALLINFO_CALLVIRT,
&virtCallInfo);
if (virtCallInfo.kind == CORINFO_VIRTUALCALL_LDVIRTFTN)
{
int this_ptr = findOffsetOfThisPtr(targetSigInfo);
emit_getSP((STACK_BUFFER + this_ptr - SIZE_STACK_SLOT));
emit_LDIND_I4(false);
emit_ldvirtftn_helper(token, jitInfo->getMemberParent(methodInfo->scope, token));
emit_save_TOS(); // squirel away the target ftn address
emit_POP_PTR(); // and remove from stack
}
argBytes = buildCall(&targetSigInfo, CALL_NONE, stackPadorRetBase, false);
sizeRetBuff = targetSigInfo.hasRetBuffArg() ? typeSizeInBytes(jitInfo, targetSigInfo.retTypeClass) : 0;
_ASSERTE (virtCallInfo.kind != CORINFO_CALL_CODE_POINTER);
if (virtCallInfo.kind == CORINFO_VIRTUALCALL_LDVIRTFTN)
{
emit_restore_TOS(); //push the saved target ftn address
// Now we can use the sequence for CALLI.
emit_calli(targetSigInfo.hasRetBuffArg() ? typeSizeInBytes(jitInfo,
targetSigInfo.retTypeClass) : 0);
}
else if (virtCallInfo.kind == CORINFO_VIRTUALCALL_STUB)
{
_ASSERTE (!virtCallInfo.stubLookup.lookupKind.needsRuntimeLookup);
_ASSERTE (virtCallInfo.stubLookup.constLookup.addr != NULL);
_ASSERTE(virtCallInfo.stubLookup.constLookup.accessType == IAT_PVALUE);
emit_call_stub((unsigned int) virtCallInfo.stubLookup.constLookup.addr);
}
else if (virtCallInfo.kind == CORINFO_CALL)
{
if (virtCallInfo.nullInstanceCheck)
{
emit_check_null_reference(false);
}
CORINFO_CONST_LOOKUP addrInfo;
jitInfo->getFunctionEntryPoint(targetMethod, IAT_VALUE, &addrInfo);
VALIDITY_CHECK(addrInfo.addr);
VALIDITY_CHECK(addrInfo.accessType == IAT_VALUE ||
addrInfo.accessType == IAT_PVALUE);
emit_callnonvirt((unsigned)addrInfo.addr, sizeRetBuff, addrInfo.accessType == IAT_PVALUE);
}
else if (virtCallInfo.kind == CORINFO_VIRTUALCALL_VTABLE)
{
if (jitInfo->getClassAttribs(targetClass,methodInfo->ftn) &
CORINFO_FLG_INTERFACE)
{
offset = jitInfo->getMethodVTableOffset(targetMethod);
_ASSERTE(!(methodAttribs & CORINFO_FLG_EnC));
unsigned InterfaceTableOffset;
InterfaceTableOffset = jitInfo->getInterfaceTableOffset(targetClass);
emit_callinterface_new(InterfaceTableOffset*4,
offset, sizeRetBuff );
}
else
{
offset = jitInfo->getMethodVTableOffset(targetMethod);
_ASSERTE(!(methodAttribs & CORINFO_FLG_DELEGATE_INVOKE));
emit_callvirt(offset, sizeRetBuff);
}
}
}
这里只截取了最后的一段,前面的完整性检查之内的略掉。virtCallInfo,看到这样的结构,字眼和判断,和咱估计的情况差不多。然后来查看CORINFO_CALL_KIND这个结构体的定义:
enum CORINFO_CALL_KIND
{
CORINFO_CALL,
//下面的两个CallVirt指令里面没用
CORINFO_CALL_CODE_POINTER,
CORINFO_VIRTUALCALL_RESOLVED,
CORINFO_VIRTUALCALL_STUB,
CORINFO_VIRTUALCALL_LDVIRTFTN,
CORINFO_VIRTUALCALL_VTABLE
};
转到corinfo.h文件里面,getCallInfo and CORINFO_CALL_INFO,这两个东西是EE用来指示Fjit如何具体编译不同情况下面的callvirt指令。
查看都是什么情况下使用不同的结构体,发现了
CORINFO_VIRTUALCALL_LDVIRTFTN这种情况下对this指针偏移的支持:
//找到this指针的地址
int this_ptr = findOffsetOfThisPtr(targetSigInfo);
//减去四个字节指向到instance fields
// #define SIZE_STACK_SLOT 4
emit_getSP((STACK_BUFFER + this_ptr - SIZE_STACK_SLOT));
emit_LDIND_I4(false);
emit_ldvirtftn_helper(token, jitInfo->getMemberParent(methodInfo->scope, token));
emit_save_TOS(); // squirel away the target ftn address
emit_POP_PTR(); // and remove from stack
emit_getSP方法最终指向了xp平台下x86fjit.h的实现:
// push a pointer pointing 'n' bytes back in the stack
#define x86_getSP(n) deregisterTOS;
if (n == 0)
x86_mov_reg(x86DirTo, x86Big, x86_mod_reg(X86_EAX, X86_ESP));
else
x86_lea(x86_mod_base_scale_disp(X86_EAX, X86_ESP,
X86_NO_IDX_REG, n, 0));
inRegTOS = true;
最后,再说下,再看这些il指令的实现的时候,发现constrained的实现比较有意思,也就是基于上面给出的三种判断逻辑情况下,使用了一个结构体:
enum CORINFO_THIS_TRANSFORM
{
CORINFO_NO_THIS_TRANSFORM,
CORINFO_BOX_THIS,
CORINFO_DEREF_THIS
};
来支持三种不同情况下面是this指针的转换方式:
switch (callInfo.thisTransform)
{
case CORINFO_NO_THIS_TRANSFORM:
{
//不需要改变this指针(managed Pointer,ptr)的情况下直接调用call指令
return this->compileHelperCEE_CALL(funcToken,
callInfo.targetMethodHandle,false /*readonly*/);
}
//根据managed ptr的内容来装箱(If thisType is a value type and thisType implements //method)
case CORINFO_BOX_THIS:
{
CORINFO_SIG_INFO targetSigInfo;
jitInfo->getMethodSig(callInfo.targetMethodHandle, &targetSigInfo);
// this is slightly ineffecient, especially when dealing with large
// valuetypes but effeciency is not paramount in fjit
// {... , objPtr, args} -> {..., objPtr, args, objPtr }
copyPtrUpAroundArgs(targetSigInfo);
// {..., objPtr, args, objPtr } -> {..., objPtr, args, *objPtr }
if( (retval = this->compileHelperCEE_LDOBJ(constraintToken)) != FJIT_OK)
return retval;
// {..., objPtr, args, *objPtr } -> {..., objPtr, args, boxedPtr }
if( (retval = this->compileHelperCEE_BOX(constraintToken)) != FJIT_OK)
return retval;
// {..., objPtr, args, boxedPtr } -> {... , boxedPtr, args}
copyPtrDownAroundArgs(targetSigInfo);
return this->compileHelperCEE_CALLVIRT(funcToken);
}
//最后一种情况(If thisType is a value type and thisType does not implement method),直接
//调用CALLVIRT
case CORINFO_DEREF_THIS:
{
CORINFO_SIG_INFO targetSigInfo;
jitInfo->getMethodSig(callInfo.targetMethodHandle, &targetSigInfo);
// {... , &this, args} -> {..., &this, args, &this }
copyPtrUpAroundArgs(targetSigInfo);
// it was a reference type
if( (retval = this->compileCEE_LDIND_REF()) != FJIT_OK)
return retval;
// {... , &this, args, this} -> {..., this, args}
copyPtrDownAroundArgs(targetSigInfo);
return this->compileHelperCEE_CALLVIRT(funcToken);
}
文章写的匆忙,很多看资料的时候看到的细节可能忘记了没写上。欢迎大伙讨论
有写的不正确的地方,欢迎指正。^_^
6/24/2008 4:57:24 PM lbq1221119