最近在 Windows 上开发一些逻辑的时候遇到一些关于中文的坑,中文路径会乱码,是由于 Window 系统默认的编码格式是 GBK,而传入的参数编码格式是 UTF-8,导致整个程序出错。后续使用了``MultiByteToWideCharWideCharToMultiByte` 方法对编码进行一次改变,从而避免了这个问题的产生。但不了解相关原因,经过一番学习,对相关的概念进行一些简单的总结,并对一些 api 的实现源码进行分析。

ASCII 码

ASCII ( American Standard Code for Information Interchange)
256个符号,从 00000000 到 11111111

ANSI

ANSI(American National Standards Institute,美国国家标准协会)编码:ANSI 编码是一种基于 8 位的字符编码。它包含了 128 个美国英语字符和其他 128 个特殊字符,共 256 个字符。ANSI 编码主要用于表示英语字符,但它的局限性在于无法表示其他语言的字符。为了解决这个问题,各国家和地区分别制定了自己的 ANSI 编码标准,但这又引入了新的问题,即不同编码之间的互不兼容。

​ 美国和西欧:Windows-1252
​ 中文(简体):GB2312 或 GBK
​ 中文(繁体):Big5
​ 日文:Shift-JIS
​ 韩文:EUC-KR

Unicode

为了解决字符编码之间的兼容性问题,Unicode 标准应运而生。Unicode 是一种包含世界上大多数字符的编码方案,它为每个字符分配一个唯一的数字(称为码点),无论在任何平台、程序或语言中,都可以表示这些字符。Unicode 有多种实现方式,如 UTF-8、UTF-16 和 UTF-32。UTF-8 是最常用的 Unicode 实现方式,它是一种变长编码,可以使用 1 到 4 个字节来表示一个字符,这使得它在存储和传输方面更加高效

“FE FF” 是 Unicode 字符串的字节顺序标记(Byte Order Mark,简称 BOM),用于表示字符串的字节顺序
Unicode Little-Endian,”FF FE”
Unicode Big-Endian,”FE FF”

UTF-8

UTF-8 是 Unicode 的实现方式之一 ,是一种变长编码,它使用 1 到 4 个字节(8 位)来表示一个字符

单字节 所有的ASCII 字符
二字节 带有附加符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及它拿字母则需要二个字节编码

三字节 基本等同于GBK,含21000多个汉字

四字节 中日韩超大字符集里面的汉字,有5万多个

UTF-8编码对照表

Unicode 符号范围 (十六进制) UTF-8编码方式(二进制)
0000 0000 ~ 0000 007F 0xxxxxxx
0000 0080 ~ 0000 07FF 110xxxxx 10xxxxxx
0000 0800 ~ 0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx
0001 0000 ~ 0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

源码阅读:Java String toUtf8

Java 的 String 默认用 UTF-16 存储数据,String 类的方法.getBytes(StandardCharsets.UTF_8) 将指定的字符集将字符串编码为 byte 序列,并将结果存储到一个新的 byte 数组中。

其主要逻辑在:CharsetUtils.java#toUtf8Bytes

1
public static native byte[] toUtf8Bytes(String s, int offset, int length);

对应的最终实现:java_nio_charset_Charsets.cpp#Charsets_toUtf8Bytes

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
static jbyteArray Charsets_toUtf8Bytes(JNIEnv* env, jclass, jcharArray javaChars, jint offset, jint length) {
// ....此处省略 一些检查逻辑
const int end = offset + length;
for (int i = offset; i < end; ++i) {
jint ch = chars[i];
if (ch < 0x80) {
// 单字节直接放进去
if (!out.append(ch)) {
return NULL;
}
} else if (ch < 0x800) {
// 双字节
if (!out.append((ch >> 6) | 0xc0) || !out.append((ch & 0x3f) | 0x80)) {
return NULL;
}
} else if (U16_IS_SURROGATE(ch)) {
// ....此处省略 UTF-16 代理字符串相关的逻辑
ch = U16_GET_SUPPLEMENTARY(high, low);
// 四字节
jbyte b1 = (ch >> 18) | 0xf0;
jbyte b2 = ((ch >> 12) & 0x3f) | 0x80;
jbyte b3 = ((ch >> 6) & 0x3f) | 0x80;
jbyte b4 = (ch & 0x3f) | 0x80;
if (!out.append(b1) || !out.append(b2) || !out.append(b3) || !out.append(b4)) {
return NULL;
}
} else {
// 三字节.
jbyte b1 = (ch >> 12) | 0xe0;
jbyte b2 = ((ch >> 6) & 0x3f) | 0x80;
jbyte b3 = (ch & 0x3f) | 0x80;
if (!out.append(b1) || !out.append(b2) || !out.append(b3)) {
return NULL;
}
}
}
return out.toByteArray();
}

整体的逻辑非常的好理解:判断输入值的区间,并分成单双三四字节的处理逻辑,其中有处理 UTF-16 代理字符串相关的逻辑此处忽略,可以了解代理项和增补字符。对应单字节符号处理,直接将原始值返回即可,其他的字节就一个一个地获取,这里分析一下对于双字节的逻辑处理。获取第一个字节的逻辑为:(ch >> 6) | 0xc0 第二个字节逻辑为 (ch & 0x3f) | 0x80

  • (ch >> 6) | 0xc0

    第一个字节的前两位是 11(十六进制中的 0xc0),后面的 5 位是 Unicode 码点的高 5 位

  • (ch & 0x3f) | 0x80

    第二个字节的前两位是 10(十六进制中的 0x80),后面的 6 位是 Unicode 码点的低 6 位

举例,希腊符号 ε(epsilon) 在 UTF-8 编码里面是用双字节表示, Unicode 为 0x03B5 对应二进制数据:0000001110110101,计算流程如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# ε 0x03B5 to UTF-8 

# 第一个字节 (ch >> 6) | 0xc0
0000001110110101 >> 6
0000001110 | 0xc0 (11000000)
11000000
||
11001110
0xCE

# 第二个字节 (ch & 0x3f) | 0x80
0000001110110101 & 0x3f (111111)
111111
110101 | 0x80 (10000000)
10000000
||
10110101
0xB5

从而计算出 ε 对应的 UTF-8 Encoding为0xCE 0xB5

“锟斤拷”和“烫”

“锟斤拷”通常发生在UTF-8 到 GBK 编码的转换中,在 UTF-8 编码中,”0xEF 0xBF 0xBD” 是一个特殊的字符,表示 REPLACEMENT CHARACTER(替换字符),当解码器在解码字节序列时遇到无法识别的字节或无效的编码时,通常会用 REPLACEMENT CHARACTER(U+FFFD)替换这些无效的字节 ,”0xEF 0xBF 0xBD” 在 GBK 里面则编码成 “锟斤拷”。

“烫” 则是由于在 Windows 操作系统中,开发者在使用调试器调试程序时,会发现未初始化的内存通常会被填充为0xCC,而”0xCC” 在 GBK 里面则编码成“烫”。

总结

本文主要讨论了字符编码的一些基本概念和原理,包括 ASCII、ANSI、Unicode 和 UTF-8 编码,文章分析了 Java String 类的.getBytes(StandardCharsets.UTF_8)方法的实现源码,解释了将 Unicode 字符串转换为 UTF-8 编码字节序列的过程,最后介绍了一下 “锟斤拷”和”烫”为什么会被展示。