本文为掘金文章转载
背景
上周在评审测试用例时,有一个营销话术的接口字段,业务上要求不能超过 200 字,会上有人问,后端数据表中的这个字段,最多能存储多少个中文字符,有没有对存储字数做限制。我插入了一句,那要看数据表中这个字段定义的是什么数据类型(CHAR、VARCHAR,TEXT 等),一个中文占两个字节,用这种数据类型的字节数除以 2,就是能存储的中文字符数。然后另一个同事说,UTF-8 编码,一个中文占用 3 个字节。这句话颠覆了我之前的认知,在我的印象里,一个中文占用两个字节,怎么会是三个。我决定查一查,看看谁对谁错。
一石激起千层浪
本以为搜索一下就能获得答案,可是发现这个知识点,有些渊源,一言难尽。说得太概括,人难免还是会有疑问。需要追根溯源,才能讲清楚。在查找答案的过程中,发现网上的文章良莠不齐,对于同一个知识点,不同的文章说法相互矛盾,让人思维有些凌乱,对于这种情况,我选择取这些文章的交集,摒弃矛盾与冲突。
缘起 ASCII 码
字符编码的起源,是为了解决在计算机中存储与表达特定字符的问题。比如说英文字母 A, 如何表达才能让计算机能够识别。众所周知,在计算机底层只识别 0 和 1。当计算机要存储/展示字符时,需要一个规则,在字符和 0/1 序列之间建立映射关系,这就是字符编码规则。
1945 年世界第一台计算机诞生于美国,所以美国人第一个遇到字符编码问题。自然而然第一个编码规则也是美国人制定的。美国使用的是英语,英语字符数量比较少,26 个英文字母+数字+标点符号。一个字节是 8 位,如果每一个状态对应一个特定字符,每个二进制位有0
和1
两种取值,可以组合出 256 个字符,足够英语语境使用了。最早的字符编码标准就这样诞生了。美国人起草了计算机的第一份字符集和编码标准,叫 ASCII(American Standard Code for Information Interchange–美国信息交换标准代码),一共规定了 128 个字符及对应的二进制转换关系,128 个字符包括了可显示的 26 个字母(大小写)、10 个数字、标点符号以及特殊的控制符,也就是英语与西欧语言中常见的字符。1967 年定案,最初是美国国家标准,后来被国际标准化组织 ISO(International Organization for Standardization)定为国际标准,称为 ISO 646 标准,适用于所有拉丁文字字母。
各国衍生自己的编码
计算机在世界普及之后,人们发现,在英语国家,128 个字符编码够用了,但是对于非英语国家,无法在 ASCII 字符集中找到本国的基本字符。如在法语中,字母上方有注音符号,无法用 ASCII 码表示。于是有人建议,ASCII 字符只是使用了一个字节的前 128 个,后面的 128 个完全可以利用起来,于是一些欧洲国家就决定,利用字节中闲置的最高位编入新的符号。比如,法语中的 é
的编码为 130(二进制 10000010
)。这样一来,这些欧洲国家使用的编码体系,可以最多表示 256 个符号。
但是又出现了新的问题。不同国家有不同的字母(参见下图,英语,法语属于拉丁字母体系,俄语属于斯拉夫字母体系,以色列用的是希伯来字母),它们都使用 256 个符号的编码方式,代表的字母却不一样。比如,130 在法语编码中代表了 é
,在希伯来语编码中却代表了字母 Gimel
(ג
),在俄语编码中又代表另一个符号。128-255 之间不同的字符集导致人们无法跨机器传播交流各种信息。
至于亚洲国家的文字,使用的符号就更多了,最典型就是中文,汉字多达 10 万左右。常用汉字有 6000 个左右,用一个字节是无法表示的,所以也发展出了自己的一套编码规范:
- 1980 年,中国搞了自己的编码方案 GB/T 2312,一般简称 GB2312。
- 1993 年,国际标准化组织 ISO 制定了编码标准 ISO/IEC 10646-1:1993,国内予以承认,并编号为 GB 13000.1-1993。
- 1995 年,国内基于 GB2312 扩展了一套编码方案 GBK(汉字内码扩展规范),并收录了 GB13000.1 和 Big5(由台湾资讯工业策进会在 1984 年制定)中的汉字,微软在 Windows 95、Windows NT 3.51 中进行了实现,称为 Code Page 936。
- 2000 年,制定了国标 GB 18030-2000,目前已作废。
- 2005 年,制定了国标 GB 18030-2005,为现行的 GB18030 标准。
- 2022 年,制定了国标 GB 18030-2022,2023 年 8 月 1 日生效。
前面只介绍了西方国家和中文的编码,放眼全世界,有许多种语言文字,如果各自都搞一套就乱成一锅粥了。如果有一种编码,将世界上所有的符号都纳入其中。给每一个符号赋予一个独一无二的编码,那么相同的二进制值在世界不同国家编码中代表不同的字符乱局就会消失。
Unicode 码统一乱局
Unicode 码将世界各种语言的每个字符定义一个唯一的编码,以满足跨语言、跨平台的文本信息转换。第一版 发布于 1991 年 ,目前已发展到第 15 版。Unicode 字符集的编码范围是 0x0000 - 0x10FFFF , Unicode 的编码空间从U+0000到U+10FFFF
,共有 1,112,064 个码位(code point)可用来映射字符,可以容纳一百多万个字符, 每个字符都有一个独一无二的编码,也即每个字符都有一个二进制数值和它对应,这里的二进制数值也叫 码点 , 比如:汉字 “汉” 的 码点是 0x6C49, 大写字母 A 的码点是 0x41, 具体字符对应的 Unicode 编码可以查询 Unicode 字符编码表。
Unicode 推行过程中遇到的问题
Unicode 为每个字符规定了唯一的二进制代码,却没有规定这个二进制代码应该如何存储。例如,汉字的”汉” Unicode 编码是十六进制数 0x6C49,表示这个符号需要 2 个字节。依此类推,表示其它在 Unicode 编码中排序更靠后的符号,需要 3 个或 4 个字节。
这就出现两个问题:
- 怎样区别 Unicode 和 ASCII 码?计算机无法知道三个字节是表示一个字符的 Unicode 码,还是分别表示三个字符的 ASCII 码。
- 英文字母只用一个字节表示就够了,如果按照 Unicode 编码,每个符号用三个或四个字节表示,英文文本文件的体积因此大出二三倍,这对存储来说是很大的浪费。
它们造成的结果是:
- Unicode 在很长一段时间内无法推广,由于 ASCII 字符经过 UTF-16 编码后得到的两个字节,高字节始终是 0×00,很多 C 语言的函数都将此字节视为字符串末尾从而导致无法正确解析文本。因此 UTF-16 刚推出的时候遭到很多西方国家的抵触,大大影响了 Unicode 的推行。
- 为了平衡 Unicode 浪费存储空间和表达更多字符的问题,出现了多种不同的 Unicode 编码存储格式。
UTF-8 问世
伴随着互联网的普及,强烈需要一种统一的 Unicode 的编码方案, 尤其是跨国商务办公场景。UTF-8 是目前互联网上使用最广的一种 Unicode 的编码实现方式。Unicode 的其它实现方式还有 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示),在浏览器上很少用,在本地文件中用的较多。
UTF-8 最大的特点,就是它是一种“变长”编码方式。它可以使用 1 到 4 个字节表示一个符号,根据不同的符号而变化字节长度。UTF-8 编码规则是:
对于单字节的符号,字节的第一位设为 0,后面 7 位为这个符号的 Unicode 码。因此对于英文字母,UTF-8 和 ASCII 编码是相同的;
对于 n 字节的符号(n>1),第一个字节的前 n 位都设为 1,第 n+1 位设为 0,后面字节的前两位一律设为 10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码;
下表总结了编码规则,字母 x 表示可用编码的位:
UTF-8 编码方式
Unicode 符号范围(十六进制) | 二进制表示 |
---|---|
000000-00007F (0-127) | 0xxxxxxx |
000080-0007FF (128-2047) | 110xxxxx 10xxxxxx |
000800-00FFFF (2048-65535) | 1110xxxx 10xxxxxx 10xxxxxx |
010000-10FFFF (65536 以上) | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
基于 UTF-8 编码规则,解读 UTF-8 编码就非常简单。如果一个字节的第一位是 0,则这个字节单独就是一个字符;如果第一位是 1,则连续有多少个 1,就表示当前字符占用多少个字节。
还是以汉字的汉为例,演示如何实现 UTF-8 编码:
汉的 Unicode 码是 U+6C49(十进制表示为 27721,二进制表示为 110 1100 0100 1001),根据上表,可以发现 6C49 处在第三行的范围内(0000 0800 - 0000 FFFF),因此汉的 UTF-8 编码需要三个字节,即格式是 1110xxxx 10xxxxxx 10xxxxxx。然后,从汉的最后一个二进制位开始,依次从后向前填入格式中的 x,多出的位补 0。这样就得到了,汉的 UTF-8 编码是 11100110 10110001 10001001,转换成十六进制就是 0xE6B789。
UTF-8 BOM 概念解析
如下图所示,使用 NodePad++保存文件,选择编码方式时,会看到一个 UTF-8 BOM 的选项,这个选项是什么含义?
BOM(byte order mark)是为 UTF-16 和 UTF-32 准备的,用于标记字节序(byte order)。Byte Order Mark(BOM),即字节顺序标记,通常叫做大小端。位于文件开始的地方。用于标记高位在前,还是低位在前。即文件开头有没有 U+FEFF。微软在 UTF-8 中使用 BOM 是因为这样可以把 UTF-8 和 ASCII 等编码明确区分开,但这样的文件在 Windows 之外的操作系统里会带来问题。BOM 不受欢迎主要是在 UNIX 环境下,因为 BOM 本身违反了一个 UNIX 设计的常见原则,就是文档中存在的数据必须可见。BOM 不能作为可见字符被文本编辑器编辑,就这一条很多 UNIX 开发者就不满意。因为很多 UNIX 程序不鸟 BOM。主要问题出在 UNIX 那个所有脚本语言通行的首行#!标示,这东西依赖于 shell 解析,而很多 shell 出于兼容的考虑不检测 BOM,所以加进 BOM 时 shell 会把它解释为某个普通字符输入导致破坏#!标示,这就会造成错误。所以不含 BOM 的 UTF-8 才是标准形式,在 UTF-8 文件中放置 BOM 主要是微软的习惯。BOM 解读规则如下:
相对复杂的 UTF-16 编码
平面的概念
在了解 UTF-16 之前,我们先看一下平面的概念。Unicode 编码中有很多字符,并不是一次性定义的,而是分区进行定义的。每个区存放 65536 个字符,这称为一个平面,目前共有 17 个平面。第一个平面称为基本平面,它的码点从 0-65535,写成 16 进制就是 U+0000 一 U+FFFF,那剩下的 16 个平面就是辅助平面(除了第二个和第三个平面,其它的都没有广泛使用,参见此文的平面讲解),码点范围是 U+10000–U+10FFFF。UTF-16 把 Unicode 字符集的抽象码位映射为 16 位长的整数进行数据存储。 Unicode 字符的码位需要 1 个或者 2 个 16 位长的码元来表示,因此 UTF-16 也是变长字节。
UTF-16 编码规则:
- 编号在 U+0000-U+FFFF 的字符(常用字符集),直接用两个字节表示
- 编号在 U+10000-U+10FFFF 之间的字符,需要用四个字节表示
通过上表,可以发现,UTF-16 用二个字节来表示基本平面,用四个字节来表示扩展平面。
编码识别
当遇到两个字节时,怎么知道是把它当做一个字符还是和后面的两个字节一起当做一个字符呢? 在基本平面内,从 U+D800-U+DFFF 是个空段,这个区间的码点不对应任何字符,这些空段被用来映射辅助平面的字符。110110xx xxxxxxxx
(0xd800
- 0xdbff
)为高位代理(High Surrogate),110111xx xxxxxxxx
(0xdc00
- 0xdfff
) 为低位代理(Low Surrogate)。它的作用是告诉计算机,只要碰着了这个区间的数值,就知道扩展平面的字符来了,要把连续两个双字节当做一个字符解码。
一个高位代理和一个低位代理可以组成一个代理对(Surrogate Pair)。如果 y
和 x
全为 0
,则为 0x010000
的代码点,全为 1
则为 0x10ffff
的代码点,刚好能把所有扩展平面全部编码。
- 如果代码点位于
0x000000
-0x00ffff
,直接进行二进制编码,位数不够的左边充 0。 - 如果代码点位于
0x010000
-0x10ffff
,则:
- 代码点减去
0x10000
,会得到一个位于0x000000
和0x0fffff
之间的数字。 - 这个数字转换为 20 位二进制数,位数不够的,左边充 0,记作:
yyyy yyyy yyxx xxxx xxxx
。 - 取出
yy yyyyyyyy
,并加上11011000 00000000
(0xD800
),得到高位代理。 - 取出
xx xxxxxxxx
,并加上11011100 00000000
(0xDC00
),得到低位代理。 - 高位代理和低位代理相连,得到
110110yy yyyyyyyy 110111xx xxxxxxxx
。
举例说明
以“嫦”字为例,它的 Unicode 码点为 0x21800 ,该码点超出了基本平面的范围,因此需要用四个字节来表示,步骤如下:
- 首先计算超出部分的结果: 0x21800 - 0x10000
- 将上面的计算结果转为 20 位的二进制数,不足 20 位就在前面补 0,结果为: 0001 0001 1000 0000 0000
- 将得到的两个 10 位二进制数分别对应到两个区间中
- 取出 0001000110 并加上
11011000 00000000
(0xD800
),得到高位代理 1101100001000110 ,转成 16 进制数为 0xD846 。同理计算低位代理为 0xDC00,所以这个字的 UTF-16 编码为 0xD846 0xDC00
最好理解的 UTF-32
UTF-32 就是字符所对应编号的整数二进制形式,每个字符占四个字节,这个是直接进行转换的。该编码方式占用的储存空间较多,所以使用较少。比如“马”字的 Unicode 编号是 U+9A6C ,整数编号是 39532 ,直接转化为二进制: 1001 1010 0110 1100 ,这就是它的 UTF-32 编码。
为什么 UTF-8 不需要字节序,UTF-16,UTF-32 需要字节序?
虽然 UTF-8 是变长编码,但是以单字节为编码单元,不存在谁在高低位的问题,由于 UTF-8 的首字节记录了总字节数(比如 3 个),所以读取首字节后,再读取后续字节(2 个),然后进行解码,得到完整的字节数,从而能保证解码也是正确的。
UTF-16 是变长编码,使用 1 个 16-bit 编码单元或者 2 个 16-bit 编码单元,UTF32 是定长编码,使用 2 个 16-bit 编码单元,由于硬件 CPU 的不同,对于一个由 2 个字节组成的 16 位整数,在内存中存储这两个字节有两种方法:一种是将低序字节存储在起始地址,这称为小端(little-endian)字节序;另一种方法是将高序字节存储在起始地址,这称为大端(big-endian)字节序。假如 CPU 是大端序那么高位在前,如果 CPU 是小端序那么低位在前,为了区分,所以有了 BOM(byte order mark),然后计算机才能知道谁是高位,谁是低位,知道了高低位,从而能正确组装,然后才能解码正确。
Unicode、UTF-8、UTF-16、UTF-32 区别?
Unicode 是编码字符集 (字符集) ,而 UTF-8 、UTF-16 、UTF-32 是字符集编码
UTF-16 使用变码元序列的编码方式,相较于定长码元序列的 UTF-32 算法更复杂,甚至比同样是变长码元序列的 UTF-8 也更为复杂
UTF-8 需要判断每个字节中的开头标志信息,所以如果某个字节在传送过程中出错了,就会导致后面的字节也会解析出错;而 UTF-16 不会判断开头标志,即使错也只会错一个字符,所以容错能力较强
如果字符内容全部英文或英文与其它文字混合,但英文占绝大部分,那么用 UTF-8 就比 UTF-16 节省了很多空间; 而如果字符内容全部是中文这样类似的字符或者混合字符中中文占绝大多数,那么 UTF-16 就占优势了,可以节省很多空间。
乱码问题分析
所谓“乱码”是指应用程序显示出来的字符文本无法用任何语言去解读,通常会包含大量 ? 或 �,造成乱码的根本原因就是因为使用了错误的字符编码去解码字节流,想要解决乱码问题,就先要搞清楚应用程序当前使用的字符编码是什么。
比如最常见的网页乱码问题。需要从以下三个方面查找原因:
- 网页文件本身存储时使用的字符编码和网页声明的字符编码是否一致
|
- 服务器返回的响应头 Content-Type 有没有指明字符编码
- 网页内是否使用 META HTTP-EQUIV 标签指定了字符编码
|
MySQL 中一个中文占用几个字节
varchar(n)能存储几个汉字?
varchar(n)表示 n 个字符,一个汉字也被视为一个字符,无论汉字和英文,MySQL 都能存入 n 个字符,区别是实际占用字节长度不同
一个中文汉字占多少字节与编码有关
- UTF8:一个中文汉字= 3 个字节 (常用汉字是汉字总数中占比较小,常用汉字每个占用 3 个字节,多数不太常用的汉字每个占用 4 个字节)。
- GBK:一个中文汉字= 2 个字节
Unicode 简体中文字符集范围
Unicode 简体中文字符集的范围是U+4E00 到 U+9FFF,共包括 20992 个字符。 其中:
- U+4E00 到 U+62FF 是常用汉字区
- U+6300 到 U+77FF 是次常用汉字区
- U+7800 到 U+8CFF 是非常用汉字区
- U+8D00 到 U+9FFF 是未分类汉字区
除了汉字,这个字符范围还包括了汉语拼音、注音符号、部分汉语方言文字和一些符号等。所以一个简体中文汉字的 Unicode 编码值是用 2 位 16 进制数表示,占用 2 个字节。
转载
我说一个中文占 2 个字节,同事说 UTF-8 编码中,一个中文占 3 个字节,到底谁对谁错?
- 本文作者: luckyship
- 本文链接: https://luckyship.github.io/2023/12/11/2023-12-11-unicode/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!