f2h2h1.github.io

关于字符编码的一些坑

1 常见的字符集和编码

字符集

ASCII
GB2312
GBK
GB18030
BIG5
BIG5-HKSCS
Unicode
ISO-8859-1 Latin1
ISO/IEC 10646 UCS ### 编码
Hex
ASCII
EASCII
EUC-CN
UTF-8
UTF-16
UTF-32
UCS-2
UCS-4
Base64
UrlEncode

Windows 代码页和字符集的对应关系

代码页|字符集|备注

参考 https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers

2 常见的名词解释

真值/原码/反码/补码/移码/机器数

反码 和 补码 的出现主要是为方便负数的二进制计算。

BIN/OCT/DEC/HEX

八进制或十六进制缩短了二进制数,但保持了二进制数的表达特点。

位/比特/bit

计算机里最小的单位,只有两个值,1 和 0;一般缩写为小写 b ;二进制数字(binary digit)的缩写

字节/拜特/byte

8 bit 等于 1 byte;一般缩写为大写 B

位串/比特串/bit string

一连串的位(比特)

字节序/byte order

比特序/bit order

字/word

cpu 一次能处理的位串,称为一个计算机字,简称字。 字长(word size),一个 word 的位数,通常是 2 的倍数,例如 16位,32位。 因为英特尔的术语里,一个 word 通常是 16 位。 因为 8086 的字长是 16 位的,后续的处理器为了兼容 8086 也是把一个 word 定义为 16 位。 如果要表达大于 16 位的字长时。通常会使用 dword(double word, 32位) qword(quadruple word, 64位) dqword(double quadruple word, 128位) 。 字(word)这个概念其实和字符编码的关系有点远,这个通常是硬件的概念。 但因为很容易和字符编码的相关概念混淆,所以这里也记录一下。 btw: 二十世纪九十年代时,游戏机里提及的 8 位游戏机、 16 位游戏机、 32 位游戏机,指的就是字长。

字/字符/character

在这里 字/字符 代表就是形式上的汉字或英文字母,一个字/字符就代表一个汉字或一个英文字母;一般缩写为 char

字符串/string

就是多个字符(char),一个字符也可以作为一个字符串

字符集/charset

字面上的理解就是字符的集合,是一个自然语言文字系统支持的所有抽象字符的集合。字符是各种文字和符号的总称,包括文字、数字、字母、音节、标点符号、图形符号等。计算机系统中提到的字符集准确地来说,指的是已编号的字符的有序集合(但不一定是连续的)。

编码规则/encoding

一个字符集里的字符转换成二进制数据的规则。

编码/encode

编码作为名词时,有时是指编码规则。

编码作为名词时,有时是指一个字符里在某个编码规则里对应的二进制数字。 例如,拉丁字母 A 在 ascii 里所对应的编码是 01000001 ,汉字 在 utf-8 里所对应的编码是 11100100 10111000 10000000 。

编码作为动词时是指把字符转换为二进制数据的过程,又或者是一种二进制数据转换成另一种二进制数据的过程(例如 base64 的编码)。

编码具体的意思还是要看具体的语境。

字符编码/character encoding

可以简单地理解为 字符集 + 编码规则

定长编码/变长编码

定长编码,就是指一个编码里,每个字符的位数都是相同的,例如 ascii 里每个字符都是 7 位, utf-32 里每个字符都是 32 位。 变长编码,就是指一个编码里,字符的位数可以不相同,例如,在 utf-8 里, ascii 部分的字符都是一个字节,但一些不常用的字符,可以是四字节甚至是六字节。

单字节编码/双字节编码/多字节编码

单字节编码,就是指一个编码里,每个字符都是一个字节。例如, ascii 双字节编码,就是指一个编码里,每个字符都是两个字节的。例如, UCS-2 多字节编码,就是指一个编码里,单个字符的字节可能会多于两个。例如, utf-8 。在 utf-8 , utf-32 这类字符能多于两个字节的编码出现之前,双字节编码也会被称为多字节编码。

ASCII/CP437

ASCII 是 ANSI(美国国家标准学会)制定的一套编码标准。 是应用最广泛的编码,大部分的编码都能兼容 ASCII 。 CP437 是 windows 里的代码页,基本和 ASCII 一致,但一些控制字符被替换成了其它能显示的字符。

ANSI/ISO 8859-1/CP1252

ANSI 编码是 ANSI(美国国家标准学会)制定的一套编码草案,该草案最终成为 ISO 8859-1 ,正式标准 ISO 8859-1 和 ANSI 编码草案不完全相同。 ANSI 编码在 windows 的代码页为 cp1252 ,但 cp1252 和 ANSI 编码草案不完全相同。 cp1252 在 ISO 8859-1 定稿之前实施,所以和 ISO 8859-1 也有一点不一样。 在 windows 系统里 ANSI 编码一般是指本地编码,如果语言设为英语, ANSI 就是 cp1252 ,如果语言设为中文,ANSI 就是 GBK

3 字符编码的发展历史

ASCII 时期

这一时期字符集和编码没有区分,ASCII 只支持英文,使用 7 位代表一个字符,一个字符占一个字节,最高位为 0,多余的一位没有作用。

本地化时期

因为 ASCII 只支持英文,同时为了保证前向兼容,所以其它国家在 ASCII 的基础上作出各自的拓展。一般的拓展都是把 ASCII 中的最高位利用起,这种兼容 ASCII 的字符编码那时会被成为 EASCII (Extended ASCII)。 ASCII 拓展的字符集中比较有影响的是 ISO 8859-1,这是拉丁字母的拓展,基本覆盖西欧各国的字母,所以也被称为 latin-1,这个字符集也是 JAVA 的默认字符集。ISO 8859 除了 lation-1 之外还有 14 个字符集,用来表示欧洲各个国家的文字。 因为 ISO 10646 的出现和发展,ISO 8859 现在已经停止开发。

而汉字因为字符非常多,所以即使用了 ASCII 最高位也无法表示全部汉字,为了尽可能多地收录汉字,就出现了两个字节代表一个字符的字符集,例如 GB2312,BIG-5,这些字符集通常被成为双字节字符集(DBCS,Double Byte Character Set)或多字节字符集(Multi Byte Character Set)。

笔者认为 ISO 8859-1 的制定是 ASCII 时期向本地化时期过渡的标志。在本地化时期,字符集和编码开始分离,但一个字符集几乎只有一个编码,所以这个时期字符编码仍是被放在一起的。

国际化时期

在本地化时期出现的各种 ASCII 拓展,绝大部分是互不兼容的,为了使国际间信息交流更加方便,于是由 Xerox、Apple 等软件制造商于1988年组成的统一码联盟。统一码联盟制定了 Unicode,这一能表示几乎全部字符的字符集。Unicode 定义了一个现代化的字符编码模型,把字符和编码解耦了。Unicode 是一个字符集,而实现这个字符集的编码有三种,UTF-16,UTF-32,UTF-8。刚开始时,Unicode 只有 UTF-16,这一双字节的编码,但后来发现,双字节容量仍不够大,于是就在双字节的基础上翻一倍,出现了四字节的编码,也就是 UTF-32。UTF-8 是一种可变长的编码,可以使用一到六个字节来表示一个字符,例如,兼容 ASCII 部分就是使用一个字节,常用汉字就使用两个字节,一些生僻的字符就是用四个字节或六个字节。UTF-8 是当下使用最广泛的一种编码。UTF 后面跟着的数字是代表这个编码里最少可以使用多少位来表示一个字符。

笔者认为 Unicode 的制定是本地化时期向国际化时期过渡的标志。从 Unicode 开始,字符集和编码被准确地划分。

4 中文字符集

现在比较流行的中文字符集大概有五种(GB2312,GBK,GB18030,BIG5,BIG5-HKSCS),以及包含中文的 Unicode 。

GB2312

GB2312 只包含常用的 6000多个常用简体汉字和 ascii 码,除了一些老掉牙的网站基本和一些对性能有极端要求的单片机,基本没地方在用了。

GB13000

GB13000 93年发布,字符集大概等同于Unicode 1.1.1 ,编码大概等同于 usc2 。GB13000 包含 20902 个汉字,但因为不兼容 ascii 和 GB2312 所以应用得比较少。

GBK

GBK 是对 GB2312 的拓展,GBK 能兼容 GB2312,据说 GBK 是 guo(国) biao(标) kuo(扩) 的缩写。包含了更多的汉字,也收录了一部分的繁体汉字。 GBK 能兼容 GB2312。windows 的系统语言设为中文,那么 系统里的 ASNI 编码就是 GBK 。 GBK 不是国家标准,只是技术规范指导性文件,但后续的 GB18030 兼容 GBK 而不是 GB13000 。 GBK 收录的汉字比 GB1300 多,但 GBK 没有收录彦文。

有说是微软在GB2312的基础上扩展制订了GBK,然后GBK才成为“国家标准”(也有说GBK不是国家标准,只是“技术规范指导性文件”);但网上也有资料说是先有GBK(由全国信息技术标准化技术委员会于1995年12月1日制定),然后微软才在其内部所用的CP936代码页中以GBK为参考进行了扩展。

关于 GBK 的来历,中文互联网上大概有两种说法, 一种是微软先在 GB2312 上扩展了,然后才有 GBK 的指导文件。 另一种是, GBK 先发布,然后微软才在 Windows95 里使用。

笔者在网上搜索了一下相关的资料,并列了一个时间线。

GB18030

GB18030 是对 GBK 的拓展, GB18030 能兼容 GBK 。同样地收录了更多的汉字,常用的繁体字基本也收录完了,还收录了一些少数民族的文字。因为 utf8 的广泛使用,这个字符集也用得比较少。

全角和半角

GB2312 虽然是双字节编码,但却也兼容 ascii ,所以 ascii 的字符仍然是一个字节的。在 GB2312 的 ascii 的字符会被称为半角。但 GB2312 里还有一套完整的双字节的英文字符和符号,这些双字节的英文字符和符号会被称为全角。通常情况下,半角字符只占全角字符的一半宽度。据说,全角字符的出现是为了让中英排版时好看一些。

BIG5 和 BIG5-HKSCS

BIG5 (大五码) 是台湾人搞的中文字符集,收录的字数比 GB2312 多,但没有简体字,在大陆这边几乎没用。

BIG5-HKSCS (Hong Kong Supplementary Character Set, 香港增补字符集) 是香港人基于 BIG5 搞的一套字符集,就是在 BIG5 基础上加上一些粤语字和一部分简体字。

BIG5 的由来

BIG5 的乱码和冲码问题

CCCII 和 CNS 11643

中文资讯交换码(Chinese Character Code for Information Interchange,简称CCCII)

中文标准交换码(CSIC, Chinese Standard Interchange Code),编号CNS 11643,旧名国家标准中文交换码(CISCII, Chinese Ideographic Standard Code for Information Interchange)

GB 字符集的各种码

十六进制数既可通过添加后缀 H 来表示,也可通过添加前缀 0x 来表示。

为什么要区分 区位码 国标码 和 内码,笔者没在互联网上找到确切的答案。 笔者猜测可能和 EUC-CN 以及 iso 2022 有关, 也可能和二十世纪八十年代,大量出现的中文输入法有关, 也可能是为了和 ascii 兼容,忽略和 ascii 重叠的编号。 <!– 确实和 EUC-CN 以及 iso 2022 有关 gb 的内码大概对应 big5 gb 的交换码大概对应 CCCII 或 CNS 11643

ISO 2022-CN 是交换码 EUC-CN 是机内码 EUC-TW 是机内码,是 CNS 11643 的机内码

国家标准 ISO 2022 EUC
GB2312 ISO 2022-CN EUC-CN
JIS X 0208 ISO 2022-JP EUC-JP
KS X 1001 ISO 2022-KR EUC-KR

W3C的编码技术指南规定,应将gb2312字节流视为GBK编码,与GB18030一并使用同一解码器解码。

区位码和交换码好像并没有多少实际的应用 平时遇到的 gb2312 都是机内码,所以很多时候,查看字符编码时 gb2312 会直接显示成 EUC-CN

rfc1922

–>

ISO 2022 和 EUC-CN

各种中文输入法

总结

5 Unicode 和 ISO 10646

ISO 10646 来自国际标准化组织(ISO)。1991年前后,统一码联盟(Unicode)和国际标准化组织(ISO)的参与者都认识到,世界不需要两个不兼容的字符集。于是,它们开始合并双方的工作成果,但字符集依然是独立发布的。

ISO/IEC 10646 全称 Information technology – Universal Coded Character Set (通用字符集) ,缩写为UCS。UCS 有两套编码, UCS-2 , UCS-4 。 UCS-2 大致等于 utf-16 ,UCS-4 大致等于 utf-32 。大致等于并不完全相等,例如,utf-16 双字节的部分和 UCS-2 基本一致,但 utf-16 辅助平面部分是四字节的编码,这里就和 UCS-2 不一样了。 utf-16 辅助平面 通常被称为增补字符。

汉字 一 的 Unicode 下各个字符集的编码

编码 hex dec (bytes) dec binary
UTF-8 E4 B8 80 228 184 128 14989440 11100100 10111000 10000000
UTF-16BE 4E 00 78 0 19968 01001110 00000000
UTF-16LE 00 4E 0 78 78 00000000 01001110
UTF-32BE 00 00 4E 00 0 0 78 0 19968 00000000 00000000 01001110 00000000
UTF-32LE 00 4E 00 00 0 78 0 0 5111808 00000000 01001110 00000000 00000000

Unicode 编号,一般是指 utf-32 BE 编码去掉前导 0 的部分,例如 汉字 一 的 Unicode 编号就是 4E00 。 更多的情况下 Unicode 会写成 hex 的形式 4E 00 。在不同的编程语言里可能会写成 \u4E00 \x4E00 \4E00 U+4E00

在 Windows 系统下,按住 alt ,然后键入 Unicode 编号的十进制,就能输入对应的字符。

可以在这个网站查询字符对应的编码 https://unicode-table.com/

字符编码模型(Character Encoding Model)

Unicode 字符编码模型分为四个层级(level)

除了以上四个层级外,另外还有两个有用的概念:

模型 解释 例子
ACR 抽象的字符 汉字 一
CCS 字符的编号 汉字 一 的 unicode 十进制编号 19968
CEF 用基本数据类型表示字符 汉字 一 的 unicode 二进制编号 4E00 ,通常带有前缀 0
CES 作为字节流的字符 汉字 一 具体的编码,例如 utf-16be 4E00 或 utf-16le 004E 或 utf-8 E4B880
TES 传输编码 把汉字 一 具体的编码再转换成 base64 或 urlencode 这类编码

一些文章会把字符编码模型分为五层 ACR CCS CEF CES TES

参考 https://www.unicode.org/reports/tr17/

Code Point, Code Unit, Code Value, Code Space

参考 http://www.unicode.org/glossary/

平面

unicode 目前有 17 个平面(plane)。 每个平面有 65536(2^16 或 256^2) 个码点(code point)。 一共有 17*2^16 个码点,大概能表示一百万个字符。 17 个平面 21 位比特就能表示完了。

第一平面称为 0 号平面 或 基础多语言平面(Basic Multilingual Plane, BMP)。 其余的 16 个平面称为 辅助平面 或 补充平面 或 增补平面(Supplementary Plane, SP)。

通常一个平面会以一个 1616 的二维表格表示,其中一个格子表示 256 个字符。 然后每个格子展开后又是一个 1616 的二维表格,最后才是一个格子表示一个字符。

每个平面里,还会划分多个区块(block),每个区块都是一类 文字或符号 。但不是每个区块都刚好是 256 的倍数。 例如 编号 0000—007F 是基础拉丁文(Basic Latin),编号 4E00—9FFF 是 中日韩统一表意字(CJK Unified Ideographs)。

平面 起始编号 名称
0 号平面 U+0000 - U+FFFF 基本多文种平面 (Basic Multilingual Plane,BMP)
1 号平面 U+10000 - U+1FFFF 多文种补充平面 (Supplementary Multilingual Plane,SMP)
2 号平面 U+20000 - U+2FFFF 表意文字补充平面 (Supplementary Ideographic Plane,SIP)
3 号平面 U+30000 - U+3FFFF 表意文字第三平面 (Tertiary Ideographic Plane,TIP)
4 号平面 至 13号平面 U+40000 - U+DFFF (尚未使用)
14 号平面 U+E0000 - U+EFFFF 特别用途补充平面 (Supplementary Special-purpose Plane,SSP)
15 号平面 U+F0000 - U+FFFFF 保留作为私人使用区(A区) (Private Use Area-A,PUA-A)
16 号平面 U+100000 - U+10FFFF 保留作为私人使用区(B区) (Private Use Area-B,PUA-B)

BMP 里也有一个 PUA 区块,编号 0xE000-0xF8FF 。

14 号辅助平面,目前仅摆放“语言编码标签”和“字形变换选取器”,它们都是控制字符。

在辅助平面的字符,通常会被称为 增补字符 。 在 utf-16 里,这些字符需要用到 4 个字节来表示。

传说, unicode 一开始只是规定了 65536 个码点,所以 utf-16 一开始也是两个字节。 那时的 unicode 可能是觉得 65536 就能表示人类的全部字符了。 但后来发现 65536 个码点,完全不够用,于是又增加了平面的概念。 一开始的 65536 个码点称为基本平面,后续新增的 16 个平面称为辅助平面,每个平面 65536 个码点。 所以,后续的 ucs-4 和 utf-32 都是四个字节,编码空间暂时还是很充裕。

utf-16 的代理机制

为了让 utf-16 能表示增补平面的字符, 于是 utf-16 增加了代理机制(surrogate)。

在 BMP 里有一个代理区块(Surrogate Zone)专门用于 utf-16 的代理。 代理区有 8 个区块,一共 2048(256*8) 个码点,代理区的范围是 0xD800-0xDFFF 。

代理机制,大概就是用一个代理区码点和一个非代理区码点组成一个代理码元, 两个代理码元表示一个增补平面的字符。 两个码元就是 4 个码点就是 4 个字节。 这两个代理码元会被称为 代理项对(Surrogate Pair) 。

utf-16 的代理对刚好能表示完 16 个增补平面。

大致的代理规则

代理码元1 代理码元2
1101 10pp ppxx xxxx 1101 11xx xxxx xxxx

大致的算法

  1. 把增补平面的码点值减 0x10000
  2. 获得一个 20 位长的比特串,把这个比特串分为两部分,高位 10 比特和低位 10 比特
  3. 把高位 10 比特加上 0xD800 ,得到 引导码元
  4. 把低位 10 比特加上 0xDC00 ,得到 尾随码元
  5. 把引导码元和尾随码元组和起来就是 utf-16 在增补平面的码元了

例子

  1. 汉字 𤭢 的 unicode 编号为 150370
  2. 把 unicode 编号转换位 32 位的二进制 00000000 00000010 01001011 01100010
  3. 码点值减去 0x10000 获得 00000000 00000001 01001011 01100010
    • 获得一个 20 位的比特串 00010100101101100010
  4. 分割高 10 位和低 10 位的比特串
    • 高 10 位 0001010010 0x0052
    • 低 10 位 1101100010 0x0362
  5. 高 10 位的比特串加上 0xD800 得到 引导码元, 0xD800 + 0x0052 = 0xD852
  6. 低 10 位的比特串加上 0xDC00 得到 尾随码元, 0xDC00 + 0x0362 = 0xDF62
  7. 把引导码元和尾随码元组和起来, 0xD852 0xDF62
#include <stdio.h>
#include <stdlib.h>
#include <uchar.h>
#include <string.h>
#include <locale.h>
void dump_bytes(char* str, int len)
{
    for (int i = 0; i < len; ++i)
    {
        printf("%p %x\n", str+i, *(str+i));
    }
}
void printf_utf16(unsigned int unicode)
{
    char buf[5] = {'\0', '\0', '\0', '\0', '\0'};
    mbstate_t ps;
    memset(&ps, 0, sizeof(ps));
    if (unicode < 0x10000) // 0x10000 00010000000000000000 65536
    {
        dump_bytes((char*)(&unicode), 2);
        c16rtomb(buf, (char16_t)unicode, &ps);
        printf("%s\n", buf);
    }
    else
    {
        unicode = unicode - 0x10000;
        char32_t b;
        b = (char32_t)0xd800 + (unicode >> 10);
        b = b << 16;
        b = b | (0xdc00 + (unicode & 0x3ff));
        dump_bytes((char*)(&b), 4);
        printf("%x\n", b);
    }
}
int main()
{
    setlocale(LC_ALL, "en_US.UTF-8");
    unsigned int utf16_1 = 19968; // 二字节编码的例子
    unsigned int utf16_2 = 150370; // 四字节编码的例子
    printf_utf16(utf16_1);
    printf("\n");
    printf_utf16(utf16_2);
    printf("\n");
    return 0;
}

C 语言的标准库的 c16rtomb 函数并不能处理两个码元的 utf-16 编码 https://en.cppreference.com/w/c/string/multibyte/c16rtomb

在 C11 刚发布时,不同于转换变宽多字节(如 UTF-8 )到变宽 16 位(如 UTF-16 )编码的 mbrtoc16 ,此函数只能转换单个单元的 16 位编码,这表示尽管此函数的原目的如此,它仍不能转换 UTF-16 到 UTF-8 。这为 C11 后的缺陷报告 DR488 所更正。

utf-8

UTF-8 的编码规则很简单,有二条:

  1. 对于单字节的符号,且第一位为0,后面7位为 Unicode 码. 因此对于英语字母,UTF-8 编码和 ASCII 码是相同的
  2. 对于 n 字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。
unicode 符号范围(十进制) unicode 符号范围(十六进制) utf-8 编码方式
0 - 127 0x00 - 0x7f 0xxxxxxx
128 - 2047 0x80 - 0x7ff 110xxxxx 10xxxxxx
2048 - 65535 0x800 - 0xffff 1110xxxx 10xxxxxx 10xxxxxx
65536 - 2097151 0x10000 - 0x1fffff 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
2097152 - 67108863 0x200000 - 0x3ffffff 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
67108864 - 2147483647 0x4000000 - 0x7fffffff 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

utf-8 实际上最多只能使用 31 位和 utf-32 相比还少了 1 位,但和 17 个平面的 21 位相比还是很宽裕的。

下面是一个简单的,把 unicode 转换成 utf-8 的例子,但不考虑超过 4 字节的 utf-8 ,因为 4 字节的 utf-8 足够表示 17 个平面的字符了。

基本的流程就是

  1. 先用 右移 获取一个字节的有效位
  2. 再用 与 清空其它位
  3. 再用 或 加上 utf-8 的前缀
  4. 如果已经是最后一个字节,就不用右移了
#include <stdio.h>
void printf_utf8(unsigned int unicode)
{
    char str[5] = {'\0', '\0', '\0', '\0', '\0'};
    if (unicode <= 0x7f) // 0x7f 01111111 127
    {
        str[0] = (char)unicode;
    }
    else if (unicode >= 0x80 && unicode <= 0x7ff)
    {
        str[0] = 0xc0 | ((unicode >> 6) & 0x1f); // 0xc0 11000000 0x1f 00011111
        str[1] = 0x80 | (unicode & 0x3f); // 0x80 10000000 0x3f 00111111
    }
    else if (unicode >= 0x800 && unicode <= 0xffff)
    {
        str[0] = 0xe0 | ((unicode >> (6 * 2)) & 0x0f); // 0xe0 11100000 0x0f 00001111
        str[1] = 0x80 | ((unicode >> 6) & 0x3f);
        str[2] = 0x80 | (unicode & 0x3f);
    }
    else if (unicode >= 0x10000 && unicode <= 0x10ffff)
    {
        str[0] = 0xf0 | ((unicode >> (6 * 3)) & 0x07); // 0xf0 11110000 0x07 00000111
        str[1] = 0x80 | ((unicode >> (6 * 2)) & 0x3f);
        str[2] = 0x80 | ((unicode >> 6) & 0x3f);
        str[3] = 0x80 | (unicode & 0x3f);
    }
    printf("%s", str);
}
int main()
{
    unsigned int utf8_1 = 65; // 和 ascii 码兼容的例子
    unsigned int utf8_2 = 415; // 二字节编码的例子
    unsigned int utf8_3 = 19968; // 三字节编码的例子
    unsigned int utf8_4 = 131954; // 四字节编码的例子
    printf_utf8(utf8_1);
    printf("\n");
    printf_utf8(utf8_2);
    printf("\n");
    printf_utf8(utf8_3);
    printf("\n");
    printf_utf8(utf8_4);
    return 0;
}

utf-32 和 ucs-4

组合字

其它

通用区域数据存储库 (Common Locale Data Repository, CLDR)

Unicode 国际组件 (International Components for Unicode, ICU)

UCA

rtl (right-to-left)

bidi

CJKUI 中日韩统一表意字

国际表意文字核心(International Ideographs Core,简称 IICore 或易扩)

Unihan

mysql 的 utf8 和 utf8mb4

unicode 里关于汉字的问题

emoji 表情

为什么没有 utf-24 https://www.v2ex.com/t/399575

6 关于 BOM

BOM(Byte Order Mark),字节顺序标记,出现在文本文件头部,Unicode 编码标准中用于标识文件是采用哪种格式的编码。 在 UFT-8 编码格式的文本中,如果添加了BOM,则只用它来标示该文本是由 UTF-8 编码方式编码的,而不用来说明字节序,因为 UTF-8 编码不存在字节序问题。

unicode 里有一个名为 零宽度非断空格符 (ZERO WIDTH NO-BREAK SPACE, zwnbsp) 的不可见字符,用于阻止特殊位置的换行分隔。 同时也是用于标识字节序。 通常会作为文本或字节流的开头。

GB2312、GBK、GB18030 都是兼容 ASCII,区分 ASCII 的方法是高字节的最高位为0。 在读取字符流时,只要遇到高位为1的字节,就可以将下两个字节作为一个双字节编码,而不用管低字节的高位是什么。 big5 和 ISO 8859 也是用类似的方式兼容 ASCII 。 所以 GB2312、GBK、GB18030、big5、ISO 8859 是没有 BOM 的问题的。

7 Hex,base64 和 UrlEncode

Hex 是十六进制的意思,一般就是把二进制数据转换成十六进制显示,例如 00001100 转换成十六进制 c ,一般会以 0x 开头,所以会写成 0xc 。

base64 就是把二进制数据用 ascii 里的 65 个字符表示,A ~ Z a ~ z 0 ~ 9 + / = 。

UrlEncode 类似于 base64 ,也是用 ascii 字符来表示数据,一般用在 url 里的地址部分 或 提交表格的 body 里。

punycode 是用于域名里非 ascii 字符的编码,类似于 UrlEncode 。例如,中文域名就是先转换成 punycode 再查询 DNS 的。punycode 就由26个字母+10个数字,还有“-”组成。

base58 就是把二进制数据用 ascii 里的 58 个字符表示,A ~ Z (去除大写字母 O ,大写字母 I) a ~ z (小写字母 l) 1 ~ 9 。 大写字母 O ,大写字母 I ,小写字母 l 数字 0 比较容易混淆。

Base58Check 在 base58 的基础上加上校验机制,主要用于表示 Bitcoin 的钱包地址。

8 Windows 系统下的字符编码

Windows 的系统编码

Windows api 的编码

Windows 通常以以下三种格式之一来实现操作字符的 API 函数:

Windows 记事本的编码

Windows 命令行的编码

参考

https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/chcp

https://docs.microsoft.com/zh-cn/windows/win32/intl/unicode-and-character-sets

9 一条短信为什么是 70 个汉字

按照 GSM 900/1800/1900 的标准,每条短信最多发送1120位,也就是(1120÷8=140一个字节占8位)140字节的内容。 如果发送纯 ASCII 码字符,ASCII 采用7位编码,所以1120位的限额可以传送1120÷7=160个字符。这里不会像一般的程序那样,一个字符占一字节(8位),空余一个位出来,而是一个字符占7位。 如果发送的内容里含有非 ASCII 字符,就会自动转换为 UCS-2 编码,这时所有字符都采用2个字节的8位编码,所以1120位的限额可以传送1120÷(8*2)=70个字符。 所以,只要短信里含有汉字,那么短信的编码就是 UCS-2 ,所以一条短信最多只能有 70 个汉字

运营商限制单条短信长度70字,但是允许拼接,即多条短信组合成一条短信。发送时根据协议将短信拆分,接收时根据协议将短信合并,这样就可以突破字数限制。

太长就不能称为短信了

10 字体和字库

字体 = FONT

字库 = 字体库 = FONT LIBRARY

硬件的字库

在一些嵌入式系统里,因为可用的内存十分的少,为了存储汉字或其他东亚文字的字形位图,需要一个单独的芯片。 这种单独的芯片通常会被称为字库或字库芯片。 这种芯片往往是只读不可写的。

旧时代的手机也有类似的设计。 在智能手机时代,严谨意义上的字库芯片已经没有了,但依然会把手机里的那块 flash memory 称为字库。

软件的字库

从一个写上层应用的程序员角度来看,字体 == 字库。

字体有很多种格式,但基本上都是根据编码输出对应的字形位图。

字体的渲染过程大致是这样的

  1. 加载字体
  2. 输入字符的编码
  3. 根据字体文件里的 cmap 把字符的编码转换成对应的字形索引(GlyphID)
  4. 根据索引从字体中加载这个字形
  5. 把字形渲染成位图

字体可以简单但不严谨地理解为一个很大的编码对字形索引的键值对。这个键值对会被称为 Character to Glyph Index Mapping Table 简称 cmap 。

现时(2022)的字体格式都是最多只能包含 65535 个字符,这是因为字形索引的长度最多是 16 位。 据说 HarfBuzz4.0 已经突破了 65535 的限制

字体的具体实现其实挺复杂的,可以参考一下 OpenType 的文档

FreeType2 Tutorial 这是一个关于如何实现字体的教程,相当的硬核

一些常见的字体格式

11 一个字节为什么是 8 位

一个字节占 8 位,好像没有哪个标准文件里有提及,但事实上又都是这样。

实际上一个字节可以不是 8 位, C 语言标准头文件 中定有一个宏 CHAR_BIT,用以表示字节位数。 从一个写上层应用的程序员角度来看,一个字节就是 8 位。

IBM在 1950 年设计 IBM 7030 Stretch 的时候引入 byte 的概念,表示程序访问内存的最小单位。 叫 byte 就是为了跟 bit 有所区分。 7030 一个 byte 可能包含 1-bit 到 8-bit 不等,但最多是 8-bit 。 到了 1964 年,IBM 设计出 IBM System/360 大型机,取得重大成功。 而 System/360 的一个 byte 就是 8-bit。 而 System/360 的一个 byte 之所以是 8-bit ,据说是为了兼容打孔卡的数据。

还有一个原因就是, ASCII 一个字符是 7 位,然后加上 1 位校验码,刚好是 8 位。

12 C 语言中的字符和字符串

C 语言标准库中有三套字符串处理函数,分别是

但输入和输出函数只有 字符 和 宽字符 的版本。

对于 ASCII 用普通的字符串处理函数就可以了。 对于 GBK utf-16 utf-32 这种,用 宽字符 版本的函数,但要先设置好 locale 。 对于 utf-8 这种是最麻烦的了,一般是先转换成 utf-32 然后再用 宽字节 版本的函数处理。

字符

在 C 语言中字符通常是指 char 类型。 C 语言中还有其它字符类型。

类型 描述
char 一个字节
char16_t 16位,两个字节
char32_t 32位,四个字节
wchar_t 一般是两个字节,但其实是通过 locale 的设置决定的。其实就是因为 wchar_t 无法确定具体的字节数,才会有 char16_t 和 char32_t 这两种类型的出现

几个容易混淆的符号

   
null 一般指空指针,直接使用会提示未定义
0 是数字0,一般是 int 类型,全部位数都是0
‘0’ 是字符0,一般是 char 类型,对应的ascii码 48
‘\0’ 是字符串结束的字符,一般是 char 类型,全部位数都是0
“0” 是字符串,实质是一个字符数组 {‘0’, ‘\0’}
“\0” 是字符串,实质是一个字符数组 {‘\0’, ‘\0’}
NULL 宏定义,实质是 ((void*)0),是一个指针

‘\0’ 的意思就是 ascii 的第一个字符, 反斜杠后面跟着的其实就是 ascii 码的八进制数字。

对于 char16_t char32_t wchar_t 这类字符的声明,需要在前面加上一个标识的符号

类型 符号 例子
char16_t u char16_t chr = u’中’
char32_t U char32_t chr = U’中’
wchar_t L wchar_t chr = L’中’

同样地,对应类型的字符串也是需要加上对应的符号

不然会有这种警告的

warning: multi-character character constant [-Wmultichar]

还可以使用转义符 \ 来表示字符,在字符串里同样也适用

格式 描述 例子
\hhh ASCII 编码的八进制表示,可以省略前导 0 \0 \101
\xhh ASCII 编码的十六进制表示,可以省略前导 0 \x0 \x41
\uhhhh utf-16 be 编码的十六进制表示,不可以省略前导 0 \u4e2d
\Uhhhhhhhh utf-32 be 编码的十六进制表示,不可以省略前导 0 \U00006587

但 ascii 好像不能用 unicode 编码表示,笔者是用 gcc9.2 c17 实践的

这是一个使用转义符来表示字符的例子

#include <stdio.h>
int main()
{
    char* ascii = "A\101\x41";
    char* unicode = "\u4e2d\U00006587";
    printf("%s\n", ascii);
    printf("%s\n", unicode);
    return 0;
}

输出

AAA
中文

这是一个按字节输出各种类型字符的例子

#include <stdio.h>
#include <wchar.h>
#include <uchar.h>

void dump_bytes(char* str, int len)
{
    for (int i = 0; i < len; ++i)
    {
        printf("%p %x\n", str+i, *(str+i));
    }
}

int main()
{
    char chr1 = 'a';
    wchar_t chr2 = L'中';
    char16_t chr3 = u'中';
    char32_t chr4 = U'中';

    printf("ascii ----------------\n");
    dump_bytes((char*)&chr1, sizeof chr1);
    printf("wchar_t ----------------\n");
    dump_bytes((char*)&chr2, sizeof chr2);
    printf("char16_t ----------------\n");
    dump_bytes((char*)&chr3, sizeof chr3);
    printf("char32_t ----------------\n");
    dump_bytes((char*)&chr4, sizeof chr4);

    return 0;
}

输出

ascii ----------------
0x7fff8fb4c8d5 61
wchar_t ----------------
0x7fff8fb4c8d8 ffffffad
0x7fff8fb4c8d9 ffffffb8
0x7fff8fb4c8da ffffffe4
0x7fff8fb4c8db 0
char16_t ----------------
0x7fff8fb4c8d6 ffffffad
0x7fff8fb4c8d7 ffffffb8
char32_t ----------------
0x7fff8fb4c8dc ffffffad
0x7fff8fb4c8dd ffffffb8
0x7fff8fb4c8de ffffffe4
0x7fff8fb4c8df 0

wchar_t 是根据 locale 决定的,不同的主机环境可能不一样

字符串

在 C 语言中没有字符串类型, 字符串通常是指以 ‘\0’ 结尾的字符数组。 一些多字节编码或变长编码可能会使用结构体来实现。 下文只讨论使用基本类型的字符。

字符串的长度,就是指一个字符串里除了结束标志字符之外的字符个数。 要获得准确的字符串长度,要使用编码对应版本的字符串处理函数。

字符串的大小,就是指一个字符串所占用的字节数。 要获得准确的字符串大小,最好用 sizeof 而不是 strlen 。 strlen 本质上是在计算从开始地址遇到 \0 之前的字节数量。

声明字符数组

char a[] = {'a', 'b'}; // 数组长度是2,这个不是字符串,因为最后一位不是'\0',strlen这时是不可预计的,因为最后一位不是'\0'
char a[] = "ab"; // 数组长度是3,strlen是2,因为会自动补足一位'\0'
char a[] = {"ab"}; // 这个和上面那个一样
char a[] = {'a', 'b', '\0'}; // 这个和上面那个一样
// 这几个和上面是一样的,声明数组时赋值可以忽略长度
char a[2] = {'a', 'b'};
char a[3] = "ab";
char a[3] = {"ab"};
char a[3] = {'a', 'b', '\0'};
// 这种,未赋值的元素会自动初始化为 '\0';
char a[3] = {'a', 'b'};

字符数组和字符指针是不一样的。 字符数组是数组,字符指针是指针,虽然两个都可以当作字符串那样来使用。

字符指针声明后需要初始化才能赋值。

// 正确
char *s = (char*)malloc(6*sizeof(char));
s[0] = 'a';
// 错误
char *s;
s[0] = 'a';

字符指针可以在声明时初始化

char *s = "ab";

可以直接把字符串赋值给字符指针

char *s;
s = "ab";

字符指针可以通过下标来访问

char *s = "ab";
s[0]; // a
*(s+1); // b

直接把字符串赋值字符指针,不能通过下标来修改字符串里的某个字符,但可以整体重新赋值。 之所以不能单独修改某个字符,是因为直接写在代码里的字符串会被存放在数据区里,这部分数据不能被修改。 但可以整体重新赋值,是因为修改的指针指向的地址,相当于只是修改一个变量的值。

// 错误
char *s = "ab";
s[0] = 'a';
// 正确
char *s = "ab";
s = "cd";

这是一个按字节输出各种类型字符串的例子

#include <stdio.h>
#include <wchar.h>
#include <uchar.h>

void dump_bytes(char* str, int len)
{
    for (int i = 0; i < len; ++i)
    {
        printf("%p %x\n", str+i, *(str+i));
    }
}

int main()
{
    char str1[] = "general string";
    wchar_t str2[] = L"这是 wchar_t";
    char16_t str3[] = u"这是 char16_t";
    char32_t str4[] = U"这是 char32_t";
    char str5[] = u8"这是 utf-8";

    printf("ascii ----------------\n");
    dump_bytes((char*)str1, sizeof str1);
    printf("wchar_t ----------------\n");
    dump_bytes((char*)str2, sizeof str2);
    printf("char16_t ----------------\n");
    dump_bytes((char*)str3, sizeof str3);
    printf("char32_t ----------------\n");
    dump_bytes((char*)str4, sizeof str4);
    printf("utf-8 ----------------\n");
    dump_bytes((char*)str5, sizeof str5);

    return 0;
}

输出

ascii ----------------
0x7ffe009c5401 67
0x7ffe009c5402 65
0x7ffe009c5403 6e
0x7ffe009c5404 65
0x7ffe009c5405 72
0x7ffe009c5406 61
0x7ffe009c5407 6c
0x7ffe009c5408 20
0x7ffe009c5409 73
0x7ffe009c540a 74
0x7ffe009c540b 72
0x7ffe009c540c 69
0x7ffe009c540d 6e
0x7ffe009c540e 67
0x7ffe009c540f 0
wchar_t ----------------
0x7ffe009c5430 ffffffd9
0x7ffe009c5431 ffffff8f
0x7ffe009c5432 0
0x7ffe009c5433 0
0x7ffe009c5434 2f
0x7ffe009c5435 66
0x7ffe009c5436 0
0x7ffe009c5437 0
0x7ffe009c5438 20
0x7ffe009c5439 0
0x7ffe009c543a 0
0x7ffe009c543b 0
0x7ffe009c543c 77
0x7ffe009c543d 0
0x7ffe009c543e 0
0x7ffe009c543f 0
0x7ffe009c5440 63
0x7ffe009c5441 0
0x7ffe009c5442 0
0x7ffe009c5443 0
0x7ffe009c5444 68
0x7ffe009c5445 0
0x7ffe009c5446 0
0x7ffe009c5447 0
0x7ffe009c5448 61
0x7ffe009c5449 0
0x7ffe009c544a 0
0x7ffe009c544b 0
0x7ffe009c544c 72
0x7ffe009c544d 0
0x7ffe009c544e 0
0x7ffe009c544f 0
0x7ffe009c5450 5f
0x7ffe009c5451 0
0x7ffe009c5452 0
0x7ffe009c5453 0
0x7ffe009c5454 74
0x7ffe009c5455 0
0x7ffe009c5456 0
0x7ffe009c5457 0
0x7ffe009c5458 0
0x7ffe009c5459 0
0x7ffe009c545a 0
0x7ffe009c545b 0
char16_t ----------------
0x7ffe009c5410 ffffffd9
0x7ffe009c5411 ffffff8f
0x7ffe009c5412 2f
0x7ffe009c5413 66
0x7ffe009c5414 20
0x7ffe009c5415 0
0x7ffe009c5416 63
0x7ffe009c5417 0
0x7ffe009c5418 68
0x7ffe009c5419 0
0x7ffe009c541a 61
0x7ffe009c541b 0
0x7ffe009c541c 72
0x7ffe009c541d 0
0x7ffe009c541e 31
0x7ffe009c541f 0
0x7ffe009c5420 36
0x7ffe009c5421 0
0x7ffe009c5422 5f
0x7ffe009c5423 0
0x7ffe009c5424 74
0x7ffe009c5425 0
0x7ffe009c5426 0
0x7ffe009c5427 0
char32_t ----------------
0x7ffe009c5460 ffffffd9
0x7ffe009c5461 ffffff8f
0x7ffe009c5462 0
0x7ffe009c5463 0
0x7ffe009c5464 2f
0x7ffe009c5465 66
0x7ffe009c5466 0
0x7ffe009c5467 0
0x7ffe009c5468 20
0x7ffe009c5469 0
0x7ffe009c546a 0
0x7ffe009c546b 0
0x7ffe009c546c 63
0x7ffe009c546d 0
0x7ffe009c546e 0
0x7ffe009c546f 0
0x7ffe009c5470 68
0x7ffe009c5471 0
0x7ffe009c5472 0
0x7ffe009c5473 0
0x7ffe009c5474 61
0x7ffe009c5475 0
0x7ffe009c5476 0
0x7ffe009c5477 0
0x7ffe009c5478 72
0x7ffe009c5479 0
0x7ffe009c547a 0
0x7ffe009c547b 0
0x7ffe009c547c 33
0x7ffe009c547d 0
0x7ffe009c547e 0
0x7ffe009c547f 0
0x7ffe009c5480 32
0x7ffe009c5481 0
0x7ffe009c5482 0
0x7ffe009c5483 0
0x7ffe009c5484 5f
0x7ffe009c5485 0
0x7ffe009c5486 0
0x7ffe009c5487 0
0x7ffe009c5488 74
0x7ffe009c5489 0
0x7ffe009c548a 0
0x7ffe009c548b 0
0x7ffe009c548c 0
0x7ffe009c548d 0
0x7ffe009c548e 0
0x7ffe009c548f 0
utf-8 ----------------
0x7ffe009c53f4 ffffffe8
0x7ffe009c53f5 ffffffbf
0x7ffe009c53f6 ffffff99
0x7ffe009c53f7 ffffffe6
0x7ffe009c53f8 ffffff98
0x7ffe009c53f9 ffffffaf
0x7ffe009c53fa 20
0x7ffe009c53fb 75
0x7ffe009c53fc 74
0x7ffe009c53fd 66
0x7ffe009c53fe 2d
0x7ffe009c53ff 38
0x7ffe009c5400 0

wchar_t 是根据 locale 决定的,不同的主机环境可能不一样

个人认为的最佳实践

  1. 假设这不是嵌入式环境
  2. 源码全部用 utf-8 无 bom 编码
  3. 尽量用 icu 或 iconv 处理编码
  4. 尽量使用标准库的函数
    • 如果确定程序只在 windows 下运行,可以用 windows 的 api
    • 其实笔者觉得 windows 的 api 比 icu 或 iconv 都要好用
  5. 从外部接收字符串时,统一转换成一种编码再处理
    • 如果不是 c 的话其实全部转换成 utf-8 就好了,但 c 处理 utf-8 不像其它语言那样方便。
    • 笔者比较喜欢把编码转换成 utf-32 。因为 ascii 不能显示中文, utf-8 和 utf-16 不是定长编码。 utf-32 虽然有点浪费内存,但代码写起来会简单不少。
    • 又或者只处理 ascii 完全忽略中文
  6. 输入和输出的编码通过配置来确定
    • 命令行参数
    • 环境变量
    • 配置文件
  7. 编译时显式声明源码的编码和运行时的编码
  8. 程序运行时要确保 shell 和终端能接收和输出对应的编码

参考

https://zh.cppreference.com/w/c/string/byte

https://zh.cppreference.com/w/c/string/multibyte

https://zh.cppreference.com/w/c/string/wide

https://docs.microsoft.com/zh-cn/cpp/c-runtime-library/string-manipulation-crt?view=msvc-160

13 C++ 里的字符串

C++ 里的字符处理大致和 C 一致。

C++ 里的字符串处理,就比 C 稍微方便一点。

C++ 处理字符串大致有这几种方式

14 各种乱码

原因

乱码出现的原因通常是程序没有用正确的解码器解码编码。 例如

这里只描述因为编码原因而导致的乱码,这里不讨论因为字体的原因而导致的乱码

和 vs 相关

一般情况下, windows 的系统语言设为简体中文,那么 cmd 的默认编码是 cp936 也就是 gbk 。

vc 会用一些初值来填充未赋初值或回收后的内存空间, 当这些填充的值按字符输出时,就会按照 cmd 的默认编码来显示字符。

烫 屯 葺 就是这类问题

   
CC CC
CD CD
DD DD

和 utf-8 bom 相关

表现

原因

更详细的解释

和 UTF-8 的替换字符相关

unicode 里有一个替换字符用于表示无法识别的字符

   
Replacement Character
Unicode number 65533
UTF-8 EF BF BD
UTF-16 FF FD

当以 UTF-8 方式读取 GBK 编码的中文时,就会把大量的字符显示为 � , 这时保存文件,就会把原本 GBK 编码的字符替换成 � 。 保存后又用 GBK 格式再次读取, 文件的内容就会被显示为 锟斤拷 。

两个连续的 UTF-8 替换字符 EF BF BD EF BF BD , 这 6 个字节在 gbk 里刚好能被解释成三个字符 锟斤拷 。

   
EF BF
BD EF
BF BD

其它

「古文码」

「符号码」

「拼音码」

「符号码」 和 「拼音码」 在 eclipse 里比较常见,因为 eclipse 的默认编码是 ISO8859-1 。 据说新版的 eclipse 已经将默认编码改为 utf-8

当文件出现乱码时

  1. 不要修改文件
  2. 不要保存文件
  3. 最好先把原文件备份
  4. 让编辑器自动地识别文件的编码
  5. 编辑器无法正确地识别编码时,手动选择编码
  6. 把各种编码都尝试完后,还是乱码,那么文件可能已经损坏了
  7. 尽量以 utf-8 无 bom 编码来新建文件

14 python 的编码问题

python2

  1. 在未声明编码的情况下会以 ASCII 编码运行
    • 所以 python 的源文件的第一行或第二行都是声明编码
  2. 和字符相关的是这两种类型 str 和 unicode
  3. str 本质上是一个单字节的字符数组,类似于 c 里的 char[]
    • 如果只处理 ASCII 确实没有问题,但中文编码都是多字节编码
    • 直接用单引号或双引号声明的变量是 str 类型
        "str"
        "中文"
      
  4. unicode 才是现代编程语言里的字符
    • 需要在单引号或双引号前面加一个字母 u
        u"str"
        u"中文"
      
  5. 在输入或输出时都需要 str 类型
    • 所以从文件或爬虫里获取的数据时,需要先转换成 unicode 才能继续处理(如果只是 ASCII 就不需要转换),不然就是一堆乱码
        a.decode("gbk")
      
    • 所以输出或写入文件时,需要先把 unicode 转换成 str ,不然会报各种错误
        b.encode("gbk")
      

python3

  1. 在未声明编码的情况下会以 utf-8 编码运行
    • 虽然默认以 utf-8 编码运行,但声明编码这个传统还是保留下来了
  2. python2 的 str 类型修改成 bytes 类型
    • 需要声明 bytes 的变量需要在单引号或双引号前面加一个b
    • 但不能直接声明包含中文的变量
        b"byets"
        b"中文" # 这样会报错
      
  3. python2 的 unicode 类型修改成 str 类型
    • 直接用单引号或双引号声明的变量是 str 类型
        "byets"
        "中文"
      
  4. 两种字符的类型不能直接互相操作,在 python2 里是可以的
  5. 对于输入和输出的处理和 python2 是类似的