在日常的工作中,我們經常需要進行一些二進制文件或協議的讀寫操作,用C#解析二進制文件常常是一件比較麻煩且容易出錯的工作,本文介紹了一種在C#中實現快速讀寫二進制文件通用的方法。
以一個解析Mp3 ID3V1標簽頭為例,ID3V1標簽保存在MP3文件尾的最后128個字節用來存放ID3信息,其格式具體如下表:
字節 |
長度 (字節) |
說明 |
1-3 |
3 |
存放"TAG"字符,表示ID3 V1.0標准。 |
4-33 |
30 |
歌名 |
34-63 |
30 |
作者 |
64-93 |
30 |
專輯名 |
94-97 |
4 |
年份 |
98-127 |
30 |
附注 |
128 |
1 |
MP3音樂類別,共147種。 |
如果要用C/C++語言來解析這個標簽頭,一般需要經過如下兩個步驟:
首先定義標簽頭數據結構,
typedef struct tagID3V1
{
char Header[3]; /*標簽頭必須是"TAG"否則認為沒有標簽*/
char Title[30]; /*標題*/
char Artist[30]; /*作者*/
char Album[30]; /*專集*/
char Year[4]; /*出品年代*/
char Comment[28]; /*備注*/
char reserve; /*保留*/
char track; /*音軌*/
char Genre; /*類型*/
}ID3V1;
C/C++語言定義的數據結構非常清晰的指明了各字段所占用的內存和偏移位置,由於C語言定義的數據結構是和內存中的偏移位置直接對應上的,因此,定義后數據結構后,從文件中獲取數據到數據結構是非常簡單的事情。PS:這是個c++的版本,由於只是個示例代碼,去掉了異常處理相關流程,C語言版本類似,這里就不舉例了。
void main()
{
ifstream file("r:\\te2st.mp3");
ID3V1 id3v1 = {0}; //存放讀取的mp3 ID3V1信息
file.seekg((int)(-1*sizeof(id3v1)), ios::end);
file.read((char*)(&id3v1), sizeof(id3v1));
}
從這段代碼中可以看到,只需要通過內存拷貝函數就可以將數據從數據一口氣復制到數據結構中來,無需手動一個個成員賦值,非常簡潔。
現在我們再來看看如何用C#實現這一功能,一般來講,首先也是定義一個數據結構:
class ID3V1
{
public string Header { get; set; }
public string Title { get; set; }
public string Artist { get; set; }
public string Album { get; set; }
public string Year { get; set; }
public string Comment { get; set; }
public byte Reserve { get; set; }
public byte Track { get; set; }
public byte Genre { get; set; }
}
和C語言相比,C#定義的數據結構相對較為抽象,從數據結構中看不到和ID3V1的各字段長度的對應關系,因此只能一個個字段的手動復制,解析函數實現如下:
public static ID3V1 ReadFormFile(string file)
{
var tagLength = 128;
var id3v1 = new ID3V1();
byte[] data = new byte[tagLength];
using (var stream = File.OpenRead(file))
{
stream.Seek(-1 * tagLength, SeekOrigin.End);
stream.Read(data, 0, data.Length);
}
var encoding = Encoding.Default;
id3v1.Header = encoding.GetString(data, 0, 3);
id3v1.Title = encoding.GetString(data, 3, 30).TrimEnd('\0');
id3v1.Artist = encoding.GetString(data, 33, 30).TrimEnd('\0');
id3v1.Album = encoding.GetString(data, 63, 30).TrimEnd('\0');
id3v1.Year = encoding.GetString(data, 93, 4);
id3v1.Comment = encoding.GetString(data, 97, 28).TrimEnd('\0');
id3v1.Reserve = data[125];
id3v1.Track = data[126];
id3v1.Genre = data[127];
return id3v1;
}
從上面代碼可以看出,C#把其數據格式的解析放在解析函數里面來了,比起C語言來說復雜的多,主要體現在如下地方:
-
C#定義的數據結構中無法獲取結構體大小,需要定義變量保存,而C語言可以通過sizeof獲取,具有通用性。
-
C#的數據結構中看不到每個字段的長度的偏移位置,每個字段的長度和偏移位置都需要定義變量保存,而C語言的數據結構的長度非常明確,偏移位置直接由編譯器推算。
-
C#無法從文件流中讀出來的字節編碼和各個字段的具體類型互相轉換,讀寫時需要每個字段進行單獨編碼賦值,一旦需要解析的字段較多很容易出錯和漏掉,並且沒有通用性,而C語言直接通過memcopy、read & write等函數可以一行代碼搞定,具有通用性。
有鑒於以上幾點,導致C#讀寫二進制文件沒有通用性,成了非常麻煩的一件事情。那么有沒有辦法可以讓C#也想C語言那樣使用一種通用的方式快速讀寫二進制文件呢?
經常通過P/Invoke調用Win32 API的碼農們可能知道,有的時候,win32 api的參數或返回值是一個數據結構,此時則需要我們在C#定義一個等價的數據結構,也就是說,C#中也是可以定義出像C語言那樣對成員在內存中的布局進行精確控制的,通過這種方式,也可以實現類似C語言那樣讀寫二進制文件的通用算法。
關於C#控制成員布局的方法,請參看MSDN文章:LayoutKind枚舉和MarshalAsAttribute類相關內容,如下兩篇博客StructLayout特性和C# struct實例字段的內存布局介紹的也比較詳細,我這里就不累述了。不過,就算有了相關知識,把C#對象做到像C對象那樣精確控制還是一件比較麻煩的事情,很容易出錯,我們可以借助一個P/Invoke Interop Assistant的工具把C語言結構自動轉換為C#結構,然后再在工具生成的數據結構基礎上潤色下就快多了。
還是拿前面的ID3V1 Tag為例,首先我們通過P/Invoke Interop Assistant把C語言定義的結構轉換為C#的結構,生成的數據結構如下:
[StructLayoutAttribute(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public class ID3V1
{
[MarshalAsAttribute(UnmanagedType.ByValTStr, SizeConst = 3)]
public string Header;
[MarshalAsAttribute(UnmanagedType.ByValTStr, SizeConst = 30)]
public string Title;
[MarshalAsAttribute(UnmanagedType.ByValTStr, SizeConst = 30)]
public string Artist;
[MarshalAsAttribute(UnmanagedType.ByValTStr, SizeConst = 30)]
public string Album;
[MarshalAsAttribute(UnmanagedType.ByValArray, SizeConst = 4)]
public char[] Year;
[MarshalAsAttribute(UnmanagedType.ByValTStr, SizeConst = 28)]
public string Comment;
public byte Reserve;
public byte Track;
public byte Genre;
}
微軟官方的工具還是比較厲害的,像位域之類的也能轉換,但還不算完美,存在如下幾個需要改進的地方:
-
有的字符串不是以'\0'結尾的,像ID3V1的Header和Year字段,這個時候翻譯為string的時候會導致最后一位信息丟掉,但沒有讓人手動修改的地方。
-
沒有把字段封裝為屬性。
-
沒有去掉名字空間System.Runtime.InteropServices的前綴,生成的代碼過長。
因此,基於這個生成的結構,還需要手動修改一下。最后為如下形式:
[StructLayoutAttribute(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public class ID3V1
{
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 3)]
char[] header = "TAG".ToCharArray();
public string Header
{
get { return new string(header); }
set { header = value.ToCharArray(); }
}
[MarshalAsAttribute(UnmanagedType.ByValTStr, SizeConst = 30)]
private string title;
public string Title
{
get { return title; }
set { title = value; }
}
[MarshalAsAttribute(UnmanagedType.ByValTStr, SizeConst = 30)]
private string artist;
public string Artist
{
get { return artist; }
set { artist = value; }
}
[MarshalAsAttribute(UnmanagedType.ByValTStr, SizeConst = 30)]
private string album;
public string Album
{
get { return album; }
set { album = value; }
}
[MarshalAsAttribute(UnmanagedType.ByValArray, SizeConst = 4)]
private char[] year;
public char[] Year
{
get { return year; }
set { year = value; }
}
[MarshalAsAttribute(UnmanagedType.ByValTStr, SizeConst = 28)]
private string comment;
public string Comment
{
get { return comment; }
set { comment = value; }
}
public byte Reserve { get; set; }
public byte Track { get; set; }
public byte Genre { get; set; }
}
有了這個帶有和二進制格式一一對應的數據結構后,下一步就是需要寫一個類似memcopy的通用函數實現讀寫操作。.net提供了一個Marshal類可以實現類似memcopy的內存復制功能,我利用它寫了一個字節數組到object的互像轉換函數,並基於它們提供了兩個BinaryReader的擴展函數,以方便使用。
static class MarshalExtend
{
public static T GetObject<T>(byte[] data, int size)
{
Contract.Assume(size == Marshal.SizeOf(typeof(T)));
IntPtr pnt = Marshal.AllocHGlobal(size);
try
{
// Copy the array to unmanaged memory.
Marshal.Copy(data, 0, pnt, size);
return (T)Marshal.PtrToStructure(pnt, typeof(T));
}
finally
{
// Free the unmanaged memory.
Marshal.FreeHGlobal(pnt);
}
}
public static byte[] GetData(object obj)
{
var size = Marshal.SizeOf(obj.GetType());
var data = new byte[size];
IntPtr pnt = Marshal.AllocHGlobal(size);
try
{
Marshal.StructureToPtr(obj, pnt, true);
// Copy the array to unmanaged memory.
Marshal.Copy(pnt, data, 0, size);
return data;
}
finally
{
// Free the unmanaged memory.
Marshal.FreeHGlobal(pnt);
}
}
public static T ReadMarshal<T>(this System.IO.BinaryReader reader)
{
var length = Marshal.SizeOf(typeof(T));
var data = reader.ReadBytes(length);
return GetObject<T>(data, data.Length);
}
public static void WriteMarshal<T>(this System.IO.BinaryWriter writer, T obj)
{
writer.Write(GetData(obj));
}
}
這個類和前面的ID3V1數據結構沒有任何聯系,也就是說,它是一個通用函數,只要定義好了數據結構,就可以直接用它來實現通用的讀寫操作了:
using (var reader = new BinaryReader(File.OpenRead(@"r:\test.mp3")))
{
reader.BaseStream.Seek(-1 * Marshal.SizeOf(typeof(ID3V1)), SeekOrigin.End);
var id3Tag = reader.ReadMarshal<ID3V1>();
}
PS:
-
這種方式具有一定的通用性,並且非常簡潔,我常常用這種方法實現二進制文件和協議的解析,但估計效率不高,沒有具體測試過。平時也只是拿它來用於客戶端這種對性能要求不高的地方,沒遇到啥海量數據處理的場合。如果誰有這方面的測試,歡迎共享下性能數據,應該還是有些性能提升的空間的。
-
讀寫二進制文件除了用BinaryReader直接讀寫文件外,內存映射文件也是一直非常有效的方式。這里附兩篇相關文章:使用內存映射文件實現進程通訊、[譯].NET 4 中玩耍內存映射文件,有興趣的朋友可以了解下。
-
如果遇到BigEndian的數字,可以使用我以前的文章在C#中實現BigEndian的數字中定義的數據結構。