导语
说到字符编码问题,相信写过 HTML 的小伙伴都接触过<meta charset="UTF-8" />
吧,这就是一个字符编码的声明。
还有一种常见的场景是,为了实现下载一个图片文件,将它的 dataURL
转blob
格式使用了unit8Array[n] = bStr.charCodeAt(n)
,(charCodeAt 方法返回0
到65535
之间的整数,表示给定索引处的字符UTF-16
代码单元) ,unit8Array
存储了base64 字符的 Unicode 编码,构建 ArrayBufferView
,然后传递给 Blob()
构造函数存储为二进制形式的数据。`
web 开发中编码场景很多,因此我们很有必要从源头上理解编码的原理,编码是为了统一数据的表示方式,我们先看看有哪些数据的表示法,换句话说这些数据是怎样存储到计算机上的。
目标读者
- who:对于计算机编码、二进制存储知识薄弱者。
- when:当他读完本文,理清楚字符与二进制字节、编码的关系,对于 编程语言提供的二进制操作方法不再感觉艰涩难懂。
数据表示法
发明电子计算机的最初目的是就是用来进行复杂的数学计算的。没有数据,就是无米之炊。计算机执行的每个任务都是在以某种方式管理数据的。因此,用适当的方式表示和组织数据是非常重要的。
首先,我们来区别术语数据
和信息
。
数据(data):基本值或事实。
信息(information):用有小的方式组织或处理过的数据。
计算机可以处理各种各样的信息,它可以存储、表示和帮助我们修改各种类型的数据,包括:
- 数字
- 文本
- 音频
- 图像和图形
- 视频
这些数据最终都被存储为二进制数字。每个文档、图像和广播讲话都将被表示为由0
和1
组成的字符串。
二进制表示
我们知道计算机是以二进制方式运作的,如数字39(十进制数)➡ ️编译 ➡ 00100111(8位二进制数)
,计算机处理信息的最小单位是——位
,如二进制数00100111
的第一位0
。一串二进制数的位数一般是8位、16位、32位......也就是8的倍数,这是因为计算机所处理的信息的基本单位是8位二进制数
。
计算机科学约定,8位二进制被称为一个字节
,字节是最基本的信息计量单位,内存和磁盘都使用字节单位来存储和读写数据,所有的信息最终都被存储为二进制字节,也就是二进制数字。字节即Byte
,一个字节代表8个比特(Bit
),字节通常缩写为B
,比特通常缩写为b
。字节的大小是8Bit
,即字节的范围是0000 0000 - 1111 1111
,对于无符号型,它表示的十进制范围是[0,255]
,对于有符号型,高一位表示符号位,它表示的十进制范围是[-128,127]
。
一般说来,n
位二进制数字能表示 2^n
种状态,因为 n
位数字可以构成 2^n
种0
和 1
的组合。
知道了二进制的概念后,我们来看看计算机是如何存储数据。
数字数据在计算机是如何存储的
日常生活中,一些比较传统的药店可能用的是传统的算盘进行数学计算,现在则是使用计算器和电脑居多。
数值是计算机系统最常用的数据类型。因为二进制也是一种记数系统,不需要把数字数据进行映射到二进制代码(字符需要映射),直接通过进制的转换即可。
无符号数字
一开始,数字在自然界中抽象出来的时候,一棵树,两只猪,是没有正数和负数的概念的。计算机保存最原始的数字,也是没有正和负的数字,叫无符号数字。如果我们在内存分配4位(bit)
去存放无符号的数字,是下面这样的。
十进制 | 二进制 |
---|---|
0 | 0000 |
1 | 0001 |
原码
后来,生活中为了表示“欠别人钱”这个概念,就从无符号数中,划分出了“正数”和“负数”。为了表示正与负,人们发明了“原码”,直接把左边的第一位腾出位置,存放符号。正用0来表示,负用1来表示。
正数 | |
---|---|
0 | 0000 |
1 | 0001 |
负数 | |
---|---|
-0 | 1000 |
-1 | 1001 |
但是使用“原码”的方式,方便了看的人类,却苦了计算机。我们希望(+1)
和 (-1)
相加是0
,但计算机只能算出 0001 + 1001 = 1010
(1010 取反0101 为 -2),显示不等于0
。
反码
为了解决“正负相加等于0”的问题,在“原码”的基础上,人们发明了“反码”。“反码”表示方式是用来处理负数的,符号位置不变,其余位置相反。
反码 | 负数 |
---|---|
-0 | 1111 |
-1 | 1110 |
补码
当“原码”变成“反码”后,就解决了“正负相加等于0”的问题,这时 0001 + 1110 = 1111,1111 象征 -0;此时,还有一个问题,就是有两个零存储,+0 和 -0;我们希望只有一个 0,所以发明了“补码”,同样是针对“负数”做处理的。“补码”的意思是,从原来“反码”的基础上补充一个新的代码(+1)。
补码 | 负数 |
---|---|
-0 | 0000 |
-1 | 1111 |
我们要处理"反码"中的"-0",当1111
再补上一个1
之后,变成了10000
,丢掉最高位就是0000
,刚好和左边正数的0
,完美融合掉了,这样就解决了+0
和-0
同时存在的问题。
计算机保存的是补码,当要取出数据的时候,就将补码逆运算,即可求出原码,再将原码转换一下就可以拿到真实的数据。
根据上面的描述,存储1
与 -1
为例,将十进制转成二进制。
1(10) = 0000 0001(2)(原码) = 0000 0001(反码)= 0000 0001(补码)
-1(-1) = 1000 0001(2)(原码)= 1111 1110(反码)= 1111 1111(补码)
下面使用 Java 语言来演示过程:
public class Test {
public static void main(String[] args) {
int a1 = 1; // 32 位
int a2 = -1;
// 转成二进制表示
String b1 = Integer.toBinaryString(a1);
String b2 = Integer.toBinaryString(a2);
// 转成无无符号表示
String b3 = Integer.toUnsignedString(a1);
String b4 = Integer.toUnsignedString(a2);
System.out.println("1储存到计算机后为:" + b1);
System.out.println("-1储存到计算机后为:" + b2);
System.out.println("取出储存的1 以无符号表示:" + b3);
System.out.println("取出储存的-1以无符号表示:" + b4);
}
}
// 运行结果
// 1储存到计算机后为:1
// -1储存到计算机后为:11111111111111111111111111111111
// 取出储存的1 以无符号表示:1
// 取出储存的-1以无符号表示:4294967295
上述代码可以直接在菜鸟在线编辑器运行。
文本(字符)数据是如何存储的
我们要知道,计算机上显示一个数并不是直接从他的数值来的,而是要将这个数值转换为对应的字符再显示的。
可以使用下面代码进行验证:
// Java 语言中,字符型变量是char, 整数型变量是 int,这里我们声明两个变量
int a=3; // 32位、有符号的以二进制补码表示的整数
char b=51; // char 类型是16位 unicode 字符,51 代表 '3' 字符的二进制代码。
// 然后将这两个变量的值打印到屏幕上
System.out.println(a); // 3
System.out.println(b); // 3
System.out.println((byte) '3'); // 输出 51
那么,这些字符是怎样存储到计算机中的呢?由上文数字的存储原理,可知计算机存储数字只需要转二机制存储即可。因此,人们便想到了将字符映射为数字再转为二进制存储即可,字符与数字的映射就被称作字符集
。那么关于,计算机中的字符集都有哪些呢?
关于计算机的字符与编码
最常见的字符集就是 ASCII 字符集。
字符集
ASCII 字符集将 128
个字符(分为95个可打印字符和33个控制字符)映射到了 0~127
这 128个整数上面。这其中就包括了英文26个字母的大小写和各种键盘上能够看见的符号(+-*/,.
),以及许多诸如制表符 \t
、换行符\n
等特殊字符。
一旦映射成为了整数,存储就很方便了,因为整数可以直接转二进制存储。举个例子,字符'p'
在 ASCII 码表中对应的整数是112
,它表示为二进制是0b1110000
,共7个比特。( PS:如果不想手动计算的话,可以用 在线进制转换工具,常见的换的进制都支持了,缺点是不能设置转换的字节数。)
在 java 中可以很方便通过 byte
或 int
强制转换获取字符 p 的 ASCII 码。
System.out.println((byte) 'p'); // 112
有了 ASCII 字符集,我们就可以存储和传输字符了。注意的是,将字符映射为整数和将整数存储为二进制是两个过程,我们将前者称为字符集映射,而后者称为编码。
ASCII 码有两个不足之处:
- 计算机通常以字节为基本存储单位,即8个比特,而 ASCII 码只使用了 7个比特,有了1个比特的浪费;
- ASCII 只能表示
128
个字符,世界上可显示的字符有成千上万个,仅仅中文简体字就有两千多个,这些字符如何存储?
针对上述两点,人们做出了一些改进。例如,利用上 ASCII 码的第8个比特,这样又可以多收入 128 个字符,但是还是远远不够,单单是汉字就远远超出这个数字。(PS:清朝《康熙字典》收字47,035个)。
中文字符集
对于字符集严重不足的问题,人们提出了多字节字符集来满足需求。以汉子为例,我国最早的汉子字符集是 GB2312
。里面包含了 99.7%以上的常用中文字符,并且包含了拉丁字母、希腊字母、平假名片假名等等其他字符。GB2312 以区位码(也是一个整数)来表示每个字符,并以两个字节来存储。举个例子,字符“我”
的区位码是4650
,其二进制存储内容是 0b1100111011010010
(PS:0b 表示二进制的前缀,十六进制 0xCED2),表示为十六进制为 。在这里我们发现,数字 4650
的二进制表示为 0b1001000101010
,这里用了13个比特,不到两个字节),与上面 GB2312 存储的两个字节并不一致。这也印证了前面我们所说的映射与存储是两个过程。
GB2312 在设计时兼容了 ASCII 码,将所有中文字符都放在了 128 的后面。所以,利用 GB2312 映射出来的拉丁字母和利用 ASCII 码映射来的拉丁字母是完全一致的。
GB2312 虽然涵盖了足够多的字符,但仍旧有一些字符没有收录进来。因而后续有了许多扩展,比较流行的是 GBK和目前最新的 GB18030.其中 GB18030 是我国大陆强制使用的最新字符集,而我国台湾地区则使用的是 BIG5 字符集,其主要收录了繁体字符。
国际字符集 Unicode
事实上,世界各地都在提出适用于自身的字符集,而这些字符集之间几乎无法兼容(大家都兼容了ASCII)。这就导致了一个巨大的兼容性问题。同一个二进制数字可以被解释成不同的符号。因此,要想打开一个文本文件,就必须知道它的编码方式,否则用错误的编码方式解读,就会出现乱码。
为什么电子邮件常常出现乱码?就是因为发信人呢和收信人使用的编码方式不一样,也就是从整数映射回字符的编码不同导致。
可以想象,如果有一种编码,将世界上所有的符号都纳入其中。每一个符号都给予一个独一无二的编码,那么乱码问题就会消失。因此,一些标准化组织便开始致力于提出一个全球统一的字符集,这就是 Unicode。Unicode 统一了世界各地不同字符的映射方式,拉丁字母、汉字、日文、法文、韩文等等都可以在 Unicode 映射表中找到唯一的整数与之对应。具体的符号对应表,可以查询unicode.org,或者专门的汉字对应表。
Unicode 规范规定,使用'U+'
前缀加上字符对应的16进制数值整数表示一个字符。比例 U+0041
代表大写字母 A
。只要大家都遵循 Unicode 标准,那么所有字符都可以被正确得显示出来。而整个字符 Unicode 的字符集,需要 U+000000
到 U+10FFFF
的存储空间。
UTF
复习下,字符集的概念是“收集一系列字符,然后规定每个字符映射到某个整数上。”而编码方式,就规定这些映射字符的整数,应该如何在计算机中以二进制的形式存储或传输。
Unicode 只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。比如,汉字严
的 Unicode 是十六进制数4E25
,转换成二进制数足足有15位(100111000100101)
,也就是说,这个符号的表示至少需要2个字节。表示其他更大的符号,可能需要3个字节或者4个字节,甚至更多。
Unicode 是怎么使用二进制的形式来存储和传输的呢?计算机怎么区别 Unicode 和 ASCII ?如何知道三个字节表示一个符号,而不是分别表示三个?
这就是 Unicode 编码的问题,也称为(Unicode Transform Format,简称 UTF。有三种形式,UTF-8(变长字节)、UTF-16(字符用两个字节或四个字节)、UTF-32(字符用四个字节表示)。
名称 | UTF-8 | UTF-16 | UTF-16BE | UTF-16LE | UTF-32 | UTF-32BE | UTF-32LE |
---|---|---|---|---|---|---|---|
最小值 | 0000 | 0000 | 0000 | 0000 | 0000 | 0000 | 0000 |
最大值 | 10FFFF | 10FFFF | 10FFFF | 10FFFF | 10FFFF | 10FFFF | 10FFFF |
码元大小 | 8 bits | 16 bits | 16 bits | 16 bits | 32 bits | 32 bits | 32 bits |
字节顺序 | N/A | big-endian(0xFE 0xFF) | little-endian(0xFF 0xFE) | Big Endian(0x00 0x00 0xFE 0xFF) | little-endian(0xFF 0xFE 0x00 0x00 ) | ||
每个字符最少需要的字节 | 1 | 2 | 2 | 2 | 4 | 4 | 4 |
每个字符最多需要的字节 | 4 | 4 | 4 | 4 | 4 | 4 | 4 |
上表中,码元(Code Unit)
代表编码方式使用的最小字节组合,每个字符最少需要的字节。UTF-8 的码元是一个字节,UTF-16的码元则是2个字节,而 UTF-32 的码元则是4个字节。码元大于一个字节则在存储和传输时就要考虑字节(顺)序的问题,字节序分为两种,一种是大端方式(Big Endian),另外一种是小端方式(Little Endian)。大端方式规定,一个码元内的字节按正常方式存储,而小端方式规定,一个码元内的字节序按反过来的顺序存储。
为了表示字节序,Unicode 规定 UTF-16 和 UTF-32 需要使用 BOM(nbyte order mark)
描述字节序。BOM
是一段添加在数据流开头的字符串。表中的BOM
表示字节顺序由字节顺序标记(Unicode 使用 FEFF
)确定,如果出现在数据流的开头,则为big-endian。比如,汉字严
的 Unicode 编码为 4E25
,一个字节是4E
,另一个字节是25
。存储的时候,4E在前,25在后,这就是 Big endian 方式,整体表示为FE FF 4E 25
;25在前,4E在后,这是 Little endian 方式,整体表示为FF FE 25 4E
。
UTF-8 编码规则
UTF 中使用最广的便是 UTF-8的编码方式了。因为它是一种字节变长的编码方式,可以使用 1~4个字节表示一个符号,根据不同的符号而变化字节长度。这样就可以有的放矢地应用如英文字母只需要一个字节、汉子可能要多个字节的问题了,有效地利用存储而不会导致浪费。
UTF-8 的编码规则很有两条:
- 对于单字节的符号,字节的第一位设为
0
,后面7位为这个符号的 Unicode 码。因此对于英语字母, UTF-8 编码和 ASCII 码是相同的。 - 对于
n
字节的符号(n > 1
),第一个字节前n
位都设为1
,第n+1
位设为0
,后面的字节的前两位一律设为10
。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。
下表总结了编码规则,字母 x
表示可用编码的位。
Unicode 符号范围 (十六进制) | UTF-8 编码方式(二进制) | 字节数 |
---|---|---|
0000 0000-0000 007F | 0xxxxxxx | 1 |
0000 0080-0000 07FF | 110xxxxx 10xxxxxx | 2 |
0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 3 |
0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 4 |
以汉字严为例, Unicode 是U+4E25
(100111000100101
),根据上表,可以发现4E25
处在第三行的范围内(0000 0800 - 0000 FFFF
),因此严的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx
。然后,从严的最后一个二进制位开始,依次从后向前填入格式中的x
,多出的位补0
。这样就得到了,严的 UTF-8 二进制编码是11100100 10111000 10100101
,转换成十六进制就是E4B8A5
。
至此,我们已经了解字符的存储了。
数字数据不需要映射,字符文件可以通过字符集对应的数字进行存储,而对于图像、Microsoft 文档、音频和视频等二进制文件计算机是如何存储的呢?
其他数据表示如音频、视频、文档的存储
图像、Microsoft 文档、音频和视频都等不是通过字符集或数字做中间层转换的,它们是怎么存储的呢?例如音频、图像、视频等,首先要理解模拟和数字数据的概念。
模拟数据和数字数据
自然界的大部分都是连续的和无限的。实数直线图像是连续的,直线中的数值可以是无限大或无限小的。另一方面,计算机则是有限的,计算机内存和其他硬件设备用来存储和操作一定量的数据的空间只有那么多,我们的目标是使表示的世界满足我们的计算需要和视觉及听觉功能,像一个动画短片,它便是通过一张张图像以一定的速度移动的来欺骗的我们的眼睛的。
而表示数据的方法有两种:
模拟数据(analog data):用连续形式表示的信息,如在一定区间内可以任意取值的数据叫连续数据,其数值是连续不断的,相邻两个数值可作无限分割,即可取无限个数值,如身高、体重。
数字数据(digital data):用离散形式表示信息,数值只能用自然数或整数单位计算。例如,企业个数,职工人数,设备台数。
上图中是《火影忍者》的鸣人的个子发育,三次的身高都可以用具体的刻度标注出来,但是一个人长高的时候,它实际上是连续的方式长高的,而不是跳跃的长高,这就是连续数据。
模拟数据完全对应于我们周围连续无限的世界。因此,计算机不能很好地处理模拟数据。我们需要数字化数据,把信息分割成片段,单独表示每个片段。
数字化(digitize):把信息分割成离散的片段。
比如音频,当一系列空气压缩震动我们的耳膜时,给我们的大脑发送了一个信号,我们就感觉了声音。因此,实际上,声音实际上是由与我们的耳膜交互的声波定义。要在计算机上表示音频信息,就必须要数字化声波,把它分割成离散的、便于管理的片段。
而数字化一幅图像,则是把它表示为一套独立的点,这些点称为像素,代表图像的元素。每个像素由一种颜色构成,颜色由三个值表示,每个值说明了红色、蓝色或绿色的份额,表示一幅图像使用的像素个数称为分辨率。现在的计算机,一般使用 32 位来表示颜色,32平分给 ARGB。A 代表透明度。
如a
是一个2 * 2
的小图像,总共有4
个像素,每个像素呢,由三种颜色构成(b)
,而每种颜色呢,由8位
构成(c)
,然后根据小图像的颜色,我们把颜色值写出来,为了方便书写,用16进制表示,如d图所示,四组数字,分别对应着小图像的四个像素,这样计算机就可以方便地进行存储了。
这种图像表示法是位图法,除此之外还有一种矢量表示法,它不把颜色赋予像素,而是用线段或几何形状来描述。现在最流行的 web 矢量格式莫过于 SVG (Scalable Vector Graphics,可缩放矢量图形),它是采用纯文本表示的。
视频则是被分割成了一系列静态图像放成一个序列,然后组合声音按照一定的频率进行刷新(比如,一秒刷新24副图像)它利用了人的视觉暂留效应,让人感觉画面在动,世界上它仍然是由静态图像拼接而成的。
无论是音频、图像还是视频等文件,它们都有业界编码标准的二进制序列信息,记录各自需要的信息。在计算机进行存储的过程中,是对它们进行编码成二进制字符串进行存储的,如一个像素的RGB 颜色(255, 255, 255)。
接下来来看看如何使用编程语言读取这些文件呢?它跟读取文本文件有什么区别吗?读取出来的是字节流还是字符流的?
使用编程语言读写文件
通常,我们理解文件的读写操作在计算机科学中便是 IO(输入/输出):
输入:允许程序读取外部数据(包括来自磁盘、光盘等存储设备的数据)。
输出:允许程序记录运行状态,将程序数据输出到磁盘、光盘等存储设备中。
在高级语言中,举个例子, JavaScript 中的提供 File
对象存储文件,并提供 FileReader API 来读取文件,它提供了 readAsArrayBuffer(Blob | File)
此方法会按字节读取文件内容,并存储为ArrayBuffer对象里,包括文本文件以及非文本文件(视频、音频、office 文件)。而如果是文本文件,则可以使用readAsText(Blob|File, opt_encoding)
:返回文本字符串,默认是 UTF-8
格式。
<body>
<input type="file" id="inp" />
<script>
const inp = document.getElementById('inp');
const reader = new FileReader();
reader.onload = function(e) {
console.log(reader.result);
const bytes = new Uint8Array(reader.result); // 每位都是8位字节转换后的十进制数字
const length = bytes.byteLength;
for (var i = 0; i < length; i++) {
// 转为二进制
binary += parseInt(bytes[i]).toString(2); // 这里得出的严字,以前面根据 严的 unicode 编码得出的二进制是一致的,严的 UTF-8 二进制编码是11100100 10111000 10100101
}
}
inp.onchange = function(e) {
const file = e.target.files[0];
// reader.readAsText(file, 'utf-8');
reader.readAsBinaryString(file);
};
</script>
</body>
上述的 reader.readAsText
即可以以字符的形式读取文本文件,例如一个 txt 只要编码是 utf-8 则可以正常打印出来。它也可以读取非文本文件,只不过这时候打印出来的是乱码,原因是它对非文本文件读取的是二进制字节以字符形式显示出来了。
而readAsArrayBuffer
方法,则是无论是文本文件还是非文本文件,统统以二进制字符串(10 序列) 的形式读取出来,并转为255之内的数字。
关于文件的读取处理就说这里了,另外在 Java 中读写文件时,还有字节流
与字符流
的概念。这个将会在另外一篇文章进行讲述。
小结
本文从字符、编码及二进制三个方面针对数字、文本、音频、视频等文件在计算中的处理作了理论讲述,也使用 java
和 javaScript
代码进行分析。希望读完本篇后,你能够对编码与二进制、字符的关系有了清楚的认识。
这是《web 开发中字符、编码与二进制(一)》第一部分,第二部分将会具体讲述浏览器是如何解析 HTML、JS 的,网络的请求与响应涉及到编码问题,如我们在进行文件下载时,后端返回的文件中在 Chrome Network 查看时便是一堆乱码。
(本文完,首发于 我的小站 源来如此)
参考资料
- 字符编码笔记:ASCII,Unicode 和 UTF-8 阮一峰老师的笔记,通俗易懂。
- UTF-8, UTF-16, UTF-32 & BOM-unicode 官网 Q&A。
- Unicode的设计和原理
- 《计算机科学概论》
- 怎样暴力读取二进制数据文件
- JavaScript 读写二进制数据--对 ArraryBuffer 有比较清楚的描述。