原文:http://www.blogcn.com/User8/flier_lu/index.html?id=4091564
曾经几次有朋友问,如何使用托管代码简单地精确获取一个对象在堆或栈中所占内存的大小。我想说,这基本上很难,呵呵。想要做到通用又精确,则必然涉及到 CLR 对载入类型自动的内存布局 (Layout) 控制逻辑,而这部分的逻辑,又是 CLR 在设计时就刻意隐藏的。 CLR 的架构设计师 Chris Brumme 在去年的一篇 blog 中曾经简要地讨论了这个问题,Size of a managed object,其中提到可以通过 Marshal.SizeOf 和 IL 指令 sizeof 等几种方法,有限度地获取对象或结构的内存大小。同时也举了一个例子指出这些方法并不精确。
[StructLayout(LayoutKind.Sequential)] struct B { public char c1; public int i1; public char c2; }
class c { public static void Main(string[] args) { b.c1 = 'a'; b.i1 = 1; b.c2 = 'b';
ofs1 = Marshal.OffsetOf(typeof(B), "c1"); Console.WriteLine("B.c1 at " + ofs1);
ofs1 = Marshal.OffsetOf(typeof(B), "i1"); Console.WriteLine("B.i1 at " + ofs1);
ofs1 = Marshal.OffsetOf(typeof(B), "c2"); Console.WriteLine("B.c2 at " + ofs1);
Console.WriteLine("--------------------");
unsafe { char *p = &b.c1; { byte *p2 = (byte *) p - 4;
for (int i=0; i<16; i++, p2++) Console.Write(*p2 + " ");
Console.WriteLine(); } } } }
| | 在这个例子中,我们期望类型 B 的内存布局能够按照其定义的 LayoutKind.Sequential 模式顺序排列。运行的前半部分结果也的确如我们所料,c1, i1 和 c2 三个字段顺序排列,通过 Marshal.OffsetOf 获取的字段偏移证实了这一点。 上述例子的运行结果如下:
以下为引用:
B.c1 at 0 B.i1 at 4 B.c2 at 8 -------------------- 1 0 0 0 97 0 98 0 68 42 192 4 20 43 192 4
|
但与之矛盾的是,通过 unsafe 代码打印出的实际的内存数据,却是以 i1, c1, c2 的顺序进行的排列?!也就是说,虽然我们通过 LayoutKind.Sequential 强制指定了三个字段的顺序,但 CLR 只是保证在 Metadata 这一级的静态顺序;而对于对象实际运行时的字段内存布局,实际上 CLR 还是强行进行了布局优化。而当我们想当然的将这个结构传递给非托管代码时,问题就会随机产生,因为 CLR 的运行时内存布局策略是非公开的,可能随着发行版本、系统架构、甚至配置等等改变。 实际上 MSDN 的 Marshal.SizeOf 文档中已经明确指出,“The size returned is actually the size of the unmanaged object. The managed size of the object can differ”,此函数返回的是目标类型或对象的 unmanaged 形式对象的大小,也可以说是其 marshal 后的大小,而并非运行时 CLR 维护的 managed 形式对象的大小。相应的 Marshal.OffsetOf 方法也是如此。
如果只是将此类型在托管代码中使用,这种定义和实现上的细微区别完全可以忽略不计;但如果要将此类型的实例与非托管代码进行交互,则必须考虑到这个差别。哪怕是程序调试时不存在问题,也可能因为以后 CLR 的内存布局策略的改变而发生问题。例如移植到 64 位系统后,指针的大小会发生变化,以前在 32 位系统中手工对其的结构可能还是会被字段优化等等。 而解决这个问题的方法,除了前面所说的手工对齐字段进行排列外,还可以使用 LayoutKind.ExplicitLayout 布局策略,手工指定每个字段的位置。不过这两种方法都不是很理想,麻烦而且容易出错。较好的方式是通过 CLR 提供的 Marshal.StructureToPtr 函数,显式重新构造内存布局,将托管实例的数据复制到指定的内存,进而与非托管代码进行交互。
[StructLayout(LayoutKind.Sequential)] struct B { public char c1; public int i1; public char c2; }
class c { public static void Main(string[] args) { B b = new B();
b.c1 = 'a'; b.i1 = 1; b.c2 = 'b';
IntPtr c = Marshal.AllocCoTaskMem(Marshal.SizeOf(typeof(B))); try { Marshal.StructureToPtr(b, c, false);
unsafe { char *p = (char *)c; { byte *p2 = (byte *) p;
for (int i=0; i<16; i++, p2++) Console.Write(*p2 + " ");
Console.WriteLine(); } } } finally { Marshal.FreeCoTaskMem(c); } } } | | 可以看到执行结果与原本的托管实例内存布局完全不同,而与我们所期望的相同。
以下为引用:
97 0 0 0 1 0 0 0 98 0 0 0 32 32 32 32
|
查看 Rotor 中 Marshal.StructureToPtr 方法的实现 StructureToPtr 函数 (vm\comndirect.cpp:85) 可以发现,在将结构实例复制到目标内存区域之前,首先对类型是否可以直接复制做了一个检测;如果不能直接复制,则调用 FmtClassUpdateNative 函数对字段数据进行整理后,才真正复制数据。这也是为什么通过 StructureToPtr 后,我们能获得跟静态定义相同的内存布局数据的原因,也就是 SizeOf 函数帮助里所提到的 unmanaged object。
VOID StructureToPtr(Object* pObjUNSAFE, LPVOID ptr, INT32 fDeleteOld) { OBJECTREF pObj = (OBJECTREF) pObjUNSAFE; MethodTable *pMT = pObj->GetMethodTable(); EEClass *pcls = pMT->GetClass();
if (pcls->IsBlittable()) { memcpyNoGCRefs(ptr, pObj->GetData(), pMT->GetNativeSize()); } else if (pcls->HasLayout()) { if (fDeleteOld) { LayoutDestroyNative(ptr, pcls); } FmtClassUpdateNative( &(pObj), (LPBYTE)(ptr) ); } else { COMPlusThrowArgumentException(L"structure", L"Argument_MustHaveLayoutOrBeBlittable"); } }
| | 这里的几个核心函数,IsBlittable、LayoutDestroyNative 和 FmtClassUpdateNative,等下一节介绍类型内存布局实现的时候再详细解释。
而 Marshal.SizeOf 方法的实现 SizeOfClass(vm/comndirect.cpp:191) / FCSizeOfObject(vm/comndirect.cpp:243) 等函数也很类似,基本上都是从对象获取类型,从类型获取 MethodTable,最后调用 MethodTable::GetNativeSize() 获得静态的从 Metadata 中得到的前期绑定实例大小。 与直接对类型或对象进行操作的 Marshal.SizeOf 方法不同,sizeof 指令的操作对象是一个类型的 Token。JIT 编译器将确保此 Token 的有效性,并且 Token 只能指向一个值类型。FJit::jitCompile 函数(fjit/fjitcompiler.cpp:2036)完成了这一工作,伪代码如下:
CorJitResult FJit::jitCompile() { //
case CEE_SIZEOF: { // 从栈中获取 Token GET(token, unsigned int, false);
// 验证 Token 有效性 VERIFICATION_CHECK(m_IJitInfo->isValidToken(scope, token));
// 验证 Token 是有效的 typeRef 或 typeDef 引用 VALIDITY_CHECK(targetClass = m_IJitInfo->findClass(scope,token,methodHandle));
// 验证 Token 指向一个值类型 DWORD classAttributes = m_IJitInfo->getClassAttribs(targetClass, methodHandle); VERIFICATION_CHECK( classAttributes & CORINFO_FLG_VALUECLASS ); SizeOfClass = m_IJitInfo->getClassSize(targetClass);
emit_LDC_I4(SizeOfClass);
fjit->pushOp(typeI); } break;
// }
而具体完成类型大小获取工作的 CEEInfo::getClassSize 函数(vm/jitinterface.cpp:894)会进一步对数组和普通类型进行分别处理,而最终落实到 EEClass::m_dwNumInstanceFieldBytes 这个字段上来。此字段等下一节介绍类型内存布局实现的时候再详细解释。
unsigned __stdcall CEEInfo::getClassSize (CORINFO_CLASS_HANDLE clsHnd) { REQUIRES_4K_STACK;
unsigned ret; CANNOTTHROWCOMPLUSEXCEPTION();
if (isValueArray(clsHnd)) { ValueArrayDescr* valArr = asValueArray(clsHnd); ret = valArr->sig.SizeOf(valArr->module); } else { TypeHandle VMClsHnd(clsHnd); ret = VMClsHnd.GetSize(); } return ret; }
unsigned TypeHandle::GetSize() { CorElementType type = GetNormCorElementType(); if (type == ELEMENT_TYPE_VALUETYPE) return(AsClass()->GetNumInstanceFieldBytes()); return(GetSizeForCorElementType(type)); }
inline DWORD EEClass::GetNumInstanceFieldBytes() { return(m_dwNumInstanceFieldBytes); } | | 值得注意的是,这儿对类型大小进行处理时,除了值类型外的其他类型,都是通过 GetSizeForCorElementType 函数(vm/siginfo.cpp:183) 访问全局 gElementTypeInfo 数组,获得内建类型的定义信息。
unsigned GetSizeForCorElementType(CorElementType etyp) { _ASSERTE(gElementTypeInfo[etyp].m_elementType == etyp); return gElementTypeInfo[etyp].m_cbSize; }
| | 至此,过于 CLR 运行时类型字段的内存布局的使用原理就大概清晰了,下一节将展开介绍 CLR 在载入静态类型到运行时内存布局时的策略,以及如何有效对其进行访问。
to be continue... | |