设计(二进制)文件格式时需要注意哪些重要点?

48
当设计一种用于记录二进制数据的文件格式时,您认为该格式应具有哪些属性?到目前为止,我想到了以下几个重要点:
  • 在开头有一些“幻数”,可以识别文件(在我的特定情况下,这还可以帮助区分“旧版”文件)
  • 在开头有一个文件版本号,以便稍后更改文件格式而不会破坏兼容性
  • 指定所有数据项的字节序和大小;或:包括一些空间来描述数据的字节序/大小(我倾向于使用前者)
  • 可能为将来可能需要的每个文件属性保留一些空间?
还有什么其他有用的方法可以使该格式更具未来性和最小化将来的麻烦呢?
10个回答

28

请查看PNG规范。这种格式背后有一些非常好的原理。

此外,决定未来格式的重要内容:紧凑性、兼容性、允许在其中嵌入其他格式(不同压缩算法)。另一个有趣的例子是Google的协议缓冲区,其中传输数据的大小至关重要。

至于字节序,我建议您选择一种选项并坚持使用它,不允许不同的字节顺序。否则,读写库将变得更加复杂且速度更慢。


PNG是其中一个例子。其他具有类似结构的格式还包括IFF(交换文件格式,主要用于Commodore Amiga)和RIFF(例如WAV或AVI)。 - BlaM

19

我同意以下观点:

  1. 开头的魔数。在*nix系统中非常必要:

  2. 文件版本号用于向后兼容。

  3. 字节序规范。

但你的第四个建议有些过度,因为第2个建议让你可以添加字段,只要你更改版本号(并且只要你不需要向前兼容)。

  • 可能保留一些空间以便将来可能需要的每个文件属性?

此外,其他许多答案提出的强制在您的文件上实施块结构的想法,似乎不像是二进制文件的普遍要求,而更像是解决某些有效负载问题的解决方案。

除了以上1-3之外,我还会添加以下内容:

  • 需要简单的校验和或其他方式来检测内容是否完整。否则,您无法信任魔术字节或版本号。请注意指定哪些字节包括在校验和中。通常,您将包括文件中所有没有错误检测的字节。

  • 记录写入文件的软件版本(包括最精细的版本号,例如构建号)。您会收到一个带有附加文件的错误报告,因为某些人无法打开它,而他们不知道何时编写了该文件,因为当时没有出现错误。但是错误出现在编写它的版本中,而不是尝试读取它的版本中。

  • 在规范中明确指出这是二进制格式,即对于所有字节(除了魔术数字),允许使用0-255的所有值。

以下是一些可选内容:

  • 如果您确实需要前向兼容性,则需要某种方式来表示哪些“块”是“可选的”(就像png一样),以便先前版本的软件可以优雅地跳过它们。

  • 如果您希望在文件中找到规范,则可以考虑嵌入一些提示。想象一下,在png文件中找到字符串http://www.w3.org/TR/PNG/会有多么有帮助。


为什么像你所说的魔数的完整范围0-255是不可接受的?那么,适当的范围是多少?提前致谢。 - bazz
我认为你应该始终将文件中的数字保存为大端格式,这样就不需要指定字节序。毕竟,大端是网络字节顺序,而且在文件中也适用。 - Zoltan Tirinda

12

当然,这完全取决于格式的目的。

一种灵活的方法是将整个文件结构化为TLV(标签-长度-值)三元组。例如,可以使您的文件由记录组成,每个记录以4字节标题开头:

1 byte  = record type
3 bytes = record length
followed by record content

关于字节序,如果您在文件中存储字节序指示器,则所有应用程序都必须支持所有字节序格式。另一方面,如果您为文件指定特定的字节序,则仅在具有不匹配字节序的平台上的应用程序需要进行额外的工作,并且可以在编译时决定(使用条件编译)。


TLV 似乎无处不在,到处都是,以至于我无法看出它在每个地方的意义。 - Cheery
关于TLV的优化注意事项:如果您按顺序存储它们,例如type1-length1-value1-type2-length2-value2等,那么读取整个文件(例如搜索特定条目)可能效率低下,因为您必须读取每个完整的TLV三元组。但是,如果您将所有类型和长度元组存储在开头(即type1-length1-type2-length2-value1-value2),则可以计算到每个值部分的偏移量,而无需读取所有前面的值。然而,文件结构变得更加复杂。 - oliver

7
另外,从.xz文件规范中可以得知(http://tukaani.org/xz/xz-file-format.txt):前几个字节应该是非字符,“以防止应用程序将文件误检为文本文件”。不确定编辑器和其他工具通常会检查多少个头字节,但在前四个或八个字节中使用非二进制字节似乎很有用。

5

在开始之前,了解如何使用您的文件是最重要的。

  • 随机或顺序访问是否为常态?
  • 数据读取频率有多高?
  • 数据写入频率有多高?
  • 您是否一次性写出文件,还是会随着数据输入而缓慢写入?
  • 该文件是否需要可移植性?并非所有格式都需要可移植性。
  • 它是否需要与其他版本兼容?也许更新文件就足够了。
  • 它是否需要易于读写?
  • 大小/速度/复杂度平衡。

大多数答案都在可移植性/兼容性方面提供了很好的建议,因此我不会再添加更多。但请考虑以下(通常被忽视的)事项。

  • 有些文件经常写入但很少读取(备份、日志等),您可能希望关注文件大小和易于编写。
  • 如果您的文件永远不会离开主机,或者离开得足够少,以至于转换是一个好选择,那么转换字节序会很慢(相对来说),您可以将类似于0x1234的数字作为头的一部分进行编写,以便您可以检测(并指示用户进行转换)。
  • 有时易于阅读确实很有用。如果您要处理日志或文本文档,请考虑一次全部压缩而不是每个条目分别压缩,以便您可以zcat | strings文件并查看其中的内容。

要牢记许多事项,并设计一个好的格式需要大量的规划和远见。小细节,例如使用本地整数带来的性能提升或者zcat文件并获取有用信息,可能会让您的产品获得优势,但是您需要小心,以免为此牺牲重要东西。


3
我建议定义一个子结构,用于存储数据,类似于文件中的小型文件系统。例如,即使您的文件格式将存储特定应用程序的数据,我建议在文件中定义记录/流等,以便与特定应用程序无关的代码能够理解文件的布局,但当然不能理解不透明的有效负载。
让我们更具体一些。考虑在内存中存储数据的常规方法:通常可以将它们归结为连续可扩展的数组/列表、基于指针/引用的图形和特定格式的二进制数据块。
因此,可能有益于沿着类似的方向定义二进制文件格式。使用记录头指示以下数据的长度和组成形式,无论是数组(相同类型记录的列表)、引用(文件中其他记录的偏移量)还是数据块(例如特定编码的字符串数据,但不包含任何引用)。
如果经过精心设计,这可以允许文件格式不仅用于一次性地持久化数据,而且还可以按需进行增量式使用。如果子结构被正确设计,它可以是与应用程序无关的,但仍然允许编写垃圾回收应用程序,该应用程序理解数据块、数组和引用记录类型,并能够跟踪文件并消除未使用的记录(即不再指向的记录)。
这只是一个想法。其他寻找思路的地方包括一般的文件系统设计或关系数据库物理存储策略。
当然,根据您的要求,这可能过于复杂。您可能只需要一种用于持久化内存数据的二进制格式,此时可以考虑标记记录方法。
在这种方法中,每个数据都带有一个标记。标记指示紧接着的数据类型,可能还包括其长度和名称。列表可以以无有效负载的“end-list”标记结尾。标记可能具有嵌入式标识符,因此不能被序列化机制读取时不理解的标记可以被忽略。在这方面,它有点像XML,但使用了二进制习语。
实际上,XML是寻找文件格式长期保存性的好地方。看看它的命名空间功能。如果仔细构建读写代码,应该可以编写保留标记(递归)数据位置和内容的应用程序,可能是因为它由同一应用程序的较新版本编写而成。

3

为了使文件具有未来的可扩展性,一种方法是提供块。在文件头数据之后,可以开始第一个块。该块可以有一个字节或者单词代码来表示块类型,然后是以字节为单位的大小。现在您可以任意添加新的块类型,并且可以跳过块的末尾。


3
确保您保留一个标记代码(或最好是在每个标记中保留一位),以指定已删除/空闲块/块。 然后,可以通过将块的当前标记代码更改为已删除的标记代码或设置标记的已删除位来删除块。 这样,当您删除块时,就不需要立即完全重构文件。
在标记中保留一位提供了可能恢复块的选项(如果您未更改块的数据)。
但是,出于安全考虑,您可能希望清零已删除块的数据,在这种情况下,您将使用特殊的已删除/空闲标记。
我同意Stepan的观点,您应该选择一个字节序,但我还会在文件中添加一个字节序指示器。 如果您使用字节序指示器,则可以考虑使用UniCode字节顺序标记之一作为任何文本块所使用的UniCode文本编码的指示器。 BOM通常是UniCoded文本文件的前几个字节,因此,如果您的BOM是文件中的第一个条目,则某些实用程序可能无法将其识别为UniCode文本(我认为这不是什么问题)。 我会将BOM视为您正常标记之一(使用UTF16 BOM(如果使用16位标记)或使用UTF32 BOM(如果使用32位标记)),并将其长度设置为0。
另请参见 http://en.wikipedia.org/wiki/File_format

1
你应该将字符串存储为UTF8格式的文件,这样就不需要处理BOM。 - Zoltan Tirinda

2
如果你正在处理可变长度的数据,使用指针会更加高效:建立一个指向你的数据的指针数组,最好放在文件的开头附近,而不是直接存储数据在一个数组中。在这种情况下,间接寻址更为理想,因为它允许随机访问,而只有所有项的大小相同才可能实现。如果直接将数据存储在数组中,没有指定任何记录的位置,那么数据访问将在最坏情况下需要O(n)时间;为了使文件读取代码访问特定元素,它必须知道所有先前元素的长度,而唯一的方法是查看每个元素。如果你要一次读取整个文件,那么你无论如何都要这样做,所以不会有问题。但是,如果你只想要一个东西,那么这不是正确的方法。

而对于指针数组,无论在哪里都是O(1)时间:你只需要一个索引号,就可以检索和跟随指针来获取你的数据。

当使用这种方法编写文件时,你当然需要在进行任何写入之前在内存中构建起表格。


1
我觉得你的回复很有趣,希望你能再详细解释一下。我正在设计二进制格式,并想将你的这个想法融入其中。如果你有时间,请随时私信我。 - Shane Yost

2
我同意atzz的建议,使用标签长度值系统。为了未来的兼容性,您可以在开头存储一组指向TLV条目的“指针”(或者可能是Tag,Pointer,并且指针指向Length,Value;或者Tag,Length,Pointer,然后将所有数据放在其他地方?)。
因此,我的文件可能如下所示:
magic number/file id
version
tag for first data entry
pointer to first data entry --------+
tag for second data entry           |
pointer to second data entry        |
...                                 |
length of first data entry <--------+
value for first data entry
...

“魔数(magic number)、版本(version)、标签(tags)、指针(pointers)和长度(lengths)”都是预定义的固定长度,以便于解码。例如,可以使用2字节或4字节,具体取决于需要什么。它们不一定都相同(例如,所有标签为1字节,指针为4字节等)。
“标签”告诉您正在存储什么。“指针”告诉您在哪里存储数据(可以是偏移量或绝对值,以字节为单位)。“长度”告诉您数据的大小,“值”是类型为“标签”的“长度”字节数据。 如果您在MyFileFormat v2文件上使用MyFileFormat v1解码器,则指针允许您跳过v1解码器不理解的部分。如果您只是跳过无效标签,则可能只需使用TLV而不是TPLV。
我会手动编写类似的代码,或者可能在ASN.1中定义我的格式并生成编解码器(我在电信领域工作,因此ASN.1/TLV对我来说很有意义:-D)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接