字符编码知多少(一)

前言

曾经在一场面试中,问到过UTF-8与UTF-16的区别,我一脸懵逼,惨遭羞辱。

最近在使用rider这个IDE的过程中,发现在visual studio中好好的代码,在rider中是乱码。

故此深入了解一下字符编码的前世今生。

前世:编码的蛮荒时代

由于计算机只能存储0/1二进制数据,因此计算机字符编码的起点,本质是为了解决字符与二进制数据的映射问题

ASCII

计算机诞生于美国,因此最早的编码需求仅针对英文字符。1967年发布的ASCII(American Standard Code for Information Interchange)成为第一个通用编码:

  1. 特点
    使用一个字节(8位)中的低7位来表示字符,因此可以表示2^7=128个字符。
    其内容主要包括,英语字母的大小写,数字0-9,标点符号,以及一些控制字符(换行\n,回车\r等符号)。
  2. 优点
    简单,高效,仅占1个字节,完全满足英文场景下的计算机使用需求,成为计算机编码的底层基石。
  3. 缺点
    只能表示英语字符,对于其它语言捉襟见肘。

眼见为实

复制代码
            var originalString = "C#编程";
            Console.WriteLine($"originalString = {originalString}");

            //ASCII编码
            try
            {
                var asciiBytes=Encoding.ASCII.GetBytes(originalString);
                Console.WriteLine($"ASCII 编码 (字节数: {asciiBytes.Length}):");
                Console.WriteLine($"  字节: {BitConverter.ToString(asciiBytes)}");
                Console.WriteLine($"  解码回字符串: \"{Encoding.ASCII.GetString(asciiBytes)}\"");
            }
            catch (Exception)
            {
                Console.WriteLine("ASCII 解码失败");
            }

可以看到,对于英文字符C,与特殊符号# 。 ASCII能够正常编码解码,但对中文就无能为力了。

拓展ASCII与多字节字符(MBCS)

鉴于ASCII的缺点,各国为了能在计算机中表示自己的语言,开始制定自己的编码标准。

同样是英文语系的西欧语言,选择利用ASCII的最高位,将编码范围拓展到2^8=256个,比如ISO 8859-1。

  1. 特点
    前128位与ASCII完全一致,拓展了后128位。补充了西欧重音字符以及特殊符号
  2. 缺点
    仅覆盖西欧,对亚洲,非洲,俄文依旧不支持。

由于ASCII与ISO-8859-1无法满足多语言需求,各国纷纷开始定制专属的本地化编码,只为适配本国文字,毫无兼容性可言,因此开始了编码最混乱的时代。

像中文这样拥有成千上万字符的语言,靠拓展ASCII是远远不够的,一个字节最多表示256个字符,所以人们发明了多个字节来表示一个字符的方法。

  1. GB2312
    作为中国的国家标准,GB2312大约收录了6763个常用汉字,后续推出的GBK作为其超集(支持1-2字节编码,覆盖更多生僻汉字和符号),GB18030则进一步扩展为1-4字节编码,兼容GBK并覆盖全球所有字符。
  2. BIG5
    中国台湾/香港地区使用的繁体中文编码标准
  3. Shift_JIS
    日本使用的编码标准

这个时候的核心问题在于,大多数国家和地区都各自为政,互不兼容。比如一个GBK编码的文件,在使用Big5编码的程序中打开,就会显示为乱码。

编码之间的混乱,导致了软件开发,数据交互的国际化几乎是不可能的。

眼见为实

复制代码
            // 错误地使用UTF-8来解码GBK的数据
            try
            {
                Encoding gbk = Encoding.GetEncoding("GBK");
                var gbkData = gbk.GetBytes(originalString);


                string garbledText = Encoding.UTF8.GetString(gbkData);
                Console.WriteLine($"GBK原始字节: {BitConverter.ToString(gbkData)}");
                Console.WriteLine($"用UTF-8错误解码的结果: \"{garbledText}\"");
            }
            catch (Exception)
            {
                Console.WriteLine("解码失败");
            }

使用UTF-8来解码GBK,会乱码。

过渡:Unicode 字符集的诞生

本地化编码的乱象,催生出一个核心诉求:给全球所有的字符分配唯一的数字编号,彻底摆脱编码冲突。

所以Unicode诞生的应运而生,它作为字符=>数据的映射表,它为世界上所有的文字分配了一个唯一的数据编号,这个编号被称为码点,格式为U+XXXX(XXXX为16进制数字)。

  1. 码点范围
    U+0000 ~ U+10FFFF,共划分17个平面。
  2. 核心平面
    码点范围 U+0000 ~ U+FFFF,覆盖了全球 99% 的常用字符(中英文、数字、主流符号、日韩常用字等)。
  3. 拓展平面
    剩余 16 个平面,用于表示生僻字、古文字、Emoji、专业符号等。
平面编号 平面名称 码点范围 核心用途 日常使用频率
0号 基本多文种平面(BMP) U+0000~U+FFFF 英文/中文/数字/主流符号 ✅✅✅ 100%
1号 多文种补充平面(SMP) U+10000~U+1FFFF Emoji/古文字/音乐符号 ✅✅ 偶尔
2~3号 CJK扩展平面A/B U+20000~U+3FFFF 中文生僻字/古汉字 ✅ 极少
4~13号 CJK扩展C~G+其他扩展 U+40000~U+DFFFF 极生冷僻字/专业符号 ❌ 几乎无
14号 补充特殊用途平面(SSP) U+E0000~U+EFFFF 专业自定义字符 ❌ 无
15~16号 私用使用区(PUA) U+F0000~U+10FFFF 完全自定义字符/保留区 ❌ 无

今生:UTF系列编码

虽然Unicode解决了编码的乱象,却没有规定这个数字编码应该怎么存储.

比如汉字"中"的码点是U+4E2D,这个十六进制的数字可以存为2字节,4字节。传输过程中还会遇到大端法/小端法的顺序问题。

而UTF(Unicode Transformation Format)就是这么一组编码方案。简单来说Unicode 是字符集(定义码点),而 UTF-8/16/32 是「Unicode 编码方案」(将码点转换为字节序列的规则)。

UTF-32

UTF-32是Unicode最早的实现方案,思路极其简单。固定使用4个固定字节来表示一个Unicode码点,无脑将码点转换成一个32位的二进制来存储。

优点

  1. 编码/解码逻辑最简单:固定4字节表示1个Unicode码点,直接映射码点数值,无需判断长度/代理对/字节序;
  2. 完美随机访问:字符位置与字节位置一一对应(字符数=字节数/4),极致适合字符精准定位的底层场景。

缺点

  1. 空间浪费极致严重:所有字符均占4字节,纯英文文本比ASCII浪费300%,纯中文比UTF-8浪费33%,比UTF-16浪费100%;
  2. 生态支持极少:几乎无主流协议/数据库/编程语言支持,仅部分底层系统/学术场景偶尔使用;
  3. 传输/存储成本过高 :完全不适合网络传输、文件存储,仅能用于内存临时处理的极特殊场景。

眼见为实

复制代码
			//UTF32编码
            try
            {
                var utf32Bytes = Encoding.UTF32.GetBytes(originalString);
                Console.WriteLine($"UTF-32 (Unicode) 编码 (字节数: {utf32Bytes.Length}):");
                Console.WriteLine($"  字节: {BitConverter.ToString(utf32Bytes)}");
                Console.WriteLine($"  解码回字符串: \"{Encoding.UTF32.GetString(utf32Bytes)}\"");
                Console.WriteLine();
            }
            catch (Exception)
            {
                Console.WriteLine("UTF-32 解码失败");
            }

可以看到,空间浪费极其严重。总共用了 16 个字节。每个字符都占 4 个字节。

UTF-16

于1996年推出,是对UTF-32的优化,也是可变长度编码的首次尝试

针对Unicode的BMP字符,用2字节存储,超出BMP的拓展字符,用2个2字节(代理对)拼接存储(共4字节)

优点

  1. BMP平面字符固定2字节 :常用中文(U+4E00~U+9FFF)、英文、主流符号均占2字节,纯中文场景空间效率远高于UTF-8
  2. 字符处理更高效 :BMP内可随机访问字符,计算长度、截取字符串无需遍历,直接按字节数/2即可,适合编程语言内部存储;
  3. 原生适配主流语言运行时 :.NET的string类型、Java的char类型、JavaScript的String类型、Windows内核均原生基于UTF-16实现,无编码转换损耗。

缺点

  1. 不兼容ASCII:英文/数字也占2字节,纯英文文本空间浪费100%,混合文本效率低于UTF-8;
  2. 存在字节序问题 :分UTF-16BE(大端)、UTF-16LE(小端),跨平台传输必须加BOM标记,否则必乱码;
  3. 非BMP字符需代理对:生僻字/Emoji(扩展平面)需用「2个2字节」拼接(代理对),占4字节,解码时需额外处理代理对逻辑,易出bug;
  4. 生态兼容差 :互联网协议/数据库/前端均不默认支持,仅适用于编程语言内部/Windows系统,跨端传输需转UTF-8。

眼见为实

复制代码
			//UTF16编码
            try
            {
                var utf16Bytes = Encoding.Unicode.GetBytes(originalString);
                Console.WriteLine($"UTF-16 (Unicode) 编码 (字节数: {utf16Bytes.Length}):");
                Console.WriteLine($"  字节: {BitConverter.ToString(utf16Bytes)}");
                Console.WriteLine($"  解码回字符串: \"{Encoding.Unicode.GetString(utf16Bytes)}\"");
                Console.WriteLine();
            }
            catch (Exception)
            {
                Console.WriteLine("UTF-16 解码失败");
            }

相对UTF-32,存储空间占用有了极大改善,总共用了 8 个字节。每个字符都占 2 个字节(因为 "编程" 在 BMP 范围内)。

UTF-8

当前版本答案,于1992年推出。是目前最主流,最通用的Unicode编码方案。

根据字符的码点大小,动态分配字节数。

✔ 英文字母,数字占用1字节

✔ 欧洲,西亚文字占用2字节

✔ 中/日/韩文占用3字节

✔ 生僻字,Emoji,拓展符号占用4字节

优点

  1. 完全兼容ASCII:ASCII字符(英文/数字/基础标点)占1字节,与传统ASCII文件无缝互通,无历史兼容成本;
  2. 无字节序问题 :无需BOM标记,跨平台(Linux/Mac/Windows)、跨系统传输零乱码风险
  3. 空间效率均衡:混合文本(英文+中文+符号)场景下,整体空间利用率最高(英文1字节抵消中文3字节的损耗);
  4. 生态兼容无敌 :HTML/JSON/HTTP/数据库/主流编程语言/文件系统均默认支持,是互联网、后端开发的唯一标准
  5. 错误容错性强:自同步编码规则,解码时可快速识别字符边界,局部错误不会导致整段乱码。

缺点

  1. 中文/日韩字符占3字节:纯中文文本场景,比UTF-16多占用1字节/字符;
  2. 变长解码稍复杂 :1-4字节可变长度,需通过首字节判断字符长度,无法随机访问字符(计算字符串长度需遍历,而非直接取字节数/2);
  3. 纯东亚文本空间稍浪费:无任何英文的纯中文/日文文本,空间占用比UTF-16高50%。

眼见为实

复制代码
            //UTF8编码
            try
            {
                var utf8Bytes = Encoding.UTF8.GetBytes(originalString);
                Console.WriteLine($"UTF-8 编码 (字节数: {utf8Bytes.Length}):");
                Console.WriteLine($"  字节: {BitConverter.ToString(utf8Bytes)}");
                Console.WriteLine($"  解码回字符串: \"{Encoding.UTF8.GetString(utf8Bytes)}\"");
                Console.WriteLine();
            }
            catch (Exception)
            {
                Console.WriteLine("UTF-8 解码失败");
            }

总共用了 8 个字节。C(43), #(23) 各占 1 个字节,"编"(E7-BC-96) 和 "程"(E7-A8-8B) 各占 3 个字节。

挖坑待埋

  1. 为什么中文在UTF-16下占用2字节,反而在UTF-8中占用3字节了?
  2. UTF-16/UTF-8都是不定长编码规则,它们是如何解析的?
  3. UTF-16的BOM标记长什么样?
  4. 未完待续......