文件存储差异-编码

前两天,我和同事需要做一个功能,是把 js 前端保存到 app 本地的图片给读取出来。桥接层只有一个 saveFile 函数可供使用,定义如下:

1
2
3
saveFile(string name, string content) {
// xxx
}

我同事说可以让前端把 jpg 图片直接保存,我们直接去拿图片就好了。看这个接口的入参是 string 类型,我说这可能不行,得依靠 base64。同事想了一下,说图片也是二进制存储的,和文本形式上是一样的,应该有一种方式把 jpg 图片转一下,然后就可以通过这个接口保存到本地并且还是 jpg 的图片。
然后呢,我就想到了很多年前,我向另一个同事说需要一张 png 图片,这种 jpg 的不行。然后同事给了我 png 图片后,我这边问题还是无法解决。查了很久的 bug,发现同事是把 .jpg 的后缀改成了 .png 然后给我的 :(
所以呢,这次还是继续聊下编码。之前有聊过一次,也比较深入了,详见 计算机 Unicode 编码终结,那片对文本编码做了详解。这次我要聊聊二进制文件和文本文件的存储差异。二进制文件有常见的图片文件和应用程序的可执行文件。

文本文件存储简要说明

之前文章 中深入说明过编码,当时虽然没有对文本文件存储做解释,但理解起来应该没有问题的。
简单来说,就是文本文件存储目前大多用的 utf8,用 utf16/gtk 也都没有问题。只要用同一种编码进行读写就不会乱码。
当时说内存使用的是 utf16 编码,说的有些简单了。对于字符串的内存存储,大多使用的 utf16,主要鉴于速度的考虑,比如对内容进行增删改查会更快(这里说的是字符串的内存存储。像 char/int/long 这些,都会按照对应的字节大小进行内存存储)。
但是这里只能说大多是使用的 utf16,其实具体使用 utf16,还是 utf8,是依靠编程语言本身决定的,而不是操作系统决定的。这里我们假设是 utf16,具体的在 可执行文件如何存储 章节里面再做说明。
所以我们用 IDE 打开文件写代码的时候,流程是这样的:

  1. 原始代码文件 person.c 通过 utf8 存储在磁盘上。
  2. IDE 通过系统调用读取原始代码文件,文件内容 (1、0 这些二进制数)被读入文件缓冲区,而后 IDE 从文件缓冲区拿到这些 utf8 编码的文本数据。
  3. IDE 通过 utf8 对文本内容进行解码,拿到了 struct Person {int age = 18;} 这些字符串。IDE 拿到这些字符串是没有用的,它也需要把这些字符串表示出来才有意义,它也需要有一个地方保存这些字符串,然后才能系统调用到输出,给到显示器。
  4. 于是 IDE 内部也会有一行代码 string codes = readFromFile(“./person.c”) ,这样就把 person.c 的代码内容读取到了 codes 变量中进行后续的显示器输出。
  5. 这里就有一个重点:codes 是存储在内存里面的,可能是栈或者堆,但一定是在内存里面的。这个 codes 变量所对应的字符串内存内容,是 utf16 编码(如上所说,大部分是 utf16,但也可以是其他编码)。
  6. 然后开发同学把文件内容修改成了 struct Person {int age = 28;} ,年龄改大了十岁。这个时候改动的是内存数据,而不是磁盘数据。所以蓝屏才那么可怕。
  7. 开发同学按 ctrl/command + s 的时候,打算保存文件。这个时候,IDE 将文本内容转成 utf8 编码,存储到文件缓冲区,最后保存到磁盘。

上面就是大概流程了。核心点是文件的磁盘存储是 utf8,内存存储是 utf16。而这个转码操作上面说是 IDE,也不全是。
IDE 读取和写入文件的时候会调用更下层的接口如:readLineFromFile(‘文件句柄’,’utf8’);appendWordToFile(‘文件句柄’,’utf8’,’追加文本内容’); 。所以是下层接口干了这个 utf8 -> utf16 和 utf16 -> utf8 的事情,IDE 只要定义 codes 这个变量就好了,顺带指定一下编码。下层接口一般是集成开发 SDK 或者对应语言 SDK 所完成的。
通过 readLineFromFile/appendWordToFile 这两个接口,也可以发现其实磁盘文件的编码也可以是其他类型如 utf16/gtk,只要业务层通过同一个编码进行磁盘文本的读写,就不会乱码。

可执行文件如何存储

可执行文件的存储是有别于文本文件的(这里可执行文件是静态语言编译后的文件如 C,非 js 等解释执行的文件)。
文本文件存储的重点在于编码,如一个字 A 通过 utf8 和 utf16 都可以编码存储,用 utf8 就是 0100 0001 ,用 utf16 就是 0000 0000 0100 0001 。编码不同字节数不同,但是写入磁盘的都是 1、0 这些二进制数。
可执行文件的存储,和文本文件其实完全一样,也都是 1、0 这些二进制数,但重点就在于编码上。即存入的也是 1 和 0,但存储的内容不一样。我们来看看如何理解可执行文件的编码。
假设有常量 const short stopTimeForHour = 22; 。我们定义了一个停止的小时刻度是晚上 22 点。在可执行中的存储,肯定不会存储这么一大坨。
只需要在常量区开辟 2 个字节大小的空间存储 22 即可,即 0000 0000 0001 0110
如果需要读取 stopTimeForHour 的值如 printf(“%hd”,stopTimeForHour)
首先 22 已经存储在可执行文件的常量区,stopTimeForHour 只是助记符号供编译器进行指针引用,
其次 short 的大小是通过汇编指令进行读取的。两个字节用 mov 就可以搞定 (movb 和 movl 分别对应一个和四个字节),读取汇编就像这样:mov 0x729785409 %esi ,
然后,mov 是指令,esi 是寄存器,都可以通过指令和寄存器对应的译码表示,如 34 0x729785409 27
最后,编译器会做优化,这里的 22 可能会在编译的时候直接被使用而不是从常量区二次读取,就像这样:35 0x16 27(22 的十六进制是 0x16,1 个字节就可以表达,也算做编译器优化。只有一个字节,可以使用 movb,假设译码是 35),
所以最后的二进制存储,或许是这样的:0010 0011(35, 假设代表 movb) 0001 0110(22) 0001 1011(27, 假设代表 esi 寄存器)

从上面可以看到,可执行文件的编码和文本 Unicode 有一些差异。数字 22 如果用文本 Unicode 存储至少也需要 2 个字节,即 0011 0010(2) 0011 0010(2)。(2 的 ASCII 是 50)。
但是在可执行文件里面,只在单独存储 22 的常量区,开辟了 1-2 个字节大小,即 0001 0110 。调用它的地方也通过指令换算的形式,和代码的文本形式差异非常大。
上面我们用 short 做特例说明了非字符串类型的存储,其中 char、int、long、float 这些都和 short 是一样的表现。

如果我们定义了字符串如 string name = “MYNAME”; ,那么 string 和 name 都可以不放入文件,但是 MYNAME 是一定要放进去的,它会怎么存储?如果这个 name 是英文的表达的不够纯粹,那么这个呢?string name = “韩梅梅”; 。我们可以发现,“韩梅梅”是中文,肯定不能通过 acsii 存储。而支持中文的,我们知道的有 gtk/utf8/utf16 这些。
进一步,只要是文字,就一定需要一个编码,英文字符串还不明显,中文就可以直观的看到,如果我们需要把“韩梅梅”这个 name 存入二进制文件,那么肯定是需要编码的。我们可以通过反证法来验证,如果我们存入的字符串不用编码,那么:

  1. 我们如何把“韩梅梅”这三个汉字做存储?毕竟后面二进制文件运行的过程中还是需要读取的。
  2. 如果像 short 那样二进制存进去,先得有二进制序列我们才能存。数字 22 的二进制序列可以是 1 个字节 0001 0110 ,那么”韩梅梅“在没有编码的前提下,二进制序列是什么?

通过这样的反证,可以初步得出一个结论,那就是“韩梅梅”一定是通过一个编码,转成了特定的二进制序列,然后才能存储到二进制文件中的。

这里可以先说一个结论,那就是二进制文件中的字符串编码的格式,不同语言的实现还不太一样。比如 java、phthon 这些,都是通过 utf16 实现的。而 C,本身没有字符串的概念,字符串就是一个个 8 位组合数据然后尾部加上‘\n’符代表结束这样拼接起来的,而 8 位的内容可以依靠编码定义,比如 utf8 可以做到 1110xxxx 10xxxxxx 10xxxxxx 这样的 3 字节编码。C 可以通过 utf8/utf16 做存储。
上面的字符串定义 string name = “韩梅梅”; 对于 name 这个内存数据,又是使用什么编码呢?还是前面的反证法,它一定有一个编码。name 内存是通过 mov 指令读取代码区的“韩梅梅”的二进制内容然后存储到内存中的。
这里再说一个结论,name 字符串内存数据使用的编码和二进制字符串使用的编码,是一样的。这样就可以做到:读取二进制数据的时候,直接将二进制的字节读取后放到对应的内存区域就可以了,非常快。快到不需要解码,单纯的读取&复制。而这,也是可执行文件所需要的

从上面 short 和 string 两个事例,可以发现二进制文件的存储编码,是和二进制文件运行起来后的内存编码一致的。比如 short 在内存里面是 1 个字节,那么二进制文件里面存储的也是 1 个字节。string 在内存里面是 utf8,那么二进制文件里面存储的也是 utf8。
对于 short 我们上面分析的没有犹豫,只是 string 我们说到不同语言的实现不太一样。其实准确来说,语言本身不在乎编码是什么,语言本身不会解析编码。
不管对于 Java 还是 C,只要 string 被存储到二进制文件然后被 copy 到内存区域就可以了。同一个汉字“一”,我们在 Java 下内存存储大小是 2 个字节,在 C 里面却是 4 个字节(一的 utf8 是 3 字节,尾部还需要一个‘\n’结束符)。但,没有关系。因为语言本身对这些并不感冒,我们开发人员只在必要的时候取一下大小或者做增删改查即可,不同的编码并不会影响开发人员的这些操作。当然为了快,其他更多的语言都使用 utf16 这样子。

所以可执行文件的存储编码也叫做值编码。即这个数据用什么编码,是和它的类型有关。它不是普通的文本,而是有特定类型,比如 char 或者 int 或者 string。
如果是 char 那就是 1 字节,如果是 short 那大概率是 2 字节
如果是 string 字符串,那么和文本编码一样,还是得有一个编码才行。这里不同的语言实现不一样。java 和 python 等一大众语言都使用 utf16 编码,因为 utf16 在增删改查的时候足够快。但 C 语言的实现其实是和代码源文件及操作系统有关。在 mac/linux 系统下,是和代码源文件保持一致,即大多情况下是 utf8,前些年 gtk 盛行的时候也会是 gtk。在 window 下和本地环境有关,也有可能是 gtk 或者 utf8。

还有一点说明,拿 ELF 可执行文件的文件头魔数来举例,在 ELF 文件头中,Magic 占据 4 个字节大小,表示为:7f 45 4c 46 。这是十六进制表示,通过 ASCII 换算一下就是 DEL(一个删除符号) E L F 。每一个 ELF 的可执行文件的文件头中都有这四个字节,应用程序的装载器也会先读取这四个字节验证是否为“ELF”字符,来判断文件是否合法。
所以可执行文件的内容,更多是通过字节的读取来完成的。比如程序运行起来后,虚拟内存到物理内存换页的时候,指令的取码、译码全都是通过多字节操作来完成的。即特定的字节就有特定的含义。
而文本文件更多的不是字节的概念,把文本内容加载到文件缓冲区后就可以不停的解码了,只要编码和解码是同一样,就可以不停的解下去。其实这也和下面的“图片编码”有极大的相似度,后文再说。

再谈内存编码到底是不是 utf16 以及其意义

上面说到,内存编码对于不同类型,有不同的编码表现。其中 char、int 这些都是纯粹的字节型存储,而 string 则有一些差异,但更多的语言都是使用的 utf16。
可能有人会有疑问,为什么语言对不同的 string 编码不感冒?如果要调用 printf 函数做显示器上的输出,也和 string 编码无关吗?

其实,还是有一些关系的,但不强。调用 printf 在显示器上做输出,本质上和写入文件是一样的,毕竟显示器也是输出,和文本文件性质一样。
如果我们把程序运行中的数字 1 写入文件,那么文件里面也是 1。其实这是有些不合理的,因为程序运行中的数字 1,二进制编码是 0000 0001 ,而存储文件里面的 1,二进制编码是 0101 0000(1 的 aceii 编码是 50) 。那么内存里面的 1,是如何变成字符串的呢?肯定要转码!只要想通过字符串写入文件,就一定需要转码!所以底层其实帮我们做了这些事情,我们不用去操心。我们只需要在调用 writeIntToFile(‘文件句柄’,’utf8’,1) 这个接口的时候,传一下编码类型,内部会把 int 1 变成 string 1 然后按照编码写入文件。像 writeIntToFile 的接口其实也没有,更多的时候是我们将 int 自己转成 string 或者 data,然后自己写入文件,但编码这个参数还是必不可少的。
显示器和文件其实是一样的,没有太多差别。要说有差别,就是显示器显示的内容,就是根据码表转换后的内容我们前面说到的 Unicode 其实就是一张大码表,GTK 也是。只要我们把字符串编码后的二进制+编码给到系统调用,系统调用就会帮我们找到对应的码表然后找到对应的码位,然后展示对应的符号。
举个例子:对于汉字“一”,Unicode 码位是“4E00”。
我们在打印到显示器上的时候,可以提供这样的信息:

  1. utf8 + “1110 0100 1011 1000 1000 0000”
  2. utf16 + “0100 1110 0000 0000”

如果把 utf8 的明文 “1110 0100 1011 1000 1000 0000” 按照 utf8 的解码格式 1110xxxx 10xxxxxx 10xxxxxx 进行解码,会发现解码后,就是 “0100 1110 0000 0000”, 十六进制就是 “4E00”。
而 utf16 的明文本身就是 “0100 1110 0000 0000”, 十六进制就是 “4E00”。
所以他们都代表 Unicode 里面码位是 “4E00” 这个位置的符号,即汉字“一”。
所以你看, 不管内存编码使用哪个,没有什么影响
而我们打印 int 类型 printf(“%d”,1); ,其实内部就是把 数字 1 转成字符串 1,然后默认使用 utf8 编码,就打印出来了。没啥区别,只是底层帮我们做了很多事情而已。

图片文件如何存储

有些累,不具体写了。和二进制一样,每种图片类型都有自己特定的二进制格式,比如文件头可以标记当前是什么格式的图片,图片不同的帧和信息可以通过不同的段进行存储。
还有那种渐进式图片,是通过将整个图片隔固定间距取一个值,这样就生成了整张图片的索引帧。间距越大,索引越小,缩略图也就越模糊。用不同的间距采样,就可以生成从模糊到逐渐清晰的索引帧。因为这样的图片解码和以往的图片解码不一样,所以需要特别的解码支持。直接在浏览器上面放置渐进式图片是不能直接解码索引帧的。