Unicode 学习记录

文章目录

前段时间有一道不是很难的 Python 沙箱逃逸问题,用到了 Unicode 的 NFKC。这一次详细记录一下 Unicode 里有点意思的特性。

NFKC

利用 NFKC 算是 Python 沙箱逃逸类题目里较为常用的一种方式。在编写攻击载荷时不得不用到某个字符,但是这个字符又被列入了检测的黑名单中,则会利用 Unicode 的 NFKC 标准化,而 Python 恰好也支持 NFKC,这不就巧了嘛!

简单来说,NFKC 可以让程序更好地理解一些字符,它将那些形状类似但是编码不同的字符归为一组字符。例如说在 Unicode 中合字 ffi (U+FB03) 在视觉上等同于 ffi 三个字符拼凑而成,因此需要计算机软件能够识别 ffi 三个字符等同于 ffi 合字字符,以便于用户检索。

因此在 Python 中就会有如下输出:

1print("1" == "1")  # U+FF11
2print(int("1") == int("1"))
3
4# output:
5#
6# False
7# True

在下面两个网站里可以找到取代某个 ASCII 字符的 Unicode 字符:

Github - h13t0ry/UnicodeToy: Unicode fuzzer for various purposes

List of Unicode Characters of Bidirectional Class “European Number”

看下面一道例题:

1from secret import flag
2data = input('> ')
3assert len(data) <= 9 and all(i not in '123456789' for i in data) and int(data) == 123456789
4print(flag)

看上去不是很麻烦。题目会在远程服务器上运行,secret 模块及其中的 flag 常量都存储在远程服务器上。 如果我们输入的 data 能够满足这个 assert 中的条件,flag 就会自己跳出来。

条件如下:

  • data 的长度小于等于 9

  • data 中不含 “123456789” 中的任意一个字符

  • data 转化为整型后与 123456789 相等

根据上面提到的内容,我们知道这道题需要利用 NFKC 得到正确的输入,不妨用下述代码遍历:

 1// filename: Exp_123456789
 2
 3public class Exp_123456789 {
 4    public static void main(String[] args) {
 5        // superscript numbers
 6        System.out.printf("%c%c%c", '\u00B9', '\u00B2', '\u00B3');
 7        for(char i = '\u2074'; i <= '\u2079'; i++) {
 8            System.out.print(i);
 9        }
10        System.out.print('\n');
11        // subscript numbers
12        for(char i = '\u2081'; i <= '\u2089'; i++) {
13            System.out.print(i);
14        }
15        System.out.print('\n');
16        // numbers with full stop
17        for(char i = '\u2488'; i <= '\u2490'; i++) {
18            System.out.print(i);
19        }
20        System.out.print('\n');
21        //full width numbers
22        for(char i = '\uFF11'; i <= '\uFF19'; i++) {
23            System.out.print(i);
24        }
25    }
26}
27
28/*
29output:
30¹²³⁴⁵⁶⁷⁸⁹
31₁₂₃₄₅₆₇₈₉
32⒈⒉⒊⒋⒌⒍⒎⒏⒐
33123456789
34*/

其实这段代码用 Python 实现更为方便,为了锻炼 Java 能力就先这么写了。不过后面判断哪一组数据符合条件,依然要使用 Python:

 1code = "assert len(data) <= 9 and all(i not in '123456789' for i in data) and int(data) == 123456789"
 2try:
 3    data = "¹²³⁴⁵⁶⁷⁸⁹" # superscript numbers
 4    exec(code)
 5    print(data)
 6except:
 7    try:
 8        data = "₁₂₃₄₅₆₇₈₉" # subscript numbers
 9        exec(code)
10        print(data)
11    except:
12        try:
13            data = "⒈⒉⒊⒋⒌⒍⒎⒏⒐" # numbers with full stop
14            exec(code)
15            print(data)
16        except:
17            data = "123456789" # full width numbers
18            exec(code)       
19             print(data)
20
21# output: 123456789

宽字符赢得了比赛!

零宽字符隐写

前几天想用零宽字符给述职报告凑字数,然后知道了这个东西。不妨先看看零宽字符是什么个东西。通俗来讲,零宽字符就是“宽度为 0 的字符”,一般在从右向左书写的语言以及字与字之间有连笔的语言,如阿拉伯语。下面是一个零宽字符的例子:

1string = "A" + "\u200C\u200C\u200D\u200D" + "b"
2print(string)
3print(len(string))
4
5# output:
6#
7# A‌‌‍‍b
8# 6

在上面的例子中,我们用到了 U+200CU+200D 这两个零宽字符,它们分别是 “断字符” 和 “连字符”,用于控制阿拉伯语的连笔,故在英文语境下不会被打印出来。下面这张表是 Unicode 中全部零宽字符:

编码 描述
U+200A ZERO WIDTH SPACE
U+200B ZERO WIDTH SPACE
U+200C ZERO WIDTH NON-JOINER
U+200D ZERO WIDTH JOINER
U+200E LEFT-TO-RIGHT MARK
U+200F LEFT-TO-RIGHT MARK
U+202A LEFT-TO-RIGHT EMBEDDING
U+202C POP DIRECTIONAL FORMATTING
U+202D LEFT-TO-RIGHT OVERRIDE
U+2062 INVISIBLE TIMES
U+2063 INVISIBLE SEPARATOR
U+FEFF ZERO WIDTH NO-BREAK SPACE

如果一段文本中夹杂了零宽字符,一般的文本编辑器无法显示,但是放入 Cyberchef 或者 Vim 中就能看到了。用上面的 "A‌‌‍‍b" 举例子:

那么什么是零宽字符隐写呢?零宽字符隐写将密文对应的编码转化为不同的零宽字符,并将其混杂在正常的文本中。例如说,我使用 U+200CU+200D 两种零宽字符作为用于加密,我可以令 U+200C 对应二进制 0,U+200D 对应二进制 1。下面这段代码,将 "Meschenrechte" 这个单词隐写入 "Hello, World!" 这句话中:

 1print("Hello, World", end = '')
 2secret = "Meschenrechte"
 3
 4for i in secret:
 5    s = str(bin(ord(i)))[2:]
 6    print("\u200C", end = '')
 7    for k in s:
 8        if k == "0":
 9            print("\u200C", end = '')
10        else:
11            print("\u200D", end = '')
12
13print("!")
14
15# output: Hello, World‌‍‌‌‍‍‌‍‌‍‍‌‌‍‌‍‌‍‍‍‌‌‍‍‌‍‍‌‌‌‍‍‌‍‍‌‍‌‌‌‌‍‍‌‌‍‌‍‌‍‍‌‍‍‍‌‌‍‍‍‌‌‍‌‌‍‍‌‌‍‌‍‌‍‍‌‌‌‍‍‌‍‍‌‍‌‌‌‌‍‍‍‌‍‌‌‌‍‍‌‌‍‌‍!

在这一样例程序,我们只是把密文转换成 U+200CU+200D 两种零宽字符并全部放入了 'd' 与 '!' 之间的位置。然而在实际操作中,密文可以使用其他几种零宽字符、使用其他编码格式、打乱顺序等等。由此可见,零宽字符隐写并没有一个统一的标准,我可以用任意的符号来代替一种编码,因此实际上零宽字符隐写最大的优势在于它看不见。正是出于这个原因,我认为 2023 安洵杯“疯狂的麦克斯”一题并不严谨。有许多在线工具可以进行零宽字符的加密与解密,安洵杯中使用的是这个:

Unicode Steganography with Zero-Width Characters

哦对了,还需要一个解密脚本,对应我自己设计的隐写脚本:

 1text = "Hello, World‌‍‌‌‍‍‌‍‌‍‍‌‌‍‌‍‌‍‍‍‌‌‍‍‌‍‍‌‌‌‍‍‌‍‍‌‍‌‌‌‌‍‍‌‌‍‌‍‌‍‍‌‍‍‍‌‌‍‍‍‌‌‍‌‌‍‍‌‌‍‌‍‌‍‍‌‌‌‍‍‌‍‍‌‍‌‌‌‌‍‍‍‌‍‌‌‌‍‍‌‌‍‌‍!"
 2cip = text[12: -1]
 3
 4for i in range(0, 104, 8):
 5    seg = cip[i: i + 8]
 6    dat = 0
 7    for k in range(0, 8):
 8        if seg[k] == "\u200D":
 9            dat = dat + 2**(7 - k)
10    print(chr(dat), end = '')
11
12# output: Meschenrechte

试了一下,我自己的隐写脚本得到的结果,拖到那个网站里解密会失败 (っ °Д °;)っ

Unicode 中的零宽字符早已被人们开发出更多的用途,例如添加文字水印防止剽窃、反爬虫摸出关键字防止屏蔽或者给文章凑字数。

Unicode 附加字符

这个似乎与 CTF 的关系并不大,但是也挺有意思。演示一下:

̀́̂̃̄̅̆̇̈ ̉̊̋̌̍̎̏̐̑ ̖̗̘̙̒̓̔̕̚ ̡̢̛̜̝̞̟̠̣ ̧̨̤̥̦̩̪̫̬ ̴̵̭̮̯̰̱̲̳ ̶̷̸̹̺̻̼̽̾ ͇̿̀́͂̓̈́͆ͅ ͈͉͍͎͊͋͌͏͐ ͓͔͕͖͙͑͒͗͘ ͚͛͜͟͢͝͞͠͡ ͣͤͥͦͧͨͩͪͫ ͬͭͮͯ

这一坨是 Unicode 中的一些附加字符堆在一块的样子。附加字符在很多情境下都会被使用,例如:拼音。下面两组字符虽然看上去一样,但是对应的编码不一样。前者使用附加字符实现,后者是微软输入法特殊字符表中的打印字符。

ö:\u006F\u0308

ö:\u00F6

用 Python 就可以打印出附加字符。

1string = ' '
2
3for char in range(0x300, 0x310):
4    string = string + chr(char)
5
6print(string)
7
8
9# output:  ̀́̂̃̄̅̆̇̈̉̊̋̌̍̎̏

于是我们得到了一张鬼画符(?)

Unicode 附加字符的优点正如你所看到的一样:更加灵活。

代码混淆

Python 似乎是支持中文变量名的:

 1import dis as 蒂斯
 2
 3def 我再也不学Python了():
 4    苹果 = 10
 5    香蕉 = 20
 6    print(苹果 + 香蕉)
 7    print(type(我再也不学Python了))
 8
 9我再也不学Python了()
10
11蒂斯.dis(我再也不学Python了)
12
13'''
14output:
15
1630
17<class 'function'>
18 38           0 RESUME                   0
19
20 39           2 LOAD_CONST               1 (10)
21              4 STORE_FAST               0 (苹果)
22
23 40           6 LOAD_CONST               2 (20)
24              8 STORE_FAST               1 (香蕉)
25
26 41          10 LOAD_GLOBAL              1 (NULL + print)
27             20 LOAD_FAST                0 (苹果)
28             22 LOAD_FAST                1 (香蕉)
29             24 BINARY_OP                0 (+)
30             28 CALL                     1
31             36 POP_TOP
32
33 42          38 LOAD_GLOBAL              1 (NULL + print)
34             48 LOAD_GLOBAL              3 (NULL + type)
35             58 LOAD_GLOBAL              4 (我再也不学Python了)
36             68 CALL                     1
37             76 CALL                     1
38             84 POP_TOP
39             86 RETURN_CONST             0 (None)
40
41'''

这种混淆算是较为一般的混淆,单纯修改变量名而不对运行逻辑进行修改,有些类似于“0O”混淆。但是这不禁让我想到,既然中文能够作为变量名,那么是不是大部分 Unicode 字符都可以当作变量名,甚至包括上文提到的零宽字符!

实际上是不能的。但是零宽字符确实可以用于另一种形式的代码混淆,例如下面这段代码:

1encoded = "‍‍‍‌‌‌‌​‍‍‍‌‌‍‌​‍‍‌‍‌‌‍​‍‍‌‍‍‍‌​‍‍‍‌‍‌‌​‍‌‍‌‌‌​‍‌‌‍‍‍​‍‌‌‍‌‌‌​‍‍‌‌‍‌‍​‍‍‌‍‍‌‌​‍‍‌‍‍‌‌​‍‍‌‍‍‍‍​‍‌‍‍‌‌​‍‌‌‌‌‌​‍‌‍‌‍‍‍​‍‍‌‍‍‍‍​‍‍‍‌‌‍‌​‍‍‌‍‍‌‌​‍‍‌‌‍‌‌​‍‌‌‌‌‍​‍‌‌‍‍‍​‍‌‍‌‌‍"
2exec("".join([chr(int(i.replace('\u200C', '0').replace('\u200D', '1'), 2)) for i in encoded.split('\u200B')]))
3
4# output: Hello, World!

我去,很神奇是不是!其原理也简单,使用零宽度字符隐写的方式将实际要执行的代码(在本案例中是 print("Hello, World!))编码,在执行的过程中再解码,有 SMC 那味。此案例中 encoded 生成的方式如下:

1code = """print('Hello, World!')"""
2encoded = '_' + '\u200B'.join([bin(ord(i))[2:].replace('0','\u200C').replace('1','\u200D') for i in code]) + '_'
3print(encoded)
4
5# output: _‍‍‍‌‌‌‌​‍‍‍‌‌‍‌​‍‍‌‍‌‌‍​‍‍‌‍‍‍‌​‍‍‍‌‍‌‌​‍‌‍‌‌‌​‍‌‌‍‍‍​‍‌‌‍‌‌‌​‍‍‌‌‍‌‍​‍‍‌‍‍‌‌​‍‍‌‍‍‌‌​‍‍‌‍‍‍‍​‍‌‍‍‌‌​‍‌‌‌‌‌​‍‌‍‌‍‍‍​‍‍‌‍‍‍‍​‍‍‍‌‌‍‌​‍‍‌‍‍‌‌​‍‍‌‌‍‌‌​‍‌‌‌‌‍​‍‌‌‍‍‍​‍‌‍‌‌‍_
6# 为了便于复制,我在编码的前后各加了一个下划线

UTF-7

UTF-7 编码并不是一种严格的 Unicode 编码。个人认为,这种编码实际上更加类似于 Base64 编码。UTF-7 的出现,是为了满足早期只能传输 7-bit 字符的 SMTP 标准,故现在 UTF-7 编码也正在逐渐被弃用。UTF-7 原理较为简单,下面是一个简单的 UTF-7 与 UTF-8 转化的例子。首先我们拿出待转化的 UTF-8 字符:

然后打印出它的编码的二进制:

1>>> bin(ord('绷'))[2:].rjust(16, '0')
2'0111111011110111'

然后像 Base64 编码那样将其六位一组切开,不够的位置补零。

1>>> ['0111111011110111'.ljust(18, '0')[i: i + 6] for i in range(0, 18, 6)]
2['011111', '101111', '011100']

随后查表。UTF-7 中使用的 Base64 编码表和标准 Base64 编码表有一些区别。UTF-7 使用的编码表并不使用 = 补齐。由此我们可以写出编码转化的最后一步了:

1>>> "".join(['ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'[int(j, 2)] for j in ['011111', '101111', '011100']])
2'fvc'

于是我们得到了 这个汉字的 UTF-7 编码:fvc

把上面的代码碎片合计一下,我们就能得到获取单个 UTF-8 字符的 UTF-7 编码的代码:

1print("".join(['ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'[int(j, 2)] for j in [bin(ord('绷'))[2:].rjust(16, '0').ljust(18, '0')[i: i + 6] for i in range(0, 18, 6)]]))
2
3# output: fvc

UTF-7 编码有许多例外的情况:

  • 62 个数字与英语字母不需要转化;' ( ) , - . / : ? 这九种字符也无需转化。
  • + 被编码做 +-
  • 每一个区块以 + 为开头,- 为结尾。
1print("+" + "-+".join(["".join(['ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'[int(j, 2)] for j in [bin(ord(k))[2:].rjust(16, '0').ljust(18, '0')[i: i + 6] for i in range(0, 18, 6)]]) for k in "绷不住了,笑えるwww"]) + "-")
2
3# output: +fvc-+Tg0-+T08-+ToY-+/ww-+exE-+MEg-+MIs-+AHc-+AHc-+AHc-

上面这行代码能且只能标准地编码非 ASCII 编码字符的 UTF-8 编码字符,而不能对 ASCII 编码字符进行标准地编码,因为有近一半的 ASCII 字符的 UTF-7 编码与其 ASCII 编码一致——正如上文提到的那样。Cyberchef 里面带有 UTF-7 的编码和解码工具,并且那一解码工具能够对我这段非标准的编码进行解码。

其实硬解也能解码,不是吗?

既然编码代码都有了,那不妨把对应的解码代码造出来吧。还是要优雅地塞到一行里去:

1print("".join([chr(int(int((bin('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.index(i[0]))[2:].rjust(6, '0') + bin('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.index(i[1]))[2:].rjust(6, '0') + bin('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.index(i[2]))[2:].rjust(6, '0'))[: -2], 2))) for i in '+fvc-+Tg0-+T08-+ToY-+/ww-+exE-+MEg-+MIs-+AHc-+AHc-+AHc-'[1:-1].split("-+")]))
2
3# output: 绷不住了,笑えるwww

实际上字符串位置是可以用 input() 的,我们不妨改成更易于在 Python Shell 里运行的代码:

1>>> print("+" + "-+".join(["".join(['ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'[int(j, 2)] for j in [bin(ord(k))[2:].rjust(16, '0').ljust(18, '0')[i: i + 6] for i in range(0, 18, 6)]]) for k in input("> ")]) + "-")
2> 先帝创业未半而骈死于槽枥之间
3+UUg-+Xh0-+Uhs-+Tho-+Zyo-+U0o-+gAw-+mog-+a3s-+To4-+af0-+Z6U-+Tks-+lfQ-
4>>> print("".join([chr(int(int((bin('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.index(i[0]))[2:].rjust(6, '0') + bin('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.index(i[1]))[2:].rjust(6, '0') + bin('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.index(i[2]))[2:].rjust(6, '0'))[: -2], 2))) for i in input("> ")[1:-1].split("-+")]))
5> +UUg-+Xh0-+Uhs-+Tho-+Zyo-+U0o-+gAw-+mog-+a3s-+To4-+af0-+Z6U-+Tks-+lfQ-
6先帝创业未半而骈死于槽枥之间

本段 UTF-7 相关代码纯手搓,Just for fun!

Unicode 与 UTF-8、UTF-16、UTF-32

本来这一段应该是留给 UTF-16 的,但是学习了一些内容之后我感觉我对 UTF-8 也一点都不了解。遂把这些合并到一起去。

这四者的关系,Unicode 是字符集,字符集中的每一个字符都有唯一的编号;后面四者为编码方式,用不同的方式表示出某一个字符。举一个例子: 这个字的 Unicode 编号是 U+7EF7,UTF-8 编码是 E7 BB B7,UTF-16LE 编码是 EB AF B7,UTF-32LE 编码是 F1 BB AF A7

下面这张表列出了你能见到 Unicode 编号以及编码的地方:

Unicode 编号 UTF 编码
形如 U+XXXX 的 二进制文本查看器
各种编程语言中 \uXXXX 或者 \UXXXXXXXX
各种编程语言中打印 0xXXXX 对应的字符
Python 中使用 ord() 函数

所以说,本文前面的某些内容是不严谨的(我长期错把 Unicode 编号当作 UTF-8 编码) ㄟ( ▔, ▔ )ㄏ

Unicode 与 UTF-8

Unicode 编号范围是 U+0000U+10FFFF,占据字节大小为 1 到 3 个不等。下表展示了 Unicode 编号长度与 UTF-8 编码长度的关系:

Unicode 编号范围 UTF-8 编码二进制格式 UTF-8 编码占据字节数
U+00 - U+7F 0XXXXXXX 1
U+80 - U+07FF 110XXXXX 10XXXXXX 2
U+0800 - U+FFFF 1110XXXX 10XXXXXX 10XXXXXX 3
U+010000 - U+10FFFF 11110XXX 10XXXXXX 10XXXXXX 10XXXXXX 4

0 - 7F 的范围是 ASCII 编码的范围,这一部分 UTF-8 与 ASCII 编码是一致的。在后面的编码中,每个编码由多个字节组成,其中第一个字节前面由若干个连续的 1 和一个 0 开头,这用于告诉计算机,有多少个连续的 1,就说明字符从这一个字节开始,共占据了多少个字节。其后每一个字节都以 10 开头。中间的 X 则使用 Unicode 编号的二进制填充。

例如 这个字,其 Unicode 编号为 U+7EF7,在上表中显然位于第三行。我们先打印出其二进制:

1>>> bin(ord('绷'))[2:].rjust(16, '0')
20111111011110111

随后按照 4+6+6 的宽度将其切成三份:0111 111011 110111

然后手动填充格式,得到 UTF-8 编码的二进制:11100111 10111011 10110111

1>>> hex(0b111001111011101110110111)
2'0xe7bbb7'

于是,我们得到了 这个字的 UTF-8 编码:E7 BB B7

需要注意的是,填充那一步需要从右向左填充,因而如果最高位是 0 就可以省略(不然你猜为什么 2 字节 UTF-8 编码中可填充的二进制位只有 11 个)。

Unicode 与 UTF-16

与 UTF-8 一样,UTF-16 也是一种变长字节编码格式。不过 UTF-16 的编码方式更为简单粗暴:

  1. 对于编号在 U+0000U+FFFF 的字符,直接把编号当作编码,统一占两个字节
  2. 对于编号在 U+10000U+10FFFF 的字符,先给它减去 0x10000,再填进 110110XX XXXXXXXX 110111XX XXXXXXXX

举个例子:兔

如果你复制下来,你会发现它这个字占了四个字节,它的 Unicode 编号是 U+2F80E。我在“中日韩兼容表意文字区(CJK Compatibility Ideographs)”复制了这个字符,一些日语汉字会出现在这个区间。使用某些日语输入法打印出来的日语汉字的编码会与中文一样,这一区间用于存储日、韩、越语言中特有或者异形汉字。但是根据 NFKC 标准化,在网页上你是能够直接通过搜索“兔”找到这个字的。

把它转换成 UTF-16,首先将其 Unicode 编号减去 0x10000:

1>>> bin(ord('兔') - 0x10000)[2:].rjust(20, '0')
2'00011111100000001111'

随后按照 2+8+2+8 的宽度将其切成四份:00 01111110 00 00001111

然后手动填充格式,得到 UTF-16 编码的二进制:11011000 01111110 11011100 00001111

1>>> hex(0b11011000011111101101110000001111)
2'0xd87edc0f'

于是,我们得到了 兔 这个字的 UTF-16 编码:D8 7E DC 0F

UTTF-16 编码是两个字节为一组读取,因此就要考虑字节序的问题。上面这一种为大端序,也就是 UTF-16BE,其小端序存储形式 UTF-16LE 为 7E D8 0F DC。为了区分大小端序,文件开头部分会有 FE FF(大端序)或 FF FE(小端序)的标注。而 UTF-8 编码中,计算机按顺序每次读取一个字节,因此不存在大小端序的问题。

这时聪明的小朋友就要问了,计算机怎么知道这一个字符占了两个字节还是四个字节呢?这就不得不说 Unicode 的巧妙之处了。如果你仔细观察 Unicode 字符集,你会发现 U+D800U+DBFF 被描述为"High Surrogate"(高代理项),U+DC00U+DFFF 被描述为"Low Surrogate"(低代理项)。这两个区间内(连起来就是一个区间)并不存储字符,而这两个区间又分别是 110110XX XXXXXXXX110111XX XXXXXXXX 的范围。因此,当计算机两字节一组读取到 D8DB 中的某个值,就知道这个字符占据四个字节;读取到 DCDF 中某个字节,就知道这两个字节要跟着前面两个字节一块解码。

Unicode 与 UTF-32

UTF-32 是固定宽度编码,每一个编码占据 4 个字节,因此也许是最好理解的一种编码,直接由 Unicode 编号作为编码,因此 UTF-32 也有浪费存储空间的特点。