源码阅读:utf 8

Posted by XnChen Blog on December 29, 2022

要读懂utf8包如何解析编码,就需要知道utf-8的编码规律。

我们在工作的时候经常遇到Unicode、UTF-8、UTF-16、GBK等概念,打开notepad++的编码选项,可以看到非常多的编码类型,包括我们常见的几种:ANSI、UTF-8、GB2312等等。

本文会提及的内容:

  • unicode和utf-8和ASCII编码的关系

  • unicode和gb的关系

  • go中unicode/utif8包如何从字符流中解析utf-8编码

gb系列

常见的GB,即国标编码字符集系列,包括了GB2312、GBK、GB18030。在互联网上查询他们的演化过程有一种观赏中国计算机发展历史的美……在没有互联网,没有信息互通需求的年代,为了在计算机上显示出中文字符,不同国家的程序员各显神通,分别对ASCII码表进行了扩展。因为ASCII码是128位的,而中文常用字符大约有3000+,全部已知字符更是高达8w+,对于编码空间的挑战更是巨大。因此当时的程序员将使用一个字节表示的ASCII码进行扩展,增加了一个字节来扩充编码空间。这种编码方式被称为DBCS(Double Byte Character Set),特点就是两字节长的汉子字符和一字节长的英文字符并存于同一套编码方案中。

这种方案的编码识别程序会将大于127的值识别为双字节字符集,从GB2312到GBK再到GB18030,每一个方案都是向下兼容,不断进行扩充的。但是除了中国的程序员做编码之外,其他国家和地区同样也有编码的需求,日本有JIS,台湾有BIG5,不约而同地,都是对ASCII码做了扩充之后的本地语言编码集。这样在互联网时代带来了一个问题:当你打开一个由他国编码集编码的网页后,由于编码并不统一,甚至有重合,本地字符处理系统会在屏幕上显示乱码。为了识别这些编码,需要下载对应的编码集识别系统。这种情况对于今天熟练掌握互联网在世界各地冲浪的我们很难想象的,遗迹来自于http回复的content-type中的charset属性,当声明为不同的编码,浏览器会自动识别并使用对应的方式解码该码流。

某种意义上,无论是“车同轨、书同文”,还是巴别塔的传说,语言的统一都是文明交流的基石。互联网时代将unicode的统一编码推广到所有终端,unicode也促成了互联网时代全球一村的图景。当前在大部分的场景下,已经不再需要、也不推荐使用gb编码了,除非真的遇到了比较古老的使用场景。

unicode

unicode是由官方机构unicode联盟所规定的字符库,它建立了一个全世界统一的码表。unicode可以被扩展为unique code point,这解释了它的职责:给予每个字符独一无二的编码。他涵盖的字符范围之广,除了常用的语言系统,还包括了埃及象形文字等历史文字、甚至emoji也在覆盖范围内。

Unicode索引

字符 Unicode(16进制) 注释
🥺 1F97A ぴえん……emoji
ꀀ A000 分布于云贵及广西等地区的彝族语言文字
🜞 1F71E 铁的番红花的炼金术符号
6211  

但是unicode只是一种标准,它能将每个视觉化的字符对应到一个数字,这串数字的存储,也就是将字符代码转换为字节序列,仍需要一种存储编码。要知道unicode,即“统一码”的编码对象非常广泛,当前(15.0)版本已经拥有149186个字符,如果使用定长编码去表示这么大的词库,每个字符都需要五个字节去表示。和ASCII码这种使用7个bit可以表示所有英文字符和相关符号的编码方式相比,存储消耗非常大。因此我们在有了unicode之后,还需要一个存储方式将unicode有效率地存储为机器可识别的编码,utf-8就是一种常见的存储编码。

utf-8

utf-8是unicode的一种存储编码。类似的存储编码还有utf-16、utf-32等。这些存储编码的作用是将unicode字符库中的字符代码转换为计算机存储的字节编码。通过变长地表示不同长度的编码,有效地降低了存储信息所需要的内存空间。

utf-8的变长通过识别规定的每个字节开头的比特位来实现,如下展示:

① 单字节的情况,对应 ASCII:

0???????

② 双字节的情况,第一个字节必须 110 开头,第二个字节开头必须是 10,剩余 11 位用于编码:

110????? 10??????

③ 三字节的情况,第一个字节必须 1110 开头,第二、三个字节开头都必须是 10,剩余 16 位用于编码:

1110???? 10?????? 10??????

④ 四字节的情况,第一个字节必须 11110 开头,后续三个字节开头都必须是 10,剩余 21 位用于编码:

11110??? 10?????? 10?????? 10??????

所以在最极限的情况,utf-8编码的可使用位数为3+6+6+6=21位。在单字节的情况下,utf-8还是兼容ASCII的。

我们熟悉的大部分汉字都是3字节编码的,以单字“我”为例,它的Unicode码是6211(十六进制,二进制是110 0010 0001 0001),还在三字节utf-8编码的范围内,即格式为1110 xxxx 10xx xxxx 10xx xxxx。utf-8从“我”的最低二进制位开始,依次从低位向高位填入格式中的x,多出的位补0,得到1110 0110 1000 1000 1001 0001,即E68891

utf-8和ascii码的关系在于,utf-8是兼容ascii码的:utf-8的前127位(0x00~0x7F)和ascii码相同,因此针对utf-8的识别程序会直接将第一位为0的字节输出。

使用unicode和utf-8编码,计算机的使用者可以混用不同国家不同地区的符号表示。比如在各种sns中常用的颜文字,使用者不需要知道使用字符的意义和发音,符号发挥了最本质的形态意义。比如(ಥ﹏ಥ),作为眼睛的部分实际上是卡纳达语文字,在印度卡纳塔克邦用作官方语言,读作tha。嘴巴则是来自unicode的中日韩兼容形式分区,命名为波浪形下划线。这种混搭是互联网时代的有趣发明,也只有在这个时代,不同的文化历史才会被组合在一起,形成日常交流中的表达。

编码方式

go编译器能够识别并编译utf-8编码的程序文件,在编译前端,它依赖的是unicode/utf8包来识别程序编码,将编码字符分割开来。本文不讨论编译中它做了什么,而是只观察utf8包究竟怎么识别utf-8编码的。以下面这段代码文件为例:


package main

import "fmt"

func main() {
	a := 3 + 2*4
	a = a + 1
	b := 10 * a
	fmt.Println(a, b, "我勒个去")
}

在计算机中实际上存储的编码可以使用vi来获得。vi main.go,输入:%xxd

可以发现除了Println中的那几个中文,其他的字符全部属于ASCII范围内,一个字节表示一个字符。对于汉字(红框部分),根据上文所述,大部分汉字是3个字节一个字符,因此在这里四个汉字由12个字节表示。

unicode/utf8/utf8.go:

func DecodeRune(p []byte) (r rune, size int) {
	n := len(p)
	if n < 1 {
		return RuneError, 0
	}
	p0 := p[0]
	x := first[p0]
	if x >= as {
		// The following code simulates an additional check for x == xx and
		// handling the ASCII and invalid cases accordingly. This mask-and-or
		// approach prevents an additional branch.
		mask := rune(x) << 31 >> 31 // Create 0x0000 or 0xFFFF.
		return rune(p[0])&^mask | RuneError&mask, 1
	}
	sz := int(x & 7)
	accept := acceptRanges[x>>4]
	if n < sz {
		return RuneError, 1
	}
	b1 := p[1]
	if b1 < accept.lo || accept.hi < b1 {
		return RuneError, 1
	}
	if sz <= 2 { // <= instead of == to help the compiler eliminate some bounds checks
		return rune(p0&mask2)<<6 | rune(b1&maskx), 2
	}
	b2 := p[2]
	if b2 < locb || hicb < b2 {
		return RuneError, 1
	}
	if sz <= 3 {
		return rune(p0&mask3)<<12 | rune(b1&maskx)<<6 | rune(b2&maskx), 3
	}
	b3 := p[3]
	if b3 < locb || hicb < b3 {
		return RuneError, 1
	}
	return rune(p0&mask4)<<18 | rune(b1&maskx)<<12 | rune(b2&maskx)<<6 | rune(b3&maskx), 4
}

DecodeRune函数读取切片p的第一个utf-8编码,返回一个rune和它所占用的字节长度。

根据utf-8编码规则,一个字符占几个字节,只需要读取编码首尾即可知道,6-7行读取了编码的第一个字节,first是一个长度为256的uint8数组

110xxxxx 0xc0-0xDF

1110xxxx 0xE0-0xEF

11110xxx 0xF0-0xF7

再考虑填字规则和unicode数据,就可以直接得到该utf-8编码的长度。如下图所示,这些常量的低四位代表了编码长度,而高四位代表了该编码所需要的边界检查范围。(比如对于检查到第一个字节为0xE0,即1110 0000的编码,它在规则上被认为由三个字节组成。它的第二个字节必须要大于0xA0,即1010 0000。如果第二个字节小于这个值,两个字节的utf-8编码可以完整填入对应unicode)

得到了长度之后,就可以用掩码去掉规则需要的位数,拼接得到真实的unicode编码,正如第25、32、38行做的那样。