获取非显式字段偏移量

11

我有以下的类:

[StructLayout(LayoutKind.Sequential)]
class Class
{
    public int Field1;
    public byte Field2;
    public short? Field3;
    public bool Field4;
}

我如何获得从类数据(或对象头)开始的Field4的字节偏移量?
举个例子:

Class cls = new Class();
fixed(int* ptr1 = &cls.Field1) //first field
fixed(bool* ptr2 = &cls.Field4) //requested field
{
    Console.WriteLine((byte*)ptr2-(byte*)ptr1);
}
在这种情况下,得到的偏移量是5,因为运行时实际上将Field3移动到类型的末尾(并进行了填充),可能是因为它的类型是泛型。我知道有Marshal.OffsetOf,但它返回未托管的偏移而不是托管的偏移。

如何从FieldInfo实例中检索此偏移量?是否有用于此的.NET方法,还是我必须编写自己的代码来考虑所有异常情况(类型大小、填充、显式偏移等)?


@usr 指的是 Field3 字段。实际上,出乎我的意料,它被重新排序了。它被移动到类的末尾,并填充(debug 和 release, 32-bit)。这可能与无法获取通用类型指针有关。 - IS4
1
你无法找到,托管对象布局是一项实现细节。除了你已经发现的后门之外。CLR使用此功能来优化布局,使对象尽可能小,同时仍提供对齐保证。[StructLayout]仅在编组结构上受到尊重。在这种情况下,Marshal.SizeOf()会给出偏移量。 - Hans Passant
1
@Hans 但是Marshal.SizeOf返回的是非托管类型的大小,而不是托管类型的大小。我认为LayoutKind.Explicit在结构体上得到了尊重,重叠字段就是证明,而不需要进行编组。 - IS4
糟糕,Marshal.OffsetOf()。 - Hans Passant
意想不到的元素。这在CLR中并不常见。感谢您的提问。 - leppie
2个回答

12
在.NET 4.7.2中,结构体中字段的偏移量:
public static int GetFieldOffset(this FieldInfo fi) =>
                    GetFieldOffset(fi.FieldHandle);

public static int GetFieldOffset(RuntimeFieldHandle h) => 
                    Marshal.ReadInt32(h.Value + (4 + IntPtr.Size)) & 0xFFFFFF;

这些返回一个字段在 classstruct 中的字节偏移量,相对于某个运行时受管实例的布局。这适用于所有 StructLayout 模式,以及值类型和引用类型(包括泛型、包含引用或其他不可平滑化的类型)。偏移值是基于用户定义内容或“数据体”开始的零基偏移,不包括任何头、前缀或其他填充字节。

讨论

由于 struct 类型没有头,返回的整数偏移值可以通过指针算术直接使用,并在必要时使用 System.Runtime.CompilerServices.Unsafe(此处未显示)。另一方面,引用类型对象具有必须跳过才能引用所需字段的头。这个对象头通常是单个 IntPtr,这意味着需要将 IntPtr.Size 添加到偏移值中。还需要解除 GC(“垃圾回收”)句柄的引用,以获取对象的地址。

通过考虑这些因素,我们可以在运行时通过将字段偏移量(通过上述方法获取)与class实例(例如Object句柄)组合来合成到GC object内部的跟踪引用
下面的方法仅适用于class(而不是struct)类型,并演示了该技术。为简单起见,它使用ref-returnSystem.Runtime.CompilerServices.Unsafe库。出于简单起见,还省略了错误检查,例如断言fi.DeclaringType.IsSubclassOf(obj.GetType())
/// <summary>
/// Returns a managed reference ("interior pointer") to the value or instance of type 'U'
/// stored in the field indicated by 'fi' within managed object instance 'obj'
/// </summary>
public static unsafe ref U RefFieldValue<U>(Object obj, FieldInfo fi)
{
    var pobj = Unsafe.As<Object, IntPtr>(ref obj);
    pobj += IntPtr.Size + GetFieldOffset(fi.FieldHandle);
    return ref Unsafe.AsRef<U>(pobj.ToPointer());
}

该方法返回一个托管的“跟踪”指针,指向垃圾回收对象实例 obj 的内部。 [请参见注释] 它可用于任意读取 或 写入字段,因此这个函数替换了传统的单独 getter / setter 函数对。尽管返回的指针不能存储在GC堆中,因此其生命周期仅限于当前堆栈帧(即以下),但随时通过简单地再次调用该函数获取非常便宜。

请注意,此通用方法仅使用<U>参数化,表示指向的值的类型,而不是包含类(可能是“<T>”)的类型。这是因为这种技术的基本简单性不需要它。我们已经知道包含实例必须是引用(class)类型,因此在运行时,它将通过引用句柄呈现为具有object头的GC对象,并且这些事实足够了;没有进一步了解假定类型“T”。

是否添加空洞的<T,...>,这将允许我们指示where T:class约束条件,这是一个观点问题,是否会改善上面示例的外观或感觉。当然不会有任何损害;我相信JIT足够聪明,不会为没有影响的通用参数生成额外的通用方法实例化。但是,既然这样做似乎很啰嗦(除了陈述约束条件之外),我选择了这里严格必要性的极简主义。

在我的使用中,我实际保留的是各个感兴趣的字段的整数偏移值(从GetFieldOffset返回),而不是每次传递FieldInfo或其相应的FieldHandle。因为一旦获取到这些偏移值,它们在运行时也是不变的。这消除了每次获取指针时调用GetFieldOffset的额外步骤。事实上,由于我能够在我的项目中包含IL代码,这里是我用于上述函数的精确代码。与刚才展示的C#一样,它轻松地从包含GC对象obj和其中的(保留的)整数偏移量offs合成托管指针。
// Returns a managed 'ByRef' pointer to the (struct or reference-type) instance of type U 
// stored in the field at byte offset 'offs' within reference type instance 'obj'

.method public static !!U& RefFieldValue<U>(object obj, int32 offs) aggressiveinlining
{
    ldarg obj
    ldarg offs
    sizeof object
    add
    add
    ret
}

因此,即使您无法直接将此IL纳入其中,展示它在这里,我认为可以很好地说明这种技术的运行时开销极低和普遍的诱人简单性。

示例用法

class MyClass { public byte b_bar; public String s0, s1; public int iFoo; }

第一个演示获取MyClass实例中引用类型字段s1的整数偏移量,然后使用它来获取和设置字段值。
var fi = typeof(MyClass).GetField("s1");

// note that we can get a field offset without actually
// having any instance of 'MyClass'
var offs = GetFieldOffset(fi);

// i.e., later... 

var mc = new MyClass();

RefFieldValue<String>(mc, offs) = "moo-maa";      // field "setter"

// note: method call used as l-value, on the left-hand side of '=' assignment!

RefFieldValue<String>(mc, offs) += "!!";          // in-situ access

Console.WriteLine(mc.s1);                         // --> moo-maa!! (in the original)

// can be used as a non-ref "getter" for by-value access
var _ = RefFieldValue<String>(mc, offs) + "%%";   // 'mc.s1' not affected

如果这看起来有点凌乱,您可以通过将托管指针保留为 ref local 变量来大幅简化它。正如您所知,这种类型的指针会自动调整 - 保留内部偏移 - 每当 GC 移动 包含 对象时。这意味着即使您继续不知不觉地访问该字段,它仍将保持有效。作为交换,CLR 要求 ref 局部变量 本身 不被允许逃离其堆栈帧,在这种情况下由 C# 编译器强制执行。
// demonstrate using 'RuntimeFieldHandle', and accessing a value-type
// field (int) this time
var h = typeof(MyClass).GetField(nameof(mc.iFoo)).FieldHandle; 

// later... (still using 'mc' instance created above)

// acquire managed pointer to 'mc.iFoo'
ref int i = ref RefFieldValue<int>(mc, h);      

i = 21;                                        // directly affects 'mc.iFoo'
Console.WriteLine(mc.iFoo == 21);              // --> true

i <<= 1;                                       // operates directly on 'mc.iFoo'
Console.WriteLine(mc.iFoo == 42);              // --> true

// any/all 'ref' uses of 'i' just affect 'mc.iFoo' directly:
Interlocked.CompareExchange(ref i, 34, 42);    // 'mc.iFoo' (and 'i' also): 42 -> 34

摘要

使用示例聚焦于使用技术与class对象一起使用,但是请注意,此处显示的GetFieldOffset方法也可以完美地与struct一起使用。只需确保不要对值类型使用RefFieldValue方法,因为该代码包括调整预期对象头。对于这种更简单的情况,只需使用System.Runtime.CompilerServicesUnsafe.AddByteOffset进行地址算术运算即可。

不用说,这种技术可能对某些人来说似乎有点激进。我只想指出,在.NET Framework 4.7.2上,包括32位和64位模式、调试与发布以及我尝试过的任何各种JIT优化设置中,它已经完美地为我工作了多年。


1
我不确定是否是这种情况 - 垃圾回收器可以随时移动所有引用,而且我认为作为方法参数并不能保护它。否则就没有pinned/fixed引用的必要了。 - IS4
1
当一个对象引用在对象类型变量中时,您是安全的,但如果对象引用在IntPtr中,则我认为您没有固定,所以我有点担心在将字段偏移添加到IntPtr之前,对象会被移动并转换为适当的内部引用。 - Mike Marynowski
@MikeMarynowski同意。我必须承认,你提到的似乎是C#版本(通过IntPtr转换)和上面显示的IL版本之间的区别,而只有IL版本是我广泛使用且没有问题的。 - Glenn Slayden
1
IL版本很有趣。我不确定它是否安全。当GC扫描引用堆栈时,它需要某种方式来确定哪些内存位置/寄存器可能包含引用...我不知道该算法如何查看那里的内存地址。它是基于方法调用参数类型还是ldarg对象类型或两者都是?我不知道。我记得阅读过它尝试保守,所以这应该是安全的。 - Mike Marynowski
2
C#版本的RefFieldValue绝对不安全,但是通过始终保持内部引用管理,可以使其变得安全。您可以重新解释为已知布局类型,以获得起始托管引用,然后添加偏移量。类似于:class AnyClass { public byte FirstField; }然后:static ref U RefFieldValue<U>(object obj, FieldInfo fi) => ref Unsafe.As<byte, U>(ref Unsafe.AddByteOffset(ref Unsafe.As<AnyClass>(obj).FirstField, GetFieldOffset(fi))); - saucecontrol
显示剩余12条评论

8

通过一些技巧,使用TypedReference.MakeTypedReference方法可以获取到字段的引用和对象数据的起始位置,然后进行减法运算即可。该方法可以在SharpUtils中找到。


有没有可能在PinMakeTypedReference中不使用任何代码生成的方式来完成这个操作(所以不能使用DynamicMethod或Expressions)? - Riki
@riki 或许可以使用新的 MemorySpan 类型以及类似的不安全 API,但我没有使用过它们。 - IS4

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接