计算机常用编码的二三事

ASCII

用1个字节表示所有字符,共可以表示2^8=256 个字符。用十六进制表示时只需2位即可表示所有ASCII字符。ASCII 表参见此处

ANSI

在早期,以英语为主的国家使用 ASCII 是完全够用的,但随着计算机的普及,ASCII 对非英语国家就显得捉襟见肘了,比如中国的汉字就有数万个,而 ASCII 最多只能表示 256 个,因此其他国家对ASCII编码进行扩展,用于显示本国的语言,用2个字节来表示,共可以表示2^16=65536个字符。

  • ANSI在简体中文代表GB2312,繁体中文代表BIG5,日文代表JIS
  • GB2312中共收录6763个汉字。中文通常有拼音和笔画两种排序方式,GB2312 中,3755 个一级中文汉字是按照拼音序进行编码的,而 3008 个二级汉字则是按部首笔画排列,因此并不能根据字符编码进行拼音排序,很多资料都说 GB2312(GBK) 是按照拼音排序的,实际上是不准确的,比如“赵钱孙李佘”排序的结果是“李钱孙赵佘”,显然不是我们想要的1
  • GBK编码:对GB2312进行扩充,收录了一些偏僻字、古汉字等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
$name_list = ['赵','钱','孙','李','佘'];

// 将字符转换为 GBK 编码
foreach ($name_list as $key => $name){
if ('GBK' !== 'mb_detect_encoding($name)'){
$outCharset[$key] = iconv($charset, 'GBK', $name);
}
}

// 按拼音排序
sort($outCharset);

// 还原 UTF-8 编码
foreach ($outCharset as $key => $name){
$displayCharset[$key] = iconv('GBK', 'UTF-8', $name);
}

print_r($displayCharset);

在 PHP 中,也可以通过 (new \Collator('zh-CN'))->asort($name_list); 实现按拼音排序2,前提是你可以正常安装 intl,我通过sudo pecl install intl折腾了半天,总是报错ERROR: /private/tmp/pear/temp/intl/configure --with-php-config=/usr/local/opt/php/bin/php-config --with-icu-dir=DEFAULT' failed,我用 pecl 好像就从来没有安装成功过东西:(

Unicode 字符集

由于各国都制定了自己的编码,导致了群雄割据的局面,很不利于传播与使用,就在此时,Unicode 站了出来,计划将世界上所有字符统一编码,用4个字节表示一个字符,如汉字的“好”Unicode 编码为\u597d,英文的字母“H”编码为\u0048

这样,所有字符都可以以统一的标准进行管理和表示了,因此迅速得到了Adobe、Apple、HP、IBM、Microsoft 等巨头的支持,并风靡全球。目前最新的版本为 2019 年 5 月公布的 12.1.0,已经收录超过 13 万个字符。

我们可以在字体编辑用中日韩汉字 Unicode 编码表查看到 Unicode 的 16 进制表示。

UTF-8 编码

你以为了有了Unicode就世界和平了?Too young, too simple!

Unicode 虽然结束了各国编码混战的局面,但一个英文字符也要为了符合标准而强行用两个字节来表示,这就导致浪费了一个字节的空间,将 16 进制的 Unicode 码转换成二进制得:

1
2
01011001 01111101 // 好
00000000 01001000 // H

英文字符在 Unicode 中的每个字符都要浪费 1 个字节的空间,这造成了硬盘存储于带宽流量的巨大浪费,因此,UTF-8 横空出世!

那么,UTF-8是如何平衡统一与空间呢?

  1. 单字节的字符,字节的首位设为 0,对于英语文本,UTF-8 码只占用一个字节,与 ASCII 码完全相同(ASCII 最大值是0111 1111,因此首位必然是 0,也就与 ASCII 完全相同)
  2. n 个字节的字符 (n>1),第一个字节的前 n 位设为 1(示例中的蓝色),第 n+1 位设为 0(示例中的黑色),后面字节的前两位都设为 10,这 n 个字节的其余空位填充该字符 Unicode 码,高位用 0 补足(示例中的红色)。
字符所占字节数 字节码的有效位数 起始字节码 末位字节码 Byte 1 Byte 2 Byte 3 Byte 4
1 7 U+0000 U+007F 0bbbbbbb
2 11 U+0080 U+07FF 110bbbbb 10bbbbbb
3 16 U+0800 U+FFFF 1110bbbb 10bbbbbb 10bbbbbb
4 21 U+10000 U+10FFFF 11110bbb 10bbbbbb 10bbbbbb 10bbbbbb

明白了 UTF-8 的规则,我们再看下汉字“好”的 UTF8 表示:11100101 10100101 10111101

根据 Wikipedia 的UTF8定义,UTF-8(8-bit Unicode Transformation Format)是一种可容纳1,112,064个Unicode码位的变长字符编码,每个字符会占用 1~4 个字节。通过其定义,我们明白 UTF-8 就是 Unicode 的一种转换格式,通过标记位和 Unicode 码位将 Unicode 进行转换。

明白了 Unicode 与 UTF-8,就能理解为什么绝大多数的代码都是以 UTF-8 编码,因为 UTF-8 能兼容绝大多数的语言,通用性非常好,而代码绝大部分又是英文字符,因此达到了通用性与空间的绝妙平衡。如果要储存一本《红楼梦》,使用 Unicode 是更明智的选择,它不仅包含了绝大多数的汉字,也使得每个汉字以 2 字节存储,达到兼容与空间的平衡。

UFT-8 with BOM 和 UFT-8 without BOM

UTF-8 不需要 BOM,尽管 Unicode 标准允许在 UTF-8 中使用 BOM。所以不含 BOM 的 UTF-8 才是标准形式,在 UTF-8 文件中放置 BOM 主要是微软的习惯(顺便提一下:把带有 BOM 的小端序 UTF-16 称作「Unicode」而又不详细说明,这也是微软的习惯)。BOM(byte order mark)是为 UTF-16 和 UTF-32 准备的,用于标记字节序(byte order)。微软在 UTF-8 中使用 BOM 是因为这样可以把 UTF-8 和 ASCII 等编码明确区分开,但这样的文件在 Windows 之外的操作系统里会带来问题。「UTF-8」和「带 BOM 的 UTF-8」的区别就是有没有 BOM。即文件开头有没有 U+FEFF。

来自「带 BOM 的 UTF-8」和「无 BOM 的 UTF-8」有什么区别? - 梁海的回答

但是,文字有 GBK、UTF-8、UFT-16 等多种编码方式,当我们将编写好的文件存储并发送给其他人,计算机识别的都是二进制啊,它怎么知道哪些是标志位,哪些是字符位呢?因此,我们在编写文件时,需要将该文件的编码类型一同写入文件,这样在解读文件内容时,按照对应的规则解读就可以还原了,具体可阅读之前的文章计算机是如何存储与解读各种文件的

UTF8MB4

如今,Emoji 表情已经成为社交中最常用的符号😂,如果我们采用 UTF-8 存储 Emoji,则会导致溢出问题,因此 MySQL <= 5.7 存储 Emoji 需手动将编码设置为 Emoji,MySQL 8.0+ 则默认为 UTF8MB4 编码。

我们可以在这里查询到常用的 Emoji 🤗

URL 编码

URL 编码同样是为了将 URL 中的非 ASCII 字符转换为 ASCII 字符传输,URL 编码也是 ASCII 编码,如空格的 URL 编码为%20,空格在 ASCII 中的十六进制表示也是20。其保留字符的百分号编码如下:

! # $ % & ' ( ) * + , / : ; = ? @ [ ]
%20 %21 %23 %24 %25 %26 %27 %28 %29 %2A %2B %2C %2F %3A %3B %3D %3F %40 %5B %5D

根据最新的RFC 3986标准,非 ASCII 字符建议先转换为 UTF-8 字节序列,然后对其字节值使用百分号编码。因此,汉字“好”的 UTF-8 编码为E5A5BD,URL 编码为%E5%A5%BD

另外早期的 URL 编码规则会将空格编码为+,而现在会处理为%20,PHP 中则分别对应处理函数urlencode()rawurlencode()

关于 URL 编码的历史课查看阮一峰老师于十年前所写的文章关于 URL 编码

Base64 编码

顾名思义,Base64 是一种基于 64 个可打印字符(A~Za~z0~9+/)来表示二进制数据的表示方法。由于2^6=64,所以每 6 个位为一组,对应某个可打印字符。完整的 Base64 定义可见 RFC 1421RFC 2045。由于现在需要 6bit 表示原本用 8bit 表示的 1 个字节,因此编码后的数据比原始数据大 30%。

最初的电子邮件只能使用 ASCII 字符,因此 Base64 的发明就被用于将图片或非 ASCII 字符转换为 ASCII 字符。其字符列表如下:

数值 字符 数值 字符 数值 字符 数值 字符
0 A 16 Q 32 g 48 w
1 B 17 R 33 h 49 x
2 C 18 S 34 i 50 y
3 D 19 T 35 j 51 z
4 E 20 U 36 k 52 0
5 F 21 V 37 l 53 1
6 G 22 W 38 m 54 2
7 H 23 X 39 n 55 3
8 I 24 Y 40 o 56 4
9 J 25 Z 41 p 57 5
10 K 26 a 42 q 58 6
11 L 27 b 43 r 59 7
12 M 28 c 44 s 60 8
13 N 29 d 45 t 61 9
14 O 30 e 46 u 62 +
15 P 31 f 47 v 63 /

编码示例

我们已经了解了 Base64 的编码规则,现在就来徒手编码字符Man

汉字本身可以有多种编码,比如 GB2312、UTF-8、GBK 等等,每一种编码的 Base64 对应值都不一样。我们以UTF-8编码的汉字“好”(其十六进制为597D)为例:

你可能会注意到,我们经常会在 Base64 编码中发现有=字符,比如moky的 Base64 编码为bW9reQ==,而=却没有出现在字符列表中,这是为什么呢?这是因为Base64末位的=仅仅是为了代表补足的字节数,每个=代表了 2bit。如果我们仅解码bW9reQ,同样能得到moky

这手动编码还是太费劲了,我们还是用代码自动编码吧。由于非 ASCII 字符在不同的编码规则下结果不同,因此我们只编写 ASCII 字符的 Base64 编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php
// 字符转换为二进制
function str2bin($str) {
$binary = '';
foreach (str_split($str) as $char) {
// 将字符转换为ASCII 码
$ascii_char = ord($char);

// 读取字符的二进制表示
$bin = base_convert($ascii_char, 10, 2);

// 使二进制填充为 8 位表示
$binary .= str_pad($bin, 8, 0, STR_PAD_LEFT);
}

return $binary;
}

// Base64 编码
function b64($str) {
$base_chars = array_merge(
range('A', 'Z'),
range('a', 'z'),
range(0, 9),
['+', '/']
);

// 清理字符两侧的空白字符
$str = trim($str);

// 获取字符串的二进制值
$bin = str2bin($str);

// 将二进制按每 6 位分隔
$b64_bin = rtrim(chunk_split($bin, 6, ','), ',');

$output = '';
foreach (explode(',', $b64_bin) as $b) {
// 判断末尾是否需要 = 补齐
if (strlen($b) < 6) {
$suffix_repeat_times = (6 - strlen($b)) / 2;
$b = str_pad($b, 6, 0);
} else {
$suffix_repeat_times = 0;
}

// 获取索引
$index = base_convert($b, 2, 10);

// 获取 base64 字符
$output .= $base_chars[$index] . str_repeat('=', $suffix_repeat_times);
}

return $output;
}

echo b64('moky'); // bW9reQ==

Base64 解码自然就是编码的逆运算,在此不做赘述。

Base64 有什么用?

经过Base64编码后的空间会增大 30%,我们为什么会这样做?我们用空间换取了什么?

  • Base64 将 ASCII 不可见字符转换为可见字符,因此解决了早期电子邮件中的图片传输问题。
  • 其次,Base64 能做简单的对称加密。
  • 最后,通过 Base64 将所有非 ASCII 字符转换为 ASCII,便于计算机的统一传输处理。

base64 图片

以下是一张 5*5 像素的黑色图片的 Base64 编码

1
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAyMDJwMfAxcCbmFxc4BgQ4ANUwgCjUcG3a0DVQHBZF2TWJRnvFbbWr5028n1bzP/4mz2mehTAlZJanAyk/wBxWnJBUQkDA2MKkK1cXlIAYncA2SJFQEcB2XNA7HQIewOInQRhHwGrCQlyBrJvANkCyRmJQDMYXwDZOklI4ulIbKi9IMDj4urjoxBgZGJo7kHAuaSDktSKEhDtnF9QWZSZnlGi4AgMpVQFz7xkPR0FIwMjAwYGUJhDVH++AQ5LRjEOhFjKUwYG41ygoAZCLEuAgWH3NwYGwa0IMfWHQG/NZWA4EFCQWJQIdwDjN5biNGMjCJt7OwMD67T//z+HMzCwazIw/L3+///v7f///13GwMB8C6j3GwAfrGAmadjDTwAAAGxlWElmTU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAABIAAAAAQAAAEgAAAABAAKgAgAEAAAAAQAAAAWgAwAEAAAAAQAAAAUAAAAADJnIDgAAAAlwSFlzAAALEwAACxMBAJqcGAAAABVJREFUCB1jlJGRYUACTEhsEJNUPgAbeABexhoAlQAAAABJRU5ErkJggg==

以下这张图片的十六进制内存地址表示。想必图片编码为 Base64 形式同字符相同,将二进制每 6 位为一组,用可见字符表示。但我现在读不懂内存地址😂,所以不知道从何处开始进行分组,如果起始位错误,那结果将引发雪崩效应。如果有大佬能指点一二,不胜感激。

自定义编码

了解了 Base64 的原理后,我们完全可以自行定义一套编码规则,比如比特币的钱包地址就是中本聪采用 Base58 编码,其中字符去除了 0 - OI - l 等易混淆的字符,以及非字母数字字符+/

数值 字符 数值 字符 数值 字符 数值 字符
0 1 15 G 30 X 45 n
1 2 16 H 31 Y 46 o
2 3 17 J 32 Z 47 p
3 4 18 K 33 a 48 q
4 5 19 L 34 b 49 r
5 6 20 M 35 c 50 s
6 7 21 N 36 d 51 t
7 8 22 P 37 e 52 u
8 9 23 Q 38 f 53 v
9 A 24 R 39 g 54 w
10 B 25 S 40 h 55 x
11 C 26 T 41 i 56 y
12 D 27 U 42 j 57 z
13 E 28 V 43 k
14 F 29 W 44 m

关于 Base64 编码可查看阮一峰于 2008 年所作文章Base64 笔记

JSON编码

json 在计算机中应用广泛,相信很多读者对它都不陌生。JSON 是 JavaScript Object Notation 的缩写,尽管 JSON 是 JavaScript 的一个子集,但 JSON 是独立于语言的 文本格式,并且采用了类似于 C 语言家族的一些习惯。JSON 被广泛应用于数据传输格式、配置文件等场景,其 MIME类型是application/json,文件扩展名是 .json。以下是一个简短的 JSON 示例,具体的 JSON 格式描述可以参考 RFC 4627。需要注意一点的是,JSON 中的字符串引号必须为双引号,单引号是非法的。

1
2
3
4
5
6
7
8
{
"name": "Hank",
"age": 23,
"shcool": [
"University of Oxford",
"Yale University"
]
}

PHP 中的 JSON

中文被编码问题

在 PHP < 5.4的版本中,当进行json_encode()的数据有中文时,中文会被Unicode编码,所以需要在json编码前先urlencode,编码后再urldecode,以免中文被Unicode编码。在 PHP >= 5.4的版本中,只需加入JSON_UNESCAPED_UNICODE这个参数,就无需urlencode了,如json_encode($str, JSON_UNESCAPED_UNICODE)

json_encode 浮点数精度丢失问题

在 PHP >= 7.1 的版本中,json_encode(277.2)的结果是277.19999999999999,即使最新发布的 PHP7.4 中仍存在同样的问题。

解决该问题的方式是修改 php.ini 中的serialize_precision参数,将默认值 17 修改为 -1 即可。

轶事

由于创作者认为 JSON 已足够简洁,无需再进行优化,因此JSON没有版本号。

总结

在很多命名中,数字都有其明确的含义,如 UTF-8 中的 8 表示该编码方式中,最短的文本只需 8 个比特位,Base64 中的 64 表示基于 64 个字符进行编码,而 SHA-256 中的 256 则表示 256 bit 参与散列运算,我们看到命名中的数字,应该有一定的敏锐性,也许这会让你有更好的理解,让你了解其本质时打开一扇新的大门,获取新的启发。

诸如 Base64 和 URL 编码,都是将非 ASCII 字符编码为 ASCII 字符处理,毕竟计算机能完美兼容 ASCII。世界上的字符形式有千千万,在计算机的世界只认 0 和 1,将非 ASCII 转换为 ASCII 统一处理,化繁为简,是一个不错的解决思路。


参考资料

  1. Python 中文排序
  2. php 数组如何按拼音顺序排序
  3. Unicode 和 UTF-8 有什么区别? - 邱昊宇的回答
  4. Unicode - 维基百科
  5. Base64 - 维基百科
  6. 为什么要使用base64编码,有哪些情景需求? - Wang Kai的回答
因为热爱,所以执着。