Zephus 发表于 2023-1-12 14:40

Unity分块解密执行热更C#代码——性能优化

本文章是JEngine v0.8技术分享内的一篇文章,讲解了JEngine框架内的U3D使用C#热更之代码安全:分块解密并执行的技术实现功能优化
前言

JEngine框架底层采用的解释执行C#热更DLL选择的方案是ILRuntime,而该方案底层是通过使用Mono.Cecil读取的C#工程编译的DLL内部的IL指令并进行解释执行。
在很久很久以前(2021年初),我在Mono.Cecil读取热更DLL二进制的区块并返回给ILRuntime的环节实现了对热更DLL的内存级加密,只要秘钥没泄漏,就不会泄漏热更DLL,也就不会被反编译出源码。
详细可以看:U3D使用C#热更之代码安全:分块解密并执行的技术实现,
在JEngine v0.8.0中,我打算对这个功能的性能进行优化。目前已经优化完了(可以看提交记录1、提交记录2和提交记录3),本文将对优化思路进行讲解。

目录


[*]提交记录1

[*]修改加密接口
[*]修改缓存秘钥的成员变量



[*]提交记录2

[*]使用非托管内存
[*]更高效的内存拷贝



[*]提交记录3

[*]去掉多余的边界检查
[*]去掉用于拷贝解密结果的临时托管二进制数组
[*]使用位运算提高计算AES加密区块的性能



提交记录1

修改加密接口

简化了CryptoMgr的接口(AES加密解密类),可以自选加密模式、填充方式、数据偏移、数据长度:
原接口:
public static byte[] AesDecryptWithNoPadding(byte[] toDecryptArray, string key)
public static byte[] AesDecryptWithNoPadding(byte[] toDecryptArray, string key)
原接口直接写死了加密方式、填充方式、偏移和长度,不是很灵活,于是有了新接口,
新接口:
public static byte[] AesEncrypt(byte[] data, byte[] key, int offset, int length,
         CipherMode cipherMode = CipherMode.ECB,
         PaddingMode paddingMode = PaddingMode.PKCS7)
public static byte[] AesDecrypt(byte[] data, byte[] key, CipherMode cipherMode = CipherMode.ECB,
         PaddingMode paddingMode = PaddingMode.PKCS7)
新接口比较灵活,适用于各种类型的AES加密/解密

修改缓存秘钥的成员变量

因为接口变了,所以解密秘钥也得换个存储方式。

[*]之前是存的string,现在存byte[]
这里有个小问题,如果秘钥在内存里被抓到了,黑客可以再把内存里的加密DLL二进制抓出来然后进行解密,以后我会把秘钥也在内存里保护住,只要源码没被反编译就不会出现问题原变量以及构造函数:
private string _key; //解密密码
public JStream(byte[] buffer, string key)
{
...
_key = key;
if (_key.Length < 16)
{
   _key = InitJEngine.Instance.key.Length < 16 ? _defaultKey : InitJEngine.Instance.key;
}
}
新变量以及构造函数:
private byte[] _key; //解密密码
public JStream(byte[] buffer, string key)
{
...
if (key.Length < 16)
{
   key = InitJEngine.Instance.key.Length < 16 ? _defaultKey : InitJEngine.Instance.key;
}
_key = Encoding.UTF8.GetBytes(key);
}
可以看到,我们这边把字符串key的UTF8编码取出来了,这样的好处是解密的时候可以直接用这个解密密钥的二进制去解密了,不需要每次解密的时候都去使用UTF8.GetBytes来获取秘钥的二进制(之前是这样的),这样能减少一个byte[]的申请/赋值/回收,可以加快解密效率并减少GC。

提交记录2

使用非托管内存

因为加密DLL的二进制数据需要缓存,以便解释执行的时候读取指定区块的代码,而缓存如果使用托管数组去缓存,则会有GC(Unity用的远古回收器对GC很敏感),并且效率也没申请非托管数据来得快(对比的不是Marshal.AllocHGlobal,是Unity引擎C++层提供的MAlloc)
其实如果Unity用CoreCLR的话可以用NativeMemory.Alloc,奈何现在的Unity还是用的远古Mono平台或自己造的IL2CPP。
言归正传,我们讲一下这块是怎么优化的:

[*]首先,我们用unsafe修饰符去修饰我们的JStream类,因为内部我们要存byte指针了
[*]接着我们把缓存二进制的成员变量类型变为byte*(字节指针)
[*]然后我们在JStream的构造函数内使用Unity提供的UnsafeUtility.MAlloc去申请非托管内存,并将托管DLL二进制复制到刚刚申请的非托管内
[*]最后我们去掉需要用托管数组的接口,同时在Dispose函数内申明释放非托管内存
原代码:
public class JStream : Stream
{
private byte[] _buffer; // Either allocated internally or externally.
...
public JStream(byte[] buffer, string key)
{
   _buffer = new byte;
   buffer.AsSpan().CopyTo(_buffer);
}
...
public virtual byte[] GetBuffer()
{
...
}

public virtual bool TryGetBuffer(out ArraySegment<byte> buffer)
{
...
}

protected override void Dispose(bool disposing)
{
...
   _isOpen = false;
   _buffer = null;
...
}
...
可以看到老代码的Dispose是给_buffer置空,然后等待GC回收。

新代码:
public unsafe class JStream : Stream
{
private byte* _buffer; // Either allocated internally or externally.
...
public JStream(byte[] buffer, string key)
{
   _buffer = (byte*)UnsafeUtility.Malloc(buffer.Length, 1, Allocator.Persistent);
   buffer.AsSpan().CopyTo(new Span<byte>(_buffer, buffer.Length));
}
...
public virtual byte[] GetBuffer()
{
...
}

public virtual bool TryGetBuffer(out ArraySegment<byte> buffer)
{
...
}

protected override void Dispose(bool disposing)
{
...
   _isOpen = false;
   UnsafeUtility.Free(_buffer, Allocator.Persistent);
   _buffer = null;
...
}
...
可以看到新代码申请内存的时候申请的是常驻内存,内存对齐长度为1(因为我们用的是byte*所以可以忽略内存对齐这个概念),同时Dispose内多了个释放非托管内存,然后我们删掉了GetBuffer()和TryGetBuffer接口。

更高效的内存拷贝

之前我们是把托管数组转Span,然后用Span内置的CopyTo,这样的话需要多分配两个ref struct(虽然这个在heap上分配,速度很快),但本质上这个操作就是取两个指针然后进行memcpy,所以我们可以直接写memcpy语句。
老代码:
_buffer.AsSpan(_position, n).CopyTo(buffer.AsSpan(offset));
...
_buffer.AsSpan(offset, count).CopyTo(encryptedData); //从原始数据里分割出来
新代码:
Unsafe.CopyBlockUnaligned(ref buffer, ref _buffer, (uint)n);
...
Unsafe.CopyBlockUnaligned(ref encryptedData, ref _buffer, (uint)count); //从原始数据里分割出来
Unsafe.CopyBlockUnaligned是C#提供的内存拷贝的代码,内部就是__memcpy,这里我们偷个懒,直接用ref去传指针,而不是固定并将buffer转指针后加上偏移,再转为void*去传参
如果不理解到底我偷了什么懒,我写个伪代码:
public unsafe void StressfulVersion()
{
byte[] src = new byte;
int srcOffset = 10;
byte* dst = xxxxx; //内存地址
int dstOffset = 3;
int count = 50;
//拷贝
fixed(byte* srcPtr = &src)
{
   Unsafe.CopyBlockUnaligned((void*)(dst + dstOffset), (void*)srcPtr, (uint)count);
}
}
可以看到上面这个写法比较累,用ref的话就能省事儿不少,因为相当于直接从byte*或byte[]中取到对应位置的字节,然后把它的引用传了过去(也就是内存地址,C/C++中的byte *
总而言之,这个优化相当于我们手动调用了转Span后底层要干的事情,感兴趣的可以对比下改之前和改之后的IL代码
提交记录3

去掉多余的边界检查

相信我,Mono.Cecil读取二进制数据的时候不会越界,越界了的话ILRuntime也就没办法执行对应的代码了,所以读取二进制区块的过程中,边界检查是多余的
老代码的检查:
public override int Read(byte[] buffer, int offset, int count)
{
if (buffer == null)
   throw new ArgumentNullException(nameof(buffer), "buffer == null");
if (offset < 0)
   throw new ArgumentOutOfRangeException(nameof(offset), "offset < 0");
if (count < 0)
   throw new ArgumentOutOfRangeException(nameof(count), "count < 0");
if (buffer.Length - offset < count)
   throw new ArgumentException("invalid buffer length");

if (!_isOpen || _buffer == null) throw new EndOfStreamException("stream is closed");
...
很明显,这里有5个if判断,我们可以全去掉,这样频繁读取的时候能节省很多判断,从而提高性能

去掉用于拷贝解密结果的临时托管二进制数组

之前我们会用ArrayPool去租借一个临时数组来复制AES解密的结果(AES解密会产生一个托管byte[]),然后把临时数组的数据复制到Read函数中的参数buffer,这个完全是多余的,我们可以直接把AES解密的结果的byte[]变量内的数据复制到buffer。
我们先修改Read内部进行拷贝的部分:
老代码:
var decrypted = GetBytesAt(_position, count);
decrypted.AsSpan(0, n).CopyTo(buffer.AsSpan(offset)); //复制过去
//返还借的数组
ArrayPool<byte>.Shared.Return(decrypted);
新代码:
fixed (byte* ptr = &buffer)
{
GetBytesAt(in _position, in count,in ptr);
}
可以看到,我们改了GetBytesAt接口,这个接口用于解密特定区块的二进制数据并返回,我们现在会把buffer的指针加上偏移后传过去

我们现在对比一下GetBytesAt内部的变更
老代码:
private byte[] GetBytesAt(int start, int length)
{
...
var result = ArrayPool<byte>.Shared.Rent(length); //返回值,长度为length
...
var encryptedData = ArrayPool<byte>.Shared.Rent(count); //创建加密数据数组
...
//给encryptedData解密
var decrypt = CryptoMgr.AesDecrypt(encryptedData, _key, 0, count, CipherMode.ECB, PaddingMode.None);
...
//这里有个问题,比如decrypt有16字节,而result是12字节,offset是8,那么12+8 > 16,就会出现错误
//所以这里要改一下
var total = offset + length;
var dLength = decrypt.Length;
if (total > dLength)
{
   length = decrypt.Length;
}

decrypt.AsSpan(offset, length).CopyTo(result.AsSpan());
return result;
}
新代码:
private void GetBytesAt(in int start, in int length, in byte* ret)
{
...
var encryptedData = ArrayPool<byte>.Shared.Rent(count); //创建加密数据数组
...
//给encryptedData解密
var decrypt = CryptoMgr.AesDecrypt(encryptedData, _key, 0, count, CipherMode.ECB, PaddingMode.None);
...
//直接操作指针,可以略过边界检查
Unsafe.CopyBlockUnaligned(ref ret, ref decrypt, (uint)length);
}
这里我们对接口的参数设置了个in修饰符,代表传结构体的引用到方法,不进行结构体拷贝,然而,传指针的话实际上要传的长度比int的值需要拷贝的东西还多(32位指针4字节,64位8字节,而int本身是4字节),所以这里的in修饰符纯粹是为了养眼,可以去掉。
可以看到,我们新代码开头少了个返回的托管数据的申请,并且我们在末尾用了不检查边界的快速拷贝内存的方法。
这样一来,减少了托管数组的租借(或创建)的开销,减少了将解密数据复制到返回值数据的开销,减少了标记边界的临时变量计算与判断,采用了更快的内存拷贝(非对齐)。

使用位运算提高计算AES加密区块的性能

分块解密时我们需要计算区块(AES是分块加密的,每块16字节,不够一块的字节会被特定的填充方式填充)假设我们有二进制数据的偏移值,以及长度,现在需要获取从偏移值开始长度个字节解密后的原文,我们需要:

[*]区块的起始位置:计算距离偏移值最接近的,16的倍数,且这个数要小于偏移值,例如67变64
[*]区块的长度:计算距离长度最接近的,16的倍数,且这个数要大于长度,例如77变80
[*]现在我们需要给区块的长度加上16,以避免数据有填充导致解密的结果会缺失一部分
[*]最后我们需要计算返回值的偏移:即二进制偏移值比区块的起始位置大多少

可以看到,这个做法就是基本的数学计算,因此在很久以前我写出来了以下老代码:
老代码:
int remainder = start % 16; // 余数
int offset = start - remainder; // 偏移值,截取开始的地方,比如 67 变 64
int count = length +(32 - length % 16); //获得需要切割的数组的长度,比如 77 变 96(80+16),77+ (32- 77/16的余数) = 77 + (32-13) = 77 + 19 = 96,多16位确保不丢东西
offset = remainder;
老代码基本就是直白的翻译了上面提到的四条,然后我这几天突然灵光一闪,发现可以用位移(bitwise operator)进行优化:

[*]以二进制的角度来看,求一个数字与16的余数,不就是求二进制的最后4位吗!
例如67的二进制就是1000011,而67 % 16 = 3,二进制则是0000011
再举个例子,十进制72的二进制是1001000,而72 % 16 = 8,二进制是0001000
如果不熟悉二进制算法的,可以用这个公式:
二进制1 = 2^0 * 1 = 十进制1
二进制11 = 2^1 * 1 + 2^0 * 1 = 十进制3
二进制1001 = 2^3 * 1 + 2^0 * 1 = 十进制9
二进制10010 = 2^4 * 1 + 2^1 * 1 = 十进制18
也就是假设二进制每一位是n,那么该二进制值的十进制就是每一位的数字乘以2的(n-1)次幂的总和
这样的话,二进制的最后4位的范围用十进制来看的话是在0到15之间吗?那不就是任何数字除以16的余数吗?

[*]那么,既然区块偏移值=原值-余数,在二进制的角度来看不就是给二进制的最后4位抹除?
[*]那么,我们只需要把原值右移(RSH,>>)来抹掉其二进制的最后四位(例如101111变10,最后四位被移除了),再将其左移(LSH,<<)来将其二进制末尾添上四个0(例如10变100000,不就可以求出最接近原值但比原值小的16的倍数了!
[*]区块的长度也是同理,如果我们给老代码的计算长度的公式重新排序的话,我们会得到:int count = 32 + length - length % 16,熟悉的东西来啦!我们依然可以用位运算来优化这个算法!
[*]最后我们在后面让返回值的偏移值等于了余数,那实际上就是原值的二进制的最后四位,既然区块偏移值的二进制除了末尾四位外皆与原值二进制相等,那我们直接用异或(XOR,^)就好,这样就可以得出二进制末尾四位的值了(例如101101^100000=1101),异或是把二进制每一位相同的数变0,不相同的数变1。

就这样,我们可以运用位运算的知识来优化出我们的新代码:
int offset = start >> 4 << 4; // 偏移值,截取开始的地方,比如 67 变 64,相当于start - start % 16
int count = 32 + length >> 4 << 4; //获得需要切割的数组的长度,比如 77 变 96(80+16),77+ (32- 77/16的余数)
//= 77 + (32-13) = 77 + 19 = 96,多16位确保不丢东西,相当于length +(32 - length % 16);
offset = start ^ offset; //相当于start % 16

从计算机底层的角度来看,进行位运算无疑是最快的,同时我们的新算法比老算法少了很多步骤,所以性能的提升无疑是巨大的!

结尾

到这里为止,我们对JStream的优化就结束了,我们大幅度优化了Mono.Cecil读取加密热更DLL区块的性能,从而大幅度提高了JEngine框架提供的分块解密执行计算的性能与开销和内存分配
目前(2023.1.8)JEngine v0.8.0还在开发中,让我们拭目以待!

再发一次框架的地址:JEngine

感谢阅读!
页: [1]
查看完整版本: Unity分块解密执行热更C#代码——性能优化