设计一个二进制文件格式

NOTES
本文来源:Designing File Formats
翻译由 本人(赤石俊哉) 整理,若您是原做者并认为此文涉及版权侵犯,我会配合删除。html


如今有不少不少种文件,它们又有着不少不少的文件格式。从简单的 ASCII 文本文档到复杂的数据库,下面是几个文件结构中必要的几个元素,设计者们每每会忽视掉其中一部分。数据库

一个好的文件格式应该至少拥有下面的几个元素:小程序

  • 身份标识字符(也被称为 Magic 字符或者 ID 字符。
  • 头部验证码
  • 版本信息
  • 数据位移

这也一样适用于一些历来不会实际存储在一个文件中的数据,好比经过网络传送到移动设备的数据。缓存

身份识别字符

这个叫作 Magic 字符的历史已经好久远了,它一般是一个 2 ~ 4 字符,可能更多或者更少,用来惟一地标识一个二进制文件格式。应该尽可能避免和天然语言相近的值,若是文件可能与文本文档混合使用,那使用一个纯 ASCII 字符就是一个很差的选择。随着存储容量的变大,短 Magic 正在慢慢地被长一些的字符串所代替。网络

身份识别字符在一些非强类型系统中(好比 UNIX )使用,是颇有用的。在 Macintosh HFS 文件系统中,是很难将文件和它的建立者类型分开的, 可是在 Windows 下,你只须要重命名它就好了。函数

在全部的系统中,他们都是有用的:能够确保所读取的文件是你所指望的文件内容。假如一个文件的类型在文件名中缺失,在网络传输中,可能你就要话大量的时间去猜想这个文件的内容是什么。可能你又要说了,我这做为系统“内部使用”就没有必要了吧。可是在开发中,若是做为一个资源被使用的话,当你读取错误的类型的文件时,这将会很快地让你意识到而不是发生了一系列问题以后才意识到。性能

最帅气(没有之一)的识别字符串当属 PNG 图片文件格式中定义的,它看起来是这样的:测试

(decimal)              137  80  78  71  13  10  26  10
   (hexadecimal)           89  50  4e  47  0d  0a  1a  0a
   (ASCII C notation)    \211   P   N   G  \r  \n \032 \n

第一个字符是一个非ASCII字符以防止来自文本文件的干扰。接下来的三个字符则是让人类很显眼的就认出这是一个 PNG 文件。\r\n序列则是一个能够进行一个快速测试,系统会将CRLF转换成CR仍是LF。而最后的\n则测试系统会将LF转换成CR仍是CRLF。倒数第二个字符是一个CTRL + Z,在有些系统里面,这个做为一个文本文件的结束标记。他不光会检测不正确的文本处理,假如你在 MS-DOS 中打印这个文件,他也能把你从一堆垃圾乱码中解救出来。线程

ASCII文件格式能够从身份识别字符中获益,由于读取它们的程序能够马上知道它们是否在读取正确的文件类型。当经过网络传入一个数据流的时候,能够从身份识别字符中识别出传入数据的性质。

头部验证码

你能够用任何字符校验来作,好比 32 位的 CRC,又或者是 128 位的 MD5 哈希。头部验证码紧跟在 Magic 字符以后,用来计算它以后,数据内容(用 数据偏移 标识的位置)以前的内容的校验值。它具备较高的可信度,让你确保你如今所读取的内容与当时写入时的内容是一致的。

不少开发者将内部校验码视为是没必要要的,并且他们有信息地认为 TCP/IP 网络是至关可靠的,并且若是你都不能相信你硬盘里面的数据了,你将会遇到不少问题。可是,仍然是有必要进行文件头的校验的。

好比说,存储其实并无你想的那么稳定。在早些天我收到了不少问题,在 CD 中记录的音乐文件,文本文件和 JPEG 图像都没问题,可是存入的 ZIP 文件却出问题了。而他们没有意识到,只有 ZIP 文件档是存在 CRC 校验的。全部的数据都被损坏了,多是受损的 SCSI 或者 IDE 数据线所引发的。可是问题那么少,只是由于在不少类型的文件中没有体现出来。你可能不会意识到你的“文本”变成了“又本”。也许你不会意识到你的图片上有些许奇怪的斑点。可是一个 32 位的 CRC 却极少可能会被错误给欺骗过去。

还有一个常见的可能损坏文件的途径是用一个 ASCII 模式的 FTP 传输,他会作行末字符转换(好比,将 LF 转换成 CRLF)。将字节混合或者修改以后可能会引发一些有趣的问题。若是有头部的校验码,你能够马上知道头部中是否有损坏。若是你能够信任建立这个文件的程序代码,那你能够认为这个头部是可用的,或者说你能够减小你代码执行的检查量。

有一种思想认为,校验码应该放在头部的最后位置,他老是能够在 (OffsetToData - 4) 的位置上找到,并且可让 CRC 覆盖整个头部,包含 Magic 字符。虽然测试一遍它是冗余的。可是更重要的是这样可让他做为网络传输的头部。你能够计算 CRC 在你输出了头部字节以后,而不用将插入位置回移到前面去填写它。一般来讲,文件头很小,不须要折中类型的处理,可是必定要记住。

版本信息

这应该很明显是一个有必要的字段。应用和文件格式随时间不断迭代,并且也很须要肯定一个文件的内容是否能够被读取。有两种基本的方法,序列主/次

序列方法用一个简单的值,一般用一个字节存储。数字从0或者1开始,每次递增。程序能够认清和处理它的当前版本或者更早的版本,可是拒绝任何更新的版本。

主/次方法有两个值,主版本和序列方式同样,任何旧版本均可以被处理,可是更新版本不能够。而次要版本对于每个主要版原本说,都是从0开始。当有新字段被添加的时候,增加。旧版本中不用的字段始终保留,就算过期了不用了也要填充。这个方法比较适合保证向后兼容:较旧的应用程序能够读取较新的文件,由于就算被弃用了的字段在次版本中也是确定存在的。若是一个文件的次版本更低,程序是知道如何转义它的。若是一个文件的次版本更高,则程序知道全部的字段都被明确地标识出来了,而新加的字段能够直接跳过不读。若是一个文件被从新设计整改告终构,更新主版本以防止旧版本的应用程序会读取新版本的文件。

文件版本号不该该跟程序版本号进行绑定,也不用多此一举地加额外信息,好比1.3.5d1。一个或者两个稳定增加的值就足够了。

若是你不想显式地显示出版本号,好比在 PNG 文件中,就用了一种叫作块(chunk)的东西。若是数据格式须要被修改,则块类型的名称会被修改。总体的文件结构不会改变,版本数字被有效地内嵌在块名中(或者在块自己内)。这种方法只在你确信总体文件结构不会被改变时才有用。

有些文件格式会包括一个最小程序版本的数字。这个听起来有点像把马车放在马前面:应用程序最有能力决定是否处理给定的文件格式。文件格式版本应该存储在程序中,而不是其余地方。这个调整是为了保证版本的向后兼容,由于它容许文件格式设计者告诉程序它们是否能够读取这个文件。最好仍是交给上面描述的主/次方法来处理。

数据位移

这个字段的优点并不会马上体现出来,直到哪天你在考虑向后兼容的时候。一个旧的程序能够读一个新的数据文件,由于他知道如何去寻找他须要的字段,跳过不须要的字段。这个位移值告诉程序如何跳过不须要关心的头部字段。

这个偏移值应当是基于文件最开始进行测量的。这对于真实文件(SEEK_SET)和内存缓冲区(将 char* 与位移相加)进行计算都是更简便的。

你可能会试图用这个数字做为版本号。好比,Windows 中使用 sizeof() 来肯定多种类型的结构,好比位图。请不要这样作。这会让你进入一种只能不断地把你的文件变大的状况。这对于 Windows API 结构来讲很合理,由于他要保证多个版本的二进制兼容性。除非你须要始终向后兼容,不然这在设计上是个错误的决定。

这个属性对于一个 ASCII 文件格式是一个不须要的,在一个边长的头部后面有点显式地在说“数据从这里开始”。

其余字段

有些格式有一些复杂的结构。它们可能有不少个数据区域,每一个数据区域有一个偏移值,或者是有一个链表。这些字段是从文件头部仍是放入那些区块的头部就取决于设计者你了。

有一个字段你很是值得你去考虑,就是长度字段。对于一个磁盘中的文件,数据的长度被隐式地被文件长度所表示。可是,若是内嵌长度将会让你检测到文件是否不经意之间被修改了(好比从网络上下载的文件),对于经过网络流传输的数据,这点也是很重要的。

其它考虑

结构输出

使用 C 的结构直接进行读写是很是有吸引力的。经过名字访问比较便利,并且可使代码最小化。

Stop,从历史上来讲,这是一个很是糟糕的主意。由于结构的填充和组织随着平台、编译器、甚至不一样版本的相同编译器不一样会有不一样,统一 C 的编译器和明确使用progmas可能能够大部分地解决这个问题,可是要保持最好的兼容性,你仍是单独写入它们会保险一点。

使用标准的 libc 缓冲的 I/O 方法(fopenfreadfwritegetcputc)或者使用缓存的 C++ iostream。可能会感受每一个字节调用getc或者putc会比较慢。可是请记住,这些宏在处理一个缓冲区的数据时是很小的。读取数据到一个缓冲区而后你本身再转义不会让你收获多少便利,反而会严重影响代码的清晰度。

有一个常见的错误,必定要避免,当你写 C/C++ 时,写成:

unsigned short val = getc(infp) | getc(infp) << 8;

这里遇到的麻烦是,不是全部的编译器都用相同的顺序处理参数,因此你不知道第一个getc()会被先运行仍是放到第二位。(ANSI C 中对于这个有定义,可是你不能确保它的实现是遵守 ANSI C 的),把他们分开十分简单,并且编译以后也确定都是正确的顺序。

在进行一系列的getc()putc()以后,不要忘了检查feof()ferror()(以及其余相似的函数)。

低字节序和高字节序

也就是 Little-Endian 和 Big-Endian,这里最好的建议是使用和数据使用者大致相同的格式。若是你写的文件将主要在80x86机器上使用,使用低字节序。当读取数据的时候,你须要选择假设你运行在低字节序机器或者是写可移植的代码。在前者的状况下,你能够一次抓取 2 ~ 4 字节数据,并将其填充入一个整型。在后者的状况,你须要一次读取一个字节,而后进行适当的排序。若是你读取任何东西都经过小函数(Read16LE来读取 16 位的低字节序值),你能够封装你的(非)能够执行问题。

文件数据的校验值

将一个 CRC 放入文件数据比放在文件头部更值得去作。CRC 最好是放在一个数据块的结尾。这让你能够在一个流中写入数据,而不须要回滚位置填入 CRC,对于网络传输数据来讲尤为重要。

参数跟文件头部的校验值差很少,可是文件头部要比数据区小得多。

文件结尾标志

文件更改可能会发生在经过网络传输数据或者磁盘产生坏道。你的程序能够经过如下途径检测出这些修改:

  • 拥有一个完整文件的 CRC。最可靠,可是性能最差。
  • 在头部拥有一个完整的文件长度。读取的时候终止于知足长度,而不是到EOF。若是你不马上读取整个文件,则跳转位置到(length - 1),而后尝试读取一个字节。
  • 添加一个清楚的结尾标示符。跳转到文件尾,而后读取它。这个文件长度能够从文件头部获得或者从文件系统获得(使用fseekSEEK_END)。

若是你在将一个大文件直接在内存中映射到多个线程地址空间,并且不想花大量的时间去进行整个 CRC 的校验,用一个文件位标识是一个很是值得考虑的方法。

文档说明

若是你穿件了一个二进制文件格式,文档记录每个字节的意义。好比:

All values little-endian
 +00 2B Magic number (0xd5aa)
 +02 1B Version number (currently 1)
 +03 1B (pad byte)
 +04 4B CRC-32
 +08 4B Length of data
 +0c 2B Offset to start of data
 +xx [data]

ASCII 文件格式能够自行记录,若是你在文档中定义了注释。建立一个默认的使用大量注释的配置文件,而后在你的代码树中保存下来。

相关文章
相关标签/搜索