如何设计二进制文件格式

两盒软妹~` 提交于 2020-03-07 03:52:51

前言

本文是由于需要设计一种二进制的文件格式用于保存前文中所提取出来的wav文件的采样数据故而写下本篇文章。

1、为何需要一种二进制的文件格式

程序时常需要保存自身的文档数据。比如一个矢量绘图程序,需要将用户绘制的每个图元都保存到文件中,以后再次打开。应该优先考虑文本格式,文本格式容易测试和编辑。更应该优先考虑通用的文本格式,比如 XML, JSON, Lua 等等。这些通用的文本格式已经存在大量的工具和库,可以省下很多功夫。

文本格式读取慢,并且文件尺寸也比较大(就算经过 zip 压缩),大多数情况下这都不是什么问题。但一些场合,要求更快读取速度,更小文件尺寸,这时就需要自己来设计一种二进制文件格式。游戏中的模型数据,就要求读取速度快;而经常通过网络传输的文件,就要求减少文件尺寸,比如 swf 格式。

2、文件格式的具体设计

具体的二进制文件格式,要根据具体的程序需求来设计。但有些设计思路,是所有二进制格式都通用的。了解这些,对将来分析其它的二进制格式也会有帮助。

(1)整体的文件结构

常见二进制文件格式,时常采用 文件头 + 分区 的结构:
file header
section 0
section 1
section 2
section 3

section N

1)文件头(file header ):描述了文件的整体信息,常见的字段有魔数、版本号、检验码、文件大小等等。文件头根据文件的具体用途会有额外的字段,比如一张图片,文件头当中就可以含有表示图片尺寸的字段。

2)分区(section):其结构通常是标签+长度+分区数据
tag + length
section data
tag 和 length 合起来是分区头部,后面紧跟着分区的具体数据。

tag:可以是一个整数,也可以是一个字符串。tag 用来标识分区,不同的 tag 表示不同的分区种类,不同的分区种类有各自不同的读取方式。
比如:
#define kPicShapeTag 1
#define kPreivewTag 2
当 tag 为 1 时,就表示是这个分区存放的是图元数据,当为 2 是表示这个分区存放一张预览图。

length:length 是个整数,表示分区数据的具体长度(不包括分区头部)或者表示整个分区的长度(包括分区头部)。
PS:这种分区结构使得文件格式容易扩展,有新需求时就定义一个新的分区类型,原来的文件结构不需要修改。也容易「向上兼容」。

向上兼容:新版本程序生成的新版本格式,可以使用旧版本程序打开。只是旧版本打开时,一些新功能无法应用,比如ps中新版本的滤镜功能旧版本没有会自动跳过此段二进制数据。

向下兼容:旧版本程序产生的旧版本文件格式,可以使用新版本的程序打开。应用程序升级,向下兼容是必要的。

(2)文件头魔数(magic number)

文件头当中,会有一个数字作为文件格式的标识。这个数字可以随意选定任何值,也可以占据任何字节(通常是 4 字节或者 8 字节),但这个数字选定之后就会固定下来,基本上不会再有变化。在编程领域,一些说不清来历比较任意的数字会被称呼为魔数( magic number)。因此这个随意选定用于标识文件格式的数字,就叫文件格式魔数;这个数字通常放在文件头当中,有时也就称为文件头魔数。

为了方便处理,避免数字在不同字节顺序的机器上有所区别,有时文件头魔数会定义成多字节格式,比如:

struct Header
{
    uint8_t md5[16]; // md5 作为检验码,uint8_t是代表一字节的数据类型
    char magic[8];   // 魔数
};

Header header;
memcpy(header.magic, "vecpaint", 8); //拷贝vecpaint到header.magic作为魔数,共8个字节

文件头魔数无论被当成整数还是多个字节处理,它的作用都是相同的,只是作为一个文件格式的标识。

(3)检验码

文件头通常还会有个检验码,用于检验文件是否完整并且没有经过修改的。这个检验码可以使用 crc, 可以使用 md5,也可以使用其它算法。只要达到这个目的就行。

假如文件都是在本机写入和读取,这个检验码没有什么大作用。但假如文件格式经过网络传输,这个检验码就十分有用了。网络传输经常会发生数据不全,或者某些字节被改变了,导致文件数据不完整。通过这个检验码可以检测出这种问题,以便再做进一步处理(比如重新下载一次)。

用文件的剩余数据计算出它的 md5, 存放在文件最开头。这个 md5 一方面可以作为这个文件的检验码,另一方面可以作为这个文件的 key。读取文件格式的时候,先判断魔数是否正确,再重新计算出 md5 进行比较。md5 出错,表示文件不完整或者经过改动。在需要更安全的场合,md5 可能被人伪造,但平常应用基本足够了。

(4)版本号

文件头通常还会包含版本号。版本号不同的文件格式,读取方式可能会有所不同。不支持「向上兼容」的软件,碰到比它可以支持的更高版本的文件格式,就直接读取失败,并返回一个错误信息。

大部分软件的版本号采用三个数字,用小数点分隔,格式为:

主版本号.次版本号.补丁版本号

主版本号通常表示功能有很大改动,甚至界面都改掉了;次版本号用于表示添加了一些小功能;补丁版本号只是用了 fix bugs。iOS 系统的采用这种版本表示方式。

(5)字节顺序

字节顺序有大端字节序和小端字节序。不同的机器字节序有可能不同,设计文件格式时需要考虑文件用什么字节序保存数据的。不然有可能在这一台机器上生成的文件,传输到另一台机器上就打开失败了。

PS:有些文件格式,可以同时支持大端和小端字节序。它有文件头中有个字段指明文件保存的时候是采用什么方式保存。

(6)字节对齐

假设机器是 32 位(也就是 4 字节),当数据的地址为 4 的倍数时,计算机的读取速度会更快。64位以此类推。

C/C++ 编译器编译代码时,也会尽量使得数据字节对齐,比如下面结构:

struct Test
{
    int a;
    double b;
    int c;
    double d;
};

编译器为使得数据不跨越格子边界,整个结构在 64 位机上占据 32 个字节。但稍微调整一下:

struct Test
{
    int a;
    int c;
    double b;
    double d;
};

就这样交换一些数据定义顺序,整个结构在 64 位机上占据 24 个字节,比原来节省了 8 个字节。

(7)回写和流写

1)回写:是指数据写入之后,可以回头再修改。比如分区数据最通常以 tag + length 开头,但最开始是不知道最终的分区数据长度的。这样当写入分区头的 length 字段时,就只能先随意写些临时值。当写完最后的分区数据,知道数据长度了,再回头修改长度。

2)流写:比如将数据写入网络当中,不可能回头修改网络上的数据。这种一直写,不能回头修改的就叫「流写」。

有些约束条件下,二进制文件格式就需要支持「流写」,比如在网络一端生成数据,在网络另一边读取数据。这种情况下,可以将 文件头 + 分区数据 稍微调整一下变成分区数据 + 文件尾。当按顺序写完所有分区数据,也就知道文件的整体信息,就可以依次写入文件尾的各字段。

本篇文章写到此处,具体的编程实现可以继续关注我的文章。

参考文章:
二进制文件格式设计

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!