使用C#處理基於位元流的資料
0x00 起因
最近需要處理一些基於位元流的資料,計算機處理資料一般都是以byte(8bit)為單位的,使用BinaryReader讀取的資料也是如此,即使讀取bool型也是一個byte。不過藉助於C#基礎類庫中提供的一些方法,也實現了對基於位元的資料的讀取。任務完成後覺得基於位元的資料挺有意思,自己試了下用7位元和6位元編碼常用ASCII字元。最後把一點新的寫成部落格,一方面做個記錄,另一方面希望對有類似需求的園友有所幫助。
0x01 位元流資料的讀取
假設我們有一個byte b = 35,而我們需要把其中的前4bit和後4bit分別讀取為兩個數字,那麼應該怎麼做呢。雖然沒有在基礎類庫中找到現成的方法,但用二進位制字串中轉一下,分兩步也可以做到。
1、先把b表示為二進位制字串00100011
2、分別取其前後4bit轉為數字,核心方法就是:
Convert.ToInt32("0010");
這樣就實現了基於位元的資料讀取了。
關於第一步中把byte轉化為二進位制字串有很多種方法,
1、最簡單的Convert.ToString(b,2)。不夠8位就在高位用0補足。
2、也可以把byte分別與1,2,4,8 … 128做與運算,由低到高取出各位。
3、也可以把byte和32做與運算,然後把byte左移再次與128做與運算。
其中第一種方法會產生大量的字串物件,在第2、3種方法沒有找到太大區別,我選擇的3,純靠感覺。程式碼如下:
public static char[] ByteToBinString(byte b) { var result = new char[8]; for (int i = 0; i < 8; i++) { var temp = b & 128; result[i] = temp == 0 ? '0' : '1'; b = (byte)(b << 1); }
return result; }
為了能將byte[]轉化為二進位制字串,可以
Public string BitReader(byte[] data) { BinString = new StringBuilder(data.Length * 8); for (int i = 0; i < data.Length; { BinString.Append(ByteToBinString(data[i])); } return BinString.ToString(); }
這樣一來當拿到byte[]資料時,可以轉換為二進位制字串儲存起來,根據偏移的bit位置和bit長度從中讀取二進位制字串,並保轉換為bool,Int16,Int32等。基於這個思路,可以寫一個BitReader類,其中用StringBuilder儲存二進位制字串,並提供Read方法從二進位制字串中讀取資料。為了能夠更好的處理資料流,在此基礎上新增一個Position記錄當前偏移,當使用某些Read方法讀取資料時,Position也會相應移動。例如使用ReadInt16讀取資料,BitReader會從Position當前位置,讀取16bit並轉換為Int16返回,同時Position向後移動16bit。區分方式就是當讀取資料時需要指定起始的偏移位置時,Position不移動,直接從當前Position讀取時Position移動,BitReader類部分程式碼如下:
1 public class BitReader 2 { 3 public readonly StringBuilder BinString; 4 public int Position { get; set; } 5 6 public BitReader(byte[] data) 7 { 8 BinString = new StringBuilder(data.Length * 8); 9 for (int i = 0; i < data.Length; i++) 10 { 11 BinString.Append(ByteToBinString(data[i])); 12 } 13 Position = 0; 14 } 15 16 public byte ReadByte(int offset) 17 { 18 var bin = BinString.ToString(offset, 8); 19 return Convert.ToByte(bin, 2); 20 } 21 22 public byte ReadByte() 23 { 24 var result = ReadByte(Position); 25 Position += 8; 26 return result; 27 } 28 29 public int ReadInt(int offset, int bitLength) 30 { 31 var bin = BinString.ToString(offset, bitLength); 32 return Convert.ToInt32(bin, 2); 33 } 34 35 public int ReadInt(int bitLength) 36 { 37 var result = ReadInt(Position, bitLength); 38 Position += bitLength; 39 return result; 40 } 41 42 public static char[] ByteToBinString(byte b) 43 { 44 var result = new char[8]; 45 for (int i = 0; i < 8; i++) 46 { 47 var temp = b & 128; 48 result[i] = temp == 0 ? '0' : '1'; 49 b = (byte)(b << 1); 50 } 51 return result; 52 } 53 }
使用BitReader按照4bit從byte[] buff= {35,12};中讀取資料可以這樣:
var reader = new BitReader(buff); //二進位制字串為0010001100001100 var num1 = reader.ReadInt(4); //從當前Position讀取4bit為int,Position移動4bit,結果為2,當前Position=4 var num2 = reader.ReadInt(5,6); //從偏移為5bit的位置讀取6bit為int,Position不移動,結果為48,當前Position=4 var b = reader.ReadBool(); //從當前Position讀取1bit為bool,Position移動1bit,結果為False,當前Position=5
0x02 位元流資料的寫入
把資料寫入位元流就是一個相反的過程,我們用BitWriter類實現,在其中儲存StringBuilder儲存二進位制字串,當寫入資料時,需要傳入資料並指定儲存這個資料所需要的bit數。當寫入完畢後可以將StringBuilder中儲存的二進位制字串按照8bit轉換為byte[]並返回。BitWriter的核心部分如下:
1 public class BitWriter 2 { 3 public readonly StringBuilder BinString; 4 5 public BitWriter() 6 { 7 BinString = new StringBuilder(); 8 } 9 10 public BitWriter(int bitLength) 11 { 12 var add = 8 - bitLength % 8; 13 BinString = new StringBuilder(bitLength + add); 14 } 15 16 public void WriteByte(byte b, int bitLength=8) 17 { 18 var bin = Convert.ToString(b, 2); 19 AppendBinString(bin, bitLength); 20 } 21 22 public void WriteInt(int i, int bitLength) 23 { 24 var bin = Convert.ToString(i, 2); 25 AppendBinString(bin, bitLength); 26 } 27 28 public void WriteChar7(char c) 29 { 30 var b = Convert.ToByte(c); 31 var bin = Convert.ToString(b, 2); 32 AppendBinString(bin, 7); 33 } 34 35 public byte[] GetBytes() 36 { 37 Check8(); 38 var len = BinString.Length / 8; 39 var result = new byte[len]; 40 41 for (int i = 0; i < len; i++) 42 { 43 var bits = BinString.ToString(i * 8, 8); 44 result[i] = Convert.ToByte(bits, 2); 45 } 46 47 return result; 48 } 49 50 public string GetBinString() 51 { 52 Check8(); 53 return BinString.ToString(); 54 } 55 56 57 private void AppendBinString(string bin, int bitLength) 58 { 59 if (bin.Length > bitLength) 60 throw new Exception("len is too short"); 61 var add = bitLength - bin.Length; 62 for (int i = 0; i < add; i++) 63 { 64 BinString.Append('0'); 65 } 66 BinString.Append(bin); 67 } 68 69 private void Check8() 70 { 71 var add = 8 - BinString.Length % 8; 72 for (int i = 0; i < add; i++) 73 { 74 BinString.Append("0"); 75 } 76 } 77 }
下面舉個簡單的例子:
var writer = new BitWriter(); writer.Write(12,5); //把12用5bit寫入,此時二進位制字串為:01100 writer.Write(8,16); //把8用16bit寫入,此時二進位制字串為:011000000000000001000 var result = writer.GetBytes(); //8bit對齊為011000000000000001000000 //返回結果為[96,0,64]
0x03 7位元字元編碼
我們常用的ASCII字元是使用8bit編碼的,但其中真正常用的那些字元只有7bit,最高位為0,所以對於一篇英文文章,我們可以使用7bit重新編碼而不損失資訊。編碼的過程就是把文章字元依次取出,並用BitWriter按照7bit寫入,最後獲取新編碼的byte[]。為了能夠正確讀取,我們規定當讀到8bit資料為2時代表資料開始,接下來16bit資料為後面字元個數。程式碼如下:
public byte[] Encode(string text) { var len = text.Length * 7 + 24; var writer = new BitWriter(len); writer.WriteByte(2); writer.WriteInt(text.Length, 16); for (int i = 0; i < text.Length; i++) { var b = Convert.ToByte(text[i]); writer.WriteByte(b, 7); } return writer.GetBytes(); }
同樣讀取資料的時候,我們先尋找開始識別符號,然後讀出字元個數,根據字元個數依次讀取字元,程式碼如下:
public string Decode(byte[] data) { var reader = new BitReader(data); while (reader.Remain > 8) { var start = reader.ReadByte(); if (start == 2) break; } var len = reader.ReadInt(16); var result = new StringBuilder(len); for (int i = 0; i < len; i++) { var b = reader.ReadInt(7); var ch = Convert.ToChar(b); result.Append(ch); } return result.ToString(); }
由於資料頭的存在,當編碼幾個字元時編碼後資料反而更長了
不過隨著字元越多,編碼後節省的越多。
0x04 6位元字元編碼
從節省資料量的角度,如果允許損失部分資訊,例如損失掉字母大小寫,是可以進一步減少編碼所需位元數的。26個字母+10個數字+符號,可以用6bit(64)進行編碼。不過使用這種編碼方式就不能用ASCII的對映方式了,我們可以自定義對映,例如0-10對映為十個數字等等,也可以使用自定義的字典,也就是傳說中的密碼本。經常看國產諜戰片的應該都知道密碼本吧,密碼本就是一個字典,把字元進行重新對映獲取明文,算是簡單的單碼替代,加密強度很小,在獲取足量資料樣本後基於統計很容易就能破解。下面我們就嘗試基於自定義字典用6bit重新編碼。
編碼過程:
仍然像7bit編碼那樣寫入訊息頭,然後依次取出文字中的字元,從字典中找到對應的數字,把數字按照6bit長度寫入到BitWriter
public byte[] Encode(string text) { text = text.ToUpper(); var len = text.Length * 6 + 24; var writer = new BitWriter(len); writer.WriteByte(2); writer.WriteInt(text.Length, 16); for (int i = 0; i < text.Length; i++) { var index = GetChar6Index(text[i]); writer.WriteInt(index, 6); } return writer.GetBytes(); } private int GetChar6Index(char c) { for (int i = 0; i < 64; i++) { if (Dict.Custom[i] == c) return i; } return 10; //return * }
解碼過程:
解碼也很簡單,找到訊息頭,依次按照6bit讀取資料,並從字典中找到對應的字元:
public string Decode(byte[] data) { var reader = new BitReader(data); while(reader.Remain > 8) { var start = reader.ReadByte(); if (start == 2) break; } var len = reader.ReadInt(16); var result = new StringBuilder(len); for (int i = 0; i < len; i++) { var index = reader.ReadInt(6); var ch = Dict.Custom[index]; result.Append(ch); } return result.ToString(); }
同樣一段文字用6bit自定義字典編碼後資料長度更短了,不過損失了大小寫和換行等格式。
如果從加密的角度考慮,可以設定N個自定義字典(假設10個),在訊息頭中用M bit(例如4bit)表示所用的字典。這樣在每次編碼時隨機選擇一個字典編碼,解碼時根據4bit資料選擇相應字典解碼,並且定時更換字典可以增大破解難度。感興趣的園友可以自行嘗試。
0x05 寫在最後
以上是我處理位元流資料的一點心得,僅僅是我自己能想到的一種方法,滿足了我的需求。如果有更效率的更合理的方法,希望賜教。另外編碼和解碼的兩個例子是出於有趣寫著玩的,在實際中估計也用不到。畢竟現在頻寬這麼富裕,資料加密也有N種可靠的多的方式。
示例程式碼:https://github.com/durow/TestArea/tree/master/BitStream
關於基於位元流的資料讀取封裝成了庫
安裝:PM> Install-Package Ayx.BitIO
專案地址:https://github.com/durow/Ayx.BitIO
更多內容歡迎訪問我的部落格:http://www.durow.vip