C#(含Unity)unsafe指针快速反射第二篇(属性篇 )
前言
好,我们承接上一篇,继续讲解属性反射。大家也可以直接访问我的库,下面的讲解直接围绕这个库讲解
.Net Framework 和.Net Core请访问:smopu/CSharp_QuickReflection: C# .Net Framework Quick Reflection 快速反射 (github.com)
Unity3D(已完成iL2cpp和Mono的编译)请访问:smopu/unity3d_quick_reflection: 在unity3D中使用指针进行快速反射 (github.com)
目前测试结果如下:
我发现在unity平台提升是非常大的。
如果您使用指针反射直接替换原生的反射的话,那就是对应“使用object类型 string查询”这一栏的效率。
如果你需要完成json、xml这样的字符串文件序列化/反序列化,则完全可以达到“确定类型的”这一栏的效率。本人已经写了一个json序列化库smopu/DragonJson: C#里性能最好的Json库 (github.com) ,它的效率非常高,在后面的文章里,我会仔细讲解这个库。
如果你需要完成二进制文件序列化/反序列化,则完全可以达到“忽略字符串查询”这一栏的效率。
API整理
上一篇篇幅太短,没有好好整理API,这里我们再整理一下我们所需要的最基本的API:
API | .Net Framework 和.net Core | |
---|---|---|
获得指针的内存长度(64位是8,32位是4) | sizeof(IntPtr) |
sizeof(IntPtr) 或者 #if UNITY_64 const int PTR_COUNT = 8; #else const int PTR_COUNT = 4; #endif |
引用类型对象转指针 | 用IL生成的ObjectToVoidPtr | 用委托替换法的ObjectToVoidPtr(第一篇已经讲解)或者UnsafeUtility.PinGCObjectAndGetAddress |
指针转引用类型对象 | 用IL生成的VoidPtrToObject | 用委托替换法的VoidPtrToObject(第一篇已经讲解)或者UnsafeUtility.ReadArrayElement |
当前指针位置赋值为某对象 | 自己用IL生成的SetObject | 用委托替换法的SetObject或者UnsafeUtility.CopyObjectAddressToPtr |
当前指针位置拷贝赋值为另一个指针指向的一片指定长度的内存 | 用IL生成的MemCpy或者Unsafe.CopyBlock | UnsafeUtility.MemCpy |
获得指定类型的对象在栈中的内存大小(引用类型栈内存大小就是指针的内存长度64位是8,32位是4) | 用IL生成的SizeOf | UnsafeUtility.SizeOf |
获得指定类型的对象在堆中的内存大小 | *((int*)type.TypeHandle.Value + 1); |
没有提供API,值类型对象通过栈内存大小计算出来,引用类型对象通过最大偏移的字段计算出来。 也可以调用本地函数把值取出来,mono和il2Cpp要分开处理。 |
获得指定类型的对象的类型头指针 | 非数组类型:type.TypeHandle.Value,数组类型目前只能通过创建一个对象取指针的方法获得类型头指针 | 目前只能通过创建一个对象取指针的方法获得类型头指针 |
获得指定字段在该对象的内存偏移量 |
var ptr = fieldInfo.FieldHandle.Value; ptr = ptr + 4 + sizeof(IntPtr); ushort length = *(ushort*)(ptr); byte chunkSize = *(byte*)(ptr + 2); return length + (chunkSize << 16) + PTR_COUNT; |
UnsafeUtility.GetFieldOffset |
获得指定类型和元素数量(长度)的数组对象的堆内存长度 | 在下一篇再提 | 在下一篇再提 |
获得指定类型和元素数量(长度)的数组对象的第一个元素的偏移量 | 在下一篇再提 | UnsafeUtility.PinGCArrayAndGetDataAddress |
获得托管对象的“句柄”,使得访问的对象即便因为GC改变内存也能访问 | __makeref或者用带object字段的struct取指针 | Unity的托管对象的内存不会随GC而改变 |
固定托管对象的内存,防止GC改变地址 | GCHandle.Alloc(obj, GCHandleType.Pinned) | Unity的托管对象的内存不会随GC而改变 |
值类型对象转指针 | 用IL生成的AsPointer或者Unsafe.AsPointer | UnsafeUtility.AddressOf |
转指针值类型对象 | 用IL生成的AsRef或者Unsafe.AsRef | 用委托替换法的AsRef |
用委托替换法创建API
前一篇我们讲了委托替换的方法,关键点在于:object可以和void*相互转换,值类型T可以和ref T相互转换。利用FieldOffset可以绕过编译期检查,使得类型在运行时得以转换。(目前C#9支持函数指针了,但是为了兼容其他版本,我还是没有使用C#的函数指针)
[StructLayout(LayoutKind.Explicit)]
public unsafe class UnsafeTool
{
public static UnsafeTool unsafeTool = new UnsafeTool();
public delegate void* ObjectToVoidPtrDelegate(object obj);
public delegate IntPtr* ObjectToIntPtrDelegate(object obj);
public delegate byte* ObjectToBytePtrDelegate(object obj);
public delegate void CopyObjectDelegate(void* ptr, object obj);
[FieldOffset(0)]
public ObjectToVoidPtrDelegate ObjectToVoidPtr;
[FieldOffset(0)]
public ObjectToIntPtrDelegate ObjectToIntPtr;
[FieldOffset(0)]
public ObjectToBytePtrDelegate ObjectToBytePtr;
[FieldOffset(0)]
Func<object, object> func;
public delegate object VoidPtrToObjectDelegate(void* ptr);
public delegate object IntPtrToObjectDelegate(IntPtr* ptr);
public delegate object BytePtrToObjectDelegate(byte* ptr);
[FieldOffset(8)]
public VoidPtrToObjectDelegate VoidPtrToObject;
[FieldOffset(8)]
public IntPtrToObjectDelegate IntPtrToObject;
[FieldOffset(8)]
public BytePtrToObjectDelegate BytePtrToObject;
[FieldOffset(8)]
Func<object, object> func2;
[FieldOffset(16)]
public CopyObjectDelegate SetObject;
[FieldOffset(16)]
CopyObjectDelegate_ func3;
delegate void CopyObjectDelegate_(void** ptr, void* obj);
public UnsafeTool()
{
func = Out;
func2 = Out;
func3 = _CopyObject;
}
object Out(object o) { return o; }
void _CopyObject(void** ptr, void* obj)
{
*ptr = obj;
}
}
通过IL创建API
我们不一定需要用委托替换来创建API,使用C#动态编译功能即可实现我们的需求,使用这样的Dll效率更高。
我们可以看到 void*和object的转换其实就是返回它自己,事实上C#编译不允许我们直接转换,如果编译允许通过,理论上编译后的IL是直接把object当作void*来用,这样效率更高一些
值得注意的是关于内存拷贝函数MemCpy,使用的是IL指令:cpblk,这个指令光用C#写不出来,
关于动态创建的技术是使用ModuleBuilder和ILGenerator,这里篇幅有限不再多讲,代码在smopu/CSharp_QuickReflection: C# .Net Framework Quick Reflection 快速反射 (github.com) 的CreateDll工程里。这里还要说明一点,Unity里面不支持我这样生成的Dll,只能用委托替换的方法。
生成的Dll 可调用的函数如下:
解决GC改变对象内存的地址导致指针无法定位的问题
对于这个问题,使用Unity的朋友可以不看,因为Unity的GC不会改变对象的内存地址。
而.Net Framework 和.Net Core里有两种做法:1固定托管对象的内存,防止GC改变地址。2获得托管对象的“句柄”,使得访问的对象即便因为GC改变内存也能访问。
第一种方法,对于有引用类型字段的对象,会报错:"System.ArgumentException:“Object contains non-primitive or non-blittable data. ",但我们可以使用一种技巧,把对象的类型头指针替换成byte[]类型,这样就能固定对象内存了。但是这样的方法还有一些问题,我下次在详细讲解。今天我们主要用第二种方法
第二种方法:获得托管对象的“句柄”,实际上是void**,每次取对象时,再取一次地址,即便对象的地址。只要句柄地址一直不变,对象即便因为GC改变内存也能访问。示例代码如下:
[StructLayout(LayoutKind.Explicit)]
public unsafe class UnsafeTool
{
public delegate void* ObjectToVoidPtr(object obj);
public delegate object VoidPtrToObject(void* obj);
[FieldOffset(0)]
public ObjectToVoidPtr objectToVoidPtr;
[FieldOffset(0)]
Func<object, object> func;
[FieldOffset(8)]
public VoidPtrToObject voidPtrToObject;
[FieldOffset(8)]
Func<object, object> func2;
public UnsafeTool()
{
func = Out;
func2 = Out;
}
object Out(object o) { return o; }
}
[StructLayout(LayoutKind.Explicit)]
class Data
{
[FieldOffset(0)]
public long a = 0;
[FieldOffset(67789)]
long a2 = 0;
}
unsafe class Program
{
static unsafe void Main(string[] args)
{
//工具类
UnsafeTool tool = new UnsafeTool();
object[] objs = new object[1];
{
//申请一大堆内存 用于GC
List<Data> programs = new List<Data>();
for (int i = 0; i < 10000; i++)
{
programs.Add(new Data());
}
programs.Clear();
programs = null;
}
//我们把取地址的对象 防在申请内存的中间
Data data = new Data();
data.a = 100;
TypedReference d = __makeref(data);
IntPtr p = *(IntPtr*)*(IntPtr*)(&d);
Console.WriteLine("__makeref 取对象地址:" + p);
//句柄
void** handle = *(void***)(&d);
Console.WriteLine("objectToVoidPtr 取对象地址:" + (long)tool.objectToVoidPtr(data));
Console.WriteLine("上面两种方法取到的地址应该是一样的\n");
Console.WriteLine("句柄地址:" + ((long)handle));
Console.WriteLine("句柄 取对象的取对象地址:" + ((long)*handle));
Console.WriteLine("句柄取到对象的地址应该是和两种方法取到的一样的\n");
Console.WriteLine("句柄 取对象的值:" + ((Data)tool.voidPtrToObject(*handle)).a);
{
//申请一大堆内存 用于GC,如果后面代码取到data的地址都一样,把10000加大
List<Data> programs = new List<Data>();
for (int i = 0; i < 10000; i++)
{
programs.Add(new Data());
}
programs.Clear();
programs = null;
}
System.GC.Collect();
Console.WriteLine("\n经过GC以后,可以看到对象的地址是改变了的\n");
p = *(IntPtr*)*(IntPtr*)(&d);
Console.WriteLine("__makeref 取对象地址:" + p);
Console.WriteLine("objectToVoidPtr 取对象地址:" + (long)tool.objectToVoidPtr(data));
Console.WriteLine("上面两种方法取到的地址应该是一样的,但和第一次取的不一样\n");
Console.WriteLine("句柄地址:" + ((long)handle));
Console.WriteLine("句柄 取对象的取对象地址:" + ((long)*handle));
Console.WriteLine("句柄取到对象的地址应该是和两种方法取到的一样的\n");
Console.WriteLine("句柄 取对象的值:" + ((Data)tool.voidPtrToObject(*handle)).a);
Console.WriteLine("\n句柄的值不随GC变动而变化\n");
Console.WriteLine("不用关键字__makeref,可以用下面方法取【对象句柄】:");
ObjReference objReference = new ObjReference();
objReference.obj = data;
void** handleVoid2 = (void**)Unsafe.AsPointer(ref objReference);
void** handle2 = (void**)tool.objectToVoidPtr(objs[0]);
Console.WriteLine(" 句柄地址:" + ((long)handle));
Console.Read();
}
public unsafe struct ObjReference
{
public object obj;
public ObjReference(object obj)
{
this.obj = obj;
}
}
}
结果如下:
我们可以发现对象因为GC而变化,但我们的句柄的地址不会变,我这里使用了__makeref关键字,但是实际上,这个关键字根本不神秘,我找出另一种替代关键字的方法:声明一个值类型,它只包含一个object类型的字段。我们用【值类型对象转指针】的API把值类型转换成指针,就是我们要求的“句柄”,“句柄”为什么不会随GC变化地址呢?因为它是栈中的对象(值类型),而不是托管堆中的对象。因此它在我们当前的函数作用域里,永远是不会改变地址的!这样,因为GC改变地址导致不能取指针的问题迎刃而解。
值得注意的一点是,如果我们要取得值类型的指针,不需要取“句柄”,直接【值类型对象转指针】的API把值类型转换成指针直接用就行,值类型的内存地址不会改变。
基本类型、引用类型、结构体的字段反射
上一篇留了一个作业,结构体的字段反射。现在我把基本类型、引用类型、结构体的字段反射方式给出:
设void** handleVoid是句柄 我们把句柄转换成byte的类型:byte** handleByte = (byte**)handleVoid;
设offset是字段偏移量,对于基本类型(假设是int):
int value = *(int*)(*handleByte + offset);//取值
*(int*)(*handleByte + offset) = 18;//赋值
对于引用类型(假设是string):
string value = (string)GeneralTool.VoidPtrToObject(*(void**)(*handleByte + offset));//取值
GeneralTool.SetObject(*handleByte + offset, "ABc");//赋值
对于结构体值类型(假设是Vector3,stackSize是【获得指定类型的对象在栈中的内存大小】API取到的值):
Vector3 v = new Vector3();
GeneralTool.MemCpy(GeneralTool.AsPointer(ref v), *handleByte + offset, stackSize);//取值
GeneralTool.MemCpy(*handleByte + this.offset, GeneralTool.AsPointer(ref v), stackSize);//赋值
Unity使用下面API
Vector3 value = UnsafeUtility.ReadArrayElement<Vector3>(*handleByte + offset, 0);//取值
UnsafeUtility.WriteArrayElement<Vector3>(*handleByte + offset, 0, pt);//赋值
对于结构体值类型,取值和赋值都是拷贝对象的,所以我们取值的时候,必须要先建立一个对象,然后把目标字段的内存地址拷贝一段赋值给我们的结构体对象,内存拷贝的大小(stackSize)就是结构体自身的大小(在栈中的大小),这里的Vector3大小为12。
属性反射
属性反射的原理是利用委托替换,属性本身就是get,set方法,可以转换为委托,有了委托以后,我们通过委托替换,把参数转换成通用类型的参数(void*和object),我们先看下面代码:
public unsafe delegate void ActionVoidPtr<T>(void* arg1, T arg2);//赋值
public unsafe delegate void ActionVoidPtrVoidPtr(void* arg1, void* arg2);//赋值确定类型
public unsafe delegate T FuncVoidPtr<T>(void* arg1);//取值
public unsafe delegate float FuncVoidPtr2(void* arg1);//取值确定类型
这是委托替换的类
[StructLayout(LayoutKind.Explicit)]
public unsafe partial class PropertyDelegateItem
{
[FieldOffset(0)]
public object _set;
所有基本数据类型,我们都能确定类型,使用ActionVoidPtr赋值和FuncVoidPtr取值
[FieldOffset(0)]
public ActionVoidPtr<bool> setBoolean;
[FieldOffset(0)]
public ActionVoidPtr<char> setChar;
[FieldOffset(0)]
public ActionVoidPtr<sbyte> setSByte;
………………
[FieldOffset(16)]
public FuncVoidPtr<bool> getBoolean;
[FieldOffset(16)]
public FuncVoidPtr<char> getChar;
[FieldOffset(16)]
public FuncVoidPtr<sbyte> getSByte;
对于引用类型直接用object就行,对于不确定类型的结构体,我们有两种办法,一种是使用object类型,另一种是使用类型替换,第二种方法在【IL2Cpp结构体属性反射的额外问题】里会讲到。第一种做法会使得值类型使用object会产生装箱操作,导致性能开销大。为什么不能使用void*呢?因为属性赋值和取值使用的都是拷贝对象,对于值类型T,ref T才能转换为指针,直接T类型不能转换为指针,用指针传递会当作是一个指针长度的结构体。所以我们封装成object参数传值。以下是关于object类型的代码:
[FieldOffset(0)]
public ActionVoidPtr<object> setObject;
[FieldOffset(16)]
public FuncVoidPtr<object> getObject;
委托转化
对于属性结构体反射,我们必须分四种情况讨论:
1值类型对象值类型属性 2值类型对象引用类型属性 3引用类型对象值类型属性 4引用类型对象引用类型属性
我们必须定义以下两个委托:
public delegate TResult RefFunc<T, out TResult>(ref T arg);
public delegate void RefAction<T1, T2>(ref T1 arg1, T2 arg2);
值类型对象值类型属性 get方法我们转换为RefFunc; set方法我们转换为RefAction
值类型对象引用类型属性 get方法我们转换为RefFunc; set方法我们转换为RefAction
引用类型对象值类型属性 get方法我们转换为Func; set方法我们转换为Action
引用类型对象引用类型属性 get方法我们转换为Func; set方法我们转换为Action
主要利用Delegate.CreateDelegateAPI来生成委托
下面代码是获得 值类型对象引用类型属性和引用类型对象引用类型Get属性 的方法
public static Delegate CreateClassGet(Type parntType, PropertyInfo propertyInfo)
{
if (parntType.IsValueType)
{
var setValue = Delegate.CreateDelegate(typeof(RefFunc<,>).MakeGenericType(parntType, propertyInfo.PropertyType),
propertyInfo.GetGetMethod());
return setValue;
}
else
{
var setValue = Delegate.CreateDelegate(typeof(Func<,>).MakeGenericType(parntType, propertyInfo.PropertyType),
propertyInfo.GetGetMethod());
return setValue;
}
}
然后,我们创建PropertyDelegateItem类的对象,给它赋值_get 委托
var propertyDelegateItem = new PropertyDelegateItem();
Delegate get = PropertyWrapper.CreateStructGet(parntType, propertyInfo);
propertyDelegateItem._get = get;
最后,我们调用 可以直接调用里面的get委托,设void** handleVoid是句柄,属性类型是int,我们直接调用getInt32
int k = addr.propertyDelegateItem.getInt32(*handleVoid);
如果属性是引用类型或者是结构体,我们直接调用getObject
object value = addr.propertyDelegateItem.getObject(*handleVoid);
取到object再转化为所需要的类型
那么如果我们不能在编译期确定类型,只能获取到Type对象怎么办呢?
这时候我们只要判断一下类型即可,通过APIType.GetTypeCode把Type对象转换成typeCode
var typeCode = Type.GetTypeCode(type);
switch (typeCode)
{
case TypeCode.Boolean:
return propertyDelegateItem.getBoolean(source);
case TypeCode.Byte:
return propertyDelegateItem.getByte(source);
case TypeCode.Char:
return propertyDelegateItem.getChar(source);
case TypeCode.DateTime:
return propertyDelegateItem.getDateTime(source);
case TypeCode.Decimal:
return propertyDelegateItem.getDecimal(source);
case TypeCode.Double:
return propertyDelegateItem.getDouble(source);
case TypeCode.Int16:
return propertyDelegateItem.getInt16(source);
case TypeCode.Int32:
return propertyDelegateItem.getInt32(source);
case TypeCode.Int64:
return propertyDelegateItem.getInt64(source);
case TypeCode.SByte:
return propertyDelegateItem.getSByte(source);
case TypeCode.Single:
return propertyDelegateItem.getSingle(source);
case TypeCode.UInt16:
return propertyDelegateItem.getUInt16(source);
case TypeCode.UInt32:
return propertyDelegateItem.getUInt32(source);
case TypeCode.UInt64:
return propertyDelegateItem.getUInt64(source);
case TypeCode.Object:
default:
return propertyDelegateItem.getObject(source);
}
-
我所有的示例都在本篇文章开头所有的库里了,读者可以结合库的示例来看代码
结构体属性反射的额外操作
对于结构体,我们需要额外转换,请看这个接口和类:
public interface IGetStruct
{
Delegate get { get; set; }
Delegate GetDelegate();
}
public class RefPropertyGetWrapper<Target, Value> : IGetStruct
{
public RefFunc<Target, Value> _get;
public Delegate get
{
get { return _get; }
set { _get = (RefFunc<Target, Value>)value; }
}
public object GetStruct(ref Target t)
{
object obj = _get(ref t);
return obj;
}
public Delegate GetDelegate()
{
RefFunc<Target, object> v = GetStruct;
return v;
}
}
Target是持有属性的类型,Value是属性的类型,这个类的目的就是把RefFunc<Target, Value>封装成 RefFunc<Target, object>,最关键的一句代码就是 object obj = _get(ref t);这是个装箱操作,使得结构体能装到托管堆中去。在生成委托后,我们需要通过反射生成:
IGetStruct propertyWrapper = (IGetStruct)Activator.CreateInstance(
typeof(PropertyGetWrapper<,>).MakeGenericType(parntType, propertyInfo.PropertyType));
propertyWrapper.get = getValue;
return propertyWrapper.GetDelegate();
这里的propertyWrapper.GetDelegate()其实是上面的object GetStruct(ref Target t)方法。set的使用大同小异,读者可以去库里面看。
IL2Cpp结构体属性反射的额外问题
我在进行IL2Cpp测试的时候,也遇到了一个问题:il2Cpp编译期间会把泛型编译成实际的类型,导致我们动态Make的泛型不能使用,也就是typeof(PropertyGetWrapper<,>).MakeGenericType(parntType, propertyInfo.PropertyType)这里生成的类型不能被实例化,这就是所谓的"泛型裁剪"。
为了解决这个问题,我用了桥接方法,原理就是使用内存长度大于等于我们要赋值取值的类型对象的内存长度的桥接对象去取值。我这里以16为内存对齐,Size1类型表示内存长度为16的类型,Size2类型表示内存长度为32的类型,以此类推。
[StructLayout(LayoutKind.Explicit)]
public struct Size1
{
[FieldOffset(AilSize * 1 - AilFieldSize)]
public AilFieldType data;
}
[StructLayout(LayoutKind.Explicit)]
public struct Size2
{
[FieldOffset(AilSize * 2 - AilFieldSize)]
AilFieldType data;
}
[StructLayout(LayoutKind.Explicit)]
public struct Size3
{
[FieldOffset(AilSize * 3 - AilFieldSize)]
AilFieldType data;
}
[StructLayout(LayoutKind.Explicit)]
public struct Size4
{
[FieldOffset(AilSize * 4 - AilFieldSize)]
AilFieldType data;
}
比如vector3的长度是12,我用长度是16的长度类型去取值也就是Size1,在取值到时候。在栈中,由于目标是Size1,所以Size1申请的栈内存长度为16,下图中就像这里一个格子内存长度为4:
这时候,我们的取值方法取到我们需要的属性值,推送到栈中
最后我们把带有Size1的对象通过内存拷贝的方式再拷贝到我们真正要取的对象Vector3中去。
这种方法虽然避免了装箱,但多了一次内存拷贝,而且还有额外的桥接类型,桥接类型必须在编译期间生成。这也使得我们的程序丧失了动态性。所以我还是推荐使用装箱的方法。但对于iL2Cpp的泛型裁剪,目前只能用这种方法去做。
本篇文章使用了很多C#隐藏的技巧,想要了解原理必须对C#内存有深入的理解,我在以后的文章中会再把这些技巧一一总结、讲解,如果读者有什么不理解的地方,请留言评论。下一次我会更新数组反射。
本站大部分文章、数据、图片均来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了您的权益请来信告知我们删除。邮箱:1451803763@qq.com