您好,登錄后才能下訂單哦!
這篇文章主要介紹C#從byte[]中直接讀取Structure的案例,文中介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們一定要看完!
序、前言
emmmmm,首先這篇文章講的不是用BinaryFormatter來進行結構體的二進制轉換,說真的BinaryFormatter這個類其實現在的作用并不是特別大了,因為BinaryFormatter二進制序列化出來的結果只能用于.net平臺,現在可能就用于如存入Redis這種情況下會在使用。
去年年尾的樣子,我閱讀學習某C++開發的程序源碼時,發現作者用了一個很騷的操作直接將byte[]數組轉為了結構體對象:
上面的data變量是一個指向unsigned char類型的指針,就只要一個簡單的類型轉換就可以將一堆unsigned char轉換成想要的結構體,這著實有點讓筆者有點羨慕。
后來,筆者想用C#開發一個流量分析程序,由于需要對IP報文進行仔細的特征提取,所以不能直接使用第三方數據包解析庫(如:PacketDotNet)直接解析,會丟失部分特征,然而使用BinaryReader進行報文頭解析的話,整個解析代碼會寫的喪心病狂的惡(e)心(xin),正在苦惱的時候,突然想起上面提到的那個騷操作時,筆者突然冒出了一個想法,C#里也支持結構體,那我能不能也像C++這樣直接從字節序列中讀取出結構體呢?
注:本文所有代碼在.net Standard 2.0上測試通過。
一、先聲明,后調用~
那么在開始前,我們先定義一下要用到的IPv4報文頭結構體,各位同學IPv4報文頭結構有沒有忘掉啊,如果忘了的話記得先去補補TCP網絡基礎哈~
因為IPv4頭是允許可變長度的,所以我們的結構體只需要解析到目的地址就夠了,后面的可變選項部分在報文頭前16字節解析完成之前是不知道會有多長的。
IPv4頭部結構體定義如下,由于IPv4頭部定義中,各個字段并不是都8位整長的,所以有幾個字段是相互并在一起的:
public struct IPv4Header { /// <summary> /// IP協議版本及頭部長度 /// </summary> private byte _verHlen; /// <summary> /// 差異化服務及顯式擁塞通告 /// </summary> private byte _dscpEcn; /// <summary> /// 報文全長 /// </summary> private ushort _totalLength; /// <summary> /// 標識符 /// </summary> private ushort _identification; /// <summary> /// 標志位及分片偏移 /// </summary> private ushort _flagsOffset; /// <summary> /// 存活時間 /// </summary> private byte _ttl; /// <summary> /// 協議 /// </summary> private byte _protocol; /// <summary> /// 頭部檢驗和 /// </summary> private ushort _checksum; /// <summary> /// 源地址 /// </summary> private int _srcAddr; /// <summary> /// 目標地址 /// </summary> private int _dstAddr; }
當然,為了方便后續的使用,還可以在此技術上設置一些可讀屬性:
public struct IPv4Header { /// <summary> /// IP協議版本及頭部長度 /// </summary> private byte _verHlen; /// <summary> /// 差異化服務及顯式擁塞通告 /// </summary> private byte _dscpEcn; /// <summary> /// 報文全長 /// </summary> private ushort _totalLength; /// <summary> /// 標識符 /// </summary> private ushort _identification; /// <summary> /// 標志位及分片偏移 /// </summary> private ushort _flagsOffset; /// <summary> /// 存活時間 /// </summary> private byte _ttl; /// <summary> /// 協議 /// </summary> private byte _protocol; /// <summary> /// 頭部檢驗和 /// </summary> private ushort _checksum; /// <summary> /// 源地址 /// </summary> private int _srcAddr; /// <summary> /// 目標地址 /// </summary> private int _dstAddr; /// <summary> /// IP協議版本 /// </summary> public int Version { get { return (this._verHlen & 0xF0) >> 4; } } /// <summary> /// 頭部長度 /// </summary> public int HeaderLength { get { return this._verHlen & 0x0F; } } /// <summary> /// 差異化服務 /// </summary> public int DSCP { get { return (this._dscpEcn & 0xFC) >> 2; } } /// <summary> /// 顯式擁塞通告 /// </summary> public int ECN { get { return this._dscpEcn & 0x03; } } /// <summary> /// 報文全長 /// </summary> public ushort TotalLength { get { return this._totalLength; } } /// <summary> /// 標識符 /// </summary> public ushort Identification { get { return this._identification; } } /// <summary> /// 保留字段 /// </summary> public int Reserved { get { return (this._flagsOffset & 0x80) >> 7; } } /// <summary> /// 禁止分片標志位 /// </summary> public bool DF { get { return (this._flagsOffset & 0x40) == 1; } } /// <summary> /// 更多分片標志位 /// </summary> public bool MF { get { return (this._flagsOffset & 0x20) == 1; } } /// <summary> /// 分片偏移 /// </summary> public int FragmentOffset { get { return this._flagsOffset & 0x1F; } } /// <summary> /// 存活時間 /// </summary> public byte TTL { get { return this._ttl; } } /// <summary> /// 協議 /// </summary> public byte Protocol { get { return this._protocol; } } /// <summary> /// 頭部檢驗和 /// </summary> public ushort HeaderChecksum { get { return this._checksum; } } /// <summary> /// 源地址 /// </summary> public IPAddress SrcAddr { get { return new IPAddress(BitConverter.GetBytes(this._srcAddr)); } } /// <summary> /// 目的地址 /// </summary> public IPAddress DstAddr { get { return new IPAddress(BitConverter.GetBytes(this._dstAddr)); } } }
二、byte[]轉Structure第一版
首先筆者先看了一圈文檔,看看C#有沒有什么方法支持將byte[]轉為結構體,逛了一圈發現一個有這么一個函數:
System.Runtime.InteropServices.Marshal.PtrToStructure<T>(IntPtr)
這個方法接收兩個參數,一個結構體泛型和一個指向結構體數據的安全指針(IntPtr),然后這個方法就能返回一個結構體實例出來了。
那么現在的問題就是該如何取得一個byte[]對象的安全指針呢?這里筆者第一反應是利用System.Runtime.InteropServices.Marshal.AllocHGlobal方法分配一塊堆外內存出來,然后將待轉換的byte[]對象復制到這塊堆外內存中,接著利用PtrToStructure<T>函數將byte[]對象轉換成我們想要的結構體對象實例,最后釋放掉堆外內存就可以了。
將上面的步驟轉換為C#代碼,就形成了第一版的BytesToStructure<T>函數:
/// <summary> /// 將 byte[] 轉為指定結構體實例 /// </summary> /// <typeparam name="T">目標結構體類型</typeparam> /// <param name="bytes">待轉換 byte[]</param> /// <returns>轉換后的結構體實例</returns> public static T BytesToStructure<T>(byte[] bytes) where T : struct { int size = Marshal.SizeOf(typeof(T)); IntPtr ptr = Marshal.AllocHGlobal(size); try { Marshal.Copy(bytes, 0, ptr, size); return Marshal.PtrToStructure<T>(ptr); } finally { Marshal.FreeHGlobal(ptr); } }
之后就只要抓一下包看看效果就好了。
抓包我們用SharpPcap,順便讓它幫我們過濾一下僅捕獲IP報文。代碼如下:
public static void Main(string[] args) { CaptureDeviceList devices = CaptureDeviceList.Instance; if (devices.Count <= 0) { Console.WriteLine("No device found on this machine"); return; } else { Console.WriteLine("available devices:"); Console.WriteLine("-----------------------------"); } int index = 0; foreach (ICaptureDevice item in devices) { Console.WriteLine($"{index++}) {item.Name}"); } Console.Write("enter your choose: "); index = int.Parse(Console.ReadLine()); Console.WriteLine(); ICaptureDevice device = devices[index]; device.OnPacketArrival += new PacketArrivalEventHandler((sender, e) => { Packet packet = Packet.ParsePacket(e.Packet.LinkLayerType, e.Packet.Data); if (packet.Extract(typeof(IPPacket)) is IPPacket ipPacket) { IPv4Header header = StructHelper.BytesToStructure<IPv4Header>(ipPacket.Bytes); Console.WriteLine($"{header.SrcAddr} ==> {header.DstAddr}"); } }); device.Open(DeviceMode.Promiscuous, 1000); device.Filter = "ip"; Console.CancelKeyPress += new ConsoleCancelEventHandler((sender, e) => device.Close()); device.Capture(); }
啟動上面的代碼,選擇需要捕獲數據包的網卡,就可以看到此網卡上所有IP報文記錄及其源地址與目標地址了:
三、大端字節序、小端字節序……
剛剛上面我們已經成功的將byte[]對象轉換為我們想要的結構體了,但我們轉換出來的結構體真的正確嗎,我們可以將我們讀取出來的結構體和PacketDotNet包解析出來的IP報文頭數據進行比較:
我們可以看到我們轉換出來的IPv4報文頭結構體中的報文總長字段和PacketDotNet解析出來的數據不一致,我們的轉換函數出來的包總長是15872,而PacketDotNet解析出來的包總長只有62。
到底誰是對的呢,不用猜,肯定是我們的轉換函數有問題,如果看官您不相信,可以用WireShark抓包做比較,看看WireShark會挺誰的結果。
那么到底是哪里錯了呢?相信不少有實戰經驗的看官已經知道問題的原因了:大小字節序。
我們分別將15872和62轉為二進制格式:
數值 | 15872 | 62 |
二進制 | 00111110 00000000 | 00000000 00111110 |
15872和62這兩個數字轉換為二進制之后,15872的00111110在前面,00000000在后面,而62則正好相反。
一般來說計算機硬件有兩種儲存數據的方式:大端字節序(big endian)和小端字節序(little endian)。
舉例來說,數值0x2211使用兩個字節儲存:高位字節是0x22,低位字節是0x11。
大端字節序:
高位字節在前,低位字節在后,這是人類讀寫數值的方法。
小端字節序:
低位字節在前,高位字節在后,即以0x1122形式儲存。
在網絡中傳輸數據,一般使用的是大端字節序,然而在計算機內部中,為了方便計算,大多都會使用小端字節序進行儲存。
.net CLR默認會使用當前計算機系統使用的字節順序,而筆者測試時用的系統是Windows 7 x64,內部默認用的是小端字節序,所以在一切均為默認的情況下,多字節字段在轉換后都會因為字節序不正確而讀取為錯誤值。
.net提供了一個屬性用于開發者獲取當前計算機系統使用的字節序:
System.BitConverter.IsLittleEndian
如果此屬性為true,則表示當前計算機正在使用小端字節序,否則為大端字節序。
回到剛剛的問題,為了防止大小端字節序對轉換產生影響,我們可以使用Attribute對結構體中各個多字節字段進行標記,并在轉換前判斷字節序是否一致,如果不一致則進行順序調整,代碼如下:
首先定義一個大小端字節序枚舉:
/// <summary> /// 字節序枚舉 /// </summary> public enum Endianness { /// <summary> /// 大端字節序 /// </summary> BigEndian, /// <summary> /// 小端字節序 /// </summary> LittleEndian }
然后定義大小端字節序聲明特性
/// <summary> /// 字節序特性 /// </summary> [AttributeUsage(AttributeTargets.Field)] public class EndianAttribute : Attribute { /// <summary> /// 標記字段的字節序 /// </summary> public Endianness Endianness { get; private set; } /// <summary> /// 構造函數 /// </summary> /// <param name="endianness">字節序</param> public EndianAttribute(Endianness endianness) { this.Endianness = endianness; } }
我們在這里使用AttributeUsage特性限制此EndianAttribute特性僅限字段使用。
然后是轉換函數:
/// <summary> /// 調整字節順序 /// </summary> /// <typeparam name="T">待調整字節順序的結構體類型</typeparam> /// <param name="bytes">字節數組</param> private static byte[] RespectEndianness<T>(byte[] bytes) { Type type = typeof(T); var fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .Where(f => f.IsDefined(typeof(EndianAttribute), false)).Select(field => new { Field = field, Attribute = (EndianAttribute)field.GetCustomAttributes(typeof(EndianAttribute), false).First(), Offset = Marshal.OffsetOf(type, field.Name).ToInt32() }).ToList(); foreach (var field in fields) { if ((field.Attribute.Endianness == Endianness.BigEndian && BitConverter.IsLittleEndian) || (field.Attribute.Endianness == Endianness.LittleEndian && !BitConverter.IsLittleEndian)) { Array.Reverse(bytes, field.Offset, Marshal.SizeOf(field.Field.FieldType)); } } return bytes; }
此函數會先使用反射獲取所有含有EndianAttribute特性的公開或非公開實例字段,然后依次求出其偏移,最后判斷字段標注的字節序是否與當前計算機的字節序相同,如果不同則進行順序翻轉。另外上面的函數使用了Linq,需要引入System.Linq命名空間,且Linq函數中還使用到了匿名類。
接下來要對轉換函數進行修改,只需要在轉換前調用一下調序函數即可。
/// <summary> /// 將 byte[] 轉為指定結構體實例 /// </summary> /// <typeparam name="T">目標結構體類型</typeparam> /// <param name="bytes">待轉換 byte[]</param> /// <returns>轉換后的結構體實例</returns> public static T BytesToStructure<T>(byte[] bytes) where T : struct { bytes = RespectEndianness<T>(bytes); int size = Marshal.SizeOf(typeof(T)); IntPtr ptr = Marshal.AllocHGlobal(size); try { Marshal.Copy(bytes, 0, ptr, size); return Marshal.PtrToStructure<T>(ptr); } finally { Marshal.FreeHGlobal(ptr); } }
當然了,我們還要對結構體中的各個多字節字段標記上EndianAttribute特性:
public struct IPv4Header { /// <summary> /// IP協議版本及頭部長度 /// </summary> private byte _verHlen; /// <summary> /// 差異化服務及顯式擁塞通告 /// </summary> private byte _dscpEcn; /// <summary> /// 報文全長 /// </summary> [Endian(Endianness.BigEndian)] private ushort _totalLength; /// <summary> /// 標識符 /// </summary> [Endian(Endianness.BigEndian)] private ushort _identification; /// <summary> /// 標志位及分片偏移 /// </summary> [Endian(Endianness.BigEndian)] private ushort _flagsOffset; /// <summary> /// 存活時間 /// </summary> private byte _ttl; /// <summary> /// 協議 /// </summary> private byte _protocol; /// <summary> /// 頭部檢驗和 /// </summary> [Endian(Endianness.BigEndian)] private ushort _checksum; /// <summary> /// 源地址 /// </summary> private int _srcAddr; /// <summary> /// 目標地址 /// </summary> private int _dstAddr; }
需要說一點,就是最后的源地址和目標地址,筆者上面用的是
public IPAddress(byte[] address)
這個構造函數來構造IPAddress類,并且是使用BitConverter.GetBytes這個方法將int類型轉為byte[]并傳入構造函數的,所以不用注明大端序,否則會導致轉換結果不正確(錯誤結果和正確結果會正好顛倒)。
重啟程序,看看現在我們的轉換函數轉換出來的結果是不是和PacketDotNet轉換結果一樣了?
四、性能提升!性能提升!
在解決了大字節序小字節序的問題之后,讓我們重新審視一下剛剛上面的轉換函數,可以看到在剛才的函數中,每次要從byte[]中讀取結構體時,都要經過“申請堆外內存——復制對象——讀取結構體——釋放堆外內存”這四步,申請堆外內存,復制對象和釋放堆外內存這三步照理來說是浪費性能的,明明byte[]已經在內存中了,但就是為了獲取它的安全句柄而大費周章的再去申請一塊內存,畢竟申請和釋放內存也算是一筆不小的開支了。
那除了Marshal.AllocHGlobal以外還有別的什么方法能獲取到托管對象的安全句柄呢?筆者又去網上找了一下,您還別說,這還真的有。朋友,您聽說過GCHandle嗎?
System.Runtime.InteropServices.GCHandle.Alloc
此方法允許傳入任意一個object對象,它將返回一個GCHandle實例并保護傳入的對象不會被GC回收掉,當使用完畢后,需要調用此GCHandle實例的Free方法進行釋放。而GCHandle結構體有一個實例方法AddrOfPinnedObject,此方法將返回此固定對象的地址及安全指針(IntPtr)。
利用GCHandle.Alloc方法,就可以避免重復的申請、復制和釋放內存了,由此我們對剛剛的第一版BytesToStructure函數進行改進,第二版BytesToStructure函數閃亮登場:
/// <summary> /// 將 byte[] 轉為指定結構體實例 /// </summary> /// <typeparam name="T">目標結構體類型</typeparam> /// <param name="bytes">待轉換 byte[]</param> /// <returns>轉換后的結構體實例</returns> public static T BytesToStructureV2<T>(byte[] bytes) where T : struct { bytes = RespectEndianness<T>(bytes); GCHandle handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); try { return Marshal.PtrToStructure<T>(handle.AddrOfPinnedObject()); } finally { handle.Free(); } }
現在我們來比較兩個轉換函數的效率試試:
我們使用相同的數據包,讓兩個轉換函數重復運行1000w次,查看兩個函數使用的時間差距:
注意:因為調整大小端字節序會使用到反射,會嚴重的影響到函數本身的運行效率(運行時間大部份都在用于反射),所以在測試時,筆者會注釋掉調整字節序調整的代碼。
BytesToStructure<T> | BytesToStructureV2<T> | |
1000w次轉換耗時 | 5069 ms | 2914 ms. |
五、榨干潛能,使用不安全代碼!
我們在剛剛的代碼里通過避免“申請內存——復制數據——釋放內存”的步驟來提升函數的執行效率,那經過上面的改造,我們的轉換函數還有提升的空間嗎?
答案是有的。
C#和Java最大的不同點在于C#允許程序員使用不安全代碼,這里的不安全代碼并不是指一定存在漏洞會被攻擊者利用的不安全,而是使用指針的代碼。是的!C#允許使用指針!只需要在編譯時打開/unsafe開關。
文章一開始的C++代碼利用指針進行轉換,C#其實也可以:
/// <summary> /// 將 byte[] 轉為指定結構體實例 /// </summary> /// <typeparam name="T">目標結構體類型</typeparam> /// <param name="bytes">待轉換 byte[]</param> /// <returns>轉換后的結構體實例</returns> public static unsafe T BytesToStructureV3<T>(byte[] bytes) where T : struct { bytes = RespectEndianness<T>(bytes); fixed (byte* ptr = &bytes[0]) { return (T)Marshal.PtrToStructure((IntPtr)ptr, typeof(T)); } }
這個第三版函數使用了兩個關鍵字unsafe和fixed,unsafe表示此代碼為不安全代碼,C#中不安全代碼必須在unsafe標識區域內使用,且編譯時要啟用/unsafe開關。fixed在這里主要是為了將指針所指向的變量“釘住”,避免GC誤重定位變量以產生錯誤。
同樣,我們注釋掉大小端字節序調整函數,再次重復運行1000w次,看看三個函數的用時:
BytesToStructure<T> | BytesToStructureV2<T> | BytesToStructureV3<T> | |
1000w次轉換耗時 | 5069 ms | 2914 ms. | 2004 ms |
又比之前縮短了進1s的時間。當然了因為這是重復1000w次的耗時,而因為我們注釋掉了大小端字節序調整函數,實際情況下啟用大小端字節序調整函數的話,時間會爆炸性的增長。可見反射是一個多么浪費性能的操作。
六、小結
emmmmm,說一個比較尷尬的事情,其實本文討論的這種byte[]轉為結構體的情況其實在日常開發中很少會用到,首先是因為結構體這種數據結構在日常開發中就很少會用到,平時開發的話類才是大頭,另外如果是因為要和C/C++開發的Dll交互,可以利用.net中System.Runtime.InteropServices命名空間下的StructLayoutAttribute、FieldOffset等特性自定義標記結構體的結構,CLR會在結構體傳入或傳出時自動進行托管內存與非托管內存之間內存格式的轉換。
以上是“C#從byte[]中直接讀取Structure的案例”這篇文章的所有內容,感謝各位的閱讀!希望分享的內容對大家有幫助,更多相關知識,歡迎關注億速云行業資訊頻道!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。