设计一个二进制文件格式

作者: admin 分类: 文件格式 发布时间: 2019-09-19 11:47

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


现在有很多很多种文件,它们又有着很多很多的文件格式。从简单的 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 文件格式可以自行记录,如果你在文档中定义了注释。创建一个默认的使用大量注释的配置文件,然后在你的代码树中保存下来。

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!

发表评论

标签云