Friday, April 08, 2005

 

Some notes on DotNet CLR Memory Layout

1.
Size of a managed object

By Chris Brumme
From http://blogs.msdn.com/cbrumme/archive/2003/04/15/51326.aspx

We don't expose the managed size of objects because we want to reserve the ability to change the way we lay these things out. For example, on some systems we might align and pack differently. For this to happen, you need to specify tdAutoLayout for the layout mask of your ValueType or Class. If you specify tdExplicitLayout or tdSequentialLayout, the CLR’s freedom to optimize your layout is constrained.

If you are curious to know how big an object happens to be, there are a variety of ways to discover this. You can look in the debugger. For example, Strike or SOS (son-of-strike) shows you how objects are laid out. Or you could allocate two objects and then use unverifiable operations to subtract the addresses. 99.9% of the time, the two objects will be adjacent. You can also use a managed profiler to get a sense of how much memory is consumed by instances of a particular type.

But we don't want to provide an API, because then you could form a dependency over this implementation detail.

Some people have confused the System.Runtime.InteropServices.Marshal.SizeOf() service with this API. However, Marshal.SizeOf reveals the size of an object after it has been marshaled. In other words, it yields the size of the object when converted to an unmanaged representation. These sizes will certainly differ if the CLR’s loader has re-ordered small fields so they can be packed together on a tdAutoLayout type.

2.
CLR 中类型字段的运行时内存布局原理浅析

By Flier

曾经几次有朋友问,如何使用托管代码简单地精确获取一个对象在堆或栈中所占内存的大小。我想说,这基本上很难,呵呵。想要做到通用又精确,则必然涉及到 CLR 对载入类型自动的内存布局 (Layout) 控制逻辑,而这部分的逻辑,又是 CLR 在设计时就刻意隐藏的。
CLR 的架构设计师 Chris Brumme 在去年的一篇 blog 中曾经简要地讨论了这个问题,Size of a managed object,其中提到可以通过 Marshal.SizeOf 和 IL 指令 sizeof 等几种方法,有限度地获取对象或结构的内存大小。同时也举了一个例子指出这些方法并不精确。

http://blogs.msdn.com/cbrumme/archive/2003/04/15/51326.aspx

以下内容为程序代码:

[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"[img]/images/wink.gif[/img];
Console.WriteLine("B.c1 at " + ofs1);

ofs1 = Marshal.OffsetOf(typeof(B), "i1"[img]/images/wink.gif[/img];
Console.WriteLine("B.i1 at " + ofs1);

ofs1 = Marshal.OffsetOf(typeof(B), "c2"[img]/images/wink.gif[/img];
Console.WriteLine("B.c2 at " + ofs1);

Console.WriteLine("--------------------"[img]/images/wink.gif[/img];

unsafe
{
char *p = &b.c1;
{
byte *p2 = (byte *) p - 4;

for (int i=0; i<16; i++, p2++)
Console.Write(*p2 + " "[img]/images/wink.gif[/img];

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 + " "[img]/images/wink.gif[/img];

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) [img]/images/wink.gif[/img];
} else {
COMPlusThrowArgumentException(L"structure", L"Argument_MustHaveLayoutOrBeBlittable"[img]/images/wink.gif[/img];
}
}

这里的几个核心函数,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_F L G_VALUECLASS [img]/images/wink.gif[/img]; // blogcn 这帮猪头, 居然逼着我手工在几千字文章里面找所谓的敏感词语 :S (按:哈哈,前面我转那个PE结构的blog时,也只好分段查了好多次才找到不能放F X G这个字符串)

SizeOfClass = m_IJitInfo->getClassSize(targetClass);

emit_LDC_I4(SizeOfClass);

fjit->pushOp(typeI);
} break;

//...
}

而具体完成类型大小获取工作的 CEEInfo::getClassSize 函数(vm/jitinterface.cpp:894)会进一步对数组和普通类型进行分别处理,而最终落实到 EEClass::m_dwNumInstanceFieldBytes 这个字段上来。此字段等下一节介绍类型内存布局实现的时候再详细解释。
[code]
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 在载入静态类型到运行时内存布局时的策略,以及如何有效对其进行访问。

发信人: flier (小海 [渴望并不存在的完美]), 信区: DotNET
标 题: CLR 中类型字段的运行时内存布局 (Layout) 原理浅析 [2]
发信站: BBS 水木清华站 (Fri Oct 8 10:53:24 2004), 站内


原文: http://www.blogcn.com/User8/flier_lu/index.html?id=4137331

在了解了 CLR 对类型的内存布局的访问方式后,回过头来看看 CLR 是如何在载入类型时,对其内存布局进行调整的。

与 Java 中的 ClassLoader 概念不同,CLR 中将类型的隔离和载入放到 Assembly 和 AppDomain 两个层面来维护。因为 Java 的模型是建立在以 ClassLoader 为聚集点的独立类型集合上,而 CLR 则是以 Assembly 对类型进行物理组织,以 AppDomain 进行逻辑组织。所以在 CLR 中实际上并没有一个完全对应于 Java 的 ClassLoader 的部件,而是由 Fusion 完成 Assembly 中类型载入工作,由 AppDomain 完成类型隔离和安全限定等工作。
Rotor 中的 ClassLoader 类 (vm/clsloader.hpp:308) 就是负责实际类型信息载入的主要工具类之一。
其实际进行类型载入的 LoadTypeHandleFromToken 函数 (vm/clsloader.cpp:1814),将首先通过 HasLayoutMetadata 函数判断要载入的类型是否包括 layout 信息。如在 C# 中使用缺省的 struct 定义,或者显式定义 StructLayoutAttribute 为 LayoutKind.Explicit,都将使类型中包括布局信息。
对包括布局信息的类型,此函数将进一步调用 CollectLayoutFieldMetadata 函数执行布局策略并收集其布局信息,并在构造类型方法表的时候将字段布局信息绑定进去。因此就如前面讨论对布局信息使用的方法一样,类型的布局信息是与其方法表动态绑定到一起的。

执行布局策略并收集布局信息的 CollectLayoutFieldMetadata 函数 (vm/nstruct.cpp:627),将首先检查此类型的父类型是否是普通值类型或 Object 类型,如果不是则确认其也拥有布局信息。也就是说不能从一个 LayoutKind.Auto 类型的自动布局类型中,继承出一个具有特定布局策略的类型,以保证布局策略执行的完整性。这一点一般来说是通过开发语言的编译器一级做限定的,但 CLR 不排除通过 IL 或其他方式定义的可能性,因此必须做一个检测。

然后此函数会分三个阶段完成对类型布局的策略执行和信息收集工作。

1.遍历类型字段定义,初始化后面要用到的辅助结构
2.遍历并计算每个字段的 Native 形式所需空间
3.根据字段的对齐策略对其执行布局策略

首先来看看在执行布局策略全程使用的一个布局信息中间结构 LayoutRawFieldInfo。
以下内容为程序代码:

struct LayoutRawFieldInfo
{
mdFieldDef m_MD; // mdMemberDefNil for end of array
UINT8 m_nft; // NFT_* value
UINT32 m_offset; // native offset of field
UINT32 m_cbNativeSize; // native size of field in bytes
ULONG m_sequence; // sequence # from metadata
BOOL m_fIsOverlapped;

struct {
private:
char m_space[MAXFIELDMARSHALERSIZE];
} m_FieldMarshaler;
}



在调用 CollectLayoutFieldMetadata 函数前会构造一个 LayoutRawFieldInfo 结构数组,然后在阶段 1 中初始化 m_MD (字段 Token)、m_nft(NStruct 内部使用的字段类型定义)和 m_FieldMarshaler (字段类型相关的 Marshaler 处理对象)字段,并且根据布局策略初始化 m_offset(字段偏移)和 m_sequence (字段在元数据中的顺序);接着在阶段 2 则根据每个字段的 Marshaler 计算其 m_cbNativeSize(字段实际大小);最后在阶段 3 根据布局策略调整并设置所有字段的 m_offset (类型中字段偏移)以及 m_fIsOverlapped(字段是否被覆盖)。因此,实际上 CollectLayoutFieldMetadata 函数的所有操作都是围绕着这个结构数组进行的。

下面来仔细看看每个阶段是如何操作的。

阶段 1 通过 IMDInternalImport 接口遍历类型的每个字段,在跳过所有的静态字段后,初始化中间结构数组项;然后调用 ParseNativeType 函数针对每个字段的类型进行分析,将之翻译为 NStruct 内部使用的字段类型 (m_nft),并构造字段相关的 Marshaler 对象;最后根据字段的类型判断是否需要将类型设置为可位复制的。伪代码如下:
以下内容为程序代码:

IMDInternalImport *pInternalImport = pModule->GetMDImport();
LayoutRawFieldInfo *pfwalk = pInfoArrayOut;

// 遍历类型的每个字段
for (ULONG i = 0; pInternalImport->EnumNext(phEnumField, &fd); i++)
{
// 跳过所有的静态字段
if ( !(IsFdStatic(pInternalImport->GetFieldDefProps(fd))) [img]/images/wink.gif[/img]
{
// 初始化中间结构数组项
pfwalk->m_MD = fd;
pfwalk->m_nft = NULL;
pfwalk->m_offset = (UINT32) -1;
pfwalk->m_sequence = 0;

// 针对每个字段的类型进行分析
ParseNativeType(...);

// 根据字段的类型判断是否需要将类型设置为可位复制的
BOOL resetBlittable = TRUE;

if (pfwalk->m_nft == NFT_COPY1 ||
pfwalk->m_nft == NFT_COPY2 ||
pfwalk->m_nft == NFT_COPY4 ||
pfwalk->m_nft == NFT_COPY8)
{
resetBlittable = FALSE;
}

if (pfwalk->m_nft == NFT_NESTEDVALUECLASS)
{
FieldMarshaler *pFM = (FieldMarshaler*)&(pfwalk->m_FieldMarshaler);

if (((FieldMarshaler_NestedValueClass *) pFM)->IsBlittable())
{
resetBlittable = FALSE;
}
}

if (resetBlittable)
pEEClassLayoutInfoOut->m_fBlittable = FALSE;
}
}



ParseNativeType 函数 (vm/nstruct.cpp:178) 实际上是一个巨大的多级 switch 语句,根据从字段 Signature 中获取的类型信息,初始化 LayoutRawFieldInfo::m_nft 字段,并构造相匹配的 Marshaler 处理对象。这个庞大的 Marshaler 对象树从 FieldMarshaler 基类开始,提供了所有类型的 Marhsal 支持工具函数。如对简单的内建类型 int, long 等,提供了通用的基于值语义的 FieldMarshaler_Copy4 和 FieldMarshaler_Copy8 类型,也对复杂的接口、日期和字符串,提供了 FieldMarshaler_Interface、FieldMarshaler_Date 和 FieldMarshaler_StringAnsi 等类型。这样一来在对字段进行操作的时候,就可以通过多态方便地隔离具体的字段类型信息。

阶段 1 接着会通过 IMDInternalImport::GetClassLayoutNext 方法,遍历类型的布局信息。伪代码如下:
以下内容为程序代码:

while (SUCCEEDED(hr = pInternalImport->GetClassLayoutNext(&classlayout, &fd, &ulOffset))))
{
if (!fExplicitOffsets) {
// ulOffset is the sequence
pfwalk->m_sequence = ulOffset;
}
else {

// ulOffset is the explicit offset
pfwalk->m_offset = ulOffset;
pfwalk->m_sequence = (ULONG) -1;

if (pParentClass && pParentClass->HasLayout()) {
// Treat base class as an initial member.
pfwalk->m_offset += pParentClass->GetLayoutInfo()->GetNativeSize();
}
}
}



对于 LayoutKind.Sequential 策略的类型布局,GetClassLayoutNext 方法返回的 ulOffset 值是字段的序号;而对于 LayoutKind.Explicit 策略则返回用户具体指定的字段偏移。如果类型有具有布局策略的父类型,还需要根据父类型的大小调整字段的绝对偏移量。

阶段 1 最后会根据布局策略,对前面填充的字段布局信息进行排序或复制。对 LayoutKind.Sequential 策略,会根据每个字段布局信息的 m_sequence 进行排序;而对 LayoutKind.Explicit 策略,则直接将字段布局信息复制到目标数组 pSortArray。

阶段 2 将根据每个字段的布局信息计算其所占空间大小。部分内部字段类型 (m_nft) 具有固定大小,如 int, long, string 等类型对应的内部类型 NFT_COPY4, NFT_COPY8, NFT_STRINGUNI;而其他的则需要调用字段的 Marshaler 对象,动态计算其所占大小。这也是阶段 1 初始化的字段布局信息中 Marshaler 对象的典型使用方式之一。
以下内容为程序代码:

// Now compute the native size of each field
for (pfwalk = pInfoArrayOut; pfwalk->m_MD != mdFieldDefNil; pfwalk++) {
UINT8 nft = pfwalk->m_nft;
pEEClassLayoutInfoOut->m_numCTMFields++;

// If the NFT''s size never changes, it is stored in the database.
UINT32 cbNativeSize = NFTDataBase[nft].m_cbNativeSize;

if (cbNativeSize == 0) {
// Size of 0 means NFT''s size is variable, so we have to figure it
// out case by case.
cbNativeSize = ((FieldMarshaler*)&(pfwalk->m_FieldMarshaler))->NativeSize();
}
pfwalk->m_cbNativeSize = cbNativeSize;
}




阶段 3 对所有需要进行自动偏移处理的类型,提供类似 VC 实现中的偏移计算算法。
每个字段都有一个内存对齐需求,包括其字段的最小尺寸和声明的 pack 大小,两者的较小值将作为最后的对其标准。Rotor 的实现中,是根据每个字段的实际大小和对齐要求,顺序填充进行内存分配的;而发行版的 .NET Framework 中,更是实现了对字段进行二次重组来优化内存使用效率。这也是前一篇文章中提到的静态逻辑内存布局和动态物理内存布局的不同的原因所在。
此外对 LayoutKind.Explicit 模式,阶段 3 还会在每个字段进行布局时,检查是否与其他字段重叠。对结构中字段重叠的讨论,可以参考我另外一篇文章[url=]《》[/url]。
阶段 3 的最后将根据类型的对齐策略,在类型定义末尾填充适量的空闲位置,保障整个结构能内存对齐。

此外上篇文章中提到的诸如 LayoutDestroyNative 和 FmtClassUpdateNative 等一系列函数,实际上都是对字段 Marshaler 对象调用的封装。如 LayoutUpdateComPlus 和 LayoutUpdateNative 等函数会根据内存布局,更新结构中引用 COM+ 数据或内建数据的引用等信息,保证在进行诸如 StructureToPtr 的复制操作时,不会导致 CLR 内部维护生命期的一些对象的失控。而 FmtClassUpdateComPlus 和 FmtClassUpdateNative 等函数则根据具体对象的类型,如是否能够位拷贝来决定,是直接复制对象还是调用前面的 LayoutUpdateXXX 去更新引用等信息。这些函数主要是用于在直接复制类型内容时保证结构内容的有效性。

--
. 生命的意义在于 /\ ____\ /\_ \ /\_\ http://flier.yeah.net .
. 希望 \ \ \___/_\/\ \ \/_/__ __ _ _★ .
. 工作 \ \ ____\\ \ \ /\ \ /''__`\ /\`''_\ .
. 爱你的人 \ \ \___/ \ \ \___\ \ \/\ __// \ \ \/ .
. 和你爱的人 \ \___\ \ \_____\ \__\ \____\ \ \_\ .
. …… \/___/ \/_____/\/__/\/____/ \/_/ @nsfocus.com .


※ 来源:·BBS 水木清华站 smth.org·[FROM: 211.167.254.*]



<< Home

This page is powered by Blogger. Isn't yours?