ASCII 码诞生于上世纪 60 年代的美国,它将英文字符和二进制位之间的关系做了统一规定:将 128 个英文的字符映射到一个字节的后 7 位,最前面的一位统一规定为 0。因此 ASCII 码正好使用一个字节存储一个字符,又被称为原始 8 位值,由于最高位始终为 0 ,也被称为 7 位 ASCII 编码。在 ASCII 编码中,将数字 0 映射到 48,将大写字母 A 映射到 65,将小写字母 a 映射到 97 等等。
ASCII 编码简单好用,只占用一个字节,但它只能表示 128 个字符。
Unicode 将世界上所有的符号都纳入其中,对世界上所有的符号都赋予一个独一无二的编码,那么,满足了在同一个文本信息中混合使用不同的语言文字的需求。2023 年 9 月 12 日发布的 Unicode 15.1.0 版本已经收录了 149,813 个字符,其中还包含了很多 emoji 符号。每个字符都被映射至一个整数编码,编码范围为 0~0x10FFFF 。注意,这里仅仅用了三个字节而已。
Unicode 编码创建时面临的几个问题:
Unicode 编码采用了变长存储方式解决了存储效率的问题,采用了特殊标志位解决了兼容性的问题。为此,Unicode 规定了几种储存编码的方式,这些方式被称为 Unicode 转换格式 UTF。经常听到的Unicode 为表现形式,UTF-8 为存储形式。即 UTF-8 解码之后为 Unicode ,Unicode 可以编码成 UTF-8 。同样,存储形式也可以是UTF-32,但是存储的内容解码后依然表现为Unicode。存储形式不唯一,但是内容的表现形式是唯一的。
每种 Unicode 转换格式都会把一个编码存储为一到多个编码单元,如 UTF-8 的编码单元为 8 位的字节;UTF-16 的编码单元为 16 位,即 2 个字节;UTF-32 的编码单元为 32 位,即 4 个字节。这里单字节作为一个存储单元,是不存在字节的大端和小段的问题的。但是如果使用 2 字节和 4 字节作为一个存储单元,在存储时会涉及到大小端的问题。大端模式和小端模式的多字节数据在内存中的排列方式有所不同。比如0x0001
在大端模式下被存储为 \x00\x01
,而在小端模式下被存储为\x01\x00
,与我们的阅读顺序刚好相反 。
所以,为了简单,大家一般使用的是UTF-8 编码标准。
前面提到,Unicode 编码采用了变长存储方式解决了存储效率的问题,采用了特殊标志位解决了兼容性的问题。具体体现如下:
当前UTF-8 编码标准一共支持到了4字节的编码,原则上可以支持8字节的,不过万国文字加起来也没有那么多的。编码规则的表格如下,字母 x 表示可用于编码的位,即 Unicode 码分布的位置:
Unicode 码范围(十六进制) | UTF-8 编码方式 (二进制) |
---|---|
0x00 ~ 0x7F | 0xxxxxxx |
0x80 ~ 0x7FF | 110xxxxx 10xxxxxx |
0x800 ~ 0xFFFF | 1110xxxx 10xxxxxx 10xxxxxx |
0x10000 ~ 0x10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
总结:
10
,相当于协议头,不是协议数据对于 UTF-8 编码的字符,其长度信息并不仅仅由首字节的高 5 位决定。实际上,UTF-8 编码规则中使用了多字节编码来表示较大的 Unicode 字符。
在 UTF-8 编码中,根据首字节的高位,可以确定字符的长度范围:
0xxxxxxx
,则表示该字符是单字节字符,长度为 1。110xxxxx
,则表示该字符是双字节字符,长度为 2。1110xxxx
,则表示该字符是三字节字符,长度为 3。11110xxx
,则表示该字符是四字节字符,长度为 4。对于长度超过 4 的字符,UTF-8 编码规则使用了更多的字节来表示。
长度为 5 的字符采用 5 字节编码,首字节的高位是 111110xx
。
长度为 6 的字符采用 6 字节编码,首字节的高位是 1111110x
。
然而,需要注意的是,UTF-8 编码规范中规定了 Unicode 字符的范围,而且并不是所有的 Unicode 字符都可以用 UTF-8 编码表示。UTF-8 编码只能表示 Unicode 字符集中的一部分。
在处理 UTF-8 编码时,我们需要根据首字节的高位来确定字符的长度,并根据长度信息来解码后续的字节。对于长度超过 4 的字符,可能需要使用更多的逻辑来处理。这包括检查后续字节的格式和范围,以确保正确解码字符。
我们可以按照上述规则一个一个条件判断去完成解析,但是这会带来较大的性能损失,因为这会让CPU中的分支预测器疲于奔命,每次预测错误都会带来一定的性能损失。所以, Christopher Wellons 想出来了一种a branchless decoder
,即无分支的解码器,主要根据前5位组成数字的所有可能性进行查表,完成解码。
再看一下上面的表格,我们来梳理一下首字节的数字规律。
长度 | 通配符 | 最小值 | 最大值 |
---|---|---|---|
1 | 0**** | 00000(0) | 01111(15) |
2 | 110** | 11000(24) | 11011(27) |
3 | 1110* | 11100(28) | 11101(29) |
4 | 11110 | 11110(30) | 11110(30) |
ok,前5位规律如下,其他的数值都是非法的UTF-8 编码,返回长度0就行了。
那么,输入前五位对应的数值,返回长度的映射表如下:
"\1\1\1\1\1\1\1\1\1\1\1\1\1\1\1\1\0\0\0\0\0\0\0\0\2\2\2\2\3\3\4"
11111 5
11111 10
11111 15
10000 20
00002 25
22233 30
4 31
通过这个预定义表,我们可以根据 UTF-8 字符的首字节的高 5 位来快速确定字符的长度。例如,如果首字节的高 5 位是 110**
,我们就可以根据预定义表得知对应的字符长度为 2。这种查表的方式非常高效,并且避免了显式的分支判断逻辑,避免了因分支预测带来的cpu流水线排空引发的性能损失。
需要注意的是,这个预定义表是根据 UTF-8 编码规则来设计的,并且假设输入的字符串是合法的 UTF-8 字符串。对于非法的或损坏的 UTF-8 字符串,这个预定义表可能无法正确解析字符的长度。因此,在使用这个预定义表时,需要确保输入的字符串是有效的 UTF-8 编码。
额外吐槽一下,查表法真的是最常见的优化手法了,比如前两天我刚刚看到了这个东西,把数字转换为ASCII码,并且不做任何加法运算。。。。在下目瞪狗呆。
// Converts value in the range [0, 100) to a string.
const char* digits2(size_t value) {
// GCC generates slightly better code when value is pointer-size.
return &"0001020304050607080910111213141516171819"
"2021222324252627282930313233343536373839"
"4041424344454647484950515253545556575859"
"6061626364656667686970717273747576777879"
"8081828384858687888990919293949596979899"[value * 2];
}
我们仍然按照长度1-4来列举所有可能性进行查表,完成解码,这里的通配符和前面协议头的通配符刚刚好相反,首字节一共8位,协议头长度越长,真实数据长度越短。
长度 | 通配符 | 有效长度 | 真实数据的掩码 |
---|---|---|---|
1 | X111 1111 | 7 | 0111 1111 (0x7f) |
2 | XXX1 1111 | 5 | 0001 1111 (0x1f) |
3 | XXXX 1111 | 4 | 0000 1111 (0x0f) |
4 | XXXX X111 | 3 | 0000 0111 (0x07) |
除了首字节,其他字节都是有效长度6位,掩码 0011 1111 (0x3f)
除了首字节实际数据是不定长的,其他字节都是6位,即
|首字节长度| 6 | 6 | 6 |
这里先一次计算完毕4字节的内容,然后根据字节长度再进行右移,扔掉冗余内容。当然,出发点还是为了分支预测的性能。
//input: raw str s
//output:decoded str c
//output:error flag e
//return:next char* after utf-8 decode
inline auto utf8_decode(const char* s, uint32_t* c, int* e)
-> const char* {
constexpr const int masks[] = {0x00, 0x7f, 0x1f, 0x0f, 0x07};
constexpr const uint32_t mins[] = {4194304, 0, 128, 2048, 65536};
constexpr const int shiftc[] = {0, 18, 12, 6, 0};
constexpr const int shifte[] = {0, 6, 4, 2, 0};
int len = "\1\1\1\1\1\1\1\1\1\1\1\1\1\1\1\1\0\0\0\0\0\0\0\0\2\2\2\2\3\3\4"
[static_cast<unsigned char>(*s) >> 3];
// Compute the pointer to the next character early so that the next
// iteration can start working on the next character. Neither Clang
// nor GCC figure out this reordering on their own.
const char* next = s + len + !len;
using uchar = unsigned char;
// Assume a four-byte character and load four bytes. Unused bits are
// shifted out.
*c = uint32_t(uchar(s[0]) & masks[len]) << 18;
*c |= uint32_t(uchar(s[1]) & 0x3f) << 12;
*c |= uint32_t(uchar(s[2]) & 0x3f) << 6;
*c |= uint32_t(uchar(s[3]) & 0x3f) << 0;
*c >>= shiftc[len];
// Accumulate the various error conditions.
*e = (*c < mins[len]) << 6; // non-canonical encoding
*e |= ((*c >> 11) == 0x1b) << 7; // surrogate half?
*e |= (*c > 0x10FFFF) << 8; // out of range?
*e |= (uchar(s[1]) & 0xc0) >> 2;
*e |= (uchar(s[2]) & 0xc0) >> 4;
*e |= uchar(s[3]) >> 6;
*e ^= 0x2a; // top two bits of each tail byte correct?
*e >>= shifte[len];
return next;
}
普及一个词——代码点(code point)是指 Unicode 字符集中的字符的唯一标识符。Unicode 使用整数值来表示每个字符,这些整数值被称为代码点。
写作不易,给点个赞吧。。。。。。