標籤:

二進位文件格式設計

程序時常需要保存自身的文檔數據。比如一個矢量繪圖程序,需要將用戶繪製的每個圖元都保存到文件中,以後再次打開。應該優先考慮文本格式,文本格式容易測試和編輯。更應該優先考慮通用的文本格式,比如 XML, JSON, Lua 等等。這些通用的文本格式已經存在大量的工具和庫,可以省下很多功夫。

文本格式讀取慢,並且文件尺寸也比較大(就算經過 zip 壓縮),大多數情況下這都不是什麼問題。但一些場合,要求更快讀取速度,更小文件尺寸,這時就需要自己來設計一種二進位文件格式。遊戲中的模型數據,就要求讀取速度快;而經常通過網路傳輸的文件,就要求減少文件尺寸,比如 swf 格式。

具體的二進位文件格式,要根據具體的程序需求來設計。但有些設計思路,是所有二進位格式都通用的。了解這些,對將來分析其它的二進位格式也會有幫助。

整體文件結構

常見二進位文件格式,時常採用 文件頭 + 分區 的結構:

file header nsection 0nsection 1nsection 2nsection 3n....nsection Nn

文件頭描述了文件的整體信息,常見的欄位有魔數、版本號、檢驗碼、文件大小等等。文件頭根據文件的具體用途會有額外的欄位,比如一張圖片,文件頭當中就可以含有表示圖片尺寸的欄位。

文件的整體信息通常放在文件的最前面,所以才叫文件頭。但少數情況下可以放到文件的最後面,變成文件尾,但基本上不會放在文件中間。什麼情況下會放到文件最後面呢?可以參考後面「回寫和流寫」那個小節。

分區的結構通常會是:

tag + lengthnsection datan

tag 和 length 合起來是分區頭部,後面緊跟著分區的具體數據。

tag 可以是一個整數,也可以是一個字元串。tag 用來標識分區,不同的 tag 表示不同的分區種類,不同的分區種類有各自不同的讀取方式。比如:

#define kPicShapeTag 1n#define kPreivewTag 2n

當 tag 為 1 時,就表示是這個分區存放的是圖元數據,當為 2 是表示這個分區存放一張預覽圖。

length 是個整數,表示分區數據的具體長度(不包括分區頭部)或者表示整個分區的長度(包括分區頭部)。

這種分區結構使得文件格式容易擴展,有新需求時就定義一個新的分區類型,原來的文件結構不需要修改。也容易「向上兼容」。

這裡需要解釋什麼叫文件的「向上兼容」。「向上兼容」跟「向下兼容」對應。「向下兼容」指舊版本程序產生的舊版本文件格式,可以使用新版本的程序打開。比如程序 1.0 生成文件格式 1.0,新版本程序 1.2 可以打開文件格式 1.0。「向上兼容」指新版本程序生成的新版本格式,可以使用舊版本程序打開。比如新版本程序 1.2 最新定義了文件格式 1.1,舊版本程序 1.0 雖然早就發布了,但還是可以打開新版本文件格式 1.1,雖然可能會缺少一些新版本的功能。

一個應用程序升級,「向下兼容」是最起碼的要求,但「向上兼容」就並非所有程序都可以做到的。使用分區的結構,舊版本程序讀取二進位格式時,一旦遇到不認識的分區類型可以直接忽略掉,這樣就更容易實現「向上兼容」。比如 Flash 的 swf 文件格式,新版本格式新添了濾鏡功能,濾鏡數據放到一個新定義的分區當中。這樣舊版本的 Flash 播放器不能識別濾鏡分區,但還是可以播放新版本的 swf 格式,只是不能顯示濾鏡。而新版本的 Flash 播放器可以正確處理濾鏡。

注意程序的版本號跟文件格式的版本號可以是不對應的。比如程序版本是 1.2,但文件格式可以還是停留在 1.1。

有時候分區數據本身再細分,再次採用這種分區結構。比如一個矢量繪圖程序,需要記錄下圖元。可以設計成:

繪圖 Headern繪圖分區n預覽圖分區n

繪圖分區再劃分成具體的圖元分區,仍然採用 tag + length 的結構。

#define kShapeCircle 1n#define kShapeRect 2n#define kShapeBezier 3n

這樣就可以不斷地增加各種圖元。

文件頭魔數(magic number)

文件頭當中,會有一個數字作為文件格式的標識。這個數字可以隨意選定任何值,也可以佔據任何位元組(通常是 4 位元組或者 8 位元組),但這個數字選定之後就會固定下來,基本上不會再有變化。在編程領域,一些說不清來歷比較任意的數字會被稱呼為魔數( magic number)。因此這個隨意選定用於標識文件格式的數字,就叫文件格式魔數;這個數字通常放在文件頭當中,有時也就稱為文件頭魔數。

文件格式魔數可以隨意選取,看設計者自身的喜好。有些設計者會取自己名字的縮寫,有些會取自己的生日,有些會取當前日期,有些取文件格式的後綴名,有些僅僅是拋色子得到的隨機數字。

比如 zip 格式的魔數在位元組序是小端機器上是 0x04034b50,這個整數表示成 char[4] 就是 "PKx03x04",其中 PK 就是設計者 Philip Katz 的名字首字母。

為了方便處理,避免數字在不同位元組順序的機器上有所區別,有時文件頭魔數會定義成多位元組格式,比如:

struct Headern{n uint8_t md5[16]; // md5 作為 檢驗碼,n char magic[8]; // 魔數n}nnHeader header;nmemcpy(header.magic, "vecpaint", 8);n

文件頭魔數無論被當成整數還是多個位元組處理,它的作用都是相同的,只是作為一個文件格式的標識。

比如一個矢量繪圖軟體定義的文件格式魔數為 "vecpaint",位於文件開始的第 16 個位元組處(最前面 16 個位元組放 md5)。這個軟體保存數據輸出文件 example.vecpaint,重新讀取這個文件時,就首先判斷文件對應位置,對應的魔數是否為 "vecpaint",假如是的話,就進一步讀取解析數據;假如並非這個魔數,就讀取失敗。

你可能會說,這個 example.vecpaint 文件既然是軟體產生的,從後綴名就可以關聯到對應的程序,當然一定會讀取正確,為什麼還需要先判斷魔數呢?那我舉個反面例子,我有一張 example.png 圖片,之後我將它的後綴名改成 vecpaint,再用這個矢量繪圖軟體打開。假如不判斷魔數當成正常文件處理,就很有可能出問題,甚至會引起程序崩潰。

另外一個軟體可以處理多種文件格式,比如 Photoshop 可以處理 png 圖片格式,也可以處理 jpg 圖片格式。這樣就通過魔數判斷出各種文件格式,再進入對應的讀取流程。

常見的文件格式 png, jpg,exe,swf,psd,pdf,gif,zip 等等,魔數的位置和值都有所不同,這樣就可以區分出各類不同的二進位格式。而單純採用後綴名來判斷是不準確的。

需要注意的是,假如魔數正確,文件格式並非一定能夠讀取正確,還需要進一步判斷。但假如魔數錯誤的話,這個文件就一定會讀取失敗。

檢驗碼

文件頭通常還會有個檢驗碼,用於檢驗文件是否完整並且沒有經過修改的。這個檢驗碼可以使用 crc, 可以使用 md5,也可以使用其它演算法。只要達到這個目的就行。

假如文件都是在本機寫入和讀取,這個檢驗碼沒有什麼大作用。但假如文件格式經過網路傳輸,這個檢驗碼就十分有用了。網路傳輸經常會發生數據不全,或者某些位元組被改變了,導致文件數據不完整。通過這個檢驗碼可以檢測出這種問題,以便再做進一步處理(比如重新下載一次)。

你可能會說,現在的網路這樣可靠,為什麼還需要檢驗啊?舉個我遇到的例子,4 年前我設計過一個塗鴉文件格式,最初的文件格式是不帶檢驗碼的。這樣當用戶將塗鴉文件上傳到伺服器時,上傳到一半,用戶斷網了。這時伺服器上的塗鴉文件就只有一半。以後下載讀取時,因為文件不完整就一直讀取失敗,有時甚至會引起客戶端崩潰。不要問我為什麼文件會上傳了一半,伺服器不是我寫的,具體原因我也不知道。設計這個塗鴉格式的第二版時,我學乖了,放一個 md5 作為檢驗碼,就算伺服器出錯,客戶端也可以一開始就識別出來進行異常處理。假如沒有這個檢驗欄位,客戶端是沒有辦法知道數據是否完整的。

我自己設計的二進位文件格式時,經常會在最開頭放 16 個位元組的 md5。

char md5[16];n// 文件的剩餘數據n

用文件的剩餘數據計算出它的 md5, 存放在文件最開頭。這個 md5 一方面可以作為這個文件的檢驗碼,另一方面可以作為這個文件的 key。讀取文件格式的時候,先判斷魔數是否正確,再重新計算出 md5 進行比較。md5 出錯,表示文件不完整或者經過改動。在需要更安全的場合,md5 可能被人偽造,但平常應用基本足夠了。

版本號

文件頭通常還會包含版本號。版本號不同的文件格式,讀取方式可能會有所不同。不支持「向上兼容」的軟體,碰到比它可以支持的更高版本的文件格式,就直接讀取失敗,並返回一個錯誤信息。

版本號有時只是單獨一個數字,不斷往上遞增。有時也會拆分成兩個數字,為主版本號和次版本號。主版本號修改,通常表示文件格式發生大變動。而次版本號修改,通常只是表示添加了一些小功能。

補充一些閑話,軟體的版本號制定方式是多種多樣的。有些軟體會直接採用發布年份作為版本號,比如 Windows 98,Office 2013。大部分軟體的版本號採用三個數字,用小數點分隔,格式為:

主版本號.次版本號.補丁版本號n

主版本號通常表示功能有很大改動,甚至界面都改掉了;次版本號用於表示添加了一些小功能;補丁版本號只是用了 fix bugs。iOS 系統的採用這種版本表示方式。

當判斷版本高低的時候,需要將三個數字分隔開依次判斷,這個應該是常識了。但偏偏就有人缺乏這種常識,我就修正過一個 bug,有人將版本號直接轉成浮點數,之後判斷浮點數大小來判斷版本高低,比如:

  • 版本號 2.10 從字元串轉成浮點就是 2.10,也就是 2.1。
  • 版本號 2.9 從字元串轉成浮點數就是 2.9。

因為 2.1 比 2.9 要小,就判斷得出 2.10 的的版本要比 2.9 要低。

位元組順序

位元組順序有大端位元組序和小端位元組序。不同的機器位元組序有可能不同,設計文件格式時需要考慮文件用什麼位元組序保存數據的。不然有可能在這一台機器上生成的文件,傳輸到另一台機器上就打開失敗了。

有些人不注意位元組順序,用類似下面的代碼去讀寫數據:

static void writeI32(std::vector<uint8_t>& data, int32_t val)n{n uint8_t* ptr = (uint8_t*)&val;n data.insert(data.end(), ptr, ptr + 4);n}nnstatic uint32_t readI32(uint8_t*& ptr)n{n int32_t val = *((int32_t*)ptr);n ptr += 4;n return val;n}n

這樣的代碼初看起來沒有什麼錯誤,但這些代碼時依賴機器位元組序的。同樣的代碼,保存一個 4 位元組整數 0x01020304,在大端機器,會保存為:

0x01 0x02 0x03 0x04n

但在小端機就會被保存成:

0x04 0x03 0x02 0x01n

這樣就引起讀寫不一致。設計自己的文件格式或者去分析其它的文件格式,一定要注意位元組序。有些文件格式,可以同時支持大端和小端位元組序。它有文件頭中有個欄位指明文件保存的時候是採用什麼方式保存。那為什麼文件格式需要同時支持兩種位元組方式呢?那不是自己來找麻煩嗎?

以前大端小端位元組序的機器都比較常見,同時支持兩種位元組序,就可以根據機器情況選擇對應的保存方式。當軟體在大端機器上運行,就選擇保存成大端位元組序,小端機器上就保存成小端位元組序。這樣文件在讀取的時候,不用交換位元組,讀取速度會快一些。

但現在絕大多數情況下,我們用到的機器都是小端位元組序的。文件格式傾向於小端存儲。這樣讀寫的時候,會更加方便些。

位元組或者位

現在的計算機會將 8 bit 劃分成一個位元組,平時計算和處理數據都用位元組作為最小的單位,有些語言甚至不支持位運算。文件格式也傾向於採用位元組的存儲方式。一個整數存儲成 1 位元組,2 位元組,4 位元組或者 8 位元組。

但情況總有例外,有些場合需要更高效地減少文件大小。保存數字時用位元組為單位存儲也覺得浪費,這是可以將數字按位存儲,讀入或者寫入的時候,都使用位運算。比如保存一串數字:

10, 11, 12, 14, 15, 16, 18, 19, 14, 30n

這串數字都不會大於 32,可以 5 位表示。先保存這個數字 5,表示之後的數字以多少位保存。再依次用位操作,將每個數字用 5 位保存。

比如典型的是 swf 文件格式的設計。它大量使用了位存儲,使得 swf 文件的格式壓縮得很小。但這樣寫入和讀取都會有點麻煩,更加耗費計算資源。

需要看你的設計目的,假如需要大量壓縮尺寸,可以採用位存儲,但讀取速度就慢。採用位元組保存,讀取速度會快,但文件尺寸通常會比按位保存要大些。這個其實就是空間和時間的取捨,空間換時間或者是時間換空間。

swf 的一個目標設計是方便經過網路傳輸,當時的網路條件比較差,它這樣使用位存儲是合理的。

位元組對齊

現在很多程序員都不知道位元組對齊是什麼了。

假設機器是 32 位(也就是 4 位元組),當數據的地址為 4 的倍數時,計算機的讀取速度會更快。你可以將計算機的地址,以 4 位元組為單位,從 0 開始,將地址劃分成一個個小格子,每個格式都可以放 4 位元組,格子開始地址都是 4 的倍數。計算機讀取一個格子的東西是最快的。假如一個整數是 4 位元組,它處的地址是 4 位元組對齊的話,就剛好佔據一個格子。但假如這個整數並非 4 位元組對齊,就跨越了格子邊界,佔據了兩個格子,計算機就需要讀取兩個格子的信息,再將這個整數的值拼出來,這樣讀取就會慢了。假如機器是 64 位(也就是 8 位元組),這是它就用 8 位元組來劃分成格子,需要 8 位元組對齊。

C/C++ 編譯器編譯代碼時,也會盡量使得數據位元組對齊,比如下面結構:

struct Testn{n int a;n double b;n int c;n double d;n};n

編譯器為使得數據不跨越格子邊界,整個結構在 64 位機上佔據 32 個位元組。但稍微調整一下:

struct Testn{n int a;n int c;n double b;n double d;n};n

就這樣交換一些數據定義順序,整個結構在 64 位機上佔據 24 個位元組,比原來節省了 8 個位元組。

文件格式的數據最終需要載入到內存中讀取,為加快讀取速度,設計的時候應該考慮位元組對齊。比如當採用 4 對齊時,當寫入字元串只有 31 個字元,可以在最後面再寫入一個位元組 0,這樣使得下一個數字是 4 位元組對齊。

分析已有的文件格式時,也需要注意位元組對齊。很多舊文件格式,就經常會在字元串後面填充一些 0 位元組的。有些文件格式,甚至可以調整位元組對齊方式。在文件頭中有個對齊欄位,指定讀取寫入時對齊位元組數。可以是是 1 位元組對齊(1 位元組對齊其實就是不對齊),有時是 2 位元組對齊,有時是 4 位元組對齊。

回寫和流寫

「回寫」是指數據寫入之後,可以回頭再修改。比如分區數據最通常以 tag + length 開頭,但最開始是不知道最終的分區數據長度的。這樣當寫入分區頭的 length 欄位時,就只能先隨意寫些臨時值。當寫完最後的分區數據,知道數據長度了,再回頭修改長度。

但有些情況下,數據寫入之後就不能回頭修改了。比如將數據寫入網路當中,不可能回頭修改網路上的數據。這種一直寫,不能回頭修改的就叫「流寫」。「流寫」是我自己取的名字,我其實不清楚這叫什麼。計算機中,經常有流這個概念,英文為 stream, 也就是數據像流水一樣,只能向前,不能回頭。

有些約束條件下,二進位文件格式就需要支持「流寫」,比如在網路一端生成數據,在網路另一邊讀取數據。這種情況下,可以將 文件頭 + 分區數據 稍微調整一下變成分區數據 + 文件尾。當按順序寫完所有分區數據,也就知道文件的整體信息,就可以依次寫入文件尾的各欄位。

另外分區格式,就不能採用 tag + length 的方式了,長度不太可能預先知道。這種情況下,,可以在分區開始時先寫入一個特殊的開始符號,分區結束之後再重複這個特殊符號。讀取的時候,遇到這個特殊符號,就表示分區要開始了,再次遇到這個符號,就表示分區結束了。

比如 http 的 multipart/form-data 的 post 方式,就使用特殊的符號標記數據的開始和結束。

------WebKitFormBoundaryZL8FggWNCK9cO6BinContent-Disposition: form-data; name="name"nnAdamn------WebKitFormBoundaryZL8FggWNCK9cO6BinContent-Disposition: form-data; name="password"nnHelloWorldn------WebKitFormBoundaryZL8FggWNCK9cO6Bin

代碼的坑

上面已經討論了二進位設計中的常見問題,這些討論只針對普遍情況,更詳細具體的二進位格式需要根據用途來設計。二進位讀寫的代碼通常會使用 C 或 C++ 來編寫,最後稍微提兩個常見的坑。

坑 1

讀寫二進位時,應該先將讀寫位元組的代碼封裝起來,在 C++ 中可以封裝成一個類。有些人使用模板來設計讀寫介面:

class ByteWritern{npublic:n template <typename T>n void write(T val);n};nnclass ByteReadern{npublic:n template <typename T>n T read();n};n

之後就使用:

writer.write(header.majorVersion);nwriter.write(header.minorVersion);n

這種模板介面看似很統一,其實是不好的。二進位格式的讀寫需要嚴格控制位元組數目,應該從介面當中就看出讀寫了多少個位元組,不然很容易出問題。比如將 majorVersion 的類型從 uint16_t 修改成 uint8_t,就會出問題了。這種讀寫介面,寧願笨一點,麻煩一點。寫成:

class ByteWritern{npublic:n void writeUI16(uint16_t val);n void writeUI32(uint32_t val);n void writeBytes(void* bytes, size_t val);n};nnclass ByteReadern{npublic:n uint16_t readUInt16();n uint32_t readUInt32();n void readBytes(void* dest, size_t len);n};nnwriter.writeUI16(header.majorVersion);nwriter.writeUI16(header.minorVersion);n

ByteWriter 和 ByteReader 內部實現應該考慮到位元組順序。

坑 2

讀寫二進位數據,千萬不要為了方便而將一個結構整體寫入,比如:

struct Pointn{n int16_t x;n int16_t y;n};nnPoint pt;nxxxxxnwriter.writeBytes(&pt, sizeof(pt));n

就算不考慮位元組順序,這種代碼也是很不好的。一方面 Point 中修改了位元組的順序,或者添加了位元組,甚至源碼完全不變僅僅是換了機器編譯(比如從 32 位機器換到 64 位機器),這樣的代碼都有可能會出問題。二進位的讀寫應該拆分成一個個欄位分別讀寫。比如:

inline void writePoint(ByteWriter& writer, const Point& pt)n{n writer.writeI16(pt.x);n writer.writeI16(pt.y);n}n

推薦閱讀:

openfoam需要多少linux知識?
25歲 零基礎 想入行IT的困惑?
三國殺武將技能在程序內部是如何執行的?
MySQL學習資料
最近刷完了leetcode,麻煩指導轉行IT的下一步怎麼走?

TAG:编程 | 二进制 |